From 0b0c02c2718a365671c21b57038e2ad6b654c911 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 09:17:56 +0200 Subject: [PATCH 01/26] Add powder Refln category plan --- docs/{architecture => dev}/ROADMAP.md | 0 .../adp_implementation.md | 0 docs/{architecture => dev}/architecture.md | 0 docs/{architecture => dev}/cryspy-dwf-bug.md | 0 docs/{architecture => dev}/issues_closed.md | 0 docs/{architecture => dev}/issues_open.md | 0 .../package-structure-full.md | 0 .../package-structure-short.md | 0 docs/dev/plan-powder-refln-category.md | 274 ++++++++++++++++++ .../plan-threePanelPowderPlot.prompt.md | 0 10 files changed, 274 insertions(+) rename docs/{architecture => dev}/ROADMAP.md (100%) rename docs/{architecture => dev}/adp_implementation.md (100%) rename docs/{architecture => dev}/architecture.md (100%) rename docs/{architecture => dev}/cryspy-dwf-bug.md (100%) rename docs/{architecture => dev}/issues_closed.md (100%) rename docs/{architecture => dev}/issues_open.md (100%) rename docs/{architecture => dev}/package-structure-full.md (100%) rename docs/{architecture => dev}/package-structure-short.md (100%) create mode 100644 docs/dev/plan-powder-refln-category.md rename docs/{architecture => dev}/plan-threePanelPowderPlot.prompt.md (100%) diff --git a/docs/architecture/ROADMAP.md b/docs/dev/ROADMAP.md similarity index 100% rename from docs/architecture/ROADMAP.md rename to docs/dev/ROADMAP.md diff --git a/docs/architecture/adp_implementation.md b/docs/dev/adp_implementation.md similarity index 100% rename from docs/architecture/adp_implementation.md rename to docs/dev/adp_implementation.md diff --git a/docs/architecture/architecture.md b/docs/dev/architecture.md similarity index 100% rename from docs/architecture/architecture.md rename to docs/dev/architecture.md diff --git a/docs/architecture/cryspy-dwf-bug.md b/docs/dev/cryspy-dwf-bug.md similarity index 100% rename from docs/architecture/cryspy-dwf-bug.md rename to docs/dev/cryspy-dwf-bug.md diff --git a/docs/architecture/issues_closed.md b/docs/dev/issues_closed.md similarity index 100% rename from docs/architecture/issues_closed.md rename to docs/dev/issues_closed.md diff --git a/docs/architecture/issues_open.md b/docs/dev/issues_open.md similarity index 100% rename from docs/architecture/issues_open.md rename to docs/dev/issues_open.md diff --git a/docs/architecture/package-structure-full.md b/docs/dev/package-structure-full.md similarity index 100% rename from docs/architecture/package-structure-full.md rename to docs/dev/package-structure-full.md diff --git a/docs/architecture/package-structure-short.md b/docs/dev/package-structure-short.md similarity index 100% rename from docs/architecture/package-structure-short.md rename to docs/dev/package-structure-short.md diff --git a/docs/dev/plan-powder-refln-category.md b/docs/dev/plan-powder-refln-category.md new file mode 100644 index 000000000..b43dd2394 --- /dev/null +++ b/docs/dev/plan-powder-refln-category.md @@ -0,0 +1,274 @@ +# Plan: Powder Refln Category + +Add a powder Bragg `refln` category that records calculated reflection +metadata per linked phase. The category will be populated when powder +patterns are calculated and will provide the data source for hoverable +Bragg ticks in the existing three-panel powder plot. + +## Status + +- [ ] Confirm open questions below. +- [ ] Phase 1: implement code and documentation changes only. +- [ ] Phase 2: add/update tests and run verification commands. + +## Current Context + +- Single-crystal reflection data lives in + `src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py`. + Its `Refln` item owns `_refln.id`, `_refln.d_spacing`, + `_refln.sin_theta_over_lambda`, Miller indices, measured/calculated + intensity fields, and wavelength. +- Powder Bragg measured/calculated pattern points live in + `src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py`. + `PdDataBase._update()` loops over `experiment.linked_phases`, calls the + active calculator once per linked phase, scales each phase pattern, and + stores the summed pattern in `data.intensity_calc`. +- Powder experiments expose linked phases through `_pd_phase_block.id` in + `src/easydiffraction/datablocks/experiment/categories/linked_phases/default.py`. + The runtime identity used to find structures is + `linked_phase._identity.category_entry_name`, which currently resolves + to `linked_phase.id.value`. +- The three-panel plot plan in + `docs/dev/plan-threePanelPowderPlot.prompt.md` already added a + display-side Bragg tick DTO and a temporary extractor that looks for a + future `experiment.bragg_peaks` category. This implementation should + replace that future placeholder with the real `experiment.refln` + category. + +## Proposed Category Shape + +Implement a powder-specific reflection item that inherits from the +single-crystal `Refln` item and adds the requested powder fields. + +Recommended item fields: + +| Public property | CIF name | Type | Meaning | +| --- | --- | --- | --- | +| `id` | `_refln.id` | string | Stable row identifier, unique within the experiment. | +| `linked_phase_id` | `_refln.linked_phase_id` | string | Identifier of the linked phase that produced this reflection. | +| `d_spacing` | `_refln.d_spacing` | numeric | Reflection d-spacing, used to derive plot x positions when needed. | +| `sin_theta_over_lambda` | `_refln.sin_theta_over_lambda` | numeric | Reflection sin(theta)/lambda value. | +| `index_h` | `_refln.index_h` | numeric | Miller h index. | +| `index_k` | `_refln.index_k` | numeric | Miller k index. | +| `index_l` | `_refln.index_l` | numeric | Miller l index. | +| `f_calc` | `_refln.f_calc` | numeric | Calculated structure-factor amplitude. | +| `f_squared_calc` | `_refln.f_squared_calc` | numeric | Calculated structure-factor amplitude squared. | + +Implementation notes: + +- Add the item as `Refln` in a powder Bragg module and inherit from + `easydiffraction.datablocks.experiment.categories.data.bragg_sc.Refln`. + Alias the imported single-crystal class locally to avoid a name clash. +- Add `linked_phase_id`, `f_calc`, and `f_squared_calc` descriptors in + the subclass `__init__()` after `super().__init__()`. +- Keep `self._identity.category_code = 'refln'` from the base class. +- Keep `f_calc` and `f_squared_calc` non-negative unless the answer to + the open questions requires complex structure factors. +- Add a collection class, for example `PowderReflnData`, with typed array + properties for `linked_phase_id`, `d_spacing`, `sin_theta_over_lambda`, + `index_h`, `index_k`, `index_l`, `f_calc`, and `f_squared_calc`. +- Add a private replacement method such as `_replace_from_records(...)` + so calculators can atomically clear and repopulate all reflection rows + after a successful pattern calculation. +- Use globally unique row ids, likely `1`, `2`, ... in calculation order, + while preserving phase grouping with `linked_phase_id`. + +## Experiment Wiring + +Add the category as an additional category on `BraggPdExperiment`, not as +a replacement for `experiment.data`. + +Steps: + +1. Instantiate the powder `refln` collection in + `src/easydiffraction/datablocks/experiment/item/bragg_pd.py` during + `BraggPdExperiment.__init__()`. +2. Add a read-only `refln` property on `BraggPdExperiment`. +3. Set the collection update priority after powder `data` updates, for + example `_update_priority = 110`, and keep its own `_update()` a no-op + unless a later design moves calculation ownership into the category. +4. Let normal datablock category traversal serialize the new collection + to CIF, since `experiment_to_cif()` already emits all category + collections stored on the experiment. +5. Avoid adding `refln` to `PdExperimentBase` unless total-scattering + powder experiments also need it later. + +If strict factory-backed category construction is desired, add a small +`refln` category factory package and create the powder Bragg collection +through that factory. If the project prefers the smallest local change, +instantiate the collection directly from the powder Bragg experiment. + +## Calculation Population + +The existing powder calculation flow should remain the single source of +truth for both the calculated pattern and the reflection table. + +Recommended implementation path: + +1. Define a small internal reflection-record container for calculator + output, containing `linked_phase_id`, `d_spacing`, + `sin_theta_over_lambda`, `index_h`, `index_k`, `index_l`, `f_calc`, + and `f_squared_calc`. +2. Extend the calculator abstraction with an explicit powder-reflection + extraction path. Two possible designs are viable: + - Return a typed result object from powder pattern calculation, such + as `PowderPatternResult(intensity, reflections)`. This is the + cleanest long-term API but touches every calculator implementation + and every caller of `calculate_pattern()`. + - Keep `calculate_pattern()` returning the pattern array and add an + optional calculator method such as `last_powder_refln_records(...)` + or `calculate_powder_refln(...)`. This keeps the first diff smaller + but requires careful cache/lifecycle handling so reflection data + comes from the same calculation state as the pattern. +3. Update `PdDataBase._update()` so it collects reflection records while + looping over valid linked phases. After all phases are processed, + call `experiment.refln._replace_from_records(records)` once. +4. If a linked phase is skipped because its structure id is missing, + do not emit rows for that phase. +5. If the calculator cannot provide reflection metadata, clear + `experiment.refln` and log a clear warning. Do not leave stale rows + from a previous calculation. +6. Treat phase scale consistently. The plotted pattern should keep using + `linked_phase.scale * structure_calc`; the `refln` fields should store + either raw structure-factor values or phase-scaled values depending + on the decision in the open questions. + +Calculator-specific notes: + +- Cryspy likely already computes `refln`-style arrays during powder + pattern calculation. The implementation should extract h/k/l, + d-spacing or sin(theta)/lambda, `f_calc`, and `f_squared_calc` from + the same `dict_in_out` result used by `calculate_pattern()`. +- CrysFML currently returns only the calculated powder pattern through + the EasyDiffraction wrapper. The implementation must either add a + real reflection extraction path for CrysFML or explicitly warn and + leave `refln` empty when CrysFML is active. +- The calculator base API and all concrete calculators must agree on the + same record shape, even when a calculator returns no records. + +## Plotting Integration + +Replace the temporary display contract from `experiment.bragg_peaks` to +the real powder `experiment.refln` category. + +Steps: + +1. Update `Plotter._extract_bragg_tick_sets()` in + `src/easydiffraction/display/plotting.py` to read from + `experiment.refln`. +2. Group tick rows by raw `refln.linked_phase_id` values and stringify + only when constructing display labels. This preserves numeric ids and + matches the earlier powder plot review fix. +3. Use Miller indices from `index_h`, `index_k`, and `index_l` for hover + text. +4. Use `f_squared_calc` as the default hover intensity unless the open + questions choose `f_calc` or a phase-scaled intensity instead. +5. Resolve tick x positions from the selected plot x axis: + - `d_spacing`: use `refln.d_spacing` directly. + - `two_theta`: derive from `d_spacing` and the experiment wavelength. + - `time_of_flight`: derive from `d_spacing` and TOF calibration + coefficients. +6. Add inverse conversion utilities if they do not already exist, for + example `d_to_twotheta()` and `d_to_tof()`, with invalid-domain values + mapped to `NaN` and filtered out before plotting. +7. Keep existing safeguards from the three-panel plot work: normalize + `x_min`/`x_max`, return no tick sets for empty filtered main ranges, + and tolerate an empty `refln` category without rendering stray rows. +8. Decide whether to rename display DTO fields from `structure_id` to + `phase_id`/`linked_phase_id`. A rename is clearer but touches more + tests; keeping the internal DTO name is a smaller diff. + +## CIF And Documentation + +1. Ensure `refln.as_cif` writes a loop containing the inherited + `_refln.*` fields plus `_refln.linked_phase_id`, `_refln.f_calc`, and + `_refln.f_squared_calc`. +2. Ensure CIF loading can populate the new fields if a saved experiment + contains them. If loaded rows are calculation outputs, they should be + replaced on the next calculation rather than merged. +3. Add or update user-guide parameter documentation if generated docs do + not pick up the new fields automatically. +4. Update the three-panel plot plan or display docs to say the Bragg + ticks now consume `experiment.refln`, not a future `bragg_peaks` + category. + +## Tests + +Phase 2 should add focused tests before running the full verification +commands. + +Recommended unit tests: + +1. `tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py` + or a new mirrored `test_refln.py`: defaults for the powder `Refln` + item, inherited field availability, new descriptor defaults, and + category code `refln`. +2. Collection tests for `_replace_from_records(...)`, array properties, + clearing stale rows, row id generation, and mixed `linked_phase_id` + grouping. +3. `BraggPdExperiment` tests proving `experiment.refln` exists, is + read-only, serializes as `_refln`, and does not appear on total + powder experiments unless explicitly chosen. +4. Calculator tests with fake calculators proving powder calculation + populates `refln` for multiple linked phases and clears it when a + calculator returns no records. +5. Cryspy adapter tests using small mocked `dict_in_out` payloads rather + than real engine calculations. +6. Plotting tests updating fake `bragg_peaks` fixtures to fake `refln` + fixtures, verifying grouping by `linked_phase_id`, x filtering, + default intensity field, and empty-category behavior. +7. Conversion utility tests for `d_to_twotheta()` and `d_to_tof()` if + those helpers are added. +8. CIF round-trip tests for `_refln.linked_phase_id`, `_refln.f_calc`, + and `_refln.f_squared_calc`. + +Suggested verification commands for Phase 2: + +```bash +pixi run unit-tests tests/unit/easydiffraction/datablocks/experiment/categories/data/ +pixi run unit-tests tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py +pixi run unit-tests tests/unit/easydiffraction/display/ +pixi run fix +pixi run check +pixi run unit-tests +pixi run integration-tests +pixi run script-tests +``` + +## Open Questions + +1. Should the CIF name for the phase-link column be exactly + `_refln.linked_phase_id`, or should it follow an existing CIF/pdCIF + convention such as `_refln.phase_id`? +2. Should `linked_phase_id` store `linked_phase.id.value` exactly, or a + different internal structure identifier when those ever diverge? +3. Should `f_calc` be a real non-negative amplitude `|F_calc|`, or do + you need the complex calculated structure factor? The proposed + descriptor supports only a real numeric value. +4. Should `f_squared_calc` store raw per-reflection `|F_calc|^2`, or a + value already scaled by the linked phase scale and/or other powder + intensity factors? +5. For Bragg tick hover intensity, should the plot display `f_calc`, + `f_squared_calc`, or a separately named phase-scaled contribution? +6. Should the `refln` category store explicit powder x coordinates + (`two_theta` / `time_of_flight`) as persisted columns, or should + plotting derive x positions from `d_spacing` and instrument settings? +7. Is it acceptable for the first implementation to populate powder + `refln` for Cryspy and warn/leave it empty for CrysFML until a + CrysFML reflection extraction API is wired? +8. Should loaded CIF `refln` rows be considered cached calculation + output that is always replaced on calculation, or user-visible data + that should survive until the next successful calculation only? +9. Should the display DTO and tests be renamed from `structure_id` to + `linked_phase_id` now, or should that rename be kept out of the first + implementation to minimize plotter churn? +10. Should reflection row ids be globally sequential within the + experiment, or include phase information such as + `:` for easier debugging and CIF + inspection? + +## Suggested Commit Message + +```text +Add powder Refln category plan +``` \ No newline at end of file diff --git a/docs/architecture/plan-threePanelPowderPlot.prompt.md b/docs/dev/plan-threePanelPowderPlot.prompt.md similarity index 100% rename from docs/architecture/plan-threePanelPowderPlot.prompt.md rename to docs/dev/plan-threePanelPowderPlot.prompt.md From f9966788411660369a69df69513ef0a1be06a848 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 09:35:12 +0200 Subject: [PATCH 02/26] Record powder Refln category decisions --- docs/dev/plan-powder-refln-category.md | 163 ++++++++++++++----------- 1 file changed, 92 insertions(+), 71 deletions(-) diff --git a/docs/dev/plan-powder-refln-category.md b/docs/dev/plan-powder-refln-category.md index b43dd2394..387a08248 100644 --- a/docs/dev/plan-powder-refln-category.md +++ b/docs/dev/plan-powder-refln-category.md @@ -7,7 +7,7 @@ Bragg ticks in the existing three-panel powder plot. ## Status -- [ ] Confirm open questions below. +- [x] Record design decisions below. - [ ] Phase 1: implement code and documentation changes only. - [ ] Phase 2: add/update tests and run verification commands. @@ -40,38 +40,55 @@ Bragg ticks in the existing three-panel powder plot. Implement a powder-specific reflection item that inherits from the single-crystal `Refln` item and adds the requested powder fields. -Recommended item fields: +Recommended common item fields: | Public property | CIF name | Type | Meaning | | --- | --- | --- | --- | | `id` | `_refln.id` | string | Stable row identifier, unique within the experiment. | -| `linked_phase_id` | `_refln.linked_phase_id` | string | Identifier of the linked phase that produced this reflection. | +| `phase_id` | `_refln.phase_id` | string | Identifier of the linked phase that produced this reflection. | | `d_spacing` | `_refln.d_spacing` | numeric | Reflection d-spacing, used to derive plot x positions when needed. | | `sin_theta_over_lambda` | `_refln.sin_theta_over_lambda` | numeric | Reflection sin(theta)/lambda value. | | `index_h` | `_refln.index_h` | numeric | Miller h index. | | `index_k` | `_refln.index_k` | numeric | Miller k index. | | `index_l` | `_refln.index_l` | numeric | Miller l index. | -| `f_calc` | `_refln.f_calc` | numeric | Calculated structure-factor amplitude. | -| `f_squared_calc` | `_refln.f_squared_calc` | numeric | Calculated structure-factor amplitude squared. | +| `f_calc` | `_refln.f_calc` | numeric | Calculated structure-factor amplitude `\|F_calc\|`. | +| `f_squared_calc` | `_refln.f_squared_calc` | numeric | Raw calculated structure-factor amplitude squared `\|F_calc\|^2`. | + +Recommended beam-mode-specific item fields should mirror the active +powder `data` category so Bragg ticks are always rendered in the same x +space as the main chart: + +| Beam mode | Public property | CIF name basis | Meaning | +| --- | --- | --- | --- | +| CWL | `two_theta` | same convention as `data.two_theta` | Reflection position on the constant-wavelength 2θ axis. | +| TOF | `time_of_flight` | same convention as `data.time_of_flight` | Reflection position on the time-of-flight axis. | Implementation notes: - Add the item as `Refln` in a powder Bragg module and inherit from `easydiffraction.datablocks.experiment.categories.data.bragg_sc.Refln`. Alias the imported single-crystal class locally to avoid a name clash. -- Add `linked_phase_id`, `f_calc`, and `f_squared_calc` descriptors in - the subclass `__init__()` after `super().__init__()`. +- Add `phase_id`, `f_calc`, and `f_squared_calc` descriptors in the + subclass `__init__()` after `super().__init__()`. +- Although the public/CIF field is named `phase_id`, its value should be + copied exactly from `linked_phase.id.value`. +- Add CWL and TOF powder `Refln` variants or mixins so `refln` stores + the same native x-coordinate field as the active powder `data` + category: `two_theta` for CWL and `time_of_flight` for TOF. - Keep `self._identity.category_code = 'refln'` from the base class. -- Keep `f_calc` and `f_squared_calc` non-negative unless the answer to - the open questions requires complex structure factors. +- Keep `f_calc` and `f_squared_calc` non-negative real numeric values. + `f_calc` is `|F_calc|`; `f_squared_calc` is raw `|F_calc|^2` and must + not include linked-phase scale or powder intensity factors. - Add a collection class, for example `PowderReflnData`, with typed array - properties for `linked_phase_id`, `d_spacing`, `sin_theta_over_lambda`, - `index_h`, `index_k`, `index_l`, `f_calc`, and `f_squared_calc`. + properties for `phase_id`, `d_spacing`, `sin_theta_over_lambda`, + `index_h`, `index_k`, `index_l`, `f_calc`, `f_squared_calc`, and the + active beam-mode x coordinate. - Add a private replacement method such as `_replace_from_records(...)` so calculators can atomically clear and repopulate all reflection rows after a successful pattern calculation. - Use globally unique row ids, likely `1`, `2`, ... in calculation order, - while preserving phase grouping with `linked_phase_id`. + while preserving phase grouping with `phase_id`. Add a future note to + reconsider phase-prefixed ids if CIF inspection or debugging needs it. ## Experiment Wiring @@ -106,9 +123,9 @@ truth for both the calculated pattern and the reflection table. Recommended implementation path: 1. Define a small internal reflection-record container for calculator - output, containing `linked_phase_id`, `d_spacing`, + output, containing `phase_id`, `d_spacing`, `sin_theta_over_lambda`, `index_h`, `index_k`, `index_l`, `f_calc`, - and `f_squared_calc`. + `f_squared_calc`, and the active beam-mode x coordinate. 2. Extend the calculator abstraction with an explicit powder-reflection extraction path. Two possible designs are viable: - Return a typed result object from powder pattern calculation, such @@ -129,16 +146,22 @@ Recommended implementation path: `experiment.refln` and log a clear warning. Do not leave stale rows from a previous calculation. 6. Treat phase scale consistently. The plotted pattern should keep using - `linked_phase.scale * structure_calc`; the `refln` fields should store - either raw structure-factor values or phase-scaled values depending - on the decision in the open questions. + `linked_phase.scale * structure_calc`; the `refln` structure-factor + fields remain raw values from the calculator: `f_calc = |F_calc|` and + `f_squared_calc = |F_calc|^2`. +7. Treat `refln` like the calculated columns in `data`: every powder + calculation updates the category and clears stale rows rather than + preserving previous calculation output. Calculator-specific notes: - Cryspy likely already computes `refln`-style arrays during powder pattern calculation. The implementation should extract h/k/l, d-spacing or sin(theta)/lambda, `f_calc`, and `f_squared_calc` from - the same `dict_in_out` result used by `calculate_pattern()`. + the same `dict_in_out` result used by `calculate_pattern()`. Start + with Cryspy and reuse values from the pattern calculation rather than + making explicit extra structure-factor calls if the required data are + already returned. - CrysFML currently returns only the calculated powder pattern through the EasyDiffraction wrapper. The implementation must either add a real reflection extraction path for CrysFML or explicitly warn and @@ -156,32 +179,33 @@ Steps: 1. Update `Plotter._extract_bragg_tick_sets()` in `src/easydiffraction/display/plotting.py` to read from `experiment.refln`. -2. Group tick rows by raw `refln.linked_phase_id` values and stringify +2. Group tick rows by raw `refln.phase_id` values and stringify only when constructing display labels. This preserves numeric ids and matches the earlier powder plot review fix. 3. Use Miller indices from `index_h`, `index_k`, and `index_l` for hover text. -4. Use `f_squared_calc` as the default hover intensity unless the open - questions choose `f_calc` or a phase-scaled intensity instead. +4. Hover text must show `phase_id`, `(index_h index_k index_l)`, + `f_squared_calc`, and `f_calc`. 5. Resolve tick x positions from the selected plot x axis: - `d_spacing`: use `refln.d_spacing` directly. - - `two_theta`: derive from `d_spacing` and the experiment wavelength. - - `time_of_flight`: derive from `d_spacing` and TOF calibration - coefficients. -6. Add inverse conversion utilities if they do not already exist, for - example `d_to_twotheta()` and `d_to_tof()`, with invalid-domain values - mapped to `NaN` and filtered out before plotting. + - `two_theta`: use `refln.two_theta` for CWL experiments. + - `time_of_flight`: use `refln.time_of_flight` for TOF experiments. +6. The Bragg tick row should always use the same coordinate space as the + main chart. If the plot asks for a derived x axis, the tick extractor + must select or derive the corresponding `refln` array before + filtering. 7. Keep existing safeguards from the three-panel plot work: normalize `x_min`/`x_max`, return no tick sets for empty filtered main ranges, and tolerate an empty `refln` category without rendering stray rows. -8. Decide whether to rename display DTO fields from `structure_id` to - `phase_id`/`linked_phase_id`. A rename is clearer but touches more - tests; keeping the internal DTO name is a smaller diff. +8. Rename display DTO fields from `structure_id` to `phase_id` for this + feature so the display layer matches the `refln` category. Add a note + to reconsider `structure_id` later if the API needs to emphasize the + structural datablock rather than the linked phase row. ## CIF And Documentation 1. Ensure `refln.as_cif` writes a loop containing the inherited - `_refln.*` fields plus `_refln.linked_phase_id`, `_refln.f_calc`, and + `_refln.*` fields plus `_refln.phase_id`, `_refln.f_calc`, and `_refln.f_squared_calc`. 2. Ensure CIF loading can populate the new fields if a saved experiment contains them. If loaded rows are calculation outputs, they should be @@ -204,7 +228,7 @@ Recommended unit tests: item, inherited field availability, new descriptor defaults, and category code `refln`. 2. Collection tests for `_replace_from_records(...)`, array properties, - clearing stale rows, row id generation, and mixed `linked_phase_id` + clearing stale rows, row id generation, and mixed `phase_id` grouping. 3. `BraggPdExperiment` tests proving `experiment.refln` exists, is read-only, serializes as `_refln`, and does not appear on total @@ -215,12 +239,12 @@ Recommended unit tests: 5. Cryspy adapter tests using small mocked `dict_in_out` payloads rather than real engine calculations. 6. Plotting tests updating fake `bragg_peaks` fixtures to fake `refln` - fixtures, verifying grouping by `linked_phase_id`, x filtering, - default intensity field, and empty-category behavior. -7. Conversion utility tests for `d_to_twotheta()` and `d_to_tof()` if - those helpers are added. -8. CIF round-trip tests for `_refln.linked_phase_id`, `_refln.f_calc`, - and `_refln.f_squared_calc`. + fixtures, verifying grouping by `phase_id`, x filtering, hover fields, + and empty-category behavior. +7. CWL/TOF coordinate tests proving Bragg ticks use the same x axis as + the main chart for `two_theta`, `time_of_flight`, and `d_spacing`. +8. CIF round-trip tests for `_refln.phase_id`, `_refln.f_calc`, and + `_refln.f_squared_calc`. Suggested verification commands for Phase 2: @@ -235,40 +259,37 @@ pixi run integration-tests pixi run script-tests ``` -## Open Questions - -1. Should the CIF name for the phase-link column be exactly - `_refln.linked_phase_id`, or should it follow an existing CIF/pdCIF - convention such as `_refln.phase_id`? -2. Should `linked_phase_id` store `linked_phase.id.value` exactly, or a - different internal structure identifier when those ever diverge? -3. Should `f_calc` be a real non-negative amplitude `|F_calc|`, or do - you need the complex calculated structure factor? The proposed - descriptor supports only a real numeric value. -4. Should `f_squared_calc` store raw per-reflection `|F_calc|^2`, or a - value already scaled by the linked phase scale and/or other powder - intensity factors? -5. For Bragg tick hover intensity, should the plot display `f_calc`, - `f_squared_calc`, or a separately named phase-scaled contribution? -6. Should the `refln` category store explicit powder x coordinates - (`two_theta` / `time_of_flight`) as persisted columns, or should - plotting derive x positions from `d_spacing` and instrument settings? -7. Is it acceptable for the first implementation to populate powder - `refln` for Cryspy and warn/leave it empty for CrysFML until a - CrysFML reflection extraction API is wired? -8. Should loaded CIF `refln` rows be considered cached calculation - output that is always replaced on calculation, or user-visible data - that should survive until the next successful calculation only? -9. Should the display DTO and tests be renamed from `structure_id` to - `linked_phase_id` now, or should that rename be kept out of the first - implementation to minimize plotter churn? -10. Should reflection row ids be globally sequential within the - experiment, or include phase information such as - `:` for easier debugging and CIF - inspection? +## Decisions Recorded + +1. Use `_refln.phase_id` / `phase_id` for the phase-link field. Add a + future note to consider switching to `structure_id` if that proves + clearer for users or CIF interoperability. +2. Store `linked_phase.id.value` exactly in `phase_id`. +3. Store `f_calc` as the real non-negative amplitude `|F_calc|`. +4. Store `f_squared_calc` as raw `|F_calc|^2`, without linked-phase + scale or powder intensity factors. +5. Bragg tick hover text must show `phase_id`, `(index_h index_k + index_l)`, `f_squared_calc`, and `f_calc`. +6. Make `refln` beam-mode dependent like `data`: CWL rows store + `two_theta`, TOF rows store `time_of_flight`, and all rows expose + `d_spacing`. Bragg ticks must appear in the same x coordinate space + as the main chart, including when the user plots with + `x='d_spacing'`. +7. Start with Cryspy population. Reuse structure-factor values returned + during the powder pattern calculation when Cryspy provides them; + avoid explicit extra structure-factor calls unless inspection shows + they are required. +8. Treat CIF-loaded `refln` rows as cached calculation output. Every + calculation updates this category, similar to calculated columns in + `data`. +9. Rename display internals from `structure_id` to `phase_id` for + consistency with the category. +10. Use global sequential reflection row ids for now. Add a future note + to reconsider phase-prefixed ids if debugging or CIF inspection + would benefit. ## Suggested Commit Message ```text -Add powder Refln category plan -``` \ No newline at end of file +Record powder Refln category decisions +``` From 0b2b39c024d7ab76c9d0636cb05037feb2566333 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 09:51:47 +0200 Subject: [PATCH 03/26] Add plan workflow instructions --- .github/copilot-instructions.md | 43 ++++++++++ ...egory.md => plan_powder-refln-category.md} | 83 ++++++++++++++++++- 2 files changed, 125 insertions(+), 1 deletion(-) rename docs/dev/{plan-powder-refln-category.md => plan_powder-refln-category.md} (77%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c4abd1a69..cf6dbe054 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -120,6 +120,49 @@ ## Workflow +### Planning Workflow + +- When asked to create a plan, first gather enough repository context to + make the plan concrete. Ask all ambiguous, potentially ambiguous, or + unclear questions in one concise batch, and record unresolved questions + in the plan if the user wants the plan saved before answering them. +- Save plans as Markdown files in `docs/dev` with the filename pattern + `plan_.md`. The `` part uses lowercase + words separated by dashes, for example + `docs/dev/plan_powder-refln-category.md`. +- Use the same `` to create the implementation branch, + normally `feature/`. Do not push the branch unless the + user explicitly asks. +- Each plan must include a status checklist with `[ ]` items. Mark each + item as `[x]` as it is completed during implementation. +- Plans for non-trivial work must separate the work into two phases: + - **Phase 1 — Implementation:** the agent works independently through + all implementation steps, updating the plan checklist as it goes. Do + not create tests or run tests in this phase unless the user + explicitly asks. When Phase 1 is complete, stop and ask the user to + review the implementation. + - **Phase 2 — Verification:** after user approval, add/update tests, + run formatting, linting, unit tests, integration tests, and script or + notebook checks requested by the plan. +- Every completed implementation step must end with a local commit. Stage + only the files modified for that step, using explicit paths where + practical. Do not include data files, project files, CIF files, or + other generated artifacts created by integration tests, script tests, + or notebook execution unless the user explicitly asked to update those + artifacts. +- Keep commits atomic, single-purpose, and aligned with plan steps. Use + imperative commit messages, no type prefix, and keep the subject line + at or below 72 characters. +- Before each commit, inspect the worktree and avoid staging unrelated + user changes. If unrelated dirty files exist, leave them untouched and + mention them only when relevant. +- The plan should be easy to maintain while working: include concrete + files likely to change, decisions already made, open questions, + verification commands for Phase 2, and a short suggested commit + message or branch name when useful. + +### General Workflow + - Two-phase workflow for non-trivial changes: - **Phase 1 — Implementation:** code, docs, architecture updates. Do not create new tests or run existing tests. Present for review and diff --git a/docs/dev/plan-powder-refln-category.md b/docs/dev/plan_powder-refln-category.md similarity index 77% rename from docs/dev/plan-powder-refln-category.md rename to docs/dev/plan_powder-refln-category.md index 387a08248..fc5511983 100644 --- a/docs/dev/plan-powder-refln-category.md +++ b/docs/dev/plan_powder-refln-category.md @@ -5,11 +5,92 @@ metadata per linked phase. The category will be populated when powder patterns are calculated and will provide the data source for hoverable Bragg ticks in the existing three-panel powder plot. +- Plan file: `docs/dev/plan_powder-refln-category.md` +- Feature name: `powder-refln-category` +- Feature branch: `feature/powder-refln-category` + ## Status - [x] Record design decisions below. +- [ ] Create and switch to branch `feature/powder-refln-category`. - [ ] Phase 1: implement code and documentation changes only. -- [ ] Phase 2: add/update tests and run verification commands. +- [ ] Stop for user review after Phase 1 is complete. +- [ ] Phase 2: add/update tests and run verification commands after + user approval. +- [ ] Final clean-up: update this plan, confirm commits, and summarize + remaining risks. + +## Execution Protocol + +- Work independently through Phase 1 until all implementation checklist + items are complete. Do not add tests or run tests during Phase 1 unless + the user explicitly asks. +- Mark each checklist item from `[ ]` to `[x]` when it is completed. +- Commit after every completed implementation step. Stage only the files + modified for that step and avoid staging unrelated user changes. +- Do not push commits unless the user explicitly asks. +- Do not commit data files, project files, CIF files, or other generated + artifacts created by integration tests, script tests, or notebook + execution unless the user explicitly asks to update those artifacts. +- After Phase 1, stop and ask the user to review the implementation. + Continue to Phase 2 only after user approval. + +## Implementation Checklist + +### Phase 1 — Implementation + +- [ ] Step 1: Create branch `feature/powder-refln-category` from the + current working branch. + Suggested commit: no commit; branch setup only. +- [ ] Step 2: Add the powder `Refln` item and collection, including + `phase_id`, `f_calc`, `f_squared_calc`, beam-mode x fields, array + accessors, and `_replace_from_records(...)`. + Suggested commit: `Add powder Refln category` +- [ ] Step 3: Wire `experiment.refln` into `BraggPdExperiment` as a + read-only sibling category and include it in CIF serialization. + Suggested commit: `Wire powder Refln into Bragg experiments` +- [ ] Step 4: Add calculator-side reflection record extraction for + Cryspy, reusing values from the powder pattern calculation output + when available. + Suggested commit: `Extract Cryspy powder reflection records` +- [ ] Step 5: Update `PdDataBase._update()` so every calculation clears + and repopulates `experiment.refln` in sync with `data.intensity_calc`. + Suggested commit: `Populate powder Refln during calculation` +- [ ] Step 6: Update plotting to consume `experiment.refln`, group by + `phase_id`, use the selected x-axis space, and show `phase_id`, + `(index_h index_k index_l)`, `f_squared_calc`, and `f_calc` in + Bragg tick hover content. + Suggested commit: `Use powder Refln for Bragg ticks` +- [ ] Step 7: Update documentation and any affected developer plans so + they refer to `experiment.refln` and `phase_id`. + Suggested commit: `Document powder Refln Bragg ticks` +- [ ] Step 8: Review Phase 1 diffs, update this checklist, and stop for + user review before adding tests or running verification. + Suggested commit: `Update powder Refln implementation plan` + +### Phase 2 — Verification + +- [ ] Step 9: Add/update focused unit tests for the powder `Refln` item, + collection replacement, `BraggPdExperiment` wiring, Cryspy record + extraction, data update population, plotting, and CIF round-trip. + Suggested commit: `Test powder Refln category` +- [ ] Step 10: Run targeted unit tests for the changed areas and fix any + failures. + Suggested commit: `Fix powder Refln test failures` +- [ ] Step 11: Run project formatting, linting, unit tests, integration + tests, and script tests listed below. Do not commit generated data, + project, or CIF artifacts from those runs. + Suggested commit: `Verify powder Refln implementation` +- [ ] Step 12: Update this plan with completed verification results and + any remaining risks. + Suggested commit: `Finalize powder Refln plan` + +## Clarification Questions + +All known questions have been answered and recorded in +`Decisions Recorded`. If new ambiguity appears during implementation, +pause only when the ambiguity changes public API, scientific semantics, +data persistence, or removes/replaces existing behavior. ## Current Context From 8a3c24d71175b5a70c39941203954a4769e11873 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 10:34:40 +0200 Subject: [PATCH 04/26] Add powder Refln category Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../experiment/categories/data/__init__.py | 2 + .../experiment/categories/data/refln_pd.py | 278 ++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py diff --git a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py index 3599f3b58..3c1851205 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py @@ -4,4 +4,6 @@ from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdTofData from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData +from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData +from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData diff --git a/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py new file mode 100644 index 000000000..2e945fb5e --- /dev/null +++ b/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py @@ -0,0 +1,278 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from easydiffraction.core.category import CategoryCollection +from easydiffraction.core.metadata import Compatibility +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 StringDescriptor +from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ( + Refln as SingleCrystalRefln, +) +from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum +from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum +from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum +from easydiffraction.io.cif.handler import CifHandler + +if TYPE_CHECKING: + from collections.abc import Sequence + + from easydiffraction.analysis.calculators.base import PowderReflnRecord + + +class PowderReflnBase(SingleCrystalRefln): + """Single calculated powder reflection row.""" + + def __init__(self) -> None: + super().__init__() + + self._phase_id = StringDescriptor( + name='phase_id', + description='Identifier of the linked phase for this reflection', + value_spec=AttributeSpec(default=''), + cif_handler=CifHandler(names=['_refln.phase_id']), + ) + self._f_calc = NumericDescriptor( + name='f_calc', + description='Calculated structure-factor amplitude for this reflection', + value_spec=AttributeSpec( + default=0.0, + validator=RangeValidator(ge=0), + ), + cif_handler=CifHandler(names=['_refln.f_calc']), + ) + self._f_squared_calc = NumericDescriptor( + name='f_squared_calc', + description='Calculated structure-factor amplitude squared for this reflection', + value_spec=AttributeSpec( + default=0.0, + validator=RangeValidator(ge=0), + ), + cif_handler=CifHandler(names=['_refln.f_squared_calc']), + ) + + @property + def phase_id(self) -> StringDescriptor: + """Linked-phase identifier for this reflection.""" + return self._phase_id + + @property + def f_calc(self) -> NumericDescriptor: + """Calculated structure-factor amplitude for this reflection.""" + return self._f_calc + + @property + def f_squared_calc(self) -> NumericDescriptor: + """Calculated structure-factor amplitude squared for this reflection.""" + return self._f_squared_calc + + @property + def parameters(self) -> list: + """Powder reflection descriptors serialized in CIF loops.""" + return [ + self._id, + self._phase_id, + self._d_spacing, + self._sin_theta_over_lambda, + self._index_h, + self._index_k, + self._index_l, + self._f_calc, + self._f_squared_calc, + ] + + +class PowderCwlRefln(PowderReflnBase): + """Single calculated powder reflection row for CWL experiments.""" + + def __init__(self) -> None: + super().__init__() + + self._two_theta = NumericDescriptor( + name='two_theta', + description='Calculated 2theta position for this reflection', + units='deg', + value_spec=AttributeSpec( + default=0.0, + validator=RangeValidator(ge=0, le=180), + ), + cif_handler=CifHandler(names=['_refln.two_theta']), + ) + + @property + def two_theta(self) -> NumericDescriptor: + """Calculated 2theta position for this reflection.""" + return self._two_theta + + @property + def parameters(self) -> list: + """Powder CWL reflection descriptors serialized in CIF loops.""" + return [*super().parameters, self._two_theta] + + +class PowderTofRefln(PowderReflnBase): + """Single calculated powder reflection row for TOF experiments.""" + + def __init__(self) -> None: + super().__init__() + + self._time_of_flight = NumericDescriptor( + name='time_of_flight', + description='Calculated time-of-flight position for this reflection', + units='μs', + value_spec=AttributeSpec( + default=0.0, + validator=RangeValidator(ge=0), + ), + cif_handler=CifHandler(names=['_refln.time_of_flight']), + ) + + @property + def time_of_flight(self) -> NumericDescriptor: + """Calculated time-of-flight position for this reflection.""" + return self._time_of_flight + + @property + def parameters(self) -> list: + """Powder TOF reflection descriptors serialized in CIF loops.""" + return [*super().parameters, self._time_of_flight] + + +class PowderReflnDataBase(CategoryCollection): + """Base collection for calculated powder reflection rows.""" + + _update_priority = 110 + + def _replace_from_records(self, records: Sequence[PowderReflnRecord]) -> None: + """Replace all reflection rows from calculator output records.""" + self._items = [self._item_type() for _ in records] + + for index, (item, record) in enumerate(zip(self._items, records, strict=True), start=1): + item.id._value = str(index) + item.phase_id._value = str(record.phase_id) + item.d_spacing._value = float(record.d_spacing) + item.sin_theta_over_lambda._value = float(record.sin_theta_over_lambda) + item.index_h._value = record.index_h + item.index_k._value = record.index_k + item.index_l._value = record.index_l + item.f_calc._value = float(record.f_calc) + item.f_squared_calc._value = float(record.f_squared_calc) + self._set_x_value(item=item, record=record) + + def _set_x_value( + self, + *, + item: PowderReflnBase, + record: PowderReflnRecord, + ) -> None: + """Set the beam-mode-specific x coordinate on a reflection row.""" + + @property + def id(self) -> np.ndarray: + """Reflection identifiers for all rows.""" + return np.fromiter((item.id.value for item in self._items), dtype=object) + + @property + def phase_id(self) -> np.ndarray: + """Linked-phase identifiers for all rows.""" + return np.fromiter((item.phase_id.value for item in self._items), dtype=object) + + @property + def d_spacing(self) -> np.ndarray: + """D-spacing values for all rows.""" + return np.fromiter((item.d_spacing.value for item in self._items), dtype=float) + + @property + def sin_theta_over_lambda(self) -> np.ndarray: + """sin(theta)/lambda values for all rows.""" + return np.fromiter( + (item.sin_theta_over_lambda.value for item in self._items), + dtype=float, + ) + + @property + def index_h(self) -> np.ndarray: + """Miller h indices for all rows.""" + return np.fromiter((item.index_h.value for item in self._items), dtype=float) + + @property + def index_k(self) -> np.ndarray: + """Miller k indices for all rows.""" + return np.fromiter((item.index_k.value for item in self._items), dtype=float) + + @property + def index_l(self) -> np.ndarray: + """Miller l indices for all rows.""" + return np.fromiter((item.index_l.value for item in self._items), dtype=float) + + @property + def f_calc(self) -> np.ndarray: + """Calculated structure-factor amplitudes for all rows.""" + return np.fromiter((item.f_calc.value for item in self._items), dtype=float) + + @property + def f_squared_calc(self) -> np.ndarray: + """Calculated structure-factor amplitudes squared for all rows.""" + return np.fromiter((item.f_squared_calc.value for item in self._items), dtype=float) + + +class PowderCwlReflnData(PowderReflnDataBase): + """Calculated powder reflection collection for CWL experiments.""" + + type_info = TypeInfo(tag='bragg-pd-refln', description='Bragg powder CWL reflection data') + compatibility = Compatibility( + sample_form=frozenset({SampleFormEnum.POWDER}), + scattering_type=frozenset({ScatteringTypeEnum.BRAGG}), + beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}), + ) + + def __init__(self) -> None: + super().__init__(item_type=PowderCwlRefln) + + def _set_x_value( + self, + *, + item: PowderReflnBase, + record: PowderReflnRecord, + ) -> None: + item.two_theta._value = float(record.two_theta) + + @property + def two_theta(self) -> np.ndarray: + """Calculated 2theta positions for all rows.""" + return np.fromiter((item.two_theta.value for item in self._items), dtype=float) + + +class PowderTofReflnData(PowderReflnDataBase): + """Calculated powder reflection collection for TOF experiments.""" + + type_info = TypeInfo(tag='bragg-pd-tof-refln', description='Bragg powder TOF reflection data') + compatibility = Compatibility( + sample_form=frozenset({SampleFormEnum.POWDER}), + scattering_type=frozenset({ScatteringTypeEnum.BRAGG}), + beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}), + ) + + def __init__(self) -> None: + super().__init__(item_type=PowderTofRefln) + + def _set_x_value( + self, + *, + item: PowderReflnBase, + record: PowderReflnRecord, + ) -> None: + item.time_of_flight._value = float(record.time_of_flight) + + @property + def time_of_flight(self) -> np.ndarray: + """Calculated time-of-flight positions for all rows.""" + return np.fromiter((item.time_of_flight.value for item in self._items), dtype=float) From 97e70268de1ddc33c72d77c0d626cbd46b7eeca3 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 10:35:21 +0200 Subject: [PATCH 05/26] Wire powder Refln into Bragg experiments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../datablocks/experiment/item/bragg_pd.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py index 54f8a18c3..a40b2bbdf 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py @@ -11,6 +11,8 @@ from easydiffraction.core.metadata import TypeInfo from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory +from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData +from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData from easydiffraction.datablocks.experiment.item.base import PdExperimentBase from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum @@ -62,6 +64,18 @@ def __init__( self._instrument = InstrumentFactory.create(self._instrument_type) self._background_type: str = BackgroundFactory.default_tag() self._background = BackgroundFactory.create(self._background_type) + self._refln = self._create_refln_collection() + + def _create_refln_collection(self) -> object: + """Create the beam-mode-specific calculated reflection collection.""" + beam_mode = self.type.beam_mode.value + if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: + return PowderCwlReflnData() + if beam_mode == BeamModeEnum.TIME_OF_FLIGHT: + return PowderTofReflnData() + + msg = f'Unsupported beam mode for powder reflection data: {beam_mode}.' + raise ValueError(msg) def _load_ascii_data_to_experiment( self, @@ -129,6 +143,11 @@ def instrument(self) -> object: """Active instrument model for this experiment.""" return self._instrument + @property + def refln(self) -> object: + """Calculated reflection metadata for this experiment.""" + return self._refln + # ------------------------------------------------------------------ # Background (switchable-category pattern) # ------------------------------------------------------------------ From dfb380da945cf6e461fb2c7d83c8a3f30fea31e0 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 10:36:57 +0200 Subject: [PATCH 06/26] Extract Cryspy powder reflection records Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analysis/calculators/base.py | 35 ++++++ .../analysis/calculators/cryspy.py | 108 +++++++++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/src/easydiffraction/analysis/calculators/base.py b/src/easydiffraction/analysis/calculators/base.py index 5ef47e3b0..96f18e96a 100644 --- a/src/easydiffraction/analysis/calculators/base.py +++ b/src/easydiffraction/analysis/calculators/base.py @@ -1,8 +1,11 @@ # SPDX-FileCopyrightText: 2025 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + from abc import ABC from abc import abstractmethod +from dataclasses import dataclass import numpy as np @@ -11,6 +14,22 @@ from easydiffraction.datablocks.structure.item.base import Structure +@dataclass(frozen=True) +class PowderReflnRecord: + """Calculated powder reflection metadata for one reflection row.""" + + phase_id: str + d_spacing: float + sin_theta_over_lambda: float + index_h: int + index_k: int + index_l: int + f_calc: float + f_squared_calc: float + two_theta: float | None = None + time_of_flight: float | None = None + + class CalculatorBase(ABC): """Base API for diffraction calculation engines.""" @@ -60,3 +79,19 @@ def calculate_pattern( np.ndarray The calculated diffraction pattern as a NumPy array. """ + + def last_powder_refln_records( + self, + structure: Structure, + experiment: ExperimentBase, + *, + phase_id: str, + ) -> list[PowderReflnRecord] | None: + """ + Return the last powder reflection records for one phase. + + Backends that do not expose powder reflection metadata return + ``None`` so callers can clear stale reflection rows and warn. + """ + del structure, experiment, phase_id + return None diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index 8e6a842ac..e3cace33c 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + import contextlib import copy import io @@ -9,6 +11,7 @@ import numpy as np from easydiffraction.analysis.calculators.base import CalculatorBase +from easydiffraction.analysis.calculators.base import PowderReflnRecord from easydiffraction.analysis.calculators.factory import CalculatorFactory from easydiffraction.core.metadata import TypeInfo from easydiffraction.datablocks.experiment.item.base import ExperimentBase @@ -16,6 +19,7 @@ from easydiffraction.datablocks.experiment.item.enums import PeakProfileTypeEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.structure.item.base import Structure +from easydiffraction.utils.utils import sin_theta_over_lambda_to_d_spacing try: import cryspy @@ -56,6 +60,7 @@ def __init__(self) -> None: self._cryspy_dicts: dict[str, dict[str, Any]] = {} self._cached_peak_types: dict[str, str] = {} self._cached_adp_types: dict[str, tuple[str, ...]] = {} + self._last_powder_phase_blocks: dict[str, dict[str, Any] | None] = {} def _invalidate_stale_cache( self, @@ -185,6 +190,7 @@ def calculate_pattern( """ combined_name = f'{structure.name}_{experiment.name}' self._invalidate_stale_cache(combined_name, experiment, structure) + self._last_powder_phase_blocks[combined_name] = None if called_by_minimizer: if self._cryspy_dicts and combined_name in self._cryspy_dicts: @@ -236,15 +242,113 @@ def calculate_pattern( return [] try: - signal_plus = cryspy_in_out_dict[cryspy_block_name]['signal_plus'] - signal_minus = cryspy_in_out_dict[cryspy_block_name]['signal_minus'] + powder_block = cryspy_in_out_dict[cryspy_block_name] + signal_plus = powder_block['signal_plus'] + signal_minus = powder_block['signal_minus'] y_calc = signal_plus + signal_minus + self._last_powder_phase_blocks[combined_name] = powder_block.get( + f'dict_in_out_{structure.name}' + ) except KeyError: print(f'[CryspyCalculator] Error: No calculated data for {cryspy_block_name}') return [] return y_calc + def last_powder_refln_records( + self, + structure: Structure, + experiment: ExperimentBase, + *, + phase_id: str, + ) -> list[PowderReflnRecord] | None: + """Return powder reflection records from the latest pattern run.""" + combined_name = f'{structure.name}_{experiment.name}' + phase_block = self._last_powder_phase_blocks.get(combined_name) + if phase_block is None: + return None + + try: + indices = np.asarray(phase_block['index_hkl'], dtype=int) + sin_theta_over_lambda = np.asarray(phase_block['sthovl'], dtype=float) + f_nucl = np.asarray(phase_block['f_nucl']) + except KeyError: + return None + + if indices.shape[0] != 3: + return None + + d_spacing_raw = phase_block.get('d_hkl') + if d_spacing_raw is None: + d_spacing = np.asarray( + sin_theta_over_lambda_to_d_spacing(sin_theta_over_lambda), + dtype=float, + ) + else: + d_spacing = np.asarray(d_spacing_raw, dtype=float) + + beam_mode = experiment.type.beam_mode.value + if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: + x_raw = phase_block.get('ttheta_hkl') + if x_raw is None: + return None + x_values = np.asarray(x_raw, dtype=float) + elif beam_mode == BeamModeEnum.TIME_OF_FLIGHT: + x_raw = phase_block.get('time_hkl') + if x_raw is None: + return None + x_values = np.asarray(x_raw, dtype=float) + else: + return None + + if x_values.size == 0: + return None + + f_calc = np.abs(f_nucl) + f_squared_calc = f_calc**2 + records: list[PowderReflnRecord] = [] + for index_h, index_k, index_l, sthovl, d_value, x_value, f_value, f_sq_value in zip( + indices[0], + indices[1], + indices[2], + sin_theta_over_lambda, + d_spacing, + x_values, + f_calc, + f_squared_calc, + strict=True, + ): + if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: + records.append( + PowderReflnRecord( + phase_id=phase_id, + d_spacing=float(d_value), + sin_theta_over_lambda=float(sthovl), + index_h=int(index_h), + index_k=int(index_k), + index_l=int(index_l), + f_calc=float(f_value), + f_squared_calc=float(f_sq_value), + two_theta=float(x_value), + ) + ) + else: + records.append( + PowderReflnRecord( + phase_id=phase_id, + d_spacing=float(d_value), + sin_theta_over_lambda=float(sthovl), + index_h=int(index_h), + index_k=int(index_k), + index_l=int(index_l), + f_calc=float(f_value), + f_squared_calc=float(f_sq_value), + time_of_flight=float(x_value), + ) + ) + + return records + def _recreate_cryspy_dict( self, structure: Structure, From d92dc6f906a41f9b9b312ce06ba03e8633a6164e Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 10:37:58 +0200 Subject: [PATCH 07/26] Populate powder Refln during calculation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../experiment/categories/data/bragg_pd.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py index 1d12e383e..c7f18de1c 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py @@ -3,6 +3,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np from easydiffraction.core.category import CategoryCollection @@ -25,6 +27,9 @@ from easydiffraction.utils.utils import tof_to_d from easydiffraction.utils.utils import twotheta_to_d +if TYPE_CHECKING: + from easydiffraction.analysis.calculators.base import PowderReflnRecord + # Uncertainty values below this threshold are replaced with 1.0 _MIN_UNCERTAINTY = 0.0001 @@ -382,6 +387,8 @@ def _update( initial_calc = np.zeros_like(self.x) calc = initial_calc + refln_records: list[PowderReflnRecord] = [] + missing_refln_records = False # TODO: refactor _get_valid_linked_phases to only be responsible # for returning list. Warning message should be defined here, @@ -389,6 +396,7 @@ def _update( # TODO: Adapt following the _update method in bragg_sc.py for linked_phase in experiment._get_valid_linked_phases(structures): structure_id = linked_phase._identity.category_entry_name + phase_id = linked_phase.id.value structure_scale = linked_phase.scale.value structure = structures[structure_id] @@ -401,7 +409,27 @@ def _update( structure_scaled_calc = structure_scale * structure_calc calc += structure_scaled_calc + structure_refln_records = calculator.last_powder_refln_records( + structure, + experiment, + phase_id=phase_id, + ) + if structure_refln_records is None: + missing_refln_records = True + else: + refln_records.extend(structure_refln_records) + self._set_intensity_calc(calc + self.intensity_bkg) + if missing_refln_records: + experiment.refln._replace_from_records([]) + log.warning( + 'Calculated powder reflection metadata is unavailable for ' + f"experiment '{experiment.name}' with calculator " + f"'{calculator.name}'. Clearing experiment.refln.", + ) + return + + experiment.refln._replace_from_records(refln_records) ################### # Public properties From b5d55e38aafdc158c9ebc586f9ecc24e97295518 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 10:39:15 +0200 Subject: [PATCH 08/26] Use powder Refln for Bragg ticks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/easydiffraction/display/plotters/base.py | 14 ++-- .../display/plotters/plotly.py | 18 ++--- src/easydiffraction/display/plotting.py | 81 ++++++++++++------- 3 files changed, 66 insertions(+), 47 deletions(-) diff --git a/src/easydiffraction/display/plotters/base.py b/src/easydiffraction/display/plotters/base.py index 0fc1415ff..8d85f9c5e 100644 --- a/src/easydiffraction/display/plotters/base.py +++ b/src/easydiffraction/display/plotters/base.py @@ -23,20 +23,20 @@ @dataclass(frozen=True) class BraggTickSet: """ - Bragg tick data for one structure or phase row. + Bragg tick data for one linked phase row. - The plotting facade converts future experiment-category peak data - into this display-specific container so plotting backends stay - decoupled from experiment datablock internals. + The plotting facade converts experiment reflection-category data into + this display-specific container so plotting backends stay decoupled + from experiment datablock internals. """ - structure_id: str + phase_id: str x: np.ndarray h: np.ndarray k: np.ndarray ell: np.ndarray - intensity: np.ndarray - peak_id: np.ndarray | None = None + f_squared_calc: np.ndarray + f_calc: np.ndarray @dataclass(frozen=True) diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index d34c4c298..1b2448193 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -633,26 +633,20 @@ def _get_bragg_tick_trace( row_y: float, color: str, ) -> object: - """Create a hover-capable Bragg tick trace for one structure.""" + """Create a hover-capable Bragg tick trace for one linked phase.""" y = np.full(tick_set.x.shape, row_y, dtype=float) - peak_ids = tick_set.peak_id hover_text = [] for idx, x_value in enumerate(tick_set.x): - peak_line = '' - if peak_ids is not None: - peak_value = str(peak_ids[idx]) - if peak_value: - peak_line = f'peak: {peak_value}
' hkl_text = ( f'hkl: ({int(tick_set.h[idx])} ' f'{int(tick_set.k[idx])} {int(tick_set.ell[idx])})
' ) hover_text.append( - f'structure: {tick_set.structure_id}
' - f'{peak_line}' + f'phase_id: {tick_set.phase_id}
' f'{hkl_text}' f'x: {float(x_value):.6g}
' - f'intensity: {float(tick_set.intensity[idx]):.6g}' + f'f_squared_calc: {float(tick_set.f_squared_calc[idx]):.6g}
' + f'f_calc: {float(tick_set.f_calc[idx]):.6g}' ) return go.Scatter( @@ -665,7 +659,7 @@ def _get_bragg_tick_trace( 'line': {'color': color, 'width': 2}, 'color': color, }, - name=f'Bragg ({tick_set.structure_id})', + name=f'Bragg ({tick_set.phase_id})', text=hover_text, hovertemplate='%{text}', showlegend=False, @@ -844,7 +838,7 @@ def plot_powder_meas_vs_calc( title_text='Bragg peaks', tickmode='array', tickvals=[float(idx + 1) for idx in range(len(plot_spec.bragg_tick_sets))], - ticktext=[tick_set.structure_id for tick_set in plot_spec.bragg_tick_sets], + ticktext=[tick_set.phase_id for tick_set in plot_spec.bragg_tick_sets], range=[0.5, float(len(plot_spec.bragg_tick_sets)) + 0.5], showgrid=False, row=layout.bragg_row, diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 8fe16dfbb..bbabde869 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -269,6 +269,7 @@ def _prepare_powder_context( 'x_array': x_array, 'x_min': resolved_x_min, 'x_max': resolved_x_max, + 'x_axis': x_axis, 'axes_labels': axes_labels, } @@ -1256,6 +1257,7 @@ def _plot_powder_bragg_meas_vs_calc( bragg_tick_sets = self._extract_bragg_tick_sets( experiment=experiment, expt_name=expt_name, + x_axis=ctx['x_axis'], x_min=ctx['x_min'], x_max=ctx['x_max'], ) @@ -1304,32 +1306,57 @@ def _plot_line_meas_vs_calc( def _extract_bragg_tick_sets( experiment: object, expt_name: str, + x_axis: object, x_min: float | None, x_max: float | None, ) -> tuple[BraggTickSet, ...]: """ - Convert future experiment peak-position data into display rows. - - The future category is expected to expose array-like attributes - named ``structure_id``, ``x``, ``h``, ``k``, ``l``, and - ``intensity``. Until that category exists, this method simply - returns an empty tuple. + Convert experiment reflection data into Bragg tick display rows. """ - bragg_peaks = getattr(experiment, 'bragg_peaks', None) - if bragg_peaks is None: + refln = getattr(experiment, 'refln', None) + if refln is None: return () - required_names = ('structure_id', 'x', 'h', 'k', 'l', 'intensity') + x_name = getattr(x_axis, 'value', x_axis) + if x_name == XAxisType.D_SPACING: + x_values = refln.d_spacing + elif x_name == XAxisType.TWO_THETA: + x_values = getattr(refln, 'two_theta', None) + elif x_name == XAxisType.TIME_OF_FLIGHT: + x_values = getattr(refln, 'time_of_flight', None) + else: + log.warning( + f"Unsupported Bragg tick x axis '{x_name}' for experiment '{expt_name}'. " + 'Skipping the Bragg subplot.', + ) + return () + + if x_values is None: + log.warning( + f"Experiment '{expt_name}' reflection data does not expose '{x_name}'. " + 'Skipping the Bragg subplot.', + ) + return () + + required_names = ( + 'phase_id', + 'index_h', + 'index_k', + 'index_l', + 'f_squared_calc', + 'f_calc', + ) arrays = {} for name in required_names: - value = getattr(bragg_peaks, name, None) + value = getattr(refln, name, None) if value is None: log.warning( - f"Experiment '{expt_name}' Bragg peak data is missing '{name}'. " + f"Experiment '{expt_name}' reflection data is missing '{name}'. " 'Skipping the Bragg subplot.', ) return () arrays[name] = np.asarray(value) + arrays['x'] = np.asarray(x_values) if arrays['x'].size == 0: return () @@ -1340,29 +1367,27 @@ def _extract_bragg_tick_sets( if not np.any(mask): return () - peak_id = getattr(bragg_peaks, 'peak_id', None) - peak_id_array = None if peak_id is None else np.asarray(peak_id) - structure_ids = arrays['structure_id'][mask] - unique_structure_ids = [] - for raw_structure_id in structure_ids: + phase_ids = arrays['phase_id'][mask] + unique_phase_ids = [] + for raw_phase_id in phase_ids: if not any( - np.array_equal(raw_structure_id, existing_structure_id) - for existing_structure_id in unique_structure_ids + np.array_equal(raw_phase_id, existing_phase_id) + for existing_phase_id in unique_phase_ids ): - unique_structure_ids.append(raw_structure_id) + unique_phase_ids.append(raw_phase_id) tick_sets = [] - for raw_structure_id in unique_structure_ids: - structure_mask = mask & (arrays['structure_id'] == raw_structure_id) + for raw_phase_id in unique_phase_ids: + phase_mask = mask & (arrays['phase_id'] == raw_phase_id) tick_sets.append( BraggTickSet( - structure_id=str(raw_structure_id), - x=arrays['x'][structure_mask], - h=arrays['h'][structure_mask], - k=arrays['k'][structure_mask], - ell=arrays['l'][structure_mask], - intensity=arrays['intensity'][structure_mask], - peak_id=None if peak_id_array is None else peak_id_array[structure_mask], + phase_id=str(raw_phase_id), + x=arrays['x'][phase_mask], + h=arrays['index_h'][phase_mask], + k=arrays['index_k'][phase_mask], + ell=arrays['index_l'][phase_mask], + f_squared_calc=arrays['f_squared_calc'][phase_mask], + f_calc=arrays['f_calc'][phase_mask], ) ) From 95d2bc33ccfed67b9c853047c7714a9b6bac3b39 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 10:40:26 +0200 Subject: [PATCH 09/26] Document powder Refln Bragg ticks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/dev/plan-threePanelPowderPlot.prompt.md | 101 +++++++++---------- 1 file changed, 50 insertions(+), 51 deletions(-) diff --git a/docs/dev/plan-threePanelPowderPlot.prompt.md b/docs/dev/plan-threePanelPowderPlot.prompt.md index cf66fc6bb..d445bca1c 100644 --- a/docs/dev/plan-threePanelPowderPlot.prompt.md +++ b/docs/dev/plan-threePanelPowderPlot.prompt.md @@ -3,9 +3,9 @@ Extend `project.display.plotter.plot_meas_vs_calc()` so powder Bragg plots render as three vertically stacked, X-synced Plotly subplots by default: measured/calculated pattern, Bragg peak tick rows, and residual -curve. The plotter will consume a future experiment peak-position -category, but this change should not calculate or populate peak -positions itself. +curve. The plotter consumes the calculated powder reflection category at +`experiment.refln`; the plotter itself should not calculate or populate +reflection rows. **Status** @@ -17,8 +17,9 @@ positions itself. through a composite plotting path, with residuals enabled by default only for the powder-Bragg composite branch. - [x] Step 4 completed. Added a helper that consumes the future - `experiment.bragg_peaks` arrays when present and otherwise logs a - clear warning while rendering an empty Bragg row. + category contract; this is now backed by `experiment.refln` + arrays and renders an empty Bragg row only when reflection data is + absent. - [x] Step 5 completed. Added `plot_powder_meas_vs_calc(...)` to the plotting backend contract and routed powder Bragg plots to it. - [x] Step 6 completed. Plotly now renders stacked subplots with shared @@ -51,8 +52,8 @@ positions itself. without coupling them to datablock internals. 2. Routed powder Bragg `plot_meas_vs_calc()` through a dedicated composite backend method, added Plotly three-row subplot rendering, - and added an ASCII fallback while tolerating the still-missing - `experiment.bragg_peaks` category. + and added an ASCII fallback while tolerating missing + `experiment.refln` data. 3. Updated `docs/docs/tutorials/ed-2.py` so the tutorial now uses the default three-panel powder plot without explicitly passing `show_residual=True`. @@ -83,28 +84,26 @@ positions itself. within the scale-matched subplot instead of breaking the intended physical alignment. 12. Addressed review regressions by normalizing Bragg filtering bounds - before masking future `bragg_peaks` data, keeping the residual + before masking reflection data, keeping the residual default scoped to the powder-Bragg composite path, and guarding the composite Plotly backend against empty filtered x ranges. 13. Fixed follow-up review gaps by suppressing Bragg ticks when the - filtered main pattern is empty and by grouping Bragg rows on raw - structure ids so numeric identifiers still produce populated tick - rows with string labels. + filtered main pattern is empty and by grouping Bragg rows on raw + phase ids so numeric identifiers still produce populated tick + rows with string labels. **Steps** -1. Confirm the future peak-position category contract before +1. Confirm the powder reflection category contract before implementation. It should be a powder experiment category similar to - `data`, exposing array-like peak data in the experiment's active x - coordinate: structure/phase id, x position, Miller indices h/k/l, and - peak intensity. Optional peak id can be included for hover text. This - is a prerequisite for real Bragg tick content, but can land as a - separate change. + `data`, exposing array-like calculated reflection data in the active x + coordinate: `phase_id`, x position, Miller indices h/k/l, + `f_squared_calc`, and `f_calc`. 2. Add a small plotting data transfer object for Bragg tick sets in - `src/easydiffraction/display/plotters/base.py`, for example a frozen - dataclass containing `structure_id`, `x`, `h`, `k`, `l`, and - `intensity`. Keep it display-specific so the plotting backend does - not depend on experiment category internals. + `src/easydiffraction/display/plotters/base.py`, for example a frozen + dataclass containing `phase_id`, `x`, `h`, `k`, `l`, + `f_squared_calc`, and `f_calc`. Keep it display-specific so the + plotting backend does not depend on experiment category internals. 3. Extend `Plotter.plot_meas_vs_calc()` in `src/easydiffraction/display/plotting.py` so powder Bragg plots include residuals by default. Recommended API: make residual display @@ -112,11 +111,11 @@ positions itself. `show_residual` only after confirming whether public removal is acceptable. Single-crystal behavior remains unchanged. 4. Add helper logic in `src/easydiffraction/display/plotting.py` to - extract Bragg tick sets from the future category when present. The - helper should group peaks by structure/phase id, filter x positions - to the displayed `x_min`/`x_max`, and build hover fields. If the - category is absent or empty, log a clear warning and render the Bragg - axis rectangle empty rather than silently failing. + extract Bragg tick sets from `experiment.refln`. The helper should + group rows by `phase_id`, select the x array matching the displayed + plot axis, filter x positions to the displayed `x_min`/`x_max`, and + build hover fields. If the category is absent or empty, render no + Bragg subplot rather than silently failing. 5. Route powder Bragg `plot_meas_vs_calc()` calls to a new backend method dedicated to this composite plot, such as `plot_powder_meas_vs_calc(...)`, instead of overloading the generic @@ -137,13 +136,13 @@ positions itself. exactly three residual-axis ticks (`min`, `0`, `max`) and do not add a separate colored zero line. 8. In the Bragg row, render one trace per structure/phase. Each peak - should appear as a vertical tick at its x position and at a - per-structure y row. Use a hover-capable trace representation, not - layout shapes, so hovering shows structure/phase, peak id if - available, hkl, x position, and intensity. Keep numeric y-axis labels - hidden or replace them with structure labels if that remains - readable. When no Bragg tick data exists, omit the Bragg subplot - entirely. + should appear as a vertical tick at its x position and at a + per-structure y row. Use a hover-capable trace representation, not + layout shapes, so hovering shows `phase_id`, hkl, x position, + `f_squared_calc`, and `f_calc`. Keep numeric y-axis labels hidden or + replace them with phase labels if that remains + readable. When no Bragg tick data exists, omit the Bragg subplot + entirely. 9. Make X synchronization explicit: zooming or panning the main pattern must update the Bragg tick row and residual row, and the shared x-axis range should start at the filtered data minimum and end at the @@ -169,7 +168,7 @@ positions itself. - `src/easydiffraction/display/plotting.py` — public `Plotter.plot_meas_vs_calc()`, `_plot_meas_vs_calc_data()`, and new - helper for consuming future peak-position category data. + helper for consuming `experiment.refln` data. - `src/easydiffraction/display/plotters/base.py` — `PlotterBase`, shared plotting DTO/constants, and backend method contract. - `src/easydiffraction/display/plotters/plotly.py` — Plotly @@ -179,12 +178,11 @@ positions itself. implementation for the new composite plot method. - `src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py` — reference pattern for array-like powder data category behavior; not - modified unless the peak-position category lands in the same - implementation cycle. + modified unless reflection-category population needs coordinated plot + updates. - `src/easydiffraction/datablocks/experiment/item/base.py` and - `src/easydiffraction/datablocks/experiment/item/bragg_pd.py` — future - category wiring reference if the peak-position category is implemented - in this change. + `src/easydiffraction/datablocks/experiment/item/bragg_pd.py` — + experiment-category wiring reference for `experiment.refln`. - `docs/docs/tutorials/ed-2.py` — tutorial source to update; `docs/docs/tutorials/ed-2.ipynb` is generated by `pixi run notebook-prepare`. @@ -206,13 +204,13 @@ positions itself. 2. Unit-test the Plotly backend by monkeypatching Plotly figure/subplot creation or inspecting a real figure object before display. Verify three rows, matched x axes, residual row height fraction 0.25 - relative to main, hover templates include hkl and intensity, and one - Bragg tick trace exists per structure. + relative to main, hover templates include hkl, `f_squared_calc`, and + `f_calc`, and one Bragg tick trace exists per phase. 3. Unit-test the ASCII fallback to ensure calls do not fail and the user gets a clear note about graphical-only Bragg tick rows. -4. After the future peak-position category exists, add category/unit - round-trip tests for peak x positions, h/k/l, intensity, and - structure id, plus an integration plotting test using +4. After the powder reflection category exists, add category/unit + round-trip tests for peak x positions, h/k/l, `f_squared_calc`, + `f_calc`, and `phase_id`, plus an integration plotting test using `lbco_fitted_project`. 5. Phase 2 commands: `pixi run unit-tests tests/unit/easydiffraction/display/`, @@ -234,7 +232,8 @@ positions itself. intentionally independent. - Bragg tick y positions are arbitrary row offsets used only to separate structures/phases. -- Tick hover text must show peak identity/hkl and intensity. +- Tick hover text must show `phase_id`, hkl, `f_squared_calc`, and + `f_calc`. **Further Considerations** @@ -242,11 +241,11 @@ positions itself. remove `show_residual`, change its default to true, or keep it as a compatibility option. Removing it is a public API change and should be explicit. -2. Peak-position category naming: recommended names are `bragg_peaks` - for the category and `structure_id` or `phase_id` for grouping. This - should be finalized when that category is implemented. -3. Coordinate contract: the category should expose peak x positions in - the same coordinate as `experiment.data.x`; if multiple x axes are - later supported, it should expose enough metadata or arrays to match +2. The implemented plotting contract now uses `experiment.refln` and + groups rows by `phase_id`. Revisit only if the experiment-side + category is renamed in future work. +3. Coordinate contract: the category should expose reflection x + positions in the same coordinate as `experiment.data.x`; if multiple + x axes are supported, it should expose enough arrays to match `x='two_theta'`, `x='time_of_flight'`, or `x='d_spacing'` unambiguously. From 27bc9449dc73c2c1f4b31b3a67c73c27f1c14408 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 10:40:34 +0200 Subject: [PATCH 10/26] Update powder Refln implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/dev/plan_powder-refln-category.md | 58 ++++++++++++++++---------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/docs/dev/plan_powder-refln-category.md b/docs/dev/plan_powder-refln-category.md index fc5511983..31297a913 100644 --- a/docs/dev/plan_powder-refln-category.md +++ b/docs/dev/plan_powder-refln-category.md @@ -12,9 +12,9 @@ Bragg ticks in the existing three-panel powder plot. ## Status - [x] Record design decisions below. -- [ ] Create and switch to branch `feature/powder-refln-category`. -- [ ] Phase 1: implement code and documentation changes only. -- [ ] Stop for user review after Phase 1 is complete. +- [x] Create and switch to branch `feature/powder-refln-category`. +- [x] Phase 1: implement code and documentation changes only. +- [x] Stop for user review after Phase 1 is complete. - [ ] Phase 2: add/update tests and run verification commands after user approval. - [ ] Final clean-up: update this plan, confirm commits, and summarize @@ -39,34 +39,34 @@ Bragg ticks in the existing three-panel powder plot. ### Phase 1 — Implementation -- [ ] Step 1: Create branch `feature/powder-refln-category` from the +- [x] Step 1: Create branch `feature/powder-refln-category` from the current working branch. - Suggested commit: no commit; branch setup only. -- [ ] Step 2: Add the powder `Refln` item and collection, including + Suggested commit: no commit; branch setup only. +- [x] Step 2: Add the powder `Refln` item and collection, including `phase_id`, `f_calc`, `f_squared_calc`, beam-mode x fields, array accessors, and `_replace_from_records(...)`. - Suggested commit: `Add powder Refln category` -- [ ] Step 3: Wire `experiment.refln` into `BraggPdExperiment` as a + Suggested commit: `Add powder Refln category` +- [x] Step 3: Wire `experiment.refln` into `BraggPdExperiment` as a read-only sibling category and include it in CIF serialization. - Suggested commit: `Wire powder Refln into Bragg experiments` -- [ ] Step 4: Add calculator-side reflection record extraction for + Suggested commit: `Wire powder Refln into Bragg experiments` +- [x] Step 4: Add calculator-side reflection record extraction for Cryspy, reusing values from the powder pattern calculation output when available. - Suggested commit: `Extract Cryspy powder reflection records` -- [ ] Step 5: Update `PdDataBase._update()` so every calculation clears + Suggested commit: `Extract Cryspy powder reflection records` +- [x] Step 5: Update `PdDataBase._update()` so every calculation clears and repopulates `experiment.refln` in sync with `data.intensity_calc`. - Suggested commit: `Populate powder Refln during calculation` -- [ ] Step 6: Update plotting to consume `experiment.refln`, group by + Suggested commit: `Populate powder Refln during calculation` +- [x] Step 6: Update plotting to consume `experiment.refln`, group by `phase_id`, use the selected x-axis space, and show `phase_id`, `(index_h index_k index_l)`, `f_squared_calc`, and `f_calc` in Bragg tick hover content. - Suggested commit: `Use powder Refln for Bragg ticks` -- [ ] Step 7: Update documentation and any affected developer plans so + Suggested commit: `Use powder Refln for Bragg ticks` +- [x] Step 7: Update documentation and any affected developer plans so they refer to `experiment.refln` and `phase_id`. - Suggested commit: `Document powder Refln Bragg ticks` -- [ ] Step 8: Review Phase 1 diffs, update this checklist, and stop for + Suggested commit: `Document powder Refln Bragg ticks` +- [x] Step 8: Review Phase 1 diffs, update this checklist, and stop for user review before adding tests or running verification. - Suggested commit: `Update powder Refln implementation plan` + Suggested commit: `Update powder Refln implementation plan` ### Phase 2 — Verification @@ -366,11 +366,25 @@ pixi run script-tests 9. Rename display internals from `structure_id` to `phase_id` for consistency with the category. 10. Use global sequential reflection row ids for now. Add a future note - to reconsider phase-prefixed ids if debugging or CIF inspection - would benefit. + to reconsider phase-prefixed ids if debugging or CIF inspection + would benefit. + +## Phase 1 Outcome + +- Completed implementation commits: + `8a3c24d71`, `97e70268d`, `dfb380da9`, `d92dc6f90`, `b5d55e38a`. +- `experiment.refln` now exists on Bragg powder experiments, is updated + together with `data.intensity_calc`, and drives Bragg tick extraction + through `phase_id`, h/k/l, `f_squared_calc`, `f_calc`, and the active + powder x coordinate. +- Cryspy populates reflection rows from the same powder-pattern + calculation payload. Backends without reflection metadata currently + clear `experiment.refln` and warn instead of leaving stale rows. +- Phase 2 remains pending: focused tests, formatting, linting, unit + tests, integration tests, script tests, and final risk review. ## Suggested Commit Message ```text -Record powder Refln category decisions +Update powder Refln implementation plan ``` From 1fd65b5b08537bdc99e379ea264e61bd5c2c88f7 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 11:27:37 +0200 Subject: [PATCH 11/26] Fix powder Refln CWL tick units Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/easydiffraction/analysis/calculators/cryspy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index e3cace33c..4faa02428 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -292,7 +292,7 @@ def last_powder_refln_records( x_raw = phase_block.get('ttheta_hkl') if x_raw is None: return None - x_values = np.asarray(x_raw, dtype=float) + x_values = np.degrees(np.asarray(x_raw, dtype=float)) elif beam_mode == BeamModeEnum.TIME_OF_FLIGHT: x_raw = phase_block.get('time_hkl') if x_raw is None: From bf7328c0d4e671be1bdfeb57e58d2c8650d8e9e7 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 11:37:56 +0200 Subject: [PATCH 12/26] Fix powder Refln d-spacing ticks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/easydiffraction/display/plotting.py | 33 ++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index bbabde869..4f04f3acd 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -34,6 +34,8 @@ from easydiffraction.utils.environment import in_jupyter from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log +from easydiffraction.utils.utils import tof_to_d +from easydiffraction.utils.utils import twotheta_to_d class PlotterEngineEnum(StrEnum): @@ -192,7 +194,9 @@ def _filtered_y_array( if x_max is None: x_max = self.x_max - mask = (x_array >= x_min) & (x_array <= x_max) + lower_bound = min(x_min, x_max) + upper_bound = max(x_min, x_max) + mask = (x_array >= lower_bound) & (x_array <= upper_bound) return y_array[mask] @staticmethod @@ -1319,7 +1323,7 @@ def _extract_bragg_tick_sets( x_name = getattr(x_axis, 'value', x_axis) if x_name == XAxisType.D_SPACING: - x_values = refln.d_spacing + x_values = Plotter._bragg_tick_d_spacing(refln=refln, experiment=experiment) elif x_name == XAxisType.TWO_THETA: x_values = getattr(refln, 'two_theta', None) elif x_name == XAxisType.TIME_OF_FLIGHT: @@ -1361,8 +1365,8 @@ def _extract_bragg_tick_sets( if arrays['x'].size == 0: return () - lower_bound = DEFAULT_MIN if x_min is None else x_min - upper_bound = DEFAULT_MAX if x_max is None else x_max + lower_bound = DEFAULT_MIN if x_min is None else min(x_min, x_max) + upper_bound = DEFAULT_MAX if x_max is None else max(x_min, x_max) mask = (arrays['x'] >= lower_bound) & (arrays['x'] <= upper_bound) if not np.any(mask): return () @@ -1393,6 +1397,27 @@ def _extract_bragg_tick_sets( return tuple(tick_sets) + @staticmethod + def _bragg_tick_d_spacing( + *, + refln: object, + experiment: object, + ) -> object: + """Resolve Bragg tick d-spacing in the plotted coordinate system.""" + if hasattr(refln, 'two_theta'): + return twotheta_to_d( + refln.two_theta, + experiment.instrument.setup_wavelength.value, + ) + if hasattr(refln, 'time_of_flight'): + return tof_to_d( + refln.time_of_flight, + experiment.instrument.calib_d_to_tof_offset.value, + experiment.instrument.calib_d_to_tof_linear.value, + experiment.instrument.calib_d_to_tof_quad.value, + ) + return refln.d_spacing + def _plot_param_series_from_csv( self, csv_path: str, From 1610aa1cb338352ca6ec280eef8e9497d36065dc Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 12:00:52 +0200 Subject: [PATCH 13/26] Scale Bragg panel height with the number of phases --- .../display/plotters/plotly.py | 40 ++++++++++++++++--- src/easydiffraction/display/plotting.py | 2 +- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index 1b2448193..d255093dd 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -655,8 +655,8 @@ def _get_bragg_tick_trace( mode='markers', marker={ 'symbol': 'line-ns-open', - 'size': 18, - 'line': {'color': color, 'width': 2}, + 'size': 12, + 'line': {'color': color, 'width': 1}, 'color': color, }, name=f'Bragg ({tick_set.phase_id})', @@ -695,6 +695,36 @@ def _get_display_tick_limit(raw_limit: float) -> float: return nice_fraction * base return DISPLAY_TICK_FRACTIONS[0] * base + @staticmethod + def _scaled_bragg_row_height(plot_spec: PowderMeasVsCalcSpec) -> float: + """ + Return Bragg-row height that preserves single-phase row spacing. + + ``plot_spec.bragg_peaks_height_fraction`` is treated as the + single-phase baseline. For multiple phases, the total Bragg-row + height grows so each phase keeps the same vertical space as in + the one-phase case. + """ + phase_count = len(plot_spec.bragg_tick_sets) + if phase_count == 0: + return 0.0 + + base_height = plot_spec.bragg_peaks_height_fraction + other_height = 1.0 + if plot_spec.y_resid is not None: + other_height += plot_spec.residual_height_fraction + + single_phase_normalized_height = base_height / (other_height + base_height) + target_bragg_normalized_height = phase_count * single_phase_normalized_height + + if target_bragg_normalized_height >= 1.0: + return base_height * phase_count + + return ( + target_bragg_normalized_height * other_height + / (1.0 - target_bragg_normalized_height) + ) + @staticmethod def _get_powder_composite_rows(plot_spec: PowderMeasVsCalcSpec) -> PowderCompositeRows: """Resolve subplot rows for the composite powder figure.""" @@ -708,7 +738,7 @@ def _get_powder_composite_rows(plot_spec: PowderMeasVsCalcSpec) -> PowderComposi if has_bragg_ticks: bragg_row = next_row next_row += 1 - row_heights.append(plot_spec.bragg_peaks_height_fraction) + row_heights.append(PlotlyPlotter._scaled_bragg_row_height(plot_spec)) if has_residual: residual_row = next_row row_heights.append(plot_spec.residual_height_fraction) @@ -835,7 +865,7 @@ def plot_powder_meas_vs_calc( if layout.bragg_row is not None: fig.update_yaxes( - title_text='Bragg peaks', + #title_text='Bragg peaks', tickmode='array', tickvals=[float(idx + 1) for idx in range(len(plot_spec.bragg_tick_sets))], ticktext=[tick_set.phase_id for tick_set in plot_spec.bragg_tick_sets], @@ -853,7 +883,7 @@ def plot_powder_meas_vs_calc( if layout.residual_row is not None and plot_spec.y_resid is not None: residual_tick_limit = self._get_display_tick_limit(residual_limit) fig.update_yaxes( - title_text='Residual', + #title_text='Residual', range=[-residual_limit, residual_limit], tickmode='array', tickvals=[-residual_tick_limit, 0.0, residual_tick_limit], diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 4f04f3acd..b5747da80 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -65,7 +65,7 @@ def description(self) -> str: DEFAULT_CORRELATION_THRESHOLD = 0.7 EXPECTED_COVAR_NDIM = 2 DEFAULT_RESIDUAL_HEIGHT_FRACTION = 0.25 -DEFAULT_BRAGG_PEAKS_HEIGHT_FRACTION = 0.15 +DEFAULT_BRAGG_PEAKS_HEIGHT_FRACTION = 0.10 DEFAULT_RESID_HEIGHT = DEFAULT_RESIDUAL_HEIGHT_FRACTION DEFAULT_BRAGG_ROW = DEFAULT_BRAGG_PEAKS_HEIGHT_FRACTION From 6b3bd7c33d4f041e99dc9c61800fc112d6ba02bf Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 12:10:48 +0200 Subject: [PATCH 14/26] Add powder Refln verification tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../experiment/categories/data/bragg_pd.py | 1 + .../analysis/calculators/test_cryspy.py | 65 +++++++- .../categories/data/test_refln_pd.py | 134 ++++++++++++++++ .../experiment/item/test_bragg_pd.py | 109 +++++++++++++ .../display/plotters/test_ascii.py | 5 +- .../display/plotters/test_plotly.py | 147 +++++++++++++----- .../easydiffraction/display/test_plotting.py | 139 +++++++++++------ 7 files changed, 506 insertions(+), 94 deletions(-) create mode 100644 tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py index c7f18de1c..ae8b2aad7 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py @@ -24,6 +24,7 @@ from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum from easydiffraction.io.cif.handler import CifHandler +from easydiffraction.utils.logging import log from easydiffraction.utils.utils import tof_to_d from easydiffraction.utils.utils import twotheta_to_d diff --git a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py index 178faab59..5fdda3ee4 100644 --- a/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py +++ b/tests/unit/easydiffraction/analysis/calculators/test_cryspy.py @@ -3,6 +3,9 @@ from types import SimpleNamespace +import numpy as np +import pytest + def test_module_import(): import easydiffraction.analysis.calculators.cryspy as MUT @@ -81,9 +84,6 @@ def test_update_structure_zeroes_biso_for_anisotropic_atoms(): def test_update_structure_restores_wyckoff_multiplicity_after_coordinate_wrapping(): - import numpy as np - import pytest - pytest.importorskip('cryspy') from easydiffraction.analysis.calculators.cryspy import CryspyCalculator @@ -114,3 +114,62 @@ def test_update_structure_restores_wyckoff_multiplicity_after_coordinate_wrappin assert cryspy_model_dict['atom_fract_xyz'][1][0] == -0.20587714 assert cryspy_model_dict['atom_multiplicity'][0] == 18 + + +def test_last_powder_refln_records_converts_cwl_two_theta_to_degrees(): + from easydiffraction.analysis.calculators.cryspy import CryspyCalculator + from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum + + calculator = CryspyCalculator() + calculator._last_powder_phase_blocks = { + 'phase_exp': { + 'index_hkl': np.array([[1], [0], [1]]), + 'sthovl': np.array([0.25]), + 'ttheta_hkl': np.array([np.pi / 2]), + 'f_nucl': np.array([-3.0 + 4.0j]), + } + } + structure = SimpleNamespace(name='phase') + experiment = SimpleNamespace( + name='exp', + type=SimpleNamespace(beam_mode=SimpleNamespace(value=BeamModeEnum.CONSTANT_WAVELENGTH)), + ) + + records = calculator.last_powder_refln_records(structure, experiment, phase_id='phase-a') + + assert len(records) == 1 + assert records[0].phase_id == 'phase-a' + assert records[0].two_theta == pytest.approx(90.0) + assert records[0].d_spacing == pytest.approx(2.0) + assert records[0].f_calc == pytest.approx(5.0) + assert records[0].f_squared_calc == pytest.approx(25.0) + + +def test_last_powder_refln_records_reads_tof_time_and_d_spacing(): + from easydiffraction.analysis.calculators.cryspy import CryspyCalculator + from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum + + calculator = CryspyCalculator() + calculator._last_powder_phase_blocks = { + 'phase_exp': { + 'index_hkl': np.array([[2], [1], [0]]), + 'sthovl': np.array([0.1]), + 'd_hkl': np.array([3.21]), + 'time_hkl': np.array([1234.0]), + 'f_nucl': np.array([6.0 + 0.0j]), + } + } + structure = SimpleNamespace(name='phase') + experiment = SimpleNamespace( + name='exp', + type=SimpleNamespace(beam_mode=SimpleNamespace(value=BeamModeEnum.TIME_OF_FLIGHT)), + ) + + records = calculator.last_powder_refln_records(structure, experiment, phase_id='phase-b') + + assert len(records) == 1 + assert records[0].phase_id == 'phase-b' + assert records[0].time_of_flight == pytest.approx(1234.0) + assert records[0].d_spacing == pytest.approx(3.21) + assert records[0].f_calc == pytest.approx(6.0) + assert records[0].f_squared_calc == pytest.approx(36.0) diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py new file mode 100644 index 000000000..1b762821b --- /dev/null +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py @@ -0,0 +1,134 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import numpy as np + +from easydiffraction.analysis.calculators.base import PowderReflnRecord +from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory + + +def test_powder_cwl_refln_defaults(): + from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlRefln + + refln = PowderCwlRefln() + + assert refln.id.value == '0' + assert refln.phase_id.value == '' + assert refln.two_theta.value == 0.0 + assert refln.d_spacing.value == 0.0 + assert refln.f_calc.value == 0.0 + assert refln.f_squared_calc.value == 0.0 + assert refln._identity.category_code == 'refln' + + +def test_powder_cwl_refln_data_replace_from_records_sets_arrays(): + from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData + + refln = PowderCwlReflnData() + refln._replace_from_records( + [ + PowderReflnRecord( + phase_id='alpha', + d_spacing=2.1, + sin_theta_over_lambda=0.25, + index_h=1, + index_k=0, + index_l=1, + f_calc=3.0, + f_squared_calc=9.0, + two_theta=14.5, + ), + PowderReflnRecord( + phase_id='beta', + d_spacing=1.5, + sin_theta_over_lambda=0.33, + index_h=2, + index_k=1, + index_l=0, + f_calc=4.0, + f_squared_calc=16.0, + two_theta=22.0, + ), + ] + ) + + assert [item.id.value for item in refln._items] == ['1', '2'] + np.testing.assert_array_equal(refln.phase_id, np.array(['alpha', 'beta'])) + np.testing.assert_allclose(refln.d_spacing, np.array([2.1, 1.5])) + np.testing.assert_allclose(refln.two_theta, np.array([14.5, 22.0])) + np.testing.assert_allclose(refln.f_calc, np.array([3.0, 4.0])) + np.testing.assert_allclose(refln.f_squared_calc, np.array([9.0, 16.0])) + + +def test_powder_tof_refln_data_replace_from_records_sets_arrays(): + from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData + + refln = PowderTofReflnData() + refln._replace_from_records( + [ + PowderReflnRecord( + phase_id='gamma', + d_spacing=3.2, + sin_theta_over_lambda=0.15, + index_h=1, + index_k=1, + index_l=0, + f_calc=5.0, + f_squared_calc=25.0, + time_of_flight=1200.0, + ) + ] + ) + + assert [item.id.value for item in refln._items] == ['1'] + np.testing.assert_array_equal(refln.phase_id, np.array(['gamma'])) + np.testing.assert_allclose(refln.time_of_flight, np.array([1200.0])) + np.testing.assert_allclose(refln.d_spacing, np.array([3.2])) + + +def test_powder_refln_round_trips_via_experiment_cif(): + experiment = ExperimentFactory.from_scratch( + name='powder', + sample_form='powder', + beam_mode='constant wavelength', + radiation_probe='neutron', + scattering_type='bragg', + ) + experiment.refln._replace_from_records( + [ + PowderReflnRecord( + phase_id='alpha', + d_spacing=2.1, + sin_theta_over_lambda=0.25, + index_h=1, + index_k=0, + index_l=1, + f_calc=3.0, + f_squared_calc=9.0, + two_theta=14.5, + ), + PowderReflnRecord( + phase_id='beta', + d_spacing=1.5, + sin_theta_over_lambda=0.33, + index_h=2, + index_k=1, + index_l=0, + f_calc=4.0, + f_squared_calc=16.0, + two_theta=22.0, + ), + ] + ) + experiment._need_categories_update = False + + cif = experiment.as_cif + loaded = ExperimentFactory.from_cif_str(cif) + + assert '_refln.phase_id' in cif + assert '_refln.f_calc' in cif + assert '_refln.f_squared_calc' in cif + np.testing.assert_array_equal(loaded.refln.phase_id, np.array(['alpha', 'beta'])) + np.testing.assert_allclose(loaded.refln.two_theta, np.array([14.5, 22.0])) + np.testing.assert_allclose(loaded.refln.f_calc, np.array([3.0, 4.0])) + np.testing.assert_allclose(loaded.refln.f_squared_calc, np.array([9.0, 16.0])) diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py index 57a610f3d..9ba106554 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py @@ -4,7 +4,10 @@ import numpy as np import pytest +from easydiffraction.analysis.calculators.base import PowderReflnRecord from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory +from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData +from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType from easydiffraction.datablocks.experiment.item.bragg_pd import BraggPdExperiment from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum @@ -22,6 +25,15 @@ def _mk_type_powder_cwl_bragg(): return et +def _mk_type_powder_tof_bragg(): + et = ExperimentType() + et._set_sample_form(SampleFormEnum.POWDER.value) + et._set_beam_mode(BeamModeEnum.TIME_OF_FLIGHT.value) + et._set_radiation_probe(RadiationProbeEnum.NEUTRON.value) + et._set_scattering_type(ScatteringTypeEnum.BRAGG.value) + return et + + def test_background_defaults_and_change(): expt = BraggPdExperiment(name='e1', type=_mk_type_powder_cwl_bragg()) # default background type @@ -71,3 +83,100 @@ def test_load_ascii_data_rounds_and_defaults_sy(tmp_path: pytest.TempPathFactory np.savetxt(pinv, np.ones((5, 1))) with pytest.raises(IndexError, match='tuple index out of range'): expt._load_ascii_data_to_experiment(str(pinv)) + + +def test_bragg_pd_experiment_creates_beam_mode_specific_refln_collection(): + cwl_experiment = BraggPdExperiment(name='cwl', type=_mk_type_powder_cwl_bragg()) + tof_experiment = BraggPdExperiment(name='tof', type=_mk_type_powder_tof_bragg()) + + assert isinstance(cwl_experiment.refln, PowderCwlReflnData) + assert isinstance(tof_experiment.refln, PowderTofReflnData) + + +def test_pd_data_update_populates_and_clears_refln(): + class FakeStructures(dict): + @property + def names(self): + return list(self.keys()) + + class FakeCalculator: + name = 'fake' + + def __init__(self): + self.return_records = True + + def calculate_pattern(self, structure, experiment, *, called_by_minimizer=False): + del experiment, called_by_minimizer + return structure.pattern + + def last_powder_refln_records(self, structure, experiment, *, phase_id): + del experiment, phase_id + if not self.return_records: + return None + return structure.records + + class FakeStructure: + def __init__(self, name, pattern, records): + self.name = name + self.pattern = pattern + self.records = records + + experiment = BraggPdExperiment(name='powder', type=_mk_type_powder_cwl_bragg()) + experiment.linked_phases.create(id='phase_a', scale=2.0) + experiment.linked_phases.create(id='phase_b', scale=3.0) + experiment.data._create_items_set_xcoord_and_id(np.array([10.0, 20.0, 30.0])) + experiment.data._set_intensity_meas(np.array([100.0, 110.0, 120.0])) + + structures = FakeStructures( + { + 'phase_a': FakeStructure( + 'phase_a', + np.array([1.0, 2.0, 3.0]), + [ + PowderReflnRecord( + phase_id='phase_a', + d_spacing=2.1, + sin_theta_over_lambda=0.25, + index_h=1, + index_k=0, + index_l=1, + f_calc=3.0, + f_squared_calc=9.0, + two_theta=14.5, + ) + ], + ), + 'phase_b': FakeStructure( + 'phase_b', + np.array([4.0, 5.0, 6.0]), + [ + PowderReflnRecord( + phase_id='phase_b', + d_spacing=1.8, + sin_theta_over_lambda=0.28, + index_h=2, + index_k=1, + index_l=0, + f_calc=4.0, + f_squared_calc=16.0, + two_theta=18.5, + ) + ], + ), + } + ) + project = type('Project', (), {'structures': structures})() + experiments = type('Experiments', (), {'_parent': project})() + experiment._parent = experiments + experiment._calculator = FakeCalculator() + + experiment.data._update() + + np.testing.assert_allclose(experiment.data.intensity_calc, np.array([14.0, 19.0, 24.0])) + np.testing.assert_array_equal(experiment.refln.phase_id, np.array(['phase_a', 'phase_b'])) + np.testing.assert_allclose(experiment.refln.two_theta, np.array([14.5, 18.5])) + + experiment._calculator.return_records = False + experiment.data._update() + + assert len(experiment.refln._items) == 0 diff --git a/tests/unit/easydiffraction/display/plotters/test_ascii.py b/tests/unit/easydiffraction/display/plotters/test_ascii.py index b20853592..49dd76537 100644 --- a/tests/unit/easydiffraction/display/plotters/test_ascii.py +++ b/tests/unit/easydiffraction/display/plotters/test_ascii.py @@ -64,12 +64,13 @@ def test_ascii_plotter_plot_powder_meas_vs_calc_announces_plotly_only_bragg_row( y_resid=np.array([0.5, -0.5, 1.0]), bragg_tick_sets=( BraggTickSet( - structure_id='phase-a', + phase_id='phase-a', x=np.array([0.5]), h=np.array([1]), k=np.array([0]), ell=np.array([1]), - intensity=np.array([100.0]), + f_squared_calc=np.array([100.0]), + f_calc=np.array([10.0]), ), ), axes_labels=['2θ (degree)', 'Intensity (arb. units)'], diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 6bc34756c..0fe15131f 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -220,13 +220,13 @@ def test_get_bragg_tick_trace_includes_peak_metadata(): trace = PlotlyPlotter._get_bragg_tick_trace( tick_set=BraggTickSet( - structure_id='phase-a', + phase_id='phase-a', x=np.array([1.5, 2.5]), h=np.array([1, 2]), k=np.array([0, 1]), ell=np.array([1, 0]), - intensity=np.array([100.0, 80.0]), - peak_id=np.array(['p1', 'p2']), + f_squared_calc=np.array([100.0, 80.0]), + f_calc=np.array([10.0, 9.0]), ), row_y=2.0, color='#123456', @@ -237,9 +237,10 @@ def test_get_bragg_tick_trace_includes_peak_metadata(): assert trace.mode == 'markers' assert trace.marker.symbol == 'line-ns-open' assert trace.hovertemplate == '%{text}' - assert 'peak: p1' in trace.text[0] + assert 'phase_id: phase-a' in trace.text[0] assert 'hkl: (1 0 1)' in trace.text[0] - assert 'intensity: 100' in trace.text[0] + assert 'f_squared_calc: 100' in trace.text[0] + assert 'f_calc: 10' in trace.text[0] def test_plot_powder_meas_vs_calc_creates_synced_three_panel_figure(monkeypatch): @@ -255,41 +256,41 @@ def fake_show_figure(self, fig): monkeypatch.setattr(pp.PlotlyPlotter, '_show_figure', fake_show_figure) - plotter = pp.PlotlyPlotter() - plotter.plot_powder_meas_vs_calc( - plot_spec=PowderMeasVsCalcSpec( - x=np.array([1.0, 2.0, 3.0]), - y_meas=np.array([10.0, 12.0, 11.0]), - y_calc=np.array([9.0, 11.0, 10.5]), - y_resid=np.array([1.0, 1.0, 0.5]), - bragg_tick_sets=( - BraggTickSet( - structure_id='phase-a', - x=np.array([1.5]), - h=np.array([1]), - k=np.array([0]), - ell=np.array([1]), - intensity=np.array([100.0]), - peak_id=np.array(['p1']), - ), - BraggTickSet( - structure_id='phase-b', - x=np.array([2.5]), - h=np.array([2]), - k=np.array([1]), - ell=np.array([0]), - intensity=np.array([80.0]), - peak_id=np.array(['p2']), - ), + plot_spec = PowderMeasVsCalcSpec( + x=np.array([1.0, 2.0, 3.0]), + y_meas=np.array([10.0, 12.0, 11.0]), + y_calc=np.array([9.0, 11.0, 10.5]), + y_resid=np.array([1.0, 1.0, 0.5]), + bragg_tick_sets=( + BraggTickSet( + phase_id='phase-a', + x=np.array([1.5]), + h=np.array([1]), + k=np.array([0]), + ell=np.array([1]), + f_squared_calc=np.array([100.0]), + f_calc=np.array([10.0]), + ), + BraggTickSet( + phase_id='phase-b', + x=np.array([2.5]), + h=np.array([2]), + k=np.array([1]), + ell=np.array([0]), + f_squared_calc=np.array([80.0]), + f_calc=np.array([9.0]), ), - axes_labels=['2θ (degree)', 'Intensity (arb. units)'], - title='Powder', - residual_height_fraction=0.25, - bragg_peaks_height_fraction=0.15, - height=None, ), + axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + title='Powder', + residual_height_fraction=0.25, + bragg_peaks_height_fraction=0.10, + height=None, ) + plotter = pp.PlotlyPlotter() + plotter.plot_powder_meas_vs_calc(plot_spec=plot_spec) + fig = captured['fig'] assert len(fig.data) == 5 assert fig.layout.xaxis.matches == 'x' @@ -299,18 +300,82 @@ def fake_show_figure(self, fig): main_height = fig.layout.yaxis.domain[1] - fig.layout.yaxis.domain[0] bragg_height = fig.layout.yaxis2.domain[1] - fig.layout.yaxis2.domain[0] residual_height = fig.layout.yaxis3.domain[1] - fig.layout.yaxis3.domain[0] - assert bragg_height == pytest.approx(main_height * 0.15) assert residual_height == pytest.approx(main_height * 0.25) + assert bragg_height == pytest.approx( + main_height * pp.PlotlyPlotter._scaled_bragg_row_height(plot_spec) + ) bragg_traces = [trace for trace in fig.data if trace.name.startswith('Bragg')] assert [trace.name for trace in bragg_traces] == ['Bragg (phase-a)', 'Bragg (phase-b)'] - assert fig.layout.yaxis2.title.text == 'Bragg peaks' assert list(fig.layout.yaxis2.ticktext) == ['phase-a', 'phase-b'] - assert fig.layout.yaxis3.title.text == 'Residual' + assert fig.layout.yaxis2.title.text is None + assert fig.layout.yaxis3.title.text is None assert fig.layout.yaxis3.zeroline is False assert fig.layout.xaxis3.title.text == '2θ (degree)' assert 'hkl: (1 0 1)' in bragg_traces[0].text[0] - assert 'intensity: 100' in bragg_traces[0].text[0] + assert 'f_squared_calc: 100' in bragg_traces[0].text[0] + + +def test_scaled_bragg_row_height_preserves_single_phase_baseline(): + from easydiffraction.display.plotters.base import BraggTickSet + from easydiffraction.display.plotters.base import PowderMeasVsCalcSpec + from easydiffraction.display.plotters.plotly import PlotlyPlotter + + single_phase = PowderMeasVsCalcSpec( + x=np.array([1.0, 2.0]), + y_meas=np.array([1.0, 2.0]), + y_calc=np.array([1.0, 2.0]), + y_resid=np.array([0.0, 0.0]), + bragg_tick_sets=( + BraggTickSet( + phase_id='phase-a', + x=np.array([1.5]), + h=np.array([1]), + k=np.array([0]), + ell=np.array([1]), + f_squared_calc=np.array([100.0]), + f_calc=np.array([10.0]), + ), + ), + axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + title='Powder', + residual_height_fraction=0.25, + bragg_peaks_height_fraction=0.10, + height=None, + ) + two_phase = PowderMeasVsCalcSpec( + x=single_phase.x, + y_meas=single_phase.y_meas, + y_calc=single_phase.y_calc, + y_resid=single_phase.y_resid, + bragg_tick_sets=( + single_phase.bragg_tick_sets[0], + BraggTickSet( + phase_id='phase-b', + x=np.array([2.5]), + h=np.array([2]), + k=np.array([1]), + ell=np.array([0]), + f_squared_calc=np.array([80.0]), + f_calc=np.array([9.0]), + ), + ), + axes_labels=single_phase.axes_labels, + title=single_phase.title, + residual_height_fraction=single_phase.residual_height_fraction, + bragg_peaks_height_fraction=single_phase.bragg_peaks_height_fraction, + height=single_phase.height, + ) + + single_height = PlotlyPlotter._scaled_bragg_row_height(single_phase) + two_phase_height = PlotlyPlotter._scaled_bragg_row_height(two_phase) + + single_phase_normalized = single_height / ( + 1.0 + single_phase.residual_height_fraction + single_height + ) + two_phase_normalized_per_phase = (two_phase_height / (1.0 + two_phase.residual_height_fraction + two_phase_height)) / 2 + + assert two_phase_normalized_per_phase == pytest.approx(single_phase_normalized) def test_plot_powder_meas_vs_calc_skips_bragg_row_when_no_ticks(monkeypatch): @@ -345,7 +410,7 @@ def fake_show_figure(self, fig): assert len(fig.data) == 3 assert fig.layout.xaxis.matches == 'x' assert fig.layout.xaxis2.matches == 'x' - assert fig.layout.yaxis2.title.text == 'Residual' + assert fig.layout.yaxis2.title.text is None assert fig.layout.xaxis2.title.text == '2θ (degree)' assert [trace.name for trace in fig.data] == [ 'Measured (Imeas)', diff --git a/tests/unit/easydiffraction/display/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py index 7ff4ca39b..e1cde85ab 100644 --- a/tests/unit/easydiffraction/display/test_plotting.py +++ b/tests/unit/easydiffraction/display/test_plotting.py @@ -183,37 +183,41 @@ def test_extract_bragg_tick_sets_groups_and_filters(): import numpy as np from easydiffraction.display.plotting import Plotter + from easydiffraction.display.plotting import XAxisType - class BraggPeaks: - structure_id = np.array(['phase-a', 'phase-a', 'phase-b', 'phase-b']) - x = np.array([0.5, 1.5, 2.5, 3.5]) - h = np.array([1, 2, 3, 4]) - k = np.array([0, 1, 1, 2]) - l = np.array([1, 0, 2, 1]) - intensity = np.array([10.0, 20.0, 30.0, 40.0]) - peak_id = np.array(['p0', 'p1', 'p2', 'p3']) + class Refln: + phase_id = np.array(['phase-a', 'phase-a', 'phase-b', 'phase-b']) + two_theta = np.array([0.5, 1.5, 2.5, 3.5]) + index_h = np.array([1, 2, 3, 4]) + index_k = np.array([0, 1, 1, 2]) + index_l = np.array([1, 0, 2, 1]) + f_squared_calc = np.array([10.0, 20.0, 30.0, 40.0]) + f_calc = np.array([3.0, 4.0, 5.0, 6.0]) class Experiment: - bragg_peaks = BraggPeaks() + refln = Refln() tick_sets = Plotter()._extract_bragg_tick_sets( experiment=Experiment(), expt_name='E1', + x_axis=XAxisType.TWO_THETA, x_min=1.0, x_max=3.0, ) - assert [tick_set.structure_id for tick_set in tick_sets] == ['phase-a', 'phase-b'] + assert [tick_set.phase_id for tick_set in tick_sets] == ['phase-a', 'phase-b'] assert np.allclose(tick_sets[0].x, np.array([1.5])) - assert np.array_equal(tick_sets[0].peak_id, np.array(['p1'])) + assert np.array_equal(tick_sets[0].h, np.array([2])) assert np.array_equal(tick_sets[1].h, np.array([3])) assert np.array_equal(tick_sets[1].k, np.array([1])) assert np.array_equal(tick_sets[1].ell, np.array([2])) - assert np.allclose(tick_sets[1].intensity, np.array([30.0])) + assert np.allclose(tick_sets[0].f_squared_calc, np.array([20.0])) + assert np.allclose(tick_sets[1].f_calc, np.array([5.0])) def test_extract_bragg_tick_sets_returns_empty_without_category(): from easydiffraction.display.plotting import Plotter + from easydiffraction.display.plotting import XAxisType class Experiment: pass @@ -221,6 +225,7 @@ class Experiment: tick_sets = Plotter()._extract_bragg_tick_sets( experiment=Experiment(), expt_name='E1', + x_axis=XAxisType.TWO_THETA, x_min=1.0, x_max=3.0, ) @@ -228,6 +233,41 @@ class Experiment: assert tick_sets == () +def test_extract_bragg_tick_sets_uses_derived_d_spacing_for_cwl_ticks(): + import numpy as np + + from easydiffraction.display.plotting import Plotter + from easydiffraction.display.plotting import XAxisType + from easydiffraction.utils.utils import twotheta_to_d + + class Refln: + phase_id = np.array(['phase-a']) + two_theta = np.array([20.0]) + d_spacing = np.array([999.0]) + index_h = np.array([1]) + index_k = np.array([0]) + index_l = np.array([1]) + f_squared_calc = np.array([10.0]) + f_calc = np.array([3.0]) + + class Instrument: + setup_wavelength = type('Wavelength', (), {'value': 1.0})() + + class Experiment: + refln = Refln() + instrument = Instrument() + + tick_sets = Plotter()._extract_bragg_tick_sets( + experiment=Experiment(), + expt_name='E1', + x_axis=XAxisType.D_SPACING, + x_min=0.1, + x_max=10.0, + ) + + np.testing.assert_allclose(tick_sets[0].x, twotheta_to_d(np.array([20.0]), 1.0)) + + def test_plot_meas_vs_calc_routes_powder_bragg_to_composite_backend(): import numpy as np @@ -252,14 +292,14 @@ class Pattern: intensity_meas = np.array([10.0, 20.0, 30.0, 40.0]) intensity_calc = np.array([9.0, 18.0, 27.0, 39.0]) - class BraggPeaks: - structure_id = np.array(['phase-a', 'phase-a', 'phase-b']) - x = np.array([0.5, 1.5, 2.0]) - h = np.array([1, 2, 3]) - k = np.array([0, 1, 1]) - l = np.array([1, 0, 2]) - intensity = np.array([100.0, 80.0, 60.0]) - peak_id = np.array(['p0', 'p1', 'p2']) + class Refln: + phase_id = np.array(['phase-a', 'phase-a', 'phase-b']) + two_theta = np.array([0.5, 1.5, 2.0]) + index_h = np.array([1, 2, 3]) + index_k = np.array([0, 1, 1]) + index_l = np.array([1, 0, 2]) + f_squared_calc = np.array([100.0, 80.0, 60.0]) + f_calc = np.array([10.0, 9.0, 8.0]) class ExptType: sample_form = type('SF', (), {'value': SampleFormEnum.POWDER})() @@ -269,7 +309,7 @@ class ExptType: class Experiment: data = Pattern() type = ExptType() - bragg_peaks = BraggPeaks() + refln = Refln() plotter = Plotter() plotter._backend = FakeBackend() @@ -286,7 +326,7 @@ class Experiment: assert np.allclose(call.y_meas, np.array([20.0, 30.0])) assert np.allclose(call.y_calc, np.array([18.0, 27.0])) assert np.allclose(call.y_resid, np.array([2.0, 3.0])) - assert [tick_set.structure_id for tick_set in call.bragg_tick_sets] == [ + assert [tick_set.phase_id for tick_set in call.bragg_tick_sets] == [ 'phase-a', 'phase-b', ] @@ -314,13 +354,14 @@ class Pattern: intensity_meas = np.array([100.0, 110.0, 105.0]) intensity_calc = np.array([99.0, 108.0, 104.0]) - class BraggPeaks: - structure_id = np.array(['phase-a']) - x = np.array([11.0]) - h = np.array([1]) - k = np.array([0]) - l = np.array([1]) - intensity = np.array([50.0]) + class Refln: + phase_id = np.array(['phase-a']) + time_of_flight = np.array([11.0]) + index_h = np.array([1]) + index_k = np.array([0]) + index_l = np.array([1]) + f_squared_calc = np.array([50.0]) + f_calc = np.array([7.0]) class ExptType: sample_form = type('SF', (), {'value': SampleFormEnum.POWDER})() @@ -330,7 +371,7 @@ class ExptType: class Experiment: data = Pattern() type = ExptType() - bragg_peaks = BraggPeaks() + refln = Refln() plotter = Plotter() plotter._backend = FakeBackend() @@ -342,7 +383,7 @@ class Experiment: call = captured['powder_meas_vs_calc'] assert np.allclose(call.x, np.array([10.0, 11.0, 12.0])) - assert [tick_set.structure_id for tick_set in call.bragg_tick_sets] == ['phase-a'] + assert [tick_set.phase_id for tick_set in call.bragg_tick_sets] == ['phase-a'] assert np.allclose(call.bragg_tick_sets[0].x, np.array([11.0])) @@ -366,13 +407,14 @@ class Pattern: intensity_meas = np.array([100.0, 110.0, 105.0]) intensity_calc = np.array([99.0, 108.0, 104.0]) - class BraggPeaks: - structure_id = np.array([1, 1, 2]) - x = np.array([10.0, 11.0, 12.0]) - h = np.array([1, 2, 3]) - k = np.array([0, 1, 1]) - l = np.array([1, 0, 2]) - intensity = np.array([50.0, 40.0, 30.0]) + class Refln: + phase_id = np.array([1, 1, 2]) + time_of_flight = np.array([10.0, 11.0, 12.0]) + index_h = np.array([1, 2, 3]) + index_k = np.array([0, 1, 1]) + index_l = np.array([1, 0, 2]) + f_squared_calc = np.array([50.0, 40.0, 30.0]) + f_calc = np.array([7.0, 6.0, 5.0]) class ExptType: sample_form = type('SF', (), {'value': SampleFormEnum.POWDER})() @@ -382,7 +424,7 @@ class ExptType: class Experiment: data = Pattern() type = ExptType() - bragg_peaks = BraggPeaks() + refln = Refln() plotter = Plotter() plotter._backend = FakeBackend() @@ -393,7 +435,7 @@ class Experiment: ) call = captured['powder_meas_vs_calc'] - assert [tick_set.structure_id for tick_set in call.bragg_tick_sets] == ['1', '2'] + assert [tick_set.phase_id for tick_set in call.bragg_tick_sets] == ['1', '2'] assert np.allclose(call.bragg_tick_sets[0].x, np.array([10.0, 11.0])) assert np.allclose(call.bragg_tick_sets[1].x, np.array([12.0])) @@ -418,13 +460,14 @@ class Pattern: intensity_meas = np.array([100.0, 110.0, 105.0]) intensity_calc = np.array([99.0, 108.0, 104.0]) - class BraggPeaks: - structure_id = np.array(['phase-a']) - x = np.array([8.0]) - h = np.array([1]) - k = np.array([0]) - l = np.array([1]) - intensity = np.array([50.0]) + class Refln: + phase_id = np.array(['phase-a']) + time_of_flight = np.array([8.0]) + index_h = np.array([1]) + index_k = np.array([0]) + index_l = np.array([1]) + f_squared_calc = np.array([50.0]) + f_calc = np.array([7.0]) class ExptType: sample_form = type('SF', (), {'value': SampleFormEnum.POWDER})() @@ -434,7 +477,7 @@ class ExptType: class Experiment: data = Pattern() type = ExptType() - bragg_peaks = BraggPeaks() + refln = Refln() plotter = Plotter() plotter._backend = FakeBackend() From 2810c26e11736b0c2050d138e52004d961a5ec8f Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 12:24:14 +0200 Subject: [PATCH 15/26] Verify powder Refln implementation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 21 +- docs/architecture/package-structure-full.md | 420 ++++++++++++++++++ docs/architecture/package-structure-short.md | 221 +++++++++ docs/dev/plan-threePanelPowderPlot.prompt.md | 44 +- .../analysis/calculators/base.py | 12 +- .../analysis/calculators/cryspy.py | 154 ++++--- .../experiment/categories/data/bragg_pd.py | 88 ++-- .../experiment/categories/data/refln_pd.py | 13 +- .../datablocks/experiment/item/bragg_pd.py | 6 +- src/easydiffraction/display/plotters/base.py | 6 +- .../display/plotters/plotly.py | 11 +- src/easydiffraction/display/plotting.py | 112 +++-- .../categories/data/test_refln_pd.py | 128 +++--- .../experiment/item/test_bragg_pd.py | 80 ++-- .../display/plotters/test_plotly.py | 4 +- 15 files changed, 1037 insertions(+), 283 deletions(-) create mode 100644 docs/architecture/package-structure-full.md create mode 100644 docs/architecture/package-structure-short.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cf6dbe054..c9d97ebe4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -124,8 +124,9 @@ - When asked to create a plan, first gather enough repository context to make the plan concrete. Ask all ambiguous, potentially ambiguous, or - unclear questions in one concise batch, and record unresolved questions - in the plan if the user wants the plan saved before answering them. + unclear questions in one concise batch, and record unresolved + questions in the plan if the user wants the plan saved before + answering them. - Save plans as Markdown files in `docs/dev` with the filename pattern `plan_.md`. The `` part uses lowercase words separated by dashes, for example @@ -142,14 +143,14 @@ explicitly asks. When Phase 1 is complete, stop and ask the user to review the implementation. - **Phase 2 — Verification:** after user approval, add/update tests, - run formatting, linting, unit tests, integration tests, and script or - notebook checks requested by the plan. -- Every completed implementation step must end with a local commit. Stage - only the files modified for that step, using explicit paths where - practical. Do not include data files, project files, CIF files, or - other generated artifacts created by integration tests, script tests, - or notebook execution unless the user explicitly asked to update those - artifacts. + run formatting, linting, unit tests, integration tests, and script + or notebook checks requested by the plan. +- Every completed implementation step must end with a local commit. + Stage only the files modified for that step, using explicit paths + where practical. Do not include data files, project files, CIF files, + or other generated artifacts created by integration tests, script + tests, or notebook execution unless the user explicitly asked to + update those artifacts. - Keep commits atomic, single-purpose, and aligned with plan steps. Use imperative commit messages, no type prefix, and keep the subject line at or below 72 characters. diff --git a/docs/architecture/package-structure-full.md b/docs/architecture/package-structure-full.md new file mode 100644 index 000000000..d56de83d4 --- /dev/null +++ b/docs/architecture/package-structure-full.md @@ -0,0 +1,420 @@ +# Package Structure (full) + +``` +📦 easydiffraction +├── 📁 analysis +│ ├── 📁 calculators +│ │ ├── 📄 __init__.py +│ │ ├── 📄 base.py +│ │ │ ├── 🏷️ class PowderReflnRecord +│ │ │ └── 🏷️ class CalculatorBase +│ │ ├── 📄 crysfml.py +│ │ │ └── 🏷️ class CrysfmlCalculator +│ │ ├── 📄 cryspy.py +│ │ │ └── 🏷️ class CryspyCalculator +│ │ ├── 📄 factory.py +│ │ │ └── 🏷️ class CalculatorFactory +│ │ └── 📄 pdffit.py +│ │ └── 🏷️ class PdffitCalculator +│ ├── 📁 categories +│ │ ├── 📁 aliases +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ ├── 🏷️ class Alias +│ │ │ │ └── 🏷️ class Aliases +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class AliasesFactory +│ │ ├── 📁 constraints +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ ├── 🏷️ class Constraint +│ │ │ │ └── 🏷️ class Constraints +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class ConstraintsFactory +│ │ ├── 📁 fit +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ └── 🏷️ class Fit +│ │ │ ├── 📄 enums.py +│ │ │ │ └── 🏷️ class FitModeEnum +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class FitFactory +│ │ ├── 📁 joint_fit_experiments +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ ├── 🏷️ class JointFitExperiment +│ │ │ │ └── 🏷️ class JointFitExperiments +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class JointFitExperimentsFactory +│ │ └── 📄 __init__.py +│ ├── 📁 fit_helpers +│ │ ├── 📄 __init__.py +│ │ ├── 📄 metrics.py +│ │ ├── 📄 reporting.py +│ │ │ └── 🏷️ class FitResults +│ │ └── 📄 tracking.py +│ │ ├── 🏷️ class _TerminalLiveHandle +│ │ └── 🏷️ class FitProgressTracker +│ ├── 📁 minimizers +│ │ ├── 📄 __init__.py +│ │ ├── 📄 base.py +│ │ │ └── 🏷️ class MinimizerBase +│ │ ├── 📄 bumps.py +│ │ │ ├── 🏷️ class _EasyDiffractionFitness +│ │ │ └── 🏷️ class BumpsMinimizer +│ │ ├── 📄 bumps_amoeba.py +│ │ │ └── 🏷️ class BumpsAmoebaMinimizer +│ │ ├── 📄 bumps_de.py +│ │ │ └── 🏷️ class BumpsDEMinimizer +│ │ ├── 📄 bumps_lm.py +│ │ │ └── 🏷️ class BumpsLmMinimizer +│ │ ├── 📄 dfols.py +│ │ │ └── 🏷️ class DfolsMinimizer +│ │ ├── 📄 enums.py +│ │ │ └── 🏷️ class MinimizerTypeEnum +│ │ ├── 📄 factory.py +│ │ │ └── 🏷️ class MinimizerFactory +│ │ ├── 📄 lmfit.py +│ │ │ └── 🏷️ class LmfitMinimizer +│ │ ├── 📄 lmfit_least_squares.py +│ │ │ └── 🏷️ class LmfitLeastSquaresMinimizer +│ │ └── 📄 lmfit_leastsq.py +│ │ └── 🏷️ class LmfitLeastsqMinimizer +│ ├── 📄 __init__.py +│ ├── 📄 analysis.py +│ │ ├── 🏷️ class AnalysisDisplay +│ │ └── 🏷️ class Analysis +│ ├── 📄 fitting.py +│ │ └── 🏷️ class Fitter +│ └── 📄 sequential.py +│ └── 🏷️ class SequentialFitTemplate +├── 📁 core +│ ├── 📄 __init__.py +│ ├── 📄 category.py +│ │ ├── 🏷️ class CategoryItem +│ │ └── 🏷️ class CategoryCollection +│ ├── 📄 collection.py +│ │ └── 🏷️ class CollectionBase +│ ├── 📄 datablock.py +│ │ ├── 🏷️ class DatablockItem +│ │ └── 🏷️ class DatablockCollection +│ ├── 📄 diagnostic.py +│ │ └── 🏷️ class Diagnostics +│ ├── 📄 factory.py +│ │ └── 🏷️ class FactoryBase +│ ├── 📄 guard.py +│ │ └── 🏷️ class GuardedBase +│ ├── 📄 identity.py +│ │ └── 🏷️ class Identity +│ ├── 📄 metadata.py +│ │ ├── 🏷️ class TypeInfo +│ │ ├── 🏷️ class Compatibility +│ │ └── 🏷️ class CalculatorSupport +│ ├── 📄 singleton.py +│ │ ├── 🏷️ class SingletonBase +│ │ └── 🏷️ class ConstraintsHandler +│ ├── 📄 validation.py +│ │ ├── 🏷️ class DataTypeHints +│ │ ├── 🏷️ class DataTypes +│ │ ├── 🏷️ class ValidationStage +│ │ ├── 🏷️ class ValidatorBase +│ │ ├── 🏷️ class TypeValidator +│ │ ├── 🏷️ class RangeValidator +│ │ ├── 🏷️ class MembershipValidator +│ │ ├── 🏷️ class RegexValidator +│ │ └── 🏷️ class AttributeSpec +│ └── 📄 variable.py +│ ├── 🏷️ class GenericDescriptorBase +│ ├── 🏷️ class GenericStringDescriptor +│ ├── 🏷️ class GenericNumericDescriptor +│ ├── 🏷️ class GenericParameter +│ ├── 🏷️ class StringDescriptor +│ ├── 🏷️ class NumericDescriptor +│ └── 🏷️ class Parameter +├── 📁 crystallography +│ ├── 📄 __init__.py +│ ├── 📄 crystallography.py +│ └── 📄 space_groups.py +│ └── 🏷️ class _RestrictedUnpickler +├── 📁 datablocks +│ ├── 📁 experiment +│ │ ├── 📁 categories +│ │ │ ├── 📁 background +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ │ └── 🏷️ class BackgroundBase +│ │ │ │ ├── 📄 chebyshev.py +│ │ │ │ │ ├── 🏷️ class PolynomialTerm +│ │ │ │ │ └── 🏷️ class ChebyshevPolynomialBackground +│ │ │ │ ├── 📄 enums.py +│ │ │ │ │ └── 🏷️ class BackgroundTypeEnum +│ │ │ │ ├── 📄 factory.py +│ │ │ │ │ └── 🏷️ class BackgroundFactory +│ │ │ │ └── 📄 line_segment.py +│ │ │ │ ├── 🏷️ class LineSegment +│ │ │ │ └── 🏷️ class LineSegmentBackground +│ │ │ ├── 📁 calculation +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ └── 🏷️ class Calculation +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class CalculationFactory +│ │ │ ├── 📁 data +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 bragg_pd.py +│ │ │ │ │ ├── 🏷️ class PdDataPointBaseMixin +│ │ │ │ │ ├── 🏷️ class PdCwlDataPointMixin +│ │ │ │ │ ├── 🏷️ class PdTofDataPointMixin +│ │ │ │ │ ├── 🏷️ class PdCwlDataPoint +│ │ │ │ │ ├── 🏷️ class PdTofDataPoint +│ │ │ │ │ ├── 🏷️ class PdDataBase +│ │ │ │ │ ├── 🏷️ class PdCwlData +│ │ │ │ │ └── 🏷️ class PdTofData +│ │ │ │ ├── 📄 bragg_sc.py +│ │ │ │ │ ├── 🏷️ class Refln +│ │ │ │ │ └── 🏷️ class ReflnData +│ │ │ │ ├── 📄 factory.py +│ │ │ │ │ └── 🏷️ class DataFactory +│ │ │ │ ├── 📄 refln_pd.py +│ │ │ │ │ ├── 🏷️ class PowderReflnBase +│ │ │ │ │ ├── 🏷️ class PowderCwlRefln +│ │ │ │ │ ├── 🏷️ class PowderTofRefln +│ │ │ │ │ ├── 🏷️ class PowderReflnDataBase +│ │ │ │ │ ├── 🏷️ class PowderCwlReflnData +│ │ │ │ │ └── 🏷️ class PowderTofReflnData +│ │ │ │ └── 📄 total_pd.py +│ │ │ │ ├── 🏷️ class TotalDataPoint +│ │ │ │ ├── 🏷️ class TotalDataBase +│ │ │ │ └── 🏷️ class TotalData +│ │ │ ├── 📁 diffrn +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ └── 🏷️ class DefaultDiffrn +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class DiffrnFactory +│ │ │ ├── 📁 excluded_regions +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ ├── 🏷️ class ExcludedRegion +│ │ │ │ │ └── 🏷️ class ExcludedRegions +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class ExcludedRegionsFactory +│ │ │ ├── 📁 experiment_type +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ └── 🏷️ class ExperimentType +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class ExperimentTypeFactory +│ │ │ ├── 📁 extinction +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 becker_coppens.py +│ │ │ │ │ └── 🏷️ class BeckerCoppensExtinction +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class ExtinctionFactory +│ │ │ ├── 📁 instrument +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ │ └── 🏷️ class InstrumentBase +│ │ │ │ ├── 📄 cwl.py +│ │ │ │ │ ├── 🏷️ class CwlInstrumentBase +│ │ │ │ │ ├── 🏷️ class CwlScInstrument +│ │ │ │ │ └── 🏷️ class CwlPdInstrument +│ │ │ │ ├── 📄 factory.py +│ │ │ │ │ └── 🏷️ class InstrumentFactory +│ │ │ │ └── 📄 tof.py +│ │ │ │ ├── 🏷️ class TofScInstrument +│ │ │ │ └── 🏷️ class TofPdInstrument +│ │ │ ├── 📁 linked_crystal +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ └── 🏷️ class LinkedCrystal +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class LinkedCrystalFactory +│ │ │ ├── 📁 linked_phases +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ ├── 🏷️ class LinkedPhase +│ │ │ │ │ └── 🏷️ class LinkedPhases +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class LinkedPhasesFactory +│ │ │ ├── 📁 peak +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ │ └── 🏷️ class PeakBase +│ │ │ │ ├── 📄 cwl.py +│ │ │ │ │ ├── 🏷️ class CwlPseudoVoigt +│ │ │ │ │ ├── 🏷️ class CwlPseudoVoigtEmpiricalAsymmetry +│ │ │ │ │ └── 🏷️ class CwlThompsonCoxHastings +│ │ │ │ ├── 📄 cwl_mixins.py +│ │ │ │ │ ├── 🏷️ class CwlBroadeningMixin +│ │ │ │ │ ├── 🏷️ class EmpiricalAsymmetryMixin +│ │ │ │ │ └── 🏷️ class FcjAsymmetryMixin +│ │ │ │ ├── 📄 factory.py +│ │ │ │ │ └── 🏷️ class PeakFactory +│ │ │ │ ├── 📄 tof.py +│ │ │ │ │ ├── 🏷️ class TofPseudoVoigt +│ │ │ │ │ ├── 🏷️ class TofJorgensen +│ │ │ │ │ ├── 🏷️ class TofJorgensenVonDreele +│ │ │ │ │ └── 🏷️ class TofDoubleJorgensenVonDreele +│ │ │ │ ├── 📄 tof_mixins.py +│ │ │ │ │ ├── 🏷️ class TofGaussianBroadeningMixin +│ │ │ │ │ ├── 🏷️ class TofLorentzianBroadeningMixin +│ │ │ │ │ ├── 🏷️ class TofBackToBackExponentialMixin +│ │ │ │ │ └── 🏷️ class TofDoubleExponentialMixin +│ │ │ │ ├── 📄 total.py +│ │ │ │ │ └── 🏷️ class TotalGaussianDampedSinc +│ │ │ │ └── 📄 total_mixins.py +│ │ │ │ └── 🏷️ class TotalBroadeningMixin +│ │ │ └── 📄 __init__.py +│ │ ├── 📁 item +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ │ ├── 🏷️ class ExperimentBase +│ │ │ │ ├── 🏷️ class ScExperimentBase +│ │ │ │ └── 🏷️ class PdExperimentBase +│ │ │ ├── 📄 bragg_pd.py +│ │ │ │ └── 🏷️ class BraggPdExperiment +│ │ │ ├── 📄 bragg_sc.py +│ │ │ │ ├── 🏷️ class CwlScExperiment +│ │ │ │ └── 🏷️ class TofScExperiment +│ │ │ ├── 📄 enums.py +│ │ │ │ ├── 🏷️ class SampleFormEnum +│ │ │ │ ├── 🏷️ class ScatteringTypeEnum +│ │ │ │ ├── 🏷️ class RadiationProbeEnum +│ │ │ │ ├── 🏷️ class BeamModeEnum +│ │ │ │ ├── 🏷️ class CalculatorEnum +│ │ │ │ ├── 🏷️ class PeakProfileTypeEnum +│ │ │ │ └── 🏷️ class ExtinctionModelEnum +│ │ │ ├── 📄 factory.py +│ │ │ │ └── 🏷️ class ExperimentFactory +│ │ │ └── 📄 total_pd.py +│ │ │ └── 🏷️ class TotalPdExperiment +│ │ ├── 📄 __init__.py +│ │ └── 📄 collection.py +│ │ └── 🏷️ class Experiments +│ ├── 📁 structure +│ │ ├── 📁 categories +│ │ │ ├── 📁 atom_site_aniso +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ ├── 🏷️ class AtomSiteAniso +│ │ │ │ │ └── 🏷️ class AtomSiteAnisoCollection +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class AtomSiteAnisoFactory +│ │ │ ├── 📁 atom_sites +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ ├── 🏷️ class AtomSite +│ │ │ │ │ └── 🏷️ class AtomSites +│ │ │ │ ├── 📄 enums.py +│ │ │ │ │ └── 🏷️ class AdpTypeEnum +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class AtomSitesFactory +│ │ │ ├── 📁 cell +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ └── 🏷️ class Cell +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class CellFactory +│ │ │ ├── 📁 space_group +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ │ └── 🏷️ class SpaceGroup +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class SpaceGroupFactory +│ │ │ └── 📄 __init__.py +│ │ ├── 📁 item +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ │ └── 🏷️ class Structure +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class StructureFactory +│ │ ├── 📄 __init__.py +│ │ └── 📄 collection.py +│ │ └── 🏷️ class Structures +│ └── 📄 __init__.py +├── 📁 display +│ ├── 📁 plotters +│ │ ├── 📄 __init__.py +│ │ ├── 📄 ascii.py +│ │ │ └── 🏷️ class AsciiPlotter +│ │ ├── 📄 base.py +│ │ │ ├── 🏷️ class BraggTickSet +│ │ │ ├── 🏷️ class PowderMeasVsCalcSpec +│ │ │ ├── 🏷️ class XAxisType +│ │ │ └── 🏷️ class PlotterBase +│ │ └── 📄 plotly.py +│ │ ├── 🏷️ class PowderCompositeRows +│ │ └── 🏷️ class PlotlyPlotter +│ ├── 📁 tablers +│ │ ├── 📄 __init__.py +│ │ ├── 📄 base.py +│ │ │ └── 🏷️ class TableBackendBase +│ │ ├── 📄 pandas.py +│ │ │ └── 🏷️ class PandasTableBackend +│ │ └── 📄 rich.py +│ │ └── 🏷️ class RichTableBackend +│ ├── 📄 __init__.py +│ ├── 📄 base.py +│ │ ├── 🏷️ class RendererBase +│ │ └── 🏷️ class RendererFactoryBase +│ ├── 📄 plotting.py +│ │ ├── 🏷️ class PlotterEngineEnum +│ │ ├── 🏷️ class _MeasVsCalcPlotOptions +│ │ ├── 🏷️ class Plotter +│ │ └── 🏷️ class PlotterFactory +│ ├── 📄 tables.py +│ │ ├── 🏷️ class TableEngineEnum +│ │ ├── 🏷️ class TableRenderer +│ │ └── 🏷️ class TableRendererFactory +│ └── 📄 utils.py +│ └── 🏷️ class JupyterScrollManager +├── 📁 io +│ ├── 📁 cif +│ │ ├── 📄 __init__.py +│ │ ├── 📄 handler.py +│ │ │ └── 🏷️ class CifHandler +│ │ ├── 📄 parse.py +│ │ └── 📄 serialize.py +│ ├── 📄 __init__.py +│ └── 📄 ascii.py +├── 📁 project +│ ├── 📁 categories +│ │ ├── 📁 display +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ │ └── 🏷️ class Display +│ │ │ └── 📄 factory.py +│ │ │ └── 🏷️ class DisplayFactory +│ │ └── 📄 __init__.py +│ ├── 📄 __init__.py +│ ├── 📄 project.py +│ │ └── 🏷️ class Project +│ └── 📄 project_info.py +│ └── 🏷️ class ProjectInfo +├── 📁 summary +│ ├── 📄 __init__.py +│ └── 📄 summary.py +│ └── 🏷️ class Summary +├── 📁 utils +│ ├── 📁 _vendored +│ │ ├── 📁 jupyter_dark_detect +│ │ │ ├── 📄 __init__.py +│ │ │ └── 📄 detector.py +│ │ ├── 📄 __init__.py +│ │ └── 📄 theme_detect.py +│ ├── 📄 __init__.py +│ ├── 📄 enums.py +│ │ └── 🏷️ class VerbosityEnum +│ ├── 📄 environment.py +│ ├── 📄 logging.py +│ │ ├── 🏷️ class IconifiedRichHandler +│ │ ├── 🏷️ class ConsoleManager +│ │ ├── 🏷️ class LoggerConfig +│ │ ├── 🏷️ class ExceptionHookManager +│ │ ├── 🏷️ class Logger +│ │ └── 🏷️ class ConsolePrinter +│ └── 📄 utils.py +├── 📄 __init__.py +└── 📄 __main__.py +``` diff --git a/docs/architecture/package-structure-short.md b/docs/architecture/package-structure-short.md new file mode 100644 index 000000000..dc947e51d --- /dev/null +++ b/docs/architecture/package-structure-short.md @@ -0,0 +1,221 @@ +# Package Structure (short) + +``` +📦 easydiffraction +├── 📁 analysis +│ ├── 📁 calculators +│ │ ├── 📄 __init__.py +│ │ ├── 📄 base.py +│ │ ├── 📄 crysfml.py +│ │ ├── 📄 cryspy.py +│ │ ├── 📄 factory.py +│ │ └── 📄 pdffit.py +│ ├── 📁 categories +│ │ ├── 📁 aliases +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ └── 📄 factory.py +│ │ ├── 📁 constraints +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ └── 📄 factory.py +│ │ ├── 📁 fit +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ ├── 📄 enums.py +│ │ │ └── 📄 factory.py +│ │ ├── 📁 joint_fit_experiments +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ └── 📄 factory.py +│ │ └── 📄 __init__.py +│ ├── 📁 fit_helpers +│ │ ├── 📄 __init__.py +│ │ ├── 📄 metrics.py +│ │ ├── 📄 reporting.py +│ │ └── 📄 tracking.py +│ ├── 📁 minimizers +│ │ ├── 📄 __init__.py +│ │ ├── 📄 base.py +│ │ ├── 📄 bumps.py +│ │ ├── 📄 bumps_amoeba.py +│ │ ├── 📄 bumps_de.py +│ │ ├── 📄 bumps_lm.py +│ │ ├── 📄 dfols.py +│ │ ├── 📄 enums.py +│ │ ├── 📄 factory.py +│ │ ├── 📄 lmfit.py +│ │ ├── 📄 lmfit_least_squares.py +│ │ └── 📄 lmfit_leastsq.py +│ ├── 📄 __init__.py +│ ├── 📄 analysis.py +│ ├── 📄 fitting.py +│ └── 📄 sequential.py +├── 📁 core +│ ├── 📄 __init__.py +│ ├── 📄 category.py +│ ├── 📄 collection.py +│ ├── 📄 datablock.py +│ ├── 📄 diagnostic.py +│ ├── 📄 factory.py +│ ├── 📄 guard.py +│ ├── 📄 identity.py +│ ├── 📄 metadata.py +│ ├── 📄 singleton.py +│ ├── 📄 validation.py +│ └── 📄 variable.py +├── 📁 crystallography +│ ├── 📄 __init__.py +│ ├── 📄 crystallography.py +│ └── 📄 space_groups.py +├── 📁 datablocks +│ ├── 📁 experiment +│ │ ├── 📁 categories +│ │ │ ├── 📁 background +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ ├── 📄 chebyshev.py +│ │ │ │ ├── 📄 enums.py +│ │ │ │ ├── 📄 factory.py +│ │ │ │ └── 📄 line_segment.py +│ │ │ ├── 📁 calculation +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 data +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 bragg_pd.py +│ │ │ │ ├── 📄 bragg_sc.py +│ │ │ │ ├── 📄 factory.py +│ │ │ │ ├── 📄 refln_pd.py +│ │ │ │ └── 📄 total_pd.py +│ │ │ ├── 📁 diffrn +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 excluded_regions +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 experiment_type +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 extinction +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 becker_coppens.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 instrument +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ ├── 📄 cwl.py +│ │ │ │ ├── 📄 factory.py +│ │ │ │ └── 📄 tof.py +│ │ │ ├── 📁 linked_crystal +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 linked_phases +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 peak +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 base.py +│ │ │ │ ├── 📄 cwl.py +│ │ │ │ ├── 📄 cwl_mixins.py +│ │ │ │ ├── 📄 factory.py +│ │ │ │ ├── 📄 tof.py +│ │ │ │ ├── 📄 tof_mixins.py +│ │ │ │ ├── 📄 total.py +│ │ │ │ └── 📄 total_mixins.py +│ │ │ └── 📄 __init__.py +│ │ ├── 📁 item +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ ├── 📄 bragg_pd.py +│ │ │ ├── 📄 bragg_sc.py +│ │ │ ├── 📄 enums.py +│ │ │ ├── 📄 factory.py +│ │ │ └── 📄 total_pd.py +│ │ ├── 📄 __init__.py +│ │ └── 📄 collection.py +│ ├── 📁 structure +│ │ ├── 📁 categories +│ │ │ ├── 📁 atom_site_aniso +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 atom_sites +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ ├── 📄 enums.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 cell +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ ├── 📁 space_group +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 default.py +│ │ │ │ └── 📄 factory.py +│ │ │ └── 📄 __init__.py +│ │ ├── 📁 item +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 base.py +│ │ │ └── 📄 factory.py +│ │ ├── 📄 __init__.py +│ │ └── 📄 collection.py +│ └── 📄 __init__.py +├── 📁 display +│ ├── 📁 plotters +│ │ ├── 📄 __init__.py +│ │ ├── 📄 ascii.py +│ │ ├── 📄 base.py +│ │ └── 📄 plotly.py +│ ├── 📁 tablers +│ │ ├── 📄 __init__.py +│ │ ├── 📄 base.py +│ │ ├── 📄 pandas.py +│ │ └── 📄 rich.py +│ ├── 📄 __init__.py +│ ├── 📄 base.py +│ ├── 📄 plotting.py +│ ├── 📄 tables.py +│ └── 📄 utils.py +├── 📁 io +│ ├── 📁 cif +│ │ ├── 📄 __init__.py +│ │ ├── 📄 handler.py +│ │ ├── 📄 parse.py +│ │ └── 📄 serialize.py +│ ├── 📄 __init__.py +│ └── 📄 ascii.py +├── 📁 project +│ ├── 📁 categories +│ │ ├── 📁 display +│ │ │ ├── 📄 __init__.py +│ │ │ ├── 📄 default.py +│ │ │ └── 📄 factory.py +│ │ └── 📄 __init__.py +│ ├── 📄 __init__.py +│ ├── 📄 project.py +│ └── 📄 project_info.py +├── 📁 summary +│ ├── 📄 __init__.py +│ └── 📄 summary.py +├── 📁 utils +│ ├── 📁 _vendored +│ │ ├── 📁 jupyter_dark_detect +│ │ │ ├── 📄 __init__.py +│ │ │ └── 📄 detector.py +│ │ ├── 📄 __init__.py +│ │ └── 📄 theme_detect.py +│ ├── 📄 __init__.py +│ ├── 📄 enums.py +│ ├── 📄 environment.py +│ ├── 📄 logging.py +│ └── 📄 utils.py +├── 📄 __init__.py +└── 📄 __main__.py +``` diff --git a/docs/dev/plan-threePanelPowderPlot.prompt.md b/docs/dev/plan-threePanelPowderPlot.prompt.md index d445bca1c..367e38151 100644 --- a/docs/dev/plan-threePanelPowderPlot.prompt.md +++ b/docs/dev/plan-threePanelPowderPlot.prompt.md @@ -16,10 +16,9 @@ reflection rows. - [x] Step 3 completed. Powder Bragg `plot_meas_vs_calc()` now routes through a composite plotting path, with residuals enabled by default only for the powder-Bragg composite branch. -- [x] Step 4 completed. Added a helper that consumes the future - category contract; this is now backed by `experiment.refln` - arrays and renders an empty Bragg row only when reflection data is - absent. +- [x] Step 4 completed. Added a helper that consumes the future category + contract; this is now backed by `experiment.refln` arrays and + renders an empty Bragg row only when reflection data is absent. - [x] Step 5 completed. Added `plot_powder_meas_vs_calc(...)` to the plotting backend contract and routed powder Bragg plots to it. - [x] Step 6 completed. Plotly now renders stacked subplots with shared @@ -84,26 +83,26 @@ reflection rows. within the scale-matched subplot instead of breaking the intended physical alignment. 12. Addressed review regressions by normalizing Bragg filtering bounds - before masking reflection data, keeping the residual - default scoped to the powder-Bragg composite path, and guarding the - composite Plotly backend against empty filtered x ranges. + before masking reflection data, keeping the residual default scoped + to the powder-Bragg composite path, and guarding the composite + Plotly backend against empty filtered x ranges. 13. Fixed follow-up review gaps by suppressing Bragg ticks when the - filtered main pattern is empty and by grouping Bragg rows on raw - phase ids so numeric identifiers still produce populated tick - rows with string labels. + filtered main pattern is empty and by grouping Bragg rows on raw + phase ids so numeric identifiers still produce populated tick rows + with string labels. **Steps** 1. Confirm the powder reflection category contract before implementation. It should be a powder experiment category similar to - `data`, exposing array-like calculated reflection data in the active x - coordinate: `phase_id`, x position, Miller indices h/k/l, + `data`, exposing array-like calculated reflection data in the active + x coordinate: `phase_id`, x position, Miller indices h/k/l, `f_squared_calc`, and `f_calc`. 2. Add a small plotting data transfer object for Bragg tick sets in - `src/easydiffraction/display/plotters/base.py`, for example a frozen - dataclass containing `phase_id`, `x`, `h`, `k`, `l`, - `f_squared_calc`, and `f_calc`. Keep it display-specific so the - plotting backend does not depend on experiment category internals. + `src/easydiffraction/display/plotters/base.py`, for example a frozen + dataclass containing `phase_id`, `x`, `h`, `k`, `l`, + `f_squared_calc`, and `f_calc`. Keep it display-specific so the + plotting backend does not depend on experiment category internals. 3. Extend `Plotter.plot_meas_vs_calc()` in `src/easydiffraction/display/plotting.py` so powder Bragg plots include residuals by default. Recommended API: make residual display @@ -136,13 +135,12 @@ reflection rows. exactly three residual-axis ticks (`min`, `0`, `max`) and do not add a separate colored zero line. 8. In the Bragg row, render one trace per structure/phase. Each peak - should appear as a vertical tick at its x position and at a - per-structure y row. Use a hover-capable trace representation, not - layout shapes, so hovering shows `phase_id`, hkl, x position, - `f_squared_calc`, and `f_calc`. Keep numeric y-axis labels hidden or - replace them with phase labels if that remains - readable. When no Bragg tick data exists, omit the Bragg subplot - entirely. + should appear as a vertical tick at its x position and at a + per-structure y row. Use a hover-capable trace representation, not + layout shapes, so hovering shows `phase_id`, hkl, x position, + `f_squared_calc`, and `f_calc`. Keep numeric y-axis labels hidden or + replace them with phase labels if that remains readable. When no + Bragg tick data exists, omit the Bragg subplot entirely. 9. Make X synchronization explicit: zooming or panning the main pattern must update the Bragg tick row and residual row, and the shared x-axis range should start at the filtered data minimum and end at the diff --git a/src/easydiffraction/analysis/calculators/base.py b/src/easydiffraction/analysis/calculators/base.py index 96f18e96a..b0e74ba57 100644 --- a/src/easydiffraction/analysis/calculators/base.py +++ b/src/easydiffraction/analysis/calculators/base.py @@ -6,12 +6,14 @@ from abc import ABC from abc import abstractmethod from dataclasses import dataclass +from typing import TYPE_CHECKING -import numpy as np +if TYPE_CHECKING: + import numpy as np -from easydiffraction.datablocks.experiment.item.base import ExperimentBase -from easydiffraction.datablocks.structure.collection import Structures -from easydiffraction.datablocks.structure.item.base import Structure + from easydiffraction.datablocks.experiment.item.base import ExperimentBase + from easydiffraction.datablocks.structure.collection import Structures + from easydiffraction.datablocks.structure.item.base import Structure @dataclass(frozen=True) @@ -93,5 +95,5 @@ def last_powder_refln_records( Backends that do not expose powder reflection metadata return ``None`` so callers can clear stale reflection rows and warn. """ - del structure, experiment, phase_id + del self, structure, experiment, phase_id return None diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index 4faa02428..d65626aa3 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -6,6 +6,7 @@ import contextlib import copy import io +from typing import TYPE_CHECKING from typing import Any import numpy as np @@ -14,13 +15,15 @@ from easydiffraction.analysis.calculators.base import PowderReflnRecord from easydiffraction.analysis.calculators.factory import CalculatorFactory from easydiffraction.core.metadata import TypeInfo -from easydiffraction.datablocks.experiment.item.base import ExperimentBase from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum from easydiffraction.datablocks.experiment.item.enums import PeakProfileTypeEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum -from easydiffraction.datablocks.structure.item.base import Structure from easydiffraction.utils.utils import sin_theta_over_lambda_to_d_spacing +if TYPE_CHECKING: + from easydiffraction.datablocks.experiment.item.base import ExperimentBase + from easydiffraction.datablocks.structure.item.base import Structure + try: import cryspy from cryspy.H_functions_global.function_1_cryspy_objects import str_to_globaln @@ -35,6 +38,9 @@ cryspy = None +EXPECTED_HKL_INDEX_ROWS = 3 + + @CalculatorFactory.register class CryspyCalculator(CalculatorBase): """ @@ -262,12 +268,50 @@ def last_powder_refln_records( *, phase_id: str, ) -> list[PowderReflnRecord] | None: - """Return powder reflection records from the latest pattern run.""" + """ + Return powder reflection records from the latest pattern run. + """ combined_name = f'{structure.name}_{experiment.name}' phase_block = self._last_powder_phase_blocks.get(combined_name) if phase_block is None: return None + core_arrays = self._powder_refln_core_arrays(phase_block) + if core_arrays is None: + return None + x_values = self._powder_refln_x_values(phase_block, experiment.type.beam_mode.value) + if x_values is None: + return None + + indices, sin_theta_over_lambda, f_nucl = core_arrays + d_spacing = self._powder_refln_d_spacing(phase_block, sin_theta_over_lambda) + f_calc = np.abs(f_nucl) + f_squared_calc = f_calc**2 + + return [ + self._powder_refln_record( + phase_id=phase_id, + beam_mode=experiment.type.beam_mode.value, + hkl=(index_h, index_k, index_l), + values=(sthovl, d_value, x_value, f_value, f_sq_value), + ) + for index_h, index_k, index_l, sthovl, d_value, x_value, f_value, f_sq_value in zip( + indices[0], + indices[1], + indices[2], + sin_theta_over_lambda, + d_spacing, + x_values, + f_calc, + f_squared_calc, + strict=True, + ) + ] + + @staticmethod + def _powder_refln_core_arrays( + phase_block: dict[str, Any], + ) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None: try: indices = np.asarray(phase_block['index_hkl'], dtype=int) sin_theta_over_lambda = np.asarray(phase_block['sthovl'], dtype=float) @@ -275,79 +319,67 @@ def last_powder_refln_records( except KeyError: return None - if indices.shape[0] != 3: + if indices.shape[0] != EXPECTED_HKL_INDEX_ROWS: return None + return indices, sin_theta_over_lambda, f_nucl + @staticmethod + def _powder_refln_d_spacing( + phase_block: dict[str, Any], + sin_theta_over_lambda: np.ndarray, + ) -> np.ndarray: d_spacing_raw = phase_block.get('d_hkl') if d_spacing_raw is None: - d_spacing = np.asarray( + return np.asarray( sin_theta_over_lambda_to_d_spacing(sin_theta_over_lambda), dtype=float, ) - else: - d_spacing = np.asarray(d_spacing_raw, dtype=float) + return np.asarray(d_spacing_raw, dtype=float) - beam_mode = experiment.type.beam_mode.value + @staticmethod + def _powder_refln_x_values( + phase_block: dict[str, Any], + beam_mode: BeamModeEnum, + ) -> np.ndarray | None: + x_values = None if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: x_raw = phase_block.get('ttheta_hkl') - if x_raw is None: - return None - x_values = np.degrees(np.asarray(x_raw, dtype=float)) + if x_raw is not None: + x_values = np.degrees(np.asarray(x_raw, dtype=float)) elif beam_mode == BeamModeEnum.TIME_OF_FLIGHT: x_raw = phase_block.get('time_hkl') - if x_raw is None: - return None - x_values = np.asarray(x_raw, dtype=float) - else: - return None + if x_raw is not None: + x_values = np.asarray(x_raw, dtype=float) - if x_values.size == 0: + if x_values is None or x_values.size == 0: return None + return x_values - f_calc = np.abs(f_nucl) - f_squared_calc = f_calc**2 - records: list[PowderReflnRecord] = [] - for index_h, index_k, index_l, sthovl, d_value, x_value, f_value, f_sq_value in zip( - indices[0], - indices[1], - indices[2], - sin_theta_over_lambda, - d_spacing, - x_values, - f_calc, - f_squared_calc, - strict=True, - ): - if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: - records.append( - PowderReflnRecord( - phase_id=phase_id, - d_spacing=float(d_value), - sin_theta_over_lambda=float(sthovl), - index_h=int(index_h), - index_k=int(index_k), - index_l=int(index_l), - f_calc=float(f_value), - f_squared_calc=float(f_sq_value), - two_theta=float(x_value), - ) - ) - else: - records.append( - PowderReflnRecord( - phase_id=phase_id, - d_spacing=float(d_value), - sin_theta_over_lambda=float(sthovl), - index_h=int(index_h), - index_k=int(index_k), - index_l=int(index_l), - f_calc=float(f_value), - f_squared_calc=float(f_sq_value), - time_of_flight=float(x_value), - ) - ) - - return records + @staticmethod + def _powder_refln_record( + *, + phase_id: str, + beam_mode: BeamModeEnum, + hkl: tuple[int, int, int], + values: tuple[float, float, float, float, float], + ) -> PowderReflnRecord: + index_h, index_k, index_l = hkl + sin_theta_over_lambda, d_spacing, x_value, f_calc, f_squared_calc = values + x_kwargs = {'two_theta': float(x_value)} + if beam_mode == BeamModeEnum.TIME_OF_FLIGHT: + x_kwargs = {'time_of_flight': float(x_value)} + + return PowderReflnRecord( + phase_id=phase_id, + d_spacing=float(d_spacing), + sin_theta_over_lambda=float(sin_theta_over_lambda), + index_h=int(index_h), + index_k=int(index_k), + index_l=int(index_l), + f_calc=float(f_calc), + f_squared_calc=float(f_squared_calc), + **x_kwargs, + ) def _recreate_cryspy_dict( self, diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py index ae8b2aad7..ea0cc62ff 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py @@ -386,51 +386,75 @@ def _update( structures = project.structures calculator = experiment.calculation.calculator - initial_calc = np.zeros_like(self.x) - calc = initial_calc + calc, refln_records, missing_refln_records = self._phase_calculation_results( + experiment=experiment, + structures=structures, + calculator=calculator, + called_by_minimizer=called_by_minimizer, + ) + self._set_intensity_calc(calc + self.intensity_bkg) + if missing_refln_records: + experiment.refln._replace_from_records([]) + log.debug( + 'Calculated powder reflection metadata is unavailable for ' + f"experiment '{experiment.name}' with calculator " + f"'{calculator.name}'. Clearing experiment.refln.", + ) + return + + experiment.refln._replace_from_records(refln_records) + + def _phase_calculation_results( + self, + *, + experiment: object, + structures: object, + calculator: object, + called_by_minimizer: bool, + ) -> tuple[np.ndarray, list[PowderReflnRecord], bool]: + calc = np.zeros_like(self.x) refln_records: list[PowderReflnRecord] = [] missing_refln_records = False - # TODO: refactor _get_valid_linked_phases to only be responsible - # for returning list. Warning message should be defined here, - # at least some of them. - # TODO: Adapt following the _update method in bragg_sc.py for linked_phase in experiment._get_valid_linked_phases(structures): structure_id = linked_phase._identity.category_entry_name - phase_id = linked_phase.id.value - structure_scale = linked_phase.scale.value structure = structures[structure_id] - - structure_calc = calculator.calculate_pattern( - structure, - experiment, + structure_scaled_calc, structure_refln_records = self._phase_result( + structure=structure, + experiment=experiment, + calculator=calculator, + linked_phase=linked_phase, called_by_minimizer=called_by_minimizer, ) - - structure_scaled_calc = structure_scale * structure_calc calc += structure_scaled_calc - - structure_refln_records = calculator.last_powder_refln_records( - structure, - experiment, - phase_id=phase_id, - ) if structure_refln_records is None: missing_refln_records = True - else: - refln_records.extend(structure_refln_records) + continue + refln_records.extend(structure_refln_records) - self._set_intensity_calc(calc + self.intensity_bkg) - if missing_refln_records: - experiment.refln._replace_from_records([]) - log.warning( - 'Calculated powder reflection metadata is unavailable for ' - f"experiment '{experiment.name}' with calculator " - f"'{calculator.name}'. Clearing experiment.refln.", - ) - return + return calc, refln_records, missing_refln_records - experiment.refln._replace_from_records(refln_records) + @staticmethod + def _phase_result( + *, + structure: object, + experiment: object, + calculator: object, + linked_phase: object, + called_by_minimizer: bool, + ) -> tuple[np.ndarray, list[PowderReflnRecord] | None]: + structure_calc = calculator.calculate_pattern( + structure, + experiment, + called_by_minimizer=called_by_minimizer, + ) + structure_scaled_calc = linked_phase.scale.value * structure_calc + structure_refln_records = calculator.last_powder_refln_records( + structure, + experiment, + phase_id=linked_phase.id.value, + ) + return structure_scaled_calc, structure_refln_records ################### # Public properties diff --git a/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py index 2e945fb5e..0b1598e28 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py @@ -71,7 +71,7 @@ def f_calc(self) -> NumericDescriptor: @property def f_squared_calc(self) -> NumericDescriptor: - """Calculated structure-factor amplitude squared for this reflection.""" + """Calculated structure-factor amplitude squared.""" return self._f_squared_calc @property @@ -152,7 +152,7 @@ class PowderReflnDataBase(CategoryCollection): _update_priority = 110 def _replace_from_records(self, records: Sequence[PowderReflnRecord]) -> None: - """Replace all reflection rows from calculator output records.""" + """Replace all rows from calculator reflection records.""" self._items = [self._item_type() for _ in records] for index, (item, record) in enumerate(zip(self._items, records, strict=True), start=1): @@ -173,7 +173,8 @@ def _set_x_value( item: PowderReflnBase, record: PowderReflnRecord, ) -> None: - """Set the beam-mode-specific x coordinate on a reflection row.""" + """Set the beam-mode-specific x coordinate.""" + del self, item, record @property def id(self) -> np.ndarray: @@ -220,7 +221,9 @@ def f_calc(self) -> np.ndarray: @property def f_squared_calc(self) -> np.ndarray: - """Calculated structure-factor amplitudes squared for all rows.""" + """ + Calculated structure-factor amplitudes squared for all rows. + """ return np.fromiter((item.f_squared_calc.value for item in self._items), dtype=float) @@ -243,6 +246,7 @@ def _set_x_value( item: PowderReflnBase, record: PowderReflnRecord, ) -> None: + del self item.two_theta._value = float(record.two_theta) @property @@ -270,6 +274,7 @@ def _set_x_value( item: PowderReflnBase, record: PowderReflnRecord, ) -> None: + del self item.time_of_flight._value = float(record.time_of_flight) @property diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py index a40b2bbdf..fd9a95e89 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py @@ -10,9 +10,9 @@ from easydiffraction.core.metadata import Compatibility from easydiffraction.core.metadata import TypeInfo from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory -from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData +from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory from easydiffraction.datablocks.experiment.item.base import PdExperimentBase from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum @@ -67,7 +67,9 @@ def __init__( self._refln = self._create_refln_collection() def _create_refln_collection(self) -> object: - """Create the beam-mode-specific calculated reflection collection.""" + """ + Create the beam-mode-specific calculated reflection collection. + """ beam_mode = self.type.beam_mode.value if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: return PowderCwlReflnData() diff --git a/src/easydiffraction/display/plotters/base.py b/src/easydiffraction/display/plotters/base.py index 8d85f9c5e..ec5053951 100644 --- a/src/easydiffraction/display/plotters/base.py +++ b/src/easydiffraction/display/plotters/base.py @@ -25,9 +25,9 @@ class BraggTickSet: """ Bragg tick data for one linked phase row. - The plotting facade converts experiment reflection-category data into - this display-specific container so plotting backends stay decoupled - from experiment datablock internals. + The plotting facade converts experiment reflection-category data + into this display-specific container so plotting backends stay + decoupled from experiment datablock internals. """ phase_id: str diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index d255093dd..c74cf926a 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -633,7 +633,9 @@ def _get_bragg_tick_trace( row_y: float, color: str, ) -> object: - """Create a hover-capable Bragg tick trace for one linked phase.""" + """ + Create a hover-capable Bragg tick trace for one linked phase. + """ y = np.full(tick_set.x.shape, row_y, dtype=float) hover_text = [] for idx, x_value in enumerate(tick_set.x): @@ -721,8 +723,7 @@ def _scaled_bragg_row_height(plot_spec: PowderMeasVsCalcSpec) -> float: return base_height * phase_count return ( - target_bragg_normalized_height * other_height - / (1.0 - target_bragg_normalized_height) + target_bragg_normalized_height * other_height / (1.0 - target_bragg_normalized_height) ) @staticmethod @@ -865,7 +866,7 @@ def plot_powder_meas_vs_calc( if layout.bragg_row is not None: fig.update_yaxes( - #title_text='Bragg peaks', + # title_text='Bragg peaks', tickmode='array', tickvals=[float(idx + 1) for idx in range(len(plot_spec.bragg_tick_sets))], ticktext=[tick_set.phase_id for tick_set in plot_spec.bragg_tick_sets], @@ -883,7 +884,7 @@ def plot_powder_meas_vs_calc( if layout.residual_row is not None and plot_spec.y_resid is not None: residual_tick_limit = self._get_display_tick_limit(residual_limit) fig.update_yaxes( - #title_text='Residual', + # title_text='Residual', range=[-residual_limit, residual_limit], tickmode='array', tickvals=[-residual_tick_limit, 0.0, residual_tick_limit], diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index b5747da80..5fbd94afb 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -1321,56 +1321,106 @@ def _extract_bragg_tick_sets( if refln is None: return () - x_name = getattr(x_axis, 'value', x_axis) - if x_name == XAxisType.D_SPACING: - x_values = Plotter._bragg_tick_d_spacing(refln=refln, experiment=experiment) - elif x_name == XAxisType.TWO_THETA: - x_values = getattr(refln, 'two_theta', None) - elif x_name == XAxisType.TIME_OF_FLIGHT: - x_values = getattr(refln, 'time_of_flight', None) - else: - log.warning( - f"Unsupported Bragg tick x axis '{x_name}' for experiment '{expt_name}'. " - 'Skipping the Bragg subplot.', - ) + x_values = Plotter._bragg_tick_x_values( + refln=refln, + experiment=experiment, + expt_name=expt_name, + x_axis=x_axis, + ) + arrays = Plotter._bragg_tick_arrays(refln=refln, expt_name=expt_name) + if x_values is None or arrays is None: return () - if x_values is None: - log.warning( - f"Experiment '{expt_name}' reflection data does not expose '{x_name}'. " - 'Skipping the Bragg subplot.', - ) + arrays['x'] = np.asarray(x_values) + if arrays['x'].size == 0: + return () + + mask = Plotter._bragg_tick_mask(arrays['x'], x_min=x_min, x_max=x_max) + if not np.any(mask): return () - required_names = ( + return Plotter._group_bragg_tick_sets(arrays=arrays, mask=mask) + + @staticmethod + def _bragg_tick_x_values( + *, + refln: object, + experiment: object, + expt_name: str, + x_axis: object, + ) -> object | None: + x_name = getattr(x_axis, 'value', x_axis) + if x_name == XAxisType.D_SPACING: + return Plotter._bragg_tick_d_spacing(refln=refln, experiment=experiment) + if x_name == XAxisType.TWO_THETA: + return Plotter._bragg_tick_attr(refln, x_name, expt_name) + if x_name == XAxisType.TIME_OF_FLIGHT: + return Plotter._bragg_tick_attr(refln, x_name, expt_name) + + log.warning( + f"Unsupported Bragg tick x axis '{x_name}' for experiment '{expt_name}'. " + 'Skipping the Bragg subplot.', + ) + return None + + @staticmethod + def _bragg_tick_attr( + refln: object, + name: str, + expt_name: str, + ) -> object | None: + value = getattr(refln, name, None) + if value is not None: + return value + + log.warning( + f"Experiment '{expt_name}' reflection data does not expose '{name}'. " + 'Skipping the Bragg subplot.', + ) + return None + + @staticmethod + def _bragg_tick_arrays( + *, + refln: object, + expt_name: str, + ) -> dict[str, np.ndarray] | None: + arrays: dict[str, np.ndarray] = {} + for name in ( 'phase_id', 'index_h', 'index_k', 'index_l', 'f_squared_calc', 'f_calc', - ) - arrays = {} - for name in required_names: + ): value = getattr(refln, name, None) if value is None: log.warning( f"Experiment '{expt_name}' reflection data is missing '{name}'. " 'Skipping the Bragg subplot.', ) - return () + return None arrays[name] = np.asarray(value) - arrays['x'] = np.asarray(x_values) - - if arrays['x'].size == 0: - return () + return arrays + @staticmethod + def _bragg_tick_mask( + x_values: np.ndarray, + *, + x_min: float | None, + x_max: float | None, + ) -> np.ndarray: lower_bound = DEFAULT_MIN if x_min is None else min(x_min, x_max) upper_bound = DEFAULT_MAX if x_max is None else max(x_min, x_max) - mask = (arrays['x'] >= lower_bound) & (arrays['x'] <= upper_bound) - if not np.any(mask): - return () + return (x_values >= lower_bound) & (x_values <= upper_bound) + @staticmethod + def _group_bragg_tick_sets( + *, + arrays: dict[str, np.ndarray], + mask: np.ndarray, + ) -> tuple[BraggTickSet, ...]: phase_ids = arrays['phase_id'][mask] unique_phase_ids = [] for raw_phase_id in phase_ids: @@ -1403,7 +1453,9 @@ def _bragg_tick_d_spacing( refln: object, experiment: object, ) -> object: - """Resolve Bragg tick d-spacing in the plotted coordinate system.""" + """ + Resolve Bragg tick d-spacing in the plotted coordinate system. + """ if hasattr(refln, 'two_theta'): return twotheta_to_d( refln.two_theta, diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py index 1b762821b..1f5a30eeb 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py @@ -25,32 +25,30 @@ def test_powder_cwl_refln_data_replace_from_records_sets_arrays(): from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData refln = PowderCwlReflnData() - refln._replace_from_records( - [ - PowderReflnRecord( - phase_id='alpha', - d_spacing=2.1, - sin_theta_over_lambda=0.25, - index_h=1, - index_k=0, - index_l=1, - f_calc=3.0, - f_squared_calc=9.0, - two_theta=14.5, - ), - PowderReflnRecord( - phase_id='beta', - d_spacing=1.5, - sin_theta_over_lambda=0.33, - index_h=2, - index_k=1, - index_l=0, - f_calc=4.0, - f_squared_calc=16.0, - two_theta=22.0, - ), - ] - ) + refln._replace_from_records([ + PowderReflnRecord( + phase_id='alpha', + d_spacing=2.1, + sin_theta_over_lambda=0.25, + index_h=1, + index_k=0, + index_l=1, + f_calc=3.0, + f_squared_calc=9.0, + two_theta=14.5, + ), + PowderReflnRecord( + phase_id='beta', + d_spacing=1.5, + sin_theta_over_lambda=0.33, + index_h=2, + index_k=1, + index_l=0, + f_calc=4.0, + f_squared_calc=16.0, + two_theta=22.0, + ), + ]) assert [item.id.value for item in refln._items] == ['1', '2'] np.testing.assert_array_equal(refln.phase_id, np.array(['alpha', 'beta'])) @@ -64,21 +62,19 @@ def test_powder_tof_refln_data_replace_from_records_sets_arrays(): from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData refln = PowderTofReflnData() - refln._replace_from_records( - [ - PowderReflnRecord( - phase_id='gamma', - d_spacing=3.2, - sin_theta_over_lambda=0.15, - index_h=1, - index_k=1, - index_l=0, - f_calc=5.0, - f_squared_calc=25.0, - time_of_flight=1200.0, - ) - ] - ) + refln._replace_from_records([ + PowderReflnRecord( + phase_id='gamma', + d_spacing=3.2, + sin_theta_over_lambda=0.15, + index_h=1, + index_k=1, + index_l=0, + f_calc=5.0, + f_squared_calc=25.0, + time_of_flight=1200.0, + ) + ]) assert [item.id.value for item in refln._items] == ['1'] np.testing.assert_array_equal(refln.phase_id, np.array(['gamma'])) @@ -94,32 +90,30 @@ def test_powder_refln_round_trips_via_experiment_cif(): radiation_probe='neutron', scattering_type='bragg', ) - experiment.refln._replace_from_records( - [ - PowderReflnRecord( - phase_id='alpha', - d_spacing=2.1, - sin_theta_over_lambda=0.25, - index_h=1, - index_k=0, - index_l=1, - f_calc=3.0, - f_squared_calc=9.0, - two_theta=14.5, - ), - PowderReflnRecord( - phase_id='beta', - d_spacing=1.5, - sin_theta_over_lambda=0.33, - index_h=2, - index_k=1, - index_l=0, - f_calc=4.0, - f_squared_calc=16.0, - two_theta=22.0, - ), - ] - ) + experiment.refln._replace_from_records([ + PowderReflnRecord( + phase_id='alpha', + d_spacing=2.1, + sin_theta_over_lambda=0.25, + index_h=1, + index_k=0, + index_l=1, + f_calc=3.0, + f_squared_calc=9.0, + two_theta=14.5, + ), + PowderReflnRecord( + phase_id='beta', + d_spacing=1.5, + sin_theta_over_lambda=0.33, + index_h=2, + index_k=1, + index_l=0, + f_calc=4.0, + f_squared_calc=16.0, + two_theta=22.0, + ), + ]) experiment._need_categories_update = False cif = experiment.as_cif diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py index 9ba106554..5266095c2 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py @@ -94,10 +94,12 @@ def test_bragg_pd_experiment_creates_beam_mode_specific_refln_collection(): def test_pd_data_update_populates_and_clears_refln(): - class FakeStructures(dict): + from collections import UserDict + + class FakeStructures(UserDict): @property def names(self): - return list(self.keys()) + return list(self.data.keys()) class FakeCalculator: name = 'fake' @@ -127,44 +129,42 @@ def __init__(self, name, pattern, records): experiment.data._create_items_set_xcoord_and_id(np.array([10.0, 20.0, 30.0])) experiment.data._set_intensity_meas(np.array([100.0, 110.0, 120.0])) - structures = FakeStructures( - { - 'phase_a': FakeStructure( - 'phase_a', - np.array([1.0, 2.0, 3.0]), - [ - PowderReflnRecord( - phase_id='phase_a', - d_spacing=2.1, - sin_theta_over_lambda=0.25, - index_h=1, - index_k=0, - index_l=1, - f_calc=3.0, - f_squared_calc=9.0, - two_theta=14.5, - ) - ], - ), - 'phase_b': FakeStructure( - 'phase_b', - np.array([4.0, 5.0, 6.0]), - [ - PowderReflnRecord( - phase_id='phase_b', - d_spacing=1.8, - sin_theta_over_lambda=0.28, - index_h=2, - index_k=1, - index_l=0, - f_calc=4.0, - f_squared_calc=16.0, - two_theta=18.5, - ) - ], - ), - } - ) + structures = FakeStructures({ + 'phase_a': FakeStructure( + 'phase_a', + np.array([1.0, 2.0, 3.0]), + [ + PowderReflnRecord( + phase_id='phase_a', + d_spacing=2.1, + sin_theta_over_lambda=0.25, + index_h=1, + index_k=0, + index_l=1, + f_calc=3.0, + f_squared_calc=9.0, + two_theta=14.5, + ) + ], + ), + 'phase_b': FakeStructure( + 'phase_b', + np.array([4.0, 5.0, 6.0]), + [ + PowderReflnRecord( + phase_id='phase_b', + d_spacing=1.8, + sin_theta_over_lambda=0.28, + index_h=2, + index_k=1, + index_l=0, + f_calc=4.0, + f_squared_calc=16.0, + two_theta=18.5, + ) + ], + ), + }) project = type('Project', (), {'structures': structures})() experiments = type('Experiments', (), {'_parent': project})() experiment._parent = experiments diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 0fe15131f..87c1d8dbb 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -373,7 +373,9 @@ def test_scaled_bragg_row_height_preserves_single_phase_baseline(): single_phase_normalized = single_height / ( 1.0 + single_phase.residual_height_fraction + single_height ) - two_phase_normalized_per_phase = (two_phase_height / (1.0 + two_phase.residual_height_fraction + two_phase_height)) / 2 + two_phase_normalized_per_phase = ( + two_phase_height / (1.0 + two_phase.residual_height_fraction + two_phase_height) + ) / 2 assert two_phase_normalized_per_phase == pytest.approx(single_phase_normalized) From 00125a1acb82edca36c7defb9352db53eefc94fc Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 12:24:20 +0200 Subject: [PATCH 16/26] Finalize powder Refln plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/dev/plan_powder-refln-category.md | 203 ++++++++++++++----------- 1 file changed, 110 insertions(+), 93 deletions(-) diff --git a/docs/dev/plan_powder-refln-category.md b/docs/dev/plan_powder-refln-category.md index 31297a913..4ca1c5045 100644 --- a/docs/dev/plan_powder-refln-category.md +++ b/docs/dev/plan_powder-refln-category.md @@ -15,75 +15,75 @@ Bragg ticks in the existing three-panel powder plot. - [x] Create and switch to branch `feature/powder-refln-category`. - [x] Phase 1: implement code and documentation changes only. - [x] Stop for user review after Phase 1 is complete. -- [ ] Phase 2: add/update tests and run verification commands after - user approval. -- [ ] Final clean-up: update this plan, confirm commits, and summarize - remaining risks. +- [x] Phase 2: add/update tests and run verification commands after user + approval. +- [x] Final clean-up: update this plan, confirm commits, and summarize + remaining risks. ## Execution Protocol - Work independently through Phase 1 until all implementation checklist - items are complete. Do not add tests or run tests during Phase 1 unless - the user explicitly asks. + items are complete. Do not add tests or run tests during Phase 1 + unless the user explicitly asks. - Mark each checklist item from `[ ]` to `[x]` when it is completed. - Commit after every completed implementation step. Stage only the files - modified for that step and avoid staging unrelated user changes. + modified for that step and avoid staging unrelated user changes. - Do not push commits unless the user explicitly asks. - Do not commit data files, project files, CIF files, or other generated - artifacts created by integration tests, script tests, or notebook - execution unless the user explicitly asks to update those artifacts. + artifacts created by integration tests, script tests, or notebook + execution unless the user explicitly asks to update those artifacts. - After Phase 1, stop and ask the user to review the implementation. - Continue to Phase 2 only after user approval. + Continue to Phase 2 only after user approval. ## Implementation Checklist ### Phase 1 — Implementation - [x] Step 1: Create branch `feature/powder-refln-category` from the - current working branch. - Suggested commit: no commit; branch setup only. + current working branch. Suggested commit: no commit; branch setup + only. - [x] Step 2: Add the powder `Refln` item and collection, including - `phase_id`, `f_calc`, `f_squared_calc`, beam-mode x fields, array - accessors, and `_replace_from_records(...)`. - Suggested commit: `Add powder Refln category` + `phase_id`, `f_calc`, `f_squared_calc`, beam-mode x fields, array + accessors, and `_replace_from_records(...)`. Suggested commit: + `Add powder Refln category` - [x] Step 3: Wire `experiment.refln` into `BraggPdExperiment` as a - read-only sibling category and include it in CIF serialization. - Suggested commit: `Wire powder Refln into Bragg experiments` + read-only sibling category and include it in CIF serialization. + Suggested commit: `Wire powder Refln into Bragg experiments` - [x] Step 4: Add calculator-side reflection record extraction for - Cryspy, reusing values from the powder pattern calculation output - when available. - Suggested commit: `Extract Cryspy powder reflection records` + Cryspy, reusing values from the powder pattern calculation output + when available. Suggested commit: + `Extract Cryspy powder reflection records` - [x] Step 5: Update `PdDataBase._update()` so every calculation clears - and repopulates `experiment.refln` in sync with `data.intensity_calc`. - Suggested commit: `Populate powder Refln during calculation` + and repopulates `experiment.refln` in sync with + `data.intensity_calc`. Suggested commit: + `Populate powder Refln during calculation` - [x] Step 6: Update plotting to consume `experiment.refln`, group by - `phase_id`, use the selected x-axis space, and show `phase_id`, - `(index_h index_k index_l)`, `f_squared_calc`, and `f_calc` in - Bragg tick hover content. - Suggested commit: `Use powder Refln for Bragg ticks` + `phase_id`, use the selected x-axis space, and show `phase_id`, + `(index_h index_k index_l)`, `f_squared_calc`, and `f_calc` in + Bragg tick hover content. Suggested commit: + `Use powder Refln for Bragg ticks` - [x] Step 7: Update documentation and any affected developer plans so - they refer to `experiment.refln` and `phase_id`. - Suggested commit: `Document powder Refln Bragg ticks` + they refer to `experiment.refln` and `phase_id`. Suggested commit: + `Document powder Refln Bragg ticks` - [x] Step 8: Review Phase 1 diffs, update this checklist, and stop for - user review before adding tests or running verification. - Suggested commit: `Update powder Refln implementation plan` + user review before adding tests or running verification. Suggested + commit: `Update powder Refln implementation plan` ### Phase 2 — Verification -- [ ] Step 9: Add/update focused unit tests for the powder `Refln` item, - collection replacement, `BraggPdExperiment` wiring, Cryspy record - extraction, data update population, plotting, and CIF round-trip. - Suggested commit: `Test powder Refln category` -- [ ] Step 10: Run targeted unit tests for the changed areas and fix any - failures. - Suggested commit: `Fix powder Refln test failures` -- [ ] Step 11: Run project formatting, linting, unit tests, integration - tests, and script tests listed below. Do not commit generated data, - project, or CIF artifacts from those runs. - Suggested commit: `Verify powder Refln implementation` -- [ ] Step 12: Update this plan with completed verification results and - any remaining risks. - Suggested commit: `Finalize powder Refln plan` +- [x] Step 9: Add/update focused unit tests for the powder `Refln` item, + collection replacement, `BraggPdExperiment` wiring, Cryspy record + extraction, data update population, plotting, and CIF round-trip. + Suggested commit: `Test powder Refln category` +- [x] Step 10: Run targeted unit tests for the changed areas and fix any + failures. Suggested commit: `Fix powder Refln test failures` +- [x] Step 11: Run project formatting, linting, unit tests, integration + tests, and script tests listed below. Do not commit generated + data, project, or CIF artifacts from those runs. Suggested commit: + `Verify powder Refln implementation` +- [x] Step 12: Update this plan with completed verification results and + any remaining risks. Suggested commit: + `Finalize powder Refln plan` ## Clarification Questions @@ -101,10 +101,11 @@ data persistence, or removes/replaces existing behavior. intensity fields, and wavelength. - Powder Bragg measured/calculated pattern points live in `src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py`. - `PdDataBase._update()` loops over `experiment.linked_phases`, calls the - active calculator once per linked phase, scales each phase pattern, and - stores the summed pattern in `data.intensity_calc`. -- Powder experiments expose linked phases through `_pd_phase_block.id` in + `PdDataBase._update()` loops over `experiment.linked_phases`, calls + the active calculator once per linked phase, scales each phase + pattern, and stores the summed pattern in `data.intensity_calc`. +- Powder experiments expose linked phases through `_pd_phase_block.id` + in `src/easydiffraction/datablocks/experiment/categories/linked_phases/default.py`. The runtime identity used to find structures is `linked_phase._identity.category_entry_name`, which currently resolves @@ -123,26 +124,26 @@ single-crystal `Refln` item and adds the requested powder fields. Recommended common item fields: -| Public property | CIF name | Type | Meaning | -| --- | --- | --- | --- | -| `id` | `_refln.id` | string | Stable row identifier, unique within the experiment. | -| `phase_id` | `_refln.phase_id` | string | Identifier of the linked phase that produced this reflection. | -| `d_spacing` | `_refln.d_spacing` | numeric | Reflection d-spacing, used to derive plot x positions when needed. | -| `sin_theta_over_lambda` | `_refln.sin_theta_over_lambda` | numeric | Reflection sin(theta)/lambda value. | -| `index_h` | `_refln.index_h` | numeric | Miller h index. | -| `index_k` | `_refln.index_k` | numeric | Miller k index. | -| `index_l` | `_refln.index_l` | numeric | Miller l index. | -| `f_calc` | `_refln.f_calc` | numeric | Calculated structure-factor amplitude `\|F_calc\|`. | -| `f_squared_calc` | `_refln.f_squared_calc` | numeric | Raw calculated structure-factor amplitude squared `\|F_calc\|^2`. | +| Public property | CIF name | Type | Meaning | +| ----------------------- | ------------------------------ | ------- | ------------------------------------------------------------------ | +| `id` | `_refln.id` | string | Stable row identifier, unique within the experiment. | +| `phase_id` | `_refln.phase_id` | string | Identifier of the linked phase that produced this reflection. | +| `d_spacing` | `_refln.d_spacing` | numeric | Reflection d-spacing, used to derive plot x positions when needed. | +| `sin_theta_over_lambda` | `_refln.sin_theta_over_lambda` | numeric | Reflection sin(theta)/lambda value. | +| `index_h` | `_refln.index_h` | numeric | Miller h index. | +| `index_k` | `_refln.index_k` | numeric | Miller k index. | +| `index_l` | `_refln.index_l` | numeric | Miller l index. | +| `f_calc` | `_refln.f_calc` | numeric | Calculated structure-factor amplitude `\|F_calc\|`. | +| `f_squared_calc` | `_refln.f_squared_calc` | numeric | Raw calculated structure-factor amplitude squared `\|F_calc\|^2`. | Recommended beam-mode-specific item fields should mirror the active powder `data` category so Bragg ticks are always rendered in the same x space as the main chart: -| Beam mode | Public property | CIF name basis | Meaning | -| --- | --- | --- | --- | -| CWL | `two_theta` | same convention as `data.two_theta` | Reflection position on the constant-wavelength 2θ axis. | -| TOF | `time_of_flight` | same convention as `data.time_of_flight` | Reflection position on the time-of-flight axis. | +| Beam mode | Public property | CIF name basis | Meaning | +| --------- | ---------------- | ---------------------------------------- | ------------------------------------------------------- | +| CWL | `two_theta` | same convention as `data.two_theta` | Reflection position on the constant-wavelength 2θ axis. | +| TOF | `time_of_flight` | same convention as `data.time_of_flight` | Reflection position on the time-of-flight axis. | Implementation notes: @@ -160,21 +161,22 @@ Implementation notes: - Keep `f_calc` and `f_squared_calc` non-negative real numeric values. `f_calc` is `|F_calc|`; `f_squared_calc` is raw `|F_calc|^2` and must not include linked-phase scale or powder intensity factors. -- Add a collection class, for example `PowderReflnData`, with typed array - properties for `phase_id`, `d_spacing`, `sin_theta_over_lambda`, +- Add a collection class, for example `PowderReflnData`, with typed + array properties for `phase_id`, `d_spacing`, `sin_theta_over_lambda`, `index_h`, `index_k`, `index_l`, `f_calc`, `f_squared_calc`, and the active beam-mode x coordinate. - Add a private replacement method such as `_replace_from_records(...)` so calculators can atomically clear and repopulate all reflection rows after a successful pattern calculation. -- Use globally unique row ids, likely `1`, `2`, ... in calculation order, - while preserving phase grouping with `phase_id`. Add a future note to - reconsider phase-prefixed ids if CIF inspection or debugging needs it. +- Use globally unique row ids, likely `1`, `2`, ... in calculation + order, while preserving phase grouping with `phase_id`. Add a future + note to reconsider phase-prefixed ids if CIF inspection or debugging + needs it. ## Experiment Wiring -Add the category as an additional category on `BraggPdExperiment`, not as -a replacement for `experiment.data`. +Add the category as an additional category on `BraggPdExperiment`, not +as a replacement for `experiment.data`. Steps: @@ -183,8 +185,9 @@ Steps: `BraggPdExperiment.__init__()`. 2. Add a read-only `refln` property on `BraggPdExperiment`. 3. Set the collection update priority after powder `data` updates, for - example `_update_priority = 110`, and keep its own `_update()` a no-op - unless a later design moves calculation ownership into the category. + example `_update_priority = 110`, and keep its own `_update()` a + no-op unless a later design moves calculation ownership into the + category. 4. Let normal datablock category traversal serialize the new collection to CIF, since `experiment_to_cif()` already emits all category collections stored on the experiment. @@ -204,9 +207,9 @@ truth for both the calculated pattern and the reflection table. Recommended implementation path: 1. Define a small internal reflection-record container for calculator - output, containing `phase_id`, `d_spacing`, - `sin_theta_over_lambda`, `index_h`, `index_k`, `index_l`, `f_calc`, - `f_squared_calc`, and the active beam-mode x coordinate. + output, containing `phase_id`, `d_spacing`, `sin_theta_over_lambda`, + `index_h`, `index_k`, `index_l`, `f_calc`, `f_squared_calc`, and the + active beam-mode x coordinate. 2. Extend the calculator abstraction with an explicit powder-reflection extraction path. Two possible designs are viable: - Return a typed result object from powder pattern calculation, such @@ -221,8 +224,8 @@ Recommended implementation path: 3. Update `PdDataBase._update()` so it collects reflection records while looping over valid linked phases. After all phases are processed, call `experiment.refln._replace_from_records(records)` once. -4. If a linked phase is skipped because its structure id is missing, - do not emit rows for that phase. +4. If a linked phase is skipped because its structure id is missing, do + not emit rows for that phase. 5. If the calculator cannot provide reflection metadata, clear `experiment.refln` and log a clear warning. Do not leave stale rows from a previous calculation. @@ -244,9 +247,9 @@ Calculator-specific notes: making explicit extra structure-factor calls if the required data are already returned. - CrysFML currently returns only the calculated powder pattern through - the EasyDiffraction wrapper. The implementation must either add a - real reflection extraction path for CrysFML or explicitly warn and - leave `refln` empty when CrysFML is active. + the EasyDiffraction wrapper. The implementation must either add a real + reflection extraction path for CrysFML or explicitly warn and leave + `refln` empty when CrysFML is active. - The calculator base API and all concrete calculators must agree on the same record shape, even when a calculator returns no records. @@ -260,8 +263,8 @@ Steps: 1. Update `Plotter._extract_bragg_tick_sets()` in `src/easydiffraction/display/plotting.py` to read from `experiment.refln`. -2. Group tick rows by raw `refln.phase_id` values and stringify - only when constructing display labels. This preserves numeric ids and +2. Group tick rows by raw `refln.phase_id` values and stringify only + when constructing display labels. This preserves numeric ids and matches the earlier powder plot review fix. 3. Use Miller indices from `index_h`, `index_k`, and `index_l` for hover text. @@ -320,8 +323,8 @@ Recommended unit tests: 5. Cryspy adapter tests using small mocked `dict_in_out` payloads rather than real engine calculations. 6. Plotting tests updating fake `bragg_peaks` fixtures to fake `refln` - fixtures, verifying grouping by `phase_id`, x filtering, hover fields, - and empty-category behavior. + fixtures, verifying grouping by `phase_id`, x filtering, hover + fields, and empty-category behavior. 7. CWL/TOF coordinate tests proving Bragg ticks use the same x axis as the main chart for `two_theta`, `time_of_flight`, and `d_spacing`. 8. CIF round-trip tests for `_refln.phase_id`, `_refln.f_calc`, and @@ -349,8 +352,8 @@ pixi run script-tests 3. Store `f_calc` as the real non-negative amplitude `|F_calc|`. 4. Store `f_squared_calc` as raw `|F_calc|^2`, without linked-phase scale or powder intensity factors. -5. Bragg tick hover text must show `phase_id`, `(index_h index_k - index_l)`, `f_squared_calc`, and `f_calc`. +5. Bragg tick hover text must show `phase_id`, + `(index_h index_k index_l)`, `f_squared_calc`, and `f_calc`. 6. Make `refln` beam-mode dependent like `data`: CWL rows store `two_theta`, TOF rows store `time_of_flight`, and all rows expose `d_spacing`. Bragg ticks must appear in the same x coordinate space @@ -366,13 +369,13 @@ pixi run script-tests 9. Rename display internals from `structure_id` to `phase_id` for consistency with the category. 10. Use global sequential reflection row ids for now. Add a future note - to reconsider phase-prefixed ids if debugging or CIF inspection - would benefit. + to reconsider phase-prefixed ids if debugging or CIF inspection + would benefit. ## Phase 1 Outcome -- Completed implementation commits: - `8a3c24d71`, `97e70268d`, `dfb380da9`, `d92dc6f90`, `b5d55e38a`. +- Completed implementation commits: `8a3c24d71`, `97e70268d`, + `dfb380da9`, `d92dc6f90`, `b5d55e38a`. - `experiment.refln` now exists on Bragg powder experiments, is updated together with `data.intensity_calc`, and drives Bragg tick extraction through `phase_id`, h/k/l, `f_squared_calc`, `f_calc`, and the active @@ -380,11 +383,25 @@ pixi run script-tests - Cryspy populates reflection rows from the same powder-pattern calculation payload. Backends without reflection metadata currently clear `experiment.refln` and warn instead of leaving stale rows. -- Phase 2 remains pending: focused tests, formatting, linting, unit - tests, integration tests, script tests, and final risk review. + +## Phase 2 Outcome + +- Added focused tests for the powder `refln` item/collection, + `BraggPdExperiment` wiring, Cryspy record extraction, plotting, and + CIF round-trip. +- Targeted verification for the changed areas passed after fixing one + directly coupled issue: the powder data update warning path now + imports `log` before clearing `experiment.refln`. +- `pixi run fix`, `pixi run check`, `pixi run unit-tests`, + `pixi run integration-tests`, and `pixi run script-tests` completed + successfully. +- `pixi run fix` also reformatted affected source files and regenerated + package-structure documentation under `docs/architecture/`. +- No remaining implementation-specific risks are known beyond the + unrelated tutorial worktree changes left untouched during this phase. ## Suggested Commit Message ```text -Update powder Refln implementation plan +Finalize powder Refln plan ``` From f4aeec46ecfcd5c4871bca09a829bd9397a69b08 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 12:34:28 +0200 Subject: [PATCH 17/26] Remove completed powder plot plans Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 2 +- docs/dev/issues_open.md | 44 ++ docs/dev/plan-threePanelPowderPlot.prompt.md | 249 ------------ docs/dev/plan_powder-refln-category.md | 407 ------------------- 4 files changed, 45 insertions(+), 657 deletions(-) delete mode 100644 docs/dev/plan-threePanelPowderPlot.prompt.md delete mode 100644 docs/dev/plan_powder-refln-category.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c9d97ebe4..45504df50 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -130,7 +130,7 @@ - Save plans as Markdown files in `docs/dev` with the filename pattern `plan_.md`. The `` part uses lowercase words separated by dashes, for example - `docs/dev/plan_powder-refln-category.md`. + `docs/dev/plan_background-refactor.md`. - Use the same `` to create the implementation branch, normally `feature/`. Do not push the branch unless the user explicitly asks. diff --git a/docs/dev/issues_open.md b/docs/dev/issues_open.md index 298cf9a67..7198026d2 100644 --- a/docs/dev/issues_open.md +++ b/docs/dev/issues_open.md @@ -621,6 +621,50 @@ are also marked. --- +## 93. 🟢 Decide Future of `show_residual` in `plot_meas_vs_calc` + +**Type:** API cleanup + +Powder Bragg plots now show the residual row by default when +`show_residual=None`, but the public `show_residual` argument still +exists and some call sites still pass `show_residual=True` explicitly. +The API should be clarified: either keep the argument as a compatibility +option, remove it, or standardize a single meaning across powder and +single-crystal plots. + +**TODOs:** + +- [plotting.py](src/easydiffraction/display/plotting.py#L459) +- [__main__.py](src/easydiffraction/__main__.py#L105) + +**Depends on:** nothing. + +--- + +## 94. 🟢 Revisit Powder `refln` Phase Labels and Row IDs + +**Type:** Naming / CIF UX + +The implemented powder reflection category uses `phase_id` throughout +(`experiment.refln`, `PowderReflnRecord`, Bragg tick labels) and assigns +global sequential row ids. This matches the current implementation, but +the archived planning notes left two follow-up questions open: + +1. whether `structure_id` would be clearer than `phase_id` in the public + API / CIF output, and +2. whether phase-prefixed row ids would make CIF inspection and + debugging easier than simple `1`, `2`, `3`, ... + +**TODOs:** + +- [refln_pd.py](src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py#L37) +- [refln_pd.py](src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py#L154) +- [base.py](src/easydiffraction/display/plotters/base.py#L24) + +**Depends on:** nothing. + +--- + ## 40. 🟢 Implement Resetting `.constrained` to `False` **Type:** Feature diff --git a/docs/dev/plan-threePanelPowderPlot.prompt.md b/docs/dev/plan-threePanelPowderPlot.prompt.md deleted file mode 100644 index 367e38151..000000000 --- a/docs/dev/plan-threePanelPowderPlot.prompt.md +++ /dev/null @@ -1,249 +0,0 @@ -## Plan: Three-Panel Powder Plot - -Extend `project.display.plotter.plot_meas_vs_calc()` so powder Bragg -plots render as three vertically stacked, X-synced Plotly subplots by -default: measured/calculated pattern, Bragg peak tick rows, and residual -curve. The plotter consumes the calculated powder reflection category at -`experiment.refln`; the plotter itself should not calculate or populate -reflection rows. - -**Status** - -- [x] Step 1 confirmed. Peak-position data will come from a future - experiment category and is out of scope for this implementation. -- [x] Step 2 completed. Added a display-specific Bragg tick DTO in - `src/easydiffraction/display/plotters/base.py`. -- [x] Step 3 completed. Powder Bragg `plot_meas_vs_calc()` now routes - through a composite plotting path, with residuals enabled by - default only for the powder-Bragg composite branch. -- [x] Step 4 completed. Added a helper that consumes the future category - contract; this is now backed by `experiment.refln` arrays and - renders an empty Bragg row only when reflection data is absent. -- [x] Step 5 completed. Added `plot_powder_meas_vs_calc(...)` to the - plotting backend contract and routed powder Bragg plots to it. -- [x] Step 6 completed. Plotly now renders stacked subplots with shared - x axes and configurable row-height fractions. -- [x] Step 7 completed. The residual row now uses a scale-aware, - symmetric y range with exactly three ticks and no extra colored - zero line. -- [x] Step 8 completed. The Bragg row renders one hover-capable tick - trace per structure or phase row. -- [x] Step 9 completed. The composite Plotly figure explicitly matches x - axes across the main, Bragg, and residual rows. -- [x] Step 10 completed. The ASCII backend now falls back to the - measured/calculated/residual chart and announces the Plotly-only - Bragg row. -- [x] Step 11 completed for the targeted tutorial source. Updated - `docs/docs/tutorials/ed-2.py` to rely on the new default plot - call. -- [x] Step 12 completed. Added focused facade/backend tests, regenerated - `docs/docs/tutorials/ed-2.ipynb`, and ran the targeted Phase 2 - verification commands. -- [x] Follow-up review fixes completed. Empty filtered powder ranges no - longer render stray Bragg rows, and Bragg grouping now preserves - raw structure ids before converting labels for display. - -**Atomic Change Log** - -1. Added `BraggTickSet` in - `src/easydiffraction/display/plotters/base.py` so future - experiment-category peak arrays can be passed to plotting backends - without coupling them to datablock internals. -2. Routed powder Bragg `plot_meas_vs_calc()` through a dedicated - composite backend method, added Plotly three-row subplot rendering, - and added an ASCII fallback while tolerating missing - `experiment.refln` data. -3. Updated `docs/docs/tutorials/ed-2.py` so the tutorial now uses the - default three-panel powder plot without explicitly passing - `show_residual=True`. -4. Added focused display tests for composite powder routing, Bragg tick - extraction, Plotly subplot rendering, and the ASCII fallback, then - regenerated `docs/docs/tutorials/ed-2.ipynb`. -5. Refined the residual subplot axis so its physical y scale follows the - main chart, its ticks are symmetric about zero, and the extra green - zero line is removed. -6. Locked the composite plot x axis to the actual data minimum and - maximum so Plotly no longer adds autorange padding around the scan. -7. Refactored the composite plot so the Bragg subplot is created only - when tick data exists; otherwise the figure collapses to the main and - residual rows without a no-data warning. -8. Refactored the composite powder plotting path around a typed spec - object so the backend contract, facade routing, and Bragg DTO stay - within the repository's lint-complexity limits while preserving the - same three-panel behavior. -9. Removed residual-axis rounding after the physical scale match so the - residual panel now preserves the intended pixel-per-unit alignment - for positive-background datasets such as the HRPT tutorial case. -10. Kept the exact residual range for scale matching but moved the - visible residual y-axis ticks to cleaner symmetric display values so - the subplot no longer shows awkward endpoint labels such as - `78.8649` or `439.109`. -11. Stopped expanding the residual y-axis to fit outlier spikes when the - main plot has a real y range, so oversized residuals are now clipped - within the scale-matched subplot instead of breaking the intended - physical alignment. -12. Addressed review regressions by normalizing Bragg filtering bounds - before masking reflection data, keeping the residual default scoped - to the powder-Bragg composite path, and guarding the composite - Plotly backend against empty filtered x ranges. -13. Fixed follow-up review gaps by suppressing Bragg ticks when the - filtered main pattern is empty and by grouping Bragg rows on raw - phase ids so numeric identifiers still produce populated tick rows - with string labels. - -**Steps** - -1. Confirm the powder reflection category contract before - implementation. It should be a powder experiment category similar to - `data`, exposing array-like calculated reflection data in the active - x coordinate: `phase_id`, x position, Miller indices h/k/l, - `f_squared_calc`, and `f_calc`. -2. Add a small plotting data transfer object for Bragg tick sets in - `src/easydiffraction/display/plotters/base.py`, for example a frozen - dataclass containing `phase_id`, `x`, `h`, `k`, `l`, - `f_squared_calc`, and `f_calc`. Keep it display-specific so the - plotting backend does not depend on experiment category internals. -3. Extend `Plotter.plot_meas_vs_calc()` in - `src/easydiffraction/display/plotting.py` so powder Bragg plots - include residuals by default. Recommended API: make residual display - the default without requiring `show_residual=True`; keep or repurpose - `show_residual` only after confirming whether public removal is - acceptable. Single-crystal behavior remains unchanged. -4. Add helper logic in `src/easydiffraction/display/plotting.py` to - extract Bragg tick sets from `experiment.refln`. The helper should - group rows by `phase_id`, select the x array matching the displayed - plot axis, filter x positions to the displayed `x_min`/`x_max`, and - build hover fields. If the category is absent or empty, render no - Bragg subplot rather than silently failing. -5. Route powder Bragg `plot_meas_vs_calc()` calls to a new backend - method dedicated to this composite plot, such as - `plot_powder_meas_vs_calc(...)`, instead of overloading the generic - `plot_powder()` used by `plot_meas()` and `plot_calc()`. The method - should receive x, measured y, calculated y, residual y, axes labels, - Bragg tick sets, residual height fraction, Bragg height fraction, - title, and height. -6. Implement the new backend method in - `src/easydiffraction/display/plotters/plotly.py` using Plotly - subplots with `shared_xaxes=True` plus explicit `matches='x'` on all - x axes. Row heights should be normalized from main = 1, Bragg = - configurable value, residual = `residual_height_fraction` with - default 0.25, so the residual row is 25% of the main row height. -7. In the Plotly main row, render measured and calculated traces using - the existing `SERIES_CONFIG` colors and names. In the residual row, - render `Imeas - Icalc` in the same intensity units with a symmetric, - scale-aware y range derived from the main chart height ratio. Show - exactly three residual-axis ticks (`min`, `0`, `max`) and do not add - a separate colored zero line. -8. In the Bragg row, render one trace per structure/phase. Each peak - should appear as a vertical tick at its x position and at a - per-structure y row. Use a hover-capable trace representation, not - layout shapes, so hovering shows `phase_id`, hkl, x position, - `f_squared_calc`, and `f_calc`. Keep numeric y-axis labels hidden or - replace them with phase labels if that remains readable. When no - Bragg tick data exists, omit the Bragg subplot entirely. -9. Make X synchronization explicit: zooming or panning the main pattern - must update the Bragg tick row and residual row, and the shared - x-axis range should start at the filtered data minimum and end at the - filtered data maximum instead of using Plotly autorange padding. Y - axes should remain independent: the main y-axis reflects intensity, - the Bragg y-axis is arbitrary row placement, and the residual y-axis - auto-ranges in intensity-difference units. -10. Implement an ASCII backend fallback in - `src/easydiffraction/display/plotters/ascii.py` for the new method. - It can render measured/calculated/residual using the existing single - ASCII chart behavior and print a clear note that Bragg tick subplots - are only available in graphical Plotly output. -11. Update docs and examples after the implementation shape is approved. - Edit `docs/docs/tutorials/ed-2.py`, not the generated notebook, - replacing calls that currently pass `show_residual=True` with the - new default call. Run `pixi run notebook-prepare` in verification to - regenerate notebooks. -12. Follow the repository's two-phase workflow. Phase 1: implement code - and docs only; do not add tests or run tests. Phase 2 after - approval: add/update focused tests and run verification commands. - -**Relevant files** - -- `src/easydiffraction/display/plotting.py` — public - `Plotter.plot_meas_vs_calc()`, `_plot_meas_vs_calc_data()`, and new - helper for consuming `experiment.refln` data. -- `src/easydiffraction/display/plotters/base.py` — `PlotterBase`, shared - plotting DTO/constants, and backend method contract. -- `src/easydiffraction/display/plotters/plotly.py` — Plotly - implementation with three synced subplot rows, hoverable Bragg ticks, - residual row sizing, and x-axis matching. -- `src/easydiffraction/display/plotters/ascii.py` — fallback - implementation for the new composite plot method. -- `src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py` - — reference pattern for array-like powder data category behavior; not - modified unless reflection-category population needs coordinated plot - updates. -- `src/easydiffraction/datablocks/experiment/item/base.py` and - `src/easydiffraction/datablocks/experiment/item/bragg_pd.py` — - experiment-category wiring reference for `experiment.refln`. -- `docs/docs/tutorials/ed-2.py` — tutorial source to update; - `docs/docs/tutorials/ed-2.ipynb` is generated by - `pixi run notebook-prepare`. -- `tests/unit/easydiffraction/display/test_plotting.py` and - `tests/unit/easydiffraction/display/test_plotting_coverage.py` — - facade tests to update with fake peak-position category data. -- `tests/unit/easydiffraction/display/plotters/test_plotly.py` — Plotly - backend tests for row heights, x-axis matching, hover data, and trace - routing. -- `tests/integration/fitting/test_plotting.py` — integration test once a - real peak-position category exists on fitted projects. - -**Verification** - -1. Unit-test the facade with a fake powder Bragg experiment exposing a - fake peak-position category. Verify residual is included by default, - Bragg peaks are grouped by structure id, x filtering is applied to - all three plot rows, and single-crystal routing is unchanged. -2. Unit-test the Plotly backend by monkeypatching Plotly figure/subplot - creation or inspecting a real figure object before display. Verify - three rows, matched x axes, residual row height fraction 0.25 - relative to main, hover templates include hkl, `f_squared_calc`, and - `f_calc`, and one Bragg tick trace exists per phase. -3. Unit-test the ASCII fallback to ensure calls do not fail and the user - gets a clear note about graphical-only Bragg tick rows. -4. After the powder reflection category exists, add category/unit - round-trip tests for peak x positions, h/k/l, `f_squared_calc`, - `f_calc`, and `phase_id`, plus an integration plotting test using - `lbco_fitted_project`. -5. Phase 2 commands: - `pixi run unit-tests tests/unit/easydiffraction/display/`, - `pixi run integration-tests tests/integration/fitting/test_plotting.py`, - `pixi run notebook-prepare`, and then the project-prescribed - `pixi run fix`, `pixi run check`, `pixi run unit-tests`, - `pixi run integration-tests`, `pixi run script-tests` when the full - verification pass is requested. - -**Decisions** - -- The Bragg tick data source is a future experiment category, not an ad - hoc calculation inside the plotter. -- `plot_meas_vs_calc()` should show the three-panel powder Bragg layout - by default; tutorial calls should no longer need `show_residual=True`. -- Residual y-axis uses the same intensity units but auto-ranges - independently from the main y-axis. -- X axes are synchronized across all three rows; y axes are - intentionally independent. -- Bragg tick y positions are arbitrary row offsets used only to separate - structures/phases. -- Tick hover text must show `phase_id`, hkl, `f_squared_calc`, and - `f_calc`. - -**Further Considerations** - -1. Public API cleanup: decide during implementation approval whether to - remove `show_residual`, change its default to true, or keep it as a - compatibility option. Removing it is a public API change and should - be explicit. -2. The implemented plotting contract now uses `experiment.refln` and - groups rows by `phase_id`. Revisit only if the experiment-side - category is renamed in future work. -3. Coordinate contract: the category should expose reflection x - positions in the same coordinate as `experiment.data.x`; if multiple - x axes are supported, it should expose enough arrays to match - `x='two_theta'`, `x='time_of_flight'`, or `x='d_spacing'` - unambiguously. diff --git a/docs/dev/plan_powder-refln-category.md b/docs/dev/plan_powder-refln-category.md deleted file mode 100644 index 4ca1c5045..000000000 --- a/docs/dev/plan_powder-refln-category.md +++ /dev/null @@ -1,407 +0,0 @@ -# Plan: Powder Refln Category - -Add a powder Bragg `refln` category that records calculated reflection -metadata per linked phase. The category will be populated when powder -patterns are calculated and will provide the data source for hoverable -Bragg ticks in the existing three-panel powder plot. - -- Plan file: `docs/dev/plan_powder-refln-category.md` -- Feature name: `powder-refln-category` -- Feature branch: `feature/powder-refln-category` - -## Status - -- [x] Record design decisions below. -- [x] Create and switch to branch `feature/powder-refln-category`. -- [x] Phase 1: implement code and documentation changes only. -- [x] Stop for user review after Phase 1 is complete. -- [x] Phase 2: add/update tests and run verification commands after user - approval. -- [x] Final clean-up: update this plan, confirm commits, and summarize - remaining risks. - -## Execution Protocol - -- Work independently through Phase 1 until all implementation checklist - items are complete. Do not add tests or run tests during Phase 1 - unless the user explicitly asks. -- Mark each checklist item from `[ ]` to `[x]` when it is completed. -- Commit after every completed implementation step. Stage only the files - modified for that step and avoid staging unrelated user changes. -- Do not push commits unless the user explicitly asks. -- Do not commit data files, project files, CIF files, or other generated - artifacts created by integration tests, script tests, or notebook - execution unless the user explicitly asks to update those artifacts. -- After Phase 1, stop and ask the user to review the implementation. - Continue to Phase 2 only after user approval. - -## Implementation Checklist - -### Phase 1 — Implementation - -- [x] Step 1: Create branch `feature/powder-refln-category` from the - current working branch. Suggested commit: no commit; branch setup - only. -- [x] Step 2: Add the powder `Refln` item and collection, including - `phase_id`, `f_calc`, `f_squared_calc`, beam-mode x fields, array - accessors, and `_replace_from_records(...)`. Suggested commit: - `Add powder Refln category` -- [x] Step 3: Wire `experiment.refln` into `BraggPdExperiment` as a - read-only sibling category and include it in CIF serialization. - Suggested commit: `Wire powder Refln into Bragg experiments` -- [x] Step 4: Add calculator-side reflection record extraction for - Cryspy, reusing values from the powder pattern calculation output - when available. Suggested commit: - `Extract Cryspy powder reflection records` -- [x] Step 5: Update `PdDataBase._update()` so every calculation clears - and repopulates `experiment.refln` in sync with - `data.intensity_calc`. Suggested commit: - `Populate powder Refln during calculation` -- [x] Step 6: Update plotting to consume `experiment.refln`, group by - `phase_id`, use the selected x-axis space, and show `phase_id`, - `(index_h index_k index_l)`, `f_squared_calc`, and `f_calc` in - Bragg tick hover content. Suggested commit: - `Use powder Refln for Bragg ticks` -- [x] Step 7: Update documentation and any affected developer plans so - they refer to `experiment.refln` and `phase_id`. Suggested commit: - `Document powder Refln Bragg ticks` -- [x] Step 8: Review Phase 1 diffs, update this checklist, and stop for - user review before adding tests or running verification. Suggested - commit: `Update powder Refln implementation plan` - -### Phase 2 — Verification - -- [x] Step 9: Add/update focused unit tests for the powder `Refln` item, - collection replacement, `BraggPdExperiment` wiring, Cryspy record - extraction, data update population, plotting, and CIF round-trip. - Suggested commit: `Test powder Refln category` -- [x] Step 10: Run targeted unit tests for the changed areas and fix any - failures. Suggested commit: `Fix powder Refln test failures` -- [x] Step 11: Run project formatting, linting, unit tests, integration - tests, and script tests listed below. Do not commit generated - data, project, or CIF artifacts from those runs. Suggested commit: - `Verify powder Refln implementation` -- [x] Step 12: Update this plan with completed verification results and - any remaining risks. Suggested commit: - `Finalize powder Refln plan` - -## Clarification Questions - -All known questions have been answered and recorded in -`Decisions Recorded`. If new ambiguity appears during implementation, -pause only when the ambiguity changes public API, scientific semantics, -data persistence, or removes/replaces existing behavior. - -## Current Context - -- Single-crystal reflection data lives in - `src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py`. - Its `Refln` item owns `_refln.id`, `_refln.d_spacing`, - `_refln.sin_theta_over_lambda`, Miller indices, measured/calculated - intensity fields, and wavelength. -- Powder Bragg measured/calculated pattern points live in - `src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py`. - `PdDataBase._update()` loops over `experiment.linked_phases`, calls - the active calculator once per linked phase, scales each phase - pattern, and stores the summed pattern in `data.intensity_calc`. -- Powder experiments expose linked phases through `_pd_phase_block.id` - in - `src/easydiffraction/datablocks/experiment/categories/linked_phases/default.py`. - The runtime identity used to find structures is - `linked_phase._identity.category_entry_name`, which currently resolves - to `linked_phase.id.value`. -- The three-panel plot plan in - `docs/dev/plan-threePanelPowderPlot.prompt.md` already added a - display-side Bragg tick DTO and a temporary extractor that looks for a - future `experiment.bragg_peaks` category. This implementation should - replace that future placeholder with the real `experiment.refln` - category. - -## Proposed Category Shape - -Implement a powder-specific reflection item that inherits from the -single-crystal `Refln` item and adds the requested powder fields. - -Recommended common item fields: - -| Public property | CIF name | Type | Meaning | -| ----------------------- | ------------------------------ | ------- | ------------------------------------------------------------------ | -| `id` | `_refln.id` | string | Stable row identifier, unique within the experiment. | -| `phase_id` | `_refln.phase_id` | string | Identifier of the linked phase that produced this reflection. | -| `d_spacing` | `_refln.d_spacing` | numeric | Reflection d-spacing, used to derive plot x positions when needed. | -| `sin_theta_over_lambda` | `_refln.sin_theta_over_lambda` | numeric | Reflection sin(theta)/lambda value. | -| `index_h` | `_refln.index_h` | numeric | Miller h index. | -| `index_k` | `_refln.index_k` | numeric | Miller k index. | -| `index_l` | `_refln.index_l` | numeric | Miller l index. | -| `f_calc` | `_refln.f_calc` | numeric | Calculated structure-factor amplitude `\|F_calc\|`. | -| `f_squared_calc` | `_refln.f_squared_calc` | numeric | Raw calculated structure-factor amplitude squared `\|F_calc\|^2`. | - -Recommended beam-mode-specific item fields should mirror the active -powder `data` category so Bragg ticks are always rendered in the same x -space as the main chart: - -| Beam mode | Public property | CIF name basis | Meaning | -| --------- | ---------------- | ---------------------------------------- | ------------------------------------------------------- | -| CWL | `two_theta` | same convention as `data.two_theta` | Reflection position on the constant-wavelength 2θ axis. | -| TOF | `time_of_flight` | same convention as `data.time_of_flight` | Reflection position on the time-of-flight axis. | - -Implementation notes: - -- Add the item as `Refln` in a powder Bragg module and inherit from - `easydiffraction.datablocks.experiment.categories.data.bragg_sc.Refln`. - Alias the imported single-crystal class locally to avoid a name clash. -- Add `phase_id`, `f_calc`, and `f_squared_calc` descriptors in the - subclass `__init__()` after `super().__init__()`. -- Although the public/CIF field is named `phase_id`, its value should be - copied exactly from `linked_phase.id.value`. -- Add CWL and TOF powder `Refln` variants or mixins so `refln` stores - the same native x-coordinate field as the active powder `data` - category: `two_theta` for CWL and `time_of_flight` for TOF. -- Keep `self._identity.category_code = 'refln'` from the base class. -- Keep `f_calc` and `f_squared_calc` non-negative real numeric values. - `f_calc` is `|F_calc|`; `f_squared_calc` is raw `|F_calc|^2` and must - not include linked-phase scale or powder intensity factors. -- Add a collection class, for example `PowderReflnData`, with typed - array properties for `phase_id`, `d_spacing`, `sin_theta_over_lambda`, - `index_h`, `index_k`, `index_l`, `f_calc`, `f_squared_calc`, and the - active beam-mode x coordinate. -- Add a private replacement method such as `_replace_from_records(...)` - so calculators can atomically clear and repopulate all reflection rows - after a successful pattern calculation. -- Use globally unique row ids, likely `1`, `2`, ... in calculation - order, while preserving phase grouping with `phase_id`. Add a future - note to reconsider phase-prefixed ids if CIF inspection or debugging - needs it. - -## Experiment Wiring - -Add the category as an additional category on `BraggPdExperiment`, not -as a replacement for `experiment.data`. - -Steps: - -1. Instantiate the powder `refln` collection in - `src/easydiffraction/datablocks/experiment/item/bragg_pd.py` during - `BraggPdExperiment.__init__()`. -2. Add a read-only `refln` property on `BraggPdExperiment`. -3. Set the collection update priority after powder `data` updates, for - example `_update_priority = 110`, and keep its own `_update()` a - no-op unless a later design moves calculation ownership into the - category. -4. Let normal datablock category traversal serialize the new collection - to CIF, since `experiment_to_cif()` already emits all category - collections stored on the experiment. -5. Avoid adding `refln` to `PdExperimentBase` unless total-scattering - powder experiments also need it later. - -If strict factory-backed category construction is desired, add a small -`refln` category factory package and create the powder Bragg collection -through that factory. If the project prefers the smallest local change, -instantiate the collection directly from the powder Bragg experiment. - -## Calculation Population - -The existing powder calculation flow should remain the single source of -truth for both the calculated pattern and the reflection table. - -Recommended implementation path: - -1. Define a small internal reflection-record container for calculator - output, containing `phase_id`, `d_spacing`, `sin_theta_over_lambda`, - `index_h`, `index_k`, `index_l`, `f_calc`, `f_squared_calc`, and the - active beam-mode x coordinate. -2. Extend the calculator abstraction with an explicit powder-reflection - extraction path. Two possible designs are viable: - - Return a typed result object from powder pattern calculation, such - as `PowderPatternResult(intensity, reflections)`. This is the - cleanest long-term API but touches every calculator implementation - and every caller of `calculate_pattern()`. - - Keep `calculate_pattern()` returning the pattern array and add an - optional calculator method such as `last_powder_refln_records(...)` - or `calculate_powder_refln(...)`. This keeps the first diff smaller - but requires careful cache/lifecycle handling so reflection data - comes from the same calculation state as the pattern. -3. Update `PdDataBase._update()` so it collects reflection records while - looping over valid linked phases. After all phases are processed, - call `experiment.refln._replace_from_records(records)` once. -4. If a linked phase is skipped because its structure id is missing, do - not emit rows for that phase. -5. If the calculator cannot provide reflection metadata, clear - `experiment.refln` and log a clear warning. Do not leave stale rows - from a previous calculation. -6. Treat phase scale consistently. The plotted pattern should keep using - `linked_phase.scale * structure_calc`; the `refln` structure-factor - fields remain raw values from the calculator: `f_calc = |F_calc|` and - `f_squared_calc = |F_calc|^2`. -7. Treat `refln` like the calculated columns in `data`: every powder - calculation updates the category and clears stale rows rather than - preserving previous calculation output. - -Calculator-specific notes: - -- Cryspy likely already computes `refln`-style arrays during powder - pattern calculation. The implementation should extract h/k/l, - d-spacing or sin(theta)/lambda, `f_calc`, and `f_squared_calc` from - the same `dict_in_out` result used by `calculate_pattern()`. Start - with Cryspy and reuse values from the pattern calculation rather than - making explicit extra structure-factor calls if the required data are - already returned. -- CrysFML currently returns only the calculated powder pattern through - the EasyDiffraction wrapper. The implementation must either add a real - reflection extraction path for CrysFML or explicitly warn and leave - `refln` empty when CrysFML is active. -- The calculator base API and all concrete calculators must agree on the - same record shape, even when a calculator returns no records. - -## Plotting Integration - -Replace the temporary display contract from `experiment.bragg_peaks` to -the real powder `experiment.refln` category. - -Steps: - -1. Update `Plotter._extract_bragg_tick_sets()` in - `src/easydiffraction/display/plotting.py` to read from - `experiment.refln`. -2. Group tick rows by raw `refln.phase_id` values and stringify only - when constructing display labels. This preserves numeric ids and - matches the earlier powder plot review fix. -3. Use Miller indices from `index_h`, `index_k`, and `index_l` for hover - text. -4. Hover text must show `phase_id`, `(index_h index_k index_l)`, - `f_squared_calc`, and `f_calc`. -5. Resolve tick x positions from the selected plot x axis: - - `d_spacing`: use `refln.d_spacing` directly. - - `two_theta`: use `refln.two_theta` for CWL experiments. - - `time_of_flight`: use `refln.time_of_flight` for TOF experiments. -6. The Bragg tick row should always use the same coordinate space as the - main chart. If the plot asks for a derived x axis, the tick extractor - must select or derive the corresponding `refln` array before - filtering. -7. Keep existing safeguards from the three-panel plot work: normalize - `x_min`/`x_max`, return no tick sets for empty filtered main ranges, - and tolerate an empty `refln` category without rendering stray rows. -8. Rename display DTO fields from `structure_id` to `phase_id` for this - feature so the display layer matches the `refln` category. Add a note - to reconsider `structure_id` later if the API needs to emphasize the - structural datablock rather than the linked phase row. - -## CIF And Documentation - -1. Ensure `refln.as_cif` writes a loop containing the inherited - `_refln.*` fields plus `_refln.phase_id`, `_refln.f_calc`, and - `_refln.f_squared_calc`. -2. Ensure CIF loading can populate the new fields if a saved experiment - contains them. If loaded rows are calculation outputs, they should be - replaced on the next calculation rather than merged. -3. Add or update user-guide parameter documentation if generated docs do - not pick up the new fields automatically. -4. Update the three-panel plot plan or display docs to say the Bragg - ticks now consume `experiment.refln`, not a future `bragg_peaks` - category. - -## Tests - -Phase 2 should add focused tests before running the full verification -commands. - -Recommended unit tests: - -1. `tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_pd.py` - or a new mirrored `test_refln.py`: defaults for the powder `Refln` - item, inherited field availability, new descriptor defaults, and - category code `refln`. -2. Collection tests for `_replace_from_records(...)`, array properties, - clearing stale rows, row id generation, and mixed `phase_id` - grouping. -3. `BraggPdExperiment` tests proving `experiment.refln` exists, is - read-only, serializes as `_refln`, and does not appear on total - powder experiments unless explicitly chosen. -4. Calculator tests with fake calculators proving powder calculation - populates `refln` for multiple linked phases and clears it when a - calculator returns no records. -5. Cryspy adapter tests using small mocked `dict_in_out` payloads rather - than real engine calculations. -6. Plotting tests updating fake `bragg_peaks` fixtures to fake `refln` - fixtures, verifying grouping by `phase_id`, x filtering, hover - fields, and empty-category behavior. -7. CWL/TOF coordinate tests proving Bragg ticks use the same x axis as - the main chart for `two_theta`, `time_of_flight`, and `d_spacing`. -8. CIF round-trip tests for `_refln.phase_id`, `_refln.f_calc`, and - `_refln.f_squared_calc`. - -Suggested verification commands for Phase 2: - -```bash -pixi run unit-tests tests/unit/easydiffraction/datablocks/experiment/categories/data/ -pixi run unit-tests tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py -pixi run unit-tests tests/unit/easydiffraction/display/ -pixi run fix -pixi run check -pixi run unit-tests -pixi run integration-tests -pixi run script-tests -``` - -## Decisions Recorded - -1. Use `_refln.phase_id` / `phase_id` for the phase-link field. Add a - future note to consider switching to `structure_id` if that proves - clearer for users or CIF interoperability. -2. Store `linked_phase.id.value` exactly in `phase_id`. -3. Store `f_calc` as the real non-negative amplitude `|F_calc|`. -4. Store `f_squared_calc` as raw `|F_calc|^2`, without linked-phase - scale or powder intensity factors. -5. Bragg tick hover text must show `phase_id`, - `(index_h index_k index_l)`, `f_squared_calc`, and `f_calc`. -6. Make `refln` beam-mode dependent like `data`: CWL rows store - `two_theta`, TOF rows store `time_of_flight`, and all rows expose - `d_spacing`. Bragg ticks must appear in the same x coordinate space - as the main chart, including when the user plots with - `x='d_spacing'`. -7. Start with Cryspy population. Reuse structure-factor values returned - during the powder pattern calculation when Cryspy provides them; - avoid explicit extra structure-factor calls unless inspection shows - they are required. -8. Treat CIF-loaded `refln` rows as cached calculation output. Every - calculation updates this category, similar to calculated columns in - `data`. -9. Rename display internals from `structure_id` to `phase_id` for - consistency with the category. -10. Use global sequential reflection row ids for now. Add a future note - to reconsider phase-prefixed ids if debugging or CIF inspection - would benefit. - -## Phase 1 Outcome - -- Completed implementation commits: `8a3c24d71`, `97e70268d`, - `dfb380da9`, `d92dc6f90`, `b5d55e38a`. -- `experiment.refln` now exists on Bragg powder experiments, is updated - together with `data.intensity_calc`, and drives Bragg tick extraction - through `phase_id`, h/k/l, `f_squared_calc`, `f_calc`, and the active - powder x coordinate. -- Cryspy populates reflection rows from the same powder-pattern - calculation payload. Backends without reflection metadata currently - clear `experiment.refln` and warn instead of leaving stale rows. - -## Phase 2 Outcome - -- Added focused tests for the powder `refln` item/collection, - `BraggPdExperiment` wiring, Cryspy record extraction, plotting, and - CIF round-trip. -- Targeted verification for the changed areas passed after fixing one - directly coupled issue: the powder data update warning path now - imports `log` before clearing `experiment.refln`. -- `pixi run fix`, `pixi run check`, `pixi run unit-tests`, - `pixi run integration-tests`, and `pixi run script-tests` completed - successfully. -- `pixi run fix` also reformatted affected source files and regenerated - package-structure documentation under `docs/architecture/`. -- No remaining implementation-specific risks are known beyond the - unrelated tutorial worktree changes left untouched during this phase. - -## Suggested Commit Message - -```text -Finalize powder Refln plan -``` From 18b8eec5a913252798296ee9dda840df8cb0774b Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 13:06:49 +0200 Subject: [PATCH 18/26] Reverse Bragg tick row order Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/dev/issues_open.md | 2 +- src/easydiffraction/display/plotters/plotly.py | 2 +- tests/unit/easydiffraction/display/plotters/test_plotly.py | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/dev/issues_open.md b/docs/dev/issues_open.md index 7198026d2..014aa4675 100644 --- a/docs/dev/issues_open.md +++ b/docs/dev/issues_open.md @@ -635,7 +635,7 @@ single-crystal plots. **TODOs:** - [plotting.py](src/easydiffraction/display/plotting.py#L459) -- [__main__.py](src/easydiffraction/__main__.py#L105) +- [\_\_main\_\_.py](src/easydiffraction/__main__.py#L105) **Depends on:** nothing. diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index c74cf926a..a609300cc 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -870,7 +870,7 @@ def plot_powder_meas_vs_calc( tickmode='array', tickvals=[float(idx + 1) for idx in range(len(plot_spec.bragg_tick_sets))], ticktext=[tick_set.phase_id for tick_set in plot_spec.bragg_tick_sets], - range=[0.5, float(len(plot_spec.bragg_tick_sets)) + 0.5], + range=[float(len(plot_spec.bragg_tick_sets)) + 0.5, 0.5], showgrid=False, row=layout.bragg_row, col=1, diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 87c1d8dbb..2c7da2b82 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -307,7 +307,10 @@ def fake_show_figure(self, fig): bragg_traces = [trace for trace in fig.data if trace.name.startswith('Bragg')] assert [trace.name for trace in bragg_traces] == ['Bragg (phase-a)', 'Bragg (phase-b)'] + assert list(bragg_traces[0].y) == [1.0] + assert list(bragg_traces[1].y) == [2.0] assert list(fig.layout.yaxis2.ticktext) == ['phase-a', 'phase-b'] + assert list(fig.layout.yaxis2.range) == [2.5, 0.5] assert fig.layout.yaxis2.title.text is None assert fig.layout.yaxis3.title.text is None assert fig.layout.yaxis3.zeroline is False From 19715c70e8e1f304042563c0d928ebc89d1dcbe3 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 13:39:14 +0200 Subject: [PATCH 19/26] Fix powder refln lifecycle and Bragg scaling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../experiment/categories/data/bragg_pd.py | 19 ++++- .../experiment/categories/data/refln_pd.py | 20 +++++- .../datablocks/experiment/item/bragg_pd.py | 38 ++++++++-- .../display/plotters/plotly.py | 43 ++++++------ .../categories/data/test_refln_pd.py | 58 +++++++++++++++ .../experiment/item/test_bragg_pd.py | 63 +++++++++++++++++ .../display/plotters/test_plotly.py | 70 ++++++++++++++++--- 7 files changed, 271 insertions(+), 40 deletions(-) diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py index ea0cc62ff..0f8633010 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_pd.py @@ -385,24 +385,29 @@ def _update( project = experiments._parent structures = project.structures calculator = experiment.calculation.calculator + refln = experiment.refln calc, refln_records, missing_refln_records = self._phase_calculation_results( experiment=experiment, structures=structures, calculator=calculator, called_by_minimizer=called_by_minimizer, + collect_refln_records=refln is not None, ) self._set_intensity_calc(calc + self.intensity_bkg) + if refln is None: + return + if missing_refln_records: - experiment.refln._replace_from_records([]) - log.debug( + refln._replace_from_records([]) + log.warning( 'Calculated powder reflection metadata is unavailable for ' f"experiment '{experiment.name}' with calculator " f"'{calculator.name}'. Clearing experiment.refln.", ) return - experiment.refln._replace_from_records(refln_records) + refln._replace_from_records(refln_records) def _phase_calculation_results( self, @@ -411,6 +416,7 @@ def _phase_calculation_results( structures: object, calculator: object, called_by_minimizer: bool, + collect_refln_records: bool, ) -> tuple[np.ndarray, list[PowderReflnRecord], bool]: calc = np.zeros_like(self.x) refln_records: list[PowderReflnRecord] = [] @@ -425,8 +431,11 @@ def _phase_calculation_results( calculator=calculator, linked_phase=linked_phase, called_by_minimizer=called_by_minimizer, + collect_refln_records=collect_refln_records, ) calc += structure_scaled_calc + if not collect_refln_records: + continue if structure_refln_records is None: missing_refln_records = True continue @@ -442,6 +451,7 @@ def _phase_result( calculator: object, linked_phase: object, called_by_minimizer: bool, + collect_refln_records: bool, ) -> tuple[np.ndarray, list[PowderReflnRecord] | None]: structure_calc = calculator.calculate_pattern( structure, @@ -449,6 +459,9 @@ def _phase_result( called_by_minimizer=called_by_minimizer, ) structure_scaled_calc = linked_phase.scale.value * structure_calc + if not collect_refln_records: + return structure_scaled_calc, [] + structure_refln_records = calculator.last_powder_refln_records( structure, experiment, diff --git a/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py b/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py index 0b1598e28..adcdac735 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py @@ -8,6 +8,7 @@ import numpy as np from easydiffraction.core.category import CategoryCollection +from easydiffraction.core.metadata import CalculatorSupport from easydiffraction.core.metadata import Compatibility from easydiffraction.core.metadata import TypeInfo from easydiffraction.core.validation import AttributeSpec @@ -18,6 +19,7 @@ Refln as SingleCrystalRefln, ) from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum +from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum from easydiffraction.io.cif.handler import CifHandler @@ -153,9 +155,13 @@ class PowderReflnDataBase(CategoryCollection): def _replace_from_records(self, records: Sequence[PowderReflnRecord]) -> None: """Replace all rows from calculator reflection records.""" - self._items = [self._item_type() for _ in records] + for item in self._items: + item._parent = None - for index, (item, record) in enumerate(zip(self._items, records, strict=True), start=1): + new_items = [] + for index, record in enumerate(records, start=1): + item = self._item_type() + item._parent = self item.id._value = str(index) item.phase_id._value = str(record.phase_id) item.d_spacing._value = float(record.d_spacing) @@ -166,6 +172,10 @@ def _replace_from_records(self, records: Sequence[PowderReflnRecord]) -> None: item.f_calc._value = float(record.f_calc) item.f_squared_calc._value = float(record.f_squared_calc) self._set_x_value(item=item, record=record) + new_items.append(item) + + self._items = new_items + self._rebuild_index() def _set_x_value( self, @@ -236,6 +246,9 @@ class PowderCwlReflnData(PowderReflnDataBase): scattering_type=frozenset({ScatteringTypeEnum.BRAGG}), beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}), ) + calculator_support = CalculatorSupport( + calculators=frozenset({CalculatorEnum.CRYSPY}), + ) def __init__(self) -> None: super().__init__(item_type=PowderCwlRefln) @@ -264,6 +277,9 @@ class PowderTofReflnData(PowderReflnDataBase): scattering_type=frozenset({ScatteringTypeEnum.BRAGG}), beam_mode=frozenset({BeamModeEnum.TIME_OF_FLIGHT}), ) + calculator_support = CalculatorSupport( + calculators=frozenset({CalculatorEnum.CRYSPY}), + ) def __init__(self) -> None: super().__init__(item_type=PowderTofRefln) diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py index fd9a95e89..fbd480565 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py @@ -15,6 +15,7 @@ from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory from easydiffraction.datablocks.experiment.item.base import PdExperimentBase from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum +from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory @@ -64,21 +65,44 @@ def __init__( self._instrument = InstrumentFactory.create(self._instrument_type) self._background_type: str = BackgroundFactory.default_tag() self._background = BackgroundFactory.create(self._background_type) - self._refln = self._create_refln_collection() + self._refln = None + self._sync_refln_category() - def _create_refln_collection(self) -> object: + def _refln_collection_type(self) -> object: """ - Create the beam-mode-specific calculated reflection collection. + Return the reflection-collection type for this beam mode. """ beam_mode = self.type.beam_mode.value if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: - return PowderCwlReflnData() + return PowderCwlReflnData if beam_mode == BeamModeEnum.TIME_OF_FLIGHT: - return PowderTofReflnData() + return PowderTofReflnData msg = f'Unsupported beam mode for powder reflection data: {beam_mode}.' raise ValueError(msg) + def _sync_refln_category(self) -> None: + """Create or remove ``refln`` for the active calculator.""" + calculator_type = self._calculator_type or self._default_calculator_tag() + refln_collection_type = self._refln_collection_type() + calculator = CalculatorEnum(calculator_type) + if refln_collection_type.calculator_support.supports(calculator): + if not isinstance(self._refln, refln_collection_type): + self._refln = refln_collection_type() + return + + self._refln = None + + def _set_calculator_type( + self, + tag: str, + *, + announce: bool = True, + ) -> None: + """Switch calculator backend and sync ``refln`` availability.""" + super()._set_calculator_type(tag, announce=announce) + self._sync_refln_category() + def _load_ascii_data_to_experiment( self, data_path: str, @@ -146,8 +170,8 @@ def instrument(self) -> object: return self._instrument @property - def refln(self) -> object: - """Calculated reflection metadata for this experiment.""" + def refln(self) -> object | None: + """Calculated reflection metadata when supported.""" return self._refln # ------------------------------------------------------------------ diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index a609300cc..44ca62ebf 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -25,6 +25,7 @@ display = None HTML = None +from easydiffraction.display.plotters.base import DEFAULT_HEIGHT from easydiffraction.display.plotters.base import SERIES_CONFIG from easydiffraction.display.plotters.base import BraggTickSet from easydiffraction.display.plotters.base import PlotterBase @@ -49,6 +50,7 @@ NICE_AXIS_FRACTIONS = (1.0, 2.0, 5.0, 10.0) DISPLAY_TICK_FRACTIONS = (1.0, 2.0, 2.5, 4.0, 5.0, 7.5, 10.0) +PLOTLY_HEIGHT_PER_UNIT = 24 @dataclass(frozen=True) @@ -59,6 +61,8 @@ class PowderCompositeRows: normalized_heights: list[float] bragg_row: int | None residual_row: int | None + total_weight: float + baseline_weight: float class PlotlyPlotter(PlotterBase): @@ -700,31 +704,13 @@ def _get_display_tick_limit(raw_limit: float) -> float: @staticmethod def _scaled_bragg_row_height(plot_spec: PowderMeasVsCalcSpec) -> float: """ - Return Bragg-row height that preserves single-phase row spacing. - - ``plot_spec.bragg_peaks_height_fraction`` is treated as the - single-phase baseline. For multiple phases, the total Bragg-row - height grows so each phase keeps the same vertical space as in - the one-phase case. + Return Bragg-row weight for the current number of phases. """ phase_count = len(plot_spec.bragg_tick_sets) if phase_count == 0: return 0.0 - base_height = plot_spec.bragg_peaks_height_fraction - other_height = 1.0 - if plot_spec.y_resid is not None: - other_height += plot_spec.residual_height_fraction - - single_phase_normalized_height = base_height / (other_height + base_height) - target_bragg_normalized_height = phase_count * single_phase_normalized_height - - if target_bragg_normalized_height >= 1.0: - return base_height * phase_count - - return ( - target_bragg_normalized_height * other_height / (1.0 - target_bragg_normalized_height) - ) + return plot_spec.bragg_peaks_height_fraction * phase_count @staticmethod def _get_powder_composite_rows(plot_spec: PowderMeasVsCalcSpec) -> PowderCompositeRows: @@ -732,6 +718,7 @@ def _get_powder_composite_rows(plot_spec: PowderMeasVsCalcSpec) -> PowderComposi has_bragg_ticks = bool(plot_spec.bragg_tick_sets) has_residual = plot_spec.y_resid is not None row_heights = [1.0] + baseline_weight = 1.0 bragg_row = None residual_row = None next_row = 2 @@ -740,9 +727,11 @@ def _get_powder_composite_rows(plot_spec: PowderMeasVsCalcSpec) -> PowderComposi bragg_row = next_row next_row += 1 row_heights.append(PlotlyPlotter._scaled_bragg_row_height(plot_spec)) + baseline_weight += plot_spec.bragg_peaks_height_fraction if has_residual: residual_row = next_row row_heights.append(plot_spec.residual_height_fraction) + baseline_weight += plot_spec.residual_height_fraction total_height = sum(row_heights) normalized_heights = [row_height / total_height for row_height in row_heights] @@ -751,8 +740,21 @@ def _get_powder_composite_rows(plot_spec: PowderMeasVsCalcSpec) -> PowderComposi normalized_heights=normalized_heights, bragg_row=bragg_row, residual_row=residual_row, + total_weight=total_height, + baseline_weight=baseline_weight, ) + @staticmethod + def _composite_figure_height( + plot_spec: PowderMeasVsCalcSpec, + layout: PowderCompositeRows, + ) -> int: + """Return figure height scaled by Bragg-row growth.""" + base_height = DEFAULT_HEIGHT if plot_spec.height is None else plot_spec.height + base_pixels = int(base_height * PLOTLY_HEIGHT_PER_UNIT) + scaled_pixels = np.ceil(base_pixels * layout.total_weight / layout.baseline_weight) + return int(scaled_pixels) + @classmethod def _get_residual_limit(cls, plot_spec: PowderMeasVsCalcSpec) -> float: """Return a symmetric residual limit matched to the main row.""" @@ -824,6 +826,7 @@ def plot_powder_meas_vs_calc( ) fig.update_layout( + height=self._composite_figure_height(plot_spec, layout), margin={ 'autoexpand': True, 'r': 30, diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py index 1f5a30eeb..ae3679e66 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py @@ -2,9 +2,11 @@ # SPDX-License-Identifier: BSD-3-Clause import numpy as np +import pytest from easydiffraction.analysis.calculators.base import PowderReflnRecord from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory +from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum def test_powder_cwl_refln_defaults(): @@ -82,6 +84,62 @@ def test_powder_tof_refln_data_replace_from_records_sets_arrays(): np.testing.assert_allclose(refln.d_spacing, np.array([3.2])) +def test_powder_refln_is_cryspy_only(): + from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData + from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData + + assert PowderCwlReflnData.calculator_support.calculators == frozenset({CalculatorEnum.CRYSPY}) + assert PowderTofReflnData.calculator_support.calculators == frozenset({CalculatorEnum.CRYSPY}) + + +def test_powder_refln_replace_from_records_rebuilds_index_and_parents(): + from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData + + refln = PowderCwlReflnData() + refln._replace_from_records([ + PowderReflnRecord( + phase_id='alpha', + d_spacing=2.1, + sin_theta_over_lambda=0.25, + index_h=1, + index_k=0, + index_l=1, + f_calc=3.0, + f_squared_calc=9.0, + two_theta=14.5, + ) + ]) + + old_item = refln['1'] + assert old_item._parent is refln + + refln._replace_from_records([]) + + assert len(refln) == 0 + assert old_item._parent is None + with pytest.raises(KeyError): + refln['1'] + + refln._replace_from_records([ + PowderReflnRecord( + phase_id='beta', + d_spacing=1.5, + sin_theta_over_lambda=0.33, + index_h=2, + index_k=1, + index_l=0, + f_calc=4.0, + f_squared_calc=16.0, + two_theta=22.0, + ) + ]) + + new_item = refln['1'] + assert new_item is refln._items[0] + assert new_item._parent is refln + assert new_item.phase_id.value == 'beta' + + def test_powder_refln_round_trips_via_experiment_cif(): experiment = ExperimentFactory.from_scratch( name='powder', diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py index 5266095c2..d86e722c5 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py @@ -11,6 +11,7 @@ from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType from easydiffraction.datablocks.experiment.item.bragg_pd import BraggPdExperiment from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum +from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum from easydiffraction.datablocks.experiment.item.enums import RadiationProbeEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum @@ -93,6 +94,20 @@ def test_bragg_pd_experiment_creates_beam_mode_specific_refln_collection(): assert isinstance(tof_experiment.refln, PowderTofReflnData) +def test_bragg_pd_experiment_disables_refln_for_crysfml_and_restores_it_for_cryspy(): + experiment = BraggPdExperiment(name='powder', type=_mk_type_powder_cwl_bragg()) + + assert isinstance(experiment.refln, PowderCwlReflnData) + + experiment._calculator_type = CalculatorEnum.CRYSFML.value + experiment._sync_refln_category() + assert experiment.refln is None + + experiment._calculator_type = CalculatorEnum.CRYSPY.value + experiment._sync_refln_category() + assert isinstance(experiment.refln, PowderCwlReflnData) + + def test_pd_data_update_populates_and_clears_refln(): from collections import UserDict @@ -180,3 +195,51 @@ def __init__(self, name, pattern, records): experiment.data._update() assert len(experiment.refln._items) == 0 + + +def test_pd_data_update_skips_refln_records_when_category_is_disabled(): + from collections import UserDict + + class FakeStructures(UserDict): + @property + def names(self): + return list(self.data.keys()) + + class FakeCalculator: + name = 'fake' + + def calculate_pattern(self, structure, experiment, *, called_by_minimizer=False): + del experiment, called_by_minimizer + return structure.pattern + + def last_powder_refln_records(self, structure, experiment, *, phase_id): + del structure, experiment, phase_id + msg = 'Powder reflection metadata should not be requested' + raise AssertionError(msg) + + class FakeStructure: + def __init__(self, name, pattern): + self.name = name + self.pattern = pattern + + experiment = BraggPdExperiment(name='powder', type=_mk_type_powder_cwl_bragg()) + experiment.linked_phases.create(id='phase_a', scale=2.0) + experiment.data._create_items_set_xcoord_and_id(np.array([10.0, 20.0, 30.0])) + experiment.data._set_intensity_meas(np.array([100.0, 110.0, 120.0])) + + structures = FakeStructures({ + 'phase_a': FakeStructure( + 'phase_a', + np.array([1.0, 2.0, 3.0]), + ) + }) + project = type('Project', (), {'structures': structures})() + experiments = type('Experiments', (), {'_parent': project})() + experiment._parent = experiments + experiment._calculator = FakeCalculator() + experiment._refln = None + + experiment.data._update() + + np.testing.assert_allclose(experiment.data.intensity_calc, np.array([2.0, 4.0, 6.0])) + assert experiment.refln is None diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 2c7da2b82..dcc37adff 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -319,7 +319,7 @@ def fake_show_figure(self, fig): assert 'f_squared_calc: 100' in bragg_traces[0].text[0] -def test_scaled_bragg_row_height_preserves_single_phase_baseline(): +def test_scaled_bragg_row_height_scales_linearly_with_phase_count(): from easydiffraction.display.plotters.base import BraggTickSet from easydiffraction.display.plotters.base import PowderMeasVsCalcSpec from easydiffraction.display.plotters.plotly import PlotlyPlotter @@ -372,15 +372,69 @@ def test_scaled_bragg_row_height_preserves_single_phase_baseline(): single_height = PlotlyPlotter._scaled_bragg_row_height(single_phase) two_phase_height = PlotlyPlotter._scaled_bragg_row_height(two_phase) + assert single_height == pytest.approx(0.10) + assert two_phase_height == pytest.approx(0.20) - single_phase_normalized = single_height / ( - 1.0 + single_phase.residual_height_fraction + single_height - ) - two_phase_normalized_per_phase = ( - two_phase_height / (1.0 + two_phase.residual_height_fraction + two_phase_height) - ) / 2 - assert two_phase_normalized_per_phase == pytest.approx(single_phase_normalized) +def test_plot_powder_meas_vs_calc_grows_total_height_for_many_phases(monkeypatch): + import easydiffraction.display.plotters.plotly as pp + + from easydiffraction.display.plotters.base import BraggTickSet + from easydiffraction.display.plotters.base import PowderMeasVsCalcSpec + + captured = {} + + def fake_show_figure(self, fig): + captured.setdefault('figures', []).append(fig) + + monkeypatch.setattr(pp.PlotlyPlotter, '_show_figure', fake_show_figure) + + def plot_spec(phase_count: int) -> PowderMeasVsCalcSpec: + bragg_tick_sets = tuple( + BraggTickSet( + phase_id=f'phase-{idx}', + x=np.array([1.0 + idx]), + h=np.array([idx + 1]), + k=np.array([0]), + ell=np.array([1]), + f_squared_calc=np.array([100.0 - idx]), + f_calc=np.array([10.0 - 0.1 * idx]), + ) + for idx in range(phase_count) + ) + return PowderMeasVsCalcSpec( + x=np.array([1.0, 2.0, 3.0]), + y_meas=np.array([10.0, 12.0, 11.0]), + y_calc=np.array([9.0, 11.0, 10.5]), + y_resid=np.array([1.0, 1.0, 0.5]), + bragg_tick_sets=bragg_tick_sets, + axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + title='Powder', + residual_height_fraction=0.25, + bragg_peaks_height_fraction=0.10, + height=None, + ) + + plotter = pp.PlotlyPlotter() + plotter.plot_powder_meas_vs_calc(plot_spec=plot_spec(1)) + plotter.plot_powder_meas_vs_calc(plot_spec=plot_spec(10)) + + single_fig, multi_fig = captured['figures'] + + def row_height_pixels(fig, axis_name: str) -> float: + axis = getattr(fig.layout, axis_name) + return fig.layout.height * (axis.domain[1] - axis.domain[0]) + + assert multi_fig.layout.height > single_fig.layout.height + assert row_height_pixels(multi_fig, 'yaxis') == pytest.approx( + row_height_pixels(single_fig, 'yaxis') + ) + assert row_height_pixels(multi_fig, 'yaxis3') == pytest.approx( + row_height_pixels(single_fig, 'yaxis3') + ) + assert row_height_pixels(multi_fig, 'yaxis2') == pytest.approx( + row_height_pixels(single_fig, 'yaxis2') * 10 + ) def test_plot_powder_meas_vs_calc_skips_bragg_row_when_no_ticks(monkeypatch): From a245eec97b3ee97eba075a08ecacb1fbda315b75 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 14:57:04 +0200 Subject: [PATCH 20/26] Extract refln into a dedicated experiment categor --- docs/dev/architecture.md | 34 ++- docs/dev/plan_refln-category-extraction.md | 197 ++++++++++++++++++ .../analysis/calculators/cryspy.py | 2 +- .../analysis/fit_helpers/metrics.py | 7 +- src/easydiffraction/analysis/fitting.py | 7 +- .../experiment/categories/data/__init__.py | 3 - .../experiment/categories/data/factory.py | 4 - .../experiment/categories/refln/__init__.py | 14 ++ .../{data/refln_pd.py => refln/bragg_pd.py} | 5 +- .../categories/{data => refln}/bragg_sc.py | 4 +- .../experiment/categories/refln/factory.py | 39 ++++ .../datablocks/experiment/item/base.py | 50 +++-- .../datablocks/experiment/item/bragg_pd.py | 27 +-- .../datablocks/experiment/item/bragg_sc.py | 22 +- src/easydiffraction/display/plotting.py | 8 +- .../categories/data/test_bragg_sc.py | 8 +- .../categories/data/test_factory.py | 15 +- .../categories/data/test_refln_pd.py | 22 +- .../experiment/item/test_bragg_pd.py | 8 +- .../experiment/item/test_bragg_sc_coverage.py | 4 +- 20 files changed, 384 insertions(+), 96 deletions(-) create mode 100644 docs/dev/plan_refln-category-extraction.md create mode 100644 src/easydiffraction/datablocks/experiment/categories/refln/__init__.py rename src/easydiffraction/datablocks/experiment/categories/{data/refln_pd.py => refln/bragg_pd.py} (97%) rename src/easydiffraction/datablocks/experiment/categories/{data => refln}/bragg_sc.py (99%) create mode 100644 src/easydiffraction/datablocks/experiment/categories/refln/factory.py diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index c3ed05789..6cb8c67e2 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -286,7 +286,8 @@ experiment.linked_phases # CategoryCollection experiment.excluded_regions # CategoryCollection experiment.instrument # CategoryItem experiment.peak # CategoryItem -experiment.data # CategoryCollection +experiment.data # CategoryCollection (powder / total only) +experiment.refln # CategoryCollection (Bragg powder + single crystal) # Type-switchable — recreates the underlying object experiment.background_type = 'chebyshev' # triggers BackgroundFactory.create(...) @@ -461,7 +462,8 @@ from .line_segment import LineSegmentBackground | `BackgroundFactory` | Background categories | `LineSegmentBackground`, `ChebyshevPolynomialBackground` | | `PeakFactory` | Peak profiles | `CwlPseudoVoigt`, `TofJorgensen`, `TofJorgensenVonDreele`, … | | `InstrumentFactory` | Instruments | `CwlPdInstrument`, `TofPdInstrument`, … | -| `DataFactory` | Data collections | `PdCwlData`, `PdTofData`, `ReflnData`, `TotalData` | +| `DataFactory` | Data collections | `PdCwlData`, `PdTofData`, `TotalData` | +| `ReflnFactory` | Reflection collections | `ReflnData`, `PowderCwlReflnData`, `PowderTofReflnData` | | `ExtinctionFactory` | Extinction models | `BeckerCoppensExtinction` | | `LinkedCrystalFactory` | Linked-crystal refs | `LinkedCrystal` | | `ExcludedRegionsFactory` | Excluded regions | `ExcludedRegions` | @@ -552,11 +554,18 @@ the choice. | Tag | Class | | -------------- | ----------- | -| `bragg-pd-cwl` | `PdCwlData` | +| `bragg-pd` | `PdCwlData` | | `bragg-pd-tof` | `PdTofData` | -| `bragg-sc` | `ReflnData` | | `total-pd` | `TotalData` | +**Refln tags** + +| Tag | Class | +| -------------------- | -------------------- | +| `bragg-sc` | `ReflnData` | +| `bragg-pd-refln` | `PowderCwlReflnData` | +| `bragg-pd-tof-refln` | `PowderTofReflnData` | + **Extinction tags** | Tag | Class | @@ -650,7 +659,9 @@ line-segment points. | `PdCwlData` | `DataFactory` | | `PdTofData` | `DataFactory` | | `TotalData` | `DataFactory` | -| `ReflnData` | `DataFactory` | +| `ReflnData` | `ReflnFactory` | +| `PowderCwlReflnData` | `ReflnFactory` | +| `PowderTofReflnData` | `ReflnFactory` | | `ExcludedRegions` | `ExcludedRegionsFactory` | | `LinkedPhases` | `LinkedPhasesFactory` | | `AtomSites` | `AtomSitesFactory` | @@ -700,8 +711,9 @@ line-segment points. The calculator performs the actual diffraction computation. It is attached per-experiment on the `ExperimentBase` object. Each experiment -auto-resolves its calculator on first access based on the data -category's `calculator_support` metadata and +auto-resolves its calculator on first access based on the experiment's +active support category (`data` for powder, `refln` for Bragg +single-crystal) `calculator_support` metadata and `CalculatorFactory._default_rules`. The `CalculatorFactory` filters its registry by `engine_imported` (whether the third-party library is available in the environment). @@ -712,8 +724,8 @@ The experiment exposes a dedicated `calculation` category: backend tag - `calculation.calculator` — read-only access to the live backend instance -- `calculation.show_calculator_types()` — filtered by data category - support and marks the current type +- `calculation.show_calculator_types()` — filtered by the active support + category and marks the current type ### 6.2 Minimiser @@ -1067,7 +1079,7 @@ Categories that are **fixed at creation** (determined by the experiment type and never changed) expose only a read-only `` property with no `_type` getter, setter, or show methods: -- **Experiment:** `instrument`, `data`. +- **Experiment:** `instrument`, `data`, `refln`. For categories with **only one implementation** (single-type), the `_type` getter, setter, and show methods are omitted from the public API @@ -1140,7 +1152,7 @@ project.display.show_tabler_types() ``` Available calculators are filtered by `engine_imported` (whether the -library is installed) and by the experiment's data category +library is installed) and by the experiment's active support category `calculator_support` metadata. ### 9.6 Enums for Finite Value Sets diff --git a/docs/dev/plan_refln-category-extraction.md b/docs/dev/plan_refln-category-extraction.md new file mode 100644 index 000000000..dbb66ac3e --- /dev/null +++ b/docs/dev/plan_refln-category-extraction.md @@ -0,0 +1,197 @@ +# Plan: extract `refln` into a dedicated experiment category + +## Problem + +The `_refln` CIF category is currently split across two ownership +models: + +- single-crystal Bragg experiments expose reflections through + `experiment.data` via + `src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py` +- powder Bragg experiments expose measured pattern points through + `experiment.data` and calculated reflections through + `experiment.refln` via + `src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py` + +This is inconsistent with the domain model and with the architecture +rule that categories are flat sibling children of the experiment. The +target state is: + +- single-crystal Bragg experiments own **only** `refln` +- powder Bragg experiments own both `data` and `refln` +- all reflection implementations live under a dedicated + `categories/refln/` package + +## Assumptions + +- This is a **hard API transition** for single-crystal Bragg + experiments: callers move from `experiment.data` to + `experiment.refln`. No compatibility alias is planned. +- Total-scattering powder experiments remain unchanged. +- The pending powder `refln` refresh fix should be folded into the move, + so `refln` collection semantics are corrected in the new package + rather than patched twice. + +## Target architecture + +1. Add a new + `src/easydiffraction/datablocks/experiment/categories/refln/` package + with explicit `__init__.py` imports and a `ReflnFactory`. +2. Move both current reflection implementations into that package: + - single-crystal `Refln` / `ReflnData` + - powder `PowderReflnBase`, `PowderCwlReflnData`, + `PowderTofReflnData`, and related item classes +3. Make `refln` a first-class experiment sibling category: + - `ScExperimentBase` owns `_refln` + - `BraggPdExperiment` owns `_refln` +4. Keep `data` focused on measured point collections only: + - powder Bragg and total scattering still use `data` + - single-crystal Bragg no longer uses `DataFactory` +5. Generalize calculator-support discovery so it does not assume + `experiment._data` always exists. + +## Status checklist + +- [x] Phase 1 — create `categories/refln/` package and factory +- [x] Phase 1 — move single-crystal reflection classes out of `data/` +- [x] Phase 1 — move powder reflection classes out of `data/` +- [x] Phase 1 — rewire experiment bases so SC owns `refln` and powder + owns `data` + `refln` +- [x] Phase 1 — update calculator-support lookup, CIF loading, and + runtime consumers to the new ownership model +- [x] Phase 1 — fold the powder `refln` refresh/index-parent fix into + the migrated implementation +- [x] Phase 1 — update docs, tutorials, and code references from + single-crystal `experiment.data` to `experiment.refln` +- [x] Phase 1 — stop for review before verification work +- [ ] Phase 2 — add/update tests for factories, experiment wiring, CIF + round-trip, plotting, and migrated regressions +- [ ] Phase 2 — run `pixi run fix` +- [ ] Phase 2 — run `pixi run check` +- [ ] Phase 2 — run `pixi run unit-tests` +- [ ] Phase 2 — run `pixi run integration-tests` +- [ ] Phase 2 — run `pixi run script-tests` + +## Phase 1 — Implementation + +### 1. Create the dedicated `refln` category package + +Create a new package under `categories/refln/` following the repo’s +factory-based category pattern. The package should include: + +- `factory.py` with `ReflnFactory` +- `__init__.py` importing all concrete classes to trigger registration +- concrete modules for single-crystal and powder reflection collections +- shared base helpers only where reuse is real + +The exact file split should mirror existing category packages rather +than leaving all implementations in one large module. + +### 2. Move single-crystal reflections out of `data` + +Extract `Refln` and `ReflnData` from `categories/data/bragg_sc.py` into +the new `categories/refln/` package. Preserve: + +- CIF names (`_refln.*`) +- compatibility metadata +- calculator support metadata +- category identity (`category_code == 'refln'`) + +After the move, `data/bragg_sc.py` should no longer define the +single-crystal reflection collection. + +### 3. Move powder reflections out of `data` + +Extract `refln_pd.py` into the new `categories/refln/` package and keep +the current powder-specific distinction between CWL and TOF reflection +collections. While touching this code, implement the already-identified +refresh fix so replacement: + +- detaches old item parents +- attaches new item parents +- rebuilds the keyed collection index + +### 4. Rewire experiment ownership + +Update experiment bases so ownership matches the domain model: + +- `ScExperimentBase` creates `_refln` via `ReflnFactory` and exposes a + public read-only `refln` property +- `ScExperimentBase` stops creating `_data` for Bragg single-crystal + experiments +- `BraggPdExperiment` creates `_refln` via `ReflnFactory` instead of + importing powder reflection classes directly +- powder experiments keep their existing `data` ownership unchanged + +### 5. Generalize calculator-support and runtime consumers + +The current calculator-selection path reads support from `self._data`. +That must be generalized so single-crystal Bragg experiments still +resolve supported calculators after `data` is removed. Likely update +points: + +- `ExperimentBase._supported_calculator_tags()` +- any helper that assumes every experiment has `.data` +- single-crystal ASCII loaders in `item/bragg_sc.py` +- plotting paths that currently use `experiment.data` for both powder + and single crystal + +The goal is to make single-crystal code read from `experiment.refln` +everywhere, while powder code keeps using `experiment.data` for pattern +arrays and `experiment.refln` for reflection metadata. + +### 6. Sweep docs, tutorials, and imports + +Update code, tests, and tutorial scripts so they reflect the new public +API and package locations. This includes: + +- import paths moving from `categories.data.*` to `categories.refln.*` +- factory tests that currently expect `DataFactory` to own the + single-crystal Bragg reflection collection +- single-crystal experiment tests and tutorials that refer to + `experiment.data` +- any docs that describe experiment category ownership + +At the end of Phase 1, stop for review before creating or updating +verification tests. + +## Phase 2 — Verification + +Add or update tests to cover the migration end-to-end: + +- new `ReflnFactory` behavior and registrations +- single-crystal experiment wiring (`refln` present, `data` no longer + used for Bragg SC) +- powder experiment wiring (`data` plus `refln`) +- CIF round-trip for both powder and single-crystal reflection + categories +- plotting paths that now consume single-crystal `experiment.refln` +- migrated regression coverage for powder `refln` refresh semantics + +Then run: + +- `pixi run fix` +- `pixi run check` +- `pixi run unit-tests` +- `pixi run integration-tests` +- `pixi run script-tests` + +## Likely files + +- `src/easydiffraction/datablocks/experiment/categories/refln/__init__.py` +- `src/easydiffraction/datablocks/experiment/categories/refln/factory.py` +- `src/easydiffraction/datablocks/experiment/categories/refln/...` +- `src/easydiffraction/datablocks/experiment/categories/data/__init__.py` +- `src/easydiffraction/datablocks/experiment/categories/data/factory.py` +- `src/easydiffraction/datablocks/experiment/item/base.py` +- `src/easydiffraction/datablocks/experiment/item/bragg_pd.py` +- `src/easydiffraction/datablocks/experiment/item/bragg_sc.py` +- `src/easydiffraction/display/plotting.py` +- `tests/unit/easydiffraction/datablocks/experiment/categories/...` +- `tests/unit/easydiffraction/datablocks/experiment/item/...` +- `docs/docs/tutorials/ed-14.py` +- `docs/docs/tutorials/ed-15.py` + +## Suggested branch + +`feature/refln-category-extraction` diff --git a/src/easydiffraction/analysis/calculators/cryspy.py b/src/easydiffraction/analysis/calculators/cryspy.py index d65626aa3..3c2da252c 100644 --- a/src/easydiffraction/analysis/calculators/cryspy.py +++ b/src/easydiffraction/analysis/calculators/cryspy.py @@ -1181,7 +1181,7 @@ def _cif_measured_data_sc( experiment: ExperimentBase, ) -> None: """Append single crystal measured data loop.""" - data = experiment.data + data = experiment.refln cif_lines.extend(( '', 'loop_', diff --git a/src/easydiffraction/analysis/fit_helpers/metrics.py b/src/easydiffraction/analysis/fit_helpers/metrics.py index 61700006b..eb02bbde9 100644 --- a/src/easydiffraction/analysis/fit_helpers/metrics.py +++ b/src/easydiffraction/analysis/fit_helpers/metrics.py @@ -179,9 +179,10 @@ def get_reliability_inputs( structure._update_categories() experiment._update_categories() - y_calc = experiment.data.intensity_calc - y_meas = experiment.data.intensity_meas - y_meas_su = experiment.data.intensity_meas_su + intensity_category = experiment._intensity_category() + y_calc = intensity_category.intensity_calc + y_meas = intensity_category.intensity_meas + y_meas_su = intensity_category.intensity_meas_su if y_meas is not None and y_calc is not None: # If standard uncertainty is not provided, use ones diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py index a29062037..c9b894606 100644 --- a/src/easydiffraction/analysis/fitting.py +++ b/src/easydiffraction/analysis/fitting.py @@ -225,9 +225,10 @@ def _residual_function( # Calculate the difference between measured and calculated # patterns - y_calc = experiment.data.intensity_calc - y_meas = experiment.data.intensity_meas - y_meas_su = experiment.data.intensity_meas_su + intensity_category = experiment._intensity_category() + y_calc = intensity_category.intensity_calc + y_meas = intensity_category.intensity_meas + y_meas_su = intensity_category.intensity_meas_su diff = (y_meas - y_calc) / y_meas_su # Residuals are squared before going into reduced diff --git a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py index 3c1851205..5b2a41923 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py @@ -3,7 +3,4 @@ from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdTofData -from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData -from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData -from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData diff --git a/src/easydiffraction/datablocks/experiment/categories/data/factory.py b/src/easydiffraction/datablocks/experiment/categories/data/factory.py index 703a1fe73..5785491a8 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/factory.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/factory.py @@ -30,8 +30,4 @@ class DataFactory(FactoryBase): ('sample_form', SampleFormEnum.POWDER), ('scattering_type', ScatteringTypeEnum.TOTAL), }): 'total-pd', - frozenset({ - ('sample_form', SampleFormEnum.SINGLE_CRYSTAL), - ('scattering_type', ScatteringTypeEnum.BRAGG), - }): 'bragg-sc', } diff --git a/src/easydiffraction/datablocks/experiment/categories/refln/__init__.py b/src/easydiffraction/datablocks/experiment/categories/refln/__init__.py new file mode 100644 index 000000000..8f3992353 --- /dev/null +++ b/src/easydiffraction/datablocks/experiment/categories/refln/__init__.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderCwlRefln +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderCwlReflnData, +) +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderReflnBase +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderTofRefln +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderTofReflnData, +) +from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import Refln +from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData diff --git a/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py b/src/easydiffraction/datablocks/experiment/categories/refln/bragg_pd.py similarity index 97% rename from src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py rename to src/easydiffraction/datablocks/experiment/categories/refln/bragg_pd.py index adcdac735..71d367ddd 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py +++ b/src/easydiffraction/datablocks/experiment/categories/refln/bragg_pd.py @@ -15,9 +15,10 @@ from easydiffraction.core.validation import RangeValidator from easydiffraction.core.variable import NumericDescriptor from easydiffraction.core.variable import StringDescriptor -from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ( +from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ( Refln as SingleCrystalRefln, ) +from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum @@ -237,6 +238,7 @@ def f_squared_calc(self) -> np.ndarray: return np.fromiter((item.f_squared_calc.value for item in self._items), dtype=float) +@ReflnFactory.register class PowderCwlReflnData(PowderReflnDataBase): """Calculated powder reflection collection for CWL experiments.""" @@ -268,6 +270,7 @@ def two_theta(self) -> np.ndarray: return np.fromiter((item.two_theta.value for item in self._items), dtype=float) +@ReflnFactory.register class PowderTofReflnData(PowderReflnDataBase): """Calculated powder reflection collection for TOF experiments.""" diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py b/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py similarity index 99% rename from src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py rename to src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py index 3e0669ecf..52329abb0 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py +++ b/src/easydiffraction/datablocks/experiment/categories/refln/bragg_sc.py @@ -15,7 +15,7 @@ from easydiffraction.core.validation import RegexValidator from easydiffraction.core.variable import NumericDescriptor from easydiffraction.core.variable import StringDescriptor -from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory +from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum @@ -236,7 +236,7 @@ def wavelength(self) -> NumericDescriptor: return self._wavelength -@DataFactory.register +@ReflnFactory.register class ReflnData(CategoryCollection): """Collection of reflections for single crystal diffraction data.""" diff --git a/src/easydiffraction/datablocks/experiment/categories/refln/factory.py b/src/easydiffraction/datablocks/experiment/categories/refln/factory.py new file mode 100644 index 000000000..5069b1478 --- /dev/null +++ b/src/easydiffraction/datablocks/experiment/categories/refln/factory.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Reflection collection factory — delegates to ``FactoryBase``.""" + +from __future__ import annotations + +from typing import ClassVar + +from easydiffraction.core.factory import FactoryBase +from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum +from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum +from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum + + +class ReflnFactory(FactoryBase): + """Factory for creating reflection collections.""" + + _default_rules: ClassVar[dict] = { + frozenset({ + ('sample_form', SampleFormEnum.SINGLE_CRYSTAL), + ('scattering_type', ScatteringTypeEnum.BRAGG), + ('beam_mode', BeamModeEnum.CONSTANT_WAVELENGTH), + }): 'bragg-sc', + frozenset({ + ('sample_form', SampleFormEnum.SINGLE_CRYSTAL), + ('scattering_type', ScatteringTypeEnum.BRAGG), + ('beam_mode', BeamModeEnum.TIME_OF_FLIGHT), + }): 'bragg-sc', + frozenset({ + ('sample_form', SampleFormEnum.POWDER), + ('scattering_type', ScatteringTypeEnum.BRAGG), + ('beam_mode', BeamModeEnum.CONSTANT_WAVELENGTH), + }): 'bragg-pd-refln', + frozenset({ + ('sample_form', SampleFormEnum.POWDER), + ('scattering_type', ScatteringTypeEnum.BRAGG), + ('beam_mode', BeamModeEnum.TIME_OF_FLIGHT), + }): 'bragg-pd-tof-refln', + } diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index 1a4858bb0..ca6855b63 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -24,6 +24,7 @@ LinkedPhasesFactory, ) from easydiffraction.datablocks.experiment.categories.peak.factory import PeakFactory +from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory from easydiffraction.io.cif.parse import read_cif_str from easydiffraction.io.cif.serialize import experiment_to_cif from easydiffraction.utils.logging import console @@ -199,7 +200,7 @@ def _set_calculator_type( console.print(tag) def _resolve_calculation(self) -> None: - """Auto-resolve the default calculator from data category.""" + """Auto-resolve the default calculator from category support.""" from easydiffraction.analysis.calculators.factory import CalculatorFactory # noqa: PLC0415 tag = self._default_calculator_tag() @@ -214,19 +215,28 @@ def _supported_calculator_tags(self) -> list[str]: """ Return calculator tags supported by this experiment. - Intersects the data category's ``calculator_support`` with + Intersects the active support category's ``calculator_support`` with calculators whose engines are importable. """ from easydiffraction.analysis.calculators.factory import CalculatorFactory # noqa: PLC0415 available = CalculatorFactory.supported_tags() - data = getattr(self, '_data', None) - if data is not None: - data_support = getattr(data, 'calculator_support', None) + support_category = self._calculator_support_category() + if support_category is not None: + data_support = getattr(support_category, 'calculator_support', None) if data_support and data_support.calculators: return [t for t in available if t in data_support.calculators] return available + def _calculator_support_category(self) -> object | None: + """Return the category that constrains calculator availability.""" + return None + + def _intensity_category(self) -> object: + """Return the experiment category exposing intensity arrays.""" + msg = f"Experiment '{self.name}' has no intensity category." + raise AttributeError(msg) + class ScExperimentBase(ExperimentBase): """Base class for all single crystal experiments.""" @@ -249,12 +259,12 @@ def __init__( sample_form=self.type.sample_form.value, ) self._instrument = InstrumentFactory.create(self._instrument_type) - self._data_type: str = DataFactory.default_tag( + self._refln_type: str = ReflnFactory.default_tag( sample_form=self.type.sample_form.value, beam_mode=self.type.beam_mode.value, scattering_type=self.type.scattering_type.value, ) - self._data = DataFactory.create(self._data_type) + self._refln = ReflnFactory.create(self._refln_type) self._resolve_calculation() @abstractmethod @@ -347,14 +357,18 @@ def instrument(self) -> object: """Active instrument model for this experiment.""" return self._instrument - # ------------------------------------------------------------------ - # Data (fixed at creation) - # ------------------------------------------------------------------ - @property - def data(self) -> object: - """Data collection for this experiment.""" - return self._data + def refln(self) -> object: + """Reflection collection for this experiment.""" + return self._refln + + def _calculator_support_category(self) -> object | None: + """Return the reflection collection that constrains calculators.""" + return self._refln + + def _intensity_category(self) -> object: + """Return the single-crystal reflection collection.""" + return self._refln class PdExperimentBase(ExperimentBase): @@ -459,6 +473,14 @@ def data(self) -> object: """Data collection for this experiment.""" return self._data + def _calculator_support_category(self) -> object | None: + """Return the powder data collection that constrains calculators.""" + return self._data + + def _intensity_category(self) -> object: + """Return the powder intensity data collection.""" + return self._data + @property def peak(self) -> object: """Peak category object with profile parameters and mixins.""" diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py index fbd480565..da88b8206 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py @@ -10,8 +10,7 @@ from easydiffraction.core.metadata import Compatibility from easydiffraction.core.metadata import TypeInfo from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory -from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData -from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData +from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory from easydiffraction.datablocks.experiment.item.base import PdExperimentBase from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum @@ -68,18 +67,22 @@ def __init__( self._refln = None self._sync_refln_category() - def _refln_collection_type(self) -> object: + def _refln_collection_tag(self) -> str: """ - Return the reflection-collection type for this beam mode. + Return the reflection-collection tag for this beam mode. """ - beam_mode = self.type.beam_mode.value - if beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH: - return PowderCwlReflnData - if beam_mode == BeamModeEnum.TIME_OF_FLIGHT: - return PowderTofReflnData + return ReflnFactory.default_tag( + sample_form=self.type.sample_form.value, + beam_mode=self.type.beam_mode.value, + scattering_type=self.type.scattering_type.value, + ) - msg = f'Unsupported beam mode for powder reflection data: {beam_mode}.' - raise ValueError(msg) + def _refln_collection_type(self) -> type[object]: + """ + Return the reflection-collection type for this beam mode. + """ + refln_tag = self._refln_collection_tag() + return ReflnFactory._supported_map()[refln_tag] def _sync_refln_category(self) -> None: """Create or remove ``refln`` for the active calculator.""" @@ -88,7 +91,7 @@ def _sync_refln_category(self) -> None: calculator = CalculatorEnum(calculator_type) if refln_collection_type.calculator_support.supports(calculator): if not isinstance(self._refln, refln_collection_type): - self._refln = refln_collection_type() + self._refln = ReflnFactory.create(self._refln_collection_tag()) return self._refln = None diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_sc.py b/src/easydiffraction/datablocks/experiment/item/bragg_sc.py index 7d2a95469..5e0659fae 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_sc.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_sc.py @@ -47,7 +47,7 @@ def __init__( def _load_ascii_data_to_experiment(self, data_path: str) -> int: """ - Load measured data from an ASCII file into the data category. + Load measured data from an ASCII file into the refln category. The file format is space/column separated with 5 columns: ``h k l Iobs sIobs``. @@ -80,10 +80,10 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> int: integrated_intensities = data[:, 3] integrated_intensities_su = data[:, 4] - # Set the experiment data - self.data._create_items_set_hkl_and_id(indices_h, indices_k, indices_l) - self.data._set_intensity_meas(integrated_intensities) - self.data._set_intensity_meas_su(integrated_intensities_su) + # Set the experiment reflections + self.refln._create_items_set_hkl_and_id(indices_h, indices_k, indices_l) + self.refln._set_intensity_meas(integrated_intensities) + self.refln._set_intensity_meas_su(integrated_intensities_su) return len(indices_h) @@ -112,7 +112,7 @@ def __init__( def _load_ascii_data_to_experiment(self, data_path: str) -> int: """ - Load measured data from an ASCII file into the data category. + Load measured data from an ASCII file into the refln category. The file format is space/column separated with 6 columns: ``h k l Iobs sIobs wavelength``. @@ -155,10 +155,10 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> int: # Extract wavelength values wavelength = data[:, 5] - # Set the experiment data - self.data._create_items_set_hkl_and_id(indices_h, indices_k, indices_l) - self.data._set_intensity_meas(integrated_intensities) - self.data._set_intensity_meas_su(integrated_intensities_su) - self.data._set_wavelength(wavelength) + # Set the experiment reflections + self.refln._create_items_set_hkl_and_id(indices_h, indices_k, indices_l) + self.refln._set_intensity_meas(integrated_intensities) + self.refln._set_intensity_meas_su(integrated_intensities_su) + self.refln._set_wavelength(wavelength) return len(indices_h) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 5fbd94afb..59d8ff7e6 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -410,7 +410,7 @@ def plot_meas( self._update_project_categories(expt_name) experiment = self._project.experiments[expt_name] self._plot_meas_data( - experiment.data, + experiment._intensity_category(), expt_name, experiment.type, x_min=x_min, @@ -442,7 +442,7 @@ def plot_calc( self._update_project_categories(expt_name) experiment = self._project.experiments[expt_name] self._plot_calc_data( - experiment.data, + experiment._intensity_category(), expt_name, experiment.type, x_min=x_min, @@ -1134,13 +1134,13 @@ def _plot_meas_vs_calc_data( Parameters ---------- experiment : object - Experiment instance with ``.data`` and ``.type`` attributes. + Experiment instance with an intensity category and ``.type``. expt_name : str Experiment name for the title. plot_options : _MeasVsCalcPlotOptions X-range, residual, and x-axis selection options. """ - pattern = experiment.data + pattern = experiment._intensity_category() expt_type = experiment.type x_axis, _, sample_form, scattering_type, _ = self._resolve_x_axis( diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py index 534fd1923..ce535ce7f 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py @@ -5,7 +5,7 @@ def test_refln_data_point_defaults(): - from easydiffraction.datablocks.experiment.categories.data.bragg_sc import Refln + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import Refln pt = Refln() assert pt.id.value == '0' @@ -22,7 +22,7 @@ def test_refln_data_point_defaults(): def test_refln_data_collection_create_and_properties(): - from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData coll = ReflnData() @@ -66,7 +66,7 @@ def test_refln_data_collection_create_and_properties(): def test_refln_data_d_spacing_and_stol(): - from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData coll = ReflnData() h = np.array([1.0, 2.0]) @@ -86,7 +86,7 @@ def test_refln_data_d_spacing_and_stol(): def test_refln_data_type_info(): - from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData assert ReflnData.type_info.tag == 'bragg-sc' assert ReflnData.type_info.description == 'Bragg single-crystal reflection data' diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py index 5132591a1..af0096c1d 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py @@ -16,11 +16,8 @@ def test_data_factory_default_and_errors(): obj2 = DataFactory.create('bragg-pd-tof') assert obj2.__class__.__name__ == 'PdTofData' - obj3 = DataFactory.create('bragg-sc') - assert obj3.__class__.__name__ == 'ReflnData' - - obj4 = DataFactory.create('total-pd') - assert obj4.__class__.__name__ == 'TotalData' + obj3 = DataFactory.create('total-pd') + assert obj3.__class__.__name__ == 'TotalData' # Unsupported tag should raise ValueError with pytest.raises( @@ -60,13 +57,6 @@ def test_data_factory_default_tag_resolution(): ) assert tag == 'total-pd' - # Context-dependent default: single crystal - tag = DataFactory.default_tag( - sample_form=SampleFormEnum.SINGLE_CRYSTAL, - scattering_type=ScatteringTypeEnum.BRAGG, - ) - assert tag == 'bragg-sc' - def test_data_factory_supported_tags(): # Ensure concrete classes are registered @@ -75,5 +65,4 @@ def test_data_factory_supported_tags(): tags = DataFactory.supported_tags() assert 'bragg-pd' in tags assert 'bragg-pd-tof' in tags - assert 'bragg-sc' in tags assert 'total-pd' in tags diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py index ae3679e66..27ab4e533 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py @@ -10,7 +10,7 @@ def test_powder_cwl_refln_defaults(): - from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlRefln + from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderCwlRefln refln = PowderCwlRefln() @@ -24,7 +24,9 @@ def test_powder_cwl_refln_defaults(): def test_powder_cwl_refln_data_replace_from_records_sets_arrays(): - from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderCwlReflnData, + ) refln = PowderCwlReflnData() refln._replace_from_records([ @@ -61,7 +63,9 @@ def test_powder_cwl_refln_data_replace_from_records_sets_arrays(): def test_powder_tof_refln_data_replace_from_records_sets_arrays(): - from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderTofReflnData, + ) refln = PowderTofReflnData() refln._replace_from_records([ @@ -85,15 +89,21 @@ def test_powder_tof_refln_data_replace_from_records_sets_arrays(): def test_powder_refln_is_cryspy_only(): - from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData - from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderCwlReflnData, + ) + from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderTofReflnData, + ) assert PowderCwlReflnData.calculator_support.calculators == frozenset({CalculatorEnum.CRYSPY}) assert PowderTofReflnData.calculator_support.calculators == frozenset({CalculatorEnum.CRYSPY}) def test_powder_refln_replace_from_records_rebuilds_index_and_parents(): - from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderCwlReflnData, + ) refln = PowderCwlReflnData() refln._replace_from_records([ diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py index d86e722c5..2c8d17a95 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_pd.py @@ -6,9 +6,13 @@ from easydiffraction.analysis.calculators.base import PowderReflnRecord from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory -from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderCwlReflnData -from easydiffraction.datablocks.experiment.categories.data.refln_pd import PowderTofReflnData from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderCwlReflnData, +) +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( + PowderTofReflnData, +) from easydiffraction.datablocks.experiment.item.bragg_pd import BraggPdExperiment from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py index 8d86c6d94..9a196b67c 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py @@ -74,8 +74,8 @@ def test_switchable_categories(self): assert ex.linked_crystal is not None # instrument assert ex.instrument is not None - # data - assert ex.data is not None + # refln + assert ex.refln is not None def test_extinction_type_invalid(self): ex = CwlScExperiment(name='cwl_sc', type=_mk_type_sc_cwl()) From 82b48f2b5fad5bbc6f37985f78a5ed5b5de9de4f Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 15:03:55 +0200 Subject: [PATCH 21/26] Fix linting, formatting and tests --- docs/architecture/package-structure-full.md | 24 +++++++++++-------- docs/architecture/package-structure-short.md | 7 ++++-- .../experiment/categories/refln/__init__.py | 8 ++----- .../datablocks/experiment/item/base.py | 18 +++++++++----- .../datablocks/experiment/item/bragg_pd.py | 2 +- src/easydiffraction/display/plotting.py | 3 ++- 6 files changed, 36 insertions(+), 26 deletions(-) diff --git a/docs/architecture/package-structure-full.md b/docs/architecture/package-structure-full.md index d56de83d4..f3647b63f 100644 --- a/docs/architecture/package-structure-full.md +++ b/docs/architecture/package-structure-full.md @@ -170,18 +170,8 @@ │ │ │ │ │ ├── 🏷️ class PdDataBase │ │ │ │ │ ├── 🏷️ class PdCwlData │ │ │ │ │ └── 🏷️ class PdTofData -│ │ │ │ ├── 📄 bragg_sc.py -│ │ │ │ │ ├── 🏷️ class Refln -│ │ │ │ │ └── 🏷️ class ReflnData │ │ │ │ ├── 📄 factory.py │ │ │ │ │ └── 🏷️ class DataFactory -│ │ │ │ ├── 📄 refln_pd.py -│ │ │ │ │ ├── 🏷️ class PowderReflnBase -│ │ │ │ │ ├── 🏷️ class PowderCwlRefln -│ │ │ │ │ ├── 🏷️ class PowderTofRefln -│ │ │ │ │ ├── 🏷️ class PowderReflnDataBase -│ │ │ │ │ ├── 🏷️ class PowderCwlReflnData -│ │ │ │ │ └── 🏷️ class PowderTofReflnData │ │ │ │ └── 📄 total_pd.py │ │ │ │ ├── 🏷️ class TotalDataPoint │ │ │ │ ├── 🏷️ class TotalDataBase @@ -265,6 +255,20 @@ │ │ │ │ │ └── 🏷️ class TotalGaussianDampedSinc │ │ │ │ └── 📄 total_mixins.py │ │ │ │ └── 🏷️ class TotalBroadeningMixin +│ │ │ ├── 📁 refln +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 bragg_pd.py +│ │ │ │ │ ├── 🏷️ class PowderReflnBase +│ │ │ │ │ ├── 🏷️ class PowderCwlRefln +│ │ │ │ │ ├── 🏷️ class PowderTofRefln +│ │ │ │ │ ├── 🏷️ class PowderReflnDataBase +│ │ │ │ │ ├── 🏷️ class PowderCwlReflnData +│ │ │ │ │ └── 🏷️ class PowderTofReflnData +│ │ │ │ ├── 📄 bragg_sc.py +│ │ │ │ │ ├── 🏷️ class Refln +│ │ │ │ │ └── 🏷️ class ReflnData +│ │ │ │ └── 📄 factory.py +│ │ │ │ └── 🏷️ class ReflnFactory │ │ │ └── 📄 __init__.py │ │ ├── 📁 item │ │ │ ├── 📄 __init__.py diff --git a/docs/architecture/package-structure-short.md b/docs/architecture/package-structure-short.md index dc947e51d..ba2a0b33b 100644 --- a/docs/architecture/package-structure-short.md +++ b/docs/architecture/package-structure-short.md @@ -85,9 +85,7 @@ │ │ │ ├── 📁 data │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 bragg_pd.py -│ │ │ │ ├── 📄 bragg_sc.py │ │ │ │ ├── 📄 factory.py -│ │ │ │ ├── 📄 refln_pd.py │ │ │ │ └── 📄 total_pd.py │ │ │ ├── 📁 diffrn │ │ │ │ ├── 📄 __init__.py @@ -129,6 +127,11 @@ │ │ │ │ ├── 📄 tof_mixins.py │ │ │ │ ├── 📄 total.py │ │ │ │ └── 📄 total_mixins.py +│ │ │ ├── 📁 refln +│ │ │ │ ├── 📄 __init__.py +│ │ │ │ ├── 📄 bragg_pd.py +│ │ │ │ ├── 📄 bragg_sc.py +│ │ │ │ └── 📄 factory.py │ │ │ └── 📄 __init__.py │ │ ├── 📁 item │ │ │ ├── 📄 __init__.py diff --git a/src/easydiffraction/datablocks/experiment/categories/refln/__init__.py b/src/easydiffraction/datablocks/experiment/categories/refln/__init__.py index 8f3992353..ddeb357a6 100644 --- a/src/easydiffraction/datablocks/experiment/categories/refln/__init__.py +++ b/src/easydiffraction/datablocks/experiment/categories/refln/__init__.py @@ -2,13 +2,9 @@ # SPDX-License-Identifier: BSD-3-Clause from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderCwlRefln -from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( - PowderCwlReflnData, -) +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderCwlReflnData from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderReflnBase from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderTofRefln -from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import ( - PowderTofReflnData, -) +from easydiffraction.datablocks.experiment.categories.refln.bragg_pd import PowderTofReflnData from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import Refln from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ReflnData diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index ca6855b63..debb3fe76 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -215,8 +215,8 @@ def _supported_calculator_tags(self) -> list[str]: """ Return calculator tags supported by this experiment. - Intersects the active support category's ``calculator_support`` with - calculators whose engines are importable. + Intersects the active support category's ``calculator_support`` + with calculators whose engines are importable. """ from easydiffraction.analysis.calculators.factory import CalculatorFactory # noqa: PLC0415 @@ -229,8 +229,10 @@ def _supported_calculator_tags(self) -> list[str]: return available def _calculator_support_category(self) -> object | None: - """Return the category that constrains calculator availability.""" - return None + """ + Return the category that constrains calculator availability. + """ + return getattr(self, '_data', None) or getattr(self, '_refln', None) def _intensity_category(self) -> object: """Return the experiment category exposing intensity arrays.""" @@ -363,7 +365,9 @@ def refln(self) -> object: return self._refln def _calculator_support_category(self) -> object | None: - """Return the reflection collection that constrains calculators.""" + """ + Return the reflection collection that constrains calculators. + """ return self._refln def _intensity_category(self) -> object: @@ -474,7 +478,9 @@ def data(self) -> object: return self._data def _calculator_support_category(self) -> object | None: - """Return the powder data collection that constrains calculators.""" + """ + Return the powder data collection that constrains calculators. + """ return self._data def _intensity_category(self) -> object: diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py index da88b8206..4ae7f79ec 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py @@ -10,8 +10,8 @@ from easydiffraction.core.metadata import Compatibility from easydiffraction.core.metadata import TypeInfo from easydiffraction.datablocks.experiment.categories.background.factory import BackgroundFactory -from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory from easydiffraction.datablocks.experiment.categories.instrument.factory import InstrumentFactory +from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory from easydiffraction.datablocks.experiment.item.base import PdExperimentBase from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum from easydiffraction.datablocks.experiment.item.enums import CalculatorEnum diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 59d8ff7e6..0f2d48f4a 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -1134,7 +1134,8 @@ def _plot_meas_vs_calc_data( Parameters ---------- experiment : object - Experiment instance with an intensity category and ``.type``. + Experiment instance with an intensity category and + ``.type``. expt_name : str Experiment name for the title. plot_options : _MeasVsCalcPlotOptions From 4369d603fc58725297393410411af08650a8c83c Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 15:21:09 +0200 Subject: [PATCH 22/26] Fix refln category extraction --- docs/dev/plan_refln-category-extraction.md | 12 ++-- .../analysis/fit_helpers/metrics.py | 4 +- src/easydiffraction/analysis/fitting.py | 3 +- .../datablocks/experiment/item/base.py | 19 ++++++ src/easydiffraction/display/plotting.py | 7 +- .../test_bragg_pd.py} | 0 .../{data => refln}/test_bragg_sc.py | 11 --- .../categories/refln/test_factory.py | 67 +++++++++++++++++++ 8 files changed, 101 insertions(+), 22 deletions(-) rename tests/unit/easydiffraction/datablocks/experiment/categories/{data/test_refln_pd.py => refln/test_bragg_pd.py} (100%) rename tests/unit/easydiffraction/datablocks/experiment/categories/{data => refln}/test_bragg_sc.py (91%) create mode 100644 tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_factory.py diff --git a/docs/dev/plan_refln-category-extraction.md b/docs/dev/plan_refln-category-extraction.md index dbb66ac3e..6f080e4ac 100644 --- a/docs/dev/plan_refln-category-extraction.md +++ b/docs/dev/plan_refln-category-extraction.md @@ -64,13 +64,13 @@ target state is: - [x] Phase 1 — update docs, tutorials, and code references from single-crystal `experiment.data` to `experiment.refln` - [x] Phase 1 — stop for review before verification work -- [ ] Phase 2 — add/update tests for factories, experiment wiring, CIF +- [x] Phase 2 — add/update tests for factories, experiment wiring, CIF round-trip, plotting, and migrated regressions -- [ ] Phase 2 — run `pixi run fix` -- [ ] Phase 2 — run `pixi run check` -- [ ] Phase 2 — run `pixi run unit-tests` -- [ ] Phase 2 — run `pixi run integration-tests` -- [ ] Phase 2 — run `pixi run script-tests` +- [x] Phase 2 — run `pixi run fix` +- [x] Phase 2 — run `pixi run check` +- [x] Phase 2 — run `pixi run unit-tests` +- [x] Phase 2 — run `pixi run integration-tests` +- [x] Phase 2 — run `pixi run script-tests` ## Phase 1 — Implementation diff --git a/src/easydiffraction/analysis/fit_helpers/metrics.py b/src/easydiffraction/analysis/fit_helpers/metrics.py index eb02bbde9..2de2064cd 100644 --- a/src/easydiffraction/analysis/fit_helpers/metrics.py +++ b/src/easydiffraction/analysis/fit_helpers/metrics.py @@ -7,6 +7,8 @@ import numpy as np +from easydiffraction.datablocks.experiment.item.base import intensity_category_for + if TYPE_CHECKING: from easydiffraction.datablocks.experiment.item.base import ExperimentBase from easydiffraction.datablocks.structure.collection import Structures @@ -179,7 +181,7 @@ def get_reliability_inputs( structure._update_categories() experiment._update_categories() - intensity_category = experiment._intensity_category() + intensity_category = intensity_category_for(experiment) y_calc = intensity_category.intensity_calc y_meas = intensity_category.intensity_meas y_meas_su = intensity_category.intensity_meas_su diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py index c9b894606..9b0360143 100644 --- a/src/easydiffraction/analysis/fitting.py +++ b/src/easydiffraction/analysis/fitting.py @@ -12,6 +12,7 @@ from easydiffraction.analysis.minimizers.enums import MinimizerTypeEnum from easydiffraction.analysis.minimizers.factory import MinimizerFactory from easydiffraction.core.variable import Parameter +from easydiffraction.datablocks.experiment.item.base import intensity_category_for from easydiffraction.utils.enums import VerbosityEnum if TYPE_CHECKING: @@ -225,7 +226,7 @@ def _residual_function( # Calculate the difference between measured and calculated # patterns - intensity_category = experiment._intensity_category() + intensity_category = intensity_category_for(experiment) y_calc = intensity_category.intensity_calc y_meas = intensity_category.intensity_meas y_meas_su = intensity_category.intensity_meas_su diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index debb3fe76..563a62229 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -37,6 +37,25 @@ from easydiffraction.datablocks.structure.collection import Structures +def intensity_category_for(experiment: object) -> object: + """Return the category exposing measured and calculated values.""" + resolver = getattr(experiment, '_intensity_category', None) + if callable(resolver): + return resolver() + + data = getattr(experiment, 'data', None) + if data is not None: + return data + + refln = getattr(experiment, 'refln', None) + if refln is not None: + return refln + + name = getattr(experiment, 'name', type(experiment).__name__) + msg = f"Experiment '{name}' has no intensity category." + raise AttributeError(msg) + + class ExperimentBase(DatablockItem): """Base class for all experiment datablock items.""" diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 0f2d48f4a..4ed5fb863 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -16,6 +16,7 @@ import numpy as np import pandas as pd +from easydiffraction.datablocks.experiment.item.base import intensity_category_for from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum from easydiffraction.display.base import RendererBase @@ -410,7 +411,7 @@ def plot_meas( self._update_project_categories(expt_name) experiment = self._project.experiments[expt_name] self._plot_meas_data( - experiment._intensity_category(), + intensity_category_for(experiment), expt_name, experiment.type, x_min=x_min, @@ -442,7 +443,7 @@ def plot_calc( self._update_project_categories(expt_name) experiment = self._project.experiments[expt_name] self._plot_calc_data( - experiment._intensity_category(), + intensity_category_for(experiment), expt_name, experiment.type, x_min=x_min, @@ -1141,7 +1142,7 @@ def _plot_meas_vs_calc_data( plot_options : _MeasVsCalcPlotOptions X-range, residual, and x-axis selection options. """ - pattern = experiment._intensity_category() + pattern = intensity_category_for(experiment) expt_type = experiment.type x_axis, _, sample_form, scattering_type, _ = self._resolve_x_axis( diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_pd.py similarity index 100% rename from tests/unit/easydiffraction/datablocks/experiment/categories/data/test_refln_pd.py rename to tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_pd.py diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py similarity index 91% rename from tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py rename to tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py index ce535ce7f..fc7175d4a 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_bragg_sc.py @@ -26,40 +26,31 @@ def test_refln_data_collection_create_and_properties(): coll = ReflnData() - # Create items with hkl h = np.array([1.0, 2.0, 0.0]) k = np.array([0.0, 1.0, 0.0]) l = np.array([0.0, 0.0, 2.0]) coll._create_items_set_hkl_and_id(h, k, l) assert len(coll._items) == 3 - - # Check hkl arrays np.testing.assert_array_almost_equal(coll.index_h, h) np.testing.assert_array_almost_equal(coll.index_k, k) np.testing.assert_array_almost_equal(coll.index_l, l) - - # Check IDs are sequential assert coll._items[0].id.value == '1' assert coll._items[1].id.value == '2' assert coll._items[2].id.value == '3' - # Set and read measured intensities meas = np.array([50.0, 100.0, 150.0]) coll._set_intensity_meas(meas) np.testing.assert_array_almost_equal(coll.intensity_meas, meas) - # Set and read su su = np.array([5.0, 10.0, 15.0]) coll._set_intensity_meas_su(su) np.testing.assert_array_almost_equal(coll.intensity_meas_su, su) - # Set wavelength wl = np.array([0.84, 0.84, 0.84]) coll._set_wavelength(wl) np.testing.assert_array_almost_equal(coll.wavelength, wl) - # Set and read calculated intensities calc = np.array([48.0, 102.0, 148.0]) coll._set_intensity_calc(calc) np.testing.assert_array_almost_equal(coll.intensity_calc, calc) @@ -74,12 +65,10 @@ def test_refln_data_d_spacing_and_stol(): l = np.array([0.0, 0.0]) coll._create_items_set_hkl_and_id(h, k, l) - # Set d-spacing d = np.array([5.43, 2.715]) coll._set_d_spacing(d) np.testing.assert_array_almost_equal(coll.d_spacing, d) - # Set sin(theta)/lambda stol = np.array([0.092, 0.184]) coll._set_sin_theta_over_lambda(stol) np.testing.assert_array_almost_equal(coll.sin_theta_over_lambda, stol) diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_factory.py new file mode 100644 index 000000000..76ac4b49a --- /dev/null +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/refln/test_factory.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + + +def test_refln_factory_default_and_errors(): + from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory + + obj = ReflnFactory.create('bragg-sc') + assert obj.__class__.__name__ == 'ReflnData' + + obj2 = ReflnFactory.create('bragg-pd-refln') + assert obj2.__class__.__name__ == 'PowderCwlReflnData' + + obj3 = ReflnFactory.create('bragg-pd-tof-refln') + assert obj3.__class__.__name__ == 'PowderTofReflnData' + + with pytest.raises( + ValueError, + match=r"Unsupported type: 'nonexistent'\. Supported: .*", + ): + ReflnFactory.create('nonexistent') + + +def test_refln_factory_default_tag_resolution(): + from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory + from easydiffraction.datablocks.experiment.item.enums import BeamModeEnum + from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum + from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum + + tag = ReflnFactory.default_tag( + sample_form=SampleFormEnum.SINGLE_CRYSTAL, + scattering_type=ScatteringTypeEnum.BRAGG, + beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH, + ) + assert tag == 'bragg-sc' + + tag = ReflnFactory.default_tag( + sample_form=SampleFormEnum.SINGLE_CRYSTAL, + scattering_type=ScatteringTypeEnum.BRAGG, + beam_mode=BeamModeEnum.TIME_OF_FLIGHT, + ) + assert tag == 'bragg-sc' + + tag = ReflnFactory.default_tag( + sample_form=SampleFormEnum.POWDER, + scattering_type=ScatteringTypeEnum.BRAGG, + beam_mode=BeamModeEnum.CONSTANT_WAVELENGTH, + ) + assert tag == 'bragg-pd-refln' + + tag = ReflnFactory.default_tag( + sample_form=SampleFormEnum.POWDER, + scattering_type=ScatteringTypeEnum.BRAGG, + beam_mode=BeamModeEnum.TIME_OF_FLIGHT, + ) + assert tag == 'bragg-pd-tof-refln' + + +def test_refln_factory_supported_tags(): + from easydiffraction.datablocks.experiment.categories.refln.factory import ReflnFactory + + tags = ReflnFactory.supported_tags() + assert 'bragg-sc' in tags + assert 'bragg-pd-refln' in tags + assert 'bragg-pd-tof-refln' in tags From 04e908f4d5fbf2a8ceb31691454f1815552a18ff Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 15:22:52 +0200 Subject: [PATCH 23/26] Remove completed plan --- docs/dev/plan_refln-category-extraction.md | 197 --------------------- 1 file changed, 197 deletions(-) delete mode 100644 docs/dev/plan_refln-category-extraction.md diff --git a/docs/dev/plan_refln-category-extraction.md b/docs/dev/plan_refln-category-extraction.md deleted file mode 100644 index 6f080e4ac..000000000 --- a/docs/dev/plan_refln-category-extraction.md +++ /dev/null @@ -1,197 +0,0 @@ -# Plan: extract `refln` into a dedicated experiment category - -## Problem - -The `_refln` CIF category is currently split across two ownership -models: - -- single-crystal Bragg experiments expose reflections through - `experiment.data` via - `src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py` -- powder Bragg experiments expose measured pattern points through - `experiment.data` and calculated reflections through - `experiment.refln` via - `src/easydiffraction/datablocks/experiment/categories/data/refln_pd.py` - -This is inconsistent with the domain model and with the architecture -rule that categories are flat sibling children of the experiment. The -target state is: - -- single-crystal Bragg experiments own **only** `refln` -- powder Bragg experiments own both `data` and `refln` -- all reflection implementations live under a dedicated - `categories/refln/` package - -## Assumptions - -- This is a **hard API transition** for single-crystal Bragg - experiments: callers move from `experiment.data` to - `experiment.refln`. No compatibility alias is planned. -- Total-scattering powder experiments remain unchanged. -- The pending powder `refln` refresh fix should be folded into the move, - so `refln` collection semantics are corrected in the new package - rather than patched twice. - -## Target architecture - -1. Add a new - `src/easydiffraction/datablocks/experiment/categories/refln/` package - with explicit `__init__.py` imports and a `ReflnFactory`. -2. Move both current reflection implementations into that package: - - single-crystal `Refln` / `ReflnData` - - powder `PowderReflnBase`, `PowderCwlReflnData`, - `PowderTofReflnData`, and related item classes -3. Make `refln` a first-class experiment sibling category: - - `ScExperimentBase` owns `_refln` - - `BraggPdExperiment` owns `_refln` -4. Keep `data` focused on measured point collections only: - - powder Bragg and total scattering still use `data` - - single-crystal Bragg no longer uses `DataFactory` -5. Generalize calculator-support discovery so it does not assume - `experiment._data` always exists. - -## Status checklist - -- [x] Phase 1 — create `categories/refln/` package and factory -- [x] Phase 1 — move single-crystal reflection classes out of `data/` -- [x] Phase 1 — move powder reflection classes out of `data/` -- [x] Phase 1 — rewire experiment bases so SC owns `refln` and powder - owns `data` + `refln` -- [x] Phase 1 — update calculator-support lookup, CIF loading, and - runtime consumers to the new ownership model -- [x] Phase 1 — fold the powder `refln` refresh/index-parent fix into - the migrated implementation -- [x] Phase 1 — update docs, tutorials, and code references from - single-crystal `experiment.data` to `experiment.refln` -- [x] Phase 1 — stop for review before verification work -- [x] Phase 2 — add/update tests for factories, experiment wiring, CIF - round-trip, plotting, and migrated regressions -- [x] Phase 2 — run `pixi run fix` -- [x] Phase 2 — run `pixi run check` -- [x] Phase 2 — run `pixi run unit-tests` -- [x] Phase 2 — run `pixi run integration-tests` -- [x] Phase 2 — run `pixi run script-tests` - -## Phase 1 — Implementation - -### 1. Create the dedicated `refln` category package - -Create a new package under `categories/refln/` following the repo’s -factory-based category pattern. The package should include: - -- `factory.py` with `ReflnFactory` -- `__init__.py` importing all concrete classes to trigger registration -- concrete modules for single-crystal and powder reflection collections -- shared base helpers only where reuse is real - -The exact file split should mirror existing category packages rather -than leaving all implementations in one large module. - -### 2. Move single-crystal reflections out of `data` - -Extract `Refln` and `ReflnData` from `categories/data/bragg_sc.py` into -the new `categories/refln/` package. Preserve: - -- CIF names (`_refln.*`) -- compatibility metadata -- calculator support metadata -- category identity (`category_code == 'refln'`) - -After the move, `data/bragg_sc.py` should no longer define the -single-crystal reflection collection. - -### 3. Move powder reflections out of `data` - -Extract `refln_pd.py` into the new `categories/refln/` package and keep -the current powder-specific distinction between CWL and TOF reflection -collections. While touching this code, implement the already-identified -refresh fix so replacement: - -- detaches old item parents -- attaches new item parents -- rebuilds the keyed collection index - -### 4. Rewire experiment ownership - -Update experiment bases so ownership matches the domain model: - -- `ScExperimentBase` creates `_refln` via `ReflnFactory` and exposes a - public read-only `refln` property -- `ScExperimentBase` stops creating `_data` for Bragg single-crystal - experiments -- `BraggPdExperiment` creates `_refln` via `ReflnFactory` instead of - importing powder reflection classes directly -- powder experiments keep their existing `data` ownership unchanged - -### 5. Generalize calculator-support and runtime consumers - -The current calculator-selection path reads support from `self._data`. -That must be generalized so single-crystal Bragg experiments still -resolve supported calculators after `data` is removed. Likely update -points: - -- `ExperimentBase._supported_calculator_tags()` -- any helper that assumes every experiment has `.data` -- single-crystal ASCII loaders in `item/bragg_sc.py` -- plotting paths that currently use `experiment.data` for both powder - and single crystal - -The goal is to make single-crystal code read from `experiment.refln` -everywhere, while powder code keeps using `experiment.data` for pattern -arrays and `experiment.refln` for reflection metadata. - -### 6. Sweep docs, tutorials, and imports - -Update code, tests, and tutorial scripts so they reflect the new public -API and package locations. This includes: - -- import paths moving from `categories.data.*` to `categories.refln.*` -- factory tests that currently expect `DataFactory` to own the - single-crystal Bragg reflection collection -- single-crystal experiment tests and tutorials that refer to - `experiment.data` -- any docs that describe experiment category ownership - -At the end of Phase 1, stop for review before creating or updating -verification tests. - -## Phase 2 — Verification - -Add or update tests to cover the migration end-to-end: - -- new `ReflnFactory` behavior and registrations -- single-crystal experiment wiring (`refln` present, `data` no longer - used for Bragg SC) -- powder experiment wiring (`data` plus `refln`) -- CIF round-trip for both powder and single-crystal reflection - categories -- plotting paths that now consume single-crystal `experiment.refln` -- migrated regression coverage for powder `refln` refresh semantics - -Then run: - -- `pixi run fix` -- `pixi run check` -- `pixi run unit-tests` -- `pixi run integration-tests` -- `pixi run script-tests` - -## Likely files - -- `src/easydiffraction/datablocks/experiment/categories/refln/__init__.py` -- `src/easydiffraction/datablocks/experiment/categories/refln/factory.py` -- `src/easydiffraction/datablocks/experiment/categories/refln/...` -- `src/easydiffraction/datablocks/experiment/categories/data/__init__.py` -- `src/easydiffraction/datablocks/experiment/categories/data/factory.py` -- `src/easydiffraction/datablocks/experiment/item/base.py` -- `src/easydiffraction/datablocks/experiment/item/bragg_pd.py` -- `src/easydiffraction/datablocks/experiment/item/bragg_sc.py` -- `src/easydiffraction/display/plotting.py` -- `tests/unit/easydiffraction/datablocks/experiment/categories/...` -- `tests/unit/easydiffraction/datablocks/experiment/item/...` -- `docs/docs/tutorials/ed-14.py` -- `docs/docs/tutorials/ed-15.py` - -## Suggested branch - -`feature/refln-category-extraction` From 0b861b0598f072820ac2ff21c325d06b62fefb27 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 15:34:55 +0200 Subject: [PATCH 24/26] Restore refln compatibility entry points --- docs/architecture/package-structure-full.md | 1 + docs/architecture/package-structure-short.md | 1 + .../experiment/categories/data/__init__.py | 4 ++ .../experiment/categories/data/bragg_sc.py | 13 ++++++ .../experiment/categories/data/factory.py | 4 ++ .../datablocks/experiment/item/base.py | 5 +++ .../display/plotters/plotly.py | 6 ++- .../categories/data/test_bragg_sc.py | 21 ++++++++++ .../categories/data/test_factory.py | 10 +++++ .../experiment/item/test_bragg_sc_coverage.py | 1 + .../display/plotters/test_plotly.py | 42 +++++++++++++++++++ 11 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py create mode 100644 tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py diff --git a/docs/architecture/package-structure-full.md b/docs/architecture/package-structure-full.md index f3647b63f..5575430d3 100644 --- a/docs/architecture/package-structure-full.md +++ b/docs/architecture/package-structure-full.md @@ -170,6 +170,7 @@ │ │ │ │ │ ├── 🏷️ class PdDataBase │ │ │ │ │ ├── 🏷️ class PdCwlData │ │ │ │ │ └── 🏷️ class PdTofData +│ │ │ │ ├── 📄 bragg_sc.py │ │ │ │ ├── 📄 factory.py │ │ │ │ │ └── 🏷️ class DataFactory │ │ │ │ └── 📄 total_pd.py diff --git a/docs/architecture/package-structure-short.md b/docs/architecture/package-structure-short.md index ba2a0b33b..eae6a1206 100644 --- a/docs/architecture/package-structure-short.md +++ b/docs/architecture/package-structure-short.md @@ -85,6 +85,7 @@ │ │ │ ├── 📁 data │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 bragg_pd.py +│ │ │ │ ├── 📄 bragg_sc.py │ │ │ │ ├── 📄 factory.py │ │ │ │ └── 📄 total_pd.py │ │ │ ├── 📁 diffrn diff --git a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py index 5b2a41923..48f874308 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py @@ -1,6 +1,10 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdTofData +from easydiffraction.datablocks.experiment.categories.data.bragg_sc import Refln +from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py new file mode 100644 index 000000000..45ba48ed0 --- /dev/null +++ b/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Compatibility exports for single-crystal reflection data.""" + +from __future__ import annotations + +from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory +from easydiffraction.datablocks.experiment.categories.refln import bragg_sc as refln_bragg_sc + +Refln = refln_bragg_sc.Refln +ReflnData = refln_bragg_sc.ReflnData + +DataFactory.register(ReflnData) diff --git a/src/easydiffraction/datablocks/experiment/categories/data/factory.py b/src/easydiffraction/datablocks/experiment/categories/data/factory.py index 5785491a8..388248605 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/factory.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/factory.py @@ -16,6 +16,10 @@ class DataFactory(FactoryBase): """Factory for creating diffraction data collections.""" _default_rules: ClassVar[dict] = { + frozenset({ + ('sample_form', SampleFormEnum.SINGLE_CRYSTAL), + ('scattering_type', ScatteringTypeEnum.BRAGG), + }): 'bragg-sc', frozenset({ ('sample_form', SampleFormEnum.POWDER), ('scattering_type', ScatteringTypeEnum.BRAGG), diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index 563a62229..116d25199 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -383,6 +383,11 @@ def refln(self) -> object: """Reflection collection for this experiment.""" return self._refln + @property + def data(self) -> object: + """Compatibility alias for the reflection collection.""" + return self._refln + def _calculator_support_category(self) -> object | None: """ Return the reflection collection that constrains calculators. diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index 44ca62ebf..969761528 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -750,8 +750,10 @@ def _composite_figure_height( layout: PowderCompositeRows, ) -> int: """Return figure height scaled by Bragg-row growth.""" - base_height = DEFAULT_HEIGHT if plot_spec.height is None else plot_spec.height - base_pixels = int(base_height * PLOTLY_HEIGHT_PER_UNIT) + if plot_spec.height is None: + base_pixels = DEFAULT_HEIGHT * PLOTLY_HEIGHT_PER_UNIT + else: + base_pixels = plot_spec.height scaled_pixels = np.ceil(base_pixels * layout.total_weight / layout.baseline_weight) return int(scaled_pixels) diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py new file mode 100644 index 000000000..a5b4e8c88 --- /dev/null +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory + + +def test_bragg_sc_module_reexports_single_crystal_reflection_types(): + from easydiffraction.datablocks.experiment.categories.data.bragg_sc import Refln + from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import Refln as NewRefln + from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ( + ReflnData as NewReflnData, + ) + + assert Refln is NewRefln + assert ReflnData is NewReflnData + + +def test_bragg_sc_module_registers_refln_data_with_data_factory(): + obj = DataFactory.create('bragg-sc') + assert obj.__class__.__name__ == 'ReflnData' diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py index af0096c1d..a1a455655 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py @@ -8,6 +8,9 @@ def test_data_factory_default_and_errors(): # Ensure concrete classes are registered from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory + obj0 = DataFactory.create('bragg-sc') + assert obj0.__class__.__name__ == 'ReflnData' + # Explicit type by tag obj = DataFactory.create('bragg-pd') assert obj.__class__.__name__ == 'PdCwlData' @@ -34,6 +37,12 @@ def test_data_factory_default_tag_resolution(): from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum + tag = DataFactory.default_tag( + sample_form=SampleFormEnum.SINGLE_CRYSTAL, + scattering_type=ScatteringTypeEnum.BRAGG, + ) + assert tag == 'bragg-sc' + # Context-dependent default: Bragg powder CWL tag = DataFactory.default_tag( sample_form=SampleFormEnum.POWDER, @@ -63,6 +72,7 @@ def test_data_factory_supported_tags(): from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory tags = DataFactory.supported_tags() + assert 'bragg-sc' in tags assert 'bragg-pd' in tags assert 'bragg-pd-tof' in tags assert 'total-pd' in tags diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py index 9a196b67c..a3651dfc0 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py @@ -76,6 +76,7 @@ def test_switchable_categories(self): assert ex.instrument is not None # refln assert ex.refln is not None + assert ex.data is ex.refln def test_extinction_type_invalid(self): ex = CwlScExperiment(name='cwl_sc', type=_mk_type_sc_cwl()) diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index dcc37adff..7db809f08 100644 --- a/tests/unit/easydiffraction/display/plotters/test_plotly.py +++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py @@ -437,6 +437,48 @@ def row_height_pixels(fig, axis_name: str) -> float: ) +def test_plot_powder_meas_vs_calc_uses_explicit_plotly_height_as_pixels(monkeypatch): + import easydiffraction.display.plotters.plotly as pp + + from easydiffraction.display.plotters.base import BraggTickSet + from easydiffraction.display.plotters.base import PowderMeasVsCalcSpec + + captured = {} + + def fake_show_figure(self, fig): + captured['fig'] = fig + + monkeypatch.setattr(pp.PlotlyPlotter, '_show_figure', fake_show_figure) + + plotter = pp.PlotlyPlotter() + plotter.plot_powder_meas_vs_calc( + plot_spec=PowderMeasVsCalcSpec( + x=np.array([1.0, 2.0, 3.0]), + y_meas=np.array([10.0, 12.0, 11.0]), + y_calc=np.array([9.0, 11.0, 10.5]), + y_resid=None, + bragg_tick_sets=( + BraggTickSet( + phase_id='phase-a', + x=np.array([1.5]), + h=np.array([1]), + k=np.array([0]), + ell=np.array([1]), + f_squared_calc=np.array([100.0]), + f_calc=np.array([10.0]), + ), + ), + axes_labels=['2θ (degree)', 'Intensity (arb. units)'], + title='Powder', + residual_height_fraction=0.25, + bragg_peaks_height_fraction=0.10, + height=800, + ), + ) + + assert captured['fig'].layout.height == 800 + + def test_plot_powder_meas_vs_calc_skips_bragg_row_when_no_ticks(monkeypatch): import easydiffraction.display.plotters.plotly as pp From fc561929142aca59dda73e2b7e4d299a0e1e2a1a Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 15:45:51 +0200 Subject: [PATCH 25/26] Keep refln API break and fix Plotly height --- docs/architecture/package-structure-full.md | 1 - docs/architecture/package-structure-short.md | 1 - .../experiment/categories/data/__init__.py | 4 ---- .../experiment/categories/data/bragg_sc.py | 13 ------------ .../experiment/categories/data/factory.py | 4 ---- .../datablocks/experiment/item/base.py | 5 ----- .../categories/data/test_bragg_sc.py | 21 ------------------- .../categories/data/test_factory.py | 10 --------- .../experiment/item/test_bragg_sc_coverage.py | 1 - 9 files changed, 60 deletions(-) delete mode 100644 src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py delete mode 100644 tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py diff --git a/docs/architecture/package-structure-full.md b/docs/architecture/package-structure-full.md index 5575430d3..f3647b63f 100644 --- a/docs/architecture/package-structure-full.md +++ b/docs/architecture/package-structure-full.md @@ -170,7 +170,6 @@ │ │ │ │ │ ├── 🏷️ class PdDataBase │ │ │ │ │ ├── 🏷️ class PdCwlData │ │ │ │ │ └── 🏷️ class PdTofData -│ │ │ │ ├── 📄 bragg_sc.py │ │ │ │ ├── 📄 factory.py │ │ │ │ │ └── 🏷️ class DataFactory │ │ │ │ └── 📄 total_pd.py diff --git a/docs/architecture/package-structure-short.md b/docs/architecture/package-structure-short.md index eae6a1206..ba2a0b33b 100644 --- a/docs/architecture/package-structure-short.md +++ b/docs/architecture/package-structure-short.md @@ -85,7 +85,6 @@ │ │ │ ├── 📁 data │ │ │ │ ├── 📄 __init__.py │ │ │ │ ├── 📄 bragg_pd.py -│ │ │ │ ├── 📄 bragg_sc.py │ │ │ │ ├── 📄 factory.py │ │ │ │ └── 📄 total_pd.py │ │ │ ├── 📁 diffrn diff --git a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py index 48f874308..5b2a41923 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/__init__.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/__init__.py @@ -1,10 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from __future__ import annotations - from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdCwlData from easydiffraction.datablocks.experiment.categories.data.bragg_pd import PdTofData -from easydiffraction.datablocks.experiment.categories.data.bragg_sc import Refln -from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData from easydiffraction.datablocks.experiment.categories.data.total_pd import TotalData diff --git a/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py b/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py deleted file mode 100644 index 45ba48ed0..000000000 --- a/src/easydiffraction/datablocks/experiment/categories/data/bragg_sc.py +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -"""Compatibility exports for single-crystal reflection data.""" - -from __future__ import annotations - -from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory -from easydiffraction.datablocks.experiment.categories.refln import bragg_sc as refln_bragg_sc - -Refln = refln_bragg_sc.Refln -ReflnData = refln_bragg_sc.ReflnData - -DataFactory.register(ReflnData) diff --git a/src/easydiffraction/datablocks/experiment/categories/data/factory.py b/src/easydiffraction/datablocks/experiment/categories/data/factory.py index 388248605..5785491a8 100644 --- a/src/easydiffraction/datablocks/experiment/categories/data/factory.py +++ b/src/easydiffraction/datablocks/experiment/categories/data/factory.py @@ -16,10 +16,6 @@ class DataFactory(FactoryBase): """Factory for creating diffraction data collections.""" _default_rules: ClassVar[dict] = { - frozenset({ - ('sample_form', SampleFormEnum.SINGLE_CRYSTAL), - ('scattering_type', ScatteringTypeEnum.BRAGG), - }): 'bragg-sc', frozenset({ ('sample_form', SampleFormEnum.POWDER), ('scattering_type', ScatteringTypeEnum.BRAGG), diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index 116d25199..563a62229 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -383,11 +383,6 @@ def refln(self) -> object: """Reflection collection for this experiment.""" return self._refln - @property - def data(self) -> object: - """Compatibility alias for the reflection collection.""" - return self._refln - def _calculator_support_category(self) -> object | None: """ Return the reflection collection that constrains calculators. diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py deleted file mode 100644 index a5b4e8c88..000000000 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_bragg_sc.py +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: 2026 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause - -from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory - - -def test_bragg_sc_module_reexports_single_crystal_reflection_types(): - from easydiffraction.datablocks.experiment.categories.data.bragg_sc import Refln - from easydiffraction.datablocks.experiment.categories.data.bragg_sc import ReflnData - from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import Refln as NewRefln - from easydiffraction.datablocks.experiment.categories.refln.bragg_sc import ( - ReflnData as NewReflnData, - ) - - assert Refln is NewRefln - assert ReflnData is NewReflnData - - -def test_bragg_sc_module_registers_refln_data_with_data_factory(): - obj = DataFactory.create('bragg-sc') - assert obj.__class__.__name__ == 'ReflnData' diff --git a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py index a1a455655..af0096c1d 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py +++ b/tests/unit/easydiffraction/datablocks/experiment/categories/data/test_factory.py @@ -8,9 +8,6 @@ def test_data_factory_default_and_errors(): # Ensure concrete classes are registered from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory - obj0 = DataFactory.create('bragg-sc') - assert obj0.__class__.__name__ == 'ReflnData' - # Explicit type by tag obj = DataFactory.create('bragg-pd') assert obj.__class__.__name__ == 'PdCwlData' @@ -37,12 +34,6 @@ def test_data_factory_default_tag_resolution(): from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum - tag = DataFactory.default_tag( - sample_form=SampleFormEnum.SINGLE_CRYSTAL, - scattering_type=ScatteringTypeEnum.BRAGG, - ) - assert tag == 'bragg-sc' - # Context-dependent default: Bragg powder CWL tag = DataFactory.default_tag( sample_form=SampleFormEnum.POWDER, @@ -72,7 +63,6 @@ def test_data_factory_supported_tags(): from easydiffraction.datablocks.experiment.categories.data.factory import DataFactory tags = DataFactory.supported_tags() - assert 'bragg-sc' in tags assert 'bragg-pd' in tags assert 'bragg-pd-tof' in tags assert 'total-pd' in tags diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py index a3651dfc0..9a196b67c 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc_coverage.py @@ -76,7 +76,6 @@ def test_switchable_categories(self): assert ex.instrument is not None # refln assert ex.refln is not None - assert ex.data is ex.refln def test_extinction_type_invalid(self): ex = CwlScExperiment(name='cwl_sc', type=_mk_type_sc_cwl()) From 4c55c66e29e3cd7b54aba435a9fa50e979b2a9d2 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Mon, 4 May 2026 16:50:18 +0200 Subject: [PATCH 26/26] Restore default Plotly composite height --- src/easydiffraction/display/plotting.py | 13 +++++++++++-- .../display/test_plotting_coverage.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 4ed5fb863..6ef6ef6c2 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -96,7 +96,8 @@ def __init__(self) -> None: self._x_min = DEFAULT_MIN self._x_max = DEFAULT_MAX # Chart height - self.height = DEFAULT_HEIGHT + self._height = DEFAULT_HEIGHT + self._height_is_explicit = False # Back-reference to the owning Project (set via _set_project) self._project = None @@ -365,8 +366,16 @@ def height(self, value: object) -> None: """ if value is not None: self._height = value + self._height_is_explicit = True else: self._height = DEFAULT_HEIGHT + self._height_is_explicit = False + + def _composite_plot_height(self) -> int | None: + """Return explicit composite height or backend default.""" + if self._height_is_explicit: + return self._height + return None # ------------------------------------------------------------------ # Public methods @@ -1277,7 +1286,7 @@ def _plot_powder_bragg_meas_vs_calc( title=title, residual_height_fraction=plot_options.residual_height_fraction, bragg_peaks_height_fraction=plot_options.bragg_peaks_height_fraction, - height=self.height, + height=self._composite_plot_height(), ) self._backend.plot_powder_meas_vs_calc(plot_spec=plot_spec) diff --git a/tests/unit/easydiffraction/display/test_plotting_coverage.py b/tests/unit/easydiffraction/display/test_plotting_coverage.py index d0d63329b..806e68da2 100644 --- a/tests/unit/easydiffraction/display/test_plotting_coverage.py +++ b/tests/unit/easydiffraction/display/test_plotting_coverage.py @@ -90,6 +90,7 @@ def test_height_setter_with_value(self): p = Plotter() p.height = 50 assert p.height == 50 + assert p._composite_plot_height() == 50 def test_height_setter_with_none_resets_default(self): from easydiffraction.display.plotters.base import DEFAULT_HEIGHT @@ -99,6 +100,15 @@ def test_height_setter_with_none_resets_default(self): p.height = 99 p.height = None assert p.height == DEFAULT_HEIGHT + assert p._composite_plot_height() is None + + def test_default_height_uses_backend_composite_default(self): + from easydiffraction.display.plotters.base import DEFAULT_HEIGHT + from easydiffraction.display.plotting import Plotter + + p = Plotter() + assert p.height == DEFAULT_HEIGHT + assert p._composite_plot_height() is None # ------------------------------------------------------------------