Skip to content

Inequality Constraints

Cross-parameter inequalities such as t_head < t_tail or t1 + t2 <= total are not dependencies: no parameter is removed from the fit. They are declared as InequalitySpec objects on the project and enforced by the BUMPS engines (the Bumps* minimizers and the DREAM sampler) as penalties on the fit problem. LMFit and DFO-LS cannot enforce them, and fit raises ValueError in that case rather than silently dropping the physics.

from easyscience.fitting import AvailableMinimizers

from easyreflectometry import InequalitySpec

project.minimizer = AvailableMinimizers.Bumps
t_a = project.parameter_path(layer_a.thickness)  # 'models/0/sample/1/layers/0/thickness'
t_b = project.parameter_path(layer_b.thickness)
project.add_inequality_constraint(InequalitySpec('a', '<', 'b', {'a': t_a}, {'b': t_b}, name='order'))
project.add_inequality_constraint(InequalitySpec('a + b', '<', '90', {'a': t_a, 'b': t_b}, {}))

project.violated_inequality_constraints()  # check the start point first
project.fitter.fit_single_data_set_1d(dataset)  # penalties applied automatically

Parameters are referenced by structural path (see Project.parameter_path) so the constraints are saved with the project. While a constraint is violated BUMPS skips the model evaluation and adds a penalty growing with the violation, steering the optimizer back into the feasible region; the Bumps_lm method spreads the penalty over the residuals instead and enforces inequalities more weakly.

Both sides of a spec are unit-checked when it is registered; a mismatch raises UnitError, a subclass of ValueError.

inequality_constraints

Cross-parameter inequality constraints enforced by the fit.

An :class:InequalitySpec is a purely declarative statement such as t_head < t_tail or t1 + t2 <= total: two expressions over parameter aliases and a relation. Unlike the equality constraints in :mod:easyreflectometry.constraints it is not a parameter dependency — no parameter is removed from the fit. Instead the specs are translated, at the start of every fit, into penalty terms attached to the BUMPS FitProblem (FitProblem(constraints=[...])): while a constraint is violated BUMPS skips the model, adds a large penalty and a term growing with the violation, which steers the optimizer back into the feasible region. Only the BUMPS engine family (Bumps* minimizers and the DREAM sampler) supports this; Fitter.fit rejects inequalities for LMFit and DFO-LS.

Specs reference parameters by structural path (models/0/sample/1/layers/0/thickness, see :meth:easyreflectometry.Project.parameter_path) rather than by object or unique name, so they survive project save/load and can be rebuilt against a reloaded object tree.

Design notes — why the translation happens inside the fit, and reads the BUMPS parameters:

  • BUMPS evaluates the constraints before the model and skips the model while any fails. The EasyScience parameter values are only written inside the model call, so at constraint-evaluation time they lag the optimizer's trial vector — and freeze entirely once a constraint fails. The operands built here therefore read bumps.Parameter.value of the live problem, which BUMPS sets for every trial point.
  • Those BUMPS parameters only exist for the free EasyScience parameters and are rebuilt per fit, which is why a factory (constraints_factory) is passed down the fitting chain and invoked as each BUMPS problem is built (see :mod:easyreflectometry._bumps_constraints). Fixed parameters are frozen as constants; dependent (constrained or derived) parameters are expanded recursively into their independent leaves.
  • Each penalty term returns the linear violation; BUMPS squares it once, giving a quadratic penalty.

__all__ = ['RELATIONS', 'InequalityEvaluation', 'InequalitySpec', 'UnitError', 'build_constraints_factory', 'check_units', 'evaluate_spec'] module-attribute

RELATIONS = ('<', '<=', '>', '>=') module-attribute

_RELATION_ALIASES = {'≤': '<=', '≥': '>=', '=<': '<=', '=>': '>='} module-attribute

_BUMPS_PREFIX = 'p' module-attribute

_SAFE_SYMBOLS = {'pi': np.pi, 'e': np.e, 'sqrt': np.sqrt, 'exp': np.exp, 'log': np.log, 'log10': np.log10, 'sin': np.sin, 'cos': np.cos, 'tan': np.tan, 'abs': abs, 'min': min, 'max': max} module-attribute

