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.
- modacor/__init__.py +30 -0
- modacor/dataclasses/__init__.py +0 -0
- modacor/dataclasses/basedata.py +973 -0
- modacor/dataclasses/databundle.py +23 -0
- modacor/dataclasses/helpers.py +45 -0
- modacor/dataclasses/messagehandler.py +75 -0
- modacor/dataclasses/process_step.py +233 -0
- modacor/dataclasses/process_step_describer.py +146 -0
- modacor/dataclasses/processing_data.py +59 -0
- modacor/dataclasses/trace_event.py +118 -0
- modacor/dataclasses/uncertainty_tools.py +132 -0
- modacor/dataclasses/validators.py +84 -0
- modacor/debug/pipeline_tracer.py +548 -0
- modacor/io/__init__.py +33 -0
- modacor/io/csv/__init__.py +0 -0
- modacor/io/csv/csv_sink.py +114 -0
- modacor/io/csv/csv_source.py +210 -0
- modacor/io/hdf/__init__.py +27 -0
- modacor/io/hdf/hdf_source.py +120 -0
- modacor/io/io_sink.py +41 -0
- modacor/io/io_sinks.py +61 -0
- modacor/io/io_source.py +164 -0
- modacor/io/io_sources.py +208 -0
- modacor/io/processing_path.py +113 -0
- modacor/io/tiled/__init__.py +16 -0
- modacor/io/tiled/tiled_source.py +403 -0
- modacor/io/yaml/__init__.py +27 -0
- modacor/io/yaml/yaml_source.py +116 -0
- modacor/modules/__init__.py +53 -0
- modacor/modules/base_modules/__init__.py +0 -0
- modacor/modules/base_modules/append_processing_data.py +329 -0
- modacor/modules/base_modules/append_sink.py +141 -0
- modacor/modules/base_modules/append_source.py +181 -0
- modacor/modules/base_modules/bitwise_or_masks.py +113 -0
- modacor/modules/base_modules/combine_uncertainties.py +120 -0
- modacor/modules/base_modules/combine_uncertainties_max.py +105 -0
- modacor/modules/base_modules/divide.py +82 -0
- modacor/modules/base_modules/find_scale_factor1d.py +373 -0
- modacor/modules/base_modules/multiply.py +77 -0
- modacor/modules/base_modules/multiply_databundles.py +73 -0
- modacor/modules/base_modules/poisson_uncertainties.py +69 -0
- modacor/modules/base_modules/reduce_dimensionality.py +252 -0
- modacor/modules/base_modules/sink_processing_data.py +80 -0
- modacor/modules/base_modules/subtract.py +80 -0
- modacor/modules/base_modules/subtract_databundles.py +67 -0
- modacor/modules/base_modules/units_label_update.py +66 -0
- modacor/modules/instrument_modules/__init__.py +0 -0
- modacor/modules/instrument_modules/readme.md +9 -0
- modacor/modules/technique_modules/__init__.py +0 -0
- modacor/modules/technique_modules/scattering/__init__.py +0 -0
- modacor/modules/technique_modules/scattering/geometry_helpers.py +114 -0
- modacor/modules/technique_modules/scattering/index_pixels.py +492 -0
- modacor/modules/technique_modules/scattering/indexed_averager.py +628 -0
- modacor/modules/technique_modules/scattering/pixel_coordinates_3d.py +417 -0
- modacor/modules/technique_modules/scattering/solid_angle_correction.py +63 -0
- modacor/modules/technique_modules/scattering/xs_geometry.py +571 -0
- modacor/modules/technique_modules/scattering/xs_geometry_from_pixel_coordinates.py +293 -0
- modacor/runner/__init__.py +0 -0
- modacor/runner/pipeline.py +749 -0
- modacor/runner/process_step_registry.py +224 -0
- modacor/tests/__init__.py +27 -0
- modacor/tests/dataclasses/test_basedata.py +519 -0
- modacor/tests/dataclasses/test_basedata_operations.py +439 -0
- modacor/tests/dataclasses/test_basedata_to_base_units.py +57 -0
- modacor/tests/dataclasses/test_process_step_describer.py +73 -0
- modacor/tests/dataclasses/test_processstep.py +282 -0
- modacor/tests/debug/test_tracing_integration.py +188 -0
- modacor/tests/integration/__init__.py +0 -0
- modacor/tests/integration/test_pipeline_run.py +238 -0
- modacor/tests/io/__init__.py +27 -0
- modacor/tests/io/csv/__init__.py +0 -0
- modacor/tests/io/csv/test_csv_source.py +156 -0
- modacor/tests/io/hdf/__init__.py +27 -0
- modacor/tests/io/hdf/test_hdf_source.py +92 -0
- modacor/tests/io/test_io_sources.py +119 -0
- modacor/tests/io/tiled/__init__.py +12 -0
- modacor/tests/io/tiled/test_tiled_source.py +120 -0
- modacor/tests/io/yaml/__init__.py +27 -0
- modacor/tests/io/yaml/static_data_example.yaml +26 -0
- modacor/tests/io/yaml/test_yaml_source.py +47 -0
- modacor/tests/modules/__init__.py +27 -0
- modacor/tests/modules/base_modules/__init__.py +27 -0
- modacor/tests/modules/base_modules/test_append_processing_data.py +219 -0
- modacor/tests/modules/base_modules/test_append_sink.py +76 -0
- modacor/tests/modules/base_modules/test_append_source.py +180 -0
- modacor/tests/modules/base_modules/test_bitwise_or_masks.py +264 -0
- modacor/tests/modules/base_modules/test_combine_uncertainties.py +105 -0
- modacor/tests/modules/base_modules/test_combine_uncertainties_max.py +109 -0
- modacor/tests/modules/base_modules/test_divide.py +140 -0
- modacor/tests/modules/base_modules/test_find_scale_factor1d.py +220 -0
- modacor/tests/modules/base_modules/test_multiply.py +113 -0
- modacor/tests/modules/base_modules/test_multiply_databundles.py +136 -0
- modacor/tests/modules/base_modules/test_poisson_uncertainties.py +61 -0
- modacor/tests/modules/base_modules/test_reduce_dimensionality.py +358 -0
- modacor/tests/modules/base_modules/test_sink_processing_data.py +119 -0
- modacor/tests/modules/base_modules/test_subtract.py +111 -0
- modacor/tests/modules/base_modules/test_subtract_databundles.py +136 -0
- modacor/tests/modules/base_modules/test_units_label_update.py +91 -0
- modacor/tests/modules/technique_modules/__init__.py +0 -0
- modacor/tests/modules/technique_modules/scattering/__init__.py +0 -0
- modacor/tests/modules/technique_modules/scattering/test_geometry_helpers.py +198 -0
- modacor/tests/modules/technique_modules/scattering/test_index_pixels.py +426 -0
- modacor/tests/modules/technique_modules/scattering/test_indexed_averaging.py +559 -0
- modacor/tests/modules/technique_modules/scattering/test_pixel_coordinates_3d.py +282 -0
- modacor/tests/modules/technique_modules/scattering/test_xs_geometry_from_pixel_coordinates.py +224 -0
- modacor/tests/modules/technique_modules/scattering/test_xsgeometry.py +635 -0
- modacor/tests/requirements.txt +12 -0
- modacor/tests/runner/test_pipeline.py +438 -0
- modacor/tests/runner/test_process_step_registry.py +65 -0
- modacor/tests/test_import.py +43 -0
- modacor/tests/test_modacor.py +17 -0
- modacor/tests/test_units.py +79 -0
- modacor/units.py +97 -0
- modacor-1.0.0.dist-info/METADATA +482 -0
- modacor-1.0.0.dist-info/RECORD +120 -0
- modacor-1.0.0.dist-info/WHEEL +5 -0
- modacor-1.0.0.dist-info/licenses/AUTHORS.md +11 -0
- modacor-1.0.0.dist-info/licenses/LICENSE +11 -0
- modacor-1.0.0.dist-info/licenses/LICENSE.txt +11 -0
- 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
|