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,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)
|