PathResolver = Callable[[str], DescriptorNumber] module-attribute

UnitError

Bases: ValueError

A unit problem in an inequality constraint.

Raised by :func:check_units when a side cannot be evaluated with its units or the two sides of a spec carry incompatible units. Subclasses ValueError so existing except ValueError callers keep working; new callers can catch this type instead of matching message substrings.

Source code in src/easyreflectometry/inequality_constraints.py
75
76
77
78
79
80
81
82
class UnitError(ValueError):
    """A unit problem in an inequality constraint.

    Raised by :func:`check_units` when a side cannot be evaluated with its
    units or the two sides of a spec carry incompatible units. Subclasses
    ``ValueError`` so existing ``except ValueError`` callers keep working;
    new callers can catch this type instead of matching message substrings.
    """

InequalitySpec dataclass

Declarative cross-parameter inequality, e.g. t_head < t_tail.

Attributes:

Name Type Description
lhs_expression str

Expression over the aliases in lhs_paths, e.g. 'a + b'.

op str

One of '<', '<=', '>', '>='.

rhs_expression str

Expression over the aliases in rhs_paths; may also be a plain number (dimensionless or understood in the unit of the left side).

lhs_paths, rhs_paths dict[str, str]

Alias to structural parameter path (models/0/...); no live objects are held so the spec is trivially serializable.

name str

Optional user label.

enabled bool

Disabled specs are kept but not applied to fits.

Source code in src/easyreflectometry/inequality_constraints.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
@dataclass
class InequalitySpec:
    """Declarative cross-parameter inequality, e.g. ``t_head < t_tail``.

    Attributes
    ----------
    lhs_expression : str
        Expression over the aliases in `lhs_paths`, e.g. ``'a + b'``.
    op : str
        One of ``'<'``, ``'<='``, ``'>'``, ``'>='``.
    rhs_expression : str
        Expression over the aliases in `rhs_paths`; may also be a plain
        number (dimensionless or understood in the unit of the left side).
    lhs_paths, rhs_paths : dict[str, str]
        Alias to structural parameter path (``models/0/...``); no live objects
        are held so the spec is trivially serializable.
    name : str
        Optional user label.
    enabled : bool
        Disabled specs are kept but not applied to fits.
    """

    lhs_expression: str
    op: str
    rhs_expression: str
    lhs_paths: Dict[str, str] = field(default_factory=dict)
    rhs_paths: Dict[str, str] = field(default_factory=dict)
    name: str = ''
    enabled: bool = True

    def __post_init__(self) -> None:
        self.op = _normalize_relation(self.op)
        self.lhs_expression = str(self.lhs_expression).strip()
        self.rhs_expression = str(self.rhs_expression).strip()
        self.lhs_paths = dict(self.lhs_paths)
        self.rhs_paths = dict(self.rhs_paths)
        self.validate_syntax()

    # ----- validation -----

    def validate_syntax(self) -> None:
        """Check that both sides parse and every identifier has an alias mapping."""
        for side, expression, paths in (
            ('left', self.lhs_expression, self.lhs_paths),
            ('right', self.rhs_expression, self.rhs_paths),
        ):
            if not expression:
                raise ValueError(f'The {side}-hand side of an inequality cannot be empty.')
            for alias in paths:
                if not alias.isidentifier() or keyword.iskeyword(alias):
                    raise ValueError(f"Alias '{alias}' is not a valid identifier.")
            missing = [n for n in _identifiers(expression) if n not in paths]
            if missing:
                raise ValueError(f"The {side}-hand side '{expression}' references unmapped names: {', '.join(missing)}.")
            interpreter = _new_interpreter()
            try:
                _evaluate(interpreter, expression, {alias: 1.0 for alias in paths})
            except Exception as error:  # asteval raises its own hierarchy
                raise SyntaxError(f"Cannot evaluate the {side}-hand side '{expression}': {error}") from None
        # `paths` merges both sides, so one alias cannot mean two different
        # parameters — the right side would silently win.
        conflicting = sorted(
            alias for alias, path in self.lhs_paths.items() if alias in self.rhs_paths and self.rhs_paths[alias] != path
        )
        if conflicting:
            raise ValueError(
                f'Alias(es) {", ".join(repr(a) for a in conflicting)} map to different parameters on the '
                'two sides of the inequality. Use distinct alias names per side.'
            )

    @property
    def paths(self) -> Dict[str, str]:
        """All alias → path pairs of both sides."""
        merged = dict(self.lhs_paths)
        merged.update(self.rhs_paths)
        return merged

    # ----- serialization -----

    def to_dict(self) -> dict:
        return {
            'lhs_expression': self.lhs_expression,
            'op': self.op,
            'rhs_expression': self.rhs_expression,
            'lhs_paths': dict(self.lhs_paths),
            'rhs_paths': dict(self.rhs_paths),
            'name': self.name,
            'enabled': bool(self.enabled),
        }

    @classmethod
    def from_dict(cls, d: dict) -> 'InequalitySpec':
        return cls(
            lhs_expression=d['lhs_expression'],
            op=d['op'],
            rhs_expression=d['rhs_expression'],
            lhs_paths=d.get('lhs_paths', {}),
            rhs_paths=d.get('rhs_paths', {}),
            name=d.get('name', ''),
            enabled=d.get('enabled', True),
        )

    def __str__(self) -> str:
        return f'{self.lhs_expression} {self.op} {self.rhs_expression}'

