From ce2123ea5b6c40928a7b09ab9ea404ccd58d0cac Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:04:07 +0200 Subject: [PATCH 1/9] Add cwl-second-wavelength-placeholder implementation plan --- .../cwl-second-wavelength-placeholder.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/dev/plans/cwl-second-wavelength-placeholder.md diff --git a/docs/dev/plans/cwl-second-wavelength-placeholder.md b/docs/dev/plans/cwl-second-wavelength-placeholder.md new file mode 100644 index 000000000..7bf4bb412 --- /dev/null +++ b/docs/dev/plans/cwl-second-wavelength-placeholder.md @@ -0,0 +1,320 @@ +# Plan: Second-Wavelength (Kα₁/Kα₂) Placeholder on the CWL Instrument + +Follows [`AGENTS.md`](../../../AGENTS.md). No deliberate exceptions to +those instructions. + +## ADR + +No new ADR is required. This adds two parameters to an existing +`CategoryItem` (the constant-wavelength instrument) and serialises them +through the established CIF machinery; it introduces no new category, +factory, switchable-category wiring, or datablock. It touches CIF +serialisation only by populating the **already-scaffolded** +`_diffrn_radiation_wavelength` loop, so it stays within +[`edstar-project-persistence`](../adrs/accepted/edstar-project-persistence.md) +(which documents the existing `_instrument.setup_wavelength` ↔ +`_diffrn_radiation_wavelength.value` mapping). If review decides the +collision/precedence rules below deserve a recorded decision, promote +this section to a short ADR before the PR. + +That ADR carries a persisted-field **inventory** (its CWL-instrument +rows and the per-attribute Edi-name table), so adding two persisted +fields makes the ADR stale unless it is updated in lockstep. This plan +therefore includes a Phase 1 step (P1.3) to update those inventory rows +— it amends the inventory, not the decision (Review 1, F3). + +## Summary of the change + +Constant-wavelength instruments today carry a single `setup_wavelength`. +Laboratory X-ray sources (and the FullProf X-ray reference under +`docs/docs/verification/fullprof/pd-xray-pbso4/`) use a Cu Kα₁/Kα₂ +**doublet**: a second wavelength with a fixed relative intensity. +FullProf encodes this as `Lambda1 Lambda2 Ratio`; the future crysfml CFL +reads it as `LAMBDA λ₁ λ₂ ratio`; CIF core encodes it as a looped +`DIFFRN_RADIATION_WAVELENGTH` with per-row `value` + `wt`. + +This change adds a **placeholder** for that doublet on the CWL +instrument category: two new parameters that can be set, read, and +round-trip through CIF. **Binding to the calculation engines is out of +scope** — no calculator reads these values yet, so a doublet experiment +calculates exactly as today (single wavelength). cryspy has no +second-wavelength support and is explicitly not addressed; the eventual +consumer is a future crysfml build via the `LAMBDA` CFL directive. + +New public settings on `CwlInstrumentBase`, both **non-refinable** +`NumericDescriptor`s (see Decision "Non-refinable by construction"): + +- `setup_wavelength_2` — the second wavelength λ₂ (Å). Default `0.0` + meaning "no second component" (monochromatic, today's behaviour). +- `setup_wavelength_2_to_1_ratio` — the relative intensity of the second + component to the first, **I(wavelength_2) / I(wavelength)**. The + `_2_to_1_` ordering names the direction explicitly (numerator = + component 2, denominator = component 1). Default `0.0` (second + component contributes nothing). This is the FullProf `Ratio` column, + the CFL `LAMBDA` third value, and the CIF `wt` of the second loop row + (with the first row's `wt` normalised to 1). + +### Reachable states (all four are publicly settable) + +The two fields are set independently, so every combination is reachable. +The semantics are fully specified — there are no undefined mixed states: + +| `setup_wavelength_2` | `…_2_to_1_ratio` | Meaning | CIF / output | +| -------------------- | ---------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------- | +| `0` | `0` | Monochromatic (default, today) | single row / scalar | +| `> 0` | `0` | Second λ recorded but **disabled** — matches the CFL `LAMBDA … 0.0` convention | single row / scalar; λ₂ value preserved on edi-CIF round-trip | +| `> 0` | `> 0` | **Active doublet** | 2-row `_diffrn_radiation_wavelength` loop | +| `0` | `> 0` | **Invalid**: a relative intensity for a wavelength that does not exist | export raises a clear `ValueError` | + +So "active doublet" ≡ +`setup_wavelength_2 > 0 and setup_wavelength_2_to_1_ratio > 0`; +"disabled" is governed by `ratio == 0` (any λ₂); the incomplete pair +`(λ₂ == 0, ratio > 0)` is a boundary user-input error and is rejected, +not silently dropped. + +## Decisions + +- **Names.** `setup_wavelength_2` and `setup_wavelength_2_to_1_ratio`, + chosen in conversation. The ratio name encodes numerator/denominator + direction so it cannot be misread as `λ/λ₂` vs `λ₂/λ`. +- **Scope.** Settings **plus CIF round-trip**; no engine binding. +- **Non-refinable by construction (Review 1, F1).** Both fields are + `NumericDescriptor`s, **not** `Parameter`s. A `NumericDescriptor` has + no `free` flag, so the fitter — which collects only free `Parameter`s + (`src/easydiffraction/analysis/fitting.py:103`) and syncs them back + each residual evaluation (`…/fitting.py:479`) — can never pick these + up. This removes the silent no-op a refinable but engine-unbound value + would create: a scientist cannot mark a doublet setting `free` and + watch the optimiser move a value that changes nothing. They follow the + existing editable-but-non-refinable pattern used for the CWL + data-range bounds (`…/categories/data_range/cwl.py`). When engine + binding lands, a follow-up can promote them to `Parameter`. +- **Mixed states are fully specified (Review 1, F2).** See the + "Reachable states" table above. `ratio == 0` means _disabled_ + (single-row output, λ₂ preserved), matching the CFL `LAMBDA … 0.0` + convention; `ratio > 0 and λ₂ == 0` is an incomplete pair and is + **rejected at export time** with a clear `ValueError` (via the + project's `log.error(..., exc_type=ValueError)` pattern) rather than + silently emitting a single row and dropping the ratio. +- **Ratio is a relative intensity, not a wavelength ratio.** Range + `[0, 1]` (matches CIF `wt` `_enumeration.range 0.0:1.0`). Documented + in the docstring and field description. +- **edi-CIF round-trip is automatic.** `category_item_to_cif` writes + each field by its **`edi_name`** (`io/cif/serialize.py:189`), and + `category_item_from_cif` reads by the union `read_names` + (`io/cif/handler.py` `read_names`); `NumericDescriptor`s serialise + through this same path (the CWL data-range bounds prove it). Distinct + `edi_names` (`_instrument.setup_wavelength_2`, + `_instrument.setup_wavelength_2_to_1_ratio`) give a clean scalar + round-trip with **no extra serialise code** — the placeholder persists + the moment the fields exist. +- **IUCr/pdCIF export uses the loop.** The strict export + (`io/cif/iucr_writer.py` → `WavelengthTransformer`) emits the proper + `_diffrn_radiation_wavelength` loop when the doublet is active and + keeps today's single-row scalar output otherwise. The transformer + already has an `items()` path and an unused `loop()` stub + (`io/cif/iucr_transformers.py:107`) reserved for exactly this. +- **Avoid the shared-tag collision.** `setup_wavelength` already lists + `_diffrn_radiation_wavelength.value` in its `cif_names`. The two new + fields' `cif_names` contain **only** their edi-CIF short names + (`_instr.wavelength_2`, `_instr.wavelength_2_to_1_ratio`) — they do + **not** list any `_diffrn_radiation_wavelength.*` tag, so the generic + scalar writer/reader can never emit or consume a duplicate bare + `_diffrn_radiation_wavelength.value`/`.wt`. The IUCr `value`/`wt` + pairing is produced solely by the loop transformer, which + disambiguates components by row `id`. (`setup_wavelength`'s existing + tags are left unchanged.) + +## Open questions + +1. **IUCr loop _import_.** This plan round-trips through **edi-CIF** + (the project-persistence format) and additionally _exports_ the IUCr + loop. Reading a 2-row `_diffrn_radiation_wavelength` loop back from a + strict-IUCr file into the two parameters is **not** in scope — the + generic scalar reader only sees the first row. Flag for a follow-up + if strict-IUCr import of doublets is wanted. (Recommended: defer.) +2. **`xray_symbol` / `type` metadata.** CIF core also offers + `_diffrn_radiation_wavelength.xray_symbol` (e.g. `K-L~3~`/`K-L~2~`) + and `.type`. Out of scope for the placeholder; note as deferred. +3. **TOF instruments.** The doublet is a CWL concept; no change to + `instrument/tof.py`. Confirm reviewers agree the fields live on + `CwlInstrumentBase` only. +4. **Where the incomplete-pair guard lives.** The plan raises the + `(λ₂ == 0, ratio > 0)` error at IUCr export (in the transformer), + because the two fields are set independently and a set-time check + would trip on the transient half-set state. Reviewers may prefer an + additional validation hook; export-time is proposed as the single + enforced boundary for the placeholder. (Recommended: export-time + only.) + +## Deferred work — engine binding and its performance note + +Engine binding is out of scope here, but recording the trade-off so the +follow-up starts informed: + +- **Two ways to compute the doublet.** (a) *Calculator-independent* — + edi calls the engine once at λ₁ and once at λ₂ and weight-sums the two + patterns; works for any engine (and is the only option for cryspy, + which has no native doublet). (b) *Native crysfml* — a future build + computes both lines in one pass via the `LAMBDA λ₁ λ₂ ratio` CFL + directive. +- **(a) is expected to be slower, bounded by ~2× the single-wavelength + cost.** In crysfml's `cw_powder_pattern_profile` + (`tmp/crysfml/Src/CFML_Utilities/Utilities_Patterns.f90`) the + per-reflection structure factors `ref(i)%fc(2)**2` are computed + **once** before the reflection loop; a native doublet extends that + same loop to lay down a second peak at the λ₂ position scaled by + `ratio`, reusing the same |F|². So native pays 1× structure-factors + + 1× reflection generation + ~2× profile summation + 1× call overhead, + whereas the two-pass approach repeats **all** of that. The gap is + small when profile summation dominates (fine grid / broad peaks) and + approaches the full 2× when structure-factor / reflection-generation / + marshalling dominate; it compounds across a fit's many residual + evaluations. +- **Caveats.** The crysfml routine wired into easydiffraction today is + itself single-wavelength (uses `Lambda(1)`, ignores the `twowaves` + flag set in `Format_CFL.f90`), so even the native path needs the + future build. Because Kα₁/Kα₂ differ by ~0.25%, |F|² is effectively + identical for both lines — so a smarter calculator-independent variant + could compute reflections/|F|² once and place only the second peak set + itself, matching native's "compute once, place twice", at the cost of + edi owning profile generation (a larger change than this placeholder). + +## Concrete files likely to change + +- `src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py` + — add the two `NumericDescriptor`s on `CwlInstrumentBase` with + getter/setter properties (mirroring the `data_range/cwl.py` + non-refinable pattern), distinct `edi_names`, range `ge=0.0` / + `[0, 1]` validators, and `TagSpec`s carrying only the `_instr.*` short + `cif_names` per the collision decision. +- `src/easydiffraction/io/cif/iucr_transformers.py` — make + `WavelengthTransformer.items()` return the single row when + monochromatic-or-disabled (`ratio == 0`) and `None` when the doublet + is active (so the writer falls through to `loop()`); implement + `loop()` to emit the 2-row `_diffrn_radiation_wavelength` loop (`id`, + `value`, `wt`) when active; and raise a clear `ValueError` for the + incomplete pair `(λ₂ == 0, ratio > 0)`. +- `src/easydiffraction/io/cif/iucr_writer.py` — + `_write_wavelength_section` already prefers `items()` then `loop()` + (lines 247-269); verify it needs no change once `items()`/`loop()` + cooperate. Adjust only if the fall-through guard + (`if wavelength is None`) interferes. +- `docs/dev/adrs/accepted/edstar-project-persistence.md` — extend the + CWL-instrument inventory row (~line 633) and the per-attribute Edi + name table (~line 847) with the two new fields and their `_instr.*` / + `_instrument.*` names and the IUCr loop note (F3). +- Tests (Phase 2): + - `tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_cwl.py` + — defaults are off; set/get; range validation; monochromatic + behaviour unchanged; **non-refinable** (the field is a + `NumericDescriptor` with no `free`, so it is never collected by the + fitter). + - `tests/unit/easydiffraction/io/cif/test_iucr_transformers.py` and + `test_iucr_writer.py` — scalar output when monochromatic and when + disabled (`ratio == 0`, `λ₂ > 0`); 2-row loop when active; clear + `ValueError` for the incomplete pair `(λ₂ == 0, ratio > 0)`. + - edi-CIF round-trip: a `setup_wavelength_2`/ratio set on an + experiment (including the disabled `ratio == 0`, `λ₂ > 0` state) + survives `as_cif` → `from_cif` (extend the nearest existing + serialize round-trip test under + `tests/unit/easydiffraction/io/cif/`). +- Docs: no tutorial change. The `docs/docs/verification/pd-xray-pbso4` + page stays a `known_discrepancy` (engine binding is still absent), so + it is untouched by this plan. + +## Branch and PR notes + +- Flat-slug implementation branch off `develop`: + `cwl-second-wavelength-placeholder` (created by `/draft-impl-1`, not + now). PR targets `develop`. +- Each Phase 1 step is staged with explicit paths and committed locally + before the next step (per `AGENTS.md` §Commits). Atomic, + single-purpose commits. + +## Implementation steps (Phase 1) + +- [ ] **P1.1 — Add the two placeholder fields to `CwlInstrumentBase`.** + In `instrument/cwl.py`, add `_setup_wavelength_2` + (`NumericDescriptor`, default `0.0`, `RangeValidator(ge=0.0)`, + units `angstroms`) and `_setup_wavelength_2_to_1_ratio` + (`NumericDescriptor`, default `0.0`, + `RangeValidator(ge=0.0, le=1.0)`, dimensionless) with getter + properties and value setters following the non-refinable + `data_range/cwl.py` pattern. Give them distinct `edi_names` + (`_instrument.setup_wavelength_2`, + `_instrument.setup_wavelength_2_to_1_ratio`) and `cif_names` + containing only the `_instr.*` short names (collision decision + above). Docstrings state the ratio direction and the `[0, 1]` + range. No tests yet (Phase 1 is code + docstrings only). Commit: + `Add second-wavelength placeholder fields to CWL instrument` + +- [ ] **P1.2 — Emit the IUCr wavelength loop and guard mixed states.** + In `iucr_transformers.py`, update `WavelengthTransformer.items()` + to return the single-row items when monochromatic **or disabled** + (`ratio == 0`, any λ₂) and `None` when the doublet is active; + implement `loop()` to return an `IucrLoop` of + `_diffrn_radiation_wavelength.{id,value,wt}` with rows + `(1, λ₁, 1.0)` and `(2, λ₂, ratio)` when active; and raise a clear + `ValueError` (project `log.error(..., exc_type=ValueError)` + pattern) for the incomplete pair `(λ₂ == 0, ratio > 0)`. Verify + `_write_wavelength_section` in `iucr_writer.py` routes correctly; + adjust the guard only if required. Commit: + `Emit diffrn_radiation_wavelength loop for CWL doublet` + +- [ ] **P1.3 — Update the Edi persistence inventory ADR.** In + `docs/dev/adrs/accepted/edstar-project-persistence.md`, add the + two new fields to the CWL-instrument inventory row (~line 633) and + the per-attribute Edi-name table (~line 847), with their + `_instr.*` and `_instrument.*` names and an IUCr-loop note. + Inventory amendment only; the decision is unchanged. Commit: + `Record CWL second-wavelength fields in persistence ADR` + +- [ ] **P1.4 — Phase 1 review gate.** No-code step. Mark complete and + commit the checklist update alone. Commit: + `Reach Phase 1 review gate` + +## Verification (Phase 2) + +Run in order; capture output with the zsh-safe pattern when analysis is +needed: + +```bash +pixi run fix +pixi run check > /tmp/edi-check.log 2>&1; check_exit_code=$?; tail -n 200 /tmp/edi-check.log; exit $check_exit_code +pixi run unit-tests > /tmp/edi-unit.log 2>&1; unit_tests_exit_code=$?; tail -n 100 /tmp/edi-unit.log; exit $unit_tests_exit_code +pixi run integration-tests +pixi run script-tests +``` + +Phase 2 adds the tests listed under _Concrete files_: + +- CWL instrument: defaults off, set/get, range validation, monochromatic + output unchanged, and **non-refinable** (field is a + `NumericDescriptor` with no `free`; never collected by the fitter). +- IUCr transformer/writer: scalar when monochromatic and when disabled + (`ratio == 0`, `λ₂ > 0`); 2-row loop when active; `ValueError` for the + incomplete pair `(λ₂ == 0, ratio > 0)`. +- edi-CIF round-trip of both new fields, including the disabled state. + +## Status checklist + +- [ ] P1.1 — placeholder fields added (non-refinable + `NumericDescriptor`s) +- [ ] P1.2 — IUCr loop emitted when active; mixed states guarded +- [ ] P1.3 — Edi persistence inventory ADR updated +- [ ] P1.4 — Phase 1 review gate +- [ ] Phase 2 — tests added and all five `pixi run` tasks clean + +## Suggested Pull Request + +**Title:** Support a second X-ray wavelength (Kα₁/Kα₂) on +constant-wavelength instruments + +**Description:** Constant-wavelength instruments can now record a second +incident wavelength and its relative intensity — the Kα₁/Kα₂ doublet +typical of laboratory X-ray sources. The new `setup_wavelength_2` and +`setup_wavelength_2_to_1_ratio` settings are saved and restored with the +project and exported in standard CIF. This is a data-model step: the +values are stored and shared but not yet used in pattern calculation, so +existing single-wavelength experiments are unaffected. From 4d05124a817b71254b8e8e769d2fda9023b8035f Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:05:26 +0200 Subject: [PATCH 2/9] Add second-wavelength placeholder fields to CWL instrument --- .../cwl-second-wavelength-placeholder.md | 4 +- .../experiment/categories/instrument/cwl.py | 79 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/docs/dev/plans/cwl-second-wavelength-placeholder.md b/docs/dev/plans/cwl-second-wavelength-placeholder.md index 7bf4bb412..7f6e8a839 100644 --- a/docs/dev/plans/cwl-second-wavelength-placeholder.md +++ b/docs/dev/plans/cwl-second-wavelength-placeholder.md @@ -234,7 +234,7 @@ follow-up starts informed: ## Implementation steps (Phase 1) -- [ ] **P1.1 — Add the two placeholder fields to `CwlInstrumentBase`.** +- [x] **P1.1 — Add the two placeholder fields to `CwlInstrumentBase`.** In `instrument/cwl.py`, add `_setup_wavelength_2` (`NumericDescriptor`, default `0.0`, `RangeValidator(ge=0.0)`, units `angstroms`) and `_setup_wavelength_2_to_1_ratio` @@ -299,7 +299,7 @@ Phase 2 adds the tests listed under _Concrete files_: ## Status checklist -- [ ] P1.1 — placeholder fields added (non-refinable +- [x] P1.1 — placeholder fields added (non-refinable `NumericDescriptor`s) - [ ] P1.2 — IUCr loop emitted when active; mixed states guarded - [ ] P1.3 — Edi persistence inventory ADR updated diff --git a/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py b/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py index bd84e6a0d..39f205141 100644 --- a/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py +++ b/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py @@ -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 @@ -45,6 +46,50 @@ def __init__(self) -> None: ), ) + # Placeholder for a second incident wavelength (e.g. the X-ray + # Cu Kα₁/Kα₂ doublet). Non-refinable NumericDescriptors: no + # calculation engine consumes them yet, so a refinable Parameter + # would let a fit silently move a value with no effect. Defaults + # of 0.0 mean "no second component" (monochromatic, as today). + self._setup_wavelength_2: NumericDescriptor = NumericDescriptor( + name='wavelength_2', + description='Second incident wavelength (e.g. X-ray Kα₂)', + 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: """ @@ -60,6 +105,40 @@ 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α₂ 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): From fcb9b6a6caeedddae944163c28dbcbd3b1af7b74 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:08:06 +0200 Subject: [PATCH 3/9] Emit diffrn_radiation_wavelength loop for CWL doublet --- .../cwl-second-wavelength-placeholder.md | 4 +- .../io/cif/iucr_transformers.py | 88 ++++++++++++++++--- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/docs/dev/plans/cwl-second-wavelength-placeholder.md b/docs/dev/plans/cwl-second-wavelength-placeholder.md index 7f6e8a839..2580188ee 100644 --- a/docs/dev/plans/cwl-second-wavelength-placeholder.md +++ b/docs/dev/plans/cwl-second-wavelength-placeholder.md @@ -249,7 +249,7 @@ follow-up starts informed: range. No tests yet (Phase 1 is code + docstrings only). Commit: `Add second-wavelength placeholder fields to CWL instrument` -- [ ] **P1.2 — Emit the IUCr wavelength loop and guard mixed states.** +- [x] **P1.2 — Emit the IUCr wavelength loop and guard mixed states.** In `iucr_transformers.py`, update `WavelengthTransformer.items()` to return the single-row items when monochromatic **or disabled** (`ratio == 0`, any λ₂) and `None` when the doublet is active; @@ -301,7 +301,7 @@ Phase 2 adds the tests listed under _Concrete files_: - [x] P1.1 — placeholder fields added (non-refinable `NumericDescriptor`s) -- [ ] P1.2 — IUCr loop emitted when active; mixed states guarded +- [x] P1.2 — IUCr loop emitted when active; mixed states guarded - [ ] P1.3 — Edi persistence inventory ADR updated - [ ] P1.4 — Phase 1 review gate - [ ] Phase 2 — tests added and all five `pixi run` tasks clean diff --git a/src/easydiffraction/io/cif/iucr_transformers.py b/src/easydiffraction/io/cif/iucr_transformers.py index fa8dbf5ad..1bd7f7857 100644 --- a/src/easydiffraction/io/cif/iucr_transformers.py +++ b/src/easydiffraction/io/cif/iucr_transformers.py @@ -9,6 +9,8 @@ from dataclasses import dataclass from typing import ClassVar +from easydiffraction.utils.logging import log + @dataclass(frozen=True) class IucrItem: @@ -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 ---------- @@ -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), @@ -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 ---------- @@ -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 @@ -445,3 +475,41 @@ 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. + + Raises + ------ + ValueError + If the ratio is positive but no second wavelength is set. + """ + 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 From 6ddb8af4a409461cae1fe468420afb4a5972a76a Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:09:46 +0200 Subject: [PATCH 4/9] Record CWL second-wavelength fields in persistence ADR --- docs/dev/adrs/accepted/edstar-project-persistence.md | 4 +++- docs/dev/plans/cwl-second-wavelength-placeholder.md | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/dev/adrs/accepted/edstar-project-persistence.md b/docs/dev/adrs/accepted/edstar-project-persistence.md index a78ad06c4..b12d23171 100644 --- a/docs/dev/adrs/accepted/edstar-project-persistence.md +++ b/docs/dev/adrs/accepted/edstar-project-persistence.md @@ -630,7 +630,7 @@ descriptor absent from this table is a migration blocker. | `experiment.excluded_regions` | `id`, `start`, `end` | `_excluded_region.{id,start,end}` | same | report free text `_pd_proc.info_excluded_regions` plus extension rows | | `experiment.type` → `experiment_type` | `sample_form`, `beam_mode`, `radiation_probe`, `scattering_type` | `_expt_type.{sample_form,beam_mode,radiation_probe,scattering_type}` | `_experiment_type.{sample_form,beam_mode,radiation_probe,scattering_type}` | | | `experiment.extinction` | `type`, `model`, `mosaicity`, `radius` | `_extinction.{type,model,mosaicity,radius}` | same | `_refine_ls.extinction_method`, `_refine_ls.extinction_coef`, `_refine.special_details` by report transform | -| `experiment.instrument` CWL | `setup_wavelength`, `calib_twotheta_offset`, `calib_sample_displacement`, `calib_sample_transparency` | `_instr.wavelength`, `_instr.2theta_offset`, `_instr.sample_displacement`, `_instr.sample_transparency` | `_instrument.{setup_wavelength,calib_twotheta_offset,calib_sample_displacement,calib_sample_transparency}` | `_diffrn_radiation_wavelength.value`; `_pd_calib.2theta_offset` | +| `experiment.instrument` CWL | `setup_wavelength`, `setup_wavelength_2`, `setup_wavelength_2_to_1_ratio`, `calib_twotheta_offset`, `calib_sample_displacement`, `calib_sample_transparency` | `_instr.wavelength`, `_instr.wavelength_2`, `_instr.wavelength_2_to_1_ratio`, `_instr.2theta_offset`, `_instr.sample_displacement`, `_instr.sample_transparency` | `_instrument.{setup_wavelength,setup_wavelength_2,setup_wavelength_2_to_1_ratio,calib_twotheta_offset,calib_sample_displacement,calib_sample_transparency}` | `_diffrn_radiation_wavelength.value` (or a `_diffrn_radiation_wavelength` id/value/wt loop for a Kα₁/Kα₂ doublet); `_pd_calib.2theta_offset` | | `experiment.instrument` TOF | `setup_twotheta_bank`, `calib_d_to_tof_offset`, `calib_d_to_tof_linear`, `calib_d_to_tof_quad`, `calib_d_to_tof_recip` | `_instr.2theta_bank`, `_instr.{d_to_tof_offset,d_to_tof_linear,d_to_tof_quad,d_to_tof_recip}` | `_instrument.{setup_twotheta_bank,calib_d_to_tof_offset,calib_d_to_tof_linear,calib_d_to_tof_quadratic,calib_d_to_tof_reciprocal}` | `_pd_calib_d_to_tof.{id,power,coeff,coeff_su,diffractogram_id}` loop for nonzero coefficients | | `experiment.linked_crystal` → `linked_structure` (single crystal) | `id`, `scale` | `_sc_crystal_block.{id,scale}` | `_linked_structure.{structure_id,scale}` | | | `experiment.linked_phases` → `linked_structures` (powder) | `id`, `scale` | `_pd_phase_block.{id,scale}` | `_linked_structure.{structure_id,scale}` | `_pd_phase_block.{id,scale}` | @@ -845,6 +845,8 @@ column is the persisted data name. | `experiment.background[''].order` | same | `_background.order` | | `experiment.background[''].coef` | same | `_background.coef` | | `experiment.instrument.setup_wavelength` | same | `_instrument.setup_wavelength` | +| `experiment.instrument.setup_wavelength_2` | same | `_instrument.setup_wavelength_2` | +| `experiment.instrument.setup_wavelength_2_to_1_ratio` | same | `_instrument.setup_wavelength_2_to_1_ratio` | | `experiment.instrument.calib_twotheta_offset` | same | `_instrument.calib_twotheta_offset` | | `experiment.instrument.calib_sample_displacement` | same | `_instrument.calib_sample_displacement` | | `experiment.instrument.calib_sample_transparency` | same | `_instrument.calib_sample_transparency` | diff --git a/docs/dev/plans/cwl-second-wavelength-placeholder.md b/docs/dev/plans/cwl-second-wavelength-placeholder.md index 2580188ee..38288167c 100644 --- a/docs/dev/plans/cwl-second-wavelength-placeholder.md +++ b/docs/dev/plans/cwl-second-wavelength-placeholder.md @@ -262,7 +262,7 @@ follow-up starts informed: adjust the guard only if required. Commit: `Emit diffrn_radiation_wavelength loop for CWL doublet` -- [ ] **P1.3 — Update the Edi persistence inventory ADR.** In +- [x] **P1.3 — Update the Edi persistence inventory ADR.** In `docs/dev/adrs/accepted/edstar-project-persistence.md`, add the two new fields to the CWL-instrument inventory row (~line 633) and the per-attribute Edi-name table (~line 847), with their @@ -302,7 +302,7 @@ Phase 2 adds the tests listed under _Concrete files_: - [x] P1.1 — placeholder fields added (non-refinable `NumericDescriptor`s) - [x] P1.2 — IUCr loop emitted when active; mixed states guarded -- [ ] P1.3 — Edi persistence inventory ADR updated +- [x] P1.3 — Edi persistence inventory ADR updated - [ ] P1.4 — Phase 1 review gate - [ ] Phase 2 — tests added and all five `pixi run` tasks clean From e05213c5cf9c5f60998ce7d79091cd6288f466b9 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:10:01 +0200 Subject: [PATCH 5/9] Reach Phase 1 review gate --- docs/dev/plans/cwl-second-wavelength-placeholder.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev/plans/cwl-second-wavelength-placeholder.md b/docs/dev/plans/cwl-second-wavelength-placeholder.md index 38288167c..c11ca97e4 100644 --- a/docs/dev/plans/cwl-second-wavelength-placeholder.md +++ b/docs/dev/plans/cwl-second-wavelength-placeholder.md @@ -270,7 +270,7 @@ follow-up starts informed: Inventory amendment only; the decision is unchanged. Commit: `Record CWL second-wavelength fields in persistence ADR` -- [ ] **P1.4 — Phase 1 review gate.** No-code step. Mark complete and +- [x] **P1.4 — Phase 1 review gate.** No-code step. Mark complete and commit the checklist update alone. Commit: `Reach Phase 1 review gate` @@ -303,7 +303,7 @@ Phase 2 adds the tests listed under _Concrete files_: `NumericDescriptor`s) - [x] P1.2 — IUCr loop emitted when active; mixed states guarded - [x] P1.3 — Edi persistence inventory ADR updated -- [ ] P1.4 — Phase 1 review gate +- [x] P1.4 — Phase 1 review gate - [ ] Phase 2 — tests added and all five `pixi run` tasks clean ## Suggested Pull Request From e1d3015de9816168eff7f059b7261bf9adccaff4 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:20:37 +0200 Subject: [PATCH 6/9] Add tests for CWL second-wavelength placeholder --- .../categories/instrument/test_cwl.py | 46 +++++++++++++ .../io/cif/test_iucr_transformers.py | 66 +++++++++++++++++++ .../easydiffraction/io/cif/test_serialize.py | 36 ++++++++++ 3 files changed, 148 insertions(+) diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_cwl.py b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_cwl.py index a0a1d07df..c044c53ab 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_cwl.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/instrument/test_cwl.py @@ -1,7 +1,11 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # 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(): @@ -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 diff --git a/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py b/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py index e5d5fadbc..cb50d96cb 100644 --- a/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py +++ b/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py @@ -6,6 +6,8 @@ from types import SimpleNamespace +import pytest + from easydiffraction.io.cif.handler import TagSpec @@ -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): + WavelengthTransformer().items(experiment) + + def test_tof_calibration_transformer_emits_powers_and_ids(): from easydiffraction.io.cif.iucr_transformers import TofCalibrationTransformer diff --git a/tests/unit/easydiffraction/io/cif/test_serialize.py b/tests/unit/easydiffraction/io/cif/test_serialize.py index 069dbd600..eca59af05 100644 --- a/tests/unit/easydiffraction/io/cif/test_serialize.py +++ b/tests/unit/easydiffraction/io/cif/test_serialize.py @@ -313,3 +313,39 @@ def test_beta_atom_round_trips_through_cif(): expected_b_eq = 8.0 * math.pi**2 * u_eq assert structure.atom_sites['Fe'].adp_iso_as_b == pytest.approx(expected_b_eq, rel=1e-9) assert reloaded.atom_sites['Fe'].adp_iso_as_b == pytest.approx(expected_b_eq, rel=1e-9) + + +def test_cwl_second_wavelength_round_trips_through_cif(): + import gemmi + + from easydiffraction.datablocks.experiment.categories.instrument.cwl import CwlPdInstrument + + instr = CwlPdInstrument() + instr.setup_wavelength = 1.5406 + instr.setup_wavelength_2 = 1.5444 + instr.setup_wavelength_2_to_1_ratio = 0.5 + + block = gemmi.cif.read_string('data_x\n' + instr.as_cif + '\n').sole_block() + restored = CwlPdInstrument() + restored.from_cif(block) + + assert restored.setup_wavelength_2.value == 1.5444 + assert restored.setup_wavelength_2_to_1_ratio.value == 0.5 + + +def test_cwl_disabled_second_wavelength_preserves_value_through_cif(): + import gemmi + + from easydiffraction.datablocks.experiment.categories.instrument.cwl import CwlPdInstrument + + # Disabled state (ratio == 0) still persists the recorded λ₂. + instr = CwlPdInstrument() + instr.setup_wavelength = 1.5406 + instr.setup_wavelength_2 = 1.5444 + + block = gemmi.cif.read_string('data_x\n' + instr.as_cif + '\n').sole_block() + restored = CwlPdInstrument() + restored.from_cif(block) + + assert restored.setup_wavelength_2.value == 1.5444 + assert restored.setup_wavelength_2_to_1_ratio.value == 0.0 From e4ed34d66adb2f2db1152e6c67a249b17430fc99 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:27:39 +0200 Subject: [PATCH 7/9] Resolve lint and formatting findings for placeholder --- .../accepted/edstar-project-persistence.md | 118 +++++++++--------- .../cwl-second-wavelength-placeholder.md | 4 +- .../experiment/categories/instrument/cwl.py | 18 +-- .../io/cif/iucr_transformers.py | 13 +- .../io/cif/test_iucr_transformers.py | 2 +- 5 files changed, 76 insertions(+), 79 deletions(-) diff --git a/docs/dev/adrs/accepted/edstar-project-persistence.md b/docs/dev/adrs/accepted/edstar-project-persistence.md index b12d23171..27f8b2abd 100644 --- a/docs/dev/adrs/accepted/edstar-project-persistence.md +++ b/docs/dev/adrs/accepted/edstar-project-persistence.md @@ -597,65 +597,65 @@ descriptor in `src/easydiffraction`. Implementation must verify that claim with a generated inventory before changing write tags; any descriptor absent from this table is a migration blocker. -| Area | Current EasyDiffraction names | Current project tags | Suggested Edi tags | Official/report CIF names | -| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `analysis.aliases` | `label`, `param_unique_name` | `_alias.{label,param_unique_name}` | `_alias.{id,parameter_unique_name}` | | -| `analysis.constraints` | `id`, `expression` | `_constraint.{id,expression}` | `_constraint.{id,expression}` | | -| `analysis.fit_parameter_correlations` | `id`, `source_kind`, `param_unique_name_i`, `param_unique_name_j`, `correlation` | `_fit_parameter_correlation.{id,source_kind,param_unique_name_i,param_unique_name_j,correlation}` | `_fit_parameter_correlation.{id,source_kind,parameter_unique_name_i,parameter_unique_name_j,correlation}` | | -| `analysis.fit_parameters` | `param_unique_name`, `fit_min`, `fit_max`, `fit_bounds_uncertainty_multiplier`, `start_value`, `start_uncertainty`, `posterior_best_sample_value`, `posterior_median`, `posterior_uncertainty`, `posterior_interval_68_low`, `posterior_interval_68_high`, `posterior_interval_95_low`, `posterior_interval_95_high`, `posterior_gelman_rubin`, `posterior_effective_sample_size_bulk` | `_fit_parameter.*` with same item names | `_fit_parameter.{parameter_unique_name,fit_min,fit_max,bounds_uncertainty_multiplier,start_value,start_uncertainty,posterior_best_sample_value,posterior_median,posterior_uncertainty,posterior_interval_68_low,posterior_interval_68_high,posterior_interval_95_low,posterior_interval_95_high,posterior_gelman_rubin,posterior_effective_sample_size_bulk}` | | -| `analysis.fit_result` common | `result_kind`, `success`, `message`, `iterations`, `fitting_time`, `reduced_chi_square` | `_fit_result.{result_kind,success,message,iterations,fitting_time,reduced_chi_square}` | same | `reduced_chi_square` maps by report topology to `_refine_ls.*` or `_pd_proc_ls.*` | -| `analysis.fit_result` least-squares core | `objective_name`, `objective_value`, `n_data_points`, `n_parameters`, `n_free_parameters`, `degrees_of_freedom`, `covariance_available`, `correlation_available`, `exit_reason` | `_fit_result.*` with same item names | same | topology-specific `_refine_ls.*` / `_pd_proc_ls.*` for counts where reportable | -| `analysis.fit_result` least-squares R factors | `r_factor_all`, `wr_factor_all`, `r_factor_gt`, `wr_factor_gt` | `_fit_result.R_factor_all`, `_fit_result.wR_factor_all`, `_fit_result.R_factor_gt`, `_fit_result.wR_factor_gt` | `_fit_result.{r_factor_all,wr_factor_all,r_factor_gt,wr_factor_gt}` | `_refine_ls.{R_factor_all,wR_factor_all,R_factor_gt,wR_factor_gt}` | -| `analysis.fit_result` powder profile | `prof_r_factor`, `prof_wr_factor`, `prof_wr_expected`, `profile_function`, `background_function` | `_fit_result.prof_R_factor`, `_fit_result.prof_wR_factor`, `_fit_result.prof_wR_expected`, `_fit_result.profile_function`, `_fit_result.background_function` | `_fit_result.{prof_r_factor,prof_wr_factor,prof_wr_expected,profile_function,background_function}` | `_pd_proc_ls.{prof_R_factor,prof_wR_factor,prof_wR_expected,profile_function,background_function}` | -| `analysis.fit_result` fit counts | `number_restraints`, `number_constraints`, `shift_over_su_max`, `shift_over_su_mean` | `_fit_result.*` with same item names | same | `_refine_ls.{number_restraints,number_constraints}` for counts | -| `analysis.fit_result` reflection summaries | `threshold_expression`, `number_reflns_total`, `number_reflns_gt` | `_fit_result.*` with same item names | same | `_reflns.{threshold_expression,number_total,number_gt}` | -| `analysis.fit_result` Bayesian | `point_estimate_name`, `sampler_completed`, `credible_interval_inner`, `credible_interval_outer`, `acceptance_rate_mean`, `resolved_random_seed`, `gelman_rubin_max`, `effective_sample_size_min`, `best_log_posterior` | `_fit_result.*` with same item names | same | | -| `analysis.fitting_mode` | `type` | `_fitting_mode.type` | same | | -| `analysis.joint_fit` | `experiment_id`, `weight` | `_joint_fit.{experiment_id,weight}` | same | | -| `analysis.minimizer` common | `type`, `max_iterations` | `_minimizer.{type,max_iterations}` | same | | -| `analysis.minimizer` Bayesian | `sampling_steps`, `burn_in_steps`, `thinning_interval`, `population_size`, `parallel_workers`, `initialization_method`, `random_seed`, `proposal_moves` | `_minimizer.*` with same item names | same | | -| `analysis.sequential_fit` | `data_dir`, `file_pattern`, `max_workers`, `chunk_size`, `reverse` | `_sequential_fit.*` with same item names | same | | -| `analysis.sequential_fit_extract` | `id`, `target`, `pattern`, `required` | `_sequential_fit_extract.*` with same item names | same | | -| `analysis.software` (role loop) | `framework.{name,version,url}`, `calculator.{name,version,url}`, `minimizer.{name,version,url}`, `timestamp` | `_software.{framework,calculator,minimizer}_{name,version,url}`, `_software.timestamp` | `_software.{id,name,version,url}` loop (`id` ∈ framework/calculator/minimizer); `timestamp` → `_metadata.timestamp` | `_computing.structure_refinement` and `_easydiffraction_software.*` derived in report CIF | -| `experiment.background` selector | `type` | `_background.type` | same | | -| `experiment.background` line segment | `id`, `x`, `y` | `_pd_background.id`, `_pd_background.line_segment_X`, `_pd_background.line_segment_intensity` | `_background.{id,position,intensity}` | `_pd_background.*` where representable | -| `experiment.background` Chebyshev | `id`, `order`, `coef` | `_pd_background.id`, `_pd_background.Chebyshev_order`, `_pd_background.Chebyshev_coef` | `_background.{id,order,coef}` | `_pd_background.*` where representable | -| `experiment.calculator` | `type` | `_calculator.type` | same | | -| `experiment.data` Bragg powder | `point_id`, `d_spacing`, `intensity_meas`, `intensity_meas_su`, `intensity_calc`, `intensity_bkg`, `calc_status`, `two_theta`, `time_of_flight` | `_pd_data.point_id`, `_pd_proc.d_spacing`, `_pd_meas.intensity_total`, `_pd_meas.intensity_total_su`, `_pd_calc.intensity_total`, `_pd_calc.intensity_bkg`, `_pd_data.refinement_status`, `_pd_proc.2theta_scan`, `_pd_meas.time_of_flight` | `_data.{id,d_spacing,intensity_meas,intensity_meas_su,intensity_calc,intensity_bkg,calc_status,two_theta,time_of_flight}` | current `_pd_*` tags, with report profile loop using `_pd_meas.*`, `_pd_calc.*`, `_pd_proc.*`, and `_pd_proc_ls.weight` | -| `experiment.data` total powder | `point_id`, `r`, `g_r_meas`, `g_r_meas_su`, `g_r_calc`, `calc_status` | `_pd_data.point_id`, `_pd_proc.r`, `_pd_meas.intensity_total`, `_pd_meas.intensity_total_su`, `_pd_calc.intensity_total`, `_pd_data.refinement_status` | `_data.{id,r,g_r_meas,g_r_meas_su,g_r_calc,calc_status}` | PDF-specific report names are not finalized | -| `experiment.data_range` CWL powder | `two_theta_min`, `two_theta_max`, `two_theta_inc` | `_pd_meas.{2theta_range_min,2theta_range_max,2theta_range_inc}` | `_data_range.{two_theta_min,two_theta_max,two_theta_inc}` | current `_pd_meas.*` tags | -| `experiment.data_range` single crystal | `sin_theta_over_lambda_min`, `sin_theta_over_lambda_max` | `_refln.{sin_theta_over_lambda_range_min,sin_theta_over_lambda_range_max}` | `_data_range.{sin_theta_over_lambda_min,sin_theta_over_lambda_max}` | current `_refln.*` tags | -| `experiment.data_range` TOF | `time_of_flight_min`, `time_of_flight_max`, `time_of_flight_inc` | `_pd_meas.{time_of_flight_range_min,time_of_flight_range_max,time_of_flight_range_inc}` | `_data_range.{time_of_flight_min,time_of_flight_max,time_of_flight_inc}` | current `_pd_meas.*` tags | -| `experiment.diffrn` | `ambient_temperature`, `ambient_pressure`, `ambient_magnetic_field`, `ambient_electric_field` | `_diffrn.*` with same item names | same | `_diffrn.{ambient_temperature,ambient_pressure}`; fields and electric/magnetic fields are report extensions | -| `experiment.excluded_regions` | `id`, `start`, `end` | `_excluded_region.{id,start,end}` | same | report free text `_pd_proc.info_excluded_regions` plus extension rows | -| `experiment.type` → `experiment_type` | `sample_form`, `beam_mode`, `radiation_probe`, `scattering_type` | `_expt_type.{sample_form,beam_mode,radiation_probe,scattering_type}` | `_experiment_type.{sample_form,beam_mode,radiation_probe,scattering_type}` | | -| `experiment.extinction` | `type`, `model`, `mosaicity`, `radius` | `_extinction.{type,model,mosaicity,radius}` | same | `_refine_ls.extinction_method`, `_refine_ls.extinction_coef`, `_refine.special_details` by report transform | -| `experiment.instrument` CWL | `setup_wavelength`, `setup_wavelength_2`, `setup_wavelength_2_to_1_ratio`, `calib_twotheta_offset`, `calib_sample_displacement`, `calib_sample_transparency` | `_instr.wavelength`, `_instr.wavelength_2`, `_instr.wavelength_2_to_1_ratio`, `_instr.2theta_offset`, `_instr.sample_displacement`, `_instr.sample_transparency` | `_instrument.{setup_wavelength,setup_wavelength_2,setup_wavelength_2_to_1_ratio,calib_twotheta_offset,calib_sample_displacement,calib_sample_transparency}` | `_diffrn_radiation_wavelength.value` (or a `_diffrn_radiation_wavelength` id/value/wt loop for a Kα₁/Kα₂ doublet); `_pd_calib.2theta_offset` | -| `experiment.instrument` TOF | `setup_twotheta_bank`, `calib_d_to_tof_offset`, `calib_d_to_tof_linear`, `calib_d_to_tof_quad`, `calib_d_to_tof_recip` | `_instr.2theta_bank`, `_instr.{d_to_tof_offset,d_to_tof_linear,d_to_tof_quad,d_to_tof_recip}` | `_instrument.{setup_twotheta_bank,calib_d_to_tof_offset,calib_d_to_tof_linear,calib_d_to_tof_quadratic,calib_d_to_tof_reciprocal}` | `_pd_calib_d_to_tof.{id,power,coeff,coeff_su,diffractogram_id}` loop for nonzero coefficients | -| `experiment.linked_crystal` → `linked_structure` (single crystal) | `id`, `scale` | `_sc_crystal_block.{id,scale}` | `_linked_structure.{structure_id,scale}` | | -| `experiment.linked_phases` → `linked_structures` (powder) | `id`, `scale` | `_pd_phase_block.{id,scale}` | `_linked_structure.{structure_id,scale}` | `_pd_phase_block.{id,scale}` | -| `experiment.peak` CWL profile | `type`, `broad_gauss_u`, `broad_gauss_v`, `broad_gauss_w`, `broad_lorentz_x`, `broad_lorentz_y`, `asym_beba_a0`, `asym_beba_b0`, `asym_beba_a1`, `asym_beba_b1`, `asym_fcj_1`, `asym_fcj_2` | `_peak.*` with same item names | same | no pdCIF one-to-one parametric profile tags | -| `experiment.peak` TOF profile | `broad_gauss_sigma_{0,1,2}`, `broad_lorentz_gamma_{0,1,2}`, `rise_alpha_{0,1}`, `decay_beta_{0,1}`, `dexp_rise_alpha_{1,2}`, `dexp_decay_beta_{00,01,10}`, `dexp_switch_r_{01,02,03}` | `_peak.{gauss_sigma_0,gauss_sigma_1,gauss_sigma_2,lorentz_gamma_0,lorentz_gamma_1,lorentz_gamma_2,rise_alpha_0,rise_alpha_1,decay_beta_0,decay_beta_1,dexp_rise_alpha_1,dexp_rise_alpha_2,dexp_decay_beta_00,dexp_decay_beta_01,dexp_decay_beta_10,dexp_switch_r_01,dexp_switch_r_02,dexp_switch_r_03}` | `_peak.{broad_gauss_sigma_0,broad_gauss_sigma_1,broad_gauss_sigma_2,broad_lorentz_gamma_0,broad_lorentz_gamma_1,broad_lorentz_gamma_2,rise_alpha_0,rise_alpha_1,decay_beta_0,decay_beta_1,dexp_rise_alpha_1,dexp_rise_alpha_2,dexp_decay_beta_00,dexp_decay_beta_01,dexp_decay_beta_10,dexp_switch_r_01,dexp_switch_r_02,dexp_switch_r_03}` | no pdCIF one-to-one parametric profile tags | -| `experiment.peak` total scattering | `damp_q`, `broad_q`, `cutoff_q`, `sharp_delta_1`, `sharp_delta_2`, `damp_particle_diameter` | `_peak.*` with same item names | same | no finalized PDF-specific CIF tags | -| `experiment.preferred_orientation` | `phase_id`, `march_r`, `index_h`, `index_k`, `index_l`, `march_random_fract` | `_pref_orient.*` with same item names | `_preferred_orientation.{structure_id,march_r,index_h,index_k,index_l,march_random_fract}` | `_pd_pref_orient_March_Dollase.{phase_id,r,index_h,index_k,index_l}`; random fraction is an EasyDiffraction report extension | -| `experiment.refln` powder calculated | `id`, `d_spacing`, `sin_theta_over_lambda`, `index_h`, `index_k`, `index_l`, `phase_id`, `f_calc`, `f_squared_calc`, `two_theta`, `time_of_flight` | `_refln.*` with same item names | `_refln.{id,d_spacing,sin_theta_over_lambda,index_h,index_k,index_l,structure_id,f_calc,f_squared_calc,two_theta,time_of_flight}` | report powder reflection loop uses `_refln.index_*`, `_refln.F_squared_*`, `_pd_refln.phase_id`, `_refln.d_spacing` | -| `experiment.refln` single crystal | `id`, `d_spacing`, `sin_theta_over_lambda`, `index_h`, `index_k`, `index_l`, `intensity_meas`, `intensity_meas_su`, `intensity_calc`, `wavelength` | `_refln.*` with same item names | same | `_refln.*`; report maps intensities to `_refln.F_squared_*` where applicable | -| `structure.atom_sites` identity and coordinates | `label`, `type_symbol`, `fract_x`, `fract_y`, `fract_z`, `occupancy` | `_atom_site.*` with same item names | `_atom_site.{id,type_symbol,fract_x,fract_y,fract_z,occupancy}` | `_atom_site.{label,type_symbol,fract_x,fract_y,fract_z,occupancy}` | -| `structure.atom_sites` Wyckoff | `wyckoff_letter`, `multiplicity` | `_atom_site.Wyckoff_symbol`, `_atom_site.site_symmetry_multiplicity` | `_atom_site.{wyckoff_letter,multiplicity}` | `_atom_site.Wyckoff_symbol`, `_atom_site.site_symmetry_multiplicity` | -| `structure.atom_sites` ADP | `adp_iso`, `adp_type` | `_atom_site.B_iso_or_equiv`, `_atom_site.ADP_type` | `_atom_site.{adp_iso,adp_type}` | `_atom_site.{B_iso_or_equiv,U_iso_or_equiv}`, `_atom_site.ADP_type` | -| `structure.atom_site_aniso` | `label`, `adp_11`, `adp_22`, `adp_33`, `adp_12`, `adp_13`, `adp_23` | `_atom_site_aniso.label`, `_atom_site_aniso.B_11`, `_atom_site_aniso.B_22`, `_atom_site_aniso.B_33`, `_atom_site_aniso.B_12`, `_atom_site_aniso.B_13`, `_atom_site_aniso.B_23` | `_atom_site_aniso.{id,adp_11,adp_22,adp_33,adp_12,adp_13,adp_23}` | `_atom_site_aniso.{label,B_*,U_*,beta_*}` by `adp_type` | -| `structure.cell` | `length_a`, `length_b`, `length_c`, `angle_alpha`, `angle_beta`, `angle_gamma` | `_cell.*` with same item names | same | same | -| `structure.geom` | `min_bond_distance_cutoff`, `bond_distance_incr` | `_geom.*` with same item names | `_geom.{min_bond_distance_cutoff,bond_distance_inc}` | same | -| `structure.space_group` | `name_h_m`, `it_coordinate_system_code` | `_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code` | `_space_group.{name_h_m,coord_system_code}` | `_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code` | -| `structure.space_group_wyckoff` (derived; **not persisted**) | `id`, `letter`, `multiplicity`, `site_symmetry`, `coords_xyz` | `_space_group_Wyckoff.{id,letter,multiplicity,site_symmetry,coords_xyz}` | — (regenerated from `space_group` on load; never written) | `_space_group_Wyckoff.*` | -| `project.info` → `metadata` | `name`, `title`, `description`, `created`, `last_modified`; relocated `analysis.software.timestamp` as `timestamp` | `_project.id`, `_project.title`, `_project.description`, `_project.created`, `_project.last_modified`; `_software.timestamp` | `_metadata.{name,title,description,created,last_modified,timestamp}` | | -| `project.rendering_plot` | `type` | `_rendering_plot.type` | same | | -| `project.rendering_structure` | `type` | `_rendering_structure.type` | same | | -| `project.rendering_table` | `type` | `_rendering_table.type` | same | | -| `project.report` | `cif`, `html`, `tex`, `pdf`, `html_offline` | `_report.*` with same item names | same | report-output configuration only | -| `project.structure_style` | `atom_view`, `color_scheme`, `adp_probability`, `atom_scale` | `_structure_style.*` with same item names | same | | -| `project.structure_view` | `show_labels`, `show_moments`, `range_a_min`, `range_a_max`, `range_b_min`, `range_b_max`, `range_c_min`, `range_c_max` | `_structure_view.*` with same item names | same | | -| `project.verbosity` | `fit` | `_verbosity.fit` | same | | +| Area | Current EasyDiffraction names | Current project tags | Suggested Edi tags | Official/report CIF names | +| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `analysis.aliases` | `label`, `param_unique_name` | `_alias.{label,param_unique_name}` | `_alias.{id,parameter_unique_name}` | | +| `analysis.constraints` | `id`, `expression` | `_constraint.{id,expression}` | `_constraint.{id,expression}` | | +| `analysis.fit_parameter_correlations` | `id`, `source_kind`, `param_unique_name_i`, `param_unique_name_j`, `correlation` | `_fit_parameter_correlation.{id,source_kind,param_unique_name_i,param_unique_name_j,correlation}` | `_fit_parameter_correlation.{id,source_kind,parameter_unique_name_i,parameter_unique_name_j,correlation}` | | +| `analysis.fit_parameters` | `param_unique_name`, `fit_min`, `fit_max`, `fit_bounds_uncertainty_multiplier`, `start_value`, `start_uncertainty`, `posterior_best_sample_value`, `posterior_median`, `posterior_uncertainty`, `posterior_interval_68_low`, `posterior_interval_68_high`, `posterior_interval_95_low`, `posterior_interval_95_high`, `posterior_gelman_rubin`, `posterior_effective_sample_size_bulk` | `_fit_parameter.*` with same item names | `_fit_parameter.{parameter_unique_name,fit_min,fit_max,bounds_uncertainty_multiplier,start_value,start_uncertainty,posterior_best_sample_value,posterior_median,posterior_uncertainty,posterior_interval_68_low,posterior_interval_68_high,posterior_interval_95_low,posterior_interval_95_high,posterior_gelman_rubin,posterior_effective_sample_size_bulk}` | | +| `analysis.fit_result` common | `result_kind`, `success`, `message`, `iterations`, `fitting_time`, `reduced_chi_square` | `_fit_result.{result_kind,success,message,iterations,fitting_time,reduced_chi_square}` | same | `reduced_chi_square` maps by report topology to `_refine_ls.*` or `_pd_proc_ls.*` | +| `analysis.fit_result` least-squares core | `objective_name`, `objective_value`, `n_data_points`, `n_parameters`, `n_free_parameters`, `degrees_of_freedom`, `covariance_available`, `correlation_available`, `exit_reason` | `_fit_result.*` with same item names | same | topology-specific `_refine_ls.*` / `_pd_proc_ls.*` for counts where reportable | +| `analysis.fit_result` least-squares R factors | `r_factor_all`, `wr_factor_all`, `r_factor_gt`, `wr_factor_gt` | `_fit_result.R_factor_all`, `_fit_result.wR_factor_all`, `_fit_result.R_factor_gt`, `_fit_result.wR_factor_gt` | `_fit_result.{r_factor_all,wr_factor_all,r_factor_gt,wr_factor_gt}` | `_refine_ls.{R_factor_all,wR_factor_all,R_factor_gt,wR_factor_gt}` | +| `analysis.fit_result` powder profile | `prof_r_factor`, `prof_wr_factor`, `prof_wr_expected`, `profile_function`, `background_function` | `_fit_result.prof_R_factor`, `_fit_result.prof_wR_factor`, `_fit_result.prof_wR_expected`, `_fit_result.profile_function`, `_fit_result.background_function` | `_fit_result.{prof_r_factor,prof_wr_factor,prof_wr_expected,profile_function,background_function}` | `_pd_proc_ls.{prof_R_factor,prof_wR_factor,prof_wR_expected,profile_function,background_function}` | +| `analysis.fit_result` fit counts | `number_restraints`, `number_constraints`, `shift_over_su_max`, `shift_over_su_mean` | `_fit_result.*` with same item names | same | `_refine_ls.{number_restraints,number_constraints}` for counts | +| `analysis.fit_result` reflection summaries | `threshold_expression`, `number_reflns_total`, `number_reflns_gt` | `_fit_result.*` with same item names | same | `_reflns.{threshold_expression,number_total,number_gt}` | +| `analysis.fit_result` Bayesian | `point_estimate_name`, `sampler_completed`, `credible_interval_inner`, `credible_interval_outer`, `acceptance_rate_mean`, `resolved_random_seed`, `gelman_rubin_max`, `effective_sample_size_min`, `best_log_posterior` | `_fit_result.*` with same item names | same | | +| `analysis.fitting_mode` | `type` | `_fitting_mode.type` | same | | +| `analysis.joint_fit` | `experiment_id`, `weight` | `_joint_fit.{experiment_id,weight}` | same | | +| `analysis.minimizer` common | `type`, `max_iterations` | `_minimizer.{type,max_iterations}` | same | | +| `analysis.minimizer` Bayesian | `sampling_steps`, `burn_in_steps`, `thinning_interval`, `population_size`, `parallel_workers`, `initialization_method`, `random_seed`, `proposal_moves` | `_minimizer.*` with same item names | same | | +| `analysis.sequential_fit` | `data_dir`, `file_pattern`, `max_workers`, `chunk_size`, `reverse` | `_sequential_fit.*` with same item names | same | | +| `analysis.sequential_fit_extract` | `id`, `target`, `pattern`, `required` | `_sequential_fit_extract.*` with same item names | same | | +| `analysis.software` (role loop) | `framework.{name,version,url}`, `calculator.{name,version,url}`, `minimizer.{name,version,url}`, `timestamp` | `_software.{framework,calculator,minimizer}_{name,version,url}`, `_software.timestamp` | `_software.{id,name,version,url}` loop (`id` ∈ framework/calculator/minimizer); `timestamp` → `_metadata.timestamp` | `_computing.structure_refinement` and `_easydiffraction_software.*` derived in report CIF | +| `experiment.background` selector | `type` | `_background.type` | same | | +| `experiment.background` line segment | `id`, `x`, `y` | `_pd_background.id`, `_pd_background.line_segment_X`, `_pd_background.line_segment_intensity` | `_background.{id,position,intensity}` | `_pd_background.*` where representable | +| `experiment.background` Chebyshev | `id`, `order`, `coef` | `_pd_background.id`, `_pd_background.Chebyshev_order`, `_pd_background.Chebyshev_coef` | `_background.{id,order,coef}` | `_pd_background.*` where representable | +| `experiment.calculator` | `type` | `_calculator.type` | same | | +| `experiment.data` Bragg powder | `point_id`, `d_spacing`, `intensity_meas`, `intensity_meas_su`, `intensity_calc`, `intensity_bkg`, `calc_status`, `two_theta`, `time_of_flight` | `_pd_data.point_id`, `_pd_proc.d_spacing`, `_pd_meas.intensity_total`, `_pd_meas.intensity_total_su`, `_pd_calc.intensity_total`, `_pd_calc.intensity_bkg`, `_pd_data.refinement_status`, `_pd_proc.2theta_scan`, `_pd_meas.time_of_flight` | `_data.{id,d_spacing,intensity_meas,intensity_meas_su,intensity_calc,intensity_bkg,calc_status,two_theta,time_of_flight}` | current `_pd_*` tags, with report profile loop using `_pd_meas.*`, `_pd_calc.*`, `_pd_proc.*`, and `_pd_proc_ls.weight` | +| `experiment.data` total powder | `point_id`, `r`, `g_r_meas`, `g_r_meas_su`, `g_r_calc`, `calc_status` | `_pd_data.point_id`, `_pd_proc.r`, `_pd_meas.intensity_total`, `_pd_meas.intensity_total_su`, `_pd_calc.intensity_total`, `_pd_data.refinement_status` | `_data.{id,r,g_r_meas,g_r_meas_su,g_r_calc,calc_status}` | PDF-specific report names are not finalized | +| `experiment.data_range` CWL powder | `two_theta_min`, `two_theta_max`, `two_theta_inc` | `_pd_meas.{2theta_range_min,2theta_range_max,2theta_range_inc}` | `_data_range.{two_theta_min,two_theta_max,two_theta_inc}` | current `_pd_meas.*` tags | +| `experiment.data_range` single crystal | `sin_theta_over_lambda_min`, `sin_theta_over_lambda_max` | `_refln.{sin_theta_over_lambda_range_min,sin_theta_over_lambda_range_max}` | `_data_range.{sin_theta_over_lambda_min,sin_theta_over_lambda_max}` | current `_refln.*` tags | +| `experiment.data_range` TOF | `time_of_flight_min`, `time_of_flight_max`, `time_of_flight_inc` | `_pd_meas.{time_of_flight_range_min,time_of_flight_range_max,time_of_flight_range_inc}` | `_data_range.{time_of_flight_min,time_of_flight_max,time_of_flight_inc}` | current `_pd_meas.*` tags | +| `experiment.diffrn` | `ambient_temperature`, `ambient_pressure`, `ambient_magnetic_field`, `ambient_electric_field` | `_diffrn.*` with same item names | same | `_diffrn.{ambient_temperature,ambient_pressure}`; fields and electric/magnetic fields are report extensions | +| `experiment.excluded_regions` | `id`, `start`, `end` | `_excluded_region.{id,start,end}` | same | report free text `_pd_proc.info_excluded_regions` plus extension rows | +| `experiment.type` → `experiment_type` | `sample_form`, `beam_mode`, `radiation_probe`, `scattering_type` | `_expt_type.{sample_form,beam_mode,radiation_probe,scattering_type}` | `_experiment_type.{sample_form,beam_mode,radiation_probe,scattering_type}` | | +| `experiment.extinction` | `type`, `model`, `mosaicity`, `radius` | `_extinction.{type,model,mosaicity,radius}` | same | `_refine_ls.extinction_method`, `_refine_ls.extinction_coef`, `_refine.special_details` by report transform | +| `experiment.instrument` CWL | `setup_wavelength`, `setup_wavelength_2`, `setup_wavelength_2_to_1_ratio`, `calib_twotheta_offset`, `calib_sample_displacement`, `calib_sample_transparency` | `_instr.wavelength`, `_instr.wavelength_2`, `_instr.wavelength_2_to_1_ratio`, `_instr.2theta_offset`, `_instr.sample_displacement`, `_instr.sample_transparency` | `_instrument.{setup_wavelength,setup_wavelength_2,setup_wavelength_2_to_1_ratio,calib_twotheta_offset,calib_sample_displacement,calib_sample_transparency}` | `_diffrn_radiation_wavelength.value` (or a `_diffrn_radiation_wavelength` id/value/wt loop for a Kα₁/Kα₂ doublet); `_pd_calib.2theta_offset` | +| `experiment.instrument` TOF | `setup_twotheta_bank`, `calib_d_to_tof_offset`, `calib_d_to_tof_linear`, `calib_d_to_tof_quad`, `calib_d_to_tof_recip` | `_instr.2theta_bank`, `_instr.{d_to_tof_offset,d_to_tof_linear,d_to_tof_quad,d_to_tof_recip}` | `_instrument.{setup_twotheta_bank,calib_d_to_tof_offset,calib_d_to_tof_linear,calib_d_to_tof_quadratic,calib_d_to_tof_reciprocal}` | `_pd_calib_d_to_tof.{id,power,coeff,coeff_su,diffractogram_id}` loop for nonzero coefficients | +| `experiment.linked_crystal` → `linked_structure` (single crystal) | `id`, `scale` | `_sc_crystal_block.{id,scale}` | `_linked_structure.{structure_id,scale}` | | +| `experiment.linked_phases` → `linked_structures` (powder) | `id`, `scale` | `_pd_phase_block.{id,scale}` | `_linked_structure.{structure_id,scale}` | `_pd_phase_block.{id,scale}` | +| `experiment.peak` CWL profile | `type`, `broad_gauss_u`, `broad_gauss_v`, `broad_gauss_w`, `broad_lorentz_x`, `broad_lorentz_y`, `asym_beba_a0`, `asym_beba_b0`, `asym_beba_a1`, `asym_beba_b1`, `asym_fcj_1`, `asym_fcj_2` | `_peak.*` with same item names | same | no pdCIF one-to-one parametric profile tags | +| `experiment.peak` TOF profile | `broad_gauss_sigma_{0,1,2}`, `broad_lorentz_gamma_{0,1,2}`, `rise_alpha_{0,1}`, `decay_beta_{0,1}`, `dexp_rise_alpha_{1,2}`, `dexp_decay_beta_{00,01,10}`, `dexp_switch_r_{01,02,03}` | `_peak.{gauss_sigma_0,gauss_sigma_1,gauss_sigma_2,lorentz_gamma_0,lorentz_gamma_1,lorentz_gamma_2,rise_alpha_0,rise_alpha_1,decay_beta_0,decay_beta_1,dexp_rise_alpha_1,dexp_rise_alpha_2,dexp_decay_beta_00,dexp_decay_beta_01,dexp_decay_beta_10,dexp_switch_r_01,dexp_switch_r_02,dexp_switch_r_03}` | `_peak.{broad_gauss_sigma_0,broad_gauss_sigma_1,broad_gauss_sigma_2,broad_lorentz_gamma_0,broad_lorentz_gamma_1,broad_lorentz_gamma_2,rise_alpha_0,rise_alpha_1,decay_beta_0,decay_beta_1,dexp_rise_alpha_1,dexp_rise_alpha_2,dexp_decay_beta_00,dexp_decay_beta_01,dexp_decay_beta_10,dexp_switch_r_01,dexp_switch_r_02,dexp_switch_r_03}` | no pdCIF one-to-one parametric profile tags | +| `experiment.peak` total scattering | `damp_q`, `broad_q`, `cutoff_q`, `sharp_delta_1`, `sharp_delta_2`, `damp_particle_diameter` | `_peak.*` with same item names | same | no finalized PDF-specific CIF tags | +| `experiment.preferred_orientation` | `phase_id`, `march_r`, `index_h`, `index_k`, `index_l`, `march_random_fract` | `_pref_orient.*` with same item names | `_preferred_orientation.{structure_id,march_r,index_h,index_k,index_l,march_random_fract}` | `_pd_pref_orient_March_Dollase.{phase_id,r,index_h,index_k,index_l}`; random fraction is an EasyDiffraction report extension | +| `experiment.refln` powder calculated | `id`, `d_spacing`, `sin_theta_over_lambda`, `index_h`, `index_k`, `index_l`, `phase_id`, `f_calc`, `f_squared_calc`, `two_theta`, `time_of_flight` | `_refln.*` with same item names | `_refln.{id,d_spacing,sin_theta_over_lambda,index_h,index_k,index_l,structure_id,f_calc,f_squared_calc,two_theta,time_of_flight}` | report powder reflection loop uses `_refln.index_*`, `_refln.F_squared_*`, `_pd_refln.phase_id`, `_refln.d_spacing` | +| `experiment.refln` single crystal | `id`, `d_spacing`, `sin_theta_over_lambda`, `index_h`, `index_k`, `index_l`, `intensity_meas`, `intensity_meas_su`, `intensity_calc`, `wavelength` | `_refln.*` with same item names | same | `_refln.*`; report maps intensities to `_refln.F_squared_*` where applicable | +| `structure.atom_sites` identity and coordinates | `label`, `type_symbol`, `fract_x`, `fract_y`, `fract_z`, `occupancy` | `_atom_site.*` with same item names | `_atom_site.{id,type_symbol,fract_x,fract_y,fract_z,occupancy}` | `_atom_site.{label,type_symbol,fract_x,fract_y,fract_z,occupancy}` | +| `structure.atom_sites` Wyckoff | `wyckoff_letter`, `multiplicity` | `_atom_site.Wyckoff_symbol`, `_atom_site.site_symmetry_multiplicity` | `_atom_site.{wyckoff_letter,multiplicity}` | `_atom_site.Wyckoff_symbol`, `_atom_site.site_symmetry_multiplicity` | +| `structure.atom_sites` ADP | `adp_iso`, `adp_type` | `_atom_site.B_iso_or_equiv`, `_atom_site.ADP_type` | `_atom_site.{adp_iso,adp_type}` | `_atom_site.{B_iso_or_equiv,U_iso_or_equiv}`, `_atom_site.ADP_type` | +| `structure.atom_site_aniso` | `label`, `adp_11`, `adp_22`, `adp_33`, `adp_12`, `adp_13`, `adp_23` | `_atom_site_aniso.label`, `_atom_site_aniso.B_11`, `_atom_site_aniso.B_22`, `_atom_site_aniso.B_33`, `_atom_site_aniso.B_12`, `_atom_site_aniso.B_13`, `_atom_site_aniso.B_23` | `_atom_site_aniso.{id,adp_11,adp_22,adp_33,adp_12,adp_13,adp_23}` | `_atom_site_aniso.{label,B_*,U_*,beta_*}` by `adp_type` | +| `structure.cell` | `length_a`, `length_b`, `length_c`, `angle_alpha`, `angle_beta`, `angle_gamma` | `_cell.*` with same item names | same | same | +| `structure.geom` | `min_bond_distance_cutoff`, `bond_distance_incr` | `_geom.*` with same item names | `_geom.{min_bond_distance_cutoff,bond_distance_inc}` | same | +| `structure.space_group` | `name_h_m`, `it_coordinate_system_code` | `_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code` | `_space_group.{name_h_m,coord_system_code}` | `_space_group.name_H-M_alt`, `_space_group.IT_coordinate_system_code` | +| `structure.space_group_wyckoff` (derived; **not persisted**) | `id`, `letter`, `multiplicity`, `site_symmetry`, `coords_xyz` | `_space_group_Wyckoff.{id,letter,multiplicity,site_symmetry,coords_xyz}` | — (regenerated from `space_group` on load; never written) | `_space_group_Wyckoff.*` | +| `project.info` → `metadata` | `name`, `title`, `description`, `created`, `last_modified`; relocated `analysis.software.timestamp` as `timestamp` | `_project.id`, `_project.title`, `_project.description`, `_project.created`, `_project.last_modified`; `_software.timestamp` | `_metadata.{name,title,description,created,last_modified,timestamp}` | | +| `project.rendering_plot` | `type` | `_rendering_plot.type` | same | | +| `project.rendering_structure` | `type` | `_rendering_structure.type` | same | | +| `project.rendering_table` | `type` | `_rendering_table.type` | same | | +| `project.report` | `cif`, `html`, `tex`, `pdf`, `html_offline` | `_report.*` with same item names | same | report-output configuration only | +| `project.structure_style` | `atom_view`, `color_scheme`, `adp_probability`, `atom_scale` | `_structure_style.*` with same item names | same | | +| `project.structure_view` | `show_labels`, `show_moments`, `range_a_min`, `range_a_max`, `range_b_min`, `range_b_max`, `range_c_min`, `range_c_max` | `_structure_view.*` with same item names | same | | +| `project.verbosity` | `fit` | `_verbosity.fit` | same | | ## Naming Precedent and Guard Notes diff --git a/docs/dev/plans/cwl-second-wavelength-placeholder.md b/docs/dev/plans/cwl-second-wavelength-placeholder.md index c11ca97e4..58808beb5 100644 --- a/docs/dev/plans/cwl-second-wavelength-placeholder.md +++ b/docs/dev/plans/cwl-second-wavelength-placeholder.md @@ -152,10 +152,10 @@ not silently dropped. Engine binding is out of scope here, but recording the trade-off so the follow-up starts informed: -- **Two ways to compute the doublet.** (a) *Calculator-independent* — +- **Two ways to compute the doublet.** (a) _Calculator-independent_ — edi calls the engine once at λ₁ and once at λ₂ and weight-sums the two patterns; works for any engine (and is the only option for cryspy, - which has no native doublet). (b) *Native crysfml* — a future build + which has no native doublet). (b) _Native crysfml_ — a future build computes both lines in one pass via the `LAMBDA λ₁ λ₂ ratio` CFL directive. - **(a) is expected to be slower, bounded by ~2× the single-wavelength diff --git a/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py b/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py index 39f205141..bafb8d96f 100644 --- a/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py +++ b/src/easydiffraction/datablocks/experiment/categories/instrument/cwl.py @@ -46,14 +46,14 @@ def __init__(self) -> None: ), ) - # Placeholder for a second incident wavelength (e.g. the X-ray - # Cu Kα₁/Kα₂ doublet). Non-refinable NumericDescriptors: no - # calculation engine consumes them yet, so a refinable Parameter - # would let a fit silently move a value with no effect. Defaults - # of 0.0 mean "no second component" (monochromatic, as today). + # 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α₂)', + description='Second incident wavelength (e.g. X-ray K-alpha2)', units='angstroms', display_handler=DisplayHandler( display_name='Wavelength 2', @@ -108,7 +108,7 @@ def setup_wavelength(self, value: float) -> None: @property def setup_wavelength_2(self) -> NumericDescriptor: """ - Second incident wavelength λ₂ (Å), e.g. the X-ray Kα₂ line. + 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 @@ -136,7 +136,9 @@ def setup_wavelength_2_to_1_ratio(self) -> NumericDescriptor: @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₁).""" + """ + Set the wavelength_2-to-wavelength intensity ratio (I₂/I₁). + """ self._setup_wavelength_2_to_1_ratio.value = value diff --git a/src/easydiffraction/io/cif/iucr_transformers.py b/src/easydiffraction/io/cif/iucr_transformers.py index 1bd7f7857..01eb33c95 100644 --- a/src/easydiffraction/io/cif/iucr_transformers.py +++ b/src/easydiffraction/io/cif/iucr_transformers.py @@ -118,10 +118,10 @@ def loop(experiment: object) -> IucrLoop | None: 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 + 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 @@ -497,11 +497,6 @@ def _wavelength_doublet_active(instrument: object) -> bool: ------- bool ``True`` when an active doublet should be emitted as a loop. - - Raises - ------ - ValueError - If the ratio is positive but no second wavelength is set. """ 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 diff --git a/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py b/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py index cb50d96cb..c2c9699cc 100644 --- a/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py +++ b/tests/unit/easydiffraction/io/cif/test_iucr_transformers.py @@ -96,7 +96,7 @@ def test_wavelength_transformer_rejects_incomplete_pair(monkeypatch): ) experiment = SimpleNamespace(instrument=instrument) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match='second wavelength'): WavelengthTransformer().items(experiment) From df9da44ed90a8aeaf0d5fbfe46d2766b4dde6146 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:48:52 +0200 Subject: [PATCH 8/9] Add writer-level tests for second-wavelength export --- .../io/cif/test_iucr_writer.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py index f39b1042e..f55aaecc0 100644 --- a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py +++ b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py @@ -352,6 +352,58 @@ def test_write_iucr_cif_emits_powder_cwl_blocks(tmp_path): assert '_pd_meas.info_author_' not in text +def test_write_iucr_cif_disabled_second_wavelength_stays_scalar(tmp_path): + from easydiffraction.io.cif.iucr_writer import write_iucr_cif + + # Second wavelength recorded but disabled (ratio == 0): the report + # keeps the single-row scalar wavelength and omits the disabled λ₂. + experiment = _powder_experiment('disabled') + experiment.instrument.setup_wavelength_2 = _descriptor(1.5444) + experiment.instrument.setup_wavelength_2_to_1_ratio = _descriptor(0.0) + + project = _project( + 'disabled', + tmp_path, + _collection(_structure()), + _collection(experiment), + ) + + text = write_iucr_cif(project).read_text(encoding='utf-8') + + assert '_diffrn_radiation_wavelength.value' in text + assert '_diffrn_radiation_wavelength.wt' in text + assert '1.5444' not in text + + +def test_write_iucr_cif_active_doublet_emits_wavelength_loop(tmp_path): + from easydiffraction.io.cif.iucr_writer import write_iucr_cif + + # Active doublet: the report emits the two-row + # _diffrn_radiation_wavelength loop with both wavelengths. + experiment = _powder_experiment('doublet') + experiment.instrument.setup_wavelength_2 = _descriptor(1.5444) + experiment.instrument.setup_wavelength_2_to_1_ratio = _descriptor(0.5) + + project = _project( + 'doublet', + tmp_path, + _collection(_structure()), + _collection(experiment), + ) + + text = write_iucr_cif(project).read_text(encoding='utf-8') + + assert ( + 'loop_\n' + '_diffrn_radiation_wavelength.id\n' + '_diffrn_radiation_wavelength.value\n' + '_diffrn_radiation_wavelength.wt\n' + ) in text + # Both components present: id 1 at λ1 and id 2 at λ2. + assert '1.5406' in text + assert '1.5444' in text + + def test_write_iucr_cif_emits_joint_tof_pattern_blocks(tmp_path): from easydiffraction.io.cif.iucr_writer import write_iucr_cif From df804a8cfe1f7d1ee759fb87dacb6bb4bdfeb46c Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Wed, 17 Jun 2026 18:53:04 +0200 Subject: [PATCH 9/9] Assert doublet loop row payload in writer test --- tests/unit/easydiffraction/io/cif/test_iucr_writer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py index f55aaecc0..ab89afb2f 100644 --- a/tests/unit/easydiffraction/io/cif/test_iucr_writer.py +++ b/tests/unit/easydiffraction/io/cif/test_iucr_writer.py @@ -398,10 +398,9 @@ def test_write_iucr_cif_active_doublet_emits_wavelength_loop(tmp_path): '_diffrn_radiation_wavelength.id\n' '_diffrn_radiation_wavelength.value\n' '_diffrn_radiation_wavelength.wt\n' + ' 1 1.5406 1.\n' + ' 2 1.5444 0.5\n' ) in text - # Both components present: id 1 at λ1 and id 2 at λ2. - assert '1.5406' in text - assert '1.5444' in text def test_write_iucr_cif_emits_joint_tof_pattern_blocks(tmp_path):