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