xarray-ms 0.4.0a2__tar.gz → 0.4.0a4__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.
Files changed (71) hide show
  1. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.github/workflows/ci.yml +2 -23
  2. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/PKG-INFO +1 -1
  3. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/changelog.rst +8 -0
  4. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/conf.py +1 -1
  5. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/pyproject.toml +2 -2
  6. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_antenna.py +59 -0
  7. xarray_ms-0.4.0a4/tests/test_table_utils.py +89 -0
  8. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_write.py +10 -10
  9. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/factories/antenna.py +5 -1
  10. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/factories/correlated.py +2 -1
  11. xarray_ms-0.4.0a4/xarray_ms/backend/msv2/table_utils.py +53 -0
  12. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/writes.py +124 -126
  13. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/errors.py +5 -0
  14. xarray_ms-0.4.0a2/xarray_ms/backend/msv2/table_utils.py +0 -19
  15. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.github/ISSUE_TEMPLATE.md +0 -0
  16. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.github/dependabot.yml +0 -0
  18. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.github/workflows/pre-commit.yml +0 -0
  19. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.github/workflows/readthedocs.yml +0 -0
  20. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.gitignore +0 -0
  21. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.pre-commit-config.yaml +0 -0
  22. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/.readthedocs.yaml +0 -0
  23. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/LICENSE +0 -0
  24. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/README.rst +0 -0
  25. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/Makefile +0 -0
  26. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/make.bat +0 -0
  27. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/api.rst +0 -0
  28. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/index.rst +0 -0
  29. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/install.rst +0 -0
  30. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/introduction.rst +0 -0
  31. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/links.rst +0 -0
  32. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/partitioning.rst +0 -0
  33. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/roadmap.rst +0 -0
  34. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/doc/source/tutorial.rst +0 -0
  35. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/hello.txt +0 -0
  36. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/__init__.py +0 -0
  37. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/conftest.py +0 -0
  38. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/msv4_test_corpus/__init__.py +0 -0
  39. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/msv4_test_corpus/conftest.py +0 -0
  40. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/msv4_test_corpus/test_msv_corpus.py +0 -0
  41. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_backend.py +0 -0
  42. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_basic.py +0 -0
  43. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_field_and_source.py +0 -0
  44. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_github.py +0 -0
  45. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_imputation.py +0 -0
  46. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_measures.py +0 -0
  47. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_multiton.py +0 -0
  48. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_read.py +0 -0
  49. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_structure.py +0 -0
  50. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_utils.py +0 -0
  51. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/tests/test_zarr_roundtrip.py +0 -0
  52. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/__init__.py +0 -0
  53. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/array.py +0 -0
  54. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/entrypoint.py +0 -0
  55. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/entrypoint_utils.py +0 -0
  56. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/factories/__init__.py +0 -0
  57. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/factories/core.py +0 -0
  58. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/factories/field_and_source.py +0 -0
  59. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/imputation.py +0 -0
  60. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/measures_adapters.py +0 -0
  61. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/measures_encoders.py +0 -0
  62. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/partition.py +0 -0
  63. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/backend/msv2/structure.py +0 -0
  64. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/casa_types.py +0 -0
  65. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/msv4_types.py +0 -0
  66. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/multiton.py +0 -0
  67. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/query.py +0 -0
  68. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/testing/__init__.py +0 -0
  69. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/testing/simulator.py +0 -0
  70. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/testing/utils.py +0 -0
  71. {xarray_ms-0.4.0a2 → xarray_ms-0.4.0a4}/xarray_ms/utils.py +0 -0
@@ -29,29 +29,8 @@ jobs:
29
29
  strategy:
30
30
  fail-fast: false
31
31
  matrix:
32
- os: ["ubuntu-22.04", "macos-14", "macos-15"]
33
- python-version: ["3.11", "3.12", "3.13"]
34
- is_main_or_release:
35
- - ${{ contains(github.ref, 'main') || startsWith(github.ref, 'refs/tags')}}
36
- exclude:
37
- # Don't test on macos-{13,14} in PR's
38
- - is_main_or_release: false
39
- os: "macos-14"
40
- # Don't test on 3.11 - 3.12 in PR's
41
- - is_main_or_release: false
42
- python-version: "3.11"
43
- - is_main_or_release: false
44
- python-version: "3.12"
45
- # Don't test on macos on 3.11 - 3.12 in PRs
46
- - os: "macos-14"
47
- python-version: "3.11"
48
- - os: "macos-14"
49
- python-version: "3.12"
50
- - os: "macos-15"
51
- python-version: "3.11"
52
- - os: "macos-15"
53
- python-version: "3.12"
54
-
32
+ os: ["ubuntu-22.04", "macos-13", "macos-14", "macos-15"]
33
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
55
34
 
56
35
  steps:
