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