How to write a new Solver#

An hklpy2 Solver is an adapter [2] for a backend diffractometer computation library.

Steps#

To write a new solver for hklpy2, you need to create a Python class that inherits from SolverBase and register it as an entry point.

Tip

Create a new project [6] for this work.

Here are the essential steps:

Step 1. Create a Solver Class#

Create a Python class that inherits from SolverBase and implement all required abstract methods (methods marked with decorator @abstractmethod [1]):

 1from hklpy2.backends.base import SolverBase
 2from hklpy2.misc import IDENTITY_MATRIX_3X3
 3from hklpy2.misc import KeyValueMap
 4from hklpy2.misc import Matrix3x3
 5from hklpy2.misc import NamedFloatDict
 6
 7class MySolver(SolverBase):
 8    name = "my_solver"
 9    version = "1.0.0"
10
11    def __init__(self, geometry: str, **kwargs):
12        super().__init__(geometry, **kwargs)
13
14    # Required abstract methods
15    def addReflection(self, reflection: KeyValueMap) -> None:
16        """Add an observed diffraction reflection."""
17        pass  # TODO: send to your library
18
19    def calculate_UB(self, r1: KeyValueMap, r2: KeyValueMap) -> Matrix3x3:
20        """Calculate the UB matrix with two reflections."""
21        return IDENTITY_MATRIX_3X3  # TODO: calculate with your library
22
23    def forward(self, pseudos: NamedFloatDict) -> list[NamedFloatDict]:
24        """Compute list of solutions(reals) from pseudos."""
25        return [{}]  # TODO: calculate with your library
26
27    def inverse(self, reals: NamedFloatDict) -> NamedFloatDict:
28        """Compute pseudos from reals."""
29        return {}  # TODO: calculate with your library
30
31    def refineLattice(self, reflections: list[KeyValueMap]) -> NamedFloatDict:
32        """Refine lattice parameters from reflections."""
33        return {}  # TODO: calculate with your library
34
35    def removeAllReflections(self) -> None:
36        """Remove all reflections."""
37        pass  # TODO: use your library
38
39    # Required properties
40    @property
41    def extra_axis_names(self) -> list[str]:
42        """Ordered list of extra axis names."""
43        return []
44
45    @classmethod
46    def geometries(cls) -> list[str]:
47        """Supported diffractometer geometries."""
48        return ["MY_GEOMETRY"]
49
50    @property
51    def modes(self) -> list[str]:
52        """Available operating modes."""
53        return []
54
55    @property
56    def pseudo_axis_names(self) -> list[str]:
57        """Ordered list of pseudo axis names (h, k, l)."""
58        return []
59
60    @property
61    def real_axis_names(self) -> list[str]:
62        """Ordered list of real axis names (omega, chi, phi, tth)."""
63        return []

Step 2. Register as Entry Point#

Create a [project.entry-points."hklpy2.solver"] section in your project’s pyproject.toml file and declare your solver. Here’s an example:

1[project.entry-points."hklpy2.solver"]
2my_solver = "my_package.my_solver:MySolver"

Step 3. Install and Test#

Install your package to make the solver discoverable. Then test that it loads correctly:

 1import hklpy2
 2
 3# List available solvers
 4print(hklpy2.solvers())
 5
 6# Load your solver class
 7SolverClass = hklpy2.get_solver("my_solver")
 8
 9# Create an instance
10solver = SolverClass("MY_GEOMETRY")

Use the creator() factory to create a diffractometer with your solver and test it. Here’s a suggested start:

1import hklpy2
2
3sim = hklpy2.creator(name="sim", solver="my_solver")
4sim.wh()

Key Implementation Details#

Required Methods Contract#

All solvers must implement these attributes, methods, and properties:

method (or property)

description

name

(string attribute) Name of this solver.

version

(string attribute) Version of this solver.

addReflection(reflection)

Add an observed diffraction reflection.

calculate_UB(r1, r2)

