xradio 0.0.41__py3-none-any.whl → 0.0.42__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 (62) hide show
  1. xradio/_utils/coord_math.py +100 -0
  2. xradio/_utils/list_and_array.py +49 -4
  3. xradio/_utils/schema.py +36 -16
  4. xradio/image/_util/_casacore/xds_from_casacore.py +5 -5
  5. xradio/image/_util/_casacore/xds_to_casacore.py +12 -11
  6. xradio/image/_util/_fits/xds_from_fits.py +18 -17
  7. xradio/image/_util/_zarr/zarr_low_level.py +29 -12
  8. xradio/image/_util/common.py +1 -1
  9. xradio/image/_util/image_factory.py +1 -1
  10. xradio/{correlated_data → measurement_set}/__init__.py +7 -4
  11. xradio/measurement_set/_utils/__init__.py +5 -0
  12. xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/_tables/load_main_table.py +1 -1
  13. xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/_tables/read.py +1 -1
  14. xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/conversion.py +78 -35
  15. xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/create_antenna_xds.py +62 -37
  16. xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/create_field_and_source_xds.py +109 -22
  17. xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/msv4_sub_xdss.py +47 -13
  18. xradio/{correlated_data → measurement_set}/_utils/_utils/xds_helper.py +1 -1
  19. xradio/{correlated_data/_utils/ms.py → measurement_set/_utils/msv2.py} +4 -4
  20. xradio/{correlated_data → measurement_set}/convert_msv2_to_processing_set.py +2 -2
  21. xradio/{correlated_data → measurement_set}/load_processing_set.py +5 -5
  22. xradio/measurement_set/measurement_set_xds.py +83 -0
  23. xradio/{correlated_data → measurement_set}/open_processing_set.py +9 -16
  24. xradio/measurement_set/processing_set.py +777 -0
  25. xradio/{correlated_data → measurement_set}/schema.py +1101 -610
  26. xradio/schema/check.py +42 -22
  27. xradio/schema/dataclass.py +56 -6
  28. xradio/sphinx/__init__.py +12 -0
  29. xradio/sphinx/schema_table.py +351 -0
  30. {xradio-0.0.41.dist-info → xradio-0.0.42.dist-info}/METADATA +9 -6
  31. xradio-0.0.42.dist-info/RECORD +76 -0
  32. {xradio-0.0.41.dist-info → xradio-0.0.42.dist-info}/WHEEL +1 -1
  33. xradio/_utils/common.py +0 -101
  34. xradio/correlated_data/_utils/__init__.py +0 -5
  35. xradio/correlated_data/correlated_xds.py +0 -13
  36. xradio/correlated_data/processing_set.py +0 -301
  37. xradio/correlated_data/test__processing_set.py +0 -74
  38. xradio-0.0.41.dist-info/RECORD +0 -75
  39. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/_tables/load.py +0 -0
  40. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/_tables/read_main_table.py +0 -0
  41. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/_tables/read_subtables.py +0 -0
  42. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/_tables/table_query.py +0 -0
  43. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/_tables/write.py +0 -0
  44. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/_tables/write_exp_api.py +0 -0
  45. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/chunks.py +0 -0
  46. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/descr.py +0 -0
  47. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/msv2_msv3.py +0 -0
  48. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/msv2_to_msv4_meta.py +0 -0
  49. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/msv4_info_dicts.py +0 -0
  50. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/optimised_functions.py +0 -0
  51. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/partition_queries.py +0 -0
  52. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/partitions.py +0 -0
  53. /xradio/{correlated_data/_utils/_ms → measurement_set/_utils/_msv2}/subtables.py +0 -0
  54. /xradio/{correlated_data → measurement_set}/_utils/_utils/cds.py +0 -0
  55. /xradio/{correlated_data → measurement_set}/_utils/_utils/partition_attrs.py +0 -0
  56. /xradio/{correlated_data → measurement_set}/_utils/_utils/stokes_types.py +0 -0
  57. /xradio/{correlated_data → measurement_set}/_utils/_zarr/encoding.py +0 -0
  58. /xradio/{correlated_data → measurement_set}/_utils/_zarr/read.py +0 -0
  59. /xradio/{correlated_data → measurement_set}/_utils/_zarr/write.py +0 -0
  60. /xradio/{correlated_data → measurement_set}/_utils/zarr.py +0 -0
  61. {xradio-0.0.41.dist-info → xradio-0.0.42.dist-info}/LICENSE.txt +0 -0
  62. {xradio-0.0.41.dist-info → xradio-0.0.42.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,777 @@
1
+ import pandas as pd
2
+ from xradio._utils.list_and_array import to_list
3
+ import numbers
4
+ import numpy as np
5
+ import toolviper.utils.logger as logger
6
+ import xarray as xr
7
+
8
+
9
+ class ProcessingSet(dict):
10
+ """
11
+ A dictionary subclass representing a Processing Set (PS) containing Measurement Sets v4 (MS).
12
+
13
+ This class extends the built-in `dict` class to provide additional methods for
14
+ manipulating and selecting subsets of the Processing Set. It includes functionality
15
+ for summarizing metadata, selecting subsets based on various criteria, and
16
+ exporting the data to storage formats.
17
+
18
+ Parameters
19
+ ----------
20
+ *args : dict, optional
21
+ Variable length argument list passed to the base `dict` class.
22
+ **kwargs : dict, optional
23
+ Arbitrary keyword arguments passed to the base `dict` class.
24
+ """
25
+
26
+ def __init__(self, *args, **kwargs):
27
+ """
28
+ Initialize the ProcessingSet instance.
29
+
30
+ Parameters
31
+ ----------
32
+ *args : dict, optional
33
+ Variable length argument list passed to the base `dict` class.
34
+ **kwargs : dict, optional
35
+ Arbitrary keyword arguments passed to the base `dict` class.
36
+ """
37
+ super().__init__(*args, **kwargs)
38
+ self.meta = {"summary": {}}
39
+
40
+ def summary(self, data_group="base"):
41
+ """
42
+ Generate and retrieve a summary of the Processing Set.
43
+
44
+ The summary includes information such as the names of the Measurement Sets,
45
+ their intents, polarizations, spectral window names, field names, source names,
46
+ field coordinates, start frequencies, and end frequencies.
47
+
48
+ Parameters
49
+ ----------
50
+ data_group : str, optional
51
+ The data group to summarize. Default is "base".
52
+
53
+ Returns
54
+ -------
55
+ pandas.DataFrame
56
+ A DataFrame containing the summary information of the specified data group.
57
+ """
58
+
59
+ if data_group in self.meta["summary"]:
60
+ return self.meta["summary"][data_group]
61
+ else:
62
+ self.meta["summary"][data_group] = self._summary(data_group).sort_values(
63
+ by=["name"], ascending=True
64
+ )
65
+ return self.meta["summary"][data_group]
66
+
67
+ def get_ps_max_dims(self):
68
+ """
69
+ Determine the maximum dimensions across all Measurement Sets in the Processing Set.
70
+
71
+ This method examines each Measurement Set's dimensions and computes the maximum
72
+ size for each dimension across the entire Processing Set.
73
+
74
+ For example, if the Processing Set contains two MSs with dimensions (50, 20, 30) and (10, 30, 40),
75
+ the maximum dimensions will be (50, 30, 40).
76
+
77
+ Returns
78
+ -------
79
+ dict
80
+ A dictionary containing the maximum dimensions of the Processing Set, with dimension names as keys
81
+ and their maximum sizes as values.
82
+ """
83
+ if "max_dims" in self.meta:
84
+ return self.meta["max_dims"]
85
+ else:
86
+ self.meta["max_dims"] = self._get_ps_max_dims()
87
+ return self.meta["max_dims"]
88
+
89
+ def get_ps_freq_axis(self):
90
+ """
91
+ Combine the frequency axes of all Measurement Sets in the Processing Set.
92
+
93
+ This method aggregates the frequency information from each Measurement Set to create
94
+ a unified frequency axis for the entire Processing Set.
95
+
96
+ Returns
97
+ -------
98
+ xarray.DataArray
99
+ The combined frequency axis of the Processing Set.
100
+ """
101
+ if "freq_axis" in self.meta:
102
+ return self.meta["freq_axis"]
103
+ else:
104
+ self.meta["freq_axis"] = self._get_ps_freq_axis()
105
+ return self.meta["freq_axis"]
106
+
107
+ def _summary(self, data_group="base"):
108
+ summary_data = {
109
+ "name": [],
110
+ "intents": [],
111
+ "shape": [],
112
+ "polarization": [],
113
+ "scan_number": [],
114
+ "spw_name": [],
115
+ "field_name": [],
116
+ "source_name": [],
117
+ "line_name": [],
118
+ "field_coords": [],
119
+ "start_frequency": [],
120
+ "end_frequency": [],
121
+ }
122
+ from astropy.coordinates import SkyCoord
123
+ import astropy.units as u
124
+
125
+ for key, value in self.items():
126
+ summary_data["name"].append(key)
127
+ summary_data["intents"].append(value.attrs["partition_info"]["intents"])
128
+ summary_data["spw_name"].append(
129
+ value.attrs["partition_info"]["spectral_window_name"]
130
+ )
131
+ summary_data["polarization"].append(value.polarization.values)
132
+ summary_data["scan_number"].append(
133
+ value.attrs["partition_info"]["scan_number"]
134
+ )
135
+ data_name = value.attrs["data_groups"][data_group]["correlated_data"]
136
+
137
+ if "VISIBILITY" in data_name:
138
+ center_name = "FIELD_PHASE_CENTER"
139
+
140
+ if "SPECTRUM" in data_name:
141
+ center_name = "FIELD_REFERENCE_CENTER"
142
+
143
+ summary_data["shape"].append(value[data_name].shape)
144
+
145
+ summary_data["field_name"].append(
146
+ value.attrs["partition_info"]["field_name"]
147
+ )
148
+ summary_data["source_name"].append(
149
+ value.attrs["partition_info"]["source_name"]
150
+ )
151
+
152
+ summary_data["line_name"].append(value.attrs["partition_info"]["line_name"])
153
+
154
+ summary_data["start_frequency"].append(
155
+ to_list(value["frequency"].values)[0]
156
+ )
157
+ summary_data["end_frequency"].append(to_list(value["frequency"].values)[-1])
158
+
159
+ if value[data_name].attrs["field_and_source_xds"].is_ephemeris:
160
+ summary_data["field_coords"].append("Ephemeris")
161
+ elif (
162
+ "time"
163
+ in value[data_name].attrs["field_and_source_xds"][center_name].coords
164
+ ):
165
+ summary_data["field_coords"].append("Multi-Phase-Center")
166
+ else:
167
+ ra_dec_rad = (
168
+ value[data_name].attrs["field_and_source_xds"][center_name].values
169
+ )
170
+ frame = (
171
+ value[data_name]
172
+ .attrs["field_and_source_xds"][center_name]
173
+ .attrs["frame"]
174
+ .lower()
175
+ )
176
+
177
+ coord = SkyCoord(
178
+ ra=ra_dec_rad[0] * u.rad, dec=ra_dec_rad[1] * u.rad, frame=frame
179
+ )
180
+
181
+ summary_data["field_coords"].append(
182
+ [
183
+ frame,
184
+ coord.ra.to_string(unit=u.hour, precision=2),
185
+ coord.dec.to_string(unit=u.deg, precision=2),
186
+ ]
187
+ )
188
+
189
+ summary_df = pd.DataFrame(summary_data)
190
+ return summary_df
191
+
192
+ def _get_ps_freq_axis(self):
193
+
194
+ spw_ids = []
195
+ freq_axis_list = []
196
+ frame = self.get(0).frequency.attrs["frame"]
197
+ for ms_xds in self.values():
198
+ assert (
199
+ frame == ms_xds.frequency.attrs["frame"]
200
+ ), "Frequency reference frame not consistent in Processing Set."
201
+ if ms_xds.frequency.attrs["spectral_window_id"] not in spw_ids:
202
+ spw_ids.append(ms_xds.frequency.attrs["spectral_window_id"])
203
+ freq_axis_list.append(ms_xds.frequency)
204
+
205
+ freq_axis = xr.concat(freq_axis_list, dim="frequency").sortby("frequency")
206
+ return freq_axis
207
+
208
+ def _get_ps_max_dims(self):
209
+ max_dims = None
210
+ for ms_xds in self.values():
211
+ if max_dims is None:
212
+ max_dims = dict(ms_xds.sizes)
213
+ else:
214
+ for dim_name, size in ms_xds.sizes.items():
215
+ if dim_name in max_dims:
216
+ if max_dims[dim_name] < size:
217
+ max_dims[dim_name] = size
218
+ else:
219
+ max_dims[dim_name] = size
220
+ return max_dims
221
+
222
+ def get(self, id):
223
+ return self[list(self.keys())[id]]
224
+
225
+ def sel(self, string_exact_match: bool = True, query: str = None, **kwargs):
226
+ """
227
+ Select a subset of the Processing Set based on specified criteria.
228
+
229
+ This method allows filtering the Processing Set by matching column names and values
230
+ or by applying a Pandas query string. The selection criteria can target various
231
+ attributes of the Measurement Sets such as intents, polarization, spectral window names, etc.
232
+
233
+ Note
234
+ ----
235
+ This selection does not modify the actual data within the Measurement Sets. For example, if
236
+ a Measurement Set has `field_name=['field_0','field_10','field_08']` and `ps.sel(field_name='field_0')`
237
+ is invoked, the resulting subset will still contain the original list `['field_0','field_10','field_08']`.
238
+
239
+ Parameters
240
+ ----------
241
+ string_exact_match : bool, optional
242
+ If `True`, string matching will require exact matches for string and string list columns.
243
+ If `False`, partial matches are allowed. Default is `True`.
244
+ query : str, optional
245
+ A Pandas query string to apply additional filtering. Default is `None`.
246
+ **kwargs : dict
247
+ Keyword arguments representing column names and their corresponding values to filter the Processing Set.
248
+
249
+ Returns
250
+ -------
251
+ ProcessingSet
252
+ A new `ProcessingSet` instance containing only the Measurement Sets that match the selection criteria.
253
+
254
+ Examples
255
+ --------
256
+ >>> # Select all MSs with intents 'OBSERVE_TARGET#ON_SOURCE' and polarization 'RR' or 'LL'
257
+ >>> selected_ps = ps.sel(intents='OBSERVE_TARGET#ON_SOURCE', polarization=['RR', 'LL'])
258
+
259
+ >>> # Select all MSs with start_frequency greater than 100 GHz and less than 200 GHz
260
+ >>> selected_ps = ps.sel(query='start_frequency > 100e9 AND end_frequency < 200e9')
261
+ """
262
+ import numpy as np
263
+
264
+ def select_rows(df, col, sel_vals, string_exact_match):
265
+ def check_selection(row_val):
266
+ row_val = to_list(
267
+ row_val
268
+ ) # make sure that it is a list so that we can iterate over it.
269
+
270
+ for rw in row_val:
271
+ for s in sel_vals:
272
+ if string_exact_match:
273
+ if rw == s:
274
+ return True
275
+ else:
276
+ if s in rw:
277
+ return True
278
+ return False
279
+
280
+ return df[df[col].apply(check_selection)]
281
+
282
+ summary_table = self.summary()
283
+ for key, value in kwargs.items():
284
+ value = to_list(value) # make sure value is a list.
285
+
286
+ if len(value) == 1 and isinstance(value[0], slice):
287
+ summary_table = summary_table[
288
+ summary_table[key].between(value[0].start, value[0].stop)
289
+ ]
290
+ else:
291
+ summary_table = select_rows(
292
+ summary_table, key, value, string_exact_match
293
+ )
294
+
295
+ if query is not None:
296
+ summary_table = summary_table.query(query)
297
+
298
+ sub_ps = ProcessingSet()
299
+ for key, val in self.items():
300
+ if key in summary_table["name"].values:
301
+ sub_ps[key] = val
302
+
303
+ return sub_ps
304
+
305
+ def ms_sel(self, **kwargs):
306
+ """
307
+ Select a subset of the Processing Set by applying the `xarray.Dataset.sel` method to each Measurement Set.
308
+
309
+ This method allows for selection based on label-based indexing for each dimension of the datasets.
310
+
311
+ Parameters
312
+ ----------
313
+ **kwargs : dict
314
+ Keyword arguments representing dimension names and the labels to select along those dimensions.
315
+ These are passed directly to the `xarray.Dataset.sel` method.
316
+
317
+ Returns
318
+ -------
319
+ ProcessingSet
320
+ A new `ProcessingSet` instance containing the selected subsets of each Measurement Set.
321
+ """
322
+ sub_ps = ProcessingSet()
323
+ for key, val in self.items():
324
+ sub_ps[key] = val.sel(kwargs)
325
+ return sub_ps
326
+
327
+ def ms_isel(self, **kwargs):
328
+ """
329
+ Select a subset of the Processing Set by applying the `isel` method to each Measurement Set.
330
+
331
+ This method allows for selection based on integer-based indexing for each dimension of the datasets.
332
+
333
+ Parameters
334
+ ----------
335
+ **kwargs : dict
336
+ Keyword arguments representing dimension names and the integer indices to select along those dimensions.
337
+ These are passed directly to the `isel` method.
338
+
339
+ Returns
340
+ -------
341
+ ProcessingSet
342
+ A new `ProcessingSet` instance containing the selected subsets of each Measurement Set.
343
+ """
344
+ sub_ps = ProcessingSet()
345
+ for key, val in self.items():
346
+ sub_ps[key] = val.isel(kwargs)
347
+ return sub_ps
348
+
349
+ def to_store(self, store, **kwargs):
350
+ """
351
+ Write the Processing Set to a Zarr store.
352
+
353
+ This method serializes each Measurement Set within the Processing Set to a separate Zarr group
354
+ within the specified store directory. Note that writing to cloud storage is not supported yet.
355
+
356
+ Parameters
357
+ ----------
358
+ store : str
359
+ The filesystem path to the Zarr store directory where the data will be saved.
360
+ **kwargs : dict, optional
361
+ Additional keyword arguments to be passed to the `xarray.Dataset.to_zarr` method.
362
+ Refer to the [xarray documentation](https://docs.xarray.dev/en/latest/generated/xarray.Dataset.to_zarr.html)
363
+ for available options.
364
+
365
+ Returns
366
+ -------
367
+ None
368
+
369
+ Raises
370
+ ------
371
+ OSError
372
+ If the specified store path is invalid or not writable.
373
+
374
+ Examples
375
+ --------
376
+ >>> # Save the Processing Set to a local Zarr store
377
+ >>> ps.to_store('/path/to/zarr_store')
378
+ """
379
+ import os
380
+
381
+ for key, value in self.items():
382
+ value.to_store(os.path.join(store, key), **kwargs)
383
+
384
+ def get_combined_field_and_source_xds(self, data_group="base"):
385
+ """
386
+ Combine the `field_and_source_xds` datasets from all Measurement Sets into a single dataset.
387
+
388
+ The combined `xarray.Dataset` will have a new dimension 'field_name', consolidating data from
389
+ each Measurement Set. Ephemeris data is handled separately.
390
+
391
+ Parameters
392
+ ----------
393
+ data_group : str, optional
394
+ The data group to process. Default is "base".
395
+
396
+ Returns
397
+ -------
398
+ tuple of xarray.Dataset
399
+ A tuple containing two `xarray.Dataset` objects:
400
+ - combined_field_and_source_xds: Combined dataset for standard fields.
401
+ - combined_ephemeris_field_and_source_xds: Combined dataset for ephemeris fields.
402
+
403
+ Raises
404
+ ------
405
+ ValueError
406
+ If the `field_and_source_xds` attribute is missing or improperly formatted in any Measurement Set.
407
+ """
408
+ df = self.summary(data_group)
409
+
410
+ combined_field_and_source_xds = xr.Dataset()
411
+ combined_ephemeris_field_and_source_xds = xr.Dataset()
412
+ for ms_name, ms_xds in self.items():
413
+
414
+ correlated_data_name = ms_xds.attrs["data_groups"][data_group][
415
+ "correlated_data"
416
+ ]
417
+
418
+ field_and_source_xds = (
419
+ ms_xds[correlated_data_name]
420
+ .attrs["field_and_source_xds"]
421
+ .copy(deep=True)
422
+ )
423
+
424
+ if (
425
+ "line_name" in field_and_source_xds.coords
426
+ ): # Not including line info since it is a function of spw.
427
+ field_and_source_xds = field_and_source_xds.drop_vars(
428
+ ["LINE_REST_FREQUENCY", "LINE_SYSTEMIC_VELOCITY"], errors="ignore"
429
+ )
430
+ del field_and_source_xds["line_name"]
431
+ del field_and_source_xds["line_label"]
432
+
433
+ if "time" in field_and_source_xds.coords:
434
+ if "time" not in field_and_source_xds.field_name.dims:
435
+ field_names = np.array(
436
+ [field_and_source_xds.field_name.values.item()]
437
+ * len(field_and_source_xds.time.values)
438
+ )
439
+ source_names = np.array(
440
+ [field_and_source_xds.source_name.values.item()]
441
+ * len(field_and_source_xds.time.values)
442
+ )
443
+ del field_and_source_xds["field_name"]
444
+ del field_and_source_xds["source_name"]
445
+ field_and_source_xds = field_and_source_xds.assign_coords(
446
+ field_name=("time", field_names)
447
+ )
448
+ field_and_source_xds = field_and_source_xds.assign_coords(
449
+ source_name=("time", source_names)
450
+ )
451
+ field_and_source_xds = field_and_source_xds.swap_dims(
452
+ {"time": "field_name"}
453
+ )
454
+ del field_and_source_xds["time"]
455
+ elif "time_ephemeris" in field_and_source_xds.coords:
456
+ if "time_ephemeris" not in field_and_source_xds.field_name.dims:
457
+ field_names = np.array(
458
+ [field_and_source_xds.field_name.values.item()]
459
+ * len(field_and_source_xds.time_ephemeris.values)
460
+ )
461
+ source_names = np.array(
462
+ [field_and_source_xds.source_name.values.item()]
463
+ * len(field_and_source_xds.time_ephemeris.values)
464
+ )
465
+ del field_and_source_xds["field_name"]
466
+ del field_and_source_xds["source_name"]
467
+ field_and_source_xds = field_and_source_xds.assign_coords(
468
+ field_name=("time_ephemeris", field_names)
469
+ )
470
+ field_and_source_xds = field_and_source_xds.assign_coords(
471
+ source_name=("time_ephemeris", source_names)
472
+ )
473
+ field_and_source_xds = field_and_source_xds.swap_dims(
474
+ {"time_ephemeris": "field_name"}
475
+ )
476
+ del field_and_source_xds["time_ephemeris"]
477
+ else:
478
+ for dv_names in field_and_source_xds.data_vars:
479
+ if "field_name" not in field_and_source_xds[dv_names].dims:
480
+ field_and_source_xds[dv_names] = field_and_source_xds[
481
+ dv_names
482
+ ].expand_dims("field_name")
483
+
484
+ if field_and_source_xds.is_ephemeris:
485
+ if len(combined_ephemeris_field_and_source_xds.data_vars) == 0:
486
+ combined_ephemeris_field_and_source_xds = field_and_source_xds
487
+ else:
488
+ combined_ephemeris_field_and_source_xds = xr.concat(
489
+ [combined_ephemeris_field_and_source_xds, field_and_source_xds],
490
+ dim="field_name",
491
+ )
492
+ else:
493
+ if len(combined_field_and_source_xds.data_vars) == 0:
494
+ combined_field_and_source_xds = field_and_source_xds
495
+ else:
496
+ combined_field_and_source_xds = xr.concat(
497
+ [combined_field_and_source_xds, field_and_source_xds],
498
+ dim="field_name",
499
+ )
500
+
501
+ if (len(combined_field_and_source_xds.data_vars) > 0) and (
502
+ "FIELD_PHASE_CENTER" in combined_field_and_source_xds
503
+ ):
504
+ combined_field_and_source_xds = (
505
+ combined_field_and_source_xds.drop_duplicates("field_name")
506
+ )
507
+
508
+ combined_field_and_source_xds["MEAN_PHASE_CENTER"] = (
509
+ combined_field_and_source_xds["FIELD_PHASE_CENTER"].mean(
510
+ dim=["field_name"]
511
+ )
512
+ )
513
+
514
+ ra1 = (
515
+ combined_field_and_source_xds["FIELD_PHASE_CENTER"]
516
+ .sel(sky_dir_label="ra")
517
+ .values
518
+ )
519
+ dec1 = (
520
+ combined_field_and_source_xds["FIELD_PHASE_CENTER"]
521
+ .sel(sky_dir_label="dec")
522
+ .values
523
+ )
524
+ ra2 = (
525
+ combined_field_and_source_xds["MEAN_PHASE_CENTER"]
526
+ .sel(sky_dir_label="ra")
527
+ .values
528
+ )
529
+ dec2 = (
530
+ combined_field_and_source_xds["MEAN_PHASE_CENTER"]
531
+ .sel(sky_dir_label="dec")
532
+ .values
533
+ )
534
+
535
+ from xradio._utils.coord_math import haversine
536
+
537
+ distance = haversine(ra1, dec1, ra2, dec2)
538
+ min_index = distance.argmin()
539
+
540
+ combined_field_and_source_xds.attrs["center_field_name"] = (
541
+ combined_field_and_source_xds.field_name[min_index].values
542
+ )
543
+
544
+ if (len(combined_ephemeris_field_and_source_xds.data_vars) > 0) and (
545
+ "FIELD_PHASE_CENTER" in combined_ephemeris_field_and_source_xds
546
+ ):
547
+ combined_ephemeris_field_and_source_xds = (
548
+ combined_ephemeris_field_and_source_xds.drop_duplicates("field_name")
549
+ )
550
+
551
+ from xradio._utils.coord_math import wrap_to_pi
552
+
553
+ offset = (
554
+ combined_ephemeris_field_and_source_xds["FIELD_PHASE_CENTER"]
555
+ - combined_ephemeris_field_and_source_xds["SOURCE_LOCATION"]
556
+ )
557
+ combined_ephemeris_field_and_source_xds["FIELD_OFFSET"] = xr.DataArray(
558
+ wrap_to_pi(offset.sel(sky_pos_label=["ra", "dec"])).values,
559
+ dims=["field_name", "sky_dir_label"],
560
+ )
561
+ combined_ephemeris_field_and_source_xds["FIELD_OFFSET"].attrs = (
562
+ combined_ephemeris_field_and_source_xds["FIELD_PHASE_CENTER"].attrs
563
+ )
564
+ combined_ephemeris_field_and_source_xds["FIELD_OFFSET"].attrs["units"] = (
565
+ combined_ephemeris_field_and_source_xds["FIELD_OFFSET"].attrs["units"][
566
+ :2
567
+ ]
568
+ )
569
+
570
+ ra1 = (
571
+ combined_ephemeris_field_and_source_xds["FIELD_OFFSET"]
572
+ .sel(sky_dir_label="ra")
573
+ .values
574
+ )
575
+ dec1 = (
576
+ combined_ephemeris_field_and_source_xds["FIELD_OFFSET"]
577
+ .sel(sky_dir_label="dec")
578
+ .values
579
+ )
580
+ ra2 = 0.0
581
+ dec2 = 0.0
582
+
583
+ from xradio._utils.coord_math import haversine
584
+
585
+ distance = haversine(ra1, dec1, ra2, dec2)
586
+ min_index = distance.argmin()
587
+
588
+ combined_ephemeris_field_and_source_xds.attrs["center_field_name"] = (
589
+ combined_ephemeris_field_and_source_xds.field_name[min_index].values
590
+ )
591
+
592
+ return combined_field_and_source_xds, combined_ephemeris_field_and_source_xds
593
+
594
+ def plot_phase_centers(self, label_all_fields=False, data_group="base"):
595
+ """
596
+ Plot the phase center locations of all fields in the Processing Set.
597
+
598
+ This method is primarily used for visualizing mosaics. It generates scatter plots of
599
+ the phase center coordinates for both standard and ephemeris fields. The central field
600
+ is highlighted in red based on the closest phase center calculation.
601
+
602
+ Parameters
603
+ ----------
604
+ label_all_fields : bool, optional
605
+ If `True`, all fields will be labeled on the plot. Default is `False`.
606
+ data_group : str, optional
607
+ The data group to use for processing. Default is "base".
608
+
609
+ Returns
610
+ -------
611
+ None
612
+
613
+ Raises
614
+ ------
615
+ ValueError
616
+ If the combined datasets are empty or improperly formatted.
617
+ """
618
+ combined_field_and_source_xds, combined_ephemeris_field_and_source_xds = (
619
+ self.get_combined_field_and_source_xds(data_group)
620
+ )
621
+ from matplotlib import pyplot as plt
622
+
623
+ if (len(combined_field_and_source_xds.data_vars) > 0) and (
624
+ "FIELD_PHASE_CENTER" in combined_field_and_source_xds
625
+ ):
626
+ plt.figure()
627
+ plt.title("Field Phase Center Locations")
628
+ plt.scatter(
629
+ combined_field_and_source_xds["FIELD_PHASE_CENTER"].sel(
630
+ sky_dir_label="ra"
631
+ ),
632
+ combined_field_and_source_xds["FIELD_PHASE_CENTER"].sel(
633
+ sky_dir_label="dec"
634
+ ),
635
+ )
636
+
637
+ center_field_name = combined_field_and_source_xds.attrs["center_field_name"]
638
+ center_field = combined_field_and_source_xds.sel(
639
+ field_name=center_field_name
640
+ )
641
+ plt.scatter(
642
+ center_field["FIELD_PHASE_CENTER"].sel(sky_dir_label="ra"),
643
+ center_field["FIELD_PHASE_CENTER"].sel(sky_dir_label="dec"),
644
+ color="red",
645
+ label=center_field_name,
646
+ )
647
+ plt.xlabel("RA (rad)")
648
+ plt.ylabel("DEC (rad)")
649
+ plt.legend()
650
+ plt.show()
651
+
652
+ if (len(combined_ephemeris_field_and_source_xds.data_vars) > 0) and (
653
+ "FIELD_PHASE_CENTER" in combined_ephemeris_field_and_source_xds
654
+ ):
655
+
656
+ plt.figure()
657
+ plt.title(
658
+ "Offset of Field Phase Center from Source Location (Ephemeris Data)"
659
+ )
660
+ plt.scatter(
661
+ combined_ephemeris_field_and_source_xds["FIELD_OFFSET"].sel(
662
+ sky_dir_label="ra"
663
+ ),
664
+ combined_ephemeris_field_and_source_xds["FIELD_OFFSET"].sel(
665
+ sky_dir_label="dec"
666
+ ),
667
+ )
668
+
669
+ center_field_name = combined_ephemeris_field_and_source_xds.attrs[
670
+ "center_field_name"
671
+ ]
672
+ center_field = combined_ephemeris_field_and_source_xds.sel(
673
+ field_name=center_field_name
674
+ )
675
+ plt.scatter(
676
+ center_field["FIELD_OFFSET"].sel(sky_dir_label="ra"),
677
+ center_field["FIELD_OFFSET"].sel(sky_dir_label="dec"),
678
+ color="red",
679
+ label=center_field_name,
680
+ )
681
+ plt.xlabel("RA Offset (rad)")
682
+ plt.ylabel("DEC Offset (rad)")
683
+ plt.legend()
684
+ plt.show()
685
+
686
+ def get_combined_antenna_xds(self):
687
+ """
688
+ Combine the `antenna_xds` datasets from all Measurement Sets into a single dataset.
689
+
690
+ This method concatenates the antenna datasets from each Measurement Set along the 'antenna_name' dimension.
691
+
692
+ Returns
693
+ -------
694
+ xarray.Dataset
695
+ A combined `xarray.Dataset` containing antenna information from all Measurement Sets.
696
+
697
+ Raises
698
+ ------
699
+ ValueError
700
+ If antenna datasets are missing required variables or improperly formatted.
701
+ """
702
+ combined_antenna_xds = xr.Dataset()
703
+ for cor_name, ms_xds in self.items():
704
+ antenna_xds = ms_xds.antenna_xds.copy(deep=True)
705
+
706
+ if len(combined_antenna_xds.data_vars) == 0:
707
+ combined_antenna_xds = antenna_xds
708
+ else:
709
+ combined_antenna_xds = xr.concat(
710
+ [combined_antenna_xds, antenna_xds],
711
+ dim="antenna_name",
712
+ data_vars="minimal",
713
+ coords="minimal",
714
+ )
715
+
716
+ # ALMA WVR antenna_xds data has a NaN value for the antenna receptor angle.
717
+ if "ANTENNA_RECEPTOR_ANGLE" in combined_antenna_xds.data_vars:
718
+ combined_antenna_xds = combined_antenna_xds.dropna("antenna_name")
719
+
720
+ combined_antenna_xds = combined_antenna_xds.drop_duplicates("antenna_name")
721
+
722
+ return combined_antenna_xds
723
+
724
+ def plot_antenna_positions(self):
725
+ """
726
+ Plot the antenna positions of all antennas in the Processing Set.
727
+
728
+ This method generates three scatter plots displaying the antenna positions in different planes:
729
+ - X vs Y
730
+ - X vs Z
731
+ - Y vs Z
732
+
733
+ Parameters
734
+ ----------
735
+ None
736
+
737
+ Returns
738
+ -------
739
+ None
740
+
741
+ Raises
742
+ ------
743
+ ValueError
744
+ If the combined antenna dataset is empty or missing required coordinates.
745
+ """
746
+ combined_antenna_xds = self.get_combined_antenna_xds()
747
+ from matplotlib import pyplot as plt
748
+
749
+ plt.figure()
750
+ plt.title("Antenna Positions")
751
+ plt.scatter(
752
+ combined_antenna_xds["ANTENNA_POSITION"].sel(cartesian_pos_label="x"),
753
+ combined_antenna_xds["ANTENNA_POSITION"].sel(cartesian_pos_label="y"),
754
+ )
755
+ plt.xlabel("x (m)")
756
+ plt.ylabel("y (m)")
757
+ plt.show()
758
+
759
+ plt.figure()
760
+ plt.title("Antenna Positions")
761
+ plt.scatter(
762
+ combined_antenna_xds["ANTENNA_POSITION"].sel(cartesian_pos_label="x"),
763
+ combined_antenna_xds["ANTENNA_POSITION"].sel(cartesian_pos_label="z"),
764
+ )
765
+ plt.xlabel("x (m)")
766
+ plt.ylabel("z (m)")
767
+ plt.show()
768
+
769
+ plt.figure()
770
+ plt.title("Antenna Positions")
771
+ plt.scatter(
772
+ combined_antenna_xds["ANTENNA_POSITION"].sel(cartesian_pos_label="y"),
773
+ combined_antenna_xds["ANTENNA_POSITION"].sel(cartesian_pos_label="z"),
774
+ )
775
+ plt.xlabel("y (m)")
776
+ plt.ylabel("z (m)")
777
+ plt.show()