paths property

All alias → path pairs of both sides.

validate_syntax()

Check that both sides parse and every identifier has an alias mapping.

Source code in src/easyreflectometry/inequality_constraints.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
def validate_syntax(self) -> None:
    """Check that both sides parse and every identifier has an alias mapping."""
    for side, expression, paths in (
        ('left', self.lhs_expression, self.lhs_paths),
        ('right', self.rhs_expression, self.rhs_paths),
    ):
        if not expression:
            raise ValueError(f'The {side}-hand side of an inequality cannot be empty.')
        for alias in paths:
            if not alias.isidentifier() or keyword.iskeyword(alias):
                raise ValueError(f"Alias '{alias}' is not a valid identifier.")
        missing = [n for n in _identifiers(expression) if n not in paths]
        if missing:
            raise ValueError(f"The {side}-hand side '{expression}' references unmapped names: {', '.join(missing)}.")
        interpreter = _new_interpreter()
        try:
            _evaluate(interpreter, expression, {alias: 1.0 for alias in paths})
        except Exception as error:  # asteval raises its own hierarchy
            raise SyntaxError(f"Cannot evaluate the {side}-hand side '{expression}': {error}") from None
    # `paths` merges both sides, so one alias cannot mean two different
    # parameters — the right side would silently win.
    conflicting = sorted(
        alias for alias, path in self.lhs_paths.items() if alias in self.rhs_paths and self.rhs_paths[alias] != path
    )
    if conflicting:
        raise ValueError(
            f'Alias(es) {", ".join(repr(a) for a in conflicting)} map to different parameters on the '
            'two sides of the inequality. Use distinct alias names per side.'
        )

InequalityEvaluation dataclass

Result of evaluating a spec against the current parameter values.

Source code in src/easyreflectometry/inequality_constraints.py
242
243
244
245
246
247
248
249
250
251
252
253
@dataclass
class InequalityEvaluation:
    """Result of evaluating a spec against the current parameter values."""

    lhs: float
    rhs: float
    satisfied: bool
    violation: float

    @property
    def feasible(self) -> bool:
        return self.satisfied

_Constant

Source code in src/easyreflectometry/inequality_constraints.py
259
260
261
262
263
264
265
266
class _Constant:
    __slots__ = ('value',)

    def __init__(self, value: float) -> None:
        self.value = float(value)

    def __call__(self) -> float:
        return self.value

_BumpsValue

Reads a BUMPS parameter — i.e. the optimizer's current trial value.

Source code in src/easyreflectometry/inequality_constraints.py
269
270
271
272
273
274
275
276
277
278
class _BumpsValue:
    """Reads a BUMPS parameter — i.e. the optimizer's current trial value."""

    __slots__ = ('parameter',)

    def __init__(self, bumps_parameter: Any) -> None:
        self.parameter = bumps_parameter

    def __call__(self) -> float:
        return float(self.parameter.value)

