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,628 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # /usr/bin/env python3
3
+ # -*- coding: utf-8 -*-
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Dict, List, Tuple
8
+
9
+ __coding__ = "utf-8"
10
+ __authors__ = ["Brian R. Pauw"]
11
+ __copyright__ = "Copyright 2025, The MoDaCor team"
12
+ __date__ = "30/11/2025"
13
+ __status__ = "Development" # "Development", "Production"
14
+
15
+ __version__ = "20251130.1"
16
+ __all__ = ["IndexedAverager"]
17
+
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ from modacor import ureg
23
+ from modacor.dataclasses.basedata import BaseData
24
+ from modacor.dataclasses.databundle import DataBundle
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
+ logger = MessageHandler(name=__name__)
30
+
31
+
32
+ class IndexedAverager(ProcessStep):
33
+ """
34
+ Perform averaging of signal using precomputed per-pixel bin indices.
35
+
36
+ This module expects that a previous step (e.g. IndexPixels) has produced a
37
+ "pixel_index" BaseData map containing, for each pixel, the bin index it
38
+ belongs to (or -1 for "not participating").
39
+
40
+ It then:
41
+ - Combines the per-pixel signal into bin-averaged values using optional
42
+ weights.
43
+ - Computes a weighted mean Q per bin.
44
+ - Computes a weighted circular mean Psi per bin.
45
+ - Propagates per-pixel uncertainties on signal and Q to the bin-mean.
46
+ - Estimates a bin-level SEM ("SEM" key) from the scatter of the signal for Q, Psi and signal.
47
+
48
+ Inputs (from the databundle selected via with_processing_keys)
49
+ --------------------------------------------------------------
50
+ - "signal": BaseData
51
+ - "Q": BaseData
52
+ - "Psi": BaseData
53
+ - "pixel_index": BaseData
54
+ same spatial rank and shape as (at least) the signal data.
55
+ - "Mask": BaseData (optional)
56
+ boolean mask, True meaning "masked" (pixel ignored).
57
+
58
+ Configuration
59
+ -------------
60
+ with_processing_keys : list[str]
61
+ Databundle key(s) to work on. If None and there is exactly one
62
+ databundle, that one is used.
63
+
64
+ averaging_direction : {"radial", "azimuthal"}, default "azimuthal"
65
+ Only used to decide which axis is attached to the output signal:
66
+ - "azimuthal": signal.axes[0] will reference Q_1d.
67
+ - "radial": signal.axes[0] will reference Psi_1d.
68
+ The underlying binning is entirely determined by "pixel_index".
69
+
70
+ use_signal_weights : bool, default True
71
+ If True, multiply per-pixel weights into the total weight.
72
+
73
+ use_signal_uncertainty_weights : bool, default False
74
+ If True, use 1 / sigma^2 (for the specified key) as an additional
75
+ factor in the weights.
76
+
77
+ uncertainty_weight_key : str | None, default None
78
+ Uncertainty key in signal.uncertainties to use when
79
+ use_signal_uncertainty_weights is True. Must be provided and present
80
+ in signal.uncertainties in that case.
81
+
82
+ Outputs (returned from calculate())
83
+ -----------------------------------
84
+ For each key in with_processing_keys, the corresponding databundle will
85
+ be updated with 1D BaseData:
86
+
87
+ - "signal": BaseData
88
+ Bin-averaged signal as 1D array (length n_bins).
89
+ Units: same as input signal.units.
90
+ uncertainties:
91
+ * For each original signal uncertainty key 'k', a propagated sigma
92
+ for the bin mean under that key.
93
+ * Optional keys "SEM" and "STD" with bin-level standard error on the
94
+ mean and standard deviation derived from the weighted scatter.
95
+
96
+ - "Q": BaseData
97
+ Weighted mean Q per bin (length n_bins).
98
+ units: same as input Q.units.
99
+ uncertainties:
100
+ * For each original Q uncertainty key 'k', propagated sigma on the
101
+ bin mean for that key.
102
+ * Optional keys "SEM" and "STD" derived from the weighted scatter.
103
+
104
+ - "Psi": BaseData
105
+ Weighted circular mean of Psi per bin (length n_bins).
106
+ units: same as input Psi.units.
107
+ uncertainties:
108
+ * For each original Psi uncertainty key 'k', propagated sigma on the
109
+ bin mean for that key (using linear propagation on angles).
110
+ * Optional keys "SEM" and "STD" derived from the weighted scatter.
111
+
112
+ The original 2D/1D "pixel_index" and optional "Mask" remain present in
113
+ the databundle, enabling further inspection or reuse.
114
+ """
115
+
116
+ documentation = ProcessStepDescriber(
117
+ calling_name="Indexed Averager",
118
+ calling_id="IndexedAverager",
119
+ calling_module_path=Path(__file__),
120
+ calling_version=__version__,
121
+ required_data_keys=["signal", "Q", "Psi", "pixel_index"],
122
+ arguments={
123
+ "with_processing_keys": {
124
+ "type": (str, list, type(None)),
125
+ "required": True,
126
+ "default": None,
127
+ "doc": "ProcessingData key or list of keys to average.",
128
+ },
129
+ "output_processing_key": {
130
+ "type": (str, type(None)),
131
+ "default": None,
132
+ "doc": "Optional output key override (currently unused).",
133
+ },
134
+ "averaging_direction": {
135
+ "type": str,
136
+ "required": True,
137
+ "default": "azimuthal",
138
+ "doc": "Averaging direction: 'radial' or 'azimuthal'.",
139
+ },
140
+ "use_signal_weights": {
141
+ "type": bool,
142
+ "default": True,
143
+ "doc": "Use BaseData weights when averaging signal.",
144
+ },
145
+ "use_signal_uncertainty_weights": {
146
+ "type": bool,
147
+ "default": False,
148
+ "doc": "Use signal uncertainty as weights.",
149
+ },
150
+ "uncertainty_weight_key": {
151
+ "type": (str, type(None)),
152
+ "default": None,
153
+ "doc": "Uncertainty key to use as weights if enabled.",
154
+ },
155
+ "stats_keys": {
156
+ "type": (list, str, type(None)),
157
+ "default": None,
158
+ "doc": (
159
+ "BaseData keys to receive SEM/STD statistics (e.g. ['signal', 'Q']). "
160
+ "If None, statistics are computed for all outputs."
161
+ ),
162
+ },
163
+ },
164
+ modifies={
165
+ # We overwrite 'signal', 'Q', 'Psi' with their 1D binned versions.
166
+ "signal": ["signal", "uncertainties"],
167
+ "Q": ["signal", "uncertainties"],
168
+ "Psi": ["signal", "uncertainties"],
169
+ },
170
+ step_keywords=[
171
+ "radial",
172
+ "azimuthal",
173
+ "averaging",
174
+ "binning",
175
+ "scattering",
176
+ ],
177
+ step_doc="Average signal and geometry using precomputed pixel bin indices.",
178
+ step_reference="DOI 10.1088/0953-8984/25/38/383201",
179
+ step_note=(
180
+ "IndexedAverager expects a 'pixel_index' map from a previous "
181
+ "IndexPixels step and performs per-bin weighted means of signal, "
182
+ "Q and Psi, including uncertainty propagation."
183
+ ),
184
+ )
185
+
186
+ def __attrs_post_init__(self) -> None:
187
+ super().__attrs_post_init__()
188
+
189
+ # ------------------------------------------------------------------
190
+ # Helper: normalise with_processing_keys to a list
191
+ # ------------------------------------------------------------------
192
+ def _normalised_keys(self) -> List[str]:
193
+ """
194
+ Normalise with_processing_keys into a non-empty list of strings.
195
+
196
+ If configuration value is None and exactly one databundle is present
197
+ in processing_data, that key is returned as the single entry.
198
+ """
199
+ return self._normalised_processing_keys()
200
+
201
+ # ------------------------------------------------------------------
202
+ # Helper: validate geometry, signal and pixel_index for a databundle
203
+ # ------------------------------------------------------------------
204
+ def _validate_inputs(
205
+ self,
206
+ databundle: DataBundle,
207
+ ) -> Tuple[BaseData, BaseData, BaseData, BaseData, BaseData | None, Tuple[int, ...]]:
208
+ """
209
+ Validate presence and shapes of signal, Q, Psi, pixel_index
210
+ (and optional Mask) for a given databundle.
211
+
212
+ Returns:
213
+ signal_bd, q_bd, psi_bd, pix_bd, mask_bd_or_None, spatial_shape
214
+ """
215
+ try:
216
+ signal_bd: BaseData = databundle["signal"]
217
+ q_bd: BaseData = databundle["Q"]
218
+ psi_bd: BaseData = databundle["Psi"]
219
+ pix_bd: BaseData = databundle["pixel_index"]
220
+ except KeyError as exc:
221
+ raise KeyError(
222
+ "IndexedAverager: databundle missing required keys 'signal', 'Q', 'Psi', or 'pixel_index'."
223
+ ) from exc
224
+
225
+ spatial_shape: Tuple[int, ...] = tuple(signal_bd.shape)
226
+ if q_bd.shape != spatial_shape:
227
+ raise ValueError(f"IndexedAverager: Q shape {q_bd.shape} does not match signal shape {spatial_shape}.")
228
+ if psi_bd.shape != spatial_shape:
229
+ raise ValueError(f"IndexedAverager: Psi shape {psi_bd.shape} does not match signal shape {spatial_shape}.")
230
+ if pix_bd.shape != spatial_shape:
231
+ raise ValueError(
232
+ f"IndexedAverager: pixel_index shape {pix_bd.shape} does not match signal shape {spatial_shape}."
233
+ )
234
+
235
+ mask_bd: BaseData | None = None
236
+ # Optional mask: we accept 'Mask' or 'mask'
237
+ if "Mask" in databundle:
238
+ mask_bd = databundle["Mask"]
239
+ elif "mask" in databundle:
240
+ mask_bd = databundle["mask"]
241
+
242
+ if mask_bd is not None and mask_bd.shape != spatial_shape:
243
+ raise ValueError(
244
+ f"IndexedAverager: Mask shape {mask_bd.shape} does not match signal shape {spatial_shape}."
245
+ )
246
+
247
+ return signal_bd, q_bd, psi_bd, pix_bd, mask_bd, spatial_shape
248
+
249
+ # ------------------------------------------------------------------
250
+ # Helper: core binning/averaging logic
251
+ # ------------------------------------------------------------------
252
+ @staticmethod
253
+ def _compute_bin_averages( # noqa: C901 -- complexity # TODO: reduce complexity after testing with index_pixels
254
+ signal_bd: BaseData,
255
+ q_bd: BaseData,
256
+ psi_bd: BaseData,
257
+ pix_bd: BaseData,
258
+ mask_bd: BaseData | None,
259
+ use_signal_weights: bool,
260
+ use_signal_uncertainty_weights: bool,
261
+ uncertainty_weight_key: str | None,
262
+ stats_keys: list[str] | None,
263
+ ) -> Tuple[BaseData, BaseData, BaseData]:
264
+ """
265
+ Core binning logic: produce 1D BaseData for signal, Q, Psi.
266
+
267
+ All inputs are assumed to have identical shapes.
268
+ """
269
+
270
+ # Flatten arrays
271
+ sig_full = np.asarray(signal_bd.signal, dtype=float).ravel()
272
+ q_full = np.asarray(q_bd.signal, dtype=float).ravel()
273
+ psi_full = np.asarray(psi_bd.signal, dtype=float).ravel()
274
+
275
+ pix_flat = np.asarray(pix_bd.signal, dtype=float).ravel().astype(int)
276
+
277
+ if sig_full.size == 0:
278
+ raise ValueError("IndexedAverager: signal array is empty.")
279
+
280
+ if not np.all(np.isfinite(pix_flat) | (pix_flat == -1)):
281
+ logger.warning("IndexedAverager: pixel_index contains non-finite entries; treating them as -1.")
282
+ pix_flat[~np.isfinite(pix_flat)] = -1
283
+
284
+ # Base validity: a pixel participates if index >= 0
285
+ valid = pix_flat >= 0
286
+
287
+ # Apply optional mask: True means "masked" → exclude
288
+ if mask_bd is not None:
289
+ mask_flat = np.asarray(mask_bd.signal, dtype=bool).ravel()
290
+ if mask_flat.shape != pix_flat.shape:
291
+ raise ValueError("IndexedAverager: Mask shape does not match pixel_index.")
292
+ valid &= ~mask_flat
293
+
294
+ # Exclude non-finite signal / Q / Psi
295
+ valid &= np.isfinite(sig_full) & np.isfinite(q_full) & np.isfinite(psi_full)
296
+
297
+ if not np.any(valid):
298
+ raise ValueError("IndexedAverager: no valid pixels to average.")
299
+
300
+ bin_idx = pix_flat[valid]
301
+ sig_valid = sig_full[valid]
302
+ q_valid = q_full[valid]
303
+ psi_valid = psi_full[valid]
304
+
305
+ n_bins = int(bin_idx.max()) + 1
306
+ if n_bins <= 0:
307
+ raise ValueError("IndexedAverager: inferred n_bins is non-positive.")
308
+
309
+ # ------------------------------------------------------------------
310
+ # 1. Combined weights
311
+ # ------------------------------------------------------------------
312
+ w = np.ones_like(sig_full, dtype=float)
313
+
314
+ if use_signal_weights:
315
+ w_bd = np.asarray(signal_bd.weights, dtype=float)
316
+ try:
317
+ w_bd_full = np.broadcast_to(w_bd, signal_bd.shape).ravel()
318
+ except ValueError as exc:
319
+ raise ValueError("IndexedAverager: could not broadcast signal.weights to signal shape.") from exc
320
+ w *= w_bd_full
321
+
322
+ if use_signal_uncertainty_weights:
323
+ if uncertainty_weight_key is None:
324
+ raise ValueError(
325
+ "IndexedAverager: use_signal_uncertainty_weights=True but uncertainty_weight_key is None."
326
+ )
327
+ if uncertainty_weight_key not in signal_bd.uncertainties:
328
+ raise KeyError(
329
+ f"IndexedAverager: uncertainty key {uncertainty_weight_key!r} not found in signal.uncertainties." # noqa: E713
330
+ )
331
+
332
+ sigma_u = np.asarray(signal_bd.uncertainties[uncertainty_weight_key], dtype=float)
333
+ try:
334
+ sigma_full = np.broadcast_to(sigma_u, signal_bd.shape).ravel()
335
+ except ValueError as exc:
336
+ raise ValueError(
337
+ "IndexedAverager: could not broadcast chosen uncertainty array to signal shape."
338
+ ) from exc
339
+
340
+ var_full = sigma_full**2
341
+ # Only accept strictly positive finite variances for weighting
342
+ valid_sigma = np.isfinite(var_full) & (var_full > 0.0)
343
+ if not np.any(valid_sigma & valid):
344
+ raise ValueError(
345
+ "IndexedAverager: no pixels have positive finite variance under the chosen uncertainty_weight_key."
346
+ )
347
+
348
+ # Pixels with non-positive/NaN variance are effectively dropped
349
+ valid &= valid_sigma
350
+ var_full[~valid_sigma] = np.inf # avoid division by zero
351
+ w *= 1.0 / var_full
352
+
353
+ # Recompute valid slice after potential tightening due to uncertainty weights
354
+ valid_idx = np.where(valid)[0]
355
+ bin_idx = pix_flat[valid_idx]
356
+ sig_valid = sig_full[valid_idx]
357
+ q_valid = q_full[valid_idx]
358
+ psi_valid = psi_full[valid_idx]
359
+ w_valid = w[valid_idx]
360
+
361
+ if not np.any(w_valid > 0.0):
362
+ raise ValueError("IndexedAverager: all weights are zero; cannot compute averages.")
363
+
364
+ # Clamp negative weights to zero (should not happen, but be robust)
365
+ w_valid = np.clip(w_valid, a_min=0.0, a_max=None)
366
+
367
+ # ------------------------------------------------------------------
368
+ # 2. Weighted sums for signal and Q
369
+ # ------------------------------------------------------------------
370
+ sum_w = np.bincount(bin_idx, weights=w_valid, minlength=n_bins)
371
+ sum_wx = np.bincount(bin_idx, weights=w_valid * sig_valid, minlength=n_bins)
372
+ sum_wq = np.bincount(bin_idx, weights=w_valid * q_valid, minlength=n_bins)
373
+
374
+ with np.errstate(divide="ignore", invalid="ignore"):
375
+ mean_signal = np.full(n_bins, np.nan, dtype=float)
376
+ mean_q = np.full(n_bins, np.nan, dtype=float)
377
+
378
+ positive = sum_w > 0.0
379
+ mean_signal[positive] = sum_wx[positive] / sum_w[positive]
380
+ mean_q[positive] = sum_wq[positive] / sum_w[positive]
381
+
382
+ # ------------------------------------------------------------------
383
+ # 3. Weighted circular mean for Psi
384
+ # ------------------------------------------------------------------
385
+ psi_unit = psi_bd.units
386
+
387
+ # Convert Psi to radians for trigonometric operations
388
+ cf_to_rad = ureg.radian.m_from(psi_unit)
389
+ psi_rad_valid = psi_valid * cf_to_rad
390
+
391
+ cos_psi = np.cos(psi_rad_valid)
392
+ sin_psi = np.sin(psi_rad_valid)
393
+
394
+ sum_wcos = np.bincount(bin_idx, weights=w_valid * cos_psi, minlength=n_bins)
395
+ sum_wsin = np.bincount(bin_idx, weights=w_valid * sin_psi, minlength=n_bins)
396
+
397
+ with np.errstate(divide="ignore", invalid="ignore"):
398
+ mean_cos = np.full(n_bins, np.nan, dtype=float)
399
+ mean_sin = np.full(n_bins, np.nan, dtype=float)
400
+ mean_psi_rad = np.full(n_bins, np.nan, dtype=float)
401
+
402
+ positive = sum_w > 0.0
403
+ mean_cos[positive] = sum_wcos[positive] / sum_w[positive]
404
+ mean_sin[positive] = sum_wsin[positive] / sum_w[positive]
405
+
406
+ mean_psi_rad[positive] = np.arctan2(mean_sin[positive], mean_cos[positive])
407
+
408
+ # Convert back to original Psi units
409
+ cf_from_rad = psi_unit.m_from(ureg.radian)
410
+ mean_psi = mean_psi_rad * cf_from_rad
411
+
412
+ # ------------------------------------------------------------------
413
+ # 4. Propagate uncertainties on signal, Q, Psi
414
+ # ------------------------------------------------------------------
415
+ sig_unc_binned: Dict[str, np.ndarray] = {}
416
+ q_unc_binned: Dict[str, np.ndarray] = {}
417
+ psi_unc_binned: Dict[str, np.ndarray] = {}
418
+
419
+ # Helper for propagation: sigma_mean = sqrt(sum (w^2 * sigma^2)) / sum_w
420
+ def _propagate_uncertainties(unc_dict: Dict[str, np.ndarray], ref_bd: BaseData) -> Dict[str, np.ndarray]:
421
+ result: Dict[str, np.ndarray] = {}
422
+ for key, arr in unc_dict.items():
423
+ arr_full = np.asarray(arr, dtype=float)
424
+ try:
425
+ arr_full = np.broadcast_to(arr_full, ref_bd.shape).ravel()
426
+ except ValueError as exc:
427
+ raise ValueError(
428
+ f"IndexedAverager: could not broadcast uncertainty[{key!r}] to reference shape."
429
+ ) from exc
430
+
431
+ arr_valid = arr_full[valid_idx]
432
+ var_valid = arr_valid**2
433
+
434
+ sum_w2_var = np.bincount(bin_idx, weights=(w_valid**2) * var_valid, minlength=n_bins)
435
+
436
+ sigma = np.full(n_bins, np.nan, dtype=float)
437
+ with np.errstate(divide="ignore", invalid="ignore"):
438
+ positive = sum_w > 0.0
439
+ sigma[positive] = np.sqrt(sum_w2_var[positive]) / sum_w[positive]
440
+
441
+ result[key] = sigma
442
+ return result
443
+
444
+ if signal_bd.uncertainties:
445
+ sig_unc_binned.update(_propagate_uncertainties(signal_bd.uncertainties, signal_bd))
446
+ if q_bd.uncertainties:
447
+ q_unc_binned.update(_propagate_uncertainties(q_bd.uncertainties, q_bd))
448
+ if psi_bd.uncertainties:
449
+ psi_unc_binned.update(_propagate_uncertainties(psi_bd.uncertainties, psi_bd))
450
+
451
+ # ------------------------------------------------------------------
452
+ # 5. SEM/STD from scatter of selected outputs
453
+ # ------------------------------------------------------------------
454
+ if stats_keys is None:
455
+ stats_keys = ["signal", "Q", "Psi"]
456
+
457
+ stats_keys = [str(key) for key in stats_keys]
458
+
459
+ # Effective sample size:
460
+ sum_w2 = np.bincount(bin_idx, weights=w_valid**2, minlength=n_bins)
461
+ with np.errstate(divide="ignore", invalid="ignore"):
462
+ N_eff = np.full(n_bins, np.nan, dtype=float)
463
+ positive = sum_w2 > 0.0
464
+ N_eff[positive] = (sum_w[positive] ** 2) / sum_w2[positive]
465
+
466
+ def _scatter_stats(values: np.ndarray, mean_per_bin: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
467
+ mean_per_pixel = mean_per_bin[bin_idx]
468
+ dev = values - mean_per_pixel
469
+ sum_w_dev2 = np.bincount(bin_idx, weights=w_valid * (dev**2), minlength=n_bins)
470
+ with np.errstate(divide="ignore", invalid="ignore"):
471
+ var_spread = np.full(n_bins, np.nan, dtype=float)
472
+ sem_spread = np.full(n_bins, np.nan, dtype=float)
473
+ std_spread = np.full(n_bins, np.nan, dtype=float)
474
+
475
+ valid_bins = (sum_w > 0.0) & np.isfinite(N_eff) & (N_eff > 1.0)
476
+ var_spread[valid_bins] = sum_w_dev2[valid_bins] / sum_w[valid_bins]
477
+ std_spread[valid_bins] = np.sqrt(var_spread[valid_bins])
478
+ sem_spread[valid_bins] = np.sqrt(var_spread[valid_bins] / N_eff[valid_bins])
479
+
480
+ return sem_spread, std_spread
481
+
482
+ if "signal" in stats_keys:
483
+ sem_signal, std_signal = _scatter_stats(sig_valid, mean_signal)
484
+ sig_unc_binned["SEM"] = sem_signal
485
+ sig_unc_binned["STD"] = std_signal
486
+
487
+ if "Q" in stats_keys:
488
+ sem_q, std_q = _scatter_stats(q_valid, mean_q)
489
+ q_unc_binned["SEM"] = sem_q
490
+ q_unc_binned["STD"] = std_q
491
+
492
+ if "Psi" in stats_keys:
493
+ mean_psi_rad_per_pixel = mean_psi_rad[bin_idx]
494
+ dev_rad = psi_rad_valid - mean_psi_rad_per_pixel
495
+ dev_rad = (dev_rad + np.pi) % (2 * np.pi) - np.pi
496
+ sem_psi_rad, std_psi_rad = _scatter_stats(dev_rad + mean_psi_rad_per_pixel, mean_psi_rad)
497
+ sem_psi = sem_psi_rad * cf_from_rad
498
+ std_psi = std_psi_rad * cf_from_rad
499
+ psi_unc_binned["SEM"] = sem_psi
500
+ psi_unc_binned["STD"] = std_psi
501
+
502
+ # ------------------------------------------------------------------
503
+ # 6. Build output BaseData objects
504
+ # ------------------------------------------------------------------
505
+ # 1D signal
506
+ signal_1d = BaseData(
507
+ signal=mean_signal,
508
+ units=signal_bd.units,
509
+ uncertainties=sig_unc_binned,
510
+ weights=np.ones_like(mean_signal, dtype=float),
511
+ axes=[], # will be filled based on averaging_direction in caller
512
+ rank_of_data=1,
513
+ )
514
+
515
+ # 1D Q
516
+ Q_1d = BaseData(
517
+ signal=mean_q,
518
+ units=q_bd.units,
519
+ uncertainties=q_unc_binned,
520
+ weights=np.ones_like(mean_q, dtype=float),
521
+ axes=[],
522
+ rank_of_data=1,
523
+ )
524
+
525
+ # 1D Psi
526
+ Psi_1d = BaseData(
527
+ signal=mean_psi,
528
+ units=psi_bd.units,
529
+ uncertainties=psi_unc_binned,
530
+ weights=np.ones_like(mean_psi, dtype=float),
531
+ axes=[],
532
+ rank_of_data=1,
533
+ )
534
+
535
+ return signal_1d, Q_1d, Psi_1d
536
+
537
+ # ------------------------------------------------------------------
538
+ # prepare_execution: nothing heavy here for now
539
+ # ------------------------------------------------------------------
540
+ def prepare_execution(self) -> None:
541
+ """
542
+ For IndexedAverager, there is no heavy geometry to precompute.
543
+
544
+ All binning work depends on the per-frame signal, so we perform the
545
+ averaging inside calculate(). This method only validates that the
546
+ configuration is at least minimally sensible.
547
+ """
548
+ # ensure configuration keys are present; defaults are already set by __attrs_post_init__
549
+ direction = str(self.configuration.get("averaging_direction", "azimuthal")).lower()
550
+ if direction not in ("radial", "azimuthal"):
551
+ raise ValueError(
552
+ f"IndexedAverager: averaging_direction must be 'radial' or 'azimuthal', got {direction!r}."
553
+ )
554
+
555
+ # ------------------------------------------------------------------
556
+ # calculate: perform per-key averaging using pixel_index
557
+ # ------------------------------------------------------------------
558
+ def calculate(self) -> Dict[str, DataBundle]:
559
+ """
560
+ For each databundle in with_processing_keys, perform the binning /
561
+ averaging using the precomputed pixel_index map and return updated
562
+ DataBundles containing 1D 'signal', 'Q', and 'Psi' BaseData.
563
+ """
564
+ output: Dict[str, DataBundle] = {}
565
+
566
+ if self.processing_data is None:
567
+ logger.warning("IndexedAverager: processing_data is None in calculate; nothing to do.")
568
+ return output
569
+
570
+ keys = self._normalised_keys()
571
+ use_signal_weights = bool(self.configuration.get("use_signal_weights", True))
572
+ use_unc_w = bool(self.configuration.get("use_signal_uncertainty_weights", False))
573
+ uncertainty_weight_key = self.configuration.get("uncertainty_weight_key", None)
574
+ direction = str(self.configuration.get("averaging_direction", "azimuthal")).lower()
575
+ stats_keys_cfg = self.configuration.get("stats_keys", None)
576
+ if isinstance(stats_keys_cfg, str):
577
+ stats_keys_cfg = [stats_keys_cfg]
578
+
579
+ for key in keys:
580
+ if key not in self.processing_data:
581
+ logger.warning(
582
+ "IndexedAverager: processing_data has no entry for key=%r; skipping.",
583
+ key,
584
+ )
585
+ continue
586
+
587
+ databundle = self.processing_data[key]
588
+
589
+ (
590
+ signal_bd,
591
+ q_bd,
592
+ psi_bd,
593
+ pix_bd,
594
+ mask_bd,
595
+ _spatial_shape,
596
+ ) = self._validate_inputs(databundle)
597
+
598
+ # Compute binned 1D BaseData
599
+ signal_1d, Q_1d, Psi_1d = self._compute_bin_averages(
600
+ signal_bd=signal_bd,
601
+ q_bd=q_bd,
602
+ psi_bd=psi_bd,
603
+ pix_bd=pix_bd,
604
+ mask_bd=mask_bd,
605
+ use_signal_weights=use_signal_weights,
606
+ use_signal_uncertainty_weights=use_unc_w,
607
+ uncertainty_weight_key=uncertainty_weight_key,
608
+ stats_keys=stats_keys_cfg,
609
+ )
610
+
611
+ # Attach axis: Q for azimuthal, Psi for radial (convention)
612
+ if direction == "azimuthal":
613
+ signal_1d.axes = [Q_1d]
614
+ else: # "radial"
615
+ signal_1d.axes = [Psi_1d]
616
+
617
+ db_out = DataBundle(
618
+ {
619
+ "signal": signal_1d,
620
+ "Q": Q_1d,
621
+ "Psi": Psi_1d,
622
+ # pixel_index, Mask, etc. remain in the original databundle
623
+ }
624
+ )
625
+
626
+ output[key] = db_out
627
+
628
+ return output