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,282 @@
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 2026, The MoDaCor team"
10
+ __date__ = "06/01/2026"
11
+ __status__ = "Development" # "Development", "Production"
12
+ # end of header and standard imports
13
+ __version__ = "20260106.1"
14
+
15
+ import numpy as np
16
+ import pytest
17
+
18
+ from modacor import ureg
19
+ from modacor.dataclasses.basedata import BaseData
20
+ from modacor.dataclasses.databundle import DataBundle
21
+ from modacor.dataclasses.processing_data import ProcessingData
22
+ from modacor.io.io_sources import IoSources
23
+ from modacor.modules.technique_modules.scattering.geometry_helpers import prepare_static_scalar
24
+ from modacor.modules.technique_modules.scattering.pixel_coordinates_3d import CanonicalDetectorFrame, PixelCoordinates3D
25
+
26
+ # ----------------------------
27
+ # helpers
28
+ # ----------------------------
29
+
30
+
31
+ def _make_processing_data_2d(shape: tuple[int, int] = (11, 20), *, rod: int = 2) -> ProcessingData:
32
+ pd = ProcessingData()
33
+ b = DataBundle()
34
+ b["signal"] = BaseData(signal=np.zeros(shape, dtype=float), units=ureg.dimensionless, rank_of_data=rod)
35
+ pd["sample"] = b
36
+ return pd
37
+
38
+
39
+ def _make_frame(
40
+ *,
41
+ det_x: BaseData,
42
+ det_y: BaseData,
43
+ det_z: BaseData,
44
+ pitch_slow: BaseData,
45
+ pitch_fast: BaseData,
46
+ e_fast=(1.0, 0.0, 0.0),
47
+ e_slow=(0.0, 1.0, 0.0),
48
+ e_norm=(0.0, 0.0, 1.0),
49
+ ) -> CanonicalDetectorFrame:
50
+ """
51
+ In the new structure, the PixelCoordinates3D implementation reduces "static config"
52
+ arrays (NeXus-style) to scalars via prepare_static_scalar(...) during loading.
53
+
54
+ Because this test double bypasses IO loading (_load_canonical_frame), we perform the same
55
+ reduction here so the frame matches the module’s expectations (scalar det_coord_* / pitches).
56
+ """
57
+ det_z_s = prepare_static_scalar(det_z, require_units=ureg.m, uncertainty_key="detector_position_jitter")
58
+ det_x_s = prepare_static_scalar(det_x, require_units=ureg.m, uncertainty_key="detector_position_jitter")
59
+ det_y_s = prepare_static_scalar(det_y, require_units=ureg.m, uncertainty_key="detector_position_jitter")
60
+
61
+ pitch_slow_s = prepare_static_scalar(
62
+ pitch_slow,
63
+ require_units=ureg.m / ureg.pixel,
64
+ uncertainty_key="pixel_pitch_jitter",
65
+ )
66
+ pitch_fast_s = prepare_static_scalar(
67
+ pitch_fast,
68
+ require_units=ureg.m / ureg.pixel,
69
+ uncertainty_key="pixel_pitch_jitter",
70
+ )
71
+
72
+ return CanonicalDetectorFrame(
73
+ det_coord_z=det_z_s,
74
+ det_coord_x=det_x_s,
75
+ det_coord_y=det_y_s,
76
+ e_fast=np.array(e_fast, dtype=float),
77
+ e_slow=np.array(e_slow, dtype=float),
78
+ e_normal=np.array(e_norm, dtype=float),
79
+ pixel_pitch_slow=pitch_slow_s,
80
+ pixel_pitch_fast=pitch_fast_s,
81
+ )
82
+
83
+
84
+ class DummyPixelCoordinates3D(PixelCoordinates3D):
85
+ """
86
+ Test double that bypasses IO loading and returns a fixed CanonicalDetectorFrame.
87
+ """
88
+
89
+ def __init__(self, *, frame: CanonicalDetectorFrame, **kwargs):
90
+ super().__init__(**kwargs)
91
+ self._frame = frame
92
+
93
+ def _load_canonical_frame(self, *, RoD, detector_shape, reference_signal):
94
+ return self._frame
95
+
96
+
97
+ # ----------------------------
98
+ # tests: static-scalar preparation (moved from pixel module to helpers)
99
+ # ----------------------------
100
+
101
+
102
+ def test_prepare_static_scalar_passes_through_scalar():
103
+ bd = BaseData(signal=np.array(2.5), units=ureg.m, rank_of_data=0)
104
+ out = prepare_static_scalar(bd, require_units=ureg.m, uncertainty_key="detector_position_jitter")
105
+ assert np.size(out.signal) == 1
106
+ assert out.rank_of_data == 0
107
+ assert out.units.is_compatible_with(ureg.m)
108
+ np.testing.assert_allclose(out.signal, 2.5)
109
+
110
+
111
+ def test_prepare_static_scalar_reduces_shape_5_1_1_1_to_scalar_mean_and_sem():
112
+ # Mimics NeXus vector stored as [5,1,1,1]
113
+ values = np.array([2.50, 2.52, 2.48, 2.51, 2.49], dtype=float).reshape(5, 1, 1, 1)
114
+ bd = BaseData(signal=values, units=ureg.m, rank_of_data=0)
115
+
116
+ out = prepare_static_scalar(bd, require_units=ureg.m, uncertainty_key="detector_position_jitter")
117
+
118
+ assert np.size(out.signal) == 1
119
+ assert out.rank_of_data == 0
120
+ assert out.units.is_compatible_with(ureg.m)
121
+
122
+ exp_mean = float(np.mean(values))
123
+
124
+ # For equal weights, this helper uses:
125
+ # var = mean((x-mean)^2) (population-style)
126
+ # sem = sqrt(var) / sqrt(N)
127
+ flat = values.ravel()
128
+ exp_var = float(np.mean((flat - exp_mean) ** 2))
129
+ exp_sem = float(np.sqrt(exp_var) / np.sqrt(flat.size))
130
+
131
+ np.testing.assert_allclose(out.signal, exp_mean, rtol=0, atol=1e-15)
132
+ assert "detector_position_jitter" in out.uncertainties
133
+ np.testing.assert_allclose(out.uncertainties["detector_position_jitter"], exp_sem, rtol=0, atol=1e-15)
134
+
135
+
136
+ def test_prepare_static_scalar_rejects_wrong_units():
137
+ bd = BaseData(signal=np.array([1.0, 2.0, 3.0]), units=ureg.pixel, rank_of_data=0)
138
+ with pytest.raises(ValueError, match="Value must be in"):
139
+ prepare_static_scalar(bd, require_units=ureg.m)
140
+
141
+
142
+ # ----------------------------
143
+ # tests: pixel coordinate math
144
+ # ----------------------------
145
+
146
+
147
+ def test_pixel_coordinates_2d_identity_basis_constant_z_and_expected_x_y():
148
+ """
149
+ 2D detector: (slow, fast) = (11, 20)
150
+
151
+ Convention under test:
152
+ - det_coord_* is the lab-frame position of the *pixel-grid origin corner* (before +0.5 center shift)
153
+ - pixel centers at (j+0.5, i+0.5)
154
+ - identity basis: fast->x, slow->y, no z components => coord_z should be constant at det_coord_z
155
+ """
156
+ pd = _make_processing_data_2d((11, 20), rod=2)
157
+
158
+ # det_z given as a NeXus-like array (5,1,1,1), reduced to scalar in _make_frame()
159
+ det_z_vals = np.array([2.507, 2.508, 2.509, 2.507, 2.508], dtype=float).reshape(5, 1, 1, 1)
160
+ det_z = BaseData(signal=det_z_vals, units=ureg.m, rank_of_data=0)
161
+
162
+ det_x = BaseData(signal=np.array(0.0), units=ureg.m, rank_of_data=0)
163
+ det_y = BaseData(signal=np.array(0.0), units=ureg.m, rank_of_data=0)
164
+
165
+ pitch_fast = BaseData(signal=np.array(1e-3), units=ureg.m / ureg.pixel, rank_of_data=0) # 1 mm/px
166
+ pitch_slow = BaseData(signal=np.array(2e-3), units=ureg.m / ureg.pixel, rank_of_data=0) # 2 mm/px
167
+
168
+ frame = _make_frame(
169
+ det_x=det_x,
170
+ det_y=det_y,
171
+ det_z=det_z,
172
+ pitch_slow=pitch_slow,
173
+ pitch_fast=pitch_fast,
174
+ e_fast=(1.0, 0.0, 0.0),
175
+ e_slow=(0.0, 1.0, 0.0),
176
+ e_norm=(0.0, 0.0, 1.0),
177
+ )
178
+
179
+ step = DummyPixelCoordinates3D(io_sources=IoSources(), frame=frame)
180
+ step.configuration["with_processing_keys"] = ["sample"]
181
+
182
+ step.execute(pd)
183
+ out = pd["sample"]
184
+
185
+ cx = out["coord_x"].signal
186
+ cy = out["coord_y"].signal
187
+ cz = out["coord_z"].signal
188
+
189
+ assert cx.shape == (11, 20)
190
+ assert cy.shape == (11, 20)
191
+ assert cz.shape == (11, 20)
192
+
193
+ # expected x: (i + 0.5) * pitch_fast
194
+ i = np.arange(20, dtype=float) + 0.5
195
+ exp_x = np.broadcast_to(i[None, :] * 1e-3, (11, 20))
196
+
197
+ # expected y: (j + 0.5) * pitch_slow
198
+ j = np.arange(11, dtype=float) + 0.5
199
+ exp_y = np.broadcast_to(j[:, None] * 2e-3, (11, 20))
200
+
201
+ # expected z: scalar mean(det_z_vals) broadcast
202
+ exp_z_scalar = float(np.mean(det_z_vals))
203
+ exp_z = np.full((11, 20), exp_z_scalar, dtype=float)
204
+
205
+ np.testing.assert_allclose(cx, exp_x)
206
+ np.testing.assert_allclose(cy, exp_y)
207
+ np.testing.assert_allclose(cz, exp_z)
208
+
209
+ assert out["coord_x"].units.is_compatible_with(ureg.m)
210
+ assert out["coord_x"].rank_of_data == 2
211
+
212
+
213
+ def test_pixel_coordinates_2d_offset_origin_shifts_coordinates():
214
+ pd = _make_processing_data_2d((11, 20), rod=2)
215
+
216
+ det_z = BaseData(signal=np.array(2.0), units=ureg.m, rank_of_data=0)
217
+ det_x = BaseData(signal=np.array(0.10), units=ureg.m, rank_of_data=0) # 10 cm offset
218
+ det_y = BaseData(signal=np.array(-0.05), units=ureg.m, rank_of_data=0) # -5 cm offset
219
+
220
+ pitch_fast = BaseData(signal=np.array(1e-3), units=ureg.m / ureg.pixel, rank_of_data=0)
221
+ pitch_slow = BaseData(signal=np.array(1e-3), units=ureg.m / ureg.pixel, rank_of_data=0)
222
+
223
+ frame = _make_frame(
224
+ det_x=det_x,
225
+ det_y=det_y,
226
+ det_z=det_z,
227
+ pitch_slow=pitch_slow,
228
+ pitch_fast=pitch_fast,
229
+ e_fast=(1.0, 0.0, 0.0),
230
+ e_slow=(0.0, 1.0, 0.0),
231
+ e_norm=(0.0, 0.0, 1.0),
232
+ )
233
+
234
+ step = DummyPixelCoordinates3D(io_sources=IoSources(), frame=frame)
235
+ step.configuration["with_processing_keys"] = ["sample"]
236
+ step.execute(pd)
237
+ out = pd["sample"]
238
+
239
+ cx = out["coord_x"].signal
240
+ cy = out["coord_y"].signal
241
+ cz = out["coord_z"].signal
242
+
243
+ # Spot check a few pixels
244
+ # pixel (slow=j, fast=i) => center at (j+0.5, i+0.5)
245
+ j0, i0 = 0, 0
246
+ np.testing.assert_allclose(cx[j0, i0], 0.10 + 0.5e-3)
247
+ np.testing.assert_allclose(cy[j0, i0], -0.05 + 0.5e-3)
248
+ np.testing.assert_allclose(cz[j0, i0], 2.0)
249
+
250
+ j1, i1 = 10, 19
251
+ np.testing.assert_allclose(cx[j1, i1], 0.10 + 19.5e-3)
252
+ np.testing.assert_allclose(cy[j1, i1], -0.05 + 10.5e-3)
253
+ np.testing.assert_allclose(cz[j1, i1], 2.0)
254
+
255
+
256
+ def test_pixel_coordinates_rod0_returns_scalars():
257
+ pd = ProcessingData()
258
+ b = DataBundle()
259
+ b["signal"] = BaseData(signal=np.array(1.0), units=ureg.dimensionless, rank_of_data=0)
260
+ pd["sample"] = b
261
+
262
+ frame = _make_frame(
263
+ det_x=BaseData(signal=np.array(0.1), units=ureg.m, rank_of_data=0),
264
+ det_y=BaseData(signal=np.array(0.2), units=ureg.m, rank_of_data=0),
265
+ det_z=BaseData(signal=np.array(2.0), units=ureg.m, rank_of_data=0),
266
+ pitch_slow=BaseData(signal=np.array(1e-3), units=ureg.m / ureg.pixel, rank_of_data=0),
267
+ pitch_fast=BaseData(signal=np.array(1e-3), units=ureg.m / ureg.pixel, rank_of_data=0),
268
+ )
269
+
270
+ step = DummyPixelCoordinates3D(io_sources=IoSources(), frame=frame)
271
+ step.configuration["with_processing_keys"] = ["sample"]
272
+
273
+ step.execute(pd)
274
+ out = pd["sample"]
275
+
276
+ assert np.size(out["coord_x"].signal) == 1
277
+ assert np.size(out["coord_y"].signal) == 1
278
+ assert np.size(out["coord_z"].signal) == 1
279
+
280
+ np.testing.assert_allclose(out["coord_x"].signal, 0.1)
281
+ np.testing.assert_allclose(out["coord_y"].signal, 0.2)
282
+ np.testing.assert_allclose(out["coord_z"].signal, 2.0)
@@ -0,0 +1,224 @@
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 2026, The MoDaCor team"
10
+ __date__ = "06/01/2026"
11
+ __status__ = "Development" # "Development", "Production"
12
+ # end of header and standard imports
13
+ __version__ = "20260106.1"
14
+
15
+ import numpy as np
16
+
17
+ from modacor import ureg
18
+ from modacor.dataclasses.basedata import BaseData
19
+ from modacor.dataclasses.databundle import DataBundle
20
+ from modacor.dataclasses.processing_data import ProcessingData
21
+ from modacor.io.io_sources import IoSources
22
+ from modacor.modules.technique_modules.scattering.xs_geometry_from_pixel_coordinates import (
23
+ XSGeometryFromPixelCoordinates,
24
+ )
25
+
26
+ # ----------------------------
27
+ # helpers
28
+ # ----------------------------
29
+
30
+
31
+ def _make_processing_data_with_coords(shape=(11, 20), *, rod=2) -> ProcessingData:
32
+ """
33
+ Create ProcessingData with a single databundle 'sample' containing coord_x/coord_y/coord_z.
34
+ """
35
+ n_slow, n_fast = shape
36
+
37
+ # Choose pitches that make expected arrays easy to compute
38
+ pitch_fast = 1e-3 # m / px
39
+ pitch_slow = 2e-3 # m / px
40
+
41
+ x = (np.arange(n_fast, dtype=float) + 0.5)[None, :] * pitch_fast
42
+ y = (np.arange(n_slow, dtype=float) + 0.5)[:, None] * pitch_slow
43
+
44
+ coord_x = np.broadcast_to(x, shape)
45
+ coord_y = np.broadcast_to(y, shape)
46
+
47
+ det_z = 2.5 # m
48
+ coord_z = np.full(shape, det_z, dtype=float)
49
+
50
+ pd = ProcessingData()
51
+ b = DataBundle()
52
+ b["coord_x"] = BaseData(signal=coord_x, units=ureg.m, rank_of_data=rod)
53
+ b["coord_y"] = BaseData(signal=coord_y, units=ureg.m, rank_of_data=rod)
54
+ b["coord_z"] = BaseData(signal=coord_z, units=ureg.m, rank_of_data=rod)
55
+ pd["sample"] = b
56
+ return pd
57
+
58
+
59
+ class DummyXSGeometryFromPixelCoordinates(XSGeometryFromPixelCoordinates):
60
+ """
61
+ Test double that bypasses IO loading and returns fixed BaseData objects for config sources.
62
+ """
63
+
64
+ def __init__(self, *, sources: dict[str, BaseData], **kwargs):
65
+ super().__init__(**kwargs)
66
+ self._sources = sources
67
+
68
+ def _load_from_sources(self, key: str) -> BaseData:
69
+ return self._sources[key]
70
+
71
+
72
+ def _expected_geometry_arrays(
73
+ *,
74
+ coord_x: np.ndarray,
75
+ coord_y: np.ndarray,
76
+ coord_z: np.ndarray,
77
+ sample_z: float,
78
+ wavelength: float,
79
+ pitch_fast: float,
80
+ pitch_slow: float,
81
+ detector_normal: tuple[float, float, float] = (0.0, 0.0, 1.0),
82
+ ):
83
+ """
84
+ Numpy-only expected values matching the implementation.
85
+ """
86
+ dx = coord_x
87
+ dy = coord_y
88
+ dz = coord_z - sample_z
89
+
90
+ r_perp = np.sqrt(dx * dx + dy * dy)
91
+ R = np.sqrt(dx * dx + dy * dy + dz * dz)
92
+
93
+ two_theta = np.arctan(r_perp / dz)
94
+ psi = np.arctan2(dy, dx)
95
+
96
+ k = (2.0 * np.pi) / wavelength # 1/m
97
+
98
+ rhat_x = dx / R
99
+ rhat_y = dy / R
100
+ rhat_z = dz / R
101
+
102
+ Q0 = k * rhat_x
103
+ Q1 = k * rhat_y
104
+ Q2 = k * (rhat_z - 1.0)
105
+ Q = np.sqrt(Q0 * Q0 + Q1 * Q1 + Q2 * Q2)
106
+
107
+ n = np.asarray(detector_normal, dtype=float)
108
+ n = n / np.linalg.norm(n)
109
+ cos_alpha = rhat_x * n[0] + rhat_y * n[1] + rhat_z * n[2]
110
+
111
+ area = pitch_fast * pitch_slow # (m/px)*(m/px) = m^2/px^2
112
+ omega = (area * cos_alpha) / (R * R)
113
+
114
+ return two_theta, psi, Q0, Q1, Q2, Q, omega
115
+
116
+
117
+ # ----------------------------
118
+ # tests
119
+ # ----------------------------
120
+
121
+
122
+ def test_geometry_from_pixel_coordinates_2d_identity_normal_matches_expected_arrays():
123
+ pd = _make_processing_data_with_coords((11, 20), rod=2)
124
+ b = pd["sample"]
125
+
126
+ # sample_z as NeXus-like (5,1,1,1) array -> should be reduced to scalar mean
127
+ sample_z_vals = np.array([0.10, 0.12, 0.08, 0.11, 0.09], dtype=float).reshape(5, 1, 1, 1)
128
+ sample_z_bd = BaseData(signal=sample_z_vals, units=ureg.m, rank_of_data=0)
129
+
130
+ # wavelength scalar
131
+ wavelength_bd = BaseData(signal=np.array(1.0e-10, dtype=float), units=ureg.m, rank_of_data=0)
132
+
133
+ # pitches scalar (m/pixel)
134
+ pitch_fast_bd = BaseData(signal=np.array(1e-3, dtype=float), units=ureg.m / ureg.pixel, rank_of_data=0)
135
+ pitch_slow_bd = BaseData(signal=np.array(2e-3, dtype=float), units=ureg.m / ureg.pixel, rank_of_data=0)
136
+
137
+ sources = {
138
+ "sample_z": sample_z_bd,
139
+ "wavelength": wavelength_bd,
140
+ "pixel_pitch_fast": pitch_fast_bd,
141
+ "pixel_pitch_slow": pitch_slow_bd,
142
+ }
143
+
144
+ step = DummyXSGeometryFromPixelCoordinates(io_sources=IoSources(), sources=sources)
145
+ step.configuration["with_processing_keys"] = ["sample"]
146
+ step.configuration["detector_normal"] = (0.0, 0.0, 1.0)
147
+
148
+ step.execute(pd)
149
+
150
+ # compute expected
151
+ exp_sample_z = float(np.mean(sample_z_vals))
152
+ exp_two_theta, exp_psi, exp_Q0, exp_Q1, exp_Q2, exp_Q, exp_omega = _expected_geometry_arrays(
153
+ coord_x=b["coord_x"].signal,
154
+ coord_y=b["coord_y"].signal,
155
+ coord_z=b["coord_z"].signal,
156
+ sample_z=exp_sample_z,
157
+ wavelength=float(wavelength_bd.signal),
158
+ pitch_fast=float(pitch_fast_bd.signal),
159
+ pitch_slow=float(pitch_slow_bd.signal),
160
+ detector_normal=(0.0, 0.0, 1.0),
161
+ )
162
+
163
+ out = pd["sample"]
164
+
165
+ np.testing.assert_allclose(out["TwoTheta"].signal, exp_two_theta)
166
+ np.testing.assert_allclose(out["Psi"].signal, exp_psi)
167
+
168
+ np.testing.assert_allclose(out["Q0"].signal, exp_Q0)
169
+ np.testing.assert_allclose(out["Q1"].signal, exp_Q1)
170
+ np.testing.assert_allclose(out["Q2"].signal, exp_Q2)
171
+ np.testing.assert_allclose(out["Q"].signal, exp_Q)
172
+
173
+ np.testing.assert_allclose(out["Omega"].signal, exp_omega)
174
+
175
+ # basic metadata checks
176
+ assert out["Q"].rank_of_data == 2
177
+ assert out["TwoTheta"].units.is_compatible_with(ureg.radian)
178
+ assert out["Psi"].units.is_compatible_with(ureg.radian)
179
+ assert out["Q"].units.is_compatible_with(ureg.m**-1)
180
+ assert out["Omega"].units.is_compatible_with(ureg.steradian)
181
+
182
+
183
+ def test_geometry_from_pixel_coordinates_detector_normal_is_normalized():
184
+ """
185
+ detector_normal=(0,0,2) should behave identically to (0,0,1).
186
+ """
187
+ pd = _make_processing_data_with_coords((11, 20), rod=2)
188
+ b = pd["sample"]
189
+
190
+ sample_z_bd = BaseData(
191
+ signal=np.array([0.10, 0.12, 0.08, 0.11, 0.09], dtype=float).reshape(5, 1, 1, 1), units=ureg.m, rank_of_data=0
192
+ )
193
+ wavelength_bd = BaseData(signal=np.array(1.0e-10, dtype=float), units=ureg.m, rank_of_data=0)
194
+ pitch_fast_bd = BaseData(signal=np.array(1e-3, dtype=float), units=ureg.m / ureg.pixel, rank_of_data=0)
195
+ pitch_slow_bd = BaseData(signal=np.array(2e-3, dtype=float), units=ureg.m / ureg.pixel, rank_of_data=0)
196
+
197
+ sources = {
198
+ "sample_z": sample_z_bd,
199
+ "wavelength": wavelength_bd,
200
+ "pixel_pitch_fast": pitch_fast_bd,
201
+ "pixel_pitch_slow": pitch_slow_bd,
202
+ }
203
+
204
+ # run with non-unit normal
205
+ step = DummyXSGeometryFromPixelCoordinates(io_sources=IoSources(), sources=sources)
206
+ step.configuration["with_processing_keys"] = ["sample"]
207
+ step.configuration["detector_normal"] = (0.0, 0.0, 2.0)
208
+ step.execute(pd)
209
+ omega_nonunit = pd["sample"]["Omega"].signal.copy()
210
+
211
+ # expected omega with unit normal
212
+ exp_sample_z = float(np.mean(sample_z_bd.signal))
213
+ *_rest, exp_omega_unit = _expected_geometry_arrays(
214
+ coord_x=b["coord_x"].signal,
215
+ coord_y=b["coord_y"].signal,
216
+ coord_z=b["coord_z"].signal,
217
+ sample_z=exp_sample_z,
218
+ wavelength=float(wavelength_bd.signal),
219
+ pitch_fast=float(pitch_fast_bd.signal),
220
+ pitch_slow=float(pitch_slow_bd.signal),
221
+ detector_normal=(0.0, 0.0, 1.0),
222
+ )
223
+
224
+ np.testing.assert_allclose(omega_nonunit, exp_omega_unit)