uda-xarray 0.2.3__tar.gz → 0.3.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uda-xarray
3
- Version: 0.2.3
3
+ Version: 0.3.1
4
4
  Summary: UDA backend for xarray
5
5
  Author-email: Samuel Jackson <samuel.jackson@ukaea.uk>
6
6
  Maintainer-email: Samuel Jackson <samuel.jackson@ukaea.uk>
@@ -9,13 +9,16 @@ Classifier: Development Status :: 4 - Beta
9
9
  Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Programming Language :: Python :: 3.11
11
11
  Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
12
14
  Classifier: License :: OSI Approved :: MIT License
13
- Requires-Python: <3.13,>=3.9
15
+ Requires-Python: >=3.9
14
16
  Description-Content-Type: text/markdown
15
17
  License-File: LICENSE
16
18
  Requires-Dist: uda>=2.9.2
17
19
  Requires-Dist: uda-mast
18
20
  Requires-Dist: xarray
21
+ Requires-Dist: pydantic>=2.0
19
22
  Dynamic: license-file
20
23
 
21
24
  # uda-xarray
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "uda-xarray"
3
- version = "0.2.3"
3
+ version = "0.3.1"
4
4
  description = "UDA backend for xarray"
5
5
  readme = "README.md"
6
- requires-python = ">=3.9, <3.13"
6
+ requires-python = ">=3.9"
7
7
  authors = [
8
8
  {name = "Samuel Jackson", email = "samuel.jackson@ukaea.uk"},
9
9
  ]
@@ -16,6 +16,8 @@ classifiers = [
16
16
  "Programming Language :: Python :: 3",
17
17
  "Programming Language :: Python :: 3.11",
18
18
  "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
19
21
  "License :: OSI Approved :: MIT License",
20
22
  ]
21
23
 
@@ -23,11 +25,15 @@ dependencies = [
23
25
  "uda>=2.9.2",
24
26
  "uda-mast",
25
27
  "xarray",
28
+ "pydantic>=2.0",
26
29
  ]
27
30
 
28
31
  [tool.setuptools]
29
32
  packages = ["uda_xarray"]
30
33
 
34
+ [tool.setuptools.package-data]
35
+ uda_xarray = ["*.json"]
36
+
31
37
  [project.urls]
32
38
  repository = "https://github.com/samueljackson92/uda-xarray"
33
39
 
