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,252 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
# /usr/bin/env python3
|
|
3
|
+
# -*- coding: utf-8 -*-
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__coding__ = "utf-8"
|
|
8
|
+
__authors__ = ["Brian R. Pauw"] # add names to the list as appropriate
|
|
9
|
+
__copyright__ = "Copyright 2025, The MoDaCor team"
|
|
10
|
+
__date__ = "16/11/2025"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
|
|
13
|
+
__all__ = ["ReduceDimensionality"]
|
|
14
|
+
__version__ = "20251116.1"
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from modacor.dataclasses.basedata import BaseData
|
|
22
|
+
from modacor.dataclasses.databundle import DataBundle
|
|
23
|
+
from modacor.dataclasses.messagehandler import MessageHandler
|
|
24
|
+
from modacor.dataclasses.process_step import ProcessStep
|
|
25
|
+
from modacor.dataclasses.process_step_describer import ProcessStepDescriber
|
|
26
|
+
|
|
27
|
+
# Facility-pluggable logger; by default this uses std logging
|
|
28
|
+
logger = MessageHandler(name=__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReduceDimensionality(ProcessStep):
|
|
32
|
+
"""
|
|
33
|
+
Compute a (possibly weighted) average of a BaseData signal over one or more axes,
|
|
34
|
+
propagating uncertainties.
|
|
35
|
+
|
|
36
|
+
For each uncertainty key `k`, assumes uncorrelated errors:
|
|
37
|
+
|
|
38
|
+
μ = Σ w_i x_i / Σ w_i
|
|
39
|
+
σ_μ^2 = Σ (w_i^2 σ_i^2) / (Σ w_i)^2
|
|
40
|
+
|
|
41
|
+
NaN handling:
|
|
42
|
+
- If nan_policy == 'omit', NaNs in `signal` (and their σ) are ignored.
|
|
43
|
+
- If nan_policy == 'propagate', NaNs behave like in plain numpy: if any NaN is
|
|
44
|
+
present along the reduced axes, the result becomes NaN.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
documentation = ProcessStepDescriber(
|
|
48
|
+
calling_name="average or sum, weighted or unweighted, over axes",
|
|
49
|
+
calling_id="ReduceDimensionality",
|
|
50
|
+
calling_module_path=Path(__file__),
|
|
51
|
+
calling_version=__version__,
|
|
52
|
+
required_data_keys=["signal"],
|
|
53
|
+
modifies={"signal": ["signal", "uncertainties", "units", "weights"]},
|
|
54
|
+
arguments={
|
|
55
|
+
"axes": {
|
|
56
|
+
"type": (int, list, tuple, type(None)),
|
|
57
|
+
"default": None,
|
|
58
|
+
"doc": "Axis or axes to reduce (int, list/tuple, or None for all).",
|
|
59
|
+
},
|
|
60
|
+
"use_weights": {
|
|
61
|
+
"type": bool,
|
|
62
|
+
"default": True,
|
|
63
|
+
"doc": "Use BaseData weights for weighted reduction.",
|
|
64
|
+
},
|
|
65
|
+
"nan_policy": {
|
|
66
|
+
"type": str,
|
|
67
|
+
"default": "omit",
|
|
68
|
+
"doc": "NaN handling policy: 'omit' or 'propagate'.",
|
|
69
|
+
},
|
|
70
|
+
"reduction": {
|
|
71
|
+
"type": str,
|
|
72
|
+
"default": "mean",
|
|
73
|
+
"doc": "Reduction method: 'mean' or 'sum'.",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
step_keywords=["average", "mean", "weighted", "nanmean", "reduce", "axis", "sum"],
|
|
77
|
+
step_doc=(
|
|
78
|
+
"Compute (default weighted) mean of the BaseData signal over the given axes, "
|
|
79
|
+
"with proper uncertainty propagation."
|
|
80
|
+
),
|
|
81
|
+
step_reference="DOI 10.1088/0953-8984/25/38/383201",
|
|
82
|
+
step_note=(
|
|
83
|
+
"This step reduces the dimensionality of the signal by averaging over one or more axes. "
|
|
84
|
+
"Units are preserved; axes metadata is currently not adjusted and is left empty on the result."
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# ---------------------------- helpers ---------------------------------
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _normalize_axes(axes: Any) -> int | tuple[int, ...] | None:
|
|
92
|
+
"""
|
|
93
|
+
Normalize configuration 'axes' into a numpy-compatible axis argument.
|
|
94
|
+
Allowed:
|
|
95
|
+
- None → reduce over all axes
|
|
96
|
+
- int → single axis
|
|
97
|
+
- list/tuple[int] → tuple of axes
|
|
98
|
+
"""
|
|
99
|
+
if axes is None:
|
|
100
|
+
logger.debug("ReduceDimensionality: axes=None → reducing over all axes.")
|
|
101
|
+
return None
|
|
102
|
+
if isinstance(axes, int):
|
|
103
|
+
logger.debug(f"ReduceDimensionality: single axis requested: axes={axes}.")
|
|
104
|
+
return axes
|
|
105
|
+
# list/tuple of ints
|
|
106
|
+
normalized = tuple(int(a) for a in axes)
|
|
107
|
+
logger.debug(f"ReduceDimensionality: multiple axes requested: axes={normalized}.")
|
|
108
|
+
return normalized
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _weighted_mean_with_uncertainty(
|
|
112
|
+
bd: BaseData,
|
|
113
|
+
axis: int | tuple[int, ...] | None,
|
|
114
|
+
use_weights: bool,
|
|
115
|
+
nan_policy: str,
|
|
116
|
+
reduction: str = "mean", # NEW
|
|
117
|
+
) -> BaseData:
|
|
118
|
+
"""
|
|
119
|
+
Compute weighted reduction ('mean' or 'sum') of a BaseData over axis,
|
|
120
|
+
with uncertainty propagation.
|
|
121
|
+
|
|
122
|
+
reduction:
|
|
123
|
+
'mean' → μ = Σ w x / Σ w
|
|
124
|
+
'sum' → S = Σ w x
|
|
125
|
+
"""
|
|
126
|
+
x = np.asarray(bd.signal, dtype=float)
|
|
127
|
+
|
|
128
|
+
# Choose weights
|
|
129
|
+
if use_weights:
|
|
130
|
+
w = np.asarray(bd.weights, dtype=float)
|
|
131
|
+
w = np.broadcast_to(w, x.shape)
|
|
132
|
+
else:
|
|
133
|
+
w = np.ones_like(x, dtype=float)
|
|
134
|
+
|
|
135
|
+
# NaN handling
|
|
136
|
+
if nan_policy == "omit":
|
|
137
|
+
mask = np.isnan(x) | np.isnan(w)
|
|
138
|
+
x_eff = np.where(mask, 0.0, x)
|
|
139
|
+
w_eff = np.where(mask, 0.0, w)
|
|
140
|
+
elif nan_policy == "propagate":
|
|
141
|
+
mask = np.zeros_like(x, dtype=bool)
|
|
142
|
+
x_eff = x
|
|
143
|
+
w_eff = w
|
|
144
|
+
else:
|
|
145
|
+
raise ValueError(f"Invalid nan_policy: {nan_policy!r}. Use 'omit' or 'propagate'.")
|
|
146
|
+
|
|
147
|
+
# Weighted sums
|
|
148
|
+
w_sum = np.sum(w_eff, axis=axis)
|
|
149
|
+
wx_sum = np.sum(w_eff * x_eff, axis=axis)
|
|
150
|
+
|
|
151
|
+
# Σ w_i^2 σ_i^2 for each key
|
|
152
|
+
uncertainties_out: dict[str, np.ndarray] = {}
|
|
153
|
+
|
|
154
|
+
# Precompute denom for mean case
|
|
155
|
+
if reduction == "mean":
|
|
156
|
+
denom = np.where(w_sum == 0, np.nan, w_sum)
|
|
157
|
+
signal_out = wx_sum / denom
|
|
158
|
+
elif reduction == "sum":
|
|
159
|
+
# For sum, just take Σ w x (or Σ x when use_weights=False)
|
|
160
|
+
signal_out = wx_sum
|
|
161
|
+
else:
|
|
162
|
+
raise ValueError(f"Invalid reduction: {reduction!r}. Use 'mean' or 'sum'.")
|
|
163
|
+
|
|
164
|
+
for key, err in bd.uncertainties.items():
|
|
165
|
+
err_arr = np.asarray(err, dtype=float)
|
|
166
|
+
err_arr = np.broadcast_to(err_arr, x.shape)
|
|
167
|
+
|
|
168
|
+
if nan_policy == "omit":
|
|
169
|
+
err_arr_eff = np.where(mask, 0.0, err_arr)
|
|
170
|
+
else:
|
|
171
|
+
err_arr_eff = err_arr
|
|
172
|
+
|
|
173
|
+
var_sum = np.sum((w_eff**2) * (err_arr_eff**2), axis=axis)
|
|
174
|
+
|
|
175
|
+
if reduction == "mean":
|
|
176
|
+
sigma = np.sqrt(var_sum) / denom
|
|
177
|
+
else: # 'sum'
|
|
178
|
+
sigma = np.sqrt(var_sum)
|
|
179
|
+
|
|
180
|
+
uncertainties_out[key] = sigma
|
|
181
|
+
|
|
182
|
+
# --- build result BaseData (numeric content) ---
|
|
183
|
+
result = BaseData(
|
|
184
|
+
signal=signal_out,
|
|
185
|
+
units=bd.units,
|
|
186
|
+
uncertainties=uncertainties_out,
|
|
187
|
+
weights=np.array(1.0) if reduction == "mean" else w_sum,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# --- metadata: axes + rank_of_data ---
|
|
191
|
+
|
|
192
|
+
# New dimensionality after reduction
|
|
193
|
+
new_ndim = result.signal.ndim
|
|
194
|
+
|
|
195
|
+
# Determine which axes were reduced, in normalized (non-negative) form
|
|
196
|
+
if axis is None:
|
|
197
|
+
# reducing over all axes
|
|
198
|
+
reduced_axes_tuple: tuple[int, ...] = tuple(range(x.ndim))
|
|
199
|
+
elif isinstance(axis, tuple):
|
|
200
|
+
reduced_axes_tuple = axis
|
|
201
|
+
else:
|
|
202
|
+
reduced_axes_tuple = (axis,)
|
|
203
|
+
|
|
204
|
+
reduced_axes_norm: set[int] = set()
|
|
205
|
+
for a in reduced_axes_tuple:
|
|
206
|
+
a_norm = a if a >= 0 else x.ndim + a
|
|
207
|
+
reduced_axes_norm.add(a_norm)
|
|
208
|
+
|
|
209
|
+
# Reduce axes metadata if we have a full set (one entry per dimension).
|
|
210
|
+
old_axes = bd.axes
|
|
211
|
+
if len(old_axes) == x.ndim:
|
|
212
|
+
# Keep only axes that were NOT reduced
|
|
213
|
+
new_axes = [ax for i, ax in enumerate(old_axes) if i not in reduced_axes_norm]
|
|
214
|
+
else:
|
|
215
|
+
# If metadata length does not match ndim, fall back to empty list
|
|
216
|
+
new_axes = []
|
|
217
|
+
|
|
218
|
+
result.axes = new_axes
|
|
219
|
+
|
|
220
|
+
# Rank of data: cannot exceed new ndim, and should not exceed original rank
|
|
221
|
+
result.rank_of_data = min(bd.rank_of_data, new_ndim)
|
|
222
|
+
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
# ---------------------------- main API ---------------------------------
|
|
226
|
+
|
|
227
|
+
def calculate(self) -> dict[str, DataBundle]:
|
|
228
|
+
axis = self._normalize_axes(self.configuration.get("axes"))
|
|
229
|
+
use_weights = bool(self.configuration.get("use_weights", True))
|
|
230
|
+
nan_policy = self.configuration.get("nan_policy", "omit")
|
|
231
|
+
reduction = self.configuration.get("reduction", "mean") # NEW
|
|
232
|
+
|
|
233
|
+
output: dict[str, DataBundle] = {}
|
|
234
|
+
|
|
235
|
+
for key in self._normalised_processing_keys():
|
|
236
|
+
databundle: DataBundle = self.processing_data.get(key)
|
|
237
|
+
bd: BaseData = databundle["signal"]
|
|
238
|
+
|
|
239
|
+
averaged = self._weighted_mean_with_uncertainty(
|
|
240
|
+
bd=bd,
|
|
241
|
+
axis=axis,
|
|
242
|
+
use_weights=use_weights,
|
|
243
|
+
nan_policy=nan_policy,
|
|
244
|
+
reduction=reduction, # NEW
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
databundle["signal"] = averaged
|
|
248
|
+
output[key] = databundle
|
|
249
|
+
|
|
250
|
+
logger.info(f"ReduceDimensionality: calculation finished for {len(output)} keys.")
|
|
251
|
+
|
|
252
|
+
return output
|
|
@@ -0,0 +1,80 @@
|
|
|
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 2026, The MoDaCor team"
|
|
10
|
+
__date__ = "09/01/2026"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
# end of header and standard imports
|
|
13
|
+
|
|
14
|
+
__all__ = ["SinkProcessingData"]
|
|
15
|
+
__version__ = "20260901.1"
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from modacor.dataclasses.databundle import DataBundle
|
|
20
|
+
from modacor.dataclasses.messagehandler import MessageHandler
|
|
21
|
+
from modacor.dataclasses.process_step import ProcessStep
|
|
22
|
+
from modacor.dataclasses.process_step_describer import ProcessStepDescriber
|
|
23
|
+
|
|
24
|
+
# Module-level handler; facilities can swap MessageHandler implementation as needed
|
|
25
|
+
logger = MessageHandler(name=__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SinkProcessingData(ProcessStep):
|
|
29
|
+
"""
|
|
30
|
+
Export ProcessingData to an IoSink.
|
|
31
|
+
|
|
32
|
+
- target: 'sink_id::subpath' (for CSV usually 'export_csv::')
|
|
33
|
+
- data_paths: ProcessingData paths without '::', e.g. '/sample/Q/signal'
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
documentation = ProcessStepDescriber(
|
|
37
|
+
calling_name="Sink Processing Data",
|
|
38
|
+
calling_id="SinkProcessingData",
|
|
39
|
+
calling_module_path=Path(__file__),
|
|
40
|
+
calling_version=__version__,
|
|
41
|
+
required_data_keys=[], # no new databundle produced
|
|
42
|
+
modifies={}, # side-effect only (writing)
|
|
43
|
+
arguments={
|
|
44
|
+
"target": {
|
|
45
|
+
"type": str,
|
|
46
|
+
"required": True,
|
|
47
|
+
"default": "",
|
|
48
|
+
"doc": "Sink target in the form 'sink_id::subpath'.",
|
|
49
|
+
},
|
|
50
|
+
"data_paths": {
|
|
51
|
+
"type": (str, list),
|
|
52
|
+
"required": True,
|
|
53
|
+
"default": [],
|
|
54
|
+
"doc": "ProcessingData paths to write (string or list of strings).",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
step_keywords=["sink", "export", "write"],
|
|
58
|
+
step_doc="Write selected ProcessingData leaves to an IoSink.",
|
|
59
|
+
step_reference="",
|
|
60
|
+
step_note="This step performs an export side-effect and returns an empty output dict.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def calculate(self) -> dict[str, DataBundle]:
|
|
64
|
+
output: dict[str, DataBundle] = {}
|
|
65
|
+
|
|
66
|
+
target: str = self.configuration["target"]
|
|
67
|
+
data_paths: str | list[str] = self.configuration["data_paths"]
|
|
68
|
+
|
|
69
|
+
if isinstance(data_paths, str):
|
|
70
|
+
data_paths = [data_paths]
|
|
71
|
+
|
|
72
|
+
# Delegate determinism + validation to sink implementation
|
|
73
|
+
self.io_sinks.write_data(
|
|
74
|
+
target,
|
|
75
|
+
self.processing_data,
|
|
76
|
+
data_paths=data_paths,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
logger.debug(f"SinkProcessingData wrote {len(data_paths)} paths to target '{target}'.")
|
|
80
|
+
return output
|
|
@@ -0,0 +1,80 @@
|
|
|
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", "Armin Moser"] # add names to the list as appropriate
|
|
9
|
+
__copyright__ = "Copyright 2025, The MoDaCor team"
|
|
10
|
+
__date__ = "29/10/2025"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
# end of header and standard imports
|
|
13
|
+
|
|
14
|
+
__all__ = ["Subtract"]
|
|
15
|
+
__version__ = "20251029.1"
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from modacor.dataclasses.databundle import DataBundle
|
|
20
|
+
from modacor.dataclasses.helpers import basedata_from_sources
|
|
21
|
+
from modacor.dataclasses.process_step import ProcessStep
|
|
22
|
+
from modacor.dataclasses.process_step_describer import ProcessStepDescriber
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Subtract(ProcessStep):
|
|
26
|
+
"""
|
|
27
|
+
Subtract a DataBundle by a BaseData from an IoSource
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
documentation = ProcessStepDescriber(
|
|
31
|
+
calling_name="Subtract by IoSource data",
|
|
32
|
+
calling_id="SubtractBySourceData",
|
|
33
|
+
calling_module_path=Path(__file__),
|
|
34
|
+
calling_version=__version__,
|
|
35
|
+
required_data_keys=["signal"],
|
|
36
|
+
modifies={"signal": ["signal", "uncertainties", "units"]},
|
|
37
|
+
arguments={
|
|
38
|
+
"subtrahend_source": {
|
|
39
|
+
"type": str,
|
|
40
|
+
"default": None,
|
|
41
|
+
"doc": "IoSources key for the subtrahend signal.",
|
|
42
|
+
},
|
|
43
|
+
"subtrahend_units_source": {
|
|
44
|
+
"type": str,
|
|
45
|
+
"default": None,
|
|
46
|
+
"doc": "IoSources key for subtrahend units metadata.",
|
|
47
|
+
},
|
|
48
|
+
"subtrahend_uncertainties_sources": {
|
|
49
|
+
"type": dict,
|
|
50
|
+
"default": {},
|
|
51
|
+
"doc": "Mapping of uncertainty name to IoSources key.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
step_keywords=["subtract", "scalar", "array"],
|
|
55
|
+
step_doc="Subtract a DataBundle element by a subtrahend loaded from a data source",
|
|
56
|
+
step_reference="DOI 10.1088/0953-8984/25/38/383201",
|
|
57
|
+
step_note="""This loads a scalar (value, units and uncertainty)
|
|
58
|
+
from an IOSource and applies it to the data signal""",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def calculate(self) -> dict[str, DataBundle]:
|
|
62
|
+
# build up the subtrahend BaseData object from the IoSources
|
|
63
|
+
subtrahend = basedata_from_sources(
|
|
64
|
+
io_sources=self.io_sources,
|
|
65
|
+
signal_source=self.configuration.get("subtrahend_source"),
|
|
66
|
+
units_source=self.configuration.get("subtrahend_units_source", None),
|
|
67
|
+
uncertainty_sources=self.configuration.get("subtrahend_uncertainties_sources", {}),
|
|
68
|
+
)
|
|
69
|
+
# Get the data
|
|
70
|
+
data = self.processing_data
|
|
71
|
+
|
|
72
|
+
output: dict[str, DataBundle] = {}
|
|
73
|
+
# actual work happens here:
|
|
74
|
+
for key in self._normalised_processing_keys():
|
|
75
|
+
databundle = data.get(key)
|
|
76
|
+
# subtract the data
|
|
77
|
+
# databundle['signal'] is a BaseData object
|
|
78
|
+
databundle["signal"] -= subtrahend
|
|
79
|
+
output[key] = databundle
|
|
80
|
+
return output
|
|
@@ -0,0 +1,67 @@
|
|
|
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", "Armin Moser"] # add names to the list as appropriate
|
|
9
|
+
__copyright__ = "Copyright 2025, The MoDaCor team"
|
|
10
|
+
__date__ = "29/10/2025"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
# end of header and standard imports
|
|
13
|
+
|
|
14
|
+
__all__ = ["SubtractDatabundles"]
|
|
15
|
+
__version__ = "20251029.1"
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from modacor.dataclasses.databundle import DataBundle
|
|
20
|
+
from modacor.dataclasses.process_step import ProcessStep
|
|
21
|
+
from modacor.dataclasses.process_step_describer import ProcessStepDescriber
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SubtractDatabundles(ProcessStep):
|
|
25
|
+
"""
|
|
26
|
+
Subtract a DataBundle from a DataBundle, useful for background subtraction
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
documentation = ProcessStepDescriber(
|
|
30
|
+
calling_name="Subtract another DataBundle",
|
|
31
|
+
calling_id="SubtractDatabundles",
|
|
32
|
+
calling_module_path=Path(__file__),
|
|
33
|
+
calling_version=__version__,
|
|
34
|
+
required_data_keys=["signal"],
|
|
35
|
+
modifies={"signal": ["signal", "uncertainties", "units"]},
|
|
36
|
+
arguments={
|
|
37
|
+
"with_processing_keys": {
|
|
38
|
+
"type": list,
|
|
39
|
+
"required": True,
|
|
40
|
+
"default": None,
|
|
41
|
+
"doc": "Two processing keys: minuend then subtrahend.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
step_keywords=["subtract", "background", "databundle"],
|
|
45
|
+
step_doc="Subtract a DataBundle element using another DataBundle",
|
|
46
|
+
step_reference="DOI 10.1088/0953-8984/25/38/383201",
|
|
47
|
+
step_note="""
|
|
48
|
+
This subtracts one DataBundle's signal from another, useful for background subtraction.
|
|
49
|
+
'with_processing_keys' in the configuration should contain two keys, the operation
|
|
50
|
+
will subtract the second key's DataBundle from the first key's DataBundle.
|
|
51
|
+
""",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def calculate(self) -> dict[str, DataBundle]:
|
|
55
|
+
# actual work happens here:
|
|
56
|
+
keys = self._normalised_processing_keys()
|
|
57
|
+
assert len(keys) == 2, (
|
|
58
|
+
"SubtractDatabundles requires exactly two processing keys in 'with_processing_keys': "
|
|
59
|
+
"the first is the minuend, the second is the subtrahend."
|
|
60
|
+
)
|
|
61
|
+
minuend_key = keys[0]
|
|
62
|
+
minuend = self.processing_data.get(minuend_key)
|
|
63
|
+
subtrahend = self.processing_data.get(keys[1])
|
|
64
|
+
# subtract the data
|
|
65
|
+
minuend["signal"] -= subtrahend["signal"]
|
|
66
|
+
output: dict[str, DataBundle] = {minuend_key: minuend}
|
|
67
|
+
return output
|
|
@@ -0,0 +1,66 @@
|
|
|
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", "Armin Moser"] # add names to the list as appropriate
|
|
9
|
+
__copyright__ = "Copyright 2025, The MoDaCor team"
|
|
10
|
+
__date__ = "16/12/2025"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
|
|
13
|
+
__all__ = ["UnitsLabelUpdate"]
|
|
14
|
+
__version__ = "20251216.1"
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from modacor import ureg
|
|
19
|
+
from modacor.dataclasses.databundle import DataBundle
|
|
20
|
+
from modacor.dataclasses.process_step import ProcessStep
|
|
21
|
+
from modacor.dataclasses.process_step_describer import ProcessStepDescriber
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnitsLabelUpdate(ProcessStep):
|
|
25
|
+
"""
|
|
26
|
+
Update the units of one or more BaseData elements in a DataBundle.
|
|
27
|
+
Note: this only changes the *unit label* (no numerical conversion).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
documentation = ProcessStepDescriber(
|
|
31
|
+
calling_name="Update unit labels",
|
|
32
|
+
calling_id="UnitsLabelUpdate",
|
|
33
|
+
calling_module_path=Path(__file__),
|
|
34
|
+
calling_version=__version__,
|
|
35
|
+
required_data_keys=[""], # provided via update_pairs
|
|
36
|
+
modifies={"": ["units"]},
|
|
37
|
+
arguments={
|
|
38
|
+
"update_pairs": {
|
|
39
|
+
"type": dict,
|
|
40
|
+
"required": True,
|
|
41
|
+
"default": {},
|
|
42
|
+
"doc": "Mapping of BaseData key to unit string or {'units': str}.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
step_keywords=["units", "update", "standardize"],
|
|
46
|
+
step_doc="Update unit labels of one or more BaseData elements (no conversion).",
|
|
47
|
+
step_reference="DOI 10.1088/0953-8984/25/38/383201",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def calculate(self) -> dict[str, DataBundle]:
|
|
51
|
+
pairs = self.configuration["update_pairs"]
|
|
52
|
+
parsed = {
|
|
53
|
+
bd_key: ureg.Unit(spec["units"] if isinstance(spec, dict) else spec) for bd_key, spec in pairs.items()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
output: dict[str, DataBundle] = {}
|
|
57
|
+
for key in self._normalised_processing_keys():
|
|
58
|
+
databundle = self.processing_data.get(key)
|
|
59
|
+
for bd_key, unit in parsed.items():
|
|
60
|
+
databundle[bd_key].units = unit
|
|
61
|
+
output[key] = databundle
|
|
62
|
+
info_msg = f"UnitsLabelUpdate: updated units for DataBundle '{key}': " + ", ".join(
|
|
63
|
+
f"{bd_key} -> {databundle[bd_key].units}" for bd_key in parsed.keys()
|
|
64
|
+
)
|
|
65
|
+
self.logger.info(info_msg)
|
|
66
|
+
return output
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Instrument modules structure explainer:
|
|
2
|
+
--
|
|
3
|
+
|
|
4
|
+
Welcome to the location for instrument-specfific modules. If neither the base modules or the technique-specific modules satisfy the needs for data from a given instrument, you can write sprcific modules that are purpose-built and put them in here.
|
|
5
|
+
|
|
6
|
+
The subdirectory structure to place these in is recommended to follow the following convention:
|
|
7
|
+
./institute_abbreviation/instrument_name/module_name.
|
|
8
|
+
|
|
9
|
+
Please follow the code practices of the project, and consider making your modules available to the wider world. This will be considered if modules have the standard header, use ProcessStepDescriber to describe the module, and have tests in the corresponding directory in src/modacor/tests/modules/instrument_modules/...
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
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 2026, The MoDaCor team"
|
|
10
|
+
__date__ = "06/01/2026"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
|
|
13
|
+
__version__ = "20260106.1"
|
|
14
|
+
__all__ = ["unit_vec3", "require_scalar", "prepare_static_scalar"]
|
|
15
|
+
|
|
16
|
+
from typing import Tuple
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pint
|
|
20
|
+
|
|
21
|
+
from modacor import ureg
|
|
22
|
+
from modacor.dataclasses.basedata import BaseData
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def unit_vec3(v: Tuple[float, float, float] | np.ndarray, *, name: str = "vector") -> np.ndarray:
|
|
26
|
+
"""Normalize a 3-vector to unit length."""
|
|
27
|
+
v = np.asarray(v, dtype=float).reshape(3)
|
|
28
|
+
n = float(np.linalg.norm(v))
|
|
29
|
+
if n == 0.0:
|
|
30
|
+
raise ValueError(f"{name} must be non-zero")
|
|
31
|
+
return v / n
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def require_scalar(name: str, bd: BaseData) -> BaseData:
|
|
35
|
+
"""Ensure a BaseData is scalar; returns a squeezed copy with RoD=0."""
|
|
36
|
+
out = bd.squeeze().copy()
|
|
37
|
+
if np.size(out.signal) != 1:
|
|
38
|
+
raise ValueError(f"{name} must be scalar (size==1). Got shape={np.shape(out.signal)}.")
|
|
39
|
+
out.rank_of_data = 0
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def prepare_static_scalar(
|
|
44
|
+
bd: BaseData,
|
|
45
|
+
*,
|
|
46
|
+
require_units: pint.Unit = ureg.m,
|
|
47
|
+
uncertainty_key: str = "static_config_jitter",
|
|
48
|
+
) -> BaseData:
|
|
49
|
+
"""
|
|
50
|
+
Reduce a possibly-array BaseData to a scalar via weighted mean (bd.weights) and
|
|
51
|
+
assign uncertainty as standard error of the mean (SEM).
|
|
52
|
+
|
|
53
|
+
- If bd is already scalar: squeeze+RoD=0 and return.
|
|
54
|
+
- If bd.weights is None: uniform weights are assumed.
|
|
55
|
+
|
|
56
|
+
Notes
|
|
57
|
+
-----
|
|
58
|
+
SEM here uses:
|
|
59
|
+
- weighted mean
|
|
60
|
+
- weighted variance (about mean)
|
|
61
|
+
- effective sample size n_eff = (sum(w)^2) / sum(w^2)
|
|
62
|
+
- sem = sqrt(var) / sqrt(n_eff)
|
|
63
|
+
"""
|
|
64
|
+
if not bd.units.is_compatible_with(require_units):
|
|
65
|
+
raise ValueError(f"Value must be in {require_units}, got {bd.units}")
|
|
66
|
+
|
|
67
|
+
# scalar passthrough
|
|
68
|
+
if np.size(bd.signal) == 1:
|
|
69
|
+
out = bd.squeeze().copy()
|
|
70
|
+
out.rank_of_data = 0
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
x = np.asarray(bd.signal, dtype=float).ravel()
|
|
74
|
+
|
|
75
|
+
# --- robust weights handling ---
|
|
76
|
+
if bd.weights is None:
|
|
77
|
+
w = np.ones_like(x)
|
|
78
|
+
else:
|
|
79
|
+
w_raw = np.asarray(bd.weights, dtype=float)
|
|
80
|
+
|
|
81
|
+
# allow scalar/length-1 weights
|
|
82
|
+
if w_raw.size == 1:
|
|
83
|
+
w = np.full_like(x, float(w_raw.reshape(-1)[0]))
|
|
84
|
+
else:
|
|
85
|
+
# allow broadcastable weights (e.g. (5,1,1,1) vs (5,))
|
|
86
|
+
try:
|
|
87
|
+
w = np.broadcast_to(w_raw, np.shape(bd.signal)).ravel()
|
|
88
|
+
except ValueError as e:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"weights shape {w_raw.shape} does not match signal shape {np.shape(bd.signal)}"
|
|
91
|
+
) from e
|
|
92
|
+
|
|
93
|
+
if w.size != x.size:
|
|
94
|
+
raise ValueError(f"weights size {w.size} does not match signal size {x.size}")
|
|
95
|
+
|
|
96
|
+
wsum = float(np.sum(w))
|
|
97
|
+
if wsum <= 0:
|
|
98
|
+
raise ValueError("weights must sum to > 0")
|
|
99
|
+
|
|
100
|
+
mean = float(np.sum(w * x) / wsum)
|
|
101
|
+
|
|
102
|
+
# effective N for SEM (works for equal weights too)
|
|
103
|
+
n_eff = float((wsum**2) / np.sum(w**2))
|
|
104
|
+
|
|
105
|
+
# weighted population variance about the weighted mean
|
|
106
|
+
var = float(np.sum(w * (x - mean) ** 2) / wsum)
|
|
107
|
+
sem = float(np.sqrt(var) / np.sqrt(n_eff))
|
|
108
|
+
|
|
109
|
+
return BaseData(
|
|
110
|
+
signal=np.array(mean, dtype=float),
|
|
111
|
+
units=bd.units,
|
|
112
|
+
uncertainties={uncertainty_key: np.array(sem, dtype=float)},
|
|
113
|
+
rank_of_data=0,
|
|
114
|
+
)
|