modacor 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. modacor/__init__.py +30 -0
  2. modacor/dataclasses/__init__.py +0 -0
  3. modacor/dataclasses/basedata.py +973 -0
  4. modacor/dataclasses/databundle.py +23 -0
  5. modacor/dataclasses/helpers.py +45 -0
  6. modacor/dataclasses/messagehandler.py +75 -0
  7. modacor/dataclasses/process_step.py +233 -0
  8. modacor/dataclasses/process_step_describer.py +146 -0
  9. modacor/dataclasses/processing_data.py +59 -0
  10. modacor/dataclasses/trace_event.py +118 -0
  11. modacor/dataclasses/uncertainty_tools.py +132 -0
  12. modacor/dataclasses/validators.py +84 -0
  13. modacor/debug/pipeline_tracer.py +548 -0
  14. modacor/io/__init__.py +33 -0
  15. modacor/io/csv/__init__.py +0 -0
  16. modacor/io/csv/csv_sink.py +114 -0
  17. modacor/io/csv/csv_source.py +210 -0
  18. modacor/io/hdf/__init__.py +27 -0
  19. modacor/io/hdf/hdf_source.py +120 -0
  20. modacor/io/io_sink.py +41 -0
  21. modacor/io/io_sinks.py +61 -0
  22. modacor/io/io_source.py +164 -0
  23. modacor/io/io_sources.py +208 -0
  24. modacor/io/processing_path.py +113 -0
  25. modacor/io/tiled/__init__.py +16 -0
  26. modacor/io/tiled/tiled_source.py +403 -0
  27. modacor/io/yaml/__init__.py +27 -0
  28. modacor/io/yaml/yaml_source.py +116 -0
  29. modacor/modules/__init__.py +53 -0
  30. modacor/modules/base_modules/__init__.py +0 -0
  31. modacor/modules/base_modules/append_processing_data.py +329 -0
  32. modacor/modules/base_modules/append_sink.py +141 -0
  33. modacor/modules/base_modules/append_source.py +181 -0
  34. modacor/modules/base_modules/bitwise_or_masks.py +113 -0
  35. modacor/modules/base_modules/combine_uncertainties.py +120 -0
  36. modacor/modules/base_modules/combine_uncertainties_max.py +105 -0
  37. modacor/modules/base_modules/divide.py +82 -0
  38. modacor/modules/base_modules/find_scale_factor1d.py +373 -0
  39. modacor/modules/base_modules/multiply.py +77 -0
  40. modacor/modules/base_modules/multiply_databundles.py +73 -0
  41. modacor/modules/base_modules/poisson_uncertainties.py +69 -0
  42. modacor/modules/base_modules/reduce_dimensionality.py +252 -0
  43. modacor/modules/base_modules/sink_processing_data.py +80 -0
  44. modacor/modules/base_modules/subtract.py +80 -0
  45. modacor/modules/base_modules/subtract_databundles.py +67 -0
  46. modacor/modules/base_modules/units_label_update.py +66 -0
  47. modacor/modules/instrument_modules/__init__.py +0 -0
  48. modacor/modules/instrument_modules/readme.md +9 -0
  49. modacor/modules/technique_modules/__init__.py +0 -0
  50. modacor/modules/technique_modules/scattering/__init__.py +0 -0
  51. modacor/modules/technique_modules/scattering/geometry_helpers.py +114 -0
  52. modacor/modules/technique_modules/scattering/index_pixels.py +492 -0
  53. modacor/modules/technique_modules/scattering/indexed_averager.py +628 -0
  54. modacor/modules/technique_modules/scattering/pixel_coordinates_3d.py +417 -0
  55. modacor/modules/technique_modules/scattering/solid_angle_correction.py +63 -0
  56. modacor/modules/technique_modules/scattering/xs_geometry.py +571 -0
  57. modacor/modules/technique_modules/scattering/xs_geometry_from_pixel_coordinates.py +293 -0
  58. modacor/runner/__init__.py +0 -0
  59. modacor/runner/pipeline.py +749 -0
  60. modacor/runner/process_step_registry.py +224 -0
  61. modacor/tests/__init__.py +27 -0
  62. modacor/tests/dataclasses/test_basedata.py +519 -0
  63. modacor/tests/dataclasses/test_basedata_operations.py +439 -0
  64. modacor/tests/dataclasses/test_basedata_to_base_units.py +57 -0
  65. modacor/tests/dataclasses/test_process_step_describer.py +73 -0
  66. modacor/tests/dataclasses/test_processstep.py +282 -0
  67. modacor/tests/debug/test_tracing_integration.py +188 -0
  68. modacor/tests/integration/__init__.py +0 -0
  69. modacor/tests/integration/test_pipeline_run.py +238 -0
  70. modacor/tests/io/__init__.py +27 -0
  71. modacor/tests/io/csv/__init__.py +0 -0
  72. modacor/tests/io/csv/test_csv_source.py +156 -0
  73. modacor/tests/io/hdf/__init__.py +27 -0
  74. modacor/tests/io/hdf/test_hdf_source.py +92 -0
  75. modacor/tests/io/test_io_sources.py +119 -0
  76. modacor/tests/io/tiled/__init__.py +12 -0
  77. modacor/tests/io/tiled/test_tiled_source.py +120 -0
  78. modacor/tests/io/yaml/__init__.py +27 -0
  79. modacor/tests/io/yaml/static_data_example.yaml +26 -0
  80. modacor/tests/io/yaml/test_yaml_source.py +47 -0
  81. modacor/tests/modules/__init__.py +27 -0
  82. modacor/tests/modules/base_modules/__init__.py +27 -0
  83. modacor/tests/modules/base_modules/test_append_processing_data.py +219 -0
  84. modacor/tests/modules/base_modules/test_append_sink.py +76 -0
  85. modacor/tests/modules/base_modules/test_append_source.py +180 -0
  86. modacor/tests/modules/base_modules/test_bitwise_or_masks.py +264 -0
  87. modacor/tests/modules/base_modules/test_combine_uncertainties.py +105 -0
  88. modacor/tests/modules/base_modules/test_combine_uncertainties_max.py +109 -0
  89. modacor/tests/modules/base_modules/test_divide.py +140 -0
  90. modacor/tests/modules/base_modules/test_find_scale_factor1d.py +220 -0
  91. modacor/tests/modules/base_modules/test_multiply.py +113 -0
  92. modacor/tests/modules/base_modules/test_multiply_databundles.py +136 -0
  93. modacor/tests/modules/base_modules/test_poisson_uncertainties.py +61 -0
  94. modacor/tests/modules/base_modules/test_reduce_dimensionality.py +358 -0
  95. modacor/tests/modules/base_modules/test_sink_processing_data.py +119 -0
  96. modacor/tests/modules/base_modules/test_subtract.py +111 -0
  97. modacor/tests/modules/base_modules/test_subtract_databundles.py +136 -0
  98. modacor/tests/modules/base_modules/test_units_label_update.py +91 -0
  99. modacor/tests/modules/technique_modules/__init__.py +0 -0
  100. modacor/tests/modules/technique_modules/scattering/__init__.py +0 -0
  101. modacor/tests/modules/technique_modules/scattering/test_geometry_helpers.py +198 -0
  102. modacor/tests/modules/technique_modules/scattering/test_index_pixels.py +426 -0
  103. modacor/tests/modules/technique_modules/scattering/test_indexed_averaging.py +559 -0
  104. modacor/tests/modules/technique_modules/scattering/test_pixel_coordinates_3d.py +282 -0
  105. modacor/tests/modules/technique_modules/scattering/test_xs_geometry_from_pixel_coordinates.py +224 -0
  106. modacor/tests/modules/technique_modules/scattering/test_xsgeometry.py +635 -0
  107. modacor/tests/requirements.txt +12 -0
  108. modacor/tests/runner/test_pipeline.py +438 -0
  109. modacor/tests/runner/test_process_step_registry.py +65 -0
  110. modacor/tests/test_import.py +43 -0
  111. modacor/tests/test_modacor.py +17 -0
  112. modacor/tests/test_units.py +79 -0
  113. modacor/units.py +97 -0
  114. modacor-1.0.0.dist-info/METADATA +482 -0
  115. modacor-1.0.0.dist-info/RECORD +120 -0
  116. modacor-1.0.0.dist-info/WHEEL +5 -0
  117. modacor-1.0.0.dist-info/licenses/AUTHORS.md +11 -0
  118. modacor-1.0.0.dist-info/licenses/LICENSE +11 -0
  119. modacor-1.0.0.dist-info/licenses/LICENSE.txt +11 -0
  120. modacor-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,571 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # /usr/bin/env python3
