From 60d9e039bb979f37f58cc03c42d76ab37dc11a06 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 00:07:20 +0300 Subject: [PATCH 01/24] Adds powerfit core API --- pyproject.toml | 2 +- src/pyvoro2/__about__.py | 2 +- src/pyvoro2/__init__.py | 47 +- src/pyvoro2/_powerfit_constraints.py | 457 ++++++++++ src/pyvoro2/_powerfit_model.py | 192 ++++ src/pyvoro2/_powerfit_realize.py | 151 ++++ src/pyvoro2/_powerfit_solver.py | 809 +++++++++++++++++ src/pyvoro2/inverse.py | 1202 +------------------------- src/pyvoro2/powerfit.py | 38 + tests/test_inverse_fit.py | 152 ++-- tests/test_powerfit_realization.py | 67 ++ 11 files changed, 1838 insertions(+), 1281 deletions(-) create mode 100644 src/pyvoro2/_powerfit_constraints.py create mode 100644 src/pyvoro2/_powerfit_model.py create mode 100644 src/pyvoro2/_powerfit_realize.py create mode 100644 src/pyvoro2/_powerfit_solver.py create mode 100644 src/pyvoro2/powerfit.py create mode 100644 tests/test_powerfit_realization.py diff --git a/pyproject.toml b/pyproject.toml index 694af4d..80cbb59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ 'numpy>=1.23,<3; python_version >= "3.11"', ] classifiers = [ - 'Development Status :: 4 - Beta', + 'Development Status :: 3 - Alpha', 'Intended Audience :: Science/Research', 'Topic :: Scientific/Engineering :: Chemistry', 'Topic :: Scientific/Engineering :: Physics', diff --git a/src/pyvoro2/__about__.py b/src/pyvoro2/__about__.py index 1c77f3d..7c44b95 100644 --- a/src/pyvoro2/__about__.py +++ b/src/pyvoro2/__about__.py @@ -5,4 +5,4 @@ """ # Keep this as a simple assignment so scikit-build-core can extract it via regex. -__version__ = '0.4.2.post1' +__version__ = '0.5.0' diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index d5d5212..14522f1 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -1,13 +1,7 @@ """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. """ from __future__ import annotations @@ -44,13 +38,24 @@ 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, + PowerWeightFitResult, + RealizedPairDiagnostics, + fit_power_weights, + match_realized_pairs, radii_to_weights, weights_to_radii, - fit_power_weights_from_plane_fractions, - fit_power_weights_from_plane_positions, ) __all__ = [ @@ -78,10 +83,22 @@ 'normalize_vertices', 'normalize_edges_faces', 'normalize_topology', - 'FitWeightsResult', + 'PairBisectorConstraints', + 'resolve_pair_bisector_constraints', + 'SquaredLoss', + 'HuberLoss', + 'Interval', + 'FixedValue', + 'SoftIntervalPenalty', + 'ExponentialBoundaryPenalty', + 'ReciprocalBoundaryPenalty', + 'L2Regularization', + 'FitModel', + 'PowerWeightFitResult', + 'RealizedPairDiagnostics', + 'fit_power_weights', + 'match_realized_pairs', 'radii_to_weights', 'weights_to_radii', - 'fit_power_weights_from_plane_fractions', - 'fit_power_weights_from_plane_positions', '__version__', ] diff --git a/src/pyvoro2/_powerfit_constraints.py b/src/pyvoro2/_powerfit_constraints.py new file mode 100644 index 0000000..d7cd3be --- /dev/null +++ b/src/pyvoro2/_powerfit_constraints.py @@ -0,0 +1,457 @@ +"""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 .domains import Box, OrthorhombicCell, PeriodicCell + +ConstraintInput = Sequence[ + tuple[int, int, float] | tuple[int, int, float, tuple[int, int, int]] +] + + +@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.shape != (m, 3): + raise ValueError('PairBisectorConstraints.shifts must have shape (m, 3)') + 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.shape != (m, 3): + raise ValueError('PairBisectorConstraints.delta must have shape (m, 3)') + if self.measurement not in ('fraction', 'position'): + raise ValueError('measurement must be "fraction" or "position"') + + @property + def n_constraints(self) -> int: + return int(self.i.shape[0]) + + 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: 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, + confidence: Sequence[float] | None = None, + allow_empty: bool = False, +) -> PairBisectorConstraints: + """Parse and resolve pairwise separator constraints. + + Args: + points: Site coordinates with shape ``(n, 3)``. + 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 cells. + 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] != 3: + raise ValueError('points must have shape (n, 3)') + if measurement not in ('fraction', 'position'): + raise ValueError('measurement must be "fraction" or "position"') + + i_idx, j_idx, target, shifts, shift_given, warnings = _parse_constraints( + constraints, + n_points=pts.shape[0], + ids=ids, + index_mode=index_mode, + allow_empty=allow_empty, + ) + + 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 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: + ids_arr = None if ids is None else np.asarray(ids) + zeros_i = np.zeros(0, dtype=np.int64) + zeros_f = np.zeros(0, dtype=np.float64) + zeros_s = np.zeros((0, 3), 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, 3), 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) + + target_arr = np.asarray(target, dtype=np.float64) + if measurement == 'fraction': + target_fraction = target_arr.copy() + target_position = target_fraction * d + else: + target_position = target_arr.copy() + target_fraction = target_position / d + + ids_arr = None if ids is None else np.asarray(ids) + + 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, +) -> 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, 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: + return _maybe_remap_points(points, domain) + + + +def _maybe_remap_points( + points: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None +) -> np.ndarray: + 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 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) + + 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': + 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') + + 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: + (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 + 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: + 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 = pj - pi + 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) + return cand[best].astype(np.int64) + + +def shift_to_cart( + shifts: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None +) -> 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 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') 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..44da045 --- /dev/null +++ b/src/pyvoro2/_powerfit_realize.py @@ -0,0 +1,151 @@ +"""Realized-face matching for resolved pairwise separator constraints.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from ._powerfit_constraints import PairBisectorConstraints +from .api import compute +from .domains import Box, OrthorhombicCell, PeriodicCell +from .face_properties import annotate_face_properties + + +@dataclass(frozen=True, slots=True) +class RealizedPairDiagnostics: + """Diagnostics for matching candidate constraints to realized faces.""" + + realized: np.ndarray + unrealized: tuple[int, ...] + realized_same_shift: np.ndarray + realized_other_shift: np.ndarray + realized_shifts: tuple[tuple[tuple[int, int, int], ...], ...] + endpoint_i_empty: np.ndarray + endpoint_j_empty: np.ndarray + boundary_measure: np.ndarray | None + cells: list[dict[str, Any]] | None + + + +def match_realized_pairs( + points: np.ndarray, + *, + domain: Box | OrthorhombicCell | PeriodicCell, + radii: np.ndarray, + constraints: PairBisectorConstraints, + return_boundary_measure: bool = False, + return_cells: bool = False, +) -> RealizedPairDiagnostics: + """Determine which resolved pair constraints correspond to realized faces.""" + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] != 3: + raise ValueError('points must have shape (n, 3)') + if pts.shape[0] != constraints.n_points: + raise ValueError('points do not match the resolved constraint set') + + periodic = isinstance(domain, PeriodicCell) or ( + isinstance(domain, OrthorhombicCell) and any(domain.periodic) + ) + + cells = compute( + pts, + 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, + ) + + if return_boundary_measure: + annotate_face_properties(cells, domain) + + empty_by_id: dict[int, bool] = {} + shifts_by_pair: dict[tuple[int, int], set[tuple[int, int, int]]] = {} + measure_by_pair_shift: dict[tuple[int, int, int, int, int], float] = {} + + for cell in cells: + ci = int(cell['id']) + verts = np.asarray(cell.get('vertices', []), dtype=float) + faces = cell.get('faces', []) + empty_by_id[ci] = bool(verts.size == 0 or len(faces) == 0) + for face in faces: + cj = int(face.get('adjacent_cell', -1)) + if cj < 0: + continue + sh = face.get('adjacent_shift', (0, 0, 0)) + shift = (int(sh[0]), int(sh[1]), int(sh[2])) + shifts_by_pair.setdefault((ci, cj), set()).add(shift) + if return_boundary_measure: + measure = float(face.get('area', 0.0)) + measure_by_pair_shift[(ci, cj, shift[0], shift[1], shift[2])] = measure + + 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[tuple[int, int, int], ...]] = [] + 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 = ( + int(constraints.shifts[k, 0]), + int(constraints.shifts[k, 1]), + int(constraints.shifts[k, 2]), + ) + 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 = {(-sx, -sy, -sz) for (sx, sy, sz) 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: + if same: + key_f = (i, j, target_shift[0], target_shift[1], target_shift[2]) + key_r = (j, i, -target_shift[0], -target_shift[1], -target_shift[2]) + 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] + else: + chosen = realized_set[0] + key_f = (i, j, chosen[0], chosen[1], chosen[2]) + key_r = (j, i, -chosen[0], -chosen[1], -chosen[2]) + 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] + + 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, + ) diff --git a/src/pyvoro2/_powerfit_solver.py b/src/pyvoro2/_powerfit_solver.py new file mode 100644 index 0000000..51f078c --- /dev/null +++ b/src/pyvoro2/_powerfit_solver.py @@ -0,0 +1,809 @@ +"""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 ._powerfit_constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from ._powerfit_model import ( + ExponentialBoundaryPenalty, + FitModel, + FixedValue, + HardConstraint, + HuberLoss, + Interval, + L2Regularization, + ReciprocalBoundaryPenalty, + SoftIntervalPenalty, + SquaredLoss, +) +from .domains import Box, OrthorhombicCell, PeriodicCell + + +@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 + infeasible_constraints: tuple[int, ...] | None + warnings: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class _MeasurementGeometry: + alpha: np.ndarray + beta: np.ndarray + target: np.ndarray + target_fraction: np.ndarray + target_position: np.ndarray + + +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 power weights to radii using a global gauge shift.""" + + 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 + 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, + solver: Literal['auto', 'analytic', 'admm'] = 'auto', + max_iter: int = 2000, + rho: float = 1.0, + tol_abs: float = 1e-6, + tol_rel: float = 1e-5, +) -> 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``. + """ + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] != 3: + raise ValueError('points must have shape (n, 3)') + + 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.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, + solver=solver, + max_iter=max_iter, + rho=rho, + tol_abs=tol_abs, + tol_rel=tol_rel, + ) + + + +def _fit_power_weights_resolved( + constraints: PairBisectorConstraints, + *, + model: FitModel, + r_min: float, + solver: Literal['auto', 'analytic', 'admm'], + max_iter: int, + rho: float, + tol_abs: float, + tol_rel: float, +) -> 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') + + reg = model.regularization + lam = float(reg.strength) + w0 = _regularization_reference(reg, n) + + 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 + + if hard is not None: + feasible, infeasible_idx = _check_hard_feasibility( + n, + constraints.i, + constraints.j, + z_lo, + z_hi, + ) + if not feasible: + warnings_list.append('hard feasibility check failed before optimization') + 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, + infeasible_constraints=infeasible_idx, + warnings=tuple(warnings_list), + ) + else: + infeasible_idx = None + + if m == 0: + weights = np.zeros(n, dtype=np.float64) + radii, shift = weights_to_radii(weights, r_min=r_min) + warnings_list.append('empty constraint set; returning zero weights') + 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, + infeasible_constraints=infeasible_idx, + warnings=tuple(warnings_list), + ) + + 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' + ) + + comps = _connected_components(n, constraints.i, constraints.j) + if len(comps) > 1 and lam == 0.0: + warnings_list.append( + 'constraint graph has multiple connected components; each component is gauge-fixed independently' + ) + + weights = np.zeros(n, dtype=np.float64) + converged_all = True + n_iter_max = 0 + + for nodes in comps: + if len(nodes) <= 1: + if lam > 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(constraints.i, constraints.j) + ], + dtype=bool, + ) + 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[np.array(nodes, dtype=np.int64)] + 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, + ) + weights[np.array(nodes, dtype=np.int64)] = w_c + converged_all = converged_all and conv + n_iter_max = max(n_iter_max, iters) + + radii, shift = weights_to_radii(weights, r_min=r_min) + pred_fraction, pred_position, pred = _predict_measurements(weights, constraints) + residuals = pred - geom.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 + + 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), + infeasible_constraints=infeasible_idx, + warnings=tuple(warnings_list), + ) + + + +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 _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, tuple[int, ...] | None]: + """Check feasibility of difference constraints via Bellman-Ford.""" + + edges: list[tuple[int, int, float, int]] = [] + 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((j, i, float(hi), k)) + # w_i - w_j >= lo -> w_j - w_i <= -lo: edge i -> j with weight -lo + edges.append((i, j, float(-lo), k)) + + dist = np.zeros(n, dtype=np.float64) + pred_node = np.full(n, -1, dtype=np.int64) + pred_constraint = np.full(n, -1, dtype=np.int64) + last_updated = -1 + tol = 1e-12 + + for it in range(n): + updated = False + last_updated = -1 + for u, v, w, k in edges: + cand = dist[u] + w + if cand < dist[v] - tol: + dist[v] = cand + pred_node[v] = u + pred_constraint[v] = k + updated = True + last_updated = v + 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_constraints: list[int] = [] + cur = y + seen: set[int] = set() + while cur not in seen and cur >= 0: + seen.add(cur) + ck = int(pred_constraint[cur]) + if ck >= 0: + cycle_constraints.append(ck) + cur = int(pred_node[cur]) + return False, tuple(sorted(set(cycle_constraints))) or None + + + +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: + 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)] + try: + chol = np.linalg.cholesky(Mf) + except np.linalg.LinAlgError: + 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 + + # 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 + + 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 + + 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 + step = g / gp + 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/inverse.py b/src/pyvoro2/inverse.py index 4a2b556..9576a14 100644 --- a/src/pyvoro2/inverse.py +++ b/src/pyvoro2/inverse.py @@ -1,1204 +1,8 @@ -"""Inverse utilities for power (Laguerre) tessellations. +"""Legacy module path for the power-weight inverse solver. -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. +The math API now lives in :mod:`pyvoro2.powerfit`. """ 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) +from .powerfit import * # noqa: F401,F403 diff --git a/src/pyvoro2/powerfit.py b/src/pyvoro2/powerfit.py new file mode 100644 index 0000000..c2bd113 --- /dev/null +++ b/src/pyvoro2/powerfit.py @@ -0,0 +1,38 @@ +"""Public API for inverse fitting of power weights from pairwise constraints.""" + +from __future__ import annotations + +from ._powerfit_constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from ._powerfit_model import ( + ExponentialBoundaryPenalty, + FitModel, + FixedValue, + HuberLoss, + Interval, + L2Regularization, + ReciprocalBoundaryPenalty, + SoftIntervalPenalty, + SquaredLoss, +) +from ._powerfit_realize import RealizedPairDiagnostics, match_realized_pairs +from ._powerfit_solver import 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', + 'PowerWeightFitResult', + 'RealizedPairDiagnostics', + 'fit_power_weights', + 'match_realized_pairs', + 'radii_to_weights', + 'weights_to_radii', +] diff --git a/tests/test_inverse_fit.py b/tests/test_inverse_fit.py index b005ea3..3d0fda4 100644 --- a/tests/test_inverse_fit.py +++ b/tests/test_inverse_fit.py @@ -2,136 +2,158 @@ def test_fit_power_weights_fraction_two_points_analytic(): - from pyvoro2 import fit_power_weights_from_plane_fractions + 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_from_plane_fractions(pts, [(0, 1, 0.25)]) + res = fit_power_weights(pts, [(0, 1, 0.25)], measurement='fraction') - # 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 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_t_outside_segment(): - from pyvoro2 import fit_power_weights_from_plane_fractions +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_from_plane_fractions( - pts, [(0, 1, 1.2)], t_bounds_mode='none' - ) + res = fit_power_weights(pts, [(0, 1, 1.2)], measurement='fraction') - 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.allclose(res.predicted[0], 1.2, atol=1e-10) 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 +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_from_plane_fractions( + res = fit_power_weights( pts, [(0, 1, -0.2)], - t_bounds=(0.0, 1.0), - t_bounds_mode='hard', + measurement='fraction', + model=FitModel(feasible=Interval(0.0, 1.0)), 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) + 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_from_plane_fractions + 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_from_plane_fractions(pts, [(0, 1, 0.25)], r_min=1.0) + res = fit_power_weights(pts, [(0, 1, 0.25)], measurement='fraction', r_min=1.0) - assert np.min(res.radii) == np.min(res.radii) # not NaN + assert np.min(res.radii) == np.min(res.radii) 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 +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) - # 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) + res0 = fit_power_weights(pts, [(0, 1, -0.2)], measurement='fraction') + assert np.allclose(res0.predicted[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( + res = fit_power_weights( pts, [(0, 1, -0.2)], - t_bounds=(0.0, 1.0), - t_bounds_mode='soft_quadratic', - alpha_out=100.0, + measurement='fraction', + model=FitModel(penalties=(SoftIntervalPenalty(0.0, 1.0, 100.0),)), solver='admm', max_iter=5000, ) - assert res.t_pred[0] > res0.t_pred[0] + assert res.predicted[0] > res0.predicted[0] -def test_fit_power_weights_fraction_near_boundary_penalty_pushes_away(): - from pyvoro2 import fit_power_weights_from_plane_fractions +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) - # Target is very close to 0. With hard bounds only, it should fit near 0. - res_hard = fit_power_weights_from_plane_fractions( + res_hard = fit_power_weights( pts, [(0, 1, 1e-3)], - t_bounds=(0.0, 1.0), - t_bounds_mode='hard', + measurement='fraction', + model=FitModel(feasible=Interval(0.0, 1.0)), 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( + res_repulse = fit_power_weights( 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, + 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.t_pred[0] >= res_hard.t_pred[0] - 1e-6 - assert res_repulse.t_pred[0] > 0.01 # should not hug the boundary + 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') -def test_fit_power_weights_check_contacts_flags_inactive_constraints(): - from pyvoro2 import Box, fit_power_weights_from_plane_fractions + 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 - # 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]], + [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], dtype=float, ) - domain = Box(((-5, 5), (-5, 5), (-5, 5))) + # 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.infeasible_constraints is not None + + +def test_huber_loss_is_available_as_an_alternative_mismatch(): + from pyvoro2 import FitModel, HuberLoss, fit_power_weights - res = 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( pts, - constraints=[(0, 2, 0.5)], - domain=domain, - check_contacts=True, + [(0, 1, 1.2)], + measurement='fraction', + model=FitModel(mismatch=HuberLoss(delta=0.1)), + solver='admm', + max_iter=5000, ) - 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,) + assert res.status in ('optimal', 'max_iter') + assert res.predicted is not None diff --git a/tests/test_powerfit_realization.py b/tests/test_powerfit_realization.py new file mode 100644 index 0000000..efbe20e --- /dev/null +++ b/tests/test_powerfit_realization.py @@ -0,0 +1,67 @@ +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 From 91a86d72e73778000cd3823a98567fc82a3b9a87 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 00:08:12 +0300 Subject: [PATCH 02/24] Adds self-consistent active-set solver --- src/pyvoro2/__init__.py | 10 + src/pyvoro2/_powerfit_active.py | 439 ++++++++++++++++++++++++++++++ src/pyvoro2/powerfit.py | 12 + tests/test_powerfit_active_set.py | 45 +++ 4 files changed, 506 insertions(+) create mode 100644 src/pyvoro2/_powerfit_active.py create mode 100644 tests/test_powerfit_active_set.py diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index 14522f1..bcf1935 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -52,8 +52,13 @@ FitModel, PowerWeightFitResult, RealizedPairDiagnostics, + ActiveSetOptions, + ActiveSetIteration, + PairConstraintDiagnostics, + SelfConsistentPowerFitResult, fit_power_weights, match_realized_pairs, + solve_self_consistent_power_weights, radii_to_weights, weights_to_radii, ) @@ -96,8 +101,13 @@ 'FitModel', 'PowerWeightFitResult', 'RealizedPairDiagnostics', + 'ActiveSetOptions', + 'ActiveSetIteration', + 'PairConstraintDiagnostics', + 'SelfConsistentPowerFitResult', 'fit_power_weights', 'match_realized_pairs', + 'solve_self_consistent_power_weights', 'radii_to_weights', 'weights_to_radii', '__version__', diff --git a/src/pyvoro2/_powerfit_active.py b/src/pyvoro2/_powerfit_active.py new file mode 100644 index 0000000..ff2ba35 --- /dev/null +++ b/src/pyvoro2/_powerfit_active.py @@ -0,0 +1,439 @@ +"""Self-consistent active-set refinement for pairwise separator constraints.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +import numpy as np + +from ._powerfit_constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from ._powerfit_model import FitModel +from ._powerfit_realize import RealizedPairDiagnostics, match_realized_pairs +from ._powerfit_solver import ( + PowerWeightFitResult, + _connected_components, + _predict_measurements, + fit_power_weights, + weights_to_radii, +) +from .domains import Box, OrthorhombicCell, PeriodicCell + + +@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 + + +@dataclass(frozen=True, slots=True) +class PairConstraintDiagnostics: + 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 + toggle_count: np.ndarray + realized_toggle_count: np.ndarray + first_realized_iter: np.ndarray + last_realized_iter: np.ndarray + marginal: np.ndarray + + +@dataclass(frozen=True, slots=True) +class SelfConsistentPowerFitResult: + 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' + ] + cycle_length: int | None + marginal_constraints: tuple[int, ...] + history: tuple[ActiveSetIteration, ...] | None + warnings: tuple[str, ...] + + + +def solve_self_consistent_power_weights( + points: np.ndarray, + constraints: PairBisectorConstraints | list[tuple] | tuple[tuple, ...], + *, + measurement: Literal['fraction', 'position'] = 'fraction', + domain: Box | 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 = ActiveSetOptions(), + r_min: float = 0.0, + 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, +) -> SelfConsistentPowerFitResult: + """Iteratively refine an active pair set against realized power-diagram faces.""" + + pts = np.asarray(points, dtype=float) + if pts.ndim != 2 or pts.shape[1] != 3: + raise ValueError('points must have shape (n, 3)') + + 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') + 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, + ) + + 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] = [] + prev_weights_eval: np.ndarray | None = None + prev_realized_same: np.ndarray | None = None + seen_masks: dict[bytes, int] = {active.tobytes(): 0} + comps = _connected_components(resolved.n_points, resolved.i, resolved.j) + + termination: Literal[ + 'self_consistent', 'cycle_detected', 'max_outer_iter', 'infeasible_active_set' + ] = '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, + solver=fit_solver, + max_iter=fit_max_iter, + rho=fit_rho, + tol_abs=fit_tol_abs, + tol_rel=fit_tol_rel, + ) + if fit.weights is None: + warnings_list.extend(fit.warnings) + termination = 'infeasible_active_set' + final_realized = 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, + ) + diag_all = PairConstraintDiagnostics( + 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(), + 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), + ) + return SelfConsistentPowerFitResult( + 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(), + history=tuple(history_rows) if return_history else None, + warnings=tuple(warnings_list), + ) + + weights_exact = fit.weights.copy() + if prev_weights_eval is not None: + weights_exact = _align_weights_to_reference(weights_exact, prev_weights_eval, comps) + 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 + + radii_eval, _ = weights_to_radii(weights_eval, r_min=r_min) + diag = match_realized_pairs( + pts, + domain=domain, + radii=radii_eval, + constraints=resolved, + return_boundary_measure=False, + return_cells=False, + ) + last_diag = diag + 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, + ) + ) + + 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, + solver=fit_solver, + max_iter=fit_max_iter, + rho=fit_rho, + tol_abs=fit_tol_abs, + tol_rel=fit_tol_rel, + ) + warnings_list.extend(final_fit.warnings) + + if final_fit.weights is not None and final_fit.radii is not None: + final_realized = match_realized_pairs( + pts, + domain=domain, + radii=final_fit.radii, + constraints=resolved, + return_boundary_measure=return_boundary_measure, + return_cells=return_cells, + ) + 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 = 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, + ) + + target = ( + resolved.target_fraction + if resolved.measurement == 'fraction' + else resolved.target_position + ) + residuals = pred - target + 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()) + + diag_all = PairConstraintDiagnostics( + 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(), + 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(), + ) + + return SelfConsistentPowerFitResult( + 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, + history=tuple(history_rows) if return_history else None, + warnings=tuple(warnings_list), + ) + + + +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 diff --git a/src/pyvoro2/powerfit.py b/src/pyvoro2/powerfit.py index c2bd113..9b5090d 100644 --- a/src/pyvoro2/powerfit.py +++ b/src/pyvoro2/powerfit.py @@ -14,6 +14,13 @@ SoftIntervalPenalty, SquaredLoss, ) +from ._powerfit_active import ( + ActiveSetIteration, + ActiveSetOptions, + PairConstraintDiagnostics, + SelfConsistentPowerFitResult, + solve_self_consistent_power_weights, +) from ._powerfit_realize import RealizedPairDiagnostics, match_realized_pairs from ._powerfit_solver import PowerWeightFitResult, fit_power_weights, radii_to_weights, weights_to_radii @@ -31,8 +38,13 @@ 'FitModel', 'PowerWeightFitResult', 'RealizedPairDiagnostics', + 'ActiveSetOptions', + 'ActiveSetIteration', + 'PairConstraintDiagnostics', + 'SelfConsistentPowerFitResult', 'fit_power_weights', 'match_realized_pairs', + 'solve_self_consistent_power_weights', 'radii_to_weights', 'weights_to_radii', ] diff --git a/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py new file mode 100644 index 0000000..a289499 --- /dev/null +++ b/tests/test_powerfit_active_set.py @@ -0,0 +1,45 @@ +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, + ) + + 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 + + +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 From 9b04fc877ba1cc8e3ef15f71cc8fb1ab587c5fa2 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 03:14:05 +0300 Subject: [PATCH 03/24] Adds new diagnostics for pair constraints --- CHANGELOG.md | 16 ++ README.md | 11 +- docs/guide/inverse.md | 266 ++++++++++++++++++------- docs/index.md | 11 +- docs/notebooks/04_inverse_fit.ipynb | 296 +++++++++------------------- docs/reference/inverse.md | 4 +- mkdocs.yml | 4 +- pyproject.toml | 4 +- src/pyvoro2/_powerfit_active.py | 158 ++++++++++++--- src/pyvoro2/_powerfit_realize.py | 23 ++- src/pyvoro2/face_properties.py | 4 +- tests/test_powerfit_active_set.py | 15 ++ tests/test_powerfit_realization.py | 32 +++ 13 files changed, 514 insertions(+), 330 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34f360c..9a29b13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ 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.5.0] - 2026-03-14 + +### Added + +- New `pyvoro2.powerfit` API for inverse power fitting from generic pairwise bisector constraints. +- `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. + ## [0.4.2] - 2026-03-04 ### Changed diff --git a/README.md b/README.md index 3aa5d0c..44bc63d 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,9 @@ - **power / Laguerre tessellations** (weighted Voronoi, via per-site radii) 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**. +scientific and geometric settings that need **periodic boundary conditions**, +explicit **neighbor-image shifts**, and a reusable mathematical interface to +Voronoi and power tessellations. pyvoro2 is designed to be **honest and predictable**: @@ -118,7 +119,7 @@ practical pieces that are easy to get wrong: - 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 + - power-fitting utilities for **fitting power weights** from desired pairwise separator locations ## Documentation overview @@ -132,7 +133,7 @@ implementation-oriented details. | [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). | +| [Power fitting](https://delonecommons.github.io/pyvoro2/guide/inverse/) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | | [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). | @@ -185,7 +186,7 @@ Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`. ## Project status -pyvoro2 is currently in **beta**. +pyvoro2 is currently in **alpha**. 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. diff --git a/docs/guide/inverse.md b/docs/guide/inverse.md index 15de1e7..f6e7ef9 100644 --- a/docs/guide/inverse.md +++ b/docs/guide/inverse.md @@ -1,121 +1,235 @@ -# Inverse fitting (weights/radii from desired planes) +# Power fitting from pairwise bisector constraints -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. +`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. -Examples include: +The API is intentionally **geometry-first** and **domain-agnostic**. +Downstream code decides: -- atom-in-molecule partitions (chemistry) -- promolecular or model density partitions -- custom interface placements used as a geometric descriptor +- 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 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. +`pyvoro2` then provides the mathematical pieces: -## Power bisector position along a line +- 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. -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: +## 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`: $$ -\lVert x-p_i \rVert^2 - w_i = \lVert x-p_j \rVert^2 - w_j. + t(z) = \frac{1}{2} + \frac{z}{2 d^2} $$ -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$: +for normalized fraction, and $$ - t(w) = \frac{1}{2} + \frac{w_i - w_j}{2 d^2}, - \qquad d = \lVert p_j^* - p_i \rVert. + x(z) = \frac{d}{2} + \frac{z}{2 d} $$ -- $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. +for absolute position measured from site `i`. -## Fitting API +This is why `pyvoro2` exposes the measurement type explicitly: a loss in +fraction-space and a loss in position-space are **different optimization +problems**. -Two convenience functions are provided: +## Step 1: resolve pair constraints once -- `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 +```python +import numpy as np +import pyvoro2 as pv -Constraints are given as a list of tuples: +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))) -- `(i, j, t)` or `(i, j, t, shift)` -- `(i, j, x)` or `(i, j, x, shift)` +constraints = pv.resolve_pair_bisector_constraints( + points, + [(0, 1, 0.25)], + measurement='fraction', + domain=box, +) +``` -where `shift=(na, nb, nc)` specifies which periodic image of $j$ should be used. +Each raw tuple is `(i, j, value[, shift])`, where `shift=(na, nb, nc)` is the +integer lattice image applied to site `j`. -If you omit the shift in a periodic domain, pyvoro2 can (optionally) choose a “nearest image”. +The resolved object stores the validated pair indices, shifts, connector +geometry, and targets in both fraction and position form. -## Restricting where the *predicted* bisector can go +## Step 2: define the fitting model -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. +```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(...)` -pyvoro2 supports three regimes for the **predicted** $t(w)$: +## Step 3: fit power weights -- `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) +```python +fit = pv.fit_power_weights( + points, + constraints, + model=model, + r_min=1.0, +) +``` -In addition, you can add a near-boundary repulsion: +The result contains: -- `t_near_penalty='exp'` +- 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. -which discourages $t(w)$ from approaching the bounds too closely. +For example, if hard interval or equality restrictions cannot all hold +simultaneously, the fit returns: -These options make the fit a small convex optimization problem that pyvoro2 solves in pure NumPy. +- `status == 'infeasible_hard_constraints'` +- `hard_feasible == False` +- `weights is None` -## Radii gauge and `r_min` +instead of pretending the issue is merely slow convergence. -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: +## Step 4: check which pairs are actually realized -- `min(radii) == r_min` +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. -This is useful if you want radii that are never exactly zero. +```python +realized = pv.match_realized_pairs( + points, + domain=box, + radii=fit.radii, + constraints=constraints, + return_boundary_measure=True, + return_tessellation_diagnostics=True, +) +``` -## “Inactive” constraints (pairs that are not a face) +This returns purely geometric diagnostics: -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. +- 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 face, +- and optional tessellation-wide diagnostics. -If you pass `check_contacts=True`, pyvoro2 will compute a tessellation using the fitted radii and report -which constraints became real neighbor faces. +## Step 5: solve the self-consistent active-set problem -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). +For sparse or noisy candidate sets, the useful high-level workflow is often: -## Typical workflow +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. -1) Fit weights/radii from constraints -2) Compute a power tessellation with the fitted radii +`pyvoro2` provides this as: ```python -import pyvoro2 as pv - -res = pv.fit_power_weights_from_plane_fractions( +result = pv.solve_self_consistent_power_weights( 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, + 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, +- `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. + +Status labels are intentionally generic, for example: + +- `stable_active` +- `stable_inactive` +- `toggled_active` +- `toggled_inactive` +- `realized_other_shift` +- `active_unrealized` +- `cycle_member` + +## Current scope + +The current implementation is 3D because it builds on the existing Voro++-based +power tessellation core. The **API vocabulary** is already dimension-safe: +constraint fitting is phrased in terms of pairwise separators and boundary +measure rather than chemistry-specific or 3D-only semantics. diff --git a/docs/index.md b/docs/index.md index 831aa49..84b1323 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,8 +7,9 @@ - **power / Laguerre tessellations** (weighted Voronoi, via per-site radii) 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**. +scientific and geometric settings that need **periodic boundary conditions**, +explicit **neighbor-image shifts**, and a reusable mathematical interface to +Voronoi and power tessellations. pyvoro2 is designed to be **honest and predictable**: @@ -111,7 +112,7 @@ practical pieces that are easy to get wrong: - 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 + - power-fitting utilities for **fitting power weights** from desired pairwise separator locations ## Documentation overview @@ -125,7 +126,7 @@ implementation-oriented details. | [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). | +| [Power fitting](guide/inverse.md) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | | [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). | @@ -178,7 +179,7 @@ Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`. ## Project status -pyvoro2 is currently in **beta**. +pyvoro2 is currently in **alpha**. 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. diff --git a/docs/notebooks/04_inverse_fit.ipynb b/docs/notebooks/04_inverse_fit.ipynb index f723801..4a67759 100644 --- a/docs/notebooks/04_inverse_fit.ipynb +++ b/docs/notebooks/04_inverse_fit.ipynb @@ -2,308 +2,194 @@ "cells": [ { "cell_type": "markdown", - "id": "c76412f0a2844e34964ce3a2e7cbc84a", + "id": "85309bbd", "metadata": {}, "source": [ - "# Inverse fitting of power weights (Laguerre radii)\n", + "# Power fitting from pairwise bisector constraints\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", + "This notebook shows the new math-oriented inverse API in `pyvoro2`:\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" + "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": 1, - "id": "daf0256eb4c24037a1a010122fff1985", + "execution_count": null, + "id": "52ddd452", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", - "from pprint import pprint\n", "\n", "import pyvoro2 as pv\n" ] }, { "cell_type": "markdown", - "id": "b0a781f26d0f443a83013b7a79fdf764", + "id": "40d64698", "metadata": {}, "source": [ - "## 1) Two-site example (easy to interpret)\n", + "## 1) Resolve and fit a simple two-site constraint\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" + "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": 2, - "id": "ba10df2cbd3c4d85a85bab86883acb36", + "execution_count": null, + "id": "ffa208ac", "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" - ] - } - ], + "outputs": [], "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", + "constraints = pv.resolve_pair_bisector_constraints(\n", " points,\n", - " constraints,\n", + " [(0, 1, 0.25)],\n", + " measurement='fraction',\n", " domain=box,\n", - " t_bounds_mode='none',\n", - " r_min=1.0,\n", ")\n", "\n", + "fit = pv.fit_power_weights(points, constraints, r_min=1.0)\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" + "print('predicted fraction:', fit.predicted_fraction)\n", + "print('predicted position:', fit.predicted_position)\n", + "print('status:', fit.status)\n" ] }, { "cell_type": "markdown", - "id": "da700da1f3a04af78affd4a18033d2fc", + "id": "b8d1b5dd", "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" + "## 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": 3, - "id": "7b09c37330a8446ea96e96338aa53ce6", + "execution_count": null, + "id": "0167fdb2", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "volumes: [550.0, 449.9999999999999]\n" - ] - } - ], + "outputs": [], "source": [ - "cells = pv.compute(\n", + "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", - " mode='power',\n", - " radii=fit.radii,\n", - " return_faces=True,\n", - " return_vertices=True,\n", + " model=model,\n", + " solver='admm',\n", ")\n", "\n", - "print('volumes:', [float(c['volume']) for c in cells])\n" + "print('predicted fraction with penalty:', fit_penalized.predicted_fraction[0])\n" ] }, { "cell_type": "markdown", - "id": "96a55f1eb4964d4eb6be5c265ee96dc0", + "id": "3b826381", "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", + "## 3) Match realized pairs after fitting\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" + "Requested pairwise separators do not automatically become realized faces\n", + "in the full power tessellation." ] }, { "cell_type": "code", - "execution_count": 4, - "id": "9fa78c05b21d4da7a2de5f9db0862cab", + "execution_count": null, + "id": "8e66d663", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "t_target: [1.4]\n", - "t_pred: [0.90387053]\n", - "warnings: ()\n" - ] - } - ], + "outputs": [], "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", + "realized = pv.match_realized_pairs(\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", + " radii=fit.radii,\n", + " constraints=constraints,\n", + " return_boundary_measure=True,\n", + " return_tessellation_diagnostics=True,\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" + "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": "eeca6a9ea2a844b5bb58dd625ee733b3", + "id": "aee9d927", "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", + "## 4) Self-consistent active-set refinement\n", "\n", - "This is valuable when you plan to iterate:\n", - "fit → compute tessellation → update constraints/weights.\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": 5, - "id": "2135561dce5c4fd9a3fd8b325c7837f4", + "execution_count": null, + "id": "83eff155", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "rms_residual: 0.0\n", - "inactive_constraints: (1,)\n" - ] - } - ], + "outputs": [], "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", + "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", + ")\n", + "box3 = pv.Box(((-5, 5), (-5, 5), (-5, 5)))\n", "\n", - "fit3 = pv.fit_power_weights_from_plane_fractions(\n", + "result = pv.solve_self_consistent_power_weights(\n", " points3,\n", - " constraints3,\n", + " [(0, 1, 0.5), (1, 2, 0.5), (0, 2, 0.5)],\n", + " measurement='fraction',\n", " domain=box3,\n", - " check_contacts=True,\n", - " r_min=0.0,\n", + " options=pv.ActiveSetOptions(add_after=1, drop_after=2, relax=0.5),\n", + " return_history=True,\n", + " return_boundary_measure=True,\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" + "print('termination:', result.termination)\n", + "print('active mask:', result.active_mask)\n", + "print('constraint status:', result.diagnostics.status)\n", + "print('marginal constraints:', result.marginal_constraints)\n" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.19" + "pygments_lexer": "ipython3" } }, "nbformat": 4, diff --git a/docs/reference/inverse.md b/docs/reference/inverse.md index f201b1b..ea308ee 100644 --- a/docs/reference/inverse.md +++ b/docs/reference/inverse.md @@ -1,4 +1,4 @@ -# Inverse fitting +# Power fitting API -::: pyvoro2.inverse +::: pyvoro2.powerfit ::: diff --git a/mkdocs.yml b/mkdocs.yml index 43cd27b..eb9c572 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,7 +72,7 @@ nav: - Domains: guide/domains.md - Operations: guide/operations.md - Topology and graphs: guide/topology.md - - Inverse fitting: guide/inverse.md + - Power fitting: guide/inverse.md - Visualization: guide/visualization.md - Examples: - notebooks/01_basic_compute.ipynb @@ -88,7 +88,7 @@ nav: - Duplicate check: reference/duplicates.md - Normalization: reference/normalize.md - Face properties: reference/face_properties.md - - Inverse fitting: reference/inverse.md + - Power fitting: reference/inverse.md - Visualization: reference/viz3d.md - Project: - About: project/about.md diff --git a/pyproject.toml b/pyproject.toml index 80cbb59..e90a91f 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++ (3D Voronoi and power/Laguerre tessellations) with periodic, topology, and inverse power-fitting utilities.' readme = 'README.md' requires-python = '>=3.10' license = {file = 'LICENSE'} @@ -24,7 +24,7 @@ dependencies = [ classifiers = [ 'Development Status :: 3 - Alpha', 'Intended Audience :: Science/Research', - 'Topic :: Scientific/Engineering :: Chemistry', + 'Topic :: Scientific/Engineering :: Mathematics', 'Topic :: Scientific/Engineering :: Physics', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', diff --git a/src/pyvoro2/_powerfit_active.py b/src/pyvoro2/_powerfit_active.py index ff2ba35..c516d11 100644 --- a/src/pyvoro2/_powerfit_active.py +++ b/src/pyvoro2/_powerfit_active.py @@ -17,6 +17,7 @@ fit_power_weights, weights_to_radii, ) +from .diagnostics import TessellationDiagnostics from .domains import Box, OrthorhombicCell, PeriodicCell @@ -58,6 +59,11 @@ class ActiveSetIteration: @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 @@ -66,15 +72,20 @@ class PairConstraintDiagnostics: realized: np.ndarray realized_same_shift: np.ndarray realized_other_shift: np.ndarray + 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, ...] @dataclass(frozen=True, slots=True) class SelfConsistentPowerFitResult: + constraints: PairBisectorConstraints fit: PowerWeightFitResult realized: RealizedPairDiagnostics diagnostics: PairConstraintDiagnostics @@ -86,6 +97,9 @@ class SelfConsistentPowerFitResult: ] cycle_length: int | None marginal_constraints: tuple[int, ...] + rms_residual_all: float + max_residual_all: float + tessellation_diagnostics: TessellationDiagnostics | None history: tuple[ActiveSetIteration, ...] | None warnings: tuple[str, ...] @@ -114,6 +128,8 @@ def solve_self_consistent_power_weights( 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', ) -> SelfConsistentPowerFitResult: """Iteratively refine an active pair set against realized power-diagram faces.""" @@ -186,22 +202,16 @@ def solve_self_consistent_power_weights( if fit.weights is None: warnings_list.extend(fit.warnings) termination = 'infeasible_active_set' - final_realized = 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, + 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), @@ -210,13 +220,22 @@ def solve_self_consistent_power_weights( realized=final_realized.realized.copy(), realized_same_shift=final_realized.realized_same_shift.copy(), realized_other_shift=final_realized.realized_other_shift.copy(), + 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('infeasible_active_set' for _ in range(m)), ) return SelfConsistentPowerFitResult( + constraints=resolved, fit=fit, realized=final_realized, diagnostics=diag_all, @@ -226,6 +245,9 @@ def solve_self_consistent_power_weights( 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, warnings=tuple(warnings_list), ) @@ -250,6 +272,8 @@ def solve_self_consistent_power_weights( constraints=resolved, return_boundary_measure=False, return_cells=False, + return_tessellation_diagnostics=False, + tessellation_check='none', ) last_diag = diag realized_same = diag.realized_same_shift @@ -356,6 +380,8 @@ def solve_self_consistent_power_weights( constraints=resolved, return_boundary_measure=return_boundary_measure, return_cells=return_cells, + return_tessellation_diagnostics=return_tessellation_diagnostics, + tessellation_check=tessellation_check, ) pred_fraction, pred_position, pred = _predict_measurements(final_fit.weights, resolved) else: @@ -364,20 +390,9 @@ def solve_self_consistent_power_weights( 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 = 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, + final_realized = _empty_realized_pair_diagnostics( + m, + return_boundary_measure=return_boundary_measure, ) target = ( @@ -386,12 +401,29 @@ def solve_self_consistent_power_weights( 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, @@ -400,14 +432,23 @@ def solve_self_consistent_power_weights( realized=final_realized.realized.copy(), realized_same_shift=final_realized.realized_same_shift.copy(), realized_other_shift=final_realized.realized_other_shift.copy(), + 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, ) return SelfConsistentPowerFitResult( + constraints=resolved, fit=final_fit, realized=final_realized, diagnostics=diag_all, @@ -417,6 +458,9 @@ def solve_self_consistent_power_weights( 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, warnings=tuple(warnings_list), ) @@ -437,3 +481,61 @@ def _align_weights_to_reference( shift = float(np.mean(aligned[idx] - ref[idx])) aligned[idx] -= shift return aligned + + + +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, + ) + + + +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 == '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_realize.py b/src/pyvoro2/_powerfit_realize.py index 44da045..bb8d82f 100644 --- a/src/pyvoro2/_powerfit_realize.py +++ b/src/pyvoro2/_powerfit_realize.py @@ -3,12 +3,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Literal import numpy as np from ._powerfit_constraints import PairBisectorConstraints from .api import compute +from .diagnostics import TessellationDiagnostics from .domains import Box, OrthorhombicCell, PeriodicCell from .face_properties import annotate_face_properties @@ -26,6 +27,7 @@ class RealizedPairDiagnostics: endpoint_j_empty: np.ndarray boundary_measure: np.ndarray | None cells: list[dict[str, Any]] | None + tessellation_diagnostics: TessellationDiagnostics | None @@ -37,8 +39,15 @@ def match_realized_pairs( constraints: PairBisectorConstraints, return_boundary_measure: bool = False, return_cells: bool = False, + return_tessellation_diagnostics: bool = False, + tessellation_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'diagnose', ) -> RealizedPairDiagnostics: - """Determine which resolved pair constraints correspond to realized faces.""" + """Determine which resolved pair constraints correspond to realized faces. + + The matching is purely geometric: each requested ordered pair ``(i, j, shift)`` + is checked against the set of realized faces in the power tessellation, + including explicit periodic image shifts. + """ pts = np.asarray(points, dtype=float) if pts.ndim != 2 or pts.shape[1] != 3: @@ -50,7 +59,7 @@ def match_realized_pairs( isinstance(domain, OrthorhombicCell) and any(domain.periodic) ) - cells = compute( + compute_result = compute( pts, domain=domain, mode='power', @@ -60,7 +69,14 @@ def match_realized_pairs( 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) @@ -148,4 +164,5 @@ def match_realized_pairs( endpoint_j_empty=endpoint_j_empty, boundary_measure=boundary_measure, cells=cells if return_cells else None, + tessellation_diagnostics=tessellation_diagnostics, ) diff --git a/src/pyvoro2/face_properties.py b/src/pyvoro2/face_properties.py index 4f226c8..d21a233 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. """ diff --git a/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py index a289499..bbba69b 100644 --- a/tests/test_powerfit_active_set.py +++ b/tests/test_powerfit_active_set.py @@ -15,6 +15,8 @@ def test_self_consistent_solver_drops_unrealized_pair(): measurement='fraction', domain=domain, return_history=True, + return_boundary_measure=True, + return_tessellation_diagnostics=True, ) assert res.termination == 'self_consistent' @@ -24,6 +26,18 @@ def test_self_consistent_solver_drops_unrealized_pair(): 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(): @@ -43,3 +57,4 @@ def test_self_consistent_solver_can_start_from_empty_active_set(): 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',) diff --git a/tests/test_powerfit_realization.py b/tests/test_powerfit_realization.py index efbe20e..6675b41 100644 --- a/tests/test_powerfit_realization.py +++ b/tests/test_powerfit_realization.py @@ -36,6 +36,7 @@ def test_match_realized_pairs_flags_unrealized_constraints(): assert diag.unrealized == (0,) + def test_match_realized_pairs_reports_boundary_measure_when_requested(): from pyvoro2 import ( Box, @@ -65,3 +66,34 @@ def test_match_realized_pairs_reports_boundary_measure_when_requested(): 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 From 9035368c806a114a3dad19af162ceeb61dc57c5f Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 03:50:22 +0300 Subject: [PATCH 04/24] Adds hard constraint conflict reporting --- src/pyvoro2/__init__.py | 4 + src/pyvoro2/_powerfit_solver.py | 162 +++++++++++++++++++++++++---- src/pyvoro2/powerfit.py | 11 +- tests/test_powerfit_feasibility.py | 49 +++++++++ 4 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 tests/test_powerfit_feasibility.py diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index bcf1935..259114d 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -50,6 +50,8 @@ ReciprocalBoundaryPenalty, L2Regularization, FitModel, + HardConstraintConflictTerm, + HardConstraintConflict, PowerWeightFitResult, RealizedPairDiagnostics, ActiveSetOptions, @@ -99,6 +101,8 @@ 'ReciprocalBoundaryPenalty', 'L2Regularization', 'FitModel', + 'HardConstraintConflictTerm', + 'HardConstraintConflict', 'PowerWeightFitResult', 'RealizedPairDiagnostics', 'ActiveSetOptions', diff --git a/src/pyvoro2/_powerfit_solver.py b/src/pyvoro2/_powerfit_solver.py index 51f078c..3b1f9df 100644 --- a/src/pyvoro2/_powerfit_solver.py +++ b/src/pyvoro2/_powerfit_solver.py @@ -47,9 +47,53 @@ class PowerWeightFitResult: n_iter: int converged: bool infeasible_constraints: tuple[int, ...] | None + conflict: 'HardConstraintConflict | None' warnings: tuple[str, ...] +@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 + + +@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})) + + +@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 @@ -194,7 +238,7 @@ def _fit_power_weights_resolved( z_hi = hard[1] if hard is not None else None if hard is not None: - feasible, infeasible_idx = _check_hard_feasibility( + feasible, conflict = _check_hard_feasibility( n, constraints.i, constraints.j, @@ -202,7 +246,10 @@ def _fit_power_weights_resolved( z_hi, ) if not feasible: + infeasible_idx = None if conflict is None else conflict.constraint_indices 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, @@ -222,10 +269,13 @@ def _fit_power_weights_resolved( n_iter=0, converged=False, infeasible_constraints=infeasible_idx, + conflict=conflict, warnings=tuple(warnings_list), ) + infeasible_idx = None else: infeasible_idx = None + conflict = None if m == 0: weights = np.zeros(n, dtype=np.float64) @@ -253,6 +303,7 @@ def _fit_power_weights_resolved( n_iter=0, converged=True, infeasible_constraints=infeasible_idx, + conflict=conflict, warnings=tuple(warnings_list), ) @@ -362,6 +413,7 @@ def _fit_power_weights_resolved( n_iter=int(n_iter_max), converged=bool(converged_all), infeasible_constraints=infeasible_idx, + conflict=conflict, warnings=tuple(warnings_list), ) @@ -482,35 +534,57 @@ def _check_hard_feasibility( j_idx: np.ndarray, z_lo: np.ndarray, z_hi: np.ndarray, -) -> tuple[bool, tuple[int, ...] | None]: +) -> tuple[bool, HardConstraintConflict | None]: """Check feasibility of difference constraints via Bellman-Ford.""" - edges: list[tuple[int, int, float, int]] = [] + 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((j, i, float(hi), k)) + 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((i, j, float(-lo), k)) + 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_constraint = np.full(n, -1, dtype=np.int64) + pred_edge = np.full(n, -1, dtype=np.int64) last_updated = -1 tol = 1e-12 for it in range(n): updated = False last_updated = -1 - for u, v, w, k in edges: - cand = dist[u] + w - if cand < dist[v] - tol: - dist[v] = cand - pred_node[v] = u - pred_constraint[v] = k + 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 = v + last_updated = edge.target if not updated: return True, None @@ -523,16 +597,60 @@ def _check_hard_feasibility( if y < 0: return False, None - cycle_constraints: list[int] = [] + cycle_edges_rev: list[_DifferenceEdge] = [] cur = y - seen: set[int] = set() - while cur not in seen and cur >= 0: - seen.add(cur) - ck = int(pred_constraint[cur]) - if ck >= 0: - cycle_constraints.append(ck) - cur = int(pred_node[cur]) - return False, tuple(sorted(set(cycle_constraints))) or None + 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 diff --git a/src/pyvoro2/powerfit.py b/src/pyvoro2/powerfit.py index 9b5090d..c138063 100644 --- a/src/pyvoro2/powerfit.py +++ b/src/pyvoro2/powerfit.py @@ -22,7 +22,14 @@ solve_self_consistent_power_weights, ) from ._powerfit_realize import RealizedPairDiagnostics, match_realized_pairs -from ._powerfit_solver import PowerWeightFitResult, fit_power_weights, radii_to_weights, weights_to_radii +from ._powerfit_solver import ( + HardConstraintConflict, + HardConstraintConflictTerm, + PowerWeightFitResult, + fit_power_weights, + radii_to_weights, + weights_to_radii, +) __all__ = [ 'PairBisectorConstraints', @@ -36,6 +43,8 @@ 'ReciprocalBoundaryPenalty', 'L2Regularization', 'FitModel', + 'HardConstraintConflictTerm', + 'HardConstraintConflict', 'PowerWeightFitResult', 'RealizedPairDiagnostics', 'ActiveSetOptions', diff --git a/tests/test_powerfit_feasibility.py b/tests/test_powerfit_feasibility.py new file mode 100644 index 0000000..f85977c --- /dev/null +++ b/tests/test_powerfit_feasibility.py @@ -0,0 +1,49 @@ +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.infeasible_constraints == (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 From 1d2cb2ca5e6b8ce934a1bfbf7ff714ecf8930dca Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 03:51:19 +0300 Subject: [PATCH 05/24] Improves tests --- src/pyvoro2/_powerfit_active.py | 3 + tests/test_powerfit_active_set.py | 131 +++++++++++++++++++++++++++++ tests/test_powerfit_constraints.py | 43 ++++++++++ tests/test_powerfit_realization.py | 28 ++++++ 4 files changed, 205 insertions(+) create mode 100644 tests/test_powerfit_constraints.py diff --git a/src/pyvoro2/_powerfit_active.py b/src/pyvoro2/_powerfit_active.py index c516d11..e669156 100644 --- a/src/pyvoro2/_powerfit_active.py +++ b/src/pyvoro2/_powerfit_active.py @@ -72,6 +72,7 @@ class PairConstraintDiagnostics: realized: np.ndarray realized_same_shift: np.ndarray realized_other_shift: np.ndarray + realized_shifts: tuple[tuple[tuple[int, int, int], ...], ...] endpoint_i_empty: np.ndarray endpoint_j_empty: np.ndarray boundary_measure: np.ndarray | None @@ -220,6 +221,7 @@ def solve_self_consistent_power_weights( 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=( @@ -432,6 +434,7 @@ def solve_self_consistent_power_weights( 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=( diff --git a/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py index bbba69b..db7c048 100644 --- a/tests/test_powerfit_active_set.py +++ b/tests/test_powerfit_active_set.py @@ -58,3 +58,134 @@ def test_self_consistent_solver_can_start_from_empty_active_set(): 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') diff --git a/tests/test_powerfit_constraints.py b/tests/test_powerfit_constraints.py new file mode 100644 index 0000000..395e194 --- /dev/null +++ b/tests/test_powerfit_constraints.py @@ -0,0 +1,43 @@ +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', + ) diff --git a/tests/test_powerfit_realization.py b/tests/test_powerfit_realization.py index 6675b41..8ed1ecd 100644 --- a/tests/test_powerfit_realization.py +++ b/tests/test_powerfit_realization.py @@ -97,3 +97,31 @@ def test_match_realized_pairs_can_return_tessellation_diagnostics(): 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_other_shift_when_same_pair_is_realized_periodically(): + 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] From 1813149cf2c8f77ba175f1b4455dd812a9416c77 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 03:52:25 +0300 Subject: [PATCH 06/24] Cleanups powerfit & removes inverse.py --- README.md | 2 +- docs/guide/{inverse.md => powerfit.md} | 0 docs/index.md | 2 +- .../notebooks/{04_inverse_fit.ipynb => 04_powerfit.ipynb} | 0 docs/reference/{inverse.md => powerfit.md} | 0 mkdocs.yml | 6 +++--- src/pyvoro2/inverse.py | 8 -------- tests/{test_inverse_fit.py => test_powerfit_fit.py} | 0 8 files changed, 5 insertions(+), 13 deletions(-) rename docs/guide/{inverse.md => powerfit.md} (100%) rename docs/notebooks/{04_inverse_fit.ipynb => 04_powerfit.ipynb} (100%) rename docs/reference/{inverse.md => powerfit.md} (100%) delete mode 100644 src/pyvoro2/inverse.py rename tests/{test_inverse_fit.py => test_powerfit_fit.py} (100%) diff --git a/README.md b/README.md index 44bc63d..4e35b00 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ implementation-oriented details. | [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. | -| [Power fitting](https://delonecommons.github.io/pyvoro2/guide/inverse/) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | +| [Power fitting](https://delonecommons.github.io/pyvoro2/guide/powerfit/) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | | [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). | diff --git a/docs/guide/inverse.md b/docs/guide/powerfit.md similarity index 100% rename from docs/guide/inverse.md rename to docs/guide/powerfit.md diff --git a/docs/index.md b/docs/index.md index 84b1323..489ee11 100644 --- a/docs/index.md +++ b/docs/index.md @@ -126,7 +126,7 @@ implementation-oriented details. | [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. | -| [Power fitting](guide/inverse.md) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | +| [Power fitting](guide/powerfit.md) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | | [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). | diff --git a/docs/notebooks/04_inverse_fit.ipynb b/docs/notebooks/04_powerfit.ipynb similarity index 100% rename from docs/notebooks/04_inverse_fit.ipynb rename to docs/notebooks/04_powerfit.ipynb diff --git a/docs/reference/inverse.md b/docs/reference/powerfit.md similarity index 100% rename from docs/reference/inverse.md rename to docs/reference/powerfit.md diff --git a/mkdocs.yml b/mkdocs.yml index eb9c572..03af49f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,13 +72,13 @@ nav: - Domains: guide/domains.md - Operations: guide/operations.md - Topology and graphs: guide/topology.md - - Power fitting: guide/inverse.md + - Power fitting: guide/powerfit.md - Visualization: guide/visualization.md - Examples: - notebooks/01_basic_compute.ipynb - notebooks/02_periodic_graph.ipynb - notebooks/03_locate_and_ghost.ipynb - - notebooks/04_inverse_fit.ipynb + - notebooks/04_powerfit.ipynb - notebooks/05_visualization.ipynb - API reference: - Domains: reference/domains.md @@ -88,7 +88,7 @@ nav: - Duplicate check: reference/duplicates.md - Normalization: reference/normalize.md - Face properties: reference/face_properties.md - - Power fitting: reference/inverse.md + - Power fitting: reference/powerfit.md - Visualization: reference/viz3d.md - Project: - About: project/about.md diff --git a/src/pyvoro2/inverse.py b/src/pyvoro2/inverse.py deleted file mode 100644 index 9576a14..0000000 --- a/src/pyvoro2/inverse.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Legacy module path for the power-weight inverse solver. - -The math API now lives in :mod:`pyvoro2.powerfit`. -""" - -from __future__ import annotations - -from .powerfit import * # noqa: F401,F403 diff --git a/tests/test_inverse_fit.py b/tests/test_powerfit_fit.py similarity index 100% rename from tests/test_inverse_fit.py rename to tests/test_powerfit_fit.py From e72f858211400fda671a0180badd7891536ad488 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 04:23:46 +0300 Subject: [PATCH 07/24] Organizes powerfit into a package --- docs/reference/powerfit/active.md | 4 ++++ docs/reference/powerfit/constraints.md | 4 ++++ docs/reference/{powerfit.md => powerfit/index.md} | 2 +- docs/reference/powerfit/model.md | 4 ++++ docs/reference/powerfit/realize.md | 4 ++++ docs/reference/powerfit/solver.md | 4 ++++ mkdocs.yml | 8 +++++++- src/pyvoro2/{powerfit.py => powerfit/__init__.py} | 10 +++++----- .../{_powerfit_active.py => powerfit/active.py} | 12 ++++++------ .../constraints.py} | 2 +- .../{_powerfit_model.py => powerfit/model.py} | 0 .../{_powerfit_realize.py => powerfit/realize.py} | 10 +++++----- .../{_powerfit_solver.py => powerfit/solver.py} | 6 +++--- tests/test_powerfit_active_set.py | 4 ++-- 14 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 docs/reference/powerfit/active.md create mode 100644 docs/reference/powerfit/constraints.md rename docs/reference/{powerfit.md => powerfit/index.md} (52%) create mode 100644 docs/reference/powerfit/model.md create mode 100644 docs/reference/powerfit/realize.md create mode 100644 docs/reference/powerfit/solver.md rename src/pyvoro2/{powerfit.py => powerfit/__init__.py} (82%) rename src/pyvoro2/{_powerfit_active.py => powerfit/active.py} (98%) rename src/pyvoro2/{_powerfit_constraints.py => powerfit/constraints.py} (99%) rename src/pyvoro2/{_powerfit_model.py => powerfit/model.py} (100%) rename src/pyvoro2/{_powerfit_realize.py => powerfit/realize.py} (96%) rename src/pyvoro2/{_powerfit_solver.py => powerfit/solver.py} (99%) 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.md b/docs/reference/powerfit/index.md similarity index 52% rename from docs/reference/powerfit.md rename to docs/reference/powerfit/index.md index ea308ee..a84f36d 100644 --- a/docs/reference/powerfit.md +++ b/docs/reference/powerfit/index.md @@ -1,4 +1,4 @@ -# Power fitting API +# 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/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/mkdocs.yml b/mkdocs.yml index 03af49f..b605ff4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -88,7 +88,13 @@ nav: - Duplicate check: reference/duplicates.md - Normalization: reference/normalize.md - Face properties: reference/face_properties.md - - Power fitting: reference/powerfit.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 - Visualization: reference/viz3d.md - Project: - About: project/about.md diff --git a/src/pyvoro2/powerfit.py b/src/pyvoro2/powerfit/__init__.py similarity index 82% rename from src/pyvoro2/powerfit.py rename to src/pyvoro2/powerfit/__init__.py index c138063..2a3411f 100644 --- a/src/pyvoro2/powerfit.py +++ b/src/pyvoro2/powerfit/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from ._powerfit_constraints import PairBisectorConstraints, resolve_pair_bisector_constraints -from ._powerfit_model import ( +from .constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from .model import ( ExponentialBoundaryPenalty, FitModel, FixedValue, @@ -14,15 +14,15 @@ SoftIntervalPenalty, SquaredLoss, ) -from ._powerfit_active import ( +from .active import ( ActiveSetIteration, ActiveSetOptions, PairConstraintDiagnostics, SelfConsistentPowerFitResult, solve_self_consistent_power_weights, ) -from ._powerfit_realize import RealizedPairDiagnostics, match_realized_pairs -from ._powerfit_solver import ( +from .realize import RealizedPairDiagnostics, match_realized_pairs +from .solver import ( HardConstraintConflict, HardConstraintConflictTerm, PowerWeightFitResult, diff --git a/src/pyvoro2/_powerfit_active.py b/src/pyvoro2/powerfit/active.py similarity index 98% rename from src/pyvoro2/_powerfit_active.py rename to src/pyvoro2/powerfit/active.py index e669156..2cd9774 100644 --- a/src/pyvoro2/_powerfit_active.py +++ b/src/pyvoro2/powerfit/active.py @@ -7,18 +7,18 @@ import numpy as np -from ._powerfit_constraints import PairBisectorConstraints, resolve_pair_bisector_constraints -from ._powerfit_model import FitModel -from ._powerfit_realize import RealizedPairDiagnostics, match_realized_pairs -from ._powerfit_solver import ( +from .constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from .model import FitModel +from .realize import RealizedPairDiagnostics, match_realized_pairs +from .solver import ( PowerWeightFitResult, _connected_components, _predict_measurements, fit_power_weights, weights_to_radii, ) -from .diagnostics import TessellationDiagnostics -from .domains import Box, OrthorhombicCell, PeriodicCell +from ..diagnostics import TessellationDiagnostics +from ..domains import Box, OrthorhombicCell, PeriodicCell @dataclass(frozen=True, slots=True) diff --git a/src/pyvoro2/_powerfit_constraints.py b/src/pyvoro2/powerfit/constraints.py similarity index 99% rename from src/pyvoro2/_powerfit_constraints.py rename to src/pyvoro2/powerfit/constraints.py index d7cd3be..f4cdb32 100644 --- a/src/pyvoro2/_powerfit_constraints.py +++ b/src/pyvoro2/powerfit/constraints.py @@ -7,7 +7,7 @@ import numpy as np -from .domains import Box, OrthorhombicCell, PeriodicCell +from ..domains import Box, OrthorhombicCell, PeriodicCell ConstraintInput = Sequence[ tuple[int, int, float] | tuple[int, int, float, tuple[int, int, int]] diff --git a/src/pyvoro2/_powerfit_model.py b/src/pyvoro2/powerfit/model.py similarity index 100% rename from src/pyvoro2/_powerfit_model.py rename to src/pyvoro2/powerfit/model.py diff --git a/src/pyvoro2/_powerfit_realize.py b/src/pyvoro2/powerfit/realize.py similarity index 96% rename from src/pyvoro2/_powerfit_realize.py rename to src/pyvoro2/powerfit/realize.py index bb8d82f..a8cd5da 100644 --- a/src/pyvoro2/_powerfit_realize.py +++ b/src/pyvoro2/powerfit/realize.py @@ -7,11 +7,11 @@ import numpy as np -from ._powerfit_constraints import PairBisectorConstraints -from .api import compute -from .diagnostics import TessellationDiagnostics -from .domains import Box, OrthorhombicCell, PeriodicCell -from .face_properties import annotate_face_properties +from .constraints import PairBisectorConstraints +from ..api import compute +from ..diagnostics import TessellationDiagnostics +from ..domains import Box, OrthorhombicCell, PeriodicCell +from ..face_properties import annotate_face_properties @dataclass(frozen=True, slots=True) diff --git a/src/pyvoro2/_powerfit_solver.py b/src/pyvoro2/powerfit/solver.py similarity index 99% rename from src/pyvoro2/_powerfit_solver.py rename to src/pyvoro2/powerfit/solver.py index 3b1f9df..06f573e 100644 --- a/src/pyvoro2/_powerfit_solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -7,8 +7,8 @@ import numpy as np -from ._powerfit_constraints import PairBisectorConstraints, resolve_pair_bisector_constraints -from ._powerfit_model import ( +from .constraints import PairBisectorConstraints, resolve_pair_bisector_constraints +from .model import ( ExponentialBoundaryPenalty, FitModel, FixedValue, @@ -20,7 +20,7 @@ SoftIntervalPenalty, SquaredLoss, ) -from .domains import Box, OrthorhombicCell, PeriodicCell +from ..domains import Box, OrthorhombicCell, PeriodicCell @dataclass(frozen=True, slots=True) diff --git a/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py index db7c048..b618163 100644 --- a/tests/test_powerfit_active_set.py +++ b/tests/test_powerfit_active_set.py @@ -140,9 +140,9 @@ def test_self_consistent_solver_reports_realized_other_shift_for_periodic_pair() def test_self_consistent_solver_detects_active_mask_cycle(monkeypatch): - import pyvoro2._powerfit_active as active_mod + import pyvoro2.powerfit.active as active_mod from pyvoro2 import ActiveSetOptions, Box - from pyvoro2._powerfit_realize import RealizedPairDiagnostics + 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]], From c4c0313078cfc21e08c6630316aa7e8531dbea39 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 04:24:30 +0300 Subject: [PATCH 08/24] Adds record exports --- CHANGELOG.md | 2 + docs/guide/powerfit.md | 22 +++++++++ src/pyvoro2/powerfit/active.py | 43 ++++++++++++++++ src/pyvoro2/powerfit/constraints.py | 35 +++++++++++++ src/pyvoro2/powerfit/realize.py | 32 ++++++++++++ src/pyvoro2/powerfit/solver.py | 76 ++++++++++++++++++++++++++--- tests/test_powerfit_active_set.py | 29 +++++++++++ tests/test_powerfit_constraints.py | 23 +++++++++ tests/test_powerfit_feasibility.py | 30 +++++++++++- tests/test_powerfit_fit.py | 3 +- tests/test_powerfit_realization.py | 19 ++++++++ 11 files changed, 305 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a29b13..bc6ad56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve ### 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. diff --git a/docs/guide/powerfit.md b/docs/guide/powerfit.md index f6e7ef9..82a0a75 100644 --- a/docs/guide/powerfit.md +++ b/docs/guide/powerfit.md @@ -132,9 +132,15 @@ 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. + ## Step 4: check which pairs are actually realized A requested pairwise separator is not automatically a realized face in the full @@ -227,6 +233,22 @@ Status labels are intentionally generic, for example: - `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. + ## Current scope The current implementation is 3D because it builds on the existing Voro++-based diff --git a/src/pyvoro2/powerfit/active.py b/src/pyvoro2/powerfit/active.py index 2cd9774..5fe2875 100644 --- a/src/pyvoro2/powerfit/active.py +++ b/src/pyvoro2/powerfit/active.py @@ -83,6 +83,43 @@ class PairConstraintDiagnostics: 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])): + site_i = int(self.site_i[k]) if ids is None else ids[int(self.site_i[k])].item() + site_j = int(self.site_j[k]) if ids is None else ids[int(self.site_j[k])].item() + rows.append( + { + 'constraint_index': int(k), + 'site_i': site_i, + 'site_j': site_j, + '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': tuple(tuple(int(v) for v in sh) for sh in self.realized_shifts[k]), + 'endpoint_i_empty': bool(self.endpoint_i_empty[k]), + 'endpoint_j_empty': bool(self.endpoint_j_empty[k]), + 'boundary_measure': (None if self.boundary_measure is None or np.isnan(self.boundary_measure[k]) else float(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: @@ -104,6 +141,12 @@ class SelfConsistentPowerFitResult: history: tuple[ActiveSetIteration, ...] | None warnings: tuple[str, ...] + 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 solve_self_consistent_power_weights( diff --git a/src/pyvoro2/powerfit/constraints.py b/src/pyvoro2/powerfit/constraints.py index f4cdb32..e2f7ea4 100644 --- a/src/pyvoro2/powerfit/constraints.py +++ b/src/pyvoro2/powerfit/constraints.py @@ -68,6 +68,41 @@ def __post_init__(self) -> None: def n_constraints(self) -> int: return int(self.i.shape[0]) + 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): + rows.append( + { + 'constraint_index': int(k), + 'site_i': int(left[k]) if left_is_int else left[k].item() if hasattr(left[k], 'item') else left[k], + 'site_j': int(right[k]) if right_is_int else right[k].item() if hasattr(right[k], 'item') else right[k], + '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.""" diff --git a/src/pyvoro2/powerfit/realize.py b/src/pyvoro2/powerfit/realize.py index a8cd5da..6534323 100644 --- a/src/pyvoro2/powerfit/realize.py +++ b/src/pyvoro2/powerfit/realize.py @@ -29,6 +29,38 @@ class RealizedPairDiagnostics: cells: list[dict[str, Any]] | None tessellation_diagnostics: TessellationDiagnostics | None + 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): + rows.append( + { + 'constraint_index': int(k), + 'site_i': int(left[k]) if left_is_int else left[k].item() if hasattr(left[k], 'item') else left[k], + 'site_j': int(right[k]) if right_is_int else right[k].item() if hasattr(right[k], 'item') else right[k], + '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': tuple(tuple(int(v) for v in sh) for sh in self.realized_shifts[k]), + 'endpoint_i_empty': bool(self.endpoint_i_empty[k]), + 'endpoint_j_empty': bool(self.endpoint_j_empty[k]), + 'boundary_measure': (None if self.boundary_measure is None or np.isnan(self.boundary_measure[k]) else float(self.boundary_measure[k])), + } + ) + return tuple(rows) + def match_realized_pairs( diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py index 06f573e..2a9cdb5 100644 --- a/src/pyvoro2/powerfit/solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -46,10 +46,60 @@ class PowerWeightFitResult: solver: str n_iter: int converged: bool - infeasible_constraints: tuple[int, ...] | None conflict: 'HardConstraintConflict | None' warnings: tuple[str, ...] + @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): + rows.append( + { + 'constraint_index': int(k), + 'site_i': int(left[k]) if left_is_int else left[k].item() if hasattr(left[k], 'item') else left[k], + 'site_j': int(right[k]) if right_is_int else right[k].item() if hasattr(right[k], 'item') else right[k], + '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) + @dataclass(frozen=True, slots=True) class HardConstraintConflictTerm: @@ -65,6 +115,19 @@ class HardConstraintConflictTerm: 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: @@ -81,6 +144,11 @@ def constraint_indices(self) -> tuple[int, ...]: 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: @@ -246,7 +314,6 @@ def _fit_power_weights_resolved( z_hi, ) if not feasible: - infeasible_idx = None if conflict is None else conflict.constraint_indices warnings_list.append('hard feasibility check failed before optimization') if conflict is not None: warnings_list.append(conflict.message) @@ -268,13 +335,10 @@ def _fit_power_weights_resolved( solver='none', n_iter=0, converged=False, - infeasible_constraints=infeasible_idx, conflict=conflict, warnings=tuple(warnings_list), ) - infeasible_idx = None else: - infeasible_idx = None conflict = None if m == 0: @@ -302,7 +366,6 @@ def _fit_power_weights_resolved( solver='analytic', n_iter=0, converged=True, - infeasible_constraints=infeasible_idx, conflict=conflict, warnings=tuple(warnings_list), ) @@ -412,7 +475,6 @@ def _fit_power_weights_resolved( solver=solver_eff, n_iter=int(n_iter_max), converged=bool(converged_all), - infeasible_constraints=infeasible_idx, conflict=conflict, warnings=tuple(warnings_list), ) diff --git a/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py index b618163..73da3b7 100644 --- a/tests/test_powerfit_active_set.py +++ b/tests/test_powerfit_active_set.py @@ -189,3 +189,32 @@ def fake_match_realized_pairs(*args, **kwargs): 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, 5.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'} diff --git a/tests/test_powerfit_constraints.py b/tests/test_powerfit_constraints.py index 395e194..24a24e2 100644 --- a/tests/test_powerfit_constraints.py +++ b/tests/test_powerfit_constraints.py @@ -41,3 +41,26 @@ def test_resolve_pair_bisector_constraints_rejects_shifts_on_nonperiodic_axes(): 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' diff --git a/tests/test_powerfit_feasibility.py b/tests/test_powerfit_feasibility.py index f85977c..6776194 100644 --- a/tests/test_powerfit_feasibility.py +++ b/tests/test_powerfit_feasibility.py @@ -20,7 +20,8 @@ def test_infeasible_hard_constraints_return_conflict_witness(): assert res.hard_feasible is False assert res.weights is None assert res.conflict is not None - assert res.infeasible_constraints == (0, 1, 2) + 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} @@ -47,3 +48,30 @@ def test_feasible_fit_has_no_conflict_witness(): 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 index 3d0fda4..83918e3 100644 --- a/tests/test_powerfit_fit.py +++ b/tests/test_powerfit_fit.py @@ -139,7 +139,8 @@ def test_infeasible_hard_constraints_are_reported(): assert res.status == 'infeasible_hard_constraints' assert res.hard_feasible is False assert res.weights is None - assert res.infeasible_constraints is not 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(): diff --git a/tests/test_powerfit_realization.py b/tests/test_powerfit_realization.py index 8ed1ecd..c6f6efb 100644 --- a/tests/test_powerfit_realization.py +++ b/tests/test_powerfit_realization.py @@ -125,3 +125,22 @@ def test_match_realized_pairs_reports_other_shift_when_same_pair_is_realized_per 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] + + +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 From de7c7aa4775cff9210598957c9ac4887e4e15d37 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 08:49:25 +0300 Subject: [PATCH 09/24] Adds powerfit report bundles --- docs/guide/powerfit.md | 22 +++ docs/reference/powerfit/report.md | 3 + mkdocs.yml | 1 + src/pyvoro2/__init__.py | 6 + src/pyvoro2/powerfit/__init__.py | 8 + src/pyvoro2/powerfit/active.py | 83 ++++++++--- src/pyvoro2/powerfit/realize.py | 43 +++++- src/pyvoro2/powerfit/report.py | 234 ++++++++++++++++++++++++++++++ src/pyvoro2/powerfit/solver.py | 103 +++++++------ tests/test_powerfit_reports.py | 90 ++++++++++++ 10 files changed, 516 insertions(+), 77 deletions(-) create mode 100644 docs/reference/powerfit/report.md create mode 100644 src/pyvoro2/powerfit/report.py create mode 100644 tests/test_powerfit_reports.py diff --git a/docs/guide/powerfit.md b/docs/guide/powerfit.md index 82a0a75..7f67cca 100644 --- a/docs/guide/powerfit.md +++ b/docs/guide/powerfit.md @@ -249,6 +249,28 @@ 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. + ## Current scope The current implementation is 3D because it builds on the existing Voro++-based 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/mkdocs.yml b/mkdocs.yml index b605ff4..3422018 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -95,6 +95,7 @@ nav: - Solver: reference/powerfit/solver.md - Realization: reference/powerfit/realize.md - Active set: reference/powerfit/active.md + - Reports: reference/powerfit/report.md - Visualization: reference/viz3d.md - Project: - About: project/about.md diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index 259114d..f05f365 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -54,6 +54,9 @@ HardConstraintConflict, PowerWeightFitResult, RealizedPairDiagnostics, + build_fit_report, + build_realized_report, + build_active_set_report, ActiveSetOptions, ActiveSetIteration, PairConstraintDiagnostics, @@ -105,6 +108,9 @@ 'HardConstraintConflict', 'PowerWeightFitResult', 'RealizedPairDiagnostics', + 'build_fit_report', + 'build_realized_report', + 'build_active_set_report', 'ActiveSetOptions', 'ActiveSetIteration', 'PairConstraintDiagnostics', diff --git a/src/pyvoro2/powerfit/__init__.py b/src/pyvoro2/powerfit/__init__.py index 2a3411f..02eee0e 100644 --- a/src/pyvoro2/powerfit/__init__.py +++ b/src/pyvoro2/powerfit/__init__.py @@ -22,6 +22,11 @@ solve_self_consistent_power_weights, ) from .realize import RealizedPairDiagnostics, match_realized_pairs +from .report import ( + build_active_set_report, + build_fit_report, + build_realized_report, +) from .solver import ( HardConstraintConflict, HardConstraintConflictTerm, @@ -47,6 +52,9 @@ 'HardConstraintConflict', 'PowerWeightFitResult', 'RealizedPairDiagnostics', + 'build_fit_report', + 'build_realized_report', + 'build_active_set_report', 'ActiveSetOptions', 'ActiveSetIteration', 'PairConstraintDiagnostics', diff --git a/src/pyvoro2/powerfit/active.py b/src/pyvoro2/powerfit/active.py index 5fe2875..44c9faa 100644 --- a/src/pyvoro2/powerfit/active.py +++ b/src/pyvoro2/powerfit/active.py @@ -20,6 +20,20 @@ from ..diagnostics import TessellationDiagnostics from ..domains import Box, OrthorhombicCell, PeriodicCell +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]) @dataclass(frozen=True, slots=True) class ActiveSetOptions: @@ -44,7 +58,6 @@ def __post_init__(self) -> None: 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 @@ -56,7 +69,6 @@ class ActiveSetIteration: max_residual_all: float weight_step_norm: float - @dataclass(frozen=True, slots=True) class PairConstraintDiagnostics: site_i: np.ndarray @@ -83,18 +95,22 @@ class PairConstraintDiagnostics: marginal: np.ndarray status: tuple[str, ...] - def to_records(self, *, ids: np.ndarray | None = None) -> tuple[dict[str, object], ...]: + 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])): - site_i = int(self.site_i[k]) if ids is None else ids[int(self.site_i[k])].item() - site_j = int(self.site_j[k]) if ids is None else ids[int(self.site_j[k])].item() + 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, + '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]), @@ -106,10 +122,10 @@ def to_records(self, *, ids: np.ndarray | None = None) -> tuple[dict[str, object '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': tuple(tuple(int(v) for v in sh) for sh in self.realized_shifts[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': (None if self.boundary_measure is None or np.isnan(self.boundary_measure[k]) else float(self.boundary_measure[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]), @@ -120,7 +136,6 @@ def to_records(self, *, ids: np.ndarray | None = None) -> tuple[dict[str, object ) return tuple(rows) - @dataclass(frozen=True, slots=True) class SelfConsistentPowerFitResult: constraints: PairBisectorConstraints @@ -147,7 +162,12 @@ def to_records(self, *, use_ids: bool = False) -> tuple[dict[str, object], ...]: 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, @@ -162,7 +182,7 @@ def solve_self_consistent_power_weights( confidence: list[float] | tuple[float, ...] | np.ndarray | None = None, model: FitModel | None = None, active0: np.ndarray | None = None, - options: ActiveSetOptions = ActiveSetOptions(), + options: ActiveSetOptions | None = None, r_min: float = 0.0, fit_solver: Literal['auto', 'analytic', 'admm'] = 'auto', fit_max_iter: int = 2000, @@ -183,6 +203,8 @@ def solve_self_consistent_power_weights( if model is None: model = FitModel() + if options is None: + options = ActiveSetOptions() if isinstance(constraints, PairBisectorConstraints): resolved = constraints @@ -299,7 +321,11 @@ def solve_self_consistent_power_weights( weights_exact = fit.weights.copy() if prev_weights_eval is not None: - weights_exact = _align_weights_to_reference(weights_exact, prev_weights_eval, comps) + weights_exact = _align_weights_to_reference( + weights_exact, + prev_weights_eval, + comps, + ) weights_eval = ( (1.0 - float(options.relax)) * prev_weights_eval + float(options.relax) * weights_exact @@ -349,7 +375,10 @@ def solve_self_consistent_power_weights( 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) + pred_fraction, pred_position, pred = _predict_measurements( + weights_eval, + resolved, + ) target = ( resolved.target_fraction if resolved.measurement == 'fraction' @@ -387,7 +416,10 @@ def solve_self_consistent_power_weights( 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: + 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 @@ -428,7 +460,10 @@ def solve_self_consistent_power_weights( return_tessellation_diagnostics=return_tessellation_diagnostics, tessellation_check=tessellation_check, ) - pred_fraction, pred_position, pred = _predict_measurements(final_fit.weights, resolved) + 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) @@ -511,8 +546,6 @@ def solve_self_consistent_power_weights( warnings=tuple(warnings_list), ) - - def _align_weights_to_reference( weights: np.ndarray, reference: np.ndarray, comps: list[list[int]] ) -> np.ndarray: @@ -528,8 +561,6 @@ def _align_weights_to_reference( aligned[idx] -= shift return aligned - - def _empty_realized_pair_diagnostics( m: int, *, return_boundary_measure: bool ) -> RealizedPairDiagnostics: @@ -548,8 +579,6 @@ def _empty_realized_pair_diagnostics( tessellation_diagnostics=None, ) - - def _build_constraint_statuses( *, active: np.ndarray, @@ -572,10 +601,18 @@ def _build_constraint_statuses( 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') + 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') + 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') diff --git a/src/pyvoro2/powerfit/realize.py b/src/pyvoro2/powerfit/realize.py index 6534323..74cef86 100644 --- a/src/pyvoro2/powerfit/realize.py +++ b/src/pyvoro2/powerfit/realize.py @@ -13,6 +13,13 @@ from ..domains import Box, OrthorhombicCell, PeriodicCell from ..face_properties import annotate_face_properties +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]) @dataclass(frozen=True, slots=True) class RealizedPairDiagnostics: @@ -38,30 +45,51 @@ def to_records( """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') + 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': int(left[k]) if left_is_int else left[k].item() if hasattr(left[k], 'item') else left[k], - 'site_j': int(right[k]) if right_is_int else right[k].item() if hasattr(right[k], 'item') else right[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': tuple(tuple(int(v) for v in sh) for sh in self.realized_shifts[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': (None if self.boundary_measure is None or np.isnan(self.boundary_measure[k]) else float(self.boundary_measure[k])), + 'boundary_measure': _boundary_value( + self.boundary_measure, + k, + ), } ) return tuple(rows) + def to_report( + self, + constraints: PairBisectorConstraints, + *, + use_ids: bool = False, + ) -> dict[str, object]: + """Return a JSON-friendly report for realized-face matching.""" + + from .report import build_realized_report + return build_realized_report(self, constraints, use_ids=use_ids) def match_realized_pairs( points: np.ndarray, @@ -157,7 +185,10 @@ def match_realized_pairs( endpoint_j_empty[k] = bool(empty_by_id.get(j, False)) forward = shifts_by_pair.get((i, j), set()) - reverse = {(-sx, -sy, -sz) for (sx, sy, sz) in shifts_by_pair.get((j, i), set())} + reverse = { + (-sx, -sy, -sz) + for (sx, sy, sz) 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 diff --git a/src/pyvoro2/powerfit/report.py b/src/pyvoro2/powerfit/report.py new file mode 100644 index 0000000..31c1243 --- /dev/null +++ b/src/pyvoro2/powerfit/report.py @@ -0,0 +1,234 @@ +"""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 + +from typing import Any + +import numpy as np + +from .constraints import PairBisectorConstraints +from .realize import RealizedPairDiagnostics +from .solver import HardConstraintConflict, PowerWeightFitResult +from ..diagnostics import TessellationDiagnostics + + +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 _tessellation_record( + diagnostics: TessellationDiagnostics | 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), + } + ) + return { + 'n_sites_expected': int(diagnostics.n_sites_expected), + 'n_cells_returned': int(diagnostics.n_cells_returned), + '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], + 'face_shift_available': bool(diagnostics.face_shift_available), + 'reciprocity_checked': bool(diagnostics.reciprocity_checked), + '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_volume': bool(diagnostics.ok_volume), + 'ok_reciprocity': bool(diagnostics.ok_reciprocity), + 'ok': bool(diagnostics.ok), + 'issues': issue_rows, + } + + +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), + } + + +def build_realized_report( + diagnostics: RealizedPairDiagnostics, + constraints: PairBisectorConstraints, + *, + use_ids: bool = False, +) -> dict[str, object]: + """Return a JSON-friendly report for realized-face matching.""" + + 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)), + }, + 'records': list(diagnostics.to_records(constraints, use_ids=use_ids)), + 'unrealized': [int(idx) for idx in diagnostics.unrealized], + '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), + } + ) + + 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, + 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, + 'tessellation_diagnostics': _tessellation_record( + result.tessellation_diagnostics + ), + 'warnings': list(result.warnings), + } + + +__all__ = [ + 'build_fit_report', + 'build_realized_report', + 'build_active_set_report', +] diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py index 2a9cdb5..1f335bf 100644 --- a/src/pyvoro2/powerfit/solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -22,6 +22,8 @@ ) 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 PowerWeightFitResult: @@ -84,22 +86,51 @@ def to_records( 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': int(left[k]) if left_is_int else left[k].item() if hasattr(left[k], 'item') else left[k], - 'site_j': int(right[k]) if right_is_int else right[k].item() if hasattr(right[k], 'item') else right[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]), + '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: @@ -128,7 +159,6 @@ def to_record(self, *, ids: np.ndarray | None = None) -> dict[str, object]: 'bound_value': float(self.bound_value), } - @dataclass(frozen=True, slots=True) class HardConstraintConflict: """Compact witness for inconsistent hard separator restrictions.""" @@ -144,12 +174,13 @@ def constraint_indices(self) -> tuple[int, ...]: 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], ...]: + 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 @@ -161,7 +192,6 @@ class _DifferenceEdge: relation: Literal['<=', '>='] bound_value: float - @dataclass(frozen=True, slots=True) class _MeasurementGeometry: alpha: np.ndarray @@ -170,7 +200,6 @@ class _MeasurementGeometry: target_fraction: np.ndarray target_position: np.ndarray - def radii_to_weights(radii: np.ndarray) -> np.ndarray: """Convert radii to power weights (``w = r^2``).""" @@ -181,8 +210,6 @@ def radii_to_weights(radii: np.ndarray) -> np.ndarray: 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]: @@ -203,8 +230,6 @@ def weights_to_radii( 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, ...], @@ -269,8 +294,6 @@ def fit_power_weights( tol_rel=tol_rel, ) - - def _fit_power_weights_resolved( constraints: PairBisectorConstraints, *, @@ -379,13 +402,15 @@ def _fit_power_weights_resolved( 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' + 'analytic solver cannot be used with hard constraints ' + 'or non-quadratic penalties' ) comps = _connected_components(n, constraints.i, constraints.j) if len(comps) > 1 and lam == 0.0: warnings_list.append( - 'constraint graph has multiple connected components; each component is gauge-fixed independently' + 'constraint graph has multiple connected components; ' + 'each component is gauge-fixed independently' ) weights = np.zeros(n, dtype=np.float64) @@ -407,8 +432,14 @@ def _fit_power_weights_resolved( dtype=bool, ) 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) + 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] @@ -479,8 +510,6 @@ def _fit_power_weights_resolved( warnings=tuple(warnings_list), ) - - def _measurement_geometry(constraints: PairBisectorConstraints) -> _MeasurementGeometry: d = constraints.distance d2 = constraints.distance2 @@ -500,8 +529,6 @@ def _measurement_geometry(constraints: PairBisectorConstraints) -> _MeasurementG target_position=constraints.target_position.copy(), ) - - def _predict_measurements( weights: np.ndarray, constraints: PairBisectorConstraints ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -515,8 +542,6 @@ def _predict_measurements( 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) @@ -525,8 +550,6 @@ def _regularization_reference(reg: L2Regularization, n: int) -> np.ndarray: raise ValueError('regularization.reference must have shape (n,)') return w0.astype(np.float64) - - def _hard_constraint_bounds( feasible: HardConstraint | None, alpha: np.ndarray, @@ -548,8 +571,6 @@ def _hard_constraint_bounds( 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 @@ -557,8 +578,6 @@ def _requires_admm(model: FitModel) -> bool: return True return not isinstance(model.mismatch, SquaredLoss) - - def _connected_components( n: int, i_idx: np.ndarray, j_idx: np.ndarray ) -> list[list[int]]: @@ -588,8 +607,6 @@ def _connected_components( comps.append(sorted(comp)) return comps - - def _check_hard_feasibility( n: int, i_idx: np.ndarray, @@ -636,7 +653,7 @@ def _check_hard_feasibility( last_updated = -1 tol = 1e-12 - for it in range(n): + for _ in range(n): updated = False last_updated = -1 for edge_index, edge in enumerate(edges): @@ -714,8 +731,6 @@ def _check_hard_feasibility( ) return False, conflict - - def _solve_component_analytic( I: np.ndarray, J: np.ndarray, @@ -755,8 +770,6 @@ def _solve_component_analytic( w[free] = wf return w - - def _solve_component_admm( I: np.ndarray, J: np.ndarray, @@ -815,7 +828,7 @@ def solve_M(rhs_free: np.ndarray) -> np.ndarray: w = np.zeros(n_c, dtype=np.float64) converged = False - for it in range(1, max_iter + 1): + 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) @@ -864,9 +877,7 @@ def solve_M(rhs_free: np.ndarray) -> np.ndarray: converged = True break - return w, it, converged - - + return w, _it, converged def _prox_edge_objective( v: np.ndarray, @@ -904,8 +915,6 @@ def _prox_edge_objective( z = z_new return z - - def _mismatch_derivatives( y: np.ndarray, target: np.ndarray, @@ -926,8 +935,6 @@ def _mismatch_derivatives( return fp_y, fpp_y raise TypeError(f'unsupported mismatch: {type(mismatch)!r}') - - def _penalty_derivatives( y: np.ndarray, penalty: SoftIntervalPenalty diff --git a/tests/test_powerfit_reports.py b/tests/test_powerfit_reports.py new file mode 100644 index 0000000..a245a8a --- /dev/null +++ b/tests/test_powerfit_reports.py @@ -0,0 +1,90 @@ +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, 5.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, 5.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['tessellation_diagnostics'] is not None + assert report_via_method == report From 1a88aabae1ce24372946d7abd129f9ce5082bcf3 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 09:11:36 +0300 Subject: [PATCH 10/24] Fixes formatting --- src/pyvoro2/api.py | 6 +++--- src/pyvoro2/diagnostics.py | 5 ++++- src/pyvoro2/duplicates.py | 5 ++++- src/pyvoro2/powerfit/active.py | 10 ++++++++++ src/pyvoro2/powerfit/constraints.py | 25 ++++++++++++++++++------- src/pyvoro2/powerfit/realize.py | 4 ++++ src/pyvoro2/powerfit/solver.py | 22 ++++++++++++++++++++++ src/pyvoro2/validation.py | 7 +++++-- src/pyvoro2/viz3d.py | 4 ++-- tests/test_periodic_face_shifts.py | 4 ++-- tests/test_powerfit_active_set.py | 17 ++++++++++------- tests/test_powerfit_constraints.py | 2 -- tests/test_powerfit_feasibility.py | 21 ++++++++++++++++++--- tests/test_powerfit_fit.py | 7 ++++++- tests/test_powerfit_realization.py | 26 +++++++++++++++++++------- 15 files changed, 127 insertions(+), 38 deletions(-) diff --git a/src/pyvoro2/api.py b/src/pyvoro2/api.py index 4714983..90417b3 100644 --- a/src/pyvoro2/api.py +++ b/src/pyvoro2/api.py @@ -26,7 +26,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 @@ -457,7 +457,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 +596,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 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/powerfit/active.py b/src/pyvoro2/powerfit/active.py index 44c9faa..095837a 100644 --- a/src/pyvoro2/powerfit/active.py +++ b/src/pyvoro2/powerfit/active.py @@ -20,6 +20,7 @@ from ..diagnostics import TessellationDiagnostics from ..domains import Box, OrthorhombicCell, PeriodicCell + def _label_value( values: np.ndarray, index: int, @@ -30,11 +31,13 @@ def _label_value( 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]) + @dataclass(frozen=True, slots=True) class ActiveSetOptions: add_after: int = 1 @@ -58,6 +61,7 @@ def __post_init__(self) -> None: 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 @@ -69,6 +73,7 @@ class ActiveSetIteration: max_residual_all: float weight_step_norm: float + @dataclass(frozen=True, slots=True) class PairConstraintDiagnostics: site_i: np.ndarray @@ -136,6 +141,7 @@ def to_records( ) return tuple(rows) + @dataclass(frozen=True, slots=True) class SelfConsistentPowerFitResult: constraints: PairBisectorConstraints @@ -169,6 +175,7 @@ def to_report(self, *, use_ids: bool = False) -> dict[str, object]: 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, ...], @@ -546,6 +553,7 @@ def solve_self_consistent_power_weights( warnings=tuple(warnings_list), ) + def _align_weights_to_reference( weights: np.ndarray, reference: np.ndarray, comps: list[list[int]] ) -> np.ndarray: @@ -561,6 +569,7 @@ def _align_weights_to_reference( aligned[idx] -= shift return aligned + def _empty_realized_pair_diagnostics( m: int, *, return_boundary_measure: bool ) -> RealizedPairDiagnostics: @@ -579,6 +588,7 @@ def _empty_realized_pair_diagnostics( tessellation_diagnostics=None, ) + def _build_constraint_statuses( *, active: np.ndarray, diff --git a/src/pyvoro2/powerfit/constraints.py b/src/pyvoro2/powerfit/constraints.py index e2f7ea4..c480b6b 100644 --- a/src/pyvoro2/powerfit/constraints.py +++ b/src/pyvoro2/powerfit/constraints.py @@ -14,6 +14,10 @@ ] +def _plain_value(value: object) -> object: + return value.item() if hasattr(value, 'item') else value + + @dataclass(frozen=True, slots=True) class PairBisectorConstraints: """Resolved pairwise separator constraints. @@ -73,11 +77,15 @@ def pair_labels(self, *, use_ids: bool = False) -> tuple[np.ndarray, np.ndarray] if use_ids: if self.ids is None: - raise ValueError('use_ids=True requires ids on the resolved constraint set') + 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], ...]: + 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) @@ -85,11 +93,13 @@ def to_records(self, *, use_ids: bool = False) -> tuple[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': int(left[k]) if left_is_int else left[k].item() if hasattr(left[k], 'item') else left[k], - 'site_j': int(right[k]) if right_is_int else right[k].item() if hasattr(right[k], 'item') else right[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]), @@ -226,7 +236,10 @@ def resolve_pair_bisector_constraints( 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)') + raise ValueError( + 'some constraints have zero distance ' + '(coincident points/image)' + ) d = np.sqrt(d2) target_arr = np.asarray(target, dtype=np.float64) @@ -258,7 +271,6 @@ def resolve_pair_bisector_constraints( warnings=warnings, ) - # ---------------------------- internal helpers ---------------------------- @@ -335,7 +347,6 @@ def maybe_remap_points( return _maybe_remap_points(points, domain) - def _maybe_remap_points( points: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None ) -> np.ndarray: diff --git a/src/pyvoro2/powerfit/realize.py b/src/pyvoro2/powerfit/realize.py index 74cef86..55140d7 100644 --- a/src/pyvoro2/powerfit/realize.py +++ b/src/pyvoro2/powerfit/realize.py @@ -13,14 +13,17 @@ from ..domains import Box, OrthorhombicCell, PeriodicCell from ..face_properties import annotate_face_properties + 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]) + @dataclass(frozen=True, slots=True) class RealizedPairDiagnostics: """Diagnostics for matching candidate constraints to realized faces.""" @@ -91,6 +94,7 @@ def to_report( return build_realized_report(self, constraints, use_ids=use_ids) + def match_realized_pairs( points: np.ndarray, *, diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py index 1f335bf..54a67ca 100644 --- a/src/pyvoro2/powerfit/solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -22,9 +22,11 @@ ) 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 PowerWeightFitResult: """Result of inverse fitting of power weights.""" @@ -132,6 +134,7 @@ def to_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. @@ -159,6 +162,7 @@ def to_record(self, *, ids: np.ndarray | None = None) -> dict[str, object]: 'bound_value': float(self.bound_value), } + @dataclass(frozen=True, slots=True) class HardConstraintConflict: """Compact witness for inconsistent hard separator restrictions.""" @@ -181,6 +185,7 @@ def to_records( return tuple(term.to_record(ids=ids) for term in self.terms) + @dataclass(frozen=True, slots=True) class _DifferenceEdge: source: int @@ -192,6 +197,7 @@ class _DifferenceEdge: relation: Literal['<=', '>='] bound_value: float + @dataclass(frozen=True, slots=True) class _MeasurementGeometry: alpha: np.ndarray @@ -200,6 +206,7 @@ class _MeasurementGeometry: target_fraction: np.ndarray target_position: np.ndarray + def radii_to_weights(radii: np.ndarray) -> np.ndarray: """Convert radii to power weights (``w = r^2``).""" @@ -210,6 +217,7 @@ def radii_to_weights(radii: np.ndarray) -> np.ndarray: 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]: @@ -230,6 +238,7 @@ def weights_to_radii( 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, ...], @@ -294,6 +303,7 @@ def fit_power_weights( tol_rel=tol_rel, ) + def _fit_power_weights_resolved( constraints: PairBisectorConstraints, *, @@ -510,6 +520,7 @@ def _fit_power_weights_resolved( warnings=tuple(warnings_list), ) + def _measurement_geometry(constraints: PairBisectorConstraints) -> _MeasurementGeometry: d = constraints.distance d2 = constraints.distance2 @@ -529,6 +540,7 @@ def _measurement_geometry(constraints: PairBisectorConstraints) -> _MeasurementG target_position=constraints.target_position.copy(), ) + def _predict_measurements( weights: np.ndarray, constraints: PairBisectorConstraints ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -542,6 +554,7 @@ def _predict_measurements( 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) @@ -550,6 +563,7 @@ def _regularization_reference(reg: L2Regularization, n: int) -> np.ndarray: raise ValueError('regularization.reference must have shape (n,)') return w0.astype(np.float64) + def _hard_constraint_bounds( feasible: HardConstraint | None, alpha: np.ndarray, @@ -571,6 +585,7 @@ def _hard_constraint_bounds( 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 @@ -578,6 +593,7 @@ def _requires_admm(model: FitModel) -> bool: return True return not isinstance(model.mismatch, SquaredLoss) + def _connected_components( n: int, i_idx: np.ndarray, j_idx: np.ndarray ) -> list[list[int]]: @@ -607,6 +623,7 @@ def _connected_components( comps.append(sorted(comp)) return comps + def _check_hard_feasibility( n: int, i_idx: np.ndarray, @@ -731,6 +748,7 @@ def _check_hard_feasibility( ) return False, conflict + def _solve_component_analytic( I: np.ndarray, J: np.ndarray, @@ -770,6 +788,7 @@ def _solve_component_analytic( w[free] = wf return w + def _solve_component_admm( I: np.ndarray, J: np.ndarray, @@ -879,6 +898,7 @@ def solve_M(rhs_free: np.ndarray) -> np.ndarray: return w, _it, converged + def _prox_edge_objective( v: np.ndarray, alpha: np.ndarray, @@ -915,6 +935,7 @@ def _prox_edge_objective( z = z_new return z + def _mismatch_derivatives( y: np.ndarray, target: np.ndarray, @@ -935,6 +956,7 @@ def _mismatch_derivatives( return fp_y, fpp_y raise TypeError(f'unsupported mismatch: {type(mismatch)!r}') + def _penalty_derivatives( y: np.ndarray, penalty: SoftIntervalPenalty 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/viz3d.py b/src/pyvoro2/viz3d.py index 8c16769..f4b0070 100644 --- a/src/pyvoro2/viz3d.py +++ b/src/pyvoro2/viz3d.py @@ -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_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_powerfit_active_set.py b/tests/test_powerfit_active_set.py index 73da3b7..cfc66ba 100644 --- a/tests/test_powerfit_active_set.py +++ b/tests/test_powerfit_active_set.py @@ -39,7 +39,6 @@ def test_self_consistent_solver_drops_unrealized_pair(): 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 @@ -60,7 +59,6 @@ def test_self_consistent_solver_can_start_from_empty_active_set(): 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 @@ -85,7 +83,6 @@ def test_self_consistent_solver_respects_add_hysteresis_from_empty_start(): 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 @@ -114,9 +111,12 @@ def test_self_consistent_solver_under_relaxation_records_nonzero_weight_step(): 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 + 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) @@ -138,7 +138,6 @@ def test_self_consistent_solver_reports_realized_other_shift_for_periodic_pair() 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 @@ -217,4 +216,8 @@ def test_self_consistent_result_exports_records_with_ids(): 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'} + assert rows[0]['status'] in { + 'stable_active', + 'stable_inactive', + 'active_unrealized', + } diff --git a/tests/test_powerfit_constraints.py b/tests/test_powerfit_constraints.py index 24a24e2..3f5d0f7 100644 --- a/tests/test_powerfit_constraints.py +++ b/tests/test_powerfit_constraints.py @@ -2,7 +2,6 @@ import pytest - def test_resolve_pair_bisector_constraints_preserves_explicit_periodic_shift(): from pyvoro2 import PeriodicCell, resolve_pair_bisector_constraints @@ -24,7 +23,6 @@ def test_resolve_pair_bisector_constraints_preserves_explicit_periodic_shift(): 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 diff --git a/tests/test_powerfit_feasibility.py b/tests/test_powerfit_feasibility.py index 6776194..1deaa54 100644 --- a/tests/test_powerfit_feasibility.py +++ b/tests/test_powerfit_feasibility.py @@ -51,7 +51,12 @@ def test_feasible_fit_has_no_conflict_witness(): def test_conflict_and_fit_records_are_exportable(): - from pyvoro2 import FixedValue, FitModel, fit_power_weights, resolve_pair_bisector_constraints + 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]], @@ -68,9 +73,19 @@ def test_conflict_and_fit_records_are_exportable(): rows = res.conflict.to_records() assert len(rows) >= 3 - assert set(rows[0]) == {'constraint_index', 'site_i', 'site_j', 'relation', 'bound_value'} + assert set(rows[0]) == { + 'constraint_index', + 'site_i', + 'site_j', + 'relation', + 'bound_value', + } - resolved = resolve_pair_bisector_constraints(pts, constraints, measurement='position') + 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' diff --git a/tests/test_powerfit_fit.py b/tests/test_powerfit_fit.py index 83918e3..41976b9 100644 --- a/tests/test_powerfit_fit.py +++ b/tests/test_powerfit_fit.py @@ -72,7 +72,12 @@ def test_soft_interval_penalty_prefers_inside_interval(): def test_exponential_boundary_penalty_pushes_away_from_boundary(): - from pyvoro2 import ExponentialBoundaryPenalty, FitModel, Interval, fit_power_weights + 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) diff --git a/tests/test_powerfit_realization.py b/tests/test_powerfit_realization.py index c6f6efb..075b142 100644 --- a/tests/test_powerfit_realization.py +++ b/tests/test_powerfit_realization.py @@ -29,14 +29,18 @@ def test_match_realized_pairs_flags_unrealized_constraints(): solver='admm', max_iter=5000, ) - diag = match_realized_pairs(pts, domain=domain, radii=fit.radii, constraints=constraints) + 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, @@ -68,7 +72,6 @@ def test_match_realized_pairs_reports_boundary_measure_when_requested(): assert diag.boundary_measure[0] > 0.0 - def test_match_realized_pairs_can_return_tessellation_diagnostics(): from pyvoro2 import ( Box, @@ -99,8 +102,7 @@ def test_match_realized_pairs_can_return_tessellation_diagnostics(): assert diag.tessellation_diagnostics.ok is True - -def test_match_realized_pairs_reports_other_shift_when_same_pair_is_realized_periodically(): +def test_match_realized_pairs_reports_periodic_wrong_shift(): from pyvoro2 import ( PeriodicCell, fit_power_weights, @@ -118,7 +120,12 @@ def test_match_realized_pairs_reports_other_shift_when_same_pair_is_realized_per image='given_only', ) fit = fit_power_weights(pts, constraints) - diag = match_realized_pairs(pts, domain=cell, radii=fit.radii, constraints=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 @@ -133,7 +140,12 @@ def test_realized_pair_diagnostics_export_records(): 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 + pts, + [(11, 22, 0.5)], + ids=[11, 22], + index_mode='id', + measurement='fraction', + domain=box, ) realized = match_realized_pairs( From f8abf5455431f5265a46ce82666a9c5300f097f3 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 09:56:10 +0300 Subject: [PATCH 11/24] Adds powerfit JSON serialization helpers --- docs/guide/powerfit.md | 7 +++++ src/pyvoro2/__init__.py | 4 +++ src/pyvoro2/powerfit/__init__.py | 4 +++ src/pyvoro2/powerfit/report.py | 52 ++++++++++++++++++++++++++++++++ tests/test_powerfit_reports.py | 46 ++++++++++++++++++++++++++++ 5 files changed, 113 insertions(+) diff --git a/docs/guide/powerfit.md b/docs/guide/powerfit.md index 7f67cca..e89e064 100644 --- a/docs/guide/powerfit.md +++ b/docs/guide/powerfit.md @@ -271,6 +271,13 @@ 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 is 3D because it builds on the existing Voro++-based diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index f05f365..5878654 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -57,6 +57,8 @@ build_fit_report, build_realized_report, build_active_set_report, + dumps_report_json, + write_report_json, ActiveSetOptions, ActiveSetIteration, PairConstraintDiagnostics, @@ -111,6 +113,8 @@ 'build_fit_report', 'build_realized_report', 'build_active_set_report', + 'dumps_report_json', + 'write_report_json', 'ActiveSetOptions', 'ActiveSetIteration', 'PairConstraintDiagnostics', diff --git a/src/pyvoro2/powerfit/__init__.py b/src/pyvoro2/powerfit/__init__.py index 02eee0e..a8bd8dc 100644 --- a/src/pyvoro2/powerfit/__init__.py +++ b/src/pyvoro2/powerfit/__init__.py @@ -26,6 +26,8 @@ build_active_set_report, build_fit_report, build_realized_report, + dumps_report_json, + write_report_json, ) from .solver import ( HardConstraintConflict, @@ -55,6 +57,8 @@ 'build_fit_report', 'build_realized_report', 'build_active_set_report', + 'dumps_report_json', + 'write_report_json', 'ActiveSetOptions', 'ActiveSetIteration', 'PairConstraintDiagnostics', diff --git a/src/pyvoro2/powerfit/report.py b/src/pyvoro2/powerfit/report.py index 31c1243..c9059d9 100644 --- a/src/pyvoro2/powerfit/report.py +++ b/src/pyvoro2/powerfit/report.py @@ -7,6 +7,8 @@ from __future__ import annotations +import json +from pathlib import Path from typing import Any import numpy as np @@ -227,8 +229,58 @@ def build_active_set_report( } +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/tests/test_powerfit_reports.py b/tests/test_powerfit_reports.py index a245a8a..da5e08c 100644 --- a/tests/test_powerfit_reports.py +++ b/tests/test_powerfit_reports.py @@ -1,3 +1,4 @@ +import json import numpy as np @@ -88,3 +89,48 @@ def test_active_set_report_collects_nested_diagnostics_and_history(): assert len(report['history']) >= 1 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, 5.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 + From f3fcc896ed1fc0a455aae9b036b63a964348dac3 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 09:58:54 +0300 Subject: [PATCH 12/24] Improves powerfit docs --- CHANGELOG.md | 1 + docs/guide/powerfit.md | 34 +++++++ docs/index.md | 2 +- docs/notebooks/06_powerfit_reports.ipynb | 94 +++++++++++++++++++ .../notebooks/07_powerfit_infeasibility.ipynb | 91 ++++++++++++++++++ docs/project/roadmap.md | 17 ++-- mkdocs.yml | 2 + tests/test_powerfit_reports.py | 2 +- 8 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 docs/notebooks/06_powerfit_reports.ipynb create mode 100644 docs/notebooks/07_powerfit_infeasibility.ipynb diff --git a/CHANGELOG.md b/CHANGELOG.md index bc6ad56..33a7bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - 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 diff --git a/docs/guide/powerfit.md b/docs/guide/powerfit.md index e89e064..9a75061 100644 --- a/docs/guide/powerfit.md +++ b/docs/guide/powerfit.md @@ -284,3 +284,37 @@ The current implementation is 3D because it builds on the existing Voro++-based power tessellation core. The **API vocabulary** is already dimension-safe: constraint fitting is phrased in terms of pairwise separators and boundary measure rather than chemistry-specific or 3D-only semantics. + +### Objective-model scope for 0.5.x + +The 0.5.x series intentionally 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.5.x does **not** try to +freeze an open-ended callback API for arbitrary user-defined objectives. + +## Worked example notebooks + +Two focused notebooks complement the guide: + +- [`06_powerfit_reports.ipynb`](../notebooks/06_powerfit_reports.ipynb) + 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.ipynb`](../notebooks/07_powerfit_infeasibility.ipynb) + shows how contradictory hard restrictions are reported through + `status`, `is_infeasible`, `conflict`, and report bundles. + +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/index.md b/docs/index.md index 489ee11..088bfbf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -128,7 +128,7 @@ implementation-oriented details. | [Topology and graphs](guide/topology.md) | How to build a neighbor graph that respects periodic images, and how normalization helps. | | [Power fitting](guide/powerfit.md) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | | [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. | +| [Examples (notebooks)](notebooks/01_basic_compute.ipynb) | End-to-end examples, including focused power-fitting notebooks for reports and infeasibility witnesses. | | [API reference](reference/api.md) | The full reference (docstrings). | ## Installation diff --git a/docs/notebooks/06_powerfit_reports.ipynb b/docs/notebooks/06_powerfit_reports.ipynb new file mode 100644 index 0000000..1ecc15a --- /dev/null +++ b/docs/notebooks/06_powerfit_reports.ipynb @@ -0,0 +1,94 @@ +{ + "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 r_min=1.0,\n)\n\nfit_rows = fit.to_records(constraints, use_ids=True)\nfit_report = fit.to_report(constraints, use_ids=True)\nfit_report[\"summary\"]\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": "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" + }, + { + "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/docs/notebooks/07_powerfit_infeasibility.ipynb b/docs/notebooks/07_powerfit_infeasibility.ipynb new file mode 100644 index 0000000..05c8d14 --- /dev/null +++ b/docs/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/docs/project/roadmap.md b/docs/project/roadmap.md index d48f00e..733f529 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -9,17 +9,18 @@ This page lists intended future improvements. It is not a guarantee of timelines 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. -### Inverse-fitting iteration helpers +### Powerfit objective-model expansion -The inverse fitter can report constraints that do not become active faces (“inactive constraints”). -A future iteration could provide helper routines to: +The self-consistent active-set solver is now part of the package, so the next +powerfit design question is not iteration support but **objective-model scope**. -1) fit weights -2) compute the diagram -3) keep only active neighbor constraints -4) refit +For the 0.5.x series, the built-in family is intentionally compact: quadratic +and Huber mismatch terms, interval and fixed-value hard feasibility, +outside-interval penalties, near-boundary penalties, and L2 regularization. -This would make it easier to use the inverse workflow as an iterative model-fitting loop. +Additional mismatch or penalty families should be added only after downstream +packages validate a concrete need for them. The goal is to expand from real +workflows rather than to freeze a broad callback surface too early. ## Potential diff --git a/mkdocs.yml b/mkdocs.yml index 3422018..7b5fdf4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -80,6 +80,8 @@ nav: - notebooks/03_locate_and_ghost.ipynb - notebooks/04_powerfit.ipynb - notebooks/05_visualization.ipynb + - notebooks/06_powerfit_reports.ipynb + - notebooks/07_powerfit_infeasibility.ipynb - API reference: - Domains: reference/domains.md - API: reference/api.md diff --git a/tests/test_powerfit_reports.py b/tests/test_powerfit_reports.py index da5e08c..f88bfb5 100644 --- a/tests/test_powerfit_reports.py +++ b/tests/test_powerfit_reports.py @@ -90,6 +90,7 @@ def test_active_set_report_collects_nested_diagnostics_and_history(): 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, @@ -133,4 +134,3 @@ def test_report_json_helpers_roundtrip_plain_report(tmp_path): 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 - From dc3ace030539a562fedcf2633012aea842e5f73c Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Sun, 15 Mar 2026 20:06:16 +0300 Subject: [PATCH 13/24] Fixes power-fit related functionality --- CHANGELOG.md | 21 ++ src/pyvoro2/__about__.py | 2 +- src/pyvoro2/powerfit/constraints.py | 62 ++++- src/pyvoro2/powerfit/solver.py | 31 ++- tests/test_powerfit_validation_regressions.py | 118 +++++++++ tools/install_wheel_overlay.py | 227 ++++++++++++++++++ 6 files changed, 451 insertions(+), 10 deletions(-) create mode 100644 tests/test_powerfit_validation_regressions.py create mode 100644 tools/install_wheel_overlay.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 33a7bc8..d56f185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ 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.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`. + +### 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. + ## [0.5.0] - 2026-03-14 ### Added diff --git a/src/pyvoro2/__about__.py b/src/pyvoro2/__about__.py index 7c44b95..28058ba 100644 --- a/src/pyvoro2/__about__.py +++ b/src/pyvoro2/__about__.py @@ -5,4 +5,4 @@ """ # Keep this as a simple assignment so scikit-build-core can extract it via regex. -__version__ = '0.5.0' +__version__ = '0.5.1' diff --git a/src/pyvoro2/powerfit/constraints.py b/src/pyvoro2/powerfit/constraints.py index c480b6b..adacdd3 100644 --- a/src/pyvoro2/powerfit/constraints.py +++ b/src/pyvoro2/powerfit/constraints.py @@ -18,6 +18,24 @@ 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. @@ -67,6 +85,34 @@ def __post_init__(self) -> None: raise ValueError('PairBisectorConstraints.delta must have shape (m, 3)') 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: @@ -173,17 +219,25 @@ def resolve_pair_bisector_constraints( pts = np.asarray(points, dtype=float) 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') 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, + ids=ids_arr, index_mode=index_mode, allow_empty=allow_empty, ) + 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) @@ -191,6 +245,8 @@ def resolve_pair_bisector_constraints( 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') @@ -208,7 +264,6 @@ def resolve_pair_bisector_constraints( warnings = warnings + warnings2 if m == 0: - ids_arr = None if ids is None else np.asarray(ids) zeros_i = np.zeros(0, dtype=np.int64) zeros_f = np.zeros(0, dtype=np.float64) zeros_s = np.zeros((0, 3), dtype=np.int64) @@ -242,7 +297,6 @@ def resolve_pair_bisector_constraints( ) d = np.sqrt(d2) - target_arr = np.asarray(target, dtype=np.float64) if measurement == 'fraction': target_fraction = target_arr.copy() target_position = target_fraction * d @@ -250,8 +304,6 @@ def resolve_pair_bisector_constraints( target_position = target_arr.copy() target_fraction = target_position / d - ids_arr = None if ids is None else np.asarray(ids) - return PairBisectorConstraints( n_points=int(pts.shape[0]), i=np.asarray(i_idx, dtype=np.int64), diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py index 54a67ca..8a1a789 100644 --- a/src/pyvoro2/powerfit/solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -213,6 +213,8 @@ def radii_to_weights(radii: np.ndarray) -> np.ndarray: 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 @@ -226,6 +228,8 @@ def weights_to_radii( 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') r_min = float(r_min) if r_min < 0: raise ValueError('r_min must be >= 0') @@ -375,9 +379,15 @@ def _fit_power_weights_resolved( conflict = None if m == 0: - weights = np.zeros(n, dtype=np.float64) + if lam > 0.0: + weights = w0.copy() + warnings_list.append( + 'empty constraint set; using the regularization-only solution' + ) + else: + weights = np.zeros(n, dtype=np.float64) + warnings_list.append('empty constraint set; returning zero weights') radii, shift = weights_to_radii(weights, r_min=r_min) - warnings_list.append('empty constraint set; returning zero weights') 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 @@ -416,10 +426,23 @@ def _fit_power_weights_resolved( 'or non-quadratic penalties' ) - comps = _connected_components(n, constraints.i, constraints.j) + if solver_eff == 'analytic' and lam == 0.0: + effective_mask = a > 0.0 + comps = _connected_components( + n, + constraints.i[effective_mask], + constraints.j[effective_mask], + ) + if np.any(~effective_mask): + warnings_list.append( + 'zero-confidence constraints do not affect the quadratic fit ' + 'objective and are ignored for gauge connectivity' + ) + else: + comps = _connected_components(n, constraints.i, constraints.j) if len(comps) > 1 and lam == 0.0: warnings_list.append( - 'constraint graph has multiple connected components; ' + 'effective constraint graph has multiple connected components; ' 'each component is gauge-fixed independently' ) diff --git a/tests/test_powerfit_validation_regressions.py b/tests/test_powerfit_validation_regressions.py new file mode 100644 index 0000000..dcf6b48 --- /dev/null +++ b/tests/test_powerfit_validation_regressions.py @@ -0,0 +1,118 @@ +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])) diff --git a/tools/install_wheel_overlay.py b/tools/install_wheel_overlay.py new file mode 100644 index 0000000..8bd95f9 --- /dev/null +++ b/tools/install_wheel_overlay.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Install a dev overlay that keeps the wheel C++ core and uses repo Python. + +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 ``pyvoro2._core`` extension from the wheel. + +The script performs three steps: + +- copies or symlinks the compiled ``_core`` binary 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 ``pyvoro2._core`` is loadable. + +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 ``_core`` +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 + + +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_core_path() -> Path | None: + for site_dir in _candidate_site_packages(): + pkg_dir = site_dir / PACKAGE_NAME + if not pkg_dir.exists(): + continue + cores = sorted(pkg_dir.glob('_core*.so')) + sorted(pkg_dir.glob('_core*.pyd')) + if cores: + return cores[0] + return None + + + +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_core_from_wheel(wheel_path: Path, target_dir: Path) -> Path: + if not wheel_path.exists(): + raise OverlayError(f'wheel file not found: {wheel_path}') + with zipfile.ZipFile(wheel_path) as zf: + names = [ + name + for name in zf.namelist() + if name.startswith(f'{PACKAGE_NAME}/_core') + and (name.endswith('.so') or name.endswith('.pyd')) + ] + if not names: + raise OverlayError(f'no {PACKAGE_NAME}._core binary found in {wheel_path}') + 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) + return target + + + +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]: + code = textwrap.dedent( + f''' + import pyvoro2 + import pyvoro2.api as api + print(pyvoro2.__file__) + print(api._core.__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) != 2: + raise OverlayError(f'unexpected verification output: {proc.stdout!r}') + py_file, core_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})' + ) + return py_file, core_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 _core from if the wheel is not installed', + ) + parser.add_argument( + '--mode', + choices=('copy', 'symlink'), + default='copy', + help='how to place the _core binary into src/pyvoro2 (default: %(default)s)', + ) + 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: + core_target = _extract_core_from_wheel(args.wheel.resolve(), package_dir) + core_source_note = f'extracted from wheel {args.wheel.resolve()}' + else: + installed_core = _installed_core_path() + if installed_core is None: + 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.' + ) + core_target = package_dir / installed_core.name + _copy_or_symlink(installed_core, core_target, mode=args.mode) + core_source_note = f'{args.mode} from installed wheel core {installed_core}' + + pth_path = _write_pth(repo_src, pth_name=args.pth_name) + py_file, core_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}') + 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) From 31b4d31cd6f38033feefe2dec6474e988e36c668 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Mon, 16 Mar 2026 00:27:12 +0300 Subject: [PATCH 14/24] Refactors power-fit functionality and improves tests --- CHANGELOG.md | 19 ++ DEV_PLAN.md | 210 +++++++++++++ README.md | 2 +- docs/index.md | 2 +- pyproject.toml | 2 +- src/pyvoro2/_domain_geometry.py | 226 ++++++++++++++ src/pyvoro2/_inputs.py | 100 +++++++ src/pyvoro2/api.py | 282 ++++-------------- src/pyvoro2/powerfit/active.py | 27 +- src/pyvoro2/powerfit/constraints.py | 147 +++------ src/pyvoro2/powerfit/solver.py | 203 ++++++++----- tests/test_api_input_validation.py | 103 +++++++ tests/test_powerfit_constraints.py | 20 ++ tests/test_powerfit_validation_regressions.py | 109 +++++++ 14 files changed, 1050 insertions(+), 402 deletions(-) create mode 100644 DEV_PLAN.md create mode 100644 src/pyvoro2/_domain_geometry.py create mode 100644 src/pyvoro2/_inputs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d56f185..20ca8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,18 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - `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 @@ -24,6 +36,13 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve 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 diff --git a/DEV_PLAN.md b/DEV_PLAN.md new file mode 100644 index 0000000..b21e76b --- /dev/null +++ b/DEV_PLAN.md @@ -0,0 +1,210 @@ +# Development plan (0.6.x track) + +This file is an internal working plan for the next refactoring/feature cycle. +It is intentionally more concrete than the public roadmap and may evolve during +implementation. + +## Current backend decision + +We should **not** switch pyvoro2 to `voro-dev` just to obtain 2D support. + +From the currently vendored upstream snapshots: + +- the dedicated legacy 2D code already provides a realistic first 2D surface + for pyvoro2 (standard + power tessellations in planar rectangular domains, + with optional x/y periodicity), while +- `voro-dev` still does **not** appear to add a 2D analogue of pyvoro2's 3D + `PeriodicCell` / triclinic non-orthogonal periodic domain. It reorganizes the + backend (separate `_2d` / `_3d` code, iterators, threading-related changes), + but the non-orthogonal periodic machinery remains 3D-oriented. + +That means a future backend migration remains optional rather than required for +2D feature parity. The public Python API should therefore be designed now so +that a later engine swap is mostly internal. + +## 0.6.x goals + +1. finish the 3D refactoring needed to avoid duplicating geometry and input + logic when 2D lands; +2. add a first-class planar namespace and bindings on top of the existing 2D + backend; +3. keep the inverse-fitting / mathematical API reusable across dimensions; +4. prepare, but do not prematurely promise, a future path for planar oblique + periodic domains. + +## Public 2D API shape + +### Namespace + +Expose 2D from a dedicated namespace: + +```python +import pyvoro2.planar as pv2 +``` + +Do **not** silently overload the current 3D top-level API based on +`points.shape[1] == 2`. + +### Domains + +First release should expose: + +- `pyvoro2.planar.Box` +- `pyvoro2.planar.RectangularCell` + +The first 2D release should **not** expose `pyvoro2.planar.PeriodicCell` yet, +because the current backend scope is honest only for rectangular planar domains +with optional x/y periodicity. + +### Main operations + +Plan for symmetry with the 3D API: + +- `pyvoro2.planar.compute(...)` +- `pyvoro2.planar.locate(...)` +- `pyvoro2.planar.ghost_cells(...)` +- `pyvoro2.planar.analyze_tessellation(...)` +- `pyvoro2.planar.validate_tessellation(...)` +- `pyvoro2.planar.normalize_vertices(...)` +- `pyvoro2.planar.normalize_topology(...)` +- `pyvoro2.planar.validate_normalized_topology(...)` +- `pyvoro2.planar.annotate_edge_properties(...)` + +### Raw 2D cell schema + +Keep raw 2D output natural and dimension-specific: + +- `area` instead of `volume` +- `edges` instead of `faces` +- `adjacent_shift` remains acceptable as the periodic-image key, but it is now + a length-2 tuple + +We should not force a fake dimension-neutral raw schema. Cross-dimensional +consistency belongs one layer above, not in the lowest-level cell dictionary. + +### Visualization + +Add a dedicated `viz2d.py` using `matplotlib` and return `(fig, ax)`. +This should be a small optional dependency surface, separate from `viz3d.py`. + +## Power-fit / inverse-fitting plan + +The inverse-fitting layer is the main candidate for real cross-dimensional reuse. + +### Keep the stable math boundary + +Retain `PairBisectorConstraints` as the stable boundary between: + +- pair-generation / geometric normalization, and +- weight solving / active-set refinement / reporting. + +### Dimension-neutral refactor targets + +Refactor the internals so that: + +- `PairBisectorConstraints.shifts` is shape `(m, d)` +- `PairBisectorConstraints.delta` is shape `(m, d)` +- shift parsing is parameterized by dimension +- shift-to-Cartesian conversion is delegated to a domain adapter +- `boundary_measure` remains generic (`face area` in 3D, `edge length` in 2D) + +The public solver names can stay shared: + +- `resolve_pair_bisector_constraints(...)` +- `fit_power_weights(...)` +- `match_realized_pairs(...)` +- `solve_self_consistent_power_weights(...)` + +## Internal refactoring plan + +### 1. Shared validation helpers + +Keep extracting validation/coercion from the top-level wrappers into internal +helpers so 2D can reuse them without copy/paste. + +### 2. Internal domain-geometry adapters + +Continue building small internal adapters that answer: + +- dimension +- periodic axes +- lattice vectors / shift-to-Cartesian mapping +- remapping into the primary domain +- nearest-image resolution +- default block-grid heuristics + +Current 3D work lives in `_domain_geometry.py`; 2D should get a matching +adapter rather than duplicating geometry logic inside the planar API wrapper. + +### 3. Thin public wrappers + +`src/pyvoro2/api.py` should remain a thin public surface. +More of the work should move into internal modules so that: + +- 3D and 2D wrappers stay parallel, and +- package-wide validation / geometry behavior is easier to test in isolation. + +## Binding plan + +### Separate extension modules + +Build 2D as a separate compiled module: + +- `_core` for 3D (existing) +- `_core2d` for 2D (new) + +Do not try to merge 2D and 3D into one extension. + +### First 2D binding scope + +Bind the following operations first: + +- `compute_box_standard` / `compute_box_power` +- `locate_box_standard` / `locate_box_power` +- `ghost_box_standard` / `ghost_box_power` + +with 2D-specific payloads and reconstruction of edge records from the ordered +polygon vertices and neighbor data returned by the backend. + +## Testing plan + +Mirror the current 3D coverage categories for 2D: + +- standard tessellations +- power tessellations +- bounded boxes +- rectangular periodic domains +- empty-cell behavior in power mode +- locate +- ghost cells +- periodic edge shifts +- topology normalization and validation +- edge-property annotation +- power-fit resolution / solving / realization / active set +- visualization smoke tests +- fuzz/property tests + +## Planar `PeriodicCell` note + +The current C++ situation does not obviously provide a native 2D oblique +periodic container. That should **not** block the first 2D release. + +However, we should keep one exploratory fallback in mind: + +- implement a future planar `PeriodicCell` via a **pseudo-3D run** with + zero `z` coordinates, a carefully controlled out-of-plane setup, and + post-processing that projects the resulting 3D cell data back to 2D. + +This is **not** the preferred first implementation path, and it may turn out to +be too fragile or too expensive for general use. It should be treated as a +research option for later, not as the baseline 0.6.x plan. + +## Remaining 0.6.x preparatory steps before public 2D + +- finish extracting shared 3D API validation / geometry logic; +- complete the first dimension-neutral refactor of the power-fit boundary; +- add `_core2d` build scaffolding and minimal planar Python namespace; +- add `viz2d.py`; +- document the exact first-release scope for 2D rectangular domains; +- decide whether any exploratory pseudo-3D `PeriodicCell` prototype is worth + testing behind an internal or experimental flag. diff --git a/README.md b/README.md index 4e35b00..f4b8c5a 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`. ## Project status -pyvoro2 is currently in **alpha**. +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. diff --git a/docs/index.md b/docs/index.md index 088bfbf..1c7bbfa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -179,7 +179,7 @@ Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`. ## Project status -pyvoro2 is currently in **alpha**. +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. diff --git a/pyproject.toml b/pyproject.toml index e90a91f..5936d5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ 'numpy>=1.23,<3; python_version >= "3.11"', ] classifiers = [ - 'Development Status :: 3 - Alpha', + 'Development Status :: 4 - Beta', 'Intended Audience :: Science/Research', 'Topic :: Scientific/Engineering :: Mathematics', 'Topic :: Scientific/Engineering :: Physics', diff --git a/src/pyvoro2/_domain_geometry.py b/src/pyvoro2/_domain_geometry.py new file mode 100644 index 0000000..06e0595 --- /dev/null +++ b/src/pyvoro2/_domain_geometry.py @@ -0,0 +1,226 @@ +"""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 + +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 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 + + 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 + if isinstance(self.domain, OrthorhombicCell): + return self.domain.remap_cart(pts, return_shifts=False) + 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) + if isinstance(self.domain, OrthorhombicCell): + a, b, c = self.domain.lattice_vectors + else: + a, b, c = (np.asarray(v, dtype=float) for v in self.domain.vectors) + return ( + sh[:, 0:1] * a[None, :] + + sh[:, 1:2] * b[None, :] + + sh[:, 2:3] * c[None, :] + ) + + 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/_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 90417b3..f53c5b9 100644 --- a/src/pyvoro2/api.py +++ b/src/pyvoro2/api.py @@ -10,6 +10,14 @@ 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 .duplicates import duplicate_check as _duplicate_check from .diagnostics import ( TessellationDiagnostics, @@ -259,11 +267,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 +275,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 +289,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 +305,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 +335,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, @@ -659,40 +619,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 +641,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 +660,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 +670,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 +686,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 +805,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 +815,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 +829,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 +864,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 +894,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 +917,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, diff --git a/src/pyvoro2/powerfit/active.py b/src/pyvoro2/powerfit/active.py index 095837a..e59e17c 100644 --- a/src/pyvoro2/powerfit/active.py +++ b/src/pyvoro2/powerfit/active.py @@ -152,7 +152,11 @@ class SelfConsistentPowerFitResult: n_outer_iter: int converged: bool termination: Literal[ - 'self_consistent', 'cycle_detected', 'max_outer_iter', 'infeasible_active_set' + 'self_consistent', + 'cycle_detected', + 'max_outer_iter', + 'infeasible_active_set', + 'numerical_failure', ] cycle_length: int | None marginal_constraints: tuple[int, ...] @@ -253,7 +257,11 @@ def solve_self_consistent_power_weights( comps = _connected_components(resolved.n_points, resolved.i, resolved.j) termination: Literal[ - 'self_consistent', 'cycle_detected', 'max_outer_iter', 'infeasible_active_set' + 'self_consistent', + 'cycle_detected', + 'max_outer_iter', + 'infeasible_active_set', + 'numerical_failure', ] = 'max_outer_iter' cycle_length: int | None = None converged = False @@ -274,7 +282,11 @@ def solve_self_consistent_power_weights( ) if fit.weights is None: warnings_list.extend(fit.warnings) - termination = 'infeasible_active_set' + 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, @@ -306,7 +318,7 @@ def solve_self_consistent_power_weights( first_realized_iter=first_realized_iter.copy(), last_realized_iter=last_realized_iter.copy(), marginal=np.zeros(m, dtype=bool), - status=tuple('infeasible_active_set' for _ in range(m)), + status=tuple(termination for _ in range(m)), ) return SelfConsistentPowerFitResult( constraints=resolved, @@ -456,6 +468,10 @@ def solve_self_consistent_power_weights( ) warnings_list.extend(final_fit.warnings) + if final_fit.status == 'numerical_failure': + termination = 'numerical_failure' + converged = False + if final_fit.weights is not None and final_fit.radii is not None: final_realized = match_realized_pairs( pts, @@ -599,6 +615,9 @@ def _build_constraint_statuses( ) -> 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) ): diff --git a/src/pyvoro2/powerfit/constraints.py b/src/pyvoro2/powerfit/constraints.py index adacdd3..8263dd0 100644 --- a/src/pyvoro2/powerfit/constraints.py +++ b/src/pyvoro2/powerfit/constraints.py @@ -8,6 +8,7 @@ import numpy as np from ..domains import Box, OrthorhombicCell, PeriodicCell +from .._domain_geometry import geometry3d ConstraintInput = Sequence[ tuple[int, int, float] | tuple[int, int, float, tuple[int, int, int]] @@ -66,8 +67,10 @@ 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.shape != (m, 3): - raise ValueError('PairBisectorConstraints.shifts must have shape (m, 3)') + 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', @@ -81,8 +84,12 @@ def __post_init__(self) -> None: arr = getattr(self, name) if arr.shape != (m,): raise ValueError(f'PairBisectorConstraints.{name} must have shape (m,)') - if self.delta.shape != (m, 3): - raise ValueError('PairBisectorConstraints.delta must have shape (m, 3)') + 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 ( @@ -232,6 +239,7 @@ def resolve_pair_bisector_constraints( ids=ids_arr, index_mode=index_mode, allow_empty=allow_empty, + shift_dim=pts.shape[1], ) target_arr = np.asarray(target, dtype=np.float64) @@ -266,7 +274,7 @@ def resolve_pair_bisector_constraints( if m == 0: zeros_i = np.zeros(0, dtype=np.int64) zeros_f = np.zeros(0, dtype=np.float64) - zeros_s = np.zeros((0, 3), dtype=np.int64) + 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]), @@ -278,7 +286,7 @@ def resolve_pair_bisector_constraints( measurement=measurement, distance=zeros_f, distance2=zeros_f, - delta=np.zeros((0, 3), dtype=np.float64), + delta=np.zeros((0, pts.shape[1]), dtype=np.float64), target_fraction=zeros_f, target_position=zeros_f, input_index=zeros_i, @@ -333,6 +341,7 @@ def _parse_constraints( 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. @@ -357,7 +366,7 @@ def _parse_constraints( 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) + shifts = np.zeros((m, shift_dim), dtype=np.int64) shift_given = np.zeros(m, dtype=bool) warnings: list[str] = [] @@ -385,9 +394,11 @@ def _parse_constraints( 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])) + if not (isinstance(sh, tuple) or isinstance(sh, 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) @@ -402,13 +413,7 @@ def maybe_remap_points( def _maybe_remap_points( points: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None ) -> np.ndarray: - 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) + return geometry3d(domain).remap_cart(points) def _resolve_constraint_shifts( @@ -426,6 +431,7 @@ def _resolve_constraint_shifts( m = i_idx.shape[0] warnings: list[str] = [] + geom = geometry3d(domain) shifts = np.asarray(shifts, dtype=np.int64) if shifts.shape != (m, 3): @@ -434,14 +440,8 @@ def _resolve_constraint_shifts( if shift_given.shape != (m,): raise ValueError('shift_given must have shape (m,)') - 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) - - if isinstance(domain, Box): - if np.any(shifts[shift_given] != 0): - raise ValueError('Box domain does not support periodic shifts') + if geom.kind in ('none', 'box'): + geom.validate_shifts(shifts[shift_given]) return np.zeros((m, 3), dtype=np.int64), tuple(warnings) shifts2 = shifts.copy() @@ -450,7 +450,7 @@ def _resolve_constraint_shifts( if image == 'given_only': if np.any(~provided_mask): raise ValueError('some constraints are missing shifts (image="given_only")') - _validate_shifts_against_domain(shifts2, domain) + geom.validate_shifts(shifts2) return shifts2, tuple(warnings) if image != 'nearest': @@ -460,96 +460,27 @@ def _resolve_constraint_shifts( 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') + 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 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' + ) - _validate_shifts_against_domain(shifts2, domain) + geom.validate_shifts(shifts2) 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: - (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 - 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: - 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 = pj - pi - 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) - return cand[best].astype(np.int64) - def shift_to_cart( shifts: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None ) -> 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 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') + return geometry3d(domain).shift_to_cart(shifts) diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py index 8a1a789..7645147 100644 --- a/src/pyvoro2/powerfit/solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -207,6 +207,10 @@ class _MeasurementGeometry: 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``).""" @@ -269,8 +273,10 @@ def fit_power_weights( """ pts = np.asarray(points, dtype=float) - if pts.ndim != 2 or pts.shape[1] != 3: - raise ValueError('points must have shape (n, 3)') + 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() @@ -279,6 +285,10 @@ def fit_power_weights( resolved = constraints if resolved.n_points != pts.shape[0]: raise ValueError('resolved constraints do not match the number of points') + if resolved.delta.shape[1] != pts.shape[1]: + raise ValueError( + 'resolved constraints do not match the point dimension' + ) if resolved.measurement != measurement: measurement = resolved.measurement else: @@ -450,70 +460,103 @@ def _fit_power_weights_resolved( converged_all = True n_iter_max = 0 - for nodes in comps: - if len(nodes) <= 1: - if lam > 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(constraints.i, constraints.j) - ], - dtype=bool, - ) - 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[np.array(nodes, dtype=np.int64)] - 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, + try: + for nodes in comps: + if len(nodes) <= 1: + if lam > 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(constraints.i, constraints.j) + ], + dtype=bool, ) - weights[np.array(nodes, dtype=np.int64)] = w_c - converged_all = converged_all and conv - n_iter_max = max(n_iter_max, iters) - - radii, shift = weights_to_radii(weights, r_min=r_min) - pred_fraction, pred_position, pred = _predict_measurements(weights, constraints) - residuals = pred - geom.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 + 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[np.array(nodes, dtype=np.int64)] + 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[np.array(nodes, dtype=np.int64)] = w_c + converged_all = converged_all and conv + n_iter_max = max(n_iter_max, iters) + + 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) + 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), + ) if converged_all: status: Literal['optimal', 'max_iter', 'numerical_failure'] = 'optimal' @@ -798,7 +841,7 @@ def _solve_component_analytic( rhs += lam * w0 if n_c == 1: - return np.zeros(1, dtype=np.float64) + return w0.astype(np.float64, copy=True) if lam > 0 else np.zeros(1, dtype=np.float64) if lam > 0: return np.linalg.solve(L, rhs).astype(np.float64) @@ -850,16 +893,27 @@ def _solve_component_admm( 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) + 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]) - chol = np.linalg.cholesky(Mf2) + 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. @@ -883,6 +937,8 @@ def solve_M(rhs_free: np.ndarray) -> np.ndarray: 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() @@ -901,6 +957,13 @@ def solve_M(rhs_free: np.ndarray) -> np.ndarray: 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)) @@ -948,7 +1011,11 @@ def _prox_edge_objective( 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) diff --git a/tests/test_api_input_validation.py b/tests/test_api_input_validation.py index f076fce..3b70d25 100644 --- a/tests/test_api_input_validation.py +++ b/tests/test_api_input_validation.py @@ -146,3 +146,106 @@ 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_powerfit_constraints.py b/tests/test_powerfit_constraints.py index 3f5d0f7..02af8bd 100644 --- a/tests/test_powerfit_constraints.py +++ b/tests/test_powerfit_constraints.py @@ -62,3 +62,23 @@ def test_resolved_constraints_export_records_and_ids(): 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) diff --git a/tests/test_powerfit_validation_regressions.py b/tests/test_powerfit_validation_regressions.py index dcf6b48..3aa1bfb 100644 --- a/tests/test_powerfit_validation_regressions.py +++ b/tests/test_powerfit_validation_regressions.py @@ -116,3 +116,112 @@ def test_weight_radius_conversions_reject_nonfinite_values(): 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): + m = constraints.n_constraints + 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])) From 4eaaf4201c699bb5ff2f9b877b90ad7bda8e5b04 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Mon, 16 Mar 2026 01:19:57 +0300 Subject: [PATCH 15/24] Extracts 3D functionality to separate helper modules --- src/pyvoro2/_domain_geometry.py | 45 +- src/pyvoro2/_face_shifts3d.py | 477 ++++++++++++++++++ src/pyvoro2/api.py | 466 +---------------- src/pyvoro2/face_properties.py | 33 +- src/pyvoro2/powerfit/active.py | 26 +- src/pyvoro2/powerfit/constraints.py | 19 +- src/pyvoro2/powerfit/realize.py | 61 ++- src/pyvoro2/powerfit/solver.py | 20 +- tests/test_api_input_validation.py | 2 - tests/test_powerfit_constraints.py | 1 - tests/test_powerfit_validation_regressions.py | 102 +++- tools/install_wheel_overlay.py | 12 +- 12 files changed, 703 insertions(+), 561 deletions(-) create mode 100644 src/pyvoro2/_face_shifts3d.py diff --git a/src/pyvoro2/_domain_geometry.py b/src/pyvoro2/_domain_geometry.py index 06e0595..5f11968 100644 --- a/src/pyvoro2/_domain_geometry.py +++ b/src/pyvoro2/_domain_geometry.py @@ -7,6 +7,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Sequence import numpy as np @@ -26,6 +27,10 @@ class DomainGeometry3D: domain: Domain3D | None + @property + def dim(self) -> int: + return 3 + @property def kind(self) -> str: if self.domain is None: @@ -70,12 +75,29 @@ def internal_params(self) -> tuple[float, float, float, float, float, float] | N 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 - if isinstance(self.domain, OrthorhombicCell): - return self.domain.remap_cart(pts, return_shifts=False) return self.domain.remap_cart(pts, return_shifts=False) def shift_to_cart(self, shifts: np.ndarray) -> np.ndarray: @@ -84,16 +106,21 @@ def shift_to_cart(self, shifts: np.ndarray) -> np.ndarray: 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) - if isinstance(self.domain, OrthorhombicCell): - a, b, c = self.domain.lattice_vectors - else: - a, b, c = (np.asarray(v, dtype=float) for v in self.domain.vectors) + 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: @@ -185,9 +212,10 @@ def geometry3d(domain: Domain3D | None) -> DomainGeometry3D: return DomainGeometry3D(domain) - def _nearest_image_shifts_orthorhombic( - pi: np.ndarray, pj: np.ndarray, cell: OrthorhombicCell + 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) @@ -201,7 +229,6 @@ def _nearest_image_shifts_orthorhombic( return shifts - def _nearest_image_shifts_triclinic( pi: np.ndarray, pj: np.ndarray, 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/api.py b/src/pyvoro2/api.py index f53c5b9..d61c95c 100644 --- a/src/pyvoro2/api.py +++ b/src/pyvoro2/api.py @@ -18,6 +18,7 @@ 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, @@ -974,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/face_properties.py b/src/pyvoro2/face_properties.py index d21a233..330a155 100644 --- a/src/pyvoro2/face_properties.py +++ b/src/pyvoro2/face_properties.py @@ -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/powerfit/active.py b/src/pyvoro2/powerfit/active.py index e59e17c..13c8995 100644 --- a/src/pyvoro2/powerfit/active.py +++ b/src/pyvoro2/powerfit/active.py @@ -20,6 +20,8 @@ from ..diagnostics import TessellationDiagnostics from ..domains import Box, OrthorhombicCell, PeriodicCell +ShiftTuple = tuple[int, ...] + def _label_value( values: np.ndarray, @@ -38,6 +40,15 @@ def _boundary_value(values: np.ndarray | None, index: int) -> float | None: return float(values[index]) +def _require_self_consistent_dim_3(constraints: PairBisectorConstraints) -> None: + if constraints.dim != 3: + raise ValueError( + 'solve_self_consistent_power_weights currently requires 3D ' + 'resolved constraints because realized-face matching still uses ' + 'the 3D tessellation backend' + ) + + @dataclass(frozen=True, slots=True) class ActiveSetOptions: add_after: int = 1 @@ -89,7 +100,7 @@ class PairConstraintDiagnostics: realized: np.ndarray realized_same_shift: np.ndarray realized_other_shift: np.ndarray - realized_shifts: tuple[tuple[tuple[int, int, int], ...], ...] + realized_shifts: tuple[tuple[ShiftTuple, ...], ...] endpoint_i_empty: np.ndarray endpoint_j_empty: np.ndarray boundary_measure: np.ndarray | None @@ -209,8 +220,8 @@ def solve_self_consistent_power_weights( """Iteratively refine an active pair set against realized power-diagram faces.""" pts = np.asarray(points, dtype=float) - if pts.ndim != 2 or pts.shape[1] != 3: - raise ValueError('points must have shape (n, 3)') + if pts.ndim != 2 or pts.shape[1] <= 0: + raise ValueError('points must have shape (n, d) with d >= 1') if model is None: model = FitModel() @@ -221,7 +232,16 @@ def solve_self_consistent_power_weights( 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_dim_3(resolved) else: + if pts.shape[1] != 3: + raise ValueError( + 'solve_self_consistent_power_weights currently requires 3D ' + 'points; lower-dimensional resolved constraints are supported ' + 'only by fit_power_weights for now' + ) resolved = resolve_pair_bisector_constraints( pts, constraints, diff --git a/src/pyvoro2/powerfit/constraints.py b/src/pyvoro2/powerfit/constraints.py index 8263dd0..f987aa8 100644 --- a/src/pyvoro2/powerfit/constraints.py +++ b/src/pyvoro2/powerfit/constraints.py @@ -10,9 +10,8 @@ from ..domains import Box, OrthorhombicCell, PeriodicCell from .._domain_geometry import geometry3d -ConstraintInput = Sequence[ - tuple[int, int, float] | tuple[int, int, float, tuple[int, int, int]] -] +ConstraintRow = tuple[int, int, float] | tuple[int, int, float, Sequence[int]] +ConstraintInput = Sequence[ConstraintRow] def _plain_value(value: object) -> object: @@ -125,6 +124,10 @@ def __post_init__(self) -> None: 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.""" @@ -208,7 +211,9 @@ def resolve_pair_bisector_constraints( """Parse and resolve pairwise separator constraints. Args: - points: Site coordinates with shape ``(n, 3)``. + points: Site coordinates with shape ``(n, 3)``. The low-level + resolved-constraint object stores the working dimension generically, + but raw parsing still targets the current 3D public API. 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. @@ -394,7 +399,10 @@ def _parse_constraints( if len(c) == 4: sh = c[3] - if not (isinstance(sh, tuple) or isinstance(sh, list)) or len(sh) != shift_dim: + if ( + not (isinstance(sh, tuple) or isinstance(sh, list)) + or len(sh) != shift_dim + ): raise ValueError( f'constraint {k} shift must be a length-{shift_dim} tuple' ) @@ -479,7 +487,6 @@ def _resolve_constraint_shifts( return shifts2, tuple(warnings) - def shift_to_cart( shifts: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None ) -> np.ndarray: diff --git a/src/pyvoro2/powerfit/realize.py b/src/pyvoro2/powerfit/realize.py index 55140d7..a1bed04 100644 --- a/src/pyvoro2/powerfit/realize.py +++ b/src/pyvoro2/powerfit/realize.py @@ -8,11 +8,15 @@ import numpy as np from .constraints import PairBisectorConstraints +from .._domain_geometry import geometry3d from ..api import compute from ..diagnostics import TessellationDiagnostics from ..domains import Box, OrthorhombicCell, PeriodicCell from ..face_properties import annotate_face_properties +ShiftTuple = tuple[int, ...] +MeasureKey = tuple[int, int, ShiftTuple] + def _plain_value(value: object) -> object: return value.item() if hasattr(value, 'item') else value @@ -24,6 +28,15 @@ def _boundary_value(values: np.ndarray | None, index: int) -> float | None: return float(values[index]) +def _require_realization_dim_3(constraints: PairBisectorConstraints) -> None: + if constraints.dim != 3: + raise ValueError( + 'match_realized_pairs currently requires 3D resolved constraints; ' + 'lower-dimensional resolved constraints are supported only by ' + 'fit_power_weights for now' + ) + + @dataclass(frozen=True, slots=True) class RealizedPairDiagnostics: """Diagnostics for matching candidate constraints to realized faces.""" @@ -32,7 +45,7 @@ class RealizedPairDiagnostics: unrealized: tuple[int, ...] realized_same_shift: np.ndarray realized_other_shift: np.ndarray - realized_shifts: tuple[tuple[tuple[int, int, int], ...], ...] + realized_shifts: tuple[tuple[ShiftTuple, ...], ...] endpoint_i_empty: np.ndarray endpoint_j_empty: np.ndarray boundary_measure: np.ndarray | None @@ -74,10 +87,7 @@ def to_records( '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, - ), + 'boundary_measure': _boundary_value(self.boundary_measure, k), } ) return tuple(rows) @@ -114,14 +124,15 @@ def match_realized_pairs( """ pts = np.asarray(points, dtype=float) - if pts.ndim != 2 or pts.shape[1] != 3: - raise ValueError('points must have shape (n, 3)') + 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') + _require_realization_dim_3(constraints) - periodic = isinstance(domain, PeriodicCell) or ( - isinstance(domain, OrthorhombicCell) and any(domain.periodic) - ) + periodic = geometry3d(domain).has_any_periodic_axis compute_result = compute( pts, @@ -146,8 +157,8 @@ def match_realized_pairs( annotate_face_properties(cells, domain) empty_by_id: dict[int, bool] = {} - shifts_by_pair: dict[tuple[int, int], set[tuple[int, int, int]]] = {} - measure_by_pair_shift: dict[tuple[int, int, int, int, int], float] = {} + shifts_by_pair: dict[tuple[int, int], set[ShiftTuple]] = {} + measure_by_pair_shift: dict[MeasureKey, float] = {} for cell in cells: ci = int(cell['id']) @@ -158,12 +169,10 @@ def match_realized_pairs( cj = int(face.get('adjacent_cell', -1)) if cj < 0: continue - sh = face.get('adjacent_shift', (0, 0, 0)) - shift = (int(sh[0]), int(sh[1]), int(sh[2])) + shift = tuple(int(v) for v in face.get('adjacent_shift', (0, 0, 0))) shifts_by_pair.setdefault((ci, cj), set()).add(shift) if return_boundary_measure: - measure = float(face.get('area', 0.0)) - measure_by_pair_shift[(ci, cj, shift[0], shift[1], shift[2])] = measure + measure_by_pair_shift[(ci, cj, shift)] = float(face.get('area', 0.0)) m = constraints.n_constraints realized = np.zeros(m, dtype=bool) @@ -171,7 +180,7 @@ def match_realized_pairs( 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[tuple[int, int, int], ...]] = [] + realized_shifts_rows: list[tuple[ShiftTuple, ...]] = [] boundary_measure = ( np.full(m, np.nan, dtype=np.float64) if return_boundary_measure else None ) @@ -180,18 +189,14 @@ def match_realized_pairs( for k in range(m): i = int(constraints.i[k]) j = int(constraints.j[k]) - target_shift = ( - int(constraints.shifts[k, 0]), - int(constraints.shifts[k, 1]), - int(constraints.shifts[k, 2]), - ) + 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 = { - (-sx, -sy, -sz) - for (sx, sy, sz) in shifts_by_pair.get((j, i), set()) + 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) @@ -206,16 +211,16 @@ def match_realized_pairs( if boundary_measure is not None and any_realized: if same: - key_f = (i, j, target_shift[0], target_shift[1], target_shift[2]) - key_r = (j, i, -target_shift[0], -target_shift[1], -target_shift[2]) + key_f = (i, j, target_shift) + key_r = (j, i, tuple(-int(v) for v in target_shift)) 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] else: chosen = realized_set[0] - key_f = (i, j, chosen[0], chosen[1], chosen[2]) - key_r = (j, i, -chosen[0], -chosen[1], -chosen[2]) + 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: diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py index 7645147..586479b 100644 --- a/src/pyvoro2/powerfit/solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -285,7 +285,7 @@ def fit_power_weights( resolved = constraints if resolved.n_points != pts.shape[0]: raise ValueError('resolved constraints do not match the number of points') - if resolved.delta.shape[1] != pts.shape[1]: + if resolved.dim != pts.shape[1]: raise ValueError( 'resolved constraints do not match the point dimension' ) @@ -531,7 +531,9 @@ def _fit_power_weights_resolved( 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') + 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: @@ -841,7 +843,9 @@ def _solve_component_analytic( rhs += lam * w0 if n_c == 1: - return w0.astype(np.float64, copy=True) if lam > 0 else np.zeros(1, dtype=np.float64) + 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) @@ -896,7 +900,11 @@ def _solve_component_admm( 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) + 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: @@ -1012,7 +1020,9 @@ def _prox_edge_objective( 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') + 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') diff --git a/tests/test_api_input_validation.py b/tests/test_api_input_validation.py index 3b70d25..d1307bf 100644 --- a/tests/test_api_input_validation.py +++ b/tests/test_api_input_validation.py @@ -148,7 +148,6 @@ def fake_compute_box_standard( ) - 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) @@ -219,7 +218,6 @@ def test_public_wrappers_reject_invalid_duplicate_check(func, kwargs) -> None: 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) diff --git a/tests/test_powerfit_constraints.py b/tests/test_powerfit_constraints.py index 02af8bd..c7033f2 100644 --- a/tests/test_powerfit_constraints.py +++ b/tests/test_powerfit_constraints.py @@ -64,7 +64,6 @@ def test_resolved_constraints_export_records_and_ids(): 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 diff --git a/tests/test_powerfit_validation_regressions.py b/tests/test_powerfit_validation_regressions.py index 3aa1bfb..1e36dd7 100644 --- a/tests/test_powerfit_validation_regressions.py +++ b/tests/test_powerfit_validation_regressions.py @@ -18,7 +18,6 @@ def test_powerfit_rejects_nonfinite_points_values_and_confidence(): 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 @@ -44,7 +43,6 @@ def test_powerfit_constraint_ids_must_match_points_and_be_unique(): ) - def test_zero_confidence_constraints_do_not_crash_quadratic_fit(): from pyvoro2 import fit_power_weights @@ -62,7 +60,6 @@ def test_zero_confidence_constraints_do_not_crash_quadratic_fit(): 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 @@ -82,7 +79,6 @@ def test_zero_confidence_rows_do_not_join_effective_components(): 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 @@ -108,7 +104,6 @@ def test_empty_resolved_constraints_use_regularization_only_solution(): 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 @@ -118,7 +113,6 @@ def test_weight_radius_conversions_reject_nonfinite_values(): weights_to_radii(np.array([0.0, np.inf])) - def test_fit_power_weights_returns_numerical_failure_on_internal_solver_error( monkeypatch, ): @@ -147,7 +141,6 @@ def boom(*args, **kwargs): 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 @@ -157,7 +150,6 @@ def test_active_set_propagates_numerical_failure(monkeypatch): domain = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) def fake_fit_power_weights(points, constraints, **kwargs): - m = constraints.n_constraints return PowerWeightFitResult( status='numerical_failure', hard_feasible=True, @@ -196,7 +188,6 @@ def fake_fit_power_weights(points, constraints, **kwargs): 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 @@ -225,3 +216,96 @@ def test_fit_power_weights_accepts_pre_resolved_lower_dim_constraints(): 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_rejects_pre_resolved_lower_dim_constraints(): + from pyvoro2 import Box, 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(), + ) + + with pytest.raises(ValueError, match='currently requires 3D resolved constraints'): + match_realized_pairs( + pts, + domain=Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))), + radii=np.array([0.0, 0.0]), + constraints=constraints, + ) + + +def test_active_set_rejects_pre_resolved_lower_dim_constraints(): + from pyvoro2 import ( + Box, + 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(), + ) + + with pytest.raises(ValueError, match='currently requires 3D resolved constraints'): + solve_self_consistent_power_weights( + pts, + constraints, + measurement='fraction', + domain=Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))), + ) diff --git a/tools/install_wheel_overlay.py b/tools/install_wheel_overlay.py index 8bd95f9..349e85f 100644 --- a/tools/install_wheel_overlay.py +++ b/tools/install_wheel_overlay.py @@ -37,7 +37,6 @@ import zipfile from pathlib import Path - PROJECT_ROOT = Path(__file__).resolve().parents[1] PACKAGE_NAME = 'pyvoro2' PACKAGE_SRC = PROJECT_ROOT / 'src' / PACKAGE_NAME @@ -47,7 +46,6 @@ class OverlayError(RuntimeError): """Raised when the dev overlay cannot be installed.""" - def _candidate_site_packages() -> list[Path]: keys = ('purelib', 'platlib') out: list[Path] = [] @@ -61,7 +59,6 @@ def _candidate_site_packages() -> list[Path]: return out - def _installed_core_path() -> Path | None: for site_dir in _candidate_site_packages(): pkg_dir = site_dir / PACKAGE_NAME @@ -73,7 +70,6 @@ def _installed_core_path() -> Path | None: return None - 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(): @@ -86,7 +82,6 @@ def _copy_or_symlink(src: Path, dst: Path, *, mode: str) -> None: raise OverlayError(f'unsupported mode: {mode!r}') - def _extract_core_from_wheel(wheel_path: Path, target_dir: Path) -> Path: if not wheel_path.exists(): raise OverlayError(f'wheel file not found: {wheel_path}') @@ -107,7 +102,6 @@ def _extract_core_from_wheel(wheel_path: Path, target_dir: Path) -> Path: return target - def _write_pth(repo_src: Path, *, pth_name: str) -> Path: site_dirs = _candidate_site_packages() if not site_dirs: @@ -122,10 +116,9 @@ def _write_pth(repo_src: Path, *, pth_name: str) -> Path: return pth_path - def _verify_overlay(repo_src: Path) -> tuple[str, str]: code = textwrap.dedent( - f''' + ''' import pyvoro2 import pyvoro2.api as api print(pyvoro2.__file__) @@ -156,7 +149,6 @@ def _verify_overlay(repo_src: Path) -> tuple[str, str]: return py_file, core_file - def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -214,7 +206,7 @@ def main() -> int: print(f' package: {py_file}') print(f' core: {core_file}') print(f' .pth file: {pth_path}') - print(f' core source:{core_source_note}') + print(f' core source: {core_source_note}') print('To remove the overlay later, delete the .pth file shown above.') return 0 From bceca3f846c0d9854880f4389d5fdd43e7e5dfb1 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Mon, 16 Mar 2026 13:02:45 +0300 Subject: [PATCH 16/24] Adds 2D functionality --- CHANGELOG.md | 13 + CMakeLists.txt | 78 +-- COPYING | 674 +++++++++++++++++++++++++ LICENSE | 186 ++++++- NOTICE.md | 23 +- README.md | 9 +- cpp/bindings2d.cpp | 543 ++++++++++++++++++++ docs/index.md | 7 +- docs/project/license.md | 14 +- pyproject.toml | 6 +- src/pyvoro2/__about__.py | 4 +- src/pyvoro2/__init__.py | 2 + src/pyvoro2/_cell_output.py | 89 ++++ src/pyvoro2/edge_properties.py | 92 ++++ src/pyvoro2/planar/__init__.py | 23 + src/pyvoro2/planar/_domain_geometry.py | 157 ++++++ src/pyvoro2/planar/_edge_shifts2d.py | 277 ++++++++++ src/pyvoro2/planar/api.py | 432 ++++++++++++++++ src/pyvoro2/planar/domains.py | 140 +++++ src/pyvoro2/planar/duplicates.py | 95 ++++ src/pyvoro2/viz2d.py | 55 ++ src/pyvoro2/viz3d.py | 2 +- tests/test_planar_api_dispatch.py | 284 +++++++++++ tests/test_planar_domains.py | 50 ++ tests/test_planar_edge_properties.py | 49 ++ tests/test_planar_edge_shifts2d.py | 97 ++++ tests/test_planar_integration.py | 51 ++ tests/test_planar_lazy_core_import.py | 24 + tools/build_wheels_wsl.sh | 4 +- tools/install_wheel_overlay.py | 121 +++-- 30 files changed, 3481 insertions(+), 120 deletions(-) create mode 100644 COPYING create mode 100644 cpp/bindings2d.cpp create mode 100644 src/pyvoro2/_cell_output.py create mode 100644 src/pyvoro2/edge_properties.py create mode 100644 src/pyvoro2/planar/__init__.py create mode 100644 src/pyvoro2/planar/_domain_geometry.py create mode 100644 src/pyvoro2/planar/_edge_shifts2d.py create mode 100644 src/pyvoro2/planar/api.py create mode 100644 src/pyvoro2/planar/domains.py create mode 100644 src/pyvoro2/planar/duplicates.py create mode 100644 src/pyvoro2/viz2d.py create mode 100644 tests/test_planar_api_dispatch.py create mode 100644 tests/test_planar_domains.py create mode 100644 tests/test_planar_edge_properties.py create mode 100644 tests/test_planar_edge_shifts2d.py create mode 100644 tests/test_planar_integration.py create mode 100644 tests/test_planar_lazy_core_import.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 20ca8ab..2174599 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ 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.0.dev0] - 2026-03-15 + +### 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. + +### Changed + +- `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 now marks the start of the 0.6.0 development line. + ## [0.5.1] - 2026-03-15 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/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 f4b8c5a..bd1535d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ 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 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 @@ -135,7 +137,7 @@ implementation-oriented details. | [Topology and graphs](https://delonecommons.github.io/pyvoro2/guide/topology/) | How to build a neighbor graph that respects periodic images, and how normalization helps. | | [Power fitting](https://delonecommons.github.io/pyvoro2/guide/powerfit/) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | | [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. | +| [Examples (notebooks)](https://delonecommons.github.io/pyvoro2/notebooks/01_basic_compute/) | End-to-end examples, including focused power-fitting notebooks for reports and infeasibility witnesses. | | [API reference](https://delonecommons.github.io/pyvoro2/reference/api/) | The full reference (docstrings). | ## Installation @@ -203,8 +205,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/index.md b/docs/index.md index 1c7bbfa..5468c63 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,6 +16,8 @@ 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 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 @@ -196,5 +198,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/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/pyproject.toml b/pyproject.toml index 5936d5a..b866395 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ 'Intended Audience :: Science/Research', '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', @@ -56,7 +56,11 @@ docs = [ 'pymdown-extensions>=10.0', 'mkdocs-section-index>=0.3.9', ] +viz2d = [ + 'matplotlib', +] viz = [ + 'matplotlib', 'py3Dmol', ] diff --git a/src/pyvoro2/__about__.py b/src/pyvoro2/__about__.py index 28058ba..38a0836 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.5.1' +__version__ = '0.6.0.dev0' diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index 5878654..0b6146f 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations from .__about__ import __version__ +from . import planar from .domains import Box, OrthorhombicCell, PeriodicCell from .api import compute, locate, ghost_cells @@ -125,4 +126,5 @@ 'radii_to_weights', 'weights_to_radii', '__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/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/planar/__init__.py b/src/pyvoro2/planar/__init__.py new file mode 100644 index 0000000..f448d44 --- /dev/null +++ b/src/pyvoro2/planar/__init__.py @@ -0,0 +1,23 @@ +"""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 .domains import Box, RectangularCell +from .duplicates import duplicate_check + +__all__ = [ + 'Box', + 'RectangularCell', + 'compute', + 'locate', + 'ghost_cells', + 'DuplicatePair', + 'DuplicateError', + 'duplicate_check', + 'annotate_edge_properties', + 'plot_tessellation', +] 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..4b08eb5 --- /dev/null +++ b/src/pyvoro2/planar/_edge_shifts2d.py @@ -0,0 +1,277 @@ +"""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, + 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. + """ + + 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] = {} + 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) + + 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) + + if mode == 'power': + if radii is None: + raise ValueError('radii is required for mode="power"') + weights = np.asarray(radii, dtype=np.float64) ** 2 + else: + weights = None + + def _residual_for_trans( + *, + pid: int, + nid: int, + p_i: np.ndarray, + p_j: np.ndarray, + trans_subset: np.ndarray, + verts: np.ndarray, + ) -> np.ndarray: + p_img = p_j.reshape(1, 2) + trans_subset + 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 + wi = float(weights[pid]) + wj = float(weights[nid]) + rhs = 0.5 * ( + (np.sum(p_img * p_img, axis=1) - wj) + - (np.dot(p_i, p_i) - wi) + ) + else: # pragma: no cover + raise ValueError(f'unknown mode: {mode}') + + dist = np.abs(proj - rhs[:, None]) / dn[:, None] + return np.max(dist, axis=1) + + residuals_by_edge: dict[tuple[int, int], float] = {} + + for cell in cells: + pid = int(cell.get('id', -1)) + if pid < 0: + continue + p_i = sites.get(pid) + if p_i is None: + continue + 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): + nid = int(edge.get('adjacent_cell', -999999)) + if nid < 0: + edge['adjacent_shift'] = (0, 0) + residuals_by_edge[(pid, ei)] = 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(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] + + 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) + + idx0 = shift_to_idx.get((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 edge shift candidates for self-neighbor ' + 'edge; increase search' + ) + if idx0 is None: + raise ValueError('internal error: missing (0, 0) shift candidate') + seed_idx = [idx0] + + seen: set[int] = set() + seed_idx = [ii for ii in seed_idx if not (ii in seen or seen.add(ii))] + + resid_seed = _residual_for_trans( + pid=pid, + nid=nid, + p_i=p_i, + p_j=p_j, + trans_subset=trans_arr[seed_idx], + 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): + resid_full = _residual_for_trans( + pid=pid, + nid=nid, + p_i=p_i, + p_j=p_j, + trans_subset=trans_arr, + verts=verts, + ) + 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_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]) + + 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..70c1eab --- /dev/null +++ b/src/pyvoro2/planar/api.py @@ -0,0 +1,432 @@ +"""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 .domains import Box, RectangularCell +from .duplicates import duplicate_check as _duplicate_check + +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 _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, +) -> list[dict[str, Any]]: + """Compute planar Voronoi or power tessellation cells. + + Supported domains: + - :class:`~pyvoro2.planar.domains.Box` + - :class:`~pyvoro2.planar.domains.RectangularCell` + """ + + 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') + + 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') + + 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.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 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') + 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, + 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/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/viz2d.py b/src/pyvoro2/viz2d.py new file mode 100644 index 0000000..d2d5813 --- /dev/null +++ b/src/pyvoro2/viz2d.py @@ -0,0 +1,55 @@ +"""Optional matplotlib-based visualization helpers for planar tessellations.""" + +from __future__ import annotations + +from typing import Iterable + + +def plot_tessellation( + cells: Iterable[dict], + *, + ax=None, + annotate_ids: bool = False, +): + """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. + 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 + + for cell in cells: + vertices = cell.get('vertices') or [] + edges = cell.get('edges') or [] + if not vertices or not edges: + continue + 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]]) + + if annotate_ids: + site = cell.get('site') + if site is not None: + 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 f4b0070..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 diff --git a/tests/test_planar_api_dispatch.py b/tests/test_planar_api_dispatch.py new file mode 100644 index 0000000..ba477f0 --- /dev/null +++ b/tests/test_planar_api_dispatch.py @@ -0,0 +1,284 @@ +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, + ) 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..1c5d1f4 --- /dev/null +++ b/tests/test_planar_edge_shifts2d.py @@ -0,0 +1,97 @@ +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)] diff --git a/tests/test_planar_integration.py b/tests/test_planar_integration.py new file mode 100644 index 0000000..1f9a0f0 --- /dev/null +++ b/tests/test_planar_integration.py @@ -0,0 +1,51 @@ +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 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/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/install_wheel_overlay.py b/tools/install_wheel_overlay.py index 349e85f..ba28983 100644 --- a/tools/install_wheel_overlay.py +++ b/tools/install_wheel_overlay.py @@ -1,29 +1,30 @@ #!/usr/bin/env python3 -"""Install a dev overlay that keeps the wheel C++ core and uses repo Python. +"""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 ``pyvoro2._core`` extension from the wheel. + compiled extension module(s) from the wheel. The script performs three steps: -- copies or symlinks the compiled ``_core`` binary into ``src/pyvoro2/``; +- 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 ``pyvoro2._core`` is loadable. + repository sources and that the copied extension module(s) are importable. -Typical usage: +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 ``_core`` -directly from a wheel file via ``--wheel``. In that mode it still writes the -``.pth`` overlay for the current environment. +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 @@ -40,6 +41,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] PACKAGE_NAME = 'pyvoro2' PACKAGE_SRC = PROJECT_ROOT / 'src' / PACKAGE_NAME +EXTENSION_PREFIXES = ('_core', '_core2d') class OverlayError(RuntimeError): @@ -59,15 +61,19 @@ def _candidate_site_packages() -> list[Path]: return out -def _installed_core_path() -> Path | None: +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 - cores = sorted(pkg_dir.glob('_core*.so')) + sorted(pkg_dir.glob('_core*.pyd')) - if cores: - return cores[0] - return None + 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: @@ -82,24 +88,33 @@ def _copy_or_symlink(src: Path, dst: Path, *, mode: str) -> None: raise OverlayError(f'unsupported mode: {mode!r}') -def _extract_core_from_wheel(wheel_path: Path, target_dir: Path) -> Path: +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: - names = [ - name - for name in zf.namelist() - if name.startswith(f'{PACKAGE_NAME}/_core') - and (name.endswith('.so') or name.endswith('.pyd')) - ] - if not names: - raise OverlayError(f'no {PACKAGE_NAME}._core binary found in {wheel_path}') - 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) - return target + 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: @@ -116,13 +131,15 @@ def _write_pth(repo_src: Path, *, pth_name: str) -> Path: return pth_path -def _verify_overlay(repo_src: Path) -> tuple[str, str]: +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( @@ -132,9 +149,9 @@ def _verify_overlay(repo_src: Path) -> tuple[str, str]: text=True, ) lines = [line.strip() for line in proc.stdout.splitlines() if line.strip()] - if len(lines) != 2: + if len(lines) != 3: raise OverlayError(f'unexpected verification output: {proc.stdout!r}') - py_file, core_file = lines + py_file, core_file, core2d_file = lines repo_prefix = str(repo_src.resolve()) if not py_file.startswith(repo_prefix): raise OverlayError( @@ -146,7 +163,14 @@ def _verify_overlay(repo_src: Path) -> tuple[str, str]: 'overlay verification failed: _core was not imported from the ' f'repository package directory ({core_file})' ) - return py_file, 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: @@ -161,13 +185,13 @@ def main() -> int: '--wheel', type=Path, default=None, - help='optional wheel file to extract _core from if the wheel is not installed', + help='optional wheel file to extract extension modules from', ) parser.add_argument( '--mode', choices=('copy', 'symlink'), default='copy', - help='how to place the _core binary into src/pyvoro2 (default: %(default)s)', + help='how to place extension binaries into src/pyvoro2', ) parser.add_argument( '--pth-name', @@ -183,29 +207,36 @@ def main() -> int: raise OverlayError(f'package directory not found: {package_dir}') if args.wheel is not None: - core_target = _extract_core_from_wheel(args.wheel.resolve(), package_dir) + placed = _extract_extensions_from_wheel(args.wheel.resolve(), package_dir) core_source_note = f'extracted from wheel {args.wheel.resolve()}' else: - installed_core = _installed_core_path() - if installed_core is None: + 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.' ) - core_target = package_dir / installed_core.name - _copy_or_symlink(installed_core, core_target, mode=args.mode) - core_source_note = f'{args.mode} from installed wheel core {installed_core}' + 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 = _verify_overlay(repo_src) + 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}') - print(f' .pth file: {pth_path}') + 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 From 5a95bc873fcd082662f18261ee01f885b6334616 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Mon, 16 Mar 2026 14:07:09 +0300 Subject: [PATCH 17/24] Implements planar diagnostics and normalization --- CHANGELOG.md | 6 + src/pyvoro2/planar/__init__.py | 34 ++ src/pyvoro2/planar/_edge_shifts2d.py | 309 ++++++++++++------ src/pyvoro2/planar/diagnostics.py | 471 +++++++++++++++++++++++++++ src/pyvoro2/planar/normalize.py | 404 +++++++++++++++++++++++ src/pyvoro2/planar/validation.py | 400 +++++++++++++++++++++++ tests/test_planar_diagnostics.py | 92 ++++++ tests/test_planar_normalize.py | 72 ++++ 8 files changed, 1680 insertions(+), 108 deletions(-) create mode 100644 src/pyvoro2/planar/diagnostics.py create mode 100644 src/pyvoro2/planar/normalize.py create mode 100644 src/pyvoro2/planar/validation.py create mode 100644 tests/test_planar_diagnostics.py create mode 100644 tests/test_planar_normalize.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2174599..2d3638d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,18 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - 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(...)`. ### Changed - `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 now marks the start of the 0.6.0 development line. +### 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 diff --git a/src/pyvoro2/planar/__init__.py b/src/pyvoro2/planar/__init__.py index f448d44..14549b1 100644 --- a/src/pyvoro2/planar/__init__.py +++ b/src/pyvoro2/planar/__init__.py @@ -6,8 +6,28 @@ 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 .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', @@ -20,4 +40,18 @@ '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/_edge_shifts2d.py b/src/pyvoro2/planar/_edge_shifts2d.py index 4b08eb5..d2518b8 100644 --- a/src/pyvoro2/planar/_edge_shifts2d.py +++ b/src/pyvoro2/planar/_edge_shifts2d.py @@ -26,6 +26,11 @@ def _add_periodic_edge_shifts_inplace( 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: @@ -64,6 +69,16 @@ def _add_periodic_edge_shifts_inplace( 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]] = [] @@ -76,6 +91,7 @@ def _add_periodic_edge_shifts_inplace( 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: @@ -84,29 +100,25 @@ def _add_periodic_edge_shifts_inplace( else: weights = None - def _residual_for_trans( + def _residual_for_images( *, pid: int, - nid: int, + nid_arr: np.ndarray, p_i: np.ndarray, - p_j: np.ndarray, - trans_subset: np.ndarray, + p_img: np.ndarray, verts: np.ndarray, ) -> np.ndarray: - p_img = p_j.reshape(1, 2) + trans_subset 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) - ) + 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 wi = float(weights[pid]) - wj = float(weights[nid]) + wj = weights[nid_arr] rhs = 0.5 * ( (np.sum(p_img * p_img, axis=1) - wj) - (np.dot(p_i, p_i) - wi) @@ -117,6 +129,169 @@ def _residual_for_trans( dist = np.abs(proj - rhs[:, None]) / dn[:, None] return np.max(dist, axis=1) + def _best_shift_for_neighbor( + *, + pid: int, + nid: int, + p_i: np.ndarray, + 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( + pid=pid, + nid_arr=np.full(len(seed_idx), int(nid), dtype=np.int64), + p_i=p_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( + pid=pid, + nid_arr=np.full(len(shifts), int(nid), dtype=np.int64), + p_i=p_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( + *, + pid: int, + p_i: np.ndarray, + 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( + pid=pid, + nid_arr=nid_arr, + p_i=p_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: @@ -136,16 +311,6 @@ def _residual_for_trans( edges = cell.get('edges') or [] for ei, edge in enumerate(edges): - nid = int(edge.get('adjacent_cell', -999999)) - if nid < 0: - edge['adjacent_shift'] = (0, 0) - residuals_by_edge[(pid, ei)] = 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(edge.get('vertices', []), dtype=np.int64) if idx.shape != (2,): edge['adjacent_shift'] = (0, 0) @@ -153,102 +318,30 @@ def _residual_for_trans( continue verts = vertices[idx] - 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' - ) + nid = int(edge.get('adjacent_cell', -999999)) + if nid < 0: + resolved = _best_unknown_neighbor(pid=pid, p_i=p_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 - 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) - - idx0 = shift_to_idx.get((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 edge shift candidates for self-neighbor ' - 'edge; increase search' - ) - if idx0 is None: - raise ValueError('internal error: missing (0, 0) shift candidate') - seed_idx = [idx0] - - seen: set[int] = set() - seed_idx = [ii for ii in seed_idx if not (ii in seen or seen.add(ii))] - - resid_seed = _residual_for_trans( + 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( pid=pid, nid=nid, p_i=p_i, p_j=p_j, - trans_subset=trans_arr[seed_idx], 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): - resid_full = _residual_for_trans( - pid=pid, - nid=nid, - p_i=p_i, - p_j=p_j, - trans_subset=trans_arr, - verts=verts, - ) - 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_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]) - edge['adjacent_shift'] = shifts[best_idx] residuals_by_edge[(pid, ei)] = best_resid 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/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/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/tests/test_planar_diagnostics.py b/tests/test_planar_diagnostics.py new file mode 100644 index 0000000..da22d4c --- /dev/null +++ b/tests/test_planar_diagnostics.py @@ -0,0 +1,92 @@ +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') 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') From 425a78ecb515553e85920d179e011b9337c772b9 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Mon, 16 Mar 2026 15:06:19 +0300 Subject: [PATCH 18/24] Wires planar powerfit realization and active set --- CHANGELOG.md | 3 + src/pyvoro2/powerfit/active.py | 35 ++- src/pyvoro2/powerfit/constraints.py | 110 ++++--- src/pyvoro2/powerfit/realize.py | 288 +++++++++++++----- src/pyvoro2/powerfit/report.py | 93 ++++-- tests/test_powerfit_active_set.py | 54 ++++ tests/test_powerfit_constraints.py | 39 +++ tests/test_powerfit_realization.py | 67 ++++ tests/test_powerfit_reports.py | 39 +++ tests/test_powerfit_validation_regressions.py | 41 +-- 10 files changed, 599 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3638d..4742031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,14 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - 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(...)`. +- `pyvoro2.powerfit` realized-boundary matching and self-consistent active-set refinement now support planar 2D domains in addition to the original 3D path. ### Changed - `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 now marks the start of the 0.6.0 development line. +- `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 diff --git a/src/pyvoro2/powerfit/active.py b/src/pyvoro2/powerfit/active.py index 13c8995..569031a 100644 --- a/src/pyvoro2/powerfit/active.py +++ b/src/pyvoro2/powerfit/active.py @@ -17,8 +17,10 @@ fit_power_weights, weights_to_radii, ) -from ..diagnostics import TessellationDiagnostics -from ..domains import Box, OrthorhombicCell, PeriodicCell +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, ...] @@ -40,12 +42,13 @@ def _boundary_value(values: np.ndarray | None, index: int) -> float | None: return float(values[index]) -def _require_self_consistent_dim_3(constraints: PairBisectorConstraints) -> None: - if constraints.dim != 3: +def _require_self_consistent_supported_dim( + constraints: PairBisectorConstraints, +) -> None: + if constraints.dim not in (2, 3): raise ValueError( - 'solve_self_consistent_power_weights currently requires 3D ' - 'resolved constraints because realized-face matching still uses ' - 'the 3D tessellation backend' + 'solve_self_consistent_power_weights currently supports only 2D ' + 'and 3D resolved constraints' ) @@ -173,7 +176,9 @@ class SelfConsistentPowerFitResult: marginal_constraints: tuple[int, ...] rms_residual_all: float max_residual_all: float - tessellation_diagnostics: TessellationDiagnostics | None + tessellation_diagnostics: ( + TessellationDiagnostics2D | TessellationDiagnostics3D | None + ) history: tuple[ActiveSetIteration, ...] | None warnings: tuple[str, ...] @@ -196,7 +201,7 @@ def solve_self_consistent_power_weights( constraints: PairBisectorConstraints | list[tuple] | tuple[tuple, ...], *, measurement: Literal['fraction', 'position'] = 'fraction', - domain: Box | OrthorhombicCell | PeriodicCell, + 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', @@ -217,7 +222,8 @@ def solve_self_consistent_power_weights( return_tessellation_diagnostics: bool = False, tessellation_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'diagnose', ) -> SelfConsistentPowerFitResult: - """Iteratively refine an active pair set against realized power-diagram faces.""" + """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: @@ -234,13 +240,12 @@ def solve_self_consistent_power_weights( 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_dim_3(resolved) + _require_self_consistent_supported_dim(resolved) else: - if pts.shape[1] != 3: + if pts.shape[1] not in (2, 3): raise ValueError( - 'solve_self_consistent_power_weights currently requires 3D ' - 'points; lower-dimensional resolved constraints are supported ' - 'only by fit_power_weights for now' + 'solve_self_consistent_power_weights currently supports only ' + '2D and 3D points' ) resolved = resolve_pair_bisector_constraints( pts, diff --git a/src/pyvoro2/powerfit/constraints.py b/src/pyvoro2/powerfit/constraints.py index f987aa8..ddad5f9 100644 --- a/src/pyvoro2/powerfit/constraints.py +++ b/src/pyvoro2/powerfit/constraints.py @@ -7,11 +7,16 @@ import numpy as np -from ..domains import Box, OrthorhombicCell, PeriodicCell 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: @@ -67,9 +72,7 @@ def __post_init__(self) -> None: 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)' - ) + raise ValueError('PairBisectorConstraints.shifts must have shape (m, d)') for name in ( 'target', 'confidence', @@ -139,9 +142,7 @@ def pair_labels(self, *, use_ids: bool = False) -> tuple[np.ndarray, np.ndarray] 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], ...]: + 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) @@ -200,7 +201,7 @@ def resolve_pair_bisector_constraints( constraints: ConstraintInput, *, measurement: Literal['fraction', 'position'] = 'fraction', - domain: Box | OrthorhombicCell | PeriodicCell | None = None, + domain: DomainAny | None = None, ids: Sequence[int] | None = None, index_mode: Literal['index', 'id'] = 'index', image: Literal['nearest', 'given_only'] = 'nearest', @@ -211,9 +212,8 @@ def resolve_pair_bisector_constraints( """Parse and resolve pairwise separator constraints. Args: - points: Site coordinates with shape ``(n, 3)``. The low-level - resolved-constraint object stores the working dimension generically, - but raw parsing still targets the current 3D public API. + 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. @@ -223,14 +223,14 @@ def resolve_pair_bisector_constraints( 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 cells. + 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] != 3: - raise ValueError('points must have shape (n, 3)') + 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'): @@ -305,8 +305,7 @@ def resolve_pair_bisector_constraints( d2 = np.einsum('mi,mi->m', delta, delta) if np.any(d2 <= 0.0): raise ValueError( - 'some constraints have zero distance ' - '(coincident points/image)' + 'some constraints have zero distance (coincident points/image)' ) d = np.sqrt(d2) @@ -336,6 +335,7 @@ def resolve_pair_bisector_constraints( warnings=warnings, ) + # ---------------------------- internal helpers ---------------------------- @@ -376,7 +376,7 @@ def _parse_constraints( warnings: list[str] = [] for k, c in enumerate(constraints): - if not isinstance(c, tuple) and not isinstance(c, list): + 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( @@ -400,7 +400,7 @@ def _parse_constraints( if len(c) == 4: sh = c[3] if ( - not (isinstance(sh, tuple) or isinstance(sh, list)) + not isinstance(sh, (tuple, list)) or len(sh) != shift_dim ): raise ValueError( @@ -412,16 +412,35 @@ def _parse_constraints( 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: +def maybe_remap_points(points: np.ndarray, domain: DomainAny | None) -> np.ndarray: return _maybe_remap_points(points, domain) -def _maybe_remap_points( - points: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None -) -> np.ndarray: - return geometry3d(domain).remap_cart(points) +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( @@ -431,7 +450,7 @@ def _resolve_constraint_shifts( shifts: np.ndarray, shift_given: np.ndarray, *, - domain: Box | OrthorhombicCell | PeriodicCell | None, + domain: DomainAny | None, image: Literal['nearest', 'given_only'], image_search: int, ) -> tuple[np.ndarray, tuple[str, ...]]: @@ -439,18 +458,19 @@ def _resolve_constraint_shifts( m = i_idx.shape[0] warnings: list[str] = [] - geom = geometry3d(domain) + dim = int(points.shape[1]) + geom = _geometry_for_dim(dim, domain) shifts = np.asarray(shifts, dtype=np.int64) - if shifts.shape != (m, 3): - raise ValueError('shifts must have shape (m,3)') + 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 geom.kind in ('none', 'box'): + if not geom.has_any_periodic_axis: geom.validate_shifts(shifts[shift_given]) - return np.zeros((m, 3), dtype=np.int64), tuple(warnings) + return np.zeros((m, dim), dtype=np.int64), tuple(warnings) shifts2 = shifts.copy() provided_mask = shift_given.copy() @@ -468,16 +488,23 @@ def _resolve_constraint_shifts( missing = ~provided_mask if np.any(missing): - resolved, boundary_hits = geom.nearest_image_shifts( - points[i_idx[missing]], - points[j_idx[missing]], - search=image_search, - ) + 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 geom.is_triclinic and np.any(boundary_hits): + 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' @@ -487,7 +514,8 @@ def _resolve_constraint_shifts( return shifts2, tuple(warnings) -def shift_to_cart( - shifts: np.ndarray, domain: Box | OrthorhombicCell | PeriodicCell | None -) -> np.ndarray: - return geometry3d(domain).shift_to_cart(shifts) +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/realize.py b/src/pyvoro2/powerfit/realize.py index a1bed04..30f1623 100644 --- a/src/pyvoro2/powerfit/realize.py +++ b/src/pyvoro2/powerfit/realize.py @@ -1,21 +1,36 @@ -"""Realized-face matching for resolved pairwise separator constraints.""" +"""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 -from ..diagnostics import TessellationDiagnostics -from ..domains import Box, OrthorhombicCell, PeriodicCell +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] +Domain3D = Box3D | OrthorhombicCell | PeriodicCell +Domain2D = Box2D | RectangularCell +DomainAny = Domain2D | Domain3D +TessellationDiagnosticsAny = TessellationDiagnostics2D | TessellationDiagnostics3D def _plain_value(value: object) -> object: @@ -28,18 +43,17 @@ def _boundary_value(values: np.ndarray | None, index: int) -> float | None: return float(values[index]) -def _require_realization_dim_3(constraints: PairBisectorConstraints) -> None: - if constraints.dim != 3: +def _supported_realization_dim(constraints: PairBisectorConstraints) -> None: + if constraints.dim not in (2, 3): raise ValueError( - 'match_realized_pairs currently requires 3D resolved constraints; ' - 'lower-dimensional resolved constraints are supported only by ' - 'fit_power_weights for now' + 'match_realized_pairs currently supports only 2D and 3D resolved ' + 'constraints' ) @dataclass(frozen=True, slots=True) class RealizedPairDiagnostics: - """Diagnostics for matching candidate constraints to realized faces.""" + """Diagnostics for matching candidate constraints to realized boundaries.""" realized: np.ndarray unrealized: tuple[int, ...] @@ -50,7 +64,7 @@ class RealizedPairDiagnostics: endpoint_j_empty: np.ndarray boundary_measure: np.ndarray | None cells: list[dict[str, Any]] | None - tessellation_diagnostics: TessellationDiagnostics | None + tessellation_diagnostics: TessellationDiagnosticsAny | None def to_records( self, @@ -98,7 +112,7 @@ def to_report( *, use_ids: bool = False, ) -> dict[str, object]: - """Return a JSON-friendly report for realized-face matching.""" + """Return a JSON-friendly report for realized-boundary matching.""" from .report import build_realized_report @@ -108,7 +122,7 @@ def to_report( def match_realized_pairs( points: np.ndarray, *, - domain: Box | OrthorhombicCell | PeriodicCell, + domain: DomainAny, radii: np.ndarray, constraints: PairBisectorConstraints, return_boundary_measure: bool = False, @@ -116,11 +130,11 @@ def match_realized_pairs( return_tessellation_diagnostics: bool = False, tessellation_check: Literal['none', 'diagnose', 'warn', 'raise'] = 'diagnose', ) -> RealizedPairDiagnostics: - """Determine which resolved pair constraints correspond to realized faces. + """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 faces in the power tessellation, - including explicit periodic image shifts. + is checked against the set of realized cell boundaries in the power + tessellation, including explicit periodic image shifts. """ pts = np.asarray(points, dtype=float) @@ -130,49 +144,45 @@ def match_realized_pairs( 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') - _require_realization_dim_3(constraints) - - periodic = geometry3d(domain).has_any_periodic_axis - - compute_result = compute( - pts, - 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 + _supported_realization_dim(constraints) + + 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: - cells = compute_result - tessellation_diagnostics = None - - if return_boundary_measure: - annotate_face_properties(cells, domain) - - empty_by_id: dict[int, bool] = {} - shifts_by_pair: dict[tuple[int, int], set[ShiftTuple]] = {} - measure_by_pair_shift: dict[MeasureKey, float] = {} + raise ValueError( + 'match_realized_pairs currently supports only 2D and 3D points' + ) - for cell in cells: - ci = int(cell['id']) - verts = np.asarray(cell.get('vertices', []), dtype=float) - faces = cell.get('faces', []) - empty_by_id[ci] = bool(verts.size == 0 or len(faces) == 0) - for face in faces: - cj = int(face.get('adjacent_cell', -1)) - if cj < 0: - continue - shift = tuple(int(v) for v in face.get('adjacent_shift', (0, 0, 0))) - shifts_by_pair.setdefault((ci, cj), set()).add(shift) - if return_boundary_measure: - measure_by_pair_shift[(ci, cj, shift)] = float(face.get('area', 0.0)) + 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) @@ -210,21 +220,13 @@ def match_realized_pairs( unrealized.append(k) if boundary_measure is not None and any_realized: - if same: - key_f = (i, j, target_shift) - key_r = (j, i, tuple(-int(v) for v in target_shift)) - 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] - else: - chosen = 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] + 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] return RealizedPairDiagnostics( realized=realized, @@ -238,3 +240,145 @@ def match_realized_pairs( cells=cells if return_cells else None, tessellation_diagnostics=tessellation_diagnostics, ) + + +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 _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 index c9059d9..0da0d30 100644 --- a/src/pyvoro2/powerfit/report.py +++ b/src/pyvoro2/powerfit/report.py @@ -16,7 +16,6 @@ from .constraints import PairBisectorConstraints from .realize import RealizedPairDiagnostics from .solver import HardConstraintConflict, PowerWeightFitResult -from ..diagnostics import TessellationDiagnostics def _label_nodes(nodes: tuple[int, ...], ids: np.ndarray | None) -> list[object]: @@ -30,9 +29,7 @@ def _label_nodes(nodes: tuple[int, ...], ids: np.ndarray | None) -> list[object] return labeled -def _tessellation_record( - diagnostics: TessellationDiagnostics | None, -) -> dict[str, object] | None: +def _tessellation_record(diagnostics: Any | None) -> dict[str, object] | None: if diagnostics is None: return None issue_rows = [] @@ -45,26 +42,74 @@ def _tessellation_record( 'examples': list(issue.examples), } ) - return { - 'n_sites_expected': int(diagnostics.n_sites_expected), - 'n_cells_returned': int(diagnostics.n_cells_returned), - '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], - 'face_shift_available': bool(diagnostics.face_shift_available), - 'reciprocity_checked': bool(diagnostics.reciprocity_checked), - '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_volume': bool(diagnostics.ok_volume), - 'ok_reciprocity': bool(diagnostics.ok_reciprocity), - 'ok': bool(diagnostics.ok), - 'issues': issue_rows, - } + + 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( diff --git a/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py index cfc66ba..ab437f6 100644 --- a/tests/test_powerfit_active_set.py +++ b/tests/test_powerfit_active_set.py @@ -221,3 +221,57 @@ def test_self_consistent_result_exports_records_with_ids(): '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] diff --git a/tests/test_powerfit_constraints.py b/tests/test_powerfit_constraints.py index c7033f2..e04ee64 100644 --- a/tests/test_powerfit_constraints.py +++ b/tests/test_powerfit_constraints.py @@ -81,3 +81,42 @@ def test_resolve_pair_bisector_constraints_warns_on_triclinic_search_boundary(): 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_realization.py b/tests/test_powerfit_realization.py index 075b142..3b371a6 100644 --- a/tests/test_powerfit_realization.py +++ b/tests/test_powerfit_realization.py @@ -156,3 +156,70 @@ def test_realized_pair_diagnostics_export_records(): 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] diff --git a/tests/test_powerfit_reports.py b/tests/test_powerfit_reports.py index f88bfb5..7a41abc 100644 --- a/tests/test_powerfit_reports.py +++ b/tests/test_powerfit_reports.py @@ -134,3 +134,42 @@ def test_report_json_helpers_roundtrip_plain_report(tmp_path): 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 diff --git a/tests/test_powerfit_validation_regressions.py b/tests/test_powerfit_validation_regressions.py index 1e36dd7..22670ac 100644 --- a/tests/test_powerfit_validation_regressions.py +++ b/tests/test_powerfit_validation_regressions.py @@ -243,8 +243,9 @@ def test_pre_resolved_constraints_expose_dimension_property(): assert constraints.dim == 2 -def test_match_realized_pairs_rejects_pre_resolved_lower_dim_constraints(): - from pyvoro2 import Box, PairBisectorConstraints, match_realized_pairs +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( @@ -266,18 +267,20 @@ def test_match_realized_pairs_rejects_pre_resolved_lower_dim_constraints(): warnings=tuple(), ) - with pytest.raises(ValueError, match='currently requires 3D resolved constraints'): - match_realized_pairs( - pts, - domain=Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))), - radii=np.array([0.0, 0.0]), - constraints=constraints, - ) + 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_rejects_pre_resolved_lower_dim_constraints(): +def test_active_set_supports_pre_resolved_planar_constraints(): + import pyvoro2.planar as pv2 from pyvoro2 import ( - Box, PairBisectorConstraints, solve_self_consistent_power_weights, ) @@ -302,10 +305,12 @@ def test_active_set_rejects_pre_resolved_lower_dim_constraints(): warnings=tuple(), ) - with pytest.raises(ValueError, match='currently requires 3D resolved constraints'): - solve_self_consistent_power_weights( - pts, - constraints, - measurement='fraction', - domain=Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))), - ) + 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 From 93d8010877de82ae8a080b04684895ff147b71a0 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Mon, 16 Mar 2026 16:12:54 +0300 Subject: [PATCH 19/24] Implements planar compute diagnostics convenience --- CHANGELOG.md | 1 + src/pyvoro2/planar/api.py | 145 ++++++++++++++++++++++++++++-- tests/test_planar_api_dispatch.py | 85 ++++++++++++++++++ tests/test_planar_diagnostics.py | 32 +++++++ tests/test_planar_fuzz_compute.py | 119 ++++++++++++++++++++++++ 5 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 tests/test_planar_fuzz_compute.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4742031..47fa603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve ### Changed +- `pyvoro2.planar.compute(...)` now supports wrapper-level tessellation diagnostics (`return_diagnostics=...`, `tessellation_check=...`) and automatically computes temporary periodic edge shifts/geometry when needed for those checks, stripping the temporary fields back out 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 now marks the start of the 0.6.0 development line. - `resolve_pair_bisector_constraints(...)` now accepts both planar (2D) and spatial (3D) point sets, with dimension-aware shift validation and nearest-image resolution. diff --git a/src/pyvoro2/planar/api.py b/src/pyvoro2/planar/api.py index 70c1eab..b481513 100644 --- a/src/pyvoro2/planar/api.py +++ b/src/pyvoro2/planar/api.py @@ -18,6 +18,11 @@ ) 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 @@ -33,6 +38,34 @@ 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.""" @@ -100,12 +133,30 @@ def compute( validate_edge_shifts: bool = True, repair_edge_shifts: bool = False, edge_shift_tol: float | None = None, -) -> list[dict[str, Any]]: + return_diagnostics: bool = False, + 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]: """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. + + For periodic domains, diagnostics 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 returned cells unless they were explicitly requested. """ pts = coerce_point_array(points, name='points', dim=2) @@ -114,19 +165,44 @@ def compute( 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' + ) + + 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) - if return_edge_shifts: + need_diag = bool(return_diagnostics) or tessellation_check != 'none' + need_periodic_diag_geometry = bool(need_diag and geom.has_any_periodic_axis) + + internal_return_vertices = user_return_vertices or need_periodic_diag_geometry + internal_return_adjacency = user_return_adjacency + internal_return_edges = user_return_edges or need_periodic_diag_geometry + internal_return_edge_shifts = ( + user_return_edge_shifts or need_periodic_diag_geometry + ) + + if user_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: + if not user_return_edges: raise ValueError('return_edge_shifts requires return_edges=True') - if not return_vertices: + 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() @@ -149,7 +225,11 @@ def compute( ) bounds = geom.bounds periodic_flags = geom.periodic_axes - opts = (bool(return_vertices), bool(return_adjacency), bool(return_edges)) + opts = ( + internal_return_vertices, + internal_return_adjacency, + internal_return_edges, + ) rr: np.ndarray | None = None if mode == 'standard': @@ -188,7 +268,7 @@ def compute( else: raise ValueError(f'unknown mode: {mode}') - if return_edge_shifts: + if internal_return_edge_shifts: _add_periodic_edge_shifts_inplace( cells, lattice_vectors=geom.lattice_vectors_cart, @@ -203,6 +283,59 @@ def compute( 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)) + periodic = bool(geom.has_any_periodic_axis) + 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) + + _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_diagnostics: + assert diag is not None + return cells, diag return cells diff --git a/tests/test_planar_api_dispatch.py b/tests/test_planar_api_dispatch.py index ba477f0..2b91963 100644 --- a/tests/test_planar_api_dispatch.py +++ b/tests/test_planar_api_dispatch.py @@ -282,3 +282,88 @@ def test_planar_return_edge_shifts_requires_periodicity(fake_core) -> None: 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_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] + ) diff --git a/tests/test_planar_diagnostics.py b/tests/test_planar_diagnostics.py index da22d4c..940c711 100644 --- a/tests/test_planar_diagnostics.py +++ b/tests/test_planar_diagnostics.py @@ -90,3 +90,35 @@ def test_planar_validate_tessellation_strict_raises_on_area_gap() -> None: 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_fuzz_compute.py b/tests/test_planar_fuzz_compute.py new file mode 100644 index 0000000..8bf2237 --- /dev/null +++ b/tests/test_planar_fuzz_compute.py @@ -0,0 +1,119 @@ +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) From 1eb23c3c33b053f67ffa05fe59630e3ed03b1d05 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Mon, 16 Mar 2026 16:57:50 +0300 Subject: [PATCH 20/24] Adds planar compute result convenience --- CHANGELOG.md | 3 +- src/pyvoro2/planar/__init__.py | 2 + src/pyvoro2/planar/api.py | 79 +++++++++++++++++++---- src/pyvoro2/planar/result.py | 103 ++++++++++++++++++++++++++++++ tests/test_planar_api_dispatch.py | 81 +++++++++++++++++++++++ tests/test_planar_fuzz_compute.py | 38 +++++++++++ tests/test_planar_integration.py | 42 ++++++++++++ 7 files changed, 336 insertions(+), 12 deletions(-) create mode 100644 src/pyvoro2/planar/result.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fa603..bea74af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,12 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - 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 automatically computes temporary periodic edge shifts/geometry when needed for those checks, stripping the temporary fields back out unless they were explicitly requested. +- `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 now marks the start of the 0.6.0 development line. - `resolve_pair_bisector_constraints(...)` now accepts both planar (2D) and spatial (3D) point sets, with dimension-aware shift validation and nearest-image resolution. diff --git a/src/pyvoro2/planar/__init__.py b/src/pyvoro2/planar/__init__.py index 14549b1..f5ec850 100644 --- a/src/pyvoro2/planar/__init__.py +++ b/src/pyvoro2/planar/__init__.py @@ -14,6 +14,7 @@ validate_tessellation, ) from .domains import Box, RectangularCell +from .result import PlanarComputeResult from .duplicates import duplicate_check from .normalize import ( NormalizedTopology, @@ -32,6 +33,7 @@ __all__ = [ 'Box', 'RectangularCell', + 'PlanarComputeResult', 'compute', 'locate', 'ghost_cells', diff --git a/src/pyvoro2/planar/api.py b/src/pyvoro2/planar/api.py index b481513..0cb08e3 100644 --- a/src/pyvoro2/planar/api.py +++ b/src/pyvoro2/planar/api.py @@ -25,6 +25,8 @@ ) 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] @@ -134,13 +136,20 @@ def compute( 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]: +) -> ( + list[dict[str, Any]] + | tuple[list[dict[str, Any]], TessellationDiagnostics] + | PlanarComputeResult +): """Compute planar Voronoi or power tessellation cells. Supported domains: @@ -153,10 +162,18 @@ def compute( ``tessellation_check='warn'`` or ``'raise'`` to have common area and reciprocity issues handled directly by the wrapper. - For periodic domains, diagnostics 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 returned cells unless they were explicitly requested. + 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) @@ -169,6 +186,8 @@ def compute( 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) @@ -176,18 +195,32 @@ def compute( 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_periodic_diag_geometry = bool(need_diag and geom.has_any_periodic_axis) + need_norm_vertices = normalize in ('vertices', 'topology') + need_norm_topology = normalize == 'topology' - internal_return_vertices = user_return_vertices or need_periodic_diag_geometry + 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 + 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 + user_return_edge_shifts + or need_periodic_diag_geometry + or need_periodic_norm_geometry ) if user_return_edge_shifts: - if not geom.has_any_periodic_axis: + if not periodic: raise ValueError( 'return_edge_shifts is only supported for periodic domains ' '(RectangularCell with any periodic axis)' @@ -287,7 +320,6 @@ def compute( diag: TessellationDiagnostics | None = None if need_diag: expected = ids_user.tolist() if ids_user is not None else list(range(n)) - periodic = bool(geom.has_any_periodic_axis) diag = analyze_tessellation( cells, domain, @@ -325,6 +357,24 @@ def compute( 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, @@ -333,6 +383,13 @@ def compute( 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 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/tests/test_planar_api_dispatch.py b/tests/test_planar_api_dispatch.py index 2b91963..4e35d00 100644 --- a/tests/test_planar_api_dispatch.py +++ b/tests/test_planar_api_dispatch.py @@ -301,6 +301,77 @@ def test_planar_compute_return_diagnostics(fake_core) -> None: 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: @@ -367,3 +438,13 @@ def test_planar_compute_invalid_tessellation_check(fake_core) -> None: 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_fuzz_compute.py b/tests/test_planar_fuzz_compute.py index 8bf2237..ab722aa 100644 --- a/tests/test_planar_fuzz_compute.py +++ b/tests/test_planar_fuzz_compute.py @@ -117,3 +117,41 @@ def test_fuzz_planar_compute_periodic_power_with_diagnostics( 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 diff --git a/tests/test_planar_integration.py b/tests/test_planar_integration.py index 1f9a0f0..c720235 100644 --- a/tests/test_planar_integration.py +++ b/tests/test_planar_integration.py @@ -49,3 +49,45 @@ def test_planar_ghost_cells_standard_smoke() -> None: assert len(cells) == 1 assert cells[0]['query_index'] == 0 assert cells[0]['empty'] is False + + +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 From e6bbf2943260be7846c1ed74d73ba69badf84c7a Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Mon, 16 Mar 2026 22:24:48 +0300 Subject: [PATCH 21/24] Finalizes 0.6.0 release scope --- CHANGELOG.md | 4 +- DEV_PLAN.md | 255 ++++++++------------------- README.md | 81 ++++++--- docs/guide/domains.md | 7 + docs/guide/operations.md | 7 +- docs/guide/planar.md | 179 +++++++++++++++++++ docs/guide/powerfit.md | 27 ++- docs/guide/visualization.md | 39 +++- docs/index.md | 81 ++++++--- docs/project/about.md | 16 +- docs/project/roadmap.md | 58 ++++-- docs/reference/edge_properties.md | 4 + docs/reference/planar/api.md | 4 + docs/reference/planar/diagnostics.md | 4 + docs/reference/planar/domains.md | 4 + docs/reference/planar/index.md | 4 + docs/reference/planar/normalize.md | 4 + docs/reference/planar/result.md | 4 + docs/reference/planar/validation.md | 4 + docs/reference/viz2d.md | 4 + mkdocs.yml | 32 ++-- pyproject.toml | 2 +- src/pyvoro2/__about__.py | 2 +- src/pyvoro2/__init__.py | 3 +- src/pyvoro2/planar/_edge_shifts2d.py | 71 ++++++-- src/pyvoro2/planar/api.py | 2 + tests/test_planar_edge_shifts2d.py | 44 +++++ tests/test_planar_fuzz_compute.py | 33 ++++ tests/test_planar_integration.py | 130 ++++++++++++++ 29 files changed, 812 insertions(+), 297 deletions(-) create mode 100644 docs/guide/planar.md create mode 100644 docs/reference/edge_properties.md create mode 100644 docs/reference/planar/api.md create mode 100644 docs/reference/planar/diagnostics.md create mode 100644 docs/reference/planar/domains.md create mode 100644 docs/reference/planar/index.md create mode 100644 docs/reference/planar/normalize.md create mode 100644 docs/reference/planar/result.md create mode 100644 docs/reference/planar/validation.md create mode 100644 docs/reference/viz2d.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bea74af..14dea83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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.0.dev0] - 2026-03-15 +## [0.6.0] - 2026-03-16 ### Added @@ -20,7 +20,7 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - `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 now marks the start of the 0.6.0 development line. +- 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. diff --git a/DEV_PLAN.md b/DEV_PLAN.md index b21e76b..c887889 100644 --- a/DEV_PLAN.md +++ b/DEV_PLAN.md @@ -1,210 +1,97 @@ -# Development plan (0.6.x track) +# Development plan (post-0.6.0) -This file is an internal working plan for the next refactoring/feature cycle. -It is intentionally more concrete than the public roadmap and may evolve during -implementation. +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. -## Current backend decision +## Release sequence -We should **not** switch pyvoro2 to `voro-dev` just to obtain 2D support. +### 0.6.0 (freeze and release) -From the currently vendored upstream snapshots: +Scope of the 0.6.0 release: -- the dedicated legacy 2D code already provides a realistic first 2D surface - for pyvoro2 (standard + power tessellations in planar rectangular domains, - with optional x/y periodicity), while -- `voro-dev` still does **not** appear to add a 2D analogue of pyvoro2's 3D - `PeriodicCell` / triclinic non-orthogonal periodic domain. It reorganizes the - backend (separate `_2d` / `_3d` code, iterators, threading-related changes), - but the non-orthogonal periodic machinery remains 3D-oriented. +- 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. -That means a future backend migration remains optional rather than required for -2D feature parity. The public Python API should therefore be designed now so -that a later engine swap is mostly internal. +The 0.6.0 line should **not** add major new inverse-fitting policies. Those +belong in 0.6.1. -## 0.6.x goals +### 0.6.1 (powerfit robustness) -1. finish the 3D refactoring needed to avoid duplicating geometry and input - logic when 2D lands; -2. add a first-class planar namespace and bindings on top of the existing 2D - backend; -3. keep the inverse-fitting / mathematical API reusable across dimensions; -4. prepare, but do not prematurely promise, a future path for planar oblique - periodic domains. +This is the next planned feature line after 0.6.0. -## Public 2D API shape +Planned focus: -### Namespace +- detect realized pair adjacencies that exist in the tessellation but are + **absent** from the supplied candidate set; +- distinguish clearly between: + - candidate-present but inactive rows, + - candidate-absent but realized pairs, + - disconnected candidate graphs, + - disconnected final active graphs; +- add graph/connectivity diagnostics for both the full candidate graph and the + final active graph; +- improve disconnected-component gauge handling so relative offsets are chosen + by a stable, explainable convention rather than by arbitrary anchor order; +- revisit the current `weights_to_radii(...)` / “minimum radius is one” style + legacy convention, which is one of the remaining chemistry-driven pieces of + terminology and output policy. -Expose 2D from a dedicated namespace: +The current preferred default policy for disconnected components is: -```python -import pyvoro2.planar as pv2 -``` +- if an explicit reference exists, align each disconnected component to that + reference; +- otherwise, center each disconnected component by its mean; +- in the self-consistent loop, preserve component offsets relative to the + previous iterate whenever the active graph is disconnected. -Do **not** silently overload the current 3D top-level API based on -`points.shape[1] == 2`. +This remains a convention, not information identified by the pairwise data, so +0.6.1 should also expose diagnostics and `diagnose` / `warn` / `raise` style +policies around disconnectedness. -### Domains +### Deferred / exploratory (candidate 0.6.2+ work) -First release should expose: +#### Planar `PeriodicCell` -- `pyvoro2.planar.Box` -- `pyvoro2.planar.RectangularCell` +Planar oblique-periodic support is **deferred** rather than promised for a +specific release. -The first 2D release should **not** expose `pyvoro2.planar.PeriodicCell` yet, -because the current backend scope is honest only for rectangular planar domains -with optional x/y periodicity. +The options to keep in mind are: -### Main operations +- 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. -Plan for symmetry with the 3D API: +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. -- `pyvoro2.planar.compute(...)` -- `pyvoro2.planar.locate(...)` -- `pyvoro2.planar.ghost_cells(...)` -- `pyvoro2.planar.analyze_tessellation(...)` -- `pyvoro2.planar.validate_tessellation(...)` -- `pyvoro2.planar.normalize_vertices(...)` -- `pyvoro2.planar.normalize_topology(...)` -- `pyvoro2.planar.validate_normalized_topology(...)` -- `pyvoro2.planar.annotate_edge_properties(...)` +## 1.0 gate -### Raw 2D cell schema +Do not freeze 1.0 immediately after 0.6.0. -Keep raw 2D output natural and dimension-specific: +The intended checkpoint is: -- `area` instead of `volume` -- `edges` instead of `faces` -- `adjacent_shift` remains acceptable as the periodic-image key, but it is now - a length-2 tuple +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. -We should not force a fake dimension-neutral raw schema. Cross-dimensional -consistency belongs one layer above, not in the lowest-level cell dictionary. +The public API should be frozen around a stable mathematical surface, not +around any particular backend snapshot. -### Visualization +## Notes carried forward from the backend review -Add a dedicated `viz2d.py` using `matplotlib` and return `(fig, ax)`. -This should be a small optional dependency surface, separate from `viz3d.py`. - -## Power-fit / inverse-fitting plan - -The inverse-fitting layer is the main candidate for real cross-dimensional reuse. - -### Keep the stable math boundary - -Retain `PairBisectorConstraints` as the stable boundary between: - -- pair-generation / geometric normalization, and -- weight solving / active-set refinement / reporting. - -### Dimension-neutral refactor targets - -Refactor the internals so that: - -- `PairBisectorConstraints.shifts` is shape `(m, d)` -- `PairBisectorConstraints.delta` is shape `(m, d)` -- shift parsing is parameterized by dimension -- shift-to-Cartesian conversion is delegated to a domain adapter -- `boundary_measure` remains generic (`face area` in 3D, `edge length` in 2D) - -The public solver names can stay shared: - -- `resolve_pair_bisector_constraints(...)` -- `fit_power_weights(...)` -- `match_realized_pairs(...)` -- `solve_self_consistent_power_weights(...)` - -## Internal refactoring plan - -### 1. Shared validation helpers - -Keep extracting validation/coercion from the top-level wrappers into internal -helpers so 2D can reuse them without copy/paste. - -### 2. Internal domain-geometry adapters - -Continue building small internal adapters that answer: - -- dimension -- periodic axes -- lattice vectors / shift-to-Cartesian mapping -- remapping into the primary domain -- nearest-image resolution -- default block-grid heuristics - -Current 3D work lives in `_domain_geometry.py`; 2D should get a matching -adapter rather than duplicating geometry logic inside the planar API wrapper. - -### 3. Thin public wrappers - -`src/pyvoro2/api.py` should remain a thin public surface. -More of the work should move into internal modules so that: - -- 3D and 2D wrappers stay parallel, and -- package-wide validation / geometry behavior is easier to test in isolation. - -## Binding plan - -### Separate extension modules - -Build 2D as a separate compiled module: - -- `_core` for 3D (existing) -- `_core2d` for 2D (new) - -Do not try to merge 2D and 3D into one extension. - -### First 2D binding scope - -Bind the following operations first: - -- `compute_box_standard` / `compute_box_power` -- `locate_box_standard` / `locate_box_power` -- `ghost_box_standard` / `ghost_box_power` - -with 2D-specific payloads and reconstruction of edge records from the ordered -polygon vertices and neighbor data returned by the backend. - -## Testing plan - -Mirror the current 3D coverage categories for 2D: - -- standard tessellations -- power tessellations -- bounded boxes -- rectangular periodic domains -- empty-cell behavior in power mode -- locate -- ghost cells -- periodic edge shifts -- topology normalization and validation -- edge-property annotation -- power-fit resolution / solving / realization / active set -- visualization smoke tests -- fuzz/property tests - -## Planar `PeriodicCell` note - -The current C++ situation does not obviously provide a native 2D oblique -periodic container. That should **not** block the first 2D release. - -However, we should keep one exploratory fallback in mind: - -- implement a future planar `PeriodicCell` via a **pseudo-3D run** with - zero `z` coordinates, a carefully controlled out-of-plane setup, and - post-processing that projects the resulting 3D cell data back to 2D. - -This is **not** the preferred first implementation path, and it may turn out to -be too fragile or too expensive for general use. It should be treated as a -research option for later, not as the baseline 0.6.x plan. - -## Remaining 0.6.x preparatory steps before public 2D - -- finish extracting shared 3D API validation / geometry logic; -- complete the first dimension-neutral refactor of the power-fit boundary; -- add `_core2d` build scaffolding and minimal planar Python namespace; -- add `viz2d.py`; -- document the exact first-release scope for 2D rectangular domains; -- decide whether any exploratory pseudo-3D `PeriodicCell` prototype is worth - testing behind an internal or experimental flag. +- 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/README.md b/README.md index bd1535d..0e0f102 100644 --- a/README.md +++ b/README.md @@ -8,26 +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 and geometric settings that need **periodic boundary conditions**, -explicit **neighbor-image shifts**, and a reusable mathematical interface to +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]"`. @@ -49,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) @@ -63,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( @@ -102,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 @@ -114,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) - - power-fitting utilities for **fitting power weights** from desired pairwise separator 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 @@ -132,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. | -| [Power fitting](https://delonecommons.github.io/pyvoro2/guide/powerfit/) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | -| [Visualization](https://delonecommons.github.io/pyvoro2/guide/visualization/) | Optional py3Dmol helpers for debugging and exploratory analysis. | +| [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/notebooks/01_basic_compute/) | End-to-end examples, including focused power-fitting notebooks for reports and infeasibility witnesses. | -| [API reference](https://delonecommons.github.io/pyvoro2/reference/api/) | The full reference (docstrings). | +| [API reference](https://delonecommons.github.io/pyvoro2/reference/planar/) | The full reference (docstrings) for both the spatial and planar APIs. | ## Installation @@ -148,6 +179,11 @@ 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 + To build from source (requires a C++ compiler and Python development headers): ```bash @@ -190,10 +226,11 @@ Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`. 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 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/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 index 9a75061..eb8d7a1 100644 --- a/docs/guide/powerfit.md +++ b/docs/guide/powerfit.md @@ -5,7 +5,8 @@ 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**. -Downstream code decides: +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, @@ -164,7 +165,8 @@ This returns purely geometric diagnostics: - 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 face, +- an optional boundary measure of the matched boundary + (**face area** in 3D, **edge length** in 2D), - and optional tessellation-wide diagnostics. ## Step 5: solve the self-consistent active-set problem @@ -280,14 +282,21 @@ pv.write_report_json(solve_report, 'solve_report.json', sort_keys=True) ## Current scope -The current implementation is 3D because it builds on the existing Voro++-based -power tessellation core. The **API vocabulary** is already dimension-safe: -constraint fitting is phrased in terms of pairwise separators and boundary -measure rather than chemistry-specific or 3D-only semantics. +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. -### Objective-model scope for 0.5.x +The main current restriction is geometric, not algebraic: -The 0.5.x series intentionally keeps the built-in objective family compact: +- 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.0 + +The 0.6.0 series intentionally keeps the built-in objective family compact: - mismatch terms: `SquaredLoss`, `HuberLoss` - hard feasibility: `Interval`, `FixedValue` @@ -300,7 +309,7 @@ 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.5.x does **not** try to +validate a concrete need for them. In particular, 0.6.0 does **not** try to freeze an open-ended callback API for arbitrary user-defined objectives. ## Worked example notebooks diff --git a/docs/guide/visualization.md b/docs/guide/visualization.md index d61253d..bb9ce04 100644 --- a/docs/guide/visualization.md +++ b/docs/guide/visualization.md @@ -7,17 +7,46 @@ 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. -## A minimal example +## A minimal 3D example ```python import numpy as np @@ -45,7 +74,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 5468c63..06d83f3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,26 +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 and geometric settings that need **periodic boundary conditions**, -explicit **neighbor-image shifts**, and a reusable mathematical interface to +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]"`. @@ -42,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) @@ -56,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( @@ -95,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 @@ -107,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) - - power-fitting utilities for **fitting power weights** from desired pairwise separator 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 @@ -125,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. | -| [Power fitting](guide/powerfit.md) | Fit power weights from pairwise bisector constraints, realized-face matching, and self-consistent active sets. | -| [Visualization](guide/visualization.md) | Optional py3Dmol helpers for debugging and exploratory analysis. | +| [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)](notebooks/01_basic_compute.ipynb) | End-to-end examples, including focused power-fitting notebooks for reports and infeasibility witnesses. | -| [API reference](reference/api.md) | The full reference (docstrings). | +| [API reference](reference/planar/index.md) | The full reference (docstrings) for both the spatial and planar APIs. | ## Installation @@ -141,6 +172,11 @@ 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 + To build from source (requires a C++ compiler and Python development headers): ```bash @@ -183,10 +219,11 @@ Tip: you can combine markers, e.g. `pytest -m "fuzz and pyvoro" --fuzz-n 100`. 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 diff --git a/docs/project/about.md b/docs/project/about.md index 5cea2d0..dc61b40 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. diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 733f529..259ce0a 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -2,33 +2,54 @@ This page lists intended future improvements. It is not a guarantee of timelines. +## 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`. + ## Planned / likely -### Native 2D support +### Powerfit robustness (next cycle) -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 next high-priority design question is no longer “can the active-set loop +iterate?”, but whether the package clearly reports when the inverse model is +underspecified. -### Powerfit objective-model expansion +Planned work includes: -The self-consistent active-set solver is now part of the package, so the next -powerfit design question is not iteration support but **objective-model scope**. +- reporting realized pair adjacencies that are present in the tessellation but + absent from the supplied candidate set; +- graph/connectivity diagnostics for candidate and active-set graphs; +- clearer policies for disconnected components and unconstrained sites; +- revisiting the remaining chemistry-driven radius-convention legacy in the + `weights_to_radii(...)` path. -For the 0.5.x series, the built-in family is intentionally compact: quadratic -and Huber mismatch terms, interval and fixed-value hard feasibility, -outside-interval penalties, near-boundary penalties, and L2 regularization. +## Potential / exploratory -Additional mismatch or penalty families should be added only after downstream -packages validate a concrete need for them. The goal is to expand from real -workflows rather than to freeze a broad callback surface too early. +### 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. Future work is expected to focus +on usability and examples, not on making visualization a heavy core +dependency. ## Release stability @@ -36,5 +57,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 post-0.6.0 powerfit robustness work lands, +- 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/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/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/mkdocs.yml b/mkdocs.yml index 7b5fdf4..674e45e 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 @@ -69,7 +69,8 @@ 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 - Power fitting: guide/powerfit.md @@ -83,13 +84,25 @@ nav: - notebooks/06_powerfit_reports.ipynb - notebooks/07_powerfit_infeasibility.ipynb - 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 + - 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 @@ -98,7 +111,6 @@ nav: - Realization: reference/powerfit/realize.md - Active set: reference/powerfit/active.md - Reports: reference/powerfit/report.md - - Visualization: reference/viz3d.md - Project: - About: project/about.md - Changelog: about/changelog.md diff --git a/pyproject.toml b/pyproject.toml index b866395..3ca4ccb 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 power/Laguerre tessellations) with periodic, topology, and inverse power-fitting 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'} diff --git a/src/pyvoro2/__about__.py b/src/pyvoro2/__about__.py index 38a0836..4883f48 100644 --- a/src/pyvoro2/__about__.py +++ b/src/pyvoro2/__about__.py @@ -5,4 +5,4 @@ """ # Keep this as a simple assignment so scikit-build-core can extract it via regex. -__version__ = '0.6.0.dev0' +__version__ = '0.6.0' diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index 0b6146f..458af61 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -1,7 +1,8 @@ """pyvoro2 package. This package provides Python bindings to the Voro++ cell-based Voronoi -and power (Laguerre) tessellation library. +and power (Laguerre) tessellation library, including the planar +``pyvoro2.planar`` namespace for 2D workflows. """ from __future__ import annotations diff --git a/src/pyvoro2/planar/_edge_shifts2d.py b/src/pyvoro2/planar/_edge_shifts2d.py index d2518b8..3f8759b 100644 --- a/src/pyvoro2/planar/_edge_shifts2d.py +++ b/src/pyvoro2/planar/_edge_shifts2d.py @@ -15,6 +15,8 @@ def _add_periodic_edge_shifts_inplace( 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, @@ -61,6 +63,13 @@ def _add_periodic_edge_shifts_inplace( 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: @@ -97,14 +106,40 @@ def _add_periodic_edge_shifts_inplace( 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( *, - pid: int, nid_arr: np.ndarray, p_i: np.ndarray, + w_i: float | None, p_img: np.ndarray, verts: np.ndarray, ) -> np.ndarray: @@ -117,11 +152,11 @@ def _residual_for_images( 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 - wi = float(weights[pid]) + 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) - wi) + - (np.dot(p_i, p_i) - w_i) ) else: # pragma: no cover raise ValueError(f'unknown mode: {mode}') @@ -131,9 +166,9 @@ def _residual_for_images( def _best_shift_for_neighbor( *, - pid: int, nid: int, p_i: np.ndarray, + w_i: float | None, p_j: np.ndarray, verts: np.ndarray, ) -> tuple[int, float]: @@ -180,9 +215,9 @@ def _best_shift_for_neighbor( p_img_seed = p_j.reshape(1, 2) + trans_arr[seed_idx] resid_seed = _residual_for_images( - pid=pid, 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, ) @@ -193,9 +228,9 @@ def _best_shift_for_neighbor( 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( - pid=pid, 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, ) @@ -240,8 +275,9 @@ def _best_shift_for_neighbor( def _best_unknown_neighbor( *, - pid: int, p_i: np.ndarray, + pid: int, + w_i: float | None, verts: np.ndarray, ) -> tuple[int, int, float] | None: cand_nids: list[int] = [] @@ -262,9 +298,9 @@ def _best_unknown_neighbor( 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( - pid=pid, nid_arr=nid_arr, p_i=p_i, + w_i=w_i, p_img=p_img, verts=verts, ) @@ -296,11 +332,15 @@ def _best_unknown_neighbor( for cell in cells: pid = int(cell.get('id', -1)) - if pid < 0: - continue - p_i = sites.get(pid) + 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)) @@ -320,7 +360,12 @@ def _best_unknown_neighbor( nid = int(edge.get('adjacent_cell', -999999)) if nid < 0: - resolved = _best_unknown_neighbor(pid=pid, p_i=p_i, verts=verts) + 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 @@ -336,9 +381,9 @@ def _best_unknown_neighbor( raise ValueError(f'missing site for adjacent_cell={nid}') best_idx, best_resid = _best_shift_for_neighbor( - pid=pid, nid=nid, p_i=p_i, + w_i=w_i, p_j=p_j, verts=verts, ) diff --git a/src/pyvoro2/planar/api.py b/src/pyvoro2/planar/api.py index 0cb08e3..7e40b12 100644 --- a/src/pyvoro2/planar/api.py +++ b/src/pyvoro2/planar/api.py @@ -608,6 +608,8 @@ def ghost_cells( 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), diff --git a/tests/test_planar_edge_shifts2d.py b/tests/test_planar_edge_shifts2d.py index 1c5d1f4..d49b48c 100644 --- a/tests/test_planar_edge_shifts2d.py +++ b/tests/test_planar_edge_shifts2d.py @@ -95,3 +95,47 @@ def test_planar_edge_shifts_can_repair_reciprocity() -> None: 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 index ab722aa..1c47441 100644 --- a/tests/test_planar_fuzz_compute.py +++ b/tests/test_planar_fuzz_compute.py @@ -155,3 +155,36 @@ def test_fuzz_planar_compute_result_periodic_topology( 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 index c720235..bccd75b 100644 --- a/tests/test_planar_integration.py +++ b/tests/test_planar_integration.py @@ -51,6 +51,58 @@ def test_planar_ghost_cells_standard_smoke() -> None: 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( @@ -91,3 +143,81 @@ def test_planar_compute_result_topology_periodic_smoke() -> None: 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 + ) From c166c10d611183a33b97e59c40a45cf6751c9e9a Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Tue, 17 Mar 2026 11:06:58 +0300 Subject: [PATCH 22/24] Adds powerfit diagnostics for connectivity, unaccounted pairs, and explicit gauges --- CHANGELOG.md | 19 + DEV_PLAN.md | 60 +-- docs/guide/powerfit.md | 34 +- docs/project/roadmap.md | 29 +- src/pyvoro2/__about__.py | 2 +- src/pyvoro2/__init__.py | 10 + src/pyvoro2/powerfit/__init__.py | 15 +- src/pyvoro2/powerfit/active.py | 137 ++++- src/pyvoro2/powerfit/realize.py | 156 ++++++ src/pyvoro2/powerfit/report.py | 71 ++- src/pyvoro2/powerfit/solver.py | 469 ++++++++++++++++-- tests/test_powerfit_active_set.py | 114 ++++- tests/test_powerfit_fit.py | 88 +++- tests/test_powerfit_realization.py | 76 +++ tests/test_powerfit_reports.py | 122 ++++- tests/test_powerfit_validation_regressions.py | 52 ++ 16 files changed, 1342 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14dea83..738e697 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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. + +### 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, and realized-diagnostics warnings through the plain-Python report helpers. + +### 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 diff --git a/DEV_PLAN.md b/DEV_PLAN.md index c887889..a66514c 100644 --- a/DEV_PLAN.md +++ b/DEV_PLAN.md @@ -19,38 +19,40 @@ Scope of the 0.6.0 release: The 0.6.0 line should **not** add major new inverse-fitting policies. Those belong in 0.6.1. -### 0.6.1 (powerfit robustness) - -This is the next planned feature line after 0.6.0. - -Planned focus: - -- detect realized pair adjacencies that exist in the tessellation but are - **absent** from the supplied candidate set; -- distinguish clearly between: - - candidate-present but inactive rows, - - candidate-absent but realized pairs, - - disconnected candidate graphs, - - disconnected final active graphs; -- add graph/connectivity diagnostics for both the full candidate graph and the - final active graph; -- improve disconnected-component gauge handling so relative offsets are chosen - by a stable, explainable convention rather than by arbitrary anchor order; -- revisit the current `weights_to_radii(...)` / “minimum radius is one” style - legacy convention, which is one of the remaining chemistry-driven pieces of - terminology and output policy. - -The current preferred default policy for disconnected components is: - -- if an explicit reference exists, align each disconnected component to that - reference; -- otherwise, center each disconnected component by its mean; +### 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. + +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 graph is disconnected. + previous iterate whenever the active effective graph is disconnected. This remains a convention, not information identified by the pairwise data, so -0.6.1 should also expose diagnostics and `diagnose` / `warn` / `raise` style -policies around disconnectedness. +connectivity diagnostics continue to support `none` / `diagnose` / `warn` / +`raise` policies. ### Deferred / exploratory (candidate 0.6.2+ work) diff --git a/docs/guide/powerfit.md b/docs/guide/powerfit.md index eb8d7a1..de99778 100644 --- a/docs/guide/powerfit.md +++ b/docs/guide/powerfit.md @@ -115,7 +115,6 @@ fit = pv.fit_power_weights( points, constraints, model=model, - r_min=1.0, ) ``` @@ -142,6 +141,24 @@ Both low-level fits and active-set results also provide `to_records(...)` helper 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. + ## Step 4: check which pairs are actually realized A requested pairwise separator is not automatically a realized face in the full @@ -156,6 +173,7 @@ realized = pv.match_realized_pairs( constraints=constraints, return_boundary_measure=True, return_tessellation_diagnostics=True, + unaccounted_pair_check='warn', ) ``` @@ -167,6 +185,8 @@ This returns purely geometric diagnostics: - 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 @@ -216,14 +236,20 @@ 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, +- `result.realized`: realized-face matching diagnostics, including + `unaccounted_pairs` when the final tessellation realizes candidate-absent + pairs, +- `result.connectivity`: candidate-graph and active-graph connectivity + diagnostics plus the gauge-policy description used for disconnected + components, - `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. +- `result.marginal_constraints`: indices of toggling / cycle / wrong-shift + pairs. Status labels are intentionally generic, for example: @@ -294,7 +320,7 @@ The main current restriction is geometric, not algebraic: - 2D currently supports `Box` and rectangular `RectangularCell`; - there is **no** planar oblique-periodic `PeriodicCell` yet. -### Objective-model scope for 0.6.0 +### Objective-model scope for 0.6.1 The 0.6.0 series intentionally keeps the built-in objective family compact: diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 259ce0a..25a101d 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -18,22 +18,25 @@ honest: It does **not** yet promise a planar oblique-periodic analogue of the 3D `PeriodicCell`. -## Planned / likely +### Powerfit robustness in 0.6.1 -### Powerfit robustness (next cycle) +The 0.6.1 line hardens the inverse-fitting stack around underdetermined and +mis-specified candidate graphs: -The next high-priority design question is no longer “can the active-set loop -iterate?”, but whether the package clearly reports when the inverse model is -underspecified. +- 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; +- the weight-to-radius conversion path now exposes `weight_shift=` directly + instead of relying only on the older minimum-radius convention. -Planned work includes: +## Planned / likely -- reporting realized pair adjacencies that are present in the tessellation but - absent from the supplied candidate set; -- graph/connectivity diagnostics for candidate and active-set graphs; -- clearer policies for disconnected components and unconstrained sites; -- revisiting the remaining chemistry-driven radius-convention legacy in the - `weights_to_radii(...)` path. +The next roadmap questions are no longer about the basic powerfit surface, but +about validation depth and overall API stabilization. ## Potential / exploratory @@ -57,6 +60,6 @@ pyvoro2 is currently in **beta**. A “stable” 1.0 release is expected only after: -- the post-0.6.0 powerfit robustness work lands, +- 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/src/pyvoro2/__about__.py b/src/pyvoro2/__about__.py index 4883f48..ff52f36 100644 --- a/src/pyvoro2/__about__.py +++ b/src/pyvoro2/__about__.py @@ -5,4 +5,4 @@ """ # Keep this as a simple assignment so scikit-build-core can extract it via regex. -__version__ = '0.6.0' +__version__ = '0.6.1' diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index 458af61..02889f8 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -52,10 +52,15 @@ ReciprocalBoundaryPenalty, L2Regularization, FitModel, + ConstraintGraphDiagnostics, + ConnectivityDiagnostics, + ConnectivityDiagnosticsError, HardConstraintConflictTerm, HardConstraintConflict, PowerWeightFitResult, RealizedPairDiagnostics, + UnaccountedRealizedPair, + UnaccountedRealizedPairError, build_fit_report, build_realized_report, build_active_set_report, @@ -108,10 +113,15 @@ 'ReciprocalBoundaryPenalty', 'L2Regularization', 'FitModel', + 'ConstraintGraphDiagnostics', + 'ConnectivityDiagnostics', + 'ConnectivityDiagnosticsError', 'HardConstraintConflictTerm', 'HardConstraintConflict', 'PowerWeightFitResult', 'RealizedPairDiagnostics', + 'UnaccountedRealizedPair', + 'UnaccountedRealizedPairError', 'build_fit_report', 'build_realized_report', 'build_active_set_report', diff --git a/src/pyvoro2/powerfit/__init__.py b/src/pyvoro2/powerfit/__init__.py index a8bd8dc..ed3a3f6 100644 --- a/src/pyvoro2/powerfit/__init__.py +++ b/src/pyvoro2/powerfit/__init__.py @@ -21,7 +21,12 @@ SelfConsistentPowerFitResult, solve_self_consistent_power_weights, ) -from .realize import RealizedPairDiagnostics, match_realized_pairs +from .realize import ( + RealizedPairDiagnostics, + UnaccountedRealizedPair, + UnaccountedRealizedPairError, + match_realized_pairs, +) from .report import ( build_active_set_report, build_fit_report, @@ -30,6 +35,9 @@ write_report_json, ) from .solver import ( + ConnectivityDiagnostics, + ConnectivityDiagnosticsError, + ConstraintGraphDiagnostics, HardConstraintConflict, HardConstraintConflictTerm, PowerWeightFitResult, @@ -50,10 +58,15 @@ 'ReciprocalBoundaryPenalty', 'L2Regularization', 'FitModel', + 'ConstraintGraphDiagnostics', + 'ConnectivityDiagnostics', + 'ConnectivityDiagnosticsError', 'HardConstraintConflictTerm', 'HardConstraintConflict', 'PowerWeightFitResult', 'RealizedPairDiagnostics', + 'UnaccountedRealizedPair', + 'UnaccountedRealizedPairError', 'build_fit_report', 'build_realized_report', 'build_active_set_report', diff --git a/src/pyvoro2/powerfit/active.py b/src/pyvoro2/powerfit/active.py index 569031a..9d322e0 100644 --- a/src/pyvoro2/powerfit/active.py +++ b/src/pyvoro2/powerfit/active.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal import numpy as np @@ -11,8 +11,12 @@ 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, @@ -181,6 +185,7 @@ class SelfConsistentPowerFitResult: ) history: tuple[ActiveSetIteration, ...] | 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.""" @@ -211,6 +216,7 @@ def solve_self_consistent_power_weights( 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, @@ -221,6 +227,8 @@ def solve_self_consistent_power_weights( 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.""" @@ -228,6 +236,14 @@ def solve_self_consistent_power_weights( 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() @@ -279,7 +295,6 @@ def solve_self_consistent_power_weights( prev_weights_eval: np.ndarray | None = None prev_realized_same: np.ndarray | None = None seen_masks: dict[bytes, int] = {active.tobytes(): 0} - comps = _connected_components(resolved.n_points, resolved.i, resolved.j) termination: Literal[ 'self_consistent', @@ -299,11 +314,13 @@ def solve_self_consistent_power_weights( 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) @@ -345,6 +362,19 @@ def solve_self_consistent_power_weights( 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=_self_consistent_gauge_policy_description(), + ) + _apply_connectivity_policy( + connectivity_check, + connectivity, + warnings_list, + ) return SelfConsistentPowerFitResult( constraints=resolved, fit=fit, @@ -361,6 +391,7 @@ def solve_self_consistent_power_weights( tessellation_diagnostics=None, history=tuple(history_rows) if return_history else None, warnings=tuple(warnings_list), + connectivity=connectivity, ) weights_exact = fit.weights.copy() @@ -368,7 +399,7 @@ def solve_self_consistent_power_weights( weights_exact = _align_weights_to_reference( weights_exact, prev_weights_eval, - comps, + _active_alignment_components(active_constraints, model), ) weights_eval = ( (1.0 - float(options.relax)) * prev_weights_eval @@ -379,7 +410,11 @@ def solve_self_consistent_power_weights( weights_eval = weights_exact step_norm = 0.0 - radii_eval, _ = weights_to_radii(weights_eval, r_min=r_min) + radii_eval, _ = weights_to_radii( + weights_eval, + r_min=r_min, + weight_shift=weight_shift, + ) diag = match_realized_pairs( pts, domain=domain, @@ -389,6 +424,7 @@ def solve_self_consistent_power_weights( return_cells=False, return_tessellation_diagnostics=False, tessellation_check='none', + unaccounted_pair_check='none', ) last_diag = diag realized_same = diag.realized_same_shift @@ -485,11 +521,13 @@ def solve_self_consistent_power_weights( 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) @@ -497,7 +535,21 @@ def solve_self_consistent_power_weights( termination = 'numerical_failure' converged = False - if final_fit.weights is not None and final_fit.radii is not None: + 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, @@ -507,7 +559,9 @@ def solve_self_consistent_power_weights( 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, @@ -576,6 +630,20 @@ def solve_self_consistent_power_weights( status=status, ) + connectivity = None + if connectivity_check != 'none': + connectivity = _build_active_set_connectivity_diagnostics( + resolved, + active, + model=model, + gauge_policy=_self_consistent_gauge_policy_description(), + ) + _apply_connectivity_policy( + connectivity_check, + connectivity, + warnings_list, + ) + return SelfConsistentPowerFitResult( constraints=resolved, fit=final_fit, @@ -592,6 +660,7 @@ def solve_self_consistent_power_weights( tessellation_diagnostics=final_realized.tessellation_diagnostics, history=tuple(history_rows) if return_history else None, warnings=tuple(warnings_list), + connectivity=connectivity, ) @@ -611,6 +680,62 @@ def _align_weights_to_reference( 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 _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: @@ -627,6 +752,8 @@ def _empty_realized_pair_diagnostics( ), cells=None, tessellation_diagnostics=None, + unaccounted_pairs=tuple(), + warnings=tuple(), ) diff --git a/src/pyvoro2/powerfit/realize.py b/src/pyvoro2/powerfit/realize.py index 30f1623..c3dd8b1 100644 --- a/src/pyvoro2/powerfit/realize.py +++ b/src/pyvoro2/powerfit/realize.py @@ -27,6 +27,8 @@ 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 @@ -51,6 +53,46 @@ def _supported_realization_dim(constraints: PairBisectorConstraints) -> None: ) +@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) + self.unaccounted_pairs = unaccounted_pairs + + @dataclass(frozen=True, slots=True) class RealizedPairDiagnostics: """Diagnostics for matching candidate constraints to realized boundaries.""" @@ -65,6 +107,8 @@ class RealizedPairDiagnostics: 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, @@ -106,6 +150,15 @@ def to_records( ) 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, @@ -129,6 +182,7 @@ def match_realized_pairs( 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. @@ -145,6 +199,10 @@ def match_realized_pairs( 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: @@ -228,6 +286,22 @@ def match_realized_pairs( 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), @@ -239,6 +313,8 @@ def match_realized_pairs( boundary_measure=boundary_measure, cells=cells if return_cells else None, tessellation_diagnostics=tessellation_diagnostics, + unaccounted_pairs=unaccounted_pairs, + warnings=tuple(warning_messages), ) @@ -348,6 +424,86 @@ def _compute_planar_cells( 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]], *, diff --git a/src/pyvoro2/powerfit/report.py b/src/pyvoro2/powerfit/report.py index 0da0d30..95d2fd4 100644 --- a/src/pyvoro2/powerfit/report.py +++ b/src/pyvoro2/powerfit/report.py @@ -15,7 +15,12 @@ from .constraints import PairBisectorConstraints from .realize import RealizedPairDiagnostics -from .solver import HardConstraintConflict, PowerWeightFitResult +from .solver import ( + ConnectivityDiagnostics, + ConstraintGraphDiagnostics, + HardConstraintConflict, + PowerWeightFitResult, +) def _label_nodes(nodes: tuple[int, ...], ids: np.ndarray | None) -> list[object]: @@ -29,6 +34,59 @@ def _label_nodes(nodes: tuple[int, ...], ids: np.ndarray | None) -> list[object] 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 _tessellation_record(diagnostics: Any | None) -> dict[str, object] | None: if diagnostics is None: return None @@ -172,6 +230,7 @@ def build_fit_report( ], 'warnings': list(result.warnings), 'conflict': _conflict_record(result.conflict, ids=ids), + 'connectivity': _connectivity_record(result.connectivity, ids=ids), } @@ -183,6 +242,7 @@ def build_realized_report( ) -> 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': { @@ -191,9 +251,12 @@ def build_realized_report( '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 ), @@ -256,7 +319,7 @@ def build_active_set_report( 'constraints': list(result.constraints.to_records(use_ids=use_ids)), 'fit': build_fit_report( result.fit, - result.constraints, + result.constraints.subset(result.active_mask), use_ids=use_ids, ), 'realized': build_realized_report( @@ -271,6 +334,10 @@ def build_active_set_report( result.tessellation_diagnostics ), 'warnings': list(result.warnings), + 'connectivity': _connectivity_record( + result.connectivity, + ids=(result.constraints.ids if use_ids else None), + ), } diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py index 586479b..66ded10 100644 --- a/src/pyvoro2/powerfit/solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -27,6 +27,52 @@ 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) + self.diagnostics = diagnostics + + @dataclass(frozen=True, slots=True) class PowerWeightFitResult: """Result of inverse fitting of power weights.""" @@ -52,6 +98,7 @@ class PowerWeightFitResult: converged: bool conflict: 'HardConstraintConflict | None' warnings: tuple[str, ...] + connectivity: ConnectivityDiagnostics | None = None @property def is_optimal(self) -> bool: @@ -225,21 +272,37 @@ def radii_to_weights(radii: np.ndarray) -> np.ndarray: def weights_to_radii( - weights: np.ndarray, *, r_min: float = 0.0 + weights: np.ndarray, + *, + r_min: float = 0.0, + weight_shift: float | None = None, ) -> tuple[np.ndarray, float]: - """Convert power weights to radii using a global gauge shift.""" + """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') - 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 + 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)') @@ -260,16 +323,20 @@ def fit_power_weights( 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 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) @@ -310,11 +377,13 @@ def fit_power_weights( 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, ) @@ -323,11 +392,13 @@ def _fit_power_weights_resolved( *, 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) @@ -339,10 +410,15 @@ def _fit_power_weights_resolved( 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 @@ -352,6 +428,38 @@ def _fit_power_weights_resolved( 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, @@ -384,6 +492,7 @@ def _fit_power_weights_resolved( converged=False, conflict=conflict, warnings=tuple(warnings_list), + connectivity=connectivity, ) else: conflict = None @@ -394,10 +503,22 @@ def _fit_power_weights_resolved( 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 zero weights') - radii, shift = weights_to_radii(weights, r_min=r_min) + 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 @@ -421,39 +542,7 @@ def _fit_power_weights_resolved( converged=True, conflict=conflict, warnings=tuple(warnings_list), - ) - - 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' - ) - - if solver_eff == 'analytic' and lam == 0.0: - effective_mask = a > 0.0 - comps = _connected_components( - n, - constraints.i[effective_mask], - constraints.j[effective_mask], - ) - if np.any(~effective_mask): - warnings_list.append( - 'zero-confidence constraints do not affect the quadratic fit ' - 'objective and are ignored for gauge connectivity' - ) - else: - comps = _connected_components(n, constraints.i, constraints.j) - if len(comps) > 1 and lam == 0.0: - warnings_list.append( - 'effective constraint graph has multiple connected components; ' - 'each component is gauge-fixed independently' + connectivity=connectivity, ) weights = np.zeros(n, dtype=np.float64) @@ -462,18 +551,20 @@ def _fit_power_weights_resolved( try: for nodes in comps: - if len(nodes) <= 1: - if lam > 0 and len(nodes) == 1: - weights[nodes[0]] = w0[nodes[0]] + 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 = np.array( - [ + 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( @@ -490,7 +581,7 @@ def _fit_power_weights_resolved( beta_c = geom.beta[mask] target_c = geom.target[mask] conf_c = constraints.confidence[mask] - w0_c = w0[np.array(nodes, dtype=np.int64)] + 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] @@ -518,14 +609,24 @@ def _fit_power_weights_resolved( ) if not np.all(np.isfinite(w_c)): raise _NumericalFailure('component solver returned non-finite weights') - weights[np.array(nodes, dtype=np.int64)] = w_c + 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) + 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) @@ -558,6 +659,7 @@ def _fit_power_weights_resolved( converged=False, conflict=conflict, warnings=tuple(warnings_list), + connectivity=connectivity, ) if converged_all: @@ -586,6 +688,7 @@ def _fit_power_weights_resolved( converged=bool(converged_all), conflict=conflict, warnings=tuple(warnings_list), + connectivity=connectivity, ) @@ -632,6 +735,268 @@ def _regularization_reference(reg: L2Regularization, n: int) -> np.ndarray: 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, diff --git a/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py index ab437f6..6eb1b92 100644 --- a/tests/test_powerfit_active_set.py +++ b/tests/test_powerfit_active_set.py @@ -200,7 +200,7 @@ def test_self_consistent_result_exports_records_with_ids(): ) 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))) + 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)], @@ -275,3 +275,115 @@ def test_self_consistent_solver_supports_planar_periodic_wrong_shift() -> None: 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])) diff --git a/tests/test_powerfit_fit.py b/tests/test_powerfit_fit.py index 41976b9..43b6ef7 100644 --- a/tests/test_powerfit_fit.py +++ b/tests/test_powerfit_fit.py @@ -1,4 +1,5 @@ import numpy as np +import pytest def test_fit_power_weights_fraction_two_points_analytic(): @@ -48,7 +49,7 @@ def test_r_min_sets_minimum_radius_via_weight_shift(): 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.weights[0], 0.0, atol=1e-12) + assert np.allclose(res.radii * res.radii, res.weights + res.weight_shift) def test_soft_interval_penalty_prefers_inside_interval(): @@ -163,3 +164,88 @@ def test_huber_loss_is_available_as_an_alternative_mismatch(): 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 index 3b371a6..e680b03 100644 --- a/tests/test_powerfit_realization.py +++ b/tests/test_powerfit_realization.py @@ -132,6 +132,7 @@ def test_match_realized_pairs_reports_periodic_wrong_shift(): 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(): @@ -223,3 +224,78 @@ def test_match_realized_pairs_supports_planar_periodic_wrong_shift() -> None: 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 index 7a41abc..4870fd1 100644 --- a/tests/test_powerfit_reports.py +++ b/tests/test_powerfit_reports.py @@ -16,7 +16,7 @@ def test_fit_report_exports_nested_plain_python_payload(): [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], dtype=float, ) - box = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + 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)], @@ -57,7 +57,7 @@ def test_active_set_report_collects_nested_diagnostics_and_history(): ) 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))) + box = Box(((-5.0, 15.0), (-5.0, 5.0), (-5.0, 5.0))) constraints = resolve_pair_bisector_constraints( pts, [(100, 200, 0.5)], @@ -107,7 +107,7 @@ def test_report_json_helpers_roundtrip_plain_report(tmp_path): [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [4.0, 0.0, 0.0]], dtype=float, ) - box = Box(((-5.0, 5.0), (-5.0, 5.0), (-5.0, 5.0))) + 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)], @@ -173,3 +173,119 @@ def test_active_set_report_supports_planar_tessellation_diagnostics() -> 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)} diff --git a/tests/test_powerfit_validation_regressions.py b/tests/test_powerfit_validation_regressions.py index 22670ac..2e0ec95 100644 --- a/tests/test_powerfit_validation_regressions.py +++ b/tests/test_powerfit_validation_regressions.py @@ -314,3 +314,55 @@ def test_active_set_supports_pre_resolved_planar_constraints(): 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, + ) From 9f9a89dbdaf490d57ea236485a990fd11764c479 Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Tue, 17 Mar 2026 11:25:00 +0300 Subject: [PATCH 23/24] Adds active-set path diagnostics for powerfit --- CHANGELOG.md | 4 +- DEV_PLAN.md | 6 +- docs/guide/powerfit.md | 27 ++- docs/guide/visualization.md | 4 +- docs/notebooks/04_powerfit.ipynb | 57 ++----- docs/notebooks/06_powerfit_reports.ipynb | 11 +- docs/notebooks/08_powerfit_active_path.ipynb | 74 +++++++++ docs/project/roadmap.md | 8 +- mkdocs.yml | 1 + src/pyvoro2/__init__.py | 2 + src/pyvoro2/powerfit/__init__.py | 2 + src/pyvoro2/powerfit/active.py | 166 ++++++++++++++++++- src/pyvoro2/powerfit/realize.py | 5 +- src/pyvoro2/powerfit/report.py | 66 ++++++++ src/pyvoro2/powerfit/solver.py | 5 +- src/pyvoro2/viz2d.py | 50 ++++-- tests/test_powerfit_active_set.py | 114 +++++++++++++ tests/test_powerfit_reports.py | 43 +++++ 18 files changed, 571 insertions(+), 74 deletions(-) create mode 100644 docs/notebooks/08_powerfit_active_path.ipynb diff --git a/CHANGELOG.md b/CHANGELOG.md index 738e697..41c45db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,15 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - 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. ### 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, and realized-diagnostics warnings through the plain-Python report helpers. +- 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 optional planar `plot_tessellation(...)` helper now accepts `domain=` and `show_sites=` to match the published guide examples. ### Fixed diff --git a/DEV_PLAN.md b/DEV_PLAN.md index a66514c..7c9cfb1 100644 --- a/DEV_PLAN.md +++ b/DEV_PLAN.md @@ -39,7 +39,11 @@ Implemented focus: `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. + 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. The current preferred default policy for disconnected components is now the implemented behavior: diff --git a/docs/guide/powerfit.md b/docs/guide/powerfit.md index de99778..98625d1 100644 --- a/docs/guide/powerfit.md +++ b/docs/guide/powerfit.md @@ -159,6 +159,12 @@ anchor-order dependent: - `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 @@ -239,9 +245,18 @@ Useful fields include: - `result.realized`: realized-face matching diagnostics, including `unaccounted_pairs` when the final tessellation realizes candidate-absent pairs, -- `result.connectivity`: candidate-graph and active-graph connectivity +- `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, @@ -251,6 +266,12 @@ Useful fields include: - `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` @@ -322,7 +343,7 @@ The main current restriction is geometric, not algebraic: ### Objective-model scope for 0.6.1 -The 0.6.0 series intentionally keeps the built-in objective family compact: +The 0.6.1 line still keeps the built-in objective family compact: - mismatch terms: `SquaredLoss`, `HuberLoss` - hard feasibility: `Interval`, `FixedValue` @@ -335,7 +356,7 @@ 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.0 does **not** try to +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 diff --git a/docs/guide/visualization.md b/docs/guide/visualization.md index bb9ce04..855db9a 100644 --- a/docs/guide/visualization.md +++ b/docs/guide/visualization.md @@ -44,7 +44,9 @@ 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. +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 3D example diff --git a/docs/notebooks/04_powerfit.ipynb b/docs/notebooks/04_powerfit.ipynb index 4a67759..6fcc40c 100644 --- a/docs/notebooks/04_powerfit.ipynb +++ b/docs/notebooks/04_powerfit.ipynb @@ -44,25 +44,7 @@ "id": "ffa208ac", "metadata": {}, "outputs": [], - "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 = pv.resolve_pair_bisector_constraints(\n", - " points,\n", - " [(0, 1, 0.25)],\n", - " measurement='fraction',\n", - " domain=box,\n", - ")\n", - "\n", - "fit = pv.fit_power_weights(points, constraints, r_min=1.0)\n", - "\n", - "print('weights:', fit.weights)\n", - "print('radii:', fit.radii)\n", - "print('predicted fraction:', fit.predicted_fraction)\n", - "print('predicted position:', fit.predicted_position)\n", - "print('status:', fit.status)\n" - ] + "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", @@ -157,27 +139,22 @@ "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": [ - "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", - ")\n", - "box3 = pv.Box(((-5, 5), (-5, 5), (-5, 5)))\n", - "\n", - "result = 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", - "\n", - "print('termination:', result.termination)\n", - "print('active mask:', result.active_mask)\n", - "print('constraint status:', result.diagnostics.status)\n", - "print('marginal constraints:', result.marginal_constraints)\n" + "## 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" ] } ], @@ -194,4 +171,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/docs/notebooks/06_powerfit_reports.ipynb b/docs/notebooks/06_powerfit_reports.ipynb index 1ecc15a..79653bb 100644 --- a/docs/notebooks/06_powerfit_reports.ipynb +++ b/docs/notebooks/06_powerfit_reports.ipynb @@ -34,7 +34,7 @@ "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 r_min=1.0,\n)\n\nfit_rows = fit.to_records(constraints, use_ids=True)\nfit_report = fit.to_report(constraints, use_ids=True)\nfit_report[\"summary\"]\n" + "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", @@ -53,12 +53,19 @@ "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" + "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", diff --git a/docs/notebooks/08_powerfit_active_path.ipynb b/docs/notebooks/08_powerfit_active_path.ipynb new file mode 100644 index 0000000..346328a --- /dev/null +++ b/docs/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/docs/project/roadmap.md b/docs/project/roadmap.md index 25a101d..1207a6f 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -30,6 +30,8 @@ mis-specified candidate graphs: 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. @@ -50,9 +52,9 @@ part of the public contract. ### Visualization usability The optional viewers (`pyvoro2[viz]` / `pyvoro2[viz2d]`) are intended as -lightweight debugging and exploration tools. Future work is expected to focus -on usability and examples, not on making visualization a heavy core -dependency. +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 diff --git a/mkdocs.yml b/mkdocs.yml index 674e45e..f75cb1d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,6 +83,7 @@ nav: - notebooks/05_visualization.ipynb - notebooks/06_powerfit_reports.ipynb - notebooks/07_powerfit_infeasibility.ipynb + - notebooks/08_powerfit_active_path.ipynb - API reference: - Spatial (3D): - Domains: reference/domains.md diff --git a/src/pyvoro2/__init__.py b/src/pyvoro2/__init__.py index 02889f8..2c51c17 100644 --- a/src/pyvoro2/__init__.py +++ b/src/pyvoro2/__init__.py @@ -68,6 +68,7 @@ write_report_json, ActiveSetOptions, ActiveSetIteration, + ActiveSetPathSummary, PairConstraintDiagnostics, SelfConsistentPowerFitResult, fit_power_weights, @@ -129,6 +130,7 @@ 'write_report_json', 'ActiveSetOptions', 'ActiveSetIteration', + 'ActiveSetPathSummary', 'PairConstraintDiagnostics', 'SelfConsistentPowerFitResult', 'fit_power_weights', diff --git a/src/pyvoro2/powerfit/__init__.py b/src/pyvoro2/powerfit/__init__.py index ed3a3f6..fd67d70 100644 --- a/src/pyvoro2/powerfit/__init__.py +++ b/src/pyvoro2/powerfit/__init__.py @@ -17,6 +17,7 @@ from .active import ( ActiveSetIteration, ActiveSetOptions, + ActiveSetPathSummary, PairConstraintDiagnostics, SelfConsistentPowerFitResult, solve_self_consistent_power_weights, @@ -74,6 +75,7 @@ 'write_report_json', 'ActiveSetOptions', 'ActiveSetIteration', + 'ActiveSetPathSummary', 'PairConstraintDiagnostics', 'SelfConsistentPowerFitResult', 'fit_power_weights', diff --git a/src/pyvoro2/powerfit/active.py b/src/pyvoro2/powerfit/active.py index 9d322e0..c3b1419 100644 --- a/src/pyvoro2/powerfit/active.py +++ b/src/pyvoro2/powerfit/active.py @@ -90,6 +90,43 @@ class ActiveSetIteration: 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) @@ -184,7 +221,8 @@ class SelfConsistentPowerFitResult: TessellationDiagnostics2D | TessellationDiagnostics3D | None ) history: tuple[ActiveSetIteration, ...] | None - warnings: tuple[str, ...] + path_summary: ActiveSetPathSummary | None = None + warnings: tuple[str, ...] = () connectivity: ConnectivityDiagnostics | None = None def to_records(self, *, use_ids: bool = False) -> tuple[dict[str, object], ...]: @@ -292,6 +330,8 @@ def solve_self_consistent_power_weights( 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} @@ -368,7 +408,7 @@ def solve_self_consistent_power_weights( resolved, active, model=model, - gauge_policy=_self_consistent_gauge_policy_description(), + gauge_policy=gauge_policy, ) _apply_connectivity_policy( connectivity_check, @@ -390,6 +430,7 @@ def solve_self_consistent_power_weights( 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, ) @@ -410,6 +451,12 @@ def solve_self_consistent_power_weights( 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, @@ -424,9 +471,16 @@ def solve_self_consistent_power_weights( return_cells=False, return_tessellation_diagnostics=False, tessellation_check='none', - unaccounted_pair_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 @@ -479,6 +533,21 @@ def solve_self_consistent_power_weights( 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, ) ) @@ -636,7 +705,7 @@ def solve_self_consistent_power_weights( resolved, active, model=model, - gauge_policy=_self_consistent_gauge_policy_description(), + gauge_policy=gauge_policy, ) _apply_connectivity_policy( connectivity_check, @@ -659,6 +728,7 @@ def solve_self_consistent_power_weights( 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, ) @@ -700,6 +770,94 @@ def _self_consistent_gauge_policy_description() -> str: ) +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, diff --git a/src/pyvoro2/powerfit/realize.py b/src/pyvoro2/powerfit/realize.py index c3dd8b1..b3c89b3 100644 --- a/src/pyvoro2/powerfit/realize.py +++ b/src/pyvoro2/powerfit/realize.py @@ -89,9 +89,12 @@ def __init__( message: str, unaccounted_pairs: tuple[UnaccountedRealizedPair, ...], ) -> None: - super().__init__(message) + 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: diff --git a/src/pyvoro2/powerfit/report.py b/src/pyvoro2/powerfit/report.py index 95d2fd4..1a88718 100644 --- a/src/pyvoro2/powerfit/report.py +++ b/src/pyvoro2/powerfit/report.py @@ -87,6 +87,46 @@ def _connectivity_record( } +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 @@ -292,6 +332,31 @@ def build_active_set_report( '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) + ), } ) @@ -330,6 +395,7 @@ def build_active_set_report( '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 ), diff --git a/src/pyvoro2/powerfit/solver.py b/src/pyvoro2/powerfit/solver.py index 66ded10..0cedb14 100644 --- a/src/pyvoro2/powerfit/solver.py +++ b/src/pyvoro2/powerfit/solver.py @@ -69,9 +69,12 @@ def __init__( message: str, diagnostics: ConnectivityDiagnostics, ) -> None: - super().__init__(message) + super().__init__(message, diagnostics) self.diagnostics = diagnostics + def __str__(self) -> str: + return str(self.args[0]) + @dataclass(frozen=True, slots=True) class PowerWeightFitResult: diff --git a/src/pyvoro2/viz2d.py b/src/pyvoro2/viz2d.py index d2d5813..5e9c9c7 100644 --- a/src/pyvoro2/viz2d.py +++ b/src/pyvoro2/viz2d.py @@ -9,6 +9,8 @@ def plot_tessellation( cells: Iterable[dict], *, ax=None, + domain=None, + show_sites: bool = False, annotate_ids: bool = False, ): """Plot planar cells using matplotlib. @@ -17,6 +19,9 @@ def plot_tessellation( 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: @@ -30,26 +35,37 @@ def plot_tessellation( 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 not vertices or not edges: - continue - 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]]) - - if annotate_ids: - site = cell.get('site') - if site is not None: - ax.text(float(site[0]), float(site[1]), str(cell.get('id', '?'))) + 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/tests/test_powerfit_active_set.py b/tests/test_powerfit_active_set.py index 6eb1b92..97b9d52 100644 --- a/tests/test_powerfit_active_set.py +++ b/tests/test_powerfit_active_set.py @@ -387,3 +387,117 @@ def fake_match_realized_pairs(*args, **kwargs): 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_reports.py b/tests/test_powerfit_reports.py index 4870fd1..bdb9a7f 100644 --- a/tests/test_powerfit_reports.py +++ b/tests/test_powerfit_reports.py @@ -87,6 +87,8 @@ def test_active_set_report_collects_nested_diagnostics_and_history(): 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 @@ -289,3 +291,44 @@ def test_active_set_report_uses_final_active_subset_and_top_level_connectivity() (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 From 11530dbbcb7ca2324f06b64bf8b1307f2dd2112f Mon Sep 17 00:00:00 2001 From: Ivan Chernyshov Date: Tue, 17 Mar 2026 14:12:36 +0300 Subject: [PATCH 24/24] Adds sync/release tooling --- .github/workflows/ci.yml | 93 +- .github/workflows/docs.yml | 32 +- CHANGELOG.md | 3 + DEV_PLAN.md | 4 + README.md | 18 +- docs/guide/notebooks.md | 54 + docs/guide/powerfit.md | 9 +- docs/index.md | 18 +- docs/notebooks/01_basic_compute.md | 490 ++ docs/notebooks/02_periodic_graph.md | 162 + docs/notebooks/03_locate_and_ghost.md | 97 + docs/notebooks/04_powerfit.md | 142 + docs/notebooks/05_visualization.md | 4153 +++++++++++++++++ docs/notebooks/06_powerfit_reports.md | 116 + docs/notebooks/07_powerfit_infeasibility.md | 68 + docs/notebooks/08_powerfit_active_path.md | 46 + docs/project/about.md | 8 + docs/project/roadmap.md | 12 + docs/requirements.txt | 1 - mkdocs.yml | 32 +- .../01_basic_compute.ipynb | 0 .../02_periodic_graph.ipynb | 0 .../03_locate_and_ghost.ipynb | 0 .../notebooks => notebooks}/04_powerfit.ipynb | 0 .../05_visualization.ipynb | 0 .../06_powerfit_reports.ipynb | 0 .../07_powerfit_infeasibility.ipynb | 0 .../08_powerfit_active_path.ipynb | 0 notebooks/README.md | 18 + pyproject.toml | 24 +- src/pyvoro2/viz2d.py | 18 +- tests/test_notebook_checker_import_mode.py | 35 + tests/test_notebooks_meta.py | 39 + tests/test_readme_sync.py | 20 + tests/test_release_tools.py | 31 + tests/test_text_generation_tools.py | 32 + tools/README.md | 26 + tools/check_dist.py | 122 + tools/check_notebooks.py | 140 + tools/export_notebooks.py | 198 + tools/gen_readme.py | 34 +- tools/release_check.py | 109 + 42 files changed, 6318 insertions(+), 86 deletions(-) create mode 100644 docs/guide/notebooks.md create mode 100644 docs/notebooks/01_basic_compute.md create mode 100644 docs/notebooks/02_periodic_graph.md create mode 100644 docs/notebooks/03_locate_and_ghost.md create mode 100644 docs/notebooks/04_powerfit.md create mode 100644 docs/notebooks/05_visualization.md create mode 100644 docs/notebooks/06_powerfit_reports.md create mode 100644 docs/notebooks/07_powerfit_infeasibility.md create mode 100644 docs/notebooks/08_powerfit_active_path.md rename {docs/notebooks => notebooks}/01_basic_compute.ipynb (100%) rename {docs/notebooks => notebooks}/02_periodic_graph.ipynb (100%) rename {docs/notebooks => notebooks}/03_locate_and_ghost.ipynb (100%) rename {docs/notebooks => notebooks}/04_powerfit.ipynb (100%) rename {docs/notebooks => notebooks}/05_visualization.ipynb (100%) rename {docs/notebooks => notebooks}/06_powerfit_reports.ipynb (100%) rename {docs/notebooks => notebooks}/07_powerfit_infeasibility.ipynb (100%) rename {docs/notebooks => notebooks}/08_powerfit_active_path.ipynb (100%) create mode 100644 notebooks/README.md create mode 100644 tests/test_notebook_checker_import_mode.py create mode 100644 tests/test_notebooks_meta.py create mode 100644 tests/test_readme_sync.py create mode 100644 tests/test_release_tools.py create mode 100644 tests/test_text_generation_tools.py create mode 100644 tools/README.md create mode 100644 tools/check_dist.py create mode 100644 tools/check_notebooks.py create mode 100644 tools/export_notebooks.py create mode 100644 tools/release_check.py 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 41c45db..506c730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - 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 @@ -18,6 +19,8 @@ The format is based on *Keep a Changelog*, and this project follows *Semantic Ve - 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 diff --git a/DEV_PLAN.md b/DEV_PLAN.md index 7c9cfb1..f6dbc52 100644 --- a/DEV_PLAN.md +++ b/DEV_PLAN.md @@ -44,6 +44,10 @@ Implemented focus: 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: diff --git a/README.md b/README.md index 0e0f102..8d37b14 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ implementation-oriented details. | [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/notebooks/01_basic_compute/) | End-to-end examples, including focused power-fitting notebooks for reports and infeasibility witnesses. | +| [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 @@ -183,6 +183,8 @@ 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): @@ -190,6 +192,12 @@ To build from source (requires a C++ compiler and Python development headers): 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: @@ -222,6 +230,14 @@ 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**. 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/powerfit.md b/docs/guide/powerfit.md index 98625d1..eeb0823 100644 --- a/docs/guide/powerfit.md +++ b/docs/guide/powerfit.md @@ -361,14 +361,17 @@ freeze an open-ended callback API for arbitrary user-defined objectives. ## Worked example notebooks -Two focused notebooks complement the guide: +Three focused notebooks complement the guide: -- [`06_powerfit_reports.ipynb`](../notebooks/06_powerfit_reports.ipynb) +- [`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.ipynb`](../notebooks/07_powerfit_infeasibility.ipynb) +- [`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 diff --git a/docs/index.md b/docs/index.md index 06d83f3..33e1b5f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -161,7 +161,7 @@ implementation-oriented details. | [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)](notebooks/01_basic_compute.ipynb) | End-to-end examples, including focused power-fitting notebooks for reports and infeasibility witnesses. | +| [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 @@ -176,6 +176,8 @@ 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): @@ -183,6 +185,12 @@ To build from source (requires a C++ compiler and Python development headers): 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: @@ -215,6 +223,14 @@ 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**. 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_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 dc61b40..1680e62 100644 --- a/docs/project/about.md +++ b/docs/project/about.md @@ -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/roadmap.md b/docs/project/roadmap.md index 1207a6f..e493075 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -18,6 +18,18 @@ honest: It does **not** yet promise a planar oblique-periodic analogue of the 3D `PeriodicCell`. +### Documentation and release hygiene in 0.6.1 + +The 0.6.1 line also cleans up the repository-facing documentation workflow: + +- 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 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 f75cb1d..379cc77 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 @@ -75,15 +60,16 @@ nav: - Topology and graphs: guide/topology.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_powerfit.ipynb - - notebooks/05_visualization.ipynb - - notebooks/06_powerfit_reports.ipynb - - notebooks/07_powerfit_infeasibility.ipynb - - notebooks/08_powerfit_active_path.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: - Spatial (3D): - Domains: reference/domains.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/docs/notebooks/04_powerfit.ipynb b/notebooks/04_powerfit.ipynb similarity index 100% rename from docs/notebooks/04_powerfit.ipynb rename to notebooks/04_powerfit.ipynb 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/docs/notebooks/06_powerfit_reports.ipynb b/notebooks/06_powerfit_reports.ipynb similarity index 100% rename from docs/notebooks/06_powerfit_reports.ipynb rename to notebooks/06_powerfit_reports.ipynb diff --git a/docs/notebooks/07_powerfit_infeasibility.ipynb b/notebooks/07_powerfit_infeasibility.ipynb similarity index 100% rename from docs/notebooks/07_powerfit_infeasibility.ipynb rename to notebooks/07_powerfit_infeasibility.ipynb diff --git a/docs/notebooks/08_powerfit_active_path.ipynb b/notebooks/08_powerfit_active_path.ipynb similarity index 100% rename from docs/notebooks/08_powerfit_active_path.ipynb rename to notebooks/08_powerfit_active_path.ipynb 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 3ca4ccb..0de5344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,12 +47,17 @@ 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', ] @@ -64,6 +69,21 @@ viz = [ '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] wheel.packages = ['src/pyvoro2'] cmake.version = '>=3.20' diff --git a/src/pyvoro2/viz2d.py b/src/pyvoro2/viz2d.py index 5e9c9c7..d694a8f 100644 --- a/src/pyvoro2/viz2d.py +++ b/src/pyvoro2/viz2d.py @@ -2,17 +2,27 @@ from __future__ import annotations -from typing import Iterable +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=None, - domain=None, + 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: 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_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/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/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())