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,635 @@
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
+ from modacor.dataclasses.databundle import DataBundle
15
+
16
+ """
17
+ Tests for the XSGeometry processing step.
18
+
19
+ We test:
20
+ - Low-level geometry helpers (_compute_coordinates, _compute_angles, _compute_Q, ...)
21
+ - Basic symmetry properties for a simple 2D detector
22
+ - Simple checks for 1D and 0D cases
23
+ - A thin integration test for prepare_execution() + calculate()
24
+ """
25
+
26
+ import numpy as np
27
+ import pytest
28
+ from numpy.testing import assert_allclose
29
+
30
+ from modacor import ureg
31
+ from modacor.dataclasses.basedata import BaseData
32
+ from modacor.dataclasses.processing_data import ProcessingData
33
+ from modacor.io.io_sources import IoSources
34
+ from modacor.modules.technique_modules.scattering.xs_geometry import XSGeometry
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Small helpers for building geometry BaseData
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def _bd_scalar(value: float, unit) -> BaseData:
42
+ return BaseData(signal=np.array(float(value), dtype=float), units=unit)
43
+
44
+
45
+ def _bd_vector(values, unit) -> BaseData:
46
+ return BaseData(signal=np.asarray(values, dtype=float), units=unit)
47
+
48
+
49
+ def make_geom_2d(n0: int, n1: int):
50
+ """
51
+ Convenience helper: simple 2D geometry
52
+
53
+ - Detector: n0 x n1
54
+ - Detector distance: 1.0 m with 1 mm uncertainty
55
+ - Pixel size: 1e-3 m/pixel in both directions (with small uncertainty)
56
+ - Beam center: exact centre pixel (with 0.25 pixel uncertainty)
57
+ - Wavelength: 1 Å (1e-10 m) with 2% uncertainty
58
+ """
59
+ # --- detector distance: 1.0 m ± 1 mm ---
60
+ D_value = 1.0
61
+ D_unc = 1e-3 # 1 mm
62
+ D_bd = BaseData(
63
+ signal=np.array(D_value, dtype=float),
64
+ units=ureg.meter,
65
+ uncertainties={"propagate_to_all": np.array(D_unc, dtype=float)},
66
+ )
67
+
68
+ # --- pixel size: 1e-3 m/pixel ± 1e-6 m/pixel ---
69
+ pixel_signal = np.asarray([1e-3, 1e-3], dtype=float)
70
+ pixel_unc = np.full_like(pixel_signal, 1e-6, dtype=float)
71
+ pixel_size_bd = BaseData(
72
+ signal=pixel_signal,
73
+ units=ureg.meter / ureg.pixel,
74
+ uncertainties={"propagate_to_all": pixel_unc},
75
+ )
76
+
77
+ # --- beam centre: at the centre pixel, ±0.25 pixel ---
78
+ center_row = (n0 - 1) / 2.0
79
+ center_col = (n1 - 1) / 2.0
80
+ beam_signal = np.asarray([center_row, center_col], dtype=float)
81
+ beam_unc = np.full_like(beam_signal, 0.25, dtype=float)
82
+ beam_center_bd = BaseData(
83
+ signal=beam_signal,
84
+ units=ureg.pixel,
85
+ uncertainties={"pixel_index": beam_unc},
86
+ )
87
+
88
+ # --- wavelength: 1 Å ± 2% ---
89
+ lambda_value = 1.0e-10 # 1 Å in meters
90
+ lambda_unc = 0.02 * lambda_value
91
+ wavelength_bd = BaseData(
92
+ signal=np.array(lambda_value, dtype=float),
93
+ units=ureg.meter,
94
+ uncertainties={"propagate_to_all": np.array(lambda_unc, dtype=float)},
95
+ )
96
+
97
+ return D_bd, pixel_size_bd, beam_center_bd, wavelength_bd
98
+
99
+
100
+ def make_geom_1d(n: int):
101
+ """
102
+ Simple 1D geometry: n pixels in a line.
103
+
104
+ Units follow the same conventions as make_geom_2d.
105
+ """
106
+ # --- detector distance: 1.0 m ± 1 mm ---
107
+ D_value = 1.0
108
+ D_unc = 1e-3
109
+ D_bd = BaseData(
110
+ signal=np.array(D_value, dtype=float),
111
+ units=ureg.meter,
112
+ uncertainties={"propagate_to_all": np.array(D_unc, dtype=float)},
113
+ )
114
+
115
+ # --- pixel size: 1e-3 m/pixel ± 1e-6 m/pixel ---
116
+ pixel_signal = np.asarray([1e-3, 1e-3], dtype=float)
117
+ pixel_unc = np.full_like(pixel_signal, 1e-6, dtype=float)
118
+ pixel_size_bd = BaseData(
119
+ signal=pixel_signal,
120
+ units=ureg.meter / ureg.pixel,
121
+ uncertainties={"propagate_to_all": pixel_unc},
122
+ )
123
+
124
+ # --- beam centre: central pixel ±0.25 pixel ---
125
+ center = (n - 1) / 2.0
126
+ beam_signal = np.asarray([center], dtype=float)
127
+ beam_unc = np.full_like(beam_signal, 0.25, dtype=float)
128
+ beam_center_bd = BaseData(
129
+ signal=beam_signal,
130
+ units=ureg.pixel,
131
+ uncertainties={"pixel_index": beam_unc},
132
+ )
133
+
134
+ # --- wavelength: 1 Å ± 2% ---
135
+ lambda_value = 1.0e-10
136
+ lambda_unc = 0.02 * lambda_value
137
+ wavelength_bd = BaseData(
138
+ signal=np.array(lambda_value, dtype=float),
139
+ units=ureg.meter,
140
+ uncertainties={"propagate_to_all": np.array(lambda_unc, dtype=float)},
141
+ )
142
+
143
+ return D_bd, pixel_size_bd, beam_center_bd, wavelength_bd
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Tests for helper methods (math / symmetry)
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ def test_xsgeometry_2d_center_q_zero_and_symmetry():
152
+ """
153
+ For a symmetric 2D detector with the beam between the central pixels:
154
+ - Q at the nominal center pixel should be the global minimum of Q.
155
+ - Q2 should be identically zero.
156
+ - Along the central row:
157
+ * Q1 should change sign left vs right of the beam (antisymmetric),
158
+ while Q0 remains positive (symmetric in sign).
159
+ - Along the central column:
160
+ * Q0 should change sign above vs below the beam (antisymmetric),
161
+ while Q1 remains positive (symmetric in sign).
162
+ - Psi at the four corners should lie in the expected quadrants:
163
+ top-left ~ (-π, -π/2)
164
+ top-right ~ (π/2, π)
165
+ bottom-left ~ (-π/2, 0)
166
+ bottom-right ~ (0, π/2)
167
+ - Omega (solid angle) should be largest near the beam centre and smaller at the corners.
168
+ - θ should increase with distance from the centre, along at least one row.
169
+ """
170
+ step = XSGeometry(io_sources=IoSources())
171
+
172
+ n0, n1 = 5, 5
173
+ spatial_shape = (n0, n1)
174
+ D_bd, pixel_size_bd, beam_center_bd, wavelength_bd = make_geom_2d(n0, n1)
175
+ px0_bd, px1_bd = pixel_size_bd.indexed(0, rank_of_data=0), pixel_size_bd.indexed(1, rank_of_data=0)
176
+
177
+ # coordinates
178
+ x0_bd, x1_bd, r_perp_bd, R_bd = step._compute_coordinates(
179
+ RoD=2,
180
+ spatial_shape=spatial_shape,
181
+ beam_center_bd=beam_center_bd,
182
+ px0_bd=px0_bd,
183
+ px1_bd=px1_bd,
184
+ detector_distance_bd=D_bd,
185
+ )
186
+
187
+ # angles, Q magnitude & components, Psi, Omega
188
+ _, theta_bd, sin_theta_bd = step._compute_angles(
189
+ r_perp_bd=r_perp_bd,
190
+ detector_distance_bd=D_bd,
191
+ )
192
+ Q_bd, Q0_bd, Q1_bd, Q2_bd = step._compute_Q_and_components(
193
+ sin_theta_bd=sin_theta_bd,
194
+ wavelength_bd=wavelength_bd,
195
+ x0_bd=x0_bd,
196
+ x1_bd=x1_bd,
197
+ r_perp_bd=r_perp_bd,
198
+ )
199
+ Psi_bd = step._compute_psi(x0_bd=x0_bd, x1_bd=x1_bd)
200
+ Omega_bd = step._compute_solid_angle(
201
+ R_bd=R_bd,
202
+ px0_bd=px0_bd,
203
+ px1_bd=px1_bd,
204
+ detector_distance_bd=D_bd,
205
+ )
206
+
207
+ center = (n0 // 2, n1 // 2)
208
+ row_c, col_c = center
209
+
210
+ # ------------------------------------------------------------------
211
+ # Q behaviour near the beam centre
212
+ # ------------------------------------------------------------------
213
+
214
+ # Q at the "centre" pixel should be the global minimum
215
+ q_center = Q_bd.signal[center]
216
+ q_min = np.min(Q_bd.signal)
217
+ assert q_center == pytest.approx(q_min, rel=1e-12, abs=1e-12)
218
+
219
+ # Q2 should be identically zero
220
+ assert_allclose(Q2_bd.signal, 0.0, atol=1e-12)
221
+
222
+ # ------------------------------------------------------------------
223
+ # Left-right and up-down behaviour of Q0/Q1
224
+ #
225
+ # NOTE: with the current implementation (x0 from rows, x1 from columns),
226
+ # Q0 varies primarily along the "vertical" direction and Q1 along "horizontal".
227
+ # So the antisymmetry/symmetry expectations are effectively swapped
228
+ # compared to an (x, y) convention.
229
+ # ------------------------------------------------------------------
230
+
231
+ # Left-right: inspect the central row
232
+ col_left = col_c - 1
233
+ col_right = col_c + 1
234
+ q0_left_row = Q0_bd.signal[row_c, col_left]
235
+ q0_right_row = Q0_bd.signal[row_c, col_right]
236
+ q1_left_row = Q1_bd.signal[row_c, col_left]
237
+ q1_right_row = Q1_bd.signal[row_c, col_right]
238
+
239
+ # Q1 changes sign (antisymmetric) left vs right
240
+ assert q1_left_row < 0.0
241
+ assert q1_right_row > 0.0
242
+ # Q0 stays positive on both sides of the beam row
243
+ assert q0_left_row > 0.0
244
+ assert q0_right_row > 0.0
245
+
246
+ # Up-down: inspect the central column
247
+ row_up = row_c - 1
248
+ row_down = row_c + 1
249
+ q0_up_col = Q0_bd.signal[row_up, col_c]
250
+ q0_down_col = Q0_bd.signal[row_down, col_c]
251
+ q1_up_col = Q1_bd.signal[row_up, col_c]
252
+ q1_down_col = Q1_bd.signal[row_down, col_c]
253
+
254
+ # Q0 changes sign (antisymmetric) above vs below
255
+ assert q0_up_col < 0.0
256
+ assert q0_down_col > 0.0
257
+ # Q1 remains positive above and below (symmetric in sign)
258
+ assert q1_up_col > 0.0
259
+ assert q1_down_col > 0.0
260
+
261
+ # ------------------------------------------------------------------
262
+ # Psi behaviour at the four corners (clear quadrants)
263
+ # ------------------------------------------------------------------
264
+
265
+ # Indices: (row, col)
266
+ psi_tl = Psi_bd.signal[0, 0] # top-left
267
+ psi_tr = Psi_bd.signal[0, -1] # top-right
268
+ psi_bl = Psi_bd.signal[-1, 0] # bottom-left
269
+ psi_br = Psi_bd.signal[-1, -1] # bottom-right
270
+
271
+ # Top-left: x0 < 0, x1 < 0 → atan2(neg, neg) ∈ (-π, -π/2)
272
+ assert -np.pi < psi_tl < -np.pi / 2.0
273
+
274
+ # Top-right: x0 < 0, x1 > 0 → atan2(pos, neg) ∈ (π/2, π)
275
+ assert np.pi / 2.0 < psi_tr < np.pi
276
+
277
+ # Bottom-left: x0 > 0, x1 < 0 → atan2(neg, pos) ∈ (-π/2, 0)
278
+ assert -np.pi / 2.0 < psi_bl < 0.0
279
+
280
+ # Bottom-right: x0 > 0, x1 > 0 → atan2(pos, pos) ∈ (0, π/2)
281
+ assert 0.0 < psi_br < np.pi / 2.0
282
+
283
+ # ------------------------------------------------------------------
284
+ # Omega behaviour and θ monotonicity
285
+ # ------------------------------------------------------------------
286
+
287
+ # Omega largest near the beam centre, smaller at a corner
288
+ omega_center = Omega_bd.signal[center]
289
+ omega_corner = Omega_bd.signal[0, 0]
290
+ assert omega_corner < omega_center
291
+ assert np.all(Omega_bd.signal > 0.0)
292
+
293
+ # θ increases with distance from the centre along the central row
294
+ theta_row = theta_bd.signal[row_c, :]
295
+ # centre is smallest
296
+ theta_center = theta_row[col_c]
297
+ assert theta_center == pytest.approx(np.min(theta_row), abs=1e-12)
298
+ # neighbours further out have larger θ
299
+ # assert theta_row[col_left] > theta_center
300
+ assert theta_row[col_right] > theta_center
301
+
302
+
303
+ def test_xsgeometry_1d_center_q_zero_and_monotonic():
304
+ """
305
+ For a symmetric 1D detector:
306
+ - Q at the pixel closest to the beam centre should be minimal (not necessarily zero
307
+ with half-pixel indexing).
308
+ - |Q| should increase as we move away from the center.
309
+ - Q1 and Q2 should be zero.
310
+ """
311
+ step = XSGeometry(io_sources=IoSources())
312
+
313
+ n = 7
314
+ spatial_shape = (n,)
315
+ D_bd, pixel_size_bd, beam_center_bd, wavelength_bd = make_geom_1d(n)
316
+ px0_bd, px1_bd = pixel_size_bd.indexed(0, rank_of_data=0), pixel_size_bd.indexed(1, rank_of_data=0)
317
+
318
+ x0_bd, x1_bd, r_perp_bd, R_bd = step._compute_coordinates(
319
+ RoD=1,
320
+ spatial_shape=spatial_shape,
321
+ beam_center_bd=beam_center_bd,
322
+ px0_bd=px0_bd,
323
+ px1_bd=px1_bd,
324
+ detector_distance_bd=D_bd,
325
+ )
326
+
327
+ _, theta_bd, sin_theta_bd = step._compute_angles(
328
+ r_perp_bd=r_perp_bd,
329
+ detector_distance_bd=D_bd,
330
+ )
331
+
332
+ # Q magnitude and components
333
+ Q_bd, Q0_bd, Q1_bd, Q2_bd = step._compute_Q_and_components(
334
+ sin_theta_bd=sin_theta_bd,
335
+ wavelength_bd=wavelength_bd,
336
+ x0_bd=x0_bd,
337
+ x1_bd=x1_bd,
338
+ r_perp_bd=r_perp_bd,
339
+ )
340
+
341
+ center = n // 2
342
+
343
+ # Centre pixel should have minimal |Q| (but not exactly zero with half-pixel indexing)
344
+ abs_Q = np.abs(Q_bd.signal)
345
+ assert abs_Q[center] == pytest.approx(abs_Q.min(), rel=1e-12, abs=1e-12)
346
+
347
+ # In 1D, Q is entirely along the single axis: |Q0| == |Q|, Q1 == Q2 == 0
348
+ assert_allclose(np.abs(Q0_bd.signal), abs_Q, rtol=1e-12, atol=1e-12)
349
+ assert_allclose(Q1_bd.signal, 0.0, atol=1e-12)
350
+ assert_allclose(Q2_bd.signal, 0.0, atol=1e-12)
351
+
352
+ # |Q| grows away from center on the positive side
353
+ assert abs_Q[center + 1] > abs_Q[center]
354
+ assert abs_Q[center + 2] > abs_Q[center + 1]
355
+
356
+
357
+ def test_xsgeometry_0d_shapes_and_units():
358
+ """
359
+ For a 0D detector (scalar signal):
360
+ - All geometry outputs should be scalars.
361
+ - Q and its components should be zero.
362
+ - Omega should be positive scalar.
363
+ """
364
+ step = XSGeometry(io_sources=IoSources())
365
+
366
+ # 0D: no spatial shape
367
+ spatial_shape: tuple[int, ...] = ()
368
+ D_bd = _bd_scalar(1.0, ureg.meter)
369
+ # pixel size / beam center technically irrelevant for RoD=0, but we supply valid shapes
370
+ pixel_size_bd = _bd_vector([1e-3, 1e-3], ureg.meter / ureg.pixel)
371
+ beam_center_bd = _bd_vector([0.0], ureg.pixel)
372
+ wavelength_bd = _bd_scalar(1.0, ureg.meter)
373
+
374
+ px0_bd, px1_bd = pixel_size_bd.indexed(0, rank_of_data=0), pixel_size_bd.indexed(1, rank_of_data=0)
375
+ x0_bd, x1_bd, r_perp_bd, R_bd = step._compute_coordinates(
376
+ RoD=0,
377
+ spatial_shape=spatial_shape,
378
+ beam_center_bd=beam_center_bd,
379
+ px0_bd=px0_bd,
380
+ px1_bd=px1_bd,
381
+ detector_distance_bd=D_bd,
382
+ )
383
+
384
+ _, theta_bd, sin_theta_bd = step._compute_angles(
385
+ r_perp_bd=r_perp_bd,
386
+ detector_distance_bd=D_bd,
387
+ )
388
+ Q_bd, Q0_bd, Q1_bd, Q2_bd = step._compute_Q_and_components(
389
+ sin_theta_bd=sin_theta_bd,
390
+ wavelength_bd=wavelength_bd,
391
+ x0_bd=x0_bd,
392
+ x1_bd=x1_bd,
393
+ r_perp_bd=r_perp_bd,
394
+ )
395
+ Omega_bd = step._compute_solid_angle(
396
+ R_bd=R_bd,
397
+ px0_bd=px0_bd,
398
+ px1_bd=px1_bd,
399
+ detector_distance_bd=D_bd,
400
+ )
401
+
402
+ # all scalars
403
+ assert Q_bd.signal.shape == ()
404
+ assert Q0_bd.signal.shape == ()
405
+ assert Q1_bd.signal.shape == ()
406
+ assert Q2_bd.signal.shape == ()
407
+ assert theta_bd.signal.shape == ()
408
+ assert Omega_bd.signal.shape == ()
409
+
410
+ # Q and components should be zero
411
+ assert_allclose(Q_bd.signal, 0.0, atol=1e-12)
412
+ assert_allclose(Q0_bd.signal, 0.0, atol=1e-12)
413
+ assert_allclose(Q1_bd.signal, 0.0, atol=1e-12)
414
+ assert_allclose(Q2_bd.signal, 0.0, atol=1e-12)
415
+
416
+ # solid angle > 0
417
+ assert Omega_bd.signal > 0.0
418
+
419
+
420
+ @pytest.mark.filterwarnings("ignore:divide by zero encountered in divide:RuntimeWarning")
421
+ def test_xsgeometry_pixel_index_uncertainty_propagates_to_coordinates():
422
+ """
423
+ Check that the 'pixel_index' uncertainty defined on beam_center and the index grid
424
+ shows up on the detector-plane coordinates x0 and r_perp.
425
+ """
426
+ step = XSGeometry(io_sources=IoSources())
427
+
428
+ n0, n1 = 5, 5
429
+ spatial_shape = (n0, n1)
430
+ D_bd, pixel_size_bd, beam_center_bd, wavelength_bd = make_geom_2d(n0, n1)
431
+ px0_bd, px1_bd = pixel_size_bd.indexed(0, rank_of_data=0), pixel_size_bd.indexed(1, rank_of_data=0)
432
+
433
+ x0_bd, x1_bd, r_perp_bd, R_bd = step._compute_coordinates(
434
+ RoD=2,
435
+ spatial_shape=spatial_shape,
436
+ beam_center_bd=beam_center_bd,
437
+ px0_bd=px0_bd,
438
+ px1_bd=px1_bd,
439
+ detector_distance_bd=D_bd,
440
+ )
441
+
442
+ # We expect 'pixel_index' uncertainties to be present on x0 and r_perp
443
+ assert "pixel_index" in x0_bd.uncertainties
444
+ assert "pixel_index" in r_perp_bd.uncertainties
445
+
446
+ unc_x0 = x0_bd.uncertainties["pixel_index"]
447
+ unc_r = r_perp_bd.uncertainties["pixel_index"]
448
+
449
+ # Off-centre pixels should have finite, non-zero uncertainties.
450
+ # Choose a pixel where x0 != 0 to avoid the relative-error degeneracy at x0 == 0.
451
+ row_c, col_c = n0 // 2, n1 // 2
452
+ row_up = row_c - 1
453
+
454
+ assert np.isfinite(unc_x0[row_up, col_c])
455
+ assert unc_x0[row_up, col_c] > 0.0
456
+
457
+ assert np.isfinite(unc_r[row_up, col_c])
458
+ assert unc_r[row_up, col_c] > 0.0
459
+
460
+
461
+ @pytest.mark.filterwarnings("ignore:divide by zero encountered in divide:RuntimeWarning")
462
+ def test_xsgeometry_Q_has_nonzero_uncertainty_off_center():
463
+ """
464
+ Check that the 'pixel_index' uncertainties propagate all the way to Q
465
+ and are non-zero away from the beam centre.
466
+ """
467
+ step = XSGeometry(io_sources=IoSources())
468
+
469
+ n0, n1 = 5, 5
470
+ spatial_shape = (n0, n1)
471
+ D_bd, pixel_size_bd, beam_center_bd, wavelength_bd = make_geom_2d(n0, n1)
472
+ px0_bd, px1_bd = pixel_size_bd.indexed(0, rank_of_data=0), pixel_size_bd.indexed(1, rank_of_data=0)
473
+
474
+ x0_bd, x1_bd, r_perp_bd, R_bd = step._compute_coordinates(
475
+ RoD=2,
476
+ spatial_shape=spatial_shape,
477
+ beam_center_bd=beam_center_bd,
478
+ px0_bd=px0_bd,
479
+ px1_bd=px1_bd,
480
+ detector_distance_bd=D_bd,
481
+ )
482
+
483
+ _, theta_bd, sin_theta_bd = step._compute_angles(
484
+ r_perp_bd=r_perp_bd,
485
+ detector_distance_bd=D_bd,
486
+ )
487
+ Q_bd, Q0_bd, Q1_bd, Q2_bd = step._compute_Q_and_components(
488
+ sin_theta_bd=sin_theta_bd,
489
+ wavelength_bd=wavelength_bd,
490
+ x0_bd=x0_bd,
491
+ x1_bd=x1_bd,
492
+ r_perp_bd=r_perp_bd,
493
+ )
494
+
495
+ # We expect the 'pixel_index' uncertainty key to exist on Q as well
496
+ assert "pixel_index" in Q_bd.uncertainties
497
+
498
+ unc_Q = Q_bd.uncertainties["pixel_index"]
499
+
500
+ row_c, col_c = n0 // 2, n1 // 2
501
+ col_right = col_c + 1
502
+
503
+ # At the exact beam pixel, Q ≈ 0 and derivative is singular; uncertainty may be inf/NaN.
504
+ # Off-centre, we expect finite, non-zero uncertainty.
505
+ assert np.isfinite(unc_Q[row_c, col_right])
506
+ assert unc_Q[row_c, col_right] > 0.0
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # Thin integration test using prepare_execution + calculate
511
+ # ---------------------------------------------------------------------------
512
+
513
+
514
+ def test_xsgeometry_prepare_and_calculate_integration():
515
+ """
516
+ Integration-style test:
517
+ - Build a minimal processing_data and configuration.
518
+ - Override _load_geometry to return synthetic BaseData.
519
+ - Run prepare_execution() and calculate().
520
+ - Check that the expected geometry keys are present in the output databundle.
521
+ """
522
+ step = XSGeometry(io_sources=IoSources())
523
+
524
+ # Build a simple 2D signal databundle
525
+ n0, n1 = 5, 5
526
+ signal_bd = BaseData(
527
+ signal=np.ones((n0, n1), dtype=float),
528
+ units=ureg.dimensionless,
529
+ rank_of_data=2,
530
+ )
531
+
532
+ processing_data = ProcessingData()
533
+ processing_data["signal"] = DataBundle({"signal": signal_bd})
534
+
535
+ # Fake geometry via helper; we inject this directly into _load_geometry.
536
+ D_bd, pixel_size_bd, beam_center_bd, wavelength_bd = make_geom_2d(n0, n1)
537
+ fake_geom = {
538
+ "detector_distance": D_bd,
539
+ "pixel_size": pixel_size_bd,
540
+ "beam_center": beam_center_bd,
541
+ "wavelength": wavelength_bd,
542
+ }
543
+
544
+ # Minimal configuration
545
+ step.configuration = {
546
+ "with_processing_keys": ["signal"],
547
+ "output_processing_key": None,
548
+ }
549
+
550
+ # Attach processing_data and bypass I/O in _load_geometry
551
+ step.processing_data = processing_data
552
+ step._prepared_data = {}
553
+ step._load_geometry = lambda: fake_geom # type: ignore[assignment]
554
+
555
+ # Execute prepare + calculate directly (no ProcessStep.execute merge)
556
+ step.prepare_execution()
557
+ out = step.calculate()
558
+
559
+ assert "signal" in out
560
+ databundle = out["signal"]
561
+
562
+ for key in ["Q", "Q0", "Q1", "Q2", "Psi", "TwoTheta", "Omega"]:
563
+ assert key in databundle, f"Missing geometry key '{key}' in databundle."
564
+ assert isinstance(databundle[key], BaseData), f"{key} is not a BaseData."
565
+
566
+ # simple sanity check on Q field: central pixel is closest to beam
567
+ Q_bd = databundle["Q"]
568
+ center = (n0 // 2, n1 // 2)
569
+ abs_Q = np.abs(Q_bd.signal)
570
+ assert abs_Q[center] == pytest.approx(abs_Q.min(), rel=1e-12, abs=1e-12)
571
+
572
+
573
+ def test_xsgeometry_Q0_and_Omega_have_uncertainty_off_center():
574
+ step = XSGeometry(io_sources=IoSources())
575
+
576
+ n0, n1 = 5, 5
577
+ spatial_shape = (n0, n1)
578
+ D_bd, pixel_size_bd, beam_center_bd, wavelength_bd = make_geom_2d(n0, n1)
579
+ px0_bd, px1_bd = pixel_size_bd.indexed(0, rank_of_data=0), pixel_size_bd.indexed(1, rank_of_data=0)
580
+
581
+ x0_bd, x1_bd, r_perp_bd, R_bd = step._compute_coordinates(
582
+ RoD=2,
583
+ spatial_shape=spatial_shape,
584
+ beam_center_bd=beam_center_bd,
585
+ px0_bd=px0_bd,
586
+ px1_bd=px1_bd,
587
+ detector_distance_bd=D_bd,
588
+ )
589
+
590
+ _, theta_bd, sin_theta_bd = step._compute_angles(
591
+ r_perp_bd=r_perp_bd,
592
+ detector_distance_bd=D_bd,
593
+ )
594
+ Q_bd, Q0_bd, Q1_bd, Q2_bd = step._compute_Q_and_components(
595
+ sin_theta_bd=sin_theta_bd,
596
+ wavelength_bd=wavelength_bd,
597
+ x0_bd=x0_bd,
598
+ x1_bd=x1_bd,
599
+ r_perp_bd=r_perp_bd,
600
+ )
601
+ Omega_bd = step._compute_solid_angle(
602
+ R_bd=R_bd,
603
+ px0_bd=px0_bd,
604
+ px1_bd=px1_bd,
605
+ detector_distance_bd=D_bd,
606
+ )
607
+
608
+ row_c, col_c = n0 // 2, n1 // 2
609
+ col_right = col_c + 1
610
+
611
+ # Q0 should carry pixel_index uncertainty at some off-centre pixel
612
+ assert "pixel_index" in Q0_bd.uncertainties
613
+ unc_Q0_pix = Q0_bd.uncertainties["pixel_index"][row_c, col_right]
614
+ assert np.isfinite(unc_Q0_pix)
615
+ assert unc_Q0_pix > 0.0
616
+
617
+ # Omega should also carry non-zero propagated uncertainty (from distance,
618
+ # pixel_size, beam_center, etc.). We don't require every key to be finite
619
+ # (some keys may legitimately produce NaNs near singular points), but at
620
+ # least one uncertainty contribution at the off-centre pixel must be finite.
621
+ assert Omega_bd.uncertainties # dict not empty
622
+
623
+ target_shape = (n0, n1)
624
+ found_finite_positive = False
625
+
626
+ for key, u in Omega_bd.uncertainties.items():
627
+ # Uncertainty arrays may be scalar or lower-dimensional; broadcast to the
628
+ # detector shape so we can safely index the off-centre pixel.
629
+ u_full = np.broadcast_to(u, target_shape)
630
+ val = u_full[row_c, col_right]
631
+ if np.isfinite(val) and val > 0.0:
632
+ found_finite_positive = True
633
+ break
634
+
635
+ assert found_finite_positive
@@ -0,0 +1,12 @@
1
+ attrs
2
+ entrypoints
3
+ matplotlib
4
+ numpy
5
+ hdf5plugin
6
+ h5py
7
+ pint
8
+ pytest
9
+ stamina
10
+ tox
11
+ scipy
12
+ uncertainties