modacor 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. modacor/__init__.py +30 -0
  2. modacor/dataclasses/__init__.py +0 -0
  3. modacor/dataclasses/basedata.py +973 -0
  4. modacor/dataclasses/databundle.py +23 -0
  5. modacor/dataclasses/helpers.py +45 -0
  6. modacor/dataclasses/messagehandler.py +75 -0
  7. modacor/dataclasses/process_step.py +233 -0
  8. modacor/dataclasses/process_step_describer.py +146 -0
  9. modacor/dataclasses/processing_data.py +59 -0
  10. modacor/dataclasses/trace_event.py +118 -0
  11. modacor/dataclasses/uncertainty_tools.py +132 -0
  12. modacor/dataclasses/validators.py +84 -0
  13. modacor/debug/pipeline_tracer.py +548 -0
  14. modacor/io/__init__.py +33 -0
  15. modacor/io/csv/__init__.py +0 -0
  16. modacor/io/csv/csv_sink.py +114 -0
  17. modacor/io/csv/csv_source.py +210 -0
  18. modacor/io/hdf/__init__.py +27 -0
  19. modacor/io/hdf/hdf_source.py +120 -0
  20. modacor/io/io_sink.py +41 -0
  21. modacor/io/io_sinks.py +61 -0
  22. modacor/io/io_source.py +164 -0
  23. modacor/io/io_sources.py +208 -0
  24. modacor/io/processing_path.py +113 -0
  25. modacor/io/tiled/__init__.py +16 -0
  26. modacor/io/tiled/tiled_source.py +403 -0
  27. modacor/io/yaml/__init__.py +27 -0
  28. modacor/io/yaml/yaml_source.py +116 -0
  29. modacor/modules/__init__.py +53 -0
  30. modacor/modules/base_modules/__init__.py +0 -0
  31. modacor/modules/base_modules/append_processing_data.py +329 -0
  32. modacor/modules/base_modules/append_sink.py +141 -0
  33. modacor/modules/base_modules/append_source.py +181 -0
  34. modacor/modules/base_modules/bitwise_or_masks.py +113 -0
  35. modacor/modules/base_modules/combine_uncertainties.py +120 -0
  36. modacor/modules/base_modules/combine_uncertainties_max.py +105 -0
  37. modacor/modules/base_modules/divide.py +82 -0
  38. modacor/modules/base_modules/find_scale_factor1d.py +373 -0
  39. modacor/modules/base_modules/multiply.py +77 -0
  40. modacor/modules/base_modules/multiply_databundles.py +73 -0
  41. modacor/modules/base_modules/poisson_uncertainties.py +69 -0
  42. modacor/modules/base_modules/reduce_dimensionality.py +252 -0
  43. modacor/modules/base_modules/sink_processing_data.py +80 -0
  44. modacor/modules/base_modules/subtract.py +80 -0
  45. modacor/modules/base_modules/subtract_databundles.py +67 -0
  46. modacor/modules/base_modules/units_label_update.py +66 -0
  47. modacor/modules/instrument_modules/__init__.py +0 -0
  48. modacor/modules/instrument_modules/readme.md +9 -0
  49. modacor/modules/technique_modules/__init__.py +0 -0
  50. modacor/modules/technique_modules/scattering/__init__.py +0 -0
  51. modacor/modules/technique_modules/scattering/geometry_helpers.py +114 -0
  52. modacor/modules/technique_modules/scattering/index_pixels.py +492 -0
  53. modacor/modules/technique_modules/scattering/indexed_averager.py +628 -0
  54. modacor/modules/technique_modules/scattering/pixel_coordinates_3d.py +417 -0
  55. modacor/modules/technique_modules/scattering/solid_angle_correction.py +63 -0
  56. modacor/modules/technique_modules/scattering/xs_geometry.py +571 -0
  57. modacor/modules/technique_modules/scattering/xs_geometry_from_pixel_coordinates.py +293 -0
  58. modacor/runner/__init__.py +0 -0
  59. modacor/runner/pipeline.py +749 -0
  60. modacor/runner/process_step_registry.py +224 -0
  61. modacor/tests/__init__.py +27 -0
  62. modacor/tests/dataclasses/test_basedata.py +519 -0
  63. modacor/tests/dataclasses/test_basedata_operations.py +439 -0
  64. modacor/tests/dataclasses/test_basedata_to_base_units.py +57 -0
  65. modacor/tests/dataclasses/test_process_step_describer.py +73 -0
  66. modacor/tests/dataclasses/test_processstep.py +282 -0
  67. modacor/tests/debug/test_tracing_integration.py +188 -0
  68. modacor/tests/integration/__init__.py +0 -0
  69. modacor/tests/integration/test_pipeline_run.py +238 -0
  70. modacor/tests/io/__init__.py +27 -0
  71. modacor/tests/io/csv/__init__.py +0 -0
  72. modacor/tests/io/csv/test_csv_source.py +156 -0
  73. modacor/tests/io/hdf/__init__.py +27 -0
  74. modacor/tests/io/hdf/test_hdf_source.py +92 -0
  75. modacor/tests/io/test_io_sources.py +119 -0
  76. modacor/tests/io/tiled/__init__.py +12 -0
  77. modacor/tests/io/tiled/test_tiled_source.py +120 -0
  78. modacor/tests/io/yaml/__init__.py +27 -0
  79. modacor/tests/io/yaml/static_data_example.yaml +26 -0
  80. modacor/tests/io/yaml/test_yaml_source.py +47 -0
  81. modacor/tests/modules/__init__.py +27 -0
  82. modacor/tests/modules/base_modules/__init__.py +27 -0
  83. modacor/tests/modules/base_modules/test_append_processing_data.py +219 -0
  84. modacor/tests/modules/base_modules/test_append_sink.py +76 -0
  85. modacor/tests/modules/base_modules/test_append_source.py +180 -0
  86. modacor/tests/modules/base_modules/test_bitwise_or_masks.py +264 -0
  87. modacor/tests/modules/base_modules/test_combine_uncertainties.py +105 -0
  88. modacor/tests/modules/base_modules/test_combine_uncertainties_max.py +109 -0
  89. modacor/tests/modules/base_modules/test_divide.py +140 -0
  90. modacor/tests/modules/base_modules/test_find_scale_factor1d.py +220 -0
  91. modacor/tests/modules/base_modules/test_multiply.py +113 -0
  92. modacor/tests/modules/base_modules/test_multiply_databundles.py +136 -0
  93. modacor/tests/modules/base_modules/test_poisson_uncertainties.py +61 -0
  94. modacor/tests/modules/base_modules/test_reduce_dimensionality.py +358 -0
  95. modacor/tests/modules/base_modules/test_sink_processing_data.py +119 -0
  96. modacor/tests/modules/base_modules/test_subtract.py +111 -0
  97. modacor/tests/modules/base_modules/test_subtract_databundles.py +136 -0
  98. modacor/tests/modules/base_modules/test_units_label_update.py +91 -0
  99. modacor/tests/modules/technique_modules/__init__.py +0 -0
  100. modacor/tests/modules/technique_modules/scattering/__init__.py +0 -0
  101. modacor/tests/modules/technique_modules/scattering/test_geometry_helpers.py +198 -0
  102. modacor/tests/modules/technique_modules/scattering/test_index_pixels.py +426 -0
  103. modacor/tests/modules/technique_modules/scattering/test_indexed_averaging.py +559 -0
  104. modacor/tests/modules/technique_modules/scattering/test_pixel_coordinates_3d.py +282 -0
  105. modacor/tests/modules/technique_modules/scattering/test_xs_geometry_from_pixel_coordinates.py +224 -0
  106. modacor/tests/modules/technique_modules/scattering/test_xsgeometry.py +635 -0
  107. modacor/tests/requirements.txt +12 -0
  108. modacor/tests/runner/test_pipeline.py +438 -0
  109. modacor/tests/runner/test_process_step_registry.py +65 -0
  110. modacor/tests/test_import.py +43 -0
  111. modacor/tests/test_modacor.py +17 -0
  112. modacor/tests/test_units.py +79 -0
  113. modacor/units.py +97 -0
  114. modacor-1.0.0.dist-info/METADATA +482 -0
  115. modacor-1.0.0.dist-info/RECORD +120 -0
  116. modacor-1.0.0.dist-info/WHEEL +5 -0
  117. modacor-1.0.0.dist-info/licenses/AUTHORS.md +11 -0
  118. modacor-1.0.0.dist-info/licenses/LICENSE +11 -0
  119. modacor-1.0.0.dist-info/licenses/LICENSE.txt +11 -0
  120. modacor-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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