_Expression

Evaluates an expression whose symbols are value sources.

Source code in src/easyreflectometry/inequality_constraints.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
class _Expression:
    """Evaluates an expression whose symbols are value sources."""

    __slots__ = ('expression', 'sources', '_interpreter')

    def __init__(self, expression: str, sources: Dict[str, Callable[[], float]]) -> None:
        self.expression = expression
        self.sources = sources
        self._interpreter = _new_interpreter()

    def __call__(self) -> float:
        return float(_evaluate(self._interpreter, self.expression, {k: src() for k, src in self.sources.items()}))

    def __float__(self) -> float:
        return self()

_Violation

The BUMPS constraint object: float() is 0 when satisfied, else the violation.

Source code in src/easyreflectometry/inequality_constraints.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
class _Violation:
    """The BUMPS constraint object: ``float()`` is ``0`` when satisfied, else the violation."""

    __slots__ = ('lhs', 'op', 'rhs', 'label')

    def __init__(self, lhs: _Expression, op: str, rhs: _Expression, label: str) -> None:
        self.lhs, self.op, self.rhs, self.label = lhs, op, rhs, label

    def __float__(self) -> float:
        return _violation(self.lhs(), self.op, self.rhs())

    def __str__(self) -> str:
        return self.label

    def __bool__(self) -> bool:  # mirror bumps.Constraint: never silently truthy
        raise TypeError('Inequality constraints cannot be used as booleans')

_normalize_relation(op)

Source code in src/easyreflectometry/inequality_constraints.py
110
111
112
113
114
def _normalize_relation(op: str) -> str:
    op = _RELATION_ALIASES.get(op.strip(), op.strip())
    if op not in RELATIONS:
        raise ValueError(f"Unsupported relation '{op}'. Use one of {', '.join(RELATIONS)}.")
    return op

_new_interpreter()

Source code in src/easyreflectometry/inequality_constraints.py
117
118
119
120
121
def _new_interpreter() -> Interpreter:
    interpreter = Interpreter(minimal=True, use_numpy=False)
    for name, value in _SAFE_SYMBOLS.items():
        interpreter.symtable[name] = value
    return interpreter

_evaluate(interpreter, expression, symbols)

Source code in src/easyreflectometry/inequality_constraints.py
124
125
126
127
def _evaluate(interpreter: Interpreter, expression: str, symbols: Dict[str, Any]) -> Any:
    interpreter.symtable.update(symbols)
    result = interpreter.eval(expression, raise_errors=True)
    return result

_identifiers(expression)

Identifiers used in expression that are not builtin math symbols.

Source code in src/easyreflectometry/inequality_constraints.py
130
131
132
133
def _identifiers(expression: str) -> List[str]:
    """Identifiers used in `expression` that are not builtin math symbols."""
    names = set(re.findall(r'\b[A-Za-z_][A-Za-z0-9_]*\b', expression))
    return sorted(n for n in names if n not in _SAFE_SYMBOLS and not keyword.iskeyword(n))

_violation(lhs, op, rhs)

Linear violation of lhs op rhs (0 when satisfied).

Strict and non-strict relations are treated alike: a penalty of exactly zero at the boundary is the only continuous choice.

Source code in src/easyreflectometry/inequality_constraints.py
316
317
318
319
320
321
322
323
324
325
326
def _violation(lhs: float, op: str, rhs: float) -> float:
    """Linear violation of ``lhs op rhs`` (``0`` when satisfied).

    Strict and non-strict relations are treated alike: a penalty of exactly
    zero at the boundary is the only continuous choice.
    """
    if op in ('<', '<='):
        diff = lhs - rhs
    else:
        diff = rhs - lhs
    return diff if diff > 0.0 else 0.0

_source_for(parameter, bumps_pars, trail)

Translate an EasyScience parameter into a source reading the BUMPS trial vector.

  • free parameter → the BUMPS parameter of the problem;
  • fixed parameter (or plain descriptor) → constant;
  • dependent parameter → its dependency expression, expanded recursively.
