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,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
@@ -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
+ )