Skip to content
Merged
120 changes: 61 additions & 59 deletions docs/dev/adrs/accepted/edstar-project-persistence.md

Large diffs are not rendered by default.

320 changes: 320 additions & 0 deletions docs/dev/plans/cwl-second-wavelength-placeholder.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from easydiffraction.core.metadata import TypeInfo
from easydiffraction.core.validation import AttributeSpec
from easydiffraction.core.validation import RangeValidator
from easydiffraction.core.variable import NumericDescriptor
from easydiffraction.core.variable import Parameter
from easydiffraction.datablocks.experiment.categories.instrument.base import InstrumentBase
from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory
Expand Down Expand Up @@ -45,6 +46,50 @@ def __init__(self) -> None:
),
)

# Placeholder for a second incident wavelength (the X-ray Cu
# K-alpha1/K-alpha2 doublet). These are non-refinable
# NumericDescriptors: no engine consumes them yet, so a
# refinable Parameter would let a fit silently move a value
# with no effect. Defaults of 0.0 mean monochromatic, as today.
self._setup_wavelength_2: NumericDescriptor = NumericDescriptor(
name='wavelength_2',
description='Second incident wavelength (e.g. X-ray K-alpha2)',
units='angstroms',
display_handler=DisplayHandler(
display_name='Wavelength 2',
display_units='Å',
latex_name='Wavelength 2',
latex_units=r'\AA',
),
value_spec=AttributeSpec(
default=0.0,
validator=RangeValidator(ge=0.0),
),
tags=TagSpec(
edi_names=['_instrument.setup_wavelength_2'],
cif_names=['_instr.wavelength_2'],
),
)
self._setup_wavelength_2_to_1_ratio: NumericDescriptor = NumericDescriptor(
name='wavelength_2_to_1_ratio',
description='Relative intensity of wavelength_2 to wavelength (I₂/I₁)',
units='',
display_handler=DisplayHandler(
display_name='Wavelength 2/1 ratio',
display_units='',
latex_name='Wavelength 2/1 ratio',
latex_units='',
),
value_spec=AttributeSpec(
default=0.0,
validator=RangeValidator(ge=0.0, le=1.0),
),
tags=TagSpec(
edi_names=['_instrument.setup_wavelength_2_to_1_ratio'],
cif_names=['_instr.wavelength_2_to_1_ratio'],
),
)

@property
def setup_wavelength(self) -> Parameter:
"""
Expand All @@ -60,6 +105,42 @@ def setup_wavelength(self, value: float) -> None:
"""Set the incident neutron or X-ray wavelength (Å)."""
self._setup_wavelength.value = value

@property
def setup_wavelength_2(self) -> NumericDescriptor:
"""
Second incident wavelength λ₂ (Å), e.g. the X-ray K-alpha2 line.

Reading returns the underlying ``NumericDescriptor``; assigning
a number updates its value. Default ``0.0`` means no second
component (monochromatic). Non-refinable placeholder: no engine
consumes it yet.
"""
return self._setup_wavelength_2

@setup_wavelength_2.setter
def setup_wavelength_2(self, value: float) -> None:
"""Set the second incident wavelength λ₂ (Å)."""
self._setup_wavelength_2.value = value

@property
def setup_wavelength_2_to_1_ratio(self) -> NumericDescriptor:
"""
Relative intensity of wavelength_2 to wavelength (I₂/I₁).

The ``_2_to_1_`` ordering names the direction: numerator is the
second component, denominator the first. Range ``[0, 1]``;
default ``0.0`` disables the second component. Non-refinable
placeholder.
"""
return self._setup_wavelength_2_to_1_ratio

@setup_wavelength_2_to_1_ratio.setter
def setup_wavelength_2_to_1_ratio(self, value: float) -> None:
"""
Set the wavelength_2-to-wavelength intensity ratio (I₂/I₁).
"""
self._setup_wavelength_2_to_1_ratio.value = value


@InstrumentFactory.register
class CwlScInstrument(CwlInstrumentBase):
Expand Down
83 changes: 73 additions & 10 deletions src/easydiffraction/io/cif/iucr_transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from dataclasses import dataclass
from typing import ClassVar

from easydiffraction.utils.logging import log


@dataclass(frozen=True)
class IucrItem:
Expand Down Expand Up @@ -80,7 +82,13 @@ class WavelengthTransformer(IucrCategoryTransformer):
@staticmethod
def items(experiment: object) -> tuple[IucrItem, ...] | None:
"""
Return wavelength items for a monochromatic experiment.
Return single-row wavelength items for a monochromatic beam.

Emits the single ``_diffrn_radiation_wavelength`` row when the
beam is monochromatic, or when a second wavelength is recorded
but disabled (``setup_wavelength_2_to_1_ratio == 0``). Returns
``None`` when no wavelength exists, or when an active doublet is
present so the writer falls through to :meth:`loop`.

