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,492 @@
|
|
|
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"]
|
|
9
|
+
__copyright__ = "Copyright 2025, The MoDaCor team"
|
|
10
|
+
__date__ = "29/11/2025"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
|
|
13
|
+
__version__ = "20251130.1"
|
|
14
|
+
__all__ = ["IndexPixels"]
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Tuple
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from modacor import ureg
|
|
22
|
+
from modacor.dataclasses.basedata import BaseData
|
|
23
|
+
from modacor.dataclasses.databundle import DataBundle
|
|
24
|
+
from modacor.dataclasses.messagehandler import MessageHandler
|
|
25
|
+
from modacor.dataclasses.process_step import ProcessStep
|
|
26
|
+
from modacor.dataclasses.process_step_describer import ProcessStepDescriber
|
|
27
|
+
|
|
28
|
+
logger = MessageHandler(name=__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IndexPixels(ProcessStep):
|
|
32
|
+
"""
|
|
33
|
+
Compute pixel bin indices for a single dataset, for subsequent 1D averaging.
|
|
34
|
+
|
|
35
|
+
Depending on `averaging_direction`, this step can prepare indices for:
|
|
36
|
+
- azimuthal averaging (bin along Q, normal for 1D X-ray scattering curves).
|
|
37
|
+
- radial averaging (bin along Psi, usually for getting orientation information);
|
|
38
|
+
|
|
39
|
+
This step:
|
|
40
|
+
- Interprets Q limits in user-specified units (q_limits_unit).
|
|
41
|
+
- Interprets Psi limits in user-specified units (psi_limits_unit).
|
|
42
|
+
- Builds bin edges internally (Q or Psi depending on averaging_direction).
|
|
43
|
+
- For each pixel, decides which bin it belongs to, or -1 if it does
|
|
44
|
+
not participate in any bin (out of range / outside ROI / non-finite).
|
|
45
|
+
|
|
46
|
+
Inputs (from the databundle selected via with_processing_keys)
|
|
47
|
+
--------------------------------------------------------------
|
|
48
|
+
- "signal": BaseData (together with its rank_of_data used for data shape)
|
|
49
|
+
- "Q": BaseData (modulus of scattering vector)
|
|
50
|
+
- "Psi": BaseData (azimuthal angle)
|
|
51
|
+
|
|
52
|
+
This step does *not* apply the Mask. Mask is left to downstream modules
|
|
53
|
+
(e.g., the averaging step), so that it can vary per frame for dynamic masking.
|
|
54
|
+
|
|
55
|
+
Configuration
|
|
56
|
+
-------------
|
|
57
|
+
with_processing_keys : str | list[str] | None
|
|
58
|
+
Databundle key(s) to work on. The pixel index map is computed from
|
|
59
|
+
the first key and attached to all specified keys.
|
|
60
|
+
If None and there is exactly one databundle, that one is used.
|
|
61
|
+
|
|
62
|
+
averaging_direction : {"radial", "azimuthal"}, default "azimuthal"
|
|
63
|
+
- "azimuthal": bins along Q, using q_min/q_max and bin_type;
|
|
64
|
+
- "radial": bins along Psi (linear bins), using psi_min/psi_max.
|
|
65
|
+
In this case q_min/q_max define a radial ROI mask (optional).
|
|
66
|
+
|
|
67
|
+
q_min, q_max : float, optional
|
|
68
|
+
Q limits expressed in units given by q_limits_unit.
|
|
69
|
+
If omitted:
|
|
70
|
+
- For "radial" + "log" binning: q_min = smallest positive finite Q;
|
|
71
|
+
- Otherwise: q_min = min(Q), q_max = max(Q).
|
|
72
|
+
q_min may be negative if not using "log" binning. Useful for e.g. USAXS scans.
|
|
73
|
+
|
|
74
|
+
q_limits_unit : str or pint.Unit, optional
|
|
75
|
+
Units in which q_min/q_max are defined, e.g. "1/nm".
|
|
76
|
+
Defaults to the Q.units of the dataset.
|
|
77
|
+
|
|
78
|
+
n_bins : int, default 100
|
|
79
|
+
Number of bins along the averaging direction (Q or Psi).
|
|
80
|
+
|
|
81
|
+
bin_type : {"log", "linear"}, default "log"
|
|
82
|
+
- For averaging_direction="radial":
|
|
83
|
+
"log" uses geometric spacing (np.geomspace);
|
|
84
|
+
"linear" uses np.linspace.
|
|
85
|
+
- For averaging_direction="azimuthal":
|
|
86
|
+
Must be "linear" (logarithmic psi is not implemented).
|
|
87
|
+
|
|
88
|
+
psi_min, psi_max : float, optional
|
|
89
|
+
Azimuth limits expressed in psi_limits_unit.
|
|
90
|
+
For averaging_direction="azimuthal":
|
|
91
|
+
- These define an azimuthal mask (ROI).
|
|
92
|
+
- Defaults to a full circle:
|
|
93
|
+
* 0 .. 360 if psi_limits_unit is degree;
|
|
94
|
+
* 0 .. 2π if psi_limits_unit is radian.
|
|
95
|
+
For averaging_direction="radial":
|
|
96
|
+
- These also define the binning range along Psi.
|
|
97
|
+
|
|
98
|
+
psi_limits_unit : str or pint.Unit, optional
|
|
99
|
+
Units in which psi_min/psi_max are defined (i.e. "degree" or "radian").
|
|
100
|
+
Defaults to the Psi.units of the dataset.
|
|
101
|
+
|
|
102
|
+
Outputs (returned from calculate())
|
|
103
|
+
-----------------------------------
|
|
104
|
+
One DataBundle per key in with_processing_keys, each containing:
|
|
105
|
+
|
|
106
|
+
- "pixel_index": BaseData
|
|
107
|
+
signal : ndarray with same shape as the last rank_of_data ndims
|
|
108
|
+
of the chosen "signal" BaseData.
|
|
109
|
+
Each entry is an integer bin index (stored as float in
|
|
110
|
+
BaseData; will be cast back to int when used).
|
|
111
|
+
-1 means "this pixel does not participate in any bin".
|
|
112
|
+
units : dimensionless
|
|
113
|
+
uncertainties : empty dict
|
|
114
|
+
axes : copied from the *last* rank_of_data axes of the original signal
|
|
115
|
+
rank_of_data : same as the original signal BaseData
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
documentation = ProcessStepDescriber(
|
|
119
|
+
calling_name="Index Pixels",
|
|
120
|
+
calling_id="IndexPixels",
|
|
121
|
+
calling_module_path=Path(__file__),
|
|
122
|
+
calling_version=__version__,
|
|
123
|
+
required_data_keys=["signal", "Q", "Psi"],
|
|
124
|
+
arguments={
|
|
125
|
+
"with_processing_keys": {
|
|
126
|
+
"type": (str, list, type(None)),
|
|
127
|
+
"required": True,
|
|
128
|
+
"default": None,
|
|
129
|
+
"doc": "ProcessingData key or list of keys to index.",
|
|
130
|
+
},
|
|
131
|
+
"averaging_direction": {
|
|
132
|
+
"type": str,
|
|
133
|
+
"required": True,
|
|
134
|
+
"default": "radial",
|
|
135
|
+
"doc": "Averaging direction: 'radial' or 'azimuthal'.",
|
|
136
|
+
},
|
|
137
|
+
"q_min": {
|
|
138
|
+
"type": (float, int, type(None)),
|
|
139
|
+
"default": None,
|
|
140
|
+
"doc": "Minimum Q value for binning.",
|
|
141
|
+
},
|
|
142
|
+
"q_max": {
|
|
143
|
+
"type": (float, int, type(None)),
|
|
144
|
+
"default": None,
|
|
145
|
+
"doc": "Maximum Q value for binning.",
|
|
146
|
+
},
|
|
147
|
+
"q_limits_unit": {
|
|
148
|
+
"type": (str, type(None)),
|
|
149
|
+
"default": None,
|
|
150
|
+
"doc": "Units for q_min/q_max if provided.",
|
|
151
|
+
},
|
|
152
|
+
"n_bins": {
|
|
153
|
+
"type": int,
|
|
154
|
+
"default": 100,
|
|
155
|
+
"doc": "Number of bins.",
|
|
156
|
+
},
|
|
157
|
+
"bin_type": {
|
|
158
|
+
"type": str,
|
|
159
|
+
"default": "log",
|
|
160
|
+
"doc": "Binning type: 'linear' or 'log'.",
|
|
161
|
+
},
|
|
162
|
+
"psi_min": {
|
|
163
|
+
"type": (float, int, type(None)),
|
|
164
|
+
"default": None,
|
|
165
|
+
"doc": "Minimum Psi value for binning.",
|
|
166
|
+
},
|
|
167
|
+
"psi_max": {
|
|
168
|
+
"type": (float, int, type(None)),
|
|
169
|
+
"default": None,
|
|
170
|
+
"doc": "Maximum Psi value for binning.",
|
|
171
|
+
},
|
|
172
|
+
"psi_limits_unit": {
|
|
173
|
+
"type": (str, type(None)),
|
|
174
|
+
"default": None,
|
|
175
|
+
"doc": "Units for psi_min/psi_max if provided.",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
modifies={}, # nothing, we only add.
|
|
179
|
+
step_keywords=[
|
|
180
|
+
"radial",
|
|
181
|
+
"azimuthal",
|
|
182
|
+
"pixel indexing",
|
|
183
|
+
"binning",
|
|
184
|
+
"scattering",
|
|
185
|
+
],
|
|
186
|
+
step_doc="Compute per-pixel bin indices (radial or azimuthal) for later 1D averaging.",
|
|
187
|
+
step_reference="DOI 10.1088/0953-8984/25/38/383201",
|
|
188
|
+
step_note=(
|
|
189
|
+
"IndexPixels computes bin indices purely from geometry (Q, Psi) and "
|
|
190
|
+
"user-defined limits; Mask is not used here so it can be applied "
|
|
191
|
+
"per frame in downstream steps."
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def __attrs_post_init__(self) -> None:
|
|
196
|
+
super().__attrs_post_init__()
|
|
197
|
+
# Prepared state lives in self._prepared_data.
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# internal helper: normalise with_processing_keys
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
def _normalised_keys(self) -> Tuple[str, List[str]]:
|
|
203
|
+
"""
|
|
204
|
+
Return (primary_key, keys_to_update).
|
|
205
|
+
|
|
206
|
+
primary_key: the key used to compute the pixel index map.
|
|
207
|
+
keys_to_update: all keys that should receive the map.
|
|
208
|
+
"""
|
|
209
|
+
keys = self._normalised_processing_keys()
|
|
210
|
+
primary_key = keys[0]
|
|
211
|
+
if len(keys) > 1:
|
|
212
|
+
logger.warning(
|
|
213
|
+
(
|
|
214
|
+
"IndexPixels: multiple with_processing_keys given; "
|
|
215
|
+
"pixel index map will be computed from the first (%r) and "
|
|
216
|
+
"attached to all %r."
|
|
217
|
+
),
|
|
218
|
+
primary_key,
|
|
219
|
+
keys,
|
|
220
|
+
)
|
|
221
|
+
return primary_key, keys
|
|
222
|
+
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
# internal helper: geometry / shape validation
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
def _validate_and_get_geometry(
|
|
227
|
+
self,
|
|
228
|
+
databundle: DataBundle,
|
|
229
|
+
) -> Tuple[BaseData, BaseData, BaseData, int, Tuple[int, ...], List[BaseData | None]]:
|
|
230
|
+
"""
|
|
231
|
+
Validate signal/Q/Psi for azimuthal geometry and return:
|
|
232
|
+
|
|
233
|
+
signal_bd, q_bd, psi_bd, RoD, spatial_shape, spatial_axes
|
|
234
|
+
"""
|
|
235
|
+
signal_bd: BaseData = databundle["signal"]
|
|
236
|
+
q_bd: BaseData = databundle["Q"]
|
|
237
|
+
psi_bd: BaseData = databundle["Psi"]
|
|
238
|
+
|
|
239
|
+
RoD: int = int(signal_bd.rank_of_data)
|
|
240
|
+
if RoD not in (1, 2):
|
|
241
|
+
raise ValueError(f"IndexPixels: rank_of_data must be 1 or 2 for azimuthal geometry, got {RoD}.")
|
|
242
|
+
|
|
243
|
+
spatial_shape: Tuple[int, ...] = signal_bd.shape[-RoD:] if RoD > 0 else ()
|
|
244
|
+
|
|
245
|
+
if q_bd.shape != spatial_shape:
|
|
246
|
+
raise ValueError(f"IndexPixels: Q shape {q_bd.shape} does not match spatial shape {spatial_shape}.")
|
|
247
|
+
if psi_bd.shape != spatial_shape:
|
|
248
|
+
raise ValueError(f"IndexPixels: Psi shape {psi_bd.shape} does not match spatial shape {spatial_shape}.")
|
|
249
|
+
|
|
250
|
+
if signal_bd.axes:
|
|
251
|
+
spatial_axes: List[BaseData | None] = list(signal_bd.axes[-RoD:])
|
|
252
|
+
else:
|
|
253
|
+
spatial_axes = []
|
|
254
|
+
|
|
255
|
+
return signal_bd, q_bd, psi_bd, RoD, spatial_shape, spatial_axes
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# prepare_execution: all geometry + array work happens here
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
def prepare_execution(self) -> None: # noqa: C901 # complexity issue / separation of concerns TODO: fix this later.
|
|
261
|
+
"""
|
|
262
|
+
Prepare the pixel index map for the selected databundle.
|
|
263
|
+
|
|
264
|
+
All heavy computations and array manipulations are done here.
|
|
265
|
+
calculate() only wraps the prepared BaseData into DataBundles.
|
|
266
|
+
"""
|
|
267
|
+
if self._prepared_data.get("pixel_index_bd") is not None:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
if self.processing_data is None:
|
|
271
|
+
raise RuntimeError("IndexPixels: processing_data is None in prepare_execution.")
|
|
272
|
+
|
|
273
|
+
primary_key, keys_to_update = self._normalised_keys()
|
|
274
|
+
self._prepared_data["keys_to_update"] = keys_to_update
|
|
275
|
+
|
|
276
|
+
if primary_key not in self.processing_data:
|
|
277
|
+
raise KeyError(f"IndexPixels: key {primary_key!r} not found in processing_data.") # noqa: E713
|
|
278
|
+
|
|
279
|
+
databundle: DataBundle = self.processing_data[primary_key]
|
|
280
|
+
(
|
|
281
|
+
signal_bd,
|
|
282
|
+
q_bd,
|
|
283
|
+
psi_bd,
|
|
284
|
+
RoD,
|
|
285
|
+
spatial_shape,
|
|
286
|
+
spatial_axes,
|
|
287
|
+
) = self._validate_and_get_geometry(databundle)
|
|
288
|
+
|
|
289
|
+
# Direction of averaging: "radial" or "azimuthal"
|
|
290
|
+
direction = str(self.configuration.get("averaging_direction", "azimuthal")).lower()
|
|
291
|
+
if direction not in ("radial", "azimuthal"):
|
|
292
|
+
raise ValueError(f"IndexPixels: averaging_direction must be 'radial' or 'azimuthal', got {direction!r}.")
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
# 1. Resolve Q limits (mask +, for radial, binning)
|
|
296
|
+
# ------------------------------------------------------------------
|
|
297
|
+
q_min_cfg = self.configuration.get("q_min", None)
|
|
298
|
+
q_max_cfg = self.configuration.get("q_max", None)
|
|
299
|
+
n_bins = int(self.configuration.get("n_bins", 100))
|
|
300
|
+
bin_type = str(self.configuration.get("bin_type", "log")).lower()
|
|
301
|
+
|
|
302
|
+
if n_bins <= 0:
|
|
303
|
+
raise ValueError(f"IndexPixels: n_bins must be positive, got {n_bins}.")
|
|
304
|
+
|
|
305
|
+
q_limits_unit_cfg = self.configuration.get("q_limits_unit", None)
|
|
306
|
+
if q_limits_unit_cfg is None:
|
|
307
|
+
q_limits_unit = q_bd.units
|
|
308
|
+
else:
|
|
309
|
+
q_limits_unit = ureg.Unit(q_limits_unit_cfg)
|
|
310
|
+
|
|
311
|
+
q_full = np.asarray(q_bd.signal, dtype=float)
|
|
312
|
+
try:
|
|
313
|
+
q_flat = q_full.ravel()
|
|
314
|
+
except Exception as exc: # noqa: BLE001
|
|
315
|
+
raise ValueError("IndexPixels: could not flatten Q array.") from exc
|
|
316
|
+
|
|
317
|
+
finite_q = q_flat[np.isfinite(q_flat)]
|
|
318
|
+
if finite_q.size == 0:
|
|
319
|
+
raise ValueError("IndexPixels: Q array has no finite values.")
|
|
320
|
+
|
|
321
|
+
data_q_min = float(np.nanmin(finite_q))
|
|
322
|
+
data_q_max = float(np.nanmax(finite_q))
|
|
323
|
+
|
|
324
|
+
if direction == "azimuthal":
|
|
325
|
+
# q_min/q_max define both mask and bin range
|
|
326
|
+
if q_min_cfg is not None:
|
|
327
|
+
q_min_val = (float(q_min_cfg) * q_limits_unit).to(q_bd.units).magnitude
|
|
328
|
+
else:
|
|
329
|
+
if bin_type == "log":
|
|
330
|
+
positive = finite_q[finite_q > 0.0]
|
|
331
|
+
if positive.size == 0:
|
|
332
|
+
raise ValueError("IndexPixels: cannot determine positive q_min for log binning.")
|
|
333
|
+
q_min_val = float(np.nanmin(positive))
|
|
334
|
+
else:
|
|
335
|
+
q_min_val = data_q_min
|
|
336
|
+
|
|
337
|
+
if q_max_cfg is not None:
|
|
338
|
+
q_max_val = (float(q_max_cfg) * q_limits_unit).to(q_bd.units).magnitude
|
|
339
|
+
else:
|
|
340
|
+
q_max_val = data_q_max
|
|
341
|
+
else:
|
|
342
|
+
# radial: q_min/q_max are optional ROI only; ignore bin_type here
|
|
343
|
+
if q_min_cfg is not None:
|
|
344
|
+
q_min_val = (float(q_min_cfg) * q_limits_unit).to(q_bd.units).magnitude
|
|
345
|
+
else:
|
|
346
|
+
q_min_val = data_q_min
|
|
347
|
+
|
|
348
|
+
if q_max_cfg is not None:
|
|
349
|
+
q_max_val = (float(q_max_cfg) * q_limits_unit).to(q_bd.units).magnitude
|
|
350
|
+
else:
|
|
351
|
+
q_max_val = data_q_max
|
|
352
|
+
|
|
353
|
+
if q_max_val <= q_min_val or not np.isfinite(q_min_val) or not np.isfinite(q_max_val):
|
|
354
|
+
raise ValueError(f"IndexPixels: invalid Q range q_min={q_min_val}, q_max={q_max_val}.")
|
|
355
|
+
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
# 2. Resolve Psi limits (mask +, for azimuthal, binning)
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
psi_limits_unit_cfg = self.configuration.get("psi_limits_unit", None)
|
|
360
|
+
if psi_limits_unit_cfg is None:
|
|
361
|
+
psi_limits_unit = psi_bd.units
|
|
362
|
+
else:
|
|
363
|
+
psi_limits_unit = ureg.Unit(psi_limits_unit_cfg)
|
|
364
|
+
|
|
365
|
+
psi_min_cfg = self.configuration.get("psi_min", None)
|
|
366
|
+
psi_max_cfg = self.configuration.get("psi_max", None)
|
|
367
|
+
|
|
368
|
+
if psi_min_cfg is None:
|
|
369
|
+
psi_min_cfg = 0.0
|
|
370
|
+
|
|
371
|
+
if psi_max_cfg is None:
|
|
372
|
+
# Choose a default full-circle depending on psi_limits_unit
|
|
373
|
+
if psi_limits_unit == ureg.degree:
|
|
374
|
+
psi_max_cfg = 360.0
|
|
375
|
+
elif psi_limits_unit == ureg.radian:
|
|
376
|
+
psi_max_cfg = 2.0 * np.pi
|
|
377
|
+
else:
|
|
378
|
+
raise ValueError(
|
|
379
|
+
"IndexPixels: psi_limits_unit is neither degree nor radian "
|
|
380
|
+
"and no psi_max is specified; cannot infer a full-circle default."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
psi_min_val = (float(psi_min_cfg) * psi_limits_unit).to(psi_bd.units).magnitude
|
|
384
|
+
psi_max_val = (float(psi_max_cfg) * psi_limits_unit).to(psi_bd.units).magnitude
|
|
385
|
+
|
|
386
|
+
psi_full = np.asarray(psi_bd.signal, dtype=float)
|
|
387
|
+
try:
|
|
388
|
+
psi_flat = psi_full.ravel()
|
|
389
|
+
except Exception as exc: # noqa: BLE001
|
|
390
|
+
raise ValueError("IndexPixels: could not flatten Psi array.") from exc
|
|
391
|
+
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
# 3. Build masks
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
finite_mask = np.isfinite(q_flat) & np.isfinite(psi_flat)
|
|
396
|
+
|
|
397
|
+
# Radial mask from Q limits
|
|
398
|
+
q_range_mask = (q_flat >= q_min_val) & (q_flat <= q_max_val)
|
|
399
|
+
|
|
400
|
+
# Azimuthal mask from Psi limits
|
|
401
|
+
if np.isclose(psi_min_val, psi_max_val):
|
|
402
|
+
# Full circle
|
|
403
|
+
psi_mask = np.ones_like(psi_flat, dtype=bool)
|
|
404
|
+
elif psi_min_val < psi_max_val:
|
|
405
|
+
psi_mask = (psi_flat >= psi_min_val) & (psi_flat <= psi_max_val)
|
|
406
|
+
else:
|
|
407
|
+
# Wrap-around (e.g. 350° .. 10° converted to Psi.units)
|
|
408
|
+
psi_mask = (psi_flat >= psi_min_val) | (psi_flat <= psi_max_val)
|
|
409
|
+
|
|
410
|
+
valid_geom = q_range_mask & psi_mask & finite_mask
|
|
411
|
+
|
|
412
|
+
# ------------------------------------------------------------------
|
|
413
|
+
# 4. Build bin edges and assign indices
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
if direction == "azimuthal":
|
|
416
|
+
coord_flat = q_flat
|
|
417
|
+
if bin_type == "log":
|
|
418
|
+
if q_min_val <= 0.0:
|
|
419
|
+
raise ValueError("IndexPixels: q_min must be > 0 for log binning.")
|
|
420
|
+
bin_edges = np.geomspace(q_min_val, q_max_val, num=n_bins + 1, dtype=float)
|
|
421
|
+
elif bin_type == "linear":
|
|
422
|
+
bin_edges = np.linspace(q_min_val, q_max_val, num=n_bins + 1, dtype=float)
|
|
423
|
+
else:
|
|
424
|
+
raise ValueError(
|
|
425
|
+
f"IndexPixels: unknown bin_type {bin_type!r} for radial averaging. Expected 'log' or 'linear'."
|
|
426
|
+
)
|
|
427
|
+
else: # radial
|
|
428
|
+
# direction == "radial": bin along Psi, require linear spacing
|
|
429
|
+
coord_flat = psi_flat
|
|
430
|
+
if bin_type != "linear":
|
|
431
|
+
raise ValueError("IndexPixels: for averaging_direction='radial', only bin_type='linear' is supported.")
|
|
432
|
+
bin_edges = np.linspace(psi_min_val, psi_max_val, num=n_bins + 1, dtype=float)
|
|
433
|
+
|
|
434
|
+
bin_idx = np.searchsorted(bin_edges, coord_flat, side="right") - 1
|
|
435
|
+
out_of_range = (bin_idx < 0) | (bin_idx >= n_bins)
|
|
436
|
+
valid_idx = valid_geom & ~out_of_range
|
|
437
|
+
|
|
438
|
+
# Pixels that are not valid for any reason get index -1
|
|
439
|
+
bin_idx[~valid_idx] = -1
|
|
440
|
+
|
|
441
|
+
# Reshape to the spatial shape
|
|
442
|
+
bin_idx_reshaped = bin_idx.reshape(spatial_shape)
|
|
443
|
+
|
|
444
|
+
pixel_index_bd = BaseData(
|
|
445
|
+
signal=bin_idx_reshaped,
|
|
446
|
+
units=ureg.dimensionless,
|
|
447
|
+
uncertainties={},
|
|
448
|
+
weights=np.array(1.0),
|
|
449
|
+
axes=spatial_axes,
|
|
450
|
+
rank_of_data=signal_bd.rank_of_data,
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
self._prepared_data["pixel_index_bd"] = pixel_index_bd
|
|
454
|
+
|
|
455
|
+
# ------------------------------------------------------------------
|
|
456
|
+
# calculate: only wraps the prepared BaseData into DataBundles
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
def calculate(self) -> Dict[str, DataBundle]:
|
|
459
|
+
"""
|
|
460
|
+
Add the pixel index as BaseData to the databundles specified in
|
|
461
|
+
'with_processing_keys'. If multiple keys are given, the same pixel
|
|
462
|
+
index map (computed from the first) is added to all.
|
|
463
|
+
"""
|
|
464
|
+
output: Dict[str, DataBundle] = {}
|
|
465
|
+
|
|
466
|
+
if self.processing_data is None:
|
|
467
|
+
logger.warning("IndexPixels: processing_data is None in calculate; nothing to do.")
|
|
468
|
+
return output
|
|
469
|
+
|
|
470
|
+
if self._prepared_data.get("pixel_index_bd") is None:
|
|
471
|
+
self.prepare_execution()
|
|
472
|
+
|
|
473
|
+
pixel_index_bd: BaseData = self._prepared_data["pixel_index_bd"]
|
|
474
|
+
_primary, keys_to_update = self._normalised_keys()
|
|
475
|
+
|
|
476
|
+
logger.info(f"IndexPixels: adding pixel indices to keys={keys_to_update}")
|
|
477
|
+
|
|
478
|
+
for key in keys_to_update:
|
|
479
|
+
databundle = self.processing_data.get(key)
|
|
480
|
+
if databundle is None:
|
|
481
|
+
logger.warning(
|
|
482
|
+
"IndexPixels: processing_data has no entry for key=%r; skipping.",
|
|
483
|
+
key,
|
|
484
|
+
)
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
# Use a copy so each databundle has its own BaseData instance
|
|
488
|
+
databundle["pixel_index"] = pixel_index_bd.copy(with_axes=True)
|
|
489
|
+
output[key] = databundle
|
|
490
|
+
|
|
491
|
+
logger.info(f"IndexPixels: pixel indices attached for {len(output)} keys.")
|
|
492
|
+
return output
|