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,417 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # /usr/bin/env python3
3
+ # -*- coding: utf-8 -*-
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from modacor.dataclasses.helpers import basedata_from_sources
10
+ from modacor.dataclasses.process_step_describer import ProcessStepDescriber
11
+
12
+ __coding__ = "utf-8"
13
+ __authors__ = ["Brian R. Pauw"] # add names to the list as appropriate
14
+ __copyright__ = "Copyright 2026, The MoDaCor team"
15
+ __date__ = "04/01/2026"
16
+ __status__ = "Development"
17
+
18
+ __version__ = "20260103.1"
19
+ __all__ = ["CanonicalDetectorFrame", "PixelCoordinates3D"]
20
+
21
+ from typing import Dict, Tuple
22
+
23
+ import numpy as np
24
+
25
+ # import pint
26
+ from attrs import define
27
+
28
+ from modacor import ureg
29
+ from modacor.dataclasses.basedata import BaseData
30
+ from modacor.dataclasses.messagehandler import MessageHandler
31
+ from modacor.dataclasses.process_step import ProcessStep
32
+ from modacor.modules.technique_modules.scattering.geometry_helpers import (
33
+ prepare_static_scalar,
34
+ require_scalar,
35
+ unit_vec3,
36
+ )
37
+
38
+ logger = MessageHandler(name=__name__)
39
+
40
+
41
+ @define(frozen=True, slots=True)
42
+ class CanonicalDetectorFrame:
43
+ """
44
+ Canonical detector frame for pixel coordinate calculation.
45
+
46
+ Coordinates are in lab-frame NeXus axes (x, y, z), z= along beam, y=up, x=left when looking downstream from source.
47
+ Beam center is implicitly encoded as the lab-frame cartesian offset of the detector origin to the beam center in length units.
48
+ - det_coord_z: lab-frame z-coordinate of the beam intersection with the detector plane (x=y=0 on that plane). units of length
49
+ - det_coord_x: lab-frame x-coordinate of the detector origin. This indicates the offset of the detector origin to the beam center in the lab frame. units of length.
50
+ - det_coord_y: lab-frame y-coordinate of the detector origin. This indicates the offset of the detector origin to the beam center in the lab frame. units of length.
51
+ - e_fast/e_slow/e_normal: unit vectors in lab frame (shape (3,)), defining detector orientation.
52
+ - pixel_pitch_{slow,fast}: scalar length/pixel
53
+
54
+ Notes:
55
+ Tilt support will be integrated when needed following the NeXus pitch, yaw, roll for rotations around x, y, z.
56
+ This implementation assumes a non-moving, planar detector.
57
+ For other, instrument-specific implementations, subclass PixelCoordinates3D and replace _load_canonical_frame().
58
+ Origin of the detector is at pixel with index (0,0).
59
+ """
60
+
61
+ det_coord_z: BaseData
62
+ det_coord_x: BaseData
63
+ det_coord_y: BaseData
64
+
65
+ e_fast: np.ndarray
66
+ e_slow: np.ndarray
67
+ e_normal: np.ndarray
68
+
69
+ pixel_pitch_slow: BaseData
70
+ pixel_pitch_fast: BaseData
71
+
72
+ def __attrs_post_init__(self):
73
+ object.__setattr__(self, "det_coord_z", prepare_static_scalar(self.det_coord_z, require_units=ureg.m))
74
+ object.__setattr__(self, "det_coord_x", prepare_static_scalar(self.det_coord_x, require_units=ureg.m))
75
+ object.__setattr__(self, "det_coord_y", prepare_static_scalar(self.det_coord_y, require_units=ureg.m))
76
+
77
+ object.__setattr__(
78
+ self,
79
+ "pixel_pitch_slow",
80
+ prepare_static_scalar(self.pixel_pitch_slow, require_units=ureg.m / ureg.pixel),
81
+ )
82
+ object.__setattr__(
83
+ self,
84
+ "pixel_pitch_fast",
85
+ prepare_static_scalar(self.pixel_pitch_fast, require_units=ureg.m / ureg.pixel),
86
+ )
87
+
88
+
89
+ class PixelCoordinates3D(ProcessStep):
90
+ """
91
+ Primary arrays module: compute 3D pixel center coordinates in lab-frame NeXus-like axes.
92
+
93
+ Outputs (BaseData, length units, detector shape):
94
+ - coord_x
95
+ - coord_y
96
+ - coord_z
97
+
98
+ Notes:
99
+ - output coordinate ndim is clamped to RoD (which can never be larger than signal.ndim), so we never produce arrays larger than the detector.
100
+ - Planar detector assumed; tilt support will be implemented (following NeXus pitch, yaw, roll for rotations around x, y, z) in the future as needed.
101
+ - no sensor thickness offset applied, it is assumed the photon detection happens at the coordinates computed.
102
+ """
103
+
104
+ documentation = ProcessStepDescriber(
105
+ calling_name="Add 3D pixel coordinates (generic)",
106
+ calling_id="PixelCoordinates3D",
107
+ calling_module_path=Path(__file__),
108
+ calling_version=__version__,
109
+ required_data_keys=["signal"],
110
+ arguments={
111
+ "det_coord_z_source": {
112
+ "type": (str, type(None)),
113
+ "required": True,
114
+ "default": None,
115
+ "doc": "IoSources key for detector z-coordinate signal.",
116
+ },
117
+ "det_coord_z_units_source": {
118
+ "type": (str, type(None)),
119
+ "default": None,
120
+ "doc": "IoSources key for detector z-coordinate units.",
121
+ },
122
+ "det_coord_z_uncertainties_sources": {
123
+ "type": dict,
124
+ "default": {},
125
+ "doc": "Uncertainty sources for detector z-coordinate.",
126
+ },
127
+ "det_coord_x_source": {
128
+ "type": (str, type(None)),
129
+ "required": True,
130
+ "default": None,
131
+ "doc": "IoSources key for detector x-coordinate signal.",
132
+ },
133
+ "det_coord_x_units_source": {
134
+ "type": (str, type(None)),
135
+ "default": None,
136
+ "doc": "IoSources key for detector x-coordinate units.",
137
+ },
138
+ "det_coord_x_uncertainties_sources": {
139
+ "type": dict,
140
+ "default": {},
141
+ "doc": "Uncertainty sources for detector x-coordinate.",
142
+ },
143
+ "det_coord_y_source": {
144
+ "type": (str, type(None)),
145
+ "required": True,
146
+ "default": None,
147
+ "doc": "IoSources key for detector y-coordinate signal.",
148
+ },
149
+ "det_coord_y_units_source": {
150
+ "type": (str, type(None)),
151
+ "default": None,
152
+ "doc": "IoSources key for detector y-coordinate units.",
153
+ },
154
+ "det_coord_y_uncertainties_sources": {
155
+ "type": dict,
156
+ "default": {},
157
+ "doc": "Uncertainty sources for detector y-coordinate.",
158
+ },
159
+ "pixel_pitch_slow_source": {
160
+ "type": (str, type(None)),
161
+ "required": True,
162
+ "default": None,
163
+ "doc": "IoSources key for slow-axis pixel pitch signal.",
164
+ },
165
+ "pixel_pitch_slow_units_source": {
166
+ "type": (str, type(None)),
167
+ "default": None,
168
+ "doc": "IoSources key for slow-axis pixel pitch units.",
169
+ },
170
+ "pixel_pitch_slow_uncertainties_sources": {
171
+ "type": dict,
172
+ "default": {},
173
+ "doc": "Uncertainty sources for slow-axis pixel pitch.",
174
+ },
175
+ "pixel_pitch_fast_source": {
176
+ "type": (str, type(None)),
177
+ "required": True,
178
+ "default": None,
179
+ "doc": "IoSources key for fast-axis pixel pitch signal.",
180
+ },
181
+ "pixel_pitch_fast_units_source": {
182
+ "type": (str, type(None)),
183
+ "default": None,
184
+ "doc": "IoSources key for fast-axis pixel pitch units.",
185
+ },
186
+ "pixel_pitch_fast_uncertainties_sources": {
187
+ "type": dict,
188
+ "default": {},
189
+ "doc": "Uncertainty sources for fast-axis pixel pitch.",
190
+ },
191
+ "basis_fast": {
192
+ "type": tuple,
193
+ "default": (1.0, 0.0, 0.0),
194
+ "doc": "Basis vector for the fast detector axis.",
195
+ },
196
+ "basis_slow": {
197
+ "type": tuple,
198
+ "default": (0.0, 1.0, 0.0),
199
+ "doc": "Basis vector for the slow detector axis.",
200
+ },
201
+ "basis_normal": {
202
+ "type": tuple,
203
+ "default": (0.0, 0.0, 1.0),
204
+ "doc": "Basis vector for the detector normal.",
205
+ },
206
+ },
207
+ modifies={
208
+ "coord_x": ["signal", "uncertainties"],
209
+ "coord_y": ["signal", "uncertainties"],
210
+ "coord_z": ["signal", "uncertainties"],
211
+ },
212
+ step_keywords=["geometry", "coordinates", "detector"],
213
+ step_doc="Computes 3D pixel center coordinates in lab-frame axes.",
214
+ )
215
+
216
+ def _load_from_sources(self, key: str) -> BaseData:
217
+ return basedata_from_sources(
218
+ io_sources=self.io_sources,
219
+ signal_source=self.configuration.get(f"{key}_source"),
220
+ units_source=self.configuration.get(f"{key}_units_source", None),
221
+ uncertainty_sources=self.configuration.get(f"{key}_uncertainties_sources", {}),
222
+ )
223
+
224
+ def _load_canonical_frame(
225
+ self,
226
+ *,
227
+ RoD: int,
228
+ detector_shape: Tuple[int, ...],
229
+ reference_signal: BaseData,
230
+ ) -> CanonicalDetectorFrame:
231
+ det_coord_z = prepare_static_scalar(
232
+ self._load_from_sources("det_coord_z"), require_units=ureg.m, uncertainty_key="detector_position_jitter"
233
+ ) # scalar length
234
+ det_coord_x = prepare_static_scalar(
235
+ self._load_from_sources("det_coord_x"), require_units=ureg.m, uncertainty_key="detector_position_jitter"
236
+ ) # scalar length
237
+ det_coord_y = prepare_static_scalar(
238
+ self._load_from_sources("det_coord_y"), require_units=ureg.m, uncertainty_key="detector_position_jitter"
239
+ ) # scalar length
240
+
241
+ pitch_slow = prepare_static_scalar(
242
+ self._load_from_sources("pixel_pitch_slow"),
243
+ require_units=ureg.m / ureg.pixel,
244
+ uncertainty_key="pixel_pitch_jitter",
245
+ ) # scalar length/pixel
246
+ pitch_fast = prepare_static_scalar(
247
+ self._load_from_sources("pixel_pitch_fast"),
248
+ require_units=ureg.m / ureg.pixel,
249
+ uncertainty_key="pixel_pitch_jitter",
250
+ ) # scalar length/pixel
251
+
252
+ e_fast = unit_vec3(self.configuration.get("basis_fast", (1.0, 0.0, 0.0)), name="basis_fast")
253
+ e_slow = unit_vec3(self.configuration.get("basis_slow", (0.0, 1.0, 0.0)), name="basis_slow")
254
+ e_norm = unit_vec3(self.configuration.get("basis_normal", (0.0, 0.0, 1.0)), name="basis_normal")
255
+
256
+ return CanonicalDetectorFrame(
257
+ det_coord_z=det_coord_z,
258
+ det_coord_x=det_coord_x,
259
+ det_coord_y=det_coord_y,
260
+ e_fast=e_fast,
261
+ e_slow=e_slow,
262
+ e_normal=e_norm,
263
+ pixel_pitch_slow=pitch_slow,
264
+ pixel_pitch_fast=pitch_fast,
265
+ )
266
+
267
+ # ----------------------------
268
+ # rank/shape helpers
269
+ # ----------------------------
270
+
271
+ @staticmethod
272
+ def _detector_shape(signal_bd: BaseData, RoD: int) -> Tuple[int, ...]:
273
+ return () if RoD <= 0 else tuple(signal_bd.signal.shape[-RoD:])
274
+
275
+ @staticmethod
276
+ def _require_scalar(name: str, bd: BaseData) -> None:
277
+ if np.size(bd.signal) != 1:
278
+ raise ValueError(f"{name} must be scalar (size==1). Got shape={np.shape(bd.signal)}.")
279
+
280
+ @staticmethod
281
+ def _unit(v: np.ndarray | Tuple[float, float, float]) -> np.ndarray:
282
+ v = np.asarray(v, dtype=float).reshape(3)
283
+ n = float(np.linalg.norm(v))
284
+ if n == 0.0:
285
+ raise ValueError("basis vector must be non-zero")
286
+ return v / n
287
+
288
+ # ----------------------------
289
+ # broadcast-friendly pixel indices (pixel-center convention)
290
+ # ----------------------------
291
+
292
+ @staticmethod
293
+ def _idx_fast_1d(n_fast: int) -> BaseData:
294
+ sig = np.arange(n_fast, dtype=float) + 0.5
295
+ return BaseData(signal=sig, units=ureg.pixel, uncertainties={"pixel_index_fast": np.full_like(sig, 0.5)})
296
+
297
+ @staticmethod
298
+ def _idx_slow_2d(n_slow: int) -> BaseData:
299
+ sig = (np.arange(n_slow, dtype=float) + 0.5)[:, None]
300
+ return BaseData(signal=sig, units=ureg.pixel, uncertainties={"pixel_index_slow": np.full_like(sig, 0.5)})
301
+
302
+ @staticmethod
303
+ def _idx_fast_2d(n_fast: int) -> BaseData:
304
+ sig = (np.arange(n_fast, dtype=float) + 0.5)[None, :]
305
+ return BaseData(signal=sig, units=ureg.pixel, uncertainties={"pixel_index_fast": np.full_like(sig, 0.5)})
306
+
307
+ # ----------------------------
308
+ # core compute
309
+ # ----------------------------
310
+
311
+ def _compute_pixel_positions(
312
+ self,
313
+ *,
314
+ RoD: int,
315
+ detector_shape: Tuple[int, ...],
316
+ frame: CanonicalDetectorFrame,
317
+ ) -> Dict[str, BaseData]:
318
+ """
319
+ here:
320
+ det_coord_x/y/z represent the lab-frame position of the detector “pixel grid origin” (i.e. the corner before applying the +0.5 pixel-center shift).
321
+ Pixel centers are at (i+0.5, j+0.5) which matches the current _idx_* methods and the ±0.5 px index uncertainty.
322
+ """
323
+
324
+ # Scalars in length units (already averaged + SEM in CanonicalDetectorFrame.__attrs_post_init__)
325
+ ox = require_scalar("det_coord_x", frame.det_coord_x)
326
+ oy = require_scalar("det_coord_y", frame.det_coord_y)
327
+ oz = require_scalar("det_coord_z", frame.det_coord_z)
328
+ pitch_fast = require_scalar("pixel_pitch_fast", frame.pixel_pitch_fast)
329
+ pitch_slow = require_scalar("pixel_pitch_slow", frame.pixel_pitch_slow)
330
+
331
+ e_fast = unit_vec3(frame.e_fast, name="e_fast")
332
+ e_slow = unit_vec3(frame.e_slow, name="e_slow")
333
+ # e_normal kept for future tilt support
334
+
335
+ # RoD==0: no detector axes, just return the detector origin position as scalars
336
+ if RoD == 0:
337
+ return {"coord_x": ox, "coord_y": oy, "coord_z": oz}
338
+
339
+ # RoD==1: one detector axis ("fast")
340
+ if RoD == 1:
341
+ (n_fast,) = detector_shape
342
+ i_fast_px = self._idx_fast_1d(n_fast) # (n_fast,), centers at i+0.5
343
+
344
+ off_fast = i_fast_px * pitch_fast # length along fast axis
345
+
346
+ coord_x = ox + (off_fast * e_fast[0])
347
+ coord_y = oy + (off_fast * e_fast[1])
348
+ coord_z = oz + (off_fast * e_fast[2])
349
+ return {"coord_x": coord_x, "coord_y": coord_y, "coord_z": coord_z}
350
+
351
+ # RoD==2: (slow, fast)
352
+ if RoD != 2:
353
+ raise NotImplementedError(
354
+ f"PixelCoordinates3D: only RoD in (0, 1, 2) supported; got RoD={RoD}." # noqa: E702
355
+ )
356
+
357
+ n_slow, n_fast = detector_shape
358
+ j_slow_px = self._idx_slow_2d(n_slow) # (n_slow, 1)
359
+ i_fast_px = self._idx_fast_2d(n_fast) # (1, n_fast)
360
+
361
+ off_slow = j_slow_px * pitch_slow # (n_slow, 1) length
362
+ off_fast = i_fast_px * pitch_fast # (1, n_fast) length
363
+
364
+ # Broadcast to (n_slow, n_fast) automatically via BaseData arithmetic
365
+ coord_x = ox + (off_slow * e_slow[0]) + (off_fast * e_fast[0])
366
+ coord_y = oy + (off_slow * e_slow[1]) + (off_fast * e_fast[1])
367
+ coord_z = oz + (off_slow * e_slow[2]) + (off_fast * e_fast[2])
368
+
369
+ return {"coord_x": coord_x, "coord_y": coord_y, "coord_z": coord_z}
370
+
371
+ # ----------------------------
372
+ # ProcessStep lifecycle
373
+ # ----------------------------
374
+
375
+ def prepare_execution(self):
376
+ super().prepare_execution()
377
+
378
+ with_keys = self.configuration.get("with_processing_keys") or []
379
+ if not with_keys:
380
+ raise ValueError("PixelCoordinates3D: configuration.with_processing_keys is empty.")
381
+
382
+ ref_signal: BaseData = self.processing_data[with_keys[0]]["signal"]
383
+
384
+ RoD = ref_signal.rank_of_data
385
+ if RoD not in (0, 1, 2):
386
+ raise NotImplementedError(
387
+ f"PixelCoordinates3D: only RoD in (0, 1, 2) supported; got RoD={RoD}." # noqa: E702
388
+ )
389
+
390
+ detector_shape = self._detector_shape(ref_signal, RoD)
391
+ frame = self._load_canonical_frame(RoD=RoD, detector_shape=detector_shape, reference_signal=ref_signal)
392
+ outputs = self._compute_pixel_positions(RoD=RoD, detector_shape=detector_shape, frame=frame)
393
+
394
+ for bd in outputs.values():
395
+ bd.rank_of_data = min(RoD, int(np.ndim(bd.signal)))
396
+
397
+ self._prepared_data = {k: outputs[k] for k in ("coord_x", "coord_y", "coord_z")}
398
+
399
+ def calculate(self):
400
+ with_keys = self.configuration.get("with_processing_keys") or []
401
+ if not with_keys:
402
+ logger.warning("PixelCoordinates3D: no with_processing_keys specified; nothing to do.")
403
+ return {}
404
+
405
+ out: Dict[str, object] = {}
406
+ for key in with_keys:
407
+ bundle = self.processing_data.get(key)
408
+ if bundle is None:
409
+ logger.warning(
410
+ f"PixelCoordinates3D: processing_data has no entry for key={key!r}; skipping." # noqa: E702
411
+ ) # noqa: E702
412
+ continue
413
+ for out_key, bd in self._prepared_data.items():
414
+ bundle[out_key] = bd
415
+ out[key] = bundle
416
+
417
+ return out
@@ -0,0 +1,63 @@
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", "Armin Moser"] # add names to the list as appropriate
9
+ __copyright__ = "Copyright 2025, The MoDaCor team"
10
+ __date__ = "29/10/2025"
11
+ __status__ = "Development" # "Development", "Production"
12
+ # end of header and standard imports
13
+
14
+ __all__ = ["SolidAngleCorrection"]
15
+ __version__ = "20251029.1"
16
+
17
+ from pathlib import Path
18
+
19
+ # from modacor import ureg
20
+ # from modacor.dataclasss.basedata import BaseData
21
+ from modacor.dataclasses.databundle import DataBundle
22
+ from modacor.dataclasses.process_step import ProcessStep
23
+ from modacor.dataclasses.process_step_describer import ProcessStepDescriber
24
+
25
+
26
+ class SolidAngleCorrection(ProcessStep):
27
+ """
28
+ Normalize a signal by a solid angle "Omega" calculated using XSGeometry
29
+ """
30
+
31
+ documentation = ProcessStepDescriber(
32
+ calling_name="Solid Angle Correction",
33
+ calling_id="SolidAngleCorrection",
34
+ calling_module_path=Path(__file__),
35
+ calling_version=__version__,
36
+ required_data_keys=["signal", "Omega"],
37
+ modifies={"signal": ["signal", "uncertainties", "units"]},
38
+ arguments={
39
+ "with_processing_keys": {
40
+ "type": list,
41
+ "required": True,
42
+ "default": None,
43
+ "doc": "ProcessingData keys whose signal should be divided by Omega.",
44
+ },
45
+ },
46
+ step_keywords=["divide", "normalize", "solid angle"],
47
+ step_doc="Divide the pixels in a signal by their solid angle coverage",
48
+ step_reference="DOI 10.1088/0953-8984/25/38/383201",
49
+ step_note="""This divides the signal by the value previously calculated
50
+ using the XSGeometry module""",
51
+ )
52
+
53
+ def calculate(self) -> dict[str, DataBundle]:
54
+ output: dict[str, DataBundle] = {}
55
+
56
+ # actual work happens here:
57
+ for key in self._normalised_processing_keys():
58
+ databundle = self.processing_data.get(key)
59
+ # divide the data
60
+ # Rely on BaseData.__truediv__ for units + uncertainty propagation
61
+ databundle["signal"] /= databundle["Omega"]
62
+ output[key] = databundle
63
+ return output