Calculate the UB matrix with two reflections.

extra_axis_names

Returns list of any extra axes in the current mode.

forward(pseudos)

Compute list of solutions(reals) from pseudos. A single-element list is acceptable (see forward() contract).

geometries

@classmethod [3] : Returns list of all geometries support by this solver.

inverse(reals)

Compute pseudos from reals.

modes

Returns list of all modes support by this geometry.

pseudo_axis_names

Returns list of all pseudos support by this geometry.

real_axis_names

Returns list of all reals support by this geometry.

refineLattice(reflections)

Return refined lattice parameters given reflections.

removeAllReflections()

Clears sample of all stored reflections.

forward() Contract#

The forward() method appears at three layers, each with a distinct role and return type:

SolverBase.forward(pseudos)

Returns list[NamedFloatDict] — all valid real-axis solutions the backend engine can find for the given pseudo-axis values, geometry, and mode. A single-element list is a valid return value.

Core.forward(pseudos)

Calls the solver’s forward(), then applies constraint filtering to each solution. Returns the filtered list — the full set of candidate motor angle combinations that satisfy all constraints.

DiffractometerBase.forward(pseudos)

The ophyd.PseudoPositioner interface, called by motion commands during bluesky plans. Calls Core.forward(), then applies a solution picker to select one solution for motor motion. Returns a single NamedTuple.

When writing a solver, only SolverBase.forward() needs to be implemented. The number of solutions depends on the backend library’s capabilities. An empty list (or raising NoForwardSolutions) signals that no solution exists for the requested pseudo-axis values.

The four-stage forward pipeline

All three backend libraries (Hkl/Soleil, diffcalc, SPEC) follow the same pattern: the engine returns all theoretical solutions; post-processing stages then wrap, filter, and select. hklpy2 implements the same four stages. A solver adapter is responsible only for Stage 1; Stages 2–4 are handled by the hklpy2 Core and DiffractometerBase layers.

digraph forward_pipeline {
    graph [rankdir=TB, splines=ortho, nodesep=0.6, ranksep=0.5,
           fontname="sans-serif", bgcolor="transparent"]
    node  [shape=box, style="rounded,filled", fontname="sans-serif",
           fontsize=11, margin="0.15,0.08"]
    edge  [fontname="sans-serif", fontsize=10]

    pseudos [label="pseudos\n(h, k, l)", shape=ellipse,
             fillcolor="#e8f4e8", color="#4a7c4a"]

    s1 [label="Stage 1: SolverBase.forward()\nBackend engine returns ALL theoretical\nsolutions for the pseudos, geometry, mode.\nSPEC/diffcalc: geometry engine\nlibhkl: pseudo_axis_values_set()",
        fillcolor="#dce8f8", color="#3a6898"]

    s2 [label="Stage 2: apply_cut()  [Core]\nEach axis angle is mapped into\n[cut_point, cut_point+360).\nControls representation, not validity.\nSPEC: cuts  |  diffcalc: _cut_angles()",
        fillcolor="#fdf3dc", color="#a07820"]

    s3 [label="Stage 3: LimitsConstraint.valid()  [Core]\nSolutions whose wrapped axis values\nfall outside configured limits are discarded.\nSPEC: lm  |  diffcalc: is_position_within_limits()",
        fillcolor="#fdf3dc", color="#a07820"]

    s4 [label="Stage 4: solution picker  [DiffractometerBase]\nOne solution is selected from survivors\nfor motor motion (ophyd PseudoPositioner).",
        fillcolor="#f0e8f8", color="#6a3a98"]

    result [label="single real-axis position\nfor motor motion",
            shape=ellipse, fillcolor="#e8f4e8", color="#4a7c4a"]

    pseudos -> s1 [label="all theoretical solutions"]
    s1      -> s2 [label="wrapped solutions"]
    s2      -> s3 [label="filtered solutions"]
    s3      -> s4 [label="one solution"]
    s4      -> result
}

