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,439 @@
|
|
|
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
|
+
import unittest
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from modacor import ureg
|
|
18
|
+
from modacor.dataclasses.basedata import BaseData
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestBinaryOpsWithUncertainties(unittest.TestCase):
|
|
22
|
+
"""
|
|
23
|
+
Tests binary operations (+, -, *, /) between BaseData objects,
|
|
24
|
+
including units and uncertainty propagation with propagate_to_all.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def setUp(self):
|
|
28
|
+
# 10x10 integer signal
|
|
29
|
+
self.signal = np.arange(1, 101, dtype=float).reshape((10, 10))
|
|
30
|
+
self.base = BaseData(signal=self.signal, units=ureg.Unit("count"))
|
|
31
|
+
# Poisson variance (≥1 to avoid zero)
|
|
32
|
+
poisson_variance = self.signal.copy()
|
|
33
|
+
self.base.variances["Poisson"] = poisson_variance
|
|
34
|
+
|
|
35
|
+
# Multiplier: 2.0 ± 0.2 s, stored as absolute uncertainty via propagate_to_all
|
|
36
|
+
self.mult = BaseData(
|
|
37
|
+
signal=2.0,
|
|
38
|
+
units=ureg.Unit("second"),
|
|
39
|
+
uncertainties={"propagate_to_all": 0.1 * 2.0},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def test_multiply_basedata(self):
|
|
43
|
+
result = self.base * self.mult
|
|
44
|
+
|
|
45
|
+
# Expected nominal values
|
|
46
|
+
expected_val = (self.signal * self.base.units) * (self.mult.signal * self.mult.units)
|
|
47
|
+
|
|
48
|
+
# Expected uncertainties using standard propagation:
|
|
49
|
+
# (σ_M / M)^2 = (σ_A / A)^2 + (σ_B / B)^2
|
|
50
|
+
sigma_A = np.sqrt(self.base.variances["Poisson"]) # absolute σ_A
|
|
51
|
+
A = self.signal * self.base.units
|
|
52
|
+
# B = self.mult.signal * self.mult.units
|
|
53
|
+
sigma_B = self.mult.uncertainties["propagate_to_all"] # absolute σ_B
|
|
54
|
+
rel_sigma_A = sigma_A / A.magnitude # unitless
|
|
55
|
+
rel_sigma_B = sigma_B / self.mult.signal # unitless
|
|
56
|
+
sigma_M = np.sqrt(rel_sigma_A**2 + rel_sigma_B**2) * expected_val.magnitude
|
|
57
|
+
|
|
58
|
+
# Build expected BaseData
|
|
59
|
+
expected = BaseData(
|
|
60
|
+
signal=expected_val.magnitude,
|
|
61
|
+
units=expected_val.units,
|
|
62
|
+
uncertainties={"Poisson": sigma_M},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.assertEqual(result.units, expected.units)
|
|
66
|
+
np.testing.assert_allclose(result.signal, expected.signal)
|
|
67
|
+
np.testing.assert_allclose(result.uncertainties["Poisson"], expected.uncertainties["Poisson"])
|
|
68
|
+
|
|
69
|
+
def test_divide_basedata(self):
|
|
70
|
+
result = self.base / self.mult
|
|
71
|
+
|
|
72
|
+
expected_val = (self.signal * self.base.units) / (self.mult.signal * self.mult.units)
|
|
73
|
+
|
|
74
|
+
sigma_A = np.sqrt(self.base.variances["Poisson"])
|
|
75
|
+
A = self.signal * self.base.units
|
|
76
|
+
# B = self.mult.signal * self.mult.units
|
|
77
|
+
sigma_B = self.mult.uncertainties["propagate_to_all"]
|
|
78
|
+
rel_sigma_A = sigma_A / A.magnitude
|
|
79
|
+
rel_sigma_B = sigma_B / self.mult.signal
|
|
80
|
+
sigma_Q = np.sqrt(rel_sigma_A**2 + rel_sigma_B**2) * expected_val.magnitude
|
|
81
|
+
|
|
82
|
+
expected = BaseData(
|
|
83
|
+
signal=expected_val.magnitude,
|
|
84
|
+
units=expected_val.units,
|
|
85
|
+
uncertainties={"Poisson": sigma_Q},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self.assertEqual(result.units, expected.units)
|
|
89
|
+
np.testing.assert_allclose(result.signal, expected.signal)
|
|
90
|
+
np.testing.assert_allclose(result.uncertainties["Poisson"], expected.uncertainties["Poisson"])
|
|
91
|
+
|
|
92
|
+
def test_add_basedata(self):
|
|
93
|
+
result = self.base + self.base
|
|
94
|
+
|
|
95
|
+
expected_val = (self.signal * self.base.units) + (self.signal * self.base.units)
|
|
96
|
+
# σ_sum^2 = σ_A^2 + σ_B^2 = 2 * variance
|
|
97
|
+
sigma_sum = np.sqrt(2.0 * self.base.variances["Poisson"])
|
|
98
|
+
|
|
99
|
+
expected = BaseData(
|
|
100
|
+
signal=expected_val.magnitude,
|
|
101
|
+
units=expected_val.units,
|
|
102
|
+
uncertainties={"Poisson": sigma_sum},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self.assertEqual(result.units, expected.units)
|
|
106
|
+
np.testing.assert_allclose(result.signal, expected.signal)
|
|
107
|
+
np.testing.assert_allclose(result.uncertainties["Poisson"], expected.uncertainties["Poisson"])
|
|
108
|
+
|
|
109
|
+
def test_subtract_basedata(self):
|
|
110
|
+
result = self.base - self.base
|
|
111
|
+
|
|
112
|
+
expected_val = (self.signal * self.base.units) - (self.signal * self.base.units)
|
|
113
|
+
sigma_diff = np.sqrt(2.0 * self.base.variances["Poisson"]) # same as sum for uncorrelated
|
|
114
|
+
|
|
115
|
+
expected = BaseData(
|
|
116
|
+
signal=expected_val.magnitude,
|
|
117
|
+
units=expected_val.units,
|
|
118
|
+
uncertainties={"Poisson": sigma_diff},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self.assertEqual(result.units, expected.units)
|
|
122
|
+
np.testing.assert_allclose(result.signal, expected.signal)
|
|
123
|
+
np.testing.assert_allclose(result.uncertainties["Poisson"], expected.uncertainties["Poisson"])
|
|
124
|
+
|
|
125
|
+
def test_nonmatching_uncertainties_transfer_and_propagate_independently(self):
|
|
126
|
+
# Non-matching non-global keys: result should contain the union of keys.
|
|
127
|
+
a = BaseData(signal=5.0, uncertainties={"Poisson": 0.1}, units=ureg.Unit("m"))
|
|
128
|
+
b = BaseData(signal=3.0, uncertainties={"apply_to_all": 0.2}, units=ureg.Unit("s"))
|
|
129
|
+
|
|
130
|
+
result = a * b
|
|
131
|
+
|
|
132
|
+
self.assertIn("Poisson", result.uncertainties)
|
|
133
|
+
self.assertIn("apply_to_all", result.uncertainties)
|
|
134
|
+
|
|
135
|
+
# Mul: σR² = (B σA)² + (A σB)², but per-key independent:
|
|
136
|
+
# - "Poisson" comes only from a: σ = |B| σA
|
|
137
|
+
# - "apply_to_all" comes only from b: σ = |A| σB
|
|
138
|
+
self.assertAlmostEqual(float(result.uncertainties["Poisson"]), 3.0 * 0.1)
|
|
139
|
+
self.assertAlmostEqual(float(result.uncertainties["apply_to_all"]), 5.0 * 0.2)
|
|
140
|
+
|
|
141
|
+
def test_propagate_to_all_fallback(self):
|
|
142
|
+
# other has only propagate_to_all → used for all keys of left
|
|
143
|
+
a = BaseData(signal=np.array([1.0, 2.0]), uncertainties={"u": np.array([0.1, 0.2])}, units=ureg.m)
|
|
144
|
+
b = BaseData(signal=2.0, uncertainties={"propagate_to_all": 0.3}, units=ureg.dimensionless)
|
|
145
|
+
|
|
146
|
+
result = a * b
|
|
147
|
+
# simple scalar check: check first element manually
|
|
148
|
+
A1, σA1 = 1.0, 0.1
|
|
149
|
+
B, σB = 2.0, 0.3
|
|
150
|
+
M1 = A1 * B
|
|
151
|
+
σM1 = np.sqrt((σA1 / A1) ** 2 + (σB / B) ** 2) * M1
|
|
152
|
+
self.assertAlmostEqual(result.signal[0], M1)
|
|
153
|
+
self.assertAlmostEqual(result.uncertainties["u"][0], σM1)
|
|
154
|
+
|
|
155
|
+
def test_both_propagate_to_all_results_in_propagate_to_all_only(self):
|
|
156
|
+
a = BaseData(
|
|
157
|
+
signal=np.array([2.0, 3.0]), units=ureg.m, uncertainties={"propagate_to_all": np.array([0.2, 0.3])}
|
|
158
|
+
)
|
|
159
|
+
b = BaseData(signal=4.0, units=ureg.s, uncertainties={"propagate_to_all": 0.4})
|
|
160
|
+
|
|
161
|
+
result = a * b
|
|
162
|
+
|
|
163
|
+
self.assertEqual(set(result.uncertainties.keys()), {"propagate_to_all"})
|
|
164
|
+
|
|
165
|
+
A = a.signal
|
|
166
|
+
B = 4.0
|
|
167
|
+
sigma_A = np.array([0.2, 0.3])
|
|
168
|
+
sigma_B = 0.4
|
|
169
|
+
|
|
170
|
+
expected = np.sqrt((B * sigma_A) ** 2 + (A * sigma_B) ** 2)
|
|
171
|
+
np.testing.assert_allclose(result.uncertainties["propagate_to_all"], expected)
|
|
172
|
+
|
|
173
|
+
def test_nonmatching_keys_union_for_addition(self):
|
|
174
|
+
a = BaseData(signal=np.array([1.0, 2.0]), units=ureg.m, uncertainties={"u": np.array([0.1, 0.2])})
|
|
175
|
+
b = BaseData(signal=np.array([3.0, 4.0]), units=ureg.m, uncertainties={"v": np.array([0.3, 0.4])})
|
|
176
|
+
|
|
177
|
+
result = a + b
|
|
178
|
+
|
|
179
|
+
self.assertEqual(set(result.uncertainties.keys()), {"u", "v"})
|
|
180
|
+
# Add: each key propagates independently; "u" comes only from a, "v" only from b
|
|
181
|
+
np.testing.assert_allclose(result.uncertainties["u"], np.array([0.1, 0.2]))
|
|
182
|
+
np.testing.assert_allclose(result.uncertainties["v"], np.array([0.3, 0.4]))
|
|
183
|
+
|
|
184
|
+
def test_nonmatching_keys_union_for_division(self):
|
|
185
|
+
a = BaseData(signal=np.array([2.0, 4.0]), units=ureg.m, uncertainties={"u": np.array([0.2, 0.4])})
|
|
186
|
+
b = BaseData(signal=np.array([5.0, 10.0]), units=ureg.s, uncertainties={"v": np.array([0.5, 1.0])})
|
|
187
|
+
|
|
188
|
+
result = a / b
|
|
189
|
+
|
|
190
|
+
self.assertEqual(set(result.uncertainties.keys()), {"u", "v"})
|
|
191
|
+
|
|
192
|
+
A = np.array([2.0, 4.0])
|
|
193
|
+
B = np.array([5.0, 10.0])
|
|
194
|
+
sigma_u = np.array([0.2, 0.4])
|
|
195
|
+
sigma_v = np.array([0.5, 1.0])
|
|
196
|
+
|
|
197
|
+
# Div per-key independent:
|
|
198
|
+
# u: σ = σA / B
|
|
199
|
+
expected_u = sigma_u / B
|
|
200
|
+
# v: σ = A σB / B^2
|
|
201
|
+
expected_v = (A * sigma_v) / (B**2)
|
|
202
|
+
|
|
203
|
+
np.testing.assert_allclose(result.uncertainties["u"], expected_u)
|
|
204
|
+
np.testing.assert_allclose(result.uncertainties["v"], expected_v)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class TestScalarAndQuantityCoercion(unittest.TestCase):
|
|
208
|
+
"""
|
|
209
|
+
Tests that scalars and pint.Quantity operands are correctly coerced into BaseData
|
|
210
|
+
with zero uncertainties.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def setUp(self):
|
|
214
|
+
self.signal = np.array([1.0, 2.0, 3.0])
|
|
215
|
+
self.base = BaseData(
|
|
216
|
+
signal=self.signal,
|
|
217
|
+
units=ureg.m,
|
|
218
|
+
uncertainties={"u": np.array([0.1, 0.2, 0.3])},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def test_multiply_by_scalar(self):
|
|
222
|
+
result = self.base * 2.0
|
|
223
|
+
|
|
224
|
+
np.testing.assert_allclose(result.signal, self.signal * 2.0)
|
|
225
|
+
# uncertainties should scale by the same factor
|
|
226
|
+
np.testing.assert_allclose(result.uncertainties["u"], np.array([0.1, 0.2, 0.3]) * 2.0)
|
|
227
|
+
self.assertEqual(result.units, self.base.units)
|
|
228
|
+
|
|
229
|
+
def test_right_multiply_by_scalar(self):
|
|
230
|
+
result = 2.0 * self.base
|
|
231
|
+
np.testing.assert_allclose(result.signal, self.signal * 2.0)
|
|
232
|
+
np.testing.assert_allclose(result.uncertainties["u"], np.array([0.1, 0.2, 0.3]) * 2.0)
|
|
233
|
+
self.assertEqual(result.units, self.base.units)
|
|
234
|
+
|
|
235
|
+
def test_add_scalar(self):
|
|
236
|
+
result = self.base + 1.0
|
|
237
|
+
np.testing.assert_allclose(result.signal, self.signal + 1.0)
|
|
238
|
+
# uncertainties unchanged if scalar has zero uncertainty
|
|
239
|
+
np.testing.assert_allclose(result.uncertainties["u"], np.array([0.1, 0.2, 0.3]))
|
|
240
|
+
self.assertEqual(result.units, self.base.units)
|
|
241
|
+
|
|
242
|
+
def test_add_pint_quantity_same_units(self):
|
|
243
|
+
result = self.base + 2.0 * ureg.m
|
|
244
|
+
np.testing.assert_allclose(result.signal, self.signal + 2.0)
|
|
245
|
+
np.testing.assert_allclose(result.uncertainties["u"], np.array([0.1, 0.2, 0.3]))
|
|
246
|
+
self.assertEqual(result.units, self.base.units)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestUnaryOps(unittest.TestCase):
|
|
250
|
+
"""
|
|
251
|
+
Tests unary transformations: sqrt, square, power, log, exp, trig functions, reciprocal.
|
|
252
|
+
Checks both signal and uncertainty propagation, plus domain masking behavior.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
def setUp(self):
|
|
256
|
+
self.signal = np.array([1.0, 4.0, 9.0])
|
|
257
|
+
self.unc = np.array([0.1, 0.2, 0.3])
|
|
258
|
+
self.base = BaseData(
|
|
259
|
+
signal=self.signal,
|
|
260
|
+
units=ureg.m,
|
|
261
|
+
uncertainties={"u": self.unc},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def test_sqrt(self):
|
|
265
|
+
result = self.base.sqrt()
|
|
266
|
+
expected_signal = np.sqrt(self.signal)
|
|
267
|
+
expected_sigma = np.abs(0.5 / np.sqrt(self.signal) * self.unc)
|
|
268
|
+
|
|
269
|
+
np.testing.assert_allclose(result.signal, expected_signal)
|
|
270
|
+
np.testing.assert_allclose(result.uncertainties["u"], expected_sigma)
|
|
271
|
+
self.assertEqual(result.units, self.base.units**0.5)
|
|
272
|
+
|
|
273
|
+
def test_square(self):
|
|
274
|
+
result = self.base.square()
|
|
275
|
+
expected_signal = self.signal**2
|
|
276
|
+
expected_sigma = np.abs(2.0 * self.signal * self.unc)
|
|
277
|
+
|
|
278
|
+
np.testing.assert_allclose(result.signal, expected_signal)
|
|
279
|
+
np.testing.assert_allclose(result.uncertainties["u"], expected_sigma)
|
|
280
|
+
self.assertEqual(result.units, self.base.units**2)
|
|
281
|
+
|
|
282
|
+
def test_power(self):
|
|
283
|
+
exponent = 3.0
|
|
284
|
+
result = self.base**exponent
|
|
285
|
+
expected_signal = self.signal**exponent
|
|
286
|
+
expected_sigma = np.abs(exponent * self.signal ** (exponent - 1.0) * self.unc)
|
|
287
|
+
|
|
288
|
+
np.testing.assert_allclose(result.signal, expected_signal)
|
|
289
|
+
np.testing.assert_allclose(result.uncertainties["u"], expected_sigma)
|
|
290
|
+
self.assertEqual(result.units, self.base.units**exponent)
|
|
291
|
+
|
|
292
|
+
def test_log(self):
|
|
293
|
+
# all positive
|
|
294
|
+
base = BaseData(
|
|
295
|
+
signal=np.array([1.0, 2.0, 4.0]), units=ureg.dimensionless, uncertainties={"u": np.array([0.1, 0.2, 0.4])}
|
|
296
|
+
)
|
|
297
|
+
result = base.log()
|
|
298
|
+
expected_signal = np.log(base.signal)
|
|
299
|
+
expected_sigma = np.abs((1.0 / base.signal) * base.uncertainties["u"])
|
|
300
|
+
|
|
301
|
+
np.testing.assert_allclose(result.signal, expected_signal)
|
|
302
|
+
np.testing.assert_allclose(result.uncertainties["u"], expected_sigma)
|
|
303
|
+
self.assertEqual(result.units, ureg.dimensionless)
|
|
304
|
+
|
|
305
|
+
def test_log_domain_masking(self):
|
|
306
|
+
base = BaseData(
|
|
307
|
+
signal=np.array([-1.0, 0.0, 1.0]),
|
|
308
|
+
units=ureg.dimensionless,
|
|
309
|
+
uncertainties={"u": np.array([0.1, 0.1, 0.1])},
|
|
310
|
+
)
|
|
311
|
+
result = base.log()
|
|
312
|
+
|
|
313
|
+
# invalid at -1 and 0 → NaN
|
|
314
|
+
self.assertTrue(np.isnan(result.signal[0]))
|
|
315
|
+
self.assertTrue(np.isnan(result.signal[1]))
|
|
316
|
+
self.assertTrue(np.isnan(result.uncertainties["u"][0]))
|
|
317
|
+
self.assertTrue(np.isnan(result.uncertainties["u"][1]))
|
|
318
|
+
# valid at 1
|
|
319
|
+
self.assertFalse(np.isnan(result.signal[2]))
|
|
320
|
+
self.assertFalse(np.isnan(result.uncertainties["u"][2]))
|
|
321
|
+
|
|
322
|
+
def test_reciprocal(self):
|
|
323
|
+
base = BaseData(
|
|
324
|
+
signal=np.array([1.0, 2.0, 4.0]),
|
|
325
|
+
units=ureg.s,
|
|
326
|
+
uncertainties={"u": np.array([0.1, 0.2, 0.4])},
|
|
327
|
+
)
|
|
328
|
+
result = base.reciprocal()
|
|
329
|
+
expected_signal = 1.0 / base.signal
|
|
330
|
+
expected_sigma = np.abs(1.0 / (base.signal**2) * base.uncertainties["u"])
|
|
331
|
+
|
|
332
|
+
np.testing.assert_allclose(result.signal, expected_signal)
|
|
333
|
+
np.testing.assert_allclose(result.uncertainties["u"], expected_sigma)
|
|
334
|
+
self.assertEqual(result.units, 1 / base.units)
|
|
335
|
+
|
|
336
|
+
def test_trig_functions(self):
|
|
337
|
+
# small angles in radians
|
|
338
|
+
base = BaseData(
|
|
339
|
+
signal=np.array([0.0, np.pi / 4]),
|
|
340
|
+
units=ureg.radian,
|
|
341
|
+
uncertainties={"u": np.array([0.01, 0.02])},
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
sin_res = base.sin()
|
|
345
|
+
cos_res = base.cos()
|
|
346
|
+
tan_res = base.tan()
|
|
347
|
+
|
|
348
|
+
# sin: σ ≈ |cos(x)| σ_x
|
|
349
|
+
expected_sin_sigma = np.abs(np.cos(base.signal) * base.uncertainties["u"])
|
|
350
|
+
np.testing.assert_allclose(sin_res.signal, np.sin(base.signal))
|
|
351
|
+
np.testing.assert_allclose(sin_res.uncertainties["u"], expected_sin_sigma)
|
|
352
|
+
self.assertEqual(sin_res.units, ureg.dimensionless)
|
|
353
|
+
|
|
354
|
+
# cos: σ ≈ |sin(x)| σ_x
|
|
355
|
+
expected_cos_sigma = np.abs(np.sin(base.signal) * base.uncertainties["u"])
|
|
356
|
+
np.testing.assert_allclose(cos_res.signal, np.cos(base.signal))
|
|
357
|
+
np.testing.assert_allclose(cos_res.uncertainties["u"], expected_cos_sigma)
|
|
358
|
+
self.assertEqual(cos_res.units, ureg.dimensionless)
|
|
359
|
+
|
|
360
|
+
# tan: σ ≈ |1/cos^2(x)| σ_x
|
|
361
|
+
expected_tan_sigma = np.abs(1.0 / (np.cos(base.signal) ** 2) * base.uncertainties["u"])
|
|
362
|
+
np.testing.assert_allclose(tan_res.signal, np.tan(base.signal))
|
|
363
|
+
np.testing.assert_allclose(tan_res.uncertainties["u"], expected_tan_sigma)
|
|
364
|
+
self.assertEqual(tan_res.units, ureg.dimensionless)
|
|
365
|
+
|
|
366
|
+
def test_inverse_trig_domain_masking(self):
|
|
367
|
+
base = BaseData(
|
|
368
|
+
signal=np.array([-1.5, -1.0, 0.0, 1.0, 1.5]),
|
|
369
|
+
units=ureg.dimensionless,
|
|
370
|
+
uncertainties={"u": np.array([0.1, 0.1, 0.1, 0.1, 0.1])},
|
|
371
|
+
)
|
|
372
|
+
asin_res = base.arcsin()
|
|
373
|
+
acos_res = base.arccos()
|
|
374
|
+
|
|
375
|
+
# |x| > 1 → NaN
|
|
376
|
+
for idx in (0, 4):
|
|
377
|
+
self.assertTrue(np.isnan(asin_res.signal[idx]))
|
|
378
|
+
self.assertTrue(np.isnan(asin_res.uncertainties["u"][idx]))
|
|
379
|
+
self.assertTrue(np.isnan(acos_res.signal[idx]))
|
|
380
|
+
self.assertTrue(np.isnan(acos_res.uncertainties["u"][idx]))
|
|
381
|
+
|
|
382
|
+
# |x| <= 1 → finite
|
|
383
|
+
for idx in (1, 2, 3):
|
|
384
|
+
self.assertFalse(np.isnan(asin_res.signal[idx]))
|
|
385
|
+
self.assertFalse(np.isnan(asin_res.uncertainties["u"][idx]))
|
|
386
|
+
self.assertFalse(np.isnan(acos_res.signal[idx]))
|
|
387
|
+
self.assertFalse(np.isnan(acos_res.uncertainties["u"][idx]))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class TestNegationAndCopySafety(unittest.TestCase):
|
|
391
|
+
"""
|
|
392
|
+
Tests that negation preserves uncertainty magnitudes and that uncertainties
|
|
393
|
+
are deep-copied (no aliasing between original and result).
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
def test_negation_copies_uncertainties(self):
|
|
397
|
+
base = BaseData(
|
|
398
|
+
signal=np.array([1.0, 2.0]),
|
|
399
|
+
units=ureg.m,
|
|
400
|
+
uncertainties={"u": np.array([0.1, 0.2])},
|
|
401
|
+
)
|
|
402
|
+
neg = -base
|
|
403
|
+
|
|
404
|
+
# values & units
|
|
405
|
+
np.testing.assert_allclose(neg.signal, -base.signal)
|
|
406
|
+
self.assertEqual(neg.units, base.units)
|
|
407
|
+
np.testing.assert_allclose(neg.uncertainties["u"], base.uncertainties["u"])
|
|
408
|
+
|
|
409
|
+
# modify neg; base must not change
|
|
410
|
+
neg.uncertainties["u"][0] = 999.0
|
|
411
|
+
self.assertNotEqual(neg.uncertainties["u"][0], base.uncertainties["u"][0])
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class TestBroadcastValidation(unittest.TestCase):
|
|
415
|
+
"""
|
|
416
|
+
Tests that validate_broadcast is effectively enforced for uncertainties.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
def test_invalid_uncertainty_shape_raises(self):
|
|
420
|
+
signal = np.zeros((2, 2))
|
|
421
|
+
# Shape (3,) cannot broadcast to (2,2)
|
|
422
|
+
with self.assertRaises(ValueError):
|
|
423
|
+
BaseData(
|
|
424
|
+
signal=signal,
|
|
425
|
+
units=ureg.count,
|
|
426
|
+
uncertainties={"u": np.array([1.0, 2.0, 3.0])},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def test_scalar_uncertainty_broadcasts(self):
|
|
430
|
+
signal = np.zeros((2, 2))
|
|
431
|
+
bd = BaseData(
|
|
432
|
+
signal=signal,
|
|
433
|
+
units=ureg.count,
|
|
434
|
+
uncertainties={"u": 0.1},
|
|
435
|
+
)
|
|
436
|
+
self.assertEqual(bd.uncertainties["u"].shape, ())
|
|
437
|
+
# and unary op should broadcast this fine
|
|
438
|
+
res = bd.square()
|
|
439
|
+
self.assertEqual(res.uncertainties["u"].shape, signal.shape)
|
|
@@ -0,0 +1,57 @@
|
|
|
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__ = "02/01/2026"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from modacor import ureg
|
|
17
|
+
from modacor.dataclasses.basedata import BaseData
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_to_base_units_scales_signal_and_uncertainties():
|
|
21
|
+
bd = BaseData(
|
|
22
|
+
signal=np.array([100.0, 200.0]), # cm
|
|
23
|
+
units=ureg.cm,
|
|
24
|
+
uncertainties={"propagate_to_all": np.array([1.0, 2.0])}, # cm
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
bd.to_base_units()
|
|
28
|
+
|
|
29
|
+
assert bd.units == ureg.m
|
|
30
|
+
np.testing.assert_allclose(bd.signal, [1.0, 2.0]) # 100 cm -> 1 m, 200 cm -> 2 m
|
|
31
|
+
np.testing.assert_allclose(bd.uncertainties["propagate_to_all"], [0.01, 0.02]) # 1 cm -> 0.01 m
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_to_base_units_noop_if_already_base_units():
|
|
35
|
+
bd = BaseData(
|
|
36
|
+
signal=np.array([1.0, 2.0]),
|
|
37
|
+
units=ureg.m,
|
|
38
|
+
uncertainties={"u": np.array([0.1, 0.2])},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
bd.to_base_units()
|
|
42
|
+
|
|
43
|
+
assert bd.units == ureg.m
|
|
44
|
+
np.testing.assert_allclose(bd.signal, [1.0, 2.0])
|
|
45
|
+
np.testing.assert_allclose(bd.uncertainties["u"], [0.1, 0.2])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_to_base_units_offset_units_raise():
|
|
49
|
+
bd = BaseData(
|
|
50
|
+
signal=np.array([20.0, 25.0]),
|
|
51
|
+
units=ureg.degF,
|
|
52
|
+
uncertainties={"u": np.array([0.5, 0.5])},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
with pytest.raises(NotImplementedError):
|
|
56
|
+
# the multiplicative_conversion is a bit of a cop-out. Tests whether a unit conversion is purely multiplicative are not straightforward and fast.
|
|
57
|
+
bd.to_base_units(multiplicative_conversion=False)
|
|
@@ -0,0 +1,73 @@
|
|
|
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__ = "28/11/2025"
|
|
11
|
+
__status__ = "Development" # "Development", "Production"
|
|
12
|
+
# end of header and standard imports
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from modacor.dataclasses.process_step_describer import ProcessStepDescriber
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_describer(**kwargs) -> ProcessStepDescriber:
|
|
22
|
+
return ProcessStepDescriber(
|
|
23
|
+
calling_name="Test",
|
|
24
|
+
calling_id="test.step",
|
|
25
|
+
calling_module_path=Path(__file__),
|
|
26
|
+
calling_version="0",
|
|
27
|
+
**kwargs,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_arguments_must_be_mapping_of_dicts():
|
|
32
|
+
with pytest.raises(TypeError):
|
|
33
|
+
_build_describer(arguments=["not a mapping"])
|
|
34
|
+
|
|
35
|
+
with pytest.raises(TypeError):
|
|
36
|
+
_build_describer(arguments={"key": "not a dict"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_arguments_required_flag_must_be_boolean():
|
|
40
|
+
with pytest.raises(TypeError):
|
|
41
|
+
_build_describer(arguments={"key": {"required": "yes"}})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_list_fields_allow_tuples_and_strip_whitespace():
|
|
45
|
+
describer = _build_describer(
|
|
46
|
+
required_data_keys=(" signal ", "units"),
|
|
47
|
+
step_keywords=[" foo ", "bar"],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
assert describer.required_data_keys == ["signal", "units"]
|
|
51
|
+
assert describer.step_keywords == ["foo", "bar"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_initial_configuration_is_isolated_from_defaults():
|
|
55
|
+
describer = _build_describer(
|
|
56
|
+
arguments={"nested": {"default": {"values": [1, 2]}}},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
copied = describer.initial_configuration()
|
|
60
|
+
copied["nested"]["values"].append(3)
|
|
61
|
+
|
|
62
|
+
assert describer.arguments["nested"]["default"]["values"] == [1, 2]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_required_argument_names():
|
|
66
|
+
describer = _build_describer(
|
|
67
|
+
arguments={
|
|
68
|
+
"needed": {"default": "", "required": True},
|
|
69
|
+
"optional": {"default": 0},
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert describer.required_argument_names() == ("needed",)
|