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