@@ -47,7 +53,7 @@ dev = [
47
53
  uda = "uda_xarray.main:UDABackendEntrypoint"
48
54
 
49
55
  [tool.bumpversion]
50
- current_version = "0.2.3"
56
+ current_version = "0.3.1"
51
57
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
52
58
  serialize = ["{major}.{minor}.{patch}"]
53
59
  search = "{current_version}"
@@ -0,0 +1,534 @@
1
+ """Tests for the UDA xarray backend."""
2
+
3
+ from unittest.mock import Mock
4
+
5
+ import numpy as np
6
+ import pyuda
7
+ import pytest
8
+ import xarray as xr
9
+
10
+ from uda_xarray.mappings import SignalMappings, SignalRange
11
+
12
+
13
+ def test_open_uda_dataset(mocker):
14
+ """Test loading a 1D UDA signal as an xarray Dataset."""
15
+ # Create mock signal object
16
+ mock_signal = Mock()
17
+ mock_signal.data = np.array([1.0, 2.0, 3.0])
18
+ mock_signal.shape = (3,)
19
+ dim1 = Mock(label="time")
20
+ dim1.data = np.array([0.0, 1.0, 2.0])
21
+ mock_signal.dims = [dim1]
22
+
23
+ mock_signal.units = "A"
24
+ mock_signal.time = Mock()
25
+ mock_signal.time.data = np.array([0.0, 1.0, 2.0])
26
+ mock_signal.time.label = "time"
27
+ mock_signal.errors = Mock()
28
+ mock_signal.errors.data = np.array([0.1, 0.1, 0.1])
29
+
30
+ # Mock the pyuda Client
31
+ mock_client = Mock()
32
+ mock_client.get.return_value = mock_signal
33
+ mocker.patch("pyuda.Client", return_value=mock_client)
34
+ mocker.patch(
35
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
36
+ return_value="Signal",
37
+ )
38
+
39
+ ds = xr.open_dataset("uda://ip:30421", engine="uda")
40
+
41
+ # Verify the client was called correctly
42
+ mock_client.get.assert_called_once_with("ip", 30421)
43
+
44
+ assert ds["data"].name == "data"
45
+ assert ds["data"].dims == ("time",)
46
+ assert "time" in ds.coords
47
+
48
+ assert ds["data"].shape == ds["time"].shape
49
+ assert "units" in ds["data"].attrs
50
+ assert ds["data"].attrs["uda_name"] == "ip"
51
+
52
+
53
+ def test_open_uda_dataset_2d(mocker):
54
+ """Test loading a 2D UDA signal as an xarray Dataset."""
55
+ # Create mock 2D signal object
56
+ mock_signal = Mock()
57
+ mock_signal.data = np.array([[1.0, 2.0], [3.0, 4.0]])
58
+ mock_signal.shape = (2, 2)
59
+ dim1 = Mock(label="time")
60
+ dim1.data = np.array([0.0, 1.0])
61
+ dim2 = Mock(label="channel")
62
+ dim2.data = np.array([0, 1])
63
+ mock_signal.dims = [dim1, dim2]
64
+ mock_signal.units = "eV"
65
+ mock_signal.errors = Mock()
66
+ mock_signal.errors.data = np.array([[0.1, 0.1], [0.1, 0.1]])
67
+ mock_signal.time = Mock()
68
+ mock_signal.time.data = np.array([0.0, 1.0])
69
+ mock_signal.time.label = "time"
70
+
71
+ # Mock the pyuda Client
72
+ mock_client = Mock()
73
+ mock_client.get.return_value = mock_signal
74
+ mocker.patch("pyuda.Client", return_value=mock_client)
75
+ mocker.patch(
76
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
77
+ return_value="Signal",
78
+ )
79
+
80
+ ds = xr.open_dataset("uda://AYE_TE:30421", engine="uda")
81
+
82
+ mock_client.get.assert_called_once_with("AYE_TE", 30421)
83
+
84
+ assert ds["data"].name == "data"
85
+ assert ds["data"].dims == ("time", "channel")
86
+ assert "time" in ds.coords
87
+ assert "channel" in ds.coords
88
+
89
+
90
+ def test_open_uda_dataset_video(mocker):
91
+ """Test loading a UDA video signal as an xarray Dataset."""
92
+ mock_signal = Mock()
93
+ mock_signal.is_color = False
94
+ mock_signal.frame_times = np.array([0.0, 0.033, 0.066])
95
+ frame1 = Mock()
96
+ frame1.k = np.array([[10, 20], [30, 40]])
97
+ frame2 = Mock()
98
+ frame2.k = np.array([[15, 25], [35, 45]])
99
+ frame3 = Mock()
100
+ frame3.k = np.array([[20, 30], [40, 50]])
101
+ mock_signal.frames = [frame1, frame2, frame3]
102
+ mock_signal.height = 2
103
+ mock_signal.width = 2
104
+ mock_signal.duration = 0.066
105
+ mock_signal.num_frames = 3
106
+ mock_signal.name = "rba"
107
+ mock_signal.description = "Mock video signal"
108
+ mock_signal.units = "counts"
109
+
110
+ mock_client = Mock()
111
+ mock_client.get_images.return_value = mock_signal
112
+ mocker.patch("pyuda.Client", return_value=mock_client)
113
+ mocker.patch(
114
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
115
+ return_value="Image",
116
+ )
117
+
118
+ ds = xr.open_dataset("uda://rba:30421", engine="uda")
119
+ assert ds["data"].name == "data"
120
+ assert ds["data"].dims == ("time", "height", "width")
121
+ assert "time" in ds.coords
122
+ assert ds.sizes["time"] == 3
123
+
124
+
125
+ def test_open_uda_dataset_invalid_signal(mocker):
126
+ """Test that an invalid UDA signal raises a RuntimeError."""
127
+ # Mock the pyuda Client to raise an exception
128
+ mock_client = Mock()
129
+ mock_client.get.side_effect = pyuda.ServerException("Signal not found")
130
+ mocker.patch("pyuda.Client", return_value=mock_client)
131
+ mocker.patch(
132
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
133
+ return_value="Signal",
134
+ )
135
+
136
+ try:
137
+ xr.open_dataset("uda://invalid_signal:99999", engine="uda")
138
+ except RuntimeError as e:
139
+ assert "Could not open UDA dataset" in str(e)
140
+ else:
141
+ assert False, "Expected RuntimeError was not raised"
142
+
143
+
144
+ def test_open_uda_dataset_invalid_format():
145
+ """Test that an invalid UDA URL format raises a ValueError."""
146
+ try:
147
+ xr.open_dataset("invalid_format", engine="uda")
148
+ except ValueError as e:
149
+ assert (
150
+ "UDA dataset must be specified as uda://<signal_name>:<shot_number>"
151
+ in str(e)
152
+ )
153
+ else:
154
+ assert False, "Expected ValueError was not raised"
155
+
156
+ try:
157
+ xr.open_dataset("http://invalid_scheme:12345", engine="uda")
158
+ except ValueError as e:
159
+ assert "UDA dataset must start with the uda:// scheme" in str(e)
160
+ else:
161
+ assert False, "Expected ValueError was not raised"
162
+
163
+
164
+ def test_open_datatree(mocker):
165
+ """Test loading a 1D UDA signal as an xarray DataTree."""
166
+ # Create mock signal object
167
+ mock_signal = Mock()
168
+ mock_signal.data = np.array([1.0, 2.0, 3.0])
169
+ mock_signal.shape = (3,)
170
+ dim1 = Mock(label="time")
171
+ dim1.data = np.array([0.0, 1.0, 2.0])
172
+ mock_signal.dims = [dim1]
173
+
174
+ mock_signal.units = "A"
175
+ mock_signal.time = Mock()
176
+ mock_signal.time.data = np.array([0.0, 1.0, 2.0])
177
+ mock_signal.time.label = "time"
178
+ mock_signal.errors = Mock()
179
+ mock_signal.errors.data = np.array([0.1, 0.1, 0.1])
180
+
181
+ # Mock the pyuda Client
182
+ mock_client = Mock()
183
+ mock_client.get.return_value = mock_signal
184
+ mocker.patch("pyuda.Client", return_value=mock_client)
185
+ mocker.patch(
186
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
187
+ return_value="Signal",
188
+ )
189
+
190
+ dt = xr.open_datatree("uda://ip:30421", engine="uda")
191
+
192
+ # Verify the client was called correctly
193
+ mock_client.get.assert_called_once_with("ip", 30421)
194
+
195
+ # Verify it's a DataTree object
196
+ assert isinstance(dt, xr.DataTree)
197
+
198
+ # Access the dataset from the root node
199
+ ds = dt.to_dataset()
200
+ assert ds["data"].name == "data"
201
+ assert ds["data"].dims == ("time",)
202
+ assert "time" in ds.coords
203
+ assert ds["data"].shape == ds["time"].shape
204
+ assert "units" in ds["data"].attrs
205
+ assert ds["data"].attrs["uda_name"] == "ip"
206
+
207
+
208
+ def test_open_datatree_2d(mocker):
209
+ """Test loading a 2D UDA signal as an xarray DataTree."""
210
+ # Create mock 2D signal object
211
+ mock_signal = Mock()
212
+ mock_signal.data = np.array([[1.0, 2.0], [3.0, 4.0]])
213
+ mock_signal.shape = (2, 2)
214
+ dim1 = Mock(label="time")
215
+ dim1.data = np.array([0.0, 1.0])
216
+ dim2 = Mock(label="channel")
217
+ dim2.data = np.array([0, 1])
218
+ mock_signal.dims = [dim1, dim2]
219
+ mock_signal.units = "eV"
220
+ mock_signal.errors = Mock()
221
+ mock_signal.errors.data = np.array([[0.1, 0.1], [0.1, 0.1]])
222
+ mock_signal.time = Mock()
223
+ mock_signal.time.data = np.array([0.0, 1.0])
224
+ mock_signal.time.label = "time"
225
+
226
+ # Mock the pyuda Client
227
+ mock_client = Mock()
228
+ mock_client.get.return_value = mock_signal
229
+ mocker.patch("pyuda.Client", return_value=mock_client)
230
+ mocker.patch(
231
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
232
+ return_value="Signal",
233
+ )
234
+
235
+ dt = xr.open_datatree("uda://AYE_TE:30421", engine="uda")
236
+
237
+ mock_client.get.assert_called_once_with("AYE_TE", 30421)
238
+
239
+ # Verify it's a DataTree object
240
+ assert isinstance(dt, xr.DataTree)
241
+
242
+ # Access the dataset from the root node
243
+ ds = dt.to_dataset()
244
+ assert ds["data"].name == "data"
245
+ assert ds["data"].dims == ("time", "channel")
246
+ assert "time" in ds.coords
247
+ assert "channel" in ds.coords
248
+
249
+
250
+ def test_open_datatree_video(mocker):
251
+ """Test loading a UDA video signal as an xarray DataTree."""
252
+ mock_signal = Mock()
253
+ mock_signal.is_color = False
254
+ mock_signal.frame_times = np.array([0.0, 0.033, 0.066])
255
+ frame1 = Mock()
256
+ frame1.k = np.array([[10, 20], [30, 40]])
257
+ frame2 = Mock()
258
+ frame2.k = np.array([[15, 25], [35, 45]])
259
+ frame3 = Mock()
260
+ frame3.k = np.array([[20, 30], [40, 50]])
261
+ mock_signal.frames = [frame1, frame2, frame3]
262
+ mock_signal.height = 2
263
+ mock_signal.width = 2
264
+ mock_signal.duration = 0.066
265
+ mock_signal.num_frames = 3
266
+ mock_signal.name = "rba"
267
+ mock_signal.description = "Mock video signal"
268
+ mock_signal.units = "counts"
269
+
270
+ mock_client = Mock()
271
+ mock_client.get_images.return_value = mock_signal
272
+ mocker.patch("pyuda.Client", return_value=mock_client)
273
+ mocker.patch(
274
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
275
+ return_value="Image",
276
+ )
277
+
278
+ dt = xr.open_datatree("uda://rba:30421", engine="uda")
279
+
280
+ # Verify it's a DataTree object
281
+ assert isinstance(dt, xr.DataTree)
282
+
283
+ # Access the dataset from the root node
284
+ ds = dt.to_dataset()
285
+ assert ds["data"].name == "data"
286
+ assert ds["data"].dims == ("time", "height", "width")
287
+ assert "time" in ds.coords
288
+ assert ds.sizes["time"] == 3
289
+
290
+
291
+ @pytest.mark.live
292
+ def test_open_uda_dataset_video_frame():
293
+ """Test loading a single video frame from a real UDA server."""
294
+ ds = xr.open_dataset(
295
+ "uda://rba:30421", engine="uda", drop_variables=None, frame_number=1
296
+ )
297
+ assert ds["data"].name == "data"
298
+ assert ds["data"].dims == ("time", "height", "width")
299
+ assert "time" in ds.coords
300
+ assert ds.sizes["time"] == 1
301
+
302
+
303
+ @pytest.mark.live
304
+ def test_real_data_2d():
305
+ """Test loading real 2D data from a live UDA server."""
306
+ ds = xr.open_dataset("uda://AYE_TE:30421", engine="uda")
307
+ assert ds["data"].name == "data"
308
+ assert ds["data"].dims == ("time", "radial index")
309
+ assert "time" in ds.coords
310
+ assert "radial index" in ds.coords
311
+
312
+
313
+ def test_remapped_signal_uses_legacy_name(mocker):
314
+ """Requesting a canonical signal name for an old shot should transparently
315
+ remap it to the legacy stored name before calling the UDA client."""
316
+
317
+ # Build a minimal in-memory mapping:
318
+ # canonical "AYC_TE" -> "AYC_TE" for shots 22832-30471
319
+ # -> "ATM_TE" for shots 1-22831
320
+ fake_mappings = SignalMappings(
321
+ mappings={
322
+ "AYC_TE": [
323
+ SignalRange(shot_min=22832, shot_max=30471, name="AYC_TE"),
324
+ SignalRange(shot_min=1, shot_max=22831, name="ATM_TE"),
325
+ ]
326
+ }
327
+ )
328
+ mocker.patch("uda_xarray.main._MAPPINGS", fake_mappings)
329
+
330
+ mock_signal = Mock()
331
+ mock_signal.data = np.array([1.0, 2.0, 3.0])
332
+ mock_signal.shape = (3,)
333
+ dim1 = Mock(label="time")
334
+ dim1.data = np.array([0.0, 1.0, 2.0])
335
+ mock_signal.dims = [dim1]
336
+ mock_signal.units = "eV"
337
+ mock_signal.time = Mock(label="time", data=np.array([0.0, 1.0, 2.0]))
338
+ mock_signal.errors = Mock(data=np.array([0.01, 0.01, 0.01]))
339
+
340
+ mock_client = Mock()
341
+ mock_client.get.return_value = mock_signal
342
+ mocker.patch("pyuda.Client", return_value=mock_client)
343
+ mocker.patch(
344
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
345
+ return_value="Signal",
346
+ )
347
+
348
+ # Shot 15000 is in the legacy range -> should call client with "ATM_TE"
349
+ ds = xr.open_dataset("uda://AYC_TE:15000", engine="uda")
350
+
351
+ mock_client.get.assert_called_once_with("ATM_TE", 15000)
352
+ assert ds["data"].attrs["uda_name"] == "ATM_TE"
353
+
354
+
355
+ def test_remapped_signal_uses_modern_name(mocker):
356
+ """Requesting a canonical signal name for a recent shot should keep the
357
+ modern stored name."""
358
+
359
+ fake_mappings = SignalMappings(
360
+ mappings={
361
+ "AYC_TE": [
362
+ SignalRange(shot_min=22832, shot_max=30471, name="AYC_TE"),
363
+ SignalRange(shot_min=1, shot_max=22831, name="ATM_TE"),
364
+ ]
365
+ }
366
+ )
367
+ mocker.patch("uda_xarray.main._MAPPINGS", fake_mappings)
368
+
369
+ mock_signal = Mock()
370
+ mock_signal.data = np.array([1.0, 2.0, 3.0])
371
+ mock_signal.shape = (3,)
372
+ dim1 = Mock(label="time")
373
+ dim1.data = np.array([0.0, 1.0, 2.0])
374
+ mock_signal.dims = [dim1]
375
+ mock_signal.units = "eV"
376
+ mock_signal.time = Mock(label="time", data=np.array([0.0, 1.0, 2.0]))
377
+ mock_signal.errors = Mock(data=np.array([0.01, 0.01, 0.01]))
378
+
379
+ mock_client = Mock()
380
+ mock_client.get.return_value = mock_signal
381
+ mocker.patch("pyuda.Client", return_value=mock_client)
382
+ mocker.patch(
383
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
384
+ return_value="Signal",
385
+ )
386
+
387
+ # Shot 30000 is in the modern range -> should call client with "AYC_TE"
388
+ ds = xr.open_dataset("uda://AYC_TE:30000", engine="uda")
389
+
390
+ mock_client.get.assert_called_once_with("AYC_TE", 30000)
391
+ assert ds["data"].attrs["uda_name"] == "AYC_TE"
392
+
393
+
394
+ def test_mastu_hcam_remapped_to_sanx_name(mocker):
395
+ """For a MASTU shot in the SAnx05 range, the canonical HCAM channel name
396
+ should be remapped to the SAnx05 digitiser path."""
397
+
398
+ # /XSX/HCAM/L/CH01/DATA is the canonical name; for shots 46353-49476 the
399
+ # data was stored as /xsx/SAnx05-01/ch14.
400
+ fake_mappings = SignalMappings(
401
+ mappings={
402
+ "/XSX/HCAM/L/CH01/DATA": [
403
+ SignalRange(
404
+ shot_min=49904, shot_max=51056, name="/XSX/HCAM/L/CH01/DATA"
405
+ ),
406
+ SignalRange(shot_min=49476, shot_max=49904, name="/XSX/HCAM/L/CH01"),
407
+ SignalRange(shot_min=46353, shot_max=49476, name="/xsx/SAnx05-01/ch14"),
408
+ SignalRange(shot_min=44395, shot_max=46353, name="/XSX/HCAM/L/CH01"),
409
+ ]
410
+ }
411
+ )
412
+ mocker.patch("uda_xarray.main._MAPPINGS", fake_mappings)
413
+
414
+ mock_signal = Mock()
415
+ mock_signal.data = np.array([1.0, 2.0, 3.0])
416
+ mock_signal.shape = (3,)
417
+ dim1 = Mock(label="time")
418
+ dim1.data = np.array([0.0, 1.0, 2.0])
419
+ mock_signal.dims = [dim1]
420
+ mock_signal.units = "V"
421
+ mock_signal.time = Mock(label="time", data=np.array([0.0, 1.0, 2.0]))
422
+ mock_signal.errors = Mock(data=np.array([0.01, 0.01, 0.01]))
423
+
424
+ mock_client = Mock()
425
+ mock_client.get.return_value = mock_signal
426
+ mocker.patch("pyuda.Client", return_value=mock_client)
427
+ mocker.patch(
428
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
429
+ return_value="Signal",
430
+ )
431
+
432
+ # Shot 47000 falls in the SAnx05 range -> should call client with ch14 path
433
+ ds = xr.open_dataset("uda:///XSX/HCAM/L/CH01/DATA:47000", engine="uda")
434
+
435
+ mock_client.get.assert_called_once_with("/xsx/SAnx05-01/ch14", 47000)
436
+ assert ds["data"].attrs["uda_name"] == "/xsx/SAnx05-01/ch14"
437
+
438
+
439
+ def test_mastu_hcam_modern_name_passthrough(mocker):
440
+ """For the most-recent MASTU shot range, the canonical HCAM channel name
441
+ should be used unchanged (it already matches the stored path)."""
442
+
443
+ fake_mappings = SignalMappings(
444
+ mappings={
445
+ "/XSX/HCAM/L/CH01/DATA": [
446
+ SignalRange(
447
+ shot_min=49904, shot_max=51056, name="/XSX/HCAM/L/CH01/DATA"
448
+ ),
449
+ SignalRange(shot_min=49476, shot_max=49904, name="/XSX/HCAM/L/CH01"),
450
+ SignalRange(shot_min=46353, shot_max=49476, name="/xsx/SAnx05-01/ch14"),
451
+ SignalRange(shot_min=44395, shot_max=46353, name="/XSX/HCAM/L/CH01"),
452
+ ]
453
+ }
454
+ )
455
+ mocker.patch("uda_xarray.main._MAPPINGS", fake_mappings)
456
+
457
+ mock_signal = Mock()
458
+ mock_signal.data = np.array([1.0, 2.0, 3.0])
459
+ mock_signal.shape = (3,)
460
+ dim1 = Mock(label="time")
461
+ dim1.data = np.array([0.0, 1.0, 2.0])
462
+ mock_signal.dims = [dim1]
463
+ mock_signal.units = "V"
464
+ mock_signal.time = Mock(label="time", data=np.array([0.0, 1.0, 2.0]))
465
+ mock_signal.errors = Mock(data=np.array([0.01, 0.01, 0.01]))
466
+
467
+ mock_client = Mock()
468
+ mock_client.get.return_value = mock_signal
469
+ mocker.patch("pyuda.Client", return_value=mock_client)
470
+ mocker.patch(
471
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
472
+ return_value="Signal",
473
+ )
474
+
475
+ # Shot 50000 is in the most-recent range -> canonical name used as-is
476
+ ds = xr.open_dataset("uda:///XSX/HCAM/L/CH01/DATA:50000", engine="uda")
477
+
478
+ mock_client.get.assert_called_once_with("/XSX/HCAM/L/CH01/DATA", 50000)
479
+ assert ds["data"].attrs["uda_name"] == "/XSX/HCAM/L/CH01/DATA"
480
+
481
+
482
+ def test_unmapped_signal_passes_through(mocker):
483
+ """A signal not present in the mappings should be forwarded as-is."""
484
+
485
+ fake_mappings = SignalMappings(mappings={})
486
+ mocker.patch("uda_xarray.main._MAPPINGS", fake_mappings)
487
+
488
+ mock_signal = Mock()
489
+ mock_signal.data = np.array([1.0, 2.0])
490
+ mock_signal.shape = (2,)
491
+ dim1 = Mock(label="time")
492
+ dim1.data = np.array([0.0, 1.0])
493
+ mock_signal.dims = [dim1]
494
+ mock_signal.units = "A"
495
+ mock_signal.time = Mock(label="time", data=np.array([0.0, 1.0]))
496
+ mock_signal.errors = Mock(data=np.array([0.1, 0.1]))
497
+
498
+ mock_client = Mock()
499
+ mock_client.get.return_value = mock_signal
500
+ mocker.patch("pyuda.Client", return_value=mock_client)
501
+ mocker.patch(
502
+ "uda_xarray.main.UDABackendEntrypoint._get_signal_type",
503
+ return_value="Signal",
504
+ )
505
+
506
+ ds = xr.open_dataset("uda://SOME_UNKNOWN_SIGNAL:30421", engine="uda")
507
+
508
+ mock_client.get.assert_called_once_with("SOME_UNKNOWN_SIGNAL", 30421)
509
+ assert ds["data"].attrs["uda_name"] == "SOME_UNKNOWN_SIGNAL"
510
+
511
+
512
+ def test_resolve_ignores_case_and_leading_slash():
513
+ mappings = SignalMappings(
514
+ mappings={
515
+ "/ESM/VLOOP/DYNAMIC": [
516
+ SignalRange(shot_min=1, shot_max=10, name="/ESM/VLOOP_DYNAMIC")
517
+ ]
518
+ }
519
+ )
520
+ for name in ("/ESM/VLOOP/DYNAMIC", "esm/vloop/dynamic", "/esm/vloop/dynamic"):
521
+ assert mappings.resolve(name, 5) == "/ESM/VLOOP_DYNAMIC"
522
+ assert mappings.resolve("esm/vloop/dynamic", 11) is None
523
+
524
+
525
+ def test_bundled_mastu_renames():
526
+ mappings = SignalMappings.from_file()
527
+ # Shot with only the old name in the catalogue.
528
+ assert mappings.resolve("esm/vloop/dynamic", 43477) == "/ESM/VLOOP_DYNAMIC"
529
+ assert mappings.resolve("esm/density/nebar", 43482) == "/ESM/NEBAR"
530
+ assert mappings.resolve("xdc/ai/cpu1/rogext_TF", 40240) == "/XDC/AI/CPU1/TF_CURRENT"
531
+ # Shots with the new name are not remapped.
532
+ assert mappings.resolve("esm/vloop/dynamic", 43478) is None
533
+ assert mappings.resolve("esm/vloop/dynamic", 52000) is None
534
+ assert mappings.resolve("xdc/ai/cpu1/rogext_TF", 40300) is None
@@ -0,0 +1,72 @@
1
+ """Live tests for the MAST-U signal mappings in ``mastu.json``.
2
+
3
+ These tests read real data, so they need a UDA server. ``conftest.py`` skips them when the
4
+ server cannot be reached (for example on CI). Run them from a machine with UDA access.
5
+ """
6
+
7
+ import pytest
8
+ import xarray as xr
9
+
10
+ from uda_xarray.mappings import SignalMappings
11
+
12
+ # Renamed signals. The key is the current name. A shot with the current name needs no mapping.
13
+ RENAMED = (
14
+ "/ESM/VLOOP/DYNAMIC",
15
+ "/ESM/VLOOP/STATIC",
16
+ "/ESM/DENSITY/NEBAR",
17
+ "/ESM/DENSITY/NG",
18
+ "/ESM/DENSITY/FG",
19
+ "/ESM/POWER/POHM",
20
+ "/XDC/AI/CPU1/ROGEXT_TF",
21
+ )
22
+ # Shots that have the current name of every signal in RENAMED, and are not in a mapped range.
23
+ # 47000 has both the old and the current names. 52002 is after the old names end (shot 51257).
24
+ CURRENT_NAME_SHOTS = (47000, 52002)
25
+ # Number of shots to read for each signal. Each read takes a few seconds.
26
+ SAMPLES_PER_SIGNAL = 5
27
+
28
+
29
+ pytestmark = pytest.mark.live
30
+
31
+ MAPPINGS = SignalMappings.from_file()
32
+
33
+
34
+ def _sample_shots(signal: str) -> list[int]:
35
+ """Return shots spread over the mapped ranges, including the first and the last."""
36
+ ranges = sorted(MAPPINGS.mappings[signal], key=lambda r: r.shot_min)
37
+ shots = [ranges[0].shot_min, ranges[-1].shot_max]
38
+ step = max(1, len(ranges) // (SAMPLES_PER_SIGNAL - 1))
39
+ shots += [r.shot_min for r in ranges[::step]]
40
+ return sorted(set(shots))[: SAMPLES_PER_SIGNAL + 1]
41
+
42
+
43
+ def _open(name: str, shot: int) -> xr.Dataset:
44
+ return xr.open_dataset(f"uda://{name}:{shot}", engine="uda")
45
+
46
+
47
+ def _cases() -> list[tuple[str, int]]:
48
+ return [(signal, shot) for signal in RENAMED for shot in _sample_shots(signal)]
49
+
50
+
51
+ @pytest.mark.parametrize(("signal", "shot"), _cases())
52
+ def test_mapped_shot_returns_data_under_old_name(signal, shot):
53
+ """The current name is read from the old name in a mapped shot."""
54
+ old_name = MAPPINGS.resolve(signal, shot)
55
+ assert old_name is not None
56
+
57
+ dataset = _open(signal.lstrip("/").lower(), shot)
58
+
59
+ assert dataset["data"].size > 0
60
+ assert dataset["data"].attrs["uda_name"] == old_name
61
+
62
+
63
+ @pytest.mark.parametrize("shot", CURRENT_NAME_SHOTS)
64
+ @pytest.mark.parametrize("signal", RENAMED)
65
+ def test_current_name_shot_is_not_remapped(signal, shot):
66
+ """A shot with the current name is not remapped, and still returns data."""
67
+ assert MAPPINGS.resolve(signal, shot) is None
68
+
69
+ dataset = _open(signal.lstrip("/").lower(), shot)
70
+
71
+ assert dataset["data"].size > 0
72
+ assert dataset["data"].attrs["uda_name"].lstrip("/").upper() == signal.lstrip("/")
@@ -0,0 +1 @@
1
+ """UDA backend for xarray."""