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,519 @@
|
|
|
1
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
# /usr/bin/env python3
|
|
3
|
+
# -*- coding: utf-8 -*-
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
__coding__ = "utf-8"
|
|
10
|
+
__authors__ = ["Brian R. Pauw"] # add names to the list as appropriate
|
|
11
|
+
__copyright__ = "Copyright 2025, The MoDaCor team"
|
|
12
|
+
__date__ = "18/06/2025"
|
|
13
|
+
__status__ = "Development" # "Development", "Production"
|
|
14
|
+
# end of header and standard imports
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from modacor import ureg
|
|
20
|
+
|
|
21
|
+
# import tiled.client # not sure what the class of tiled.client is...
|
|
22
|
+
from modacor.dataclasses.basedata import ( # adjust the import path as needed
|
|
23
|
+
BaseData,
|
|
24
|
+
signal_converter,
|
|
25
|
+
validate_broadcast,
|
|
26
|
+
validate_rank_of_data,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.fixture
|
|
31
|
+
def simple_basedata():
|
|
32
|
+
sig = np.arange(6, dtype=float).reshape((2, 3))
|
|
33
|
+
uncs = {
|
|
34
|
+
"poisson": np.full((2, 3), 0.5),
|
|
35
|
+
"sem": 0.2, # scalar uncertainty
|
|
36
|
+
}
|
|
37
|
+
return BaseData(signal=sig, uncertainties=uncs, units=ureg.dimensionless)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Basic helpers & broadcast tests
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_signal_converter_converts_scalars_and_preserves_arrays():
|
|
46
|
+
# Scalar int → array
|
|
47
|
+
arr1 = signal_converter(5)
|
|
48
|
+
assert isinstance(arr1, np.ndarray)
|
|
49
|
+
assert arr1.shape == ()
|
|
50
|
+
assert arr1.item() == 5.0
|
|
51
|
+
|
|
52
|
+
# Scalar float → array
|
|
53
|
+
arr2 = signal_converter(3.14)
|
|
54
|
+
assert isinstance(arr2, np.ndarray)
|
|
55
|
+
assert arr2.shape == ()
|
|
56
|
+
assert arr2.item() == pytest.approx(3.14)
|
|
57
|
+
|
|
58
|
+
# ndarray → unchanged
|
|
59
|
+
original = np.array([[1, 2], [3, 4]], dtype=float)
|
|
60
|
+
arr3 = signal_converter(original)
|
|
61
|
+
assert arr3 is original
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_validate_broadcast_accepts_scalars_and_matching_shapes():
|
|
65
|
+
signal = np.zeros((4, 5, 6))
|
|
66
|
+
|
|
67
|
+
# Scalar arrays (size 1) always okay
|
|
68
|
+
scalar = np.array(2.0)
|
|
69
|
+
validate_broadcast(signal, scalar, "scalar")
|
|
70
|
+
|
|
71
|
+
# Exact shape
|
|
72
|
+
arr_full = np.ones((4, 5, 6))
|
|
73
|
+
validate_broadcast(signal, arr_full, "full")
|
|
74
|
+
|
|
75
|
+
# Broadcastable suffix shape
|
|
76
|
+
arr_suffix = np.ones((5, 6))
|
|
77
|
+
validate_broadcast(signal, arr_suffix, "suffix")
|
|
78
|
+
|
|
79
|
+
# Leading ones OK
|
|
80
|
+
arr_leading = np.ones((1, 5, 6))
|
|
81
|
+
validate_broadcast(signal, arr_leading, "leading")
|
|
82
|
+
|
|
83
|
+
# Single dimension match
|
|
84
|
+
arr_last = np.ones((6,))
|
|
85
|
+
validate_broadcast(signal, arr_last, "last_dim")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_validate_broadcast_raises_on_incompatible_shapes():
|
|
89
|
+
signal = np.zeros((3, 4, 5))
|
|
90
|
+
|
|
91
|
+
# Totally incompatible
|
|
92
|
+
with pytest.raises(ValueError):
|
|
93
|
+
validate_broadcast(signal, np.ones((2, 2)), "bad1")
|
|
94
|
+
|
|
95
|
+
# Broadcasts to wrong shape (e.g., (3,4,4) → (3,4,5))
|
|
96
|
+
with pytest.raises(ValueError):
|
|
97
|
+
validate_broadcast(signal, np.ones((3, 4, 4)), "bad2")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Variances / uncertainties
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_initial_variances_and_uncertainties(simple_basedata):
|
|
106
|
+
bd = simple_basedata
|
|
107
|
+
# variances property squares each uncertainty
|
|
108
|
+
vars_dict = bd.variances
|
|
109
|
+
assert np.allclose(vars_dict["poisson"], 0.5**2)
|
|
110
|
+
assert np.allclose(vars_dict["sem"], 0.2**2)
|
|
111
|
+
|
|
112
|
+
# ensure uncertainties remain unchanged
|
|
113
|
+
assert "poisson" in bd.uncertainties and "sem" in bd.uncertainties
|
|
114
|
+
assert bd.uncertainties["sem"] == pytest.approx(0.2)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_variances_setter_updates_uncertainties_and_validates_shape(simple_basedata):
|
|
118
|
+
bd = simple_basedata
|
|
119
|
+
# valid new variances (scalar and full array)
|
|
120
|
+
new_vars = {
|
|
121
|
+
"poisson": np.full((2, 3), 0.25),
|
|
122
|
+
"sem": 0.04,
|
|
123
|
+
}
|
|
124
|
+
bd.variances = new_vars
|
|
125
|
+
# uncertainties become sqrt(var)
|
|
126
|
+
assert np.allclose(bd.uncertainties["poisson"], 0.25**0.5)
|
|
127
|
+
assert bd.uncertainties["sem"] == pytest.approx(0.04**0.5)
|
|
128
|
+
|
|
129
|
+
# invalid shape (wrong shape)
|
|
130
|
+
with pytest.raises(ValueError):
|
|
131
|
+
bd.variances = {"poisson": np.ones((3, 2))}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_variances_setitem_updates_underlying_uncertainties(simple_basedata):
|
|
135
|
+
bd = simple_basedata
|
|
136
|
+
|
|
137
|
+
new_var = np.array([[4.0, 9.0, 16.0], [25.0, 36.0, 49.0]], dtype=float)
|
|
138
|
+
|
|
139
|
+
bd.variances["poisson"] = new_var
|
|
140
|
+
|
|
141
|
+
# Underlying uncertainties should now be sqrt(new_var)
|
|
142
|
+
np.testing.assert_allclose(bd.uncertainties["poisson"], np.sqrt(new_var))
|
|
143
|
+
|
|
144
|
+
# Reading variances again returns the original variance values
|
|
145
|
+
np.testing.assert_allclose(bd.variances["poisson"], new_var)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_variances_setitem_rejects_incompatible_shape(simple_basedata):
|
|
149
|
+
"""
|
|
150
|
+
Assigning a variance array that cannot broadcast to signal.shape should raise.
|
|
151
|
+
"""
|
|
152
|
+
bd = simple_basedata
|
|
153
|
+
|
|
154
|
+
bad_var = np.ones((2,), dtype=float) # signal has shape (2,3)
|
|
155
|
+
|
|
156
|
+
with pytest.raises(ValueError):
|
|
157
|
+
bd.variances["bad"] = bad_var
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_variances_setter_rejects_non_dict(simple_basedata):
|
|
161
|
+
bd = simple_basedata
|
|
162
|
+
|
|
163
|
+
with pytest.raises(TypeError):
|
|
164
|
+
bd.variances = ["not", "a", "dict"] # type: ignore[arg-type]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Rank-of-data validation
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_validate_rank_of_data_bounds_and_ndim():
|
|
173
|
+
class Dummy:
|
|
174
|
+
def __init__(self, signal, rank):
|
|
175
|
+
self.signal = signal
|
|
176
|
+
self.rank_of_data = rank
|
|
177
|
+
|
|
178
|
+
sig1 = np.zeros((2, 3))
|
|
179
|
+
# Valid ranks: 0, 1, 2
|
|
180
|
+
for r in (0, 1, 2):
|
|
181
|
+
dummy = Dummy(sig1, r)
|
|
182
|
+
validate_rank_of_data(dummy, type("A", (), {"name": "rank_of_data"}), r)
|
|
183
|
+
|
|
184
|
+
# Negative or >3 invalid
|
|
185
|
+
for r in (-1, 4):
|
|
186
|
+
dummy = Dummy(sig1, r)
|
|
187
|
+
with pytest.raises(ValueError):
|
|
188
|
+
validate_rank_of_data(dummy, type("A", (), {"name": "rank_of_data"}), r)
|
|
189
|
+
|
|
190
|
+
# Rank > ndim invalid
|
|
191
|
+
dummy2 = Dummy(np.zeros((5,)), 2)
|
|
192
|
+
with pytest.raises(ValueError):
|
|
193
|
+
validate_rank_of_data(dummy2, type("A", (), {"name": "rank_of_data"}), 2)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_rank_of_data_validation_errors(simple_basedata):
|
|
197
|
+
bd = simple_basedata
|
|
198
|
+
# valid rank
|
|
199
|
+
bd.rank_of_data = 1 # <= ndim
|
|
200
|
+
assert bd.rank_of_data == 1
|
|
201
|
+
|
|
202
|
+
# invalid rank, 3 > ndim
|
|
203
|
+
with pytest.raises(ValueError):
|
|
204
|
+
bd.rank_of_data = 3
|
|
205
|
+
|
|
206
|
+
# invalid rank > 3
|
|
207
|
+
with pytest.raises(ValueError):
|
|
208
|
+
bd.rank_of_data = 5
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Weights broadcast validation and axes tests
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_weighting_broadcast_validation(simple_basedata):
|
|
217
|
+
bd = simple_basedata
|
|
218
|
+
# valid weighting (broadcastable to (2,3))
|
|
219
|
+
bd.weights = np.array([1.0, 2.0, 3.0])
|
|
220
|
+
bd.__attrs_post_init__() # should not raise
|
|
221
|
+
|
|
222
|
+
# invalid weighting shape
|
|
223
|
+
with pytest.raises(ValueError):
|
|
224
|
+
bd.weights = np.ones((3, 2))
|
|
225
|
+
bd.__attrs_post_init__()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_axes_sanity_check_logs_when_length_mismatched(simple_basedata, caplog):
|
|
229
|
+
bd = simple_basedata
|
|
230
|
+
# signal.ndim == 2, but axes length is 1 → mismatch
|
|
231
|
+
bd.axes = [None]
|
|
232
|
+
|
|
233
|
+
with caplog.at_level(logging.DEBUG):
|
|
234
|
+
bd.__attrs_post_init__()
|
|
235
|
+
|
|
236
|
+
messages = [rec.getMessage() for rec in caplog.records]
|
|
237
|
+
assert any("BaseData.axes length" in msg for msg in messages)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_axes_sanity_check_no_log_when_length_matches(simple_basedata, caplog):
|
|
241
|
+
bd = simple_basedata
|
|
242
|
+
# signal.ndim == 2, axes length == 2 → OK
|
|
243
|
+
bd.axes = [None, None]
|
|
244
|
+
|
|
245
|
+
with caplog.at_level(logging.DEBUG):
|
|
246
|
+
bd.__attrs_post_init__()
|
|
247
|
+
|
|
248
|
+
messages = [rec.getMessage() for rec in caplog.records]
|
|
249
|
+
assert not any("BaseData.axes length" in msg for msg in messages)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Unit conversion behaviour
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_to_units_converts_properly():
|
|
258
|
+
sig = np.array([[1.0, 2.0], [3.0, 4.0]])
|
|
259
|
+
bd = BaseData(signal=sig.copy(), units=ureg.meter)
|
|
260
|
+
|
|
261
|
+
bd.to_units(ureg.centimeter)
|
|
262
|
+
expected = sig * 100 # m to cm
|
|
263
|
+
assert bd.units == ureg.centimeter
|
|
264
|
+
np.testing.assert_allclose(bd.signal, expected)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_to_units_multiplicative_conversion_scales_signal_and_uncertainties():
|
|
268
|
+
sig = np.array([1.0, 2.0, 3.0], dtype=float)
|
|
269
|
+
uncs = {"stat": np.array([0.1, 0.2, 0.3], dtype=float)}
|
|
270
|
+
bd = BaseData(signal=sig.copy(), units=ureg.meter, uncertainties=uncs)
|
|
271
|
+
|
|
272
|
+
signal_before = bd.signal.copy()
|
|
273
|
+
uncs_before = {k: v.copy() for k, v in bd.uncertainties.items()}
|
|
274
|
+
|
|
275
|
+
# Use same pint logic as BaseData.to_units
|
|
276
|
+
cfact = ureg.millimeter.m_from(ureg.meter)
|
|
277
|
+
|
|
278
|
+
bd.to_units(ureg.millimeter, multiplicative_conversion=True)
|
|
279
|
+
|
|
280
|
+
# Units updated
|
|
281
|
+
assert bd.units == ureg.millimeter
|
|
282
|
+
|
|
283
|
+
# Signal scaled
|
|
284
|
+
np.testing.assert_allclose(bd.signal, signal_before * cfact)
|
|
285
|
+
|
|
286
|
+
# Each uncertainty scaled by the same factor
|
|
287
|
+
for key, unc_after in bd.uncertainties.items():
|
|
288
|
+
np.testing.assert_allclose(unc_after, uncs_before[key] * cfact)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_to_units_same_units_is_noop(simple_basedata):
|
|
292
|
+
bd = simple_basedata
|
|
293
|
+
|
|
294
|
+
signal_before = bd.signal.copy()
|
|
295
|
+
uncs_before = {k: v.copy() for k, v in bd.uncertainties.items()}
|
|
296
|
+
|
|
297
|
+
bd.to_units(ureg.dimensionless, multiplicative_conversion=True)
|
|
298
|
+
|
|
299
|
+
# Nothing should have changed
|
|
300
|
+
np.testing.assert_allclose(bd.signal, signal_before)
|
|
301
|
+
for key, unc_after in bd.uncertainties.items():
|
|
302
|
+
np.testing.assert_allclose(unc_after, uncs_before[key])
|
|
303
|
+
assert bd.units == ureg.dimensionless
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def test_to_units_incompatible_units_raises(simple_basedata):
|
|
307
|
+
bd = simple_basedata
|
|
308
|
+
# dimensionless vs. time is not compatible
|
|
309
|
+
with pytest.raises(ValueError):
|
|
310
|
+
bd.to_units(ureg.second, multiplicative_conversion=True)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_to_units_non_multiplicative_path_not_implemented(simple_basedata):
|
|
314
|
+
"""
|
|
315
|
+
Once the non-multiplicative branch in BaseData.to_units is guarded
|
|
316
|
+
with NotImplementedError, this ensures we don't silently do the wrong thing.
|
|
317
|
+
"""
|
|
318
|
+
bd = simple_basedata
|
|
319
|
+
bd.units = ureg.kelvin
|
|
320
|
+
|
|
321
|
+
with pytest.raises(NotImplementedError):
|
|
322
|
+
bd.to_units(ureg.rankine, multiplicative_conversion=False)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ---------------------------------------------------------------------------
|
|
326
|
+
# Metadata preservation in ops
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_binary_ops_preserve_rank_axes_and_weights(simple_basedata):
|
|
331
|
+
bd = simple_basedata
|
|
332
|
+
|
|
333
|
+
# Set some non-default metadata
|
|
334
|
+
bd.rank_of_data = 2
|
|
335
|
+
bd.axes = [None, None] # two-dimensional signal
|
|
336
|
+
bd.weights = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
|
|
337
|
+
|
|
338
|
+
other = BaseData(signal=np.ones_like(bd.signal), units=bd.units)
|
|
339
|
+
|
|
340
|
+
# BaseData / BaseData
|
|
341
|
+
res = bd / other
|
|
342
|
+
assert res.rank_of_data == bd.rank_of_data
|
|
343
|
+
assert res.axes == bd.axes
|
|
344
|
+
assert res.axes is not bd.axes # new list, no aliasing
|
|
345
|
+
np.testing.assert_allclose(res.weights, bd.weights)
|
|
346
|
+
|
|
347
|
+
# BaseData / scalar
|
|
348
|
+
res2 = bd / 2.0
|
|
349
|
+
assert res2.rank_of_data == bd.rank_of_data
|
|
350
|
+
assert res2.axes == bd.axes
|
|
351
|
+
assert res2.axes is not bd.axes
|
|
352
|
+
np.testing.assert_allclose(res2.weights, bd.weights)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_unary_ops_preserve_rank_axes_and_weights(simple_basedata):
|
|
356
|
+
bd = simple_basedata
|
|
357
|
+
|
|
358
|
+
bd.rank_of_data = 1
|
|
359
|
+
bd.axes = [None, None] # arbitrary axes metadata
|
|
360
|
+
bd.weights = np.array([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]])
|
|
361
|
+
|
|
362
|
+
neg = -bd
|
|
363
|
+
sqrt_bd = bd.sqrt()
|
|
364
|
+
log_bd = bd.log() # valid for all positive elements; 0 will yield NaN, which is fine
|
|
365
|
+
|
|
366
|
+
for out in (neg, sqrt_bd, log_bd):
|
|
367
|
+
assert out.rank_of_data == bd.rank_of_data
|
|
368
|
+
assert out.axes == bd.axes
|
|
369
|
+
assert out.axes is not bd.axes
|
|
370
|
+
np.testing.assert_allclose(out.weights, bd.weights)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------------------
|
|
374
|
+
# indexed() tests
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_indexed_scalar_component_preserves_units_and_uncertainties():
|
|
379
|
+
"""
|
|
380
|
+
Indexing a 1D BaseData with an integer should return a scalar BaseData
|
|
381
|
+
with sliced signal, uncertainties, and weights, and preserved units.
|
|
382
|
+
"""
|
|
383
|
+
sig = np.array([10.0, 20.0, 30.0], dtype=float)
|
|
384
|
+
uncs = {"u": np.array([1.0, 2.0, 3.0], dtype=float)}
|
|
385
|
+
weights = np.array([0.1, 0.2, 0.3], dtype=float)
|
|
386
|
+
|
|
387
|
+
bd = BaseData(
|
|
388
|
+
signal=sig,
|
|
389
|
+
units=ureg.meter,
|
|
390
|
+
uncertainties=uncs,
|
|
391
|
+
weights=weights,
|
|
392
|
+
rank_of_data=1,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# Take the middle component
|
|
396
|
+
sub = bd.indexed(1)
|
|
397
|
+
|
|
398
|
+
# Signal becomes scalar
|
|
399
|
+
assert sub.signal.shape == ()
|
|
400
|
+
assert sub.signal == pytest.approx(20.0)
|
|
401
|
+
|
|
402
|
+
# Units preserved
|
|
403
|
+
assert sub.units == ureg.meter
|
|
404
|
+
|
|
405
|
+
# Uncertainties sliced
|
|
406
|
+
assert "u" in sub.uncertainties
|
|
407
|
+
assert sub.uncertainties["u"].shape == ()
|
|
408
|
+
assert sub.uncertainties["u"] == pytest.approx(2.0)
|
|
409
|
+
|
|
410
|
+
# Weights sliced
|
|
411
|
+
assert sub.weights.shape == ()
|
|
412
|
+
assert sub.weights == pytest.approx(0.2)
|
|
413
|
+
|
|
414
|
+
# rank_of_data default: min(original_rank, ndim_of_result) → min(1, 0) = 0
|
|
415
|
+
assert sub.rank_of_data == 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def test_indexed_slice_reduces_dimension_and_preserves_metadata():
|
|
419
|
+
"""
|
|
420
|
+
Indexing with a slice should reduce dimensionality and keep metadata
|
|
421
|
+
(axes, units, rank_of_data) consistent.
|
|
422
|
+
"""
|
|
423
|
+
sig = np.arange(6, dtype=float).reshape((2, 3))
|
|
424
|
+
uncs = {"stat": np.ones_like(sig, dtype=float)}
|
|
425
|
+
weights = np.full_like(sig, 0.5, dtype=float)
|
|
426
|
+
|
|
427
|
+
bd = BaseData(
|
|
428
|
+
signal=sig,
|
|
429
|
+
units=ureg.dimensionless,
|
|
430
|
+
uncertainties=uncs,
|
|
431
|
+
weights=weights,
|
|
432
|
+
axes=[None, None],
|
|
433
|
+
rank_of_data=2,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
# Take the first row
|
|
437
|
+
sub = bd.indexed(0)
|
|
438
|
+
|
|
439
|
+
# Shape reduced (2,3) -> (3,)
|
|
440
|
+
assert sub.signal.shape == (3,)
|
|
441
|
+
np.testing.assert_allclose(sub.signal, sig[0])
|
|
442
|
+
|
|
443
|
+
# Uncertainties and weights sliced consistently
|
|
444
|
+
assert "stat" in sub.uncertainties
|
|
445
|
+
np.testing.assert_allclose(sub.uncertainties["stat"], uncs["stat"][0])
|
|
446
|
+
np.testing.assert_allclose(sub.weights, weights[0])
|
|
447
|
+
|
|
448
|
+
# Units preserved
|
|
449
|
+
assert sub.units == bd.units
|
|
450
|
+
|
|
451
|
+
# Axes preserved as a *new* list
|
|
452
|
+
assert sub.axes == bd.axes
|
|
453
|
+
assert sub.axes is not bd.axes
|
|
454
|
+
|
|
455
|
+
# Default rank_of_data: min(2, 1) = 1
|
|
456
|
+
assert sub.rank_of_data == 1
|
|
457
|
+
|
|
458
|
+
# Explicit rank_of_data override works
|
|
459
|
+
sub0 = bd.indexed(0, rank_of_data=0)
|
|
460
|
+
assert sub0.rank_of_data == 0
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ---------------------------------------------------------------------------
|
|
464
|
+
# Copy tests:
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_copy_creates_independent_arrays_and_axes_list(simple_basedata):
|
|
469
|
+
bd = simple_basedata
|
|
470
|
+
|
|
471
|
+
# Set some metadata so we can check it is carried over
|
|
472
|
+
bd.rank_of_data = 2
|
|
473
|
+
bd.axes = [None, None]
|
|
474
|
+
bd.weights = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
|
|
475
|
+
|
|
476
|
+
bd_copy = bd.copy()
|
|
477
|
+
|
|
478
|
+
# Different object
|
|
479
|
+
assert bd_copy is not bd
|
|
480
|
+
assert isinstance(bd_copy, BaseData)
|
|
481
|
+
|
|
482
|
+
# Signal copied, not aliased
|
|
483
|
+
np.testing.assert_allclose(bd_copy.signal, bd.signal)
|
|
484
|
+
assert bd_copy.signal is not bd.signal
|
|
485
|
+
|
|
486
|
+
# Weights copied, not aliased
|
|
487
|
+
np.testing.assert_allclose(bd_copy.weights, bd.weights)
|
|
488
|
+
assert bd_copy.weights is not bd.weights
|
|
489
|
+
|
|
490
|
+
# Uncertainties copied, not aliased
|
|
491
|
+
assert set(bd_copy.uncertainties.keys()) == set(bd.uncertainties.keys())
|
|
492
|
+
for key in bd.uncertainties:
|
|
493
|
+
np.testing.assert_allclose(bd_copy.uncertainties[key], bd.uncertainties[key])
|
|
494
|
+
assert bd_copy.uncertainties[key] is not bd.uncertainties[key]
|
|
495
|
+
|
|
496
|
+
# Axes list shallow-copied: new list, same elements
|
|
497
|
+
assert bd_copy.axes == bd.axes
|
|
498
|
+
assert bd_copy.axes is not bd.axes
|
|
499
|
+
# Elements themselves are the same objects (shallow copy)
|
|
500
|
+
for a_orig, a_copy in zip(bd.axes, bd_copy.axes):
|
|
501
|
+
assert a_orig is a_copy
|
|
502
|
+
|
|
503
|
+
# rank_of_data and units preserved
|
|
504
|
+
assert bd_copy.rank_of_data == bd.rank_of_data
|
|
505
|
+
assert bd_copy.units == bd.units
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def test_copy_without_axes_uses_empty_axes(simple_basedata):
|
|
509
|
+
bd = simple_basedata
|
|
510
|
+
bd.axes = [None, None]
|
|
511
|
+
|
|
512
|
+
bd_copy = bd.copy(with_axes=False)
|
|
513
|
+
|
|
514
|
+
# Axes dropped
|
|
515
|
+
assert bd_copy.axes == []
|
|
516
|
+
# Other content still copied
|
|
517
|
+
np.testing.assert_allclose(bd_copy.signal, bd.signal)
|
|
518
|
+
for key in bd.uncertainties:
|
|
519
|
+
np.testing.assert_allclose(bd_copy.uncertainties[key], bd.uncertainties[key])
|