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,358 @@
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
+ # end of header and standard imports
13
+
14
+ # import pytest
15
+ import logging
16
+ import unittest
17
+
18
+ import numpy as np
19
+
20
+ from modacor import ureg
21
+ from modacor.dataclasses.basedata import BaseData
22
+ from modacor.dataclasses.databundle import DataBundle
23
+ from modacor.dataclasses.processing_data import ProcessingData
24
+ from modacor.io.io_sources import IoSources
25
+
26
+ # adjust this import path to where you put the step:
27
+ from modacor.modules.base_modules.reduce_dimensionality import ReduceDimensionality # noqa: E402
28
+
29
+ TEST_IO_SOURCES = IoSources()
30
+
31
+
32
+ class TestReduceDimensionality(unittest.TestCase):
33
+ """Testing class for modacor/modules/base_modules/reduce_dim_weighted_average.py"""
34
+
35
+ def setUp(self):
36
+ # Simple 2x3 example so we can verify by hand:
37
+ #
38
+ # x = [[1, 2, 3],
39
+ # [4, 5, 6]]
40
+ #
41
+ self.signal = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=float)
42
+
43
+ # absolute 1σ uncertainties = 0.1 everywhere
44
+ self.unc = 0.1 * np.ones_like(self.signal)
45
+
46
+ # weights: second row has weight 2, first row weight 1
47
+ # (broadcastable to signal.shape)
48
+ self.weights = np.array([[1.0], [2.0]], dtype=float)
49
+
50
+ self.test_processing_data = ProcessingData()
51
+ self.test_basedata = BaseData(
52
+ signal=self.signal,
53
+ units=ureg.Unit("count"),
54
+ uncertainties={"u": self.unc},
55
+ weights=self.weights,
56
+ )
57
+ self.test_data_bundle = DataBundle(signal=self.test_basedata)
58
+ self.test_processing_data["bundle"] = self.test_data_bundle
59
+
60
+ def tearDown(self):
61
+ pass
62
+
63
+ # ------------------------------------------------------------------
64
+ # Basic unweighted mean (use_weights=False, nan_policy='propagate')
65
+ # ------------------------------------------------------------------
66
+
67
+ def test_unweighted_mean_axis0(self):
68
+ """
69
+ Unweighted mean over axis=0 should match np.mean(signal, axis=0)
70
+ and propagate uncertainties as:
71
+ σ_mean = sqrt(σ1^2 + σ2^2) / N.
72
+ """
73
+ avg_step = ReduceDimensionality(io_sources=TEST_IO_SOURCES)
74
+ avg_step.modify_config_by_kwargs(
75
+ with_processing_keys=["bundle"],
76
+ axes=0,
77
+ use_weights=False,
78
+ nan_policy="propagate",
79
+ )
80
+ avg_step.processing_data = self.test_processing_data
81
+
82
+ avg_step.calculate()
83
+
84
+ result_bd: BaseData = self.test_processing_data["bundle"]["signal"]
85
+
86
+ # Expected mean
87
+ expected_mean = np.mean(self.signal, axis=0)
88
+ np.testing.assert_allclose(result_bd.signal, expected_mean)
89
+
90
+ # Expected uncertainty:
91
+ # Two points each with σ=0.1 -> σ_mean = sqrt(0.1^2 + 0.1^2) / 2
92
+ expected_sigma = np.sqrt(0.1**2 + 0.1**2) / 2.0
93
+ expected_u = np.full_like(expected_mean, expected_sigma)
94
+ np.testing.assert_allclose(result_bd.uncertainties["u"], expected_u)
95
+
96
+ # Units should be preserved
97
+ self.assertEqual(result_bd.units, ureg.Unit("count"))
98
+
99
+ # ------------------------------------------------------------------
100
+ # Weighted mean (use_weights=True)
101
+ # ------------------------------------------------------------------
102
+
103
+ def test_weighted_mean_axis0(self):
104
+ """
105
+ Weighted mean over axis=0 using BaseData.weights.
106
+
107
+ For each column:
108
+ μ = (1*x1 + 2*x2) / (1+2)
109
+ σ^2 = (1^2 σ1^2 + 2^2 σ2^2) / (1+2)^2
110
+ with σ1 = σ2 = 0.1.
111
+ """
112
+ avg_step = ReduceDimensionality(io_sources=TEST_IO_SOURCES)
113
+ avg_step.modify_config_by_kwargs(
114
+ with_processing_keys=["bundle"],
115
+ axes=0,
116
+ use_weights=True,
117
+ nan_policy="propagate",
118
+ )
119
+ avg_step.processing_data = self.test_processing_data
120
+
121
+ avg_step.calculate()
122
+
123
+ result_bd: BaseData = self.test_processing_data["bundle"]["signal"]
124
+
125
+ # Expected weighted mean along axis 0
126
+ w1, w2 = 1.0, 2.0
127
+ w_sum = w1 + w2
128
+ expected_mean = (w1 * self.signal[0, :] + w2 * self.signal[1, :]) / w_sum
129
+ np.testing.assert_allclose(result_bd.signal, expected_mean)
130
+
131
+ # Uncertainty:
132
+ # σ_μ^2 = (w1^2 σ1^2 + w2^2 σ2^2) / (w_sum^2)
133
+ sigma1 = sigma2 = 0.1
134
+ var_num = w1**2 * sigma1**2 + w2**2 * sigma2**2 # = (1 + 4)*0.01 = 0.05
135
+ expected_sigma = np.sqrt(var_num) / w_sum
136
+ expected_u = np.full_like(expected_mean, expected_sigma)
137
+ np.testing.assert_allclose(result_bd.uncertainties["u"], expected_u)
138
+
139
+ self.assertEqual(result_bd.units, ureg.Unit("count"))
140
+
141
+ def test_weighted_sum_axis0(self):
142
+ """
143
+ Weighted sum over axis=0 using BaseData.weights.
144
+
145
+ For each column:
146
+ S = Σ w_i x_i = 1*x1 + 2*x2
147
+ σ_S^2 = Σ w_i^2 σ_i^2 = (1^2 + 2^2) * σ^2
148
+ with σ = 0.1 everywhere.
149
+ """
150
+ avg_step = ReduceDimensionality(io_sources=TEST_IO_SOURCES)
151
+ avg_step.modify_config_by_kwargs(
152
+ with_processing_keys=["bundle"],
153
+ axes=0,
154
+ use_weights=True,
155
+ nan_policy="propagate",
156
+ reduction="sum", # NEW
157
+ )
158
+ avg_step.processing_data = self.test_processing_data
159
+
160
+ avg_step.calculate()
161
+
162
+ result_bd: BaseData = self.test_processing_data["bundle"]["signal"]
163
+
164
+ # Expected weighted sum along axis 0
165
+ w1, w2 = 1.0, 2.0
166
+ expected_sum = w1 * self.signal[0, :] + w2 * self.signal[1, :]
167
+ np.testing.assert_allclose(result_bd.signal, expected_sum)
168
+
169
+ # Uncertainty:
170
+ # σ_S^2 = (w1^2 + w2^2) * σ^2
171
+ sigma = 0.1
172
+ var_factor = w1**2 + w2**2 # 1 + 4 = 5
173
+ expected_sigma = np.sqrt(var_factor * sigma**2)
174
+ expected_u = np.full_like(expected_sum, expected_sigma)
175
+ np.testing.assert_allclose(result_bd.uncertainties["u"], expected_u)
176
+
177
+ # Units preserved
178
+ self.assertEqual(result_bd.units, ureg.Unit("count"))
179
+
180
+ # ------------------------------------------------------------------
181
+ # nan_policy='omit'
182
+ # ------------------------------------------------------------------
183
+
184
+ def test_nanmean_omit(self):
185
+ """
186
+ When nan_policy='omit', NaNs in the signal are ignored.
187
+ For columns with only one finite value, the mean is that value
188
+ and the uncertainty is its σ.
189
+ """
190
+ # Introduce NaNs: one partial column, one fully NaN column
191
+ signal_nan = self.signal.copy()
192
+ signal_nan[0, 1] = np.nan # second column: [NaN, 5]
193
+ signal_nan[:, 2] = np.nan # third column: [NaN, NaN]
194
+
195
+ # Update processing data with this modified signal
196
+ bd_nan = BaseData(
197
+ signal=signal_nan,
198
+ units=ureg.Unit("count"),
199
+ uncertainties={"u": self.unc}, # still 0.1 everywhere
200
+ weights=self.weights,
201
+ )
202
+ self.test_processing_data["bundle"]["signal"] = bd_nan
203
+
204
+ avg_step = ReduceDimensionality(io_sources=TEST_IO_SOURCES)
205
+ avg_step.modify_config_by_kwargs(
206
+ with_processing_keys=["bundle"],
207
+ axes=0,
208
+ use_weights=False,
209
+ nan_policy="omit",
210
+ )
211
+ avg_step.processing_data = self.test_processing_data
212
+
213
+ avg_step.calculate()
214
+
215
+ result_bd: BaseData = self.test_processing_data["bundle"]["signal"]
216
+
217
+ # Column 0: mean of [1, 4]
218
+ expected_col0_mean = (1.0 + 4.0) / 2.0
219
+ # Column 1: mean of [5] (since NaN is omitted)
220
+ expected_col1_mean = 5.0
221
+ # Column 2: all NaN -> NaN
222
+ expected_mean = np.array([expected_col0_mean, expected_col1_mean, np.nan])
223
+ np.testing.assert_allclose(result_bd.signal, expected_mean, equal_nan=True)
224
+
225
+ # Uncertainties:
226
+ # Col0: two points with σ=0.1 -> σ_mean = sqrt(0.1^2+0.1^2)/2
227
+ col0_sigma = np.sqrt(0.1**2 + 0.1**2) / 2.0
228
+ # Col1: single finite point with σ=0.1 -> σ_mean = 0.1
229
+ col1_sigma = 0.1
230
+ # Col2: no finite points -> NaN
231
+ expected_u = np.array([col0_sigma, col1_sigma, np.nan])
232
+ np.testing.assert_allclose(result_bd.uncertainties["u"], expected_u, equal_nan=True)
233
+
234
+ self.assertEqual(result_bd.units, ureg.Unit("count"))
235
+
236
+ # ------------------------------------------------------------------
237
+ # Execution via __call__ shortcut
238
+ # ------------------------------------------------------------------
239
+
240
+ def test_weighted_average_execution_via_call(self):
241
+ """
242
+ Ensure the ProcessStep __call__ interface works, like for PoissonUncertainties.
243
+ """
244
+ avg_step = ReduceDimensionality(io_sources=TEST_IO_SOURCES)
245
+ avg_step.modify_config_by_kwargs(
246
+ with_processing_keys=["bundle"],
247
+ axes=0,
248
+ use_weights=True,
249
+ nan_policy="omit",
250
+ )
251
+
252
+ # Execute via __call__
253
+ avg_step(self.test_processing_data)
254
+
255
+ result_bd: BaseData = self.test_processing_data["bundle"]["signal"]
256
+
257
+ # Basic sanity checks: shape reduced along axis 0 → (3,)
258
+ self.assertEqual(result_bd.signal.shape, (3,))
259
+ # Units preserved
260
+ self.assertEqual(result_bd.units, ureg.Unit("count"))
261
+ # Uncertainty key still present
262
+ self.assertIn("u", result_bd.uncertainties)
263
+ # No unexpected NaNs for this simple case
264
+ self.assertFalse(np.isnan(result_bd.signal).any())
265
+ self.assertFalse(np.isnan(result_bd.uncertainties["u"]).any())
266
+
267
+
268
+ def test_reduce_dimensionality_rank_and_axes_reduce_as_expected():
269
+ # 2D signal with matching axes metadata
270
+ sig = np.arange(12.0).reshape(3, 4)
271
+ bd = BaseData(signal=sig, units=ureg.dimensionless)
272
+
273
+ # original rank and axes
274
+ bd.rank_of_data = 2
275
+ axis0 = BaseData(signal=np.arange(3.0), units=ureg.dimensionless)
276
+ axis1 = BaseData(signal=np.arange(4.0), units=ureg.dimensionless)
277
+ bd.axes = [axis0, axis1]
278
+
279
+ # --- reduce over axis=1 -> shape (3,) ---
280
+ out_axis1 = ReduceDimensionality._weighted_mean_with_uncertainty(
281
+ bd=bd,
282
+ axis=1,
283
+ use_weights=False,
284
+ nan_policy="omit",
285
+ reduction="mean",
286
+ )
287
+
288
+ # shape reduced correctly
289
+ assert out_axis1.signal.shape == (3,)
290
+ # rank reduced: min(old_rank=2, new_ndim=1) -> 1
291
+ assert out_axis1.rank_of_data == 1
292
+ # axes: axis 1 removed, axis 0 preserved
293
+ assert len(out_axis1.axes) == 1
294
+ assert out_axis1.axes[0] is axis0
295
+
296
+ # --- reduce over all axes -> scalar ---
297
+ out_all = ReduceDimensionality._weighted_mean_with_uncertainty(
298
+ bd=bd,
299
+ axis=None,
300
+ use_weights=False,
301
+ nan_policy="omit",
302
+ reduction="mean",
303
+ )
304
+
305
+ # scalar output
306
+ assert out_all.signal.shape == ()
307
+ # rank cannot exceed new_ndim (0)
308
+ assert out_all.rank_of_data == 0
309
+ # axes should be empty for scalar
310
+ assert out_all.axes == []
311
+
312
+
313
+ def test_reduce_dimensionality_emits_info_and_debug_logs(caplog):
314
+ """
315
+ Ensure that ReduceDimensionality.calculate() emits at least one INFO and
316
+ one DEBUG log record via the MessageHandler-backed logger.
317
+ """
318
+ signal = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]], dtype=float)
319
+ unc = 0.1 * np.ones_like(signal)
320
+ weights = np.array([[1.0], [2.0]], dtype=float)
321
+
322
+ processing_data = ProcessingData()
323
+ bd = BaseData(
324
+ signal=signal,
325
+ units=ureg.Unit("count"),
326
+ uncertainties={"u": unc},
327
+ weights=weights,
328
+ )
329
+ bundle = DataBundle(signal=bd)
330
+ processing_data["bundle"] = bundle
331
+
332
+ step = ReduceDimensionality(io_sources=TEST_IO_SOURCES)
333
+ step.modify_config_by_kwargs(
334
+ with_processing_keys=["bundle"],
335
+ axes=0,
336
+ use_weights=True,
337
+ nan_policy="omit",
338
+ reduction="mean",
339
+ )
340
+ step.processing_data = processing_data
341
+
342
+ logger_name = "modacor.modules.base_modules.reduce_dimensionality"
343
+
344
+ with caplog.at_level(logging.DEBUG, logger=logger_name):
345
+ step.calculate()
346
+
347
+ # Sanity: output exists
348
+ assert "bundle" in processing_data
349
+ out_bd: BaseData = processing_data["bundle"]["signal"]
350
+ assert isinstance(out_bd, BaseData)
351
+
352
+ # Collect log records from expected logger
353
+ records = [rec for rec in caplog.records if rec.name == logger_name]
354
+ assert records, "Expected at least one log record from ReduceDimensionality logger."
355
+
356
+ levels = {rec.levelno for rec in records}
357
+ assert logging.INFO in levels, "Expected at least one INFO log from ReduceDimensionality."
358
+ assert logging.DEBUG in levels, "Expected at least one DEBUG log from ReduceDimensionality."
@@ -0,0 +1,119 @@
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__ = "11/01/2026"
11
+ __status__ = "Development" # "Development", "Production"
12
+ # end of header and standard imports
13
+
14
+ from pathlib import Path
15
+
16
+ import numpy as np
17
+ import pytest
18
+
19
+ from modacor import ureg
20
+ from modacor.dataclasses.basedata import BaseData
21
+ from modacor.dataclasses.databundle import DataBundle
22
+ from modacor.dataclasses.processing_data import ProcessingData
23
+ from modacor.io.io_sinks import IoSinks
24
+ from modacor.modules.base_modules.append_sink import AppendSink
25
+ from modacor.modules.base_modules.sink_processing_data import SinkProcessingData
26
+
27
+
28
+ @pytest.fixture
29
+ def processing_data_1d() -> ProcessingData:
30
+ """
31
+ Minimal ProcessingData with a single DataBundle containing two 1D BaseData entries.
32
+ """
33
+ pd = ProcessingData()
34
+ b = DataBundle()
35
+
36
+ q = BaseData(signal=np.linspace(0.1, 1.0, 5), units=ureg.Unit("1/nm"))
37
+ i = BaseData(signal=np.array([10, 11, 12, 13, 14], dtype=float), units=ureg.dimensionless)
38
+
39
+ b["Q"] = q
40
+ b["signal"] = i
41
+ pd["sample"] = b
42
+ return pd
43
+
44
+
45
+ def _register_csv_sink(io_sinks: IoSinks, out_file: Path) -> None:
46
+ """
47
+ Register a CSVSink via AppendSink.
48
+ """
49
+ step = AppendSink(io_sources=None, io_sinks=io_sinks)
50
+ step.modify_config_by_dict(
51
+ {
52
+ "sink_identifier": ["export_csv"],
53
+ "sink_location": [str(out_file)],
54
+ "iosink_module": "modacor.io.csv.csv_sink.CSVSink",
55
+ # simplified: delimiter (and any np.savetxt kwargs) live here
56
+ "iosink_method_kwargs": {"delimiter": ","},
57
+ }
58
+ )
59
+ step.calculate()
60
+
61
+
62
+ def _run_sink_step(io_sinks: IoSinks, processing_data: ProcessingData, *, target: str, data_paths: list[str]):
63
+ step = SinkProcessingData(io_sources=None, io_sinks=io_sinks, processing_data=processing_data)
64
+ step.modify_config_by_dict({"target": target, "data_paths": data_paths})
65
+ return step.calculate()
66
+
67
+
68
+ def test_sink_processing_data_writes_csv_numpy(tmp_path: Path, processing_data_1d: ProcessingData):
69
+ out_file = tmp_path / "export.csv"
70
+ io_sinks = IoSinks()
71
+ _register_csv_sink(io_sinks, out_file)
72
+
73
+ data_paths = ["/sample/Q/signal", "/sample/signal/signal"]
74
+ output = _run_sink_step(io_sinks, processing_data_1d, target="export_csv::", data_paths=data_paths)
75
+
76
+ assert output == {}
77
+ assert out_file.is_file()
78
+
79
+ lines = out_file.read_text(encoding="utf-8").splitlines()
80
+ assert len(lines) == 2 + 5 # 2 headers + 5 rows
81
+
82
+ # header row: names derived from data_paths
83
+ assert lines[0] == "sample/Q/signal,sample/signal/signal"
84
+
85
+ # units row: inferred from BaseData units
86
+ q_units = str(processing_data_1d["sample"]["Q"].units)
87
+ i_units = str(processing_data_1d["sample"]["signal"].units)
88
+ assert lines[1] == f"{q_units},{i_units}" # noqa: E231
89
+
90
+ # first numeric row should match first entries
91
+ first_row = [float(x) for x in lines[2].split(",")]
92
+ assert first_row == [
93
+ float(processing_data_1d["sample"]["Q"].signal[0]),
94
+ float(processing_data_1d["sample"]["signal"].signal[0]),
95
+ ]
96
+
97
+
98
+ def test_sink_processing_data_rejects_csv_subpath(tmp_path: Path, processing_data_1d: ProcessingData):
99
+ out_file = tmp_path / "export.csv"
100
+ io_sinks = IoSinks()
101
+ _register_csv_sink(io_sinks, out_file)
102
+
103
+ with pytest.raises(ValueError):
104
+ _run_sink_step(
105
+ io_sinks,
106
+ processing_data_1d,
107
+ target="export_csv::not_supported",
108
+ data_paths=["/sample/Q/signal"],
109
+ )
110
+
111
+
112
+ def test_sink_processing_data_requires_explicit_leaf_path(tmp_path: Path, processing_data_1d: ProcessingData):
113
+ out_file = tmp_path / "export.csv"
114
+ io_sinks = IoSinks()
115
+ _register_csv_sink(io_sinks, out_file)
116
+
117
+ # Missing leaf (BaseData object root) -> CSVSink should refuse
118
+ with pytest.raises(ValueError):
119
+ _run_sink_step(io_sinks, processing_data_1d, target="export_csv::", data_paths=["/sample/Q"])
@@ -0,0 +1,111 @@
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
+ # end of header and standard imports
13
+
14
+ import unittest
15
+
16
+ import numpy as np
17
+
18
+ import modacor.modules.base_modules.subtract as subtract_module
19
+ from modacor import ureg
20
+ from modacor.dataclasses.basedata import BaseData
21
+ from modacor.dataclasses.databundle import DataBundle
22
+ from modacor.dataclasses.processing_data import ProcessingData
23
+ from modacor.io.io_sources import IoSources
24
+ from modacor.modules.base_modules.subtract import Subtract
25
+
26
+ TEST_IO_SOURCES = IoSources()
27
+
28
+
29
+ class TestSubtractProcessingStep(unittest.TestCase):
30
+ """Testing class for modacor/modules/base_modules/subtract.py"""
31
+
32
+ def setUp(self):
33
+ # 2x3 BaseData
34
+ signal = np.array([[10.0, 20.0, 30.0], [40.0, 50.0, 60.0]], dtype=float)
35
+ data_unc = 0.5 * np.ones_like(signal)
36
+
37
+ self.test_processing_data = ProcessingData()
38
+ self.base = BaseData(
39
+ signal=signal,
40
+ units=ureg.Unit("count"),
41
+ uncertainties={"u": data_unc},
42
+ )
43
+ self.test_data_bundle = DataBundle(signal=self.base)
44
+ self.test_processing_data["bundle"] = self.test_data_bundle
45
+
46
+ # Subtrahend: scalar BaseData with same units so subtraction is valid
47
+ self.subtrahend = BaseData(
48
+ signal=5.0,
49
+ units=ureg.Unit("count"),
50
+ uncertainties={"propagate_to_all": np.array(0.2, dtype=float)},
51
+ )
52
+
53
+ # Ground truth using BaseData.__sub__
54
+ self.expected_result = self.base - self.subtrahend
55
+
56
+ # Monkeypatch basedata_from_sources
57
+ self._orig_basedata_from_sources = subtract_module.basedata_from_sources
58
+ subtract_module.basedata_from_sources = self._fake_basedata_from_sources
59
+
60
+ def tearDown(self):
61
+ subtract_module.basedata_from_sources = self._orig_basedata_from_sources
62
+
63
+ def _fake_basedata_from_sources(self, io_sources, signal_source, units_source=None, uncertainty_sources=None):
64
+ """Fake basedata_from_sources that always returns self.subtrahend."""
65
+ return self.subtrahend
66
+
67
+ def test_subtract_calculation(self):
68
+ """
69
+ Subtract.calculate() should subtract the subtrahend from the DataBundle's BaseData,
70
+ using BaseData.__sub__ semantics.
71
+ """
72
+ step = Subtract(io_sources=TEST_IO_SOURCES)
73
+ step.modify_config_by_kwargs(
74
+ with_processing_keys=["bundle"],
75
+ subtrahend_source="dummy", # ignored
76
+ )
77
+ step.processing_data = self.test_processing_data
78
+
79
+ step.calculate()
80
+
81
+ result_bd: BaseData = self.test_processing_data["bundle"]["signal"]
82
+
83
+ np.testing.assert_allclose(result_bd.signal, self.expected_result.signal)
84
+ for key in self.expected_result.uncertainties:
85
+ np.testing.assert_allclose(
86
+ result_bd.uncertainties[key],
87
+ self.expected_result.uncertainties[key],
88
+ )
89
+ self.assertEqual(result_bd.units, self.expected_result.units)
90
+
91
+ def test_subtract_execution_via_call(self):
92
+ """
93
+ Subtract.__call__ should run the step and update ProcessingData in-place.
94
+ """
95
+ step = Subtract(io_sources=TEST_IO_SOURCES)
96
+ step.modify_config_by_kwargs(
97
+ with_processing_keys=["bundle"],
98
+ subtrahend_source="dummy",
99
+ )
100
+
101
+ step(self.test_processing_data)
102
+
103
+ result_bd: BaseData = self.test_processing_data["bundle"]["signal"]
104
+
105
+ np.testing.assert_allclose(result_bd.signal, self.expected_result.signal)
106
+ for key in self.expected_result.uncertainties:
107
+ np.testing.assert_allclose(
108
+ result_bd.uncertainties[key],
109
+ self.expected_result.uncertainties[key],
110
+ )
111
+ self.assertEqual(result_bd.units, self.expected_result.units)