Work with Constraints and Diffraction Modes#

This guide explains the constraint system in detail: how modes are defined, how to customise constraint values, how to build entirely new modes, and how to work with the extras dict for advanced modes.

Background: degrees of freedom#

Specifying a reflection (h, k, l) provides exactly 3 equations on the motor angles (the three components of the scattering vector Q). A geometry with N real motor axes therefore has N − 3 free parameters after the Bragg condition is satisfied. Each free parameter must be resolved by exactly one constraint.

Geometry family

N

N − 3

fourcv(), fourch(), zaxis(), s2d2()

4

1

fivec()

5

2

psic(), kappa6c(), sixc()

6

3

import ad_hoc_diffractometer as ahd

print(ahd.presets.fourcv().free_dof_after_bragg)   # 1
print(ahd.presets.psic().free_dof_after_bragg)     # 3

Constraint categories#

Every constraint belongs to one of three categories:

Sample constraint — fixes one sample stage at a declared value, or declares the bisecting relational condition:

from ad_hoc_diffractometer import SampleConstraint, BisectConstraint

SampleConstraint("chi", 90.0)          # chi fixed at 90°
SampleConstraint("omega", 0.0)         # omega fixed at 0°
BisectConstraint("omega", "ttheta")    # omega = ttheta / 2
BisectConstraint("eta", "delta")       # eta = delta / 2  (psic)

Detector constraint — fixes one detector stage at a declared value, or constrains the "qaz" pseudo-angle from You (1999) eq. 18 (tan(qaz) = tan(delta) / sin(nu)):

from ad_hoc_diffractometer import DetectorConstraint

DetectorConstraint("nu", 0.0)     # nu fixed at 0°
DetectorConstraint("gamma", 0.0)  # gamma fixed at 0°
DetectorConstraint("qaz", 90.0)   # Q confined to the vertical plane

qaz = 90° constrains scattering to the vertical plane; qaz = to the horizontal plane. This is implemented for all geometries with two or more detector stages (psic, kappa6c) and used by the lifting_detector_* mode family.

Reference constraint — expresses a condition between Q and a reference vector n̂ (surface normal, polarisation axis, etc.). The incidence/exit-angle constraints (alpha_i, beta_out, a_eq_b) are implemented when surface_normal is set; psi and naz are not yet implemented as forward constraints. See Surface Geometry and the Reference Vector:

from ad_hoc_diffractometer import ReferenceConstraint

ReferenceConstraint("psi", 90.0)       # azimuthal angle of n̂ about Q
ReferenceConstraint("alpha_i", 5.0)    # incidence angle
ReferenceConstraint("a_eq_b", True)    # alpha_i = beta_out (symmetric)

Rules: at most one DetectorConstraint, at most one ReferenceConstraint, remainder must be SampleConstraint or BisectConstraint. Total must equal N − 3.

Design principles#

The constraint system is built on three key decisions from the #122 planning discussion:

  1. Constraints are geometry-agnostic. A ConstraintSet is defined without reference to a specific geometry; validation against actual DOF count and stage names happens at solve time (is_fully_constrained(g) and is_implemented(g)).

  2. At most one detector constraint and at most one reference constraint. Fixing more than one detector angle over-constrains the scattered beam direction. More than one reference constraint would also over-constrain the problem. Any number of sample constraints (fixed-angle or bisect) are allowed, subject to the total equalling N − 3.

  3. The bisect condition is relational, not heuristic. BisectConstraint names both stages explicitly (sample_stage = detector_stage / 2). No geometric heuristics are used to infer which stage is “co-axial” with which.

These principles make it possible to define valid modes programmatically at run time without any knowledge of geometry internals.

Use a factory-defined mode#

g = ahd.presets.fourcv()
g.mode_name = "bisecting"   # 1 BisectConstraint (N-3=1)
solutions = g.forward(1, 0, 0)

Set a constraint value at run time#

A constraint value is constant for the duration of a single compute_forward() call. The ConstraintSet object persists on the geometry until explicitly replaced — there is no need to reassign it if the value does not change between calls.

from ad_hoc_diffractometer import ConstraintSet, SampleConstraint

g = ahd.presets.fourcv()

# Set once — all subsequent forward() calls use chi = 45°
g.modes["my_chi"] = ConstraintSet([SampleConstraint("chi", 45.0)])
g.mode_name = "my_chi"

