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,571 @@
|
|
|
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
|
+
__version__ = "20251122.1"
|
|
15
|
+
__all__ = ["XSGeometry"]
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict, Tuple
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from modacor import ureg
|
|
23
|
+
from modacor.dataclasses.basedata import BaseData
|
|
24
|
+
from modacor.dataclasses.helpers import basedata_from_sources
|
|
25
|
+
from modacor.dataclasses.messagehandler import MessageHandler
|
|
26
|
+
from modacor.dataclasses.process_step import ProcessStep
|
|
27
|
+
from modacor.dataclasses.process_step_describer import ProcessStepDescriber
|
|
28
|
+
|
|
29
|
+
# Module-level handler; facilities can swap MessageHandler implementation as needed
|
|
30
|
+
logger = MessageHandler(name=__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class XSGeometry(ProcessStep):
|
|
34
|
+
"""
|
|
35
|
+
Calculates the geometric information Q, Q0, Q1, Q2, Psi, TwoTheta, and Omega (solid angle)
|
|
36
|
+
for X-ray scattering data and adds them to the databundle.
|
|
37
|
+
|
|
38
|
+
Geometry model
|
|
39
|
+
--------------
|
|
40
|
+
* The last `rank_of_data` dimensions of `signal` are the detector dimensions,
|
|
41
|
+
ordered as (..., y, x) for 2D and (..., y) for 1D.
|
|
42
|
+
* `beam_center.signal` is given in [y, x] pixel coordinates for 2D,
|
|
43
|
+
and [y] for 1D.
|
|
44
|
+
* `pixel_size.signal` is [pixel_size_y, pixel_size_x] in length units / pixel.
|
|
45
|
+
* `pixel_size` is a BaseData vector of length 2 or 3 (length units):
|
|
46
|
+
- first component = pixel size along the "Q0" axis,
|
|
47
|
+
- second component = pixel size along the "Q1" axis,
|
|
48
|
+
- third component (if present) is currently unused.
|
|
49
|
+
* `detector_distance` and `wavelength` are BaseData scalars with length units.
|
|
50
|
+
|
|
51
|
+
All computed outputs (Q, Q0, Q1, Q2, Psi, TwoTheta, Omega) are BaseData objects.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
documentation = ProcessStepDescriber(
|
|
55
|
+
calling_name="Add Q, Psi, TwoTheta, Omega", # Omega is Solid Angle
|
|
56
|
+
calling_id="XSGeometry",
|
|
57
|
+
calling_module_path=Path(__file__),
|
|
58
|
+
calling_version=__version__,
|
|
59
|
+
required_data_keys=["signal"], # list of databundle keys required by the process
|
|
60
|
+
arguments={
|
|
61
|
+
"detector_distance_source": {
|
|
62
|
+
"type": (str, type(None)),
|
|
63
|
+
"required": True,
|
|
64
|
+
"default": None,
|
|
65
|
+
"doc": "IoSources key for detector distance signal.",
|
|
66
|
+
},
|
|
67
|
+
"detector_distance_units_source": {
|
|
68
|
+
"type": (str, type(None)),
|
|
69
|
+
"default": None,
|
|
70
|
+
"doc": "IoSources key for detector distance units.",
|
|
71
|
+
},
|
|
72
|
+
"detector_distance_uncertainties_sources": {
|
|
73
|
+
"type": dict,
|
|
74
|
+
"default": {},
|
|
75
|
+
"doc": "Uncertainty sources for detector distance.",
|
|
76
|
+
},
|
|
77
|
+
"pixel_size_source": {
|
|
78
|
+
"type": (str, type(None)),
|
|
79
|
+
"required": True,
|
|
80
|
+
"default": None,
|
|
81
|
+
"doc": "IoSources key for pixel size signal.",
|
|
82
|
+
},
|
|
83
|
+
"pixel_size_units_source": {
|
|
84
|
+
"type": (str, type(None)),
|
|
85
|
+
"default": None,
|
|
86
|
+
"doc": "IoSources key for pixel size units.",
|
|
87
|
+
},
|
|
88
|
+
"pixel_size_uncertainties_sources": {
|
|
89
|
+
"type": dict,
|
|
90
|
+
"default": {},
|
|
91
|
+
"doc": "Uncertainty sources for pixel size.",
|
|
92
|
+
},
|
|
93
|
+
"beam_center_source": {
|
|
94
|
+
"type": (str, type(None)),
|
|
95
|
+
"required": True,
|
|
96
|
+
"default": None,
|
|
97
|
+
"doc": "IoSources key for beam center signal.",
|
|
98
|
+
},
|
|
99
|
+
"beam_center_units_source": {
|
|
100
|
+
"type": (str, type(None)),
|
|
101
|
+
"default": None,
|
|
102
|
+
"doc": "IoSources key for beam center units.",
|
|
103
|
+
},
|
|
104
|
+
"beam_center_uncertainties_sources": {
|
|
105
|
+
"type": dict,
|
|
106
|
+
"default": {},
|
|
107
|
+
"doc": "Uncertainty sources for beam center.",
|
|
108
|
+
},
|
|
109
|
+
"wavelength_source": {
|
|
110
|
+
"type": (str, type(None)),
|
|
111
|
+
"required": True,
|
|
112
|
+
"default": None,
|
|
113
|
+
"doc": "IoSources key for wavelength signal.",
|
|
114
|
+
},
|
|
115
|
+
"wavelength_units_source": {
|
|
116
|
+
"type": (str, type(None)),
|
|
117
|
+
"default": None,
|
|
118
|
+
"doc": "IoSources key for wavelength units.",
|
|
119
|
+
},
|
|
120
|
+
"wavelength_uncertainties_sources": {
|
|
121
|
+
"type": dict,
|
|
122
|
+
"default": {},
|
|
123
|
+
"doc": "Uncertainty sources for wavelength.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
modifies={
|
|
127
|
+
"Q": ["signal", "uncertainties"],
|
|
128
|
+
"Q0": ["signal", "uncertainties"],
|
|
129
|
+
"Q1": ["signal", "uncertainties"],
|
|
130
|
+
"Q2": ["signal", "uncertainties"],
|
|
131
|
+
"Psi": ["signal", "uncertainties"],
|
|
132
|
+
"TwoTheta": ["signal", "uncertainties"],
|
|
133
|
+
"Omega": ["signal", "uncertainties"],
|
|
134
|
+
},
|
|
135
|
+
step_keywords=[
|
|
136
|
+
"geometry",
|
|
137
|
+
"Q",
|
|
138
|
+
"Psi",
|
|
139
|
+
"TwoTheta",
|
|
140
|
+
"Solid Angle",
|
|
141
|
+
"Omega",
|
|
142
|
+
"X-ray scattering",
|
|
143
|
+
],
|
|
144
|
+
step_doc="Add geometric information Q, Psi, TwoTheta, and Solid Angle to the data",
|
|
145
|
+
step_reference="DOI 10.1088/0953-8984/25/38/383201",
|
|
146
|
+
step_note="This calculates geometric factors relevant for X-ray scattering data",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Small helpers: geometry loading & shape utilities
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _load_geometry(self) -> Dict[str, BaseData]:
|
|
154
|
+
"""
|
|
155
|
+
Load all required geometry parameters as BaseData objects.
|
|
156
|
+
|
|
157
|
+
Expected configuration keys:
|
|
158
|
+
- detector_distance
|
|
159
|
+
- pixel_size
|
|
160
|
+
- beam_center
|
|
161
|
+
- wavelength
|
|
162
|
+
for each their *_source / *_units_source / *_uncertainties_sources.
|
|
163
|
+
"""
|
|
164
|
+
geom: Dict[str, BaseData] = {}
|
|
165
|
+
required_keys = ["detector_distance", "pixel_size", "beam_center", "wavelength"]
|
|
166
|
+
|
|
167
|
+
logger.debug(
|
|
168
|
+
f"XSGeometry: loading geometry for keys {required_keys} "
|
|
169
|
+
f"from configuration for processing keys={self.configuration.get('with_processing_keys')}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
for key in required_keys:
|
|
173
|
+
for subkey in [f"{key}_source", f"{key}_units_source", f"{key}_uncertainties_sources"]:
|
|
174
|
+
if subkey not in self.configuration:
|
|
175
|
+
raise ValueError(f"Missing required configuration parameter: {subkey}")
|
|
176
|
+
geom[key] = basedata_from_sources(
|
|
177
|
+
io_sources=self.io_sources,
|
|
178
|
+
signal_source=self.configuration.get(f"{key}_source"),
|
|
179
|
+
units_source=self.configuration.get(f"{key}_units_source", None),
|
|
180
|
+
uncertainty_sources=self.configuration.get(f"{key}_uncertainties_sources", {}),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
logger.debug(
|
|
184
|
+
"XSGeometry: loaded geometry BaseData objects: "
|
|
185
|
+
+ ", ".join(f"{k}: shape={v.signal.shape}, units={v.units}" for k, v in geom.items())
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return geom
|
|
189
|
+
|
|
190
|
+
def _validate_geometry(
|
|
191
|
+
self,
|
|
192
|
+
geom: Dict[str, BaseData],
|
|
193
|
+
RoD: int,
|
|
194
|
+
spatial_shape: tuple[int, ...],
|
|
195
|
+
) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Validate that geometry inputs are consistent with the detector rank.
|
|
198
|
+
"""
|
|
199
|
+
beam_center_bd = geom["beam_center"]
|
|
200
|
+
pixel_size_bd = geom["pixel_size"]
|
|
201
|
+
|
|
202
|
+
if RoD not in (0, 1, 2):
|
|
203
|
+
raise NotImplementedError(f"XSGeometry supports RoD 0, 1, or 2; got RoD={RoD}.") # noqa: E702
|
|
204
|
+
|
|
205
|
+
# Beam center: for RoD>0, we expect a vector of length RoD.
|
|
206
|
+
if RoD > 0:
|
|
207
|
+
if beam_center_bd.signal.size != RoD:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"Beam center must have {RoD} components for RoD={RoD}, got size={beam_center_bd.signal.size}."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Pixel size: vector of 2 or 3 components.
|
|
213
|
+
if pixel_size_bd.shape not in ((2,), (3,)):
|
|
214
|
+
raise ValueError(f"Pixel size should be a 2D or 3D vector, got shape={pixel_size_bd.shape}.")
|
|
215
|
+
|
|
216
|
+
# Sanity check on spatial_shape vs RoD
|
|
217
|
+
if RoD == 1 and len(spatial_shape) != 1:
|
|
218
|
+
raise ValueError(f"RoD=1 expects 1D spatial shape, got {spatial_shape}.")
|
|
219
|
+
if RoD == 2 and len(spatial_shape) != 2:
|
|
220
|
+
raise ValueError(f"RoD=2 expects 2D spatial shape, got {spatial_shape}.")
|
|
221
|
+
|
|
222
|
+
if pixel_size_bd.units.is_compatible_with(ureg.m / ureg.pixel) is False:
|
|
223
|
+
logger.warning(
|
|
224
|
+
f"Pixel size units are {pixel_size_bd.units}, xs_geometry expects pixel size units compatible with"
|
|
225
|
+
" [m/pixel]."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
logger.debug(
|
|
229
|
+
f"XSGeometry: validated geometry for RoD={RoD}, spatial_shape={spatial_shape}, "
|
|
230
|
+
f"beam_center.size={beam_center_bd.signal.size}, pixel_size.shape={pixel_size_bd.shape}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _make_index_basedata(
|
|
234
|
+
self,
|
|
235
|
+
shape: tuple[int, ...],
|
|
236
|
+
axis: int,
|
|
237
|
+
uncertainty_key: str = "pixel_index",
|
|
238
|
+
) -> BaseData:
|
|
239
|
+
"""
|
|
240
|
+
Create a BaseData representing pixel indices along a given axis.
|
|
241
|
+
|
|
242
|
+
Each index gets an uncertainty of ±0.5 pixel to reflect the
|
|
243
|
+
pixel-center assumption.
|
|
244
|
+
|
|
245
|
+
the indices are shifted by half a pixel to represent pixel centers.
|
|
246
|
+
This means if you floor a float coordinate in pixel units, you get the correct pixel index.
|
|
247
|
+
"""
|
|
248
|
+
if len(shape) == 0:
|
|
249
|
+
signal = np.array(0.0, dtype=float)
|
|
250
|
+
else:
|
|
251
|
+
grids = np.meshgrid(
|
|
252
|
+
*[np.arange(n, dtype=float) + 0.5 for n in shape],
|
|
253
|
+
indexing="ij",
|
|
254
|
+
)
|
|
255
|
+
signal = grids[axis]
|
|
256
|
+
|
|
257
|
+
# always add half-pixel uncertainty estimate to pixel indices
|
|
258
|
+
uncertainties: Dict[str, np.ndarray] = {uncertainty_key: np.full_like(signal, 0.5, dtype=float)}
|
|
259
|
+
|
|
260
|
+
return BaseData(
|
|
261
|
+
signal=signal,
|
|
262
|
+
units=ureg.pixel,
|
|
263
|
+
uncertainties=uncertainties,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
# Coordinate calculation per dimensionality
|
|
268
|
+
# ------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def _compute_coordinates(
|
|
271
|
+
self,
|
|
272
|
+
RoD: int,
|
|
273
|
+
spatial_shape: tuple[int, ...],
|
|
274
|
+
beam_center_bd: BaseData,
|
|
275
|
+
px0_bd: BaseData,
|
|
276
|
+
px1_bd: BaseData,
|
|
277
|
+
detector_distance_bd: BaseData,
|
|
278
|
+
) -> Tuple[BaseData, BaseData, BaseData, BaseData]:
|
|
279
|
+
"""
|
|
280
|
+
Compute detector-plane coordinates (x0, x1), in-plane radius r_perp,
|
|
281
|
+
and distance R from sample to pixel center, all as BaseData.
|
|
282
|
+
|
|
283
|
+
Returns
|
|
284
|
+
-------
|
|
285
|
+
x0_bd, x1_bd, r_perp_bd, R_bd
|
|
286
|
+
"""
|
|
287
|
+
if RoD == 0:
|
|
288
|
+
# 0D: no spatial axes, use the detector distance directly.
|
|
289
|
+
x0_bd = BaseData(signal=np.array(0.0), units=px0_bd.units)
|
|
290
|
+
x1_bd = BaseData(signal=np.array(0.0), units=px1_bd.units)
|
|
291
|
+
r_perp_bd = BaseData(signal=np.array(0.0), units=px0_bd.units * ureg.pixel)
|
|
292
|
+
R_bd = detector_distance_bd
|
|
293
|
+
logger.debug("XSGeometry: RoD=0, using detector distance directly for R.")
|
|
294
|
+
return x0_bd, x1_bd, r_perp_bd, R_bd
|
|
295
|
+
|
|
296
|
+
if RoD == 1:
|
|
297
|
+
(n0,) = spatial_shape
|
|
298
|
+
idx0_bd = self._make_index_basedata(shape=(n0,), axis=0)
|
|
299
|
+
|
|
300
|
+
rel_idx0_bd = idx0_bd - beam_center_bd.indexed(0, rank_of_data=0)
|
|
301
|
+
x0_bd = rel_idx0_bd * px0_bd
|
|
302
|
+
x1_bd = BaseData(
|
|
303
|
+
signal=np.zeros_like(x0_bd.signal),
|
|
304
|
+
units=x0_bd.units,
|
|
305
|
+
)
|
|
306
|
+
logger.debug(
|
|
307
|
+
f"XSGeometry: computed 1D coordinates for shape={spatial_shape}, x0.units={x0_bd.units}, x1 is zero."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
else: # RoD == 2
|
|
311
|
+
# image dimensions
|
|
312
|
+
n0, n1 = spatial_shape
|
|
313
|
+
# Axis 1 (columns) → x0, Axis 0 (rows) → x1
|
|
314
|
+
idx0_bd = self._make_index_basedata(shape=(n0, n1), axis=0)
|
|
315
|
+
idx1_bd = self._make_index_basedata(shape=(n0, n1), axis=1)
|
|
316
|
+
|
|
317
|
+
rel_idx0_bd = idx0_bd - beam_center_bd.indexed(0, rank_of_data=0)
|
|
318
|
+
rel_idx1_bd = idx1_bd - beam_center_bd.indexed(1, rank_of_data=0)
|
|
319
|
+
|
|
320
|
+
x0_bd = rel_idx0_bd * px0_bd
|
|
321
|
+
x1_bd = rel_idx1_bd * px1_bd
|
|
322
|
+
|
|
323
|
+
logger.debug(
|
|
324
|
+
f"XSGeometry: computed 2D coordinates for spatial_shape={spatial_shape}, "
|
|
325
|
+
f"x0.shape={x0_bd.signal.shape}, x1.shape={x1_bd.signal.shape}"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Common for RoD = 1, 2
|
|
329
|
+
r_perp_bd = ((x0_bd**2) + (x1_bd**2)).sqrt()
|
|
330
|
+
R_bd = ((r_perp_bd**2) + (detector_distance_bd**2)).sqrt()
|
|
331
|
+
|
|
332
|
+
logger.debug(
|
|
333
|
+
f"XSGeometry: computed r_perp and R; r_perp.shape={r_perp_bd.signal.shape}, R.shape={R_bd.signal.shape}" # noqa: E702
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return x0_bd, x1_bd, r_perp_bd, R_bd
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
# Derived quantities: angles, Q, Psi, solid angle
|
|
340
|
+
# ------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def _compute_angles(
|
|
343
|
+
self,
|
|
344
|
+
r_perp_bd: BaseData,
|
|
345
|
+
detector_distance_bd: BaseData,
|
|
346
|
+
) -> Tuple[BaseData, BaseData, BaseData]:
|
|
347
|
+
"""
|
|
348
|
+
Compute 2θ, θ, and sin(θ) as BaseData.
|
|
349
|
+
"""
|
|
350
|
+
ratio_bd = r_perp_bd / detector_distance_bd # dimensionless
|
|
351
|
+
two_theta_bd = ratio_bd.arctan() # radians
|
|
352
|
+
theta_bd = 0.5 * two_theta_bd # radians
|
|
353
|
+
sin_theta_bd = theta_bd.sin() # dimensionless
|
|
354
|
+
|
|
355
|
+
logger.debug(
|
|
356
|
+
f"XSGeometry: computed angles; two_theta.units={two_theta_bd.units}, theta.units={theta_bd.units}" # noqa: E702
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return two_theta_bd, theta_bd, sin_theta_bd
|
|
360
|
+
|
|
361
|
+
def _compute_Q_and_components(
|
|
362
|
+
self,
|
|
363
|
+
sin_theta_bd: BaseData,
|
|
364
|
+
wavelength_bd: BaseData,
|
|
365
|
+
x0_bd: BaseData,
|
|
366
|
+
x1_bd: BaseData,
|
|
367
|
+
r_perp_bd: BaseData,
|
|
368
|
+
) -> Tuple[BaseData, BaseData, BaseData, BaseData]:
|
|
369
|
+
"""
|
|
370
|
+
Compute Q magnitude and components Q0, Q1, Q2.
|
|
371
|
+
|
|
372
|
+
Uncertainties are propagated from:
|
|
373
|
+
- wavelength_bd (e.g. 'propagate_to_all'),
|
|
374
|
+
- r_perp_bd / x0_bd / x1_bd (pixel_index, pixel_size, distance, ...).
|
|
375
|
+
|
|
376
|
+
Q2 is nominally zero for a flat detector but we keep the same
|
|
377
|
+
uncertainty structure as Q to avoid empty/NaN uncertainty fields.
|
|
378
|
+
"""
|
|
379
|
+
four_pi = 4.0 * np.pi
|
|
380
|
+
|
|
381
|
+
# Q magnitude: (4π / λ) * sin θ
|
|
382
|
+
Q_bd = (four_pi * sin_theta_bd) / wavelength_bd # BaseData op → uncertainty propagation
|
|
383
|
+
|
|
384
|
+
# Build a "safe" r_perp copy where zeros in the signal are replaced by 1.0,
|
|
385
|
+
# but keep the original uncertainties so division still propagates correctly.
|
|
386
|
+
safe_signal = np.where(r_perp_bd.signal == 0.0, 1.0, r_perp_bd.signal)
|
|
387
|
+
|
|
388
|
+
r_perp_safe_bd = r_perp_bd.copy()
|
|
389
|
+
r_perp_safe_bd.signal = safe_signal
|
|
390
|
+
|
|
391
|
+
# Direction cosines (Psi components)
|
|
392
|
+
dir0_bd = x0_bd / r_perp_safe_bd
|
|
393
|
+
dir1_bd = x1_bd / r_perp_safe_bd
|
|
394
|
+
|
|
395
|
+
# Components of Q
|
|
396
|
+
Q0_bd = Q_bd * dir0_bd
|
|
397
|
+
Q1_bd = Q_bd * dir1_bd
|
|
398
|
+
|
|
399
|
+
# Flat detector: Q2 ≡ 0 but keep same uncertainties as Q
|
|
400
|
+
Q2_bd = Q_bd.copy()
|
|
401
|
+
Q2_bd.signal = np.zeros_like(Q_bd.signal)
|
|
402
|
+
|
|
403
|
+
logger.debug(
|
|
404
|
+
f"XSGeometry: computed Q and components; Q.shape={Q_bd.signal.shape}, Q.units={Q_bd.units}" # noqa: E702
|
|
405
|
+
) # noqa: E702
|
|
406
|
+
return Q_bd, Q0_bd, Q1_bd, Q2_bd
|
|
407
|
+
|
|
408
|
+
def _compute_psi(
|
|
409
|
+
self,
|
|
410
|
+
x0_bd: BaseData,
|
|
411
|
+
x1_bd: BaseData,
|
|
412
|
+
) -> BaseData:
|
|
413
|
+
"""
|
|
414
|
+
Compute azimuthal angle Psi from nominal coordinates only (no propagated uncertainty).
|
|
415
|
+
|
|
416
|
+
Psi = atan2(x1, x0)
|
|
417
|
+
"""
|
|
418
|
+
psi_signal = np.arctan2(x1_bd.signal, x0_bd.signal)
|
|
419
|
+
psi_bd = BaseData(
|
|
420
|
+
signal=psi_signal,
|
|
421
|
+
units=ureg.radian,
|
|
422
|
+
)
|
|
423
|
+
logger.debug(f"XSGeometry: computed Psi; shape={psi_bd.signal.shape}, units={psi_bd.units}") # noqa: E702
|
|
424
|
+
return psi_bd
|
|
425
|
+
|
|
426
|
+
def _compute_solid_angle(
|
|
427
|
+
self,
|
|
428
|
+
R_bd: BaseData,
|
|
429
|
+
px0_bd: BaseData,
|
|
430
|
+
px1_bd: BaseData,
|
|
431
|
+
detector_distance_bd: BaseData,
|
|
432
|
+
) -> BaseData:
|
|
433
|
+
"""
|
|
434
|
+
Compute solid angle per pixel (Omega) as BaseData.
|
|
435
|
+
|
|
436
|
+
Approximation:
|
|
437
|
+
dΩ ≈ A * D / R³
|
|
438
|
+
|
|
439
|
+
with A = pixel area (px0 * px1), D = detector distance, R = ray length.
|
|
440
|
+
"""
|
|
441
|
+
area_bd = px0_bd * px1_bd
|
|
442
|
+
R3_bd = R_bd**3
|
|
443
|
+
Omega_bd = (area_bd * detector_distance_bd) / R3_bd # dimensionless (sr)
|
|
444
|
+
# set units to steradian per pixel explicitly
|
|
445
|
+
Omega_bd.units = ureg.steradian / ureg.pixel
|
|
446
|
+
|
|
447
|
+
logger.debug(
|
|
448
|
+
f"XSGeometry: computed solid angle; Omega.shape={Omega_bd.signal.shape}, Omega.units={Omega_bd.units}" # noqa: E702
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return Omega_bd
|
|
452
|
+
|
|
453
|
+
# ------------------------------------------------------------------
|
|
454
|
+
# Main execution methods
|
|
455
|
+
# ------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
def prepare_execution(self):
|
|
458
|
+
"""
|
|
459
|
+
Precalculate Q, Q0, Q1, Q2, Psi, TwoTheta, and Omega as BaseData objects and
|
|
460
|
+
store them in self._prepared_data.
|
|
461
|
+
"""
|
|
462
|
+
super().prepare_execution()
|
|
463
|
+
|
|
464
|
+
pkey = self.configuration.get("with_processing_keys")
|
|
465
|
+
signal_bd: BaseData = self.processing_data[pkey[0]]["signal"]
|
|
466
|
+
RoD = signal_bd.rank_of_data
|
|
467
|
+
spatial_shape: tuple[int, ...] = signal_bd.shape[-RoD:] if RoD > 0 else ()
|
|
468
|
+
|
|
469
|
+
logger.info(f"XSGeometry: preparing execution for keys={pkey}, RoD={RoD}, spatial_shape={spatial_shape}")
|
|
470
|
+
|
|
471
|
+
# 2. Load and validate geometry
|
|
472
|
+
geom = self._load_geometry()
|
|
473
|
+
self._validate_geometry(geom, RoD, spatial_shape)
|
|
474
|
+
|
|
475
|
+
detector_distance_bd = geom["detector_distance"]
|
|
476
|
+
pixel_size_bd = geom["pixel_size"]
|
|
477
|
+
beam_center_bd = geom["beam_center"]
|
|
478
|
+
wavelength_bd = geom["wavelength"]
|
|
479
|
+
|
|
480
|
+
# 3. Extract pixel pitches along Q0/Q1
|
|
481
|
+
px0_bd = pixel_size_bd.indexed(0, rank_of_data=0)
|
|
482
|
+
px1_bd = pixel_size_bd.indexed(1, rank_of_data=0)
|
|
483
|
+
|
|
484
|
+
# 4. Coordinates (x0, x1, r_perp, R)
|
|
485
|
+
x0_bd, x1_bd, r_perp_bd, R_bd = self._compute_coordinates(
|
|
486
|
+
RoD=RoD,
|
|
487
|
+
spatial_shape=spatial_shape,
|
|
488
|
+
beam_center_bd=beam_center_bd,
|
|
489
|
+
px0_bd=px0_bd,
|
|
490
|
+
px1_bd=px1_bd,
|
|
491
|
+
detector_distance_bd=detector_distance_bd,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# 5. Angles: 2θ, θ, sin θ
|
|
495
|
+
two_theta_bd, theta_bd, sin_theta_bd = self._compute_angles(
|
|
496
|
+
r_perp_bd=r_perp_bd,
|
|
497
|
+
detector_distance_bd=detector_distance_bd,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# 6. Q magnitude and 7. components
|
|
501
|
+
Q_bd, Q0_bd, Q1_bd, Q2_bd = self._compute_Q_and_components(
|
|
502
|
+
sin_theta_bd=sin_theta_bd,
|
|
503
|
+
wavelength_bd=wavelength_bd,
|
|
504
|
+
x0_bd=x0_bd,
|
|
505
|
+
x1_bd=x1_bd,
|
|
506
|
+
r_perp_bd=r_perp_bd,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# 8. Psi
|
|
510
|
+
Psi_bd = self._compute_psi(
|
|
511
|
+
x0_bd=x0_bd,
|
|
512
|
+
x1_bd=x1_bd,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# 9. Solid angle (Omega)
|
|
516
|
+
Omega_bd = self._compute_solid_angle(
|
|
517
|
+
R_bd=R_bd,
|
|
518
|
+
px0_bd=px0_bd,
|
|
519
|
+
px1_bd=px1_bd,
|
|
520
|
+
detector_distance_bd=detector_distance_bd,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
# 10. Set rank_of_data on outputs and stash in prepared_data
|
|
524
|
+
for bd in (Q_bd, Q0_bd, Q1_bd, Q2_bd, Psi_bd, two_theta_bd, Omega_bd):
|
|
525
|
+
bd.rank_of_data = RoD
|
|
526
|
+
|
|
527
|
+
self._prepared_data = {
|
|
528
|
+
"Q": Q_bd,
|
|
529
|
+
"Q0": Q0_bd,
|
|
530
|
+
"Q1": Q1_bd,
|
|
531
|
+
"Q2": Q2_bd,
|
|
532
|
+
"Psi": Psi_bd,
|
|
533
|
+
"TwoTheta": two_theta_bd,
|
|
534
|
+
"Omega": Omega_bd,
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
logger.info(f"XSGeometry: prepared geometry outputs for keys={pkey}: Q, Q0, Q1, Q2, Psi, TwoTheta, Omega.")
|
|
538
|
+
|
|
539
|
+
def calculate(self):
|
|
540
|
+
"""
|
|
541
|
+
Add Q, Q0, Q1, Q2, Psi, TwoTheta, and Omega (solid angle) as BaseData objects
|
|
542
|
+
to the databundles specified in 'with_processing_keys'.
|
|
543
|
+
"""
|
|
544
|
+
data = self.processing_data
|
|
545
|
+
output: Dict[str, object] = {}
|
|
546
|
+
|
|
547
|
+
with_keys = self.configuration.get("with_processing_keys", [])
|
|
548
|
+
if not with_keys:
|
|
549
|
+
logger.warning("XSGeometry: no with_processing_keys specified; nothing to calculate.")
|
|
550
|
+
else:
|
|
551
|
+
logger.info(f"XSGeometry: adding geometry outputs to keys={with_keys}")
|
|
552
|
+
|
|
553
|
+
for key in with_keys:
|
|
554
|
+
databundle = data.get(key)
|
|
555
|
+
if databundle is None:
|
|
556
|
+
logger.warning(f"XSGeometry: processing_data has no entry for key={key!r}; skipping.") # noqa: E702
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
databundle["Q"] = self._prepared_data["Q"]
|
|
560
|
+
databundle["Q0"] = self._prepared_data["Q0"]
|
|
561
|
+
databundle["Q1"] = self._prepared_data["Q1"]
|
|
562
|
+
databundle["Q2"] = self._prepared_data["Q2"]
|
|
563
|
+
databundle["Psi"] = self._prepared_data["Psi"]
|
|
564
|
+
databundle["TwoTheta"] = self._prepared_data["TwoTheta"]
|
|
565
|
+
databundle["Omega"] = self._prepared_data["Omega"]
|
|
566
|
+
|
|
567
|
+
output[key] = databundle
|
|
568
|
+
|
|
569
|
+
logger.info(f"XSGeometry: geometry outputs attached for {len(output)} keys.")
|
|
570
|
+
|
|
571
|
+
return output
|