diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bef99e..6b300d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,42 +7,42 @@ on: branches: [main] jobs: - lint: + lint-sync: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 with: python-version: '3.13' - - - name: Upgrade pip - run: python -m pip install -U pip - - - name: Install flake8 - run: python -m pip install -U flake8 - - - name: Run flake8 - run: python -m flake8 - - docs: + - name: Install lint dependencies + run: | + python -m pip install --upgrade pip + python -m pip install .[dev] + - name: Lint + run: flake8 src tests tools + - name: Check notebook exports + run: python tools/export_notebooks.py --check + - name: Check README sync + run: python tools/gen_readme.py --check + + docs-and-notebooks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 with: python-version: '3.11' - - - name: Upgrade pip - run: python -m pip install -U pip - - - name: Install documentation dependencies - run: python -m pip install -r docs/requirements.txt - + - name: Install full optional stack + run: | + python -m pip install --upgrade pip + python -m pip install .[all] + - name: Validate notebooks + run: python tools/check_notebooks.py + - name: Check generated files + run: | + python tools/export_notebooks.py --check + python tools/gen_readme.py --check - name: Build docs - env: - PYTHONPATH: ${{ github.workspace }}/src run: mkdocs build --strict test: @@ -56,19 +56,56 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Upgrade pip run: python -m pip install -U pip - - name: Install build tools run: python -m pip install -U cmake ninja - - name: Install package run: python -m pip install .[test] - - name: Run tests run: pytest -q + + build-dist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install cmake ninja build twine + - name: Build distributions + run: python -m build + - 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 dist/*.whl + python - <<'PY' + import numpy as np + import pyvoro2 as pv + import pyvoro2.planar as pv2 + + pts3 = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + cells3 = pv.compute( + pts3, + domain=pv.Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))), + mode='standard', + ) + assert len(cells3) == 2 + + pts2 = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + cells2 = pv2.compute( + pts2, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_edges=True, + ) + assert len(cells2) == 2 + PY diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b459395..1b9b75e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -15,36 +15,36 @@ concurrency: cancel-in-progress: true jobs: - build: + build-docs: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 - - name: Setup Pages uses: actions/configure-pages@v5 - - uses: actions/setup-python@v5 with: python-version: '3.11' - - - name: Install documentation dependencies + - name: Install full optional stack run: | - python -m pip install -U pip - python -m pip install -r docs/requirements.txt - - - name: Build docs - env: - PYTHONPATH: ${{ github.workspace }}/src + python -m pip install --upgrade pip + python -m pip install .[all] + - name: Check generated files run: | - mkdocs build --strict --site-dir site - + python tools/export_notebooks.py --check + python tools/gen_readme.py --check + - name: Validate notebooks + run: python tools/check_notebooks.py + - name: Build docs + run: mkdocs build --strict --site-dir site - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: site - deploy: - needs: build + deploy-docs: + needs: build-docs runs-on: ubuntu-latest environment: name: github-pages diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f360c..506c730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,113 @@ All notable changes to this project are documented in this file. The format is based on *Keep a Changelog*, and this project follows *Semantic Versioning*. +## [0.6.1] - 2026-03-16 + +### Added + +- Explicit realized-but-unaccounted pair diagnostics in both 3D and planar 2D power-fit realization, including public `UnaccountedRealizedPair` / `UnaccountedRealizedPairError` types and JSON/report export support. +- Structured connectivity diagnostics for low-level fits and self-consistent active-set solves, covering unconstrained points, isolated points, connected components of candidate and active graphs, and whether relative offsets are identified by the data or only by gauge policy. +- Active-set path diagnostics via `result.path_summary` and richer per-iteration `history` rows, so downstream code can distinguish final disconnectedness from transient component splits or transient candidate-absent realized pairs during optimization. +- Repo-root notebook sources plus notebook-export / notebook-check tooling, distribution-content checks, and a one-shot `tools/release_check.py` helper for local publishability validation. + +### Changed + +- Disconnected standalone fits no longer inherit arbitrary anchor-order gauges: each effective component is centered to mean zero by default, or aligned to the regularization-reference mean when a zero-strength reference is supplied. +- Self-consistent active-set fitting now preserves offsets per connected component of the current active effective graph by aligning each component to the previous iterate, including the final recomputed fit returned to the user. +- `weights_to_radii(...)` and the fitting APIs now support an explicit `weight_shift=` gauge, while keeping `r_min=` as a backward-compatible convenience rather than the primary convention. +- Power-fit reports now serialize connectivity diagnostics, unaccounted realized pairs, realized-diagnostics warnings, and active-set path summaries through the plain-Python report helpers. +- The example notebooks now live in a repo-root `notebooks/` directory and are exported into generated docs pages, while `README.md` and docs deployment are checked for sync in CI. +- The package metadata now includes a convenience `pyvoro2[all]` extra for contributors who want the full optional notebook/docs/release-check stack. +- The optional planar `plot_tessellation(...)` helper now accepts `domain=` and `show_sites=` to match the published guide examples. + +### Fixed + +- Active-set reports now nest the final low-level fit against the final active constraint subset rather than the full candidate table. +- Periodic self-image boundaries are excluded from the new unaccounted-pair diagnostics, so wrong-shift reporting does not misclassify self-adjacencies as missing candidate pairs. + +## [0.6.0] - 2026-03-16 + +### Added + +- New `pyvoro2.planar` namespace with the first 2D public surface: `Box`, `RectangularCell`, `compute`, `locate`, `ghost_cells`, duplicate checking, edge-property annotation, and optional matplotlib visualization helpers. +- Vendored legacy Voro++ 2D backend is now wired into the build as a separate `_core2d` extension target. +- New planar edge-shift reconstruction helper and pre-wheel integration tests that skip cleanly until `_core2d` wheels are available. +- New planar tessellation diagnostics and strict validation helpers: `analyze_tessellation(...)` and `validate_tessellation(...)`. +- New planar normalization helpers: `normalize_vertices(...)`, `normalize_topology(...)`, and `validate_normalized_topology(...)`. +- New `pyvoro2.planar.PlanarComputeResult` for structured wrapper-level compute results carrying raw cells, optional tessellation diagnostics, and optional normalized outputs. +- `pyvoro2.powerfit` realized-boundary matching and self-consistent active-set refinement now support planar 2D domains in addition to the original 3D path. + +### Changed + +- `pyvoro2.planar.compute(...)` now supports wrapper-level tessellation diagnostics (`return_diagnostics=...`, `tessellation_check=...`) and structured normalization convenience (`normalize='vertices'|'topology'`, `return_result=True`), automatically computing temporary periodic edge shifts/geometry when needed and stripping the temporary fields back out of the raw returned cells unless they were explicitly requested. +- `tools/install_wheel_overlay.py` now understands both `_core` and `_core2d`, so the editable-style wheel-overlay workflow can carry planar support once new wheels are built. +- Package metadata, release notes, and top-level documentation now describe the frozen 0.6.0 release rather than the earlier development snapshot. +- `resolve_pair_bisector_constraints(...)` now accepts both planar (2D) and spatial (3D) point sets, with dimension-aware shift validation and nearest-image resolution. +- Power-fit reports now serialize both 2D and 3D tessellation diagnostics through a shared measure-oriented schema while preserving the existing area/volume-specific fields. + +### Fixed + +- Periodic 2D edge reconstruction now resolves hidden periodic adjacencies that the legacy backend can surface as negative neighbor ids, so fully periodic planar tessellations expose consistent neighbor/shift data to diagnostics and normalization utilities. + +## [0.5.1] - 2026-03-15 + + +### Added + +- `tools/install_wheel_overlay.py` to support a wheel-core + repository-source + development workflow, so the compiled extension can come from an installed + wheel while Python imports resolve to `src/pyvoro2`. +- `DEV_PLAN.md` in the repository root with the planned 0.6.x refactoring and + 2D implementation roadmap, including the current decision to ship planar 2D + against the existing dedicated 2D backend before considering a later + `voro-dev` migration. + +### Changed + +- Public API validation and block-grid resolution are now routed through shared + internal helpers (`_inputs.py`, `_domain_geometry.py`) so 3D wrappers and the + power-fit layer no longer duplicate the same coercion and geometry logic. +- Project status metadata is now consistently marked as **beta** across the + package metadata and top-level documentation. + +### Fixed + +- Power-fit input validation now rejects non-finite point coordinates, + constraint values, confidence weights, and non-finite radius/weight + conversion inputs. +- `resolve_pair_bisector_constraints(...)` now validates external `ids` + consistently, including shape/length and uniqueness checks. +- The quadratic/analytic power-fit solver no longer crashes on zero-confidence + constraints that would otherwise create singular gauge coupling. +- Empty resolved constraint sets now respect L2 regularization and return the + regularization-only solution instead of silently dropping the reference. +- `fit_power_weights(...)` and the active-set driver now return the documented + `numerical_failure` status for linear-algebra and non-finite-iterate failures + instead of surfacing them as uncaught exceptions or misclassified active-set + infeasibility. +- Triclinic nearest-image resolution now warns when a chosen image touches the + `image_search` boundary, making the search-window sensitivity explicit for + skewed periodic cells. + +## [0.5.0] - 2026-03-14 + +### Added + +- New `pyvoro2.powerfit` API for inverse power fitting from generic pairwise bisector constraints. +- Power-fitting results now export plain-Python record rows for downstream reporting and diagnostics. +- Hard infeasibility reporting is simplified around explicit contradiction witnesses. +- `resolve_pair_bisector_constraints(...)` as a reusable low-level constraint-resolution primitive. +- `fit_power_weights(...)` with configurable mismatch, hard feasibility, soft penalties, and explicit infeasibility reporting. +- `match_realized_pairs(...)` for purely geometric realized-face matching with optional tessellation diagnostics. +- `solve_self_consistent_power_weights(...)` for hysteretic active-set refinement driven by realized faces. +- Rich per-constraint diagnostics, marginal-pair reporting, and optional final tessellation diagnostics. + +### Changed + +- The inverse-fitting surface is now math-oriented and chemistry-agnostic. +- Documentation and examples now describe the unified power-fitting workflow. +- The 0.5.x objective-model scope is explicitly documented around the current built-in convex model family. + ## [0.4.2] - 2026-03-04 ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index b0b857d..b999a15 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,3 @@ - cmake_minimum_required(VERSION 3.20) project(pyvoro2 LANGUAGES CXX) @@ -10,6 +9,7 @@ find_package(pybind11 CONFIG REQUIRED) # Voro++ sources (vendored) set(VORO_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/voro++/src") +set(VORO2D_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/voro++/2d/src") set(VORO_SOURCES "${VORO_SRC_DIR}/c_loops.cc" @@ -24,6 +24,20 @@ set(VORO_SOURCES "${VORO_SRC_DIR}/wall.cc" ) +set(VORO2D_SOURCES + "${VORO2D_SRC_DIR}/common.cc" + "${VORO2D_SRC_DIR}/cell_2d.cc" + "${VORO2D_SRC_DIR}/container_2d.cc" + "${VORO2D_SRC_DIR}/v_base_2d.cc" + "${VORO2D_SRC_DIR}/v_compute_2d.cc" + "${VORO2D_SRC_DIR}/c_loops_2d.cc" + "${VORO2D_SRC_DIR}/wall_2d.cc" + "${VORO2D_SRC_DIR}/cell_nc_2d.cc" + "${VORO2D_SRC_DIR}/ctr_boundary_2d.cc" + "${VORO2D_SRC_DIR}/ctr_quad_2d.cc" + "${VORO2D_SRC_DIR}/quad_march.cc" +) + pybind11_add_module(_core cpp/bindings.cpp ${VORO_SOURCES} @@ -33,38 +47,40 @@ target_include_directories(_core PRIVATE "${VORO_SRC_DIR}" ) -# Some compilers warn about old-style casts inside vendored code; keep warnings reasonable. -if (MSVC) - target_compile_options(_core PRIVATE /EHsc) -else() - target_compile_options(_core PRIVATE -O3) -endif() +pybind11_add_module(_core2d + cpp/bindings2d.cpp + ${VORO2D_SOURCES} +) -# Place module under the Python package. -# -# On Unix, Python extension modules are built as shared libraries and -# LIBRARY_OUTPUT_DIRECTORY is sufficient. On Windows, extension modules are -# produced as a DLL-like artifact (".pyd") and CMake treats it as a RUNTIME -# output. For editable installs (pip -e / scikit-build-core metadata_editable), -# we must ensure the extension ends up inside the package directory on all -# platforms. -set(_PYVORO2_OUTDIR "${CMAKE_CURRENT_BINARY_DIR}/pyvoro2") -set_target_properties(_core PROPERTIES - LIBRARY_OUTPUT_DIRECTORY "${_PYVORO2_OUTDIR}" - RUNTIME_OUTPUT_DIRECTORY "${_PYVORO2_OUTDIR}" - ARCHIVE_OUTPUT_DIRECTORY "${_PYVORO2_OUTDIR}" +target_include_directories(_core2d PRIVATE + "${VORO2D_SRC_DIR}" ) -# Multi-config generators (Visual Studio) use per-config output directories. -# Mirror the same location for all configurations so editable builds can find -# the extension module regardless of the selected config. -foreach(_cfg DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) - string(TOUPPER "${_cfg}" _cfg_uc) - set_target_properties(_core PROPERTIES - LIBRARY_OUTPUT_DIRECTORY_${_cfg_uc} "${_PYVORO2_OUTDIR}" - RUNTIME_OUTPUT_DIRECTORY_${_cfg_uc} "${_PYVORO2_OUTDIR}" - ARCHIVE_OUTPUT_DIRECTORY_${_cfg_uc} "${_PYVORO2_OUTDIR}" +function(configure_pyvoro_module target_name) + if (MSVC) + target_compile_options(${target_name} PRIVATE /EHsc) + else() + target_compile_options(${target_name} PRIVATE -O3) + endif() + + set(_PYVORO2_OUTDIR "${CMAKE_CURRENT_BINARY_DIR}/pyvoro2") + set_target_properties(${target_name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${_PYVORO2_OUTDIR}" + RUNTIME_OUTPUT_DIRECTORY "${_PYVORO2_OUTDIR}" + ARCHIVE_OUTPUT_DIRECTORY "${_PYVORO2_OUTDIR}" ) -endforeach() -install(TARGETS _core DESTINATION pyvoro2) + foreach(_cfg DEBUG RELEASE RELWITHDEBINFO MINSIZEREL) + string(TOUPPER "${_cfg}" _cfg_uc) + set_target_properties(${target_name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY_${_cfg_uc} "${_PYVORO2_OUTDIR}" + RUNTIME_OUTPUT_DIRECTORY_${_cfg_uc} "${_PYVORO2_OUTDIR}" + ARCHIVE_OUTPUT_DIRECTORY_${_cfg_uc} "${_PYVORO2_OUTDIR}" + ) + endforeach() +endfunction() + +configure_pyvoro_module(_core) +configure_pyvoro_module(_core2d) + +install(TARGETS _core _core2d DESTINATION pyvoro2) diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/DEV_PLAN.md b/DEV_PLAN.md new file mode 100644 index 0000000..f6dbc52 --- /dev/null +++ b/DEV_PLAN.md @@ -0,0 +1,107 @@ +# Development plan (post-0.6.0) + +This file is the internal working plan after the 0.6.0 feature freeze. It is +more concrete than the public roadmap and may evolve during implementation. + +## Release sequence + +### 0.6.0 (freeze and release) + +Scope of the 0.6.0 release: + +- ship planar 2D support in a dedicated `pyvoro2.planar` namespace; +- keep the planar scope honest: `Box` and `RectangularCell`, but **no** planar + oblique-periodic `PeriodicCell` yet; +- ship planar compute/locate/ghost cells, edge shifts, diagnostics, + normalization, plotting, and planar `powerfit` support; +- finish documentation, reference pages, examples, and release cleanup. + +The 0.6.0 line should **not** add major new inverse-fitting policies. Those +belong in 0.6.1. + +### 0.6.1 (powerfit robustness, implemented in the current tree) + +The 0.6.1 scope is now implemented in the working tree attached to this chat. + +Implemented focus: + +- realized pair adjacencies that exist in the tessellation but are **absent** + from the supplied candidate set are now reported explicitly rather than being + silently ignored or auto-added; +- low-level fits and self-consistent active-set solves now expose structured + graph/connectivity diagnostics for unconstrained points, isolated points, + connected components, and whether relative offsets are identified by the + pairwise data; +- disconnected standalone fits now use an explainable component-mean gauge + policy, while self-consistent solves preserve offsets per connected active + component by alignment to the previous iterate; +- `weights_to_radii(...)` now supports an explicit `weight_shift=` gauge, with + `r_min=` retained as a compatibility-oriented convenience rather than the + preferred mathematical framing; +- plain-Python report helpers now serialize both connectivity diagnostics and + realized-but-unaccounted pair diagnostics; +- self-consistent solves now retain optimization-path diagnostics through a + compact `path_summary` object plus richer optional per-iteration history rows; +- notebook/documentation examples are refreshed around explicit gauge language, + path diagnostics, and the lightweight planar plotting helper. +- repository workflow now keeps the source notebooks in a repo-root `notebooks/` + directory, exports them to `docs/notebooks/*.md`, exposes a convenience + `.[all]` extra, and provides single-command publishability checks through + `tools/release_check.py`. + +The current preferred default policy for disconnected components is now the +implemented behavior: + +- if an explicit reference exists, align each disconnected standalone component + to the reference mean on that component; +- otherwise, center each disconnected standalone component by its mean; +- in the self-consistent loop, preserve component offsets relative to the + previous iterate whenever the active effective graph is disconnected. + +This remains a convention, not information identified by the pairwise data, so +connectivity diagnostics continue to support `none` / `diagnose` / `warn` / +`raise` policies. + +### Deferred / exploratory (candidate 0.6.2+ work) + +#### Planar `PeriodicCell` + +Planar oblique-periodic support is **deferred** rather than promised for a +specific release. + +The options to keep in mind are: + +- continue without planar `PeriodicCell` if the rectangular 2D scope proves + sufficient in practice; +- prototype a pseudo-3D fallback (planar sites embedded in 3D, then projected + back to 2D); +- reevaluate the backend situation later if upstream Voro++ changes become + compelling. + +The current upstream assessment still stands: `voro-dev` does not appear to add +an honest 2D analogue of pyvoro2's current 3D `PeriodicCell`, so switching +engines is not required for first-class planar support. + +## 1.0 gate + +Do not freeze 1.0 immediately after 0.6.0. + +The intended checkpoint is: + +1. release 0.6.0 with the completed planar rectangular scope; +2. implement the 0.6.1 powerfit-robustness work; +3. reassess whether planar `PeriodicCell` is actually needed; +4. only then decide whether the public API is stable enough for 1.0. + +The public API should be frozen around a stable mathematical surface, not +around any particular backend snapshot. + +## Notes carried forward from the backend review + +- We should **not** switch pyvoro2 to `voro-dev` merely to obtain 2D support. + The current dedicated 2D backend is already sufficient for the honest first + planar scope. +- A later backend migration remains possible, but it should be an internal + engineering change rather than the moment when the Python API becomes mature. +- The public Python surface should stay explicit about dimension: + `pyvoro2` for 3D, `pyvoro2.planar` for 2D. diff --git a/LICENSE b/LICENSE index e4c85f3..0a04128 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,165 @@ -MIT License - -Copyright (c) 2026 Ivan Yu. Chernyshov - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/NOTICE.md b/NOTICE.md index dc7f14d..4e4ece1 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,15 +1,22 @@ # NOTICE -This project vendors and links against the following third-party software: +pyvoro2 now uses a dual historical/current licensing story: -## Voro++ (vendored in `vendor/voro++`) +- Starting with version **0.6.0**, the pyvoro2-authored code in this repository + is distributed under the **GNU Lesser General Public License v3.0 or later** + (**LGPLv3+**). See `LICENSE`. +- Versions **before 0.6.0** were released under the **MIT License**. Those + already-published historical releases remain available under MIT. -- Upstream: Voro++ (Chris Rycroft) -- Purpose: 3D Voronoi / radical Voronoi (Laguerre) cell computations -- License: See `vendor/voro++/LICENSE` +This repository also vendors third-party code under separate licenses. + +## Vendored code -pyvoro2 is licensed under the MIT License (see `LICENSE`). The included Voro++ code remains under its original license. +### Voro++ (vendored in `vendor/voro++`) -Local modifications: +- Upstream: Voro++ (Chris Rycroft) +- Purpose: 3D Voronoi / radical Voronoi (Laguerre) cell computations, plus the + legacy 2D Voro++ sources used for the planar backend work +- License: See `vendor/voro++/LICENSE` -- None. The vendored Voro++ snapshot is kept unmodified (aside from being vendored into this repository). +The vendored Voro++ snapshot remains under its original upstream license. diff --git a/README.md b/README.md index 3aa5d0c..8d37b14 100644 --- a/README.md +++ b/README.md @@ -8,23 +8,29 @@ --- **pyvoro2** is a Python interface to the C++ library **Voro++** for computing -**3D tessellations** around a set of points: +**2D and 3D Voronoi-type tessellations** around a set of points: - **Voronoi tessellations** (standard, unweighted) - **power / Laguerre tessellations** (weighted Voronoi, via per-site radii) +- a dedicated planar namespace, **`pyvoro2.planar`**, for 2D rectangular domains -The focus is not only on computing polyhedra, but on making the results *useful* in -scientific settings that are common in chemistry, materials science, and condensed -matter physics — especially **periodic boundary conditions** and **neighbor graphs**. +The focus is not only on computing cells, but on making the results *usable* +in scientific and geometric settings that need **periodic boundary +conditions**, explicit **neighbor-image shifts**, reproducible +**topology/normalization** utilities, and a reusable mathematical interface to +Voronoi and power tessellations. pyvoro2 is designed to be **honest and predictable**: - it vendors and wraps an upstream Voro++ snapshot (with a small numeric robustness patch for power/Laguerre diagrams); +- the 3D top-level API stays separate from the 2D `pyvoro2.planar` namespace; - the core tessellation modes are **standard Voronoi** and **power/Laguerre**. +**License note:** starting with **0.6.0**, the pyvoro2-authored code is released under **LGPLv3+**. Versions before **0.6.0** were released under **MIT**. Vendored third-party code remains under its own licenses. + ## Quickstart -### 1) Standard Voronoi in a bounding box +### 1) Standard Voronoi in a 3D bounding box For 3D visualization, install the optional dependency: `pip install "pyvoro2[viz]"`. @@ -46,7 +52,31 @@ view_tessellation( Voronoi tessellation in a box -### 2) Power/Laguerre tessellation (weighted Voronoi) +### 2) Planar periodic workflow + +```python +import numpy as np +import pyvoro2.planar as pv2 + +pts2 = np.array([ + [0.2, 0.2], + [0.8, 0.25], + [0.4, 0.8], +], dtype=float) + +cell2 = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) +result2 = pv2.compute( + pts2, + domain=cell2, + return_diagnostics=True, + normalize='topology', +) + +diag2 = result2.require_tessellation_diagnostics() +topo2 = result2.require_normalized_topology() +``` + +### 3) Power/Laguerre tessellation (weighted Voronoi) ```python radii = np.full(len(points), 1.2) @@ -60,7 +90,7 @@ cells = pv.compute( ) ``` -### 3) Periodic crystal cell with neighbor image shifts +### 4) Periodic crystal cell with neighbor image shifts ```python cell = pv.PeriodicCell( @@ -99,6 +129,8 @@ For stricter post-hoc checks, see: - `pyvoro2.validate_tessellation(..., level='strict')` - `pyvoro2.validate_normalized_topology(..., level='strict')` +- `pyvoro2.planar.validate_tessellation(..., level='strict')` +- `pyvoro2.planar.validate_normalized_topology(..., level='strict')` Note: pyvoro2 vendors a Voro++ snapshot that includes the upstream numeric robustness fix for *power/Laguerre* mode (radical pruning). This avoids rare cross-platform edge cases where fully @@ -111,14 +143,15 @@ Voro++ is fast and feature-rich, but it is a C++ library with a low-level API. pyvoro2 aims to be a *scientific* interface that stays close to Voro++ while adding practical pieces that are easy to get wrong: -- **triclinic periodic cells** (`PeriodicCell`) with robust coordinate mapping +- **triclinic periodic cells** (`PeriodicCell`) with robust coordinate mapping in 3D - **partially periodic orthorhombic cells** (`OrthorhombicCell`) for slabs and wires -- optional **per-face periodic image shifts** (`adjacent_shift`) for building periodic graphs +- dedicated **planar 2D support** in `pyvoro2.planar` for boxes and rectangular periodic cells +- optional **periodic image shifts** (`adjacent_shift`) on faces/edges for building periodic graphs - **diagnostics** and **normalization utilities** for reproducible topology work - convenience operations beyond full tessellation: - - `locate(...)` (owner lookup for arbitrary query points) - - `ghost_cells(...)` (probe cell at a query point without inserting it) - - inverse fitting utilities for **fitting power weights** from desired pairwise plane locations + - `locate(...)` / `pyvoro2.planar.locate(...)` (owner lookup for arbitrary query points) + - `ghost_cells(...)` / `pyvoro2.planar.ghost_cells(...)` (probe cell at a query point without inserting it) + - power-fitting utilities for **fitting power weights** from desired pairwise separator locations in both 2D and 3D ## Documentation overview @@ -129,13 +162,14 @@ implementation-oriented details. | Section | What it contains | |---|---| | [Concepts](https://delonecommons.github.io/pyvoro2/guide/concepts/) | What Voronoi and power/Laguerre tessellations are, and what you can expect from them. | -| [Domains](https://delonecommons.github.io/pyvoro2/guide/domains/) | Which containers exist (`Box`, `OrthorhombicCell`, `PeriodicCell`) and how to choose between them. | -| [Operations](https://delonecommons.github.io/pyvoro2/guide/operations/) | How to compute tessellations, assign query points, and compute probe (ghost) cells. | -| [Topology and graphs](https://delonecommons.github.io/pyvoro2/guide/topology/) | How to build a neighbor graph that respects periodic images, and how normalization helps. | -| [Inverse fitting](https://delonecommons.github.io/pyvoro2/guide/inverse/) | Fit power/Laguerre radii from desired pairwise plane positions (with optional constraints/penalties). | -| [Visualization](https://delonecommons.github.io/pyvoro2/guide/visualization/) | Optional py3Dmol helpers for debugging and exploratory analysis. | -| [Examples (notebooks)](https://delonecommons.github.io/pyvoro2/notebooks/01_basic_compute/) | End-to-end examples that combine the pieces above. | -| [API reference](https://delonecommons.github.io/pyvoro2/reference/api/) | The full reference (docstrings). | +| [Domains (3D)](https://delonecommons.github.io/pyvoro2/guide/domains/) | Which spatial containers exist (`Box`, `OrthorhombicCell`, `PeriodicCell`) and how to choose between them. | +| [Planar (2D)](https://delonecommons.github.io/pyvoro2/guide/planar/) | The planar namespace, current 2D domain scope, wrapper-level diagnostics/normalization convenience, and plotting. | +| [Operations](https://delonecommons.github.io/pyvoro2/guide/operations/) | How to compute tessellations, assign query points, and compute probe (ghost) cells in the 3D and planar namespaces. | +| [Topology and graphs](https://delonecommons.github.io/pyvoro2/guide/topology/) | How to build periodic neighbor graphs and how normalization helps in both 2D and 3D. | +| [Power fitting](https://delonecommons.github.io/pyvoro2/guide/powerfit/) | Fit power weights from pairwise bisector constraints, realized-boundary matching, and self-consistent active sets in 2D or 3D. | +| [Visualization](https://delonecommons.github.io/pyvoro2/guide/visualization/) | Optional `py3Dmol` / `matplotlib` helpers for debugging and exploratory analysis. | +| [Examples (notebooks)](https://delonecommons.github.io/pyvoro2/guide/notebooks/) | End-to-end examples, including focused power-fitting notebooks for reports, infeasibility witnesses, and active-set path diagnostics. | +| [API reference](https://delonecommons.github.io/pyvoro2/reference/planar/) | The full reference (docstrings) for both the spatial and planar APIs. | ## Installation @@ -145,12 +179,25 @@ Most users should install a prebuilt wheel: pip install pyvoro2 ``` +Optional extras: + +- `pyvoro2[viz]` for the 3D `py3Dmol` viewer (and 2D plotting too) +- `pyvoro2[viz2d]` for 2D matplotlib plotting only +- `pyvoro2[all]` to install the full optional stack used for local notebook, + docs, lint, and publishability checks + To build from source (requires a C++ compiler and Python development headers): ```bash pip install -e . ``` +For contributor-style local validation, install the full optional stack: + +```bash +pip install -e ".[all]" +``` + ## Testing pyvoro2 uses **pytest**. The default test suite is intended to be fast and deterministic: @@ -183,14 +230,23 @@ Additional test groups are **opt-in**: Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`. +## Release and publishability checks + +For a one-shot local publishability pass (lint, notebook execution, exported notebook sync, README sync, tests, docs, build, metadata checks, and wheel smoke test): + +```bash +python tools/release_check.py +``` + ## Project status pyvoro2 is currently in **beta**. -The core tessellation modes (standard and power/Laguerre) are stable, and a large -part of the work in this repository focuses on tests and documentation. -A future 1.0 release is planned once the inverse-fitting workflow is more mature -and native 2D support is added. +The core tessellation modes (standard and power/Laguerre) are stable, and the +0.6.0 release now includes a first-class planar namespace. +A future 1.0 release is planned once the inverse-fitting workflow is more mature, +its disconnected-graph / coverage diagnostics are stabilized, and the project has +reassessed whether planar `PeriodicCell` support is actually needed. ## AI-assisted development @@ -202,8 +258,9 @@ Details are documented in the [AI usage](https://delonecommons.github.io/pyvoro2 ## License -- pyvoro2 is released under the **MIT License**. -- Voro++ is vendored and redistributed under its original license (see the project pages). +- Starting with **0.6.0**, the pyvoro2-authored code is released under the **GNU Lesser General Public License v3.0 or later (LGPLv3+)**. +- Versions **before 0.6.0** were released under the **MIT License**. +- Voro++ is vendored and redistributed under its original upstream license. --- diff --git a/cpp/bindings2d.cpp b/cpp/bindings2d.cpp new file mode 100644 index 0000000..dac31af --- /dev/null +++ b/cpp/bindings2d.cpp @@ -0,0 +1,543 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "voro++_2d.hh" + +namespace py = pybind11; +using namespace voro; + +namespace { + +struct OutputOpts { + bool vertices; + bool adjacency; + bool edges; +}; + +OutputOpts parse_opts(const std::tuple& opts) { + return OutputOpts{std::get<0>(opts), std::get<1>(opts), std::get<2>(opts)}; +} + +void check_points(const py::array_t& points) { + if (points.ndim() != 2 || points.shape(1) != 2) { + throw py::value_error("points must have shape (n, 2)"); + } +} + +void check_ids(const py::array_t& ids, py::ssize_t n) { + if (ids.ndim() != 1 || ids.shape(0) != n) { + throw py::value_error("ids must have shape (n,)"); + } +} + +void check_radii(const py::array_t& radii, py::ssize_t n) { + if (radii.ndim() != 1 || radii.shape(0) != n) { + throw py::value_error("radii must have shape (n,)"); + } +} + +void check_queries(const py::array_t& queries) { + if (queries.ndim() != 2 || queries.shape(1) != 2) { + throw py::value_error("queries must have shape (m, 2)"); + } +} + +void check_ghost_radii( + const py::array_t& ghost_radii, + py::ssize_t m +) { + if (ghost_radii.ndim() != 1 || ghost_radii.shape(0) != m) { + throw py::value_error("ghost_radii must have shape (m,)"); + } +} + +py::dict build_cell_dict( + voronoicell_neighbor_2d& cell, + int pid, + double x, + double y, + const OutputOpts& opts +) { + py::dict out; + out["id"] = pid; + out["area"] = cell.area(); + + py::list site; + site.append(x); + site.append(y); + out["site"] = site; + + if (opts.vertices) { + std::vector positions; + cell.vertices(x, y, positions); + py::list verts; + for (std::size_t i = 0; i + 1 < positions.size(); i += 2) { + py::list v; + v.append(positions[i]); + v.append(positions[i + 1]); + verts.append(v); + } + out["vertices"] = verts; + } + + if (opts.adjacency) { + py::list adj; + for (int i = 0; i < cell.p; ++i) { + py::list row; + row.append(cell.ed[2 * i]); + row.append(cell.ed[2 * i + 1]); + adj.append(row); + } + out["adjacency"] = adj; + } + + if (opts.edges) { + std::vector neigh; + cell.neighbors(neigh); + if (neigh.size() != static_cast(cell.p)) { + throw std::runtime_error( + "pyvoro2 internal error: mismatch between planar neighbors and vertices" + ); + } + + py::list edges; + for (int i = 0; i < cell.p; ++i) { + py::dict edge; + edge["adjacent_cell"] = neigh[static_cast(i)]; + py::list vids; + vids.append(i); + vids.append(cell.ed[2 * i]); + edge["vertices"] = vids; + edges.append(edge); + } + out["edges"] = edges; + } + + return out; +} + +py::dict build_empty_ghost_dict( + int query_index, + double x, + double y, + const OutputOpts& opts +) { + py::dict out; + out["id"] = -1; + out["empty"] = true; + out["area"] = 0.0; + + py::list site; + site.append(x); + site.append(y); + out["site"] = site; + out["query_index"] = query_index; + + if (opts.vertices) out["vertices"] = py::list(); + if (opts.adjacency) out["adjacency"] = py::list(); + if (opts.edges) out["edges"] = py::list(); + return out; +} + +template +py::list compute_cells_impl(ContainerT& con, const OutputOpts& opts) { + py::list out; + voronoicell_neighbor_2d cell; + c_loop_all_2d loop(con); + + if (loop.start()) { + do { + if (con.compute_cell(cell, loop)) { + int pid; + double x, y, r; + loop.pos(pid, x, y, r); + out.append(build_cell_dict(cell, pid, x, y, opts)); + } + } while (loop.inc()); + } + + return out; +} + +template +bool append_ghost_cell( + ContainerT& con, + int ghost_id, + int query_index, + double x, + double y, + const OutputOpts& opts, + py::list& out +) { + c_loop_all_2d loop(con); + voronoicell_neighbor_2d cell; + if (loop.start()) { + do { + if (loop.pid() != ghost_id) { + continue; + } + if (con.compute_cell(cell, loop)) { + py::dict d = build_cell_dict(cell, -1, x, y, opts); + d["empty"] = false; + d["query_index"] = query_index; + out.append(d); + } else { + out.append(build_empty_ghost_dict(query_index, x, y, opts)); + } + return true; + } while (loop.inc()); + } + return false; +} + +} // namespace + +PYBIND11_MODULE(_core2d, m) { + m.doc() = "pyvoro2 planar core bindings (legacy 2D Voro++)"; + + m.def( + "compute_box_standard", + [](py::array_t points, + py::array_t ids, + std::array, 2> bounds, + std::array blocks, + std::array periodic, + int init_mem, + std::tuple opts_tuple) { + check_points(points); + const auto n = points.shape(0); + check_ids(ids, n); + const auto opts = parse_opts(opts_tuple); + + auto p = points.unchecked<2>(); + auto id = ids.unchecked<1>(); + + container_2d con(bounds[0][0], + bounds[0][1], + bounds[1][0], + bounds[1][1], + blocks[0], + blocks[1], + periodic[0], + periodic[1], + init_mem); + + for (py::ssize_t i = 0; i < n; ++i) { + con.put(id(i), p(i, 0), p(i, 1)); + } + + return compute_cells_impl(con, opts); + }, + py::arg("points"), + py::arg("ids"), + py::arg("bounds"), + py::arg("blocks"), + py::arg("periodic") = std::array{false, false}, + py::arg("init_mem"), + py::arg("opts")); + + m.def( + "compute_box_power", + [](py::array_t points, + py::array_t ids, + py::array_t radii, + std::array, 2> bounds, + std::array blocks, + std::array periodic, + int init_mem, + std::tuple opts_tuple) { + check_points(points); + const auto n = points.shape(0); + check_ids(ids, n); + check_radii(radii, n); + const auto opts = parse_opts(opts_tuple); + + auto p = points.unchecked<2>(); + auto id = ids.unchecked<1>(); + auto r = radii.unchecked<1>(); + + container_poly_2d con(bounds[0][0], + bounds[0][1], + bounds[1][0], + bounds[1][1], + blocks[0], + blocks[1], + periodic[0], + periodic[1], + init_mem); + + for (py::ssize_t i = 0; i < n; ++i) { + con.put(id(i), p(i, 0), p(i, 1), r(i)); + } + + return compute_cells_impl(con, opts); + }, + py::arg("points"), + py::arg("ids"), + py::arg("radii"), + py::arg("bounds"), + py::arg("blocks"), + py::arg("periodic") = std::array{false, false}, + py::arg("init_mem"), + py::arg("opts")); + + m.def( + "locate_box_standard", + [](py::array_t points, + py::array_t ids, + std::array, 2> bounds, + std::array blocks, + std::array periodic, + int init_mem, + py::array_t queries) { + check_points(points); + const auto n = points.shape(0); + check_ids(ids, n); + check_queries(queries); + + auto p = points.unchecked<2>(); + auto id = ids.unchecked<1>(); + auto q = queries.unchecked<2>(); + const py::ssize_t m_q = queries.shape(0); + + container_2d con(bounds[0][0], + bounds[0][1], + bounds[1][0], + bounds[1][1], + blocks[0], + blocks[1], + periodic[0], + periodic[1], + init_mem); + + for (py::ssize_t i = 0; i < n; ++i) { + con.put(id(i), p(i, 0), p(i, 1)); + } + + py::array_t found_arr(m_q); + py::array_t pid_arr(m_q); + py::array_t pos_arr({m_q, py::ssize_t(2)}); + + auto found = found_arr.mutable_unchecked<1>(); + auto pid_out = pid_arr.mutable_unchecked<1>(); + auto pos_out = pos_arr.mutable_unchecked<2>(); + + const double nan = std::numeric_limits::quiet_NaN(); + + for (py::ssize_t i = 0; i < m_q; ++i) { + double rx = nan; + double ry = nan; + int pid = -1; + const bool ok = con.find_voronoi_cell(q(i, 0), q(i, 1), rx, ry, pid); + found(i) = ok; + pid_out(i) = ok ? pid : -1; + pos_out(i, 0) = rx; + pos_out(i, 1) = ry; + } + + return py::make_tuple(found_arr, pid_arr, pos_arr); + }, + py::arg("points"), + py::arg("ids"), + py::arg("bounds"), + py::arg("blocks"), + py::arg("periodic") = std::array{false, false}, + py::arg("init_mem"), + py::arg("queries")); + + m.def( + "locate_box_power", + [](py::array_t points, + py::array_t ids, + py::array_t radii, + std::array, 2> bounds, + std::array blocks, + std::array periodic, + int init_mem, + py::array_t queries) { + check_points(points); + const auto n = points.shape(0); + check_ids(ids, n); + check_radii(radii, n); + check_queries(queries); + + auto p = points.unchecked<2>(); + auto id = ids.unchecked<1>(); + auto r = radii.unchecked<1>(); + auto q = queries.unchecked<2>(); + const py::ssize_t m_q = queries.shape(0); + + container_poly_2d con(bounds[0][0], + bounds[0][1], + bounds[1][0], + bounds[1][1], + blocks[0], + blocks[1], + periodic[0], + periodic[1], + init_mem); + + for (py::ssize_t i = 0; i < n; ++i) { + con.put(id(i), p(i, 0), p(i, 1), r(i)); + } + + py::array_t found_arr(m_q); + py::array_t pid_arr(m_q); + py::array_t pos_arr({m_q, py::ssize_t(2)}); + + auto found = found_arr.mutable_unchecked<1>(); + auto pid_out = pid_arr.mutable_unchecked<1>(); + auto pos_out = pos_arr.mutable_unchecked<2>(); + + const double nan = std::numeric_limits::quiet_NaN(); + + for (py::ssize_t i = 0; i < m_q; ++i) { + double rx = nan; + double ry = nan; + int pid = -1; + const bool ok = con.find_voronoi_cell(q(i, 0), q(i, 1), rx, ry, pid); + found(i) = ok; + pid_out(i) = ok ? pid : -1; + pos_out(i, 0) = rx; + pos_out(i, 1) = ry; + } + + return py::make_tuple(found_arr, pid_arr, pos_arr); + }, + py::arg("points"), + py::arg("ids"), + py::arg("radii"), + py::arg("bounds"), + py::arg("blocks"), + py::arg("periodic") = std::array{false, false}, + py::arg("init_mem"), + py::arg("queries")); + + m.def( + "ghost_box_standard", + [](py::array_t points, + py::array_t ids, + std::array, 2> bounds, + std::array blocks, + std::array periodic, + int init_mem, + std::tuple opts_tuple, + py::array_t queries) { + check_points(points); + const auto n = points.shape(0); + check_ids(ids, n); + check_queries(queries); + const auto opts = parse_opts(opts_tuple); + + auto p = points.unchecked<2>(); + auto id = ids.unchecked<1>(); + auto q = queries.unchecked<2>(); + const py::ssize_t m_q = queries.shape(0); + const int ghost_id = std::numeric_limits::max(); + + py::list out; + for (py::ssize_t qi = 0; qi < m_q; ++qi) { + container_2d con(bounds[0][0], + bounds[0][1], + bounds[1][0], + bounds[1][1], + blocks[0], + blocks[1], + periodic[0], + periodic[1], + init_mem); + for (py::ssize_t i = 0; i < n; ++i) { + con.put(id(i), p(i, 0), p(i, 1)); + } + + const double x = q(qi, 0); + const double y = q(qi, 1); + con.put(ghost_id, x, y); + if (!append_ghost_cell(con, ghost_id, static_cast(qi), x, y, opts, out)) { + out.append(build_empty_ghost_dict(static_cast(qi), x, y, opts)); + } + } + + return out; + }, + py::arg("points"), + py::arg("ids"), + py::arg("bounds"), + py::arg("blocks"), + py::arg("periodic") = std::array{false, false}, + py::arg("init_mem"), + py::arg("opts"), + py::arg("queries")); + + m.def( + "ghost_box_power", + [](py::array_t points, + py::array_t ids, + py::array_t radii, + std::array, 2> bounds, + std::array blocks, + std::array periodic, + int init_mem, + std::tuple opts_tuple, + py::array_t queries, + py::array_t ghost_radii) { + check_points(points); + const auto n = points.shape(0); + check_ids(ids, n); + check_radii(radii, n); + check_queries(queries); + const py::ssize_t m_q = queries.shape(0); + check_ghost_radii(ghost_radii, m_q); + const auto opts = parse_opts(opts_tuple); + + auto p = points.unchecked<2>(); + auto id = ids.unchecked<1>(); + auto r = radii.unchecked<1>(); + auto q = queries.unchecked<2>(); + auto gr = ghost_radii.unchecked<1>(); + const int ghost_id = std::numeric_limits::max(); + + py::list out; + for (py::ssize_t qi = 0; qi < m_q; ++qi) { + container_poly_2d con(bounds[0][0], + bounds[0][1], + bounds[1][0], + bounds[1][1], + blocks[0], + blocks[1], + periodic[0], + periodic[1], + init_mem); + for (py::ssize_t i = 0; i < n; ++i) { + con.put(id(i), p(i, 0), p(i, 1), r(i)); + } + + const double x = q(qi, 0); + const double y = q(qi, 1); + con.put(ghost_id, x, y, gr(qi)); + if (!append_ghost_cell(con, ghost_id, static_cast(qi), x, y, opts, out)) { + out.append(build_empty_ghost_dict(static_cast(qi), x, y, opts)); + } + } + + return out; + }, + py::arg("points"), + py::arg("ids"), + py::arg("radii"), + py::arg("bounds"), + py::arg("blocks"), + py::arg("periodic") = std::array{false, false}, + py::arg("init_mem"), + py::arg("opts"), + py::arg("queries"), + py::arg("ghost_radii")); +} diff --git a/docs/guide/domains.md b/docs/guide/domains.md index 8fb5cab..452ea4b 100644 --- a/docs/guide/domains.md +++ b/docs/guide/domains.md @@ -1,5 +1,9 @@ # Domains (containers) +> This page focuses on the **3D** top-level domains (`pyvoro2`). For the +> current **2D** scope, see the [Planar (2D)](planar.md) guide and +> `pyvoro2.planar`. + A Voronoi or power/Laguerre tessellation is always defined **inside a domain**: - for a finite cluster you typically want an explicit boundary (a box), @@ -9,6 +13,9 @@ A Voronoi or power/Laguerre tessellation is always defined **inside a domain**: In Voro++ terminology, these are different *containers*. In pyvoro2 they are exposed as small Python dataclasses. +This page describes the **3D** domain classes on the top-level `pyvoro2` API. +For the dedicated 2D surface, see [Planar 2D](planar.md). + ## Choosing a domain A practical rule of thumb: diff --git a/docs/guide/inverse.md b/docs/guide/inverse.md deleted file mode 100644 index 15de1e7..0000000 --- a/docs/guide/inverse.md +++ /dev/null @@ -1,121 +0,0 @@ -# Inverse fitting (weights/radii from desired planes) - -In many applications you do not start from “a tessellation”, but from a -**reference model** that tells you where the interfaces between pairs of sites -*should* be. - -Examples include: - -- atom-in-molecule partitions (chemistry) -- promolecular or model density partitions -- custom interface placements used as a geometric descriptor - -pyvoro2 provides tools to fit a **power/Laguerre tessellation** so that the -resulting pairwise bisector planes match a set of desired locations **as well as possible**. -The output is still a mathematically standard power diagram, so it always forms a valid tessellation. - -## Power bisector position along a line - -In a power diagram, each site $p_i$ carries a weight $w_i$ (in pyvoro2 you normally work with -radii $r_i$ where $w_i=r_i^2$). The bisector between two sites satisfies: - -$$ -\lVert x-p_i \rVert^2 - w_i = \lVert x-p_j \rVert^2 - w_j. -$$ - -Choose a specific periodic image of $j$ (if you are in a periodic domain) and denote it by $p_j^*$. -Along the line from $p_i$ to $p_j^*$, the bisector intersects at a fractional position $t$: - -$$ - t(w) = \frac{1}{2} + \frac{w_i - w_j}{2 d^2}, - \qquad d = \lVert p_j^* - p_i \rVert. -$$ - -- $t=0$ means “at $p_i$”, -- $t=1$ means “at $p_j^*$”, -- values outside $[0,1]$ are allowed and can occur naturally in power diagrams. - -## Fitting API - -Two convenience functions are provided: - -- `fit_power_weights_from_plane_fractions(...)` — you provide target fractions $t_{\mathrm{target}}$ -- `fit_power_weights_from_plane_positions(...)` — you provide target distances $x$ from $p_i$ along the $i\to j$ line - -Constraints are given as a list of tuples: - -- `(i, j, t)` or `(i, j, t, shift)` -- `(i, j, x)` or `(i, j, x, shift)` - -where `shift=(na, nb, nc)` specifies which periodic image of $j$ should be used. - -If you omit the shift in a periodic domain, pyvoro2 can (optionally) choose a “nearest image”. - -## Restricting where the *predicted* bisector can go - -Sometimes you want the *target* to be outside the segment (e.g. as a modeling choice), but you do not -want the fitted solution to place the bisector too far outside. In other cases you want to enforce -that the bisector lies strictly between the two sites. - -pyvoro2 supports three regimes for the **predicted** $t(w)$: - -- `t_bounds_mode='none'` — no restriction -- `t_bounds_mode='soft_quadratic'` — add a quadratic penalty for leaving an interval -- `t_bounds_mode='hard'` — enforce hard bounds (infeasible values are forbidden) - -In addition, you can add a near-boundary repulsion: - -- `t_near_penalty='exp'` - -which discourages $t(w)$ from approaching the bounds too closely. - -These options make the fit a small convex optimization problem that pyvoro2 solves in pure NumPy. - -## Radii gauge and `r_min` - -Power diagrams are invariant under adding a constant to all weights. -After fitting weights, pyvoro2 chooses a global shift so that the derived radii satisfy: - -- `min(radii) == r_min` - -This is useful if you want radii that are never exactly zero. - -## “Inactive” constraints (pairs that are not a face) - -A constraint between $(i,j)$ refers to a bisector plane, but in a full tessellation that plane becomes -an actual **cell face** only if $i$ and $j$ end up as neighbors. - -If you pass `check_contacts=True`, pyvoro2 will compute a tessellation using the fitted radii and report -which constraints became real neighbor faces. - -This is often enough for practical workflows, and it is also a stepping stone to iterative schemes -(where you refit only on active neighbor pairs). - -## Typical workflow - -1) Fit weights/radii from constraints -2) Compute a power tessellation with the fitted radii - -```python -import pyvoro2 as pv - -res = pv.fit_power_weights_from_plane_fractions( - points, - constraints, - domain=cell, - t_bounds=(0.0, 1.0), - t_bounds_mode='hard', - t_near_penalty='exp', - r_min=1.0, - check_contacts=True, -) - -cells = pv.compute( - points, - domain=cell, - mode='power', - radii=res.radii, - include_empty=True, - return_face_shifts=True, -) -``` diff --git a/docs/guide/notebooks.md b/docs/guide/notebooks.md new file mode 100644 index 0000000..09be428 --- /dev/null +++ b/docs/guide/notebooks.md @@ -0,0 +1,54 @@ +# Notebooks + +The example notebooks are kept in the repository-root `notebooks/` directory so +that users can browse them directly on GitHub without going through the docs +site. + +For the published docs, each notebook is also exported to a generated Markdown +page under `docs/notebooks/`. + +## Source notebooks in the repository + +The repository source notebooks are: + +- `notebooks/01_basic_compute.ipynb` +- `notebooks/02_periodic_graph.ipynb` +- `notebooks/03_locate_and_ghost.ipynb` +- `notebooks/04_powerfit.ipynb` +- `notebooks/05_visualization.ipynb` +- `notebooks/06_powerfit_reports.ipynb` +- `notebooks/07_powerfit_infeasibility.ipynb` +- `notebooks/08_powerfit_active_path.ipynb` + +## Published notebook pages + +The generated documentation pages are: + +- [01 basic compute](../notebooks/01_basic_compute.md) +- [02 periodic graph](../notebooks/02_periodic_graph.md) +- [03 locate and ghost cells](../notebooks/03_locate_and_ghost.md) +- [04 powerfit workflow](../notebooks/04_powerfit.md) +- [05 visualization](../notebooks/05_visualization.md) +- [06 powerfit reports](../notebooks/06_powerfit_reports.md) +- [07 powerfit infeasibility](../notebooks/07_powerfit_infeasibility.md) +- [08 active-set path diagnostics](../notebooks/08_powerfit_active_path.md) + +## Regeneration + +To refresh the generated pages after editing notebooks: + +```bash +python tools/export_notebooks.py +``` + +To validate notebook executability against the installed `pyvoro2` package in the current environment: + +```bash +python tools/check_notebooks.py +``` + +If you are using the wheel-overlay developer workflow and want notebook imports to resolve from `repo/src`, use: + +```bash +python tools/check_notebooks.py --use-src +``` diff --git a/docs/guide/operations.md b/docs/guide/operations.md index 4bff086..3de715d 100644 --- a/docs/guide/operations.md +++ b/docs/guide/operations.md @@ -1,7 +1,8 @@ # Operations pyvoro2 exposes three high-level operations. They correspond to three common -questions you may ask about a set of sites: +questions you may ask about a set of sites. The same three verbs also exist in +`pyvoro2.planar` for 2D workflows. 1. **What does the full tessellation look like?** (Compute every Voronoi/power cell.) @@ -12,6 +13,10 @@ questions you may ask about a set of sites: All operations are **stateless**: pyvoro2 creates a Voro++ container in C++, inserts the sites, performs the computation, and returns Python data structures. There is no persistent container object that you need to manage. +The same three operation names also exist in the dedicated 2D namespace +`pyvoro2.planar`. See [Planar 2D](planar.md) for the planar-specific domains, +result schema, and wrapper conveniences. + ## Coordinate scale and numerical safety Voro++ uses a few **fixed absolute tolerances** internally (notably a hard diff --git a/docs/guide/planar.md b/docs/guide/planar.md new file mode 100644 index 0000000..90c892c --- /dev/null +++ b/docs/guide/planar.md @@ -0,0 +1,179 @@ +# Planar 2D (`pyvoro2.planar`) + +pyvoro2 now ships a dedicated **planar 2D namespace**: + +```python +import pyvoro2.planar as pv2 +``` + +This is intentionally separate from the 3D top-level API. The goal is to keep +both surfaces explicit and mathematically honest: + +- `pyvoro2` is the 3D package, +- `pyvoro2.planar` is the 2D package. + +The current 2D release scope is deliberately limited to the domains that the +vendored legacy backend supports well: + +- `pv2.Box` +- `pv2.RectangularCell` + +There is **no** planar `PeriodicCell` yet. Rectangular periodic domains can be +periodic in either or both planar axes. + +## Basic compute + +```python +import numpy as np +import pyvoro2.planar as pv2 + +pts = np.array([ + [0.2, 0.2], + [0.8, 0.2], + [0.5, 0.8], +], dtype=float) + +cells = pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_vertices=True, + return_edges=True, +) +``` + +Raw planar cells are dimension-specific by design: + +- `area` instead of `volume`, +- `edges` instead of `faces`, +- `adjacent_shift` is a length-2 periodic image shift when requested. + +## Rectangular periodic cells and edge shifts + +For periodic rectangular domains, request `return_edge_shifts=True` when you +need the explicit periodic image of the neighboring site: + +```python +cell = pv2.RectangularCell( + ((0.0, 1.0), (0.0, 1.0)), + periodic=(True, True), +) + +cells = pv2.compute( + pts, + domain=cell, + return_vertices=True, + return_edges=True, + return_edge_shifts=True, +) +``` + +The planar wrapper reconstructs these edge shifts in Python and also repairs a +legacy backend quirk where some fully periodic adjacencies can otherwise appear +with negative neighbor ids. + +## `locate(...)` and `ghost_cells(...)` + +The planar namespace mirrors the 3D operation names: + +```python +owners = pv2.locate(pts, [[0.1, 0.2], [0.9, 0.2]], domain=cell) + +ghost = pv2.ghost_cells( + pts, + [[0.5, 0.5]], + domain=cell, + return_vertices=True, + return_edges=True, +) +``` + +So the same three high-level questions exist in both dimensions: + +1. compute every cell, +2. locate the owner of a query point, +3. compute the hypothetical cell of a query point without inserting it. + +## Diagnostics and wrapper-level convenience + +Planar `compute(...)` supports the same kind of post-compute convenience that +3D users already expect, but specialized for 2D semantics: + +```python +cells, diag = pv2.compute( + pts, + domain=cell, + return_diagnostics=True, +) +``` + +For periodic domains, the wrapper automatically computes the temporary geometry +needed for reciprocity checks and then strips it back out of the raw returned +cells unless you explicitly requested it. + +The same holds for normalization convenience: + +```python +result = pv2.compute( + pts, + domain=cell, + return_diagnostics=True, + normalize='topology', +) +``` + +This returns a `pv2.PlanarComputeResult` bundling: + +- raw `cells`, +- optional tessellation diagnostics, +- optional normalized vertices, +- optional normalized topology. + +This keeps the public API structured once the user wants more than a bare list +of raw cells. + +## Planar normalization + +The dedicated planar normalization helpers are: + +- `pv2.normalize_vertices(...)` +- `pv2.normalize_edges(...)` +- `pv2.normalize_topology(...)` +- `pv2.validate_normalized_topology(...)` + +In planar topology work, the globally deduplicated boundary objects are +**edges**, not faces. + +## Planar plotting + +For quick inspection, use the optional matplotlib helper: + +```python +from pyvoro2.planar import plot_tessellation + +fig, ax = plot_tessellation(cells, annotate_ids=True) +``` + +Install it with: + +```bash +pip install "pyvoro2[viz2d]" +``` + +or install both 2D and 3D visualization helpers with: + +```bash +pip install "pyvoro2[viz]" +``` + +## Planar power fitting + +The generic pairwise-separator `powerfit` API now supports planar domains too. +The solver vocabulary is shared between 2D and 3D; what changes is the meaning +of the realized boundary measure: + +- face area in 3D, +- edge length in 2D. + +The current planar domain restriction still applies here: rectangular periodic +cells are supported, but there is no planar oblique-periodic `PeriodicCell` +yet. diff --git a/docs/guide/powerfit.md b/docs/guide/powerfit.md new file mode 100644 index 0000000..eeb0823 --- /dev/null +++ b/docs/guide/powerfit.md @@ -0,0 +1,379 @@ +# Power fitting from pairwise bisector constraints + +`pyvoro2` can solve the inverse problem for **power / Laguerre tessellations**: +fit auxiliary power weights so that selected pairwise separators land at desired +locations along the connector between two sites. + +The API is intentionally **geometry-first** and **domain-agnostic**. +The same high-level functions can now be used with either 3D domains or the +planar `pyvoro2.planar` domains. Downstream code decides: + +- which site pairs are candidates, +- which periodic image shift belongs to each pair, +- the target separator location for each pair, +- and any per-constraint confidence. + +`pyvoro2` then provides the mathematical pieces: + +- resolve and validate pair constraints, +- fit power weights under a configurable convex model, +- compute the resulting power tessellation, +- detect which constraints correspond to realized faces, +- and optionally refine an active set to self-consistency. + +## Geometry of one pair + +For a pair of sites `i` and `j`, choose one specific image of `j` and call it +`j*`. Let + +- `d = ||p_j* - p_i||`, +- `z = w_i - w_j`, + +where `w` are the fitted power weights. + +Then the separator position along the connector is affine in `z`: + +$$ + t(z) = \frac{1}{2} + \frac{z}{2 d^2} +$$ + +for normalized fraction, and + +$$ + x(z) = \frac{d}{2} + \frac{z}{2 d} +$$ + +for absolute position measured from site `i`. + +This is why `pyvoro2` exposes the measurement type explicitly: a loss in +fraction-space and a loss in position-space are **different optimization +problems**. + +## Step 1: resolve pair constraints once + +```python +import numpy as np +import pyvoro2 as pv + +points = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) +box = pv.Box(((-5, 5), (-5, 5), (-5, 5))) + +constraints = pv.resolve_pair_bisector_constraints( + points, + [(0, 1, 0.25)], + measurement='fraction', + domain=box, +) +``` + +Each raw tuple is `(i, j, value[, shift])`, where `shift=(na, nb, nc)` is the +integer lattice image applied to site `j`. + +The resolved object stores the validated pair indices, shifts, connector +geometry, and targets in both fraction and position form. + +## Step 2: define the fitting model + +```python +model = pv.FitModel( + mismatch=pv.SquaredLoss(), + feasible=pv.Interval(0.0, 1.0), + penalties=( + pv.ExponentialBoundaryPenalty( + lower=0.0, + upper=1.0, + margin=0.05, + strength=1.0, + tau=0.01, + ), + ), +) +``` + +The model separates three ideas: + +- `mismatch=`: how target-vs-predicted separator locations are scored, +- `feasible=`: hard admissible sets such as an interval or fixed value, +- `penalties=`: soft penalties such as outside-interval or near-boundary + repulsion. + +Built-in pieces currently include: + +- `SquaredLoss()` +- `HuberLoss(delta=...)` +- `Interval(lower, upper)` +- `FixedValue(value)` +- `SoftIntervalPenalty(lower, upper, strength=...)` +- `ExponentialBoundaryPenalty(...)` +- `ReciprocalBoundaryPenalty(...)` +- `L2Regularization(...)` + +## Step 3: fit power weights + +```python +fit = pv.fit_power_weights( + points, + constraints, + model=model, +) +``` + +The result contains: + +- fitted `weights` and shifted `radii`, +- predicted separator locations in both fraction and position form, +- residuals in the chosen measurement space, +- solver/termination metadata, +- and explicit infeasibility reporting for contradictory hard constraints. + +For example, if hard interval or equality restrictions cannot all hold +simultaneously, the fit returns: + +- `status == 'infeasible_hard_constraints'` +- `hard_feasible == False` +- `weights is None` +- `conflict` with a compact contradiction witness +- `conflicting_constraint_indices` for the participating rows + +instead of pretending the issue is merely slow convergence. + +Both low-level fits and active-set results also provide `to_records(...)` helpers +that turn per-constraint diagnostics into plain Python rows for downstream +packages, table exporters, or custom reporting. + +For radii output, 0.6.1 makes the gauge choice explicit: + +- by default, `weights_to_radii(...)` uses the minimal additive shift that makes + all returned radii non-negative; +- `r_min=` remains available as a compatibility-oriented convenience when you + want a specific minimum radius; +- `weight_shift=` lets downstream code request one explicit global shift + directly. + +For disconnected fits, the additive gauge is now also explicit rather than +anchor-order dependent: + +- standalone fits center each disconnected effective component to mean zero; +- if a zero-strength regularization reference is supplied, each component is + shifted to the reference mean on that component; +- `connectivity_check='none'|'diagnose'|'warn'|'raise'` controls whether these + underdetermined cases are only reported, warned about, or raised as errors. + +Connectivity is computed on the graph of **site unknowns**, not on a graph of +periodic images. A periodic shift changes the geometry of one constraint row and +of realized-boundary matching, but it does not create an additional fitted +unknown. This is why interpenetrating periodic nets remain disconnected unless +some candidate row actually couples their site indices. + +## Step 4: check which pairs are actually realized + +A requested pairwise separator is not automatically a realized face in the full +power tessellation. After fitting, you can ask which requested pairs became real +neighbors. + +```python +realized = pv.match_realized_pairs( + points, + domain=box, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + return_tessellation_diagnostics=True, + unaccounted_pair_check='warn', +) +``` + +This returns purely geometric diagnostics: + +- whether each pair is realized at all, +- whether it is realized with the **same** requested periodic shift, +- whether only some **other** image is realized, +- whether one of the endpoint cells is empty, +- an optional boundary measure of the matched boundary + (**face area** in 3D, **edge length** in 2D), +- any realized-but-candidate-absent unordered point pairs through + `unaccounted_pairs`, +- and optional tessellation-wide diagnostics. + +## Step 5: solve the self-consistent active-set problem + +For sparse or noisy candidate sets, the useful high-level workflow is often: + +1. fit on a current active set, +2. run the actual power tessellation, +3. keep or re-add only the constraints whose pairs are realized, +4. repeat until active and realized sets agree. + +`pyvoro2` provides this as: + +```python +result = pv.solve_self_consistent_power_weights( + points, + constraints, + domain=box, + model=model, + options=pv.ActiveSetOptions( + add_after=1, + drop_after=2, + relax=0.5, + max_iter=25, + cycle_window=8, + ), + return_history=True, + return_boundary_measure=True, + return_tessellation_diagnostics=True, +) +``` + +The solver is generic: + +- it never invents candidate pairs, +- it never silently changes the user-supplied periodic image, +- it uses realized faces rather than any domain-specific contact logic, +- it supports hysteresis, under-relaxation, cycle detection, and marginal-pair + reporting. + +## Reading the final diagnostics + +`solve_self_consistent_power_weights(...)` returns both a final low-level fit and +rich per-constraint diagnostics. + +Useful fields include: + +- `result.constraints`: the resolved pair set used throughout the solve, +- `result.active_mask`: final active-set membership, +- `result.realized`: realized-face matching diagnostics, including + `unaccounted_pairs` when the final tessellation realizes candidate-absent + pairs, +- `result.connectivity`: final candidate-graph and active-graph connectivity + diagnostics plus the gauge-policy description used for disconnected + components, +- `result.path_summary`: compact optimization-path diagnostics that answer + questions such as whether the fit-active graph was **ever** disconnected, + whether active-component offsets were ever not identified by the pairwise + data, and whether candidate-absent realized pairs ever occurred during the + outer iterations, +- `result.history`: optional per-iteration rows; each row distinguishes the + fit-active mask (`n_active_fit`) from the post-toggle mask used for the next + iteration (`n_active`), and also records fit-active component counts and the + number of realized-but-unaccounted pairs seen on that iteration, +- `result.diagnostics`: per-constraint targets, predictions, residuals, + endpoint-empty flags, boundary measure, toggle counts, and generic status + labels, +- `result.rms_residual_all` / `result.max_residual_all`: summaries over **all** + candidate constraints, +- `result.tessellation_diagnostics`: final tessellation-wide checks, +- `result.marginal_constraints`: indices of toggling / cycle / wrong-shift + pairs. + +Transient path diagnostics are intentionally **inspectable** rather than +noisy: final-state `connectivity_check=` / `unaccounted_pair_check=` policies +still control warnings or exceptions, while `result.path_summary` and +`result.history` expose optimization-path events without turning every transient +component split into a default warning. + +Status labels are intentionally generic, for example: + +- `stable_active` +- `stable_inactive` +- `toggled_active` +- `toggled_inactive` +- `realized_other_shift` +- `active_unrealized` +- `cycle_member` + +## Exporting diagnostics as plain records + +Downstream packages often want rows rather than structured NumPy-heavy result +objects. The power-fitting package now exposes lightweight record exporters: + +```python +rows = result.to_records(use_ids=True) +fit_rows = result.fit.to_records(result.constraints, use_ids=True) +realized_rows = result.realized.to_records(result.constraints, use_ids=True) +conflict_rows = result.fit.conflict.to_records(ids=result.constraints.ids) +``` + +These helpers keep the core API numerical while making it straightforward to +feed results into custom logs, JSON encoders, or dataframe construction in a +downstream package. + +## Full report bundles + +When downstream code wants a single nested object rather than several row sets, +use the report helpers or the corresponding result methods: + +```python +fit_report = fit.to_report(constraints, use_ids=True) +realized_report = realized.to_report(constraints, use_ids=True) +solve_report = result.to_report(use_ids=True) +``` + +The standalone helpers are also exported: + +```python +fit_report = pv.build_fit_report(fit, constraints, use_ids=True) +solve_report = pv.build_active_set_report(result, use_ids=True) +``` + +These report bundles stay plain-Python and JSON-friendly. They are useful when +a downstream package wants a complete diagnostic payload for logging, caching, +or UI work without manually unpacking NumPy-heavy result objects. + +To serialize them directly: + +```python +text = pv.dumps_report_json(solve_report, sort_keys=True) +pv.write_report_json(solve_report, 'solve_report.json', sort_keys=True) +``` + +## Current scope + +The current implementation supports both **3D** domains through `pyvoro2` and +**2D planar** domains through `pyvoro2.planar`. The shared solver vocabulary is +intentionally dimension-safe: constraint fitting is phrased in terms of +pairwise separators and generic boundary measure rather than chemistry-specific +or 3D-only semantics. + +The main current restriction is geometric, not algebraic: + +- 3D supports `Box`, `OrthorhombicCell`, and triclinic `PeriodicCell`; +- 2D currently supports `Box` and rectangular `RectangularCell`; +- there is **no** planar oblique-periodic `PeriodicCell` yet. + +### Objective-model scope for 0.6.1 + +The 0.6.1 line still keeps the built-in objective family compact: + +- mismatch terms: `SquaredLoss`, `HuberLoss` +- hard feasibility: `Interval`, `FixedValue` +- soft penalties: `SoftIntervalPenalty`, `ExponentialBoundaryPenalty`, + `ReciprocalBoundaryPenalty` +- regularization: `L2Regularization` + +That set is broad enough for the current generic inverse workflow while keeping +hard-feasibility checks, residual diagnostics, and solver behavior easy to +reason about. + +Additional mismatch or penalty families should wait until downstream packages +validate a concrete need for them. In particular, 0.6.1 does **not** try to +freeze an open-ended callback API for arbitrary user-defined objectives. + +## Worked example notebooks + +Three focused notebooks complement the guide: + +- [`06_powerfit_reports`](../notebooks/06_powerfit_reports.md) + shows how to export low-level fits, realized-pair diagnostics, and + self-consistent active-set results as rows or JSON-friendly reports. +- [`07_powerfit_infeasibility`](../notebooks/07_powerfit_infeasibility.md) + shows how contradictory hard restrictions are reported through + `status`, `is_infeasible`, `conflict`, and report bundles. +- [`08_powerfit_active_path`](../notebooks/08_powerfit_active_path.md) + shows how to inspect transient active-set path diagnostics separately from + the final-state report objects. + +These examples are aimed at downstream packages that want to keep the solver +API numerical while still producing human-readable logs, cached payloads, or UI +views. + diff --git a/docs/guide/visualization.md b/docs/guide/visualization.md index d61253d..855db9a 100644 --- a/docs/guide/visualization.md +++ b/docs/guide/visualization.md @@ -7,17 +7,48 @@ For that reason, it is often worth having a lightweight way to **look at the out pyvoro2 intentionally keeps visualization **optional**: - the core package has no plotting dependencies; -- the optional helper module is aimed at *debugging and exploratory work*, not publication-quality rendering. +- planar 2D plotting is handled by a lightweight matplotlib helper; +- spatial 3D viewing is handled by the optional `py3Dmol` helper; +- both are aimed at *debugging and exploratory work*, not publication-quality rendering. -## Installing the optional viewer +## Installing optional visualization helpers ```bash +# 2D plotting only +pip install "pyvoro2[viz2d]" + +# both 2D + 3D helpers pip install "pyvoro2[viz]" ``` -(or install the dependency directly: `pip install py3Dmol`) +## A minimal 2D example + +```python +import numpy as np +import pyvoro2.planar as pv2 +from pyvoro2.viz2d import plot_tessellation + +pts = np.array([[0.2, 0.2], [0.8, 0.25], [0.4, 0.8]], dtype=float) +domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + +cells = pv2.compute( + pts, + domain=domain, + return_vertices=True, + return_edges=True, + return_edge_shifts=True, +) + +fig, ax = plot_tessellation(cells, domain=domain, show_sites=True) +``` + +The 2D helper returns `(fig, ax)` and is best suited for inspecting raw planar +output, debugging periodic edge structure, and checking that a normalized or +power-fitted result looks qualitatively right. When `domain=` is supplied and +exposes rectangular `bounds`, the helper also draws a simple domain outline; +`show_sites=True` overlays the reported cell sites. -## A minimal example +## A minimal 3D example ```python import numpy as np @@ -45,7 +76,7 @@ v = view_tessellation( v ``` -The viewer renders: +The 3D viewer renders: - sites as small spheres (with optional text labels like `p0`, `p1`, ...), - cell faces as a simple wireframe, diff --git a/docs/index.md b/docs/index.md index 831aa49..33e1b5f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,23 +1,29 @@ # pyvoro2 **pyvoro2** is a Python interface to the C++ library **Voro++** for computing -**3D tessellations** around a set of points: +**2D and 3D Voronoi-type tessellations** around a set of points: - **Voronoi tessellations** (standard, unweighted) - **power / Laguerre tessellations** (weighted Voronoi, via per-site radii) +- a dedicated planar namespace, **`pyvoro2.planar`**, for 2D rectangular domains -The focus is not only on computing polyhedra, but on making the results *useful* in -scientific settings that are common in chemistry, materials science, and condensed -matter physics — especially **periodic boundary conditions** and **neighbor graphs**. +The focus is not only on computing cells, but on making the results *usable* +in scientific and geometric settings that need **periodic boundary +conditions**, explicit **neighbor-image shifts**, reproducible +**topology/normalization** utilities, and a reusable mathematical interface to +Voronoi and power tessellations. pyvoro2 is designed to be **honest and predictable**: - it vendors and wraps an upstream Voro++ snapshot (with a small numeric robustness patch for power/Laguerre diagrams); +- the 3D top-level API stays separate from the 2D `pyvoro2.planar` namespace; - the core tessellation modes are **standard Voronoi** and **power/Laguerre**. +**License note:** starting with **0.6.0**, the pyvoro2-authored code is released under **LGPLv3+**. Versions before **0.6.0** were released under **MIT**. Vendored third-party code remains under its own licenses. + ## Quickstart -### 1) Standard Voronoi in a bounding box +### 1) Standard Voronoi in a 3D bounding box For 3D visualization, install the optional dependency: `pip install "pyvoro2[viz]"`. @@ -39,7 +45,31 @@ view_tessellation( Voronoi tessellation in a box -### 2) Power/Laguerre tessellation (weighted Voronoi) +### 2) Planar periodic workflow + +```python +import numpy as np +import pyvoro2.planar as pv2 + +pts2 = np.array([ + [0.2, 0.2], + [0.8, 0.25], + [0.4, 0.8], +], dtype=float) + +cell2 = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) +result2 = pv2.compute( + pts2, + domain=cell2, + return_diagnostics=True, + normalize='topology', +) + +diag2 = result2.require_tessellation_diagnostics() +topo2 = result2.require_normalized_topology() +``` + +### 3) Power/Laguerre tessellation (weighted Voronoi) ```python radii = np.full(len(points), 1.2) @@ -53,7 +83,7 @@ cells = pv.compute( ) ``` -### 3) Periodic crystal cell with neighbor image shifts +### 4) Periodic crystal cell with neighbor image shifts ```python cell = pv.PeriodicCell( @@ -92,6 +122,8 @@ For stricter post-hoc checks, see: - `pyvoro2.validate_tessellation(..., level='strict')` - `pyvoro2.validate_normalized_topology(..., level='strict')` +- `pyvoro2.planar.validate_tessellation(..., level='strict')` +- `pyvoro2.planar.validate_normalized_topology(..., level='strict')` Note: pyvoro2 vendors a Voro++ snapshot that includes the upstream numeric robustness fix for *power/Laguerre* mode (radical pruning). This avoids rare cross-platform edge cases where fully @@ -104,14 +136,15 @@ Voro++ is fast and feature-rich, but it is a C++ library with a low-level API. pyvoro2 aims to be a *scientific* interface that stays close to Voro++ while adding practical pieces that are easy to get wrong: -- **triclinic periodic cells** (`PeriodicCell`) with robust coordinate mapping +- **triclinic periodic cells** (`PeriodicCell`) with robust coordinate mapping in 3D - **partially periodic orthorhombic cells** (`OrthorhombicCell`) for slabs and wires -- optional **per-face periodic image shifts** (`adjacent_shift`) for building periodic graphs +- dedicated **planar 2D support** in `pyvoro2.planar` for boxes and rectangular periodic cells +- optional **periodic image shifts** (`adjacent_shift`) on faces/edges for building periodic graphs - **diagnostics** and **normalization utilities** for reproducible topology work - convenience operations beyond full tessellation: - - `locate(...)` (owner lookup for arbitrary query points) - - `ghost_cells(...)` (probe cell at a query point without inserting it) - - inverse fitting utilities for **fitting power weights** from desired pairwise plane locations + - `locate(...)` / `pyvoro2.planar.locate(...)` (owner lookup for arbitrary query points) + - `ghost_cells(...)` / `pyvoro2.planar.ghost_cells(...)` (probe cell at a query point without inserting it) + - power-fitting utilities for **fitting power weights** from desired pairwise separator locations in both 2D and 3D ## Documentation overview @@ -122,13 +155,14 @@ implementation-oriented details. | Section | What it contains | |---|---| | [Concepts](guide/concepts.md) | What Voronoi and power/Laguerre tessellations are, and what you can expect from them. | -| [Domains](guide/domains.md) | Which containers exist (`Box`, `OrthorhombicCell`, `PeriodicCell`) and how to choose between them. | -| [Operations](guide/operations.md) | How to compute tessellations, assign query points, and compute probe (ghost) cells. | -| [Topology and graphs](guide/topology.md) | How to build a neighbor graph that respects periodic images, and how normalization helps. | -| [Inverse fitting](guide/inverse.md) | Fit power/Laguerre radii from desired pairwise plane positions (with optional constraints/penalties). | -| [Visualization](guide/visualization.md) | Optional py3Dmol helpers for debugging and exploratory analysis. | -| [Examples (notebooks)](notebooks/01_basic_compute.ipynb) | End-to-end examples that combine the pieces above. | -| [API reference](reference/api.md) | The full reference (docstrings). | +| [Domains (3D)](guide/domains.md) | Which spatial containers exist (`Box`, `OrthorhombicCell`, `PeriodicCell`) and how to choose between them. | +| [Planar (2D)](guide/planar.md) | The planar namespace, current 2D domain scope, wrapper-level diagnostics/normalization convenience, and plotting. | +| [Operations](guide/operations.md) | How to compute tessellations, assign query points, and compute probe (ghost) cells in the 3D and planar namespaces. | +| [Topology and graphs](guide/topology.md) | How to build periodic neighbor graphs and how normalization helps in both 2D and 3D. | +| [Power fitting](guide/powerfit.md) | Fit power weights from pairwise bisector constraints, realized-boundary matching, and self-consistent active sets in 2D or 3D. | +| [Visualization](guide/visualization.md) | Optional `py3Dmol` / `matplotlib` helpers for debugging and exploratory analysis. | +| [Examples (notebooks)](guide/notebooks.md) | End-to-end examples, including focused power-fitting notebooks for reports, infeasibility witnesses, and active-set path diagnostics. | +| [API reference](reference/planar/index.md) | The full reference (docstrings) for both the spatial and planar APIs. | ## Installation @@ -138,12 +172,25 @@ Most users should install a prebuilt wheel: pip install pyvoro2 ``` +Optional extras: + +- `pyvoro2[viz]` for the 3D `py3Dmol` viewer (and 2D plotting too) +- `pyvoro2[viz2d]` for 2D matplotlib plotting only +- `pyvoro2[all]` to install the full optional stack used for local notebook, + docs, lint, and publishability checks + To build from source (requires a C++ compiler and Python development headers): ```bash pip install -e . ``` +For contributor-style local validation, install the full optional stack: + +```bash +pip install -e ".[all]" +``` + ## Testing pyvoro2 uses **pytest**. The default test suite is intended to be fast and deterministic: @@ -176,14 +223,23 @@ Additional test groups are **opt-in**: Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`. +## Release and publishability checks + +For a one-shot local publishability pass (lint, notebook execution, exported notebook sync, README sync, tests, docs, build, metadata checks, and wheel smoke test): + +```bash +python tools/release_check.py +``` + ## Project status pyvoro2 is currently in **beta**. -The core tessellation modes (standard and power/Laguerre) are stable, and a large -part of the work in this repository focuses on tests and documentation. -A future 1.0 release is planned once the inverse-fitting workflow is more mature -and native 2D support is added. +The core tessellation modes (standard and power/Laguerre) are stable, and the +0.6.0 release now includes a first-class planar namespace. +A future 1.0 release is planned once the inverse-fitting workflow is more mature, +its disconnected-graph / coverage diagnostics are stabilized, and the project has +reassessed whether planar `PeriodicCell` support is actually needed. ## AI-assisted development @@ -195,5 +251,6 @@ Details are documented in the [AI usage](project/ai.md) page. ## License -- pyvoro2 is released under the **MIT License**. -- Voro++ is vendored and redistributed under its original license (see the project pages). +- Starting with **0.6.0**, the pyvoro2-authored code is released under the **GNU Lesser General Public License v3.0 or later (LGPLv3+)**. +- Versions **before 0.6.0** were released under the **MIT License**. +- Voro++ is vendored and redistributed under its original upstream license. diff --git a/docs/notebooks/01_basic_compute.md b/docs/notebooks/01_basic_compute.md new file mode 100644 index 0000000..92413c4 --- /dev/null +++ b/docs/notebooks/01_basic_compute.md @@ -0,0 +1,490 @@ + + +[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/01_basic_compute.ipynb) +# Basic tessellations in pyvoro2 + +This notebook is a compact tour of the most common `pyvoro2.compute(...)` workflows. +It is written as a narrative: each section introduces the geometric idea first, and then shows +the minimal code needed to reproduce it. + +We cover: +- Voronoi cells in a non-periodic **bounding box** (`Box`) +- Voronoi cells in a **triclinic periodic unit cell** (`PeriodicCell`) +- Power/Laguerre tessellation (`mode='power'`) and the meaning of per-site radii +- What geometry is returned (`vertices`, `faces`, `adjacency`) +- Periodic face shifts (`adjacent_shift`) and basic diagnostics +- Global enumeration utilities (`normalize_topology`) and per-face descriptors + +> Tip: If you are new to Voronoi terminology, the short conceptual background is in +> the docs section [Concepts](../guide/concepts.md). +```python +import numpy as np +from pprint import pprint + +import pyvoro2 as pv +from pyvoro2 import Box, OrthorhombicCell, PeriodicCell, compute +``` +## Voronoi tessellation in a bounding box (Box) + +In a non-periodic domain, the Voronoi cell of a site is the region of space that is closer +to that site than to any other site. In practice, we also need a finite *domain* to cut +the unbounded cells — here we use a rectangular `Box`. +```python +pts = np.array( + [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [0.0, 2.0, 0.0], + [0.0, 0.0, 2.0], + ], + dtype=float, +) + +box = Box(bounds=((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + +cells = compute( + pts, + domain=box, + mode='standard', + return_vertices=True, + return_faces=True, + return_adjacency=False, # keep output small for display +) + +print(f'Total number of cells: {len(cells)}\n') +pprint(cells[0]) +``` +**Output** + +```text +Total number of cells: 4 + +{'faces': [{'adjacent_cell': 1, 'vertices': [1, 5, 7, 3]}, + {'adjacent_cell': -3, 'vertices': [1, 0, 4, 5]}, + {'adjacent_cell': -5, 'vertices': [1, 3, 2, 0]}, + {'adjacent_cell': 2, 'vertices': [2, 3, 7, 6]}, + {'adjacent_cell': -1, 'vertices': [2, 6, 4, 0]}, + {'adjacent_cell': 3, 'vertices': [4, 6, 7, 5]}], + 'id': 0, + 'site': [0.0, 0.0, 0.0], + 'vertices': [[-5.0, -5.0, -5.0], + [1.0, -5.0, -5.0], + [-5.0, 1.0, -5.0], + [1.0, 1.0, -5.0], + [-5.0, -5.0, 1.0], + [1.0, -5.0, 1.0], + [-5.0, 1.0, 1.0], + [1.0, 1.0, 1.0]], + 'volume': 216.0} +``` +## Periodic tessellation in a triclinic unit cell (PeriodicCell) + +For crystals and other periodic systems, the natural domain is a unit cell with periodic boundary +conditions. `PeriodicCell` supports fully triclinic (skew) cells by representing the cell with +three lattice vectors. + +A useful sanity check: in a fully periodic Voronoi tessellation, the sum of all cell volumes +should equal the unit cell volume (up to numerical tolerance). +```python +cell = PeriodicCell( + vectors=( + (10.0, 0.0, 0.0), + (2.0, 9.5, 0.0), + (1.0, 0.5, 9.0), + ) +) + +pts_pbc = np.array( + [ + [1.0, 1.0, 1.0], + [5.0, 5.0, 5.0], + [8.0, 2.0, 7.0], + [3.0, 9.0, 4.0], + ], + dtype=float, +) + +cells_pbc = compute( + pts_pbc, + domain=cell, + mode='standard', + return_vertices=False, + return_faces=False, + return_adjacency=False, +) + +# In periodic mode, all Voronoi volumes should sum to the unit cell volume. +cell_volume = abs(np.linalg.det(np.array(cell.vectors, dtype=float))) +sum_vol = float(sum(c['volume'] for c in cells_pbc)) +cell_volume, sum_vol +``` +**Output** + +```text +(855.0000000000013, 855.0) +``` +## Power/Laguerre tessellation (mode="power") + +A power (Laguerre) tessellation generalizes Voronoi cells by assigning each site a weight. +Voro++ (and pyvoro2) expose this as a per-site **radius** $r_i$, which corresponds to a weight +$w_i = r_i^2$ in the power distance. + +Intuitively: increasing a site's radius tends to expand its cell at the expense of neighbors. +Unlike standard Voronoi cells, **empty cells are possible** in power mode. +```python +# Re-define the periodic cell and points (self-contained example) +cell = PeriodicCell( + vectors=( + (10.0, 0.0, 0.0), + (2.0, 9.5, 0.0), + (1.0, 0.5, 9.0), + ) +) + +pts_pbc = np.array( + [ + [1.0, 1.0, 1.0], + [5.0, 5.0, 5.0], + [8.0, 2.0, 7.0], + [3.0, 9.0, 4.0], + ], + dtype=float, +) + +radii = np.array([0.0, 0.0, 2.0, 0.0], dtype=float) + +cells_std = compute( + pts_pbc, + domain=cell, + mode='standard', + return_vertices=False, + return_faces=False, + return_adjacency=False, +) + +cells_pow = compute( + pts_pbc, + domain=cell, + mode='power', + radii=radii, + return_vertices=False, + return_faces=False, + return_adjacency=False, +) + +vols_std = [c['volume'] for c in cells_std] +vols_pow = [c['volume'] for c in cells_pow] + +vols_std, vols_pow +``` +**Output** + +```text +([204.52350840152917, + 243.35630134069405, + 231.409081979397, + 175.71110827837984], + [177.66314170369014, + 213.6503389726455, + 307.3025551674562, + 156.38396415620826]) +``` +## Inspecting geometry: vertices, faces, adjacency + +`compute(...)` can return different levels of geometric detail. For downstream analysis, the most +important pieces are: + +- `vertices`: coordinates of the cell vertices +- `faces`: polygonal faces (each includes the list of vertex indices and the adjacent cell id) +- `adjacency`: per-vertex adjacency lists (optional) + +The cell dictionaries are designed to be plain data (NumPy arrays + Python lists), so you can +serialize them or process them with your own code. +```python +# Re-define the 0D box system (self-contained example) +pts = np.array( + [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [0.0, 2.0, 0.0], + [0.0, 0.0, 2.0], + ], + dtype=float, +) + +box = Box(bounds=((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + +cells_full = compute( + pts, + domain=box, + mode='standard', + return_vertices=True, + return_faces=True, + return_adjacency=True, +) + +pprint(cells_full[0]) +``` +**Output** + +```text +{'adjacency': [[1, 4, 2], + [5, 0, 3], + [3, 0, 6], + [7, 1, 2], + [6, 0, 5], + [4, 1, 7], + [7, 2, 4], + [5, 3, 6]], + 'faces': [{'adjacent_cell': 1, 'vertices': [1, 5, 7, 3]}, + {'adjacent_cell': -3, 'vertices': [1, 0, 4, 5]}, + {'adjacent_cell': -5, 'vertices': [1, 3, 2, 0]}, + {'adjacent_cell': 2, 'vertices': [2, 3, 7, 6]}, + {'adjacent_cell': -1, 'vertices': [2, 6, 4, 0]}, + {'adjacent_cell': 3, 'vertices': [4, 6, 7, 5]}], + 'id': 0, + 'site': [0.0, 0.0, 0.0], + 'vertices': [[-5.0, -5.0, -5.0], + [1.0, -5.0, -5.0], + [-5.0, 1.0, -5.0], + [1.0, 1.0, -5.0], + [-5.0, -5.0, 1.0], + [1.0, -5.0, 1.0], + [-5.0, 1.0, 1.0], + [1.0, 1.0, 1.0]], + 'volume': 216.0} +``` +## Empty cells in power mode (include_empty=True) + +In a power diagram, some sites can be dominated by others and end up with **zero volume**. +This is mathematically valid. If you want these cases to appear explicitly in the output, +use `include_empty=True`. +```python +cell_u = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))) +pts_u = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float) +radii_u = np.array([1.0, 2.0], dtype=float) + +cells_pow = compute( + pts_u, + domain=cell_u, + mode='power', + radii=radii_u, + include_empty=True, + return_vertices=True, + return_faces=True, + return_adjacency=False, + return_face_shifts=True, + face_shift_search=1, +) + +[(int(c['id']), c.get('empty', False), float(c.get('volume', 0.0))) for c in cells_pow] +``` +**Output** + +```text +[(0, True, 0.0), (1, False, 0.9999999999999997)] +``` +## Periodic face shifts and diagnostics + +In periodic domains, an adjacency is not just “site *i* touches site *j*”. The shared face is formed +with a **particular periodic image** of *j*. pyvoro2 can annotate each face with an integer lattice +shift `adjacent_shift = (na, nb, nc)`. + +This section also shows how to request diagnostics when you want to actively validate a tessellation. +```python +cell_u = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))) +pts_u = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float) + +cells_std_u, diag_std_u = compute( + pts_u, + domain=cell_u, + mode='standard', + return_vertices=True, + return_faces=True, + return_adjacency=False, + return_face_shifts=True, + face_shift_search=1, + tessellation_check='diagnose', + return_diagnostics=True, +) + +# Inspect the face between the two sites across the x-boundary. +c0 = next(c for c in cells_std_u if int(c['id']) == 0) +idx = next(i for i, f in enumerate(c0['faces']) if int(f['adjacent_cell']) == 1) +face01 = c0['faces'][idx] + +(diag_std_u.ok, diag_std_u.volume_ratio, diag_std_u.n_faces_orphan), face01 +``` +**Output** + +```text +((True, 1.0, 0), + {'adjacent_cell': 1, + 'vertices': [1, 6, 4, 5], + 'adjacent_shift': (-1, 0, 0), + 'orphan': False, + 'reciprocal_mismatch': False, + 'reciprocal_missing': False}) +``` +## Normalization: global vertices / edges / faces + +When you compute cells, each cell has its own local vertex indexing. For graph and topology work, +it is often helpful to build a **global** pool of vertices/edges/faces with stable IDs that are +consistent across cells. + +`normalize_topology(...)` can mutate cell dicts (unless `copy_cells=True`) and adds global-id arrays +such as `vertex_global_id` and `face_global_id`. +```python +from pyvoro2 import normalize_topology + +cell_n = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))) +pts_n = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float) + +cells_n = compute( + pts_n, + domain=cell_n, + mode='standard', + return_vertices=True, + return_faces=True, + return_adjacency=False, + return_face_shifts=True, + face_shift_search=1, +) + +# Pick the periodic wrap face (0 -> 1 across x-wrap) +c0 = next(c for c in cells_n if int(c['id']) == 0) +idx = next( + i + for i, f in enumerate(c0['faces']) + if int(f['adjacent_cell']) == 1 and tuple(int(x) for x in f['adjacent_shift']) == (-1, 0, 0) +) + +# Mutate in place so the original cell dictionaries gain global id fields. +nt = normalize_topology(cells_n, domain=cell_n, copy_cells=False) + +n_global = (len(nt.global_vertices), len(nt.global_edges), len(nt.global_faces)) + +# Example: show the face's global id and its global vertex ids +fid0 = int(c0['face_global_id'][idx]) +print(f'Global counts for vertices, edges, and faces: {n_global}') +print('\nGlobal face data:') +pprint(nt.global_faces[fid0]) +print('\nUpdated cell:') +pprint(c0) +``` +**Output** + +```text +Global counts for vertices, edges, and faces: (16, 24, 6) + +Global face data: +{'cell_shifts': ((0, 0, 0), (-1, 0, 0)), + 'cells': (0, 1), + 'vertex_shifts': [(0, 0, 0), (0, 1, 0), (0, 1, -1), (0, 0, -1)], + 'vertices': [1, 5, 4, 6]} + +Updated cell: +{'edge_global_id': [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + 'edges': [(0, 3), + (0, 4), + (0, 7), + (1, 2), + (1, 5), + (1, 6), + (2, 3), + (2, 7), + (3, 5), + (4, 5), + (4, 6), + (6, 7)], + 'face_global_id': [0, 1, 2, 3, 0, 1], + 'faces': [{'adjacent_cell': 0, + 'adjacent_shift': (0, -1, 0), + 'vertices': [1, 2, 7, 6]}, + {'adjacent_cell': 0, + 'adjacent_shift': (0, 0, 1), + 'vertices': [1, 5, 3, 2]}, + {'adjacent_cell': 1, + 'adjacent_shift': (-1, 0, 0), + 'vertices': [1, 6, 4, 5]}, + {'adjacent_cell': 1, + 'adjacent_shift': (0, 0, 0), + 'vertices': [2, 3, 0, 7]}, + {'adjacent_cell': 0, + 'adjacent_shift': (0, 1, 0), + 'vertices': [3, 5, 4, 0]}, + {'adjacent_cell': 0, + 'adjacent_shift': (0, 0, -1), + 'vertices': [4, 6, 7, 0]}], + 'id': 0, + 'site': [0.1, 0.5, 0.5], + 'vertex_global_id': [0, 1, 2, 3, 4, 5, 6, 7], + 'vertex_shift': [(0, 1, 0), + (0, 0, 1), + (0, 0, 1), + (0, 1, 1), + (0, 1, 0), + (0, 1, 1), + (0, 0, 0), + (0, 0, 0)], + 'vertices': [[0.5, 1.0, 0.0], + [-1.3877787807814457e-16, 0.0, 1.0], + [0.5, 0.0, 1.0], + [0.5, 1.0, 0.9999999999999998], + [-1.3877787807814457e-16, 1.0, 0.0], + [-1.3877787807814457e-16, 1.0, 1.0], + [-1.3877787807814457e-16, 0.0, 0.0], + [0.5, 0.0, 1.1102230246251565e-16]], + 'volume': 0.5000000000000001} +``` +## Face properties: contact descriptors + +`annotate_face_properties(...)` computes per-face descriptors (centroid, normal, and intersection +with the site-to-site line) that are often useful for contact analysis. +```python +from pyvoro2 import annotate_face_properties + +cell_f = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))) +pts_f = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float) + +cells_f, diag_f = compute( + pts_f, + domain=cell_f, + mode='standard', + return_vertices=True, + return_faces=True, + return_adjacency=False, + return_face_shifts=True, + face_shift_search=1, + tessellation_check='diagnose', + return_diagnostics=True, +) + +c0 = next(c for c in cells_f if int(c['id']) == 0) +idx = next( + i + for i, f in enumerate(c0['faces']) + if int(f['adjacent_cell']) == 1 and tuple(int(x) for x in f['adjacent_shift']) == (-1, 0, 0) +) + +annotate_face_properties(cells_f, domain=cell_f, diagnostics=diag_f) +f = c0['faces'][idx] +{ + 'centroid': f.get('centroid'), + 'normal': f.get('normal'), + 'intersection': f.get('intersection'), + 'intersection_inside': f.get('intersection_inside'), + 'intersection_centroid_dist': f.get('intersection_centroid_dist'), + 'intersection_edge_min_dist': f.get('intersection_edge_min_dist'), +} +``` +**Output** + +```text +{'centroid': [-1.3877787807814457e-16, 0.5, 0.5], + 'normal': [-1.0, -0.0, -0.0], + 'intersection': [-1.3877787807814457e-16, 0.5, 0.5], + 'intersection_inside': True, + 'intersection_centroid_dist': 0.0, + 'intersection_edge_min_dist': 0.5} +``` diff --git a/docs/notebooks/02_periodic_graph.md b/docs/notebooks/02_periodic_graph.md new file mode 100644 index 0000000..1d598f1 --- /dev/null +++ b/docs/notebooks/02_periodic_graph.md @@ -0,0 +1,162 @@ + + +[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/02_periodic_graph.ipynb) +# Periodic tessellation and neighbor graphs + +In non-periodic geometry, a Voronoi tessellation naturally defines a **neighbor graph**: +two sites are neighbors if their cells share a face. + +In a **periodic** domain, there is an additional subtlety: + +- every site has infinitely many periodic images, +- a face between sites *i* and *j* is formed with a **specific image** of *j*. + +If you want a graph that is correct for crystals, you typically need that image information. +pyvoro2 can annotate each face with an integer lattice shift: + +- `adjacent_cell`: neighbor id +- `adjacent_shift = (na, nb, nc)`: which periodic image produced the face + +This notebook shows a minimal workflow: +1. compute a periodic tessellation in a triclinic cell, +2. extract graph edges `(i, j, shift)`, +3. canonicalize edges into an undirected contact list. +```python +import numpy as np +import pyvoro2 as pv + +rng = np.random.default_rng(0) + +# Random points in Cartesian coordinates (not necessarily wrapped) +points = rng.random((30, 3)) + +cell = pv.PeriodicCell( + vectors=((10.0, 0.0, 0.0), (2.0, 9.0, 0.0), (1.0, 0.5, 8.0)), + origin=(0.0, 0.0, 0.0), +) + +cells = pv.compute( + points, + domain=cell, + return_faces=True, + return_vertices=True, + return_face_shifts=True, # <-- adds `adjacent_shift` to each face + face_shift_search=2, +) +len(cells) +``` +**Output** + +```text +30 +``` +## Inspecting face shifts + +For a well-formed periodic tessellation, face shifts should be **reciprocal**: +if cell *i* has a face to neighbor *j* with shift *s*, then cell *j* should have the +corresponding face back to *i* with shift `-s`. + +Let's inspect one example face. +```python +# Pick a cell and show its first non-boundary face +c0 = next(c for c in cells if int(c['id']) == 0) +f0 = next(f for f in c0['faces'] if int(f.get('adjacent_cell', -1)) >= 0) + +(i, j, shift) = (int(c0['id']), int(f0['adjacent_cell']), tuple(int(x) for x in f0['adjacent_shift'])) +(i, j, shift) +``` +**Output** + +```text +(0, 8, (0, 0, -1)) +``` +## Extracting a periodic neighbor graph + +A simple representation for periodic adjacency is a list of **directed** edges: + +- `(i, j, shift)` + +meaning: *cell i* touches the image of *cell j* translated by `shift`. + +Depending on your application, you may want to: +- keep the graph directed (useful for some algorithms), or +- canonicalize contacts into an **undirected** set by storing only one orientation. + +Below we build both. +```python +# 1) Directed edges from faces +directed = [] +for c in cells: + i = int(c['id']) + for f in c.get('faces', []): + j = int(f.get('adjacent_cell', -1)) + if j < 0: + continue + s = tuple(int(x) for x in f.get('adjacent_shift', (0, 0, 0))) + directed.append((i, j, s)) + +print('n_directed:', len(directed)) +print('sample:', directed[:5]) +``` +**Output** + +```text +n_directed: 436 +sample: [(0, 8, (0, 0, -1)), (0, 20, (0, 0, 0)), (0, 6, (0, 0, 0)), (0, 19, (0, 0, 0)), (0, 10, (0, 0, 0))] +``` +```python +# 2) Canonicalize into an undirected contact set +# +# We choose a convention: +# - store edges with i < j +# - if we flip direction, also flip the shift (reciprocity) +undirected = set() +for (i, j, s) in directed: + if i < j: + undirected.add((i, j, s)) + elif j < i: + undirected.add((j, i, (-s[0], -s[1], -s[2]))) + +print('n_undirected:', len(undirected)) +print('sample:', list(sorted(undirected))[:5]) +``` +**Output** + +```text +n_undirected: 218 +sample: [(0, 3, (0, 0, 0)), (0, 6, (0, 0, 0)), (0, 8, (0, 0, -1)), (0, 10, (0, 0, 0)), (0, 14, (0, 0, 0))] +``` +## Building an adjacency list + +Many downstream workflows prefer an adjacency list: + +- `adj[i] = [(j, shift), ...]` + +Here we build it from the directed edges. +```python +from collections import defaultdict + +adj = defaultdict(list) +for (i, j, s) in directed: + adj[i].append((j, s)) + +# Show the neighbors of site 0 +adj[0][:10] +``` +**Output** + +```text +[(8, (0, 0, -1)), + (20, (0, 0, 0)), + (6, (0, 0, 0)), + (19, (0, 0, 0)), + (10, (0, 0, 0)), + (14, (0, 0, 0)), + (3, (0, 0, 0))] +``` +## Notes + +- For `OrthorhombicCell` with only partial periodicity, shifts on non-periodic axes are always zero. +- If you plan to compute a graph repeatedly (e.g., for many frames), consider: + - keeping your inputs in a consistent wrapped form, and + - using `tessellation_check='warn'` or `'diagnose'` during development. diff --git a/docs/notebooks/03_locate_and_ghost.md b/docs/notebooks/03_locate_and_ghost.md new file mode 100644 index 0000000..4c44ce4 --- /dev/null +++ b/docs/notebooks/03_locate_and_ghost.md @@ -0,0 +1,97 @@ + + +[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/03_locate_and_ghost.ipynb) +# Point queries: locate(...) and ghost_cells(...) + +A full tessellation (`compute`) gives you all cells at once. In many workflows you only need +**local queries**: + +- **Owner lookup**: *which site owns this point?* → `locate(...)` +- **Probe/ghost cell**: *what cell would a query point have if it were inserted?* → `ghost_cells(...)` + +Both operations are **stateless** in pyvoro2: each call builds a temporary Voro++ container, +runs the query, and returns plain Python/NumPy outputs. + +This notebook demonstrates both operations in a non-periodic `Box`. +```python +import numpy as np +from pprint import pprint + +import pyvoro2 as pv + +rng = np.random.default_rng(0) + +# Generator sites +points = rng.uniform(-1.0, 1.0, size=(25, 3)) + +box = pv.Box(((-2, 2), (-2, 2), (-2, 2))) +``` +## 1) Owner lookup with locate(...) + +`locate(points, queries, domain=...)` returns, for each query point, whether it was located and +which generator site owns it. + +- For a non-periodic `Box`, queries outside the box are typically reported as `found=False`. +```python +queries = np.array( + [ + [0.0, 0.0, 0.0], # inside + [1.5, 1.5, 1.5], # inside (near boundary) + [5.0, 0.0, 0.0], # outside + ], + dtype=float, +) + +res = pv.locate( + points, + queries, + domain=box, + return_owner_position=True, +) + +pprint(res) +``` +**Output** + +```text +{'found': array([ True, True, False]), + 'owner_id': array([14, 9, -1]), + 'owner_pos': array([[ 0.18860006, -0.32417755, -0.216762 ], + [ 0.96167068, 0.37108397, 0.30091855], + [ nan, nan, nan]])} +``` +## 2) Probe cells with ghost_cells(...) + +`ghost_cells(points, queries, domain=...)` computes the Voronoi cell **around each query point** +without inserting it permanently into the point set. + +This is useful for: +- sampling free volume at probe points, +- inspecting local environments, +- building “what-if” analyses without recomputing the entire tessellation. + +For a non-periodic `Box`, a query outside the box may yield an empty result when `include_empty=True`. +```python +ghost = pv.ghost_cells( + points, + queries, + domain=box, + include_empty=True, + return_vertices=True, + return_faces=True, +) + +# Show a compact summary +[(g['query_index'], bool(g.get('empty', False)), float(g.get('volume', 0.0))) for g in ghost] +``` +**Output** + +```text +[(0, False, 0.21887577215282997), + (1, False, 3.3710997729938335), + (2, True, 0.0)] +``` +## Notes + +- In a periodic domain, `locate` and `ghost_cells` wrap queries into a primary domain. +- In power mode (`mode='power'`), a ghost cell also needs a radius/weight for the query site (`ghost_radius`). diff --git a/docs/notebooks/04_inverse_fit.ipynb b/docs/notebooks/04_inverse_fit.ipynb deleted file mode 100644 index f723801..0000000 --- a/docs/notebooks/04_inverse_fit.ipynb +++ /dev/null @@ -1,311 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "c76412f0a2844e34964ce3a2e7cbc84a", - "metadata": {}, - "source": [ - "# Inverse fitting of power weights (Laguerre radii)\n", - "\n", - "Sometimes you do *not* want a purely distance-based partition. Instead, you may know\n", - "(or hypothesize) where the **interface** between two sites should lie along the line\n", - "connecting them.\n", - "\n", - "In a power/Laguerre tessellation, each site has a weight $w_i$ and the boundary between\n", - "sites $i$ and $j$ is defined by equal **power distance**:\n", - "\n", - "$$\n", - "\\lVert x - p_i\\rVert^2 - w_i \\;=\\; \\lVert x - p_j\\rVert^2 - w_j.\n", - "$$\n", - "\n", - "Along the line segment $p_i \\to p_j$, the separating plane intersects at a fraction $t$\n", - "(measured from $i$ toward $j$):\n", - "\n", - "$$\n", - "t \\;=\\; \\tfrac12 + \\frac{w_i - w_j}{2\\,d^2}, \\qquad d=\\lVert p_j - p_i\\rVert.\n", - "$$\n", - "\n", - "So a desired $t_{ij}$ constrains the **weight difference** $w_i - w_j$.\n", - "\n", - "pyvoro2 provides solvers that fit weights (and corresponding Voro++ radii $r_i=\\sqrt{w_i+C}$)\n", - "from a list of constraints $(i, j, t_{ij})$.\n", - "\n", - "Important practical note:\n", - "a constraint can be “algebraically satisfied” but the pair might still not become a **face** in the\n", - "final tessellation (e.g. because a third site blocks it). The result object can optionally report\n", - "such inactive constraints (`check_contacts=True`).\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "daf0256eb4c24037a1a010122fff1985", - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "from pprint import pprint\n", - "\n", - "import pyvoro2 as pv\n" - ] - }, - { - "cell_type": "markdown", - "id": "b0a781f26d0f443a83013b7a79fdf764", - "metadata": {}, - "source": [ - "## 1) Two-site example (easy to interpret)\n", - "\n", - "With only two sites, the separating plane is the only interface in the domain.\n", - "We ask for $t=0.25$, i.e. the interface is closer to site 0 than to site 1.\n", - "\n", - "Here we also set `r_min=1.0` to choose a radii gauge where the smallest returned radius is 1.0.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "ba10df2cbd3c4d85a85bab86883acb36", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "weights: [0. 2.]\n", - "radii: [1. 1.73205081]\n", - "t_target: [0.25]\n", - "t_pred: [0.25]\n" - ] - } - ], - "source": [ - "points = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float)\n", - "box = pv.Box(((-5, 5), (-5, 5), (-5, 5)))\n", - "\n", - "constraints = [(0, 1, 0.25)]\n", - "\n", - "fit = pv.fit_power_weights_from_plane_fractions(\n", - " points,\n", - " constraints,\n", - " domain=box,\n", - " t_bounds_mode='none',\n", - " r_min=1.0,\n", - ")\n", - "\n", - "print('weights:', fit.weights)\n", - "print('radii:', fit.radii)\n", - "print('t_target:', fit.t_target)\n", - "print('t_pred:', fit.t_pred)\n" - ] - }, - { - "cell_type": "markdown", - "id": "da700da1f3a04af78affd4a18033d2fc", - "metadata": {}, - "source": [ - "Now use the fitted radii in an actual power tessellation and inspect the volumes.\n", - "(For two points in a box, both cells are always present and share one face.)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "7b09c37330a8446ea96e96338aa53ce6", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "volumes: [550.0, 449.9999999999999]\n" - ] - } - ], - "source": [ - "cells = pv.compute(\n", - " points,\n", - " domain=box,\n", - " mode='power',\n", - " radii=fit.radii,\n", - " return_faces=True,\n", - " return_vertices=True,\n", - ")\n", - "\n", - "print('volumes:', [float(c['volume']) for c in cells])\n" - ] - }, - { - "cell_type": "markdown", - "id": "96a55f1eb4964d4eb6be5c265ee96dc0", - "metadata": {}, - "source": [ - "## 2) Allowing $t<0$ or $t>1$ and adding penalties\n", - "\n", - "In a power diagram, it is possible for the separating plane to lie **outside** the segment\n", - "between the two sites ($t<0$ or $t>1$). This corresponds to one site strongly dominating\n", - "the other.\n", - "\n", - "pyvoro2 lets you:\n", - "\n", - "- allow any $t$ values, and\n", - "- optionally penalize or forbid predicted $t$ outside a chosen interval.\n", - "\n", - "Two common regimes are:\n", - "\n", - "- **soft bounds**: quadratic penalty when $t$ leaves $[0,1]$ (`t_bounds_mode='soft_quadratic'`)\n", - "- **hard bounds**: infeasible outside $[0,1]$ (`t_bounds_mode='hard'`)\n", - "\n", - "You can also add an “avoid the endpoints” exponential penalty to discourage interfaces\n", - "too close to $t=0$ or $t=1$ (`t_near_penalty='exp'`).\n" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "9fa78c05b21d4da7a2de5f9db0862cab", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "t_target: [1.4]\n", - "t_pred: [0.90387053]\n", - "warnings: ()\n" - ] - } - ], - "source": [ - "# An intentionally extreme constraint\n", - "constraints2 = [(0, 1, 1.4)] # plane \"behind\" site 1 (t > 1)\n", - "\n", - "fit_soft = pv.fit_power_weights_from_plane_fractions(\n", - " points,\n", - " constraints2,\n", - " domain=box,\n", - " t_bounds_mode='soft_quadratic',\n", - " alpha_out=5.0,\n", - " t_near_penalty='exp',\n", - " beta_near=1.0,\n", - " t_margin=0.05,\n", - " r_min=1.0,\n", - ")\n", - "\n", - "print('t_target:', fit_soft.t_target)\n", - "print('t_pred:', fit_soft.t_pred)\n", - "print('warnings:', fit_soft.warnings)\n" - ] - }, - { - "cell_type": "markdown", - "id": "eeca6a9ea2a844b5bb58dd625ee733b3", - "metadata": {}, - "source": [ - "## 3) Multi-site example and contact checking\n", - "\n", - "With multiple sites, a requested pair `(i, j)` might not become adjacent in the final tessellation.\n", - "Set `check_contacts=True` to have pyvoro2 compute a tessellation using the fitted radii and report\n", - "which constraints correspond to actual faces.\n", - "\n", - "This is valuable when you plan to iterate:\n", - "fit → compute tessellation → update constraints/weights.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "2135561dce5c4fd9a3fd8b325c7837f4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "rms_residual: 0.0\n", - "inactive_constraints: (1,)\n" - ] - } - ], - "source": [ - "rng = np.random.default_rng(1)\n", - "points3 = rng.uniform(-1.0, 1.0, size=(12, 3))\n", - "box3 = pv.Box(((-2, 2), (-2, 2), (-2, 2)))\n", - "\n", - "# Pick a few arbitrary constraints. In a real workflow you would usually choose\n", - "# pairs that are expected to be near-neighbors.\n", - "constraints3 = [\n", - " (0, 1, 0.45),\n", - " (0, 2, 0.55),\n", - " (3, 4, 0.50),\n", - " (5, 6, 0.60),\n", - "]\n", - "\n", - "fit3 = pv.fit_power_weights_from_plane_fractions(\n", - " points3,\n", - " constraints3,\n", - " domain=box3,\n", - " check_contacts=True,\n", - " r_min=0.0,\n", - ")\n", - "\n", - "print('rms_residual:', fit3.rms_residual)\n", - "print('inactive_constraints:', fit3.inactive_constraints)\n" - ] - }, - { - "cell_type": "markdown", - "id": "9eabb84c5cf64f7693dd74161576d543", - "metadata": {}, - "source": [ - "You can now use `fit3.radii` in `mode='power'` computations.\n", - "\n", - "If you see many inactive constraints, that does *not* necessarily mean the optimizer failed;\n", - "it usually means the requested pair is not a Delaunay neighbor under the fitted weights.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "10e407c5eaa843e0a0dc04c6c367627c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "n_cells: 12\n", - "n_empty: 0\n" - ] - } - ], - "source": [ - "cells3 = pv.compute(points3, domain=box3, mode='power', radii=fit3.radii, include_empty=True)\n", - "\n", - "print('n_cells:', len(cells3))\n", - "print('n_empty:', sum(bool(c.get('empty', False)) for c in cells3))\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "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.10.19" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/notebooks/04_powerfit.md b/docs/notebooks/04_powerfit.md new file mode 100644 index 0000000..0daf2f4 --- /dev/null +++ b/docs/notebooks/04_powerfit.md @@ -0,0 +1,142 @@ + + +[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/04_powerfit.ipynb) +# Power fitting from pairwise bisector constraints + +This notebook shows the new math-oriented inverse API in `pyvoro2`: + +1. resolve pairwise bisector constraints, +2. fit power weights under a configurable model, +3. match realized pairs in the resulting power tessellation, +4. run the self-consistent active-set solver. +```python +import numpy as np + +import pyvoro2 as pv +``` +## 1) Resolve and fit a simple two-site constraint + +A raw constraint tuple is `(i, j, value[, shift])`, where `value` is +interpreted in either fraction-space or absolute position-space. +```python +points = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) +box = pv.Box(((-5, 5), (-5, 5), (-5, 5))) + +constraints = pv.resolve_pair_bisector_constraints( + points, + [(0, 1, 0.25)], + measurement='fraction', + domain=box, +) + +fit = pv.fit_power_weights(points, constraints) + +print('weights:', fit.weights) +print('radii:', fit.radii) +print('predicted fraction:', fit.predicted_fraction) +print('predicted position:', fit.predicted_position) +print('status:', fit.status) +print('weight shift:', fit.weight_shift) +``` +## 2) Add hard feasibility and a near-boundary penalty + +The fitting model separates mismatch, hard feasibility, and soft penalties. +```python +model = pv.FitModel( + mismatch=pv.SquaredLoss(), + feasible=pv.Interval(0.0, 1.0), + penalties=( + pv.ExponentialBoundaryPenalty( + lower=0.0, + upper=1.0, + margin=0.05, + strength=1.0, + tau=0.01, + ), + ), +) + +fit_penalized = pv.fit_power_weights( + points, + [(0, 1, 1e-3)], + measurement='fraction', + domain=box, + model=model, + solver='admm', +) + +print('predicted fraction with penalty:', fit_penalized.predicted_fraction[0]) +``` +## 3) Match realized pairs after fitting + +Requested pairwise separators do not automatically become realized faces +in the full power tessellation. +```python +realized = pv.match_realized_pairs( + points, + domain=box, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + return_tessellation_diagnostics=True, +) + +print('realized:', realized.realized) +print('same shift:', realized.realized_same_shift) +print('boundary measure:', realized.boundary_measure) +print('tessellation ok:', realized.tessellation_diagnostics.ok) +``` +## 4) Self-consistent active-set refinement + +For larger candidate sets, the active-set solver repeatedly fits, tessellates, +and keeps the constraints whose requested pairs are actually realized. +```python +points3 = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, +) +box3 = pv.Box(((-5, 5), (-5, 5), (-5, 5))) + +result = pv.solve_self_consistent_power_weights( + points3, + [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)], + measurement='fraction', + domain=box3, + options=pv.ActiveSetOptions(add_after=1, drop_after=2, relax=0.5), + return_history=True, + return_boundary_measure=True, +) + +print('termination:', result.termination) +print('active mask:', result.active_mask) +print('constraint status:', result.diagnostics.status) +print('marginal constraints:', result.marginal_constraints) + +print('path summary:', result.path_summary) +``` +## Disconnected path example + +The next example starts from an empty active set so the first fitted subproblem is completely disconnected, while the final active set reconnects into the expected nearest-neighbor chain. This illustrates the difference between final-state diagnostics and optimization-path diagnostics. +```python +points4 = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, +) +box4 = pv.Box(((-5, 5), (-5, 5), (-5, 5))) + +result_path = pv.solve_self_consistent_power_weights( + points4, + [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)], + measurement='fraction', + domain=box4, + active0=np.array([False, False, False]), + options=pv.ActiveSetOptions(add_after=1, drop_after=1, max_iter=6), + return_history=True, + connectivity_check='diagnose', + unaccounted_pair_check='diagnose', +) + +print('final active graph components:', result_path.connectivity.active_graph.n_components) +print('path summary:', result_path.path_summary) +print('first history row:', result_path.history[0]) +``` diff --git a/docs/notebooks/05_visualization.md b/docs/notebooks/05_visualization.md new file mode 100644 index 0000000..51b2ad2 --- /dev/null +++ b/docs/notebooks/05_visualization.md @@ -0,0 +1,4153 @@ + + +[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/05_visualization.ipynb) +# Visualization with py3Dmol (optional) + +pyvoro2 includes a small **optional** helper module, `pyvoro2.viz3d`, for interactive visualization +in notebooks. It is meant for exploration and debugging: + +- draw the domain wireframe (box or unit cell), +- draw cell wireframes from the `vertices`/`faces` output, +- optionally draw labeled sites and (global) vertices. + +Install the extra dependency with: + +```bash +pip install "pyvoro2[viz]" +``` + +or directly: + +```bash +pip install py3Dmol +``` + +> These helpers are intentionally lightweight. For publication-quality rendering you will usually +> want a dedicated 3D pipeline. +```python +import numpy as np +import pyvoro2 as pv + +from pyvoro2.viz3d import VizStyle, view_tessellation + +rng = np.random.default_rng(0) +``` +## 1) Non-periodic box + +In a non-periodic `Box`, all returned vertices are inside the domain boundary. +```python +points = rng.uniform(-1.0, 1.0, size=(15, 3)) +box = pv.Box(((-2, 2), (-2, 2), (-2, 2))) + +cells = pv.compute( + points, + domain=box, + return_vertices=True, + return_faces=True, +) + +view_tessellation( + cells, + domain=box, + show_vertices=False, # start simple +) +``` +
+

3Dmol.js failed to load for some reason. Please check your browser console for error messages.

+
+ +**Output** + +```text + +``` +## 2) Styling with VizStyle + +All visual parameters (radii, line widths, label sizes, colors) are collected in a small dataclass +`VizStyle`. You can pass a modified style object to `view_tessellation(...)`. + +Below we: +- make sites smaller, +- make edges thicker, +- and show vertex markers. +```python +style = VizStyle( + site_radius=0.06, + edge_line_width=4.0, + vertex_radius=0.03, + site_label_font_size=7, + vertex_label_font_size=6, +) + +view_tessellation( + cells, + domain=box, + style=style, + show_vertices=True, + show_vertex_labels='off', +) +``` +
+

3Dmol.js failed to load for some reason. Please check your browser console for error messages.

+
+ +**Output** + +```text + +``` +## 3) Periodic triclinic unit cell: vertices may lie outside the primary cell + +In periodic tessellations it is normal for some cell vertices to lie outside the “primary” unit cell. +This does **not** mean the tessellation is wrong: it is a consequence of representing a periodic +polyhedron in Cartesian space. + +In the next cell we show a random configuration in a tilted triclinic cell. +```python +cell = pv.PeriodicCell( + vectors=((10.0, 0.0, 0.0), (2.0, 9.0, 0.0), (1.0, 0.5, 8.0)), + origin=(0.0, 0.0, 0.0), +) + +points_p = rng.random((20, 3)) # arbitrary Cartesian points + +cells_p = pv.compute( + points_p, + domain=cell, + return_vertices=True, + return_faces=True, + return_face_shifts=True, +) + +view_tessellation( + cells_p, + domain=cell, + show_vertices=False, +) +``` +
+

3Dmol.js failed to load for some reason. Please check your browser console for error messages.

+
+ +**Output** + +```text + +``` +## 4) Wrapping cells for visualization + +If `wrap_cells=True`, each cell geometry is translated by an integer lattice vector so that its +site lies inside the primary unit-cell parallelepiped. This is purely a visualization convenience. +```python +view_tessellation( + cells_p, + domain=cell, + wrap_cells=True, + show_vertices=False, +) +``` +
+

3Dmol.js failed to load for some reason. Please check your browser console for error messages.

+
+ +**Output** + +```text + +``` +## 5) Global vertex labels via normalize_vertices / normalize_topology + +If you normalize vertices/topology, the view can show **global vertex ids** (\`v0\`, \`v1\`, ...). +This is useful when you want to compare connectivity across cells or debug periodic graphs. + +In periodic tessellations the same global vertex can appear in several periodic images. +The label always refers to the **global id**, while the marker position follows the drawn +wireframe geometry for the currently shown cells. +```python +from pyvoro2 import normalize_vertices + +nv = normalize_vertices(cells_p, domain=cell) + +view_tessellation( + nv, + domain=cell, + wrap_cells=False, + show_vertices=True, + show_vertex_labels='auto', # show labels only when feasible +) +``` +
+

3Dmol.js failed to load for some reason. Please check your browser console for error messages.

+
+ +**Output** + +```text + +``` +## Tips + +- For large systems, visualize a subset of cells (`cell_ids={0, 1, 2}`) or only sites (`show_vertices=False`). +- If labels clutter the view, disable them (`show_site_labels=False`) or reduce `max_site_labels`. +- The visualization helpers accept the outputs of `ghost_cells(...)` as well, which is handy for probe analysis. diff --git a/docs/notebooks/06_powerfit_reports.md b/docs/notebooks/06_powerfit_reports.md new file mode 100644 index 0000000..0bf63f4 --- /dev/null +++ b/docs/notebooks/06_powerfit_reports.md @@ -0,0 +1,116 @@ + + +[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/06_powerfit_reports.ipynb) +# Powerfit reports and record exports + +This notebook focuses on the plain-record and nested-report helpers +around low-level fits, realized-pair matching, and the self-consistent +active-set solver. +```python +import numpy as np + +import pyvoro2 as pv +``` +## 1) Resolve a small candidate set + +We use explicit integer ids so that exported rows already carry the labels +that downstream code wants to show. +```python +points = np.array( + [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [4.0, 0.0, 0.0], + ], + dtype=float, +) +ids = np.array([100, 101, 102], dtype=int) +box = pv.Box(((-1.0, 5.0), (-2.0, 2.0), (-2.0, 2.0))) + +constraints = pv.resolve_pair_bisector_constraints( + points, + [(0, 1, 0.35), (1, 2, 0.55), (0, 2, 0.50)], + measurement="fraction", + domain=box, + ids=ids, +) +constraints.to_records(use_ids=True) +``` +## 2) Fit power weights and export low-level reports +```python +model = pv.FitModel( + mismatch=pv.SquaredLoss(), + feasible=pv.Interval(0.0, 1.0), + penalties=( + pv.ExponentialBoundaryPenalty( + lower=0.0, + upper=1.0, + margin=0.05, + strength=0.2, + tau=0.02, + ), + ), +) + +fit = pv.fit_power_weights( + points, + constraints, + model=model, +) + +fit_rows = fit.to_records(constraints, use_ids=True) +fit_report = fit.to_report(constraints, use_ids=True) +fit_report["summary"] + +fit_report["weight_shift"] +``` +## 3) Check realized pairs against the actual power tessellation +```python +realized = pv.match_realized_pairs( + points, + domain=box, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + return_tessellation_diagnostics=True, +) + +realized_rows = realized.to_records(constraints, use_ids=True) +realized_report = realized.to_report(constraints, use_ids=True) +realized_report["summary"] +``` +## 4) Run the self-consistent active-set solver +## Final-state vs optimization-path reports + +`solve_report["connectivity"]` and `solve_report["realized"]` describe the final returned solution. `solve_report["path_summary"]` and the optional `history` rows capture transient disconnectivity or candidate-absent realized pairs that occurred during the outer iterations. +```python +result = pv.solve_self_consistent_power_weights( + points, + constraints, + domain=box, + model=model, + options=pv.ActiveSetOptions( + add_after=1, + drop_after=2, + relax=0.5, + max_iter=12, + cycle_window=6, + ), + return_history=True, + return_boundary_measure=True, + return_tessellation_diagnostics=True, +) + +result_rows = result.to_records(use_ids=True) +solve_report = result.to_report(use_ids=True) +solve_report["summary"] + +solve_report["path_summary"] +``` +## 5) Serialize the report bundle +```python +text = pv.dumps_report_json(solve_report, sort_keys=True) +text[:200] +``` +The numerical API stays array-oriented, while the report helpers make it +easy to hand plain Python dictionaries or rows to downstream packages. diff --git a/docs/notebooks/07_powerfit_infeasibility.md b/docs/notebooks/07_powerfit_infeasibility.md new file mode 100644 index 0000000..e7fe899 --- /dev/null +++ b/docs/notebooks/07_powerfit_infeasibility.md @@ -0,0 +1,68 @@ + + +[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/07_powerfit_infeasibility.ipynb) +# Hard infeasibility witnesses in power fitting + +This notebook shows how the low-level inverse solver reports hard +infeasibility when the requested equalities or bounds cannot all be +satisfied at once. +```python +import numpy as np + +import pyvoro2 as pv +``` +## 1) Build a contradictory hard system + +For three collinear sites, forcing all pairwise separator positions to be +at absolute position `0.0` is impossible. +```python +points = np.array( + [ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [4.0, 0.0, 0.0], + ], + dtype=float, +) +ids = np.array([10, 11, 12], dtype=int) +raw_constraints = [ + (0, 1, 0.0), + (1, 2, 0.0), + (0, 2, 0.0), +] +``` +```python +fit = pv.fit_power_weights( + points, + raw_constraints, + measurement="position", + ids=ids, + model=pv.FitModel(feasible=pv.FixedValue(0.0)), + solver="admm", +) + +fit.status, fit.hard_feasible, fit.is_infeasible +``` +## 2) Inspect the contradiction witness +```python +fit.conflicting_constraint_indices +``` +```python +fit.conflict.message +``` +```python +fit.conflict.to_records(ids=ids) +``` +## 3) Export the same information through the report helper +```python +constraints = pv.resolve_pair_bisector_constraints( + points, + raw_constraints, + measurement="position", + ids=ids, +) +fit_report = fit.to_report(constraints, use_ids=True) +fit_report["conflict"] +``` +The contradiction witness is intended to be compact and actionable rather +than a full proof certificate. diff --git a/docs/notebooks/08_powerfit_active_path.md b/docs/notebooks/08_powerfit_active_path.md new file mode 100644 index 0000000..5c06270 --- /dev/null +++ b/docs/notebooks/08_powerfit_active_path.md @@ -0,0 +1,46 @@ + + +[Open the original notebook on GitHub](https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks/08_powerfit_active_path.ipynb) +# Active-set path diagnostics + +This notebook focuses on the difference between **final-state** diagnostics and **optimization-path** diagnostics in `solve_self_consistent_power_weights(...)`. The path diagnostics are especially useful when the active graph is transiently disconnected, even though the final returned solution is connected. +```python +import numpy as np +import pyvoro2 as pv +``` +## A chain example with an initially empty active set + +The candidate graph is connected through the nearest-neighbor chain, but the first fitted subproblem is completely disconnected because `active0` is empty. The final active set reconnects after the first realization pass. +```python +points = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, +) +box = pv.Box(((-5, 5), (-5, 5), (-5, 5))) + +result = pv.solve_self_consistent_power_weights( + points, + [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)], + measurement="fraction", + domain=box, + active0=np.array([False, False, False]), + options=pv.ActiveSetOptions(add_after=1, drop_after=1, max_iter=6), + return_history=True, + connectivity_check="diagnose", + unaccounted_pair_check="diagnose", +) + +print("termination:", result.termination) +print("final active mask:", result.active_mask) +print("final active graph components:", result.connectivity.active_graph.n_components) +print("path summary:", result.path_summary) +``` +```python +for row in result.history: + print(row) +``` +Notice the distinction between `n_active_fit` (the mask that actually generated the current iterate) and `n_active` (the post-toggle mask used for the next iterate). This lets downstream code say whether disconnectivity happened **during** optimization, not just in the final answer. +```python +solve_report = result.to_report() +solve_report["path_summary"] +``` diff --git a/docs/project/about.md b/docs/project/about.md index 5cea2d0..1680e62 100644 --- a/docs/project/about.md +++ b/docs/project/about.md @@ -2,11 +2,11 @@ ## Summary -pyvoro2 is a scientific Python package for computing **3D Voronoi-type tessellations**. +pyvoro2 is a scientific Python package for computing **2D and 3D Voronoi-type tessellations**. It is built on top of the established C++ library **Voro++**, and it focuses on the parts that usually decide whether a tessellation is merely “computed” or actually **usable** in downstream analysis: -- periodic boundary conditions (including **triclinic** unit cells), +- periodic boundary conditions (including **triclinic** unit cells in 3D and rectangular periodicity in 2D), - extraction of neighbor graphs with the correct periodic images, - diagnostic checks and normalization utilities for reproducible topology work. @@ -18,7 +18,8 @@ At the core, pyvoro2 exposes only two mathematically standard tessellations: ## What is Voro++ Voro++ is a widely used C++ library for computing Voronoi cells efficiently in 3D. -It implements robust algorithms and is commonly used in computational physics and materials science. +pyvoro2 also vendors the legacy upstream 2D sources for its planar namespace. +These backends are commonly used in computational physics and materials science. pyvoro2 vendors a snapshot of upstream Voro++ and builds its Python extension against it. The vendored snapshot includes the upstream numeric robustness fix for *power/Laguerre* (radical) pruning, @@ -28,8 +29,8 @@ which avoids rare cross-platform edge cases in fully periodic power tessellation Use pyvoro2 when you need one (or more) of the following: -- Voronoi or power/Laguerre tessellations in **3D**, -- periodic domains (especially triclinic crystal cells), +- Voronoi or power/Laguerre tessellations in **2D or 3D**, +- periodic domains (especially triclinic crystal cells in 3D or rectangular periodic cells in 2D), - a neighbor graph where the periodic image is explicit, - “point queries” such as owner lookup (`locate`) or probe cells (`ghost_cells`). @@ -44,7 +45,7 @@ Voro++ is a C++ library with a low-level API. pyvoro2 provides: - Python-friendly outputs (dicts + NumPy arrays), - periodic neighbor image shifts (`adjacent_shift`) for graph work, - diagnostics (`analyze_tessellation`) and normalization helpers, -- inverse fitting tools that turn desired interface placements into a **power diagram**. +- inverse fitting tools that turn desired interface placements into a **power diagram** in both planar and spatial settings. ## Compared to `pyvoro` @@ -54,10 +55,9 @@ pyvoro2 aims to be a more modern interface with a larger emphasis on: - periodic crystals (including triclinic), - correctness checks and reproducible topology utilities, +- a dedicated planar namespace (`pyvoro2.planar`) rather than 2D/3D overload magic, - additional operations beyond “compute all cells”. -Native 2D support is not yet part of pyvoro2, but it is a planned roadmap item. - ## Design note: stateless API pyvoro2 does not keep a persistent C++ container object across calls. @@ -96,6 +96,14 @@ pip install pyvoro pytest -m pyvoro --fuzz-n 100 ``` +For contributor-style local validation of the whole repository, including +notebooks, docs, generated files, and distribution artifacts: + +```bash +pip install -e ".[all]" +python tools/release_check.py +``` + For continuous integration and local development, the recommended approach is to run the deterministic suite frequently, and run fuzz/cross-check suites periodically. diff --git a/docs/project/license.md b/docs/project/license.md index 3137be2..d667120 100644 --- a/docs/project/license.md +++ b/docs/project/license.md @@ -2,15 +2,21 @@ ## pyvoro2 -pyvoro2 is released under the **MIT License**. +Starting with **0.6.0**, the pyvoro2-authored code is released under the +**GNU Lesser General Public License v3.0 or later (LGPLv3+)**. -See the repository file `LICENSE` for the full text. +Versions **before 0.6.0** were released under the **MIT License**. Those +historical releases remain available under MIT. + +See the repository files `LICENSE` and `COPYING` for the full texts. ## Voro++ -pyvoro2 vendors the Voro++ source code as its computational core. +pyvoro2 vendors the Voro++ source code as its computational core, including the +legacy 2D Voro++ sources used for the planar backend work. -Voro++ is distributed under the **BSD 3-Clause license** (see the Voro++ source distribution and/or `vendor/voro++/LICENSE`). +Voro++ is distributed under its original upstream license (see +`vendor/voro++/LICENSE`). ## Notices diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index d48f00e..e493075 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -2,32 +2,71 @@ This page lists intended future improvements. It is not a guarantee of timelines. -## Planned / likely +## Recently completed + +### Planar 2D support in 0.6.0 + +The 0.6.0 line adds a dedicated `pyvoro2.planar` namespace built on a separate +`_core2d` backend. The supported first-release planar scope is intentionally +honest: + +- `Box` and `RectangularCell` domains, +- planar compute / locate / ghost-cell operations, +- periodic edge-shift recovery for rectangular periodic domains, +- planar diagnostics, normalization, plotting, and power-fitting support. + +It does **not** yet promise a planar oblique-periodic analogue of the 3D +`PeriodicCell`. -### Native 2D support +### Documentation and release hygiene in 0.6.1 -Voro++ ships a dedicated 2D implementation. pyvoro2 plans to expose it as a **separate extension -module** (e.g. `_core2d`) so that 2D and 3D code do not collide at link time. +The 0.6.1 line also cleans up the repository-facing documentation workflow: -### Inverse-fitting iteration helpers +- notebooks now live at the repository root and are exported into generated + Markdown pages for the docs site; +- the package metadata now exposes a convenience `pyvoro2[all]` extra for full + local validation; +- repository tooling now includes notebook export checks, notebook execution, + README sync checks, distribution-content validation, and a single + `tools/release_check.py` entry point. + +### Powerfit robustness in 0.6.1 + +The 0.6.1 line hardens the inverse-fitting stack around underdetermined and +mis-specified candidate graphs: + +- realized internal boundaries for candidate-absent pairs are now reported + explicitly in both 3D and planar 2D workflows; +- low-level fits and self-consistent solves now expose structured connectivity + diagnostics for candidate graphs, active graphs, unconstrained sites, and + component identifiability; +- disconnected-component gauge handling now follows explicit component-mean or + previous-iterate alignment policies rather than arbitrary anchor order; +- self-consistent results now distinguish final-state diagnostics from + optimization-path diagnostics through `path_summary` and richer history rows; +- the weight-to-radius conversion path now exposes `weight_shift=` directly + instead of relying only on the older minimum-radius convention. + +## Planned / likely -The inverse fitter can report constraints that do not become active faces (“inactive constraints”). -A future iteration could provide helper routines to: +The next roadmap questions are no longer about the basic powerfit surface, but +about validation depth and overall API stabilization. -1) fit weights -2) compute the diagram -3) keep only active neighbor constraints -4) refit +## Potential / exploratory -This would make it easier to use the inverse workflow as an iterative model-fitting loop. +### Planar oblique-periodic domains -## Potential +A future planar `PeriodicCell` remains possible, but it is deferred rather than +promised. One possible fallback is a pseudo-3D implementation with careful +projection back to 2D, but that needs its own evaluation before it should be +part of the public contract. ### Visualization usability -The optional `py3Dmol`-based viewer (`pyvoro2[viz]`) is intended as a lightweight debugging and -exploration tool. Future work is expected to focus on usability (better defaults, more annotations), -not on adding heavy rendering dependencies to the core. +The optional viewers (`pyvoro2[viz]` / `pyvoro2[viz2d]`) are intended as +lightweight debugging and exploration tools. The current direction is to keep +them simple but make the examples and notebook workflows more polished, rather +than turning visualization into a heavy core dependency. ## Release stability @@ -35,5 +74,6 @@ pyvoro2 is currently in **beta**. A “stable” 1.0 release is expected only after: -- the inverse-fitting workflow matures further -- native 2D support is implemented and tested +- the 0.6.1 robustness work is validated in downstream use, +- the current planar scope is validated in downstream use, +- the need (or non-need) for planar `PeriodicCell` is reassessed. diff --git a/docs/reference/edge_properties.md b/docs/reference/edge_properties.md new file mode 100644 index 0000000..1737ef4 --- /dev/null +++ b/docs/reference/edge_properties.md @@ -0,0 +1,4 @@ +# Edge properties API + +::: pyvoro2.edge_properties +::: diff --git a/docs/reference/inverse.md b/docs/reference/inverse.md deleted file mode 100644 index f201b1b..0000000 --- a/docs/reference/inverse.md +++ /dev/null @@ -1,4 +0,0 @@ -# Inverse fitting - -::: pyvoro2.inverse -::: diff --git a/docs/reference/planar/api.md b/docs/reference/planar/api.md new file mode 100644 index 0000000..41d50da --- /dev/null +++ b/docs/reference/planar/api.md @@ -0,0 +1,4 @@ +# Planar high-level API + +::: pyvoro2.planar.api +::: diff --git a/docs/reference/planar/diagnostics.md b/docs/reference/planar/diagnostics.md new file mode 100644 index 0000000..711996e --- /dev/null +++ b/docs/reference/planar/diagnostics.md @@ -0,0 +1,4 @@ +# Planar diagnostics API + +::: pyvoro2.planar.diagnostics +::: diff --git a/docs/reference/planar/domains.md b/docs/reference/planar/domains.md new file mode 100644 index 0000000..d16d5e3 --- /dev/null +++ b/docs/reference/planar/domains.md @@ -0,0 +1,4 @@ +# Planar domains API + +::: pyvoro2.planar.domains +::: diff --git a/docs/reference/planar/index.md b/docs/reference/planar/index.md new file mode 100644 index 0000000..4308380 --- /dev/null +++ b/docs/reference/planar/index.md @@ -0,0 +1,4 @@ +# Planar 2D API + +::: pyvoro2.planar +::: diff --git a/docs/reference/planar/normalize.md b/docs/reference/planar/normalize.md new file mode 100644 index 0000000..c166b23 --- /dev/null +++ b/docs/reference/planar/normalize.md @@ -0,0 +1,4 @@ +# Planar normalization API + +::: pyvoro2.planar.normalize +::: diff --git a/docs/reference/planar/result.md b/docs/reference/planar/result.md new file mode 100644 index 0000000..c3b3d25 --- /dev/null +++ b/docs/reference/planar/result.md @@ -0,0 +1,4 @@ +# Planar compute result API + +::: pyvoro2.planar.result +::: diff --git a/docs/reference/planar/validation.md b/docs/reference/planar/validation.md new file mode 100644 index 0000000..1b39fc1 --- /dev/null +++ b/docs/reference/planar/validation.md @@ -0,0 +1,4 @@ +# Planar normalization validation API + +::: pyvoro2.planar.validation +::: diff --git a/docs/reference/powerfit/active.md b/docs/reference/powerfit/active.md new file mode 100644 index 0000000..da56838 --- /dev/null +++ b/docs/reference/powerfit/active.md @@ -0,0 +1,4 @@ +# Self-consistent active set + +::: pyvoro2.powerfit.active +::: diff --git a/docs/reference/powerfit/constraints.md b/docs/reference/powerfit/constraints.md new file mode 100644 index 0000000..53b32a2 --- /dev/null +++ b/docs/reference/powerfit/constraints.md @@ -0,0 +1,4 @@ +# Power fitting constraints + +::: pyvoro2.powerfit.constraints +::: diff --git a/docs/reference/powerfit/index.md b/docs/reference/powerfit/index.md new file mode 100644 index 0000000..a84f36d --- /dev/null +++ b/docs/reference/powerfit/index.md @@ -0,0 +1,4 @@ +# Power fitting package + +::: pyvoro2.powerfit +::: diff --git a/docs/reference/powerfit/model.md b/docs/reference/powerfit/model.md new file mode 100644 index 0000000..82bd9cf --- /dev/null +++ b/docs/reference/powerfit/model.md @@ -0,0 +1,4 @@ +# Power fitting objective models + +::: pyvoro2.powerfit.model +::: diff --git a/docs/reference/powerfit/realize.md b/docs/reference/powerfit/realize.md new file mode 100644 index 0000000..28b103f --- /dev/null +++ b/docs/reference/powerfit/realize.md @@ -0,0 +1,4 @@ +# Realized-pair matching + +::: pyvoro2.powerfit.realize +::: diff --git a/docs/reference/powerfit/report.md b/docs/reference/powerfit/report.md new file mode 100644 index 0000000..c5e0b1c --- /dev/null +++ b/docs/reference/powerfit/report.md @@ -0,0 +1,3 @@ +# Powerfit report helpers + +::: pyvoro2.powerfit.report diff --git a/docs/reference/powerfit/solver.md b/docs/reference/powerfit/solver.md new file mode 100644 index 0000000..a3891fa --- /dev/null +++ b/docs/reference/powerfit/solver.md @@ -0,0 +1,4 @@ +# Power fitting solver + +::: pyvoro2.powerfit.solver +::: diff --git a/docs/reference/viz2d.md b/docs/reference/viz2d.md new file mode 100644 index 0000000..2943b79 --- /dev/null +++ b/docs/reference/viz2d.md @@ -0,0 +1,4 @@ +# Planar visualization API + +::: pyvoro2.viz2d +::: diff --git a/docs/requirements.txt b/docs/requirements.txt index 1bc09b7..f51433c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,6 +3,5 @@ numpy>=1.23 mkdocs>=1.6 mkdocs-material>=9.5 mkdocstrings[python]>=0.27 -mkdocs-jupyter>=0.25 pymdown-extensions>=10.0 mkdocs-section-index>=0.3.9 diff --git a/mkdocs.yml b/mkdocs.yml index 43cd27b..379cc77 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: pyvoro2 -site_description: Python bindings for Voro++ (3D Voronoi and power/Laguerre tessellations) with periodic and topology utilities. +site_description: Python bindings for Voro++ (2D/3D Voronoi and power/Laguerre tessellations) with periodic, topology, and inverse power-fitting utilities. site_url: https://delonecommons.github.io/pyvoro2/ repo_url: https://github.com/DeloneCommons/pyvoro2 repo_name: DeloneCommons/pyvoro2 @@ -28,21 +28,6 @@ plugins: show_root_heading: true show_root_toc_entry: false heading_level: 2 - - mkdocs-jupyter: - # Limit mkdocs-jupyter processing strictly to notebooks. - # - # Why: both mkdocstrings and pandoc use the ":::" prefix for *different* - # syntaxes. mkdocstrings uses it as a directive ("::: pyvoro2.api"), - # while pandoc interprets it as a fenced Div block and emits warnings if - # there is no closing ":::". - # - # mkdocs-jupyter can invoke pandoc internally, so we scope it to *.ipynb - # to avoid spurious warnings on API reference pages. - include: ["notebooks/*.ipynb"] - execute: false - include_source: true - allow_errors: false - markdown_extensions: - admonition - pymdownx.details @@ -69,27 +54,50 @@ nav: - Home: index.md - User guide: - Concepts: guide/concepts.md - - Domains: guide/domains.md + - Domains (3D): guide/domains.md + - Planar (2D): guide/planar.md - Operations: guide/operations.md - Topology and graphs: guide/topology.md - - Inverse fitting: guide/inverse.md + - Power fitting: guide/powerfit.md - Visualization: guide/visualization.md + - Notebooks: guide/notebooks.md - Examples: - - notebooks/01_basic_compute.ipynb - - notebooks/02_periodic_graph.ipynb - - notebooks/03_locate_and_ghost.ipynb - - notebooks/04_inverse_fit.ipynb - - notebooks/05_visualization.ipynb + - notebooks/01_basic_compute.md + - notebooks/02_periodic_graph.md + - notebooks/03_locate_and_ghost.md + - notebooks/04_powerfit.md + - notebooks/05_visualization.md + - notebooks/06_powerfit_reports.md + - notebooks/07_powerfit_infeasibility.md + - notebooks/08_powerfit_active_path.md - API reference: - - Domains: reference/domains.md - - API: reference/api.md - - Diagnostics: reference/diagnostics.md - - Validation: reference/validation.md - - Duplicate check: reference/duplicates.md - - Normalization: reference/normalize.md - - Face properties: reference/face_properties.md - - Inverse fitting: reference/inverse.md - - Visualization: reference/viz3d.md + - Spatial (3D): + - Domains: reference/domains.md + - API: reference/api.md + - Diagnostics: reference/diagnostics.md + - Validation: reference/validation.md + - Duplicate check: reference/duplicates.md + - Normalization: reference/normalize.md + - Face properties: reference/face_properties.md + - Visualization (3D): reference/viz3d.md + - Planar (2D): + - Overview: reference/planar/index.md + - Domains: reference/planar/domains.md + - API: reference/planar/api.md + - Diagnostics: reference/planar/diagnostics.md + - Normalization: reference/planar/normalize.md + - Validation: reference/planar/validation.md + - Compute result: reference/planar/result.md + - Edge properties: reference/edge_properties.md + - Visualization (2D): reference/viz2d.md + - Power fitting: + - Overview: reference/powerfit/index.md + - Constraints: reference/powerfit/constraints.md + - Objective models: reference/powerfit/model.md + - Solver: reference/powerfit/solver.md + - Realization: reference/powerfit/realize.md + - Active set: reference/powerfit/active.md + - Reports: reference/powerfit/report.md - Project: - About: project/about.md - Changelog: about/changelog.md diff --git a/docs/notebooks/01_basic_compute.ipynb b/notebooks/01_basic_compute.ipynb similarity index 100% rename from docs/notebooks/01_basic_compute.ipynb rename to notebooks/01_basic_compute.ipynb diff --git a/docs/notebooks/02_periodic_graph.ipynb b/notebooks/02_periodic_graph.ipynb similarity index 100% rename from docs/notebooks/02_periodic_graph.ipynb rename to notebooks/02_periodic_graph.ipynb diff --git a/docs/notebooks/03_locate_and_ghost.ipynb b/notebooks/03_locate_and_ghost.ipynb similarity index 100% rename from docs/notebooks/03_locate_and_ghost.ipynb rename to notebooks/03_locate_and_ghost.ipynb diff --git a/notebooks/04_powerfit.ipynb b/notebooks/04_powerfit.ipynb new file mode 100644 index 0000000..6fcc40c --- /dev/null +++ b/notebooks/04_powerfit.ipynb @@ -0,0 +1,174 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "85309bbd", + "metadata": {}, + "source": [ + "# Power fitting from pairwise bisector constraints\n", + "\n", + "This notebook shows the new math-oriented inverse API in `pyvoro2`:\n", + "\n", + "1. resolve pairwise bisector constraints,\n", + "2. fit power weights under a configurable model,\n", + "3. match realized pairs in the resulting power tessellation,\n", + "4. run the self-consistent active-set solver.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52ddd452", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "import pyvoro2 as pv\n" + ] + }, + { + "cell_type": "markdown", + "id": "40d64698", + "metadata": {}, + "source": [ + "## 1) Resolve and fit a simple two-site constraint\n", + "\n", + "A raw constraint tuple is `(i, j, value[, shift])`, where `value` is\n", + "interpreted in either fraction-space or absolute position-space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ffa208ac", + "metadata": {}, + "outputs": [], + "source": "points = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float)\nbox = pv.Box(((-5, 5), (-5, 5), (-5, 5)))\n\nconstraints = pv.resolve_pair_bisector_constraints(\n points,\n [(0, 1, 0.25)],\n measurement='fraction',\n domain=box,\n)\n\nfit = pv.fit_power_weights(points, constraints)\n\nprint('weights:', fit.weights)\nprint('radii:', fit.radii)\nprint('predicted fraction:', fit.predicted_fraction)\nprint('predicted position:', fit.predicted_position)\nprint('status:', fit.status)\nprint('weight shift:', fit.weight_shift)\n" + }, + { + "cell_type": "markdown", + "id": "b8d1b5dd", + "metadata": {}, + "source": [ + "## 2) Add hard feasibility and a near-boundary penalty\n", + "\n", + "The fitting model separates mismatch, hard feasibility, and soft penalties." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0167fdb2", + "metadata": {}, + "outputs": [], + "source": [ + "model = pv.FitModel(\n", + " mismatch=pv.SquaredLoss(),\n", + " feasible=pv.Interval(0.0, 1.0),\n", + " penalties=(\n", + " pv.ExponentialBoundaryPenalty(\n", + " lower=0.0,\n", + " upper=1.0,\n", + " margin=0.05,\n", + " strength=1.0,\n", + " tau=0.01,\n", + " ),\n", + " ),\n", + ")\n", + "\n", + "fit_penalized = pv.fit_power_weights(\n", + " points,\n", + " [(0, 1, 1e-3)],\n", + " measurement='fraction',\n", + " domain=box,\n", + " model=model,\n", + " solver='admm',\n", + ")\n", + "\n", + "print('predicted fraction with penalty:', fit_penalized.predicted_fraction[0])\n" + ] + }, + { + "cell_type": "markdown", + "id": "3b826381", + "metadata": {}, + "source": [ + "## 3) Match realized pairs after fitting\n", + "\n", + "Requested pairwise separators do not automatically become realized faces\n", + "in the full power tessellation." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e66d663", + "metadata": {}, + "outputs": [], + "source": [ + "realized = pv.match_realized_pairs(\n", + " points,\n", + " domain=box,\n", + " radii=fit.radii,\n", + " constraints=constraints,\n", + " return_boundary_measure=True,\n", + " return_tessellation_diagnostics=True,\n", + ")\n", + "\n", + "print('realized:', realized.realized)\n", + "print('same shift:', realized.realized_same_shift)\n", + "print('boundary measure:', realized.boundary_measure)\n", + "print('tessellation ok:', realized.tessellation_diagnostics.ok)\n" + ] + }, + { + "cell_type": "markdown", + "id": "aee9d927", + "metadata": {}, + "source": [ + "## 4) Self-consistent active-set refinement\n", + "\n", + "For larger candidate sets, the active-set solver repeatedly fits, tessellates,\n", + "and keeps the constraints whose requested pairs are actually realized." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83eff155", + "metadata": {}, + "outputs": [], + "source": "points3 = np.array(\n [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],\n dtype=float,\n)\nbox3 = pv.Box(((-5, 5), (-5, 5), (-5, 5)))\n\nresult = pv.solve_self_consistent_power_weights(\n points3,\n [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)],\n measurement='fraction',\n domain=box3,\n options=pv.ActiveSetOptions(add_after=1, drop_after=2, relax=0.5),\n return_history=True,\n return_boundary_measure=True,\n)\n\nprint('termination:', result.termination)\nprint('active mask:', result.active_mask)\nprint('constraint status:', result.diagnostics.status)\nprint('marginal constraints:', result.marginal_constraints)\n\nprint('path summary:', result.path_summary)\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Disconnected path example\n\nThe next example starts from an empty active set so the first fitted subproblem is completely disconnected, while the final active set reconnects into the expected nearest-neighbor chain. This illustrates the difference between final-state diagnostics and optimization-path diagnostics." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "points4 = np.array(\n [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],\n dtype=float,\n)\nbox4 = pv.Box(((-5, 5), (-5, 5), (-5, 5)))\n\nresult_path = pv.solve_self_consistent_power_weights(\n points4,\n [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)],\n measurement='fraction',\n domain=box4,\n active0=np.array([False, False, False]),\n options=pv.ActiveSetOptions(add_after=1, drop_after=1, max_iter=6),\n return_history=True,\n connectivity_check='diagnose',\n unaccounted_pair_check='diagnose',\n)\n\nprint('final active graph components:', result_path.connectivity.active_graph.n_components)\nprint('path summary:', result_path.path_summary)\nprint('first history row:', result_path.history[0])\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/notebooks/05_visualization.ipynb b/notebooks/05_visualization.ipynb similarity index 100% rename from docs/notebooks/05_visualization.ipynb rename to notebooks/05_visualization.ipynb diff --git a/notebooks/06_powerfit_reports.ipynb b/notebooks/06_powerfit_reports.ipynb new file mode 100644 index 0000000..79653bb --- /dev/null +++ b/notebooks/06_powerfit_reports.ipynb @@ -0,0 +1,101 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Powerfit reports and record exports\n\nThis notebook focuses on the plain-record and nested-report helpers\naround low-level fits, realized-pair matching, and the self-consistent\nactive-set solver.\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "import numpy as np\n\nimport pyvoro2 as pv\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 1) Resolve a small candidate set\n\nWe use explicit integer ids so that exported rows already carry the labels\nthat downstream code wants to show.\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "points = np.array(\n [\n [0.0, 0.0, 0.0],\n [2.0, 0.0, 0.0],\n [4.0, 0.0, 0.0],\n ],\n dtype=float,\n)\nids = np.array([100, 101, 102], dtype=int)\nbox = pv.Box(((-1.0, 5.0), (-2.0, 2.0), (-2.0, 2.0)))\n\nconstraints = pv.resolve_pair_bisector_constraints(\n points,\n [(0, 1, 0.35), (1, 2, 0.55), (0, 2, 0.50)],\n measurement=\"fraction\",\n domain=box,\n ids=ids,\n)\nconstraints.to_records(use_ids=True)\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 2) Fit power weights and export low-level reports\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "model = pv.FitModel(\n mismatch=pv.SquaredLoss(),\n feasible=pv.Interval(0.0, 1.0),\n penalties=(\n pv.ExponentialBoundaryPenalty(\n lower=0.0,\n upper=1.0,\n margin=0.05,\n strength=0.2,\n tau=0.02,\n ),\n ),\n)\n\nfit = pv.fit_power_weights(\n points,\n constraints,\n model=model,\n)\n\nfit_rows = fit.to_records(constraints, use_ids=True)\nfit_report = fit.to_report(constraints, use_ids=True)\nfit_report[\"summary\"]\n\nfit_report[\"weight_shift\"]\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 3) Check realized pairs against the actual power tessellation\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "realized = pv.match_realized_pairs(\n points,\n domain=box,\n radii=fit.radii,\n constraints=constraints,\n return_boundary_measure=True,\n return_tessellation_diagnostics=True,\n)\n\nrealized_rows = realized.to_records(constraints, use_ids=True)\nrealized_report = realized.to_report(constraints, use_ids=True)\nrealized_report[\"summary\"]\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 4) Run the self-consistent active-set solver\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Final-state vs optimization-path reports\n\n`solve_report[\"connectivity\"]` and `solve_report[\"realized\"]` describe the final returned solution. `solve_report[\"path_summary\"]` and the optional `history` rows capture transient disconnectivity or candidate-absent realized pairs that occurred during the outer iterations." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "result = pv.solve_self_consistent_power_weights(\n points,\n constraints,\n domain=box,\n model=model,\n options=pv.ActiveSetOptions(\n add_after=1,\n drop_after=2,\n relax=0.5,\n max_iter=12,\n cycle_window=6,\n ),\n return_history=True,\n return_boundary_measure=True,\n return_tessellation_diagnostics=True,\n)\n\nresult_rows = result.to_records(use_ids=True)\nsolve_report = result.to_report(use_ids=True)\nsolve_report[\"summary\"]\n\nsolve_report[\"path_summary\"]\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 5) Serialize the report bundle\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "text = pv.dumps_report_json(solve_report, sort_keys=True)\ntext[:200]\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "The numerical API stays array-oriented, while the report helpers make it\neasy to hand plain Python dictionaries or rows to downstream packages.\n" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/07_powerfit_infeasibility.ipynb b/notebooks/07_powerfit_infeasibility.ipynb new file mode 100644 index 0000000..05c8d14 --- /dev/null +++ b/notebooks/07_powerfit_infeasibility.ipynb @@ -0,0 +1,91 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# Hard infeasibility witnesses in power fitting\n\nThis notebook shows how the low-level inverse solver reports hard\ninfeasibility when the requested equalities or bounds cannot all be\nsatisfied at once.\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "import numpy as np\n\nimport pyvoro2 as pv\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 1) Build a contradictory hard system\n\nFor three collinear sites, forcing all pairwise separator positions to be\nat absolute position `0.0` is impossible.\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "points = np.array(\n [\n [0.0, 0.0, 0.0],\n [2.0, 0.0, 0.0],\n [4.0, 0.0, 0.0],\n ],\n dtype=float,\n)\nids = np.array([10, 11, 12], dtype=int)\nraw_constraints = [\n (0, 1, 0.0),\n (1, 2, 0.0),\n (0, 2, 0.0),\n]\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "fit = pv.fit_power_weights(\n points,\n raw_constraints,\n measurement=\"position\",\n ids=ids,\n model=pv.FitModel(feasible=pv.FixedValue(0.0)),\n solver=\"admm\",\n)\n\nfit.status, fit.hard_feasible, fit.is_infeasible\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 2) Inspect the contradiction witness\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "fit.conflicting_constraint_indices\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "fit.conflict.message\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "fit.conflict.to_records(ids=ids)\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "## 3) Export the same information through the report helper\n" + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "constraints = pv.resolve_pair_bisector_constraints(\n points,\n raw_constraints,\n measurement=\"position\",\n ids=ids,\n)\nfit_report = fit.to_report(constraints, use_ids=True)\nfit_report[\"conflict\"]\n" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "The contradiction witness is intended to be compact and actionable rather\nthan a full proof certificate.\n" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/08_powerfit_active_path.ipynb b/notebooks/08_powerfit_active_path.ipynb new file mode 100644 index 0000000..346328a --- /dev/null +++ b/notebooks/08_powerfit_active_path.ipynb @@ -0,0 +1,74 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Active-set path diagnostics\n\nThis notebook focuses on the difference between **final-state** diagnostics and **optimization-path** diagnostics in `solve_self_consistent_power_weights(...)`. The path diagnostics are especially useful when the active graph is transiently disconnected, even though the final returned solution is connected." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "import numpy as np\nimport pyvoro2 as pv\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## A chain example with an initially empty active set\n\nThe candidate graph is connected through the nearest-neighbor chain, but the first fitted subproblem is completely disconnected because `active0` is empty. The final active set reconnects after the first realization pass." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "points = np.array(\n [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],\n dtype=float,\n)\nbox = pv.Box(((-5, 5), (-5, 5), (-5, 5)))\n\nresult = pv.solve_self_consistent_power_weights(\n points,\n [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)],\n measurement=\"fraction\",\n domain=box,\n active0=np.array([False, False, False]),\n options=pv.ActiveSetOptions(add_after=1, drop_after=1, max_iter=6),\n return_history=True,\n connectivity_check=\"diagnose\",\n unaccounted_pair_check=\"diagnose\",\n)\n\nprint(\"termination:\", result.termination)\nprint(\"final active mask:\", result.active_mask)\nprint(\"final active graph components:\", result.connectivity.active_graph.n_components)\nprint(\"path summary:\", result.path_summary)\n" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "for row in result.history:\n print(row)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Notice the distinction between `n_active_fit` (the mask that actually generated the current iterate) and `n_active` (the post-toggle mask used for the next iterate). This lets downstream code say whether disconnectivity happened **during** optimization, not just in the final answer." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "solve_report = result.to_report()\nsolve_report[\"path_summary\"]\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/README.md b/notebooks/README.md new file mode 100644 index 0000000..4988042 --- /dev/null +++ b/notebooks/README.md @@ -0,0 +1,18 @@ +# Notebooks + +These notebooks are the source examples for the documentation site and are kept +at the repository root so they can be browsed directly on GitHub. + +Generated Markdown copies for the docs live under `docs/notebooks/` and are +produced by `python tools/export_notebooks.py`. + +Included notebooks: + +- `01_basic_compute.ipynb` — basic 3D tessellation usage. +- `02_periodic_graph.ipynb` — periodic topology and neighbor-graph workflows. +- `03_locate_and_ghost.ipynb` — point-location and ghost-cell queries. +- `04_powerfit.ipynb` — core power-fitting workflow. +- `05_visualization.ipynb` — optional 2D/3D visualization helpers. +- `06_powerfit_reports.ipynb` — report/export surfaces for powerfit. +- `07_powerfit_infeasibility.ipynb` — infeasibility witnesses and reporting. +- `08_powerfit_active_path.ipynb` — active-set path diagnostics. diff --git a/pyproject.toml b/pyproject.toml index 694af4d..0de5344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = 'scikit_build_core.build' [project] name = 'pyvoro2' dynamic = ['version'] -description = 'Python bindings for Voro++ (3D Voronoi and Laguerre tessellations) with periodic and topology utilities.' +description = 'Python bindings for Voro++ with 2D/3D Voronoi and power/Laguerre tessellations, periodic topology utilities, and inverse power fitting.' readme = 'README.md' requires-python = '>=3.10' license = {file = 'LICENSE'} @@ -24,9 +24,9 @@ dependencies = [ classifiers = [ 'Development Status :: 4 - Beta', 'Intended Audience :: Science/Research', - 'Topic :: Scientific/Engineering :: Chemistry', + 'Topic :: Scientific/Engineering :: Mathematics', 'Topic :: Scientific/Engineering :: Physics', - 'License :: OSI Approved :: MIT License', + 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', 'Operating System :: OS Independent', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', @@ -47,17 +47,41 @@ Issues = 'https://github.com/DeloneCommons/pyvoro2/issues' [project.optional-dependencies] test = ['pytest'] -dev = ['pytest', 'flake8', 'tomli; python_version < "3.11"'] +dev = [ + 'pytest', + 'flake8', + 'build', + 'twine', + 'tomli; python_version < "3.11"', +] docs = [ 'mkdocs>=1.6', 'mkdocs-material>=9.5', 'mkdocstrings[python]>=0.27', - 'mkdocs-jupyter>=0.25', 'pymdown-extensions>=10.0', 'mkdocs-section-index>=0.3.9', ] +viz2d = [ + 'matplotlib', +] viz = [ + 'matplotlib', + 'py3Dmol', +] + +all = [ + 'pytest', + 'flake8', + 'build', + 'twine', + 'mkdocs>=1.6', + 'mkdocs-material>=9.5', + 'mkdocstrings[python]>=0.27', + 'pymdown-extensions>=10.0', + 'mkdocs-section-index>=0.3.9', + 'matplotlib', 'py3Dmol', + 'tomli; python_version < "3.11"', ] [tool.scikit-build] diff --git a/src/pyvoro2/__about__.py b/src/pyvoro2/__about__.py index 1c77f3d..ff52f36 100644 --- a/src/pyvoro2/__about__.py +++ b/src/pyvoro2/__about__.py @@ -1,8 +1,8 @@ -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: LGPL-3.0-or-later """Package metadata. The package version is the single source of truth for packaging. """ # Keep this as a simple assignment so scikit-build-core can extract it via regex. -__version__ = '0.4.2.post1' +__version__ = '0.6.1' diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index d5d5212..2c51c17 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -1,18 +1,14 @@ """pyvoro2 package. This package provides Python bindings to the Voro++ cell-based Voronoi -tessellation library. - -Public API: - - Box, OrthorhombicCell, PeriodicCell - - compute - - locate - - ghost_cells +and power (Laguerre) tessellation library, including the planar +``pyvoro2.planar`` namespace for 2D workflows. """ from __future__ import annotations from .__about__ import __version__ +from . import planar from .domains import Box, OrthorhombicCell, PeriodicCell from .api import compute, locate, ghost_cells @@ -44,13 +40,42 @@ normalize_edges_faces, normalize_topology, ) - -from .inverse import ( - FitWeightsResult, +from .powerfit import ( + PairBisectorConstraints, + resolve_pair_bisector_constraints, + SquaredLoss, + HuberLoss, + Interval, + FixedValue, + SoftIntervalPenalty, + ExponentialBoundaryPenalty, + ReciprocalBoundaryPenalty, + L2Regularization, + FitModel, + ConstraintGraphDiagnostics, + ConnectivityDiagnostics, + ConnectivityDiagnosticsError, + HardConstraintConflictTerm, + HardConstraintConflict, + PowerWeightFitResult, + RealizedPairDiagnostics, + UnaccountedRealizedPair, + UnaccountedRealizedPairError, + build_fit_report, + build_realized_report, + build_active_set_report, + dumps_report_json, + write_report_json, + ActiveSetOptions, + ActiveSetIteration, + ActiveSetPathSummary, + PairConstraintDiagnostics, + SelfConsistentPowerFitResult, + fit_power_weights, + match_realized_pairs, + solve_self_consistent_power_weights, radii_to_weights, weights_to_radii, - fit_power_weights_from_plane_fractions, - fit_power_weights_from_plane_positions, ) __all__ = [ @@ -78,10 +103,41 @@ 'normalize_vertices', 'normalize_edges_faces', 'normalize_topology', - 'FitWeightsResult', + 'PairBisectorConstraints', + 'resolve_pair_bisector_constraints', + 'SquaredLoss', + 'HuberLoss', + 'Interval', + 'FixedValue', + 'SoftIntervalPenalty', + 'ExponentialBoundaryPenalty', + 'ReciprocalBoundaryPenalty', + 'L2Regularization', + 'FitModel', + 'ConstraintGraphDiagnostics', + 'ConnectivityDiagnostics', + 'ConnectivityDiagnosticsError', + 'HardConstraintConflictTerm', + 'HardConstraintConflict', + 'PowerWeightFitResult', + 'RealizedPairDiagnostics', + 'UnaccountedRealizedPair', + 'UnaccountedRealizedPairError', + 'build_fit_report', + 'build_realized_report', + 'build_active_set_report', + 'dumps_report_json', + 'write_report_json', + 'ActiveSetOptions', + 'ActiveSetIteration', + 'ActiveSetPathSummary', + 'PairConstraintDiagnostics', + 'SelfConsistentPowerFitResult', + 'fit_power_weights', + 'match_realized_pairs', + 'solve_self_consistent_power_weights', 'radii_to_weights', 'weights_to_radii', - 'fit_power_weights_from_plane_fractions', - 'fit_power_weights_from_plane_positions', '__version__', + 'planar', ] diff --git a/src/pyvoro2/_cell_output.py b/src/pyvoro2/_cell_output.py new file mode 100644 index 0000000..6997783 --- /dev/null +++ b/src/pyvoro2/_cell_output.py @@ -0,0 +1,89 @@ +"""Shared helpers for raw cell-dictionary post-processing.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + + +def remap_ids_inplace( + cells: list[dict[str, Any]], + ids_user: np.ndarray, + *, + boundary_key: str, +) -> None: + """Remap internal IDs (``0..n-1``) to user IDs in-place. + + Args: + cells: Raw cell dictionaries returned by the C++ layer. + ids_user: User-supplied IDs aligned with internal indices. + boundary_key: Name of the neighbor-bearing boundary list, e.g. + ``"faces"`` in 3D or ``"edges"`` in 2D. + """ + + for cell in cells: + pid = int(cell.get('id', -1)) + if 0 <= pid < ids_user.size: + cell['id'] = int(ids_user[pid]) + + boundaries = cell.get(boundary_key) + if boundaries is None: + continue + + for item in boundaries: + adj = int(item.get('adjacent_cell', -999999)) + if 0 <= adj < ids_user.size: + item['adjacent_cell'] = int(ids_user[adj]) + + +def add_empty_cells_inplace( + cells: list[dict[str, Any]], + *, + n: int, + sites: np.ndarray, + opts: tuple[bool, bool, bool], + measure_key: str, + boundary_key: str, +) -> None: + """Insert explicit empty-cell records for missing particle IDs. + + In power (Laguerre) diagrams, some sites may have empty cells and the core + backend can omit them from iteration. This helper restores a full + length-``n`` output (IDs ``0..n-1``), marking missing entries as empty. + + Args: + cells: List of per-cell dictionaries returned by the C++ layer. + n: Total number of input sites. + sites: Site positions aligned with internal IDs (shape ``(n, d)``). + opts: ``(return_vertices, return_adjacency, return_boundaries)``. + measure_key: Cell measure key, e.g. ``"volume"`` or ``"area"``. + boundary_key: Boundary list key, e.g. ``"faces"`` or ``"edges"``. + """ + + if n <= 0: + return + + present = {int(cell.get('id', -1)) for cell in cells} + missing = [i for i in range(n) if i not in present] + if not missing: + return + + ret_vertices, ret_adjacency, ret_boundaries = opts + d = int(np.asarray(sites).shape[1]) + for i in missing: + rec: dict[str, Any] = { + 'id': int(i), + 'empty': True, + measure_key: 0.0, + 'site': np.asarray(sites[i], dtype=np.float64).reshape(d).tolist(), + } + if ret_vertices: + rec['vertices'] = [] + if ret_adjacency: + rec['adjacency'] = [] + if ret_boundaries: + rec[boundary_key] = [] + cells.append(rec) + + cells.sort(key=lambda cell: int(cell.get('id', 0))) diff --git a/src/pyvoro2/_domain_geometry.py b/src/pyvoro2/_domain_geometry.py new file mode 100644 index 0000000..5f11968 --- /dev/null +++ b/src/pyvoro2/_domain_geometry.py @@ -0,0 +1,253 @@ +"""Internal domain-geometry adapter for 3D code paths. + +The current public package is still 3D-first, but centralizing the geometry +logic behind a small adapter makes the eventual 2D addition much less invasive. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + +import numpy as np + +from .domains import Box, OrthorhombicCell, PeriodicCell + +Domain3D = Box | OrthorhombicCell | PeriodicCell + + +@dataclass(frozen=True, slots=True) +class DomainGeometry3D: + """Minimal internal adapter for 3D domains. + + It exposes the geometry operations that are currently duplicated across the + API wrapper and the inverse-fitting code: primary-cell remapping, lattice + shift conversion, nearest-image search, and block-grid heuristics. + """ + + domain: Domain3D | None + + @property + def dim(self) -> int: + return 3 + + @property + def kind(self) -> str: + if self.domain is None: + return 'none' + if isinstance(self.domain, Box): + return 'box' + if isinstance(self.domain, OrthorhombicCell): + return 'orthorhombic' + return 'triclinic' + + @property + def is_rectangular(self) -> bool: + return isinstance(self.domain, (Box, OrthorhombicCell)) + + @property + def is_triclinic(self) -> bool: + return isinstance(self.domain, PeriodicCell) + + @property + def periodic_axes(self) -> tuple[bool, bool, bool]: + if self.domain is None or isinstance(self.domain, Box): + return (False, False, False) + if isinstance(self.domain, OrthorhombicCell): + return tuple(bool(v) for v in self.domain.periodic) + return (True, True, True) + + @property + def has_any_periodic_axis(self) -> bool: + return any(self.periodic_axes) + + @property + def bounds(self) -> tuple[ + tuple[float, float], tuple[float, float], tuple[float, float] + ] | None: + if self.is_rectangular: + return self.domain.bounds # type: ignore[return-value] + return None + + @property + def internal_params(self) -> tuple[float, float, float, float, float, float] | None: + if isinstance(self.domain, PeriodicCell): + return self.domain.to_internal_params() + return None + + @property + def lattice_vectors_cart(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return the 3D lattice/edge vectors in Cartesian coordinates.""" + + if self.domain is None: + raise ValueError('a domain is required to determine lattice vectors') + if isinstance(self.domain, PeriodicCell): + a, b, c = ( + np.asarray(vec, dtype=np.float64).reshape(3) + for vec in self.domain.vectors + ) + return a, b, c + + (xmin, xmax), (ymin, ymax), (zmin, zmax) = self.domain.bounds + a = np.array([xmax - xmin, 0.0, 0.0], dtype=np.float64) + b = np.array([0.0, ymax - ymin, 0.0], dtype=np.float64) + c = np.array([0.0, 0.0, zmax - zmin], dtype=np.float64) + return a, b, c + + def remap_cart(self, points: np.ndarray) -> np.ndarray: + pts = np.asarray(points, dtype=float) + if self.domain is None or isinstance(self.domain, Box): + return pts + return self.domain.remap_cart(pts, return_shifts=False) + + def shift_to_cart(self, shifts: np.ndarray) -> np.ndarray: + sh = np.asarray(shifts, dtype=np.int64) + if sh.ndim != 2 or sh.shape[1] != 3: + raise ValueError('shifts must have shape (m,3)') + if self.domain is None or isinstance(self.domain, Box): + return np.zeros((sh.shape[0], 3), dtype=np.float64) + a, b, c = self.lattice_vectors_cart + return ( + sh[:, 0:1] * a[None, :] + + sh[:, 1:2] * b[None, :] + + sh[:, 2:3] * c[None, :] + ) + + def shift_vector(self, shift: Sequence[int] | np.ndarray) -> np.ndarray: + """Return the Cartesian translation vector for one integer lattice shift.""" + + sh = np.asarray(shift, dtype=np.int64) + if sh.shape != (3,): + raise ValueError('shift must have shape (3,)') + return self.shift_to_cart(sh.reshape(1, 3)).reshape(3) + + def validate_shifts(self, shifts: np.ndarray) -> None: + sh = np.asarray(shifts, dtype=np.int64) + if sh.ndim != 2 or sh.shape[1] != 3: + raise ValueError('shifts must have shape (m,3)') + + if self.domain is None: + if np.any(sh != 0): + raise ValueError('constraint shifts require a periodic domain') + return + + if isinstance(self.domain, Box): + if np.any(sh != 0): + raise ValueError('Box domain does not support periodic shifts') + return + + if isinstance(self.domain, OrthorhombicCell): + per = self.periodic_axes + for ax in range(3): + if not per[ax] and np.any(sh[:, ax] != 0): + raise ValueError( + 'shifts on non-periodic axes must be 0 for OrthorhombicCell' + ) + + def nearest_image_shifts( + self, + pi: np.ndarray, + pj: np.ndarray, + *, + search: int, + ) -> tuple[np.ndarray, np.ndarray]: + """Return nearest-image shifts and a boundary-hit mask. + + The boundary-hit mask is only informative for triclinic search, where a + best candidate lying on the search boundary suggests that a larger + search window may be advisable. + """ + + if isinstance(self.domain, OrthorhombicCell): + shifts = _nearest_image_shifts_orthorhombic(pi, pj, self.domain) + return shifts, np.zeros(shifts.shape[0], dtype=bool) + if isinstance(self.domain, PeriodicCell): + return _nearest_image_shifts_triclinic(pi, pj, self.domain, search=search) + raise ValueError('nearest-image shifts require a periodic domain') + + def resolve_block_counts( + self, + *, + n_sites: int, + blocks: tuple[int, int, int] | None, + block_size: float | None, + ) -> tuple[int, int, int]: + """Resolve the internal Voro++ block grid.""" + + if blocks is not None: + if len(blocks) != 3: + raise ValueError('blocks must have length 3') + nx, ny, nz = (int(v) for v in blocks) + if nx <= 0 or ny <= 0 or nz <= 0: + raise ValueError('blocks must contain positive integers') + return nx, ny, nz + + lengths, volume = self._lengths_and_volume() + if block_size is None: + spacing = (volume / max(int(n_sites), 1)) ** (1.0 / 3.0) + block_size_eff = max(1e-6, 2.5 * spacing) + else: + block_size_eff = float(block_size) + if not np.isfinite(block_size_eff) or block_size_eff <= 0.0: + raise ValueError('block_size must be a positive finite scalar') + + return tuple(max(1, int(length / block_size_eff)) for length in lengths) + + def _lengths_and_volume(self) -> tuple[tuple[float, float, float], float]: + if self.domain is None: + raise ValueError('a domain is required to derive block counts') + if isinstance(self.domain, (Box, OrthorhombicCell)): + (xmin, xmax), (ymin, ymax), (zmin, zmax) = self.domain.bounds + lx = float(xmax - xmin) + ly = float(ymax - ymin) + lz = float(zmax - zmin) + return (lx, ly, lz), float(lx * ly * lz) + bx, _bxy, by, _bxz, _byz, bz = self.domain.to_internal_params() + return (float(bx), float(by), float(bz)), float(bx * by * bz) + + +def geometry3d(domain: Domain3D | None) -> DomainGeometry3D: + """Return the internal geometry adapter for a 3D domain.""" + + return DomainGeometry3D(domain) + + +def _nearest_image_shifts_orthorhombic( + pi: np.ndarray, + pj: np.ndarray, + cell: OrthorhombicCell, +) -> np.ndarray: + (xmin, xmax), (ymin, ymax), (zmin, zmax) = cell.bounds + lengths = np.array([xmax - xmin, ymax - ymin, zmax - zmin], dtype=float) + periodic = np.array(cell.periodic, dtype=bool) + delta = np.asarray(pj, dtype=float) - np.asarray(pi, dtype=float) + shifts = np.zeros_like(delta, dtype=np.int64) + for ax in range(3): + if not periodic[ax]: + continue + shifts[:, ax] = (-np.round(delta[:, ax] / lengths[ax])).astype(np.int64) + return shifts + + +def _nearest_image_shifts_triclinic( + pi: np.ndarray, + pj: np.ndarray, + cell: PeriodicCell, + *, + search: int, +) -> tuple[np.ndarray, np.ndarray]: + a, b, c = (np.asarray(v, dtype=float) for v in cell.vectors) + rng = np.arange(-search, search + 1, dtype=np.int64) + cand = np.array(np.meshgrid(rng, rng, rng, indexing='ij')).reshape(3, -1).T + base = np.asarray(pj, dtype=float) - np.asarray(pi, dtype=float) + trans = ( + cand[:, 0:1] * a[None, :] + + cand[:, 1:2] * b[None, :] + + cand[:, 2:3] * c[None, :] + ) + diff = base[:, None, :] + trans[None, :, :] + d2 = np.einsum('mki,mki->mk', diff, diff) + best = np.argmin(d2, axis=1) + shifts = cand[best].astype(np.int64) + boundary_hits = np.any(np.abs(shifts) == int(search), axis=1) + return shifts, boundary_hits.astype(bool) diff --git a/src/pyvoro2/_face_shifts3d.py b/src/pyvoro2/_face_shifts3d.py new file mode 100644 index 0000000..95b3f9f --- /dev/null +++ b/src/pyvoro2/_face_shifts3d.py @@ -0,0 +1,477 @@ +"""3D periodic face-shift reconstruction helpers. + +These helpers remain intentionally 3D-specific. They isolate the current +face-shift logic from the main API wrapper so the later planar implementation +can add a parallel edge-shift path without re-entangling ``api.py``. +""" + +from __future__ import annotations + +from typing import Any, Literal + +import numpy as np + + +def _add_periodic_face_shifts_inplace( + cells: list[dict[str, Any]], + *, + lattice_vectors: tuple[np.ndarray, np.ndarray, np.ndarray], + periodic_mask: tuple[bool, bool, bool] = (True, True, True), + mode: Literal['standard', 'power'] = 'standard', + radii: np.ndarray | None = None, + search: int = 2, + tol: float | None = None, + validate: bool = True, + repair: bool = False, +) -> None: + """Annotate periodic faces with integer neighbor-image shifts. + + This is a Python reference implementation used for correctness and testing. + A future C++ fast-path can be added to match these results. + + The shift for a face is defined as the integer lattice vector (na, nb, nc) + such that the adjacent cell on that face corresponds to the neighbor site + translated by: + + p_neighbor_image = p_neighbor + na*a + nb*b + nc*c + + where (a, b, c) are lattice translation vectors in the coordinate system of + the cell dictionaries. + + For partially periodic orthorhombic domains, `periodic_mask` can be used to + restrict shifts to periodic axes; non-periodic axes are forced to shift=0. + + Args: + cells: Cell dicts returned by the C++ layer. + lattice_vectors: Tuple (a, b, c) lattice vectors. + periodic_mask: Tuple (pa, pb, pc) of booleans. If False for an axis, + the corresponding shift component is forced to 0. + mode: 'standard' or 'power'. + radii: Radii array for power mode. + search: Search radius S; candidates in [-S..S]^3 are evaluated (with + non-periodic axes restricted to 0). + tol: Maximum allowed plane residual (absolute distance). If None, a + conservative default based on the periodic length scale is used. + validate: If True, validate plane residuals and reciprocity of shifts. + repair: If True, attempt to repair rare reciprocity mismatches by + enforcing opposite shifts on reciprocal faces. + + Raises: + ValueError: if a consistent shift cannot be determined within the search + radius, or if reciprocity validation fails. + """ + if search < 0: + raise ValueError('search must be >= 0') + + a = np.asarray(lattice_vectors[0], dtype=np.float64).reshape(3) + b = np.asarray(lattice_vectors[1], dtype=np.float64).reshape(3) + cvec = np.asarray(lattice_vectors[2], dtype=np.float64).reshape(3) + + pa, pb, pc = bool(periodic_mask[0]), bool(periodic_mask[1]), bool(periodic_mask[2]) + if not (pa or pb or pc): + raise ValueError('periodic_mask has no periodic axes (all False)') + + # Lattice basis (columns) and inverse for nearest-image seeding. + A = np.stack([a, b, cvec], axis=1) # shape (3,3) + try: + A_inv = np.linalg.inv(A) + except np.linalg.LinAlgError as e: + raise ValueError('cell lattice vectors are singular') from e + + # Characteristic length for tolerance scaling (periodic axes only). + # + # NOTE: + # We intentionally do **not** clamp this scale to 1.0. For very small or very + # large coordinate systems the user should rescale inputs explicitly. + Lcand: list[float] = [] + if pa: + Lcand.append(float(np.linalg.norm(a))) + if pb: + Lcand.append(float(np.linalg.norm(b))) + if pc: + Lcand.append(float(np.linalg.norm(cvec))) + L = float(max(Lcand)) if Lcand else 0.0 + + tol_plane = (1e-6 * L) if tol is None else float(tol) + if tol_plane < 0: + raise ValueError('tol must be >= 0') + + # Map particle id -> site position (in the same coordinates as vertices). + sites: dict[int, np.ndarray] = {} + for c in cells: + pid = int(c.get('id', -1)) + if pid < 0: + continue + s = np.asarray(c.get('site', []), dtype=np.float64) + if s.size == 3: + sites[pid] = s.reshape(3) + + # Precompute candidate shifts and their translation vectors. + ra = range(-search, search + 1) if pa else range(0, 1) + rb = range(-search, search + 1) if pb else range(0, 1) + rc = range(-search, search + 1) if pc else range(0, 1) + + shifts: list[tuple[int, int, int]] = [] + trans: list[np.ndarray] = [] + for na in ra: + for nb in rb: + for nc in rc: + shifts.append((int(na), int(nb), int(nc))) + trans.append(na * a + nb * b + nc * cvec) + + trans_arr = np.stack(trans, axis=0) if trans else np.zeros((0, 3), dtype=np.float64) + shift_to_idx = {s: i for i, s in enumerate(shifts)} + l1 = np.asarray([abs(s[0]) + abs(s[1]) + abs(s[2]) for s in shifts], dtype=np.int64) + + # Weights for power mode (Laguerre diagram) + if mode == 'power': + if radii is None: + raise ValueError('radii is required for mode="power"') + w = np.asarray(radii, dtype=np.float64) ** 2 + else: + w = None + + def _residual_for_trans( + *, + pid: int, + nid: int, + p_i: np.ndarray, + p_j: np.ndarray, + trans_subset: np.ndarray, + v: np.ndarray, + ) -> np.ndarray: + """Compute plane residuals for each candidate translation in trans_subset. + + Residual is the max absolute signed distance of face vertices to the + expected bisector plane (midplane for standard Voronoi, or the power + bisector for Laguerre diagrams). + """ + pj = p_j.reshape(1, 3) + trans_subset # (m,3) + d = pj - p_i.reshape(1, 3) # (m,3) + dn = np.linalg.norm(d, axis=1) # (m,) + dn = np.where(dn == 0.0, 1.0, dn) + + # Project vertices along the direction vector for each candidate. + # v: (k,3) -> proj: (m,k) + proj = np.einsum('mk,nk->mn', d, v) + + if mode == 'standard': + mid = 0.5 * (p_i.reshape(1, 3) + pj) # (m,3) + proj_mid = np.einsum('mk,mk->m', d, mid) # (m,) + dist = np.abs(proj - proj_mid[:, None]) / dn[:, None] + return np.max(dist, axis=1) + + if mode == 'power': + assert w is not None + wi = float(w[pid]) + wj = float(w[nid]) + # Radical plane: d·x = (|pj|^2 - wj - (|pi|^2 - wi)) / 2 + rhs = 0.5 * ( + (np.sum(pj * pj, axis=1) - wj) - (np.dot(p_i, p_i) - wi) + ) # (m,) + dist = np.abs(proj - rhs[:, None]) / dn[:, None] + return np.max(dist, axis=1) + + raise ValueError(f'unknown mode: {mode}') + + # Cache per-face residuals for potential debug / repair decisions. + resid_by_face: dict[tuple[int, int], float] = {} + + # Solve shifts face-by-face. + for c in cells: + pid = int(c.get('id', -1)) + if pid < 0: + continue + faces = c.get('faces') + if faces is None: + continue + + p_i = sites.get(pid) + if p_i is None: + continue + + verts = np.asarray(c.get('vertices', []), dtype=np.float64) + if verts.size == 0: + verts = verts.reshape((0, 3)) + if verts.ndim != 2 or verts.shape[1] != 3: + raise ValueError( + 'return_face_shifts requires vertex coordinates for each cell' + ) + + for fi, f in enumerate(faces): + nid = int(f.get('adjacent_cell', -999999)) + if nid < 0: + # Wall / invalid neighbor. + f['adjacent_shift'] = (0, 0, 0) + resid_by_face[(pid, fi)] = 0.0 + continue + + p_j = sites.get(nid) + if p_j is None: + raise ValueError(f'missing site for adjacent_cell={nid}') + + idx = np.asarray(f.get('vertices', []), dtype=np.int64) + if idx.size == 0 or verts.shape[0] == 0: + f['adjacent_shift'] = (0, 0, 0) + resid_by_face[(pid, fi)] = 0.0 + continue + v = verts[idx] + + # Periodic domains can have faces against *images of itself*. + self_neighbor = nid == pid + if self_neighbor and search == 0: + raise ValueError( + 'face_shift_search=0 cannot resolve faces against periodic images ' + 'of the same site; increase face_shift_search' + ) + + # Nearest-image seed: pick shift that brings p_j closest to p_i. + frac = A_inv @ (p_j - p_i) + base = (-np.rint(frac)).astype(np.int64) + if not pa: + base[0] = 0 + if not pb: + base[1] = 0 + if not pc: + base[2] = 0 + + da_rng = (-1, 0, 1) if pa else (0,) + db_rng = (-1, 0, 1) if pb else (0,) + dc_rng = (-1, 0, 1) if pc else (0,) + + seed_idx: list[int] = [] + for da in da_rng: + for db in db_rng: + for dc in dc_rng: + s = (int(base[0] + da), int(base[1] + db), int(base[2] + dc)) + # max() bounds check is still correct even if some + # axes are restricted. + if max(abs(s[0]), abs(s[1]), abs(s[2])) > search: + continue + ii = shift_to_idx.get(s) + if ii is not None: + seed_idx.append(ii) + + # Exclude the zero shift for self-neighbor faces. + idx0 = shift_to_idx.get((0, 0, 0)) + if self_neighbor and idx0 is not None: + seed_idx = [ii for ii in seed_idx if ii != idx0] + if not seed_idx: + if self_neighbor: + raise ValueError( + 'unable to seed face shift candidates for self-neighbor face; ' + 'increase face_shift_search' + ) + # Fall back to zero shift (may be the only allowed candidate when + # periodic axes are restricted). + if idx0 is None: + raise ValueError('internal error: missing (0,0,0) shift candidate') + seed_idx = [idx0] + + # Deduplicate while preserving order + seen: set[int] = set() + seed_idx = [x for x in seed_idx if not (x in seen or seen.add(x))] + + resid_seed = _residual_for_trans( + pid=pid, + nid=nid, + p_i=p_i, + p_j=p_j, + trans_subset=trans_arr[seed_idx], + v=v, + ) + best_local = int(np.argmin(resid_seed)) + best_idx = int(seed_idx[best_local]) + best_resid = float(resid_seed[best_local]) + + if best_resid > tol_plane and len(shifts) > len(seed_idx): + # Fall back to full candidate cube. + resid_full = _residual_for_trans( + pid=pid, nid=nid, p_i=p_i, p_j=p_j, trans_subset=trans_arr, v=v + ) + if self_neighbor and idx0 is not None and idx0 < resid_full.shape[0]: + resid_full[idx0] = np.inf + best_idx = int(np.argmin(resid_full)) + best_resid = float(resid_full[best_idx]) + resid_for_tie = resid_full + cand_idx = list(range(len(shifts))) + else: + resid_for_tie = resid_seed + cand_idx = seed_idx + + if best_resid > tol_plane: + raise ValueError( + 'unable to determine adjacent_shift within tolerance; ' + f'pid={pid}, nid={nid}, best_resid={best_resid:g}, ' + f'tol={tol_plane:g}. Consider increasing face_shift_search.' + ) + + # Tie-break deterministically among *numerically indistinguishable* + # candidates. + # + # Important: do NOT use a tolerance proportional to `tol_plane` here. + # `tol_plane` is a permissive validation threshold; using it for + # tie-breaking can incorrectly prefer a smaller-|shift| candidate even + # when it has a clearly worse residual. + scale = max( + float(np.linalg.norm(p_i)), + float(np.linalg.norm(p_j)), + L, + 1e-30, + ) + eps_tie = max(1e-12 * scale, 64.0 * np.finfo(float).eps * scale) + near = [ + cand_idx[k] + for k, rr in enumerate(resid_for_tie) + if float(rr) <= best_resid + eps_tie + ] + if len(near) > 1: + near.sort(key=lambda ii: (int(l1[ii]), shifts[ii])) + best_idx = int(near[0]) + + f['adjacent_shift'] = shifts[best_idx] + resid_by_face[(pid, fi)] = best_resid + + if not validate and not repair: + return + + # Build fast lookup of directed faces by (pid, nid, shift). + def _skey(s: Any) -> tuple[int, int, int]: + return int(s[0]), int(s[1]), int(s[2]) + + face_key_to_loc: dict[tuple[int, int, tuple[int, int, int]], tuple[int, int]] = {} + for c in cells: + pid = int(c.get('id', -1)) + if pid < 0: + continue + faces = c.get('faces') or [] + for fi, f in enumerate(faces): + nid = int(f.get('adjacent_cell', -999999)) + if nid < 0: + continue + s = _skey(f.get('adjacent_shift', (0, 0, 0))) + key = (pid, nid, s) + if key in face_key_to_loc: + raise ValueError(f'duplicate directed face key: {key}') + face_key_to_loc[key] = (pid, fi) + + def _missing_reciprocals() -> list[tuple[int, int, tuple[int, int, int]]]: + missing: list[tuple[int, int, tuple[int, int, int]]] = [] + for pid, nid, s in face_key_to_loc.keys(): + recip = (nid, pid, (-s[0], -s[1], -s[2])) + if recip not in face_key_to_loc: + missing.append((pid, nid, s)) + return missing + + missing = _missing_reciprocals() + + # Reciprocity is a strict invariant for periodic standard Voronoi and + # power diagrams. + if missing and not repair: + raise ValueError( + f'face shift reciprocity check failed for {len(missing)} faces; ' + 'set repair_face_shifts=True to attempt repair, ' + 'or inspect face_shift_search/tolerance.' + ) + + if missing and repair: + cell_by_id: dict[int, dict[str, Any]] = { + int(c.get('id', -1)): c for c in cells if int(c.get('id', -1)) >= 0 + } + + # (cell_id, face_index) already modified + used_faces: set[tuple[int, int]] = set() + + def _force_shift_on_neighbor_face( + pid: int, nid: int, s: tuple[int, int, int] + ) -> None: + """Force the reciprocal face in nid to have shift -s. + + The reciprocal face is chosen by minimal plane residual. + """ + target = (-s[0], -s[1], -s[2]) + cc = cell_by_id.get(nid) + if cc is None: + raise ValueError(f'cannot repair: missing cell dict for nid={nid}') + faces_n = cc.get('faces') or [] + verts_n = np.asarray(cc.get('vertices', []), dtype=np.float64) + if verts_n.size == 0: + verts_n = verts_n.reshape((0, 3)) + if verts_n.ndim != 2 or verts_n.shape[1] != 3: + raise ValueError('cannot repair: neighbor cell missing vertices') + + p_n = sites.get(nid) + p_p = sites.get(pid) + if p_n is None or p_p is None: + raise ValueError('cannot repair: missing site positions') + + cand: list[tuple[float, int]] = [] + for fi2, f2 in enumerate(faces_n): + if int(f2.get('adjacent_cell', -999999)) != pid: + continue + if (nid, fi2) in used_faces: + continue + idx2 = np.asarray(f2.get('vertices', []), dtype=np.int64) + if idx2.size == 0 or verts_n.shape[0] == 0: + continue + v2 = verts_n[idx2] + # Evaluate residual for forcing target shift on this candidate face. + trans_force = ( + float(target[0]) * a + + float(target[1]) * b + + float(target[2]) * cvec + ) + rr = _residual_for_trans( + pid=nid, + nid=pid, + p_i=p_n, + p_j=p_p, + trans_subset=trans_force.reshape(1, 3), + v=v2, + ) + cand.append((float(rr[0]), fi2)) + + if not cand: + raise ValueError( + f'cannot repair: no candidate faces in cell {nid} pointing to {pid}' + ) + + cand.sort(key=lambda x: x[0]) + best_r, best_fi = cand[0] + if best_r > tol_plane: + raise ValueError( + f'cannot repair: best residual {best_r:g} exceeds tol ' + f'{tol_plane:g} for reciprocal face nid={nid} -> pid={pid}' + ) + + faces_n[best_fi]['adjacent_shift'] = target + used_faces.add((nid, best_fi)) + + # Only repair faces in one direction (pid < nid) to avoid oscillations. + for pid, nid, s in missing: + if pid >= nid: + continue + _force_shift_on_neighbor_face(pid, nid, s) + + # Rebuild lookup after modifications. + face_key_to_loc.clear() + for c in cells: + pid = int(c.get('id', -1)) + if pid < 0: + continue + faces = c.get('faces') or [] + for fi, f in enumerate(faces): + nid = int(f.get('adjacent_cell', -999999)) + if nid < 0: + continue + s = _skey(f.get('adjacent_shift', (0, 0, 0))) + key = (pid, nid, s) + if key in face_key_to_loc: + raise ValueError(f'duplicate directed face key after repair: {key}') + face_key_to_loc[key] = (pid, fi) + + missing2 = _missing_reciprocals() + if missing2 and mode in ('standard', 'power'): + raise ValueError( + f'face shift reciprocity repair failed for {len(missing2)} faces' + ) diff --git a/src/pyvoro2/_inputs.py b/src/pyvoro2/_inputs.py new file mode 100644 index 0000000..ead5b39 --- /dev/null +++ b/src/pyvoro2/_inputs.py @@ -0,0 +1,100 @@ +"""Internal coercion helpers for public Python entry points. + +These helpers intentionally keep error messages stable so the public API can be +refactored without changing its validation surface. +""" + +from __future__ import annotations + +from typing import Sequence + +import numpy as np + + +def coerce_point_array( + values: Sequence[Sequence[float]] | np.ndarray, + *, + name: str, + dim: int, +) -> np.ndarray: + """Return a finite ``(n, dim)`` float64 array.""" + + arr = np.asarray(values, dtype=np.float64) + if arr.ndim != 2 or arr.shape[1] != dim: + raise ValueError(f'{name} must have shape (n, {dim})') + if not np.all(np.isfinite(arr)): + raise ValueError(f'{name} must contain only finite values') + return arr + + +def coerce_id_array( + ids: Sequence[int] | np.ndarray | None, + *, + n: int, +) -> np.ndarray | None: + """Return validated non-negative unique IDs or ``None``.""" + + if ids is None: + return None + if len(ids) != n: + raise ValueError('ids must have length n') + ids_arr = np.asarray(ids, dtype=np.int64) + if ids_arr.shape != (n,): + raise ValueError('ids must be a 1D sequence of length n') + if np.any(ids_arr < 0): + raise ValueError('ids must be non-negative') + if np.unique(ids_arr).size != n: + raise ValueError('ids must be unique') + return ids_arr + + +def coerce_nonnegative_vector( + values: Sequence[float] | np.ndarray, + *, + name: str, + n: int, +) -> np.ndarray: + """Return a finite non-negative float64 vector with shape ``(n,)``.""" + + arr = np.asarray(values, dtype=np.float64) + if arr.shape != (n,): + raise ValueError(f'{name} must have shape (n,)') + if not np.all(np.isfinite(arr)): + raise ValueError(f'{name} must contain only finite values') + if np.any(arr < 0): + raise ValueError(f'{name} must be non-negative') + return arr + + +def coerce_nonnegative_scalar_or_vector( + values: float | Sequence[float] | np.ndarray, + *, + name: str, + n: int, + length_name: str, +) -> np.ndarray: + """Return a finite non-negative float64 vector. + + Scalars are broadcast to shape ``(n,)``. Vector inputs must already have + shape ``(n,)``. + """ + + arr = np.asarray(values, dtype=np.float64) + if arr.ndim == 0: + arr = np.full((n,), float(arr), dtype=np.float64) + if arr.shape != (n,): + raise ValueError( + f'{name} must be a scalar or have shape ({length_name},)' + ) + if not np.all(np.isfinite(arr)): + raise ValueError(f'{name} must contain only finite values') + if np.any(arr < 0): + raise ValueError(f'{name} must be non-negative') + return arr + + +def validate_duplicate_check_mode(mode: str) -> None: + """Validate the public duplicate-check mode string.""" + + if mode not in ('off', 'warn', 'raise'): + raise ValueError("duplicate_check must be one of: 'off', 'warn', 'raise'") diff --git a/src/pyvoro2/api.py b/src/pyvoro2/api.py index 4714983..d61c95c 100644 --- a/src/pyvoro2/api.py +++ b/src/pyvoro2/api.py @@ -10,6 +10,15 @@ from .domains import Box, OrthorhombicCell, PeriodicCell from ._util import domain_length_scale +from ._inputs import ( + coerce_id_array, + coerce_nonnegative_scalar_or_vector, + coerce_nonnegative_vector, + coerce_point_array, + validate_duplicate_check_mode, +) +from ._domain_geometry import geometry3d +from ._face_shifts3d import _add_periodic_face_shifts_inplace from .duplicates import duplicate_check as _duplicate_check from .diagnostics import ( TessellationDiagnostics, @@ -26,7 +35,7 @@ from . import _core # type: ignore _CORE_IMPORT_ERROR: BaseException | None = None -except BaseException as _e: # pragma: no cover +except Exception as _e: # pragma: no cover _core = None # type: ignore _CORE_IMPORT_ERROR = _e @@ -259,11 +268,7 @@ def compute( Raises: ValueError: If inputs are inconsistent or an unknown mode is provided. """ - pts = np.asarray(points, dtype=np.float64) - if pts.ndim != 2 or pts.shape[1] != 3: - raise ValueError('points must have shape (n, 3)') - if not np.all(np.isfinite(pts)): - raise ValueError('points must contain only finite values') + pts = coerce_point_array(points, name='points', dim=3) _warn_if_scale_suspicious(pts=pts, domain=domain) n = int(pts.shape[0]) @@ -271,24 +276,10 @@ def compute( ids_internal = np.arange(n, dtype=np.int32) core = _require_core() - - ids_user: np.ndarray | None - if ids is None: - ids_user = None - else: - if len(ids) != n: - raise ValueError('ids must have length n') - ids_user = np.asarray(ids, dtype=np.int64) - if ids_user.shape != (n,): - raise ValueError('ids must be a 1D sequence of length n') - if np.any(ids_user < 0): - raise ValueError('ids must be non-negative') - if np.unique(ids_user).size != n: - raise ValueError('ids must be unique') + ids_user = coerce_id_array(ids, n=n) # Optional near-duplicate pre-check (to avoid Voro++ hard exit). - if duplicate_check not in ('off', 'warn', 'raise'): - raise ValueError('duplicate_check must be one of: \'off\', \'warn\', \'raise\'') + validate_duplicate_check_mode(duplicate_check) if duplicate_check != 'off' and n > 1: _duplicate_check( pts, @@ -299,32 +290,12 @@ def compute( max_pairs=int(duplicate_max_pairs), ) - # Determine blocks - if blocks is not None: - nx, ny, nz = blocks - else: - if block_size is None: - # Simple heuristic: 2.5 * mean spacing inferred from density. - if isinstance(domain, (Box, OrthorhombicCell)): - (xmin, xmax), (ymin, ymax), (zmin, zmax) = domain.bounds - vol = (xmax - xmin) * (ymax - ymin) * (zmax - zmin) - spacing = (vol / max(n, 1)) ** (1.0 / 3.0) - else: - bx, _bxy, by, _bxz, _byz, bz = domain.to_internal_params() - vol = bx * by * bz - spacing = (vol / max(n, 1)) ** (1.0 / 3.0) - block_size = max(1e-6, 2.5 * spacing) - - if isinstance(domain, (Box, OrthorhombicCell)): - (xmin, xmax), (ymin, ymax), (zmin, zmax) = domain.bounds - nx = max(1, int((xmax - xmin) / block_size)) - ny = max(1, int((ymax - ymin) / block_size)) - nz = max(1, int((zmax - zmin) / block_size)) - else: - bx, _bxy, by, _bxz, _byz, bz = domain.to_internal_params() - nx = max(1, int(bx / block_size)) - ny = max(1, int(by / block_size)) - nz = max(1, int(bz / block_size)) + geom = geometry3d(domain) + nx, ny, nz = geom.resolve_block_counts( + n_sites=n, + blocks=blocks, + block_size=block_size, + ) opts = (bool(return_vertices), bool(return_adjacency), bool(return_faces)) @@ -335,13 +306,9 @@ def compute( # --- Rectangular containers (Box / OrthorhombicCell) --- if isinstance(domain, (Box, OrthorhombicCell)): - bounds = domain.bounds - periodic_flags = ( - (False, False, False) - if isinstance(domain, Box) - else tuple(bool(x) for x in domain.periodic) - ) - is_periodic = isinstance(domain, OrthorhombicCell) and any(periodic_flags) + bounds = geom.bounds + periodic_flags = geom.periodic_axes + is_periodic = geom.has_any_periodic_axis if return_face_shifts: if not is_periodic: raise ValueError( @@ -369,13 +336,7 @@ def compute( elif mode == 'power': if radii is None: raise ValueError('radii is required for mode="power"') - rr = np.asarray(radii, dtype=np.float64) - if rr.shape != (n,): - raise ValueError('radii must have shape (n,)') - if not np.all(np.isfinite(rr)): - raise ValueError('radii must contain only finite values') - if np.any(rr < 0): - raise ValueError('radii must be non-negative') + rr = coerce_nonnegative_vector(radii, name='radii', n=n) cells = core.compute_box_power( pts, ids_internal, @@ -457,7 +418,7 @@ def compute( ) if tessellation_check == 'raise': raise TessellationError(msg, diag) - warnings.warn(msg) + warnings.warn(msg, stacklevel=2) if return_diagnostics: assert diag is not None @@ -596,7 +557,7 @@ def compute( ) if tessellation_check == 'raise': raise TessellationError(msg, diag) - warnings.warn(msg) + warnings.warn(msg, stacklevel=2) if return_diagnostics: assert diag is not None @@ -659,40 +620,18 @@ def locate( image of the primary domain. This is useful when you need a consistent nearest-image geometry for a given query. """ - pts = np.asarray(points, dtype=np.float64) - if pts.ndim != 2 or pts.shape[1] != 3: - raise ValueError('points must have shape (n, 3)') - if not np.all(np.isfinite(pts)): - raise ValueError('points must contain only finite values') + pts = coerce_point_array(points, name='points', dim=3) _warn_if_scale_suspicious(pts=pts, domain=domain) - q = np.asarray(queries, dtype=np.float64) - if q.ndim != 2 or q.shape[1] != 3: - raise ValueError('queries must have shape (m, 3)') - if not np.all(np.isfinite(q)): - raise ValueError('queries must contain only finite values') + q = coerce_point_array(queries, name='queries', dim=3) n = int(pts.shape[0]) ids_internal = np.arange(n, dtype=np.int32) core = _require_core() - - ids_user: np.ndarray | None - if ids is None: - ids_user = None - else: - if len(ids) != n: - raise ValueError('ids must have length n') - ids_user = np.asarray(ids, dtype=np.int64) - if ids_user.shape != (n,): - raise ValueError('ids must be a 1D sequence of length n') - if np.any(ids_user < 0): - raise ValueError('ids must be non-negative') - if np.unique(ids_user).size != n: - raise ValueError('ids must be unique') + ids_user = coerce_id_array(ids, n=n) # Optional near-duplicate pre-check (to avoid Voro++ hard exit). - if duplicate_check not in ('off', 'warn', 'raise'): - raise ValueError('duplicate_check must be one of: \'off\', \'warn\', \'raise\'') + validate_duplicate_check_mode(duplicate_check) if duplicate_check != 'off' and n > 1: _duplicate_check( pts, @@ -703,40 +642,17 @@ def locate( max_pairs=int(duplicate_max_pairs), ) - # Determine blocks (same heuristic as compute) - if blocks is not None: - nx, ny, nz = blocks - else: - if block_size is None: - if isinstance(domain, (Box, OrthorhombicCell)): - (xmin, xmax), (ymin, ymax), (zmin, zmax) = domain.bounds - vol = (xmax - xmin) * (ymax - ymin) * (zmax - zmin) - spacing = (vol / max(n, 1)) ** (1.0 / 3.0) - else: - bx, _bxy, by, _bxz, _byz, bz = domain.to_internal_params() - vol = bx * by * bz - spacing = (vol / max(n, 1)) ** (1.0 / 3.0) - block_size = max(1e-6, 2.5 * spacing) - - if isinstance(domain, (Box, OrthorhombicCell)): - (xmin, xmax), (ymin, ymax), (zmin, zmax) = domain.bounds - nx = max(1, int((xmax - xmin) / block_size)) - ny = max(1, int((ymax - ymin) / block_size)) - nz = max(1, int((zmax - zmin) / block_size)) - else: - bx, _bxy, by, _bxz, _byz, bz = domain.to_internal_params() - nx = max(1, int(bx / block_size)) - ny = max(1, int(by / block_size)) - nz = max(1, int(bz / block_size)) + geom = geometry3d(domain) + nx, ny, nz = geom.resolve_block_counts( + n_sites=n, + blocks=blocks, + block_size=block_size, + ) # --- Rectangular containers (Box / OrthorhombicCell) --- if isinstance(domain, (Box, OrthorhombicCell)): - bounds = domain.bounds - periodic_flags = ( - (False, False, False) - if isinstance(domain, Box) - else tuple(bool(x) for x in domain.periodic) - ) + bounds = geom.bounds + periodic_flags = geom.periodic_axes if mode == 'standard': found, owner_id, owner_pos = core.locate_box_standard( @@ -745,13 +661,7 @@ def locate( elif mode == 'power': if radii is None: raise ValueError('radii is required for mode="power"') - rr = np.asarray(radii, dtype=np.float64) - if rr.shape != (n,): - raise ValueError('radii must have shape (n,)') - if not np.all(np.isfinite(rr)): - raise ValueError('radii must contain only finite values') - if np.any(rr < 0): - raise ValueError('radii must be non-negative') + rr = coerce_nonnegative_vector(radii, name='radii', n=n) found, owner_id, owner_pos = core.locate_box_power( pts, ids_internal, rr, bounds, (nx, ny, nz), periodic_flags, init_mem, q ) @@ -761,7 +671,7 @@ def locate( # --- PeriodicCell (triclinic) --- else: cell = domain - bx, bxy, by, bxz, byz, bz = cell.to_internal_params() + bx, bxy, by, bxz, byz, bz = geom.internal_params pts_i = cell.cart_to_internal(pts) q_i = cell.cart_to_internal(q) @@ -777,13 +687,7 @@ def locate( elif mode == 'power': if radii is None: raise ValueError('radii is required for mode="power"') - rr = np.asarray(radii, dtype=np.float64) - if rr.shape != (n,): - raise ValueError('radii must have shape (n,)') - if not np.all(np.isfinite(rr)): - raise ValueError('radii must contain only finite values') - if np.any(rr < 0): - raise ValueError('radii must be non-negative') + rr = coerce_nonnegative_vector(radii, name='radii', n=n) found, owner_id, owner_pos = core.locate_periodic_power( pts_i, ids_internal, @@ -902,17 +806,9 @@ def ghost_cells( Raises: ValueError: if inputs are inconsistent. """ - pts = np.asarray(points, dtype=np.float64) - if pts.ndim != 2 or pts.shape[1] != 3: - raise ValueError('points must have shape (n, 3)') - if not np.all(np.isfinite(pts)): - raise ValueError('points must contain only finite values') + pts = coerce_point_array(points, name='points', dim=3) _warn_if_scale_suspicious(pts=pts, domain=domain) - q = np.asarray(queries, dtype=np.float64) - if q.ndim != 2 or q.shape[1] != 3: - raise ValueError('queries must have shape (m, 3)') - if not np.all(np.isfinite(q)): - raise ValueError('queries must contain only finite values') + q = coerce_point_array(queries, name='queries', dim=3) n = int(pts.shape[0]) m = int(q.shape[0]) @@ -920,24 +816,10 @@ def ghost_cells( ids_internal = np.arange(n, dtype=np.int32) core = _require_core() - - ids_user: np.ndarray | None - if ids is None: - ids_user = None - else: - if len(ids) != n: - raise ValueError('ids must have length n') - ids_user = np.asarray(ids, dtype=np.int64) - if ids_user.shape != (n,): - raise ValueError('ids must be a 1D sequence of length n') - if np.any(ids_user < 0): - raise ValueError('ids must be non-negative') - if np.unique(ids_user).size != n: - raise ValueError('ids must be unique') + ids_user = coerce_id_array(ids, n=n) # Optional near-duplicate pre-check (to avoid Voro++ hard exit). - if duplicate_check not in ('off', 'warn', 'raise'): - raise ValueError('duplicate_check must be one of: \'off\', \'warn\', \'raise\'') + validate_duplicate_check_mode(duplicate_check) if duplicate_check != 'off' and n > 1: _duplicate_check( pts, @@ -948,42 +830,19 @@ def ghost_cells( max_pairs=int(duplicate_max_pairs), ) - # Determine blocks (same heuristic as compute/locate) - if blocks is not None: - nx, ny, nz = blocks - else: - if block_size is None: - if isinstance(domain, (Box, OrthorhombicCell)): - (xmin, xmax), (ymin, ymax), (zmin, zmax) = domain.bounds - vol = (xmax - xmin) * (ymax - ymin) * (zmax - zmin) - spacing = (vol / max(n, 1)) ** (1.0 / 3.0) - else: - bx, _bxy, by, _bxz, _byz, bz = domain.to_internal_params() - vol = bx * by * bz - spacing = (vol / max(n, 1)) ** (1.0 / 3.0) - block_size = max(1e-6, 2.5 * spacing) - - if isinstance(domain, (Box, OrthorhombicCell)): - (xmin, xmax), (ymin, ymax), (zmin, zmax) = domain.bounds - nx = max(1, int((xmax - xmin) / block_size)) - ny = max(1, int((ymax - ymin) / block_size)) - nz = max(1, int((zmax - zmin) / block_size)) - else: - bx, _bxy, by, _bxz, _byz, bz = domain.to_internal_params() - nx = max(1, int(bx / block_size)) - ny = max(1, int(by / block_size)) - nz = max(1, int(bz / block_size)) + geom = geometry3d(domain) + nx, ny, nz = geom.resolve_block_counts( + n_sites=n, + blocks=blocks, + block_size=block_size, + ) opts = (bool(return_vertices), bool(return_adjacency), bool(return_faces)) # --- Rectangular containers (Box / OrthorhombicCell) --- if isinstance(domain, (Box, OrthorhombicCell)): - bounds = domain.bounds - periodic_flags = ( - (False, False, False) - if isinstance(domain, Box) - else tuple(bool(x) for x in domain.periodic) - ) + bounds = geom.bounds + periodic_flags = geom.periodic_axes # Pre-wrap query points for periodic axes so the returned vertices are # anchored at the same site that Voro++ uses internally. @@ -1006,21 +865,16 @@ def ghost_cells( elif mode == 'power': if radii is None: raise ValueError('radii is required for mode="power"') - rr = np.asarray(radii, dtype=np.float64) - if rr.shape != (n,): - raise ValueError('radii must have shape (n,)') + rr = coerce_nonnegative_vector(radii, name='radii', n=n) if ghost_radius is None: raise ValueError('ghost_radius is required for mode="power"') - gr = np.asarray(ghost_radius, dtype=np.float64) - if gr.ndim == 0: - gr = np.full((m,), float(gr), dtype=np.float64) - if gr.shape != (m,): - raise ValueError('ghost_radius must be a scalar or have shape (m,)') - if not np.all(np.isfinite(gr)): - raise ValueError('ghost_radius must contain only finite values') - if np.any(gr < 0): - raise ValueError('ghost_radius must be non-negative') + gr = coerce_nonnegative_scalar_or_vector( + ghost_radius, + name='ghost_radius', + n=m, + length_name='m', + ) cells = core.ghost_box_power( pts, @@ -1041,7 +895,7 @@ def ghost_cells( # --- PeriodicCell (triclinic) --- else: cell = domain - bx, bxy, by, bxz, byz, bz = cell.to_internal_params() + bx, bxy, by, bxz, byz, bz = geom.internal_params pts_i = cell.cart_to_internal(pts) q_i = cell.cart_to_internal(q) @@ -1064,25 +918,16 @@ def ghost_cells( elif mode == 'power': if radii is None: raise ValueError('radii is required for mode="power"') - rr = np.asarray(radii, dtype=np.float64) - if rr.shape != (n,): - raise ValueError('radii must have shape (n,)') - if not np.all(np.isfinite(rr)): - raise ValueError('radii must contain only finite values') - if np.any(rr < 0): - raise ValueError('radii must be non-negative') + rr = coerce_nonnegative_vector(radii, name='radii', n=n) if ghost_radius is None: raise ValueError('ghost_radius is required for mode="power"') - gr = np.asarray(ghost_radius, dtype=np.float64) - if gr.ndim == 0: - gr = np.full((m,), float(gr), dtype=np.float64) - if gr.shape != (m,): - raise ValueError('ghost_radius must be a scalar or have shape (m,)') - if not np.all(np.isfinite(gr)): - raise ValueError('ghost_radius must contain only finite values') - if np.any(gr < 0): - raise ValueError('ghost_radius must be non-negative') + gr = coerce_nonnegative_scalar_or_vector( + ghost_radius, + name='ghost_radius', + n=m, + length_name='m', + ) cells = core.ghost_periodic_power( pts_i, @@ -1130,468 +975,3 @@ def ghost_cells( cells = [c for c in cells if not bool(c.get('empty', False))] return cells - - -def _add_periodic_face_shifts_inplace( - cells: list[dict[str, Any]], - *, - lattice_vectors: tuple[np.ndarray, np.ndarray, np.ndarray], - periodic_mask: tuple[bool, bool, bool] = (True, True, True), - mode: Literal['standard', 'power'] = 'standard', - radii: np.ndarray | None = None, - search: int = 2, - tol: float | None = None, - validate: bool = True, - repair: bool = False, -) -> None: - """Annotate periodic faces with integer neighbor-image shifts. - - This is a Python reference implementation used for correctness and testing. - A future C++ fast-path can be added to match these results. - - The shift for a face is defined as the integer lattice vector (na, nb, nc) - such that the adjacent cell on that face corresponds to the neighbor site - translated by: - - p_neighbor_image = p_neighbor + na*a + nb*b + nc*c - - where (a, b, c) are lattice translation vectors in the coordinate system of - the cell dictionaries. - - For partially periodic orthorhombic domains, `periodic_mask` can be used to - restrict shifts to periodic axes; non-periodic axes are forced to shift=0. - - Args: - cells: Cell dicts returned by the C++ layer. - lattice_vectors: Tuple (a, b, c) lattice vectors. - periodic_mask: Tuple (pa, pb, pc) of booleans. If False for an axis, - the corresponding shift component is forced to 0. - mode: 'standard' or 'power'. - radii: Radii array for power mode. - search: Search radius S; candidates in [-S..S]^3 are evaluated (with - non-periodic axes restricted to 0). - tol: Maximum allowed plane residual (absolute distance). If None, a - conservative default based on the periodic length scale is used. - validate: If True, validate plane residuals and reciprocity of shifts. - repair: If True, attempt to repair rare reciprocity mismatches by - enforcing opposite shifts on reciprocal faces. - - Raises: - ValueError: if a consistent shift cannot be determined within the search - radius, or if reciprocity validation fails. - """ - if search < 0: - raise ValueError('search must be >= 0') - - a = np.asarray(lattice_vectors[0], dtype=np.float64).reshape(3) - b = np.asarray(lattice_vectors[1], dtype=np.float64).reshape(3) - cvec = np.asarray(lattice_vectors[2], dtype=np.float64).reshape(3) - - pa, pb, pc = bool(periodic_mask[0]), bool(periodic_mask[1]), bool(periodic_mask[2]) - if not (pa or pb or pc): - raise ValueError('periodic_mask has no periodic axes (all False)') - - # Lattice basis (columns) and inverse for nearest-image seeding. - A = np.stack([a, b, cvec], axis=1) # shape (3,3) - try: - A_inv = np.linalg.inv(A) - except np.linalg.LinAlgError as e: - raise ValueError('cell lattice vectors are singular') from e - - # Characteristic length for tolerance scaling (periodic axes only). - # - # NOTE: - # We intentionally do **not** clamp this scale to 1.0. For very small or very - # large coordinate systems the user should rescale inputs explicitly. - Lcand: list[float] = [] - if pa: - Lcand.append(float(np.linalg.norm(a))) - if pb: - Lcand.append(float(np.linalg.norm(b))) - if pc: - Lcand.append(float(np.linalg.norm(cvec))) - L = float(max(Lcand)) if Lcand else 0.0 - - tol_plane = (1e-6 * L) if tol is None else float(tol) - if tol_plane < 0: - raise ValueError('tol must be >= 0') - - # Map particle id -> site position (in the same coordinates as vertices). - sites: dict[int, np.ndarray] = {} - for c in cells: - pid = int(c.get('id', -1)) - if pid < 0: - continue - s = np.asarray(c.get('site', []), dtype=np.float64) - if s.size == 3: - sites[pid] = s.reshape(3) - - # Precompute candidate shifts and their translation vectors. - ra = range(-search, search + 1) if pa else range(0, 1) - rb = range(-search, search + 1) if pb else range(0, 1) - rc = range(-search, search + 1) if pc else range(0, 1) - - shifts: list[tuple[int, int, int]] = [] - trans: list[np.ndarray] = [] - for na in ra: - for nb in rb: - for nc in rc: - shifts.append((int(na), int(nb), int(nc))) - trans.append(na * a + nb * b + nc * cvec) - - trans_arr = np.stack(trans, axis=0) if trans else np.zeros((0, 3), dtype=np.float64) - shift_to_idx = {s: i for i, s in enumerate(shifts)} - l1 = np.asarray([abs(s[0]) + abs(s[1]) + abs(s[2]) for s in shifts], dtype=np.int64) - - # Weights for power mode (Laguerre diagram) - if mode == 'power': - if radii is None: - raise ValueError('radii is required for mode="power"') - w = np.asarray(radii, dtype=np.float64) ** 2 - else: - w = None - - def _residual_for_trans( - *, - pid: int, - nid: int, - p_i: np.ndarray, - p_j: np.ndarray, - trans_subset: np.ndarray, - v: np.ndarray, - ) -> np.ndarray: - """Compute plane residuals for each candidate translation in trans_subset. - - Residual is the max absolute signed distance of face vertices to the - expected bisector plane (midplane for standard Voronoi, or the power - bisector for Laguerre diagrams). - """ - pj = p_j.reshape(1, 3) + trans_subset # (m,3) - d = pj - p_i.reshape(1, 3) # (m,3) - dn = np.linalg.norm(d, axis=1) # (m,) - dn = np.where(dn == 0.0, 1.0, dn) - - # Project vertices along the direction vector for each candidate. - # v: (k,3) -> proj: (m,k) - proj = np.einsum('mk,nk->mn', d, v) - - if mode == 'standard': - mid = 0.5 * (p_i.reshape(1, 3) + pj) # (m,3) - proj_mid = np.einsum('mk,mk->m', d, mid) # (m,) - dist = np.abs(proj - proj_mid[:, None]) / dn[:, None] - return np.max(dist, axis=1) - - if mode == 'power': - assert w is not None - wi = float(w[pid]) - wj = float(w[nid]) - # Radical plane: d·x = (|pj|^2 - wj - (|pi|^2 - wi)) / 2 - rhs = 0.5 * ( - (np.sum(pj * pj, axis=1) - wj) - (np.dot(p_i, p_i) - wi) - ) # (m,) - dist = np.abs(proj - rhs[:, None]) / dn[:, None] - return np.max(dist, axis=1) - - raise ValueError(f'unknown mode: {mode}') - - # Cache per-face residuals for potential debug / repair decisions. - resid_by_face: dict[tuple[int, int], float] = {} - - # Solve shifts face-by-face. - for c in cells: - pid = int(c.get('id', -1)) - if pid < 0: - continue - faces = c.get('faces') - if faces is None: - continue - - p_i = sites.get(pid) - if p_i is None: - continue - - verts = np.asarray(c.get('vertices', []), dtype=np.float64) - if verts.size == 0: - verts = verts.reshape((0, 3)) - if verts.ndim != 2 or verts.shape[1] != 3: - raise ValueError( - 'return_face_shifts requires vertex coordinates for each cell' - ) - - for fi, f in enumerate(faces): - nid = int(f.get('adjacent_cell', -999999)) - if nid < 0: - # Wall / invalid neighbor. - f['adjacent_shift'] = (0, 0, 0) - resid_by_face[(pid, fi)] = 0.0 - continue - - p_j = sites.get(nid) - if p_j is None: - raise ValueError(f'missing site for adjacent_cell={nid}') - - idx = np.asarray(f.get('vertices', []), dtype=np.int64) - if idx.size == 0 or verts.shape[0] == 0: - f['adjacent_shift'] = (0, 0, 0) - resid_by_face[(pid, fi)] = 0.0 - continue - v = verts[idx] - - # Periodic domains can have faces against *images of itself*. - self_neighbor = nid == pid - if self_neighbor and search == 0: - raise ValueError( - 'face_shift_search=0 cannot resolve faces against periodic images ' - 'of the same site; increase face_shift_search' - ) - - # Nearest-image seed: pick shift that brings p_j closest to p_i. - frac = A_inv @ (p_j - p_i) - base = (-np.rint(frac)).astype(np.int64) - if not pa: - base[0] = 0 - if not pb: - base[1] = 0 - if not pc: - base[2] = 0 - - da_rng = (-1, 0, 1) if pa else (0,) - db_rng = (-1, 0, 1) if pb else (0,) - dc_rng = (-1, 0, 1) if pc else (0,) - - seed_idx: list[int] = [] - for da in da_rng: - for db in db_rng: - for dc in dc_rng: - s = (int(base[0] + da), int(base[1] + db), int(base[2] + dc)) - # max() bounds check is still correct even if some - # axes are restricted. - if max(abs(s[0]), abs(s[1]), abs(s[2])) > search: - continue - ii = shift_to_idx.get(s) - if ii is not None: - seed_idx.append(ii) - - # Exclude the zero shift for self-neighbor faces. - idx0 = shift_to_idx.get((0, 0, 0)) - if self_neighbor and idx0 is not None: - seed_idx = [ii for ii in seed_idx if ii != idx0] - if not seed_idx: - if self_neighbor: - raise ValueError( - 'unable to seed face shift candidates for self-neighbor face; ' - 'increase face_shift_search' - ) - # Fall back to zero shift (may be the only allowed candidate when - # periodic axes are restricted). - if idx0 is None: - raise ValueError('internal error: missing (0,0,0) shift candidate') - seed_idx = [idx0] - - # Deduplicate while preserving order - seen: set[int] = set() - seed_idx = [x for x in seed_idx if not (x in seen or seen.add(x))] - - resid_seed = _residual_for_trans( - pid=pid, - nid=nid, - p_i=p_i, - p_j=p_j, - trans_subset=trans_arr[seed_idx], - v=v, - ) - best_local = int(np.argmin(resid_seed)) - best_idx = int(seed_idx[best_local]) - best_resid = float(resid_seed[best_local]) - - if best_resid > tol_plane and len(shifts) > len(seed_idx): - # Fall back to full candidate cube. - resid_full = _residual_for_trans( - pid=pid, nid=nid, p_i=p_i, p_j=p_j, trans_subset=trans_arr, v=v - ) - if self_neighbor and idx0 is not None and idx0 < resid_full.shape[0]: - resid_full[idx0] = np.inf - best_idx = int(np.argmin(resid_full)) - best_resid = float(resid_full[best_idx]) - resid_for_tie = resid_full - cand_idx = list(range(len(shifts))) - else: - resid_for_tie = resid_seed - cand_idx = seed_idx - - if best_resid > tol_plane: - raise ValueError( - 'unable to determine adjacent_shift within tolerance; ' - f'pid={pid}, nid={nid}, best_resid={best_resid:g}, ' - f'tol={tol_plane:g}. Consider increasing face_shift_search.' - ) - - # Tie-break deterministically among *numerically indistinguishable* - # candidates. - # - # Important: do NOT use a tolerance proportional to `tol_plane` here. - # `tol_plane` is a permissive validation threshold; using it for - # tie-breaking can incorrectly prefer a smaller-|shift| candidate even - # when it has a clearly worse residual. - scale = max( - float(np.linalg.norm(p_i)), - float(np.linalg.norm(p_j)), - L, - 1e-30, - ) - eps_tie = max(1e-12 * scale, 64.0 * np.finfo(float).eps * scale) - near = [ - cand_idx[k] - for k, rr in enumerate(resid_for_tie) - if float(rr) <= best_resid + eps_tie - ] - if len(near) > 1: - near.sort(key=lambda ii: (int(l1[ii]), shifts[ii])) - best_idx = int(near[0]) - - f['adjacent_shift'] = shifts[best_idx] - resid_by_face[(pid, fi)] = best_resid - - if not validate and not repair: - return - - # Build fast lookup of directed faces by (pid, nid, shift). - def _skey(s: Any) -> tuple[int, int, int]: - return int(s[0]), int(s[1]), int(s[2]) - - face_key_to_loc: dict[tuple[int, int, tuple[int, int, int]], tuple[int, int]] = {} - for c in cells: - pid = int(c.get('id', -1)) - if pid < 0: - continue - faces = c.get('faces') or [] - for fi, f in enumerate(faces): - nid = int(f.get('adjacent_cell', -999999)) - if nid < 0: - continue - s = _skey(f.get('adjacent_shift', (0, 0, 0))) - key = (pid, nid, s) - if key in face_key_to_loc: - raise ValueError(f'duplicate directed face key: {key}') - face_key_to_loc[key] = (pid, fi) - - def _missing_reciprocals() -> list[tuple[int, int, tuple[int, int, int]]]: - missing: list[tuple[int, int, tuple[int, int, int]]] = [] - for pid, nid, s in face_key_to_loc.keys(): - recip = (nid, pid, (-s[0], -s[1], -s[2])) - if recip not in face_key_to_loc: - missing.append((pid, nid, s)) - return missing - - missing = _missing_reciprocals() - - # Reciprocity is a strict invariant for periodic standard Voronoi and - # power diagrams. - if missing and not repair: - raise ValueError( - f'face shift reciprocity check failed for {len(missing)} faces; ' - 'set repair_face_shifts=True to attempt repair, ' - 'or inspect face_shift_search/tolerance.' - ) - - if missing and repair: - cell_by_id: dict[int, dict[str, Any]] = { - int(c.get('id', -1)): c for c in cells if int(c.get('id', -1)) >= 0 - } - - # (cell_id, face_index) already modified - used_faces: set[tuple[int, int]] = set() - - def _force_shift_on_neighbor_face( - pid: int, nid: int, s: tuple[int, int, int] - ) -> None: - """Force the reciprocal face in nid to have shift -s. - - The reciprocal face is chosen by minimal plane residual. - """ - target = (-s[0], -s[1], -s[2]) - cc = cell_by_id.get(nid) - if cc is None: - raise ValueError(f'cannot repair: missing cell dict for nid={nid}') - faces_n = cc.get('faces') or [] - verts_n = np.asarray(cc.get('vertices', []), dtype=np.float64) - if verts_n.size == 0: - verts_n = verts_n.reshape((0, 3)) - if verts_n.ndim != 2 or verts_n.shape[1] != 3: - raise ValueError('cannot repair: neighbor cell missing vertices') - - p_n = sites.get(nid) - p_p = sites.get(pid) - if p_n is None or p_p is None: - raise ValueError('cannot repair: missing site positions') - - cand: list[tuple[float, int]] = [] - for fi2, f2 in enumerate(faces_n): - if int(f2.get('adjacent_cell', -999999)) != pid: - continue - if (nid, fi2) in used_faces: - continue - idx2 = np.asarray(f2.get('vertices', []), dtype=np.int64) - if idx2.size == 0 or verts_n.shape[0] == 0: - continue - v2 = verts_n[idx2] - # Evaluate residual for forcing target shift on this candidate face. - trans_force = ( - float(target[0]) * a - + float(target[1]) * b - + float(target[2]) * cvec - ) - rr = _residual_for_trans( - pid=nid, - nid=pid, - p_i=p_n, - p_j=p_p, - trans_subset=trans_force.reshape(1, 3), - v=v2, - ) - cand.append((float(rr[0]), fi2)) - - if not cand: - raise ValueError( - f'cannot repair: no candidate faces in cell {nid} pointing to {pid}' - ) - - cand.sort(key=lambda x: x[0]) - best_r, best_fi = cand[0] - if best_r > tol_plane: - raise ValueError( - f'cannot repair: best residual {best_r:g} exceeds tol ' - f'{tol_plane:g} for reciprocal face nid={nid} -> pid={pid}' - ) - - faces_n[best_fi]['adjacent_shift'] = target - used_faces.add((nid, best_fi)) - - # Only repair faces in one direction (pid < nid) to avoid oscillations. - for pid, nid, s in missing: - if pid >= nid: - continue - _force_shift_on_neighbor_face(pid, nid, s) - - # Rebuild lookup after modifications. - face_key_to_loc.clear() - for c in cells: - pid = int(c.get('id', -1)) - if pid < 0: - continue - faces = c.get('faces') or [] - for fi, f in enumerate(faces): - nid = int(f.get('adjacent_cell', -999999)) - if nid < 0: - continue - s = _skey(f.get('adjacent_shift', (0, 0, 0))) - key = (pid, nid, s) - if key in face_key_to_loc: - raise ValueError(f'duplicate directed face key after repair: {key}') - face_key_to_loc[key] = (pid, fi) - - missing2 = _missing_reciprocals() - if missing2 and mode in ('standard', 'power'): - raise ValueError( - f'face shift reciprocity repair failed for {len(missing2)} faces' - ) diff --git a/src/pyvoro2/diagnostics.py b/src/pyvoro2/diagnostics.py index 0509181..9b2e8e2 100644 --- a/src/pyvoro2/diagnostics.py +++ b/src/pyvoro2/diagnostics.py @@ -59,9 +59,12 @@ class TessellationError(ValueError): """Raised when tessellation sanity checks fail under strict settings.""" def __init__(self, message: str, diagnostics: TessellationDiagnostics): - super().__init__(message) + super().__init__(message, diagnostics) self.diagnostics = diagnostics + def __str__(self) -> str: + return str(self.args[0]) + def _domain_volume(domain: Box | OrthorhombicCell | PeriodicCell) -> float: if isinstance(domain, (Box, OrthorhombicCell)): diff --git a/src/pyvoro2/duplicates.py b/src/pyvoro2/duplicates.py index 28effc0..5d16d7b 100644 --- a/src/pyvoro2/duplicates.py +++ b/src/pyvoro2/duplicates.py @@ -44,10 +44,13 @@ class DuplicateError(ValueError): def __init__( self, message: str, pairs: tuple[DuplicatePair, ...], threshold: float ): - super().__init__(message) + super().__init__(message, pairs, threshold) self.pairs = pairs self.threshold = float(threshold) + def __str__(self) -> str: + return str(self.args[0]) + def duplicate_check( points: Any, diff --git a/src/pyvoro2/edge_properties.py b/src/pyvoro2/edge_properties.py new file mode 100644 index 0000000..e061c62 --- /dev/null +++ b/src/pyvoro2/edge_properties.py @@ -0,0 +1,92 @@ +"""Edge-level geometric properties for planar cells.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from .planar._domain_geometry import geometry2d +from .planar.domains import Box, RectangularCell + + +def annotate_edge_properties( + cells: list[dict[str, Any]], + domain: Box | RectangularCell, + *, + tol: float = 1e-12, +) -> None: + """Annotate 2D edges with basic geometric descriptors in-place. + + Added edge fields (when computable): + - midpoint: [x, y] + - tangent: [tx, ty] unit tangent from vertex[0] -> vertex[1] + - normal: [nx, ny] unit normal oriented from site -> edge + - length: float + - other_site: [x, y] if the neighboring site can be resolved + """ + + sites: dict[int, np.ndarray] = {} + for cell in cells: + pid = int(cell.get('id', -1)) + site = np.asarray(cell.get('site', []), dtype=np.float64) + if pid >= 0 and site.size == 2: + sites[pid] = site.reshape(2) + + geom = geometry2d(domain) + periodic = geom.has_any_periodic_axis + + def _other_site(edge: dict[str, Any]) -> np.ndarray | None: + nid = int(edge.get('adjacent_cell', -999999)) + if nid < 0: + return None + other = sites.get(nid) + if other is None: + return None + if periodic and 'adjacent_shift' in edge: + shift = np.asarray(edge.get('adjacent_shift', (0, 0)), dtype=np.int64) + if shift.shape == (2,): + other = other + geom.shift_vector(shift) + return other + + eps = float(max(tol, 1e-15)) + for cell in cells: + pid = int(cell.get('id', -1)) + site = sites.get(pid) + if site is None: + continue + vertices = np.asarray(cell.get('vertices', []), dtype=np.float64) + if vertices.ndim != 2 or vertices.shape[1] != 2: + continue + + for edge in cell.get('edges') or []: + idx = np.asarray(edge.get('vertices', []), dtype=np.int64) + if idx.shape != (2,): + edge['midpoint'] = None + edge['tangent'] = None + edge['normal'] = None + edge['length'] = 0.0 + edge['other_site'] = None + continue + + v0 = vertices[idx[0]] + v1 = vertices[idx[1]] + dv = v1 - v0 + length = float(np.linalg.norm(dv)) + midpoint = 0.5 * (v0 + v1) + edge['midpoint'] = midpoint.tolist() + edge['length'] = length + + if length <= eps: + edge['tangent'] = None + edge['normal'] = None + else: + tangent = dv / length + normal = np.array([-tangent[1], tangent[0]], dtype=np.float64) + if float(np.dot(normal, midpoint - site)) < 0.0: + normal = -normal + edge['tangent'] = tangent.tolist() + edge['normal'] = normal.tolist() + + other = _other_site(edge) + edge['other_site'] = other.tolist() if other is not None else None diff --git a/src/pyvoro2/face_properties.py b/src/pyvoro2/face_properties.py index 4f226c8..330a155 100644 --- a/src/pyvoro2/face_properties.py +++ b/src/pyvoro2/face_properties.py @@ -4,8 +4,8 @@ by :func:`pyvoro2.compute`. The core computation in Voro++ is fast and focuses on topology/geometry of the -cells. Many chemistry workflows benefit from extra per-face descriptors (face -centroid, oriented normals, and a few contact heuristics). These can be +cells. Many downstream geometry workflows benefit from extra per-face descriptors +(face centroid, oriented normals, and a few boundary heuristics). These can be expensive, so they are provided as an explicit, opt-in post-processing step. """ @@ -16,6 +16,7 @@ import numpy as np from .domains import Box, OrthorhombicCell, PeriodicCell +from ._domain_geometry import geometry3d from .diagnostics import TessellationDiagnostics @@ -144,40 +145,26 @@ def annotate_face_properties( if cid >= 0 and s.size == 3: site_by_id[cid] = s.reshape(3) - domain_periodic = isinstance(domain, PeriodicCell) or ( - isinstance(domain, OrthorhombicCell) and any(domain.periodic) - ) + geom = geometry3d(domain) + domain_periodic = geom.has_any_periodic_axis if domain_periodic: - if isinstance(domain, PeriodicCell): - vec = np.asarray(domain.vectors, dtype=np.float64) - a, b, cvec = vec[0], vec[1], vec[2] - else: - # OrthorhombicCell - (xmin, xmax), (ymin, ymax), (zmin, zmax) = domain.bounds - a = np.array([xmax - xmin, 0.0, 0.0], dtype=np.float64) - b = np.array([0.0, ymax - ymin, 0.0], dtype=np.float64) - cvec = np.array([0.0, 0.0, zmax - zmin], dtype=np.float64) - - def _other_site(i: int, f: dict[str, Any]) -> np.ndarray | None: + + def _other_site(_i: int, f: dict[str, Any]) -> np.ndarray | None: j = int(f.get('adjacent_cell', -999999)) if j < 0: return None sj = site_by_id.get(j) - if sj is None: - return None - if 'adjacent_shift' not in f: + if sj is None or 'adjacent_shift' not in f: return None - s = f.get('adjacent_shift', (0, 0, 0)) - try: - na, nb, nc = int(s[0]), int(s[1]), int(s[2]) - except Exception: + shift = np.asarray(f.get('adjacent_shift', (0, 0, 0)), dtype=np.int64) + if shift.shape != (3,): return None - return sj + na * a + nb * b + nc * cvec + return sj + geom.shift_vector(shift) else: - def _other_site(i: int, f: dict[str, Any]) -> np.ndarray | None: + def _other_site(_i: int, f: dict[str, Any]) -> np.ndarray | None: j = int(f.get('adjacent_cell', -999999)) if j < 0: return None diff --git a/src/pyvoro2/inverse.py b/src/pyvoro2/inverse.py deleted file mode 100644 index 4a2b556..0000000 --- a/src/pyvoro2/inverse.py +++ /dev/null @@ -1,1204 +0,0 @@ -"""Inverse utilities for power (Laguerre) tessellations. - -This module provides helpers to *fit* per-site power weights (and radii) from -user-specified desired locations of separating planes between selected pairs. - -The fitted result is always a valid *power diagram* (a.k.a. Laguerre / radical -Voronoi tessellation) because it returns weights/radii to be used with -``mode='power'``. - -The core quantity in a power diagram is the per-site weight ``w_i``. Voro++ -accepts radii ``r_i`` and internally uses ``w_i = r_i**2``. - -For two sites ``i`` and an (optional periodic) image of ``j`` at distance ``d``, -the separating plane intersects the line segment at a fraction ``t`` (measured -from ``i`` toward ``j``) given by: - - t = 1/2 + (w_i - w_j) / (2 d^2) - -This means that desired plane locations correspond to constraints on weight -differences, and fitting can be posed as a convex optimization problem. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal, Sequence - -import numpy as np - -from .domains import Box, OrthorhombicCell, PeriodicCell - - -@dataclass(frozen=True, slots=True) -class FitWeightsResult: - """Result object returned by the inverse fitting routines.""" - - weights: np.ndarray - radii: np.ndarray - weight_shift: float - - # Per-constraint diagnostics (order matches input constraints) - t_target: np.ndarray - t_pred: np.ndarray - residuals: np.ndarray # t_pred - t_target - rms_residual: float - max_residual: float - - used_shifts: np.ndarray # (m, 3) integer lattice shifts applied to j - - # Optional adjacency check (requires a tessellation compute) - is_contact: np.ndarray | None - inactive_constraints: tuple[int, ...] | None - - # Solver metadata - solver: str - n_iter: int - converged: bool - warnings: tuple[str, ...] - - -def radii_to_weights(radii: np.ndarray) -> np.ndarray: - """Convert radii to power weights (w = r^2).""" - - r = np.asarray(radii, dtype=float) - if r.ndim != 1: - raise ValueError('radii must be 1D') - if np.any(r < 0): - raise ValueError('radii must be non-negative') - return r * r - - -def weights_to_radii( - weights: np.ndarray, *, r_min: float = 0.0 -) -> tuple[np.ndarray, float]: - """Convert weights to radii by applying a global weight shift. - - Power diagrams are invariant under adding a constant ``C`` to all weights. - Voro++ requires radii ``r`` with ``w = r^2 >= 0``. - - This helper chooses a shift ``C`` so that: - - min_i sqrt(w_i + C) == r_min - - Args: - weights: Array of weights (n,). - r_min: Minimum radius in the returned array. - - Returns: - (radii, C) where ``radii = sqrt(weights + C)``. - """ - - w = np.asarray(weights, dtype=float) - if w.ndim != 1: - raise ValueError('weights must be 1D') - r_min = float(r_min) - if r_min < 0: - raise ValueError('r_min must be >= 0') - - w_min = float(np.min(w)) if w.size else 0.0 - C = (r_min * r_min) - w_min - # C can be negative; that is fine as long as w + C >= r_min^2 >= 0. - w_shifted = w + C - if np.any(w_shifted < -1e-14): - # Numerical guard: in theory this should not happen. - raise ValueError('weight shift produced negative values (numerical issue)') - w_shifted = np.maximum(w_shifted, 0.0) - return np.sqrt(w_shifted), float(C) - - -def fit_power_weights_from_plane_positions( - points: np.ndarray, - constraints: Sequence[ - tuple[int, int, float] | tuple[int, int, float, tuple[int, int, int]] - ], - *, - domain: Box | OrthorhombicCell | PeriodicCell | None = None, - ids: Sequence[int] | None = None, - index_mode: Literal['index', 'id'] = 'index', - image: Literal['nearest', 'given_only'] = 'nearest', - image_search: int = 1, - constraint_weights: Sequence[float] | None = None, - # Predicted t(w) restrictions/penalties - t_bounds: tuple[float, float] | None = (0.0, 1.0), - t_bounds_mode: Literal['none', 'soft_quadratic', 'hard'] = 'none', - alpha_out: float = 0.0, - t_near_penalty: Literal['none', 'exp'] = 'none', - beta_near: float = 0.0, - t_margin: float = 0.02, - t_tau: float = 0.01, - # Regularization (optional) - regularize_to: np.ndarray | None = None, - lambda_regularize: float = 0.0, - # Radii gauge - r_min: float = 0.0, - # Solver controls - solver: Literal['auto', 'analytic', 'admm'] = 'auto', - max_iter: int = 2000, - rho: float = 1.0, - tol_abs: float = 1e-6, - tol_rel: float = 1e-5, - # Optional adjacency check - check_contacts: bool = False, -) -> FitWeightsResult: - """Fit power weights from desired *absolute* plane positions. - - Each constraint specifies the desired plane intersection distance ``x`` from - site ``i`` toward an (optional periodic) image of site ``j``. - - This is converted to a fraction ``t = x / d`` where ``d`` is the distance - between ``p_i`` and the chosen image ``p_j*``. - """ - - pts = np.asarray(points, dtype=float) - if pts.ndim != 2 or pts.shape[1] != 3: - raise ValueError('points must have shape (n, 3)') - - i_idx, j_idx, x_target, shifts, shift_given, warnings = _parse_constraints( - constraints, - n_points=pts.shape[0], - ids=ids, - index_mode=index_mode, - ) - - pts2 = _maybe_remap_points(pts, domain) - - # Resolve periodic image shifts (for constraints that didn't specify them) - shifts_used, warnings2 = _resolve_constraint_shifts( - pts2, - i_idx, - j_idx, - shifts, - shift_given, - domain=domain, - image=image, - image_search=image_search, - ) - warnings = warnings + warnings2 - - # Compute distances d and convert to t targets. - pj_star = pts2[j_idx] + _shift_to_cart(shifts_used, domain) - delta = pj_star - pts2[i_idx] - d = np.linalg.norm(delta, axis=1) - if np.any(d == 0): - raise ValueError( - 'some constraints have zero distance (coincident points/image)' - ) - t_target = x_target / d - - return _fit_power_weights_core( - pts2, - i_idx, - j_idx, - t_target, - shifts_used, - domain=domain, - constraint_weights=constraint_weights, - t_bounds=t_bounds, - t_bounds_mode=t_bounds_mode, - alpha_out=alpha_out, - t_near_penalty=t_near_penalty, - beta_near=beta_near, - t_margin=t_margin, - t_tau=t_tau, - regularize_to=regularize_to, - lambda_regularize=lambda_regularize, - r_min=r_min, - solver=solver, - max_iter=max_iter, - rho=rho, - tol_abs=tol_abs, - tol_rel=tol_rel, - check_contacts=check_contacts, - warnings=warnings, - ) - - -def fit_power_weights_from_plane_fractions( - points: np.ndarray, - constraints: Sequence[ - tuple[int, int, float] | tuple[int, int, float, tuple[int, int, int]] - ], - *, - domain: Box | OrthorhombicCell | PeriodicCell | None = None, - ids: Sequence[int] | None = None, - index_mode: Literal['index', 'id'] = 'index', - image: Literal['nearest', 'given_only'] = 'nearest', - image_search: int = 1, - constraint_weights: Sequence[float] | None = None, - # Predicted t(w) restrictions/penalties - t_bounds: tuple[float, float] | None = (0.0, 1.0), - t_bounds_mode: Literal['none', 'soft_quadratic', 'hard'] = 'none', - alpha_out: float = 0.0, - t_near_penalty: Literal['none', 'exp'] = 'none', - beta_near: float = 0.0, - t_margin: float = 0.02, - t_tau: float = 0.01, - # Regularization (optional) - regularize_to: np.ndarray | None = None, - lambda_regularize: float = 0.0, - # Radii gauge - r_min: float = 0.0, - # Solver controls - solver: Literal['auto', 'analytic', 'admm'] = 'auto', - max_iter: int = 2000, - rho: float = 1.0, - tol_abs: float = 1e-6, - tol_rel: float = 1e-5, - # Optional adjacency check - check_contacts: bool = False, -) -> FitWeightsResult: - """Fit power weights from desired plane fractions t_ij. - - Each constraint specifies a desired separating plane position as a fraction - ``t`` along the line segment from site ``i`` toward an (optional periodic) - image of site ``j``. - - Notes: - - Values ``t < 0`` and ``t > 1`` are allowed. - - Additional options can *constrain* or *penalize* the predicted - plane position ``t(w)`` to lie within or away from the [0, 1] segment. - """ - - pts = np.asarray(points, dtype=float) - if pts.ndim != 2 or pts.shape[1] != 3: - raise ValueError('points must have shape (n, 3)') - - i_idx, j_idx, t_target, shifts, shift_given, warnings = _parse_constraints( - constraints, - n_points=pts.shape[0], - ids=ids, - index_mode=index_mode, - ) - - pts2 = _maybe_remap_points(pts, domain) - - shifts_used, warnings2 = _resolve_constraint_shifts( - pts2, - i_idx, - j_idx, - shifts, - shift_given, - domain=domain, - image=image, - image_search=image_search, - ) - warnings = warnings + warnings2 - - return _fit_power_weights_core( - pts2, - i_idx, - j_idx, - t_target, - shifts_used, - domain=domain, - constraint_weights=constraint_weights, - t_bounds=t_bounds, - t_bounds_mode=t_bounds_mode, - alpha_out=alpha_out, - t_near_penalty=t_near_penalty, - beta_near=beta_near, - t_margin=t_margin, - t_tau=t_tau, - regularize_to=regularize_to, - lambda_regularize=lambda_regularize, - r_min=r_min, - solver=solver, - max_iter=max_iter, - rho=rho, - tol_abs=tol_abs, - tol_rel=tol_rel, - check_contacts=check_contacts, - warnings=warnings, - ) - - -# ---------------------------- internal helpers ---------------------------- - - -def _parse_constraints( - constraints: Sequence[tuple], - *, - n_points: int, - ids: Sequence[int] | None, - index_mode: Literal['index', 'id'], -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, tuple[str, ...]]: - """Parse constraint tuples. - - Accepted forms: - (i, j, value) - (i, j, value, shift) - - where shift is a 3-tuple of ints. - """ - - if index_mode not in ('index', 'id'): - raise ValueError('index_mode must be \'index\' or \'id\'') - if index_mode == 'id': - if ids is None: - raise ValueError('ids must be provided when index_mode="id"') - id_to_index = {int(v): k for k, v in enumerate(ids)} - else: - id_to_index = None - - m = len(constraints) - if m == 0: - raise ValueError('constraints must be non-empty') - - i_idx = np.empty(m, dtype=np.int64) - j_idx = np.empty(m, dtype=np.int64) - val = np.empty(m, dtype=np.float64) - shifts = np.zeros((m, 3), dtype=np.int64) - shift_given = np.zeros(m, dtype=bool) - warnings: list[str] = [] - - for k, c in enumerate(constraints): - if not isinstance(c, tuple) and not isinstance(c, list): - raise ValueError(f'constraint {k} must be a tuple/list') - if len(c) not in (3, 4): - raise ValueError( - f'constraint {k} must have length 3 or 4: (i, j, value[, shift])' - ) - ii = int(c[0]) - jj = int(c[1]) - if id_to_index is not None: - if ii not in id_to_index or jj not in id_to_index: - raise ValueError(f'constraint {k} uses id not present in ids') - ii = id_to_index[ii] - jj = id_to_index[jj] - if not (0 <= ii < n_points and 0 <= jj < n_points): - raise ValueError(f'constraint {k} index out of range') - if ii == jj: - raise ValueError(f'constraint {k} has i == j (degenerate)') - i_idx[k] = ii - j_idx[k] = jj - val[k] = float(c[2]) - - if len(c) == 4: - sh = c[3] - if not (isinstance(sh, tuple) or isinstance(sh, list)) or len(sh) != 3: - raise ValueError(f'constraint {k} shift must be a length-3 tuple') - shifts[k] = (int(sh[0]), int(sh[1]), int(sh[2])) - shift_given[k] = True - - return i_idx, j_idx, val, shifts, shift_given, tuple(warnings) - - -def _maybe_remap_points( - points: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None -) -> np.ndarray: - """Optionally remap points into a primary periodic domain. - - This improves stability of the "nearest image" logic and makes results - deterministic with respect to lattice translations. - """ - - if domain is None: - return np.asarray(points, dtype=float) - if isinstance(domain, PeriodicCell): - return domain.remap_cart(points, return_shifts=False) - if isinstance(domain, OrthorhombicCell): - return domain.remap_cart(points, return_shifts=False) - return np.asarray(points, dtype=float) - - -def _resolve_constraint_shifts( - points: np.ndarray, - i_idx: np.ndarray, - j_idx: np.ndarray, - shifts: np.ndarray, - shift_given: np.ndarray, - *, - domain: Box | OrthorhombicCell | PeriodicCell | None, - image: Literal['nearest', 'given_only'], - image_search: int, -) -> tuple[np.ndarray, tuple[str, ...]]: - """Return per-constraint integer shifts to apply to site j.""" - - m = i_idx.shape[0] - warnings: list[str] = [] - - shifts = np.asarray(shifts, dtype=np.int64) - if shifts.shape != (m, 3): - raise ValueError('shifts must have shape (m,3)') - shift_given = np.asarray(shift_given, dtype=bool) - if shift_given.shape != (m,): - raise ValueError('shift_given must have shape (m,)') - - # If no domain, shifts must be zero. - if domain is None: - if np.any(shifts[shift_given] != 0): - raise ValueError('constraint shifts require a periodic domain') - return np.zeros((m, 3), dtype=np.int64), tuple(warnings) - - # Box is non-periodic. - if isinstance(domain, Box): - if np.any(shifts[shift_given] != 0): - raise ValueError('Box domain does not support periodic shifts') - return np.zeros((m, 3), dtype=np.int64), tuple(warnings) - - shifts2 = shifts.copy() - provided_mask = shift_given.copy() - - if image == 'given_only': - # Missing shifts are not allowed in given_only mode. - if np.any(~provided_mask): - raise ValueError('some constraints are missing shifts (image="given_only")') - _validate_shifts_against_domain(shifts2, domain) - return shifts2, tuple(warnings) - - if image != 'nearest': - raise ValueError('image must be "nearest" or "given_only"') - if image_search < 0: - raise ValueError('image_search must be >= 0') - - # Compute missing shifts by nearest-image search. - missing = ~provided_mask - if np.any(missing): - if isinstance(domain, OrthorhombicCell): - shifts2[missing] = _nearest_image_shifts_orthorhombic( - points[i_idx[missing]], - points[j_idx[missing]], - domain, - ) - elif isinstance(domain, PeriodicCell): - shifts2[missing] = _nearest_image_shifts_triclinic( - points[i_idx[missing]], - points[j_idx[missing]], - domain, - search=image_search, - ) - else: - raise ValueError('unsupported domain type') - warnings.append( - 'some constraints did not specify shifts; using nearest-image shifts' - ) - - _validate_shifts_against_domain(shifts2, domain) - return shifts2, tuple(warnings) - - -def _validate_shifts_against_domain( - shifts: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell -) -> None: - if isinstance(domain, OrthorhombicCell): - per = domain.periodic - for ax in range(3): - if not per[ax] and np.any(shifts[:, ax] != 0): - raise ValueError( - 'shifts on non-periodic axes must be 0 for OrthorhombicCell' - ) - - -def _nearest_image_shifts_orthorhombic( - pi: np.ndarray, pj: np.ndarray, cell: OrthorhombicCell -) -> np.ndarray: - """Nearest-image shifts for an orthorhombic cell.""" - (xmin, xmax), (ymin, ymax), (zmin, zmax) = cell.bounds - L = np.array([xmax - xmin, ymax - ymin, zmax - zmin], dtype=float) - per = np.array(cell.periodic, dtype=bool) - delta = pj - pi - s = np.zeros_like(delta, dtype=np.int64) - for ax in range(3): - if not per[ax]: - continue - # Choose shift to bring delta into [-L/2, L/2) - s[:, ax] = (-np.round(delta[:, ax] / L[ax])).astype(np.int64) - return s - - -def _nearest_image_shifts_triclinic( - pi: np.ndarray, pj: np.ndarray, cell: PeriodicCell, *, search: int = 1 -) -> np.ndarray: - """Nearest-image shifts via brute-force search in [-S,S]^3. - - This is robust for typical cell shapes and avoids subtle issues with - fractional rounding in highly skewed cells. - """ - - a, b, c = (np.asarray(v, dtype=float) for v in cell.vectors) - # Build candidate shifts - rng = np.arange(-search, search + 1, dtype=np.int64) - cand = ( - np.array(np.meshgrid(rng, rng, rng, indexing='ij')).reshape(3, -1).T - ) # (n_candidates, 3) - - # Compute deltas for each pair in batch: choose minimal norm. - # pi/pj are (m,3). - base = pj - pi # (m,3) - # precompute translations for candidates - trans = ( - cand[:, 0:1] * a[None, :] - + cand[:, 1:2] * b[None, :] - + cand[:, 2:3] * c[None, :] - ) - # Evaluate squared norms: for each pair (m) and each candidate. - # Use broadcasting: (m,1,3) + (1,n,3) -> (m,n,3) - diff = base[:, None, :] + trans[None, :, :] - d2 = np.einsum('mki,mki->mk', diff, diff) - best = np.argmin(d2, axis=1) - return cand[best].astype(np.int64) - - -def _shift_to_cart( - shifts: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None -) -> np.ndarray: - """Convert integer shifts to Cartesian translation vectors.""" - - sh = np.asarray(shifts, dtype=np.int64) - if sh.ndim != 2 or sh.shape[1] != 3: - raise ValueError('shifts must have shape (m,3)') - if domain is None: - return np.zeros((sh.shape[0], 3), dtype=np.float64) - if isinstance(domain, Box): - return np.zeros((sh.shape[0], 3), dtype=np.float64) - if isinstance(domain, OrthorhombicCell): - a, b, c = domain.lattice_vectors - return ( - sh[:, 0:1] * a[None, :] + sh[:, 1:2] * b[None, :] + sh[:, 2:3] * c[None, :] - ) - if isinstance(domain, PeriodicCell): - a, b, c = (np.asarray(v, dtype=float) for v in domain.vectors) - return ( - sh[:, 0:1] * a[None, :] + sh[:, 1:2] * b[None, :] + sh[:, 2:3] * c[None, :] - ) - raise ValueError('unsupported domain') - - -def _fit_power_weights_core( - points: np.ndarray, - i_idx: np.ndarray, - j_idx: np.ndarray, - t_target: np.ndarray, - shifts_used: np.ndarray, - *, - domain: Box | OrthorhombicCell | PeriodicCell | None, - constraint_weights: Sequence[float] | None, - t_bounds: tuple[float, float] | None, - t_bounds_mode: Literal['none', 'soft_quadratic', 'hard'], - alpha_out: float, - t_near_penalty: Literal['none', 'exp'], - beta_near: float, - t_margin: float, - t_tau: float, - regularize_to: np.ndarray | None, - lambda_regularize: float, - r_min: float, - solver: Literal['auto', 'analytic', 'admm'], - max_iter: int, - rho: float, - tol_abs: float, - tol_rel: float, - check_contacts: bool, - warnings: tuple[str, ...], -) -> FitWeightsResult: - """Shared implementation for the two public entry points.""" - - n = points.shape[0] - m = i_idx.shape[0] - - # Resolve constraint weights - if constraint_weights is None: - omega = np.ones(m, dtype=np.float64) - else: - omega = np.asarray(constraint_weights, dtype=float) - if omega.shape != (m,): - raise ValueError('constraint_weights must have shape (m,)') - if np.any(omega < 0): - raise ValueError('constraint_weights must be non-negative') - - # Distance squared for each constraint (using chosen periodic image). - pj_star = points[j_idx] + _shift_to_cart(shifts_used, domain) - delta = pj_star - points[i_idx] - d2 = np.einsum('mi,mi->m', delta, delta) - if np.any(d2 <= 0): - raise ValueError( - 'some constraints have zero distance (coincident points/image)' - ) - - # Convert target t to target weight differences b = d^2(2t-1) - b = d2 * (2.0 * t_target - 1.0) - # Quadratic coefficient for mismatch in *t* space: - # (t(z)-t_target)^2 = (z-b)^2 / (4 d^4) - a = omega / (4.0 * d2 * d2) - - # Bounds handling - if t_bounds is None: - t_lo, t_hi = (0.0, 1.0) - bounds_enabled = False - else: - t_lo = float(t_bounds[0]) - t_hi = float(t_bounds[1]) - if not t_hi > t_lo: - raise ValueError('t_bounds must satisfy hi > lo') - bounds_enabled = True - - t_bounds_mode = str(t_bounds_mode) - if t_bounds_mode not in ('none', 'soft_quadratic', 'hard'): - raise ValueError('t_bounds_mode must be one of: none, soft_quadratic, hard') - if not bounds_enabled and t_bounds_mode != 'none': - raise ValueError('t_bounds_mode requires t_bounds') - - alpha_out = float(alpha_out) - beta_near = float(beta_near) - lambda_regularize = float(lambda_regularize) - rho = float(rho) - if alpha_out < 0 or beta_near < 0 or lambda_regularize < 0: - raise ValueError('alpha_out/beta_near/lambda_regularize must be >= 0') - if rho <= 0: - raise ValueError('rho must be > 0') - if max_iter <= 0: - raise ValueError('max_iter must be > 0') - if tol_abs <= 0 or tol_rel <= 0: - raise ValueError('tol_abs and tol_rel must be > 0') - if t_margin < 0: - raise ValueError('t_margin must be >= 0') - if t_tau <= 0: - raise ValueError('t_tau must be > 0') - if t_near_penalty not in ('none', 'exp'): - raise ValueError('t_near_penalty must be "none" or "exp"') - if (t_near_penalty == 'exp' and beta_near > 0) and (not bounds_enabled): - raise ValueError('t_near_penalty requires t_bounds (for boundary definitions)') - - # Regularization target weights - if regularize_to is not None: - w0 = np.asarray(regularize_to, dtype=float) - if w0.shape != (n,): - raise ValueError('regularize_to must have shape (n,)') - else: - w0 = np.zeros(n, dtype=np.float64) - - # Determine whether we can use the analytic (quadratic) solver. - nonquadratic = False - if bounds_enabled and t_bounds_mode == 'hard': - # Hard bounds are explicit constraints. - nonquadratic = True - if bounds_enabled and t_bounds_mode == 'soft_quadratic' and alpha_out > 0: - # The hinge makes the objective piecewise. - nonquadratic = True - if bounds_enabled and t_near_penalty == 'exp' and beta_near > 0: - nonquadratic = True - - if solver == 'auto': - solver_eff = 'analytic' if (not nonquadratic) else 'admm' - else: - solver_eff = solver - if solver_eff not in ('analytic', 'admm'): - raise ValueError('solver must be auto, analytic, or admm') - if solver_eff == 'analytic' and nonquadratic: - raise ValueError( - 'analytic solver cannot be used with bounds/near-boundary penalties' - ) - - # Build connected components on the constraint graph. - comps = _connected_components(n, i_idx, j_idx) - weights = np.zeros(n, dtype=np.float64) - converged_all = True - n_iter_max = 0 - - # Solve each component independently (gauge freedom is per component). - for nodes in comps: - if len(nodes) <= 1: - # isolated node: keep at 0 (or regularization target if lambda > 0?) - if lambda_regularize > 0 and len(nodes) == 1: - weights[nodes[0]] = w0[nodes[0]] - continue - - node_set = set(nodes) - mask = np.array( - [ - (int(i) in node_set) and (int(j) in node_set) - for i, j in zip(i_idx, j_idx) - ], - dtype=bool, - ) - # Local mapping - local_index = {int(node): k for k, node in enumerate(nodes)} - ii = np.array([local_index[int(i)] for i in i_idx[mask]], dtype=np.int64) - jj = np.array([local_index[int(j)] for j in j_idx[mask]], dtype=np.int64) - d2_c = d2[mask] - b_c = b[mask] - a_c = a[mask] - - # Bounds in z-space for hard constraints - if bounds_enabled: - z_lo = d2_c * (2.0 * t_lo - 1.0) - z_hi = d2_c * (2.0 * t_hi - 1.0) - else: - z_lo = None - z_hi = None - - w0_c = w0[np.array(nodes, dtype=np.int64)] - - if solver_eff == 'analytic': - w_c = _solve_component_analytic(ii, jj, a_c, b_c, w0_c, lambda_regularize) - iters = 1 - conv = True - else: - w_c, iters, conv = _solve_component_admm( - ii, - jj, - d2_c, - a_c, - b_c, - w0_c, - lambda_regularize=lambda_regularize, - rho=rho, - max_iter=max_iter, - tol_abs=tol_abs, - tol_rel=tol_rel, - # penalties - bounds_enabled=bounds_enabled, - t_lo=t_lo, - t_hi=t_hi, - t_bounds_mode=t_bounds_mode, - alpha_out=alpha_out, - t_near_penalty=t_near_penalty, - beta_near=beta_near, - t_margin=t_margin, - t_tau=t_tau, - z_lo=z_lo, - z_hi=z_hi, - ) - - # Write back (anchor is internal; weights are gauge-fixed per component) - weights[np.array(nodes, dtype=np.int64)] = w_c - converged_all = converged_all and conv - n_iter_max = max(n_iter_max, iters) - - # Convert weights to radii with requested minimum. - radii, C = weights_to_radii(weights, r_min=r_min) - - # Predict t for all constraints - z_pred = weights[i_idx] - weights[j_idx] - t_pred = 0.5 + z_pred / (2.0 * d2) - residuals = t_pred - t_target - rms = float(np.sqrt(np.mean(residuals * residuals))) - mx = float(np.max(np.abs(residuals))) - - is_contact = None - inactive: tuple[int, ...] | None = None - warnings_list = list(warnings) - - if check_contacts: - if domain is None: - warnings_list.append( - 'check_contacts=True requires a domain; skipping contact check' - ) - else: - try: - is_contact, inactive = _check_contacts( - points, domain, radii, i_idx, j_idx, shifts_used - ) - if inactive and len(inactive) > 0: - warnings_list.append( - f'{len(inactive)}/{m} constraints did not correspond to a ' - 'tessellation face (inactive)' - ) - except Exception as e: # pragma: no cover - warnings_list.append(f'contact check failed: {e!r}') - - return FitWeightsResult( - weights=weights, - radii=radii, - weight_shift=C, - t_target=np.asarray(t_target, dtype=np.float64), - t_pred=np.asarray(t_pred, dtype=np.float64), - residuals=np.asarray(residuals, dtype=np.float64), - rms_residual=rms, - max_residual=mx, - used_shifts=np.asarray(shifts_used, dtype=np.int64), - is_contact=is_contact, - inactive_constraints=inactive, - solver=solver_eff, - n_iter=int(n_iter_max), - converged=bool(converged_all), - warnings=tuple(warnings_list), - ) - - -def _connected_components( - n: int, i_idx: np.ndarray, j_idx: np.ndarray -) -> list[list[int]]: - """Connected components of an undirected graph given by edge list.""" - adj: list[list[int]] = [[] for _ in range(n)] - for i, j in zip(i_idx.tolist(), j_idx.tolist()): - adj[i].append(j) - adj[j].append(i) - seen = np.zeros(n, dtype=bool) - comps: list[list[int]] = [] - for start in range(n): - if seen[start]: - continue - if len(adj[start]) == 0: - seen[start] = True - comps.append([start]) - continue - stack = [start] - seen[start] = True - comp: list[int] = [] - while stack: - v = stack.pop() - comp.append(v) - for nb in adj[v]: - if not seen[nb]: - seen[nb] = True - stack.append(nb) - comps.append(sorted(comp)) - return comps - - -def _solve_component_analytic( - I: np.ndarray, - J: np.ndarray, - a: np.ndarray, - b: np.ndarray, - w0: np.ndarray, - lambda_regularize: float, -) -> np.ndarray: - """Analytic weighted least squares for a connected component. - - Solves: - min_w sum_k a_k ( (w_i - w_j) - b_k )^2 + (lambda/2)||w-w0||^2 - - with gauge fixed by setting w[0] = 0. - """ - - n_c = int(np.max(np.maximum(I, J))) + 1 - if w0.shape != (n_c,): - w0 = np.asarray(w0, dtype=float).reshape(n_c) - lam = float(lambda_regularize) - # Build weighted Laplacian - L = np.zeros((n_c, n_c), dtype=np.float64) - rhs = np.zeros(n_c, dtype=np.float64) - for i, j, ak, bk in zip(I.tolist(), J.tolist(), a.tolist(), b.tolist()): - L[i, i] += ak - L[j, j] += ak - L[i, j] -= ak - L[j, i] -= ak - rhs[i] += ak * bk - rhs[j] -= ak * bk - if lam > 0: - L += lam * np.eye(n_c) - rhs += lam * w0 - - if n_c == 1: - return np.zeros(1, dtype=np.float64) - - if lam > 0: - # Regularization makes the system strictly convex, so we can solve - # without anchoring a node. - return np.linalg.solve(L, rhs).astype(np.float64) - - # Gauge: anchor node 0 to 0. - free = np.arange(1, n_c, dtype=np.int64) - Lf = L[np.ix_(free, free)] - rhsf = rhs[free] - wf = np.linalg.solve(Lf, rhsf) - w = np.zeros(n_c, dtype=np.float64) - w[free] = wf - w[0] = 0.0 - return w - - -def _solve_component_admm( - I: np.ndarray, - J: np.ndarray, - d2: np.ndarray, - a: np.ndarray, - b: np.ndarray, - w0: np.ndarray, - *, - lambda_regularize: float, - rho: float, - max_iter: int, - tol_abs: float, - tol_rel: float, - # penalties - bounds_enabled: bool, - t_lo: float, - t_hi: float, - t_bounds_mode: str, - alpha_out: float, - t_near_penalty: str, - beta_near: float, - t_margin: float, - t_tau: float, - z_lo: np.ndarray | None, - z_hi: np.ndarray | None, -) -> tuple[np.ndarray, int, bool]: - """ADMM solver for a connected component.""" - - n_c = int(np.max(np.maximum(I, J))) + 1 - m_c = I.shape[0] - lam = float(lambda_regularize) - - # Gauge handling: - # - if lam == 0, the objective is invariant to adding a constant to all - # weights, so we fix the gauge by anchoring node 0 to 0. - # - if lam > 0, the regularization makes the system strictly convex, so we - # do not anchor a node. - if lam > 0: - anchor: int | None = None - free = np.arange(n_c, dtype=np.int64) - else: - anchor = 0 - free = np.arange(1, n_c, dtype=np.int64) - - # Build (unweighted) Laplacian for augmented term. - L = np.zeros((n_c, n_c), dtype=np.float64) - for i, j in zip(I.tolist(), J.tolist()): - L[i, i] += 1.0 - L[j, j] += 1.0 - L[i, j] -= 1.0 - L[j, i] -= 1.0 - - M = rho * L + lam * np.eye(n_c) - Mf = M[np.ix_(free, free)] - # Pre-factorize - try: - chol = np.linalg.cholesky(Mf) - except np.linalg.LinAlgError: - # As a fallback, add a tiny diagonal and retry. - Mf2 = Mf + 1e-12 * np.eye(Mf.shape[0]) - chol = np.linalg.cholesky(Mf2) - Mf = Mf2 - - def solve_M(rhs_free: np.ndarray) -> np.ndarray: - y = np.linalg.solve(chol, rhs_free) - x = np.linalg.solve(chol.T, y) - return x - - w = np.zeros(n_c, dtype=np.float64) - # Initialize z to target differences b (clipped for hard bounds) - z = b.copy() - if ( - bounds_enabled - and t_bounds_mode == 'hard' - and z_lo is not None - and z_hi is not None - ): - z = np.clip(z, z_lo, z_hi) - u = np.zeros(m_c, dtype=np.float64) - - # Precompute some constants - dt_dz = 1.0 / (2.0 * d2) - - left_near = t_lo + t_margin - right_near = t_hi - t_margin - - converged = False - z_prev = z.copy() - - for it in range(1, max_iter + 1): - # w-update: solve (rho L + lam I) w = rho A^T(z - u) + lam w0 - y = z - u - rhs = np.zeros(n_c, dtype=np.float64) - # A^T y - # edge k: +y_k to I[k], -y_k to J[k] - np.add.at(rhs, I, rho * y) - np.add.at(rhs, J, -rho * y) - if lam > 0: - rhs += lam * w0 - - rhs_free = rhs[free] - w_free = solve_M(rhs_free) - if anchor is not None: - w[anchor] = 0.0 - w[free] = w_free - - # z-update: prox over edges - v = (w[I] - w[J]) + u - z_prev = z - z = _prox_edge_objective( - v, - d2, - a, - b, - rho=rho, - dt_dz=dt_dz, - # bounds/penalties - bounds_enabled=bounds_enabled, - t_lo=t_lo, - t_hi=t_hi, - t_bounds_mode=t_bounds_mode, - alpha_out=alpha_out, - t_near_penalty=t_near_penalty, - beta_near=beta_near, - left_near=left_near, - right_near=right_near, - t_tau=t_tau, - z_lo=z_lo, - z_hi=z_hi, - ) - - # u-update - Aw = w[I] - w[J] - r = Aw - z - u = u + r - - # Convergence check - r_norm = float(np.linalg.norm(r)) - z_norm = float(np.linalg.norm(z)) - Aw_norm = float(np.linalg.norm(Aw)) - eps_pri = np.sqrt(m_c) * tol_abs + tol_rel * max(Aw_norm, z_norm) - - # Dual residual: rho * A^T (z - z_prev) - dz = z - z_prev - s_vec = np.zeros(n_c, dtype=np.float64) - np.add.at(s_vec, I, rho * dz) - np.add.at(s_vec, J, -rho * dz) - s_norm = float(np.linalg.norm(s_vec[free])) - u_norm = float(np.linalg.norm(u)) - eps_dual = np.sqrt(len(free)) * tol_abs + tol_rel * rho * u_norm - - if r_norm <= eps_pri and s_norm <= eps_dual: - converged = True - break - - return w, it, converged - - -def _prox_edge_objective( - v: np.ndarray, - d2: np.ndarray, - a: np.ndarray, - b: np.ndarray, - *, - rho: float, - dt_dz: np.ndarray, - bounds_enabled: bool, - t_lo: float, - t_hi: float, - t_bounds_mode: str, - alpha_out: float, - t_near_penalty: str, - beta_near: float, - left_near: float, - right_near: float, - t_tau: float, - z_lo: np.ndarray | None, - z_hi: np.ndarray | None, -) -> np.ndarray: - """Vectorized proximal operator for per-edge objectives.""" - - z = v.copy() - if ( - bounds_enabled - and t_bounds_mode == 'hard' - and z_lo is not None - and z_hi is not None - ): - z = np.clip(z, z_lo, z_hi) - - # Newton iterations (vectorized) - for _ in range(50): - t = 0.5 + z / (2.0 * d2) - - # f'(z): mismatch term - fp = 2.0 * a * (z - b) - fpp = 2.0 * a - - # Soft out-of-range quadratic penalty - if bounds_enabled and t_bounds_mode == 'soft_quadratic' and alpha_out > 0: - # Below lower bound - m_lo = t < t_lo - if np.any(m_lo): - fp[m_lo] += (2.0 * alpha_out * (t[m_lo] - t_lo)) * dt_dz[m_lo] - fpp[m_lo] += (2.0 * alpha_out) * (dt_dz[m_lo] ** 2) - # Above upper bound - m_hi = t > t_hi - if np.any(m_hi): - fp[m_hi] += (2.0 * alpha_out * (t[m_hi] - t_hi)) * dt_dz[m_hi] - fpp[m_hi] += (2.0 * alpha_out) * (dt_dz[m_hi] ** 2) - - # Near-boundary exponential penalty - if bounds_enabled and t_near_penalty == 'exp' and beta_near > 0: - # exp((left - t)/tau) + exp((t - right)/tau) - A = np.exp((left_near - t) / t_tau) - B = np.exp((t - right_near) / t_tau) - fp += (beta_near * (-A + B) / t_tau) * dt_dz - fpp += (beta_near * (A + B) / (t_tau * t_tau)) * (dt_dz**2) - - # Full derivative of objective: f'(z) + rho(z - v) - g = fp + rho * (z - v) - gp = fpp + rho - step = g / gp - - z_new = z - step - if ( - bounds_enabled - and t_bounds_mode == 'hard' - and z_lo is not None - and z_hi is not None - ): - z_new = np.clip(z_new, z_lo, z_hi) - - # Stop criterion - if float(np.max(np.abs(step))) < 1e-12: - z = z_new - break - z = z_new - - return z - - -def _check_contacts( - points: np.ndarray, - domain: Box | OrthorhombicCell | PeriodicCell, - radii: np.ndarray, - i_idx: np.ndarray, - j_idx: np.ndarray, - shifts: np.ndarray, -) -> tuple[np.ndarray, tuple[int, ...]]: - """Check which constraints correspond to actual faces in the tessellation.""" - from .api import compute - - periodic = isinstance(domain, PeriodicCell) or ( - isinstance(domain, OrthorhombicCell) and any(domain.periodic) - ) - cells = compute( - points, - domain=domain, - mode='power', - radii=radii, - return_vertices=True, - return_faces=True, - return_adjacency=False, - return_face_shifts=bool(periodic), - include_empty=True, - ) - # Build neighbor set - neigh: set[tuple[int, int, int, int, int]] = set() - for cell in cells: - ci = int(cell['id']) - for face in cell.get('faces', []): - cj = int(face['adjacent_cell']) - if cj < 0: - continue - sh = face.get('adjacent_shift', (0, 0, 0)) - neigh.add((ci, cj, int(sh[0]), int(sh[1]), int(sh[2]))) - - m = i_idx.shape[0] - is_contact = np.zeros(m, dtype=bool) - inactive: list[int] = [] - for k in range(m): - key = ( - int(i_idx[k]), - int(j_idx[k]), - int(shifts[k, 0]), - int(shifts[k, 1]), - int(shifts[k, 2]), - ) - rev = ( - int(j_idx[k]), - int(i_idx[k]), - int(-shifts[k, 0]), - int(-shifts[k, 1]), - int(-shifts[k, 2]), - ) - ok = (key in neigh) or (rev in neigh) - is_contact[k] = ok - if not ok: - inactive.append(k) - return is_contact, tuple(inactive) diff --git a/src/pyvoro2/planar/__init__.py b/src/pyvoro2/planar/__init__.py new file mode 100644 index 0000000..f5ec850 --- /dev/null +++ b/src/pyvoro2/planar/__init__.py @@ -0,0 +1,59 @@ +"""Planar 2D namespace for pyvoro2.""" + +from __future__ import annotations + +from ..duplicates import DuplicateError, DuplicatePair +from ..edge_properties import annotate_edge_properties +from ..viz2d import plot_tessellation +from .api import compute, ghost_cells, locate +from .diagnostics import ( + TessellationDiagnostics, + TessellationError, + TessellationIssue, + analyze_tessellation, + validate_tessellation, +) +from .domains import Box, RectangularCell +from .result import PlanarComputeResult +from .duplicates import duplicate_check +from .normalize import ( + NormalizedTopology, + NormalizedVertices, + normalize_edges, + normalize_topology, + normalize_vertices, +) +from .validation import ( + NormalizationDiagnostics, + NormalizationError, + NormalizationIssue, + validate_normalized_topology, +) + +__all__ = [ + 'Box', + 'RectangularCell', + 'PlanarComputeResult', + 'compute', + 'locate', + 'ghost_cells', + 'DuplicatePair', + 'DuplicateError', + 'duplicate_check', + 'annotate_edge_properties', + 'plot_tessellation', + 'TessellationIssue', + 'TessellationDiagnostics', + 'TessellationError', + 'analyze_tessellation', + 'validate_tessellation', + 'NormalizedVertices', + 'NormalizedTopology', + 'normalize_vertices', + 'normalize_edges', + 'normalize_topology', + 'NormalizationIssue', + 'NormalizationDiagnostics', + 'NormalizationError', + 'validate_normalized_topology', +] diff --git a/src/pyvoro2/planar/_domain_geometry.py b/src/pyvoro2/planar/_domain_geometry.py new file mode 100644 index 0000000..7778e75 --- /dev/null +++ b/src/pyvoro2/planar/_domain_geometry.py @@ -0,0 +1,157 @@ +"""Internal geometry adapter for planar domains.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + +import numpy as np + +from .domains import Box, RectangularCell + +Domain2D = Box | RectangularCell + + +@dataclass(frozen=True, slots=True) +class DomainGeometry2D: + """Minimal internal adapter for 2D domains.""" + + domain: Domain2D | None + + @property + def dim(self) -> int: + return 2 + + @property + def kind(self) -> str: + if self.domain is None: + return 'none' + if isinstance(self.domain, Box): + return 'box' + return 'rectangular' + + @property + def periodic_axes(self) -> tuple[bool, bool]: + if self.domain is None or isinstance(self.domain, Box): + return (False, False) + return tuple(bool(v) for v in self.domain.periodic) + + @property + def has_any_periodic_axis(self) -> bool: + return any(self.periodic_axes) + + @property + def bounds(self) -> tuple[tuple[float, float], tuple[float, float]]: + if self.domain is None: + raise ValueError('a domain is required to determine planar bounds') + return self.domain.bounds + + @property + def lattice_vectors_cart(self) -> tuple[np.ndarray, np.ndarray]: + """Return planar lattice/edge vectors in Cartesian coordinates.""" + + if self.domain is None: + raise ValueError('a domain is required to determine lattice vectors') + if isinstance(self.domain, RectangularCell): + return self.domain.lattice_vectors + + (xmin, xmax), (ymin, ymax) = self.domain.bounds + a = np.array([xmax - xmin, 0.0], dtype=np.float64) + b = np.array([0.0, ymax - ymin], dtype=np.float64) + return a, b + + def remap_cart(self, points: np.ndarray) -> np.ndarray: + pts = np.asarray(points, dtype=float) + if self.domain is None or isinstance(self.domain, Box): + return pts + return self.domain.remap_cart(pts, return_shifts=False) + + def shift_to_cart(self, shifts: np.ndarray) -> np.ndarray: + sh = np.asarray(shifts, dtype=np.int64) + if sh.ndim != 2 or sh.shape[1] != 2: + raise ValueError('shifts must have shape (m, 2)') + if self.domain is None or isinstance(self.domain, Box): + return np.zeros((sh.shape[0], 2), dtype=np.float64) + a, b = self.lattice_vectors_cart + return sh[:, 0:1] * a[None, :] + sh[:, 1:2] * b[None, :] + + def shift_vector(self, shift: Sequence[int] | np.ndarray) -> np.ndarray: + sh = np.asarray(shift, dtype=np.int64) + if sh.shape != (2,): + raise ValueError('shift must have shape (2,)') + return self.shift_to_cart(sh.reshape(1, 2)).reshape(2) + + def validate_shifts(self, shifts: np.ndarray) -> None: + sh = np.asarray(shifts, dtype=np.int64) + if sh.ndim != 2 or sh.shape[1] != 2: + raise ValueError('shifts must have shape (m, 2)') + + if self.domain is None or isinstance(self.domain, Box): + if np.any(sh != 0): + raise ValueError('constraint shifts require a periodic domain') + return + + periodic = self.periodic_axes + for ax in range(2): + if not periodic[ax] and np.any(sh[:, ax] != 0): + raise ValueError( + 'shifts on non-periodic axes must be 0 for RectangularCell' + ) + + def nearest_image_shifts( + self, + pi: np.ndarray, + pj: np.ndarray, + ) -> np.ndarray: + if not isinstance(self.domain, RectangularCell): + raise ValueError('nearest-image shifts require a periodic planar domain') + (xmin, xmax), (ymin, ymax) = self.domain.bounds + lengths = np.array([xmax - xmin, ymax - ymin], dtype=float) + periodic = np.array(self.domain.periodic, dtype=bool) + delta = np.asarray(pj, dtype=float) - np.asarray(pi, dtype=float) + shifts = np.zeros_like(delta, dtype=np.int64) + for ax in range(2): + if not periodic[ax]: + continue + shifts[:, ax] = (-np.round(delta[:, ax] / lengths[ax])).astype(np.int64) + return shifts + + def resolve_block_counts( + self, + *, + n_sites: int, + blocks: tuple[int, int] | None, + block_size: float | None, + ) -> tuple[int, int]: + if blocks is not None: + if len(blocks) != 2: + raise ValueError('blocks must have length 2') + nx, ny = (int(v) for v in blocks) + if nx <= 0 or ny <= 0: + raise ValueError('blocks must contain positive integers') + return nx, ny + + lengths, area = self._lengths_and_area() + if block_size is None: + spacing = (area / max(int(n_sites), 1)) ** 0.5 + block_size_eff = max(1e-6, 2.5 * spacing) + else: + block_size_eff = float(block_size) + if not np.isfinite(block_size_eff) or block_size_eff <= 0.0: + raise ValueError('block_size must be a positive finite scalar') + + return tuple(max(1, int(length / block_size_eff)) for length in lengths) + + def _lengths_and_area(self) -> tuple[tuple[float, float], float]: + if self.domain is None: + raise ValueError('a domain is required to derive block counts') + (xmin, xmax), (ymin, ymax) = self.domain.bounds + lx = float(xmax - xmin) + ly = float(ymax - ymin) + return (lx, ly), float(lx * ly) + + +def geometry2d(domain: Domain2D | None) -> DomainGeometry2D: + """Return the internal geometry adapter for a planar domain.""" + + return DomainGeometry2D(domain) diff --git a/src/pyvoro2/planar/_edge_shifts2d.py b/src/pyvoro2/planar/_edge_shifts2d.py new file mode 100644 index 0000000..3f8759b --- /dev/null +++ b/src/pyvoro2/planar/_edge_shifts2d.py @@ -0,0 +1,415 @@ +"""2D periodic edge-shift reconstruction helpers.""" + +from __future__ import annotations + +from collections import Counter +from typing import Any, Literal + +import numpy as np + + +def _add_periodic_edge_shifts_inplace( + cells: list[dict[str, Any]], + *, + lattice_vectors: tuple[np.ndarray, np.ndarray], + periodic_mask: tuple[bool, bool] = (True, True), + mode: Literal['standard', 'power'] = 'standard', + radii: np.ndarray | None = None, + site_positions: np.ndarray | None = None, + ghost_radii: np.ndarray | None = None, + search: int = 2, + tol: float | None = None, + validate: bool = True, + repair: bool = False, +) -> None: + """Annotate periodic edges with integer neighbor-image shifts. + + The shift for an edge is the integer lattice vector ``(na, nb)`` such that + the adjacent cell on that edge corresponds to the neighbor site translated + by ``na * a + nb * b``, where ``(a, b)`` are the domain lattice vectors in + the same coordinate system as the returned vertices. + + In the legacy 2D backend, some periodic edges can also arrive with a + negative ``adjacent_cell`` even though they are not true domain walls. + This helper tries to resolve those hidden periodic adjacencies directly from + the edge geometry before running the reciprocity check. + """ + + if search < 0: + raise ValueError('search must be >= 0') + _ = repair # accepted for API symmetry with the 3D helper + + a = np.asarray(lattice_vectors[0], dtype=np.float64).reshape(2) + b = np.asarray(lattice_vectors[1], dtype=np.float64).reshape(2) + px, py = bool(periodic_mask[0]), bool(periodic_mask[1]) + if not (px or py): + raise ValueError('periodic_mask has no periodic axes (all False)') + + basis = np.stack([a, b], axis=1) + try: + basis_inv = np.linalg.inv(basis) + except np.linalg.LinAlgError as exc: + raise ValueError('cell lattice vectors are singular') from exc + + lcand: list[float] = [] + if px: + lcand.append(float(np.linalg.norm(a))) + if py: + lcand.append(float(np.linalg.norm(b))) + length_scale = float(max(lcand)) if lcand else 0.0 + + tol_line = (1e-6 * length_scale) if tol is None else float(tol) + if tol_line < 0.0: + raise ValueError('tol must be >= 0') + + sites: dict[int, np.ndarray] = {} + if site_positions is not None: + arr = np.asarray(site_positions, dtype=np.float64) + if arr.ndim != 2 or arr.shape[1] != 2: + raise ValueError('site_positions must have shape (n, 2)') + for pid, site in enumerate(arr): + sites[int(pid)] = site.reshape(2) + + for cell in cells: + pid = int(cell.get('id', -1)) + if pid < 0: + continue + site = np.asarray(cell.get('site', []), dtype=np.float64) + if site.size == 2: + sites[pid] = site.reshape(2) + + if not sites: + return + + max_pid = max(sites) + 1 + site_arr = np.zeros((max_pid, 2), dtype=np.float64) + site_mask = np.zeros(max_pid, dtype=bool) + for pid, site in sites.items(): + site_arr[pid] = site + site_mask[pid] = True + + rx = range(-search, search + 1) if px else range(0, 1) + ry = range(-search, search + 1) if py else range(0, 1) + shifts: list[tuple[int, int]] = [] + trans: list[np.ndarray] = [] + for sx in rx: + for sy in ry: + shifts.append((int(sx), int(sy))) + trans.append(sx * a + sy * b) + + trans_arr = np.stack(trans, axis=0) if trans else np.zeros((0, 2), dtype=float) + shift_to_idx = {shift: i for i, shift in enumerate(shifts)} + l1 = np.asarray([abs(sx) + abs(sy) for sx, sy in shifts], dtype=np.int64) + idx_zero = shift_to_idx.get((0, 0)) + + if mode == 'power': + if radii is None: + raise ValueError('radii is required for mode="power"') + weights = np.asarray(radii, dtype=np.float64) ** 2 + ghost_weights = ( + None + if ghost_radii is None + else np.asarray(ghost_radii, dtype=np.float64) ** 2 + ) + else: + weights = None + ghost_weights = None + + def _weight_for_cell(cell: dict[str, Any], pid: int) -> float: + if mode != 'power': + raise ValueError('cell weights are only defined in power mode') + assert weights is not None + if pid >= 0: + return float(weights[pid]) + + if ghost_weights is None: + raise ValueError( + 'ghost_radii is required to reconstruct edge shifts for ' + 'power-mode ghost cells' + ) + qidx = int(cell.get('query_index', -1)) + if qidx < 0 or qidx >= int(ghost_weights.shape[0]): + raise ValueError( + 'power-mode ghost cell is missing a valid query_index for ' + 'ghost-radius lookup' + ) + return float(ghost_weights[qidx]) + + def _residual_for_images( + *, + nid_arr: np.ndarray, + p_i: np.ndarray, + w_i: float | None, + p_img: np.ndarray, + verts: np.ndarray, + ) -> np.ndarray: + d = p_img - p_i.reshape(1, 2) + dn = np.linalg.norm(d, axis=1) + dn = np.where(dn == 0.0, 1.0, dn) + + proj = np.einsum('mk,nk->mn', d, verts) + if mode == 'standard': + rhs = 0.5 * (np.sum(p_img * p_img, axis=1) - np.dot(p_i, p_i)) + elif mode == 'power': + assert weights is not None + assert w_i is not None + wj = weights[nid_arr] + rhs = 0.5 * ( + (np.sum(p_img * p_img, axis=1) - wj) + - (np.dot(p_i, p_i) - w_i) + ) + else: # pragma: no cover + raise ValueError(f'unknown mode: {mode}') + + dist = np.abs(proj - rhs[:, None]) / dn[:, None] + return np.max(dist, axis=1) + + def _best_shift_for_neighbor( + *, + nid: int, + p_i: np.ndarray, + w_i: float | None, + p_j: np.ndarray, + verts: np.ndarray, + ) -> tuple[int, float]: + self_neighbor = nid == pid + if self_neighbor and search == 0: + raise ValueError( + 'search=0 cannot resolve edges against periodic images of the same ' + 'site; increase search' + ) + + frac = basis_inv @ (p_j - p_i) + base = (-np.rint(frac)).astype(np.int64) + if not px: + base[0] = 0 + if not py: + base[1] = 0 + + dx_rng = (-1, 0, 1) if px else (0,) + dy_rng = (-1, 0, 1) if py else (0,) + seed_idx: list[int] = [] + for dx in dx_rng: + for dy in dy_rng: + shift = (int(base[0] + dx), int(base[1] + dy)) + if max(abs(shift[0]), abs(shift[1])) > search: + continue + ii = shift_to_idx.get(shift) + if ii is not None: + seed_idx.append(ii) + + if self_neighbor and idx_zero is not None: + seed_idx = [ii for ii in seed_idx if ii != idx_zero] + if not seed_idx: + if self_neighbor: + raise ValueError( + 'unable to seed edge shift candidates for self-neighbor edge; ' + 'increase search' + ) + if idx_zero is None: + raise ValueError('internal error: missing (0, 0) shift candidate') + seed_idx = [idx_zero] + + seen: set[int] = set() + seed_idx = [ii for ii in seed_idx if not (ii in seen or seen.add(ii))] + + p_img_seed = p_j.reshape(1, 2) + trans_arr[seed_idx] + resid_seed = _residual_for_images( + nid_arr=np.full(len(seed_idx), int(nid), dtype=np.int64), + p_i=p_i, + w_i=w_i, + p_img=p_img_seed, + verts=verts, + ) + best_local = int(np.argmin(resid_seed)) + best_idx = int(seed_idx[best_local]) + best_resid = float(resid_seed[best_local]) + + if best_resid > tol_line and len(shifts) > len(seed_idx): + p_img_full = p_j.reshape(1, 2) + trans_arr + resid_full = _residual_for_images( + nid_arr=np.full(len(shifts), int(nid), dtype=np.int64), + p_i=p_i, + w_i=w_i, + p_img=p_img_full, + verts=verts, + ) + if ( + self_neighbor + and idx_zero is not None + and idx_zero < resid_full.shape[0] + ): + resid_full[idx_zero] = np.inf + best_idx = int(np.argmin(resid_full)) + best_resid = float(resid_full[best_idx]) + resid_for_tie = resid_full + cand_idx = list(range(len(shifts))) + else: + resid_for_tie = resid_seed + cand_idx = seed_idx + + if best_resid > tol_line: + raise ValueError( + 'unable to determine adjacent_shift within tolerance; ' + f'pid={pid}, nid={nid}, best_resid={best_resid:g}, ' + f'tol={tol_line:g}. Consider increasing search.' + ) + + scale = max( + float(np.linalg.norm(p_i)), + float(np.linalg.norm(p_j)), + length_scale, + 1e-30, + ) + eps_tie = max(1e-12 * scale, 64.0 * np.finfo(float).eps * scale) + near = [ + cand_idx[k] + for k, rr in enumerate(resid_for_tie) + if float(rr) <= best_resid + eps_tie + ] + if len(near) > 1: + near.sort(key=lambda ii: (int(l1[ii]), shifts[ii])) + best_idx = int(near[0]) + + return best_idx, best_resid + + def _best_unknown_neighbor( + *, + p_i: np.ndarray, + pid: int, + w_i: float | None, + verts: np.ndarray, + ) -> tuple[int, int, float] | None: + cand_nids: list[int] = [] + cand_shift_idx: list[int] = [] + for nid in range(max_pid): + if not site_mask[nid]: + continue + for sidx, shift in enumerate(shifts): + if nid == pid and shift == (0, 0): + continue + cand_nids.append(int(nid)) + cand_shift_idx.append(int(sidx)) + + if not cand_nids: + return None + + nid_arr = np.asarray(cand_nids, dtype=np.int64) + shift_idx_arr = np.asarray(cand_shift_idx, dtype=np.int64) + p_img = site_arr[nid_arr] + trans_arr[shift_idx_arr] + resid = _residual_for_images( + nid_arr=nid_arr, + p_i=p_i, + w_i=w_i, + p_img=p_img, + verts=verts, + ) + best = int(np.argmin(resid)) + best_resid = float(resid[best]) + if best_resid > tol_line: + return None + + scale = max(float(np.linalg.norm(p_i)), length_scale, 1e-30) + eps_tie = max(1e-12 * scale, 64.0 * np.finfo(float).eps * scale) + near = [ + k + for k, rr in enumerate(resid) + if float(rr) <= best_resid + eps_tie + ] + if len(near) > 1: + near.sort( + key=lambda k: ( + int(l1[shift_idx_arr[k]]), + int(nid_arr[k]), + shifts[int(shift_idx_arr[k])], + ) + ) + best = int(near[0]) + + return int(nid_arr[best]), int(shift_idx_arr[best]), best_resid + + residuals_by_edge: dict[tuple[int, int], float] = {} + + for cell in cells: + pid = int(cell.get('id', -1)) + site = np.asarray(cell.get('site', []), dtype=np.float64) + if site.size == 2: + p_i = site.reshape(2) + else: + p_i = sites.get(pid) + if p_i is None: + continue + w_i = _weight_for_cell(cell, pid) if mode == 'power' else None + + vertices = np.asarray(cell.get('vertices', []), dtype=np.float64) + if vertices.size == 0: + vertices = vertices.reshape((0, 2)) + if vertices.ndim != 2 or vertices.shape[1] != 2: + raise ValueError( + 'return_edge_shifts requires vertex coordinates for each cell' + ) + + edges = cell.get('edges') or [] + for ei, edge in enumerate(edges): + idx = np.asarray(edge.get('vertices', []), dtype=np.int64) + if idx.shape != (2,): + edge['adjacent_shift'] = (0, 0) + residuals_by_edge[(pid, ei)] = 0.0 + continue + verts = vertices[idx] + + nid = int(edge.get('adjacent_cell', -999999)) + if nid < 0: + resolved = _best_unknown_neighbor( + p_i=p_i, + pid=pid, + w_i=w_i, + verts=verts, + ) + if resolved is None: + edge['adjacent_shift'] = (0, 0) + residuals_by_edge[(pid, ei)] = 0.0 + continue + nid, best_idx, best_resid = resolved + edge['adjacent_cell'] = int(nid) + edge['adjacent_shift'] = shifts[best_idx] + residuals_by_edge[(pid, ei)] = best_resid + continue + + p_j = sites.get(nid) + if p_j is None: + raise ValueError(f'missing site for adjacent_cell={nid}') + + best_idx, best_resid = _best_shift_for_neighbor( + nid=nid, + p_i=p_i, + w_i=w_i, + p_j=p_j, + verts=verts, + ) + edge['adjacent_shift'] = shifts[best_idx] + residuals_by_edge[(pid, ei)] = best_resid + + if not validate: + return + + directed_counts: dict[tuple[int, int], Counter[tuple[int, int]]] = {} + for cell in cells: + pid = int(cell.get('id', -1)) + if pid < 0: + continue + for edge in cell.get('edges') or []: + nid = int(edge.get('adjacent_cell', -999999)) + if nid < 0: + continue + shift = tuple(int(v) for v in edge.get('adjacent_shift', (0, 0))) + directed_counts.setdefault((pid, nid), Counter())[shift] += 1 + + for (pid, nid), counts in directed_counts.items(): + rev = directed_counts.get((nid, pid), Counter()) + expected = Counter({(-sx, -sy): c for (sx, sy), c in counts.items()}) + if rev != expected: + raise ValueError( + 'edge-shift reciprocity validation failed for ' + f'({pid}, {nid}); expected {expected}, got {rev}' + ) diff --git a/src/pyvoro2/planar/api.py b/src/pyvoro2/planar/api.py new file mode 100644 index 0000000..7e40b12 --- /dev/null +++ b/src/pyvoro2/planar/api.py @@ -0,0 +1,624 @@ +"""High-level 2D API for planar Voronoi and power tessellations.""" + +from __future__ import annotations + +from typing import Any, Literal, Sequence + +import warnings + +import numpy as np + +from .._cell_output import add_empty_cells_inplace, remap_ids_inplace +from .._inputs import ( + coerce_id_array, + coerce_nonnegative_scalar_or_vector, + coerce_nonnegative_vector, + coerce_point_array, + validate_duplicate_check_mode, +) +from ._domain_geometry import geometry2d +from ._edge_shifts2d import _add_periodic_edge_shifts_inplace +from .diagnostics import ( + TessellationDiagnostics, + TessellationError, + analyze_tessellation, +) +from .domains import Box, RectangularCell +from .duplicates import duplicate_check as _duplicate_check +from .normalize import normalize_edges, normalize_vertices +from .result import PlanarComputeResult + +try: + from .. import _core2d # type: ignore[attr-defined] + + _CORE2D_IMPORT_ERROR: BaseException | None = None +except Exception as _e: # pragma: no cover + _core2d = None # type: ignore[assignment] + _CORE2D_IMPORT_ERROR = _e + + +Domain2D = Box | RectangularCell + + +def _strip_internal_geometry_inplace( + cells: list[dict[str, Any]], + *, + keep_vertices: bool, + keep_adjacency: bool, + keep_edges: bool, + keep_edge_shifts: bool, +) -> None: + """Drop internal geometry fields that were requested only for analysis. + + Periodic diagnostics may require temporary vertices/edges/edge shifts even + when the caller only wants a lightweight high-level answer. This helper + removes those internal extras before the final result is returned. + """ + + for cell in cells: + if not keep_vertices: + cell.pop('vertices', None) + if not keep_adjacency: + cell.pop('adjacency', None) + if not keep_edges: + cell.pop('edges', None) + continue + if not keep_edge_shifts: + for edge in cell.get('edges') or []: + edge.pop('adjacent_shift', None) + + +def _require_core2d(): + """Return the compiled 2D extension module or raise a helpful ImportError.""" + + if _core2d is None: # pragma: no cover + raise ImportError( + "pyvoro2 C++ extension module '_core2d' is not available. " + 'Install a prebuilt wheel with planar support or build from ' + 'source to use pyvoro2.planar.compute/locate/ghost_cells.' + ) from _CORE2D_IMPORT_ERROR + return _core2d + + +def _warn_if_scale_suspicious(*, pts: np.ndarray, domain: Domain2D) -> None: + """Warn if the planar coordinate scale is likely to be problematic.""" + + if pts.size == 0: + return + + geom = geometry2d(domain) + (lx, ly), _area = geom._lengths_and_area() + length_scale = max(float(lx), float(ly), 0.0) + if not np.isfinite(length_scale) or length_scale <= 0.0: + return + + if length_scale < 1e-3: + warnings.warn( + 'The planar domain length scale appears very small ' + f'(L≈{length_scale:.3g}). Voro++ uses fixed absolute tolerances ' + '(~1e-5) and may terminate the process if points are too close in ' + 'these units. Consider rescaling your coordinates before calling ' + 'pyvoro2.planar.', + RuntimeWarning, + stacklevel=3, + ) + elif length_scale > 1e9: + warnings.warn( + 'The planar domain length scale appears very large ' + f'(L≈{length_scale:.3g}). Floating-point precision may be poor at ' + 'this scale; consider rescaling your coordinates.', + RuntimeWarning, + stacklevel=3, + ) + + +def compute( + points: Sequence[Sequence[float]] | np.ndarray, + *, + domain: Domain2D, + ids: Sequence[int] | None = None, + duplicate_check: Literal['off', 'warn', 'raise'] = 'off', + duplicate_threshold: float = 1e-5, + duplicate_wrap: bool = True, + duplicate_max_pairs: int = 10, + block_size: float | None = None, + blocks: tuple[int, int] | None = None, + init_mem: int = 8, + mode: Literal['standard', 'power'] = 'standard', + radii: Sequence[float] | np.ndarray | None = None, + return_vertices: bool = True, + return_adjacency: bool = True, + return_edges: bool = True, + return_edge_shifts: bool = False, + edge_shift_search: int = 2, + include_empty: bool = False, + validate_edge_shifts: bool = True, + repair_edge_shifts: bool = False, + edge_shift_tol: float | None = None, + return_diagnostics: bool = False, + return_result: bool = False, + normalize: Literal['none', 'vertices', 'topology'] = 'none', + normalization_tol: float | None = None, + tessellation_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'none', + tessellation_require_reciprocity: bool | None = None, + tessellation_area_tol_rel: float = 1e-8, + tessellation_area_tol_abs: float = 1e-12, + tessellation_line_offset_tol: float | None = None, + tessellation_line_angle_tol: float | None = None, +) -> ( + list[dict[str, Any]] + | tuple[list[dict[str, Any]], TessellationDiagnostics] + | PlanarComputeResult +): + """Compute planar Voronoi or power tessellation cells. + + Supported domains: + - :class:`~pyvoro2.planar.domains.Box` + - :class:`~pyvoro2.planar.domains.RectangularCell` + + Planar compute mirrors the 3D wrapper's diagnostics convenience path: + set ``return_diagnostics=True`` to also return a + :class:`~pyvoro2.planar.TessellationDiagnostics` object, and/or set + ``tessellation_check='warn'`` or ``'raise'`` to have common area and + reciprocity issues handled directly by the wrapper. + + Wrapper-level normalization convenience is also available via + ``normalize='vertices'`` or ``'topology'``. Any request for normalized + output returns a :class:`~pyvoro2.planar.PlanarComputeResult`, as does + ``return_result=True``. The normalized structures intentionally carry their + own augmented cell copies, so the raw ``cells`` field can stay lightweight + even when internal geometry was needed for diagnostics or normalization. + + For periodic domains, diagnostics and normalization automatically compute + temporary edge shifts and the required edge/vertex geometry internally, + even when those fields were not requested by the caller. Any such + temporary fields are stripped from the raw returned cells unless they were + explicitly requested. + """ + + pts = coerce_point_array(points, name='points', dim=2) + _warn_if_scale_suspicious(pts=pts, domain=domain) + n = int(pts.shape[0]) + + if int(edge_shift_search) < 0: + raise ValueError('edge_shift_search must be >= 0') + if tessellation_check not in ('none', 'diagnose', 'warn', 'raise'): + raise ValueError( + 'tessellation_check must be one of: none, diagnose, warn, raise' + ) + if normalize not in ('none', 'vertices', 'topology'): + raise ValueError('normalize must be one of: none, vertices, topology') + + user_return_vertices = bool(return_vertices) + user_return_adjacency = bool(return_adjacency) + user_return_edges = bool(return_edges) + user_return_edge_shifts = bool(return_edge_shifts) + + geom = geometry2d(domain) + periodic = bool(geom.has_any_periodic_axis) + need_diag = bool(return_diagnostics) or tessellation_check != 'none' + need_norm_vertices = normalize in ('vertices', 'topology') + need_norm_topology = normalize == 'topology' + + need_periodic_diag_geometry = bool(need_diag and periodic) + need_periodic_norm_geometry = bool(need_norm_vertices and periodic) + + internal_return_vertices = ( + user_return_vertices or need_periodic_diag_geometry or need_norm_vertices + ) + internal_return_adjacency = user_return_adjacency + internal_return_edges = ( + user_return_edges + or need_periodic_diag_geometry + or need_norm_topology + or need_periodic_norm_geometry + ) + internal_return_edge_shifts = ( + user_return_edge_shifts + or need_periodic_diag_geometry + or need_periodic_norm_geometry + ) + + if user_return_edge_shifts: + if not periodic: + raise ValueError( + 'return_edge_shifts is only supported for periodic domains ' + '(RectangularCell with any periodic axis)' + ) + if not user_return_edges: + raise ValueError('return_edge_shifts requires return_edges=True') + if not user_return_vertices: + raise ValueError('return_edge_shifts requires return_vertices=True') + + if internal_return_edge_shifts: + if repair_edge_shifts: + validate_edge_shifts = True + if edge_shift_tol is not None and float(edge_shift_tol) < 0: + raise ValueError('edge_shift_tol must be >= 0') + + ids_internal = np.arange(n, dtype=np.int32) + ids_user = coerce_id_array(ids, n=n) + core = _require_core2d() + + validate_duplicate_check_mode(duplicate_check) + if duplicate_check != 'off' and n > 1: + _duplicate_check( + pts, + threshold=float(duplicate_threshold), + domain=domain, + wrap=bool(duplicate_wrap), + mode='warn' if duplicate_check == 'warn' else 'raise', + max_pairs=int(duplicate_max_pairs), + ) + + nx, ny = geom.resolve_block_counts( + n_sites=n, + blocks=blocks, + block_size=block_size, + ) + bounds = geom.bounds + periodic_flags = geom.periodic_axes + opts = ( + internal_return_vertices, + internal_return_adjacency, + internal_return_edges, + ) + + rr: np.ndarray | None = None + if mode == 'standard': + cells = core.compute_box_standard( + pts, + ids_internal, + bounds, + (nx, ny), + periodic_flags, + int(init_mem), + opts, + ) + elif mode == 'power': + if radii is None: + raise ValueError('radii is required for mode="power"') + rr = coerce_nonnegative_vector(radii, name='radii', n=n) + cells = core.compute_box_power( + pts, + ids_internal, + rr, + bounds, + (nx, ny), + periodic_flags, + int(init_mem), + opts, + ) + if include_empty: + add_empty_cells_inplace( + cells, + n=n, + sites=pts, + opts=opts, + measure_key='area', + boundary_key='edges', + ) + else: + raise ValueError(f'unknown mode: {mode}') + + if internal_return_edge_shifts: + _add_periodic_edge_shifts_inplace( + cells, + lattice_vectors=geom.lattice_vectors_cart, + periodic_mask=geom.periodic_axes, + mode=mode, + radii=rr, + search=int(edge_shift_search), + tol=edge_shift_tol, + validate=bool(validate_edge_shifts), + repair=bool(repair_edge_shifts), + ) + + if ids_user is not None: + remap_ids_inplace(cells, ids_user, boundary_key='edges') + + diag: TessellationDiagnostics | None = None + if need_diag: + expected = ids_user.tolist() if ids_user is not None else list(range(n)) + diag = analyze_tessellation( + cells, + domain, + expected_ids=expected, + mode=mode, + area_tol_rel=float(tessellation_area_tol_rel), + area_tol_abs=float(tessellation_area_tol_abs), + check_reciprocity=bool(periodic), + check_line_mismatch=bool(periodic), + line_offset_tol=tessellation_line_offset_tol, + line_angle_tol=tessellation_line_angle_tol, + mark_edges=bool(periodic), + ) + + if tessellation_require_reciprocity is None: + tessellation_require_reciprocity = bool(periodic) and mode in ( + 'standard', + 'power', + ) + + if tessellation_check in ('warn', 'raise'): + ok = bool(diag.ok_area) and ( + bool(diag.ok_reciprocity) + if bool(tessellation_require_reciprocity) + else True + ) + if not ok: + msg = ( + f'tessellation_check failed (mode={mode!r}): ' + f'area_ratio={diag.area_ratio:g}, ' + f'orphan_edges={diag.n_edges_orphan}, ' + f'mismatched_edges={diag.n_edges_mismatched}' + ) + if tessellation_check == 'raise': + raise TessellationError(msg, diag) + warnings.warn(msg, stacklevel=2) + + normalized_vertices = None + normalized_topology = None + if need_norm_vertices: + normalized_vertices = normalize_vertices( + cells, + domain=domain, + tol=normalization_tol, + require_edge_shifts=True, + copy_cells=True, + ) + if need_norm_topology: + normalized_topology = normalize_edges( + normalized_vertices, + domain=domain, + tol=normalization_tol, + copy_cells=False, + ) + + _strip_internal_geometry_inplace( + cells, + keep_vertices=user_return_vertices, + keep_adjacency=user_return_adjacency, + keep_edges=user_return_edges, + keep_edge_shifts=user_return_edge_shifts, + ) + + if return_result or normalize != 'none': + return PlanarComputeResult( + cells=cells, + tessellation_diagnostics=diag, + normalized_vertices=normalized_vertices, + normalized_topology=normalized_topology, + ) + if return_diagnostics: + assert diag is not None + return cells, diag + return cells + + +def locate( + points: Sequence[Sequence[float]] | np.ndarray, + queries: Sequence[Sequence[float]] | np.ndarray, + *, + domain: Domain2D, + ids: Sequence[int] | None = None, + duplicate_check: Literal['off', 'warn', 'raise'] = 'off', + duplicate_threshold: float = 1e-5, + duplicate_wrap: bool = True, + duplicate_max_pairs: int = 10, + block_size: float | None = None, + blocks: tuple[int, int] | None = None, + init_mem: int = 8, + mode: Literal['standard', 'power'] = 'standard', + radii: Sequence[float] | np.ndarray | None = None, + return_owner_position: bool = False, +) -> dict[str, np.ndarray]: + """Locate the owning generator for each planar query point.""" + + pts = coerce_point_array(points, name='points', dim=2) + _warn_if_scale_suspicious(pts=pts, domain=domain) + q = coerce_point_array(queries, name='queries', dim=2) + + n = int(pts.shape[0]) + ids_internal = np.arange(n, dtype=np.int32) + ids_user = coerce_id_array(ids, n=n) + core = _require_core2d() + + validate_duplicate_check_mode(duplicate_check) + if duplicate_check != 'off' and n > 1: + _duplicate_check( + pts, + threshold=float(duplicate_threshold), + domain=domain, + wrap=bool(duplicate_wrap), + mode='warn' if duplicate_check == 'warn' else 'raise', + max_pairs=int(duplicate_max_pairs), + ) + + geom = geometry2d(domain) + nx, ny = geom.resolve_block_counts( + n_sites=n, + blocks=blocks, + block_size=block_size, + ) + bounds = geom.bounds + periodic_flags = geom.periodic_axes + + if mode == 'standard': + found, owner_id, owner_pos = core.locate_box_standard( + pts, + ids_internal, + bounds, + (nx, ny), + periodic_flags, + int(init_mem), + q, + ) + elif mode == 'power': + if radii is None: + raise ValueError('radii is required for mode="power"') + rr = coerce_nonnegative_vector(radii, name='radii', n=n) + found, owner_id, owner_pos = core.locate_box_power( + pts, + ids_internal, + rr, + bounds, + (nx, ny), + periodic_flags, + int(init_mem), + q, + ) + else: + raise ValueError(f'unknown mode: {mode}') + + owner_id = np.asarray(owner_id) + found = np.asarray(found, dtype=bool) + if ids_user is not None: + out_ids = owner_id.astype(np.int64, copy=True) + mask = out_ids >= 0 + if np.any(mask): + out_ids[mask] = ids_user[out_ids[mask]] + owner_id = out_ids + + out: dict[str, np.ndarray] = { + 'found': found, + 'owner_id': owner_id, + } + if return_owner_position: + out['owner_pos'] = np.asarray(owner_pos, dtype=np.float64) + return out + + +def ghost_cells( + points: Sequence[Sequence[float]] | np.ndarray, + queries: Sequence[Sequence[float]] | np.ndarray, + *, + domain: Domain2D, + ids: Sequence[int] | None = None, + duplicate_check: Literal['off', 'warn', 'raise'] = 'off', + duplicate_threshold: float = 1e-5, + duplicate_wrap: bool = True, + duplicate_max_pairs: int = 10, + block_size: float | None = None, + blocks: tuple[int, int] | None = None, + init_mem: int = 8, + mode: Literal['standard', 'power'] = 'standard', + radii: Sequence[float] | np.ndarray | None = None, + ghost_radius: float | Sequence[float] | np.ndarray | None = None, + return_vertices: bool = True, + return_adjacency: bool = True, + return_edges: bool = True, + return_edge_shifts: bool = False, + edge_shift_search: int = 2, + include_empty: bool = True, + validate_edge_shifts: bool = True, + repair_edge_shifts: bool = False, + edge_shift_tol: float | None = None, +) -> list[dict[str, Any]]: + """Compute ghost Voronoi/Laguerre cells at planar query points.""" + + pts = coerce_point_array(points, name='points', dim=2) + _warn_if_scale_suspicious(pts=pts, domain=domain) + q = coerce_point_array(queries, name='queries', dim=2) + + if int(edge_shift_search) < 0: + raise ValueError('edge_shift_search must be >= 0') + + geom = geometry2d(domain) + if return_edge_shifts: + if not geom.has_any_periodic_axis: + raise ValueError( + 'return_edge_shifts is only supported for periodic domains ' + '(RectangularCell with any periodic axis)' + ) + if not return_edges: + raise ValueError('return_edge_shifts requires return_edges=True') + if not return_vertices: + raise ValueError('return_edge_shifts requires return_vertices=True') + + n = int(pts.shape[0]) + m = int(q.shape[0]) + ids_internal = np.arange(n, dtype=np.int32) + ids_user = coerce_id_array(ids, n=n) + core = _require_core2d() + + validate_duplicate_check_mode(duplicate_check) + if duplicate_check != 'off' and n > 1: + _duplicate_check( + pts, + threshold=float(duplicate_threshold), + domain=domain, + wrap=bool(duplicate_wrap), + mode='warn' if duplicate_check == 'warn' else 'raise', + max_pairs=int(duplicate_max_pairs), + ) + + nx, ny = geom.resolve_block_counts( + n_sites=n, + blocks=blocks, + block_size=block_size, + ) + bounds = geom.bounds + periodic_flags = geom.periodic_axes + opts = (bool(return_vertices), bool(return_adjacency), bool(return_edges)) + + rr: np.ndarray | None = None + if mode == 'standard': + cells = core.ghost_box_standard( + pts, + ids_internal, + bounds, + (nx, ny), + periodic_flags, + int(init_mem), + opts, + q, + ) + elif mode == 'power': + if radii is None: + raise ValueError('radii is required for mode="power"') + if ghost_radius is None: + raise ValueError('ghost_radius is required for mode="power"') + rr = coerce_nonnegative_vector(radii, name='radii', n=n) + gr = coerce_nonnegative_scalar_or_vector( + ghost_radius, + name='ghost_radius', + n=m, + length_name='m', + ) + cells = core.ghost_box_power( + pts, + ids_internal, + rr, + bounds, + (nx, ny), + periodic_flags, + int(init_mem), + opts, + q, + gr, + ) + else: + raise ValueError(f'unknown mode: {mode}') + + if return_edge_shifts: + _add_periodic_edge_shifts_inplace( + cells, + lattice_vectors=geom.lattice_vectors_cart, + periodic_mask=geom.periodic_axes, + mode=mode, + radii=rr, + site_positions=pts, + ghost_radii=gr if mode == 'power' else None, + search=int(edge_shift_search), + tol=edge_shift_tol, + validate=bool(validate_edge_shifts), + repair=bool(repair_edge_shifts), + ) + + if not include_empty: + cells = [cell for cell in cells if not bool(cell.get('empty', False))] + + if ids_user is not None: + remap_ids_inplace(cells, ids_user, boundary_key='edges') + return cells diff --git a/src/pyvoro2/planar/diagnostics.py b/src/pyvoro2/planar/diagnostics.py new file mode 100644 index 0000000..4708730 --- /dev/null +++ b/src/pyvoro2/planar/diagnostics.py @@ -0,0 +1,471 @@ +"""Planar tessellation diagnostics and sanity checks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Sequence + +import warnings + +import numpy as np + +from ._domain_geometry import geometry2d +from .domains import Box, RectangularCell + + +Domain2D = Box | RectangularCell + + +@dataclass(frozen=True, slots=True) +class TessellationIssue: + code: str + severity: Literal['info', 'warning', 'error'] + message: str + examples: tuple[Any, ...] = () + + +@dataclass(frozen=True, slots=True) +class TessellationDiagnostics: + domain_area: float + sum_cell_area: float + area_ratio: float + area_gap: float + area_overlap: float + n_sites_expected: int + n_cells_returned: int + missing_ids: tuple[int, ...] + empty_ids: tuple[int, ...] + edge_shift_available: bool + reciprocity_checked: bool + n_edges_total: int + n_edges_orphan: int + n_edges_mismatched: int + issues: tuple[TessellationIssue, ...] + ok_area: bool + ok_reciprocity: bool + ok: bool + + +class TessellationError(ValueError): + """Raised when planar tessellation sanity checks fail.""" + + def __init__(self, message: str, diagnostics: TessellationDiagnostics): + super().__init__(message, diagnostics) + self.diagnostics = diagnostics + + def __str__(self) -> str: + return str(self.args[0]) + + +def _domain_area(domain: Domain2D) -> float: + geom = geometry2d(domain) + (_lengths, area) = geom._lengths_and_area() + return float(area) + + +def _characteristic_length(domain: Domain2D) -> float: + geom = geometry2d(domain) + (lx, ly), _area = geom._lengths_and_area() + L = float(max(lx, ly)) + return L if np.isfinite(L) else 0.0 + + +def _is_periodic_domain(domain: Domain2D) -> bool: + return bool(geometry2d(domain).has_any_periodic_axis) + + +def _line_from_vertices(v: np.ndarray) -> tuple[np.ndarray, float] | None: + """Return (unit normal, d) for the line n·x = d, or None if degenerate.""" + + if v.shape[0] < 2: + return None + dv = v[1] - v[0] + nn = float(np.linalg.norm(dv)) + if nn == 0.0: + return None + tangent = dv / nn + normal = np.array([-tangent[1], tangent[0]], dtype=np.float64) + d = float(np.mean(v @ normal)) + return normal, d + + +def analyze_tessellation( + cells: Sequence[dict[str, Any]], + domain: Domain2D, + *, + expected_ids: Sequence[int] | None = None, + mode: str | None = None, + area_tol_rel: float = 1e-8, + area_tol_abs: float = 1e-12, + check_reciprocity: bool = True, + check_line_mismatch: bool = True, + line_offset_tol: float | None = None, + line_angle_tol: float | None = None, + mark_edges: bool = True, +) -> TessellationDiagnostics: + """Analyze planar tessellation sanity and optionally annotate edges.""" + + issues: list[TessellationIssue] = [] + + dom_area = _domain_area(domain) + sum_area = 0.0 + empty_ids: list[int] = [] + present_ids: list[int] = [] + for cell in cells: + cid = int(cell.get('id', -1)) + if cid >= 0: + present_ids.append(cid) + if bool(cell.get('empty', False)): + if cid >= 0: + empty_ids.append(cid) + continue + try: + sum_area += float(cell.get('area', 0.0)) + except Exception: + pass + + if dom_area <= 0.0: + issues.append( + TessellationIssue('DOMAIN_AREA', 'error', 'Domain area is non-positive') + ) + dom_area = max(dom_area, 0.0) + + area_tol = max(float(area_tol_abs), float(area_tol_rel) * dom_area) + diff = sum_area - dom_area + ok_area = abs(diff) <= area_tol + gap = max(0.0, dom_area - sum_area) + overlap = max(0.0, sum_area - dom_area) + if not ok_area: + if gap > area_tol: + issues.append( + TessellationIssue( + 'GAP', + 'warning', + f'Sum of cell areas is smaller than domain area by {gap:g}', + ) + ) + if overlap > area_tol: + issues.append( + TessellationIssue( + 'OVERLAP', + 'warning', + f'Sum of cell areas exceeds domain area by {overlap:g}', + ) + ) + + missing_ids: list[int] = [] + if expected_ids is not None: + exp = {int(x) for x in expected_ids} + missing_ids = sorted(exp - set(present_ids)) + if missing_ids: + issues.append( + TessellationIssue( + 'MISSING_IDS', + 'warning', + f'{len(missing_ids)} expected ids are missing from output', + examples=tuple(missing_ids[:10]), + ) + ) + + edge_shift_available = False + reciprocity_checked = False + n_edges_total = 0 + n_orphan = 0 + n_mismatch = 0 + + if _is_periodic_domain(domain) and check_reciprocity: + for cell in cells: + for edge in cell.get('edges') or []: + if ( + int(edge.get('adjacent_cell', -999999)) >= 0 + and 'adjacent_shift' in edge + ): + edge_shift_available = True + break + if edge_shift_available: + break + + if not edge_shift_available: + issues.append( + TessellationIssue( + 'NO_EDGE_SHIFTS', + 'info', + 'Edge shifts are not available; set return_edge_shifts=True ' + 'to enable reciprocity diagnostics', + ) + ) + else: + reciprocity_checked = True + + geom = geometry2d(domain) + avec, bvec = geom.lattice_vectors_cart + + cell_by_id: dict[int, dict[str, Any]] = {} + for cell in cells: + cid = int(cell.get('id', -1)) + if cid >= 0: + cell_by_id[cid] = cell + + L = _characteristic_length(domain) + if (line_offset_tol is None or line_angle_tol is None) and ( + float(L) < 1e-3 or float(L) > 1e9 + ): + warnings.warn( + 'analyze_tessellation is using default periodic line-mismatch ' + 'tolerances derived from the planar domain length scale ' + f'(L≈{float(L):.3g}). For very small/large units this may ' + 'be too strict/too loose. Consider rescaling inputs or ' + 'passing line_offset_tol=... and/or line_angle_tol=... ' + 'explicitly.', + RuntimeWarning, + stacklevel=2, + ) + off_tol = (1e-6 * L) if line_offset_tol is None else float(line_offset_tol) + ang_tol = 1e-6 if line_angle_tol is None else float(line_angle_tol) + eps_f = float(np.finfo(float).eps) + size_tol = float(max(1000.0 * off_tol, 128.0 * eps_f * L)) + + def _skey(s: Any) -> tuple[int, int]: + return int(s[0]), int(s[1]) + + edge_map: dict[tuple[int, int, tuple[int, int]], tuple[int, int]] = {} + for cell in cells: + i = int(cell.get('id', -1)) + if i < 0: + continue + verts = np.asarray(cell.get('vertices', []), dtype=np.float64) + if verts.size == 0: + verts = verts.reshape((0, 2)) + edges = cell.get('edges') or [] + for ei, edge in enumerate(edges): + j = int(edge.get('adjacent_cell', -999999)) + if j < 0: + continue + s = _skey(edge.get('adjacent_shift', (0, 0))) + n_edges_total += 1 + + idx = np.asarray(edge.get('vertices', []), dtype=np.int64) + if idx.shape != (2,) or verts.size == 0: + continue + vv = verts[idx] + size = float(np.linalg.norm(vv[1] - vv[0])) + if size < size_tol: + continue + + key = (i, j, s) + if key in edge_map: + issues.append( + TessellationIssue( + 'DUPLICATE_DIRECTED_EDGE', + 'error', + f'Duplicate directed edge key encountered: {key}', + ) + ) + else: + edge_map[key] = (i, ei) + + if mark_edges: + edge.setdefault('orphan', False) + edge.setdefault('reciprocal_mismatch', False) + edge.setdefault('reciprocal_missing', False) + + def _edge_segment( + cell_id: int, + edge_index: int, + *, + translate: np.ndarray | None = None, + ) -> np.ndarray | None: + cell = cell_by_id.get(cell_id) + if cell is None: + return None + verts = np.asarray(cell.get('vertices', []), dtype=np.float64) + if verts.size == 0: + verts = verts.reshape((0, 2)) + edges = cell.get('edges') or [] + if edge_index < 0 or edge_index >= len(edges): + return None + idx = np.asarray(edges[edge_index].get('vertices', []), dtype=np.int64) + if idx.shape != (2,) or verts.size == 0: + return None + vv = verts[idx] + if translate is not None: + vv = vv + translate.reshape(1, 2) + return vv + + checked: set[tuple[int, int, tuple[int, int]]] = set() + examples_missing: list[tuple[int, int, tuple[int, int]]] = [] + examples_mismatch: list[tuple[int, int, tuple[int, int]]] = [] + + for (i, j, s), loc in list(edge_map.items()): + if (i, j, s) in checked: + continue + recip = (j, i, (-s[0], -s[1])) + checked.add((i, j, s)) + checked.add(recip) + if recip not in edge_map: + n_orphan += 1 + if len(examples_missing) < 10: + examples_missing.append((i, j, s)) + if mark_edges: + ci, ei = loc + try: + cell_by_id[ci]['edges'][ei]['orphan'] = True + cell_by_id[ci]['edges'][ei]['reciprocal_missing'] = True + except Exception: + pass + continue + + if not check_line_mismatch: + continue + + (ci, ei) = loc + (cj, ej) = edge_map[recip] + T = s[0] * avec + s[1] * bvec + seg1 = _edge_segment(ci, ei) + seg2 = _edge_segment(cj, ej, translate=T) + if seg1 is None or seg2 is None: + continue + line1 = _line_from_vertices(seg1) + line2 = _line_from_vertices(seg2) + if line1 is None or line2 is None: + continue + + n1, d1 = line1 + n2, d2 = line2 + dot = float(np.dot(n1, n2)) + if dot < 0.0: + n2 = -n2 + d2 = -d2 + dot = -dot + dot = max(-1.0, min(1.0, dot)) + ang = float(np.arccos(dot)) + off = float(abs(d1 - d2)) + dist_same = max( + float(np.linalg.norm(seg1[0] - seg2[0])), + float(np.linalg.norm(seg1[1] - seg2[1])), + ) + dist_flip = max( + float(np.linalg.norm(seg1[0] - seg2[1])), + float(np.linalg.norm(seg1[1] - seg2[0])), + ) + coord_mismatch = min(dist_same, dist_flip) + + if ang > ang_tol or off > off_tol or coord_mismatch > size_tol: + n_mismatch += 1 + if len(examples_mismatch) < 10: + examples_mismatch.append((i, j, s)) + if mark_edges: + try: + cell_by_id[ci]['edges'][ei]['reciprocal_mismatch'] = True + cell_by_id[cj]['edges'][ej]['reciprocal_mismatch'] = True + except Exception: + pass + + if n_orphan: + issues.append( + TessellationIssue( + 'MISSING_RECIPROCAL', + 'warning', + f'{n_orphan} edges are missing a reciprocal', + examples=tuple(examples_missing), + ) + ) + if n_mismatch: + issues.append( + TessellationIssue( + 'RECIPROCAL_MISMATCH', + 'warning', + f'{n_mismatch} reciprocal edge pairs disagree geometrically', + examples=tuple(examples_mismatch), + ) + ) + + ok_recip = True + if reciprocity_checked: + ok_recip = (n_orphan == 0) and (n_mismatch == 0) + + ok = ok_area and (ok_recip if reciprocity_checked else True) + if not ok and mode is not None: + issues.append( + TessellationIssue('MODE', 'info', f'Diagnostics produced for mode={mode!r}') + ) + + return TessellationDiagnostics( + domain_area=float(dom_area), + sum_cell_area=float(sum_area), + area_ratio=float(sum_area / dom_area) if dom_area > 0 else 0.0, + area_gap=float(gap), + area_overlap=float(overlap), + n_sites_expected=int( + len(expected_ids) if expected_ids is not None else len(set(present_ids)) + ), + n_cells_returned=int(len(cells)), + missing_ids=tuple(int(x) for x in missing_ids), + empty_ids=tuple(int(x) for x in sorted(set(empty_ids))), + edge_shift_available=bool(edge_shift_available), + reciprocity_checked=bool(reciprocity_checked), + n_edges_total=int(n_edges_total), + n_edges_orphan=int(n_orphan), + n_edges_mismatched=int(n_mismatch), + issues=tuple(issues), + ok_area=bool(ok_area), + ok_reciprocity=bool(ok_recip), + ok=bool(ok), + ) + + +def validate_tessellation( + cells: Sequence[dict[str, Any]], + domain: Domain2D, + *, + expected_ids: Sequence[int] | None = None, + mode: str | None = None, + level: Literal['basic', 'strict'] = 'basic', + require_reciprocity: bool | None = None, + area_tol_rel: float = 1e-8, + area_tol_abs: float = 1e-12, + line_offset_tol: float | None = None, + line_angle_tol: float | None = None, + mark_edges: bool | None = None, +) -> TessellationDiagnostics: + """Validate planar tessellation sanity, optionally raising in strict mode.""" + + if level not in ('basic', 'strict'): + raise ValueError("level must be 'basic' or 'strict'") + + periodic = _is_periodic_domain(domain) + if require_reciprocity is None: + require_reciprocity = bool(periodic) + if mark_edges is None: + mark_edges = bool(periodic) + + diag = analyze_tessellation( + cells, + domain, + expected_ids=expected_ids, + mode=mode, + area_tol_rel=float(area_tol_rel), + area_tol_abs=float(area_tol_abs), + check_reciprocity=bool(periodic), + check_line_mismatch=bool(periodic), + line_offset_tol=line_offset_tol, + line_angle_tol=line_angle_tol, + mark_edges=bool(mark_edges), + ) + + if level == 'strict': + ok = bool(diag.ok_area) and ( + bool(diag.ok_reciprocity) + if bool(require_reciprocity) and bool(diag.reciprocity_checked) + else True + ) + if not ok: + raise TessellationError( + 'Tessellation validation failed: ' + f'area_ratio={diag.area_ratio:g}, ' + f'orphan_edges={diag.n_edges_orphan}, ' + f'mismatched_edges={diag.n_edges_mismatched}', + diag, + ) + + return diag diff --git a/src/pyvoro2/planar/domains.py b/src/pyvoro2/planar/domains.py new file mode 100644 index 0000000..1f7ffae --- /dev/null +++ b/src/pyvoro2/planar/domains.py @@ -0,0 +1,140 @@ +"""Planar domain specifications for 2D tessellations.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from ..domains import _default_snap_eps + + +@dataclass(frozen=True, slots=True) +class Box: + """Axis-aligned non-periodic planar box.""" + + bounds: tuple[tuple[float, float], tuple[float, float]] + + def __post_init__(self) -> None: + if len(self.bounds) != 2: + raise ValueError('bounds must have length 2') + for lo, hi in self.bounds: + if not np.isfinite(lo) or not np.isfinite(hi): + raise ValueError('bounds must be finite') + if not hi > lo: + raise ValueError('each bound must satisfy hi > lo') + + @classmethod + def from_points(cls, points: np.ndarray, padding: float = 2.0) -> 'Box': + """Create a bounding box that encloses planar points.""" + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] != 2: + raise ValueError('points must have shape (n, 2)') + mins = pts.min(axis=0) - float(padding) + maxs = pts.max(axis=0) + float(padding) + return cls( + bounds=((float(mins[0]), float(maxs[0])), (float(mins[1]), float(maxs[1]))) + ) + + +@dataclass(frozen=True, slots=True) +class RectangularCell: + """Axis-aligned planar cell with optional x/y periodicity. + + This is the honest first public 2D domain scope for pyvoro2.planar. + It intentionally does **not** cover non-orthogonal periodic cells. + """ + + bounds: tuple[tuple[float, float], tuple[float, float]] + periodic: tuple[bool, bool] = (True, True) + + def __post_init__(self) -> None: + if len(self.bounds) != 2: + raise ValueError('bounds must have length 2') + for lo, hi in self.bounds: + if not np.isfinite(lo) or not np.isfinite(hi): + raise ValueError('bounds must be finite') + if not hi > lo: + raise ValueError('each bound must satisfy hi > lo') + if len(self.periodic) != 2: + raise ValueError('periodic must have length 2') + object.__setattr__( + self, + 'periodic', + (bool(self.periodic[0]), bool(self.periodic[1])), + ) + + @property + def lattice_vectors(self) -> tuple[np.ndarray, np.ndarray]: + """Return lattice vectors ``(a, b)`` in Cartesian coordinates.""" + + (xmin, xmax), (ymin, ymax) = self.bounds + a = np.array([xmax - xmin, 0.0], dtype=np.float64) + b = np.array([0.0, ymax - ymin], dtype=np.float64) + return a, b + + def remap_cart( + self, + points: np.ndarray, + *, + return_shifts: bool = False, + eps: float | None = None, + ) -> np.ndarray | tuple[np.ndarray, np.ndarray]: + """Remap Cartesian points into the primary rectangular domain.""" + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] != 2: + raise ValueError('points must have shape (n, 2)') + + (xmin, xmax), (ymin, ymax) = self.bounds + lx = float(xmax - xmin) + ly = float(ymax - ymin) + + if eps is None: + lp = 0.0 + if self.periodic[0]: + lp = max(lp, lx) + if self.periodic[1]: + lp = max(lp, ly) + eps_val = _default_snap_eps(lp) + else: + eps_val = float(eps) + if eps_val < 0.0: + raise ValueError('eps must be >= 0') + + x = pts[:, 0].astype(float, copy=True) + y = pts[:, 1].astype(float, copy=True) + shifts = np.zeros((pts.shape[0], 2), dtype=np.int64) + + for axis, (lo, hi, length, is_periodic) in enumerate( + ( + (xmin, xmax, lx, self.periodic[0]), + (ymin, ymax, ly, self.periodic[1]), + ) + ): + if not is_periodic: + continue + coord = x if axis == 0 else y + s = np.floor((coord - lo) / length).astype(np.int64) + coord -= s * length + shifts[:, axis] = s + + if eps_val > 0.0: + m0 = np.abs(coord - lo) < eps_val + if np.any(m0): + coord[m0] = lo + m1 = coord >= (hi - eps_val) + if np.any(m1): + coord[m1] = lo + shifts[m1, axis] += 1 + + if axis == 0: + x = coord + else: + y = coord + + out = np.stack([x, y], axis=1).astype(np.float64) + if return_shifts: + return out, shifts + return out diff --git a/src/pyvoro2/planar/duplicates.py b/src/pyvoro2/planar/duplicates.py new file mode 100644 index 0000000..3efafb3 --- /dev/null +++ b/src/pyvoro2/planar/duplicates.py @@ -0,0 +1,95 @@ +"""Planar near-duplicate point detection.""" + +from __future__ import annotations + +from typing import Any, Literal + +import warnings + +import numpy as np + +from ..duplicates import DuplicateError, DuplicatePair +from .domains import Box, RectangularCell + +Domain2D = Box | RectangularCell + + +def duplicate_check( + points: Any, + *, + threshold: float = 1e-5, + domain: Domain2D | None = None, + wrap: bool = True, + mode: Literal['raise', 'warn', 'return'] = 'raise', + max_pairs: int = 10, +) -> tuple[DuplicatePair, ...]: + """Detect planar point pairs closer than an absolute threshold.""" + + if mode not in ('raise', 'warn', 'return'): + raise ValueError("mode must be one of: 'raise', 'warn', 'return'") + + thr = float(threshold) + if not np.isfinite(thr) or thr <= 0.0: + raise ValueError('threshold must be a positive finite number') + max_pairs_i = int(max_pairs) + if max_pairs_i <= 0: + raise ValueError('max_pairs must be > 0') + + pts = np.asarray(points, dtype=np.float64) + if pts.ndim != 2 or pts.shape[1] != 2: + raise ValueError('points must have shape (n, 2)') + if not np.all(np.isfinite(pts)): + raise ValueError('points must contain only finite values') + n = int(pts.shape[0]) + if n <= 1: + return tuple() + + if domain is not None and wrap and isinstance(domain, RectangularCell): + pts = np.asarray(domain.remap_cart(pts), dtype=np.float64) + + h2 = thr * thr + grid = np.floor(pts / thr).astype(np.int64) + neigh = [(dx, dy) for dx in (-1, 0, 1) for dy in (-1, 0, 1)] + + buckets: dict[tuple[int, int], list[int]] = {} + found: list[DuplicatePair] = [] + for i in range(n): + key = (int(grid[i, 0]), int(grid[i, 1])) + x = pts[i] + for dx, dy in neigh: + cand = buckets.get((key[0] + dx, key[1] + dy)) + if not cand: + continue + for j in cand: + d = x - pts[j] + dist2 = float(d[0] * d[0] + d[1] * d[1]) + if dist2 < h2: + found.append( + DuplicatePair( + i=int(j), + j=int(i), + distance=float(np.sqrt(dist2)), + ) + ) + if len(found) >= max_pairs_i: + break + if len(found) >= max_pairs_i: + break + if len(found) >= max_pairs_i: + break + buckets.setdefault(key, []).append(i) + + pairs = tuple(found) + if not pairs: + return pairs + + msg = ( + f'Found {len(pairs)} planar point pair(s) closer than ' + f'threshold={thr:g}. Such near-duplicates may cause Voro++ ' + 'to terminate the process.' + ) + if mode == 'raise': + raise DuplicateError(msg, pairs, thr) + if mode == 'warn': + warnings.warn(msg, RuntimeWarning, stacklevel=2) + return pairs diff --git a/src/pyvoro2/planar/normalize.py b/src/pyvoro2/planar/normalize.py new file mode 100644 index 0000000..5cfe3a0 --- /dev/null +++ b/src/pyvoro2/planar/normalize.py @@ -0,0 +1,404 @@ +"""Planar topology-level post-processing utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Sequence + +import warnings + +import numpy as np + +from ._domain_geometry import geometry2d +from .domains import Box, RectangularCell + + +Domain2D = Box | RectangularCell + + +@dataclass(frozen=True) +class NormalizedVertices: + """Result of :func:`normalize_vertices` for planar tessellations. + + Attributes: + global_vertices: Array of unique planar vertices in Cartesian coordinates, + remapped into the primary cell for periodic domains. + cells: Per-cell dictionaries augmented with: + - vertex_global_id: list[int] aligned with local vertices + - vertex_shift: list[tuple[int, int]] aligned with local vertices + """ + + global_vertices: np.ndarray + cells: list[dict[str, Any]] + + +@dataclass(frozen=True) +class NormalizedTopology: + """Result of :func:`normalize_topology` for planar tessellations. + + Attributes: + global_vertices: Unique planar vertices in Cartesian coordinates. + global_edges: Unique geometric edges. Each edge dict contains: + - cells: (cid0, cid1) + - cell_shifts: ((0, 0), (sx, sy)) + - vertices: (gid0, gid1) + - vertex_shifts: ((0, 0), (sx, sy)) + cells: Per-cell dictionaries including ``vertex_global_id``, + ``vertex_shift``, and ``edge_global_id`` aligned with local edges. + """ + + global_vertices: np.ndarray + global_edges: list[dict[str, Any]] + cells: list[dict[str, Any]] + + +def _domain_length_scale(domain: Domain2D) -> float: + (lx, ly), _area = geometry2d(domain)._lengths_and_area() + L = float(max(lx, ly)) + return L if np.isfinite(L) else 0.0 + + +def _is_periodic_domain(domain: Domain2D) -> bool: + return bool(geometry2d(domain).has_any_periodic_axis) + + +def _quant_key(coord: np.ndarray, tol: float) -> tuple[int, int]: + q = np.rint(coord / tol).astype(np.int64) + return int(q[0]), int(q[1]) + + +def _canonical_incident_key( + incident: Sequence[tuple[int, tuple[int, int]]] +) -> tuple[tuple[int, int, int], ...]: + """Canonicalize an incident cell-image set up to global translation.""" + + uniq = sorted(set((int(cid), (int(s[0]), int(s[1]))) for cid, s in incident)) + if not uniq: + return tuple() + + best: tuple[tuple[int, int, int], ...] | None = None + for _cid_a, s_a in uniq: + sa = np.array(s_a, dtype=np.int64) + rep = [] + for cid, s in uniq: + ss = np.array(s, dtype=np.int64) - sa + rep.append((cid, int(ss[0]), int(ss[1]))) + rep_sorted = tuple(sorted(rep)) + if best is None or rep_sorted < best: + best = rep_sorted + assert best is not None + return best + + +def normalize_vertices( + cells: list[dict[str, Any]], + *, + domain: Domain2D, + tol: float | None = None, + require_edge_shifts: bool = True, + copy_cells: bool = True, +) -> NormalizedVertices: + """Build a global planar vertex pool and per-cell vertex mappings.""" + + L = _domain_length_scale(domain) + periodic = _is_periodic_domain(domain) + if tol is None: + if not np.isfinite(L) or float(L) <= 0.0: + raise ValueError('domain has an invalid length scale; pass tol explicitly') + tol = 1e-8 * float(L) + if float(L) < 1e-3 or float(L) > 1e9: + warnings.warn( + 'normalize_vertices is using a default tolerance proportional to ' + 'the planar domain length scale ' + f'(L≈{float(L):.3g}). For very small/large units this may be ' + 'too strict/too loose. Consider rescaling your coordinates or ' + 'passing an explicit tol=... .', + RuntimeWarning, + stacklevel=2, + ) + if tol <= 0: + raise ValueError('tol must be positive') + if not isinstance(cells, list): + raise ValueError('cells must be a list of dicts') + + out_cells = [dict(c) for c in cells] if copy_cells else cells + global_vertices: list[np.ndarray] = [] + key_to_gid: dict[tuple[Any, ...], int] = {} + + if not periodic: + for cell in out_cells: + verts = np.asarray(cell.get('vertices', []), dtype=float) + if verts.size == 0: + verts = verts.reshape((0, 2)) + if verts.ndim != 2 or verts.shape[1] != 2: + raise ValueError('cells must include vertices with shape (m, 2)') + + gids: list[int] = [] + shifts: list[tuple[int, int]] = [] + for v in verts: + key = ('box',) + _quant_key(v, tol) + gid = key_to_gid.get(key) + if gid is None: + gid = len(global_vertices) + key_to_gid[key] = gid + global_vertices.append(v.astype(np.float64)) + gids.append(gid) + shifts.append((0, 0)) + cell['vertex_global_id'] = gids + cell['vertex_shift'] = shifts + + return NormalizedVertices( + global_vertices=( + np.stack(global_vertices, axis=0) + if global_vertices + else np.zeros((0, 2), dtype=np.float64) + ), + cells=out_cells, + ) + + if require_edge_shifts: + for cell in out_cells: + edges = cell.get('edges') + if edges is None: + raise ValueError('cells must include edges for periodic normalization') + for edge in edges: + if 'adjacent_shift' not in edge: + raise ValueError( + 'cells must include edge adjacent_shift ' + '(compute with return_edge_shifts=True)' + ) + + sorted_cells = sorted(out_cells, key=lambda cc: int(cc.get('id', 0))) + + for cell in sorted_cells: + verts = np.asarray(cell.get('vertices', []), dtype=float) + if verts.size == 0: + verts = verts.reshape((0, 2)) + if verts.ndim != 2 or verts.shape[1] != 2: + raise ValueError('cells must include vertices with shape (m, 2)') + edges = cell.get('edges') + if edges is None: + raise ValueError('cells must include edges for periodic normalization') + + v_edges: list[list[dict[str, Any]]] = [[] for _ in range(int(verts.shape[0]))] + for edge in edges: + idx = edge.get('vertices') + if idx is None: + continue + for vid in idx: + iv = int(vid) + if 0 <= iv < len(v_edges): + v_edges[iv].append(edge) + + gids: list[int] = [] + shifts: list[tuple[int, int]] = [] + + if not isinstance(domain, RectangularCell): + raise ValueError('periodic planar normalization requires RectangularCell') + remapped, rem_shifts = domain.remap_cart(verts, return_shifts=True) + for _ in range(2): + remapped2, extra = domain.remap_cart(remapped, return_shifts=True) + remapped = remapped2 + rem_shifts = rem_shifts + extra + if not np.any(extra): + break + + for k in range(int(verts.shape[0])): + v0 = remapped[k] + s0 = (int(rem_shifts[k, 0]), int(rem_shifts[k, 1])) + incident: list[tuple[int, tuple[int, int]]] = [] + cid_here = int(cell.get('id', 0)) + incident.append((cid_here, (0, 0))) + for edge in v_edges[k]: + adj = int(edge.get('adjacent_cell', -999999)) + sh = edge.get('adjacent_shift', (0, 0)) + sh_t = (int(sh[0]), int(sh[1])) + incident.append((adj, sh_t)) + + topo_key = _canonical_incident_key(incident) + coord_key = _quant_key(v0, tol) + key: tuple[Any, ...] = ('pbc',) + topo_key + ('@',) + coord_key + gid = key_to_gid.get(key) + if gid is None: + gid = len(global_vertices) + key_to_gid[key] = gid + global_vertices.append(v0.astype(np.float64)) + else: + dv = float(np.linalg.norm(global_vertices[gid] - v0)) + if dv > 10 * tol: + raise ValueError( + 'vertex key collision: same topology key but significantly ' + 'different coordinates; ' + f'gid={gid}, dv={dv}' + ) + gids.append(gid) + shifts.append(s0) + + cell['vertex_global_id'] = gids + cell['vertex_shift'] = shifts + + return NormalizedVertices( + global_vertices=( + np.stack(global_vertices, axis=0) + if global_vertices + else np.zeros((0, 2), dtype=np.float64) + ), + cells=out_cells, + ) + + +def _as_shift(s: Any) -> tuple[int, int]: + return int(s[0]), int(s[1]) + + +def _canon_edge( + a: tuple[int, tuple[int, int]], + b: tuple[int, tuple[int, int]], +) -> tuple[tuple[Any, ...], tuple[tuple[int, int, int], tuple[int, int, int]]]: + """Canonicalize an edge up to translation and orientation.""" + + gid0, s0 = a + gid1, s1 = b + s0a = np.array(s0, dtype=np.int64) + s1a = np.array(s1, dtype=np.int64) + + candidates = [] + for ga, sa, gb, sb in ((gid0, s0a, gid1, s1a), (gid1, s1a, gid0, s0a)): + d = sb - sa + recs = ((int(ga), 0, 0), (int(gb), int(d[0]), int(d[1]))) + candidates.append(tuple(sorted(recs))) + best = min(candidates) + + g0, x0, y0 = best[0] + g1, x1, y1 = best[1] + rep = ((int(g0), 0, 0), (int(g1), int(x1 - x0), int(y1 - y0))) + key = ('e', int(rep[0][0]), int(rep[1][0]), int(rep[1][1]), int(rep[1][2])) + return key, rep + + +def _canon_cell_pair( + cid_here: int, + adj: int, + adj_shift: tuple[int, int], +) -> tuple[int, int, int, int, int, int]: + sx, sy = int(adj_shift[0]), int(adj_shift[1]) + rep1 = (int(cid_here), 0, 0, int(adj), sx, sy) + rep2 = (int(adj), 0, 0, int(cid_here), -sx, -sy) + return rep2 if rep2 < rep1 else rep1 + + +def normalize_edges( + nv: NormalizedVertices, + *, + domain: Domain2D, + tol: float | None = None, + copy_cells: bool = True, +) -> NormalizedTopology: + """Build a global edge pool based on an existing planar normalization.""" + + L = _domain_length_scale(domain) + if tol is None: + if not np.isfinite(L) or float(L) <= 0.0: + raise ValueError('domain has an invalid length scale; pass tol explicitly') + tol = 1e-8 * float(L) + if float(L) < 1e-3 or float(L) > 1e9: + warnings.warn( + 'normalize_edges is using a default tolerance proportional to ' + 'the planar domain length scale ' + f'(L≈{float(L):.3g}). For very small/large units this may be ' + 'too strict/too loose. Consider rescaling your coordinates or ' + 'passing an explicit tol=... .', + RuntimeWarning, + stacklevel=2, + ) + if tol <= 0: + raise ValueError('tol must be positive') + + cells = [dict(c) for c in nv.cells] if copy_cells else nv.cells + global_edges: list[dict[str, Any]] = [] + edge_key_to_id: dict[tuple[Any, ...], int] = {} + periodic = _is_periodic_domain(domain) + sorted_cells = sorted(cells, key=lambda cc: int(cc.get('id', 0))) + + for cell in sorted_cells: + edges = cell.get('edges') + if edges is None: + raise ValueError('cells must include edges') + gids = cell.get('vertex_global_id') + vsh = cell.get('vertex_shift') + if gids is None or vsh is None: + raise ValueError( + 'cells must include vertex_global_id and vertex_shift ' + '(call normalize_vertices first)' + ) + + edge_ids: list[int] = [] + cid_here = int(cell.get('id', 0)) + for edge in edges: + adj = int(edge.get('adjacent_cell', -999999)) + if periodic and adj >= 0: + if 'adjacent_shift' not in edge: + raise ValueError( + 'Periodic domain edge missing adjacent_shift; compute ' + 'with return_edge_shifts=True' + ) + adj_shift = _as_shift(edge.get('adjacent_shift')) + else: + adj_shift = (0, 0) + + idx = np.asarray(edge.get('vertices', []), dtype=np.int64) + if idx.shape != (2,): + raise ValueError('edge vertices must have shape (2,)') + u = int(idx[0]) + v = int(idx[1]) + if u < 0 or v < 0 or u >= len(gids) or v >= len(gids): + raise ValueError('edge references an out-of-range local vertex index') + + ekey, erep = _canon_edge( + (int(gids[u]), _as_shift(vsh[u])), + (int(gids[v]), _as_shift(vsh[v])), + ) + eid = edge_key_to_id.get(ekey) + if eid is None: + eid = len(global_edges) + edge_key_to_id[ekey] = eid + pair = _canon_cell_pair(cid_here, adj, adj_shift) + global_edges.append( + { + 'cells': (int(pair[0]), int(pair[3])), + 'cell_shifts': ((0, 0), (int(pair[4]), int(pair[5]))), + 'vertices': (int(erep[0][0]), int(erep[1][0])), + 'vertex_shifts': ( + (0, 0), + (int(erep[1][1]), int(erep[1][2])), + ), + } + ) + edge_ids.append(eid) + cell['edge_global_id'] = edge_ids + + return NormalizedTopology( + global_vertices=nv.global_vertices, + global_edges=global_edges, + cells=cells, + ) + + +def normalize_topology( + cells: list[dict[str, Any]], + *, + domain: Domain2D, + tol: float | None = None, + require_edge_shifts: bool = True, + copy_cells: bool = True, +) -> NormalizedTopology: + """Convenience wrapper: normalize vertices, then deduplicate edges.""" + + nv = normalize_vertices( + cells, + domain=domain, + tol=tol, + require_edge_shifts=require_edge_shifts, + copy_cells=copy_cells, + ) + return normalize_edges(nv, domain=domain, tol=tol, copy_cells=False) diff --git a/src/pyvoro2/planar/result.py b/src/pyvoro2/planar/result.py new file mode 100644 index 0000000..4ef89b7 --- /dev/null +++ b/src/pyvoro2/planar/result.py @@ -0,0 +1,103 @@ +"""Structured result objects for wrapper-level planar convenience APIs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from .diagnostics import TessellationDiagnostics +from .normalize import NormalizedTopology, NormalizedVertices + + +@dataclass(frozen=True, slots=True) +class PlanarComputeResult: + """Structured return value for :func:`pyvoro2.planar.compute`. + + Attributes: + cells: Raw planar cell dictionaries returned by the compute wrapper + after any temporary internal geometry has been stripped according to + the caller's requested output flags. + tessellation_diagnostics: Wrapper-computed tessellation diagnostics, if + requested directly or needed for ``tessellation_check``. + normalized_vertices: Vertex-normalized planar output, if requested via + ``normalize='vertices'`` or ``normalize='topology'``. + normalized_topology: Edge-normalized planar topology, if requested via + ``normalize='topology'``. + + The normalized structures intentionally carry their own augmented cell + copies. They are not aliases of ``cells`` and may therefore still contain + geometry that was omitted from the raw wrapper output. + """ + + cells: list[dict[str, Any]] + tessellation_diagnostics: TessellationDiagnostics | None = None + normalized_vertices: NormalizedVertices | None = None + normalized_topology: NormalizedTopology | None = None + + @property + def has_tessellation_diagnostics(self) -> bool: + """Whether tessellation diagnostics are present.""" + + return self.tessellation_diagnostics is not None + + @property + def has_normalized_vertices(self) -> bool: + """Whether vertex normalization output is present.""" + + return self.normalized_vertices is not None + + @property + def has_normalized_topology(self) -> bool: + """Whether topology normalization output is present.""" + + return self.normalized_topology is not None + + @property + def global_vertices(self) -> np.ndarray | None: + """Global planar vertices from the available normalized output.""" + + if self.normalized_topology is not None: + return self.normalized_topology.global_vertices + if self.normalized_vertices is not None: + return self.normalized_vertices.global_vertices + return None + + @property + def global_edges(self) -> list[dict[str, Any]] | None: + """Global planar edges if topology normalization is available.""" + + if self.normalized_topology is None: + return None + return self.normalized_topology.global_edges + + def require_tessellation_diagnostics(self) -> TessellationDiagnostics: + """Return tessellation diagnostics or raise a helpful error.""" + + if self.tessellation_diagnostics is None: + raise ValueError( + 'tessellation diagnostics are not available; pass ' + 'return_diagnostics=True or enable tessellation_check' + ) + return self.tessellation_diagnostics + + def require_normalized_vertices(self) -> NormalizedVertices: + """Return vertex normalization output or raise a helpful error.""" + + if self.normalized_vertices is None: + raise ValueError( + 'normalized vertices are not available; pass ' + "normalize='vertices' or normalize='topology'" + ) + return self.normalized_vertices + + def require_normalized_topology(self) -> NormalizedTopology: + """Return topology normalization output or raise a helpful error.""" + + if self.normalized_topology is None: + raise ValueError( + 'normalized topology is not available; pass ' + "normalize='topology'" + ) + return self.normalized_topology diff --git a/src/pyvoro2/planar/validation.py b/src/pyvoro2/planar/validation.py new file mode 100644 index 0000000..f4fd36f --- /dev/null +++ b/src/pyvoro2/planar/validation.py @@ -0,0 +1,400 @@ +"""Strict validation utilities for planar normalization outputs.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +from ._domain_geometry import geometry2d +from .domains import Box, RectangularCell +from .normalize import NormalizedTopology, NormalizedVertices + + +Domain2D = Box | RectangularCell + + +@dataclass(frozen=True, slots=True) +class NormalizationIssue: + code: str + severity: Literal['info', 'warning', 'error'] + message: str + examples: tuple[Any, ...] = () + + +@dataclass(frozen=True, slots=True) +class NormalizationDiagnostics: + n_cells: int + n_global_vertices: int + n_global_edges: int | None + is_periodic_domain: bool + fully_periodic_domain: bool + has_wall_edges: bool + + n_vertex_edge_shift_mismatch: int + n_edge_vertex_set_mismatch: int + n_vertices_low_incidence: int + n_cells_bad_polygon: int + + issues: tuple[NormalizationIssue, ...] + + ok_vertex_edge_shift: bool + ok_edge_vertex_sets: bool + ok_incidence: bool + ok_polygon: bool + ok: bool + + +class NormalizationError(ValueError): + """Raised when strict planar normalization validation fails.""" + + def __init__(self, message: str, diagnostics: NormalizationDiagnostics): + super().__init__(message, diagnostics) + self.diagnostics = diagnostics + + def __str__(self) -> str: + return str(self.args[0]) + + +def _as_shift(s: Any) -> tuple[int, int]: + return int(s[0]), int(s[1]) + + +def _is_periodic_domain(domain: Domain2D) -> bool: + return bool(geometry2d(domain).has_any_periodic_axis) + + +def _fully_periodic(domain: Domain2D) -> bool: + geom = geometry2d(domain) + return bool(all(geom.periodic_axes)) + + +def _iter_edge_vertex_indices(edge: dict[str, Any]) -> list[int]: + idx = edge.get('vertices') + if idx is None: + return [] + return [int(x) for x in idx] + + +def validate_normalized_topology( + normalized: NormalizedVertices | NormalizedTopology, + domain: Domain2D, + *, + level: Literal['basic', 'strict'] = 'basic', + check_vertex_edge_shift: bool = True, + check_edge_vertex_sets: bool = True, + check_incidence: bool = True, + check_polygon: bool = True, + max_examples: int = 10, +) -> NormalizationDiagnostics: + """Validate periodic shift and topology consistency after normalization.""" + + if level not in ('basic', 'strict'): + raise ValueError("level must be 'basic' or 'strict'") + + cells = list(normalized.cells) + n_cells = len(cells) + n_global_vertices = int(normalized.global_vertices.shape[0]) + n_global_edges: int | None = None + if isinstance(normalized, NormalizedTopology): + n_global_edges = len(normalized.global_edges) + + periodic = _is_periodic_domain(domain) + fully_periodic = _fully_periodic(domain) + + has_wall_edges = False + for cell in cells: + for edge in cell.get('edges') or []: + if int(edge.get('adjacent_cell', -1)) < 0: + has_wall_edges = True + break + if has_wall_edges: + break + + issues: list[NormalizationIssue] = [] + + cell_by_id: dict[int, dict[str, Any]] = {} + gid_shift_by_cell: dict[int, dict[int, set[tuple[int, int]]]] = {} + + for cell in cells: + cid = int(cell.get('id', -1)) + if cid < 0: + continue + cell_by_id[cid] = cell + + gids = cell.get('vertex_global_id') + vsh = cell.get('vertex_shift') + if gids is None or vsh is None: + continue + mapping: dict[int, set[tuple[int, int]]] = {} + for k, gid in enumerate(gids): + g = int(gid) + s = _as_shift(vsh[k]) + mapping.setdefault(g, set()).add(s) + gid_shift_by_cell[cid] = mapping + + n_ves_mismatch = 0 + if periodic and check_vertex_edge_shift: + examples: list[ + tuple[ + int, + int, + tuple[int, int], + int, + tuple[tuple[int, int], ...], + tuple[int, int], + ] + ] = [] + missing_neighbor_cells: list[tuple[int, int, tuple[int, int]]] = [] + missing_shared_vertex: list[tuple[int, int, tuple[int, int], int]] = [] + + for cell in cells: + cid = int(cell.get('id', -1)) + if cid < 0 or bool(cell.get('empty', False)): + continue + edges = cell.get('edges') or [] + gids = cell.get('vertex_global_id') + vsh = cell.get('vertex_shift') + if gids is None or vsh is None: + continue + + gids_list = [int(x) for x in gids] + vsh_list = [_as_shift(x) for x in vsh] + + for edge in edges: + j = int(edge.get('adjacent_cell', -1)) + if j < 0: + continue + if 'adjacent_shift' not in edge: + issues.append( + NormalizationIssue( + code='EDGE_MISSING_ADJACENT_SHIFT', + severity='error', + message=( + 'A periodic neighbor edge is missing adjacent_shift. ' + 'Ensure compute(..., return_edge_shifts=True) was used.' + ), + examples=((cid, j),), + ) + ) + continue + + s = _as_shift(edge.get('adjacent_shift', (0, 0))) + cj = cell_by_id.get(j) + if cj is None: + if len(missing_neighbor_cells) < max_examples: + missing_neighbor_cells.append((cid, j, s)) + continue + map_j = gid_shift_by_cell.get(j) + if map_j is None: + if len(missing_neighbor_cells) < max_examples: + missing_neighbor_cells.append((cid, j, s)) + continue + + for lv in _iter_edge_vertex_indices(edge): + if lv < 0 or lv >= len(gids_list): + continue + gid = gids_list[lv] + si = vsh_list[lv] + sj_set = map_j.get(gid) + if not sj_set: + n_ves_mismatch += 1 + if len(missing_shared_vertex) < max_examples: + missing_shared_vertex.append((cid, j, s, gid)) + continue + expected_set = {(sj[0] + s[0], sj[1] + s[1]) for sj in sj_set} + if si not in expected_set: + n_ves_mismatch += 1 + if len(examples) < max_examples: + examples.append((cid, gid, si, j, tuple(sorted(sj_set)), s)) + + if missing_neighbor_cells: + issues.append( + NormalizationIssue( + code='MISSING_NEIGHBOR_CELL', + severity='warning', + message=( + 'Some reciprocal neighbor cells are missing from the ' + 'cell list.' + ), + examples=tuple(missing_neighbor_cells), + ) + ) + if missing_shared_vertex: + issues.append( + NormalizationIssue( + code='MISSING_SHARED_VERTEX', + severity='error', + message=( + 'A reciprocal neighboring cell does not contain a shared ' + 'global vertex referenced by a periodic edge.' + ), + examples=tuple(missing_shared_vertex), + ) + ) + if examples: + issues.append( + NormalizationIssue( + code='VERTEX_EDGE_SHIFT_MISMATCH', + severity='error', + message=( + 'vertex_shift values disagree with edge adjacent_shift across ' + 'reciprocal neighboring cells.' + ), + examples=tuple(examples), + ) + ) + + n_evt_mismatch = 0 + if periodic and check_edge_vertex_sets: + examples: list[tuple[int, int, tuple[int, int]]] = [] + for cell in cells: + cid = int(cell.get('id', -1)) + if cid < 0 or bool(cell.get('empty', False)): + continue + gids = cell.get('vertex_global_id') + if gids is None: + continue + edges = cell.get('edges') or [] + for edge in edges: + j = int(edge.get('adjacent_cell', -1)) + if j < 0 or 'adjacent_shift' not in edge: + continue + s = _as_shift(edge.get('adjacent_shift', (0, 0))) + cj = cell_by_id.get(j) + if cj is None: + continue + gids_here = tuple( + sorted(int(gids[v]) for v in _iter_edge_vertex_indices(edge)) + ) + found = False + for edge_j in cj.get('edges') or []: + if int(edge_j.get('adjacent_cell', -1)) != cid: + continue + if _as_shift(edge_j.get('adjacent_shift', (0, 0))) != ( + -s[0], + -s[1], + ): + continue + gids_j = cj.get('vertex_global_id') + if gids_j is None: + continue + peer = tuple( + sorted( + int(gids_j[v]) + for v in _iter_edge_vertex_indices(edge_j) + ) + ) + if peer == gids_here: + found = True + break + if not found: + n_evt_mismatch += 1 + if len(examples) < max_examples: + examples.append((cid, j, s)) + if examples: + issues.append( + NormalizationIssue( + code='EDGE_VERTEX_SET_MISMATCH', + severity='error', + message=( + 'Reciprocal periodic edges do not reference the same set ' + 'of global vertex ids.' + ), + examples=tuple(examples), + ) + ) + + n_vertices_low_incidence = 0 + if ( + isinstance(normalized, NormalizedTopology) + and check_incidence + and fully_periodic + and not has_wall_edges + ): + inc: dict[int, set[int]] = {i: set() for i in range(n_global_vertices)} + for eid, edge in enumerate(normalized.global_edges): + for gid in edge.get('vertices', ()): + inc[int(gid)].add(eid) + examples: list[tuple[int, int]] = [] + for gid, eids in inc.items(): + if len(eids) < 3: + n_vertices_low_incidence += 1 + if len(examples) < max_examples: + examples.append((gid, len(eids))) + if examples: + issues.append( + NormalizationIssue( + code='LOW_VERTEX_INCIDENCE', + severity='warning', + message=( + 'Some global vertices have low edge incidence in a fully ' + 'periodic planar tessellation.' + ), + examples=tuple(examples), + ) + ) + + n_cells_bad_polygon = 0 + if check_polygon: + examples: list[tuple[int, int, int]] = [] + for cell in cells: + cid = int(cell.get('id', -1)) + if cid < 0 or bool(cell.get('empty', False)): + continue + verts = cell.get('vertices') or [] + edges = cell.get('edges') or [] + nv = len(verts) + ne = len(edges) + if nv != ne: + n_cells_bad_polygon += 1 + if len(examples) < max_examples: + examples.append((cid, nv, ne)) + if examples: + issues.append( + NormalizationIssue( + code='BAD_POLYGON_COUNT', + severity='warning', + message=( + 'Some cells do not satisfy the expected planar polygon ' + 'count V == E.' + ), + examples=tuple(examples), + ) + ) + + ok_vertex_edge_shift = n_ves_mismatch == 0 + ok_edge_vertex_sets = n_evt_mismatch == 0 + ok_incidence = n_vertices_low_incidence == 0 + ok_polygon = n_cells_bad_polygon == 0 + ok = ok_vertex_edge_shift and ok_edge_vertex_sets and ok_incidence and ok_polygon + + diag = NormalizationDiagnostics( + n_cells=int(n_cells), + n_global_vertices=int(n_global_vertices), + n_global_edges=(int(n_global_edges) if n_global_edges is not None else None), + is_periodic_domain=bool(periodic), + fully_periodic_domain=bool(fully_periodic), + has_wall_edges=bool(has_wall_edges), + n_vertex_edge_shift_mismatch=int(n_ves_mismatch), + n_edge_vertex_set_mismatch=int(n_evt_mismatch), + n_vertices_low_incidence=int(n_vertices_low_incidence), + n_cells_bad_polygon=int(n_cells_bad_polygon), + issues=tuple(issues), + ok_vertex_edge_shift=bool(ok_vertex_edge_shift), + ok_edge_vertex_sets=bool(ok_edge_vertex_sets), + ok_incidence=bool(ok_incidence), + ok_polygon=bool(ok_polygon), + ok=bool(ok), + ) + + if level == 'strict' and not diag.ok: + raise NormalizationError( + 'Normalized planar topology validation failed: ' + f'vertex_edge_shift_mismatch={diag.n_vertex_edge_shift_mismatch}, ' + f'edge_vertex_set_mismatch={diag.n_edge_vertex_set_mismatch}, ' + f'low_incidence_vertices={diag.n_vertices_low_incidence}, ' + f'bad_polygon_cells={diag.n_cells_bad_polygon}', + diag, + ) + + return diag diff --git a/src/pyvoro2/powerfit/__init__.py b/src/pyvoro2/powerfit/__init__.py new file mode 100644 index 0000000..fd67d70 --- /dev/null +++ b/src/pyvoro2/powerfit/__init__.py @@ -0,0 +1,86 @@ +"""Public API for inverse fitting of power weights from pairwise constraints.""" + +from __future__ import annotations + +from .constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from .model import ( + ExponentialBoundaryPenalty, + FitModel, + FixedValue, + HuberLoss, + Interval, + L2Regularization, + ReciprocalBoundaryPenalty, + SoftIntervalPenalty, + SquaredLoss, +) +from .active import ( + ActiveSetIteration, + ActiveSetOptions, + ActiveSetPathSummary, + PairConstraintDiagnostics, + SelfConsistentPowerFitResult, + solve_self_consistent_power_weights, +) +from .realize import ( + RealizedPairDiagnostics, + UnaccountedRealizedPair, + UnaccountedRealizedPairError, + match_realized_pairs, +) +from .report import ( + build_active_set_report, + build_fit_report, + build_realized_report, + dumps_report_json, + write_report_json, +) +from .solver import ( + ConnectivityDiagnostics, + ConnectivityDiagnosticsError, + ConstraintGraphDiagnostics, + HardConstraintConflict, + HardConstraintConflictTerm, + PowerWeightFitResult, + fit_power_weights, + radii_to_weights, + weights_to_radii, +) + +__all__ = [ + 'PairBisectorConstraints', + 'resolve_pair_bisector_constraints', + 'SquaredLoss', + 'HuberLoss', + 'Interval', + 'FixedValue', + 'SoftIntervalPenalty', + 'ExponentialBoundaryPenalty', + 'ReciprocalBoundaryPenalty', + 'L2Regularization', + 'FitModel', + 'ConstraintGraphDiagnostics', + 'ConnectivityDiagnostics', + 'ConnectivityDiagnosticsError', + 'HardConstraintConflictTerm', + 'HardConstraintConflict', + 'PowerWeightFitResult', + 'RealizedPairDiagnostics', + 'UnaccountedRealizedPair', + 'UnaccountedRealizedPairError', + 'build_fit_report', + 'build_realized_report', + 'build_active_set_report', + 'dumps_report_json', + 'write_report_json', + 'ActiveSetOptions', + 'ActiveSetIteration', + 'ActiveSetPathSummary', + 'PairConstraintDiagnostics', + 'SelfConsistentPowerFitResult', + 'fit_power_weights', + 'match_realized_pairs', + 'solve_self_consistent_power_weights', + 'radii_to_weights', + 'weights_to_radii', +] diff --git a/src/pyvoro2/powerfit/active.py b/src/pyvoro2/powerfit/active.py new file mode 100644 index 0000000..c3b1419 --- /dev/null +++ b/src/pyvoro2/powerfit/active.py @@ -0,0 +1,963 @@ +"""Self-consistent active-set refinement for pairwise separator constraints.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Literal + +import numpy as np + +from .constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from .model import FitModel +from .realize import RealizedPairDiagnostics, match_realized_pairs +from .solver import ( + ConnectivityDiagnostics, + PowerWeightFitResult, + _apply_connectivity_policy, + _build_active_set_connectivity_diagnostics, + _connected_components, + _difference_identifying_mask, + _predict_measurements, + fit_power_weights, + weights_to_radii, +) +from ..diagnostics import TessellationDiagnostics as TessellationDiagnostics3D +from ..domains import Box as Box3D, OrthorhombicCell, PeriodicCell +from ..planar.diagnostics import TessellationDiagnostics as TessellationDiagnostics2D +from ..planar.domains import Box as Box2D, RectangularCell + +ShiftTuple = tuple[int, ...] + + +def _label_value( + values: np.ndarray, + index: int, + ids: np.ndarray | None, +) -> object: + if ids is None: + return int(values[index]) + item = ids[int(values[index])] + return item.item() if hasattr(item, 'item') else item + + +def _boundary_value(values: np.ndarray | None, index: int) -> float | None: + if values is None or np.isnan(values[index]): + return None + return float(values[index]) + + +def _require_self_consistent_supported_dim( + constraints: PairBisectorConstraints, +) -> None: + if constraints.dim not in (2, 3): + raise ValueError( + 'solve_self_consistent_power_weights currently supports only 2D ' + 'and 3D resolved constraints' + ) + + +@dataclass(frozen=True, slots=True) +class ActiveSetOptions: + add_after: int = 1 + drop_after: int = 2 + relax: float = 1.0 + max_iter: int = 25 + cycle_window: int = 8 + weight_step_tol: float = 1e-8 + + def __post_init__(self) -> None: + if int(self.add_after) <= 0: + raise ValueError('ActiveSetOptions.add_after must be > 0') + if int(self.drop_after) <= 0: + raise ValueError('ActiveSetOptions.drop_after must be > 0') + if not (0.0 < float(self.relax) <= 1.0): + raise ValueError('ActiveSetOptions.relax must lie in (0, 1]') + if int(self.max_iter) <= 0: + raise ValueError('ActiveSetOptions.max_iter must be > 0') + if int(self.cycle_window) <= 0: + raise ValueError('ActiveSetOptions.cycle_window must be > 0') + if float(self.weight_step_tol) < 0.0: + raise ValueError('ActiveSetOptions.weight_step_tol must be >= 0') + + +@dataclass(frozen=True, slots=True) +class ActiveSetIteration: + iteration: int + n_active: int + n_realized: int + n_added: int + n_removed: int + rms_residual_all: float + max_residual_all: float + weight_step_norm: float + n_active_fit: int | None = None + fit_active_graph_n_components: int | None = None + fit_active_effective_graph_n_components: int | None = None + fit_active_offsets_identified_by_data: bool | None = None + n_unaccounted_pairs: int | None = None + + +@dataclass(frozen=True, slots=True) +class ActiveSetPathSummary: + """Compact summary of transient active-set path diagnostics.""" + + n_iterations: int + ever_fit_active_graph_disconnected: bool + ever_fit_active_effective_graph_disconnected: bool + ever_fit_active_offsets_unidentified_by_data: bool + ever_unaccounted_pairs: bool + max_fit_active_graph_components: int + max_fit_active_effective_graph_components: int + max_n_unaccounted_pairs: int + first_fit_active_graph_disconnected_iter: int | None = None + first_fit_active_effective_graph_disconnected_iter: int | None = None + first_unaccounted_pairs_iter: int | None = None + + +@dataclass(slots=True) +class _ActiveSetPathAccumulator: + n_iterations: int = 0 + ever_fit_active_graph_disconnected: bool = False + ever_fit_active_effective_graph_disconnected: bool = False + ever_fit_active_offsets_unidentified_by_data: bool = False + ever_unaccounted_pairs: bool = False + max_fit_active_graph_components: int = 0 + max_fit_active_effective_graph_components: int = 0 + max_n_unaccounted_pairs: int = 0 + first_fit_active_graph_disconnected_iter: int | None = None + first_fit_active_effective_graph_disconnected_iter: int | None = None + first_unaccounted_pairs_iter: int | None = None + + +@dataclass(frozen=True, slots=True) +class PairConstraintDiagnostics: + site_i: np.ndarray + site_j: np.ndarray + shift: np.ndarray + target: np.ndarray + confidence: np.ndarray + predicted: np.ndarray + predicted_fraction: np.ndarray + predicted_position: np.ndarray + residuals: np.ndarray + active: np.ndarray + realized: np.ndarray + realized_same_shift: np.ndarray + realized_other_shift: np.ndarray + realized_shifts: tuple[tuple[ShiftTuple, ...], ...] + endpoint_i_empty: np.ndarray + endpoint_j_empty: np.ndarray + boundary_measure: np.ndarray | None + toggle_count: np.ndarray + realized_toggle_count: np.ndarray + first_realized_iter: np.ndarray + last_realized_iter: np.ndarray + marginal: np.ndarray + status: tuple[str, ...] + + def to_records( + self, *, ids: np.ndarray | None = None + ) -> tuple[dict[str, object], ...]: + """Return one plain-Python record per candidate pair.""" + + rows: list[dict[str, object]] = [] + for k in range(int(self.site_i.shape[0])): + realized_shifts = tuple( + tuple(int(v) for v in shift) + for shift in self.realized_shifts[k] + ) + rows.append( + { + 'constraint_index': int(k), + 'site_i': _label_value(self.site_i, k, ids), + 'site_j': _label_value(self.site_j, k, ids), + 'shift': tuple(int(v) for v in self.shift[k]), + 'target': float(self.target[k]), + 'confidence': float(self.confidence[k]), + 'predicted': float(self.predicted[k]), + 'predicted_fraction': float(self.predicted_fraction[k]), + 'predicted_position': float(self.predicted_position[k]), + 'residual': float(self.residuals[k]), + 'active': bool(self.active[k]), + 'realized': bool(self.realized[k]), + 'realized_same_shift': bool(self.realized_same_shift[k]), + 'realized_other_shift': bool(self.realized_other_shift[k]), + 'realized_shifts': realized_shifts, + 'endpoint_i_empty': bool(self.endpoint_i_empty[k]), + 'endpoint_j_empty': bool(self.endpoint_j_empty[k]), + 'boundary_measure': _boundary_value(self.boundary_measure, k), + 'toggle_count': int(self.toggle_count[k]), + 'realized_toggle_count': int(self.realized_toggle_count[k]), + 'first_realized_iter': int(self.first_realized_iter[k]), + 'last_realized_iter': int(self.last_realized_iter[k]), + 'marginal': bool(self.marginal[k]), + 'status': self.status[k], + } + ) + return tuple(rows) + + +@dataclass(frozen=True, slots=True) +class SelfConsistentPowerFitResult: + constraints: PairBisectorConstraints + fit: PowerWeightFitResult + realized: RealizedPairDiagnostics + diagnostics: PairConstraintDiagnostics + active_mask: np.ndarray + n_outer_iter: int + converged: bool + termination: Literal[ + 'self_consistent', + 'cycle_detected', + 'max_outer_iter', + 'infeasible_active_set', + 'numerical_failure', + ] + cycle_length: int | None + marginal_constraints: tuple[int, ...] + rms_residual_all: float + max_residual_all: float + tessellation_diagnostics: ( + TessellationDiagnostics2D | TessellationDiagnostics3D | None + ) + history: tuple[ActiveSetIteration, ...] | None + path_summary: ActiveSetPathSummary | None = None + warnings: tuple[str, ...] = () + connectivity: ConnectivityDiagnostics | None = None + + def to_records(self, *, use_ids: bool = False) -> tuple[dict[str, object], ...]: + """Return one plain-Python record per candidate pair.""" + + ids = self.constraints.ids if use_ids else None + return self.diagnostics.to_records(ids=ids) + + def to_report(self, *, use_ids: bool = False) -> dict[str, object]: + """Return a JSON-friendly report for this active-set solve.""" + + from .report import build_active_set_report + + return build_active_set_report(self, use_ids=use_ids) + + +def solve_self_consistent_power_weights( + points: np.ndarray, + constraints: PairBisectorConstraints | list[tuple] | tuple[tuple, ...], + *, + measurement: Literal['fraction', 'position'] = 'fraction', + domain: Box2D | RectangularCell | Box3D | OrthorhombicCell | PeriodicCell, + ids: list[int] | tuple[int, ...] | np.ndarray | None = None, + index_mode: Literal['index', 'id'] = 'index', + image: Literal['nearest', 'given_only'] = 'nearest', + image_search: int = 1, + confidence: list[float] | tuple[float, ...] | np.ndarray | None = None, + model: FitModel | None = None, + active0: np.ndarray | None = None, + options: ActiveSetOptions | None = None, + r_min: float = 0.0, + weight_shift: float | None = None, + fit_solver: Literal['auto', 'analytic', 'admm'] = 'auto', + fit_max_iter: int = 2000, + fit_rho: float = 1.0, + fit_tol_abs: float = 1e-6, + fit_tol_rel: float = 1e-5, + return_history: bool = False, + return_cells: bool = False, + return_boundary_measure: bool = False, + return_tessellation_diagnostics: bool = False, + tessellation_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'diagnose', + connectivity_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'warn', + unaccounted_pair_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'warn', +) -> SelfConsistentPowerFitResult: + """Iteratively refine an active pair set against realized power-diagram + boundaries.""" + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] <= 0: + raise ValueError('points must have shape (n, d) with d >= 1') + if connectivity_check not in ('none', 'diagnose', 'warn', 'raise'): + raise ValueError( + 'connectivity_check must be none, diagnose, warn, or raise' + ) + if unaccounted_pair_check not in ('none', 'diagnose', 'warn', 'raise'): + raise ValueError( + 'unaccounted_pair_check must be none, diagnose, warn, or raise' + ) + + if model is None: + model = FitModel() + if options is None: + options = ActiveSetOptions() + + if isinstance(constraints, PairBisectorConstraints): + resolved = constraints + if resolved.n_points != pts.shape[0]: + raise ValueError('resolved constraints do not match the number of points') + if resolved.dim != pts.shape[1]: + raise ValueError('resolved constraints do not match the point dimension') + _require_self_consistent_supported_dim(resolved) + else: + if pts.shape[1] not in (2, 3): + raise ValueError( + 'solve_self_consistent_power_weights currently supports only ' + '2D and 3D points' + ) + resolved = resolve_pair_bisector_constraints( + pts, + constraints, + measurement=measurement, + domain=domain, + ids=ids, + index_mode=index_mode, + image=image, + image_search=image_search, + confidence=confidence, + allow_empty=True, + ) + + m = resolved.n_constraints + if active0 is None: + active = np.ones(m, dtype=bool) + else: + active = np.asarray(active0, dtype=bool).copy() + if active.shape != (m,): + raise ValueError('active0 must have shape (m,)') + + warnings_list = list(resolved.warnings) + add_streak = np.zeros(m, dtype=np.int64) + drop_streak = np.zeros(m, dtype=np.int64) + toggle_count = np.zeros(m, dtype=np.int64) + realized_toggle_count = np.zeros(m, dtype=np.int64) + first_realized_iter = np.full(m, -1, dtype=np.int64) + last_realized_iter = np.full(m, -1, dtype=np.int64) + history_rows: list[ActiveSetIteration] = [] + path_acc = _ActiveSetPathAccumulator() + gauge_policy = _self_consistent_gauge_policy_description() + prev_weights_eval: np.ndarray | None = None + prev_realized_same: np.ndarray | None = None + seen_masks: dict[bytes, int] = {active.tobytes(): 0} + + termination: Literal[ + 'self_consistent', + 'cycle_detected', + 'max_outer_iter', + 'infeasible_active_set', + 'numerical_failure', + ] = 'max_outer_iter' + cycle_length: int | None = None + converged = False + last_diag: RealizedPairDiagnostics | None = None + + for outer_iter in range(1, options.max_iter + 1): + active_constraints = resolved.subset(active) + fit = fit_power_weights( + pts, + active_constraints, + model=model, + r_min=r_min, + weight_shift=weight_shift, + solver=fit_solver, + max_iter=fit_max_iter, + rho=fit_rho, + tol_abs=fit_tol_abs, + tol_rel=fit_tol_rel, + connectivity_check='diagnose', + ) + if fit.weights is None: + warnings_list.extend(fit.warnings) + termination = ( + 'numerical_failure' + if fit.status == 'numerical_failure' + else 'infeasible_active_set' + ) + final_realized = _empty_realized_pair_diagnostics( + m, + return_boundary_measure=return_boundary_measure, + ) + diag_all = PairConstraintDiagnostics( + site_i=resolved.i.copy(), + site_j=resolved.j.copy(), + shift=resolved.shifts.copy(), + target=resolved.target.copy(), + confidence=resolved.confidence.copy(), + predicted=np.full(m, np.nan, dtype=np.float64), + predicted_fraction=np.full(m, np.nan, dtype=np.float64), + predicted_position=np.full(m, np.nan, dtype=np.float64), + residuals=np.full(m, np.nan, dtype=np.float64), + active=active.copy(), + realized=final_realized.realized.copy(), + realized_same_shift=final_realized.realized_same_shift.copy(), + realized_other_shift=final_realized.realized_other_shift.copy(), + realized_shifts=final_realized.realized_shifts, + endpoint_i_empty=final_realized.endpoint_i_empty.copy(), + endpoint_j_empty=final_realized.endpoint_j_empty.copy(), + boundary_measure=( + None + if final_realized.boundary_measure is None + else final_realized.boundary_measure.copy() + ), + toggle_count=toggle_count.copy(), + realized_toggle_count=realized_toggle_count.copy(), + first_realized_iter=first_realized_iter.copy(), + last_realized_iter=last_realized_iter.copy(), + marginal=np.zeros(m, dtype=bool), + status=tuple(termination for _ in range(m)), + ) + connectivity = None + if connectivity_check != 'none': + connectivity = _build_active_set_connectivity_diagnostics( + resolved, + active, + model=model, + gauge_policy=gauge_policy, + ) + _apply_connectivity_policy( + connectivity_check, + connectivity, + warnings_list, + ) + return SelfConsistentPowerFitResult( + constraints=resolved, + fit=fit, + realized=final_realized, + diagnostics=diag_all, + active_mask=active.copy(), + n_outer_iter=outer_iter, + converged=False, + termination=termination, + cycle_length=None, + marginal_constraints=tuple(), + rms_residual_all=float('nan'), + max_residual_all=float('nan'), + tessellation_diagnostics=None, + history=tuple(history_rows) if return_history else None, + path_summary=_finalize_path_summary(path_acc), + warnings=tuple(warnings_list), + connectivity=connectivity, + ) + + weights_exact = fit.weights.copy() + if prev_weights_eval is not None: + weights_exact = _align_weights_to_reference( + weights_exact, + prev_weights_eval, + _active_alignment_components(active_constraints, model), + ) + weights_eval = ( + (1.0 - float(options.relax)) * prev_weights_eval + + float(options.relax) * weights_exact + ) + step_norm = float(np.linalg.norm(weights_eval - prev_weights_eval)) + else: + weights_eval = weights_exact + step_norm = 0.0 + + fit_active_connectivity = _build_active_set_connectivity_diagnostics( + resolved, + active, + model=model, + gauge_policy=gauge_policy, + ) + radii_eval, _ = weights_to_radii( + weights_eval, + r_min=r_min, + weight_shift=weight_shift, + ) + diag = match_realized_pairs( + pts, + domain=domain, + radii=radii_eval, + constraints=resolved, + return_boundary_measure=False, + return_cells=False, + return_tessellation_diagnostics=False, + tessellation_check='none', + unaccounted_pair_check='diagnose', + ) + last_diag = diag + n_unaccounted_pairs = len(diag.unaccounted_pairs) + _record_path_iteration( + path_acc, + iteration=outer_iter, + connectivity=fit_active_connectivity, + n_unaccounted_pairs=n_unaccounted_pairs, + ) + realized_same = diag.realized_same_shift + if prev_realized_same is not None: + realized_toggle_count += prev_realized_same != realized_same + newly_realized = realized_same & (first_realized_iter < 0) + first_realized_iter[newly_realized] = outer_iter + last_realized_iter[realized_same] = outer_iter + + new_active = active.copy() + for k in range(m): + if realized_same[k]: + add_streak[k] += 1 + drop_streak[k] = 0 + else: + drop_streak[k] += 1 + add_streak[k] = 0 + + if active[k]: + if drop_streak[k] >= options.drop_after: + new_active[k] = False + else: + if add_streak[k] >= options.add_after: + new_active[k] = True + + toggled = new_active != active + toggle_count += toggled + n_added = int(np.count_nonzero((~active) & new_active)) + n_removed = int(np.count_nonzero(active & (~new_active))) + + pred_fraction, pred_position, pred = _predict_measurements( + weights_eval, + resolved, + ) + target = ( + resolved.target_fraction + if resolved.measurement == 'fraction' + else resolved.target_position + ) + residuals = pred - target + history_rows.append( + ActiveSetIteration( + iteration=outer_iter, + n_active=int(np.count_nonzero(new_active)), + n_realized=int(np.count_nonzero(realized_same)), + n_added=n_added, + n_removed=n_removed, + rms_residual_all=float(np.sqrt(np.mean(residuals * residuals))) + if residuals.size + else 0.0, + max_residual_all=float(np.max(np.abs(residuals))) + if residuals.size + else 0.0, + weight_step_norm=step_norm, + n_active_fit=int(np.count_nonzero(active)), + fit_active_graph_n_components=( + fit_active_connectivity.active_graph.n_components + if fit_active_connectivity.active_graph is not None + else None + ), + fit_active_effective_graph_n_components=( + fit_active_connectivity.active_effective_graph.n_components + if fit_active_connectivity.active_effective_graph is not None + else None + ), + fit_active_offsets_identified_by_data=( + fit_active_connectivity.active_offsets_identified_by_data + ), + n_unaccounted_pairs=n_unaccounted_pairs, + ) + ) + + if ( + np.array_equal(new_active, active) + and np.array_equal(realized_same, active) + and step_norm <= float(options.weight_step_tol) + ): + active = new_active + prev_weights_eval = weights_eval + prev_realized_same = realized_same.copy() + termination = 'self_consistent' + converged = True + break + + active_key = new_active.tobytes() + if np.any(toggled): + if ( + active_key in seen_masks + and outer_iter - seen_masks[active_key] <= options.cycle_window + ): + cycle_length = outer_iter - seen_masks[active_key] + active = new_active + prev_weights_eval = weights_eval + prev_realized_same = realized_same.copy() + termination = 'cycle_detected' + converged = False + break + seen_masks[active_key] = outer_iter + + active = new_active + prev_weights_eval = weights_eval + prev_realized_same = realized_same.copy() + else: + termination = 'max_outer_iter' + + active_constraints = resolved.subset(active) + final_fit = fit_power_weights( + pts, + active_constraints, + model=model, + r_min=r_min, + weight_shift=weight_shift, + solver=fit_solver, + max_iter=fit_max_iter, + rho=fit_rho, + tol_abs=fit_tol_abs, + tol_rel=fit_tol_rel, + connectivity_check='diagnose', + ) + warnings_list.extend(final_fit.warnings) + + if final_fit.status == 'numerical_failure': + termination = 'numerical_failure' + converged = False + + if final_fit.weights is not None: + final_weights = final_fit.weights.copy() + if prev_weights_eval is not None: + final_weights = _align_weights_to_reference( + final_weights, + prev_weights_eval, + _active_alignment_components(active_constraints, model), + ) + final_fit = _rebuild_fit_with_weights( + final_fit, + active_constraints, + final_weights, + r_min=r_min, + weight_shift=weight_shift, + ) + final_realized = match_realized_pairs( + pts, + domain=domain, + radii=final_fit.radii, + constraints=resolved, + return_boundary_measure=return_boundary_measure, + return_cells=return_cells, + return_tessellation_diagnostics=return_tessellation_diagnostics, + tessellation_check=tessellation_check, + unaccounted_pair_check=unaccounted_pair_check, + ) + warnings_list.extend(final_realized.warnings) + pred_fraction, pred_position, pred = _predict_measurements( + final_fit.weights, + resolved, + ) + else: + final_realized = last_diag + pred_fraction = np.full(m, np.nan, dtype=np.float64) + pred_position = np.full(m, np.nan, dtype=np.float64) + pred = np.full(m, np.nan, dtype=np.float64) + if final_realized is None: + final_realized = _empty_realized_pair_diagnostics( + m, + return_boundary_measure=return_boundary_measure, + ) + + target = ( + resolved.target_fraction + if resolved.measurement == 'fraction' + else resolved.target_position + ) + residuals = pred - target + rms_residual_all = ( + float(np.sqrt(np.mean(residuals * residuals))) if residuals.size else 0.0 + ) + max_residual_all = float(np.max(np.abs(residuals))) if residuals.size else 0.0 + + marginal = (toggle_count > 0) | final_realized.realized_other_shift + if termination == 'cycle_detected': + marginal = marginal | (realized_toggle_count > 0) + marginal_constraints = tuple(np.flatnonzero(marginal).tolist()) + status = _build_constraint_statuses( + active=active, + realized=final_realized, + toggle_count=toggle_count, + realized_toggle_count=realized_toggle_count, + termination=termination, + ) + + diag_all = PairConstraintDiagnostics( + site_i=resolved.i.copy(), + site_j=resolved.j.copy(), + shift=resolved.shifts.copy(), + target=resolved.target.copy(), + confidence=resolved.confidence.copy(), + predicted=pred, + predicted_fraction=pred_fraction, + predicted_position=pred_position, + residuals=residuals, + active=active.copy(), + realized=final_realized.realized.copy(), + realized_same_shift=final_realized.realized_same_shift.copy(), + realized_other_shift=final_realized.realized_other_shift.copy(), + realized_shifts=final_realized.realized_shifts, + endpoint_i_empty=final_realized.endpoint_i_empty.copy(), + endpoint_j_empty=final_realized.endpoint_j_empty.copy(), + boundary_measure=( + None + if final_realized.boundary_measure is None + else final_realized.boundary_measure.copy() + ), + toggle_count=toggle_count.copy(), + realized_toggle_count=realized_toggle_count.copy(), + first_realized_iter=first_realized_iter.copy(), + last_realized_iter=last_realized_iter.copy(), + marginal=marginal.copy(), + status=status, + ) + + connectivity = None + if connectivity_check != 'none': + connectivity = _build_active_set_connectivity_diagnostics( + resolved, + active, + model=model, + gauge_policy=gauge_policy, + ) + _apply_connectivity_policy( + connectivity_check, + connectivity, + warnings_list, + ) + + return SelfConsistentPowerFitResult( + constraints=resolved, + fit=final_fit, + realized=final_realized, + diagnostics=diag_all, + active_mask=active.copy(), + n_outer_iter=len(history_rows), + converged=converged, + termination=termination, + cycle_length=cycle_length, + marginal_constraints=marginal_constraints, + rms_residual_all=rms_residual_all, + max_residual_all=max_residual_all, + tessellation_diagnostics=final_realized.tessellation_diagnostics, + history=tuple(history_rows) if return_history else None, + path_summary=_finalize_path_summary(path_acc), + warnings=tuple(warnings_list), + connectivity=connectivity, + ) + + +def _align_weights_to_reference( + weights: np.ndarray, reference: np.ndarray, comps: list[list[int]] +) -> np.ndarray: + aligned = np.asarray(weights, dtype=np.float64).copy() + ref = np.asarray(reference, dtype=np.float64) + if aligned.shape != ref.shape: + raise ValueError('weights and reference must have the same shape') + for comp in comps: + idx = np.asarray(comp, dtype=np.int64) + if idx.size == 0: + continue + shift = float(np.mean(aligned[idx] - ref[idx])) + aligned[idx] -= shift + return aligned + + +def _active_alignment_components( + constraints: PairBisectorConstraints, + model: FitModel, +) -> list[list[int]]: + effective_mask = _difference_identifying_mask(constraints, model) + return _connected_components( + constraints.n_points, + constraints.i[effective_mask], + constraints.j[effective_mask], + ) + + +def _self_consistent_gauge_policy_description() -> str: + return ( + 'each connected active effective component is aligned to the previous ' + 'iterate; the first iterate falls back to the standalone component-mean ' + 'gauge' + ) + + +def _record_path_iteration( + acc: _ActiveSetPathAccumulator, + *, + iteration: int, + connectivity: ConnectivityDiagnostics, + n_unaccounted_pairs: int, +) -> None: + active_graph = connectivity.active_graph + active_effective_graph = connectivity.active_effective_graph + active_offsets_identified = connectivity.active_offsets_identified_by_data + + active_graph_components = ( + 0 if active_graph is None else int(active_graph.n_components) + ) + active_effective_components = ( + 0 + if active_effective_graph is None + else int(active_effective_graph.n_components) + ) + + acc.n_iterations += 1 + acc.max_fit_active_graph_components = max( + acc.max_fit_active_graph_components, + active_graph_components, + ) + acc.max_fit_active_effective_graph_components = max( + acc.max_fit_active_effective_graph_components, + active_effective_components, + ) + acc.max_n_unaccounted_pairs = max( + acc.max_n_unaccounted_pairs, + int(n_unaccounted_pairs), + ) + + if active_graph_components > 1: + acc.ever_fit_active_graph_disconnected = True + if acc.first_fit_active_graph_disconnected_iter is None: + acc.first_fit_active_graph_disconnected_iter = int(iteration) + if active_effective_components > 1: + acc.ever_fit_active_effective_graph_disconnected = True + if acc.first_fit_active_effective_graph_disconnected_iter is None: + acc.first_fit_active_effective_graph_disconnected_iter = int(iteration) + if active_offsets_identified is False: + acc.ever_fit_active_offsets_unidentified_by_data = True + if int(n_unaccounted_pairs) > 0: + acc.ever_unaccounted_pairs = True + if acc.first_unaccounted_pairs_iter is None: + acc.first_unaccounted_pairs_iter = int(iteration) + + +def _finalize_path_summary( + acc: _ActiveSetPathAccumulator, +) -> ActiveSetPathSummary: + return ActiveSetPathSummary( + n_iterations=int(acc.n_iterations), + ever_fit_active_graph_disconnected=bool( + acc.ever_fit_active_graph_disconnected + ), + ever_fit_active_effective_graph_disconnected=bool( + acc.ever_fit_active_effective_graph_disconnected + ), + ever_fit_active_offsets_unidentified_by_data=bool( + acc.ever_fit_active_offsets_unidentified_by_data + ), + ever_unaccounted_pairs=bool(acc.ever_unaccounted_pairs), + max_fit_active_graph_components=int(acc.max_fit_active_graph_components), + max_fit_active_effective_graph_components=int( + acc.max_fit_active_effective_graph_components + ), + max_n_unaccounted_pairs=int(acc.max_n_unaccounted_pairs), + first_fit_active_graph_disconnected_iter=( + None + if acc.first_fit_active_graph_disconnected_iter is None + else int(acc.first_fit_active_graph_disconnected_iter) + ), + first_fit_active_effective_graph_disconnected_iter=( + None + if acc.first_fit_active_effective_graph_disconnected_iter is None + else int(acc.first_fit_active_effective_graph_disconnected_iter) + ), + first_unaccounted_pairs_iter=( + None + if acc.first_unaccounted_pairs_iter is None + else int(acc.first_unaccounted_pairs_iter) + ), + ) + + +def _rebuild_fit_with_weights( + fit: PowerWeightFitResult, + constraints: PairBisectorConstraints, + weights: np.ndarray, + *, + r_min: float, + weight_shift: float | None, +) -> PowerWeightFitResult: + radii, shift = weights_to_radii( + weights, + r_min=r_min, + weight_shift=weight_shift, + ) + pred_fraction, pred_position, pred = _predict_measurements(weights, constraints) + target = ( + constraints.target_fraction + if constraints.measurement == 'fraction' + else constraints.target_position + ) + residuals = pred - target + rms = float(np.sqrt(np.mean(residuals * residuals))) if residuals.size else 0.0 + mx = float(np.max(np.abs(residuals))) if residuals.size else 0.0 + return replace( + fit, + weights=np.asarray(weights, dtype=np.float64).copy(), + radii=radii, + weight_shift=shift, + predicted=pred, + predicted_fraction=pred_fraction, + predicted_position=pred_position, + residuals=residuals, + rms_residual=rms, + max_residual=mx, + ) + + +def _empty_realized_pair_diagnostics( + m: int, *, return_boundary_measure: bool +) -> RealizedPairDiagnostics: + return RealizedPairDiagnostics( + realized=np.zeros(m, dtype=bool), + unrealized=tuple(range(m)), + realized_same_shift=np.zeros(m, dtype=bool), + realized_other_shift=np.zeros(m, dtype=bool), + realized_shifts=tuple(() for _ in range(m)), + endpoint_i_empty=np.zeros(m, dtype=bool), + endpoint_j_empty=np.zeros(m, dtype=bool), + boundary_measure=( + np.full(m, np.nan, dtype=np.float64) if return_boundary_measure else None + ), + cells=None, + tessellation_diagnostics=None, + unaccounted_pairs=tuple(), + warnings=tuple(), + ) + + +def _build_constraint_statuses( + *, + active: np.ndarray, + realized: RealizedPairDiagnostics, + toggle_count: np.ndarray, + realized_toggle_count: np.ndarray, + termination: str, +) -> tuple[str, ...]: + rows: list[str] = [] + for k in range(active.shape[0]): + if termination == 'numerical_failure': + rows.append('numerical_failure') + continue + if termination == 'cycle_detected' and ( + bool(toggle_count[k] > 0) or bool(realized_toggle_count[k] > 0) + ): + rows.append('cycle_member') + continue + if bool(realized.realized_other_shift[k]): + rows.append('realized_other_shift') + continue + if bool(realized.endpoint_i_empty[k] or realized.endpoint_j_empty[k]): + rows.append('endpoint_empty') + continue + if bool(active[k]) and bool(realized.realized_same_shift[k]): + rows.append( + 'toggled_active' + if bool(toggle_count[k] > 0) + else 'stable_active' + ) + continue + if (not bool(active[k])) and (not bool(realized.realized[k])): + rows.append( + 'toggled_inactive' + if bool(toggle_count[k] > 0) + else 'stable_inactive' + ) + continue + if bool(active[k]) and (not bool(realized.realized_same_shift[k])): + rows.append('active_unrealized') + continue + if (not bool(active[k])) and bool(realized.realized_same_shift[k]): + rows.append('inactive_realized') + continue + rows.append('unresolved') + return tuple(rows) diff --git a/src/pyvoro2/powerfit/constraints.py b/src/pyvoro2/powerfit/constraints.py new file mode 100644 index 0000000..ddad5f9 --- /dev/null +++ b/src/pyvoro2/powerfit/constraints.py @@ -0,0 +1,521 @@ +"""Constraint parsing and geometric normalization for inverse power fitting.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Sequence + +import numpy as np + +from .._domain_geometry import geometry3d +from ..domains import Box as Box3D, OrthorhombicCell, PeriodicCell +from ..planar._domain_geometry import geometry2d +from ..planar.domains import Box as Box2D, RectangularCell + +ConstraintRow = tuple[int, int, float] | tuple[int, int, float, Sequence[int]] +ConstraintInput = Sequence[ConstraintRow] +Domain3D = Box3D | OrthorhombicCell | PeriodicCell +Domain2D = Box2D | RectangularCell +DomainAny = Domain2D | Domain3D + + +def _plain_value(value: object) -> object: + return value.item() if hasattr(value, 'item') else value + + +def _validated_ids_array(ids: Sequence[int] | np.ndarray, n_points: int) -> np.ndarray: + """Return validated external ids as a 1D NumPy array. + + The power-fit layer uses ids only as external labels and for mapping raw + constraint tuples when ``index_mode='id'``. The ids must therefore match + the point array length and be unique. + """ + + if len(ids) != n_points: + raise ValueError('ids must have length n_points') + ids_arr = np.asarray(ids) + if ids_arr.shape != (n_points,): + raise ValueError('ids must be a 1D sequence of length n_points') + if np.unique(ids_arr).size != n_points: + raise ValueError('ids must be unique') + return ids_arr + + +@dataclass(frozen=True, slots=True) +class PairBisectorConstraints: + """Resolved pairwise separator constraints. + + This object is the stable boundary between downstream pair-selection logic + and pyvoro2's inverse solver. Each row refers to a specific ordered pair + ``(i, j, shift)`` where ``shift`` is the lattice image applied to site ``j``. + """ + + n_points: int + i: np.ndarray + j: np.ndarray + shifts: np.ndarray + target: np.ndarray + confidence: np.ndarray + measurement: Literal['fraction', 'position'] + distance: np.ndarray + distance2: np.ndarray + delta: np.ndarray + target_fraction: np.ndarray + target_position: np.ndarray + input_index: np.ndarray + explicit_shift: np.ndarray + ids: np.ndarray | None + warnings: tuple[str, ...] + + def __post_init__(self) -> None: + m = int(self.i.shape[0]) + if self.i.shape != (m,) or self.j.shape != (m,): + raise ValueError('PairBisectorConstraints.i/j must have shape (m,)') + if self.shifts.ndim != 2 or self.shifts.shape[0] != m: + raise ValueError('PairBisectorConstraints.shifts must have shape (m, d)') + for name in ( + 'target', + 'confidence', + 'distance', + 'distance2', + 'target_fraction', + 'target_position', + 'input_index', + 'explicit_shift', + ): + arr = getattr(self, name) + if arr.shape != (m,): + raise ValueError(f'PairBisectorConstraints.{name} must have shape (m,)') + if self.delta.ndim != 2 or self.delta.shape[0] != m: + raise ValueError('PairBisectorConstraints.delta must have shape (m, d)') + if self.delta.shape[1] != self.shifts.shape[1]: + raise ValueError( + 'PairBisectorConstraints.delta and shifts must use the same dimension' + ) + if self.measurement not in ('fraction', 'position'): + raise ValueError('measurement must be "fraction" or "position"') + for name in ( + 'target', + 'confidence', + 'distance', + 'distance2', + 'delta', + 'target_fraction', + 'target_position', + ): + arr = np.asarray(getattr(self, name)) + if not np.all(np.isfinite(arr)): + raise ValueError( + f'PairBisectorConstraints.{name} must contain only finite values' + ) + if np.any(self.confidence < 0.0): + raise ValueError('PairBisectorConstraints.confidence must be non-negative') + if np.any(self.distance <= 0.0) or np.any(self.distance2 <= 0.0): + raise ValueError( + 'PairBisectorConstraints distances must be strictly positive' + ) + if self.ids is not None: + ids_arr = np.asarray(self.ids) + if ids_arr.shape != (int(self.n_points),): + raise ValueError( + 'PairBisectorConstraints.ids must have shape (n_points,)' + ) + if np.unique(ids_arr).size != int(self.n_points): + raise ValueError('PairBisectorConstraints.ids must be unique') + + @property + def n_constraints(self) -> int: + return int(self.i.shape[0]) + + @property + def dim(self) -> int: + return int(self.shifts.shape[1]) + + def pair_labels(self, *, use_ids: bool = False) -> tuple[np.ndarray, np.ndarray]: + """Return the left/right pair labels as indices or external ids.""" + + if use_ids: + if self.ids is None: + raise ValueError( + 'use_ids=True requires ids on the resolved constraint set' + ) + return self.ids[self.i].copy(), self.ids[self.j].copy() + return self.i.copy(), self.j.copy() + + def to_records(self, *, use_ids: bool = False) -> tuple[dict[str, object], ...]: + """Return one plain-Python record per constraint row.""" + + left, right = self.pair_labels(use_ids=use_ids) + rows: list[dict[str, object]] = [] + left_is_int = np.issubdtype(np.asarray(left).dtype, np.integer) + right_is_int = np.issubdtype(np.asarray(right).dtype, np.integer) + for k in range(self.n_constraints): + site_i = int(left[k]) if left_is_int else _plain_value(left[k]) + site_j = int(right[k]) if right_is_int else _plain_value(right[k]) + rows.append( + { + 'constraint_index': int(k), + 'site_i': site_i, + 'site_j': site_j, + 'shift': tuple(int(v) for v in self.shifts[k]), + 'target': float(self.target[k]), + 'confidence': float(self.confidence[k]), + 'measurement': self.measurement, + 'distance': float(self.distance[k]), + 'target_fraction': float(self.target_fraction[k]), + 'target_position': float(self.target_position[k]), + 'input_index': int(self.input_index[k]), + 'explicit_shift': bool(self.explicit_shift[k]), + } + ) + return tuple(rows) + + def subset(self, mask: np.ndarray) -> PairBisectorConstraints: + """Return a subset with row order preserved.""" + + mask = np.asarray(mask, dtype=bool) + if mask.shape != (self.n_constraints,): + raise ValueError('mask must have shape (m,)') + return PairBisectorConstraints( + n_points=self.n_points, + i=self.i[mask].copy(), + j=self.j[mask].copy(), + shifts=self.shifts[mask].copy(), + target=self.target[mask].copy(), + confidence=self.confidence[mask].copy(), + measurement=self.measurement, + distance=self.distance[mask].copy(), + distance2=self.distance2[mask].copy(), + delta=self.delta[mask].copy(), + target_fraction=self.target_fraction[mask].copy(), + target_position=self.target_position[mask].copy(), + input_index=self.input_index[mask].copy(), + explicit_shift=self.explicit_shift[mask].copy(), + ids=None if self.ids is None else self.ids.copy(), + warnings=self.warnings, + ) + + +def resolve_pair_bisector_constraints( + points: np.ndarray, + constraints: ConstraintInput, + *, + measurement: Literal['fraction', 'position'] = 'fraction', + domain: DomainAny | None = None, + ids: Sequence[int] | None = None, + index_mode: Literal['index', 'id'] = 'index', + image: Literal['nearest', 'given_only'] = 'nearest', + image_search: int = 1, + confidence: Sequence[float] | None = None, + allow_empty: bool = False, +) -> PairBisectorConstraints: + """Parse and resolve pairwise separator constraints. + + Args: + points: Site coordinates with shape ``(n, d)`` where ``d`` is currently + supported for planar (2D) and spatial (3D) workflows. + constraints: Raw constraint tuples ``(i, j, value[, shift])``. + measurement: Whether ``value`` is interpreted as a normalized fraction + in ``[0, 1]`` or as an absolute position along the connector. + domain: Optional non-periodic or periodic domain. + ids: External ids used when ``index_mode='id'``. + index_mode: Interpret the first two tuple entries as internal indices or + external ids. + image: Shift resolution policy for tuples that do not specify a shift. + image_search: Search radius for nearest-image resolution in triclinic + periodic 3D cells. It is ignored for the current planar backend. + confidence: Optional non-negative per-constraint weights. + allow_empty: Allow zero constraints and return an empty resolved object. + """ + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] not in (2, 3): + raise ValueError('points must have shape (n, d) with d in {2, 3}') + if not np.all(np.isfinite(pts)): + raise ValueError('points must contain only finite values') + if measurement not in ('fraction', 'position'): + raise ValueError('measurement must be "fraction" or "position"') + + ids_arr = None if ids is None else _validated_ids_array(ids, int(pts.shape[0])) + + i_idx, j_idx, target, shifts, shift_given, warnings = _parse_constraints( + constraints, + n_points=pts.shape[0], + ids=ids_arr, + index_mode=index_mode, + allow_empty=allow_empty, + shift_dim=pts.shape[1], + ) + + target_arr = np.asarray(target, dtype=np.float64) + if not np.all(np.isfinite(target_arr)): + raise ValueError('constraint values must contain only finite values') + + m = int(i_idx.shape[0]) + if confidence is None: + omega = np.ones(m, dtype=np.float64) + else: + omega = np.asarray(confidence, dtype=float) + if omega.shape != (m,): + raise ValueError('confidence must have shape (m,)') + if not np.all(np.isfinite(omega)): + raise ValueError('confidence must contain only finite values') + if np.any(omega < 0): + raise ValueError('confidence must be non-negative') + + pts2 = _maybe_remap_points(pts, domain) + shifts_used, warnings2 = _resolve_constraint_shifts( + pts2, + i_idx, + j_idx, + shifts, + shift_given, + domain=domain, + image=image, + image_search=image_search, + ) + warnings = warnings + warnings2 + + if m == 0: + zeros_i = np.zeros(0, dtype=np.int64) + zeros_f = np.zeros(0, dtype=np.float64) + zeros_s = np.zeros((0, pts.shape[1]), dtype=np.int64) + zeros_b = np.zeros(0, dtype=bool) + return PairBisectorConstraints( + n_points=int(pts.shape[0]), + i=zeros_i, + j=zeros_i.copy(), + shifts=zeros_s, + target=zeros_f, + confidence=zeros_f, + measurement=measurement, + distance=zeros_f, + distance2=zeros_f, + delta=np.zeros((0, pts.shape[1]), dtype=np.float64), + target_fraction=zeros_f, + target_position=zeros_f, + input_index=zeros_i, + explicit_shift=zeros_b, + ids=ids_arr, + warnings=warnings, + ) + + pj_star = pts2[j_idx] + shift_to_cart(shifts_used, domain) + delta = pj_star - pts2[i_idx] + d2 = np.einsum('mi,mi->m', delta, delta) + if np.any(d2 <= 0.0): + raise ValueError( + 'some constraints have zero distance (coincident points/image)' + ) + d = np.sqrt(d2) + + if measurement == 'fraction': + target_fraction = target_arr.copy() + target_position = target_fraction * d + else: + target_position = target_arr.copy() + target_fraction = target_position / d + + return PairBisectorConstraints( + n_points=int(pts.shape[0]), + i=np.asarray(i_idx, dtype=np.int64), + j=np.asarray(j_idx, dtype=np.int64), + shifts=np.asarray(shifts_used, dtype=np.int64), + target=target_arr, + confidence=omega, + measurement=measurement, + distance=np.asarray(d, dtype=np.float64), + distance2=np.asarray(d2, dtype=np.float64), + delta=np.asarray(delta, dtype=np.float64), + target_fraction=np.asarray(target_fraction, dtype=np.float64), + target_position=np.asarray(target_position, dtype=np.float64), + input_index=np.arange(m, dtype=np.int64), + explicit_shift=np.asarray(shift_given, dtype=bool), + ids=ids_arr, + warnings=warnings, + ) + + +# ---------------------------- internal helpers ---------------------------- + + +def _parse_constraints( + constraints: ConstraintInput, + *, + n_points: int, + ids: Sequence[int] | None, + index_mode: Literal['index', 'id'], + allow_empty: bool, + shift_dim: int, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, tuple[str, ...]]: + """Parse raw tuple/list constraints. + + Accepted forms: + ``(i, j, value)`` + ``(i, j, value, shift)`` + """ + + if index_mode not in ('index', 'id'): + raise ValueError('index_mode must be "index" or "id"') + if index_mode == 'id': + if ids is None: + raise ValueError('ids must be provided when index_mode="id"') + id_to_index = {int(v): k for k, v in enumerate(ids)} + else: + id_to_index = None + + m = len(constraints) + if m == 0 and not allow_empty: + raise ValueError('constraints must be non-empty') + + i_idx = np.empty(m, dtype=np.int64) + j_idx = np.empty(m, dtype=np.int64) + val = np.empty(m, dtype=np.float64) + shifts = np.zeros((m, shift_dim), dtype=np.int64) + shift_given = np.zeros(m, dtype=bool) + warnings: list[str] = [] + + for k, c in enumerate(constraints): + if not isinstance(c, (tuple, list)): + raise ValueError(f'constraint {k} must be a tuple/list') + if len(c) not in (3, 4): + raise ValueError( + f'constraint {k} must have length 3 or 4: (i, j, value[, shift])' + ) + ii = int(c[0]) + jj = int(c[1]) + if id_to_index is not None: + if ii not in id_to_index or jj not in id_to_index: + raise ValueError(f'constraint {k} uses id not present in ids') + ii = id_to_index[ii] + jj = id_to_index[jj] + if not (0 <= ii < n_points and 0 <= jj < n_points): + raise ValueError(f'constraint {k} index out of range') + if ii == jj: + raise ValueError(f'constraint {k} has i == j (degenerate)') + i_idx[k] = ii + j_idx[k] = jj + val[k] = float(c[2]) + + if len(c) == 4: + sh = c[3] + if ( + not isinstance(sh, (tuple, list)) + or len(sh) != shift_dim + ): + raise ValueError( + f'constraint {k} shift must be a length-{shift_dim} tuple' + ) + shifts[k] = tuple(int(v) for v in sh) + shift_given[k] = True + + return i_idx, j_idx, val, shifts, shift_given, tuple(warnings) + + +def maybe_remap_points(points: np.ndarray, domain: DomainAny | None) -> np.ndarray: + return _maybe_remap_points(points, domain) + + +def _geometry_for_dim(dim: int, domain: DomainAny | None): + if dim == 2: + if domain is not None and not isinstance(domain, (Box2D, RectangularCell)): + raise ValueError( + '2D points require domain=None or a planar domain ' + '(pyvoro2.planar.Box or RectangularCell)' + ) + return geometry2d(domain) + if dim == 3: + if domain is not None and not isinstance( + domain, (Box3D, OrthorhombicCell, PeriodicCell) + ): + raise ValueError( + '3D points require domain=None or a 3D domain ' + '(Box, OrthorhombicCell, or PeriodicCell)' + ) + return geometry3d(domain) + raise ValueError('only 2D and 3D points are supported') + + +def _maybe_remap_points(points: np.ndarray, domain: DomainAny | None) -> np.ndarray: + pts = np.asarray(points, dtype=float) + if pts.ndim != 2: + raise ValueError('points must have shape (n, d)') + return _geometry_for_dim(int(pts.shape[1]), domain).remap_cart(pts) + + +def _resolve_constraint_shifts( + points: np.ndarray, + i_idx: np.ndarray, + j_idx: np.ndarray, + shifts: np.ndarray, + shift_given: np.ndarray, + *, + domain: DomainAny | None, + image: Literal['nearest', 'given_only'], + image_search: int, +) -> tuple[np.ndarray, tuple[str, ...]]: + """Return per-constraint integer shifts to apply to site j.""" + + m = i_idx.shape[0] + warnings: list[str] = [] + dim = int(points.shape[1]) + geom = _geometry_for_dim(dim, domain) + + shifts = np.asarray(shifts, dtype=np.int64) + if shifts.shape != (m, dim): + raise ValueError(f'shifts must have shape (m,{dim})') + shift_given = np.asarray(shift_given, dtype=bool) + if shift_given.shape != (m,): + raise ValueError('shift_given must have shape (m,)') + + if not geom.has_any_periodic_axis: + geom.validate_shifts(shifts[shift_given]) + return np.zeros((m, dim), dtype=np.int64), tuple(warnings) + + shifts2 = shifts.copy() + provided_mask = shift_given.copy() + + if image == 'given_only': + if np.any(~provided_mask): + raise ValueError('some constraints are missing shifts (image="given_only")') + geom.validate_shifts(shifts2) + return shifts2, tuple(warnings) + + if image != 'nearest': + raise ValueError('image must be "nearest" or "given_only"') + if image_search < 0: + raise ValueError('image_search must be >= 0') + + missing = ~provided_mask + if np.any(missing): + if dim == 2: + resolved = geom.nearest_image_shifts( + points[i_idx[missing]], + points[j_idx[missing]], + ) + boundary_hits = np.zeros(resolved.shape[0], dtype=bool) + else: + resolved, boundary_hits = geom.nearest_image_shifts( + points[i_idx[missing]], + points[j_idx[missing]], + search=image_search, + ) + shifts2[missing] = resolved + warnings.append( + 'some constraints did not specify shifts; using nearest-image shifts' + ) + if dim == 3 and geom.is_triclinic and np.any(boundary_hits): + warnings.append( + 'some nearest-image shifts touch the image_search boundary; ' + 'increase image_search for extra safety in skewed triclinic cells' + ) + + geom.validate_shifts(shifts2) + return shifts2, tuple(warnings) + + +def shift_to_cart(shifts: np.ndarray, domain: DomainAny | None) -> np.ndarray: + sh = np.asarray(shifts, dtype=np.int64) + if sh.ndim != 2: + raise ValueError('shifts must have shape (m, d)') + return _geometry_for_dim(int(sh.shape[1]), domain).shift_to_cart(sh) diff --git a/src/pyvoro2/powerfit/model.py b/src/pyvoro2/powerfit/model.py new file mode 100644 index 0000000..cd749a9 --- /dev/null +++ b/src/pyvoro2/powerfit/model.py @@ -0,0 +1,192 @@ +"""Objective models for inverse fitting of power weights. + +The inverse-fit API is intentionally generic: downstream code specifies which +pairs matter, which periodic image is used for each pair, and which scalar +separator target should be matched. This module defines the objective pieces +used to fit power weights from those constraints. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Sequence + +import numpy as np + + +class ScalarMismatch: + """Base class for mismatch terms applied to predicted separator positions.""" + + +@dataclass(frozen=True, slots=True) +class SquaredLoss(ScalarMismatch): + """Quadratic mismatch penalty: ``(predicted - target)^2``.""" + + +@dataclass(frozen=True, slots=True) +class HuberLoss(ScalarMismatch): + """Huber mismatch penalty in the chosen measurement space. + + The penalty is quadratic near zero and linear for large residuals. + """ + + delta: float = 1.0 + + def __post_init__(self) -> None: + if float(self.delta) <= 0.0: + raise ValueError('HuberLoss.delta must be > 0') + + +class HardConstraint: + """Base class for hard feasibility restrictions.""" + + +@dataclass(frozen=True, slots=True) +class Interval(HardConstraint): + """Hard interval restriction in the chosen measurement space.""" + + lower: float + upper: float + + def __post_init__(self) -> None: + if not float(self.upper) > float(self.lower): + raise ValueError('Interval requires upper > lower') + + +@dataclass(frozen=True, slots=True) +class FixedValue(HardConstraint): + """Hard equality restriction in the chosen measurement space.""" + + value: float + + +class ScalarPenalty: + """Base class for additional scalar penalties.""" + + +@dataclass(frozen=True, slots=True) +class SoftIntervalPenalty(ScalarPenalty): + """Quadratic penalty for leaving a preferred interval. + + The penalty is zero within ``[lower, upper]`` and quadratic outside. + """ + + lower: float + upper: float + strength: float + + def __post_init__(self) -> None: + if not float(self.upper) > float(self.lower): + raise ValueError('SoftIntervalPenalty requires upper > lower') + if float(self.strength) < 0.0: + raise ValueError('SoftIntervalPenalty.strength must be >= 0') + + +@dataclass(frozen=True, slots=True) +class ExponentialBoundaryPenalty(ScalarPenalty): + """Repulsive penalty near the boundaries of an interval. + + The penalty is based on exponentials measured from an inner interval + ``[lower + margin, upper - margin]``. + """ + + lower: float = 0.0 + upper: float = 1.0 + margin: float = 0.02 + strength: float = 1.0 + tau: float = 0.01 + + def __post_init__(self) -> None: + if not float(self.upper) > float(self.lower): + raise ValueError('ExponentialBoundaryPenalty requires upper > lower') + if float(self.margin) < 0.0: + raise ValueError('ExponentialBoundaryPenalty.margin must be >= 0') + if float(self.strength) < 0.0: + raise ValueError('ExponentialBoundaryPenalty.strength must be >= 0') + if float(self.tau) <= 0.0: + raise ValueError('ExponentialBoundaryPenalty.tau must be > 0') + if float(self.lower) + float(self.margin) > float(self.upper) - float( + self.margin + ): + raise ValueError('ExponentialBoundaryPenalty margin is too large') + + +@dataclass(frozen=True, slots=True) +class ReciprocalBoundaryPenalty(ScalarPenalty): + """Reciprocal repulsion near interval boundaries. + + This penalty is intended to be used together with a hard interval or a + strong outside penalty. It penalizes separator positions that enter the + boundary layers ``[lower, lower + margin]`` and ``[upper - margin, upper]``. + """ + + lower: float = 0.0 + upper: float = 1.0 + margin: float = 0.05 + strength: float = 1.0 + epsilon: float = 1e-6 + + def __post_init__(self) -> None: + if not float(self.upper) > float(self.lower): + raise ValueError('ReciprocalBoundaryPenalty requires upper > lower') + if float(self.margin) < 0.0: + raise ValueError('ReciprocalBoundaryPenalty.margin must be >= 0') + if float(self.strength) < 0.0: + raise ValueError('ReciprocalBoundaryPenalty.strength must be >= 0') + if float(self.epsilon) <= 0.0: + raise ValueError('ReciprocalBoundaryPenalty.epsilon must be > 0') + if float(self.lower) + float(self.margin) > float(self.upper) - float( + self.margin + ): + raise ValueError('ReciprocalBoundaryPenalty margin is too large') + + +@dataclass(frozen=True, slots=True) +class L2Regularization: + """Optional L2 regularization on the weight vector.""" + + strength: float = 0.0 + reference: np.ndarray | None = None + + def __post_init__(self) -> None: + if float(self.strength) < 0.0: + raise ValueError('L2Regularization.strength must be >= 0') + ref = self.reference + if ref is not None: + arr = np.asarray(ref, dtype=float) + if arr.ndim != 1: + raise ValueError('L2Regularization.reference must be 1D') + object.__setattr__(self, 'reference', arr) + + +@dataclass(frozen=True, slots=True) +class FitModel: + """Complete objective definition for inverse power-weight fitting. + + The objective consists of: + - one required mismatch term, + - an optional hard feasibility set, + - zero or more extra penalties, + - optional L2 regularization on the weights. + """ + + mismatch: ScalarMismatch = field(default_factory=SquaredLoss) + feasible: HardConstraint | None = None + penalties: tuple[ScalarPenalty, ...] = () + regularization: L2Regularization = field(default_factory=L2Regularization) + + def __post_init__(self) -> None: + if not isinstance(self.mismatch, ScalarMismatch): + raise TypeError('FitModel.mismatch must be a ScalarMismatch instance') + if self.feasible is not None and not isinstance(self.feasible, HardConstraint): + raise TypeError('FitModel.feasible must be a HardConstraint or None') + penalties = self.penalties + if isinstance(penalties, Sequence) and not isinstance(penalties, tuple): + penalties = tuple(penalties) + object.__setattr__(self, 'penalties', penalties) + if not all(isinstance(p, ScalarPenalty) for p in penalties): + raise TypeError('FitModel.penalties must contain ScalarPenalty instances') + if not isinstance(self.regularization, L2Regularization): + raise TypeError( + 'FitModel.regularization must be an L2Regularization instance' + ) diff --git a/src/pyvoro2/powerfit/realize.py b/src/pyvoro2/powerfit/realize.py new file mode 100644 index 0000000..b3c89b3 --- /dev/null +++ b/src/pyvoro2/powerfit/realize.py @@ -0,0 +1,543 @@ +"""Realized-boundary matching for resolved pairwise separator constraints.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +import warnings + +import numpy as np + +from .constraints import PairBisectorConstraints +from .._domain_geometry import geometry3d +from ..api import compute as compute3d +from ..diagnostics import TessellationDiagnostics as TessellationDiagnostics3D +from ..domains import Box as Box3D, OrthorhombicCell, PeriodicCell +from ..edge_properties import annotate_edge_properties +from ..face_properties import annotate_face_properties +from ..planar._domain_geometry import geometry2d +from ..planar.api import compute as compute2d +from ..planar.diagnostics import ( + TessellationDiagnostics as TessellationDiagnostics2D, + TessellationError as TessellationError2D, + analyze_tessellation as analyze_tessellation2d, +) +from ..planar.domains import Box as Box2D, RectangularCell + +ShiftTuple = tuple[int, ...] +MeasureKey = tuple[int, int, ShiftTuple] +PairKey = tuple[int, int] +CanonicalMeasureKey = tuple[int, int, ShiftTuple] +Domain3D = Box3D | OrthorhombicCell | PeriodicCell +Domain2D = Box2D | RectangularCell +DomainAny = Domain2D | Domain3D +TessellationDiagnosticsAny = TessellationDiagnostics2D | TessellationDiagnostics3D + + +def _plain_value(value: object) -> object: + return value.item() if hasattr(value, 'item') else value + + +def _boundary_value(values: np.ndarray | None, index: int) -> float | None: + if values is None or np.isnan(values[index]): + return None + return float(values[index]) + + +def _supported_realization_dim(constraints: PairBisectorConstraints) -> None: + if constraints.dim not in (2, 3): + raise ValueError( + 'match_realized_pairs currently supports only 2D and 3D resolved ' + 'constraints' + ) + + +@dataclass(frozen=True, slots=True) +class UnaccountedRealizedPair: + """One realized internal boundary whose unordered pair was not supplied.""" + + site_i: int + site_j: int + realized_shifts: tuple[ShiftTuple, ...] + boundary_measure: float | None = None + + def to_record(self, *, ids: np.ndarray | None = None) -> dict[str, object]: + """Return a plain-Python record for the unaccounted pair.""" + + if ids is None: + site_i: object = int(self.site_i) + site_j: object = int(self.site_j) + else: + site_i = _plain_value(ids[int(self.site_i)]) + site_j = _plain_value(ids[int(self.site_j)]) + return { + 'site_i': site_i, + 'site_j': site_j, + 'realized_shifts': [ + tuple(int(v) for v in shift) for shift in self.realized_shifts + ], + 'boundary_measure': self.boundary_measure, + } + + +class UnaccountedRealizedPairError(ValueError): + """Raised when unaccounted_pair_check='raise' finds absent pairs.""" + + def __init__( + self, + message: str, + unaccounted_pairs: tuple[UnaccountedRealizedPair, ...], + ) -> None: + super().__init__(message, unaccounted_pairs) + self.unaccounted_pairs = unaccounted_pairs + + def __str__(self) -> str: + return str(self.args[0]) + + +@dataclass(frozen=True, slots=True) +class RealizedPairDiagnostics: + """Diagnostics for matching candidate constraints to realized boundaries.""" + + realized: np.ndarray + unrealized: tuple[int, ...] + realized_same_shift: np.ndarray + realized_other_shift: np.ndarray + realized_shifts: tuple[tuple[ShiftTuple, ...], ...] + endpoint_i_empty: np.ndarray + endpoint_j_empty: np.ndarray + boundary_measure: np.ndarray | None + cells: list[dict[str, Any]] | None + tessellation_diagnostics: TessellationDiagnosticsAny | None + unaccounted_pairs: tuple[UnaccountedRealizedPair, ...] = () + warnings: tuple[str, ...] = () + + def to_records( + self, + constraints: PairBisectorConstraints, + *, + use_ids: bool = False, + ) -> tuple[dict[str, object], ...]: + """Return one plain-Python record per candidate pair.""" + + if constraints.n_constraints != int(self.realized.shape[0]): + raise ValueError( + 'constraints do not match the realized diagnostics length' + ) + left, right = constraints.pair_labels(use_ids=use_ids) + rows: list[dict[str, object]] = [] + left_is_int = np.issubdtype(np.asarray(left).dtype, np.integer) + right_is_int = np.issubdtype(np.asarray(right).dtype, np.integer) + for k in range(constraints.n_constraints): + site_i = int(left[k]) if left_is_int else _plain_value(left[k]) + site_j = int(right[k]) if right_is_int else _plain_value(right[k]) + realized_shifts = tuple( + tuple(int(v) for v in shift) + for shift in self.realized_shifts[k] + ) + rows.append( + { + 'constraint_index': int(k), + 'site_i': site_i, + 'site_j': site_j, + 'shift': tuple(int(v) for v in constraints.shifts[k]), + 'realized': bool(self.realized[k]), + 'realized_same_shift': bool(self.realized_same_shift[k]), + 'realized_other_shift': bool(self.realized_other_shift[k]), + 'realized_shifts': realized_shifts, + 'endpoint_i_empty': bool(self.endpoint_i_empty[k]), + 'endpoint_j_empty': bool(self.endpoint_j_empty[k]), + 'boundary_measure': _boundary_value(self.boundary_measure, k), + } + ) + return tuple(rows) + + def unaccounted_records( + self, + *, + ids: np.ndarray | None = None, + ) -> tuple[dict[str, object], ...]: + """Return one record per realized-but-unaccounted unordered pair.""" + + return tuple(pair.to_record(ids=ids) for pair in self.unaccounted_pairs) + + def to_report( + self, + constraints: PairBisectorConstraints, + *, + use_ids: bool = False, + ) -> dict[str, object]: + """Return a JSON-friendly report for realized-boundary matching.""" + + from .report import build_realized_report + + return build_realized_report(self, constraints, use_ids=use_ids) + + +def match_realized_pairs( + points: np.ndarray, + *, + domain: DomainAny, + radii: np.ndarray, + constraints: PairBisectorConstraints, + return_boundary_measure: bool = False, + return_cells: bool = False, + return_tessellation_diagnostics: bool = False, + tessellation_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'diagnose', + unaccounted_pair_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'diagnose', +) -> RealizedPairDiagnostics: + """Determine which resolved pair constraints correspond to realized boundaries. + + The matching is purely geometric: each requested ordered pair ``(i, j, shift)`` + is checked against the set of realized cell boundaries in the power + tessellation, including explicit periodic image shifts. + """ + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] <= 0: + raise ValueError('points must have shape (n, d) with d >= 1') + if pts.shape[0] != constraints.n_points: + raise ValueError('points do not match the resolved constraint set') + if constraints.dim != pts.shape[1]: + raise ValueError('points do not match the resolved constraint dimension') + _supported_realization_dim(constraints) + if unaccounted_pair_check not in ('none', 'diagnose', 'warn', 'raise'): + raise ValueError( + 'unaccounted_pair_check must be none, diagnose, warn, or raise' + ) + + dim = int(pts.shape[1]) + if dim == 2: + cells, tessellation_diagnostics, periodic = _compute_planar_cells( + pts, + domain=domain, + radii=radii, + return_boundary_measure=return_boundary_measure, + return_tessellation_diagnostics=return_tessellation_diagnostics, + tessellation_check=tessellation_check, + ) + boundary_key = 'edges' + measure_field = 'length' + shift_dim = 2 + elif dim == 3: + cells, tessellation_diagnostics, periodic = _compute_3d_cells( + pts, + domain=domain, + radii=radii, + return_boundary_measure=return_boundary_measure, + return_tessellation_diagnostics=return_tessellation_diagnostics, + tessellation_check=tessellation_check, + ) + boundary_key = 'faces' + measure_field = 'area' + shift_dim = 3 + else: + raise ValueError( + 'match_realized_pairs currently supports only 2D and 3D points' + ) + + empty_by_id, shifts_by_pair, measure_by_pair_shift = _collect_boundary_maps( + cells, + boundary_key=boundary_key, + shift_dim=shift_dim, + return_boundary_measure=return_boundary_measure, + measure_field=measure_field, + ) + + m = constraints.n_constraints + realized = np.zeros(m, dtype=bool) + realized_same_shift = np.zeros(m, dtype=bool) + realized_other_shift = np.zeros(m, dtype=bool) + endpoint_i_empty = np.zeros(m, dtype=bool) + endpoint_j_empty = np.zeros(m, dtype=bool) + realized_shifts_rows: list[tuple[ShiftTuple, ...]] = [] + boundary_measure = ( + np.full(m, np.nan, dtype=np.float64) if return_boundary_measure else None + ) + unrealized: list[int] = [] + + for k in range(m): + i = int(constraints.i[k]) + j = int(constraints.j[k]) + target_shift = tuple(int(v) for v in constraints.shifts[k]) + endpoint_i_empty[k] = bool(empty_by_id.get(i, False)) + endpoint_j_empty[k] = bool(empty_by_id.get(j, False)) + + forward = shifts_by_pair.get((i, j), set()) + reverse = { + tuple(-int(v) for v in shift) + for shift in shifts_by_pair.get((j, i), set()) + } + realized_set = tuple(sorted(forward | reverse)) + realized_shifts_rows.append(realized_set) + same = target_shift in realized_set + any_realized = len(realized_set) > 0 + + realized[k] = any_realized + realized_same_shift[k] = same + realized_other_shift[k] = any_realized and (not same) + if not any_realized: + unrealized.append(k) + + if boundary_measure is not None and any_realized: + chosen = target_shift if same else realized_set[0] + key_f = (i, j, chosen) + key_r = (j, i, tuple(-int(v) for v in chosen)) + if key_f in measure_by_pair_shift: + boundary_measure[k] = measure_by_pair_shift[key_f] + elif key_r in measure_by_pair_shift: + boundary_measure[k] = measure_by_pair_shift[key_r] + + warning_messages: list[str] = [] + unaccounted_pairs: tuple[UnaccountedRealizedPair, ...] = tuple() + if unaccounted_pair_check != 'none': + unaccounted_pairs = _collect_unaccounted_pairs( + constraints, + shifts_by_pair=shifts_by_pair, + measure_by_pair_shift=measure_by_pair_shift, + include_boundary_measure=return_boundary_measure, + ) + if unaccounted_pairs: + message = _format_unaccounted_pairs_message(unaccounted_pairs) + if unaccounted_pair_check == 'warn': + warning_messages.append(message) + elif unaccounted_pair_check == 'raise': + raise UnaccountedRealizedPairError(message, unaccounted_pairs) + + return RealizedPairDiagnostics( + realized=realized, + unrealized=tuple(unrealized), + realized_same_shift=realized_same_shift, + realized_other_shift=realized_other_shift, + realized_shifts=tuple(realized_shifts_rows), + endpoint_i_empty=endpoint_i_empty, + endpoint_j_empty=endpoint_j_empty, + boundary_measure=boundary_measure, + cells=cells if return_cells else None, + tessellation_diagnostics=tessellation_diagnostics, + unaccounted_pairs=unaccounted_pairs, + warnings=tuple(warning_messages), + ) + + +def _compute_3d_cells( + points: np.ndarray, + *, + domain: DomainAny, + radii: np.ndarray, + return_boundary_measure: bool, + return_tessellation_diagnostics: bool, + tessellation_check: Literal['none', 'diagnose', 'warn', 'raise'], +) -> tuple[list[dict[str, Any]], TessellationDiagnostics3D | None, bool]: + if not isinstance(domain, (Box3D, OrthorhombicCell, PeriodicCell)): + raise ValueError( + '3D points require a 3D domain: Box, OrthorhombicCell, or ' + 'PeriodicCell' + ) + + periodic = geometry3d(domain).has_any_periodic_axis + compute_result = compute3d( + points, + domain=domain, + mode='power', + radii=np.asarray(radii, dtype=float), + return_vertices=True, + return_faces=True, + return_adjacency=False, + return_face_shifts=bool(periodic), + include_empty=True, + return_diagnostics=return_tessellation_diagnostics, + tessellation_check=tessellation_check, + ) + if return_tessellation_diagnostics: + cells, tessellation_diagnostics = compute_result + else: + cells = compute_result + tessellation_diagnostics = None + + if return_boundary_measure: + annotate_face_properties(cells, domain) + return cells, tessellation_diagnostics, bool(periodic) + + +def _compute_planar_cells( + points: np.ndarray, + *, + domain: DomainAny, + radii: np.ndarray, + return_boundary_measure: bool, + return_tessellation_diagnostics: bool, + tessellation_check: Literal['none', 'diagnose', 'warn', 'raise'], +) -> tuple[list[dict[str, Any]], TessellationDiagnostics2D | None, bool]: + if not isinstance(domain, (Box2D, RectangularCell)): + raise ValueError( + '2D points require a planar domain: pyvoro2.planar.Box or ' + 'RectangularCell' + ) + + periodic = geometry2d(domain).has_any_periodic_axis + cells = compute2d( + points, + domain=domain, + mode='power', + radii=np.asarray(radii, dtype=float), + return_vertices=True, + return_edges=True, + return_adjacency=False, + return_edge_shifts=bool(periodic), + include_empty=True, + ) + + if return_boundary_measure: + annotate_edge_properties(cells, domain) + + do_diag = bool(return_tessellation_diagnostics) or tessellation_check != 'none' + tessellation_diagnostics = None + if do_diag: + expected = list(range(int(points.shape[0]))) + tessellation_diagnostics = analyze_tessellation2d( + cells, + domain, + expected_ids=expected, + check_reciprocity=bool(periodic), + check_line_mismatch=bool(periodic), + mark_edges=bool(periodic), + ) + if tessellation_check in ('warn', 'raise'): + ok = bool(tessellation_diagnostics.ok_area) and ( + bool(tessellation_diagnostics.ok_reciprocity) + if bool(periodic) + else True + ) + if not ok: + msg = ( + "tessellation_check failed (mode='power'): " + f'area_ratio={tessellation_diagnostics.area_ratio:g}, ' + f'orphan_edges={tessellation_diagnostics.n_edges_orphan}, ' + 'mismatched_edges=' + f'{tessellation_diagnostics.n_edges_mismatched}' + ) + if tessellation_check == 'raise': + raise TessellationError2D(msg, tessellation_diagnostics) + warnings.warn(msg, stacklevel=2) + + if not return_tessellation_diagnostics: + tessellation_diagnostics = None + return cells, tessellation_diagnostics, bool(periodic) + + +def _canonical_pair_and_shift( + site_i: int, + site_j: int, + shift: ShiftTuple, +) -> tuple[PairKey, ShiftTuple]: + if site_i < site_j: + return (int(site_i), int(site_j)), tuple(int(v) for v in shift) + return (int(site_j), int(site_i)), tuple(-int(v) for v in shift) + + +def _collect_unaccounted_pairs( + constraints: PairBisectorConstraints, + *, + shifts_by_pair: dict[tuple[int, int], set[ShiftTuple]], + measure_by_pair_shift: dict[MeasureKey, float], + include_boundary_measure: bool, +) -> tuple[UnaccountedRealizedPair, ...]: + candidate_pairs = { + (int(min(i, j)), int(max(i, j))) + for i, j in zip(constraints.i.tolist(), constraints.j.tolist()) + } + + canonical_shifts: dict[PairKey, set[ShiftTuple]] = {} + canonical_measure: dict[CanonicalMeasureKey, float] = {} + for (site_i, site_j), shifts in shifts_by_pair.items(): + for shift in shifts: + pair_key, pair_shift = _canonical_pair_and_shift(site_i, site_j, shift) + canonical_shifts.setdefault(pair_key, set()).add(pair_shift) + if include_boundary_measure: + measure_key = (int(site_i), int(site_j), tuple(int(v) for v in shift)) + canonical_key = (pair_key[0], pair_key[1], pair_shift) + if ( + measure_key in measure_by_pair_shift + and canonical_key not in canonical_measure + ): + canonical_measure[canonical_key] = ( + measure_by_pair_shift[measure_key] + ) + + rows: list[UnaccountedRealizedPair] = [] + for pair_key in sorted(canonical_shifts): + if pair_key[0] == pair_key[1]: + continue + if pair_key in candidate_pairs: + continue + shifts = tuple(sorted(canonical_shifts[pair_key])) + total_measure = None + if include_boundary_measure: + total_measure = float( + sum( + canonical_measure.get((pair_key[0], pair_key[1], shift), 0.0) + for shift in shifts + ) + ) + rows.append( + UnaccountedRealizedPair( + site_i=pair_key[0], + site_j=pair_key[1], + realized_shifts=shifts, + boundary_measure=total_measure, + ) + ) + return tuple(rows) + + +def _format_unaccounted_pairs_message( + unaccounted_pairs: tuple[UnaccountedRealizedPair, ...], +) -> str: + preview = ', '.join( + f'({pair.site_i}, {pair.site_j})' for pair in unaccounted_pairs[:5] + ) + extra = '' + if len(unaccounted_pairs) > 5: + extra = f' and {len(unaccounted_pairs) - 5} more' + return ( + 'realized tessellation contains internal boundaries for candidate-absent ' + f'point pairs: {preview}{extra}' + ) + + +def _collect_boundary_maps( + cells: list[dict[str, Any]], + *, + boundary_key: Literal['edges', 'faces'], + shift_dim: int, + return_boundary_measure: bool, + measure_field: str, +) -> tuple[ + dict[int, bool], + dict[tuple[int, int], set[ShiftTuple]], + dict[MeasureKey, float], +]: + empty_by_id: dict[int, bool] = {} + shifts_by_pair: dict[tuple[int, int], set[ShiftTuple]] = {} + measure_by_pair_shift: dict[MeasureKey, float] = {} + + zero_shift = tuple(0 for _ in range(shift_dim)) + for cell in cells: + ci = int(cell['id']) + verts = np.asarray(cell.get('vertices', []), dtype=float) + boundaries = cell.get(boundary_key, []) + empty_by_id[ci] = bool(verts.size == 0 or len(boundaries) == 0) + for boundary in boundaries: + cj = int(boundary.get('adjacent_cell', -1)) + if cj < 0: + continue + shift = tuple(int(v) for v in boundary.get('adjacent_shift', zero_shift)) + shifts_by_pair.setdefault((ci, cj), set()).add(shift) + if return_boundary_measure: + measure_by_pair_shift[(ci, cj, shift)] = float( + boundary.get(measure_field, 0.0) + ) + + return empty_by_id, shifts_by_pair, measure_by_pair_shift diff --git a/src/pyvoro2/powerfit/report.py b/src/pyvoro2/powerfit/report.py new file mode 100644 index 0000000..1a88718 --- /dev/null +++ b/src/pyvoro2/powerfit/report.py @@ -0,0 +1,464 @@ +"""Plain-Python report helpers for power-fitting results. + +These helpers sit one layer above the numerical result objects. They keep the +solver API array-oriented while making it easy to export nested diagnostics into +JSON-friendly dictionaries and row lists for downstream packages. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import numpy as np + +from .constraints import PairBisectorConstraints +from .realize import RealizedPairDiagnostics +from .solver import ( + ConnectivityDiagnostics, + ConstraintGraphDiagnostics, + HardConstraintConflict, + PowerWeightFitResult, +) + + +def _label_nodes(nodes: tuple[int, ...], ids: np.ndarray | None) -> list[object]: + labeled: list[object] = [] + for node in nodes: + if ids is None: + labeled.append(int(node)) + continue + value = ids[int(node)] + labeled.append(value.item() if hasattr(value, 'item') else value) + return labeled + + +def _graph_record( + graph: ConstraintGraphDiagnostics | None, + *, + ids: np.ndarray | None, +) -> dict[str, object] | None: + if graph is None: + return None + return { + 'n_points': int(graph.n_points), + 'n_constraints': int(graph.n_constraints), + 'n_edges': int(graph.n_edges), + 'isolated_points': _label_nodes(graph.isolated_points, ids), + 'connected_components': [ + _label_nodes(component, ids) + for component in graph.connected_components + ], + 'n_components': int(graph.n_components), + 'fully_connected': bool(graph.fully_connected), + } + + +def _connectivity_record( + diagnostics: ConnectivityDiagnostics | None, + *, + ids: np.ndarray | None, +) -> dict[str, object] | None: + if diagnostics is None: + return None + return { + 'unconstrained_points': _label_nodes(diagnostics.unconstrained_points, ids), + 'candidate_graph': _graph_record(diagnostics.candidate_graph, ids=ids), + 'effective_graph': _graph_record(diagnostics.effective_graph, ids=ids), + 'active_graph': _graph_record(diagnostics.active_graph, ids=ids), + 'active_effective_graph': _graph_record( + diagnostics.active_effective_graph, + ids=ids, + ), + 'candidate_offsets_identified_by_data': bool( + diagnostics.candidate_offsets_identified_by_data + ), + 'active_offsets_identified_by_data': ( + None + if diagnostics.active_offsets_identified_by_data is None + else bool(diagnostics.active_offsets_identified_by_data) + ), + 'offsets_identified_in_objective': bool( + diagnostics.offsets_identified_in_objective + ), + 'gauge_policy': diagnostics.gauge_policy, + 'messages': list(diagnostics.messages), + } + + +def _path_summary_record(summary: Any | None) -> dict[str, object] | None: + if summary is None: + return None + return { + 'n_iterations': int(summary.n_iterations), + 'ever_fit_active_graph_disconnected': bool( + summary.ever_fit_active_graph_disconnected + ), + 'ever_fit_active_effective_graph_disconnected': bool( + summary.ever_fit_active_effective_graph_disconnected + ), + 'ever_fit_active_offsets_unidentified_by_data': bool( + summary.ever_fit_active_offsets_unidentified_by_data + ), + 'ever_unaccounted_pairs': bool(summary.ever_unaccounted_pairs), + 'max_fit_active_graph_components': int( + summary.max_fit_active_graph_components + ), + 'max_fit_active_effective_graph_components': int( + summary.max_fit_active_effective_graph_components + ), + 'max_n_unaccounted_pairs': int(summary.max_n_unaccounted_pairs), + 'first_fit_active_graph_disconnected_iter': ( + None + if summary.first_fit_active_graph_disconnected_iter is None + else int(summary.first_fit_active_graph_disconnected_iter) + ), + 'first_fit_active_effective_graph_disconnected_iter': ( + None + if summary.first_fit_active_effective_graph_disconnected_iter is None + else int(summary.first_fit_active_effective_graph_disconnected_iter) + ), + 'first_unaccounted_pairs_iter': ( + None + if summary.first_unaccounted_pairs_iter is None + else int(summary.first_unaccounted_pairs_iter) + ), + } + + +def _tessellation_record(diagnostics: Any | None) -> dict[str, object] | None: + if diagnostics is None: + return None + issue_rows = [] + for issue in diagnostics.issues: + issue_rows.append( + { + 'code': issue.code, + 'message': issue.message, + 'severity': issue.severity, + 'examples': list(issue.examples), + } + ) + + if hasattr(diagnostics, 'domain_volume'): + return { + 'dimension': 3, + 'n_sites_expected': int(diagnostics.n_sites_expected), + 'n_cells_returned': int(diagnostics.n_cells_returned), + 'sum_cell_measure': float(diagnostics.sum_cell_volume), + 'domain_measure': float(diagnostics.domain_volume), + 'measure_ratio': float(diagnostics.volume_ratio), + 'measure_gap': float(diagnostics.volume_gap), + 'measure_overlap': float(diagnostics.volume_overlap), + 'sum_cell_volume': float(diagnostics.sum_cell_volume), + 'domain_volume': float(diagnostics.domain_volume), + 'volume_ratio': float(diagnostics.volume_ratio), + 'volume_gap': float(diagnostics.volume_gap), + 'volume_overlap': float(diagnostics.volume_overlap), + 'missing_ids': [int(value) for value in diagnostics.missing_ids], + 'empty_ids': [int(value) for value in diagnostics.empty_ids], + 'boundary_shift_available': bool(diagnostics.face_shift_available), + 'face_shift_available': bool(diagnostics.face_shift_available), + 'reciprocity_checked': bool(diagnostics.reciprocity_checked), + 'n_boundaries_total': int(diagnostics.n_faces_total), + 'n_boundaries_orphan': int(diagnostics.n_faces_orphan), + 'n_boundaries_mismatched': int(diagnostics.n_faces_mismatched), + 'n_faces_total': int(diagnostics.n_faces_total), + 'n_faces_orphan': int(diagnostics.n_faces_orphan), + 'n_faces_mismatched': int(diagnostics.n_faces_mismatched), + 'ok_measure': bool(diagnostics.ok_volume), + 'ok_volume': bool(diagnostics.ok_volume), + 'ok_reciprocity': bool(diagnostics.ok_reciprocity), + 'ok': bool(diagnostics.ok), + 'issues': issue_rows, + } + + if hasattr(diagnostics, 'domain_area'): + return { + 'dimension': 2, + 'n_sites_expected': int(diagnostics.n_sites_expected), + 'n_cells_returned': int(diagnostics.n_cells_returned), + 'sum_cell_measure': float(diagnostics.sum_cell_area), + 'domain_measure': float(diagnostics.domain_area), + 'measure_ratio': float(diagnostics.area_ratio), + 'measure_gap': float(diagnostics.area_gap), + 'measure_overlap': float(diagnostics.area_overlap), + 'sum_cell_area': float(diagnostics.sum_cell_area), + 'domain_area': float(diagnostics.domain_area), + 'area_ratio': float(diagnostics.area_ratio), + 'area_gap': float(diagnostics.area_gap), + 'area_overlap': float(diagnostics.area_overlap), + 'missing_ids': [int(value) for value in diagnostics.missing_ids], + 'empty_ids': [int(value) for value in diagnostics.empty_ids], + 'boundary_shift_available': bool(diagnostics.edge_shift_available), + 'edge_shift_available': bool(diagnostics.edge_shift_available), + 'reciprocity_checked': bool(diagnostics.reciprocity_checked), + 'n_boundaries_total': int(diagnostics.n_edges_total), + 'n_boundaries_orphan': int(diagnostics.n_edges_orphan), + 'n_boundaries_mismatched': int(diagnostics.n_edges_mismatched), + 'n_edges_total': int(diagnostics.n_edges_total), + 'n_edges_orphan': int(diagnostics.n_edges_orphan), + 'n_edges_mismatched': int(diagnostics.n_edges_mismatched), + 'ok_measure': bool(diagnostics.ok_area), + 'ok_area': bool(diagnostics.ok_area), + 'ok_reciprocity': bool(diagnostics.ok_reciprocity), + 'ok': bool(diagnostics.ok), + 'issues': issue_rows, + } + + raise TypeError('unsupported tessellation diagnostics object') + + +def _conflict_record( + conflict: HardConstraintConflict | None, + *, + ids: np.ndarray | None, +) -> dict[str, object] | None: + if conflict is None: + return None + return { + 'message': conflict.message, + 'component_nodes': _label_nodes(conflict.component_nodes, ids), + 'cycle_nodes': _label_nodes(conflict.cycle_nodes, ids), + 'constraint_indices': list(conflict.constraint_indices), + 'terms': list(conflict.to_records(ids=ids)), + } + + +def build_fit_report( + result: PowerWeightFitResult, + constraints: PairBisectorConstraints, + *, + use_ids: bool = False, +) -> dict[str, object]: + """Return a JSON-friendly report for a low-level fit result.""" + + ids = constraints.ids if use_ids else None + return { + 'kind': 'power_weight_fit', + 'summary': { + 'status': result.status, + 'is_optimal': bool(result.is_optimal), + 'is_infeasible': bool(result.is_infeasible), + 'hard_feasible': bool(result.hard_feasible), + 'solver': result.solver, + 'measurement': result.measurement, + 'n_constraints': int(constraints.n_constraints), + 'n_points': int(constraints.n_points), + 'converged': bool(result.converged), + 'n_iter': int(result.n_iter), + 'rms_residual': ( + None if result.rms_residual is None else float(result.rms_residual) + ), + 'max_residual': ( + None if result.max_residual is None else float(result.max_residual) + ), + 'conflicting_constraint_indices': list( + result.conflicting_constraint_indices + ), + }, + 'constraints': list(constraints.to_records(use_ids=use_ids)), + 'fit_records': list(result.to_records(constraints, use_ids=use_ids)), + 'weights': None if result.weights is None else result.weights.tolist(), + 'radii': None if result.radii is None else result.radii.tolist(), + 'weight_shift': ( + None if result.weight_shift is None else float(result.weight_shift) + ), + 'used_shifts': [ + tuple(int(v) for v in shift_row) for shift_row in result.used_shifts + ], + 'warnings': list(result.warnings), + 'conflict': _conflict_record(result.conflict, ids=ids), + 'connectivity': _connectivity_record(result.connectivity, ids=ids), + } + + +def build_realized_report( + diagnostics: RealizedPairDiagnostics, + constraints: PairBisectorConstraints, + *, + use_ids: bool = False, +) -> dict[str, object]: + """Return a JSON-friendly report for realized-face matching.""" + + ids = constraints.ids if use_ids else None + return { + 'kind': 'realized_pair_diagnostics', + 'summary': { + 'n_constraints': int(constraints.n_constraints), + 'n_realized': int(np.count_nonzero(diagnostics.realized)), + 'n_same_shift': int(np.count_nonzero(diagnostics.realized_same_shift)), + 'n_other_shift': int(np.count_nonzero(diagnostics.realized_other_shift)), + 'n_unrealized': int(len(diagnostics.unrealized)), + 'n_unaccounted_pairs': int(len(diagnostics.unaccounted_pairs)), + }, + 'records': list(diagnostics.to_records(constraints, use_ids=use_ids)), + 'unrealized': [int(idx) for idx in diagnostics.unrealized], + 'unaccounted_pairs': list(diagnostics.unaccounted_records(ids=ids)), + 'warnings': list(diagnostics.warnings), + 'tessellation_diagnostics': _tessellation_record( + diagnostics.tessellation_diagnostics + ), + } + + +def build_active_set_report( + result: Any, + *, + use_ids: bool = False, +) -> dict[str, object]: + """Return a JSON-friendly report for a self-consistent active-set result.""" + + # Import lazily to avoid a module cycle during package initialization. + from .active import SelfConsistentPowerFitResult + + if not isinstance(result, SelfConsistentPowerFitResult): + raise TypeError( + 'build_active_set_report expects a SelfConsistentPowerFitResult' + ) + + history_rows: list[dict[str, object]] | None = None + if result.history is not None: + history_rows = [] + for row in result.history: + history_rows.append( + { + 'iteration': int(row.iteration), + 'n_active': int(row.n_active), + 'n_realized': int(row.n_realized), + 'n_added': int(row.n_added), + 'n_removed': int(row.n_removed), + 'rms_residual_all': float(row.rms_residual_all), + 'max_residual_all': float(row.max_residual_all), + 'weight_step_norm': float(row.weight_step_norm), + 'n_active_fit': ( + None + if row.n_active_fit is None + else int(row.n_active_fit) + ), + 'fit_active_graph_n_components': ( + None + if row.fit_active_graph_n_components is None + else int(row.fit_active_graph_n_components) + ), + 'fit_active_effective_graph_n_components': ( + None + if row.fit_active_effective_graph_n_components is None + else int(row.fit_active_effective_graph_n_components) + ), + 'fit_active_offsets_identified_by_data': ( + None + if row.fit_active_offsets_identified_by_data is None + else bool(row.fit_active_offsets_identified_by_data) + ), + 'n_unaccounted_pairs': ( + None + if row.n_unaccounted_pairs is None + else int(row.n_unaccounted_pairs) + ), + } + ) + + diagnostic_rows = list(result.to_records(use_ids=use_ids)) + marginal_rows = [diagnostic_rows[int(idx)] for idx in result.marginal_constraints] + + return { + 'kind': 'self_consistent_power_fit', + 'summary': { + 'termination': result.termination, + 'converged': bool(result.converged), + 'n_outer_iter': int(result.n_outer_iter), + 'cycle_length': ( + None if result.cycle_length is None else int(result.cycle_length) + ), + 'n_constraints': int(result.constraints.n_constraints), + 'n_active_final': int(np.count_nonzero(result.active_mask)), + 'n_realized_final': int(np.count_nonzero(result.realized.realized)), + 'rms_residual_all': float(result.rms_residual_all), + 'max_residual_all': float(result.max_residual_all), + 'marginal_constraint_indices': [ + int(idx) for idx in result.marginal_constraints + ], + }, + 'constraints': list(result.constraints.to_records(use_ids=use_ids)), + 'fit': build_fit_report( + result.fit, + result.constraints.subset(result.active_mask), + use_ids=use_ids, + ), + 'realized': build_realized_report( + result.realized, + result.constraints, + use_ids=use_ids, + ), + 'diagnostics': diagnostic_rows, + 'marginal_records': marginal_rows, + 'history': history_rows, + 'path_summary': _path_summary_record(result.path_summary), + 'tessellation_diagnostics': _tessellation_record( + result.tessellation_diagnostics + ), + 'warnings': list(result.warnings), + 'connectivity': _connectivity_record( + result.connectivity, + ids=(result.constraints.ids if use_ids else None), + ), + } + + +def _jsonable_report_value(value: Any) -> Any: + """Convert nested report payloads into plain JSON-safe values.""" + + if isinstance(value, dict): + return { + str(key): _jsonable_report_value(item) + for key, item in value.items() + } + if isinstance(value, (list, tuple)): + return [_jsonable_report_value(item) for item in value] + if isinstance(value, np.ndarray): + return [_jsonable_report_value(item) for item in value.tolist()] + if isinstance(value, np.generic): + return value.item() + return value + + +def dumps_report_json( + report: dict[str, object], + *, + indent: int = 2, + sort_keys: bool = False, +) -> str: + """Serialize a powerfit report into a JSON string.""" + + return json.dumps( + _jsonable_report_value(report), + indent=indent, + sort_keys=sort_keys, + ) + + +def write_report_json( + report: dict[str, object], + path: str | Path, + *, + indent: int = 2, + sort_keys: bool = False, +) -> None: + """Write a powerfit report to a JSON file.""" + + output_path = Path(path) + text = dumps_report_json(report, indent=indent, sort_keys=sort_keys) + if indent > 0 and not text.endswith('\n'): + text += '\n' + output_path.write_text(text, encoding='utf-8') + + +__all__ = [ + 'build_fit_report', + 'build_realized_report', + 'build_active_set_report', + 'dumps_report_json', + 'write_report_json', +] diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py new file mode 100644 index 0000000..0cedb14 --- /dev/null +++ b/src/pyvoro2/powerfit/solver.py @@ -0,0 +1,1486 @@ +"""Low-level inverse solver for fitting power weights from pairwise constraints.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +import numpy as np + +from .constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from .model import ( + ExponentialBoundaryPenalty, + FitModel, + FixedValue, + HardConstraint, + HuberLoss, + Interval, + L2Regularization, + ReciprocalBoundaryPenalty, + SoftIntervalPenalty, + SquaredLoss, +) +from ..domains import Box, OrthorhombicCell, PeriodicCell + + +def _plain_value(value: object) -> object: + return value.item() if hasattr(value, 'item') else value + + +@dataclass(frozen=True, slots=True) +class ConstraintGraphDiagnostics: + """Connectivity summary for a graph induced by constraint rows.""" + + n_points: int + n_constraints: int + n_edges: int + isolated_points: tuple[int, ...] + connected_components: tuple[tuple[int, ...], ...] + fully_connected: bool + + @property + def n_components(self) -> int: + """Return the number of connected components.""" + + return int(len(self.connected_components)) + + +@dataclass(frozen=True, slots=True) +class ConnectivityDiagnostics: + """Structured connectivity diagnostics for the inverse-fit graph.""" + + unconstrained_points: tuple[int, ...] + candidate_graph: ConstraintGraphDiagnostics + effective_graph: ConstraintGraphDiagnostics + active_graph: ConstraintGraphDiagnostics | None = None + active_effective_graph: ConstraintGraphDiagnostics | None = None + candidate_offsets_identified_by_data: bool = False + active_offsets_identified_by_data: bool | None = None + offsets_identified_in_objective: bool = False + gauge_policy: str = '' + messages: tuple[str, ...] = () + + +class ConnectivityDiagnosticsError(ValueError): + """Raised when connectivity_check='raise' detects a graph issue.""" + + def __init__( + self, + message: str, + diagnostics: ConnectivityDiagnostics, + ) -> None: + super().__init__(message, diagnostics) + self.diagnostics = diagnostics + + def __str__(self) -> str: + return str(self.args[0]) + + +@dataclass(frozen=True, slots=True) +class PowerWeightFitResult: + """Result of inverse fitting of power weights.""" + + status: Literal[ + 'optimal', 'infeasible_hard_constraints', 'max_iter', 'numerical_failure' + ] + hard_feasible: bool + weights: np.ndarray | None + radii: np.ndarray | None + weight_shift: float | None + measurement: Literal['fraction', 'position'] + target: np.ndarray + predicted: np.ndarray | None + predicted_fraction: np.ndarray | None + predicted_position: np.ndarray | None + residuals: np.ndarray | None + rms_residual: float | None + max_residual: float | None + used_shifts: np.ndarray + solver: str + n_iter: int + converged: bool + conflict: 'HardConstraintConflict | None' + warnings: tuple[str, ...] + connectivity: ConnectivityDiagnostics | None = None + + @property + def is_optimal(self) -> bool: + """Whether the fit terminated with a final solution.""" + + return self.status == 'optimal' + + @property + def is_infeasible(self) -> bool: + """Whether hard feasibility failed before optimization.""" + + return self.status == 'infeasible_hard_constraints' + + @property + def conflicting_constraint_indices(self) -> tuple[int, ...]: + """Constraint rows participating in the infeasibility witness.""" + + if self.conflict is None: + return tuple() + return self.conflict.constraint_indices + + def to_records( + self, + constraints: PairBisectorConstraints, + *, + use_ids: bool = False, + ) -> tuple[dict[str, object], ...]: + """Return one plain-Python record per fitted constraint row.""" + + if constraints.n_constraints != int(self.target.shape[0]): + raise ValueError('constraints do not match the fit result length') + left, right = constraints.pair_labels(use_ids=use_ids) + rows: list[dict[str, object]] = [] + left_is_int = np.issubdtype(np.asarray(left).dtype, np.integer) + right_is_int = np.issubdtype(np.asarray(right).dtype, np.integer) + for k in range(constraints.n_constraints): + site_i = int(left[k]) if left_is_int else _plain_value(left[k]) + site_j = int(right[k]) if right_is_int else _plain_value(right[k]) + rows.append( + { + 'constraint_index': int(k), + 'site_i': site_i, + 'site_j': site_j, + 'shift': tuple(int(v) for v in constraints.shifts[k]), + 'measurement': self.measurement, + 'target': float(self.target[k]), + 'predicted': ( + None + if self.predicted is None + else float(self.predicted[k]) + ), + 'predicted_fraction': ( + None + if self.predicted_fraction is None + else float(self.predicted_fraction[k]) + ), + 'predicted_position': ( + None + if self.predicted_position is None + else float(self.predicted_position[k]) + ), + 'residual': ( + None + if self.residuals is None + else float(self.residuals[k]) + ), + } + ) + return tuple(rows) + + def to_report( + self, + constraints: PairBisectorConstraints, + *, + use_ids: bool = False, + ) -> dict[str, object]: + """Return a JSON-friendly report for this fit result.""" + + from .report import build_fit_report + + return build_fit_report(self, constraints, use_ids=use_ids) + + +@dataclass(frozen=True, slots=True) +class HardConstraintConflictTerm: + """One bound relation participating in an infeasibility witness. + + Each term refers back to one input constraint row and states which bound on + ``w_i - w_j`` participates in the contradiction cycle. + """ + + constraint_index: int + site_i: int + site_j: int + relation: Literal['<=', '>='] + bound_value: float + + def to_record(self, *, ids: np.ndarray | None = None) -> dict[str, object]: + """Return a plain-Python record for this conflict term.""" + + site_i = int(self.site_i) if ids is None else ids[self.site_i].item() + site_j = int(self.site_j) if ids is None else ids[self.site_j].item() + return { + 'constraint_index': int(self.constraint_index), + 'site_i': site_i, + 'site_j': site_j, + 'relation': self.relation, + 'bound_value': float(self.bound_value), + } + + +@dataclass(frozen=True, slots=True) +class HardConstraintConflict: + """Compact witness for inconsistent hard separator restrictions.""" + + component_nodes: tuple[int, ...] + cycle_nodes: tuple[int, ...] + terms: tuple[HardConstraintConflictTerm, ...] + message: str + + @property + def constraint_indices(self) -> tuple[int, ...]: + """Sorted unique input rows participating in the conflict.""" + + return tuple(sorted({int(term.constraint_index) for term in self.terms})) + + def to_records( + self, *, ids: np.ndarray | None = None + ) -> tuple[dict[str, object], ...]: + """Return plain-Python records for the witness terms.""" + + return tuple(term.to_record(ids=ids) for term in self.terms) + + +@dataclass(frozen=True, slots=True) +class _DifferenceEdge: + source: int + target: int + weight: float + constraint_index: int + site_i: int + site_j: int + relation: Literal['<=', '>='] + bound_value: float + + +@dataclass(frozen=True, slots=True) +class _MeasurementGeometry: + alpha: np.ndarray + beta: np.ndarray + target: np.ndarray + target_fraction: np.ndarray + target_position: np.ndarray + + +class _NumericalFailure(RuntimeError): + """Raised when the numerical backend fails before producing a result.""" + + +def radii_to_weights(radii: np.ndarray) -> np.ndarray: + """Convert radii to power weights (``w = r^2``).""" + + r = np.asarray(radii, dtype=float) + if r.ndim != 1: + raise ValueError('radii must be 1D') + if not np.all(np.isfinite(r)): + raise ValueError('radii must contain only finite values') + if np.any(r < 0): + raise ValueError('radii must be non-negative') + return r * r + + +def weights_to_radii( + weights: np.ndarray, + *, + r_min: float = 0.0, + weight_shift: float | None = None, +) -> tuple[np.ndarray, float]: + """Convert power weights to radii using an explicit global shift. + + By default, the returned radii use the minimal additive shift that makes the + smallest radius equal to ``r_min``. Pass ``weight_shift`` to request an + explicit gauge instead. + """ + + w = np.asarray(weights, dtype=float) + if w.ndim != 1: + raise ValueError('weights must be 1D') + if not np.all(np.isfinite(w)): + raise ValueError('weights must contain only finite values') + + if weight_shift is not None: + if r_min != 0.0: + raise ValueError('specify at most one of r_min and weight_shift') + C = float(weight_shift) + if not np.isfinite(C): + raise ValueError('weight_shift must be finite') + else: + r_min = float(r_min) + if r_min < 0: + raise ValueError('r_min must be >= 0') + w_min = float(np.min(w)) if w.size else 0.0 + C = (r_min * r_min) - w_min + + w_shifted = w + C + if np.any(w_shifted < -1e-14): + raise ValueError('weight shift produced negative values (numerical issue)') + w_shifted = np.maximum(w_shifted, 0.0) + return np.sqrt(w_shifted), float(C) + + +def fit_power_weights( + points: np.ndarray, + constraints: PairBisectorConstraints | list[tuple] | tuple[tuple, ...], + *, + measurement: Literal['fraction', 'position'] = 'fraction', + domain: Box | OrthorhombicCell | PeriodicCell | None = None, + ids: list[int] | tuple[int, ...] | np.ndarray | None = None, + index_mode: Literal['index', 'id'] = 'index', + image: Literal['nearest', 'given_only'] = 'nearest', + image_search: int = 1, + confidence: list[float] | tuple[float, ...] | np.ndarray | None = None, + model: FitModel | None = None, + r_min: float = 0.0, + weight_shift: float | None = None, + solver: Literal['auto', 'analytic', 'admm'] = 'auto', + max_iter: int = 2000, + rho: float = 1.0, + tol_abs: float = 1e-6, + tol_rel: float = 1e-5, + connectivity_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'warn', +) -> PowerWeightFitResult: + """Fit power weights from resolved pairwise separator constraints. + + The raw constraint tuples are ``(i, j, value[, shift])`` where ``shift`` is + the integer lattice image applied to site ``j``. The returned radii use the + minimal non-negative global gauge by default; pass ``weight_shift`` for an + explicit output gauge or ``r_min`` for the legacy minimum-radius helper. + """ + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] <= 0: + raise ValueError('points must have shape (n, d) with d >= 1') + if not np.all(np.isfinite(pts)): + raise ValueError('points must contain only finite values') + + if model is None: + model = FitModel() + + if isinstance(constraints, PairBisectorConstraints): + resolved = constraints + if resolved.n_points != pts.shape[0]: + raise ValueError('resolved constraints do not match the number of points') + if resolved.dim != pts.shape[1]: + raise ValueError( + 'resolved constraints do not match the point dimension' + ) + if resolved.measurement != measurement: + measurement = resolved.measurement + else: + resolved = resolve_pair_bisector_constraints( + pts, + constraints, + measurement=measurement, + domain=domain, + ids=ids, + index_mode=index_mode, + image=image, + image_search=image_search, + confidence=confidence, + allow_empty=True, + ) + measurement = resolved.measurement + + return _fit_power_weights_resolved( + resolved, + model=model, + r_min=r_min, + weight_shift=weight_shift, + solver=solver, + max_iter=max_iter, + rho=rho, + tol_abs=tol_abs, + tol_rel=tol_rel, + connectivity_check=connectivity_check, + ) + + +def _fit_power_weights_resolved( + constraints: PairBisectorConstraints, + *, + model: FitModel, + r_min: float, + weight_shift: float | None, + solver: Literal['auto', 'analytic', 'admm'], + max_iter: int, + rho: float, + tol_abs: float, + tol_rel: float, + connectivity_check: Literal['none', 'diagnose', 'warn', 'raise'], +) -> PowerWeightFitResult: + n = int(constraints.n_points) + m = int(constraints.n_constraints) + warnings_list = list(constraints.warnings) + + if max_iter <= 0: + raise ValueError('max_iter must be > 0') + if rho <= 0: + raise ValueError('rho must be > 0') + if tol_abs <= 0 or tol_rel <= 0: + raise ValueError('tol_abs and tol_rel must be > 0') + if connectivity_check not in ('none', 'diagnose', 'warn', 'raise'): + raise ValueError( + 'connectivity_check must be none, diagnose, warn, or raise' + ) + + reg = model.regularization + lam = float(reg.strength) + w0 = _regularization_reference(reg, n) + reference = None if reg.reference is None else w0 + + geom = _measurement_geometry(constraints) + z_target = (geom.target - geom.beta) / geom.alpha + a = constraints.confidence * (geom.alpha**2) + + hard = _hard_constraint_bounds(model.feasible, geom.alpha, geom.beta) + z_lo = hard[0] if hard is not None else None + z_hi = hard[1] if hard is not None else None + + nonquadratic = _requires_admm(model) + if solver == 'auto': + solver_eff = 'analytic' if not nonquadratic else 'admm' + else: + solver_eff = solver + if solver_eff not in ('analytic', 'admm'): + raise ValueError('solver must be auto, analytic, or admm') + if solver_eff == 'analytic' and nonquadratic: + raise ValueError( + 'analytic solver cannot be used with hard constraints ' + 'or non-quadratic penalties' + ) + + effective_mask = _difference_identifying_mask(constraints, model) + comps = _connected_components( + n, + constraints.i[effective_mask], + constraints.j[effective_mask], + ) + connectivity = None + if connectivity_check != 'none': + connectivity = _build_fit_connectivity_diagnostics( + constraints, + model=model, + gauge_policy=_standalone_gauge_policy_description(reg), + ) + _apply_connectivity_policy( + connectivity_check, + connectivity, + warnings_list, + ) + + if hard is not None: + feasible, conflict = _check_hard_feasibility( + n, + constraints.i, + constraints.j, + z_lo, + z_hi, + ) + if not feasible: + warnings_list.append('hard feasibility check failed before optimization') + if conflict is not None: + warnings_list.append(conflict.message) + return PowerWeightFitResult( + status='infeasible_hard_constraints', + hard_feasible=False, + weights=None, + radii=None, + weight_shift=None, + measurement=constraints.measurement, + target=geom.target.copy(), + predicted=None, + predicted_fraction=None, + predicted_position=None, + residuals=None, + rms_residual=None, + max_residual=None, + used_shifts=constraints.shifts.copy(), + solver='none', + n_iter=0, + converged=False, + conflict=conflict, + warnings=tuple(warnings_list), + connectivity=connectivity, + ) + else: + conflict = None + + if m == 0: + if lam > 0.0: + weights = w0.copy() + warnings_list.append( + 'empty constraint set; using the regularization-only solution' + ) + elif reference is not None: + weights = reference.copy() + warnings_list.append( + 'empty constraint set; no pair data are present, so weights ' + 'follow the zero-strength reference gauge convention' + ) + else: + weights = np.zeros(n, dtype=np.float64) + warnings_list.append( + 'empty constraint set; returning the mean-zero gauge solution' + ) + radii, shift = weights_to_radii( + weights, + r_min=r_min, + weight_shift=weight_shift, + ) + pred_fraction = np.zeros(0, dtype=np.float64) + pred_position = np.zeros(0, dtype=np.float64) + pred = pred_fraction if constraints.measurement == 'fraction' else pred_position + return PowerWeightFitResult( + status='optimal', + hard_feasible=True, + weights=weights, + radii=radii, + weight_shift=shift, + measurement=constraints.measurement, + target=geom.target.copy(), + predicted=pred, + predicted_fraction=pred_fraction, + predicted_position=pred_position, + residuals=np.zeros(0, dtype=np.float64), + rms_residual=0.0, + max_residual=0.0, + used_shifts=constraints.shifts.copy(), + solver='analytic', + n_iter=0, + converged=True, + conflict=conflict, + warnings=tuple(warnings_list), + connectivity=connectivity, + ) + + weights = np.zeros(n, dtype=np.float64) + converged_all = True + n_iter_max = 0 + + try: + for nodes in comps: + idx_nodes = np.asarray(nodes, dtype=np.int64) + if idx_nodes.size <= 1: + if lam > 0.0 and idx_nodes.size == 1: + weights[idx_nodes[0]] = w0[idx_nodes[0]] + continue + + node_set = set(nodes) + mask = effective_mask & np.fromiter( + ( + (int(i) in node_set) and (int(j) in node_set) + for i, j in zip(constraints.i, constraints.j) + ), + dtype=bool, + count=m, + ) + local_index = {int(node): k for k, node in enumerate(nodes)} + ii = np.array( + [local_index[int(i)] for i in constraints.i[mask]], + dtype=np.int64, + ) + jj = np.array( + [local_index[int(j)] for j in constraints.j[mask]], + dtype=np.int64, + ) + a_c = a[mask] + b_c = z_target[mask] + alpha_c = geom.alpha[mask] + beta_c = geom.beta[mask] + target_c = geom.target[mask] + conf_c = constraints.confidence[mask] + w0_c = w0[idx_nodes] + z_lo_c = None if z_lo is None else z_lo[mask] + z_hi_c = None if z_hi is None else z_hi[mask] + + if solver_eff == 'analytic': + w_c = _solve_component_analytic(ii, jj, a_c, b_c, w0_c, lam) + iters = 1 + conv = True + else: + w_c, iters, conv = _solve_component_admm( + ii, + jj, + alpha_c, + beta_c, + target_c, + conf_c, + w0_c, + model=model, + lambda_regularize=lam, + rho=rho, + max_iter=max_iter, + tol_abs=tol_abs, + tol_rel=tol_rel, + z_lo=z_lo_c, + z_hi=z_hi_c, + ) + if not np.all(np.isfinite(w_c)): + raise _NumericalFailure('component solver returned non-finite weights') + weights[idx_nodes] = w_c + converged_all = converged_all and conv + n_iter_max = max(n_iter_max, iters) + + if lam == 0.0: + weights = _apply_component_mean_gauge( + weights, + comps, + reference=reference, + ) + if not np.all(np.isfinite(weights)): + raise _NumericalFailure('assembled weight vector is non-finite') + try: + radii, shift = weights_to_radii( + weights, + r_min=r_min, + weight_shift=weight_shift, + ) + except ValueError as exc: + raise _NumericalFailure(str(exc)) from exc + pred_fraction, pred_position, pred = _predict_measurements(weights, constraints) + residuals = pred - geom.target + if not np.all(np.isfinite(residuals)): + raise _NumericalFailure( + 'predicted measurements or residuals are non-finite' + ) + rms = float(np.sqrt(np.mean(residuals * residuals))) if residuals.size else 0.0 + mx = float(np.max(np.abs(residuals))) if residuals.size else 0.0 + except (np.linalg.LinAlgError, FloatingPointError, _NumericalFailure) as exc: + warnings_list.append(f'numerical solver failure: {exc}') + return PowerWeightFitResult( + status='numerical_failure', + hard_feasible=True, + weights=None, + radii=None, + weight_shift=None, + measurement=constraints.measurement, + target=geom.target.copy(), + predicted=None, + predicted_fraction=None, + predicted_position=None, + residuals=None, + rms_residual=None, + max_residual=None, + used_shifts=constraints.shifts.copy(), + solver=solver_eff, + n_iter=int(n_iter_max), + converged=False, + conflict=conflict, + warnings=tuple(warnings_list), + connectivity=connectivity, + ) + + if converged_all: + status: Literal['optimal', 'max_iter', 'numerical_failure'] = 'optimal' + else: + status = 'max_iter' + warnings_list.append('iterative solver reached max_iter before convergence') + + return PowerWeightFitResult( + status=status, + hard_feasible=True, + weights=weights, + radii=radii, + weight_shift=shift, + measurement=constraints.measurement, + target=geom.target.copy(), + predicted=pred, + predicted_fraction=pred_fraction, + predicted_position=pred_position, + residuals=residuals, + rms_residual=rms, + max_residual=mx, + used_shifts=constraints.shifts.copy(), + solver=solver_eff, + n_iter=int(n_iter_max), + converged=bool(converged_all), + conflict=conflict, + warnings=tuple(warnings_list), + connectivity=connectivity, + ) + + +def _measurement_geometry(constraints: PairBisectorConstraints) -> _MeasurementGeometry: + d = constraints.distance + d2 = constraints.distance2 + if constraints.measurement == 'fraction': + alpha = 1.0 / (2.0 * d2) + beta = np.full_like(alpha, 0.5) + target = constraints.target_fraction + else: + alpha = 1.0 / (2.0 * d) + beta = 0.5 * d + target = constraints.target_position + return _MeasurementGeometry( + alpha=np.asarray(alpha, dtype=np.float64), + beta=np.asarray(beta, dtype=np.float64), + target=np.asarray(target, dtype=np.float64), + target_fraction=constraints.target_fraction.copy(), + target_position=constraints.target_position.copy(), + ) + + +def _predict_measurements( + weights: np.ndarray, constraints: PairBisectorConstraints +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + z_pred = weights[constraints.i] - weights[constraints.j] + t_pred = 0.5 + z_pred / (2.0 * constraints.distance2) + x_pred = constraints.distance * t_pred + pred = t_pred if constraints.measurement == 'fraction' else x_pred + return ( + np.asarray(t_pred, dtype=np.float64), + np.asarray(x_pred, dtype=np.float64), + np.asarray(pred, dtype=np.float64), + ) + + +def _regularization_reference(reg: L2Regularization, n: int) -> np.ndarray: + if reg.reference is None: + return np.zeros(n, dtype=np.float64) + w0 = np.asarray(reg.reference, dtype=float) + if w0.shape != (n,): + raise ValueError('regularization.reference must have shape (n,)') + return w0.astype(np.float64) + + +def _difference_identifying_mask( + constraints: PairBisectorConstraints, + model: FitModel, +) -> np.ndarray: + mask = constraints.confidence > 0.0 + if model.feasible is not None or len(model.penalties) > 0: + mask = np.ones(constraints.n_constraints, dtype=bool) + return np.asarray(mask, dtype=bool) + + +def _apply_component_mean_gauge( + weights: np.ndarray, + comps: list[list[int]], + *, + reference: np.ndarray | None, +) -> np.ndarray: + aligned = np.asarray(weights, dtype=np.float64).copy() + ref = None if reference is None else np.asarray(reference, dtype=np.float64) + for comp in comps: + idx = np.asarray(comp, dtype=np.int64) + if idx.size == 0: + continue + if ref is None: + target_mean = 0.0 + else: + target_mean = float(np.mean(ref[idx])) + current_mean = float(np.mean(aligned[idx])) + aligned[idx] += target_mean - current_mean + return aligned + + +def _standalone_gauge_policy_description(reg: L2Regularization) -> str: + if reg.reference is not None: + return ( + 'each effective component is shifted so its mean matches the ' + 'reference mean on that component' + ) + return 'each effective component is centered to mean zero' + + +def _graph_diagnostics( + n: int, + i_idx: np.ndarray, + j_idx: np.ndarray, + *, + n_constraints: int, +) -> ConstraintGraphDiagnostics: + ii = np.asarray(i_idx, dtype=np.int64) + jj = np.asarray(j_idx, dtype=np.int64) + degree = np.zeros(n, dtype=np.int64) + if ii.size: + np.add.at(degree, ii, 1) + np.add.at(degree, jj, 1) + isolated = tuple(np.flatnonzero(degree == 0).tolist()) + components = tuple( + tuple(int(node) for node in comp) + for comp in _connected_components(n, ii, jj) + ) + edges = { + (int(min(i, j)), int(max(i, j))) + for i, j in zip(ii.tolist(), jj.tolist()) + } + return ConstraintGraphDiagnostics( + n_points=int(n), + n_constraints=int(n_constraints), + n_edges=int(len(edges)), + isolated_points=isolated, + connected_components=components, + fully_connected=bool((n <= 1) or len(components) == 1), + ) + + +def _format_component_counts(graph: ConstraintGraphDiagnostics) -> str: + n_components = graph.n_components + return ( + '1 connected component' + if n_components == 1 + else f'{n_components} connected components' + ) + + +def _format_point_list(points: tuple[int, ...]) -> str: + return '[' + ', '.join(str(int(v)) for v in points) + ']' + + +def _build_fit_connectivity_diagnostics( + constraints: PairBisectorConstraints, + *, + model: FitModel, + gauge_policy: str, +) -> ConnectivityDiagnostics: + n = int(constraints.n_points) + candidate_graph = _graph_diagnostics( + n, + constraints.i, + constraints.j, + n_constraints=constraints.n_constraints, + ) + effective_mask = _difference_identifying_mask(constraints, model) + effective_graph = _graph_diagnostics( + n, + constraints.i[effective_mask], + constraints.j[effective_mask], + n_constraints=int(np.count_nonzero(effective_mask)), + ) + + messages: list[str] = [] + if candidate_graph.isolated_points: + messages.append( + 'candidate graph leaves unconstrained points ' + f'{_format_point_list(candidate_graph.isolated_points)}' + ) + if candidate_graph.n_components > 1: + messages.append( + 'candidate graph has ' f'{_format_component_counts(candidate_graph)}' + ) + if np.any(~effective_mask): + messages.append( + 'zero-confidence candidate rows do not identify pair differences ' + 'in the current objective and are ignored for ' + 'connectivity/gauge diagnostics' + ) + if effective_graph.n_components > 1: + messages.append( + 'pairwise data identify only ' + f'{_format_component_counts(effective_graph)}; relative component ' + 'offsets are not identified by the data' + ) + + return ConnectivityDiagnostics( + unconstrained_points=candidate_graph.isolated_points, + candidate_graph=candidate_graph, + effective_graph=effective_graph, + candidate_offsets_identified_by_data=bool(effective_graph.fully_connected), + active_offsets_identified_by_data=None, + offsets_identified_in_objective=bool( + effective_graph.fully_connected + or float(model.regularization.strength) > 0.0 + ), + gauge_policy=gauge_policy, + messages=tuple(messages), + ) + + +def _build_active_set_connectivity_diagnostics( + constraints: PairBisectorConstraints, + active_mask: np.ndarray, + *, + model: FitModel, + gauge_policy: str, +) -> ConnectivityDiagnostics: + mask = np.asarray(active_mask, dtype=bool) + if mask.shape != (constraints.n_constraints,): + raise ValueError('active_mask must have shape (m,)') + + n = int(constraints.n_points) + candidate_graph = _graph_diagnostics( + n, + constraints.i, + constraints.j, + n_constraints=constraints.n_constraints, + ) + effective_mask = _difference_identifying_mask(constraints, model) + effective_graph = _graph_diagnostics( + n, + constraints.i[effective_mask], + constraints.j[effective_mask], + n_constraints=int(np.count_nonzero(effective_mask)), + ) + + active_constraints = constraints.subset(mask) + active_graph = _graph_diagnostics( + n, + active_constraints.i, + active_constraints.j, + n_constraints=active_constraints.n_constraints, + ) + active_effective_mask = _difference_identifying_mask(active_constraints, model) + active_effective_graph = _graph_diagnostics( + n, + active_constraints.i[active_effective_mask], + active_constraints.j[active_effective_mask], + n_constraints=int(np.count_nonzero(active_effective_mask)), + ) + + messages: list[str] = [] + if candidate_graph.isolated_points: + messages.append( + 'candidate graph leaves unconstrained points ' + f'{_format_point_list(candidate_graph.isolated_points)}' + ) + if candidate_graph.n_components > 1: + messages.append( + 'candidate graph has ' f'{_format_component_counts(candidate_graph)}' + ) + if np.any(~effective_mask): + messages.append( + 'zero-confidence candidate rows do not identify pair differences ' + 'in the current objective and are ignored for ' + 'connectivity/gauge diagnostics' + ) + if effective_graph.n_components > 1: + messages.append( + 'candidate pairwise data identify only ' + f'{_format_component_counts(effective_graph)}; relative component ' + 'offsets are not identified by the data' + ) + if active_graph.n_components > 1: + messages.append( + 'final active graph has ' f'{_format_component_counts(active_graph)}' + ) + if np.any(mask) and np.any(~active_effective_mask): + messages.append( + 'zero-confidence active rows do not identify pair differences in ' + 'the current objective and are ignored for active-component gauge ' + 'alignment' + ) + if active_effective_graph.n_components > 1: + messages.append( + 'final active pairwise data identify only ' + f'{_format_component_counts(active_effective_graph)}; relative ' + 'component offsets are preserved by the self-consistent gauge ' + 'policy rather than identified by the data' + ) + + return ConnectivityDiagnostics( + unconstrained_points=candidate_graph.isolated_points, + candidate_graph=candidate_graph, + effective_graph=effective_graph, + active_graph=active_graph, + active_effective_graph=active_effective_graph, + candidate_offsets_identified_by_data=bool(effective_graph.fully_connected), + active_offsets_identified_by_data=bool( + active_effective_graph.fully_connected + ), + offsets_identified_in_objective=bool( + active_effective_graph.fully_connected + or float(model.regularization.strength) > 0.0 + ), + gauge_policy=gauge_policy, + messages=tuple(messages), + ) + + +def _apply_connectivity_policy( + policy: Literal['none', 'diagnose', 'warn', 'raise'], + diagnostics: ConnectivityDiagnostics, + warnings_list: list[str], +) -> None: + if policy in ('none', 'diagnose') or not diagnostics.messages: + return + if policy == 'warn': + warnings_list.extend(diagnostics.messages) + return + if policy == 'raise': + raise ConnectivityDiagnosticsError( + '; '.join(diagnostics.messages), + diagnostics, + ) + raise ValueError('unsupported connectivity policy') + + +def _hard_constraint_bounds( + feasible: HardConstraint | None, + alpha: np.ndarray, + beta: np.ndarray, +) -> tuple[np.ndarray, np.ndarray] | None: + if feasible is None: + return None + if isinstance(feasible, Interval): + lower = np.full_like(alpha, float(feasible.lower)) + upper = np.full_like(alpha, float(feasible.upper)) + elif isinstance(feasible, FixedValue): + lower = np.full_like(alpha, float(feasible.value)) + upper = lower.copy() + else: # pragma: no cover - defensive + raise TypeError(f'unsupported hard constraint: {type(feasible)!r}') + z_lo = (lower - beta) / alpha + z_hi = (upper - beta) / alpha + lo = np.minimum(z_lo, z_hi) + hi = np.maximum(z_lo, z_hi) + return lo.astype(np.float64), hi.astype(np.float64) + + +def _requires_admm(model: FitModel) -> bool: + if model.feasible is not None: + return True + if model.penalties: + return True + return not isinstance(model.mismatch, SquaredLoss) + + +def _connected_components( + n: int, i_idx: np.ndarray, j_idx: np.ndarray +) -> list[list[int]]: + adj: list[list[int]] = [[] for _ in range(n)] + for i, j in zip(i_idx.tolist(), j_idx.tolist()): + adj[i].append(j) + adj[j].append(i) + seen = np.zeros(n, dtype=bool) + comps: list[list[int]] = [] + for start in range(n): + if seen[start]: + continue + if len(adj[start]) == 0: + seen[start] = True + comps.append([start]) + continue + stack = [start] + seen[start] = True + comp: list[int] = [] + while stack: + v = stack.pop() + comp.append(v) + for nb in adj[v]: + if not seen[nb]: + seen[nb] = True + stack.append(nb) + comps.append(sorted(comp)) + return comps + + +def _check_hard_feasibility( + n: int, + i_idx: np.ndarray, + j_idx: np.ndarray, + z_lo: np.ndarray, + z_hi: np.ndarray, +) -> tuple[bool, HardConstraintConflict | None]: + """Check feasibility of difference constraints via Bellman-Ford.""" + + edges: list[_DifferenceEdge] = [] + for k, (i, j, lo, hi) in enumerate( + zip(i_idx.tolist(), j_idx.tolist(), z_lo.tolist(), z_hi.tolist()) + ): + # w_i - w_j <= hi -> w_i <= w_j + hi : edge j -> i with weight hi + edges.append( + _DifferenceEdge( + source=int(j), + target=int(i), + weight=float(hi), + constraint_index=int(k), + site_i=int(i), + site_j=int(j), + relation='<=', + bound_value=float(hi), + ) + ) + # w_i - w_j >= lo -> w_j - w_i <= -lo: edge i -> j with weight -lo + edges.append( + _DifferenceEdge( + source=int(i), + target=int(j), + weight=float(-lo), + constraint_index=int(k), + site_i=int(i), + site_j=int(j), + relation='>=', + bound_value=float(lo), + ) + ) + + dist = np.zeros(n, dtype=np.float64) + pred_node = np.full(n, -1, dtype=np.int64) + pred_edge = np.full(n, -1, dtype=np.int64) + last_updated = -1 + tol = 1e-12 + + for _ in range(n): + updated = False + last_updated = -1 + for edge_index, edge in enumerate(edges): + cand = dist[edge.source] + edge.weight + if cand < dist[edge.target] - tol: + dist[edge.target] = cand + pred_node[edge.target] = edge.source + pred_edge[edge.target] = edge_index + updated = True + last_updated = edge.target + if not updated: + return True, None + + if last_updated < 0: + return True, None + + y = int(last_updated) + for _ in range(n): + y = int(pred_node[y]) + if y < 0: + return False, None + + cycle_edges_rev: list[_DifferenceEdge] = [] + cur = y + while True: + edge_index = int(pred_edge[cur]) + if edge_index < 0: + return False, None + edge = edges[edge_index] + cycle_edges_rev.append(edge) + cur = edge.source + if cur == y: + break + + cycle_edges = tuple(reversed(cycle_edges_rev)) + cycle_nodes_list: list[int] = [] + if cycle_edges: + cycle_nodes_list.append(cycle_edges[0].source) + cycle_nodes_list.extend(edge.target for edge in cycle_edges) + if len(cycle_nodes_list) >= 2 and cycle_nodes_list[0] == cycle_nodes_list[-1]: + cycle_nodes_list.pop() + + cycle_node_set = set(cycle_nodes_list) + component_nodes: tuple[int, ...] = () + for comp in _connected_components(n, i_idx, j_idx): + if any(node in cycle_node_set for node in comp): + component_nodes = tuple(int(node) for node in comp) + break + + terms = tuple( + HardConstraintConflictTerm( + constraint_index=edge.constraint_index, + site_i=edge.site_i, + site_j=edge.site_j, + relation=edge.relation, + bound_value=edge.bound_value, + ) + for edge in cycle_edges + ) + unique_constraints = tuple(sorted({term.constraint_index for term in terms})) + component_label = ( + '[' + ', '.join(str(v) for v in component_nodes) + ']' + if component_nodes + else '[]' + ) + cycle_label = '[' + ', '.join(str(v) for v in unique_constraints) + ']' + conflict = HardConstraintConflict( + component_nodes=component_nodes, + cycle_nodes=tuple(int(v) for v in cycle_nodes_list), + terms=terms, + message=( + 'inconsistent hard separator restrictions on connected component ' + f'{component_label}; contradiction cycle uses constraint rows {cycle_label}' + ), + ) + return False, conflict + + +def _solve_component_analytic( + I: np.ndarray, + J: np.ndarray, + a: np.ndarray, + b: np.ndarray, + w0: np.ndarray, + lambda_regularize: float, +) -> np.ndarray: + n_c = int(np.max(np.maximum(I, J))) + 1 + if w0.shape != (n_c,): + w0 = np.asarray(w0, dtype=float).reshape(n_c) + lam = float(lambda_regularize) + L = np.zeros((n_c, n_c), dtype=np.float64) + rhs = np.zeros(n_c, dtype=np.float64) + for i, j, ak, bk in zip(I.tolist(), J.tolist(), a.tolist(), b.tolist()): + L[i, i] += ak + L[j, j] += ak + L[i, j] -= ak + L[j, i] -= ak + rhs[i] += ak * bk + rhs[j] -= ak * bk + if lam > 0: + L += lam * np.eye(n_c) + rhs += lam * w0 + + if n_c == 1: + if lam > 0: + return w0.astype(np.float64, copy=True) + return np.zeros(1, dtype=np.float64) + + if lam > 0: + return np.linalg.solve(L, rhs).astype(np.float64) + + free = np.arange(1, n_c, dtype=np.int64) + Lf = L[np.ix_(free, free)] + rhsf = rhs[free] + wf = np.linalg.solve(Lf, rhsf) + w = np.zeros(n_c, dtype=np.float64) + w[free] = wf + return w + + +def _solve_component_admm( + I: np.ndarray, + J: np.ndarray, + alpha: np.ndarray, + beta: np.ndarray, + target: np.ndarray, + confidence: np.ndarray, + w0: np.ndarray, + *, + model: FitModel, + lambda_regularize: float, + rho: float, + max_iter: int, + tol_abs: float, + tol_rel: float, + z_lo: np.ndarray | None, + z_hi: np.ndarray | None, +) -> tuple[np.ndarray, int, bool]: + n_c = int(np.max(np.maximum(I, J))) + 1 + m_c = I.shape[0] + lam = float(lambda_regularize) + + if lam > 0: + anchor: int | None = None + free = np.arange(n_c, dtype=np.int64) + else: + anchor = 0 + free = np.arange(1, n_c, dtype=np.int64) + + L = np.zeros((n_c, n_c), dtype=np.float64) + for i, j in zip(I.tolist(), J.tolist()): + L[i, i] += 1.0 + L[j, j] += 1.0 + L[i, j] -= 1.0 + L[j, i] -= 1.0 + + M = rho * L + lam * np.eye(n_c) + Mf = M[np.ix_(free, free)] + if free.size and not np.all(np.isfinite(Mf)): + raise _NumericalFailure('ADMM system matrix contains non-finite values') + try: + chol = ( + np.linalg.cholesky(Mf) + if free.size + else np.zeros((0, 0), dtype=np.float64) + ) + except np.linalg.LinAlgError: + Mf2 = Mf + 1e-12 * np.eye(Mf.shape[0]) + try: + chol = np.linalg.cholesky(Mf2) + except np.linalg.LinAlgError as exc: + raise _NumericalFailure( + 'ADMM system matrix is not numerically positive definite' + ) from exc + Mf = Mf2 + + def solve_M(rhs_free: np.ndarray) -> np.ndarray: + if rhs_free.size == 0: + return np.zeros(0, dtype=np.float64) + y = np.linalg.solve(chol, rhs_free) + x = np.linalg.solve(chol.T, y) + if not np.all(np.isfinite(x)): + raise _NumericalFailure('ADMM linear solve produced non-finite values') + return x + + # Initialize at the target z implied by the chosen measurement. + z = (target - beta) / alpha + if z_lo is not None and z_hi is not None: + z = np.clip(z, z_lo, z_hi) + u = np.zeros(m_c, dtype=np.float64) + w = np.zeros(n_c, dtype=np.float64) + converged = False + + for _it in range(1, max_iter + 1): + y = z - u + rhs = np.zeros(n_c, dtype=np.float64) + np.add.at(rhs, I, rho * y) + np.add.at(rhs, J, -rho * y) + if lam > 0: + rhs += lam * w0 + + rhs_free = rhs[free] + w_free = solve_M(rhs_free) + if anchor is not None: + w[anchor] = 0.0 + w[free] = w_free + if not np.all(np.isfinite(w)): + raise _NumericalFailure('ADMM primal iterate became non-finite') + + v = (w[I] - w[J]) + u + z_prev = z.copy() + z = _prox_edge_objective( + v, + alpha, + beta, + target, + confidence, + model=model, + rho=rho, + z_lo=z_lo, + z_hi=z_hi, + ) + + Aw = w[I] - w[J] + r = Aw - z + u = u + r + if not ( + np.all(np.isfinite(z)) + and np.all(np.isfinite(r)) + and np.all(np.isfinite(u)) + and np.all(np.isfinite(Aw)) + ): + raise _NumericalFailure('ADMM iterates became non-finite') + + r_norm = float(np.linalg.norm(r)) + z_norm = float(np.linalg.norm(z)) + Aw_norm = float(np.linalg.norm(Aw)) + eps_pri = np.sqrt(m_c) * tol_abs + tol_rel * max(Aw_norm, z_norm) + + dz = z - z_prev + s_vec = np.zeros(n_c, dtype=np.float64) + np.add.at(s_vec, I, rho * dz) + np.add.at(s_vec, J, -rho * dz) + s_norm = float(np.linalg.norm(s_vec[free])) if free.size else 0.0 + u_norm = float(np.linalg.norm(u)) + eps_dual = np.sqrt(len(free)) * tol_abs + tol_rel * rho * u_norm + + if r_norm <= eps_pri and s_norm <= eps_dual: + converged = True + break + + return w, _it, converged + + +def _prox_edge_objective( + v: np.ndarray, + alpha: np.ndarray, + beta: np.ndarray, + target: np.ndarray, + confidence: np.ndarray, + *, + model: FitModel, + rho: float, + z_lo: np.ndarray | None, + z_hi: np.ndarray | None, +) -> np.ndarray: + z = v.copy() + if z_lo is not None and z_hi is not None: + z = np.clip(z, z_lo, z_hi) + + for _ in range(60): + y = beta + alpha * z + fp_y, fpp_y = _mismatch_derivatives(y, target, confidence, model.mismatch) + for penalty in model.penalties: + p_fp_y, p_fpp_y = _penalty_derivatives(y, penalty) + fp_y = fp_y + p_fp_y + fpp_y = fpp_y + p_fpp_y + + g = fp_y * alpha + rho * (z - v) + gp = fpp_y * (alpha**2) + rho + if not np.all(np.isfinite(gp)) or np.any(np.abs(gp) < 1e-18): + raise _NumericalFailure( + 'prox Newton derivative became singular or non-finite' + ) + step = g / gp + if not np.all(np.isfinite(step)): + raise _NumericalFailure('prox Newton step became non-finite') + z_new = z - step + if z_lo is not None and z_hi is not None: + z_new = np.clip(z_new, z_lo, z_hi) + if float(np.max(np.abs(step))) < 1e-12: + z = z_new + break + z = z_new + return z + + +def _mismatch_derivatives( + y: np.ndarray, + target: np.ndarray, + confidence: np.ndarray, + mismatch: SquaredLoss | HuberLoss, +) -> tuple[np.ndarray, np.ndarray]: + r = y - target + if isinstance(mismatch, SquaredLoss): + fp_y = 2.0 * confidence * r + fpp_y = 2.0 * confidence + return fp_y, fpp_y + if isinstance(mismatch, HuberLoss): + delta = float(mismatch.delta) + abs_r = np.abs(r) + quad = abs_r <= delta + fp_y = np.where(quad, confidence * r, confidence * delta * np.sign(r)) + fpp_y = np.where(quad, confidence, 0.0) + return fp_y, fpp_y + raise TypeError(f'unsupported mismatch: {type(mismatch)!r}') + + +def _penalty_derivatives( + y: np.ndarray, + penalty: SoftIntervalPenalty + | ExponentialBoundaryPenalty + | ReciprocalBoundaryPenalty, +) -> tuple[np.ndarray, np.ndarray]: + if isinstance(penalty, SoftIntervalPenalty): + lower = float(penalty.lower) + upper = float(penalty.upper) + strength = float(penalty.strength) + fp = np.zeros_like(y) + fpp = np.zeros_like(y) + lo_mask = y < lower + hi_mask = y > upper + if np.any(lo_mask): + fp[lo_mask] += 2.0 * strength * (y[lo_mask] - lower) + fpp[lo_mask] += 2.0 * strength + if np.any(hi_mask): + fp[hi_mask] += 2.0 * strength * (y[hi_mask] - upper) + fpp[hi_mask] += 2.0 * strength + return fp, fpp + + if isinstance(penalty, ExponentialBoundaryPenalty): + lower = float(penalty.lower) + upper = float(penalty.upper) + margin = float(penalty.margin) + strength = float(penalty.strength) + tau = float(penalty.tau) + left = lower + margin + right = upper - margin + A = np.exp((left - y) / tau) + B = np.exp((y - right) / tau) + fp = strength * (-A + B) / tau + fpp = strength * (A + B) / (tau * tau) + return fp, fpp + + if isinstance(penalty, ReciprocalBoundaryPenalty): + lower = float(penalty.lower) + upper = float(penalty.upper) + margin = float(penalty.margin) + strength = float(penalty.strength) + eps = float(penalty.epsilon) + left = lower + margin + right = upper - margin + fp = np.zeros_like(y) + fpp = np.zeros_like(y) + lo_mask = y < left + if np.any(lo_mask): + denom = np.maximum(y[lo_mask] - lower, eps) + fp[lo_mask] += -strength / (denom**2) + fpp[lo_mask] += 2.0 * strength / (denom**3) + hi_mask = y > right + if np.any(hi_mask): + denom = np.maximum(upper - y[hi_mask], eps) + fp[hi_mask] += strength / (denom**2) + fpp[hi_mask] += 2.0 * strength / (denom**3) + return fp, fpp + + raise TypeError(f'unsupported penalty: {type(penalty)!r}') diff --git a/src/pyvoro2/validation.py b/src/pyvoro2/validation.py index f956cf6..5997724 100644 --- a/src/pyvoro2/validation.py +++ b/src/pyvoro2/validation.py @@ -60,9 +60,12 @@ class NormalizationError(ValueError): """Raised when strict normalization validation fails.""" def __init__(self, message: str, diagnostics: NormalizationDiagnostics): - super().__init__(message) + super().__init__(message, diagnostics) self.diagnostics = diagnostics + def __str__(self) -> str: + return str(self.args[0]) + def _as_shift(s: Any) -> tuple[int, int, int]: return int(s[0]), int(s[1]), int(s[2]) @@ -133,7 +136,7 @@ def validate_normalized_topology( cells = list(normalized.cells) n_cells = len(cells) - n_global_vertices = int(getattr(normalized, 'global_vertices').shape[0]) + n_global_vertices = int(normalized.global_vertices.shape[0]) n_global_edges: int | None = None n_global_faces: int | None = None diff --git a/src/pyvoro2/viz2d.py b/src/pyvoro2/viz2d.py new file mode 100644 index 0000000..d694a8f --- /dev/null +++ b/src/pyvoro2/viz2d.py @@ -0,0 +1,81 @@ +"""Optional matplotlib-based visualization helpers for planar tessellations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable, Protocol + +if TYPE_CHECKING: # pragma: no cover - import only for annotations + from matplotlib.axes import Axes + from matplotlib.figure import Figure + + +class _SupportsPlanarBounds(Protocol): + """Protocol for simple 2D domains that expose rectangular bounds.""" + + bounds: tuple[tuple[float, float], tuple[float, float]] + + +def plot_tessellation( + cells: Iterable[dict], + *, + ax: Axes | None = None, + domain: _SupportsPlanarBounds | None = None, + show_sites: bool = False, + annotate_ids: bool = False, +) -> tuple[Figure, Axes]: + """Plot planar cells using matplotlib. + + Args: + cells: Iterable of raw 2D cell dictionaries as returned by + ``pyvoro2.planar.compute`` or ``pyvoro2.planar.ghost_cells``. + ax: Optional existing matplotlib axes. + domain: Optional planar domain. When it exposes ``bounds``, the domain + rectangle is drawn as a simple outline. + show_sites: If True, draw the reported cell sites. + annotate_ids: If True, label cell IDs at their reported sites. + + Returns: + ``(fig, ax)``. + """ + + import matplotlib.pyplot as plt + + if ax is None: + fig, ax = plt.subplots() + else: + fig = ax.figure + + if domain is not None and hasattr(domain, 'bounds'): + try: + (xmin, xmax), (ymin, ymax) = domain.bounds + except (TypeError, ValueError): # pragma: no cover - defensive + pass + else: + ax.plot( + [xmin, xmax, xmax, xmin, xmin], + [ymin, ymin, ymax, ymax, ymin], + ) + + for cell in cells: + vertices = cell.get('vertices') or [] + edges = cell.get('edges') or [] + if vertices and edges: + for edge in edges: + vids = edge.get('vertices', ()) + if len(vids) != 2: + continue + i, j = int(vids[0]), int(vids[1]) + if i < 0 or j < 0 or i >= len(vertices) or j >= len(vertices): + continue + vi = vertices[i] + vj = vertices[j] + ax.plot([vi[0], vj[0]], [vi[1], vj[1]]) + + site = cell.get('site') + if site is not None and show_sites: + ax.plot([float(site[0])], [float(site[1])], marker='o', linestyle='None') + if site is not None and annotate_ids: + ax.text(float(site[0]), float(site[1]), str(cell.get('id', '?'))) + + ax.set_aspect('equal', adjustable='box') + return fig, ax diff --git a/src/pyvoro2/viz3d.py b/src/pyvoro2/viz3d.py index 8c16769..a5d449a 100644 --- a/src/pyvoro2/viz3d.py +++ b/src/pyvoro2/viz3d.py @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: LGPL-3.0-or-later """Optional 3D visualization helpers. This module provides a small set of convenience functions for visualizing @@ -689,7 +689,7 @@ def view_tessellation( f'Skipping site labels because n={len(lbls)} exceeds ' f'max_site_labels={max_site_labels}.' ) - warnings.warn(msg) + warnings.warn(msg, stacklevel=2) add_sites( v, pts_arr, labels=None, color=st.site_color, radius=st.site_radius ) @@ -738,7 +738,7 @@ def view_tessellation( f'{max_vertex_labels} of {len(vtx)}. ' 'Increase max_vertex_labels to label more.' ) - warnings.warn(msg) + warnings.warn(msg, stacklevel=2) # Keep spheres for all vertices, but only label the # first `max_vertex_labels` to avoid overwhelming # the viewer. diff --git a/tests/test_api_input_validation.py b/tests/test_api_input_validation.py index f076fce..d1307bf 100644 --- a/tests/test_api_input_validation.py +++ b/tests/test_api_input_validation.py @@ -146,3 +146,104 @@ def fake_compute_box_standard( return_adjacency=False, return_faces=False, ) + + +def test_compute_rejects_invalid_block_specification() -> None: + dom = _box() + pts = np.array([[0.1, 0.2, 0.3], [0.7, 0.6, 0.5]], dtype=float) + + with pytest.raises(ValueError, match='blocks'): + pyvoro2.compute( + pts, + domain=dom, + blocks=(2, 2), + return_vertices=False, + return_adjacency=False, + return_faces=False, + ) + + with pytest.raises(ValueError, match='positive'): + pyvoro2.compute( + pts, + domain=dom, + blocks=(2, 0, 2), + return_vertices=False, + return_adjacency=False, + return_faces=False, + ) + + with pytest.raises(ValueError, match='block_size'): + pyvoro2.compute( + pts, + domain=dom, + block_size=np.nan, + return_vertices=False, + return_adjacency=False, + return_faces=False, + ) + + +@pytest.mark.parametrize( + ('func', 'kwargs'), + [ + ( + pyvoro2.compute, + dict( + return_vertices=False, + return_adjacency=False, + return_faces=False, + ), + ), + (pyvoro2.locate, dict(queries=np.array([[0.2, 0.2, 0.2]], dtype=float))), + ( + pyvoro2.ghost_cells, + dict( + queries=np.array([[0.2, 0.2, 0.2]], dtype=float), + return_vertices=False, + return_adjacency=False, + return_faces=False, + ), + ), + ], +) +def test_public_wrappers_reject_invalid_duplicate_check(func, kwargs) -> None: + dom = _box() + pts = np.array([[0.1, 0.2, 0.3], [0.7, 0.6, 0.5]], dtype=float) + + with pytest.raises(ValueError, match='duplicate_check'): + if func is pyvoro2.compute: + func(pts, domain=dom, duplicate_check='bad', **kwargs) + else: + queries = kwargs.pop('queries') + func(pts, queries, domain=dom, duplicate_check='bad', **kwargs) + + +def test_power_mode_rejects_nonfinite_radii_and_ghost_radius() -> None: + dom = _box() + pts = np.array([[0.1, 0.2, 0.3], [0.7, 0.6, 0.5]], dtype=float) + rr = np.array([0.1, np.inf], dtype=float) + q = np.array([[0.2, 0.2, 0.2]], dtype=float) + + with pytest.raises(ValueError, match='finite'): + pyvoro2.compute( + pts, + domain=dom, + mode='power', + radii=rr, + return_vertices=False, + return_adjacency=False, + return_faces=False, + ) + + with pytest.raises(ValueError, match='finite'): + pyvoro2.ghost_cells( + pts, + q, + domain=dom, + mode='power', + radii=np.array([0.1, 0.2], dtype=float), + ghost_radius=np.nan, + return_vertices=False, + return_adjacency=False, + return_faces=False, + ) diff --git a/tests/test_inverse_fit.py b/tests/test_inverse_fit.py deleted file mode 100644 index b005ea3..0000000 --- a/tests/test_inverse_fit.py +++ /dev/null @@ -1,137 +0,0 @@ -import numpy as np - - -def test_fit_power_weights_fraction_two_points_analytic(): - from pyvoro2 import fit_power_weights_from_plane_fractions - - pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) - res = fit_power_weights_from_plane_fractions(pts, [(0, 1, 0.25)]) - - # Expected: w0 - w1 = d^2 (2t-1) = 4 * (-0.5) = -2 - assert np.allclose(res.weights[0] - res.weights[1], -2.0, atol=1e-10) - assert np.allclose(res.t_pred[0], 0.25, atol=1e-10) - assert res.solver == 'analytic' - - -def test_fit_power_weights_fraction_allows_t_outside_segment(): - from pyvoro2 import fit_power_weights_from_plane_fractions - - pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) - res = fit_power_weights_from_plane_fractions( - pts, [(0, 1, 1.2)], t_bounds_mode='none' - ) - - assert np.allclose(res.t_pred[0], 1.2, atol=1e-10) - # Radii are defined via a gauge shift; should be non-negative. - assert np.all(res.radii >= 0) - - -def test_fit_power_weights_fraction_hard_bounds_clips_prediction(): - from pyvoro2 import fit_power_weights_from_plane_fractions - - pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) - res = fit_power_weights_from_plane_fractions( - pts, - [(0, 1, -0.2)], - t_bounds=(0.0, 1.0), - t_bounds_mode='hard', - solver='admm', - max_iter=5000, - ) - - assert 0.0 <= res.t_pred[0] <= 1.0 - assert np.allclose(res.t_pred[0], 0.0, atol=1e-5) - - -def test_r_min_sets_minimum_radius_via_weight_shift(): - from pyvoro2 import fit_power_weights_from_plane_fractions - - pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) - res = fit_power_weights_from_plane_fractions(pts, [(0, 1, 0.25)], r_min=1.0) - - assert np.min(res.radii) == np.min(res.radii) # not NaN - assert np.allclose(np.min(res.radii), 1.0, atol=1e-12) - # The underlying weights are not shifted; shift is reported separately. - assert np.allclose(res.weights[0], 0.0, atol=1e-12) - - -def test_fit_power_weights_fraction_soft_quadratic_penalty_prefers_inside_interval(): - from pyvoro2 import fit_power_weights_from_plane_fractions - - pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) - - # Without restriction, the solver can match t=-0.2 exactly. - res0 = fit_power_weights_from_plane_fractions( - pts, [(0, 1, -0.2)], t_bounds_mode='none' - ) - assert np.allclose(res0.t_pred[0], -0.2, atol=1e-10) - - # With a soft penalty for leaving [0,1], prediction should move toward the interval. - res = fit_power_weights_from_plane_fractions( - pts, - [(0, 1, -0.2)], - t_bounds=(0.0, 1.0), - t_bounds_mode='soft_quadratic', - alpha_out=100.0, - solver='admm', - max_iter=5000, - ) - - assert res.t_pred[0] > res0.t_pred[0] - - -def test_fit_power_weights_fraction_near_boundary_penalty_pushes_away(): - from pyvoro2 import fit_power_weights_from_plane_fractions - - pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) - - # Target is very close to 0. With hard bounds only, it should fit near 0. - res_hard = fit_power_weights_from_plane_fractions( - pts, - [(0, 1, 1e-3)], - t_bounds=(0.0, 1.0), - t_bounds_mode='hard', - solver='admm', - max_iter=5000, - ) - - # With near-boundary repulsion, the optimum should move away from the boundary. - res_repulse = fit_power_weights_from_plane_fractions( - pts, - [(0, 1, 1e-3)], - t_bounds=(0.0, 1.0), - t_bounds_mode='hard', - t_near_penalty='exp', - beta_near=1.0, - t_margin=0.05, - t_tau=0.01, - solver='admm', - max_iter=8000, - ) - - assert res_repulse.t_pred[0] >= res_hard.t_pred[0] - 1e-6 - assert res_repulse.t_pred[0] > 0.01 # should not hug the boundary - - -def test_fit_power_weights_check_contacts_flags_inactive_constraints(): - from pyvoro2 import Box, fit_power_weights_from_plane_fractions - - # Three collinear points: neighbors are (0-1) and (1-2), not (0-2). - pts = np.array( - [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], - dtype=float, - ) - domain = Box(((-5, 5), (-5, 5), (-5, 5))) - - res = fit_power_weights_from_plane_fractions( - pts, - constraints=[(0, 2, 0.5)], - domain=domain, - check_contacts=True, - ) - - assert res.is_contact is not None - assert res.inactive_constraints is not None - assert res.is_contact.shape == (1,) - assert bool(res.is_contact[0]) is False - assert tuple(res.inactive_constraints) == (0,) diff --git a/tests/test_notebook_checker_import_mode.py b/tests/test_notebook_checker_import_mode.py new file mode 100644 index 0000000..c275471 --- /dev/null +++ b/tests/test_notebook_checker_import_mode.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = REPO_ROOT / 'tools' / 'check_notebooks.py' + +spec = importlib.util.spec_from_file_location('check_notebooks_tool', MODULE_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) + + +def test_configure_import_path_default_is_noop() -> None: + original = list(sys.path) + try: + sys.path[:] = [entry for entry in sys.path if entry != str(check_notebooks.SRC)] + check_notebooks.configure_import_path(use_src=False) + assert str(check_notebooks.SRC) not in sys.path + finally: + sys.path[:] = original + + +def test_configure_import_path_use_src_prepends_repo_src() -> None: + original = list(sys.path) + try: + sys.path[:] = [entry for entry in sys.path if entry != str(check_notebooks.SRC)] + check_notebooks.configure_import_path(use_src=True) + assert sys.path[0] == str(check_notebooks.SRC) + finally: + sys.path[:] = original diff --git a/tests/test_notebooks_meta.py b/tests/test_notebooks_meta.py new file mode 100644 index 0000000..4888c88 --- /dev/null +++ b/tests/test_notebooks_meta.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXPORT_SCRIPT = REPO_ROOT / 'tools' / 'export_notebooks.py' +NOTEBOOKS = REPO_ROOT / 'notebooks' +EXPORTED_NOTEBOOKS = REPO_ROOT / 'docs' / 'notebooks' + +EXPECTED_NOTEBOOKS = { + '01_basic_compute.ipynb', + '02_periodic_graph.ipynb', + '03_locate_and_ghost.ipynb', + '04_powerfit.ipynb', + '05_visualization.ipynb', + '06_powerfit_reports.ipynb', + '07_powerfit_infeasibility.ipynb', + '08_powerfit_active_path.ipynb', +} + +EXPECTED_PAGES = {name.replace('.ipynb', '.md') for name in EXPECTED_NOTEBOOKS} + + +def test_notebook_files_exist() -> None: + actual = {path.name for path in NOTEBOOKS.glob('*.ipynb')} + assert EXPECTED_NOTEBOOKS.issubset(actual) + + +def test_exported_notebook_pages_are_in_sync() -> None: + actual = {path.name for path in EXPORTED_NOTEBOOKS.glob('*.md')} + assert EXPECTED_PAGES.issubset(actual) + subprocess.run( + [sys.executable, str(EXPORT_SCRIPT), '--check'], + cwd=REPO_ROOT, + check=True, + ) diff --git a/tests/test_periodic_face_shifts.py b/tests/test_periodic_face_shifts.py index e996db7..c3085c9 100644 --- a/tests/test_periodic_face_shifts.py +++ b/tests/test_periodic_face_shifts.py @@ -24,7 +24,7 @@ def test_return_face_shifts_requires_faces_and_vertices(): return_adjacency=False, return_face_shifts=True, ) - assert False, 'expected ValueError' + raise AssertionError('expected ValueError') except ValueError: pass @@ -39,7 +39,7 @@ def test_return_face_shifts_requires_faces_and_vertices(): return_adjacency=False, return_face_shifts=True, ) - assert False, 'expected ValueError' + raise AssertionError('expected ValueError') except ValueError: pass diff --git a/tests/test_planar_api_dispatch.py b/tests/test_planar_api_dispatch.py new file mode 100644 index 0000000..4e35d00 --- /dev/null +++ b/tests/test_planar_api_dispatch.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import pytest + +import pyvoro2.planar as pv2 +import pyvoro2.planar.api as api2d + + +@dataclass +class FakeCore2D: + last_call: tuple[str, tuple] | None = None + + def compute_box_standard( + self, + points, + ids, + bounds, + blocks, + periodic, + init_mem, + opts, + ): + self.last_call = ( + 'compute_box_standard', + (bounds, blocks, periodic, init_mem, opts), + ) + return [ + { + 'id': 0, + 'area': 0.5, + 'site': [0.1, 0.5], + 'vertices': [[0.0, 0.0], [0.5, 0.0], [0.5, 1.0], [0.0, 1.0]], + 'adjacency': [[1, 3], [2, 0], [3, 1], [0, 2]], + 'edges': [ + {'adjacent_cell': -1, 'vertices': [0, 1]}, + {'adjacent_cell': 1, 'vertices': [1, 2]}, + {'adjacent_cell': -2, 'vertices': [2, 3]}, + {'adjacent_cell': 1, 'vertices': [3, 0]}, + ], + }, + { + 'id': 1, + 'area': 0.5, + 'site': [0.9, 0.5], + 'vertices': [[0.5, 0.0], [1.0, 0.0], [1.0, 1.0], [0.5, 1.0]], + 'adjacency': [[1, 3], [2, 0], [3, 1], [0, 2]], + 'edges': [ + {'adjacent_cell': -1, 'vertices': [0, 1]}, + {'adjacent_cell': 0, 'vertices': [1, 2]}, + {'adjacent_cell': -2, 'vertices': [2, 3]}, + {'adjacent_cell': 0, 'vertices': [3, 0]}, + ], + }, + ] + + def compute_box_power( + self, + points, + ids, + radii, + bounds, + blocks, + periodic, + init_mem, + opts, + ): + self.last_call = ('compute_box_power', (radii.copy(), bounds, blocks, periodic)) + return [ + { + 'id': 1, + 'area': 1.0, + 'site': [0.7, 0.7], + 'vertices': [], + 'adjacency': [], + 'edges': [], + } + ] + + def locate_box_standard( + self, + points, + ids, + bounds, + blocks, + periodic, + init_mem, + queries, + ): + self.last_call = ('locate_box_standard', (bounds, blocks, periodic, init_mem)) + return ( + np.array([True, False]), + np.array([1, -1]), + np.array([[1.0, 0.0], [np.nan, np.nan]]), + ) + + def locate_box_power( + self, + points, + ids, + radii, + bounds, + blocks, + periodic, + init_mem, + queries, + ): + self.last_call = ('locate_box_power', (radii.copy(), bounds, blocks, periodic)) + return np.array([True]), np.array([0]), np.array([[0.0, 0.0]]) + + def ghost_box_standard( + self, + points, + ids, + bounds, + blocks, + periodic, + init_mem, + opts, + queries, + ): + self.last_call = ( + 'ghost_box_standard', + (bounds, blocks, periodic, init_mem, opts), + ) + return [ + { + 'id': -1, + 'empty': False, + 'area': 0.25, + 'site': [0.25, 0.25], + 'vertices': [[0.0, 0.0], [0.5, 0.0], [0.5, 0.5], [0.0, 0.5]], + 'adjacency': [[1, 3], [2, 0], [3, 1], [0, 2]], + 'edges': [ + {'adjacent_cell': 0, 'vertices': [0, 1]}, + {'adjacent_cell': 1, 'vertices': [1, 2]}, + {'adjacent_cell': -1, 'vertices': [2, 3]}, + {'adjacent_cell': -2, 'vertices': [3, 0]}, + ], + 'query_index': 0, + }, + { + 'id': -1, + 'empty': True, + 'area': 0.0, + 'site': [0.0, 0.0], + 'vertices': [], + 'adjacency': [], + 'edges': [], + 'query_index': 1, + }, + ] + + def ghost_box_power( + self, + points, + ids, + radii, + bounds, + blocks, + periodic, + init_mem, + opts, + queries, + ghost_radii, + ): + self.last_call = ( + 'ghost_box_power', + ( + radii.copy(), + ghost_radii.copy(), + bounds, + blocks, + periodic, + init_mem, + opts, + ), + ) + return [] + + +@pytest.fixture() +def fake_core(monkeypatch) -> FakeCore2D: + fake = FakeCore2D() + monkeypatch.setattr(api2d, '_core2d', fake, raising=False) + monkeypatch.setattr(api2d, '_CORE2D_IMPORT_ERROR', None, raising=False) + return fake + + +def test_planar_compute_remaps_ids_and_adds_edge_shifts(fake_core) -> None: + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + out = pv2.compute( + pts, + domain=pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, False)), + ids=[10, 20], + return_edge_shifts=True, + edge_shift_search=1, + ) + + assert fake_core.last_call is not None + assert fake_core.last_call[0] == 'compute_box_standard' + assert [cell['id'] for cell in out] == [10, 20] + + c0 = out[0] + c1 = out[1] + shifts01 = { + tuple(int(v) for v in edge['adjacent_shift']) + for edge in c0['edges'] + if edge['adjacent_cell'] == 20 + } + shifts10 = { + tuple(int(v) for v in edge['adjacent_shift']) + for edge in c1['edges'] + if edge['adjacent_cell'] == 10 + } + assert shifts01 == {(-1, 0), (0, 0)} + assert shifts10 == {(0, 0), (1, 0)} + + +def test_planar_compute_power_inserts_empty_cells(fake_core) -> None: + pts = np.array([[0.0, 0.0], [1.0, 0.0]], dtype=float) + out = pv2.compute( + pts, + domain=pv2.RectangularCell(((0.0, 2.0), (0.0, 1.0)), periodic=(True, False)), + mode='power', + radii=np.array([1.0, 2.0]), + include_empty=True, + ) + + assert fake_core.last_call is not None + assert fake_core.last_call[0] == 'compute_box_power' + assert len(out) == 2 + assert out[0]['id'] == 0 + assert out[0]['empty'] is True + assert out[0]['area'] == 0.0 + assert out[1]['id'] == 1 + + +def test_planar_locate_remaps_owner_ids(fake_core) -> None: + pts = np.array([[0.0, 0.0], [1.0, 0.0]], dtype=float) + queries = np.array([[0.9, 0.0], [5.0, 5.0]], dtype=float) + out = pv2.locate( + pts, + queries, + domain=pv2.Box(((0.0, 2.0), (-1.0, 1.0))), + ids=[100, 200], + return_owner_position=True, + ) + + assert fake_core.last_call is not None + assert fake_core.last_call[0] == 'locate_box_standard' + assert out['found'].tolist() == [True, False] + assert out['owner_id'].tolist() == [200, -1] + assert out['owner_pos'].shape == (2, 2) + + +def test_planar_ghost_cells_remap_neighbor_ids(fake_core) -> None: + pts = np.array([[0.0, 0.0], [1.0, 0.0]], dtype=float) + queries = np.array([[0.5, 0.5], [9.0, 9.0]], dtype=float) + out = pv2.ghost_cells( + pts, + queries, + domain=pv2.Box(((0.0, 2.0), (0.0, 1.0))), + ids=[10, 20], + include_empty=False, + ) + + assert fake_core.last_call is not None + assert fake_core.last_call[0] == 'ghost_box_standard' + assert len(out) == 1 + assert out[0]['edges'][0]['adjacent_cell'] == 10 + assert out[0]['edges'][1]['adjacent_cell'] == 20 + + +def test_planar_return_edge_shifts_requires_periodicity(fake_core) -> None: + pts = np.array([[0.0, 0.0], [1.0, 0.0]], dtype=float) + with pytest.raises(ValueError, match='periodic domains'): + pv2.compute( + pts, + domain=pv2.Box(((0.0, 2.0), (0.0, 1.0))), + return_edge_shifts=True, + ) + + +def test_planar_compute_return_diagnostics(fake_core) -> None: + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + cells, diag = pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_diagnostics=True, + tessellation_check='diagnose', + ) + + assert fake_core.last_call is not None + assert fake_core.last_call[0] == 'compute_box_standard' + assert isinstance(cells, list) + assert diag.ok is True + assert diag.ok_area is True + assert diag.area_ratio == pytest.approx(1.0) + + +def test_planar_compute_return_result_carries_diagnostics(fake_core) -> None: + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + result = pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_result=True, + tessellation_check='diagnose', + ) + + assert isinstance(result, pv2.PlanarComputeResult) + assert result.has_tessellation_diagnostics is True + assert result.require_tessellation_diagnostics().ok is True + assert result.normalized_vertices is None + assert result.normalized_topology is None + + +def test_planar_compute_normalize_vertices_returns_result(fake_core) -> None: + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + result = pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_vertices=False, + return_adjacency=False, + return_edges=False, + normalize='vertices', + ) + + assert isinstance(result, pv2.PlanarComputeResult) + assert fake_core.last_call is not None + assert fake_core.last_call[0] == 'compute_box_standard' + assert fake_core.last_call[1][-1] == (True, False, False) + assert set(result.cells[0].keys()) == {'id', 'area', 'site'} + assert result.has_normalized_vertices is True + assert result.global_vertices is not None + assert result.global_vertices.shape == (6, 2) + assert result.global_edges is None + with pytest.raises(ValueError, match='normalized topology'): + result.require_normalized_topology() + + +def test_planar_compute_normalize_topology_periodic_returns_result( + fake_core, +) -> None: + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, False)) + result = pv2.compute( + pts, + domain=domain, + return_vertices=False, + return_adjacency=False, + return_edges=False, + normalize='topology', + ) + + assert isinstance(result, pv2.PlanarComputeResult) + assert fake_core.last_call is not None + assert fake_core.last_call[0] == 'compute_box_standard' + assert fake_core.last_call[1][-1] == (True, False, True) + assert set(result.cells[0].keys()) == {'id', 'area', 'site'} + assert result.has_normalized_vertices is True + assert result.has_normalized_topology is True + assert result.global_edges is not None + diag = pv2.validate_normalized_topology( + result.require_normalized_topology(), + domain, + level='basic', + ) + assert diag.is_periodic_domain is True + assert diag.n_global_edges == len(result.global_edges) + + +def test_planar_compute_periodic_diagnostics_strip_internal_geometry( + fake_core, +) -> None: + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + cells, diag = pv2.compute( + pts, + domain=pv2.RectangularCell( + ((0.0, 1.0), (0.0, 1.0)), + periodic=(True, False), + ), + return_vertices=False, + return_adjacency=False, + return_edges=False, + return_diagnostics=True, + tessellation_check='diagnose', + ) + + assert fake_core.last_call is not None + assert fake_core.last_call[0] == 'compute_box_standard' + assert fake_core.last_call[1][-1] == (True, False, True) + + assert 'vertices' not in cells[0] + assert 'adjacency' not in cells[0] + assert 'edges' not in cells[0] + assert diag.reciprocity_checked is True + assert diag.ok_reciprocity is True + + +def test_planar_compute_tessellation_check_raise(fake_core) -> None: + def broken_compute_box_standard(*args, **kwargs): + fake_core.last_call = ('compute_box_standard', tuple()) + return [ + { + 'id': 0, + 'area': 0.25, + 'site': [0.1, 0.5], + 'vertices': [[0.0, 0.0], [0.5, 0.0], [0.5, 1.0], [0.0, 1.0]], + 'adjacency': [[1, 3], [2, 0], [3, 1], [0, 2]], + 'edges': [ + {'adjacent_cell': -1, 'vertices': [0, 1]}, + {'adjacent_cell': -1, 'vertices': [1, 2]}, + {'adjacent_cell': -1, 'vertices': [2, 3]}, + {'adjacent_cell': -1, 'vertices': [3, 0]}, + ], + } + ] + + fake_core.compute_box_standard = broken_compute_box_standard + + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + with pytest.raises(pv2.TessellationError, match='tessellation_check failed'): + pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + tessellation_check='raise', + ) + + +def test_planar_compute_invalid_tessellation_check(fake_core) -> None: + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + with pytest.raises(ValueError, match='tessellation_check'): + pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + tessellation_check='nope', # type: ignore[arg-type] + ) + + +def test_planar_compute_invalid_normalize(fake_core) -> None: + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + with pytest.raises(ValueError, match='normalize'): + pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + normalize='nope', # type: ignore[arg-type] + ) diff --git a/tests/test_planar_diagnostics.py b/tests/test_planar_diagnostics.py new file mode 100644 index 0000000..940c711 --- /dev/null +++ b/tests/test_planar_diagnostics.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pytest + + +if importlib.util.find_spec('pyvoro2._core2d') is None: + pytest.skip('pyvoro2._core2d is not available', allow_module_level=True) + +import pyvoro2.planar as pv2 + + +def _periodic_sample() -> tuple[np.ndarray, pv2.RectangularCell]: + pts = np.array([[0.2, 0.2], [0.8, 0.25], [0.4, 0.8]], dtype=float) + domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + return pts, domain + + +def test_planar_analyze_tessellation_box_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + box = pv2.Box(((0.0, 1.0), (0.0, 1.0))) + cells = pv2.compute(pts, domain=box, return_vertices=True, return_edges=True) + + diag = pv2.analyze_tessellation(cells, box) + + assert diag.ok is True + assert diag.ok_area is True + assert diag.reciprocity_checked is False + assert diag.area_ratio == pytest.approx(1.0) + + +def test_planar_analyze_tessellation_reports_missing_edge_shifts() -> None: + pts, domain = _periodic_sample() + cells = pv2.compute(pts, domain=domain, return_vertices=True, return_edges=True) + + diag = pv2.analyze_tessellation(cells, domain) + + assert diag.edge_shift_available is False + assert diag.reciprocity_checked is False + assert any(issue.code == 'NO_EDGE_SHIFTS' for issue in diag.issues) + + +def test_planar_periodic_hidden_adjacency_is_resolved() -> None: + pts, domain = _periodic_sample() + cells = pv2.compute( + pts, + domain=domain, + return_vertices=True, + return_edges=True, + return_edge_shifts=True, + ) + + assert all( + int(edge['adjacent_cell']) >= 0 + for cell in cells + for edge in cell['edges'] + ) + + diag = pv2.validate_tessellation(cells, domain, level='strict') + assert diag.ok is True + assert diag.n_edges_orphan == 0 + assert diag.n_edges_mismatched == 0 + + +def test_planar_partially_periodic_walls_remain_negative() -> None: + pts = np.array([[0.2, 0.2], [0.8, 0.25], [0.4, 0.8]], dtype=float) + domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, False)) + cells = pv2.compute( + pts, + domain=domain, + return_vertices=True, + return_edges=True, + return_edge_shifts=True, + ) + + assert any( + int(edge['adjacent_cell']) < 0 + for cell in cells + for edge in cell['edges'] + ) + + +def test_planar_validate_tessellation_strict_raises_on_area_gap() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + box = pv2.Box(((0.0, 1.0), (0.0, 1.0))) + cells = pv2.compute(pts, domain=box, return_vertices=True, return_edges=True) + broken = cells[:1] + + with pytest.raises(pv2.TessellationError): + pv2.validate_tessellation(broken, box, level='strict') + + +def test_planar_compute_periodic_diagnostics_auto_enable_edge_shifts() -> None: + pts, domain = _periodic_sample() + cells, diag = pv2.compute( + pts, + domain=domain, + return_vertices=False, + return_adjacency=False, + return_edges=False, + return_diagnostics=True, + ) + + assert all(set(cell.keys()) == {'id', 'area', 'site'} for cell in cells) + assert diag.reciprocity_checked is True + assert diag.ok_reciprocity is True + assert diag.ok is True + + +def test_planar_compute_tessellation_check_raise_uses_internal_shifts() -> None: + pts, domain = _periodic_sample() + cells = pv2.compute( + pts, + domain=domain, + return_vertices=False, + return_adjacency=False, + return_edges=False, + tessellation_check='raise', + ) + + assert len(cells) == len(pts) + assert all(set(cell.keys()) == {'id', 'area', 'site'} for cell in cells) diff --git a/tests/test_planar_domains.py b/tests/test_planar_domains.py new file mode 100644 index 0000000..398af66 --- /dev/null +++ b/tests/test_planar_domains.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from pyvoro2.planar import Box, RectangularCell +from pyvoro2.planar._domain_geometry import geometry2d + + +def test_planar_box_from_points() -> None: + pts = np.array([[0.0, 1.0], [2.0, -1.0]], dtype=float) + box = Box.from_points(pts, padding=0.5) + assert box.bounds == ((-0.5, 2.5), (-1.5, 1.5)) + + +def test_rectangular_cell_remap_cart_returns_shifts() -> None: + cell = RectangularCell(bounds=((0.0, 1.0), (0.0, 2.0)), periodic=(True, True)) + pts = np.array([[1.2, -0.1], [-0.1, 2.1]], dtype=float) + + remapped, shifts = cell.remap_cart(pts, return_shifts=True) + assert remapped.shape == (2, 2) + assert shifts.shape == (2, 2) + assert np.allclose(remapped[0], [0.2, 1.9]) + assert np.allclose(remapped[1], [0.9, 0.1]) + assert shifts.tolist() == [[1, -1], [-1, 1]] + + +def test_geometry2d_shift_to_cart_and_block_resolution() -> None: + dom = RectangularCell(bounds=((0.0, 2.0), (-1.0, 3.0)), periodic=(True, False)) + geom = geometry2d(dom) + + sh = np.array([[1, 0], [-2, 0]], dtype=np.int64) + cart = geom.shift_to_cart(sh) + assert np.allclose(cart[0], [2.0, 0.0]) + assert np.allclose(cart[1], [-4.0, 0.0]) + + assert geom.resolve_block_counts( + n_sites=10, + blocks=(3, 4), + block_size=None, + ) == (3, 4) + + +def test_geometry2d_validate_shifts_rejects_nonperiodic_axis() -> None: + dom = RectangularCell(bounds=((0.0, 1.0), (0.0, 1.0)), periodic=(True, False)) + geom = geometry2d(dom) + shifts = np.array([[0, 1]], dtype=np.int64) + + with pytest.raises(ValueError, match='non-periodic'): + geom.validate_shifts(shifts) diff --git a/tests/test_planar_edge_properties.py b/tests/test_planar_edge_properties.py new file mode 100644 index 0000000..1137af9 --- /dev/null +++ b/tests/test_planar_edge_properties.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import numpy as np + +from pyvoro2.edge_properties import annotate_edge_properties +from pyvoro2.planar import RectangularCell +from pyvoro2.planar._edge_shifts2d import _add_periodic_edge_shifts_inplace + + +def test_annotate_edge_properties_basic() -> None: + cells = [ + { + 'id': 0, + 'site': [0.1, 0.5], + 'vertices': [[0.0, 0.0], [0.5, 0.0], [0.5, 1.0], [0.0, 1.0]], + 'edges': [ + {'adjacent_cell': -1, 'vertices': [0, 1]}, + {'adjacent_cell': 1, 'vertices': [1, 2]}, + {'adjacent_cell': -2, 'vertices': [2, 3]}, + {'adjacent_cell': 1, 'vertices': [3, 0]}, + ], + }, + { + 'id': 1, + 'site': [0.9, 0.5], + 'vertices': [[0.5, 0.0], [1.0, 0.0], [1.0, 1.0], [0.5, 1.0]], + 'edges': [ + {'adjacent_cell': -1, 'vertices': [0, 1]}, + {'adjacent_cell': 0, 'vertices': [1, 2]}, + {'adjacent_cell': -2, 'vertices': [2, 3]}, + {'adjacent_cell': 0, 'vertices': [3, 0]}, + ], + }, + ] + dom = RectangularCell(bounds=((0.0, 1.0), (0.0, 1.0)), periodic=(True, False)) + + _add_periodic_edge_shifts_inplace( + cells, + lattice_vectors=dom.lattice_vectors, + periodic_mask=dom.periodic, + search=1, + ) + annotate_edge_properties(cells, dom) + + edge = cells[0]['edges'][1] + assert np.allclose(edge['midpoint'], [0.5, 0.5]) + assert np.isclose(edge['length'], 1.0) + assert edge['normal'] is not None + assert np.allclose(edge['other_site'], [0.9, 0.5]) diff --git a/tests/test_planar_edge_shifts2d.py b/tests/test_planar_edge_shifts2d.py new file mode 100644 index 0000000..d49b48c --- /dev/null +++ b/tests/test_planar_edge_shifts2d.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import numpy as np + +from pyvoro2.planar._edge_shifts2d import _add_periodic_edge_shifts_inplace + + +def _two_cell_periodic_x() -> list[dict[str, object]]: + return [ + { + 'id': 0, + 'site': [0.1, 0.5], + 'vertices': [[0.0, 0.0], [0.5, 0.0], [0.5, 1.0], [0.0, 1.0]], + 'edges': [ + {'adjacent_cell': -1, 'vertices': [0, 1]}, + {'adjacent_cell': 1, 'vertices': [1, 2]}, + {'adjacent_cell': -2, 'vertices': [2, 3]}, + {'adjacent_cell': 1, 'vertices': [3, 0]}, + ], + }, + { + 'id': 1, + 'site': [0.9, 0.5], + 'vertices': [[0.5, 0.0], [1.0, 0.0], [1.0, 1.0], [0.5, 1.0]], + 'edges': [ + {'adjacent_cell': -1, 'vertices': [0, 1]}, + {'adjacent_cell': 0, 'vertices': [1, 2]}, + {'adjacent_cell': -2, 'vertices': [2, 3]}, + {'adjacent_cell': 0, 'vertices': [3, 0]}, + ], + }, + ] + + +def test_planar_edge_shifts_detect_wraparound_standard() -> None: + cells = _two_cell_periodic_x() + + _add_periodic_edge_shifts_inplace( + cells, + lattice_vectors=(np.array([1.0, 0.0]), np.array([0.0, 1.0])), + periodic_mask=(True, False), + mode='standard', + search=1, + ) + + c0 = next(cell for cell in cells if cell['id'] == 0) + c1 = next(cell for cell in cells if cell['id'] == 1) + s01 = { + tuple(int(v) for v in edge['adjacent_shift']) + for edge in c0['edges'] + if edge['adjacent_cell'] == 1 + } + s10 = { + tuple(int(v) for v in edge['adjacent_shift']) + for edge in c1['edges'] + if edge['adjacent_cell'] == 0 + } + + assert (-1, 0) in s01 + assert (1, 0) in s10 + assert (0, 0) in s01 + assert (0, 0) in s10 + + +def test_planar_edge_shifts_can_repair_reciprocity() -> None: + cells = _two_cell_periodic_x() + for cell in cells: + for edge in cell['edges']: + if int(edge['adjacent_cell']) >= 0: + edge['adjacent_shift'] = (0, 0) + + _add_periodic_edge_shifts_inplace( + cells, + lattice_vectors=(np.array([1.0, 0.0]), np.array([0.0, 1.0])), + periodic_mask=(True, False), + mode='standard', + search=1, + validate=True, + repair=True, + ) + + c0 = next(cell for cell in cells if cell['id'] == 0) + c1 = next(cell for cell in cells if cell['id'] == 1) + + s01 = sorted( + tuple(int(v) for v in edge['adjacent_shift']) + for edge in c0['edges'] + if edge['adjacent_cell'] == 1 + ) + s10 = sorted( + tuple(int(v) for v in edge['adjacent_shift']) + for edge in c1['edges'] + if edge['adjacent_cell'] == 0 + ) + + assert s01 == [(-1, 0), (0, 0)] + assert s10 == [(0, 0), (1, 0)] + + +def test_planar_edge_shifts_support_ghost_query_cells() -> None: + cells = [ + { + 'id': -1, + 'query_index': 0, + 'site': [1.15, 0.2], + 'vertices': [ + [1.0, -0.284375], + [1.175, 0.5875], + [0.975, -0.24375], + [0.975, 0.6625], + [1.0, 0.6895833333333333], + [1.175, -0.13125], + ], + 'edges': [ + {'adjacent_cell': 2, 'vertices': [0, 5]}, + {'adjacent_cell': 2, 'vertices': [1, 4]}, + {'adjacent_cell': 2, 'vertices': [2, 0]}, + {'adjacent_cell': 1, 'vertices': [3, 2]}, + {'adjacent_cell': 2, 'vertices': [4, 3]}, + {'adjacent_cell': 0, 'vertices': [5, 1]}, + ], + } + ] + + _add_periodic_edge_shifts_inplace( + cells, + lattice_vectors=(np.array([1.0, 0.0]), np.array([0.0, 1.0])), + periodic_mask=(True, True), + mode='standard', + site_positions=np.array([[0.2, 0.2], [0.8, 0.2], [0.5, 0.8]]), + search=1, + ) + + shifts = [ + tuple(int(v) for v in edge['adjacent_shift']) + for edge in cells[0]['edges'] + if int(edge['adjacent_cell']) >= 0 + ] + + assert shifts + assert any(shift != (0, 0) for shift in shifts) diff --git a/tests/test_planar_fuzz_compute.py b/tests/test_planar_fuzz_compute.py new file mode 100644 index 0000000..1c47441 --- /dev/null +++ b/tests/test_planar_fuzz_compute.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pytest + +from conftest import rng_for_run + + +if importlib.util.find_spec('pyvoro2._core2d') is None: + pytest.skip('pyvoro2._core2d is not available', allow_module_level=True) + +import pyvoro2.planar as pv2 + + +def _sample_points_in_bounds( + rng: np.random.Generator, + n: int, + bounds: tuple[tuple[float, float], tuple[float, float]], + pad_frac: float = 0.05, +) -> np.ndarray: + (xmin, xmax), (ymin, ymax) = bounds + dx = xmax - xmin + dy = ymax - ymin + pad_x = pad_frac * dx + pad_y = pad_frac * dy + low = np.array([xmin + pad_x, ymin + pad_y], dtype=float) + high = np.array([xmax - pad_x, ymax - pad_y], dtype=float) + if np.any(high <= low): + low = np.array([xmin, ymin], dtype=float) + high = np.array([xmax, ymax], dtype=float) + return rng.uniform(low, high, size=(n, 2)) + + +@pytest.mark.fuzz +def test_fuzz_planar_compute_box_standard_with_diagnostics(fuzz_settings) -> None: + n_runs = int(fuzz_settings['n']) + seed = int(fuzz_settings['seed']) + + bounds = ((-5.0, 5.0), (-4.0, 6.0)) + domain = pv2.Box(bounds) + + for run in range(n_runs): + rng = rng_for_run(seed, 3000 + run) + pts = _sample_points_in_bounds(rng, 40, bounds) + + cells, diag = pv2.compute( + pts, + domain=domain, + mode='standard', + return_diagnostics=True, + ) + + assert diag.ok_area + assert len(cells) == len(pts) + assert all('area' in cell for cell in cells) + + +@pytest.mark.fuzz +def test_fuzz_planar_compute_periodic_with_convenience_diagnostics( + fuzz_settings, +) -> None: + n_runs = int(fuzz_settings['n']) + seed = int(fuzz_settings['seed']) + + bounds = ((0.0, 1.0), (0.0, 1.0)) + domain = pv2.RectangularCell(bounds, periodic=(True, True)) + + for run in range(n_runs): + rng = rng_for_run(seed, 4000 + run) + pts = _sample_points_in_bounds(rng, 35, bounds) + + cells, diag = pv2.compute( + pts, + domain=domain, + mode='standard', + return_vertices=False, + return_adjacency=False, + return_edges=False, + return_diagnostics=True, + ) + + assert diag.ok_area + assert diag.ok_reciprocity + assert all(set(cell.keys()) == {'id', 'area', 'site'} for cell in cells) + + +@pytest.mark.fuzz +def test_fuzz_planar_compute_periodic_power_with_diagnostics( + fuzz_settings, +) -> None: + n_runs = max(1, int(fuzz_settings['n']) // 2) + seed = int(fuzz_settings['seed']) + + bounds = ((0.0, 1.0), (0.0, 1.0)) + domain = pv2.RectangularCell(bounds, periodic=(True, True)) + + for run in range(n_runs): + rng = rng_for_run(seed, 5000 + run) + pts = _sample_points_in_bounds(rng, 24, bounds) + radii = rng.uniform(0.0, 0.08, size=(24,)) + + cells, diag = pv2.compute( + pts, + domain=domain, + mode='power', + radii=radii, + include_empty=True, + return_vertices=False, + return_adjacency=False, + return_edges=False, + return_diagnostics=True, + ) + + assert len(cells) == len(pts) + assert diag.ok_area + assert diag.ok_reciprocity + assert all(set(cell.keys()) >= {'id', 'area', 'site'} for cell in cells) + + +@pytest.mark.fuzz +def test_fuzz_planar_compute_result_periodic_topology( + fuzz_settings, +) -> None: + n_runs = max(1, int(fuzz_settings['n']) // 2) + seed = int(fuzz_settings['seed']) + + bounds = ((0.0, 1.0), (0.0, 1.0)) + domain = pv2.RectangularCell(bounds, periodic=(True, True)) + + for run in range(n_runs): + rng = rng_for_run(seed, 6000 + run) + pts = _sample_points_in_bounds(rng, 20, bounds) + radii = rng.uniform(0.0, 0.08, size=(20,)) + + result = pv2.compute( + pts, + domain=domain, + mode='power', + radii=radii, + include_empty=True, + return_vertices=False, + return_adjacency=False, + return_edges=False, + return_diagnostics=True, + normalize='topology', + ) + + assert isinstance(result, pv2.PlanarComputeResult) + assert result.require_tessellation_diagnostics().ok is True + topo = result.require_normalized_topology() + diag = pv2.validate_normalized_topology(topo, domain, level='basic') + assert diag.ok_vertex_edge_shift is True + assert diag.ok_edge_vertex_sets is True + assert result.global_vertices is not None + assert result.global_edges is not None + + +@pytest.mark.fuzz +def test_fuzz_planar_ghost_cells_periodic_power_smoke(fuzz_settings) -> None: + n_runs = max(1, int(fuzz_settings['n']) // 3) + seed = int(fuzz_settings['seed']) + + bounds = ((0.0, 1.0), (0.0, 1.0)) + domain = pv2.RectangularCell(bounds, periodic=(True, True)) + + for run in range(n_runs): + rng = rng_for_run(seed, 7000 + run) + pts = _sample_points_in_bounds(rng, 18, bounds) + radii = rng.uniform(0.0, 0.06, size=(18,)) + queries = rng.uniform(-0.25, 1.25, size=(6, 2)) + ghost_radii = rng.uniform(0.0, 0.05, size=(6,)) + + cells = pv2.ghost_cells( + pts, + queries, + domain=domain, + mode='power', + radii=radii, + ghost_radius=ghost_radii, + return_vertices=True, + return_edges=True, + return_edge_shifts=True, + include_empty=True, + ) + + assert len(cells) == len(queries) + assert all('query_index' in cell for cell in cells) + assert all('site' in cell for cell in cells) diff --git a/tests/test_planar_integration.py b/tests/test_planar_integration.py new file mode 100644 index 0000000..bccd75b --- /dev/null +++ b/tests/test_planar_integration.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pytest + + +if importlib.util.find_spec('pyvoro2._core2d') is None: + pytest.skip('pyvoro2._core2d is not available', allow_module_level=True) + +import pyvoro2.planar as pv2 + + +def test_planar_compute_standard_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + cells = pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_vertices=True, + return_edges=True, + ) + + assert len(cells) == 2 + assert {int(cell['id']) for cell in cells} == {0, 1} + assert all('area' in cell for cell in cells) + + +def test_planar_locate_standard_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + queries = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + out = pv2.locate(pts, queries, domain=pv2.Box(((0.0, 1.0), (0.0, 1.0)))) + + assert out['found'].tolist() == [True, True] + assert out['owner_id'].tolist() == [0, 1] + + +def test_planar_ghost_cells_standard_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + queries = np.array([[0.5, 0.5]], dtype=float) + cells = pv2.ghost_cells( + pts, + queries, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_vertices=True, + return_edges=True, + ) + + assert len(cells) == 1 + assert cells[0]['query_index'] == 0 + assert cells[0]['empty'] is False + + +def test_planar_compute_return_result_only_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + result = pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_result=True, + ) + + assert isinstance(result, pv2.PlanarComputeResult) + assert result.tessellation_diagnostics is None + assert result.normalized_vertices is None + assert result.normalized_topology is None + assert len(result.cells) == 2 + + +def test_planar_locate_power_asymmetric_weights_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + radii = np.array([0.05, 0.15], dtype=float) + queries = np.array([[0.15, 0.5], [0.9, 0.5]], dtype=float) + out = pv2.locate( + pts, + queries, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + mode='power', + radii=radii, + ) + + assert out['found'].tolist() == [True, True] + assert out['owner_id'].tolist() == [0, 1] + + +def test_planar_ghost_cells_power_asymmetric_weights_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + radii = np.array([0.05, 0.15], dtype=float) + queries = np.array([[0.5, 0.5]], dtype=float) + cells = pv2.ghost_cells( + pts, + queries, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + mode='power', + radii=radii, + ghost_radius=0.1, + return_vertices=True, + return_edges=True, + ) + + assert len(cells) == 1 + assert cells[0]['query_index'] == 0 + assert cells[0]['empty'] is False + assert float(cells[0]['area']) > 0.0 + + +def test_planar_compute_result_vertices_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + result = pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + return_vertices=False, + return_adjacency=False, + return_edges=False, + normalize='vertices', + ) + + assert isinstance(result, pv2.PlanarComputeResult) + assert result.global_vertices is not None + assert result.global_vertices.shape == (6, 2) + assert set(result.cells[0].keys()) == {'id', 'area', 'site'} + + +def test_planar_compute_result_topology_periodic_smoke() -> None: + pts = np.array([[0.2, 0.2], [0.8, 0.25], [0.4, 0.8]], dtype=float) + domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + result = pv2.compute( + pts, + domain=domain, + return_vertices=False, + return_adjacency=False, + return_edges=False, + return_diagnostics=True, + normalize='topology', + ) + + assert isinstance(result, pv2.PlanarComputeResult) + assert result.require_tessellation_diagnostics().ok is True + assert set(result.cells[0].keys()) == {'id', 'area', 'site'} + + topo = result.require_normalized_topology() + diag = pv2.validate_normalized_topology(topo, domain, level='strict') + + assert topo.global_vertices.shape == (6, 2) + assert len(topo.global_edges) == 9 + assert diag.ok is True + + +def test_planar_compute_power_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + radii = np.array([0.1, 0.12], dtype=float) + cells = pv2.compute( + pts, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + mode='power', + radii=radii, + include_empty=True, + return_vertices=True, + return_edges=True, + ) + + assert len(cells) == 2 + assert all('area' in cell for cell in cells) + + +def test_planar_locate_power_return_owner_position_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + radii = np.array([0.1, 0.1], dtype=float) + queries = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + out = pv2.locate( + pts, + queries, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + mode='power', + radii=radii, + return_owner_position=True, + ) + + assert out['found'].tolist() == [True, True] + assert out['owner_id'].tolist() == [0, 1] + assert out['owner_pos'].shape == (2, 2) + + +def test_planar_ghost_cells_power_with_ghost_radius_smoke() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + radii = np.array([0.1, 0.1], dtype=float) + queries = np.array([[0.5, 0.5]], dtype=float) + cells = pv2.ghost_cells( + pts, + queries, + domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), + mode='power', + radii=radii, + ghost_radius=0.08, + return_vertices=True, + return_edges=True, + include_empty=True, + ) + + assert len(cells) == 1 + assert cells[0]['query_index'] == 0 + assert 'area' in cells[0] + + +def test_planar_ghost_cells_periodic_edge_shifts_smoke() -> None: + pts = np.array([[0.2, 0.2], [0.8, 0.2], [0.5, 0.8]], dtype=float) + queries = np.array([[1.15, 0.2]], dtype=float) + domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + cells = pv2.ghost_cells( + pts, + queries, + domain=domain, + return_vertices=True, + return_edges=True, + return_edge_shifts=True, + ) + + assert len(cells) == 1 + assert cells[0]['query_index'] == 0 + assert any( + 'adjacent_shift' in edge + for edge in (cells[0].get('edges') or []) + if int(edge.get('adjacent_cell', -1)) >= 0 + ) diff --git a/tests/test_planar_lazy_core_import.py b/tests/test_planar_lazy_core_import.py new file mode 100644 index 0000000..bad815d --- /dev/null +++ b/tests/test_planar_lazy_core_import.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import pyvoro2.planar as pv2 +import pyvoro2.planar.api as api2d + + +def test_planar_compute_raises_helpful_error_when_core_missing(monkeypatch) -> None: + monkeypatch.setattr(api2d, '_core2d', None, raising=False) + monkeypatch.setattr( + api2d, + '_CORE2D_IMPORT_ERROR', + ImportError('dummy'), + raising=False, + ) + + with pytest.raises(ImportError) as exc: + pv2.compute(np.zeros((1, 2)), domain=pv2.Box(((0, 1), (0, 1)))) + + msg = str(exc.value) + assert '_core2d' in msg + assert 'planar support' in msg or 'build from source' in msg diff --git a/tests/test_planar_normalize.py b/tests/test_planar_normalize.py new file mode 100644 index 0000000..1c66f83 --- /dev/null +++ b/tests/test_planar_normalize.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import importlib.util + +import numpy as np +import pytest + + +if importlib.util.find_spec('pyvoro2._core2d') is None: + pytest.skip('pyvoro2._core2d is not available', allow_module_level=True) + +import pyvoro2.planar as pv2 + + +def _periodic_cells() -> tuple[list[dict], pv2.RectangularCell]: + pts = np.array([[0.2, 0.2], [0.8, 0.25], [0.4, 0.8]], dtype=float) + domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + cells = pv2.compute( + pts, + domain=domain, + return_vertices=True, + return_edges=True, + return_edge_shifts=True, + ) + return cells, domain + + +def test_planar_normalize_vertices_box() -> None: + pts = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float) + box = pv2.Box(((0.0, 1.0), (0.0, 1.0))) + cells = pv2.compute(pts, domain=box, return_vertices=True, return_edges=True) + + nv = pv2.normalize_vertices(cells, domain=box) + + assert nv.global_vertices.shape == (6, 2) + assert {int(cell['id']) for cell in nv.cells} == {0, 1} + for cell in nv.cells: + assert len(cell['vertex_global_id']) == len(cell['vertices']) + assert len(cell['vertex_shift']) == len(cell['vertices']) + assert all(tuple(shift) == (0, 0) for shift in cell['vertex_shift']) + + +def test_planar_normalize_topology_periodic_ok() -> None: + cells, domain = _periodic_cells() + + topo = pv2.normalize_topology(cells, domain=domain) + diag = pv2.validate_normalized_topology(topo, domain, level='strict') + + assert topo.global_vertices.shape == (6, 2) + assert len(topo.global_edges) == 9 + assert diag.ok is True + assert diag.has_wall_edges is False + assert diag.fully_periodic_domain is True + + +def test_planar_normalize_vertices_requires_edge_shifts_in_periodic_domains() -> None: + pts = np.array([[0.2, 0.2], [0.8, 0.25], [0.4, 0.8]], dtype=float) + domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + cells = pv2.compute(pts, domain=domain, return_vertices=True, return_edges=True) + + with pytest.raises(ValueError, match='return_edge_shifts=True'): + pv2.normalize_vertices(cells, domain=domain) + + +def test_planar_validate_normalized_topology_strict_raises_on_tampering() -> None: + cells, domain = _periodic_cells() + topo = pv2.normalize_topology(cells, domain=domain) + + topo.cells[1]['vertex_shift'][2] = (0, 0) + + with pytest.raises(pv2.NormalizationError): + pv2.validate_normalized_topology(topo, domain, level='strict') diff --git a/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py new file mode 100644 index 0000000..97b9d52 --- /dev/null +++ b/tests/test_powerfit_active_set.py @@ -0,0 +1,503 @@ +import numpy as np + + +def test_self_consistent_solver_drops_unrealized_pair(): + from pyvoro2 import Box, solve_self_consistent_power_weights + + pts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, + ) + domain = Box(((-5, 5), (-5, 5), (-5, 5))) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)], + measurement='fraction', + domain=domain, + return_history=True, + return_boundary_measure=True, + return_tessellation_diagnostics=True, + ) + + assert res.termination == 'self_consistent' + assert bool(res.active_mask[0]) is True + assert bool(res.active_mask[1]) is True + assert bool(res.active_mask[2]) is False + assert bool(res.realized.realized_same_shift[2]) is False + assert res.history is not None + assert len(res.history) >= 1 + assert res.constraints.n_constraints == 3 + assert res.tessellation_diagnostics is not None + assert res.tessellation_diagnostics.ok is True + assert np.isfinite(res.rms_residual_all) + assert np.isfinite(res.max_residual_all) + assert np.array_equal(res.diagnostics.site_i, np.array([0, 1, 0])) + assert np.array_equal(res.diagnostics.site_j, np.array([1, 2, 2])) + assert res.diagnostics.boundary_measure is not None + assert res.diagnostics.status[0] == 'stable_active' + assert res.diagnostics.status[1] == 'stable_active' + assert res.diagnostics.status[2] in {'toggled_inactive', 'stable_inactive'} + + +def test_self_consistent_solver_can_start_from_empty_active_set(): + from pyvoro2 import ActiveSetOptions, Box, solve_self_consistent_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + domain = Box(((-5, 5), (-5, 5), (-5, 5))) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5)], + measurement='fraction', + domain=domain, + active0=np.array([False]), + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=5), + ) + + assert res.termination == 'self_consistent' + assert bool(res.active_mask[0]) is True + assert bool(res.realized.realized_same_shift[0]) is True + assert res.diagnostics.status == ('toggled_active',) + + +def test_self_consistent_solver_respects_add_hysteresis_from_empty_start(): + from pyvoro2 import ActiveSetOptions, Box, solve_self_consistent_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + domain = Box(((-5, 5), (-5, 5), (-5, 5))) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5)], + measurement='fraction', + domain=domain, + active0=np.array([False]), + options=ActiveSetOptions(add_after=2, drop_after=1, max_iter=6), + return_history=True, + ) + + assert res.termination == 'self_consistent' + assert res.history is not None + assert len(res.history) >= 2 + assert [row.n_active for row in res.history[:2]] == [0, 1] + assert bool(res.active_mask[0]) is True + assert int(res.diagnostics.first_realized_iter[0]) == 1 + assert int(res.diagnostics.toggle_count[0]) == 1 + + +def test_self_consistent_solver_under_relaxation_records_nonzero_weight_step(): + from pyvoro2 import ActiveSetOptions, Box, solve_self_consistent_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + domain = Box(((-5, 5), (-5, 5), (-5, 5))) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.25)], + measurement='fraction', + domain=domain, + active0=np.array([False]), + options=ActiveSetOptions( + add_after=2, + drop_after=1, + relax=0.5, + max_iter=30, + weight_step_tol=1e-6, + ), + return_history=True, + ) + + assert res.history is not None + assert any(row.weight_step_norm > 0.0 for row in res.history) + assert res.fit.weights is not None + assert bool(res.active_mask[0]) is True + assert res.termination == 'self_consistent' + + +def test_self_consistent_solver_reports_realized_other_shift_for_periodic_pair(): + from pyvoro2 import ( + ActiveSetOptions, + PeriodicCell, + solve_self_consistent_power_weights, + ) + + cell = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))) + pts = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5, (1, 0, 0))], + measurement='fraction', + domain=cell, + image='given_only', + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=5), + ) + + assert res.termination == 'self_consistent' + assert bool(res.active_mask[0]) is False + assert bool(res.realized.realized[0]) is True + assert bool(res.realized.realized_same_shift[0]) is False + assert bool(res.realized.realized_other_shift[0]) is True + assert res.diagnostics.status == ('realized_other_shift',) + assert (-1, 0, 0) in res.diagnostics.realized_shifts[0] + + +def test_self_consistent_solver_detects_active_mask_cycle(monkeypatch): + import pyvoro2.powerfit.active as active_mod + from pyvoro2 import ActiveSetOptions, Box + from pyvoro2.powerfit.realize import RealizedPairDiagnostics + + pts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, + ) + domain = Box(((-5, 5), (-5, 5), (-5, 5))) + realized_masks = [ + np.array([True, False], dtype=bool), + np.array([False, True], dtype=bool), + np.array([True, False], dtype=bool), + ] + state = {'calls': 0} + + def fake_match_realized_pairs(*args, **kwargs): + idx = min(state['calls'], len(realized_masks) - 1) + same = realized_masks[idx] + state['calls'] += 1 + return RealizedPairDiagnostics( + realized=same.copy(), + unrealized=tuple(np.flatnonzero(~same).tolist()), + realized_same_shift=same.copy(), + realized_other_shift=np.zeros(2, dtype=bool), + realized_shifts=tuple(((0, 0, 0),) if bool(v) else tuple() for v in same), + endpoint_i_empty=np.zeros(2, dtype=bool), + endpoint_j_empty=np.zeros(2, dtype=bool), + boundary_measure=None, + cells=None, + tessellation_diagnostics=None, + ) + + monkeypatch.setattr(active_mod, 'match_realized_pairs', fake_match_realized_pairs) + + res = active_mod.solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5), (1, 2, 0.5)], + measurement='fraction', + domain=domain, + options=ActiveSetOptions(add_after=1, drop_after=1, cycle_window=4, max_iter=8), + return_history=True, + ) + + assert res.termination == 'cycle_detected' + assert res.converged is False + assert res.cycle_length == 2 + assert set(res.marginal_constraints) == {0, 1} + assert res.diagnostics.status == ('cycle_member', 'cycle_member') + + +def test_self_consistent_result_exports_records_with_ids(): + from pyvoro2 import ( + ActiveSetOptions, + Box, + FitModel, + Interval, + solve_self_consistent_power_weights, + ) + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + box = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) + res = solve_self_consistent_power_weights( + pts, + [(101, 202, 0.5)], + ids=[101, 202], + index_mode='id', + measurement='fraction', + domain=box, + model=FitModel(feasible=Interval(0.0, 1.0)), + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=3), + ) + + rows = res.to_records(use_ids=True) + assert len(rows) == 1 + assert rows[0]['site_i'] == 101 + assert rows[0]['site_j'] == 202 + assert rows[0]['status'] in { + 'stable_active', + 'stable_inactive', + 'active_unrealized', + } + + +def test_self_consistent_solver_supports_planar_box() -> None: + import pyvoro2.planar as pv2 + from pyvoro2 import solve_self_consistent_power_weights + + pts = np.array( + [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]], + dtype=float, + ) + domain = pv2.Box(((-5.0, 5.0), (-5.0, 5.0))) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)], + measurement='fraction', + domain=domain, + return_history=True, + return_boundary_measure=True, + return_tessellation_diagnostics=True, + ) + + assert res.termination == 'self_consistent' + assert bool(res.active_mask[0]) is True + assert bool(res.active_mask[1]) is True + assert bool(res.active_mask[2]) is False + assert bool(res.realized.realized_same_shift[2]) is False + assert res.tessellation_diagnostics is not None + assert res.tessellation_diagnostics.ok is True + assert res.diagnostics.boundary_measure is not None + assert np.isfinite(res.rms_residual_all) + + +def test_self_consistent_solver_supports_planar_periodic_wrong_shift() -> None: + import pyvoro2.planar as pv2 + from pyvoro2 import ActiveSetOptions, solve_self_consistent_power_weights + + cell = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5, (1, 0))], + measurement='fraction', + domain=cell, + image='given_only', + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=5), + ) + + assert res.termination == 'self_consistent' + assert bool(res.active_mask[0]) is False + assert bool(res.realized.realized[0]) is True + assert bool(res.realized.realized_same_shift[0]) is False + assert bool(res.realized.realized_other_shift[0]) is True + assert res.diagnostics.status == ('realized_other_shift',) + assert (-1, 0) in res.diagnostics.realized_shifts[0] + + +def test_self_consistent_solver_reports_active_connectivity_and_unaccounted_pairs(): + from pyvoro2 import ( + ActiveSetOptions, + Box, + solve_self_consistent_power_weights, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [10.0, 0.0, 0.0], [12.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5), (2, 3, 0.5), (0, 2, 0.5)], + measurement='fraction', + domain=box, + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=5), + connectivity_check='diagnose', + unaccounted_pair_check='diagnose', + ) + + assert res.termination == 'self_consistent' + assert np.array_equal(res.active_mask, np.array([True, True, False])) + assert {(pair.site_i, pair.site_j) for pair in res.realized.unaccounted_pairs} == { + (1, 2), + } + assert res.connectivity is not None + assert res.connectivity.candidate_graph.n_components == 1 + assert res.connectivity.active_graph is not None + assert res.connectivity.active_graph.n_components == 2 + assert res.connectivity.active_offsets_identified_by_data is False + + +def test_self_consistent_solver_preserves_active_component_offsets_on_final_refit( + monkeypatch, +): + import pyvoro2.powerfit.active as active_mod + from pyvoro2 import ActiveSetOptions, Box + from pyvoro2.powerfit.realize import RealizedPairDiagnostics + from pyvoro2.powerfit.solver import PowerWeightFitResult, weights_to_radii + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [10.0, 0.0, 0.0], [12.0, 0.0, 0.0]], + dtype=float, + ) + domain = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) + + def fake_fit_power_weights(points, constraints, **kwargs): + if constraints.n_constraints == 3: + weights = np.array([10.0, 12.0, 30.0, 28.0], dtype=float) + else: + weights = np.array([-1.0, 1.0, 1.0, -1.0], dtype=float) + radii, shift = weights_to_radii(weights) + return PowerWeightFitResult( + status='optimal', + hard_feasible=True, + weights=weights, + radii=radii, + weight_shift=shift, + measurement=constraints.measurement, + target=constraints.target.copy(), + predicted=np.zeros(constraints.n_constraints, dtype=float), + predicted_fraction=np.zeros(constraints.n_constraints, dtype=float), + predicted_position=np.zeros(constraints.n_constraints, dtype=float), + residuals=np.zeros(constraints.n_constraints, dtype=float), + rms_residual=0.0, + max_residual=0.0, + used_shifts=constraints.shifts.copy(), + solver='analytic', + n_iter=0, + converged=True, + conflict=None, + warnings=tuple(), + ) + + def fake_match_realized_pairs(*args, **kwargs): + same = np.array([True, True, False], dtype=bool) + return RealizedPairDiagnostics( + realized=same.copy(), + unrealized=(2,), + realized_same_shift=same.copy(), + realized_other_shift=np.zeros(3, dtype=bool), + realized_shifts=(((0, 0, 0),), ((0, 0, 0),), tuple()), + endpoint_i_empty=np.zeros(3, dtype=bool), + endpoint_j_empty=np.zeros(3, dtype=bool), + boundary_measure=None, + cells=None, + tessellation_diagnostics=None, + unaccounted_pairs=tuple(), + warnings=tuple(), + ) + + monkeypatch.setattr(active_mod, 'fit_power_weights', fake_fit_power_weights) + monkeypatch.setattr(active_mod, 'match_realized_pairs', fake_match_realized_pairs) + + res = active_mod.solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5), (2, 3, 0.5), (0, 2, 0.5)], + measurement='fraction', + domain=domain, + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=5), + connectivity_check='diagnose', + unaccounted_pair_check='diagnose', + ) + + assert res.termination == 'self_consistent' + assert np.allclose(res.fit.weights, np.array([10.0, 12.0, 30.0, 28.0])) + assert np.allclose(res.fit.weights[:2], np.array([10.0, 12.0])) + assert np.allclose(res.fit.weights[2:], np.array([30.0, 28.0])) + + +def test_self_consistent_solver_reports_transient_path_disconnectivity(): + from pyvoro2 import ActiveSetOptions, Box, solve_self_consistent_power_weights + + pts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + res = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)], + measurement='fraction', + domain=box, + active0=np.array([False, False, False]), + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=6), + return_history=True, + connectivity_check='diagnose', + unaccounted_pair_check='diagnose', + ) + + assert res.path_summary is not None + assert res.path_summary.n_iterations == len(res.history) + assert res.path_summary.ever_fit_active_graph_disconnected is True + assert res.path_summary.ever_fit_active_effective_graph_disconnected is True + assert res.path_summary.ever_fit_active_offsets_unidentified_by_data is True + assert res.path_summary.first_fit_active_graph_disconnected_iter == 1 + assert res.path_summary.first_fit_active_effective_graph_disconnected_iter == 1 + assert res.path_summary.max_fit_active_graph_components == 3 + assert res.path_summary.max_fit_active_effective_graph_components == 3 + assert res.path_summary.ever_unaccounted_pairs is False + assert res.connectivity is not None + assert res.connectivity.active_graph is not None + assert res.connectivity.active_graph.n_components == 1 + assert res.history is not None + assert res.history[0].n_active_fit == 0 + assert res.history[0].n_active == 2 + assert res.history[0].fit_active_graph_n_components == 3 + assert res.history[0].fit_active_effective_graph_n_components == 3 + assert res.history[0].fit_active_offsets_identified_by_data is False + assert res.history[0].n_unaccounted_pairs == 0 + assert res.history[-1].fit_active_graph_n_components == 1 + + +def test_self_consistent_solver_tracks_transient_unaccounted_pairs( + monkeypatch, +): + import pyvoro2.powerfit.active as active_mod + from pyvoro2 import ActiveSetOptions, Box + from pyvoro2.powerfit.realize import ( + RealizedPairDiagnostics, + UnaccountedRealizedPair, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + state = {'calls': 0} + + def fake_match_realized_pairs(*args, **kwargs): + state['calls'] += 1 + same = np.array([True, True], dtype=bool) + unaccounted = ( + ( + UnaccountedRealizedPair( + site_i=0, + site_j=2, + realized_shifts=((0, 0, 0),), + boundary_measure=None, + ), + ) + if state['calls'] == 1 + else tuple() + ) + return RealizedPairDiagnostics( + realized=same.copy(), + unrealized=tuple(), + realized_same_shift=same.copy(), + realized_other_shift=np.zeros(2, dtype=bool), + realized_shifts=(((0, 0, 0),), ((0, 0, 0),)), + endpoint_i_empty=np.zeros(2, dtype=bool), + endpoint_j_empty=np.zeros(2, dtype=bool), + boundary_measure=None, + cells=None, + tessellation_diagnostics=None, + unaccounted_pairs=unaccounted, + warnings=tuple(), + ) + + monkeypatch.setattr(active_mod, 'match_realized_pairs', fake_match_realized_pairs) + + res = active_mod.solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5), (1, 2, 0.5)], + measurement='fraction', + domain=box, + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=4), + return_history=True, + connectivity_check='diagnose', + unaccounted_pair_check='warn', + ) + + assert res.termination == 'self_consistent' + assert res.path_summary is not None + assert res.path_summary.ever_unaccounted_pairs is True + assert res.path_summary.max_n_unaccounted_pairs == 1 + assert res.path_summary.first_unaccounted_pairs_iter == 1 + assert res.history is not None + assert res.history[0].n_unaccounted_pairs == 1 + assert res.realized.unaccounted_pairs == tuple() + assert all('candidate-absent point pairs' not in msg for msg in res.warnings) diff --git a/tests/test_powerfit_constraints.py b/tests/test_powerfit_constraints.py new file mode 100644 index 0000000..e04ee64 --- /dev/null +++ b/tests/test_powerfit_constraints.py @@ -0,0 +1,122 @@ +import numpy as np +import pytest + + +def test_resolve_pair_bisector_constraints_preserves_explicit_periodic_shift(): + from pyvoro2 import PeriodicCell, resolve_pair_bisector_constraints + + cell = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))) + pts = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float) + + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5, (-1, 0, 0))], + measurement='fraction', + domain=cell, + image='given_only', + ) + + assert bool(constraints.explicit_shift[0]) is True + assert tuple(int(v) for v in constraints.shifts[0]) == (-1, 0, 0) + assert np.isclose(constraints.distance[0], 0.2) + assert np.isclose(constraints.target_fraction[0], 0.5) + assert np.isclose(constraints.target_position[0], 0.1) + + +def test_resolve_pair_bisector_constraints_rejects_shifts_on_nonperiodic_axes(): + from pyvoro2 import OrthorhombicCell, resolve_pair_bisector_constraints + + domain = OrthorhombicCell( + bounds=((0.0, 1.0), (0.0, 1.0), (0.0, 1.0)), periodic=(True, False, True) + ) + pts = np.array([[0.1, 0.2, 0.3], [0.9, 0.8, 0.7]], dtype=float) + + with pytest.raises(ValueError, match='non-periodic axes|non-periodic'): + resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5, (0, 1, 0))], + measurement='fraction', + domain=domain, + image='given_only', + ) + + +def test_resolved_constraints_export_records_and_ids(): + from pyvoro2 import Box, resolve_pair_bisector_constraints + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + domain = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + resolved = resolve_pair_bisector_constraints( + pts, + [(10, 20, 0.25)], + ids=[10, 20], + index_mode='id', + measurement='fraction', + domain=domain, + ) + + rows_idx = resolved.to_records() + rows_id = resolved.to_records(use_ids=True) + assert rows_idx[0]['site_i'] == 0 + assert rows_idx[0]['site_j'] == 1 + assert rows_id[0]['site_i'] == 10 + assert rows_id[0]['site_j'] == 20 + assert rows_id[0]['measurement'] == 'fraction' + + +def test_resolve_pair_bisector_constraints_warns_on_triclinic_search_boundary(): + from pyvoro2 import PeriodicCell, resolve_pair_bisector_constraints + + cell = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.2, 1.0, 0.0), (0.0, 0.0, 1.0))) + pts = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float) + + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5)], + measurement='fraction', + domain=cell, + image='nearest', + image_search=1, + ) + + assert tuple(int(v) for v in constraints.shifts[0]) == (-1, 0, 0) + assert any('image_search boundary' in msg for msg in constraints.warnings) + + +def test_resolve_pair_bisector_constraints_supports_planar_box() -> None: + import pyvoro2.planar as pv2 + from pyvoro2 import resolve_pair_bisector_constraints + + pts = np.array([[0.0, 0.0], [2.0, 0.0]], dtype=float) + domain = pv2.Box(((-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.25)], + measurement='fraction', + domain=domain, + ) + + assert constraints.dim == 2 + assert tuple(int(v) for v in constraints.shifts[0]) == (0, 0) + assert np.isclose(constraints.distance[0], 2.0) + assert np.isclose(constraints.target_position[0], 0.5) + + +def test_resolve_pair_bisector_constraints_supports_planar_periodic_shift() -> None: + import pyvoro2.planar as pv2 + from pyvoro2 import resolve_pair_bisector_constraints + + domain = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5, (-1, 0))], + measurement='fraction', + domain=domain, + image='given_only', + ) + + assert bool(constraints.explicit_shift[0]) is True + assert tuple(int(v) for v in constraints.shifts[0]) == (-1, 0) + assert np.isclose(constraints.distance[0], 0.2) diff --git a/tests/test_powerfit_feasibility.py b/tests/test_powerfit_feasibility.py new file mode 100644 index 0000000..1deaa54 --- /dev/null +++ b/tests/test_powerfit_feasibility.py @@ -0,0 +1,92 @@ +import numpy as np + + +def test_infeasible_hard_constraints_return_conflict_witness(): + from pyvoro2 import FixedValue, FitModel, fit_power_weights + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], + dtype=float, + ) + res = fit_power_weights( + pts, + [(0, 1, 0.0), (1, 2, 0.0), (0, 2, 0.0)], + measurement='position', + model=FitModel(feasible=FixedValue(0.0)), + solver='admm', + ) + + assert res.status == 'infeasible_hard_constraints' + assert res.hard_feasible is False + assert res.weights is None + assert res.conflict is not None + assert res.is_infeasible is True + assert res.conflicting_constraint_indices == (0, 1, 2) + assert res.conflict.constraint_indices == (0, 1, 2) + assert res.conflict.component_nodes == (0, 1, 2) + assert set(res.conflict.cycle_nodes) == {0, 1, 2} + assert len(res.conflict.terms) >= 3 + assert any(term.relation == '>=' for term in res.conflict.terms) + assert any(term.relation == '<=' for term in res.conflict.terms) + assert 'constraint rows [0, 1, 2]' in res.conflict.message + assert any('constraint rows [0, 1, 2]' in msg for msg in res.warnings) + + +def test_feasible_fit_has_no_conflict_witness(): + from pyvoro2 import FitModel, Interval, fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights( + pts, + [(0, 1, 0.5)], + measurement='fraction', + model=FitModel(feasible=Interval(0.0, 1.0)), + solver='admm', + max_iter=2000, + ) + + assert res.status in {'optimal', 'max_iter'} + assert res.hard_feasible is True + assert res.conflict is None + + +def test_conflict_and_fit_records_are_exportable(): + from pyvoro2 import ( + FixedValue, + FitModel, + fit_power_weights, + resolve_pair_bisector_constraints, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], + dtype=float, + ) + constraints = [(0, 1, 0.0), (1, 2, 0.0), (0, 2, 0.0)] + res = fit_power_weights( + pts, + constraints, + measurement='position', + model=FitModel(feasible=FixedValue(0.0)), + solver='admm', + ) + + rows = res.conflict.to_records() + assert len(rows) >= 3 + assert set(rows[0]) == { + 'constraint_index', + 'site_i', + 'site_j', + 'relation', + 'bound_value', + } + + resolved = resolve_pair_bisector_constraints( + pts, + constraints, + measurement='position', + ) + fit_rows = res.to_records(resolved) + assert len(fit_rows) == 3 + assert fit_rows[0]['measurement'] == 'position' + assert fit_rows[0]['predicted'] is None diff --git a/tests/test_powerfit_fit.py b/tests/test_powerfit_fit.py new file mode 100644 index 0000000..43b6ef7 --- /dev/null +++ b/tests/test_powerfit_fit.py @@ -0,0 +1,251 @@ +import numpy as np +import pytest + + +def test_fit_power_weights_fraction_two_points_analytic(): + from pyvoro2 import fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights(pts, [(0, 1, 0.25)], measurement='fraction') + + assert np.allclose(res.weights[0] - res.weights[1], -2.0, atol=1e-10) + assert np.allclose(res.predicted[0], 0.25, atol=1e-10) + assert res.solver == 'analytic' + assert res.status == 'optimal' + + +def test_fit_power_weights_fraction_allows_values_outside_segment(): + from pyvoro2 import fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights(pts, [(0, 1, 1.2)], measurement='fraction') + + assert np.allclose(res.predicted[0], 1.2, atol=1e-10) + assert np.all(res.radii >= 0) + + +def test_fit_power_weights_fraction_hard_interval_clips_prediction(): + from pyvoro2 import FitModel, Interval, fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights( + pts, + [(0, 1, -0.2)], + measurement='fraction', + model=FitModel(feasible=Interval(0.0, 1.0)), + solver='admm', + max_iter=5000, + ) + + assert 0.0 <= res.predicted[0] <= 1.0 + assert np.allclose(res.predicted[0], 0.0, atol=1e-5) + + +def test_r_min_sets_minimum_radius_via_weight_shift(): + from pyvoro2 import fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights(pts, [(0, 1, 0.25)], measurement='fraction', r_min=1.0) + + assert np.min(res.radii) == np.min(res.radii) + assert np.allclose(np.min(res.radii), 1.0, atol=1e-12) + assert np.allclose(res.radii * res.radii, res.weights + res.weight_shift) + + +def test_soft_interval_penalty_prefers_inside_interval(): + from pyvoro2 import FitModel, SoftIntervalPenalty, fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + + res0 = fit_power_weights(pts, [(0, 1, -0.2)], measurement='fraction') + assert np.allclose(res0.predicted[0], -0.2, atol=1e-10) + + res = fit_power_weights( + pts, + [(0, 1, -0.2)], + measurement='fraction', + model=FitModel(penalties=(SoftIntervalPenalty(0.0, 1.0, 100.0),)), + solver='admm', + max_iter=5000, + ) + + assert res.predicted[0] > res0.predicted[0] + + +def test_exponential_boundary_penalty_pushes_away_from_boundary(): + from pyvoro2 import ( + ExponentialBoundaryPenalty, + FitModel, + Interval, + fit_power_weights, + ) + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + + res_hard = fit_power_weights( + pts, + [(0, 1, 1e-3)], + measurement='fraction', + model=FitModel(feasible=Interval(0.0, 1.0)), + solver='admm', + max_iter=5000, + ) + + res_repulse = fit_power_weights( + pts, + [(0, 1, 1e-3)], + measurement='fraction', + model=FitModel( + feasible=Interval(0.0, 1.0), + penalties=( + ExponentialBoundaryPenalty( + lower=0.0, + upper=1.0, + margin=0.05, + strength=1.0, + tau=0.01, + ), + ), + ), + solver='admm', + max_iter=8000, + ) + + assert res_repulse.predicted[0] >= res_hard.predicted[0] - 1e-6 + assert res_repulse.predicted[0] > 0.01 + + +def test_position_measurement_uses_absolute_position_space(): + from pyvoro2 import fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [4.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights(pts, [(0, 1, 1.0)], measurement='position') + + assert np.allclose(res.predicted[0], 1.0, atol=1e-10) + assert np.allclose(res.predicted_position[0], 1.0, atol=1e-10) + assert np.allclose(res.predicted_fraction[0], 0.25, atol=1e-10) + + +def test_infeasible_hard_constraints_are_reported(): + from pyvoro2 import FixedValue, FitModel, fit_power_weights + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], + dtype=float, + ) + # Impossible equalities on a 3-cycle: z01=0, z12=0, z02=2. + res = fit_power_weights( + pts, + [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 3.0)], + measurement='position', + model=FitModel(feasible=FixedValue(0.0)), + solver='admm', + ) + + assert res.status == 'infeasible_hard_constraints' + assert res.hard_feasible is False + assert res.weights is None + assert res.is_infeasible is True + assert res.conflicting_constraint_indices == (0, 1, 2) + + +def test_huber_loss_is_available_as_an_alternative_mismatch(): + from pyvoro2 import FitModel, HuberLoss, fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights( + pts, + [(0, 1, 1.2)], + measurement='fraction', + model=FitModel(mismatch=HuberLoss(delta=0.1)), + solver='admm', + max_iter=5000, + ) + + assert res.status in ('optimal', 'max_iter') + assert res.predicted is not None + + +def test_fit_power_weights_accepts_explicit_weight_shift_for_radii(): + from pyvoro2 import fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights( + pts, + [(0, 1, 0.25)], + measurement='fraction', + weight_shift=2.0, + ) + + assert np.allclose(res.weights[0] - res.weights[1], -2.0, atol=1e-10) + assert np.allclose(res.weight_shift, 2.0, atol=1e-12) + assert np.allclose(res.radii * res.radii, res.weights + 2.0, atol=1e-12) + + +def test_disconnected_components_use_mean_zero_gauge_and_connectivity_diagnostics(): + from pyvoro2 import fit_power_weights + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [10.0, 0.0, 0.0], [12.0, 0.0, 0.0]], + dtype=float, + ) + res = fit_power_weights( + pts, + [(0, 1, 0.25), (2, 3, 0.75)], + measurement='fraction', + connectivity_check='diagnose', + ) + + assert np.allclose(res.weights[[0, 1]], np.array([-1.0, 1.0]), atol=1e-10) + assert np.allclose(res.weights[[2, 3]], np.array([1.0, -1.0]), atol=1e-10) + assert res.connectivity is not None + assert res.connectivity.candidate_graph.n_components == 2 + assert res.connectivity.effective_graph.n_components == 2 + assert res.connectivity.offsets_identified_in_objective is False + assert 'mean zero' in res.connectivity.gauge_policy + + +def test_disconnected_components_can_align_to_zero_strength_reference_means(): + from pyvoro2 import FitModel, L2Regularization, fit_power_weights + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [10.0, 0.0, 0.0], [12.0, 0.0, 0.0]], + dtype=float, + ) + model = FitModel( + regularization=L2Regularization( + strength=0.0, + reference=np.array([10.0, 20.0, 30.0, 40.0], dtype=float), + ) + ) + res = fit_power_weights( + pts, + [(0, 1, 0.25), (2, 3, 0.75)], + measurement='fraction', + model=model, + connectivity_check='diagnose', + ) + + assert np.allclose(res.weights[0] - res.weights[1], -2.0, atol=1e-10) + assert np.allclose(res.weights[2] - res.weights[3], 2.0, atol=1e-10) + assert np.allclose(np.mean(res.weights[:2]), 15.0, atol=1e-10) + assert np.allclose(np.mean(res.weights[2:]), 35.0, atol=1e-10) + assert res.connectivity is not None + assert 'reference mean' in res.connectivity.gauge_policy + + +def test_fit_power_weights_can_raise_connectivity_diagnostics(): + from pyvoro2 import ConnectivityDiagnosticsError, fit_power_weights + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [10.0, 0.0, 0.0], [12.0, 0.0, 0.0]], + dtype=float, + ) + + with pytest.raises(ConnectivityDiagnosticsError): + fit_power_weights( + pts, + [(0, 1, 0.25), (2, 3, 0.75)], + measurement='fraction', + connectivity_check='raise', + ) diff --git a/tests/test_powerfit_realization.py b/tests/test_powerfit_realization.py new file mode 100644 index 0000000..e680b03 --- /dev/null +++ b/tests/test_powerfit_realization.py @@ -0,0 +1,301 @@ +import numpy as np + + +def test_match_realized_pairs_flags_unrealized_constraints(): + from pyvoro2 import ( + Box, + FitModel, + Interval, + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, + ) + domain = Box(((-5, 5), (-5, 5), (-5, 5))) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 2, 0.5)], + measurement='fraction', + domain=domain, + ) + fit = fit_power_weights( + pts, + constraints, + model=FitModel(feasible=Interval(0.0, 1.0)), + solver='admm', + max_iter=5000, + ) + diag = match_realized_pairs( + pts, + domain=domain, + radii=fit.radii, + constraints=constraints, + ) + + assert diag.realized.shape == (1,) + assert bool(diag.realized[0]) is False + assert diag.unrealized == (0,) + + +def test_match_realized_pairs_reports_boundary_measure_when_requested(): + from pyvoro2 import ( + Box, + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + domain = Box(((-5, 5), (-5, 5), (-5, 5))) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5)], + measurement='fraction', + domain=domain, + ) + fit = fit_power_weights(pts, constraints) + diag = match_realized_pairs( + pts, + domain=domain, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + ) + + assert bool(diag.realized[0]) is True + assert diag.boundary_measure is not None + assert np.isfinite(diag.boundary_measure[0]) + assert diag.boundary_measure[0] > 0.0 + + +def test_match_realized_pairs_can_return_tessellation_diagnostics(): + from pyvoro2 import ( + Box, + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + domain = Box(((-5, 5), (-5, 5), (-5, 5))) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5)], + measurement='fraction', + domain=domain, + ) + fit = fit_power_weights(pts, constraints) + diag = match_realized_pairs( + pts, + domain=domain, + radii=fit.radii, + constraints=constraints, + return_tessellation_diagnostics=True, + ) + + assert diag.tessellation_diagnostics is not None + assert diag.tessellation_diagnostics.n_cells_returned == 2 + assert diag.tessellation_diagnostics.ok is True + + +def test_match_realized_pairs_reports_periodic_wrong_shift(): + from pyvoro2 import ( + PeriodicCell, + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + cell = PeriodicCell(vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))) + pts = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]], dtype=float) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5, (1, 0, 0))], + measurement='fraction', + domain=cell, + image='given_only', + ) + fit = fit_power_weights(pts, constraints) + diag = match_realized_pairs( + pts, + domain=cell, + radii=fit.radii, + constraints=constraints, + ) + + assert bool(diag.realized[0]) is True + assert bool(diag.realized_same_shift[0]) is False + assert bool(diag.realized_other_shift[0]) is True + assert (-1, 0, 0) in diag.realized_shifts[0] + assert (1, 0, 0) not in diag.realized_shifts[0] + assert diag.unaccounted_pairs == tuple() + + +def test_realized_pair_diagnostics_export_records(): + from pyvoro2 import Box, match_realized_pairs, resolve_pair_bisector_constraints + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + box = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(11, 22, 0.5)], + ids=[11, 22], + index_mode='id', + measurement='fraction', + domain=box, + ) + + realized = match_realized_pairs( + pts, domain=box, radii=np.array([1.0, 1.0]), constraints=constraints + ) + rows = realized.to_records(constraints, use_ids=True) + assert len(rows) == 1 + assert rows[0]['site_i'] == 11 + assert rows[0]['site_j'] == 22 + assert rows[0]['realized'] is True + + +def test_match_realized_pairs_supports_planar_measure_and_diag() -> None: + import pyvoro2.planar as pv2 + from pyvoro2 import ( + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + pts = np.array([[0.0, 0.0], [2.0, 0.0]], dtype=float) + domain = pv2.Box(((-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5)], + measurement='fraction', + domain=domain, + ) + fit = fit_power_weights(pts, constraints) + diag = match_realized_pairs( + pts, + domain=domain, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + return_tessellation_diagnostics=True, + ) + + assert bool(diag.realized[0]) is True + assert diag.boundary_measure is not None + assert np.isfinite(diag.boundary_measure[0]) + assert diag.boundary_measure[0] > 0.0 + assert diag.tessellation_diagnostics is not None + assert diag.tessellation_diagnostics.n_cells_returned == 2 + assert diag.tessellation_diagnostics.ok is True + + +def test_match_realized_pairs_supports_planar_periodic_wrong_shift() -> None: + import pyvoro2.planar as pv2 + from pyvoro2 import ( + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + cell = pv2.RectangularCell(((0.0, 1.0), (0.0, 1.0)), periodic=(True, True)) + pts = np.array([[0.1, 0.5], [0.9, 0.5]], dtype=float) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 1, 0.5, (1, 0))], + measurement='fraction', + domain=cell, + image='given_only', + ) + fit = fit_power_weights(pts, constraints) + diag = match_realized_pairs( + pts, + domain=cell, + radii=fit.radii, + constraints=constraints, + ) + + assert bool(diag.realized[0]) is True + assert bool(diag.realized_same_shift[0]) is False + assert bool(diag.realized_other_shift[0]) is True + assert (-1, 0) in diag.realized_shifts[0] + assert (1, 0) not in diag.realized_shifts[0] + assert diag.unaccounted_pairs == tuple() + + +def test_match_realized_pairs_reports_unaccounted_realized_pairs_in_3d(): + from pyvoro2 import ( + Box, + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 2, 0.5)], + measurement='fraction', + domain=box, + ) + fit = fit_power_weights(pts, constraints) + diag = match_realized_pairs( + pts, + domain=box, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + unaccounted_pair_check='warn', + ) + + assert {(pair.site_i, pair.site_j) for pair in diag.unaccounted_pairs} == { + (0, 1), + (1, 2), + } + assert all( + pair.boundary_measure is not None + for pair in diag.unaccounted_pairs + ) + assert any('candidate-absent' in msg for msg in diag.warnings) + + +def test_match_realized_pairs_reports_unaccounted_realized_pairs_in_planar_box( +) -> None: + import pyvoro2.planar as pv2 + from pyvoro2 import ( + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + pts = np.array([[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]], dtype=float) + box = pv2.Box(((-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(0, 2, 0.5)], + measurement='fraction', + domain=box, + ) + fit = fit_power_weights(pts, constraints) + diag = match_realized_pairs( + pts, + domain=box, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + unaccounted_pair_check='diagnose', + ) + + assert {(pair.site_i, pair.site_j) for pair in diag.unaccounted_pairs} == { + (0, 1), + (1, 2), + } + assert diag.warnings == tuple() diff --git a/tests/test_powerfit_reports.py b/tests/test_powerfit_reports.py new file mode 100644 index 0000000..bdb9a7f --- /dev/null +++ b/tests/test_powerfit_reports.py @@ -0,0 +1,334 @@ +import json +import numpy as np + + +def test_fit_report_exports_nested_plain_python_payload(): + from pyvoro2 import ( + Box, + FixedValue, + FitModel, + build_fit_report, + fit_power_weights, + resolve_pair_bisector_constraints, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(10, 20, 0.5), (20, 30, 0.5), (10, 30, 3.0)], + ids=[10, 20, 30], + index_mode='id', + measurement='position', + domain=box, + ) + fit = fit_power_weights( + pts, + constraints, + model=FitModel(feasible=FixedValue(0.0)), + solver='admm', + ) + + report = build_fit_report(fit, constraints, use_ids=True) + report_via_method = fit.to_report(constraints, use_ids=True) + + assert report['summary']['status'] == 'infeasible_hard_constraints' + assert report['summary']['is_infeasible'] is True + assert report['conflict'] is not None + assert report['conflict']['constraint_indices'] == [0, 1, 2] + assert report['constraints'][0]['site_i'] == 10 + assert report['constraints'][0]['site_j'] == 20 + assert len(report['fit_records']) == 3 + assert report_via_method == report + + +def test_active_set_report_collects_nested_diagnostics_and_history(): + from pyvoro2 import ( + ActiveSetOptions, + Box, + FitModel, + Interval, + build_active_set_report, + resolve_pair_bisector_constraints, + solve_self_consistent_power_weights, + ) + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + box = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(100, 200, 0.5)], + ids=[100, 200], + index_mode='id', + measurement='fraction', + domain=box, + ) + result = solve_self_consistent_power_weights( + pts, + constraints, + domain=box, + model=FitModel(feasible=Interval(0.0, 1.0)), + options=ActiveSetOptions(max_iter=5), + return_history=True, + return_tessellation_diagnostics=True, + ) + + report = build_active_set_report(result, use_ids=True) + report_via_method = result.to_report(use_ids=True) + + assert report['summary']['n_constraints'] == 1 + assert report['summary']['n_active_final'] in {0, 1} + assert report['constraints'][0]['site_i'] == 100 + assert report['fit']['summary']['measurement'] == 'fraction' + assert report['realized']['summary']['n_constraints'] == 1 + assert report['diagnostics'][0]['site_j'] == 200 + assert report['history'] is not None + assert len(report['history']) >= 1 + assert report['path_summary'] is not None + assert report['history'][0]['n_active_fit'] is not None + assert report['tessellation_diagnostics'] is not None + assert report_via_method == report + + +def test_report_json_helpers_roundtrip_plain_report(tmp_path): + from pyvoro2 import ( + Box, + FixedValue, + FitModel, + build_fit_report, + dumps_report_json, + fit_power_weights, + resolve_pair_bisector_constraints, + write_report_json, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(10, 20, 0.5), (20, 30, 0.5), (10, 30, 3.0)], + ids=[10, 20, 30], + index_mode='id', + measurement='position', + domain=box, + ) + fit = fit_power_weights( + pts, + constraints, + model=FitModel(feasible=FixedValue(0.0)), + solver='admm', + ) + + report = build_fit_report(fit, constraints, use_ids=True) + payload = dumps_report_json(report, sort_keys=True) + loaded = json.loads(payload) + + assert loaded['kind'] == 'power_weight_fit' + assert loaded['summary']['status'] == 'infeasible_hard_constraints' + assert loaded['conflict'] is not None + + out_path = tmp_path / 'fit_report.json' + write_report_json(report, out_path, sort_keys=True) + assert json.loads(out_path.read_text(encoding='utf-8')) == loaded + + +def test_active_set_report_supports_planar_tessellation_diagnostics() -> None: + import pyvoro2.planar as pv2 + from pyvoro2 import ( + ActiveSetOptions, + FitModel, + Interval, + build_active_set_report, + resolve_pair_bisector_constraints, + solve_self_consistent_power_weights, + ) + + pts = np.array([[0.0, 0.0], [2.0, 0.0]], dtype=float) + box = pv2.Box(((-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(100, 200, 0.5)], + ids=[100, 200], + index_mode='id', + measurement='fraction', + domain=box, + ) + result = solve_self_consistent_power_weights( + pts, + constraints, + domain=box, + model=FitModel(feasible=Interval(0.0, 1.0)), + options=ActiveSetOptions(max_iter=5), + return_tessellation_diagnostics=True, + ) + + report = build_active_set_report(result, use_ids=True) + + assert report['constraints'][0]['site_i'] == 100 + assert report['tessellation_diagnostics'] is not None + assert report['tessellation_diagnostics']['dimension'] == 2 + assert report['tessellation_diagnostics']['domain_area'] > 0.0 + assert report['tessellation_diagnostics']['ok_area'] is True + + +def test_fit_report_includes_connectivity_diagnostics(): + from pyvoro2 import ( + build_fit_report, + fit_power_weights, + resolve_pair_bisector_constraints, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [10.0, 0.0, 0.0], [12.0, 0.0, 0.0]], + dtype=float, + ) + constraints = resolve_pair_bisector_constraints( + pts, + [(10, 20, 0.25), (30, 40, 0.75)], + ids=[10, 20, 30, 40], + index_mode='id', + measurement='fraction', + ) + fit = fit_power_weights( + pts, + constraints, + connectivity_check='diagnose', + ) + + report = build_fit_report(fit, constraints, use_ids=True) + + assert report['connectivity'] is not None + assert report['connectivity']['candidate_graph']['n_components'] == 2 + assert report['connectivity']['candidate_graph']['connected_components'] == [ + [10, 20], + [30, 40], + ] + + +def test_realized_report_includes_unaccounted_pairs_and_warnings(): + from pyvoro2 import ( + Box, + build_realized_report, + fit_power_weights, + match_realized_pairs, + resolve_pair_bisector_constraints, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) + constraints = resolve_pair_bisector_constraints( + pts, + [(10, 30, 0.5)], + ids=[10, 20, 30], + index_mode='id', + measurement='fraction', + domain=box, + ) + fit = fit_power_weights(pts, constraints) + diag = match_realized_pairs( + pts, + domain=box, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + unaccounted_pair_check='warn', + ) + + report = build_realized_report(diag, constraints, use_ids=True) + + assert report['summary']['n_unaccounted_pairs'] == 2 + assert {(row['site_i'], row['site_j']) for row in report['unaccounted_pairs']} == { + (10, 20), + (20, 30), + } + assert report['warnings'] + + +def test_active_set_report_uses_final_active_subset_and_top_level_connectivity(): + from pyvoro2 import ( + ActiveSetOptions, + Box, + build_active_set_report, + solve_self_consistent_power_weights, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [10.0, 0.0, 0.0], [12.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) + result = solve_self_consistent_power_weights( + pts, + [(10, 20, 0.5), (30, 40, 0.5), (10, 30, 0.5)], + ids=[10, 20, 30, 40], + index_mode='id', + measurement='fraction', + domain=box, + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=5), + connectivity_check='diagnose', + unaccounted_pair_check='diagnose', + ) + + report = build_active_set_report(result, use_ids=True) + + assert report['summary']['n_constraints'] == 3 + assert report['fit']['summary']['n_constraints'] == 2 + assert len(report['fit']['fit_records']) == 2 + assert report['realized']['summary']['n_unaccounted_pairs'] == 1 + assert report['connectivity'] is not None + assert report['connectivity']['candidate_graph']['n_components'] == 1 + assert report['connectivity']['active_graph']['n_components'] == 2 + assert { + (row['site_i'], row['site_j']) + for row in report['realized']['unaccounted_pairs'] + } == {(20, 30)} + + +def test_active_set_report_includes_transient_path_summary_fields(): + from pyvoro2 import ( + ActiveSetOptions, + Box, + build_active_set_report, + solve_self_consistent_power_weights, + ) + + pts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]], + dtype=float, + ) + box = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + result = solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)], + measurement='fraction', + domain=box, + active0=np.array([False, False, False]), + options=ActiveSetOptions(add_after=1, drop_after=1, max_iter=6), + return_history=True, + connectivity_check='diagnose', + unaccounted_pair_check='diagnose', + ) + + report = build_active_set_report(result) + + assert report['path_summary'] is not None + assert report['path_summary']['ever_fit_active_graph_disconnected'] is True + assert report['path_summary']['max_fit_active_graph_components'] == 3 + assert report['path_summary']['first_fit_active_graph_disconnected_iter'] == 1 + assert report['path_summary']['ever_unaccounted_pairs'] is False + assert report['history'] is not None + assert report['history'][0]['n_active_fit'] == 0 + assert report['history'][0]['n_active'] == 2 + assert report['history'][0]['fit_active_graph_n_components'] == 3 + assert report['history'][0]['fit_active_effective_graph_n_components'] == 3 + assert report['history'][0]['fit_active_offsets_identified_by_data'] is False + assert report['history'][0]['n_unaccounted_pairs'] == 0 diff --git a/tests/test_powerfit_validation_regressions.py b/tests/test_powerfit_validation_regressions.py new file mode 100644 index 0000000..2e0ec95 --- /dev/null +++ b/tests/test_powerfit_validation_regressions.py @@ -0,0 +1,368 @@ +import numpy as np +import pytest + + +def test_powerfit_rejects_nonfinite_points_values_and_confidence(): + from pyvoro2 import fit_power_weights, resolve_pair_bisector_constraints + + pts_bad = np.array([[0.0, 0.0, 0.0], [np.nan, 0.0, 0.0]], dtype=float) + with pytest.raises(ValueError, match='finite'): + resolve_pair_bisector_constraints(pts_bad, [(0, 1, 0.5)]) + with pytest.raises(ValueError, match='finite'): + fit_power_weights(pts_bad, [(0, 1, 0.5)]) + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + with pytest.raises(ValueError, match='finite'): + resolve_pair_bisector_constraints(pts, [(0, 1, np.nan)]) + with pytest.raises(ValueError, match='finite'): + resolve_pair_bisector_constraints(pts, [(0, 1, 0.5)], confidence=[np.inf]) + + +def test_powerfit_constraint_ids_must_match_points_and_be_unique(): + from pyvoro2 import resolve_pair_bisector_constraints + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], + dtype=float, + ) + + with pytest.raises(ValueError, match='unique'): + resolve_pair_bisector_constraints( + pts, + [(10, 20, 0.5)], + ids=[10, 10, 20], + index_mode='id', + ) + + with pytest.raises(ValueError, match='length n_points'): + resolve_pair_bisector_constraints( + pts, + [(10, 20, 0.5)], + ids=[10, 20], + index_mode='id', + ) + + +def test_zero_confidence_constraints_do_not_crash_quadratic_fit(): + from pyvoro2 import fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + res = fit_power_weights( + pts, + [(0, 1, 0.25)], + measurement='fraction', + confidence=[0.0], + ) + + assert res.status == 'optimal' + assert np.allclose(res.weights, np.array([0.0, 0.0])) + assert np.allclose(res.predicted_fraction, np.array([0.5])) + assert any('zero-confidence' in msg for msg in res.warnings) + + +def test_zero_confidence_rows_do_not_join_effective_components(): + from pyvoro2 import fit_power_weights + + pts = np.array( + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], + dtype=float, + ) + res = fit_power_weights( + pts, + [(0, 1, 0.25), (1, 2, 0.9)], + measurement='fraction', + confidence=[1.0, 0.0], + ) + + assert res.status == 'optimal' + assert np.allclose(res.weights[0] - res.weights[1], -2.0, atol=1e-10) + assert np.allclose(res.weights[2], 0.0, atol=1e-12) + + +def test_empty_resolved_constraints_use_regularization_only_solution(): + from pyvoro2 import FitModel, L2Regularization, fit_power_weights + from pyvoro2.powerfit.constraints import resolve_pair_bisector_constraints + + pts = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=float) + constraints = resolve_pair_bisector_constraints( + pts, + [], + measurement='fraction', + allow_empty=True, + ) + model = FitModel( + regularization=L2Regularization( + strength=1.0, + reference=np.array([3.0, 5.0], dtype=float), + ) + ) + + res = fit_power_weights(pts, constraints, model=model) + + assert res.status == 'optimal' + assert np.allclose(res.weights, np.array([3.0, 5.0])) + assert any('regularization-only' in msg for msg in res.warnings) + + +def test_weight_radius_conversions_reject_nonfinite_values(): + from pyvoro2 import radii_to_weights, weights_to_radii + + with pytest.raises(ValueError, match='finite'): + radii_to_weights(np.array([1.0, np.nan])) + with pytest.raises(ValueError, match='finite'): + weights_to_radii(np.array([0.0, np.inf])) + + +def test_fit_power_weights_returns_numerical_failure_on_internal_solver_error( + monkeypatch, +): + import pyvoro2.powerfit.solver as solver_mod + from pyvoro2 import fit_power_weights + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + + def boom(*args, **kwargs): + raise np.linalg.LinAlgError('synthetic failure') + + monkeypatch.setattr(solver_mod, '_solve_component_analytic', boom) + + res = fit_power_weights( + pts, + [(0, 1, 0.25)], + measurement='fraction', + solver='analytic', + ) + + assert res.status == 'numerical_failure' + assert res.converged is False + assert res.weights is None + assert res.radii is None + assert res.predicted is None + assert any('numerical solver failure' in msg for msg in res.warnings) + + +def test_active_set_propagates_numerical_failure(monkeypatch): + import pyvoro2.powerfit.active as active_mod + from pyvoro2 import Box + from pyvoro2.powerfit.solver import PowerWeightFitResult + + pts = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float) + domain = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + + def fake_fit_power_weights(points, constraints, **kwargs): + return PowerWeightFitResult( + status='numerical_failure', + hard_feasible=True, + weights=None, + radii=None, + weight_shift=None, + measurement=constraints.measurement, + target=constraints.target.copy(), + predicted=None, + predicted_fraction=None, + predicted_position=None, + residuals=None, + rms_residual=None, + max_residual=None, + used_shifts=constraints.shifts.copy(), + solver='analytic', + n_iter=0, + converged=False, + conflict=None, + warnings=('synthetic fit failure',), + ) + + monkeypatch.setattr(active_mod, 'fit_power_weights', fake_fit_power_weights) + + res = active_mod.solve_self_consistent_power_weights( + pts, + [(0, 1, 0.5)], + measurement='fraction', + domain=domain, + ) + + assert res.termination == 'numerical_failure' + assert res.converged is False + assert res.fit.status == 'numerical_failure' + assert res.diagnostics.status == ('numerical_failure',) + assert any('synthetic fit failure' in msg for msg in res.warnings) + + +def test_fit_power_weights_accepts_pre_resolved_lower_dim_constraints(): + from pyvoro2 import PairBisectorConstraints, fit_power_weights + + pts = np.array([[0.0, 0.0], [2.0, 0.0]], dtype=float) + constraints = PairBisectorConstraints( + n_points=2, + i=np.array([0], dtype=np.int64), + j=np.array([1], dtype=np.int64), + shifts=np.zeros((1, 2), dtype=np.int64), + target=np.array([0.25], dtype=np.float64), + confidence=np.array([1.0], dtype=np.float64), + measurement='fraction', + distance=np.array([2.0], dtype=np.float64), + distance2=np.array([4.0], dtype=np.float64), + delta=np.array([[2.0, 0.0]], dtype=np.float64), + target_fraction=np.array([0.25], dtype=np.float64), + target_position=np.array([0.5], dtype=np.float64), + input_index=np.array([0], dtype=np.int64), + explicit_shift=np.array([False], dtype=bool), + ids=None, + warnings=tuple(), + ) + + res = fit_power_weights(pts, constraints, measurement='fraction') + + assert res.status == 'optimal' + assert np.allclose(res.weights[1] - res.weights[0], 2.0) + assert np.allclose(res.predicted_fraction, np.array([0.25])) + + +def test_pre_resolved_constraints_expose_dimension_property(): + from pyvoro2 import PairBisectorConstraints + + constraints = PairBisectorConstraints( + n_points=2, + i=np.array([0], dtype=np.int64), + j=np.array([1], dtype=np.int64), + shifts=np.zeros((1, 2), dtype=np.int64), + target=np.array([0.25], dtype=np.float64), + confidence=np.array([1.0], dtype=np.float64), + measurement='fraction', + distance=np.array([2.0], dtype=np.float64), + distance2=np.array([4.0], dtype=np.float64), + delta=np.array([[2.0, 0.0]], dtype=np.float64), + target_fraction=np.array([0.25], dtype=np.float64), + target_position=np.array([0.5], dtype=np.float64), + input_index=np.array([0], dtype=np.int64), + explicit_shift=np.array([False], dtype=bool), + ids=None, + warnings=tuple(), + ) + + assert constraints.dim == 2 + + +def test_match_realized_pairs_supports_pre_resolved_planar_constraints(): + import pyvoro2.planar as pv2 + from pyvoro2 import PairBisectorConstraints, match_realized_pairs + + pts = np.array([[0.0, 0.0], [2.0, 0.0]], dtype=float) + constraints = PairBisectorConstraints( + n_points=2, + i=np.array([0], dtype=np.int64), + j=np.array([1], dtype=np.int64), + shifts=np.zeros((1, 2), dtype=np.int64), + target=np.array([0.25], dtype=np.float64), + confidence=np.array([1.0], dtype=np.float64), + measurement='fraction', + distance=np.array([2.0], dtype=np.float64), + distance2=np.array([4.0], dtype=np.float64), + delta=np.array([[2.0, 0.0]], dtype=np.float64), + target_fraction=np.array([0.25], dtype=np.float64), + target_position=np.array([0.5], dtype=np.float64), + input_index=np.array([0], dtype=np.int64), + explicit_shift=np.array([False], dtype=bool), + ids=None, + warnings=tuple(), + ) + + diag = match_realized_pairs( + pts, + domain=pv2.Box(((-5.0, 5.0), (-5.0, 5.0))), + radii=np.array([0.0, 0.0]), + constraints=constraints, + ) + + assert bool(diag.realized[0]) is True + assert diag.unrealized == tuple() + + +def test_active_set_supports_pre_resolved_planar_constraints(): + import pyvoro2.planar as pv2 + from pyvoro2 import ( + PairBisectorConstraints, + solve_self_consistent_power_weights, + ) + + pts = np.array([[0.0, 0.0], [2.0, 0.0]], dtype=float) + constraints = PairBisectorConstraints( + n_points=2, + i=np.array([0], dtype=np.int64), + j=np.array([1], dtype=np.int64), + shifts=np.zeros((1, 2), dtype=np.int64), + target=np.array([0.25], dtype=np.float64), + confidence=np.array([1.0], dtype=np.float64), + measurement='fraction', + distance=np.array([2.0], dtype=np.float64), + distance2=np.array([4.0], dtype=np.float64), + delta=np.array([[2.0, 0.0]], dtype=np.float64), + target_fraction=np.array([0.25], dtype=np.float64), + target_position=np.array([0.5], dtype=np.float64), + input_index=np.array([0], dtype=np.int64), + explicit_shift=np.array([False], dtype=bool), + ids=None, + warnings=tuple(), + ) + + res = solve_self_consistent_power_weights( + pts, + constraints, + measurement='fraction', + domain=pv2.Box(((-5.0, 5.0), (-5.0, 5.0))), + ) + + assert res.termination == 'self_consistent' + assert bool(res.realized.realized_same_shift[0]) is True + + +def test_empty_resolved_constraints_can_follow_zero_strength_reference_gauge(): + from pyvoro2 import FitModel, L2Regularization, fit_power_weights + from pyvoro2.powerfit.constraints import resolve_pair_bisector_constraints + + pts = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=float) + constraints = resolve_pair_bisector_constraints( + pts, + [], + measurement='fraction', + allow_empty=True, + ) + model = FitModel( + regularization=L2Regularization( + strength=0.0, + reference=np.array([3.0, 5.0], dtype=float), + ) + ) + + res = fit_power_weights( + pts, + constraints, + model=model, + connectivity_check='diagnose', + ) + + assert res.status == 'optimal' + assert np.allclose(res.weights, np.array([3.0, 5.0])) + assert any( + 'zero-strength reference gauge convention' in msg + for msg in res.warnings + ) + + +def test_weights_to_radii_supports_explicit_weight_shift(): + from pyvoro2 import weights_to_radii + + radii, shift = weights_to_radii( + np.array([-1.0, 3.0], dtype=float), + weight_shift=1.0, + ) + + assert np.allclose(radii, np.array([0.0, 2.0])) + assert np.allclose(shift, 1.0) + + with pytest.raises(ValueError, match='at most one'): + weights_to_radii( + np.array([0.0, 1.0], dtype=float), + r_min=1.0, + weight_shift=0.0, + ) diff --git a/tests/test_readme_sync.py b/tests/test_readme_sync.py new file mode 100644 index 0000000..a4bb4fc --- /dev/null +++ b/tests/test_readme_sync.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +README = REPO_ROOT / 'README.md' +SCRIPT = REPO_ROOT / 'tools' / 'gen_readme.py' + + +def test_readme_is_in_sync(tmp_path: Path) -> None: + generated = tmp_path / 'README.generated.md' + subprocess.run( + [sys.executable, str(SCRIPT), '--output', str(generated)], + cwd=REPO_ROOT, + check=True, + ) + assert generated.read_text(encoding='utf-8') == README.read_text(encoding='utf-8') diff --git a/tests/test_release_tools.py b/tests/test_release_tools.py new file mode 100644 index 0000000..b8fca91 --- /dev/null +++ b/tests/test_release_tools.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _run_help(script_name: str) -> str: + result = subprocess.run( + [sys.executable, f'tools/{script_name}', '--help'], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def test_release_check_help() -> None: + assert 'release-preparation checks' in _run_help('release_check.py') + + +def test_check_notebooks_help() -> None: + assert 'optional notebook filenames' in _run_help('check_notebooks.py') + + +def test_check_dist_help() -> None: + assert 'dist_dir' in _run_help('check_dist.py') diff --git a/tests/test_text_generation_tools.py b/tests/test_text_generation_tools.py new file mode 100644 index 0000000..28d7a49 --- /dev/null +++ b/tests/test_text_generation_tools.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +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 newlines.""" + + output_dir = tmp_path / 'docs' + output_dir.mkdir() + + for notebook_path, output_path in export_notebooks.iter_notebook_pairs(): + rendered = export_notebooks.export_markdown(notebook_path) + (output_dir / output_path.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/tools/README.md b/tools/README.md new file mode 100644 index 0000000..c5585b0 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,26 @@ +# Tooling helpers + +This directory contains repository-maintenance helpers used in local +publishability checks and CI. + +Main entry points: + +- `python tools/export_notebooks.py` — regenerate `docs/notebooks/*.md` from + the source notebooks in the repo-root `notebooks/` directory. +- `python tools/check_notebooks.py` — validate notebook JSON and execute + notebook code cells against the installed `pyvoro2` package in the current + environment. Pass `--use-src` only in a wheel-overlay developer setup where + the compiled extensions are already available beside `src/pyvoro2/`. +- `python tools/gen_readme.py` — regenerate `README.md` from the MkDocs source. +- `python tools/release_check.py` — run the combined local release-preparation + checks. +- `python tools/check_dist.py dist` — verify that built sdists and wheels + contain the expected key files. + +For a full local pre-release pass after installing the project with all optional +extras, run: + +```bash +pip install -e ".[all]" +python tools/release_check.py +``` diff --git a/tools/build_wheels_wsl.sh b/tools/build_wheels_wsl.sh index db55b7f..31ae73d 100644 --- a/tools/build_wheels_wsl.sh +++ b/tools/build_wheels_wsl.sh @@ -7,13 +7,13 @@ set -euo pipefail # tools/build_wheels_wsl.sh dist_wheels # outputs to ./dist_wheels # # Override defaults (examples): -# CIBW_BUILD="cp312-manylinux_x86_64" tools/build_wheels_wsl.sh +# CIBW_BUILD="cp313-manylinux_x86_64" tools/build_wheels_wsl.sh # CIBW_SKIP="*musllinux* pp*" tools/build_wheels_wsl.sh OUT_DIR="${1:-wheelhouse}" # Defaults that match your usual one-liner. -export CIBW_BUILD="${CIBW_BUILD:-cp311-manylinux_x86_64}" +export CIBW_BUILD="${CIBW_BUILD:-cp313-manylinux_x86_64}" export CIBW_SKIP="${CIBW_SKIP:-*musllinux*}" # Optional: uncomment to pin the manylinux image for consistency across machines. diff --git a/tools/check_dist.py b/tools/check_dist.py new file mode 100644 index 0000000..7469860 --- /dev/null +++ b/tools/check_dist.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Verify that built distributions contain the project's key files.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import tarfile +import zipfile + + +REQUIRED_WHEEL_SUFFIXES = { + 'pyvoro2/__init__.py', + 'pyvoro2/__about__.py', + 'pyvoro2/planar/__init__.py', + 'pyvoro2/powerfit/solver.py', + 'pyvoro2/viz2d.py', + 'pyvoro2/viz3d.py', + 'pyvoro2/_core', + 'pyvoro2/_core2d', +} + +REQUIRED_SDIST_SUFFIXES = { + 'README.md', + 'CHANGELOG.md', + 'DEV_PLAN.md', + 'LICENSE', + 'pyproject.toml', + 'notebooks/01_basic_compute.ipynb', + 'notebooks/02_periodic_graph.ipynb', + 'notebooks/03_locate_and_ghost.ipynb', + 'notebooks/04_powerfit.ipynb', + 'notebooks/05_visualization.ipynb', + 'notebooks/06_powerfit_reports.ipynb', + 'notebooks/07_powerfit_infeasibility.ipynb', + 'notebooks/08_powerfit_active_path.ipynb', + 'docs/notebooks/01_basic_compute.md', + 'docs/notebooks/02_periodic_graph.md', + 'docs/notebooks/03_locate_and_ghost.md', + 'docs/notebooks/04_powerfit.md', + 'docs/notebooks/05_visualization.md', + 'docs/notebooks/06_powerfit_reports.md', + 'docs/notebooks/07_powerfit_infeasibility.md', + 'docs/notebooks/08_powerfit_active_path.md', + 'tools/check_dist.py', + 'tools/check_notebooks.py', + 'tools/export_notebooks.py', + 'tools/gen_readme.py', + 'tools/release_check.py', + 'tools/README.md', +} + + +class DistCheckError(RuntimeError): + """Raised when a built distribution is missing required members.""" + + +def _assert_members_present( + actual: set[str], + required: set[str], + *, + label: str, +) -> None: + missing = sorted(required - actual) + if missing: + joined = ', '.join(missing) + raise DistCheckError(f'{label} is missing required members: {joined}') + + +def _members_matching_suffixes(actual: set[str], suffixes: set[str]) -> set[str]: + matched: set[str] = set() + for suffix in suffixes: + if suffix in {'pyvoro2/_core', 'pyvoro2/_core2d'}: + if any(name.startswith(suffix) for name in actual): + matched.add(suffix) + continue + if any(name.endswith(suffix) for name in actual): + matched.add(suffix) + return matched + + +def check_wheel(path: Path) -> None: + """Validate the contents of one built wheel.""" + + with zipfile.ZipFile(path) as zf: + names = set(zf.namelist()) + matched = _members_matching_suffixes(names, REQUIRED_WHEEL_SUFFIXES) + _assert_members_present(matched, REQUIRED_WHEEL_SUFFIXES, 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) + + +def main() -> None: + """Validate wheel and sdist artifacts found in a distribution directory.""" + + parser = argparse.ArgumentParser() + parser.add_argument('dist_dir', type=Path, nargs='?', default=Path('dist')) + args = parser.parse_args() + + dist_dir = args.dist_dir + wheels = sorted(dist_dir.glob('*.whl')) + sdists = sorted(dist_dir.glob('*.tar.gz')) + if not wheels: + raise DistCheckError(f'no wheel files found in {dist_dir}') + if not sdists: + raise DistCheckError(f'no source distributions found in {dist_dir}') + + for wheel in wheels: + check_wheel(wheel) + for sdist in sdists: + check_sdist(sdist) + + +if __name__ == '__main__': + main() diff --git a/tools/check_notebooks.py b/tools/check_notebooks.py new file mode 100644 index 0000000..7cf5b23 --- /dev/null +++ b/tools/check_notebooks.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Validate notebook JSON structure and execute notebook code cells.""" + +from __future__ import annotations + +import argparse +from contextlib import redirect_stdout +import io +import json +import os +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC = REPO_ROOT / 'src' +NOTEBOOKS = REPO_ROOT / 'notebooks' +DEFAULT_NOTEBOOKS = ( + '01_basic_compute.ipynb', + '02_periodic_graph.ipynb', + '03_locate_and_ghost.ipynb', + '04_powerfit.ipynb', + '05_visualization.ipynb', + '06_powerfit_reports.ipynb', + '07_powerfit_infeasibility.ipynb', + '08_powerfit_active_path.ipynb', +) + +os.environ.setdefault('MPLBACKEND', 'Agg') + + +class NotebookCheckError(RuntimeError): + """Raised when a notebook is malformed or fails to execute.""" + + +def iter_notebooks(selected: tuple[str, ...] | None = None) -> tuple[Path, ...]: + """Return the notebooks that should be validated and executed.""" + + names = selected or DEFAULT_NOTEBOOKS + return tuple(NOTEBOOKS / name for name in names) + + +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 NotebookCheckError(f'{path.name}: expected top-level JSON object') + return data + + +def iter_code_cells(data: dict[str, object], *, path: Path) -> tuple[str, ...]: + """Return notebook code-cell sources in order.""" + + cells = data.get('cells') + if not isinstance(cells, list): + raise NotebookCheckError(f'{path.name}: missing notebook cell list') + + code_cells: list[str] = [] + for index, cell in enumerate(cells, start=1): + if not isinstance(cell, dict): + raise NotebookCheckError(f'{path.name}: cell {index} is not an object') + if cell.get('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' + ) + code_cells.append(text) + if not code_cells: + raise NotebookCheckError(f'{path.name}: contains no code cells') + return tuple(code_cells) + + +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 + 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 + raise NotebookCheckError( + f'{path.name}: execution failed in code cell {index}: {exc}' + ) from exc + + +def configure_import_path(*, use_src: bool) -> None: + """Configure where notebook imports should resolve pyvoro2 from.""" + + if use_src and str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + + +def main() -> int: + """Validate and execute every requested notebook.""" + + parser = argparse.ArgumentParser() + parser.add_argument( + 'notebooks', + nargs='*', + help='optional notebook filenames under notebooks/ to validate', + ) + parser.add_argument( + '--use-src', + action='store_true', + help=( + 'prepend repo/src to sys.path before execution; use this only in ' + 'a developer overlay environment where the compiled extensions are ' + 'already available beside the source tree' + ), + ) + args = parser.parse_args() + + configure_import_path(use_src=args.use_src) + + selected = tuple(args.notebooks) if args.notebooks else None + notebooks = iter_notebooks(selected) + for notebook in notebooks: + execute_notebook(notebook) + print(f'Validated {len(notebooks)} notebook(s).') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/export_notebooks.py b/tools/export_notebooks.py new file mode 100644 index 0000000..bc9c821 --- /dev/null +++ b/tools/export_notebooks.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Export repository notebooks to Markdown pages for the docs site.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys + + +REPO_ROOT = Path(__file__).resolve().parents[1] +NOTEBOOKS = REPO_ROOT / 'notebooks' +DEFAULT_OUTPUT_DIR = REPO_ROOT / 'docs' / 'notebooks' +HEADER = ( + '\n' + '\n\n' +) +GITHUB_NOTEBOOK_BASE = ( + 'https://github.com/DeloneCommons/pyvoro2/blob/main/notebooks' +) + + +class NotebookExportError(RuntimeError): + """Raised when notebook export fails.""" + + +def iter_notebook_pairs() -> tuple[tuple[Path, Path], ...]: + """Return `(notebook_path, markdown_path)` pairs in export order.""" + + pairs: list[tuple[Path, Path]] = [] + for notebook in sorted(NOTEBOOKS.glob('*.ipynb')): + pairs.append((notebook, DEFAULT_OUTPUT_DIR / f'{notebook.stem}.md')) + return tuple(pairs) + + +def _load_notebook(path: Path) -> dict[str, object]: + 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: + 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 _output_text(value: object) -> str: + if isinstance(value, str): + return value + if isinstance(value, list) and all(isinstance(line, str) for line in value): + return ''.join(value) + return '' + + +def _render_output(output: dict[str, object], *, path: Path, index: int) -> str: + output_type = output.get('output_type') + + if output_type == 'stream': + text = _output_text(output.get('text')).rstrip() + if not text: + return '' + return f'**Output**\n\n```text\n{text}\n```\n' + + if output_type == 'error': + traceback = _output_text(output.get('traceback')).rstrip() + if not traceback: + traceback = _output_text(output.get('evalue')).rstrip() + if not traceback: + ename = output.get('ename', 'error') + traceback = str(ename) + return f'**Error**\n\n```text\n{traceback}\n```\n' + + if output_type not in {'execute_result', 'display_data'}: + raise NotebookExportError( + f'{path.name}: unsupported output type in cell {index}: {output_type}' + ) + + data = output.get('data', {}) + if not isinstance(data, dict): + raise NotebookExportError(f'{path.name}: invalid output data in cell {index}') + + if 'text/markdown' in data: + text = _output_text(data['text/markdown']).strip() + if text: + return f'{text}\n' + + if 'text/html' in data: + html = _output_text(data['text/html']).strip() + if html: + return f'{html}\n' + + if 'text/plain' in data: + text = _output_text(data['text/plain']).rstrip() + if text: + return f'**Output**\n\n```text\n{text}\n```\n' + + # Some optional rich outputs (for example py3Dmol) include custom MIME + # bundles alongside text/html. If there is no text/html or text/plain + # fallback, skip the bundle rather than failing hard. + return '' + + +def export_markdown(path: Path) -> str: + """Render one notebook into a Markdown page without executing it.""" + + data = _load_notebook(path) + cells = data.get('cells') + if not isinstance(cells, list): + raise NotebookExportError(f'{path.name}: missing notebook cell list') + + parts: list[str] = [HEADER] + parts.append( + '[Open the original notebook on GitHub]' + f'({GITHUB_NOTEBOOK_BASE}/{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') + + cell_type = cell.get('cell_type') + source = _cell_source(cell, path=path, index=index) + + 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') + + outputs = cell.get('outputs', []) + if not isinstance(outputs, list): + raise NotebookExportError(f'{path.name}: invalid outputs in cell {index}') + for output in outputs: + if not isinstance(output, dict): + raise NotebookExportError( + f'{path.name}: output is not an object in cell {index}' + ) + rendered = _render_output(output, path=path, index=index) + if rendered: + parts.append(rendered.rstrip() + '\n') + + body = '\n'.join(part.rstrip() for part in parts if part).rstrip() + return body + '\n' + + +def export_notebooks(output_dir: Path, *, check: bool = False) -> int: + """Export notebooks or verify that the exported pages are in sync.""" + + output_dir.mkdir(parents=True, exist_ok=True) + for notebook_path, default_output in iter_notebook_pairs(): + rendered = export_markdown(notebook_path) + output_path = output_dir / default_output.name + if check: + if not output_path.exists(): + print(f'missing exported page: {output_path}', file=sys.stderr) + return 1 + 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 92454f8..df5e6b3 100644 --- a/tools/gen_readme.py +++ b/tools/gen_readme.py @@ -25,6 +25,7 @@ from __future__ import annotations +import argparse from pathlib import Path import os import re @@ -327,7 +328,7 @@ def img_repl(m: re.Match[str]) -> str: return '\n'.join(out_lines) -def main() -> None: +def generate_readme(output: Path = OUT) -> str: for p in PAGES: if not p.exists(): @@ -351,18 +352,15 @@ def main() -> None: parts: list[str] = [] - # Header + badges. parts.append( f'# {pkg_name}\n\n' + badges + '\n\n' + f'**Documentation:** {docs_site}\n' ) - # Add pages. for i, p in enumerate(PAGES): md = p.read_text(encoding='utf-8').strip() + '\n' md = _rewrite_links(md, src_path=p, docs_site=docs_site, raw_base=raw_base) if i == 0: - # Strip the first H1 if present (we already provide the README title). md_lines = md.splitlines() if md_lines and md_lines[0].strip().lower() in ( f'# {pkg_name}', @@ -380,10 +378,32 @@ def main() -> None: '*This README is auto-generated from the MkDocs sources in `docs/`.*\n' 'To update it, edit the docs pages and re-run: `python tools/gen_readme.py`.\n' ) + return out + '\n' - OUT.write_text(out + '\n', encoding='utf-8') - print(f'Wrote {OUT}') + +def main() -> int: + + parser = argparse.ArgumentParser() + parser.add_argument('--output', type=Path, default=OUT) + parser.add_argument( + '--check', + action='store_true', + help='exit with status 1 when README.md is out of sync', + ) + args = parser.parse_args() + + rendered = generate_readme(args.output) + if args.check: + current = args.output.read_text(encoding='utf-8').replace('\r\n', '\n') + if current != rendered: + print(f'{args.output} is out of sync with docs/', flush=True) + return 1 + return 0 + + args.output.write_text(rendered, encoding='utf-8', newline='\n') + print(f'Wrote {args.output}') + return 0 if __name__ == '__main__': - main() + raise SystemExit(main()) diff --git a/tools/install_wheel_overlay.py b/tools/install_wheel_overlay.py new file mode 100644 index 0000000..ba28983 --- /dev/null +++ b/tools/install_wheel_overlay.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Install a dev overlay that keeps wheel extension modules and uses repo code. + +This script is intended for the workflow where: + +1. a prebuilt pyvoro2 wheel is installed into the current Python environment, +2. the checked-out repository contains newer pure-Python code under ``src/``, +3. we want imports to resolve to the repository sources while still loading the + compiled extension module(s) from the wheel. + +The script performs three steps: + +- copies or symlinks compiled binaries such as ``_core`` and ``_core2d`` into + ``src/pyvoro2/``; +- writes a ``.pth`` file into the active environment to insert ``repo/src`` at + the front of ``sys.path``; +- verifies in a fresh Python process that ``import pyvoro2`` resolves to the + repository sources and that the copied extension module(s) are importable. + +Typical usage:: + + python -m pip install /path/to/pyvoro2-...whl + python tools/install_wheel_overlay.py + +If the wheel is not yet installed, the script can also extract extension +binaries directly from a wheel file via ``--wheel``. In that mode it still +writes the ``.pth`` overlay for the current environment. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import sysconfig +import textwrap +import zipfile +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PACKAGE_NAME = 'pyvoro2' +PACKAGE_SRC = PROJECT_ROOT / 'src' / PACKAGE_NAME +EXTENSION_PREFIXES = ('_core', '_core2d') + + +class OverlayError(RuntimeError): + """Raised when the dev overlay cannot be installed.""" + + +def _candidate_site_packages() -> list[Path]: + keys = ('purelib', 'platlib') + out: list[Path] = [] + for key in keys: + path_str = sysconfig.get_paths().get(key) + if not path_str: + continue + path = Path(path_str).resolve() + if path not in out: + out.append(path) + return out + + +def _installed_extension_paths() -> dict[str, Path]: + found: dict[str, Path] = {} + for site_dir in _candidate_site_packages(): + pkg_dir = site_dir / PACKAGE_NAME + if not pkg_dir.exists(): + continue + for prefix in EXTENSION_PREFIXES: + cores = sorted(pkg_dir.glob(f'{prefix}*.so')) + sorted( + pkg_dir.glob(f'{prefix}*.pyd') + ) + if cores and prefix not in found: + found[prefix] = cores[0] + return found + + +def _copy_or_symlink(src: Path, dst: Path, *, mode: str) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + if dst.exists() or dst.is_symlink(): + dst.unlink() + if mode == 'symlink': + dst.symlink_to(src) + elif mode == 'copy': + shutil.copy2(src, dst) + else: # pragma: no cover - argparse guards this + raise OverlayError(f'unsupported mode: {mode!r}') + + +def _extract_extensions_from_wheel( + wheel_path: Path, + target_dir: Path, +) -> dict[str, Path]: + if not wheel_path.exists(): + raise OverlayError(f'wheel file not found: {wheel_path}') + + extracted: dict[str, Path] = {} + with zipfile.ZipFile(wheel_path) as zf: + for prefix in EXTENSION_PREFIXES: + names = [ + name + for name in zf.namelist() + if name.startswith(f'{PACKAGE_NAME}/{prefix}') + and (name.endswith('.so') or name.endswith('.pyd')) + ] + if not names: + continue + member = names[0] + target = target_dir / Path(member).name + target.parent.mkdir(parents=True, exist_ok=True) + with zf.open(member) as src, target.open('wb') as dst: + shutil.copyfileobj(src, dst) + extracted[prefix] = target + if '_core' not in extracted: + raise OverlayError(f'no {PACKAGE_NAME}._core binary found in {wheel_path}') + return extracted + + +def _write_pth(repo_src: Path, *, pth_name: str) -> Path: + site_dirs = _candidate_site_packages() + if not site_dirs: + raise OverlayError('could not determine site-packages directories') + pth_path = site_dirs[0] / pth_name + repo_src_str = str(repo_src.resolve()) + payload = ( + 'import sys; p = ' + repr(repo_src_str) + '; ' + 'sys.path.insert(0, p) if p not in sys.path else None\n' + ) + pth_path.write_text(payload, encoding='utf-8') + return pth_path + + +def _verify_overlay(repo_src: Path) -> tuple[str, str, str]: + code = textwrap.dedent( + ''' + import pyvoro2 + import pyvoro2.api as api + import pyvoro2.planar.api as api2 + print(pyvoro2.__file__) + print(api._core.__file__) + print('MISSING' if api2._core2d is None else api2._core2d.__file__) + ''' + ) + proc = subprocess.run( + [sys.executable, '-c', code], + check=True, + capture_output=True, + text=True, + ) + lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()] + if len(lines) != 3: + raise OverlayError(f'unexpected verification output: {proc.stdout!r}') + py_file, core_file, core2d_file = lines + repo_prefix = str(repo_src.resolve()) + if not py_file.startswith(repo_prefix): + raise OverlayError( + 'overlay verification failed: Python package was not imported ' + f'from repo/src ({py_file})' + ) + if not core_file.startswith(str((repo_src / PACKAGE_NAME).resolve())): + raise OverlayError( + 'overlay verification failed: _core was not imported from the ' + f'repository package directory ({core_file})' + ) + if core2d_file != 'MISSING' and not core2d_file.startswith( + str((repo_src / PACKAGE_NAME).resolve()) + ): + raise OverlayError( + 'overlay verification failed: _core2d was not imported from the ' + f'repository package directory ({core2d_file})' + ) + return py_file, core_file, core2d_file + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--repo', + type=Path, + default=PROJECT_ROOT, + help='pyvoro2 repository root (default: %(default)s)', + ) + parser.add_argument( + '--wheel', + type=Path, + default=None, + help='optional wheel file to extract extension modules from', + ) + parser.add_argument( + '--mode', + choices=('copy', 'symlink'), + default='copy', + help='how to place extension binaries into src/pyvoro2', + ) + parser.add_argument( + '--pth-name', + default='pyvoro2_dev_overlay.pth', + help='name of the .pth file written into site-packages', + ) + args = parser.parse_args() + + repo_root = args.repo.resolve() + repo_src = repo_root / 'src' + package_dir = repo_src / PACKAGE_NAME + if not package_dir.exists(): + raise OverlayError(f'package directory not found: {package_dir}') + + if args.wheel is not None: + placed = _extract_extensions_from_wheel(args.wheel.resolve(), package_dir) + core_source_note = f'extracted from wheel {args.wheel.resolve()}' + else: + installed = _installed_extension_paths() + if '_core' not in installed: + searched = ', '.join(str(p) for p in _candidate_site_packages()) + raise OverlayError( + 'could not find an installed pyvoro2 wheel core in the current ' + f'environment (searched: {searched}). Install a wheel first or ' + 'pass --wheel /path/to/pyvoro2-...whl.' + ) + placed = {} + for prefix, src in installed.items(): + dst = package_dir / src.name + _copy_or_symlink(src, dst, mode=args.mode) + placed[prefix] = dst + core_source_note = 'copied/symlinked from installed wheel extensions' + + pth_path = _write_pth(repo_src, pth_name=args.pth_name) + py_file, core_file, core2d_file = _verify_overlay(repo_src) + + print('pyvoro2 dev overlay installed successfully') + print(f' repo src: {repo_src}') + print(f' package: {py_file}') + print(f' core: {core_file}') + if core2d_file == 'MISSING': + print(' core2d: MISSING (no planar extension in the installed wheel yet)') + else: + print(f' core2d: {core2d_file}') + print(f' .pth file: {pth_path}') + print(f' core source: {core_source_note}') + print('To remove the overlay later, delete the .pth file shown above.') + return 0 + + +if __name__ == '__main__': + try: + raise SystemExit(main()) + except OverlayError as exc: + print(f'ERROR: {exc}', file=sys.stderr) + raise SystemExit(1) diff --git a/tools/release_check.py b/tools/release_check.py new file mode 100644 index 0000000..d912770 --- /dev/null +++ b/tools/release_check.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Run the full release-preparation checks for the repository.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import venv + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DIST_DIR = REPO_ROOT / 'dist' +BUILD_DIR = REPO_ROOT / 'build' + + +def _run(*args: str, env: dict[str, str] | None = None) -> None: + """Run one subprocess command in the repository root.""" + + print('+', ' '.join(args)) + subprocess.run(args, cwd=REPO_ROOT, check=True, env=env) + + +def _fresh_build_dirs() -> None: + """Remove build artifacts from previous runs.""" + + shutil.rmtree(DIST_DIR, ignore_errors=True) + shutil.rmtree(BUILD_DIR, ignore_errors=True) + + +def _smoke_test_wheel() -> None: + """Install the built wheel into a temporary virtualenv and smoke-test it.""" + + wheels = sorted(DIST_DIR.glob('*.whl')) + if not wheels: + raise RuntimeError('no wheel found in dist/') + wheel = wheels[-1] + + with tempfile.TemporaryDirectory(prefix='pyvoro2-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', str(wheel)) + smoke = ( + "import numpy as np; " + "import pyvoro2 as pv; " + "import pyvoro2.planar as pv2; " + "pts3 = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], dtype=float); " + "cells3 = pv.compute(pts3, domain=pv.Box(((-5.0, 5.0), (-5.0, 5.0), " + "(-5.0, 5.0))), mode='standard'); " + "assert len(cells3) == 2; " + "pts2 = np.array([[0.25, 0.5], [0.75, 0.5]], dtype=float); " + "cells2 = pv2.compute(pts2, domain=pv2.Box(((0.0, 1.0), (0.0, 1.0))), " + "return_edges=True); " + "assert len(cells2) == 2" + ) + _run(str(python), '-c', smoke) + + +def main() -> int: + """Run lint, tests, docs, build, metadata, and wheel smoke checks.""" + + parser = argparse.ArgumentParser( + description='Run the full release-preparation checks for the repository.', + ) + parser.add_argument( + '--skip-docs', + action='store_true', + help='skip the strict MkDocs build step', + ) + parser.add_argument( + '--skip-build', + action='store_true', + help='skip building distributions and validating dist artifacts', + ) + parser.add_argument( + '--skip-smoke-test', + action='store_true', + help='skip the temporary-virtualenv wheel smoke test', + ) + args = parser.parse_args() + + _run('flake8', 'src', 'tests', 'tools') + _run(sys.executable, 'tools/check_notebooks.py') + _run(sys.executable, 'tools/export_notebooks.py', '--check') + _run(sys.executable, 'tools/gen_readme.py', '--check') + _run(sys.executable, '-m', 'pytest', '-q') + if not args.skip_docs: + _run('mkdocs', 'build', '--strict') + + if args.skip_build: + return 0 + + _fresh_build_dirs() + _run(sys.executable, '-m', 'build') + _run(sys.executable, '-m', 'twine', 'check', 'dist/*') + _run(sys.executable, 'tools/check_dist.py', 'dist') + if not args.skip_smoke_test: + _smoke_test_wheel() + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())