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.

See also

Presets — define constant-axis values used before forward() computation.

Glossary

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 cuts, diffcalc setcut

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)? — Set cut_point = 0.

  • Motor has a physical travel limit (e.g. chi can only reach [0, 100])? — Set limits = (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] needs cut_point = 0 and limits = (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()

See also

cut point in the glossary.

SPEC commands in hklpy2 — SPEC cuts maps to cut_point.

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()