57
36
  - name: Set up Python ${{ matrix.python-version }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xarray-ms
3
- Version: 0.4.0a2
3
+ Version: 0.4.0a4
4
4
  Summary: xarray MSv4 views over MSv2 Measurement Sets
5
5
  Author-email: Simon Perkins <simon.perkins@gmail.com>
6
6
  License-File: LICENSE
@@ -3,6 +3,14 @@
3
3
  Changelog
4
4
  =========
5
5
 
6
+ X.Y.Z (DD-MM-YYYY)
7
+ ------------------
8
+ * Handle duplicate antenna name (:pr:`151`)
9
+ * Deprecate python 3.10 support (:pr:`150`)
10
+ * Add python 3.14 to CI testing matrix (:pr:`150`)
11
+ * Test more comprehensively in pull requests (:pr:`150`)
12
+ * Depend on arcae ``>= 0.5.1, < 0.6.0`` (:pr:`149`)
13
+
6
14
  0.5.1 (06-03-2026)
7
15
  ------------------
8
16
  * Correct ``BroadcastMSv2Array`` initialisation of ``MSv2Array`` base class
@@ -11,7 +11,7 @@
11
11
  project = "xarray-ms"
12
12
  copyright = "2024 - 2025 NRF (SARAO) and Rhodes University (RATT) Centre"
13
13
  author = "Simon Perkins"
14
- release = "0.4.0-alpha.2"
14
+ release = "0.4.0-alpha.4"
15
15
 
16
16
  # -- General configuration ---------------------------------------------------
17
17
  # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xarray-ms"
3
- version = "0.4.0-alpha.2"
3
+ version = "0.4.0-alpha.4"
4
4
  description = "xarray MSv4 views over MSv2 Measurement Sets"
5
5
  authors = [{name = "Simon Perkins", email = "simon.perkins@gmail.com"}]
6
6
  readme = "README.rst"
@@ -55,7 +55,7 @@ extend-select = ["I"]
55
55
  # github_url = "https://github.com/<user or organization>/<project>/"
56
56
 
57
57
  [tool.tbump.version]
58
- current = "0.4.0-alpha.2"
58
+ current = "0.4.0-alpha.4"
59
59
 
60
60
  # https://semver.org/#spec-item-9
61
61
  regex = '''
@@ -4,6 +4,8 @@ import pytest
4
4
  import xarray
5
5
  from arcae.lib.arrow_tables import Table, ms_descriptor
6
6
 
7
+ from xarray_ms.errors import DuplicateAntennaNameWarning
8
+
7
9
  NANTENNA = 6
8
10
 
9
11
 
@@ -138,3 +140,60 @@ def test_antenna_feed_join(simmed_ms, auto_corrs):
138
140
  npt.assert_array_equal(
139
141
  p1["antenna_xds"].antenna_name, np.array(["ANTENNA-0", "ANTENNA-2"], dtype=object)
140
142
  )
143
+
144
+
145
+ @pytest.mark.parametrize(
146
+ "simmed_ms",
147
+ [{"name": "dup.ms", "nantenna": 3, "auto_corrs": True}],
148
+ indirect=True,
149
+ )
150
+ def test_duplicate_antenna_names(simmed_ms):
151
+ """When ANTENNA::NAME contains duplicates, names are made unique and a
152
+ warning is emitted. The names in antenna_xds and the baseline coordinate
153
+ must be consistent with each other."""
154
+
155
+ # Overwrite ANTENNA::NAME so that antenna 0 and antenna 1 share a name.
156
+ with Table.from_filename(f"{simmed_ms}::ANTENNA") as T:
157
+ T.putcol("NAME", np.asarray(["SAME", "SAME", "OTHER"]))
158
+
159
+ with pytest.warns(DuplicateAntennaNameWarning, match="SAME"):
160
+ dt = xarray.open_datatree(simmed_ms, auto_corrs=True).load()
161
+
162
+ partition = dt[list(dt.children)[0]]
163
+ antenna_xds = partition["antenna_xds"]
164
+
165
+ # All antenna_name coordinate values must be unique
166
+ ant_names = antenna_xds.antenna_name.values
167
+ assert len(ant_names) == len(set(ant_names)), "antenna_name coordinate has duplicates"
168
+
169
+ # Duplicated base names get -N suffixes; unique names are unchanged
170
+ assert "SAME-1" in ant_names
171
+ assert "SAME-2" in ant_names
172
+ assert "OTHER" in ant_names
173
+
174
+ # correlated dataset baseline_antenna{i}_name maps
175
+ # appropriately to the antenna dataset antenna names.
176
+ concat_ant_names = np.concatenate(
177
+ [partition.baseline_antenna1_name, partition.baseline_antenna2_name]
178
+ )
179
+ unique_ant_names, inv = np.unique(concat_ant_names, return_inverse=True)
180
+ _, lookup = np.where(unique_ant_names[:, None] == ant_names[None, :])
181
+ ant1 = lookup[inv[: len(inv) // 2]]
182
+ ant2 = lookup[inv[len(inv) // 2 :]]
183
+
184
+ np.testing.assert_array_equal(partition.baseline_antenna1_name, ant_names[ant1])
185
+ np.testing.assert_array_equal(partition.baseline_antenna2_name, ant_names[ant2])
186
+
187
+
188
+ @pytest.mark.parametrize(
189
+ "simmed_ms",
190
+ [{"name": "nodup.ms", "nantenna": 3, "auto_corrs": True}],
191
+ indirect=True,
192
+ )
193
+ def test_unique_antenna_names_no_warning(simmed_ms):
194
+ """When all antenna names are already unique no warning should be emitted."""
195
+ import warnings
196
+
197
+ with warnings.catch_warnings():
198
+ warnings.simplefilter("error", DuplicateAntennaNameWarning)
199
+ xarray.open_datatree(simmed_ms, auto_corrs=True).load()
@@ -0,0 +1,89 @@
1
+ import warnings
2
+
3
+ import numpy as np
4
+ import numpy.testing as npt
5
+ import pytest
6
+
7
+ from xarray_ms.backend.msv2.table_utils import unique_antenna_names
8
+ from xarray_ms.errors import DuplicateAntennaNameWarning
9
+
10
+
11
+ def test_all_unique_no_change():
12
+ names = np.array(["A", "B", "C"])
13
+ result = unique_antenna_names(names)
14
+ npt.assert_array_equal(result, names)
15
+
16
+
17
+ def test_all_unique_no_warning():
18
+ names = np.array(["A", "B", "C"])
19
+ with warnings.catch_warnings():
20
+ warnings.simplefilter("error", DuplicateAntennaNameWarning)
21
+ unique_antenna_names(names) # should not raise
22
+
23
+
24
+ def test_two_duplicates():
25
+ names = np.array(["A", "A", "B"])
26
+ with pytest.warns(DuplicateAntennaNameWarning):
27
+ result = unique_antenna_names(names)
28
+ npt.assert_array_equal(result, ["A-1", "A-2", "B"])
29
+
30
+
31
+ def test_triple_duplicate():
32
+ names = np.array(["A", "A", "A"])
33
+ with pytest.warns(DuplicateAntennaNameWarning):
34
+ result = unique_antenna_names(names)
35
+ npt.assert_array_equal(result, ["A-1", "A-2", "A-3"])
36
+
37
+
38
+ def test_non_contiguous_duplicates():
39
+ names = np.array(["A", "B", "A", "C", "A"])
40
+ with pytest.warns(DuplicateAntennaNameWarning):
41
+ result = unique_antenna_names(names)
42
+ npt.assert_array_equal(result, ["A-1", "B", "A-2", "C", "A-3"])
43
+
44
+
45
+ def test_unique_name_unchanged_when_mixed():
46
+ names = np.array(["X", "Y", "X"])
47
+ with pytest.warns(DuplicateAntennaNameWarning):
48
+ result = unique_antenna_names(names)
49
+ npt.assert_array_equal(result, ["X-1", "Y", "X-2"])
50
+
51
+
52
+ def test_underscore_in_base_name():
53
+ """Dash separator avoids collision with names already containing underscores."""
54
+ names = np.array(["ANTENNA_1", "ANTENNA_1"])
55
+ with pytest.warns(DuplicateAntennaNameWarning):
56
+ result = unique_antenna_names(names)
57
+ npt.assert_array_equal(result, ["ANTENNA_1-1", "ANTENNA_1-2"])
58
+
59
+
60
+ def test_empty_array():
61
+ names = np.array([], dtype=str)
62
+ result = unique_antenna_names(names)
63
+ npt.assert_array_equal(result, [])
64
+
65
+
66
+ def test_single_element():
67
+ names = np.array(["A"])
68
+ result = unique_antenna_names(names)
69
+ npt.assert_array_equal(result, ["A"])
70
+
71
+
72
+ def test_warning_raised_on_duplicates():
73
+ names = np.array(["A", "A", "B"])
74
+ with pytest.warns(DuplicateAntennaNameWarning, match="Duplicate antenna names"):
75
+ unique_antenna_names(names)
76
+
77
+
78
+ def test_warning_lists_duplicate_names():
79
+ names = np.array(["FOO", "BAR", "FOO"])
80
+ with pytest.warns(DuplicateAntennaNameWarning, match="FOO"):
81
+ unique_antenna_names(names)
82
+
83
+
84
+ def test_result_is_always_unique():
85
+ """Output names must all be distinct regardless of input."""
86
+ names = np.array(["A", "A", "B", "B", "C"])
87
+ with pytest.warns(DuplicateAntennaNameWarning):
88
+ result = unique_antenna_names(names)
89
+ assert len(set(result.tolist())) == len(result)
@@ -31,8 +31,8 @@ def test_store(simmed_ms):
31
31
  xdt[node.path] = DataTree(ds)
32
32
  assert len(node.encoding) > 0
33
33
 
34
- xdt.sync_msv2(["CORRECTED"])
35
- xdt.to_msv2(["UVW", "CORRECTED"])
34
+ xdt.sync_msv2()
35
+ xdt.to_msv2()
36
36
  written = written or True
37
37
 
38
38
  with xarray.open_datatree(simmed_ms, auto_corrs=True) as xdt:
@@ -64,7 +64,7 @@ def test_store_region(simmed_ms):
64
64
  assert len(node.encoding) > 0
65
65
 
66
66
  # Create the new MS columns
67
- xdt.sync_msv2("CORRECTED")
67
+ xdt.sync_msv2()
68
68
 
69
69
  for node in xdt.subtree:
70
70
  if node.attrs.get("type") in CORRELATED_DATASET_TYPES:
@@ -72,7 +72,7 @@ def test_store_region(simmed_ms):
72
72
  ds = ds.isel(**region)
73
73
  ds = ds.assign(CORRECTED=xarray.full_like(ds.CORRECTED, 1 + 2j))
74
74
  # Now write it out
75
- ds.to_msv2(["CORRECTED"], compute=False, region=region)
75
+ ds.to_msv2(compute=False, region=region)
76
76
 
77
77
  # We can check that CORRECTED has been written correctly
78
78
  with arcae.table(simmed_ms) as T:
@@ -115,12 +115,12 @@ def test_distributed_write(simmed_ms, processes, nworkers, chunks):
115
115
  assert len(node.encoding) > 0
116
116
 
117
117
  # Create the new MS columns
118
- dt.sync_msv2("CORRECTED")
119
- dt.to_msv2(["CORRECTED"], compute=False)
118
+ dt.sync_msv2()
119
+ dt.to_msv2(compute=False)
120
120
 
121
121
  for node in dt.subtree:
122
122
  if node.attrs.get("type") in CORRELATED_DATASET_TYPES:
123
- node.ds.to_msv2(["CORRECTED"], compute=True)
123
+ node.ds.to_msv2(compute=True)
124
124
 
125
125
  with arcae.table(simmed_ms) as T:
126
126
  corrected = T.getcol("CORRECTED")
@@ -144,11 +144,11 @@ def test_indexed_write(simmed_ms):
144
144
  ds = node.ds.assign(CORRECTED=xarray.full_like(node.VISIBILITY, 1 + 2j))
145
145
  dt[node.path] = DataTree(ds)
146
146
 
147
- dt.sync_msv2("CORRECTED")
148
- dt.to_msv2(["CORRECTED"], compute=False)
147
+ dt.sync_msv2()
148
+ dt.to_msv2(compute=False)
149
149
 
150
150
  for node in dt.subtree:
151
151
  if node.attrs.get("type") in CORRELATED_DATASET_TYPES:
152
152
  ds = node.ds.isel(time=slice(0, 2), baseline_id=slice(0, 2), frequency=1)
153
153
  with pytest.raises(MismatchedWriteRegion):
154
- ds.to_msv2(["CORRECTED"], compute=True)
154
+ ds.to_msv2(compute=True)
@@ -4,6 +4,7 @@ from xarray import Dataset, Variable
4
4
  from xarray_ms.backend.msv2.factories.core import DatasetFactory
5
5
  from xarray_ms.backend.msv2.imputation import maybe_impute_observation_table
6
6
  from xarray_ms.backend.msv2.measures_encoders import MSv2CoderFactory
7
+ from xarray_ms.backend.msv2.table_utils import unique_antenna_names
7
8
  from xarray_ms.errors import InvalidMeasurementSet
8
9
 
9
10
  RELOCATABLE_ARRAY = {"ALMA", "VLA", "NOEMA", "EVLA"}
@@ -53,7 +54,10 @@ class AntennaFactory(DatasetFactory):
53
54
  )
54
55
 
55
56
  ant_coder_factory = MSv2CoderFactory.from_arrow_table(filtered_ants)
56
- antenna_names = filtered_ants["NAME"].to_numpy().astype(str)
57
+ # Deduplicate against the full ANTENNA table so suffix assignments are
58
+ # consistent with those produced in the correlated dataset factory.
59
+ all_ant_names = unique_antenna_names(ants["NAME"].to_numpy().astype(str))
60
+ antenna_names = all_ant_names[feed_ant_id[mask]]
57
61
  telescope_names = np.asarray([telescope_name] * len(antenna_names), dtype=str)
58
62
  position = pac.list_flatten(filtered_ants["POSITION"]).to_numpy().reshape(-1, 3)
59
63
  diameter = filtered_ants["DISH_DIAMETER"].to_numpy()
@@ -26,6 +26,7 @@ from xarray_ms.backend.msv2.measures_encoders import (
26
26
  VisibilityCoder,
27
27
  )
28
28
  from xarray_ms.backend.msv2.structure import MSv2StructureFactory, PartitionKeyT
29
+ from xarray_ms.backend.msv2.table_utils import unique_antenna_names
29
30
  from xarray_ms.casa_types import ColumnDesc, Polarisations
30
31
  from xarray_ms.errors import (
31
32
  ColumnShapeImputationWarning,
@@ -266,7 +267,7 @@ class CorrelatedFactory(DatasetFactory):
266
267
  assert (partition.nbl,) == ant1.shape
267
268
 
268
269
  antenna = self._subtable_factories["ANTENNA"].instance
269
- ant_names = antenna["NAME"].to_numpy().astype(str)
270
+ ant_names = unique_antenna_names(antenna["NAME"].to_numpy().astype(str))
270
271
  ant1_names = ant_names[ant1]
271
272
  ant2_names = ant_names[ant2]
272
273
 
@@ -0,0 +1,53 @@
1
+ import json
2
+ import warnings
3
+ from typing import Any, Dict
4
+
5
+ import numpy as np
6
+ import pyarrow as pa
7
+
8
+ from xarray_ms.errors import DuplicateAntennaNameWarning
9
+
10
+
11
+ def unique_antenna_names(names: np.ndarray) -> np.ndarray:
12
+ """Return antenna names with duplicates made unique by appending -N suffixes.
13
+
14
+ All occurrences of a duplicated name are renamed: the first gets ``-1``,
15
+ the second ``-2``, and so on. Names that are already unique are left
16
+ unchanged. A :class:`~xarray_ms.errors.DuplicateAntennaNameWarning` is
17
+ emitted when any renaming is performed.
18
+ """
19
+ unique, counts = np.unique(names, return_counts=True)
20
+ if not (duplicates := set(unique[counts > 1].tolist())):
21
+ return names
22
+
23
+ warnings.warn(
24
+ f"Duplicate antenna names detected in the ANTENNA table: "
25
+ f"{sorted(duplicates)}. "
26
+ f"A numeric suffix will be appended to make names unique.",
27
+ DuplicateAntennaNameWarning,
28
+ stacklevel=2,
29
+ )
30
+ # Build as a Python list to avoid numpy fixed-width string truncation,
31
+ # then convert back to an array with a wide enough dtype.
32
+ result = names.tolist()
33
+ counters: Dict[str, int] = {}
34
+ for i, name in enumerate(result):
35
+ if name in duplicates:
36
+ counters[name] = counters.get(name, 0) + 1
37
+ result[i] = f"{name}-{counters[name]}"
38
+ return np.array(result, dtype=str)
39
+
40
+
41
+ def extract_table_desc(table: pa.Table) -> Dict[str, Any]:
42
+ """Extract the CASA table descriptor stored in an Arrow table constructed by arcae"""
43
+ try:
44
+ arcae_metadata = table.schema.metadata[b"__arcae_metadata__"]
45
+ except KeyError:
46
+ raise KeyError("__arcae_metadata__ was not present in the table metadata")
47
+
48
+ try:
49
+ return json.loads(arcae_metadata)["__casa_descriptor__"]
50
+ except KeyError:
51
+ raise KeyError(
52
+ f"arcae metadata {arcae_metadata} does not contain a __casa_descriptor__"
53
+ )
@@ -1,7 +1,8 @@
1
1
  import warnings
2
2
  from collections import defaultdict
3
+ from dataclasses import dataclass
3
4
  from importlib.metadata import version as package_version
4
- from typing import Any, Dict, Iterable, List, Literal, Mapping, Set, Tuple
5
+ from typing import Any, Dict, List, Literal, Mapping, Set, Tuple
5
6
 
6
7
  import numpy as np
7
8
  import numpy.typing as npt
@@ -25,7 +26,40 @@ else:
25
26
 
26
27
  ShapeSetType = Set[Tuple[int, ...]]
27
28
  DTypeSetType = Set[npt.DTypeLike]
28
- ShapeAndDTypeType = Dict[Tuple[str, str], Tuple[ShapeSetType, DTypeSetType]]
29
+
30
+
31
+ @dataclass
32
+ class DataVariableInfo:
33
+ counts: int
34
+ shapes: ShapeSetType
35
+ dtypes: DTypeSetType
36
+
37
+
38
+ DataVariableInfoMap = Dict[Tuple[str, str], DataVariableInfo]
39
+
40
+
41
+ MSV4_WRITE_MAP = {
42
+ "UVW": "UVW",
43
+ "VISIBILITY": "DATA",
44
+ "WEIGHT": "WEIGHT_SPECTRUM",
45
+ "FLAG": "FLAG",
46
+ }
47
+
48
+
49
+ # MSv4 variables that will not be written back to
50
+ # the MSv2 as they are effectively metadata
51
+ IGNORED_VARIABLES = [
52
+ # Standard MSv4 variables
53
+ "EFFECTIVE_INTEGRATION_TIME",
54
+ "TIME_CENTROID",
55
+ "TIME_CENTROID_EXTRA_PRECISION",
56
+ "EFFECTIVE_CHANNEL_WIDTH",
57
+ "FREQUENCY_CENTROID",
58
+ # Added by Correlated Factory if grids are irregular
59
+ "TIME",
60
+ "INTEGRATION_TIME",
61
+ "CHANNEL_WIDTH",
62
+ ]
29
63
 
30
64
 
31
65
  def validate_column_desc(
@@ -100,7 +134,7 @@ def fit_tile_shape(shape: Tuple[int, ...], dtype: npt.DTypeLike) -> Dict[str, np
100
134
 
101
135
  def generate_column_descriptor(
102
136
  table_desc: Dict[str, Any],
103
- shapes_and_dtypes: ShapeAndDTypeType,
137
+ data_var_map: DataVariableInfoMap,
104
138
  ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
105
139
  """
106
140
  Synthesises a column descriptor from:
@@ -113,9 +147,8 @@ def generate_column_descriptor(
113
147
  Args:
114
148
  table_desc: Table descriptor containing existing
115
149
  column definitions.
116
- shapes_and_dtypes: A :code:`{var_name: (set(shapes), set(dtypes))}`
117
- mapping of shapes and data types associated with each xarray
118
- Variable.
150
+ data_var_map: A mapping of shapes and data types
151
+ associated with each xarray Variable.
119
152
 
120
153
  Returns:
121
154
  A (descriptor, dminfo) tuple containing any columns
@@ -125,23 +158,22 @@ def generate_column_descriptor(
125
158
  actual_desc = {}
126
159
  dm_groups = []
127
160
 
128
- for (var_name, column), (shapes, dtypes) in shapes_and_dtypes.items():
161
+ for (var_name, msv2_column), data_var_info in data_var_map.items():
129
162
  # If there are existing descriptors, either for
130
163
  # columns present on the table, or in the canonical definition
131
164
  # validate that the variable shape matches the column
132
- if column_desc := table_desc.get(column):
133
- validate_column_desc(var_name, column, shapes, column_desc)
134
- elif column_desc := canonical_table_desc.get(column):
135
- validate_column_desc(var_name, column, shapes, column_desc)
165
+ if column_desc := table_desc.get(msv2_column):
166
+ validate_column_desc(var_name, msv2_column, data_var_info.shapes, column_desc)
167
+ elif column_desc := canonical_table_desc.get(msv2_column):
168
+ validate_column_desc(var_name, msv2_column, data_var_info.shapes, column_desc)
136
169
  else:
137
- # Construct a column descriptor and possibly an
138
- # associated data manager
170
+ # Construct a column descriptor and possibly an associated data manager
139
171
  # Unify variable numpy types
140
- dtype = np.result_type(*dtypes)
172
+ dtype = np.result_type(*data_var_info.dtypes)
141
173
 
142
174
  if dtype is object:
143
175
  raise NotImplementedError(
144
- f"Types of variable {var_name} ({list(dtypes)}) "
176
+ f"Types of variable {var_name} ({list(data_var_info.dtypes)}) "
145
177
  f"resolves to an object. "
146
178
  f"Writing of objects is not supported"
147
179
  )
@@ -155,21 +187,21 @@ def generate_column_descriptor(
155
187
 
156
188
  column_desc = {"valueType": casa_type, "option": 0}
157
189
 
158
- if len(shapes) == 1:
190
+ if len(data_var_info.shapes) == 1:
159
191
  # If the shape is fixed, Tile the column
160
192
  # column descriptor shapes are fortran ordered
161
- fixed_shape = tuple(reversed(next(iter(shapes))))
193
+ fixed_shape = tuple(reversed(next(iter(data_var_info.shapes))))
162
194
  row_only = len(fixed_shape) == 0
163
195
  if not row_only:
164
196
  column_desc["option"] |= 4
165
197
  column_desc["shape"] = list(fixed_shape)
166
198
  column_desc["ndim"] = len(fixed_shape)
167
- column_desc["dataManagerGroup"] = dm_group = f"{column}_GROUP"
199
+ column_desc["dataManagerGroup"] = dm_group = f"{msv2_column}_GROUP"
168
200
  column_desc["dataManagerType"] = dm_type = "TiledColumnStMan"
169
201
 
170
202
  dm_groups.append(
171
203
  {
172
- "COLUMNS": [column],
204
+ "COLUMNS": [msv2_column],
173
205
  "NAME": dm_group,
174
206
  "TYPE": dm_type,
175
207
  "SPEC": fit_tile_shape(fixed_shape, dtype),
@@ -182,7 +214,7 @@ def generate_column_descriptor(
182
214
  column_desc["dataManagerGroup"] = "StandardStMan"
183
215
  column_desc["dataManagerType"] = "StandardStMan"
184
216
 
185
- actual_desc[column] = column_desc
217
+ actual_desc[msv2_column] = column_desc
186
218
 
187
219
  dminfo = {f"*{i + 1}": g for i, g in enumerate(dm_groups)}
188
220
  return actual_desc, dminfo
@@ -216,68 +248,39 @@ def msv2_store_from_dataset(ds: Dataset, region="auto") -> MSv2Store:
216
248
  )
217
249
 
218
250
 
219
- WriteVariablesT = str | Tuple[str, str] | List[str | Tuple[str, str]] | Dict[str, str]
251
+ WriteMapT = Tuple[str, str] | List[Tuple[str, str]] | Dict[str, str] | None
220
252
 
221
253
 
222
- def promote_write_variables(variables: WriteVariablesT) -> Dict[str, str]:
254
+ def promote_write_map(variables: WriteMapT) -> Dict[str, str]:
255
+ """Promotes the supplied write variable mapping into a Dict[str, str]"""
223
256
  type_except = TypeError(
224
257
  f"{variables} should be one of:\n"
225
- f"1. a str representing a variable\n"
226
- f"2. a Tuple[str, str] variable -> column mapping\n"
227
- f"3. A List containing either 1. or 2.\n"
228
- f"4. A Dict[str, str] variable -> column mapping"
258
+ f"1. a Tuple[str, str] variable -> column mapping\n"
259
+ f"2. An Iterable[Tuple[str, str]].\n"
260
+ f"3. A Dict[str, str] variable -> column mapping"
229
261
  )
230
262
 
231
- def check_var(var, on_type_error="raise"):
232
- if isinstance(var, str):
233
- return (var, var)
234
- elif isinstance(var, tuple):
235
- if not len(var) == 2 and all(isinstance(v, str) for v in var):
236
- raise type_except
237
-
238
- return (var[0], var[1])
239
-
240
- if on_type_error == "raise":
241
- raise type_except
242
-
243
- return None
244
-
245
- if result := check_var(variables, on_type_error="none"):
246
- return result
247
-
248
- if isinstance(variables, list):
249
- return {src: dest for src, dest in map(check_var, variables)}
250
-
251
- if isinstance(variables, dict):
252
- for k, v in variables.items():
253
- if not isinstance(k, str) or not isinstance(v, str):
254
- raise type_except
255
-
256
- raise type_except
257
-
258
-
259
- IGNORED_VARIABLES = [
260
- # Standard MSv4 variables
261
- # spectrum_xds
262
- "SPECTRUM",
263
- # visibility_xds
264
- "VISIBILITY",
265
- "FLAG",
266
- "WEIGHT",
267
- "UVW",
268
- "EFFECTIVE_INTEGRATION_TIME",
269
- "TIME_CENTROID",
270
- "TIME_CENTROID_EXTRA_PRECISION",
271
- "EFFECTIVE_CHANNEL_WIDTH",
272
- "FREQUENCY_CENTROID",
273
- # Added by Correlated Factory if grids are irregular
274
- "TIME",
275
- "INTEGRATION_TIME",
276
- "CHANNEL_WIDTH",
277
- ]
263
+ def check_tuple(tup: Tuple[str, str]) -> Tuple[str, str]:
264
+ if (
265
+ isinstance(tup, tuple) and len(tup) == 2 and all(isinstance(t, str) for t in tup)
266
+ ):
267
+ return tup
268
+
269
+ raise type_except
270
+
271
+ if variables is None:
272
+ return {}
273
+ elif isinstance(variables, dict):
274
+ return variables
275
+ elif isinstance(variables, tuple):
276
+ return dict([check_tuple(variables)])
277
+ elif isinstance(variables, (list, set)):
278
+ return dict([check_tuple(v) for v in variables])
279
+ else:
280
+ raise type_except
278
281
 
279
282
 
280
- def sync_msv2(dt: DataTree, variables: WriteVariablesT):
283
+ def sync_msv2(dt: DataTree, write_map: WriteMapT = None):
281
284
  assert isinstance(dt, DataTree)
282
285
  vis_datasets = [
283
286
  n for n in dt.subtree if n.attrs.get("type") in CORRELATED_DATASET_TYPES
@@ -291,61 +294,56 @@ def sync_msv2(dt: DataTree, variables: WriteVariablesT):
291
294
  table_factory = msv2_store_from_dataset(next(iter(vis_datasets)).ds).table_factory
292
295
  table_desc = table_factory.instance.tabledesc()
293
296
 
294
- # write_var_map = promote_write_variables(variables)
295
-
296
- # if variables is None:
297
- # columns = table_factory.instance.columns()
298
-
299
- list_var_names: List[Any] = (
300
- [variables] if isinstance(variables, str) else list(variables)
297
+ write_map = {**MSV4_WRITE_MAP, **promote_write_map(write_map)}
298
+ var_info_map: DataVariableInfoMap = defaultdict(
299
+ lambda: DataVariableInfo(0, set(), set())
301
300
  )
302
- set_var_names = set(list_var_names)
303
-
304
- if len(set_var_names) == 0:
305
- warnings.warn("Empty 'variables'")
306
- return
307
-
308
- shapes_and_dtypes: ShapeAndDTypeType = defaultdict(lambda: (set(), set()))
309
301
 
310
- for node in vis_datasets:
302
+ for nodes_visited, node in enumerate(vis_datasets, 1):
311
303
  assert isinstance(node, DataTree)
312
- if not set_var_names.issubset(node.data_vars.keys()):
313
- raise ValueError(
314
- f"{set_var_names} are not present in all visibility DataTree nodes"
315
- )
316
-
317
- for n in set_var_names:
318
- var = node.data_vars[n]
304
+ for name, var in node.data_vars.items():
305
+ if name in IGNORED_VARIABLES:
306
+ continue
319
307
 
308
+ # Don't try to create anything that doesn't translate
309
+ # to a MAIN MSv2 row dimension
320
310
  if var.dims[: len(MAIN_PREFIX_DIMS)] != MAIN_PREFIX_DIMS:
321
- raise ValueError(
322
- f"{n} dimensions {var.dims} do not start with {MAIN_PREFIX_DIMS}"
311
+ warnings.warn(
312
+ f"Ignoring {name} in {node.path} "
313
+ f"dimensions {var.dims} do not begin with {MAIN_PREFIX_DIMS}",
314
+ UserWarning,
323
315
  )
316
+ continue
317
+
318
+ entry = var_info_map[(name, write_map.get(name, name))]
319
+ entry.counts += 1
320
+ entry.shapes.add(var.shape[len(MAIN_PREFIX_DIMS) :])
321
+ entry.dtypes.add(var.dtype)
322
+
323
+ # Ensure that the identified variables are universally
324
+ # defined across all DataTree.
325
+ for key, entry in list(var_info_map.items()):
326
+ if entry.counts != nodes_visited:
327
+ warnings.warn(
328
+ f"Ignoring {key[0]} which does not appear in all "
329
+ f"visibility datasets {list(dt.children)}",
330
+ UserWarning,
331
+ )
332
+ var_info_map.pop(key)
324
333
 
325
- shapes, dtypes = shapes_and_dtypes[(n, n)]
326
- shapes.add(var.shape[len(MAIN_PREFIX_DIMS) :])
327
- dtypes.add(var.dtype)
328
-
329
- table_factory = msv2_store_from_dataset(next(iter(vis_datasets)).ds)._table_factory
330
- table_desc = table_factory.instance.tabledesc()
331
- column_descs, dminfo = generate_column_descriptor(table_desc, shapes_and_dtypes)
334
+ # Generate column descriptors and add the columns
335
+ column_descs, dminfo = generate_column_descriptor(table_desc, var_info_map)
332
336
  table_factory.instance.addcols(column_descs, dminfo)
333
337
  assert set(column_descs.keys()).issubset(table_factory.instance.columns())
334
338
 
335
339
 
336
340
  def datatree_to_msv2(
337
341
  dt: DataTree,
338
- variables: str | Iterable[str],
342
+ write_map: WriteMapT = None,
339
343
  compute: Literal[True] = True,
340
344
  write_inherited_coords: bool = False,
341
- ):
345
+ ) -> None:
342
346
  assert isinstance(dt, DataTree)
343
- list_var_names = [variables] if isinstance(variables, str) else list(variables)
344
- set_var_names = set(list_var_names)
345
-
346
- if len(set_var_names) == 0:
347
- warnings.warn("Empty 'variables'")
348
- return
349
347
 
350
348
  if (
351
349
  len(
@@ -363,32 +361,32 @@ def datatree_to_msv2(
363
361
  for node in vis_datasets:
364
362
  at_root = node is dt.root
365
363
  ds = node.to_dataset(inherit=write_inherited_coords or at_root)
366
- dataset_to_msv2(ds, list_var_names, compute)
364
+ dataset_to_msv2(ds, write_map=write_map, compute=compute)
367
365
 
368
366
 
369
367
  def dataset_to_msv2(
370
368
  ds: Dataset,
371
- variables: str | Iterable[str],
369
+ write_map: WriteMapT = None,
372
370
  compute: Literal[True] = True,
373
371
  region: Mapping[str, slice | Literal["auto"]] | Literal["auto"] = "auto",
374
- ):
372
+ ) -> None:
375
373
  assert isinstance(ds, Dataset)
376
- list_vars = [variables] if isinstance(variables, str) else list(variables)
377
-
378
- if len(list_vars) == 0:
379
- return
380
374
 
381
375
  # Strip out
382
- # 1. variables that will not be written
376
+ # 1. ancilliary variables
383
377
  # 2. coordinates
384
378
  # 3. attributes
385
- ignored_vars = set(ds.data_vars) - set(list_vars)
386
- ds = ds.drop_vars(ignored_vars | set(ds.coords)).drop_attrs()
379
+ write_map = {**MSV4_WRITE_MAP, **promote_write_map(write_map)}
380
+ ignored = set(IGNORED_VARIABLES) & set(ds.data_vars)
381
+ write_ds = ds.drop_vars(ignored | set(ds.coords)).drop_attrs()
382
+ write_ds = write_ds.rename_vars(
383
+ {k: v for k, v in write_map.items() if k in write_ds.data_vars}
384
+ )
387
385
 
388
- msv2_store = msv2_store_from_dataset(ds, region)
389
- msv2_store.set_write_region(ds)
386
+ msv2_store = msv2_store_from_dataset(write_ds, region)
387
+ msv2_store.set_write_region(write_ds)
390
388
  writer = ArrayWriter()
391
- dump_to_store(ds, msv2_store, writer)
389
+ dump_to_store(write_ds, msv2_store, writer)
392
390
  writes = writer.sync(compute=compute)
393
391
 
394
392
  if compute:
@@ -30,6 +30,11 @@ class ImputedMetadataWarning(MissingMetadataWarning):
30
30
  if the original metadata is missing"""
31
31
 
32
32
 
33
+ class DuplicateAntennaNameWarning(UserWarning):
34
+ """Warning raised when duplicate antenna names are found in the ANTENNA table
35
+ and are made unique by appending a numeric suffix"""
36
+
37
+
33
38
  class FrameConversionWarning(UserWarning):
34
39
  """Warning raised if there's no defined conversion from a CASA
35
40
  to astropy reference frame"""
@@ -1,19 +0,0 @@
1
- import json
2
- from typing import Any, Dict
3
-
4
- import pyarrow as pa
5
-
6
-
7
- def extract_table_desc(table: pa.Table) -> Dict[str, Any]:
8
- """Extract the CASA table descriptor stored in an Arrow table constructed by arcae"""
9
- try:
10
- arcae_metadata = table.schema.metadata[b"__arcae_metadata__"]
11
- except KeyError:
12
- raise KeyError("__arcae_metadata__ was not present in the table metadata")
13
-
14
- try:
15
- return json.loads(arcae_metadata)["__casa_descriptor__"]
16
- except KeyError:
17
- raise KeyError(
18
- f"arcae metadata {arcae_metadata} does not contain a __casa_descriptor__"
19
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes