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 |
|---|---|---|
4 |
1 |
|
5 |
2 |
|
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 = 0° 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:
Constraints are geometry-agnostic. A
ConstraintSetis defined without reference to a specific geometry; validation against actual DOF count and stage names happens at solve time (is_fully_constrained(g)andis_implemented(g)).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.
The bisect condition is relational, not heuristic.
BisectConstraintnames 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 identifiercategory: str—"sample","detector", or"reference"extras: dict— input/output parametersevaluate(angles, geometry) -> float— residual (0 = satisfied)is_satisfied(angles, geometry, tol) -> boolis_implemented(geometry) -> boolto_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.