tabsim 0.0.1__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.
@@ -0,0 +1,969 @@
1
+ import jax.numpy as jnp
2
+ from jax import config, Array
3
+
4
+ import dask.array as da
5
+ import numpy as np
6
+ import xarray as xr
7
+
8
+ from tabsim.dask.coordinates import (
9
+ ENU_to_ITRF,
10
+ ITRF_to_UVW,
11
+ ENU_to_GEO,
12
+ GEO_to_XYZ_vmap0,
13
+ ITRF_to_XYZ,
14
+ orbit_vmap,
15
+ radec_to_lmn,
16
+ angular_separation,
17
+ )
18
+ from tabsim.dask.interferometry import (
19
+ astro_vis,
20
+ astro_vis_gauss,
21
+ astro_vis_exp,
22
+ rfi_vis,
23
+ add_noise,
24
+ airy_beam,
25
+ Pv_to_Sv,
26
+ SEFD_to_noise_std,
27
+ int_sample_times,
28
+ generate_gains,
29
+ generate_fourier_gains,
30
+ apply_gains,
31
+ )
32
+
33
+ from tabsim.jax.interferometry import int_sample_times
34
+
35
+ from tabsim.jax.coordinates import (
36
+ itrf_to_geo,
37
+ alt_az_of_source,
38
+ gmsa_from_jd,
39
+ mjd_to_jd,
40
+ secs_to_days,
41
+ )
42
+ from tabsim.tools import beam_size
43
+ from tabsim.write import construct_observation_ds, write_ms
44
+ from tabsim.dask.extras import get_chunksizes
45
+ from tabsim.tle import get_satellite_positions, ants_pos, sat_distance
46
+
47
+ config.update("jax_enable_x64", True)
48
+
49
+ from astropy.time import Time
50
+
51
+
52
+ class Telescope(object):
53
+ """
54
+ Construct an Observation object defining a radio interferometry
55
+ observation.
56
+
57
+ Parameters
58
+ ----------
59
+ latitude: float
60
+ Latitude of the telescope.
61
+ longitude: float
62
+ Longitude of the telescope.
63
+ elevation: float
64
+ Elevation of the telescope.
65
+ ENU_path: str
66
+ Path to a txt file containing the ENU coordinates of each antenna.
67
+ ENU_array: ndarray (n_ant, 3)
68
+ ENU coordinates of each antenna.
69
+ name: str
70
+ Name of the telescope.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ latitude: float,
76
+ longitude: float,
77
+ elevation: float = 0.0,
78
+ ENU_array: Array = None,
79
+ ENU_path: str = None,
80
+ ITRF_array: Array = None,
81
+ ITRF_path: str = None,
82
+ tel_name: str = None,
83
+ n_ant: int = None,
84
+ ):
85
+ self.tel_name = tel_name
86
+ self.latitude = da.asarray(latitude)
87
+ self.longitude = da.asarray(longitude)
88
+ self.elevation = da.asarray(elevation)
89
+ self.GEO = da.asarray([latitude, longitude, elevation])
90
+ self.ITRF = None
91
+ self.ENU = None
92
+ self.n_ant = n_ant
93
+
94
+ if ENU_array is not None or ENU_path is not None:
95
+ self.createArrayENU(ENU_array, ENU_path)
96
+ if ITRF_array is not None or ITRF_path is not None:
97
+ self.createArrayITRF(ITRF_array, ITRF_path)
98
+ if self.ITRF is None and self.ENU is None:
99
+ raise ValueError(
100
+ "One of ('ENU_array', 'ENU_path', 'ITRF_array', 'ITRF_path') must be provided to create a Telescope object."
101
+ )
102
+ self.n_ant = len(self.ITRF)
103
+
104
+ def __str__(self):
105
+ msg = """\nTelescope Location
106
+ ------------------
107
+ Latitude : {latitude}
108
+ Longitude : {longitude}
109
+ Elevation : {elevation}\n"""
110
+ params = {
111
+ "latitude": self.latitude,
112
+ "longitude": self.longitude,
113
+ "elevation": self.elevation,
114
+ }
115
+ params = {k: v.compute() for k, v in params.items()}
116
+ return msg.format(**params)
117
+
118
+ def createArrayENU(self, ENU_array=None, ENU_path=None):
119
+ if ENU_array is not None:
120
+ self.ENU = ENU_array
121
+ elif ENU_path is not None:
122
+ self.ENU = np.loadtxt(ENU_path, usecols=(0, 1, 2), max_rows=self.n_ant)
123
+ else:
124
+ self.ENU = None
125
+ msg = """Error : East-North-Up coordinates are needed either in an
126
+ array or as a csv like file."""
127
+ print(msg)
128
+ return
129
+ self.ENU = da.asarray(self.ENU)
130
+ self.ENU_path = ENU_path
131
+ self.GEO_ants = ENU_to_GEO(self.GEO, self.ENU)
132
+ self.ITRF = ENU_to_ITRF(self.ENU, self.latitude, self.longitude, self.elevation)
133
+
134
+ def createArrayITRF(self, ITRF_array, ITRF_path):
135
+ if ITRF_array is not None:
136
+ self.ITRF = ITRF_array
137
+ elif ITRF_path is not None:
138
+ self.ITRF = np.loadtxt(ITRF_path, usecols=(0, 1, 2), max_rows=self.n_ant)
139
+ else:
140
+ self.ITRF = None
141
+ msg = """Error : ITRF antenna coordinates are needed either in an
142
+ array or as a csv like file."""
143
+ print(msg)
144
+ return
145
+ self.ITRF = da.asarray(self.ITRF)
146
+ self.GEO_ants = da.asarray(itrf_to_geo(self.ITRF.compute()))
147
+
148
+
149
+ class Observation(Telescope):
150
+ """
151
+ Construct an Observation object defining a radio interferometry
152
+ observation.
153
+
154
+ Parameters
155
+ ----------
156
+ latitude: float
157
+ Latitude of the telescope.
158
+ longitude: float
159
+ Longitude of the telescope.
160
+ elevation: float
161
+ Elevation of the telescope.
162
+ ra: float
163
+ Right Ascension of the phase centre.
164
+ dec: float
165
+ Declination of the phase centre.
166
+ times: ndarray (n_time,)
167
+ Time centroids of each data point in seconds as a Greenwich Mean Sidereal Time (GMST).
168
+ freqs: ndarray (n_freq,)
169
+ Frequency centroids for each observation channel in Hz.
170
+ SEFD: ndarray (n_freq,)
171
+ System Equivalent Flux Density of the telescope over frequency.
172
+ chan_width: float
173
+ Frequency channel width in Hz. Only used if `n_freq=1`, else calculated from `freqs`.
174
+ ENU_path: str
175
+ Path to a txt file containing the ENU coordinates of each antenna.
176
+ ENU_array: ndarray (n_ant, 3)
177
+ ENU coordinates of each antenna.
178
+ dish_d: float
179
+ Diameter of each antenna dish.
180
+ random_seed: int
181
+ Random seed to use for random number generator.
182
+ auto_corrs: bool
183
+ Flag to include autocorrelations in simulation.
184
+ no_w: bool
185
+ Whether to zero out the w-component of the baselines.
186
+ n_int_samples: int
187
+ Number of samples per time step which are then averaged. Must be
188
+ large enough to capture time-smearing of RFI sources on longest
189
+ baseline.
190
+ tel_name: str
191
+ Name of the telescope.
192
+ target_name: str
193
+ Name fo the target field.
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ latitude: float,
199
+ longitude: float,
200
+ elevation: float,
201
+ ra: float,
202
+ dec: float,
203
+ freqs: Array,
204
+ SEFD: Array,
205
+ times_mjd: Array,
206
+ int_time: float = 2.0,
207
+ chan_width: float = 209e3,
208
+ ENU_array: Array = None,
209
+ ENU_path: str = None,
210
+ ITRF_array: Array = None,
211
+ ITRF_path: str = None,
212
+ n_ant: int = None,
213
+ dish_d: float = 13.5,
214
+ random_seed: int = 0,
215
+ auto_corrs: bool = False,
216
+ no_w: bool = False,
217
+ n_int_samples: int = 4,
218
+ tel_name: str = "MeerKAT",
219
+ target_name: str = "unknown",
220
+ max_chunk_MB: float = 100.0,
221
+ ):
222
+ super().__init__(
223
+ latitude,
224
+ longitude,
225
+ elevation,
226
+ ENU_array,
227
+ ENU_path,
228
+ ITRF_array,
229
+ ITRF_path,
230
+ tel_name,
231
+ n_ant,
232
+ )
233
+
234
+ n_time = len(times_mjd)
235
+ start_mjd = times_mjd[0]
236
+ # times = (times_mjd - times_mjd[0]) * 24 * 3600
237
+ times = da.linspace(0, int_time * n_time, n_time, endpoint=False)
238
+ times_mjd = start_mjd + secs_to_days(times)
239
+
240
+ n_int_samples = n_int_samples + 1 if n_int_samples % 2 == 0 else n_int_samples
241
+
242
+ self.target_name = target_name
243
+ self.auto_corrs = auto_corrs
244
+
245
+ a1, a2 = jnp.triu_indices(self.n_ant, 0 if auto_corrs else 1)
246
+ self.n_bl = len(a1)
247
+
248
+ self.ant_chunk = self.n_ant
249
+ self.bl_chunk = self.n_bl
250
+
251
+ self.a1 = da.asarray(a1, chunks=(self.bl_chunk,))
252
+ self.a2 = da.asarray(a2, chunks=(self.bl_chunk,))
253
+
254
+ self.ra = da.asarray(ra)
255
+ self.dec = da.asarray(dec)
256
+
257
+ chunksize = get_chunksizes(
258
+ len(times), len(freqs), n_int_samples, self.n_bl, max_chunk_MB
259
+ )
260
+ self.time_chunk = chunksize["time"]
261
+ self.time_fine_chunk = self.time_chunk * n_int_samples
262
+ self.freq_chunk = chunksize["freq"]
263
+
264
+ self.times = da.asarray(times).rechunk(self.time_chunk)
265
+ self.times_mjd = da.asarray(times_mjd).rechunk(self.time_chunk)
266
+ self.int_time = int_time
267
+ self.n_int_samples = n_int_samples
268
+
269
+ self.times_fine = da.asarray(
270
+ int_sample_times(self.times.compute(), n_int_samples)
271
+ ).rechunk(self.time_fine_chunk)
272
+
273
+ self.times_mjd_fine = start_mjd + secs_to_days(self.times_fine)
274
+
275
+ #######################################
276
+ # dt = da.diff(self.times_fine)[:1]
277
+ # dt_jd = da.diff(self.times_mjd_fine)[:1]
278
+
279
+ # self.times_fine = da.concatenate([self.times_fine, dt]).rechunk(
280
+ # self.time_fine_chunk + 1
281
+ # )
282
+ # self.times_mjd_fine = da.concatenate([self.times_mjd_fine, dt_jd]).rechunk(
283
+ # self.time_fine_chunk + 1
284
+ # )
285
+ #######################################
286
+
287
+ self.n_time = len(times)
288
+ self.n_time_fine = len(self.times_fine)
289
+ self.t_idx = da.arange(
290
+ self.n_int_samples // 2, self.n_time_fine, self.n_int_samples
291
+ ).rechunk(self.time_chunk)
292
+
293
+ self.gsa = da.asarray(
294
+ Time(self.times_mjd_fine.compute(), format="mjd")
295
+ .sidereal_time("mean", "greenwich")
296
+ .hour
297
+ * 15,
298
+ chunks=(self.time_fine_chunk,),
299
+ )
300
+ self.gha = (self.gsa - self.ra) % 360
301
+ self.lsa = (self.gsa + longitude) % 360
302
+ self.lha = (((self.gha + longitude) % 360 - 180) % 360) - 180
303
+ self.altaz = da.asarray(alt_az_of_source(self.lsa.compute(), latitude, ra, dec))
304
+
305
+ self.freqs = da.asarray(freqs).rechunk((self.freq_chunk,))
306
+ self.chan_width = da.diff(freqs)[0] if len(freqs) > 1 else chan_width
307
+ self.n_freq = len(freqs)
308
+ self.lamda = 299792458.0 / self.freqs
309
+
310
+ self.SEFD = da.asarray(SEFD) * da.ones(self.n_freq, chunks=(self.freq_chunk,))
311
+ self.noise_std = SEFD_to_noise_std(self.SEFD, self.chan_width, self.int_time)
312
+
313
+ self.dish_d = da.asarray(dish_d)
314
+ self.fov = beam_size(dish_d, freqs.max(), fwhp=False)
315
+
316
+ self.ants_uvw = ITRF_to_UVW(self.ITRF, self.gha, self.dec)
317
+
318
+ if no_w:
319
+ self.ants_uvw[:, :, -1] = 0.0
320
+
321
+ self.bl_uvw = self.ants_uvw[:, self.a1, :] - self.ants_uvw[:, self.a2, :]
322
+ self.mag_uvw = da.linalg.norm(self.bl_uvw[0], axis=-1)
323
+ self.syn_bw = beam_size(self.mag_uvw.max().compute(), freqs.max())
324
+
325
+ self.ants_xyz = ITRF_to_XYZ(self.ITRF, self.gsa)
326
+ self.vis_rfi = da.zeros(
327
+ shape=(self.n_time, self.n_bl, self.n_freq),
328
+ chunks=(self.time_chunk, self.bl_chunk, self.freq_chunk),
329
+ dtype=jnp.complex128,
330
+ )
331
+ self.vis_ast = da.zeros(
332
+ shape=(self.n_time, self.n_bl, self.n_freq),
333
+ chunks=(self.time_chunk, self.bl_chunk, self.freq_chunk),
334
+ dtype=jnp.complex128,
335
+ )
336
+ self.gains_ants = da.ones(
337
+ shape=(self.n_time, self.n_ant, self.n_freq),
338
+ chunks=(self.time_chunk, self.ant_chunk, self.freq_chunk),
339
+ dtype=jnp.complex128,
340
+ )
341
+ self.random_seed = random_seed
342
+
343
+ self.n_ast = 0
344
+ self.n_p_ast = 0
345
+ self.n_g_ast = 0
346
+ self.n_e_ast = 0
347
+ self.n_rfi = 0
348
+ self.n_rfi_satellite = 0
349
+ self.n_rfi_stationary = 0
350
+ self.n_rfi_tle_satellite = 0
351
+
352
+ self.create_source_dicts()
353
+
354
+ def __str__(self):
355
+ msg = """
356
+ Observation Details
357
+ -------------------
358
+ Phase Centre (ra, dec) : ({ra:.1f}, {dec:.1f}) deg.
359
+ Local Hour Angle range : ({lha_min:.1f}, {lha_max:.1f}) deg.
360
+ Source Altitude range : ({alt_min:.1f}, {alt_max:.1f}) deg.
361
+ Number of antennas : {n_ant}
362
+ Number of baselines : {n_bl}
363
+ Autocorrelations : {auto_corrs}
364
+
365
+ Frequency range : ({freq_min:.0f} - {freq_max:.0f}) MHz
366
+ Channel width : {chan_width:.0f} kHz
367
+ Number of channels : {n_freq}
368
+
369
+ Observation time : ({time_min} - {time_max})
370
+ Integration time : {int_time:.0f} s
371
+ Sampling rate : {sampling_rate:.1f} Hz
372
+ Number of time steps : {n_time}
373
+
374
+ Source Details
375
+ --------------
376
+ Number of ast. sources : {n_ast}
377
+ Number of RFI sources : {n_rfi}
378
+ Number of satellite RFI : {n_sat}
379
+ Number of stationary RFI : {n_stat}"""
380
+
381
+ params = {
382
+ "ra": self.ra,
383
+ "dec": self.dec,
384
+ "n_ant": self.n_ant,
385
+ "n_bl": self.n_bl,
386
+ "auto_corrs": self.auto_corrs,
387
+ "freq_min": self.freqs.min() / 1e6,
388
+ "freq_max": self.freqs.max() / 1e6,
389
+ "time_min": Time(self.times_mjd.min(), format="mjd").strftime(
390
+ "%Y-%m-%d %H:%M:%S"
391
+ ),
392
+ "time_max": Time(self.times_mjd.max(), format="mjd").strftime(
393
+ "%Y-%m-%d %H:%M:%S"
394
+ ),
395
+ "lha_min": self.lha.min(),
396
+ "lha_max": self.lha.max(),
397
+ "alt_min": self.altaz[:, 0].min(),
398
+ "alt_max": self.altaz[:, 0].max(),
399
+ "chan_width": self.chan_width / 1e3,
400
+ "n_freq": self.n_freq,
401
+ "int_time": self.int_time,
402
+ "n_time": self.n_time,
403
+ "sampling_rate": self.n_int_samples / self.int_time,
404
+ }
405
+ params = {
406
+ k: v.compute() if isinstance(v, da.Array) else v for k, v in params.items()
407
+ }
408
+ params.update(
409
+ {
410
+ "n_ast": self.n_ast,
411
+ "n_rfi": self.n_rfi,
412
+ "n_sat": self.n_rfi_satellite,
413
+ "n_tle_sat": self.n_rfi_tle_satellite,
414
+ "n_stat": self.n_rfi_stationary,
415
+ }
416
+ )
417
+
418
+ return super().__str__() + msg.format(**params)
419
+
420
+ def create_source_dicts(self):
421
+ self.ast_p_I = []
422
+ self.ast_p_lmn = []
423
+ self.ast_p_radec = []
424
+
425
+ self.ast_g_I = []
426
+ self.ast_g_lmn = []
427
+ self.ast_g_radec = []
428
+ self.ast_g_major = []
429
+ self.ast_g_minor = []
430
+ self.ast_g_pos_angle = []
431
+
432
+ self.ast_e_I = []
433
+ self.ast_e_lmn = []
434
+ self.ast_e_radec = []
435
+ self.ast_e_major = []
436
+
437
+ self.rfi_satellite_xyz = []
438
+ self.rfi_satellite_orbit = []
439
+ self.rfi_satellite_ang_sep = []
440
+ self.rfi_satellite_A_app = []
441
+
442
+ self.rfi_tle_satellite_xyz = []
443
+ self.rfi_tle_satellite_orbit = []
444
+ self.rfi_tle_satellite_ang_sep = []
445
+ self.rfi_tle_satellite_A_app = []
446
+ self.norad_ids = []
447
+
448
+ self.rfi_stationary_xyz = []
449
+ self.rfi_stationary_geo = []
450
+ self.rfi_stationary_ang_sep = []
451
+ self.rfi_stationary_A_app = []
452
+
453
+ def addAstro(self, I: Array, ra: Array, dec: Array):
454
+ """
455
+ Add a set of astronomical sources to the observation.
456
+
457
+ Parameters
458
+ ----------
459
+ I: ndarray (n_src, n_time, n_freq) or
460
+ Intensity of the sources in Jy. If I.ndim==2, then this is assumed
461
+ to the spectrogram (n_time, n_freq) of a single source. If
462
+ I.ndim==1, then this is assumed to be the spectral profile of a
463
+ single source.
464
+ ra: array (n_src,)
465
+ Right ascension of the sources in degrees.
466
+ dec: array (n_src,)
467
+ Declination of the sources in degrees.
468
+ """
469
+ I = da.atleast_2d(I)
470
+ if I.ndim == 2:
471
+ I = da.expand_dims(I, axis=0)
472
+ I = I * da.ones(
473
+ shape=(I.shape[0], self.n_time, self.n_freq),
474
+ chunks=(I.shape[0], self.time_chunk, self.freq_chunk),
475
+ )
476
+ ra = da.atleast_1d(ra)
477
+ dec = da.atleast_1d(dec)
478
+ lmn = radec_to_lmn(ra, dec, [self.ra, self.dec])
479
+ theta = da.rad2deg(da.arcsin(da.linalg.norm(lmn[:, :-1], axis=-1)))
480
+ I_app = (
481
+ I
482
+ * (airy_beam(theta[:, None, None], self.freqs, self.dish_d)[:, :, 0, :])
483
+ ** 2
484
+ )
485
+ vis_ast = astro_vis(I_app, self.bl_uvw[self.t_idx], lmn, self.freqs)
486
+
487
+ self.ast_p_I.append(I)
488
+ self.ast_p_lmn.append(lmn)
489
+ self.ast_p_radec.append(jnp.array([ra, dec]))
490
+ self.n_p_ast += len(I)
491
+ self.n_ast += len(I)
492
+
493
+ self.vis_ast += vis_ast
494
+
495
+ def addAstroGauss(
496
+ self,
497
+ I: Array,
498
+ major: Array,
499
+ minor: Array,
500
+ pos_angle: Array,
501
+ ra: Array,
502
+ dec: Array,
503
+ ):
504
+ """
505
+ Add a set of astronomical sources to the observation.
506
+
507
+ Parameters
508
+ ----------
509
+ I: Array (n_src, n_time, n_freq) or
510
+ Intensity of the sources in Jy. If I.ndim==2, then this is assumed
511
+ to the spectrogram (n_time, n_freq) of a single source. If
512
+ I.ndim==1, then this is assumed to be the spectral profile of a
513
+ single source.
514
+ major: Array (n_src,)
515
+ FWHM of major axis of sources in arcseconds.
516
+ major: Array (n_src,)
517
+ FWHM of minor axis of sources in arcseconds.
518
+ pos_angle: Array (n_src,)
519
+ Position angle of sources in degrees west of north for the major axis.
520
+ ra: Array (n_src,)
521
+ Right ascension of the sources in degrees.
522
+ dec: Array (n_src,)
523
+ Declination of the sources in degrees.
524
+ """
525
+ I = da.atleast_2d(I)
526
+ if I.ndim == 2:
527
+ I = da.expand_dims(I, axis=0)
528
+ I = I * da.ones(
529
+ shape=(I.shape[0], self.n_time, self.n_freq),
530
+ chunks=(I.shape[0], self.time_chunk, self.freq_chunk),
531
+ )
532
+ major = da.atleast_1d(major)
533
+ minor = da.atleast_1d(minor)
534
+ pos_angle = da.atleast_1d(pos_angle)
535
+ ra = da.atleast_1d(ra)
536
+ dec = da.atleast_1d(dec)
537
+ lmn = radec_to_lmn(ra, dec, [self.ra, self.dec])
538
+ theta = da.rad2deg(da.arcsin(da.linalg.norm(lmn[:, :-1], axis=-1)))
539
+ I_app = (
540
+ I
541
+ * (airy_beam(theta[:, None, None], self.freqs, self.dish_d)[:, :, 0, :])
542
+ ** 2
543
+ )
544
+ vis_ast = astro_vis_gauss(
545
+ I_app, major, minor, pos_angle, self.bl_uvw[self.t_idx], lmn, self.freqs
546
+ )
547
+
548
+ self.ast_g_major.append(major)
549
+ self.ast_g_minor.append(minor)
550
+ self.ast_g_pos_angle.append(pos_angle)
551
+ self.ast_g_I.append(I)
552
+ self.ast_g_lmn.append(lmn)
553
+ self.ast_g_radec.append(jnp.array([ra, dec]))
554
+ self.n_g_ast += len(I)
555
+ self.n_ast += len(I)
556
+
557
+ self.vis_ast += vis_ast
558
+
559
+ def addAstroExp(self, I: Array, shape: Array, ra: Array, dec: Array):
560
+ """
561
+ Add a set of astronomical sources to the observation.
562
+
563
+ Parameters
564
+ ----------
565
+ I: Array (n_src, n_time, n_freq) or
566
+ Intensity of the sources in Jy. If I.ndim==2, then this is assumed
567
+ to the spectrogram (n_time, n_freq) of a single source. If
568
+ I.ndim==1, then this is assumed to be the spectral profile of a
569
+ single source.
570
+ shape: array (n_src,)
571
+ Shape of gaussian sources. Only circular gaussians accepted for now.
572
+ ra: array (n_src,)
573
+ Right ascension of the sources in degrees.
574
+ dec: array (n_src,)
575
+ Declination of the sources in degrees.
576
+ """
577
+ I = da.atleast_2d(I)
578
+ if I.ndim == 2:
579
+ I = da.expand_dims(I, axis=0)
580
+ I = I * da.ones(
581
+ shape=(I.shape[0], self.n_time, self.n_freq),
582
+ chunks=(I.shape[0], self.time_chunk, self.freq_chunk),
583
+ )
584
+ shape = da.atleast_1d(shape)
585
+ ra = da.atleast_1d(ra)
586
+ dec = da.atleast_1d(dec)
587
+ lmn = radec_to_lmn(ra, dec, [self.ra, self.dec])
588
+ theta = da.rad2deg(da.arcsin(da.linalg.norm(lmn[:, :-1], axis=-1)))
589
+ I_app = (
590
+ I
591
+ * (airy_beam(theta[:, None, None], self.freqs, self.dish_d)[:, :, 0, :])
592
+ ** 2
593
+ )
594
+ vis_ast = astro_vis_exp(I_app, shape, self.bl_uvw[self.t_idx], lmn, self.freqs)
595
+
596
+ self.ast_e_major.append(shape)
597
+ self.ast_e_I.append(I)
598
+ self.ast_e_lmn.append(lmn)
599
+ self.ast_e_radec.append(jnp.array([ra, dec]))
600
+ self.n_e_ast += len(I)
601
+ self.n_ast += len(I)
602
+
603
+ self.vis_ast += vis_ast
604
+
605
+ def addSatelliteRFI(
606
+ self,
607
+ Pv: Array,
608
+ elevation: Array,
609
+ inclination: Array,
610
+ lon_asc_node: Array,
611
+ periapsis: Array,
612
+ ):
613
+ """
614
+ Add a satellite-based source of RFI to the observation.
615
+
616
+ Parameters
617
+ ----------
618
+ Pv: ndarray (n_src, n_time_fine, n_freq)
619
+ Specific Emission Power in W/Hz. If Pv.ndim==1, it is assumed to be
620
+ of shape (n_freq,) and is the spectrum of a single RFI source. If
621
+ Pv.ndim==2, it is assumed to be of shape (n_time_fine, n_freq) and
622
+ is the spectrogram of a single RFI source.
623
+ elevation: ndarray (n_src,)
624
+ Elevation/Altitude of the orbit in metres.
625
+ inclination: ndarray (n_src,)
626
+ Inclination angle of the orbit relative to the equatorial plane.
627
+ lon_asc_node: ndarray (n_src,)
628
+ Longitude of the ascending node of the orbit. This is the longitude of
629
+ when the orbit crosses the equator from the south to the north.
630
+ periapsis: ndarray (n_src,)
631
+ Perisapsis of the orbit. This is the angular starting point of the orbit
632
+ at t = 0.
633
+ """
634
+ Pv = da.atleast_2d(Pv)
635
+ if Pv.ndim == 2:
636
+ Pv = da.expand_dims(Pv, axis=0)
637
+ Pv = (
638
+ Pv
639
+ * da.ones(
640
+ shape=(1, self.n_time_fine, self.n_freq),
641
+ )
642
+ ).rechunk((-1, self.time_fine_chunk, self.freq_chunk))
643
+ elevation = da.asarray(da.atleast_1d(elevation), chunks=(-1,))
644
+ inclination = da.asarray(da.atleast_1d(inclination), chunks=(-1,))
645
+ lon_asc_node = da.asarray(da.atleast_1d(lon_asc_node), chunks=(-1,))
646
+ periapsis = da.asarray(da.atleast_1d(periapsis), chunks=(-1,))
647
+
648
+ rfi_xyz = orbit_vmap(
649
+ self.times_fine, elevation, inclination, lon_asc_node, periapsis
650
+ )
651
+ # rfi_xyz is shape (n_src,n_time_fine,3)
652
+ # self.ants_xyz is shape (n_time_fine,n_ant,3)
653
+ distances = da.linalg.norm(
654
+ self.ants_xyz[None, :, :, :] - rfi_xyz[:, :, None, :], axis=-1
655
+ )
656
+ # distances is shape (n_src,n_time_fine,n_ant)
657
+ I = Pv_to_Sv(Pv, distances)
658
+ # I is shape (n_src,n_time_fine,n_ant,n_freq)
659
+
660
+ angular_seps = angular_separation(rfi_xyz, self.ants_xyz, self.ra, self.dec)
661
+
662
+ # angular_seps is shape (n_src,n_time_fine,n_ant)
663
+ rfi_A_app = da.sqrt(da.abs(I)) * airy_beam(
664
+ angular_seps, self.freqs, self.dish_d
665
+ )
666
+ # self.rfi_A_app is shape (n_src,n_time_fine,n_ant,n_freqs)
667
+ # distances is shape (n_src,n_time_fine,n_ant)
668
+ # self.ants_uvw is shape (n_time_fine,n_ant,3)
669
+
670
+ vis_rfi = rfi_vis(
671
+ rfi_A_app.reshape(
672
+ (-1, self.n_time, self.n_int_samples, self.n_ant, self.n_freq)
673
+ ),
674
+ (distances + self.ants_uvw[None, :, :, -1]).reshape(
675
+ (-1, self.n_time, self.n_int_samples, self.n_ant)
676
+ ),
677
+ self.freqs,
678
+ self.a1,
679
+ self.a2,
680
+ )
681
+ self.vis_rfi += vis_rfi
682
+
683
+ orbits = da.stack([elevation, inclination, lon_asc_node, periapsis], axis=1)
684
+
685
+ self.rfi_satellite_xyz.append(rfi_xyz)
686
+ self.rfi_satellite_orbit.append(orbits)
687
+ self.rfi_satellite_ang_sep.append(angular_seps)
688
+ self.rfi_satellite_A_app.append(rfi_A_app)
689
+ self.n_rfi_satellite += len(I)
690
+ self.n_rfi += len(I)
691
+
692
+ def addTLESatelliteRFI(
693
+ self,
694
+ Pv: Array,
695
+ norad_ids: list[int],
696
+ tles: Array,
697
+ ):
698
+ """
699
+ Add a satellite-based source of RFI to the observation.
700
+
701
+ Parameters
702
+ ----------
703
+ Pv: ndarray (n_src, n_time_fine, n_freq)
704
+ Specific Emission Power in W/Hz. If Pv.ndim==1, it is assumed to be
705
+ of shape (n_freq,) and is the spectrum of a single RFI source. If
706
+ Pv.ndim==2, it is assumed to be of shape (n_time_fine, n_freq) and
707
+ is the spectrogram of a single RFI source.
708
+ norad_ids: list[int] (n_src,)
709
+ NORAD IDs for the satellites to include.
710
+ tles: Array (n_src, 2)
711
+ TLEs of the satellites corresponding to the NORAD IDs.
712
+ """
713
+ Pv = da.atleast_2d(Pv)
714
+ if Pv.ndim == 2:
715
+ Pv = da.expand_dims(Pv, axis=0)
716
+ Pv = (
717
+ Pv
718
+ * da.ones(
719
+ shape=(1, self.n_time_fine, self.n_freq),
720
+ )
721
+ ).rechunk((-1, self.time_fine_chunk, self.freq_chunk))
722
+ norad_ids = da.asarray(da.atleast_1d(norad_ids), chunks=(-1,))
723
+
724
+ rfi_xyz = da.asarray(
725
+ get_satellite_positions(tles, mjd_to_jd(self.times_mjd_fine.compute())),
726
+ chunks=(-1, self.time_fine_chunk, 3),
727
+ )
728
+ tles = da.asarray(da.atleast_2d(tles), chunks=(-1,))
729
+ # rfi_xyz is shape (n_src,n_time_fine,3)
730
+ # self.ants_xyz is shape (n_time_fine,n_ant,3)
731
+ distances = da.linalg.norm(
732
+ self.ants_xyz[None, :, :, :] - rfi_xyz[:, :, None, :], axis=-1
733
+ )
734
+ # distances = da.asarray(da.linalg.norm(
735
+ # ants_pos(self.ITRF.compute(), mjd_to_jd(self.times_mjd_fine.compute()))[None, :, :, :] - rfi_xyz[:, :, None, :], axis=-1
736
+ # ), chunks=(-1, self.time_fine_chunk, self.n_ant))
737
+ # distances is shape (n_src,n_time_fine,n_ant)
738
+ I = Pv_to_Sv(Pv, distances)
739
+ # I is shape (n_src,n_time_fine,n_ant,n_freq)
740
+
741
+ angular_seps = angular_separation(rfi_xyz, self.ants_xyz, self.ra, self.dec)
742
+
743
+ # angular_seps is shape (n_src,n_time_fine,n_ant)
744
+ rfi_A_app = da.sqrt(da.abs(I)) * airy_beam(
745
+ angular_seps, self.freqs, self.dish_d
746
+ )
747
+ # rfi_A_app = da.ones((Pv.shape[0], self.n_time_fine, self.n_ant, self.n_freq), chunks=(-1, self.time_fine_chunk, self.n_ant, self.freq_chunk))
748
+ # self.rfi_A_app is shape (n_src,n_time_fine,n_ant,n_freqs)
749
+ # distances is shape (n_src,n_time_fine,n_ant)
750
+ # self.ants_uvw is shape (n_time_fine,n_ant,3)
751
+
752
+ vis_rfi = rfi_vis(
753
+ rfi_A_app.reshape(
754
+ (-1, self.n_time, self.n_int_samples, self.n_ant, self.n_freq)
755
+ ),
756
+ (distances + self.ants_uvw[None, :, :, -1]).reshape(
757
+ (-1, self.n_time, self.n_int_samples, self.n_ant)
758
+ ),
759
+ self.freqs,
760
+ self.a1,
761
+ self.a2,
762
+ )
763
+ self.vis_rfi += vis_rfi
764
+
765
+ self.rfi_tle_satellite_xyz.append(rfi_xyz)
766
+ self.rfi_tle_satellite_orbit.append(tles)
767
+ self.rfi_tle_satellite_ang_sep.append(angular_seps)
768
+ self.rfi_tle_satellite_A_app.append(rfi_A_app)
769
+ self.norad_ids.append(norad_ids)
770
+ self.n_rfi_tle_satellite += len(I)
771
+ self.n_rfi += len(I)
772
+
773
+ def addStationaryRFI(
774
+ self,
775
+ Pv: Array,
776
+ latitude: Array,
777
+ longitude: Array,
778
+ elevation: Array,
779
+ ):
780
+ """
781
+ Add a stationary source of RFI to the observation.
782
+
783
+ Parameters
784
+ ----------
785
+ Pv: ndarray (n_src, n_time_fine, n_freq)
786
+ Specific Emission Power in W/Hz. If Pv.ndim==1, it is assumed to be
787
+ of shape (n_freq,) and is the spectrum of a single RFI source. If
788
+ Pv.ndim==2, it is assumed to be of shape (n_time_fine, n_freq) and
789
+ is the spectrogram of a single RFI source.
790
+ latitude: ndarray (n_src,)
791
+ Geopgraphic latitude of the source in degrees.
792
+ longitude: ndarray (n_src,)
793
+ Geographic longitude of the source in degrees.
794
+ elevation: ndarray (n_src,)
795
+ Elevation/Altitude of the source above sea level in metres.
796
+ """
797
+ Pv = da.atleast_2d(Pv)
798
+ if Pv.ndim == 2:
799
+ Pv = da.expand_dims(Pv, axis=0)
800
+ Pv = (
801
+ Pv
802
+ * da.ones(
803
+ shape=(1, self.n_time_fine, self.n_freq),
804
+ )
805
+ ).rechunk((-1, self.time_fine_chunk, self.freq_chunk))
806
+ latitude = da.asarray(da.atleast_1d(latitude), chunks=(-1,))
807
+ longitude = da.asarray(da.atleast_1d(longitude), chunks=(-1,))
808
+ elevation = da.asarray(da.atleast_1d(elevation), chunks=(-1,))
809
+
810
+ rfi_geo = (
811
+ da.stack([latitude, longitude, elevation], axis=1)[:, None, :]
812
+ * da.ones(shape=(1, self.n_time_fine, 3))
813
+ ).rechunk(
814
+ (-1, self.time_fine_chunk, 3),
815
+ )
816
+
817
+ # rfi_geo is shape (n_src,n_time,3)
818
+ rfi_xyz = GEO_to_XYZ_vmap0(rfi_geo, self.times_fine)
819
+ # rfi_xyz is shape (n_src,n_time_fine,3)
820
+ # self.ants_xyz is shape (n_time_fine,n_ant,3)
821
+ distances = da.linalg.norm(
822
+ self.ants_xyz[None, :, :, :] - rfi_xyz[:, :, None, :], axis=-1
823
+ )
824
+ # distances is shape (n_src,n_time_fine,n_ant)
825
+ I = Pv_to_Sv(Pv, distances)
826
+ # I is shape (n_src,n_time,n_ant,n_freq)
827
+
828
+ angular_seps = angular_separation(rfi_xyz, self.ants_xyz, self.ra, self.dec)
829
+ rfi_A_app = da.sqrt(da.abs(I)) * airy_beam(
830
+ angular_seps, self.freqs, self.dish_d
831
+ )
832
+
833
+ # self.rfi_A_app is shape (n_src,n_time_fine,n_ant,n_freqs)
834
+ # self.ants_uvw is shape (n_time_fine,n_ant,3)
835
+ vis_rfi = rfi_vis(
836
+ rfi_A_app.reshape(
837
+ (-1, self.n_time, self.n_int_samples, self.n_ant, self.n_freq)
838
+ ),
839
+ (distances + self.ants_uvw[None, :, :, -1]).reshape(
840
+ (-1, self.n_time, self.n_int_samples, self.n_ant)
841
+ ),
842
+ self.freqs,
843
+ self.a1,
844
+ self.a2,
845
+ )
846
+
847
+ self.vis_rfi += vis_rfi
848
+
849
+ positions = da.stack([latitude, longitude, elevation], axis=1)
850
+
851
+ self.rfi_stationary_xyz.append(rfi_xyz)
852
+ self.rfi_stationary_geo.append(positions)
853
+ self.rfi_stationary_ang_sep.append(angular_seps)
854
+ self.rfi_stationary_A_app.append(rfi_A_app)
855
+ self.n_rfi_stationary += len(I)
856
+ self.n_rfi += len(I)
857
+
858
+ def addGains(
859
+ self,
860
+ G0_mean: float,
861
+ G0_std: float,
862
+ Gt_std_amp: float,
863
+ Gt_std_phase: float,
864
+ Gt_corr_amp: float,
865
+ Gt_corr_phase: float,
866
+ random_seed=None,
867
+ ):
868
+ """Add complex antenna gains to the simulation. Gain amplitudes and phases
869
+ are modelled as linear time-variates. Gains for all antennas at t = 0
870
+ are randomly sampled from a Gaussian described by the G0 parameters.
871
+ The rate of change of both ampltudes and phases are sampled from a zero
872
+ mean Gaussian with standard deviation as provided.
873
+
874
+ Parameters
875
+ ----------
876
+ G0_mean: float
877
+ Mean of Gaussian at t = 0.
878
+ G0_std: float
879
+ Standard deviation of Gaussian at t = 0.
880
+ Gt_std_amp: float
881
+ Standard deviation of Gaussian describing the rate of change in the
882
+ gain amplitudes in 1/seconds.
883
+ Gt_std_phase: float
884
+ Standard deviation of Gaussian describing the rate of change in the
885
+ gain phases in rad/seconds.
886
+ random_seed: int, optional
887
+ Random number generator key.
888
+ """
889
+ # self.gains_ants = generate_gains(
890
+ # G0_mean,
891
+ # G0_std,
892
+ # Gt_std_amp,
893
+ # Gt_std_phase,
894
+ # self.times,
895
+ # self.n_ant,
896
+ # self.n_freq,
897
+ # random_seed if random_seed else self.random_seed,
898
+ # ).rechunk((self.time_chunk, self.ant_chunk, self.freq_chunk))
899
+ self.gains_ants = generate_fourier_gains(
900
+ G0_mean,
901
+ G0_std,
902
+ Gt_std_amp,
903
+ Gt_std_phase,
904
+ Gt_corr_amp,
905
+ Gt_corr_phase,
906
+ self.times_mjd,
907
+ self.n_ant,
908
+ self.n_freq,
909
+ random_seed if random_seed else self.random_seed,
910
+ ).rechunk((self.time_chunk, self.ant_chunk, self.freq_chunk))
911
+
912
+ def calculate_vis(self, flags: bool = True, random_seed=None):
913
+ """
914
+ Calculate the total gain amplified visibilities, average down to the
915
+ originally defined sampling rate and add noise.
916
+ """
917
+ self.vis_uncal = apply_gains(
918
+ self.vis_ast, self.vis_rfi, self.gains_ants, self.a1, self.a2
919
+ ).rechunk((self.time_chunk, self.bl_chunk, self.freq_chunk))
920
+ self.vis_obs, self.noise_data = add_noise(
921
+ self.vis_uncal,
922
+ self.noise_std,
923
+ random_seed if random_seed else self.random_seed,
924
+ )
925
+ self.vis_cal = apply_gains(
926
+ self.vis_obs,
927
+ da.zeros_like(self.vis_obs),
928
+ 1.0 / self.gains_ants,
929
+ self.a1,
930
+ self.a2,
931
+ )
932
+ if flags:
933
+ if self.noise_std.mean() > 0:
934
+ self.flags = (
935
+ da.abs(self.vis_cal - self.vis_ast)
936
+ > 3.0 * self.noise_std[None, None, :]
937
+ ) # * da.sqrt(2)
938
+ else:
939
+ self.flags = (
940
+ da.abs(self.vis_cal - self.vis_ast)
941
+ > 3.0 * da.std(self.vis_model, axis=0)[None, ...]
942
+ )
943
+ else:
944
+ self.flags = da.zeros(shape=self.vis_cal.shape, dtype=bool)
945
+
946
+ self.dataset = construct_observation_ds(self)
947
+ return self.dataset
948
+
949
+ def write_to_zarr(self, path: str = "Observation", overwrite: bool = False):
950
+ """
951
+ Write the visibilities to disk using zarr format.
952
+ """
953
+ mode = "w" if overwrite else "w-"
954
+ self.dataset.to_zarr(path, mode=mode)
955
+ self.dataset = xr.open_zarr(path)
956
+ return self.dataset
957
+
958
+ def write_to_ms(
959
+ self,
960
+ path: str = "Observation.ms",
961
+ overwrite: bool = False,
962
+ ds: xr.Dataset = None,
963
+ ):
964
+ """
965
+ Write the visibilities to disk using Measurement Set format.
966
+ """
967
+ if ds is None:
968
+ ds = self.dataset
969
+ write_ms(ds, path, overwrite=overwrite)