Four-stage forward() pipeline in hklpy2 (equivalent stages in SPEC and diffcalc shown in parentheses).#

Backend Library Requirements#

A Solver is an adapter for a backend computation library. The backend library must provide (or enable the adapter to implement) the following capabilities.

Required#

Geometry-aware rotation chain.

The library must know the physical axis directions and stacking order for each geometry it supports. This is the irreducible foundation for all diffractometer calculations.

Forward transform (pseudos to reals).

Given pseudo-axis values, orientation matrix, and wavelength, compute real-axis angles. This is geometry- and mode-specific.

Inverse transform (reals to pseudos).

Given real-axis angles, lattice parameters, orientation matrix, and wavelength, compute pseudo-axis values.

UB matrix calculation.

Given two measured reflections (each with known pseudos, measured angles, and wavelength), compute the orientation matrix. This requires the geometry’s rotation chain to convert measured angles into lab-frame scattering vectors. calculate_UB(), forward(), and inverse() all depend on the same rotation chain and cannot be separated.

Reflection management.

Store and retrieve measured reflections for UB matrix calculation. The backend library must manage reflections because UB calculation consumes them — the solver adapter passes reflections through to the backend, not buffering them in the adapter layer.

Optional#

The following capabilities enhance a solver but are not required. Solvers that lack these features remain valid — the Core layer handles their absence gracefully.

Lattice refinement.

Refine lattice parameters from multiple reflections. Solvers that lack this capability return None from refineLattice(); Core raises an informative error to the user.

Multi-solution enumeration.

Return multiple valid angle solutions for a given set of pseudo-axis values. A single-element list is a valid return from forward() (see forward() Contract).

Operating modes.

Named configurations (e.g., bisector, constant-phi) that define which axes will be modified by forward().

Design Rationale#

The required capabilities above are coupled through the geometry’s rotation chain: the same axis definitions that drive forward() and inverse() are needed to compute lab-frame scattering vectors for calculate_UB(), and to manage the reflections that feed it. A library that can compute forward() and inverse() necessarily has the geometry knowledge to also compute calculate_UB(). A library that lacks this knowledge cannot serve as a complete hklpy2 solver backend.

This coupling is why these capabilities cannot be factored into SolverBase. A “generic” implementation would require embedding a full geometry engine — effectively becoming a backend library itself, violating the separation of concerns between the adapter layer and the computation engine.

The Solver should be a thin adapter: it translates SolverBase method calls into whatever the backend library needs. Computation, data management (including reflections), and transport (for remote backends) belong in the backend or its solver adapter, not in the base class.

Backend library APIs vary widely. For example, HklSolver.forward() calls engine.pseudo_axis_values_set() — a Hkl/Soleil GObject introspection binding that sets pseudo-axis values and returns a GeometryList of solutions as a side effect. The backend function name gives no indication it is computing forward solutions. This is exactly why the solver adapter exists: to present a consistent forward(pseudos) interface regardless of how the backend library exposes its capabilities.

Engineering Units System#

Define your solver’s internal units via class constants:

  • ANGLE_UNITS: Default “degrees”

  • LENGTH_UNITS: Default “angstrom”

Example Reference#

Compare with these Solver classes:

class

description

HklSolver

Full-featured (Linux x86_64 only)

NoOpSolver

No-operation (demonstration & testing)

ThTthSolver

Minimal pure-Python (demonstration)

TrivialSolver() [7]

Minimal requirements, non-functional (internal testing)

Notes#

  • hklpy2 identifies a Solver [4] as a plugin using Python’s entry point [5] support.

  • All solvers must inherit from SolverBase which enforces a consistent interface.

  • The Core class handles unit conversion between diffractometer and solver units.

  • Solvers can be platform-specific (such as HklSolver which is C code compiled only for Linux x86_64 architectures).

  • Consider using NoOpSolver or TrivialSolver() [7] as starting references for testing infrastructure.

Footnotes#