Constraints#
Constraints filter the solutions returned by a
forward() computation.
The solver can return many candidate sets of real-axis angles for a given
\(hkl\) position. One or more constraints
(ConstraintBase), together with a choice
of operating mode, narrow those candidates by:
Accepting only solutions where each real axis falls within a specified range.
Tip
Constraints act after the solver computes solutions. If you want the solver to assume a specific value for a constant axis before computation, use Presets instead.
Examples
Many of the Examples show how to adjust constraints.
Cut Points#
A cut point is the angle at which a motor’s reported position “wraps around.” It sets the start of the 360-degree window used to express the angle. The physics is unchanged — it is the same motor position either way — only how the number is written down changes.
Two common choices:
cut_point = -180(default): angles are reported in the range −180 up to (but not including) +180.cut_point = 0: angles are reported in the range 0 up to (but not including) 360.
Technically, a cut point c maps any computed angle to its equivalent
in the range from c up to (but not including) c + 360.
Valid values
Any finite number is an acceptable cut point — including values outside
the axis limits. The cut point is independent of low_limit and
high_limit; there is no requirement that it fall within the limits.
inf, -inf, and nan are rejected immediately with a
ConstraintsError, both when the constraint is
created and when cut_point is set afterwards.
low_limit and high_limit are always kept in sorted order: setting
either one (or both via the limits property) sorts the pair
automatically, so low_limit is always <= high_limit.
Cut point vs. constraint — the key distinction
Cut point |
LimitsConstraint |
|---|---|
Controls the representation of an angle — which 360-degree window it is expressed in. |
Filters out solutions whose axis value falls outside a physically acceptable range. |
Applied first, before limit checking. |
Applied after cut-point wrapping. |
Does not accept or reject solutions. |
Accepts or rejects solutions. |
Equivalent to SPEC
|
No direct SPEC equivalent — SPEC uses cut points for both wrapping and as a proxy for limits. |
When to use each
Need angles in
[-180, +180)? — That is the default cut point (-180). No change required.Need angles in
[0, 360)? — Setcut_point = 0.Motor has a physical travel limit (e.g.
chican only reach[0, 100])? — Setlimits = (0, 100)on the constraint. The cut point controls representation; the limits control which solutions are physically reachable.Both: a motor in
[0, 360)representation with physical travel of[0, 290]needscut_point = 0andlimits = (0, 290).
In practice: if your motor can only travel from 0° to 290°, setting
cut_point = 0 means all reported angles are positive numbers that
are easy to compare against your travel limits. With the default cut
of −180, a position of 181° still reads as 181° — so in that case it
makes no difference. The cut point matters when the motor’s usable
range straddles the wrap boundary.
Pipeline order in forward()
solver.forward(pseudos)
└─→ for each solution:
for each axis:
apply_cut(value) ← wraps into [cut_point, cut_point+360)
constraints.valid(...) ← checks wrapped value against limits
if valid: keep solution
Example — using cut points
# Default: chi expressed in [-180, +180).
diffractometer.core.constraints["chi"].cut_point # -180.0
# Change to [0, 360).
diffractometer.core.constraints["chi"].cut_point = 0
# Restrict physical travel as well.
diffractometer.core.constraints["chi"].limits = (0, 290)
# Reset everything to defaults.
diffractometer.core.reset_constraints()
How Constraints Work Internally#
The valid() Method#
Every constraint class inherits from
ConstraintBase and must implement the
abstract method valid().
def valid(self, **values: dict) -> bool:
...
The method receives the full set of real-axis positions (as keyword
arguments, axis_name=value) and returns True when the constraint is
satisfied or False when it is not. The values passed to valid()
have already been cut-point-wrapped by apply_cut().
The built-in implementation,
LimitsConstraint, checks whether the
axis value falls within the configured [low_limit, high_limit] range:
# simplified from hklpy2/blocks/constraints.py
def valid(self, **values):
value = values[self.label] # already cut-point-wrapped
return (
(value + ENDPOINT_TOLERANCE) >= self.low_limit
and (value - ENDPOINT_TOLERANCE) <= self.high_limit
)
A small tolerance (ENDPOINT_TOLERANCE = 1e-4) is applied at each
endpoint so that solver solutions that land exactly on a limit boundary
are not rejected due to floating-point rounding.
How valid() Is Called During forward()#
After the solver returns its candidate solutions,
forward() iterates over every solution, applies
cut-point wrapping, then calls
valid() on the
collection of constraints:
# simplified from hklpy2/ops.py Core.forward()
for solution in self.solver.forward(pseudos):
reals = {axis: <computed_value>, ...} # full set of real-axis values
# Step 1: apply cut-point wrapping (new in #296)
for name, constraint in self.constraints.items():
reals[name] = constraint.apply_cut(reals[name])
# Step 2: check limits on wrapped values
if self.constraints.valid(**reals):
solutions.append(reals) # solution passes all constraints
# solutions that fail are discarded (and logged at INFO level)
valid() in turn calls
valid() on each individual
constraint and returns True only when all constraints are satisfied.
Solutions that fail at least one constraint are silently discarded; the
reasons are recorded at the logging.INFO level.
See also
Presets — supply a fixed value for a constant axis before the solver runs, rather than filtering solutions after.
The LimitsConstraint Label#
The label attribute of a
LimitsConstraint must match the real
axis name as it appears on the diffractometer. This is because
valid() looks up the axis value from the **values keyword-argument
dictionary using self.label as the key:
# from LimitsConstraint.valid()
value = values[self.label] # KeyError / ConstraintsError if label is wrong
The same name is used as the dictionary key in
diffractometer.core.constraints, which is a
RealAxisConstraints instance (a dict
subclass):
diffractometer.core.constraints
# {
# "omega": LimitsConstraint(label="omega", ...),
# "chi": LimitsConstraint(label="chi", ...),
# "phi": LimitsConstraint(label="phi", ...),
# "tth": LimitsConstraint(label="tth", ...),
# }
Constraints are created automatically for every real axis when the
diffractometer is initialised (or when
reset_constraints() is called). The label is set
to the axis name at that point. You do not normally need to set the label
manually; if you create a LimitsConstraint
directly you must supply a label that matches a real axis name, otherwise
valid() will raise a
ConstraintsError.
Example — adjusting a constraint
# Restrict chi to the range [0, 90] degrees.
diffractometer.core.constraints["chi"].limits = (0, 90)
# Set chi's cut point to [0, 360).
diffractometer.core.constraints["chi"].cut_point = 0
# Reset all constraints to defaults (±180 degrees, cut at -180).
diffractometer.core.reset_constraints()