3
+ # -*- coding: utf-8 -*-
4
+
5
+ from __future__ import annotations
6
+
7
+ __coding__ = "utf-8"
8
+ __authors__ = ["Brian R. Pauw"] # add names to the list as appropriate
9
+ __copyright__ = "Copyright 2025, The MoDaCor team"
10
+ __date__ = "22/11/2025"
11
+ __status__ = "Development" # "Development", "Production"
12
+ # end of header and standard imports
13
+
14
+ __version__ = "20251122.1"
15
+ __all__ = ["XSGeometry"]
16
+
17
+ from pathlib import Path
18
+ from typing import Dict, Tuple
19
+
20
+ import numpy as np
21
+
22
+ from modacor import ureg
23
+ from modacor.dataclasses.basedata import BaseData
24
+ from modacor.dataclasses.helpers import basedata_from_sources
25
+ from modacor.dataclasses.messagehandler import MessageHandler
26
+ from modacor.dataclasses.process_step import ProcessStep
27
+ from modacor.dataclasses.process_step_describer import ProcessStepDescriber
28
+
29
+ # Module-level handler; facilities can swap MessageHandler implementation as needed
30
+ logger = MessageHandler(name=__name__)
31
+
32
+
33
+ class XSGeometry(ProcessStep):
34
+ """
35
+ Calculates the geometric information Q, Q0, Q1, Q2, Psi, TwoTheta, and Omega (solid angle)
36
+ for X-ray scattering data and adds them to the databundle.
37
+
38
+ Geometry model
39
+ --------------
40
+ * The last `rank_of_data` dimensions of `signal` are the detector dimensions,
41
+ ordered as (..., y, x) for 2D and (..., y) for 1D.
42
+ * `beam_center.signal` is given in [y, x] pixel coordinates for 2D,
43
+ and [y] for 1D.
44
+ * `pixel_size.signal` is [pixel_size_y, pixel_size_x] in length units / pixel.
45
+ * `pixel_size` is a BaseData vector of length 2 or 3 (length units):
46
+ - first component = pixel size along the "Q0" axis,
47
+ - second component = pixel size along the "Q1" axis,
48
+ - third component (if present) is currently unused.
49
+ * `detector_distance` and `wavelength` are BaseData scalars with length units.
50
+
51
+ All computed outputs (Q, Q0, Q1, Q2, Psi, TwoTheta, Omega) are BaseData objects.
52
+ """
53
+
54
+ documentation = ProcessStepDescriber(
55
+ calling_name="Add Q, Psi, TwoTheta, Omega", # Omega is Solid Angle
56
+ calling_id="XSGeometry",
57
+ calling_module_path=Path(__file__),
58
+ calling_version=__version__,
59
+ required_data_keys=["signal"], # list of databundle keys required by the process
60
+ arguments={
61
+ "detector_distance_source": {
62
+ "type": (str, type(None)),
63
+ "required": True,
64
+ "default": None,
65
+ "doc": "IoSources key for detector distance signal.",
66
+ },
67
+ "detector_distance_units_source": {
68
+ "type": (str, type(None)),
69
+ "default": None,
70
+ "doc": "IoSources key for detector distance units.",
71
+ },
72
+ "detector_distance_uncertainties_sources": {
73
+ "type": dict,
74
+ "default": {},
75
+ "doc": "Uncertainty sources for detector distance.",
76
+ },
77
+ "pixel_size_source": {
78
+ "type": (str, type(None)),
79
+ "required": True,
80
+ "default": None,
81
+ "doc": "IoSources key for pixel size signal.",
82
+ },
83
+ "pixel_size_units_source": {
84
+ "type": (str, type(None)),
85
+ "default": None,
86
+ "doc": "IoSources key for pixel size units.",
87
+ },
88
+ "pixel_size_uncertainties_sources": {
89
+ "type": dict,
90
+ "default": {},
91
+ "doc": "Uncertainty sources for pixel size.",
92
+ },
93
+ "beam_center_source": {
94
+ "type": (str, type(None)),
95
+ "required": True,
96
+ "default": None,
97
+ "doc": "IoSources key for beam center signal.",
98
+ },
99
+ "beam_center_units_source": {
100
+ "type": (str, type(None)),
101
+ "default": None,
102
+ "doc": "IoSources key for beam center units.",
103
+ },
104
+ "beam_center_uncertainties_sources": {
105
+ "type": dict,
106
+ "default": {},
107
+ "doc": "Uncertainty sources for beam center.",
108
+ },
109
+ "wavelength_source": {
110
+ "type": (str, type(None)),
111
+ "required": True,
112
+ "default": None,
113
+ "doc": "IoSources key for wavelength signal.",
114
+ },
115
+ "wavelength_units_source": {
116
+ "type": (str, type(None)),
117
+ "default": None,
118
+ "doc": "IoSources key for wavelength units.",
119
+ },
120
+ "wavelength_uncertainties_sources": {
121
+ "type": dict,
122
+ "default": {},
123
+ "doc": "Uncertainty sources for wavelength.",
124
+ },
125
+ },
126
+ modifies={
127
+ "Q": ["signal", "uncertainties"],
128
+ "Q0": ["signal", "uncertainties"],
129
+ "Q1": ["signal", "uncertainties"],
130
+ "Q2": ["signal", "uncertainties"],
131
+ "Psi": ["signal", "uncertainties"],
132
+ "TwoTheta": ["signal", "uncertainties"],
133
+ "Omega": ["signal", "uncertainties"],
134
+ },
135
+ step_keywords=[
136
+ "geometry",
137
+ "Q",
138
+ "Psi",
139
+ "TwoTheta",
140
+ "Solid Angle",
141
+ "Omega",
142
+ "X-ray scattering",
143
+ ],
144
+ step_doc="Add geometric information Q, Psi, TwoTheta, and Solid Angle to the data",
145
+ step_reference="DOI 10.1088/0953-8984/25/38/383201",
146
+ step_note="This calculates geometric factors relevant for X-ray scattering data",
147
+ )
148
+
149
+ # ------------------------------------------------------------------
150
+ # Small helpers: geometry loading & shape utilities
151
+ # ------------------------------------------------------------------
152
+
153
+ def _load_geometry(self) -> Dict[str, BaseData]:
154
+ """
155
+ Load all required geometry parameters as BaseData objects.
156
+
157
+ Expected configuration keys:
158
+ - detector_distance
159
+ - pixel_size
160
+ - beam_center
161
+ - wavelength
162
+ for each their *_source / *_units_source / *_uncertainties_sources.
163
+ """
164
+ geom: Dict[str, BaseData] = {}
165
+ required_keys = ["detector_distance", "pixel_size", "beam_center", "wavelength"]
166
+
167
+ logger.debug(
168
+ f"XSGeometry: loading geometry for keys {required_keys} "
169
+ f"from configuration for processing keys={self.configuration.get('with_processing_keys')}"
170
+ )
171
+
172
+ for key in required_keys:
173
+ for subkey in [f"{key}_source", f"{key}_units_source", f"{key}_uncertainties_sources"]:
174
+ if subkey not in self.configuration:
175
+ raise ValueError(f"Missing required configuration parameter: {subkey}")
176
+ geom[key] = basedata_from_sources(
177
+ io_sources=self.io_sources,
178
+ signal_source=self.configuration.get(f"{key}_source"),
179
+ units_source=self.configuration.get(f"{key}_units_source", None),
180
+ uncertainty_sources=self.configuration.get(f"{key}_uncertainties_sources", {}),
181
+ )
182
+
183
+ logger.debug(
184
+ "XSGeometry: loaded geometry BaseData objects: "
185
+ + ", ".join(f"{k}: shape={v.signal.shape}, units={v.units}" for k, v in geom.items())
186
+ )
187
+
188
+ return geom
189
+
190
+ def _validate_geometry(
191
+ self,
192
+ geom: Dict[str, BaseData],
193
+ RoD: int,
194
+ spatial_shape: tuple[int, ...],
195
+ ) -> None:
196
+ """
197
+ Validate that geometry inputs are consistent with the detector rank.
198
+ """
199
+ beam_center_bd = geom["beam_center"]
200
+ pixel_size_bd = geom["pixel_size"]
201
+
202
+ if RoD not in (0, 1, 2):
203
+ raise NotImplementedError(f"XSGeometry supports RoD 0, 1, or 2; got RoD={RoD}.") # noqa: E702
204
+
205
+ # Beam center: for RoD>0, we expect a vector of length RoD.
206
+ if RoD > 0:
207
+ if beam_center_bd.signal.size != RoD:
208
+ raise ValueError(
209
+ f"Beam center must have {RoD} components for RoD={RoD}, got size={beam_center_bd.signal.size}."
210
+ )
211
+
212
+ # Pixel size: vector of 2 or 3 components.
213
+ if pixel_size_bd.shape not in ((2,), (3,)):
214
+ raise ValueError(f"Pixel size should be a 2D or 3D vector, got shape={pixel_size_bd.shape}.")
215
+
216
+ # Sanity check on spatial_shape vs RoD
217
+ if RoD == 1 and len(spatial_shape) != 1:
218
+ raise ValueError(f"RoD=1 expects 1D spatial shape, got {spatial_shape}.")
219
+ if RoD == 2 and len(spatial_shape) != 2:
220
+ raise ValueError(f"RoD=2 expects 2D spatial shape, got {spatial_shape}.")
221
+
222
+ if pixel_size_bd.units.is_compatible_with(ureg.m / ureg.pixel) is False:
223
+ logger.warning(
224
+ f"Pixel size units are {pixel_size_bd.units}, xs_geometry expects pixel size units compatible with"
225
+ " [m/pixel]."
226
+ )
227
+
228
+ logger.debug(
229
+ f"XSGeometry: validated geometry for RoD={RoD}, spatial_shape={spatial_shape}, "
230
+ f"beam_center.size={beam_center_bd.signal.size}, pixel_size.shape={pixel_size_bd.shape}"
231
+ )
232
+
233
+ def _make_index_basedata(
234
+ self,
235
+ shape: tuple[int, ...],
236
+ axis: int,
237
+ uncertainty_key: str = "pixel_index",
238
+ ) -> BaseData:
239
+ """
240
+ Create a BaseData representing pixel indices along a given axis.
241
+
242
+ Each index gets an uncertainty of ±0.5 pixel to reflect the
243
+ pixel-center assumption.
244
+
245
+ the indices are shifted by half a pixel to represent pixel centers.
246
+ This means if you floor a float coordinate in pixel units, you get the correct pixel index.
247
+ """
248
+ if len(shape) == 0:
249
+ signal = np.array(0.0, dtype=float)
250
+ else:
251
+ grids = np.meshgrid(
252
+ *[np.arange(n, dtype=float) + 0.5 for n in shape],
253
+ indexing="ij",
254
+ )
255
+ signal = grids[axis]
256
+
257
+ # always add half-pixel uncertainty estimate to pixel indices
258
+ uncertainties: Dict[str, np.ndarray] = {uncertainty_key: np.full_like(signal, 0.5, dtype=float)}
259
+
260
+ return BaseData(
261
+ signal=signal,
262
+ units=ureg.pixel,
263
+ uncertainties=uncertainties,
264
+ )
265
+
266
+ # ------------------------------------------------------------------
267
+ # Coordinate calculation per dimensionality
268
+ # ------------------------------------------------------------------
269
+
270
+ def _compute_coordinates(
271
+ self,
272
+ RoD: int,
273
+ spatial_shape: tuple[int, ...],
274
+ beam_center_bd: BaseData,
275
+ px0_bd: BaseData,
276
+ px1_bd: BaseData,
277
+ detector_distance_bd: BaseData,
278
+ ) -> Tuple[BaseData, BaseData, BaseData, BaseData]:
279
+ """
280
+ Compute detector-plane coordinates (x0, x1), in-plane radius r_perp,
281
+ and distance R from sample to pixel center, all as BaseData.
282
+
283
+ Returns
284
+ -------
285
+ x0_bd, x1_bd, r_perp_bd, R_bd
286
+ """
287
+ if RoD == 0:
288
+ # 0D: no spatial axes, use the detector distance directly.
289
+ x0_bd = BaseData(signal=np.array(0.0), units=px0_bd.units)
290
+ x1_bd = BaseData(signal=np.array(0.0), units=px1_bd.units)
291
+ r_perp_bd = BaseData(signal=np.array(0.0), units=px0_bd.units * ureg.pixel)
292
+ R_bd = detector_distance_bd
293
+ logger.debug("XSGeometry: RoD=0, using detector distance directly for R.")
294
+ return x0_bd, x1_bd, r_perp_bd, R_bd
295
+
296
+ if RoD == 1:
297
+ (n0,) = spatial_shape
298
+ idx0_bd = self._make_index_basedata(shape=(n0,), axis=0)
299
+
300
+ rel_idx0_bd = idx0_bd - beam_center_bd.indexed(0, rank_of_data=0)
301
+ x0_bd = rel_idx0_bd * px0_bd
302
+ x1_bd = BaseData(
303
+ signal=np.zeros_like(x0_bd.signal),
304
+ units=x0_bd.units,
305
+ )
306
+ logger.debug(
307
+ f"XSGeometry: computed 1D coordinates for shape={spatial_shape}, x0.units={x0_bd.units}, x1 is zero."
308
+ )
309
+
310
+ else: # RoD == 2
311
+ # image dimensions
312
+ n0, n1 = spatial_shape
313
+ # Axis 1 (columns) → x0, Axis 0 (rows) → x1
314
+ idx0_bd = self._make_index_basedata(shape=(n0, n1), axis=0)
315
+ idx1_bd = self._make_index_basedata(shape=(n0, n1), axis=1)
316
+
317
+ rel_idx0_bd = idx0_bd - beam_center_bd.indexed(0, rank_of_data=0)
318
+ rel_idx1_bd = idx1_bd - beam_center_bd.indexed(1, rank_of_data=0)
319
+
320
+ x0_bd = rel_idx0_bd * px0_bd
321
+ x1_bd = rel_idx1_bd * px1_bd
322
+
323
+ logger.debug(
324
+ f"XSGeometry: computed 2D coordinates for spatial_shape={spatial_shape}, "
325
+ f"x0.shape={x0_bd.signal.shape}, x1.shape={x1_bd.signal.shape}"
326
+ )
327
+
328
+ # Common for RoD = 1, 2
329
+ r_perp_bd = ((x0_bd**2) + (x1_bd**2)).sqrt()
330
+ R_bd = ((r_perp_bd**2) + (detector_distance_bd**2)).sqrt()
331
+
332
+ logger.debug(
333
+ f"XSGeometry: computed r_perp and R; r_perp.shape={r_perp_bd.signal.shape}, R.shape={R_bd.signal.shape}" # noqa: E702
334
+ )
335
+
336
+ return x0_bd, x1_bd, r_perp_bd, R_bd
337
+
338
+ # ------------------------------------------------------------------
339
+ # Derived quantities: angles, Q, Psi, solid angle
340
+ # ------------------------------------------------------------------
341
+
342
+ def _compute_angles(
343
+ self,
344
+ r_perp_bd: BaseData,
345
+ detector_distance_bd: BaseData,
346
+ ) -> Tuple[BaseData, BaseData, BaseData]:
347
+ """
348
+ Compute 2θ, θ, and sin(θ) as BaseData.
349
+ """
350
+ ratio_bd = r_perp_bd / detector_distance_bd # dimensionless
351
+ two_theta_bd = ratio_bd.arctan() # radians
352
+ theta_bd = 0.5 * two_theta_bd # radians
353
+ sin_theta_bd = theta_bd.sin() # dimensionless
354
+
355
+ logger.debug(
356
+ f"XSGeometry: computed angles; two_theta.units={two_theta_bd.units}, theta.units={theta_bd.units}" # noqa: E702
357
+ )
358
+
359
+ return two_theta_bd, theta_bd, sin_theta_bd
360
+
361
+ def _compute_Q_and_components(
362
+ self,
363
+ sin_theta_bd: BaseData,
364
+ wavelength_bd: BaseData,
365
+ x0_bd: BaseData,
366
+ x1_bd: BaseData,
367
+ r_perp_bd: BaseData,
368
+ ) -> Tuple[BaseData, BaseData, BaseData, BaseData]:
369
+ """
370
+ Compute Q magnitude and components Q0, Q1, Q2.
371
+
372
+ Uncertainties are propagated from:
373
+ - wavelength_bd (e.g. 'propagate_to_all'),
374
+ - r_perp_bd / x0_bd / x1_bd (pixel_index, pixel_size, distance, ...).
375
+
376
+ Q2 is nominally zero for a flat detector but we keep the same
377
+ uncertainty structure as Q to avoid empty/NaN uncertainty fields.
378
+ """
379
+ four_pi = 4.0 * np.pi
380
+
381
+ # Q magnitude: (4π / λ) * sin θ
382
+ Q_bd = (four_pi * sin_theta_bd) / wavelength_bd # BaseData op → uncertainty propagation
383
+
384
+ # Build a "safe" r_perp copy where zeros in the signal are replaced by 1.0,
385
+ # but keep the original uncertainties so division still propagates correctly.
386
+ safe_signal = np.where(r_perp_bd.signal == 0.0, 1.0, r_perp_bd.signal)
387
+
388
+ r_perp_safe_bd = r_perp_bd.copy()
389
+ r_perp_safe_bd.signal = safe_signal
390
+
391
+ # Direction cosines (Psi components)
392
+ dir0_bd = x0_bd / r_perp_safe_bd
393
+ dir1_bd = x1_bd / r_perp_safe_bd
394
+
395
+ # Components of Q
396
+ Q0_bd = Q_bd * dir0_bd
397
+ Q1_bd = Q_bd * dir1_bd
398
+
399
+ # Flat detector: Q2 ≡ 0 but keep same uncertainties as Q
400
+ Q2_bd = Q_bd.copy()
401
+ Q2_bd.signal = np.zeros_like(Q_bd.signal)
402
+
403
+ logger.debug(
404
+ f"XSGeometry: computed Q and components; Q.shape={Q_bd.signal.shape}, Q.units={Q_bd.units}" # noqa: E702
405
+ ) # noqa: E702
406
+ return Q_bd, Q0_bd, Q1_bd, Q2_bd
407
+
408
+ def _compute_psi(
409
+ self,
410
+ x0_bd: BaseData,
411
+ x1_bd: BaseData,
412
+ ) -> BaseData:
413
+ """
414
+ Compute azimuthal angle Psi from nominal coordinates only (no propagated uncertainty).
415
+
416
+ Psi = atan2(x1, x0)
417
+ """
418
+ psi_signal = np.arctan2(x1_bd.signal, x0_bd.signal)
419
+ psi_bd = BaseData(
420
+ signal=psi_signal,
421
+ units=ureg.radian,
422
+ )
423
+ logger.debug(f"XSGeometry: computed Psi; shape={psi_bd.signal.shape}, units={psi_bd.units}") # noqa: E702
424
+ return psi_bd
425
+
426
+ def _compute_solid_angle(
427
+ self,
428
+ R_bd: BaseData,
429
+ px0_bd: BaseData,
430
+ px1_bd: BaseData,
431
+ detector_distance_bd: BaseData,
432
+ ) -> BaseData:
433
+ """
434
+ Compute solid angle per pixel (Omega) as BaseData.
435
+
436
+ Approximation:
437
+ dΩ ≈ A * D / R³
438
+
439
+ with A = pixel area (px0 * px1), D = detector distance, R = ray length.
440
+ """
441
+ area_bd = px0_bd * px1_bd
442
+ R3_bd = R_bd**3
443
+ Omega_bd = (area_bd * detector_distance_bd) / R3_bd # dimensionless (sr)
444
+ # set units to steradian per pixel explicitly
445
+ Omega_bd.units = ureg.steradian / ureg.pixel
446
+
447
+ logger.debug(
448
+ f"XSGeometry: computed solid angle; Omega.shape={Omega_bd.signal.shape}, Omega.units={Omega_bd.units}" # noqa: E702
449
+ )
450
+
451
+ return Omega_bd
452
+
453
+ # ------------------------------------------------------------------
454
+ # Main execution methods
455
+ # ------------------------------------------------------------------
456
+
457
+ def prepare_execution(self):
458
+ """
459
+ Precalculate Q, Q0, Q1, Q2, Psi, TwoTheta, and Omega as BaseData objects and
460
+ store them in self._prepared_data.
461
+ """
462
+ super().prepare_execution()
463
+
464
+ pkey = self.configuration.get("with_processing_keys")
465
+ signal_bd: BaseData = self.processing_data[pkey[0]]["signal"]
466
+ RoD = signal_bd.rank_of_data
467
+ spatial_shape: tuple[int, ...] = signal_bd.shape[-RoD:] if RoD > 0 else ()
468
+
469
+ logger.info(f"XSGeometry: preparing execution for keys={pkey}, RoD={RoD}, spatial_shape={spatial_shape}")
470
+
471
+ # 2. Load and validate geometry
472
+ geom = self._load_geometry()
473
+ self._validate_geometry(geom, RoD, spatial_shape)
474
+
475
+ detector_distance_bd = geom["detector_distance"]
476
+ pixel_size_bd = geom["pixel_size"]
477
+ beam_center_bd = geom["beam_center"]
478
+ wavelength_bd = geom["wavelength"]
479
+
480
+ # 3. Extract pixel pitches along Q0/Q1
481
+ px0_bd = pixel_size_bd.indexed(0, rank_of_data=0)
482
+ px1_bd = pixel_size_bd.indexed(1, rank_of_data=0)
483
+
484
+ # 4. Coordinates (x0, x1, r_perp, R)
485
+ x0_bd, x1_bd, r_perp_bd, R_bd = self._compute_coordinates(
486
+ RoD=RoD,
487
+ spatial_shape=spatial_shape,
488
+ beam_center_bd=beam_center_bd,
489
+ px0_bd=px0_bd,
490
+ px1_bd=px1_bd,
491
+ detector_distance_bd=detector_distance_bd,
492
+ )
493
+
494
+ # 5. Angles: 2θ, θ, sin θ
495
+ two_theta_bd, theta_bd, sin_theta_bd = self._compute_angles(
496
+ r_perp_bd=r_perp_bd,
497
+ detector_distance_bd=detector_distance_bd,
498
+ )
499
+
500
+ # 6. Q magnitude and 7. components
501
+ Q_bd, Q0_bd, Q1_bd, Q2_bd = self._compute_Q_and_components(
502
+ sin_theta_bd=sin_theta_bd,
503
+ wavelength_bd=wavelength_bd,
504
+ x0_bd=x0_bd,
505
+ x1_bd=x1_bd,
506
+ r_perp_bd=r_perp_bd,
507
+ )
508
+
509
+ # 8. Psi
510
+ Psi_bd = self._compute_psi(
511
+ x0_bd=x0_bd,
512
+ x1_bd=x1_bd,
513
+ )
514
+
515
+ # 9. Solid angle (Omega)
516
+ Omega_bd = self._compute_solid_angle(
517
+ R_bd=R_bd,
518
+ px0_bd=px0_bd,
519
+ px1_bd=px1_bd,
520
+ detector_distance_bd=detector_distance_bd,
521
+ )
522
+
523
+ # 10. Set rank_of_data on outputs and stash in prepared_data
524
+ for bd in (Q_bd, Q0_bd, Q1_bd, Q2_bd, Psi_bd, two_theta_bd, Omega_bd):
525
+ bd.rank_of_data = RoD
526
+
527
+ self._prepared_data = {
528
+ "Q": Q_bd,
529
+ "Q0": Q0_bd,
530
+ "Q1": Q1_bd,
531
+ "Q2": Q2_bd,
532
+ "Psi": Psi_bd,
533
+ "TwoTheta": two_theta_bd,
534
+ "Omega": Omega_bd,
535
+ }
536
+
537
+ logger.info(f"XSGeometry: prepared geometry outputs for keys={pkey}: Q, Q0, Q1, Q2, Psi, TwoTheta, Omega.")
538
+
539
+ def calculate(self):
540
+ """
541
+ Add Q, Q0, Q1, Q2, Psi, TwoTheta, and Omega (solid angle) as BaseData objects
542
+ to the databundles specified in 'with_processing_keys'.
543
+ """
544
+ data = self.processing_data
545
+ output: Dict[str, object] = {}
546
+
547
+ with_keys = self.configuration.get("with_processing_keys", [])
548
+ if not with_keys:
549
+ logger.warning("XSGeometry: no with_processing_keys specified; nothing to calculate.")
550
+ else:
551
+ logger.info(f"XSGeometry: adding geometry outputs to keys={with_keys}")
552
+
553
+ for key in with_keys:
554
+ databundle = data.get(key)
555
+ if databundle is None:
556
+ logger.warning(f"XSGeometry: processing_data has no entry for key={key!r}; skipping.") # noqa: E702
557
+ continue
558
+
559
+ databundle["Q"] = self._prepared_data["Q"]
560
+ databundle["Q0"] = self._prepared_data["Q0"]
561
+ databundle["Q1"] = self._prepared_data["Q1"]
562
+ databundle["Q2"] = self._prepared_data["Q2"]
563
+ databundle["Psi"] = self._prepared_data["Psi"]
564
+ databundle["TwoTheta"] = self._prepared_data["TwoTheta"]
565
+ databundle["Omega"] = self._prepared_data["Omega"]
566
+
567
+ output[key] = databundle
568
+
569
+ logger.info(f"XSGeometry: geometry outputs attached for {len(output)} keys.")
570
+
571
+ return output