Source code in src/easyreflectometry/inequality_constraints.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def _source_for(parameter: DescriptorNumber, bumps_pars: Dict[str, Any], trail: tuple) -> Callable[[], float]:
    """Translate an EasyScience parameter into a source reading the BUMPS trial vector.

    * free parameter → the BUMPS parameter of the problem;
    * fixed parameter (or plain descriptor) → constant;
    * dependent parameter → its dependency expression, expanded recursively.
    """
    if id(parameter) in trail:
        raise ValueError(f"Circular dependency while expanding parameter '{parameter.name}'.")
    if isinstance(parameter, Parameter) and not parameter.independent:
        expression = getattr(parameter, '_clean_dependency_string', None)
        dependency_map = getattr(parameter, '_dependency_map', None) or {}
        if expression is None:
            # A dependent parameter always carries its dependency expression in
            # EasyScience; this is a defensive fallback for a foreign Parameter
            # subclass. Freezing silently would hide a bug, so say so.
            warnings.warn(
                f"Dependent parameter '{parameter.name}' has no dependency expression; "
                'its current value is frozen as a constant in the inequality constraint.',
                stacklevel=2,
            )
            return _Constant(parameter.value)
        sources = {alias: _source_for(dep, bumps_pars, trail + (id(parameter),)) for alias, dep in dependency_map.items()}
        return _Expression(expression, sources)
    key = _BUMPS_PREFIX + parameter.unique_name
    if key in bumps_pars:
        return _BumpsValue(bumps_pars[key])
    # Fixed, or not part of this fit (e.g. belongs to a model not being fitted).
    return _Constant(parameter.value)

_resolve_all(spec, resolve)

Source code in src/easyreflectometry/inequality_constraints.py
360
361
362
363
364
365
366
367
def _resolve_all(spec: InequalitySpec, resolve: PathResolver) -> Dict[str, DescriptorNumber]:
    resolved = {}
    for alias, path in spec.paths.items():
        parameter = resolve(path)
        if not isinstance(parameter, DescriptorNumber):
            raise ValueError(f"Path '{path}' (alias '{alias}') does not point to a parameter.")
        resolved[alias] = parameter
    return resolved

build_constraints_factory(specs, resolve)

Build the constraints_factory hook for the given specs.

The returned callable is what easyscience's BUMPS engine invokes with the {prefixed unique name: bumps.Parameter} mapping of a freshly built problem; it returns one penalty object per enabled spec. Paths are resolved and dependent parameters expanded at that moment, so the factory always reflects the model as it is when the fit starts.

Parameters:

Name Type Description Default
specs Iterable[InequalitySpec]

Specs to apply; disabled ones are skipped.

required
resolve Callable[[str], DescriptorNumber]

Structural-path resolver, typically project.resolve_parameter_path.

required

Returns:

Type Description
Callable | None

The factory, or None when no spec is enabled (so callers can pass it straight through as constraints_factory=...).

Source code in src/easyreflectometry/inequality_constraints.py
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
def build_constraints_factory(specs: Iterable[InequalitySpec], resolve: PathResolver) -> Optional[Callable]:
    """Build the ``constraints_factory`` hook for the given specs.

    The returned callable is what ``easyscience``'s BUMPS engine invokes
    with the ``{prefixed unique name: bumps.Parameter}`` mapping of a
    freshly built problem; it returns one penalty object per enabled spec.
    Paths are resolved and dependent parameters expanded at that moment, so
    the factory always reflects the model as it is when the fit starts.

    Parameters
    ----------
    specs : Iterable[InequalitySpec]
        Specs to apply; disabled ones are skipped.
    resolve : Callable[[str], DescriptorNumber]
        Structural-path resolver, typically ``project.resolve_parameter_path``.

    Returns
    -------
    Callable | None
        The factory, or ``None`` when no spec is enabled (so callers can pass
        it straight through as ``constraints_factory=...``).
    """
    active = [spec for spec in specs if spec.enabled]
    if not active:
        return None

    def factory(bumps_pars: Dict[str, Any]) -> list:
        constraints = []
        for spec in active:
            parameters = _resolve_all(spec, resolve)
            lhs = _Expression(
                spec.lhs_expression,
                {alias: _source_for(parameters[alias], bumps_pars, ()) for alias in spec.lhs_paths},
            )
            rhs = _Expression(
                spec.rhs_expression,
                {alias: _source_for(parameters[alias], bumps_pars, ()) for alias in spec.rhs_paths},
            )
            constraints.append(_Violation(lhs, spec.op, rhs, spec.name or str(spec)))
        return constraints

    return factory

