uda-xarray 0.2.2__tar.gz → 0.3.0__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.2
3
+ Version: 0.3.0
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>
@@ -16,6 +16,7 @@ License-File: LICENSE
16
16
  Requires-Dist: uda>=2.9.2
17
17
  Requires-Dist: uda-mast
18
18
  Requires-Dist: xarray
19
+ Requires-Dist: pydantic>=2.0
19
20
  Dynamic: license-file
20
21
 
21
22
  # uda-xarray
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "uda-xarray"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "UDA backend for xarray"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9, <3.13"
@@ -23,11 +23,15 @@ dependencies = [
23
23
  "uda>=2.9.2",
24
24
  "uda-mast",
25
25
  "xarray",
26
+ "pydantic>=2.0",
26
27
  ]
27
28
 
28
29
  [tool.setuptools]
29
30
  packages = ["uda_xarray"]
30
31
 
32
+ [tool.setuptools.package-data]
33
+ uda_xarray = ["*.json"]
34
+
31
35
  [project.urls]
32
36
  repository = "https://github.com/samueljackson92/uda-xarray"
33
37
 
@@ -47,7 +51,7 @@ dev = [
47
51
  uda = "uda_xarray.main:UDABackendEntrypoint"
48
52
 
49
53
  [tool.bumpversion]
50
- current_version = "0.2.2"
54
+ current_version = "0.3.0"
51
55
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
52
56
  serialize = ["{major}.{minor}.{patch}"]
53
57
  search = "{current_version}"
@@ -1,11 +1,17 @@
1
- import pytest
2
- import xarray as xr
1
+ """Tests for the UDA xarray backend."""
2
+
3
+ from unittest.mock import Mock
4
+
3
5
  import numpy as np
4
6
  import pyuda
5
- from unittest.mock import Mock
7
+ import pytest
8
+ import xarray as xr
9
+
10
+ from uda_xarray.mappings import SignalMappings, SignalRange
6
11
 
7
12
 
8
13
  def test_open_uda_dataset(mocker):
14
+ """Test loading a 1D UDA signal as an xarray Dataset."""
9
15
  # Create mock signal object
10
16
  mock_signal = Mock()
11
17
  mock_signal.data = np.array([1.0, 2.0, 3.0])
@@ -45,6 +51,7 @@ def test_open_uda_dataset(mocker):
45
51
 
46
52
 
47
53
  def test_open_uda_dataset_2d(mocker):
54
+ """Test loading a 2D UDA signal as an xarray Dataset."""
48
55
  # Create mock 2D signal object
49
56
  mock_signal = Mock()
50
57
  mock_signal.data = np.array([[1.0, 2.0], [3.0, 4.0]])
@@ -81,6 +88,7 @@ def test_open_uda_dataset_2d(mocker):
81
88
 
82
89
 
83
90
  def test_open_uda_dataset_video(mocker):
91
+ """Test loading a UDA video signal as an xarray Dataset."""
84
92
  mock_signal = Mock()
85
93
  mock_signal.is_color = False
86
94
  mock_signal.frame_times = np.array([0.0, 0.033, 0.066])
@@ -115,6 +123,7 @@ def test_open_uda_dataset_video(mocker):
115
123
 
116
124
 
117
125
  def test_open_uda_dataset_invalid_signal(mocker):
126
+ """Test that an invalid UDA signal raises a RuntimeError."""
118
127
  # Mock the pyuda Client to raise an exception
119
128
  mock_client = Mock()
120
129
  mock_client.get.side_effect = pyuda.ServerException("Signal not found")
@@ -133,6 +142,7 @@ def test_open_uda_dataset_invalid_signal(mocker):
133
142
 
134
143
 
135
144
  def test_open_uda_dataset_invalid_format():
145
+ """Test that an invalid UDA URL format raises a ValueError."""
136
146
  try:
137
147
  xr.open_dataset("invalid_format", engine="uda")
138
148
  except ValueError as e:
@@ -152,6 +162,7 @@ def test_open_uda_dataset_invalid_format():
152
162
 
153
163
 
154
164
  def test_open_datatree(mocker):
165
+ """Test loading a 1D UDA signal as an xarray DataTree."""
155
166
  # Create mock signal object
156
167
  mock_signal = Mock()
157
168
  mock_signal.data = np.array([1.0, 2.0, 3.0])
@@ -195,6 +206,7 @@ def test_open_datatree(mocker):
195
206
 
196
207
 
197
208
  def test_open_datatree_2d(mocker):
209
+ """Test loading a 2D UDA signal as an xarray DataTree."""
198
210
  # Create mock 2D signal object
199
211
  mock_signal = Mock()
200
212
  mock_signal.data = np.array([[1.0, 2.0], [3.0, 4.0]])
@@ -236,6 +248,7 @@ def test_open_datatree_2d(mocker):
236
248
 
237
249
 
238
250
  def test_open_datatree_video(mocker):
251
+ """Test loading a UDA video signal as an xarray DataTree."""
239
252
  mock_signal = Mock()
240
253
  mock_signal.is_color = False
241
254
  mock_signal.frame_times = np.array([0.0, 0.033, 0.066])
@@ -276,7 +289,8 @@ def test_open_datatree_video(mocker):
276
289
 
277
290
 
278
291
  @pytest.mark.skip(reason="Requires access to real UDA server with video data")
279
- def test_open_uda_dataset_video_frame(mocker):
292
+ def test_open_uda_dataset_video_frame():
293
+ """Test loading a single video frame from a real UDA server."""
280
294
  ds = xr.open_dataset(
281
295
  "uda://rba:30421", engine="uda", drop_variables=None, frame_number=1
282
296
  )
@@ -288,8 +302,208 @@ def test_open_uda_dataset_video_frame(mocker):
288
302
 
289
303
  @pytest.mark.skip(reason="Requires access to real UDA server with video data")
290
304
  def test_real_data_2d():
305
+ """Test loading real 2D data from a live UDA server."""
291
306
  ds = xr.open_dataset("uda://AYE_TE:30421", engine="uda")
292
307
  assert ds["data"].name == "data"
293
308
  assert ds["data"].dims == ("time", "radial_index")
294
309
  assert "time" in ds.coords
295
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"
@@ -0,0 +1 @@
1
+ """UDA backend for xarray."""
@@ -10,6 +10,10 @@ import xarray as xr
10
10
  from mast.mast_client import ListType
11
11
  from xarray.backends import BackendEntrypoint
12
12
 
13
+ from uda_xarray.mappings import SignalMappings
14
+
15
+ _MAPPINGS = SignalMappings.from_file()
16
+
13
17
 
14
18
  # Set up UDA environment variables with defaults if not already set. This is required for
15
19
  # the pyuda client to work correctly outside of Freia.
@@ -40,6 +44,7 @@ class UDABackendEntrypoint(BackendEntrypoint):
40
44
  *,
41
45
  drop_variables=None,
42
46
  frame_number: Optional[int] = None, # noqa: F821
47
+ **kwargs,
43
48
  ) -> xr.Dataset:
44
49
  """Open a UDA dataset given a signal name and shot number.
45
50
 
@@ -65,6 +70,8 @@ class UDABackendEntrypoint(BackendEntrypoint):
65
70
  name = name.replace("uda://", "")
66
71
  shot = int(shot)
67
72
 
73
+ name = _MAPPINGS.resolve(name, shot) or name
74
+
68
75
  client = pyuda.Client()
69
76
  signal_type = self._get_signal_type(client, name, shot)
70
77
 
@@ -161,8 +168,10 @@ class UDABackendEntrypoint(BackendEntrypoint):
161
168
  return SignalType.IMAGE
162
169
  return SignalType.SIGNAL
163
170
 
164
- def open_datatree(self, filename_or_obj, *, drop_variables=None):
165
- dataset = self.open_dataset(filename_or_obj, drop_variables=drop_variables)
171
+ def open_datatree(self, filename_or_obj, *, drop_variables=None, **kwargs):
172
+ dataset = self.open_dataset(
173
+ filename_or_obj, drop_variables=drop_variables, **kwargs
174
+ )
166
175
  return xr.DataTree(dataset)
167
176
 
168
177
  def open_groups_as_dict(self, filename_or_obj, **kwargs):
@@ -0,0 +1,78 @@
1
+ """Signal name mappings for the UDA xarray backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import json
7
+ from typing import Iterator
8
+
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class SignalRange(BaseModel):
13
+ """A single shot-range entry mapping a canonical signal name to the name
14
+ used in the data store for that range of shots."""
15
+
16
+ shot_min: int
17
+ shot_max: int
18
+ name: str
19
+
20
+ def contains(self, shot: int) -> bool:
21
+ """Return True if *shot* falls within this range (inclusive)."""
22
+ return self.shot_min <= shot <= self.shot_max
23
+
24
+
25
+ class SignalMappings(BaseModel):
26
+ """Complete signal-alias mapping table loaded from mast.json.
27
+
28
+ The mapping is a dict whose keys are the canonical (most recent) signal
29
+ names and whose values are lists of :class:`SignalRange` entries ordered
30
+ from newest to oldest.
31
+ """
32
+
33
+ mappings: dict[str, list[SignalRange]]
34
+
35
+ @classmethod
36
+ def from_file(cls, path: str | None = None) -> "SignalMappings":
37
+ """Load mappings from *path*.
38
+
39
+ When *path* is ``None`` all bundled ``*.json`` mapping files are
40
+ loaded and merged into a single :class:`SignalMappings` instance.
41
+ """
42
+ if path is None:
43
+ merged: dict[str, list] = {}
44
+ pkg = importlib.resources.files("uda_xarray")
45
+ for resource in pkg.iterdir():
46
+ if resource.name.endswith(".json"):
47
+ with importlib.resources.as_file(resource) as p:
48
+ for k, v in json.loads(p.read_text(encoding="utf-8")).items():
49
+ if k in merged:
50
+ merged[k].extend(v)
51
+ else:
52
+ merged[k] = list(v)
53
+ raw = merged
54
+ else:
55
+ with open(path, encoding="utf-8") as f:
56
+ raw = json.load(f)
57
+
58
+ return cls(mappings={k: [SignalRange(**r) for r in v] for k, v in raw.items()})
59
+
60
+ def resolve(self, signal: str, shot: int) -> str | None:
61
+ """Return the stored name for *signal* at the given *shot* number.
62
+
63
+ Returns *None* if *signal* is not in the mapping or no range covers
64
+ the shot.
65
+ """
66
+ ranges = self.mappings.get(signal)
67
+ if ranges is None:
68
+ return None
69
+ for r in ranges:
70
+ if r.contains(shot):
71
+ return r.name
72
+ return None
73
+
74
+ def __iter__(self) -> Iterator[tuple[str, list[SignalRange]]]:
75
+ return iter(self.mappings.items())
76
+
77
+ def __len__(self) -> int:
78
+ return len(self.mappings)