Parameters
----------
Expand All @@ -90,14 +98,14 @@ def items(experiment: object) -> tuple[IucrItem, ...] | None:
Returns
-------
tuple[IucrItem, ...] | None
Wavelength items, or ``None`` when no wavelength exists.
Single-row wavelength items, or ``None``.
"""
wavelength = _attribute_value(
getattr(experiment, 'instrument', None),
'setup_wavelength',
)
instrument = getattr(experiment, 'instrument', None)
wavelength = _attribute_value(instrument, 'setup_wavelength')
if wavelength is None:
return None
if _wavelength_doublet_active(instrument):
return None
return (
IucrItem('_diffrn_radiation_wavelength.id', '1'),
IucrItem('_diffrn_radiation_wavelength.value', wavelength),
Expand All @@ -107,7 +115,14 @@ def items(experiment: object) -> tuple[IucrItem, ...] | None:
@staticmethod
def loop(experiment: object) -> IucrLoop | None:
"""
Return a wavelength loop for multi-wavelength experiments.
Return a two-row wavelength loop for an active doublet.

Emits the ``_diffrn_radiation_wavelength`` loop with the primary
wavelength (``wt`` 1.0) and the second component (``wt`` =
``setup_wavelength_2_to_1_ratio``) when the doublet is active;
``None`` otherwise. The incomplete pair (a positive ratio with
no second wavelength) is rejected by
:func:`_wavelength_doublet_active`.

Parameters
----------
Expand All @@ -117,10 +132,25 @@ def loop(experiment: object) -> IucrLoop | None:
Returns
-------
IucrLoop | None
Wavelength loop, or ``None`` for monochromatic experiments.
Wavelength loop, or ``None`` when not an active doublet.
"""
del experiment
return None
instrument = getattr(experiment, 'instrument', None)
wavelength = _attribute_value(instrument, 'setup_wavelength')
if wavelength is None or not _wavelength_doublet_active(instrument):
return None
wavelength_2 = _attribute_value(instrument, 'setup_wavelength_2')
ratio = _attribute_value(instrument, 'setup_wavelength_2_to_1_ratio')
return IucrLoop(
tags=(
'_diffrn_radiation_wavelength.id',
'_diffrn_radiation_wavelength.value',
'_diffrn_radiation_wavelength.wt',
),
rows=(
('1', wavelength, 1.0),
('2', wavelength_2, ratio),
),
)


@IucrCategoryTransformer.register
Expand Down Expand Up @@ -445,3 +475,36 @@ def _finite_number(value: object) -> float | None:
return None
number = float(value)
return number if math.isfinite(number) else None


def _wavelength_doublet_active(instrument: object) -> bool:
"""
Return whether an active second-wavelength doublet is present.

A doublet is active only when both ``setup_wavelength_2`` and
``setup_wavelength_2_to_1_ratio`` are positive. A positive ratio
with no second wavelength is an incomplete user-input pair and
raises ``ValueError`` rather than silently dropping the ratio. Every
other state — both zero (monochromatic), or a recorded-but-disabled
second wavelength with a zero ratio — is not active.

Parameters
----------
instrument : object
Instrument that may expose the doublet placeholder fields.

Returns
-------
bool
``True`` when an active doublet should be emitted as a loop.
"""
wavelength_2 = _finite_number(_attribute_value(instrument, 'setup_wavelength_2')) or 0.0
ratio = _finite_number(_attribute_value(instrument, 'setup_wavelength_2_to_1_ratio')) or 0.0
if ratio > 0.0 and wavelength_2 <= 0.0:
log.error(
'setup_wavelength_2_to_1_ratio is positive but '
'setup_wavelength_2 is not set: a relative intensity needs a '
'second wavelength.',
exc_type=ValueError,
)
return wavelength_2 > 0.0 and ratio > 0.0
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors <https://github.com/easyscience>
# SPDX-License-Identifier: BSD-3-Clause

import pytest

from easydiffraction.core.variable import NumericDescriptor
from easydiffraction.datablocks.experiment.categories.instrument.cwl import CwlPdInstrument
from easydiffraction.utils.logging import Logger


def test_cwl_instrument_parameters_settable():
Expand All @@ -24,3 +28,45 @@ def test_cwl_sample_corrections_default_to_zero_in_degrees():
# Degrees, matching the FullProf SyCos/SySin convention.
assert instr.calib_sample_displacement.units == 'degrees'
assert instr.calib_sample_transparency.units == 'degrees'


def test_cwl_second_wavelength_defaults_off():
instr = CwlPdInstrument()
# No second component by default: monochromatic, as before.
assert instr.setup_wavelength_2.value == 0.0
assert instr.setup_wavelength_2_to_1_ratio.value == 0.0
assert instr.setup_wavelength_2.units == 'angstroms'


def test_cwl_second_wavelength_settable():
instr = CwlPdInstrument()
instr.setup_wavelength_2 = 1.5444
instr.setup_wavelength_2_to_1_ratio = 0.5
assert instr.setup_wavelength_2.value == 1.5444
assert instr.setup_wavelength_2_to_1_ratio.value == 0.5


def test_cwl_second_wavelength_fields_are_non_refinable():
instr = CwlPdInstrument()
# Placeholder fields are NumericDescriptors, not refinable
# Parameters, so a fit cannot silently move a value no engine
# consumes yet (NumericDescriptor has no `free` flag).
assert isinstance(instr.setup_wavelength_2, NumericDescriptor)
assert isinstance(instr.setup_wavelength_2_to_1_ratio, NumericDescriptor)
assert not hasattr(instr.setup_wavelength_2, 'free')
assert not hasattr(instr.setup_wavelength_2_to_1_ratio, 'free')
# The primary wavelength stays a refinable Parameter.
assert hasattr(instr.setup_wavelength, 'free')


def test_cwl_second_wavelength_ratio_rejects_out_of_range(monkeypatch):
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
instr = CwlPdInstrument()
# Ratio is a relative intensity bounded to [0, 1].
with pytest.raises(TypeError):
instr.setup_wavelength_2_to_1_ratio = 1.5
with pytest.raises(TypeError):
instr.setup_wavelength_2_to_1_ratio = -0.1
# A negative second wavelength is rejected too.
with pytest.raises(TypeError):
instr.setup_wavelength_2 = -1.0
66 changes: 66 additions & 0 deletions tests/unit/easydiffraction/io/cif/test_iucr_transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from types import SimpleNamespace

import pytest

from easydiffraction.io.cif.handler import TagSpec


Expand Down Expand Up @@ -34,6 +36,70 @@ def test_wavelength_transformer_emits_monochromatic_items():
assert transformer.loop(experiment) is None


def test_wavelength_transformer_disabled_second_wavelength_is_monochromatic():
from easydiffraction.io.cif.iucr_transformers import WavelengthTransformer

# Second wavelength recorded but disabled (ratio == 0): single-row
# scalar output, matching the CFL `LAMBDA … 0.0` convention.
instrument = SimpleNamespace(
setup_wavelength=_Descriptor(1.5406),
setup_wavelength_2=_Descriptor(1.5444),
setup_wavelength_2_to_1_ratio=_Descriptor(0.0),
)
experiment = SimpleNamespace(instrument=instrument)
transformer = WavelengthTransformer()

assert tuple((item.tag, item.value) for item in transformer.items(experiment)) == (
('_diffrn_radiation_wavelength.id', '1'),
('_diffrn_radiation_wavelength.value', 1.5406),
('_diffrn_radiation_wavelength.wt', 1.0),
)
assert transformer.loop(experiment) is None


def test_wavelength_transformer_emits_active_doublet_loop():
from easydiffraction.io.cif.iucr_transformers import WavelengthTransformer

instrument = SimpleNamespace(
setup_wavelength=_Descriptor(1.5406),
setup_wavelength_2=_Descriptor(1.5444),
setup_wavelength_2_to_1_ratio=_Descriptor(0.5),
)
experiment = SimpleNamespace(instrument=instrument)
transformer = WavelengthTransformer()

# Active doublet: items() defers and loop() emits the two rows.
assert transformer.items(experiment) is None
loop = transformer.loop(experiment)
assert loop.tags == (
'_diffrn_radiation_wavelength.id',
'_diffrn_radiation_wavelength.value',
'_diffrn_radiation_wavelength.wt',
)
assert loop.rows == (
('1', 1.5406, 1.0),
('2', 1.5444, 0.5),
)


def test_wavelength_transformer_rejects_incomplete_pair(monkeypatch):
from easydiffraction.io.cif.iucr_transformers import WavelengthTransformer
from easydiffraction.utils.logging import Logger

# A positive ratio with no second wavelength is an incomplete pair:
# rejected, not silently dropped.
monkeypatch.setattr(Logger, '_reaction', Logger.Reaction.RAISE, raising=True)
instrument = SimpleNamespace(
setup_wavelength=_Descriptor(1.5406),
setup_wavelength_2=_Descriptor(0.0),
setup_wavelength_2_to_1_ratio=_Descriptor(0.5),
)
experiment = SimpleNamespace(instrument=instrument)

with pytest.raises(ValueError, match='second wavelength'):
WavelengthTransformer().items(experiment)


def test_tof_calibration_transformer_emits_powers_and_ids():
from easydiffraction.io.cif.iucr_transformers import TofCalibrationTransformer

Expand Down
Loading