sols_100 = g.forward(1, 0, 0)   # chi = 45°
sols_010 = g.forward(0, 1, 0)   # chi = 45° (same constraint, no reassignment needed)
sols_111 = g.forward(1, 1, 1)   # chi = 45°

# Only reassign when the value changes
g.modes["my_chi"] = ConstraintSet([SampleConstraint("chi", 60.0)])
sols_new = g.forward(1, 0, 0)   # chi = 60° from here on

The factory mode "fixed_chi" has a default chi = 90°. Replacing the ConstraintSet in g.modes["fixed_chi"] changes the value for all subsequent calls until it is replaced again.

The computed field on ConstraintSet is informational (documents which stages the solver computes) and does not affect the calculation.

Build a multi-constraint mode (six-circle)#

For psic (N − 3 = 3), three constraints are needed:

from ad_hoc_diffractometer import (
    ConstraintSet, BisectConstraint, SampleConstraint, DetectorConstraint
)

g = ahd.presets.psic()

# Custom bisecting mode with mu=5° (non-zero mu)
g.modes["bisecting_mu5"] = ConstraintSet(
    [
        BisectConstraint("eta", "delta"),   # sample: eta = delta/2
        SampleConstraint("mu", 5.0),        # sample: mu fixed at 5°
        DetectorConstraint("nu", 0.0),      # detector: nu fixed at 0°
    ],
    computed=["eta", "chi", "phi", "delta"],
)
g.mode_name = "bisecting_mu5"

Inspect a mode#

cs = g.modes["bisecting"]

# All constraints in definition order
print(cs.constraints)

# Stage names held fixed (derived from constraints, not a separate dict)
print(cs.constant_stages)    # e.g. ['eta', 'mu', 'nu']

# Stage names computed by the solver
print(cs.computed)           # e.g. ['eta', 'chi', 'phi', 'delta']

# Bisect pair (sample stage, detector stage)
print(cs.bisect_stages())    # e.g. ('eta', 'delta')

# DOF check
print(cs.is_fully_constrained(g))   # True if len(constraints) == N-3

# Solver availability
print(cs.is_implemented(g))         # True if all constraints have solvers

The extras dict#

Some modes require additional input beyond (h, k, l), or compute additional output quantities alongside the motor angles. These are declared in the extras dict using REQUIRED and OPTIONAL sentinels:

from ad_hoc_diffractometer import REQUIRED, OPTIONAL, ConstraintSet, ReferenceConstraint

# fixed_psi mode on fourcv: requires a reference vector n̂
cs = g.modes["fixed_psi"]
print(cs.extras)
# {'n_hat': <REQUIRED>, 'psi': None}
# n_hat must be supplied; psi is an output populated by the solver

REQUIRED marks inputs that must be supplied before calling forward(). None marks output slots populated by the solver. OPTIONAL marks inputs with a sensible default.

Stub modes#

Modes whose constraint patterns do not yet have a solver implementation return False from is_implemented() and raise NotImplementedError when forward() is called. Some modes require a prerequisite to be set on the geometry (e.g. azimuthal_reference for psi modes, surface_normal for surface modes) — they are considered stubs until the prerequisite is met:

g = ahd.presets.fourcv()
g.mode_name = "fixed_psi"

# Without azimuthal_reference: not implemented
print(g.modes["fixed_psi"].is_implemented(g))  # False

# With azimuthal_reference: implemented
g.azimuthal_reference = (0, 0, 1)
print(g.modes["fixed_psi"].is_implemented(g))  # True

Serialisation#

ConstraintSet round-trips through to_dict() / from_dict():

import json
from ad_hoc_diffractometer import AdHocDiffractometer

g = ahd.presets.fourcv()
d = g.to_dict()
json.dumps(d)   # JSON-serialisable

g2 = AdHocDiffractometer.from_dict(d)
print(g2.modes.keys())    # same modes as g
print(g2.mode_name)       # 'bisecting'

Custom constraint protocol#

Any object satisfying the ConstraintSet interface can be added to a ModeDict. A minimal custom constraint must implement:

  • name: str — a unique identifier

  • category: str"sample", "detector", or "reference"

  • extras: dict — input/output parameters

  • evaluate(angles, geometry) -> float — residual (0 = satisfied)

  • is_satisfied(angles, geometry, tol) -> bool

  • is_implemented(geometry) -> bool

  • to_dict() / from_dict() — serialisation

The numeric fallback solver in forward.py handles any custom constraint that has is_implemented() returning True, even without an analytic solver.

See also#