Fit power weights from separator observations
pyvoro2 can solve a fixed-site inverse problem for power/Laguerre
tessellations: fit power weights so that selected pairwise separators land at
desired locations along the connector lines between sites. A normalized
connector fraction may be any finite real value; restricting it to the segment
between the two sites is a separate model constraint.
New code should begin with the concise fixed-observation surface in
pyvoro2.inverse. Advanced objective models, realization checks, reports, and
the experimental active-set outer loop live in
pyvoro2.inverse.separator.
The API is geometry-first and domain-agnostic. The same high-level functions
work with supported 3D domains and planar pyvoro2.planar domains. Downstream
code decides:
- which site pairs are observed or proposed;
- which periodic image shift belongs to each observation;
- the target separator location;
- and the confidence of each observation.
pyvoro2 then provides the mathematical and geometric layers:
- resolve and validate separator observations;
- fit power weights under a configurable convex model;
- expose graph, connectivity, and hard-feasibility diagnostics;
- compute the resulting power tessellation;
- detect which requested pairs and periodic images are realized;
- and optionally run a realization-aware active-set outer loop.
The fixed-observation inverse fit and the geometric realization check answer different questions. For the API-independent derivation, see Inverse fitting from separator observations.
For namespace selection and lifecycle status, begin with Choosing an API. The glossary defines gauge, component offsets, representation shift, realized face, and active set. Users migrating from v0.6.3 should also read the v0.6.3–v0.8 migration guide.
The high-level resolver, observation container, fit result, fit entry point, and neutral transforms are stable. Advanced models, problem and operator views, report/realization helpers, and layered convenience views are provisional. Active-set refinement is experimental. The optional explicit sparse quadratic backend is provisional and supports large static sparse observation graphs only.
Canonical downstream integration
External IDs follow the same contract throughout separator resolution,
fitting, active-set refinement, realization records, and reports: provide one
unique non-negative integer per input site. Python integers and NumPy integer
scalars are accepted. With index_mode='id', raw observation endpoints must be
those exact integer IDs; floats, numeric strings, and booleans are rejected
rather than converted.
The same exact policy applies to direct observation indices, periodic shift
components, search and iteration counts, and active-set hysteresis counts.
Public flags and masks accept only Python or NumPy Booleans. Confidence,
targets, model parameters, regularization references, solver tolerances,
r_min, and optional weight_shift must use finite real numeric values in
their documented positive, non-negative, or signed ranges. Validation happens
before casting or solver work.
Resolved observations own read-only C-contiguous copies of their numerical and
mask data. Model scalars are canonical built-in floats,
L2Regularization.reference is an owned read-only float64 copy even when its
strength is zero, and FitModel.penalties is an owned tuple. Mutating a caller
array or list after construction therefore does not change these values.
The repository-owned examples/chemvoro_workflow.py script is the canonical
chemistry-neutral downstream example. It uses only current canonical imports and
keeps application metadata outside pyvoro2 in an external-ID-keyed sidecar:
import numpy as np
import pyvoro2 as pv
import pyvoro2.inverse as inverse
import pyvoro2.inverse.separator as separator
points = np.array([[0.1, 0.5, 0.5], [0.9, 0.5, 0.5]])
site_ids = np.array([205, 101], dtype=int)
metadata_by_id = {
205: {'label': 'left-site', 'source_row': 0},
101: {'label': 'right-site', 'source_row': 1},
}
cell = pv.PeriodicCell(
vectors=((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))
)
observations = inverse.resolve_separator_observations(
points,
[(205, 101, 0.5, (-1, 0, 0))],
ids=site_ids,
index_mode='id',
domain=cell,
image='given_only',
)
fit = inverse.fit_weights_from_separators(
points,
observations,
connectivity_check='diagnose',
)
weights = fit.state.mathematical_weights
result = pv.compute(
points,
domain=cell,
ids=site_ids,
mode='power',
weights=weights,
include_empty=True,
return_faces=True,
return_face_shifts=True,
)
boundaries_by_input = result.require_boundaries()
realized = separator.match_realized_pairs(
points,
domain=cell,
weights=weights,
constraints=observations,
)
fit_rows = fit.to_records(observations, use_ids=True)
fit_report = fit.to_report(observations, use_ids=True)
result.ids, result.cell_measures, result.empty_mask, and the collections
returned by require_boundaries() share input-site order. A downstream package
can therefore build its own ID-labelled rows with an explicit
None if empty else measure policy without reading raw backend order. Boundary
records preserve external neighbor IDs and adjacent_shift; fit and
realization records use external IDs when use_ids=True.
Inspect fit.identification before comparing fitted state across observation
components. fit.state.global_representation_shift records only the common
backend representation shift; it is not a fitted physical quantity. The
complete executable example also demonstrates same-image and wrong-image
realization reporting and JSON-friendly report export. The public deterministic
paper-style ladder is in examples/paper_regressions.py; both scripts and their
run instructions are described in examples/README.md.
Geometry of one pair
For a pair of sites i and j, choose one specific image q_j of site j.
In a nonperiodic domain, q_j = p_j. Let
d = ||q_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:
for normalized fraction, and
for absolute position measured from site i.
The connector line extends beyond both sites, so neither the geometry nor the
measurement type inherently restricts t to [0, 1]. Add an explicit
between-sites restriction when that is part of the observation model.
This is why pyvoro2 exposes the measurement type explicitly: a loss in
fraction-space and a loss in position-space are different optimization
problems.
Step 1: resolve separator observations once
import numpy as np
import pyvoro2 as pv
import pyvoro2.inverse as inverse
import pyvoro2.inverse.separator as separator
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)))
observations = inverse.resolve_separator_observations(
points,
[(0, 1, 0.25)],
measurement='fraction',
domain=box,
)
Each raw tuple is (i, j, value[, shift]), where shift=(na, nb, nc) is the
integer lattice image applied to site j.
An explicit shift names that image and is used unchanged, even if another
image is nearer. If a periodic observation omits the shift and
image='nearest', pyvoro2 certifies the minimum-image displacement for the
exact binary64 values supplied. Exact ties use stable endpoint orientation, so
reversing an ordered pair negates its shift and displacement. For separator
inference, the stable keys are the resolved internal site indices in the fixed
point ordering. External IDs remain metadata and do not participate in exact-
tie geometry; relabeling them cannot change the selected image. This does not
define a point-array permutation invariant or a public tie mode. The public
image_search=1 default remains available as a performance hint: it seeds a
bounded incumbent search but cannot change a successful shift, displacement,
or distance. Certification that exceeds the private bounded resource contract
fails explicitly; increasing image_search is not a correctness remedy.
The resolved SeparatorObservations object stores the validated pair indices,
shifts, connector geometry, and targets in both fraction and position form.
Resolver-created observations are also bound to the exact caller-order points,
domain representation, dimension/count, and ID provenance used here. The
public direct constructor remains useful for already-resolved row data; a valid
directly constructed object is source-unbound rather than being assigned
fabricated points or a domain.
When such an already resolved object is passed to a fitting function with
points, those points establish or verify exact source points. Leaving
domain=None makes no additional domain assertion and does not erase a domain
already bound by the resolver. On an unbound object, the same call establishes
an exact no-domain source; supplying a non-None domain establishes or verifies
that exact representation. The observation object's owned IDs remain
authoritative. Realization and active-set operations always verify the full
source they use.
Every valid object has source-independent row IDs and an ordered observation-set fingerprint. Row identity includes the complete canonical row model but not warnings. Subsetting retains row IDs and input indices, duplicate rows remain distinct, and reordering changes the set fingerprint. A later verified source binding never changes those identities.
Step 2: define the fitting model
model = separator.FitModel(
mismatch=separator.SquaredLoss(),
feasible=separator.Interval(0.0, 1.0),
penalties=(
separator.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(...)
For residual
the squared loss is \(\frac12e_r^2\). Huber loss is
Row confidence multiplies only the mismatch loss. L2Regularization contributes
so its gradient is \(\lambda(w-w^{\mathrm{ref}})\) and its Hessian contribution
is \(\lambda I\). A reference with strength=0 contributes nothing to the
objective; the existing output-alignment policy may still use it to choose
otherwise unidentified component offsets.
SoftIntervalPenalty(a, b, s) contributes
while ExponentialBoundaryPenalty(a, b, m, s, tau) contributes
Those existing strengths are not rescaled by a one-half factor. For one
inward distance \(d\), ReciprocalBoundaryPenalty uses
and contributes \(q(y-a)+q(b-y)\). The linear branch is used at
\(d=\epsilon\) and the inactive branch at \(d=m\). Value and first derivative are
continuous at \(\epsilon\); the derivative jumps at \(m\), so no unique derivative
is claimed there. The parameters require upper > lower, margin > 0,
0 < epsilon < margin, 2 * margin <= upper - lower, and strength >= 0.
A scalar penalty with strength=0 is mathematically absent. Its value and
derivatives are exactly zero without evaluating dangerous branch expressions;
it does not force ADMM, hide the quadratic operator, change graph coupling, or
change the fitted solution. An exactly zero named report component may remain.
For hard lower bound \(a\), prediction \(y\), and upper bound \(b\), classification uses
where \(\epsilon_{64}\) is float64 machine epsilon. The row is satisfied exactly when \(v\le t\). This roundoff policy is also used by the hard-feasibility graph check and is separate from the ADMM stopping tolerances.
Step 3: fit power weights
fit = inverse.fit_weights_from_separators(
points,
observations,
model=model,
)
The default is a certified direct solve with dense NumPy linear algebra. Select sparse SciPy linear algebra explicitly when the fixed observation graph is large and local:
python -m pip install "pyvoro2[sparse]"
sparse_fit = inverse.fit_weights_from_separators(
points,
observations,
solver='direct',
linear_backend='sparse',
)
print(sparse_fit.solver_termination.solver) # direct
print(sparse_fit.solver_termination.linear_backend) # sparse
solver='direct' accepts a purely quadratic model. Huber mismatch, hard
constraints, and positive-strength scalar penalties require
solver='admm'. Whenever a component solve is required, explicit ADMM runs the
iterative method, including for a purely quadratic model.
linear_backend='dense' never imports SciPy;
linear_backend='sparse' explicitly requires it. Neither route changes backend
at a site-count threshold.
All four quadratic method/backend combinations use one continuous-objective
success contract. A floating objective that rounds to zero is not sufficient:
the returned binary64 weights must either make every active source term exactly
zero or pass the universal forward objective-gap certificate. Exact small-case
helpers may propose or evaluate candidates, but coordinatewise rounding of an
exact optimum is not treated as a separate discrete-optimality proof. A
continuous optimum that cannot be represented accurately by the returned
binary64 vector therefore produces status='numerical_failure' rather than a
weaker meaning of optimal.
The linear backend changes only matrix storage and weight-system linear
algebra. Both backends use the same observation rows, periodic image labels,
effective graph, component anchors, final component-alignment policy, and
quadratic success certificate. The method and backend are inspectable through
fit.solver, fit.linear_backend, and the corresponding fields on
fit.solver_termination. A degenerate fit that needs no component solve, such
as an empty observation set or an all-zero-confidence model with only singleton
components, reports solver='none', linear_backend=None, and n_iter=0.
When ADMM has completed iterations but final quadratic certification fails,
n_iter retains the completed iteration count in the structured numerical
failure result.
ADMM scalar updates are themselves certified. Mismatch-only squared and Huber
rows keep their vectorized proximal path. A row that needs a positive-strength
scalar penalty uses a private bracketed solver with exact branch breakpoints,
rigorous one-sided derivative enclosures, scaled binary64 exponential
evaluation, and safeguarded Newton/ordered-float bisection. It returns a
coordinate only with proved exact point signs or an adjacent numeric-float sign
bracket. Adjacent endpoints are selected by a direct termwise objective
difference. A bracket, evaluation,
expansion, or iteration failure produces status='numerical_failure' with no
stale weights or objective breakdown; status_detail identifies the original
and component-local observation rows and retains scalar iteration, expansion,
candidate, bracket, derivative, localization, and fallback evidence. A failure
during ADMM attempt k normally records
only the k - 1 iterations that completed.
The v0.8 migration is:
| Removed call | Current call |
|---|---|
solver='auto' |
solver='direct', linear_backend='dense' |
solver='analytic' |
solver='direct', linear_backend='dense' |
solver='sparse' |
solver='direct', linear_backend='sparse' |
solver='admm' |
solver='admm', linear_backend='dense' |
The removed ADMM keywords max_iter, rho, tol_abs, and tol_rel become
admm_max_iter, admm_rho, admm_abs_tol, and admm_rel_tol. The active-set
wrapper uses the same names with a fit_ prefix.
The result contains:
- fitted
weightsand shiftedradii, - predicted separator locations in both fraction and position form,
- residuals in the chosen measurement space,
edge_diagnosticswith quantities such asz_obs,z_fit, and weighted difference-space inconsistency summaries,objective_breakdownwith mismatch, penalty, and regularization totals for the packaged candidate weights, plushard_max_violationand the scale-awarehard_max_toleranceused for hard-bound classification,- solver/termination metadata including optional
status_detail, - and explicit infeasibility reporting for contradictory hard constraints.
Read a fit through its scientific layers
The layers are deliberately one-directional rather than one monolithic result:
resolved observations + sites
|
v
fixed-observation fit
|-- fitted state and identification
|-- observation predictions and objective
|-- graph and operator diagnostics
`-- fixed-solver termination
|
v (explicit forward realization request)
realized geometry and requested-image matching
|
v (experimental outer loop only)
active-set path and outer termination
A small algebraic residual does not imply that the requested pair is a realized face. Realization and the active-set path are therefore separate objects and separate lifecycle layers.
SeparatorFitResult keeps all existing flat fields and adds lightweight
layered views. The views reference existing arrays; they do not copy fitted or
observation data.
state = fit.state
print(state.mathematical_weights)
print(state.backend_radii, state.global_representation_shift)
observation_fit = fit.observation_view(observations)
print(observation_fit.targets, observation_fit.confidence)
print(observation_fit.predictions, observation_fit.residuals)
identification = fit.identification
print(identification.effective_observation_components)
print(identification.relative_component_offsets_identified_by_data)
print(identification.component_offsets_selected_by_objective)
print(identification.component_alignment_policy)
termination = fit.solver_termination
print(
termination.status,
termination.solver,
termination.linear_backend,
termination.converged,
)
The identification view always reports
global_geometric_gauge_identified_by_data == False: separator differences do
not identify one common additive constant. The informative observation graph
contains only positive-confidence separator rows. Its connected components are
reported by effective_observation_components, and
relative_component_offsets_identified_by_data is true exactly when this graph
is connected. A positive L2 regularization term guarantees selection of
otherwise free component offsets and is reported by
component_offsets_selected_by_objective. Other supported scalar penalties are
not classified as selecting offsets because they may have flat regions or zero
strength.
identification.unconstrained_sites reports sites isolated in that informative
graph. This can differ from the compatibility diagnostic
fit.connectivity.unconstrained_points, which retains its candidate-graph
meaning. A site mentioned only by zero-confidence rows is candidate-connected
but observationally unconstrained. Hard restrictions and penalties apply
independently of mismatch confidence and may constrain or bound offsets, but
they are not separator-observation data and never add informative graph edges.
An exact hard equality may fix an offset in a particular model; the current
identification view deliberately does not attempt to summarize that separate
constraint-identifiability question.
With connectivity_check='none', connectivity-derived identification values
are None; accessing the view does not rebuild diagnostics that the caller
disabled.
The state view uses global_representation_shift for the common shift used to
form non-negative backend radii. This backend representation choice selects a
representative within the global geometric gauge; it is distinct from
independent component offsets and is not information recovered from separator
observations. The compatibility flat field fit.weight_shift has exactly this
meaning. Likewise, the compatibility field
fit.connectivity.gauge_policy contains the same string as the canonical
component_alignment_policy, despite its historical name.
The complete mapping is:
| Scientific layer | Canonical access | Existing flat fields or objects |
|---|---|---|
| Fitted state and backend representation | fit.state |
weights, radii, weight_shift |
| Identification and component alignment | fit.identification |
connectivity and its effective components, offset flags, effective-graph isolated sites, and gauge_policy |
| Observation-space fit | fit.observation_view(observations) |
measurement, target, predicted*, residuals, residual summaries, used_shifts; confidence comes from observations |
| Objective contributions | fit.objective |
objective_breakdown |
| Algebraic diagnostics | fit.algebraic |
edge_diagnostics, connectivity |
| Fixed-solver termination | fit.solver_termination |
status, status_detail, solver, linear_backend, n_iter, converged, hard_feasible, conflict, warnings |
| Requested-image matching and realized geometry | realized.requested_image_matching, realized.geometry |
all RealizedPairDiagnostics fields |
| Experimental outer-loop termination and path | result.outer_termination, result.path |
active-set termination fields, active_mask, marginal_constraints, history, path_summary |
The observation accessor applies one exact association rule before presenting observation-owned arrays beside fit predictions:
- two source-unbound objects match only when they have the exact same observation model;
- two source-bound objects match only when they have the exact same canonical source;
- a bound/unbound pair and two differently bound sources are rejected.
Length alone is never enough. Private observation origins and source bindings
survive subsets, shallow and deep copies, same-version pickle round trips,
dataclasses.replace(...), and copy.replace(...) where available. A
source-inconsistent replacement raises. Reports always use the authoritative
origin held by the result or diagnostic rather than borrowing provenance from
an arbitrary supplied object.
For example, if hard interval or equality restrictions cannot all hold simultaneously, the fit returns:
status == 'infeasible_hard_constraints'hard_feasible == Falseweights is Noneconflictwith a compact contradiction witnessconflicting_constraint_indicesfor 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. Every observation-aligned row
contains its stable row_id in addition to the existing fields.
Measurement-space and difference-space diagnostics
SeparatorFitResult exposes two complementary diagnostic views.
Measurement-space quantities live in the same space as the chosen separator targets:
target,predicted,residuals
Difference-space quantities live in the implied weight-difference model
with
The edge diagnostics expose alpha, beta, z_obs, z_fit, the difference-space
residual z_obs - z_fit, and edge weights
The exported weighted_rmse is defined explicitly as
not as sqrt(sum(w r^2) / sum(w)). That distinction matters when you compare
results to other code that uses a normalized weighted RMSE convention.
For radii output, the API makes the global representation shift explicit:
- by default,
weights_to_radii(...)uses the minimal common additive shift that makes all returned radii non-negative; r_min=remains available as a compatibility-oriented convenience when a specific minimum radius is required;weight_shift=lets downstream code request one explicit common shift.
Both conversion directions require finite inputs and finite representable
results. weights_to_radii(...) also requires a finite, non-negative r_min.
If squaring a radius or applying the common shift would overflow, the transform
raises ValueError; it does not clip or substitute a fallback value.
One common shift of every weight is the geometric gauge of the complete power diagram. Disconnected observation graphs introduce an additional and different ambiguity: each informative component can be shifted independently without changing its observed separator equations, but relative shifts between components can change the complete realized tessellation.
The current solver therefore chooses and reports a component-alignment policy:
- the numerical decomposition follows a model-coupling mask that includes positive-confidence rows and rows touched by hard restrictions or positive-strength penalties;
- without positive regularization or a supplied reference, disconnected model-coupling components are centered to mean zero;
- if a zero-strength regularization reference is supplied, disconnected model-coupling components are aligned to the reference mean;
- positive L2 regularization selects weights relative to its zero or supplied reference;
connectivity_check='none'|'diagnose'|'warn'|'raise'controls whether the observationally unidentified component offsets are reported, warned about, or raised.
The model-coupling mask is exposed under the historical problem-field name
offset_identifying_constraint_mask; despite that name, it is a solver
decomposition detail and is not an identifiability claim. Hard restrictions may
bound offsets, and penalties or numerical conventions may choose a returned
representative without guaranteeing a unique optimum. None of those values is
information identified by disconnected separator observations. Inspect
realization results when cross-component competition matters.
The experimental active-set wrapper carries offsets from one iterate to the next only for true zero-L2 gauge components. Its final post-refit alignment is kept only when exact binary64-input checks prove that all within-component weight differences are unchanged. Positive L2 removes this gauge, so the final certified L2 solution is returned without alignment to the previous outer iterate.
Connectivity is computed on the graph of site unknowns, not on a graph of periodic images. A periodic shift changes the geometry of one observation row and of realized-boundary matching, but it does not create an additional fitted unknown. Interpenetrating periodic nets therefore remain disconnected unless an observation actually couples their site indices.
Inspect the observation graph and quadratic normal operator
Advanced workflows can inspect the fixed-observation mathematics directly from
the public problem. The graph and operator views are provisional and are
exported only from pyvoro2.inverse.separator.
import numpy as np
import pyvoro2.inverse as inverse
import pyvoro2.inverse.separator as separator
points = np.array(
[[0.0, 0.0], [2.0, 0.0], [5.0, 0.0]],
dtype=float,
)
observations = inverse.resolve_separator_observations(
points,
[(0, 1, 0.20), (1, 2, 0.70), (0, 2, 0.40)],
confidence=[1.0, 0.5, 2.0],
)
model = separator.FitModel(
regularization=separator.L2Regularization(
strength=0.25,
reference=np.array([1.0, -1.0, 2.0]),
)
)
problem = separator.build_power_fit_problem(observations, model=model)
graph = problem.observation_graph
operator = problem.quadratic_operator
B = graph.incidence_dense()
L_obs = operator.observation_laplacian_dense()
A = operator.regularized_normal_matrix_dense()
b_obs = operator.observation_rhs
b = operator.regularized_normal_rhs
fit = inverse.fit_weights_from_separators(
points,
observations,
model=model,
connectivity_check='diagnose',
)
assert np.allclose(B.T @ fit.weights, problem.predict_difference(fit.weights))
assert np.allclose(
graph.beta + graph.alpha * (B.T @ fit.weights),
fit.predicted,
)
assert np.allclose(A @ fit.weights, b)
# Optional conversion; SciPy is also used by linear_backend='sparse'.
try:
B_sparse = graph.incidence_sparse(format='csc')
L_obs_sparse = operator.observation_laplacian_sparse(format='csr')
except ImportError:
B_sparse = L_obs_sparse = None
For n sites and m observations, B.shape == (n, m). Column r has
+1 at graph.site_i[r] and -1 at graph.site_j[r], hence
B.T @ weights == weights[site_i] - weights[site_j]. Every observation remains
a column: repeated measurements and observations for different periodic images
of the same site pair are not deduplicated. graph.observation_indices maps
those columns back to the resolved input indices, while
graph.requested_shifts retains image identity.
The view names the two systems separately:
and, for L2 strength \(\lambda\) and reference \(w^{\mathrm{ref}}\),
There is no extra factor of two. Zero-confidence rows stay in B and in all
row-facing arrays, but their informative mask is false and rho is zero, so
they add nothing to \(L_{\mathrm{obs}}\) or \(b_{\mathrm{obs}}\) and do not connect
informative components. The implementation constructs \(q\) directly with
scale-safe products; z_obs remains diagnostic and is not required to be
representable for a finite normal system.
Without positive L2 regularization, the observation-Laplacian nullity is the
number of informative components, including isolated sites. One common null
direction is global geometric gauge; additional component constants are
unidentified offsets that can affect the complete realized diagram. Positive
L2 regularization removes those null directions from A, but it does not make
the separator observations themselves connected or observationally identify
the offsets.
The repository benchmark harness
benchmarks/benchmark_sparse_separator.py covers small dense-favorable and
medium/large molecular-shaped locality graphs, including disconnected static
components. It records dense/sparse assembly, direct-solve and complete-fit
times, matrix storage, and numerical agreement. This scalability support is
for large static geometries. It does not provide trajectory processing, MD
frame reuse, prepared solvers across changing frames, parallel tessellation,
GPU/distributed execution, or scalable all-pairs observation construction.
The fixed normal system is available only for SquaredLoss with no
positive-strength scalar penalties. Zero-strength penalties are absent, so
they do not hide this view. Huber mismatch and positive-strength
scalar-penalty models still expose problem.observation_graph, but
problem.quadratic_operator raises ValueError rather than claiming to
represent their full objective. Hard interval or equality restrictions may
coexist with the quadratic view; they remain separately visible through
problem.bounds, and operator.normal_equations_characterize_fit is false
because a constrained optimum need not solve the unconstrained normal
equation.
Step 4: check geometric realization
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.
realized = separator.match_realized_pairs(
points,
domain=box,
weights=fit.state.mathematical_weights,
constraints=observations,
return_boundary_measure=True,
return_tessellation_diagnostics=True,
unaccounted_pair_check='warn',
)
weights= is the preferred realization input. radii= remains accepted as a
backend-compatible representation route; supply exactly one. The common shift
used to form radii is a geometric representation choice, not another fitted
scientific variable.
This returns purely geometric diagnostics:
- whether each pair is realized at all,
- whether it is realized with the same requested periodic shift,
- whether only some other image is realized,
- whether one of the endpoint cells is empty,
- an optional boundary measure of the matched boundary (face area in 3D, edge length in 2D),
- any realized unordered site pairs that were absent from the candidate set,
exposed through
unaccounted_pairs, - and optional tessellation-wide diagnostics.
Realization remains an explicit, separate computation. Read periodic-image matching and optional geometry independently:
matching = realized.requested_image_matching
print(matching.any_realization, matching.same_requested_shift)
print(matching.another_periodic_shift, matching.realized_shifts)
geometry = realized.geometry
print(geometry.endpoint_i_empty, geometry.endpoint_j_empty)
print(geometry.boundary_measure, geometry.tessellation_diagnostics)
The fit result never computes or owns a tessellation automatically.
Optional: refine the active set
For sparse or noisy candidate sets, the useful high-level workflow is often:
- fit on a current active set;
- run the actual power tessellation;
- keep or re-add observations according to realized support;
- repeat until active and realized sets agree.
This is a practical realization-aware outer algorithm. It is not part of the exact graph/Laplacian theory of the fixed-observation inner fit, and its termination status and path diagnostics should be inspected explicitly.
The explicitly experimental separator API provides this as:
result = separator.solve_self_consistent_power_weights(
points,
observations,
domain=box,
model=model,
options=separator.ActiveSetOptions(
add_after=1,
drop_after=2,
relax=0.5,
max_iter=25,
cycle_window=8,
),
fit_solver='admm',
fit_linear_backend='dense',
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: optional realized-face matching diagnostics, includingunaccounted_pairswhen the final tessellation realizes candidate-absent pairs; it isNonewhen the final fit has no usable weights,result.connectivity: final candidate-graph and active-graph connectivity diagnostics plus the component-alignment policy 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 the tessellation ever realized pairs that were absent from the candidate set,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 pairs absent from the candidate set on that iteration,result.diagnostics: optional 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, orNonewhen final weights are unavailable,result.tessellation_diagnostics: final tessellation-wide checks,result.marginal_constraints: indices of toggling / cycle / wrong-shift pairs.
The layered aliases make the inner/outer boundary explicit:
final_inner_fit = result.inner_fit
final_realization = result.final_realization
candidate_diagnostics = result.candidate_diagnostics
outer_termination = result.outer_termination
path = result.path
path.active_mask, path.marginal_constraint_indices, path.history, and
path.summary share the existing active-set result data. The outer termination
is experimental and does not change the exact fixed-observation meaning of
final_inner_fit. In particular, outer and inner convergence are distinct:
assert result.converged == (result.termination == 'self_consistent')
final_state_available = result.final_state_available
unavailable_reason = result.final_state_unavailable_reason
final_inner_converged = result.final_refit_converged
A final optimal or max_iter fit with complete finite weights has an
available state; all final geometry, predictions, residuals, and candidate
records are recomputed from those exact weights. A weighted max_iter result
remains non-converged at the inner layer. If a final fit has no usable weights,
final_realization, candidate_diagnostics, the candidate residual summaries,
and optional tessellation diagnostics are None. Historical path data,
connectivity, warnings, cycle metadata, and path-derived marginal indices
remain inspectable. No preceding realization is presented as the final one.
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_activestable_inactivetoggled_activetoggled_inactiverealized_other_shiftactive_unrealizedcycle_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:
rows = result.to_records(use_ids=True)
fit_rows = result.fit.to_records(result.constraints, use_ids=True)
realized_rows = (
None
if result.realized is None
else result.realized.to_records(result.constraints, use_ids=True)
)
if result.fit.conflict is not None:
conflict_rows = result.fit.conflict.to_records(ids=result.constraints.ids)
These helpers keep the core API numerical while making it straightforward to feed results into custom logs, JSON encoders, or dataframe construction in a downstream package.
Full report bundles
When downstream code wants a single nested object rather than several row sets, use the report helpers or the corresponding result methods:
fit_report = fit.to_report(observations, use_ids=True)
realized_report = realized.to_report(observations, use_ids=True)
solve_report = result.to_report(use_ids=True)
The standalone helpers are also exported:
fit_report = separator.build_fit_report(fit, observations, use_ids=True)
solve_report = separator.build_active_set_report(result, use_ids=True)
These report bundles stay plain-Python and JSON-native. 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. Every family
retains its existing kind and adds the same versioned envelope:
assert fit_report['schema'] == {
'name': 'pyvoro2.inverse.separator.report',
'version': 1,
}
assert fit_report['producer'] == {
'name': 'pyvoro2',
'version': pv.__version__,
}
source = fit_report['source']
observation_set = fit_report['observation_set']
observation_set has exactly fingerprint, measurement, n_rows, and
row_ids. source has exactly binding, fingerprint, dimension,
n_points, points, domain, and ids. Resolver-backed reports use
binding='bound' and retain exact caller-order points, the exact domain record,
and ID provenance. A report produced by the valid public row-only chain uses:
{
'binding': 'unbound',
'fingerprint': None,
'dimension': 2,
'n_points': 3,
'points': None,
'domain': None,
'ids': None,
}
An unbound null domain is different from a bound source whose domain is
{'kind': 'none'}. Bound domains retain exactly one of none, planar_box,
planar_rectangular_cell, spatial_box, spatial_orthorhombic_cell, and
spatial_periodic_cell.
Report sections map to the same scientific layers:
| Report section | Layer |
|---|---|
fit weights, radii, weight_shift |
state |
fit connectivity |
identification and graph context for algebraic diagnostics |
fit constraints, fit_records, and residual summaries in summary |
observations |
fit objective_breakdown |
objective |
fit edge_diagnostics |
algebraic diagnostics |
fit summary, conflict, and warnings |
fixed solver termination |
realized records, unrealized |
requested-image matching |
realized unaccounted_pairs, tessellation_diagnostics, and optional values in records |
realized geometry |
active-set availability, fit, realized, diagnostics, summary, history, and path_summary |
final-layer availability, final inner fit, optional final realization/candidate diagnostics, outer termination, and active-set path |
The exact fit objective_breakdown fields are total, mismatch,
penalties_total, penalty_terms, regularization,
hard_constraints_satisfied, hard_max_violation, and
hard_max_tolerance. The last two are respectively the maximum raw violation
and the maximum rowwise tolerance actually used, or zero when no hard bounds
exist.
To serialize them directly:
text = separator.dumps_report_json(solve_report, sort_keys=True)
separator.write_report_json(solve_report, 'solve_report.json', sort_keys=True)
dumps_report_json(...) rejects NaN and infinity. Fit, realized, and active
reports round-trip exactly through JSON, including active no-weights failures.
The active report's availability block records weights, realization,
records, and reason. When unavailable, all three flags are false, reason
is the final fit status, and weights-dependent report sections and summary
values are JSON null. The nested fit still reports its own status and
convergence while the active summary retains the outer stop reason.
Native Huber fit on sparse outliers
A short robust-fitting example looks like this:
model = separator.FitModel(mismatch=separator.HuberLoss(delta=0.03))
fit = inverse.fit_weights_from_separators(
points,
observations,
model=model,
solver='admm',
)
print(fit.status, fit.rms_residual)
print(fit.edge_diagnostics.weighted_rmse)
This is still the native pyvoro2 solver path. The robust part comes from the
measurement-space Huber objective, while edge_diagnostics lets you inspect
the mathematical difference-space residuals directly.
Advanced problem export and result packaging
For research workflows or external solvers, pyvoro2 now exposes the resolved
inverse problem itself:
observations = inverse.resolve_separator_observations(points, raw_observations)
problem = separator.build_power_fit_problem(observations, model=model)
weights = some_external_solver(problem)
result = separator.build_power_fit_result(
problem,
weights,
solver='external',
status='external_failure',
status_detail='candidate iterate only',
)
This keeps fit_weights_from_separators(...) solver-owned while giving downstream
code a public export of the mathematics, prediction formulas, objective
evaluation, and result packaging.
The result builder rejects status='optimal' or converged=True when any
reported soft-objective component or total is NaN or infinite. Native solver
paths turn such outcomes into status='numerical_failure'. Direct
problem.evaluate_objective(...) may still return positive infinity for hard
infeasibility or a genuine extended-real objective. Failure of the optional
direct ADMM warm start falls back to the reference or zero initialization and
does not by itself end the solve.
Current scope
The current implementation supports both 3D domains through pyvoro2 and
2D planar domains through pyvoro2.planar. The shared solver vocabulary is
intentionally dimension-safe: fitting is phrased in terms of separator
observations and generic boundary measure rather than chemistry-specific or
3D-only semantics.
The v0.7 compatibility package, broad top-level separator exports, and five historical core aliases are absent in v0.8. See the migration guide, architecture, and API lifecycle.
The main current restriction is geometric, not algebraic:
- 3D supports
Box,OrthorhombicCell, and triclinicPeriodicCell; - 2D currently supports
Boxand rectangularRectangularCell; - there is no planar oblique-periodic
PeriodicCellyet.
Current objective-model scope
The built-in objective family remains 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, pyvoro2 does not try to freeze an open-ended callback API for arbitrary user-defined objectives.
Worked example notebooks
Four focused notebooks complement the guide:
-
04_powerfitpresents the canonical external-ID, weight-first periodic workflow and then introduces advanced objective and active-set features. -
06_powerfit_reportsshows how to export low-level fits, realized-pair diagnostics, and self-consistent active-set results as rows or JSON-friendly reports. 07_powerfit_infeasibilityshows how contradictory hard restrictions are reported throughstatus,is_infeasible,conflict, and report bundles.08_powerfit_active_pathshows how to inspect transient active-set path diagnostics separately from the final-state report objects.
These examples are aimed at downstream packages that want to keep the solver API numerical while still producing human-readable logs, cached payloads, or UI views.