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 connectors between sites.
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.7 migration guide.
The v0.7 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
The repository-owned examples/chemvoro_workflow.py script is the canonical
chemistry-neutral downstream example. It uses only preferred v0.7 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.
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.
The resolved SeparatorObservations object stores the validated pair indices,
shifts, connector geometry, and targets in both fraction and position form.
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(...)
Step 3: fit power weights
fit = inverse.fit_weights_from_separators(
points,
observations,
model=model,
)
For squared-loss static fits, the default solver='auto' continues to choose
the NumPy dense direct path. Select the optional SciPy sparse-direct path
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='sparse',
)
print(sparse_fit.solver_termination.backend) # sparse
solver='analytic' explicitly selects the dense quadratic path. There is no
automatic size threshold in v0.7: auto remains dense for compatibility and
predictable dependency behavior. Sparse execution supports SquaredLoss with
optional L2 regularization and no scalar penalties or hard restrictions.
Huber mismatch, hard constraints, and scalar-penalty models continue to use the
existing ADMM path and reject solver='sparse'.
The sparse path changes only matrix storage and direct linear algebra. It uses
the same observation rows, periodic image labels, effective graph, component
anchors, and final component-alignment policy as the dense path. The returned
backend is inspectable through both fit.solver and
fit.solver_termination.backend.
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,- 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.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, 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 accepts the originating resolved observations or an
independently resolved set with the same complete contents. It validates pair
indices, confidence, targets, requested shifts, and resolved geometry before
presenting observation-owned arrays beside the fit predictions. Its private
source binding survives shallow copies, deep copies, pickle round trips, and
dataclasses.replace(...); a directly constructed result without source
observations cannot safely provide this view and raises ValueError.
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.
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 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.
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 solver='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.
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 scalar
penalties. Huber mismatch and 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,
),
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, includingunaccounted_pairswhen the final tessellation realizes candidate-absent pairs,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: 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.
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.
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 = 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-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.
Existing report sections map to the same layers without changing report keys:
| 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 fit, realized, diagnostics, summary, history, and path_summary |
final inner fit, final realization, candidate diagnostics, outer termination, and active-set path |
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)
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.
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 historical pyvoro2.powerfit package, broad top-level separator exports,
and the five historical core names remain deprecated compatibility routes for
v0.7. They will be removed in v0.8. See the
compatibility reference,
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.