uda-xarray 0.3.0__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.3.0
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,8 +9,10 @@ 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
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "uda-xarray"
3
- version = "0.3.0"
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
 
@@ -51,7 +53,7 @@ dev = [
51
53
  uda = "uda_xarray.main:UDABackendEntrypoint"
52
54
 
53
55
  [tool.bumpversion]
54
- current_version = "0.3.0"
56
+ current_version = "0.3.1"
55
57
  parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
56
58
  serialize = ["{major}.{minor}.{patch}"]
57
59
  search = "{current_version}"
@@ -288,7 +288,7 @@ def test_open_datatree_video(mocker):
288
288
  assert ds.sizes["time"] == 3
289
289
 
290
290
 
291
- @pytest.mark.skip(reason="Requires access to real UDA server with video data")
291
+ @pytest.mark.live
292
292
  def test_open_uda_dataset_video_frame():
293
293
  """Test loading a single video frame from a real UDA server."""
294
294
  ds = xr.open_dataset(
@@ -300,14 +300,14 @@ def test_open_uda_dataset_video_frame():
300
300
  assert ds.sizes["time"] == 1
301
301
 
302
302
 
303
- @pytest.mark.skip(reason="Requires access to real UDA server with video data")
303
+ @pytest.mark.live
304
304
  def test_real_data_2d():
305
305
  """Test loading real 2D data from a live UDA server."""
306
306
  ds = xr.open_dataset("uda://AYE_TE:30421", engine="uda")
307
307
  assert ds["data"].name == "data"
308
- assert ds["data"].dims == ("time", "radial_index")
308
+ assert ds["data"].dims == ("time", "radial index")
309
309
  assert "time" in ds.coords
310
- assert "radial_index" in ds.coords
310
+ assert "radial index" in ds.coords
311
311
 
312
312
 
313
313
  def test_remapped_signal_uses_legacy_name(mocker):
@@ -507,3 +507,28 @@ def test_unmapped_signal_passes_through(mocker):
507
507
 
508
508
  mock_client.get.assert_called_once_with("SOME_UNKNOWN_SIGNAL", 30421)
509
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("/")
@@ -6,7 +6,15 @@ import importlib.resources
6
6
  import json
7
7
  from typing import Iterator
8
8
 
9
- from pydantic import BaseModel
9
+ from pydantic import BaseModel, PrivateAttr
10
+
11
+
12
+ def _normalise(name: str) -> str:
13
+ """Return *name* without a leading slash and in upper case.
14
+
15
+ UDA matches signal names without regard to case, and with or without the leading slash.
16
+ """
17
+ return name.lstrip("/").upper()
10
18
 
11
19
 
12
20
  class SignalRange(BaseModel):
@@ -31,6 +39,7 @@ class SignalMappings(BaseModel):
31
39
  """
32
40
 
33
41
  mappings: dict[str, list[SignalRange]]
42
+ _index: dict[str, list[SignalRange]] | None = PrivateAttr(default=None)
34
43
 
35
44
  @classmethod
36
45
  def from_file(cls, path: str | None = None) -> "SignalMappings":
@@ -63,7 +72,7 @@ class SignalMappings(BaseModel):
63
72
  Returns *None* if *signal* is not in the mapping or no range covers
64
73
  the shot.
65
74
  """
66
- ranges = self.mappings.get(signal)
75
+ ranges = self._normalised().get(_normalise(signal))
67
76
  if ranges is None:
68
77
  return None
69
78
  for r in ranges:
@@ -71,6 +80,15 @@ class SignalMappings(BaseModel):
71
80
  return r.name
72
81
  return None
73
82
 
83
+ def _normalised(self) -> dict[str, list[SignalRange]]:
84
+ """Return the mappings keyed by normalised signal name (built once)."""
85
+ if self._index is None:
86
+ index: dict[str, list[SignalRange]] = {}
87
+ for key, ranges in self.mappings.items():
88
+ index.setdefault(_normalise(key), []).extend(ranges)
89
+ self._index = index
90
+ return self._index
91
+
74
92
  def __iter__(self) -> Iterator[tuple[str, list[SignalRange]]]:
75
93
  return iter(self.mappings.items())
76
94