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