evaluate_spec(spec, resolve)

Evaluate a spec against the current parameter values.

Used for the start-point feasibility check before a fit is launched and for displaying the constraint state; dependent parameters contribute their current (already propagated) value.

Source code in src/easyreflectometry/inequality_constraints.py
414
415
416
417
418
419
420
421
422
423
424
425
426
def evaluate_spec(spec: InequalitySpec, resolve: PathResolver) -> InequalityEvaluation:
    """Evaluate a spec against the *current* parameter values.

    Used for the start-point feasibility check before a fit is launched and
    for displaying the constraint state; dependent parameters contribute
    their current (already propagated) value.
    """
    parameters = _resolve_all(spec, resolve)
    interpreter = _new_interpreter()
    lhs = float(_evaluate(interpreter, spec.lhs_expression, {a: float(parameters[a].value) for a in spec.lhs_paths}))
    rhs = float(_evaluate(interpreter, spec.rhs_expression, {a: float(parameters[a].value) for a in spec.rhs_paths}))
    violation = _violation(lhs, spec.op, rhs)
    return InequalityEvaluation(lhs=lhs, rhs=rhs, satisfied=violation == 0.0, violation=violation)

check_units(spec, resolve)

Raise :class:UnitError when the two sides of spec have incompatible units.

Each side is evaluated with the unit-carrying DescriptorNumber objects themselves (the same arithmetic the equality constraints use), so t1 + sld is rejected by EasyScience and t_head < t_tail passes. A plain numeric side is accepted against any unit: it is read in the unit of the other side. The same applies to a side mixing literals with parameters (90 - b): it is checked numerically and its literals are read in the unit of the other side.

Source code in src/easyreflectometry/inequality_constraints.py
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
def check_units(spec: InequalitySpec, resolve: PathResolver) -> None:
    """Raise :class:`UnitError` when the two sides of `spec` have incompatible units.

    Each side is evaluated with the unit-carrying ``DescriptorNumber``
    objects themselves (the same arithmetic the equality constraints use),
    so ``t1 + sld`` is rejected by EasyScience and ``t_head < t_tail``
    passes. A plain numeric side is accepted against any unit: it is read in
    the unit of the other side. The same applies to a side mixing literals
    with parameters (``90 - b``): it is checked numerically and its literals
    are read in the unit of the other side.
    """
    parameters = _resolve_all(spec, resolve)
    units = []
    for expression, paths in ((spec.lhs_expression, spec.lhs_paths), (spec.rhs_expression, spec.rhs_paths)):
        interpreter = _new_interpreter()
        try:
            result = _evaluate(interpreter, expression, {alias: parameters[alias] for alias in paths})
        except Exception as unit_error:
            # Mixed literal/parameter arithmetic such as ``90 - b`` cannot be
            # evaluated with unit-carrying objects (a bare number has no unit).
            # Fall back to a numeric evaluation and read the literals in the
            # unit of the other side; purely wrong mixes still fail here.
            try:
                numeric = _evaluate(_new_interpreter(), expression, {alias: float(parameters[alias].value) for alias in paths})
            except Exception:
                raise UnitError(f"Cannot evaluate '{expression}' with units: {unit_error}") from None
            if not isinstance(numeric, numbers.Number):
                raise ValueError(f"'{expression}' does not evaluate to a number.") from None
            units.append(None)
            continue
        if isinstance(result, DescriptorNumber):
            units.append(str(result.unit))
        elif isinstance(result, numbers.Number):
            units.append(None)
        else:
            raise ValueError(f"'{expression}' does not evaluate to a number.")
    lhs_unit, rhs_unit = units
    if lhs_unit is not None and rhs_unit is not None and lhs_unit != rhs_unit:
        raise UnitError(f"Incompatible units in '{spec}': left side is in '{lhs_unit}', right side in '{rhs_unit}'.")