ps-eor 1.0__tar.gz → 1.2__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 (45) hide show
  1. {ps_eor-1.0 → ps_eor-1.2}/CHANGELOG.md +40 -1
  2. {ps_eor-1.0 → ps_eor-1.2}/PKG-INFO +2 -2
  3. ps_eor-1.2/ps_eor/data/instruments/ska_low_aavs2_sefd.csv +36 -0
  4. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/datacube.py +136 -54
  5. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/covariance.py +4 -3
  6. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/fitter.py +12 -6
  7. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/obssimu.py +69 -21
  8. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/simu.py +21 -15
  9. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/tools/pstool.py +20 -6
  10. {ps_eor-1.0 → ps_eor-1.2}/pyproject.toml +1 -1
  11. {ps_eor-1.0 → ps_eor-1.2}/.gitignore +0 -0
  12. {ps_eor-1.0 → ps_eor-1.2}/LICENSE +0 -0
  13. {ps_eor-1.0 → ps_eor-1.2}/README.md +0 -0
  14. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/__init__.py +0 -0
  15. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/config/default_ml_gpr_settings.toml +0 -0
  16. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/aartfaac_a12_hba_statpos.data +0 -0
  17. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/aartfaac_a12_lba_statpos.data +0 -0
  18. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/lofar_statpos.data +0 -0
  19. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/mwa_rev1_statpos.data +0 -0
  20. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/nenufar80_statpos.data +0 -0
  21. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/nenufar_full_statpos.data +0 -0
  22. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/ovro-lwa_enu_statpos.data +0 -0
  23. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/ska1_low_aa2_enu_statpos.data +0 -0
  24. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/ska1_low_aastar_enu_statpos.data +0 -0
  25. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/data/instruments/ska1_low_enu_statpos.data +0 -0
  26. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/fgfit.py +0 -0
  27. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/filtering.py +0 -0
  28. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/fitutil.py +0 -0
  29. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/flagger.py +0 -0
  30. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/__init__.py +0 -0
  31. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/config.py +0 -0
  32. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/kernels.py +0 -0
  33. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/multidata.py +0 -0
  34. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/params.py +0 -0
  35. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/priors.py +0 -0
  36. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/regressor.py +0 -0
  37. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/samplers.py +0 -0
  38. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/ml_gpr/vae.py +0 -0
  39. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/pscart.py +0 -0
  40. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/pspec.py +0 -0
  41. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/pssph.py +0 -0
  42. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/psutil.py +0 -0
  43. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/sphcube.py +0 -0
  44. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/sphutil.py +0 -0
  45. {ps_eor-1.0 → ps_eor-1.2}/ps_eor/tools/__init__.py +0 -0
@@ -1,6 +1,45 @@
1
1
  # ChangeLog
2
2
 
3
- ## Unreleased
3
+ ## 1.2 - 2026-10-26
4
+
5
+ - [Changed] `SkaLow.get_sefd` now interpolates the AAVS2-based SKAO station-sensitivity lookup
6
+ (zenith, cold-sky LST 0-8h) instead of the pre-hardware SKA1-LFAA design-target specification.
7
+ Effective XX and YY SEFD values are stored separately, interpolated with independent cubic
8
+ splines, then combined using the SKAO Stokes-I convention. Values shift, especially above
9
+ 220 MHz where the old table was flat-extrapolated instead of following the lookup roll-off.
10
+ `tsys_sky` is no longer a parameter because the lookup already fixes the sky condition
11
+
12
+ ## 1.1 - 2026-09-11
13
+
14
+ - [Fixed] Fix a thermal-noise normalization regression introduced in 1.0 and limited to the
15
+ convenience default `SimuGridded.get_noise_std_cube(sefd=None)`. That path passed the effective
16
+ Stokes-I SEFD into equations that already include the two-polarization average, applying the
17
+ sensitivity gain twice. It also affected `simu_noise_ps_zrange`, which relied on this default.
18
+ Corrected results from these two paths have sqrt(2) higher visibility-noise amplitude and twice
19
+ the noise power. Existing low-level `datacube` calculations supplied with `get_sefd`, ML-GPR,
20
+ `simu_noise_img`, `simu_noise_ps`, and the power-spectrum estimators retain their previous
21
+ numerical normalization. The polarization average is now explicit through `n_pol_averaged`;
22
+ the deprecated `get_i_sefd` helper is no longer used internally
23
+ - [Fixed] `DEx` declared a primary beam name that resolved to nothing, so `SimuGridded.get_ps_gen`
24
+ raised for it. `pstool simu_noise_ps`, `pstool simu_noise_ps_zrange` and `--fov pb_fwhm` were
25
+ unusable with that telescope, including the example in `obssimu`'s module docstring. Its beam now
26
+ follows the configured antenna separation, the same aperture its SEFD already uses
27
+ - [Fixed] `pstool simu_noise_ps_zrange` built its redshift grid with
28
+ `arange(z_min, z_max + delta_z, delta_z)`, which steps one past `z_max` unless the span is an
29
+ exact multiple of `delta_z`. That slice selects no channel and the run died on an empty reduction
30
+ - [New] `get_samples` and every `SimuMultiGPCube` draw method take an `rng` argument -- a seed, a
31
+ `Generator`, or None -- so simulated cubes are reproducible. This was the one path the 1.0 move to
32
+ `numpy.random.Generator` missed. A seed is resolved once per call, so the draws within it advance
33
+ rather than repeat
34
+ - [Changed] Run the CI pipeline on tags
35
+ - [Changed] `test_independent_epochs_uncorrelated` asserted on one epoch pair's correlation, which
36
+ has std 0.09 under independence and reaches 0.31, so it failed about one run in forty. It now
37
+ averages the 28 pairs of an 8-epoch seeded draw, where the same statistic stays under 0.061 and a
38
+ 20% shared variance fraction reads 0.197
39
+ - [Changed] Assert the sampler convergence warnings the test suite provokes rather than leaving them
40
+ unread in the run summary
41
+ - [Changed] Cover `pstool` and the VAE, and add numerical regressions for the power-spectrum
42
+ estimators, taking the package from 64% to 70%
4
43
 
5
44
  ## 1.0 - 2026-08-03
6
45
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ps_eor
3
- Version: 1.0
3
+ Version: 1.2
4
4
  Summary: Power Spectra generation for 21-cm experiments and Foreground modeling/removal
5
5
  Project-URL: Homepage, https://gitlab.com/flomertens/ps_eor
6
6
  Project-URL: Documentation, https://ps-eor.readthedocs.io
@@ -0,0 +1,36 @@
1
+ frequency_mhz,sefd_xx_jy,sefd_yy_jy
2
+ 49.92,12691.708008,12706.784180
3
+ 58.88,6430.699219,6456.514648
4
+ 69.12,4400.614258,4429.739258
5
+ 79.36,3287.111084,3280.634033
6
+ 88.32,2657.459473,2628.958252
7
+ 89.60,2591.589600,2559.727051
8
+ 99.84,2175.688477,2124.371094
9
+ 108.80,1984.341553,1935.180176
10
+ 119.04,1879.441406,1837.543213
11
+ 129.28,1817.179443,1761.833984
12
+ 139.52,1726.300049,1702.780762
13
+ 149.76,1720.707642,1640.811279
14
+ 154.88,1770.666016,1661.329468
15
+ 160.00,1881.431885,1749.433594
16
+ 168.96,1985.992188,1876.033081
17
+ 179.20,2034.554932,1939.044189
18
+ 185.60,2016.972046,1949.946777
19
+ 189.44,1996.074097,1918.264404
20
+ 199.68,1980.122925,1835.012085
21
+ 209.92,1960.942261,1874.337402
22
+ 216.32,2126.444092,1988.125122
23
+ 218.88,2162.237061,2007.597900
24
+ 229.12,2321.589111,2117.384033
25
+ 239.36,2255.394531,2111.037354
26
+ 249.60,2109.016846,2050.640625
27
+ 259.84,2191.391846,2023.246094
28
+ 268.80,2117.823975,1980.030396
29
+ 279.04,2129.421387,2049.493408
30
+ 289.28,2223.825195,2104.812500
31
+ 299.52,2374.633545,2311.893066
32
+ 309.76,2340.869141,2267.793945
33
+ 320.00,2227.362793,2119.056885
34
+ 328.96,2285.774170,2193.820068
35
+ 339.20,2279.281982,2268.093018
36
+ 349.44,2503.468262,2453.347900
@@ -66,6 +66,14 @@ def _fmhz(freqs, precision=3):
66
66
  return np.round(freqs * 1e-6, precision)
67
67
 
68
68
 
69
+ def _validate_n_pol_averaged(n_pol_averaged):
70
+ """Validate the effective number of independent polarization products."""
71
+ if (not np.isscalar(n_pol_averaged)
72
+ or not np.isfinite(n_pol_averaged)
73
+ or n_pol_averaged <= 0):
74
+ raise ValueError('n_pol_averaged must be a finite, strictly positive scalar')
75
+
76
+
69
77
  def get_common_idx(cube1, cube2):
70
78
  """Return frequency and spatial-mode masks shared by two cubes."""
71
79
  freqs = np.intersect1d(_fmhz(cube1.freqs), _fmhz(cube2.freqs))
@@ -1499,40 +1507,62 @@ class CartDataCube(DataCube):
1499
1507
 
1500
1508
  return cube
1501
1509
 
1502
- def estimate_sefd(self, sefd_jansky=True):
1503
- """Overall system-equivalent flux density from this cube and its weights.
1510
+ def estimate_sefd(self, sefd_jansky=True, n_pol_averaged=2):
1511
+ """Overall single-polarization SEFD from this cube and its weights.
1512
+
1513
+ Args:
1514
+ sefd_jansky (bool): return Jy (else Kelvin).
1515
+ n_pol_averaged: number of independent polarization products averaged
1516
+ into the visibility cube (one for XX/YY, two for Stokes I).
1504
1517
 
1505
1518
  Returns:
1506
1519
  float: the SEFD, in Jy (``sefd_jansky=True``) or Kelvin.
1507
1520
  """
1508
1521
  assert self.weights is not None
1509
1522
 
1510
- return self.weights.estimate_sefd(self, sefd_jansky=sefd_jansky)
1523
+ return self.weights.estimate_sefd(
1524
+ self, sefd_jansky=sefd_jansky,
1525
+ n_pol_averaged=n_pol_averaged,
1526
+ )
1511
1527
 
1512
- def estimate_uv_sefd(self, sefd_jansky=True):
1528
+ def estimate_uv_sefd(self, sefd_jansky=True, n_pol_averaged=2):
1513
1529
  """SEFD per uv sample (see :meth:`estimate_sefd` for units).
1514
1530
 
1531
+ Args:
1532
+ sefd_jansky (bool): return Jy (else Kelvin).
1533
+ n_pol_averaged: number of independent polarization products averaged
1534
+ into the visibility cube.
1535
+
1515
1536
  Returns:
1516
1537
  CartDataCube: the per-mode SEFD.
1517
1538
  """
1518
1539
  assert self.weights is not None
1519
1540
 
1520
- return self.weights.estimate_uv_sefd(self, sefd_jansky=sefd_jansky)
1541
+ return self.weights.estimate_uv_sefd(
1542
+ self, sefd_jansky=sefd_jansky,
1543
+ n_pol_averaged=n_pol_averaged,
1544
+ )
1521
1545
 
1522
- def estimate_freqs_sefd(self, sefd_jansky=True, sefd_poly_fit_deg=0):
1546
+ def estimate_freqs_sefd(self, sefd_jansky=True, sefd_poly_fit_deg=0,
1547
+ n_pol_averaged=2):
1523
1548
  """SEFD per frequency channel (see :meth:`estimate_sefd` for units).
1524
1549
 
1525
1550
  Args:
1526
1551
  sefd_jansky (bool): return Jy (else Kelvin).
1527
1552
  sefd_poly_fit_deg (int): if > 0, smooth the SEFD with a polynomial
1528
1553
  of this degree in log-log space.
1554
+ n_pol_averaged: number of independent polarization products averaged
1555
+ into the visibility cube.
1529
1556
 
1530
1557
  Returns:
1531
1558
  ndarray: the SEFD per channel, shape ``(n_freqs,)``.
1532
1559
  """
1533
1560
  assert self.weights is not None
1534
1561
 
1535
- sefd = self.weights.estimate_freqs_sefd(self, sefd_jansky=sefd_jansky)
1562
+ sefd = self.weights.estimate_freqs_sefd(
1563
+ self, sefd_jansky=sefd_jansky,
1564
+ n_pol_averaged=n_pol_averaged,
1565
+ )
1536
1566
 
1537
1567
  if sefd_poly_fit_deg > 0:
1538
1568
  sefd_fct = np.poly1d(np.polyfit(np.log(self.freqs), np.log(sefd), sefd_poly_fit_deg))
@@ -1712,14 +1742,22 @@ class CartDataCube(DataCube):
1712
1742
  print(f'Filtering {n_filt} modes ({n_filt / n_tot * 100:.2f} %)')
1713
1743
  self.filter_uv_from_index(idx_uv)
1714
1744
 
1715
- def filter_sefd_uv(self, max_sefd, min_sefd=0):
1745
+ def filter_sefd_uv(self, max_sefd, min_sefd=0, n_pol_averaged=2):
1716
1746
  """Keep, in place, only uv modes whose mean estimated SEFD is within
1717
1747
  ``[min_sefd, max_sefd]``.
1718
1748
 
1749
+ Args:
1750
+ max_sefd: maximum retained single-polarization SEFD.
1751
+ min_sefd: minimum retained single-polarization SEFD.
1752
+ n_pol_averaged: number of independent polarization products averaged
1753
+ into the visibility cube.
1754
+
1719
1755
  Returns:
1720
1756
  ndarray: boolean mask of the kept modes.
1721
1757
  """
1722
- sefd = self.estimate_uv_sefd().data.mean(axis=0)
1758
+ sefd = self.estimate_uv_sefd(
1759
+ n_pol_averaged=n_pol_averaged,
1760
+ ).data.mean(axis=0)
1723
1761
  idx_uv = (sefd >= min_sefd) & (sefd <= max_sefd)
1724
1762
  n_filt = np.sum(~idx_uv)
1725
1763
  n_tot = float(len(self.uu))
@@ -2390,24 +2428,28 @@ class CartWeightCube(CartDataCube):
2390
2428
  return CartWeightCube(1 / noise_scale ** 2, noise_cube.uu, noise_cube.vv,
2391
2429
  noise_cube.freqs, meta)
2392
2430
 
2393
- def estimate_sefd(self, noise_cube, sefd_jansky=True, axis=None):
2394
- """SEFD estimated from a noise realization and these weights.
2431
+ def estimate_sefd(self, noise_cube, sefd_jansky=True, axis=None,
2432
+ n_pol_averaged=2):
2433
+ """Single-polarization SEFD estimated from a noise realization.
2395
2434
 
2396
2435
  Args:
2397
2436
  noise_cube: a noise :class:`CartDataCube` on the same geometry.
2398
2437
  sefd_jansky (bool): return Jy (else Kelvin).
2399
2438
  axis: reduce over this axis (0 -> per uv mode, 1 -> per channel,
2400
2439
  None -> a single overall value).
2440
+ n_pol_averaged: number of independent polarization products averaged
2441
+ into ``noise_cube`` (one for XX/YY, two for Stokes I).
2401
2442
 
2402
2443
  Returns:
2403
2444
  The SEFD (MAD-averaged over ``axis``); scalar or array per ``axis``.
2404
2445
  """
2446
+ _validate_n_pol_averaged(n_pol_averaged)
2405
2447
  df = self.meta.chan_width
2406
2448
  int_time = self.meta.int_time
2407
2449
 
2408
2450
  w = abs(self.get_slice(noise_cube.freqs[0], noise_cube.freqs[-1]).data)
2409
2451
  w[w == 0] = np.nan
2410
- sefd = noise_cube.data * (2 * df * int_time * 1) ** 0.5 * w ** 0.5
2452
+ sefd = noise_cube.data * (n_pol_averaged * df * int_time) ** 0.5 * w ** 0.5
2411
2453
 
2412
2454
  # Kelvin noise is normalized to the image field of view.
2413
2455
  if sefd_jansky:
@@ -2421,27 +2463,45 @@ class CartWeightCube(CartDataCube):
2421
2463
 
2422
2464
  return psutil.mad(sefd, axis=axis)
2423
2465
 
2424
- def estimate_uv_sefd(self, noise_cube, sefd_jansky=True):
2466
+ def estimate_uv_sefd(self, noise_cube, sefd_jansky=True, n_pol_averaged=2):
2425
2467
  """SEFD per uv sample (see :meth:`estimate_sefd`).
2426
2468
 
2469
+ Args:
2470
+ noise_cube: a noise :class:`CartDataCube` on the same geometry.
2471
+ sefd_jansky (bool): return Jy (else Kelvin).
2472
+ n_pol_averaged: number of independent polarization products averaged
2473
+ into ``noise_cube``.
2474
+
2427
2475
  Returns:
2428
2476
  CartDataCube: the per-mode SEFD (single averaged channel).
2429
2477
  """
2430
- sefd = self.estimate_sefd(noise_cube, sefd_jansky=sefd_jansky, axis=0)
2478
+ sefd = self.estimate_sefd(
2479
+ noise_cube, sefd_jansky=sefd_jansky, axis=0,
2480
+ n_pol_averaged=n_pol_averaged,
2481
+ )
2431
2482
 
2432
2483
  return CartDataCube(sefd[None, :], self.uu, self.vv, np.array([self.freqs.mean()]),
2433
2484
  self.meta.copy())
2434
2485
 
2435
- def estimate_freqs_sefd(self, noise_cube, sefd_jansky=True):
2486
+ def estimate_freqs_sefd(self, noise_cube, sefd_jansky=True, n_pol_averaged=2):
2436
2487
  """SEFD per frequency channel (see :meth:`estimate_sefd`).
2437
2488
 
2489
+ Args:
2490
+ noise_cube: a noise :class:`CartDataCube` on the same geometry.
2491
+ sefd_jansky (bool): return Jy (else Kelvin).
2492
+ n_pol_averaged: number of independent polarization products averaged
2493
+ into ``noise_cube``.
2494
+
2438
2495
  Returns:
2439
2496
  ndarray: the SEFD per channel, shape ``(n_freqs,)``.
2440
2497
  """
2441
- return self.estimate_sefd(noise_cube, sefd_jansky=sefd_jansky, axis=1)
2498
+ return self.estimate_sefd(
2499
+ noise_cube, sefd_jansky=sefd_jansky, axis=1,
2500
+ n_pol_averaged=n_pol_averaged,
2501
+ )
2442
2502
 
2443
2503
  def scale_with_noise_cube(self, noise_cube, sefd_poly_fit_deg=0, sefd_filter_n_bins=0,
2444
- expected_sefd=None, scale_freqs=False):
2504
+ expected_sefd=None, scale_freqs=False, n_pol_averaged=2):
2445
2505
  """Set ``uv_scale`` (in place) so the weights reflect the SEFD measured
2446
2506
  from ``noise_cube`` -- down-weighting noisier uv modes.
2447
2507
 
@@ -2454,8 +2514,12 @@ class CartWeightCube(CartDataCube):
2454
2514
  expected_sefd: reference SEFD the scale normalises to (default: the
2455
2515
  median measured SEFD).
2456
2516
  scale_freqs (bool): also apply a per-channel SEFD scaling.
2517
+ n_pol_averaged: number of independent polarization products averaged
2518
+ into ``noise_cube``.
2457
2519
  """
2458
- sefd_uv = self.estimate_uv_sefd(noise_cube).data
2520
+ sefd_uv = self.estimate_uv_sefd(
2521
+ noise_cube, n_pol_averaged=n_pol_averaged,
2522
+ ).data
2459
2523
  if expected_sefd is None:
2460
2524
  expected_sefd = np.nanmedian(sefd_uv)
2461
2525
 
@@ -2476,7 +2540,9 @@ class CartWeightCube(CartDataCube):
2476
2540
  fill_value='extrapolate')(self.ru)
2477
2541
 
2478
2542
  if scale_freqs:
2479
- sefd_freqs = self.estimate_freqs_sefd(noise_cube)
2543
+ sefd_freqs = self.estimate_freqs_sefd(
2544
+ noise_cube, n_pol_averaged=n_pol_averaged,
2545
+ )
2480
2546
  self.uv_scale = self.uv_scale * (np.nanmedian(sefd_freqs) / sefd_freqs)[:, None] ** 2
2481
2547
 
2482
2548
  self.uv_scale[~np.isfinite(self.uv_scale)] = 1
@@ -2499,43 +2565,63 @@ class CartWeightCube(CartDataCube):
2499
2565
  """Reset ``uv_scale`` to one."""
2500
2566
  self.uv_scale = np.ones((1, len(self.uu)))
2501
2567
 
2568
+ def _noise_complex_std_per_unit_weight(self, sefd, time, n_pol_averaged=2,
2569
+ sefd_jansky=True, fake_apply_win_fct=False):
2570
+ """Return the full complex RMS before division by UV-cell weights."""
2571
+ _validate_n_pol_averaged(n_pol_averaged)
2572
+
2573
+ df = self.meta.chan_width
2574
+ int_time = self.meta.int_time
2575
+ total_time = self.meta.total_time
2576
+ f = time / total_time
2577
+
2578
+ noise_complex_std = np.atleast_1d(
2579
+ sefd / (n_pol_averaged * df * int_time * f) ** 0.5,
2580
+ )[:, None]
2581
+
2582
+ if fake_apply_win_fct:
2583
+ # Scale as if a spatial tapering had been applied.
2584
+ noise_complex_std = noise_complex_std * self.meta.win_fct_power ** .5
2585
+
2586
+ if sefd_jansky:
2587
+ lamb = const.c.value / self.freqs
2588
+ fov = (self.meta.shape[0] * self.meta.res) ** 2
2589
+ jy2k = ((1e-26 * lamb ** 2) / (2 * const.k_B.value)) / fov
2590
+ noise_complex_std = jy2k[:, None] * noise_complex_std
2591
+
2592
+ return noise_complex_std
2593
+
2502
2594
  def simulate_noise(self, sefd, time, hermitian=True, weights_uncertainity_ratio=None,
2503
- sefd_jansky=True, fake_apply_win_fct=False, rng=None):
2595
+ sefd_jansky=True, fake_apply_win_fct=False, rng=None,
2596
+ n_pol_averaged=2):
2504
2597
  """Draw a thermal-noise visibility cube for a given SEFD and integration.
2505
2598
 
2506
2599
  Noise per mode follows the radiometer equation, scaled by these weights.
2507
2600
 
2508
2601
  Args:
2509
- sefd: system-equivalent flux density (Jy if ``sefd_jansky``, else K).
2602
+ sefd: single-polarization station SEFD (Jy if ``sefd_jansky``,
2603
+ else K).
2510
2604
  time: total integration time, in seconds.
2511
2605
  hermitian (bool): make the realization Hermitian (real image).
2512
2606
  weights_uncertainity_ratio: if set, also perturb the weights randomly
2513
2607
  by up to this ratio (see :meth:`random_scale`).
2514
2608
  sefd_jansky (bool): interpret ``sefd`` in Jy (else Kelvin).
2515
2609
  fake_apply_win_fct (bool): scale as if a spatial taper were applied.
2610
+ n_pol_averaged: number of independent polarization products averaged
2611
+ into the simulated visibilities (one for XX/YY, two for Stokes I).
2516
2612
 
2517
2613
  Returns:
2518
2614
  CartDataCube: the simulated noise cube.
2519
2615
  """
2520
- df = self.meta.chan_width
2521
- int_time = self.meta.int_time
2522
2616
  total_time = self.meta.total_time
2523
2617
  f = time / total_time
2524
2618
 
2525
- noise_rms = np.atleast_1d(sefd / (2 * df * int_time * f) ** 0.5)[:, None]
2526
-
2527
- # Split full complex variance equally between real and imaginary parts.
2528
- noise_rms = np.sqrt(0.5) * noise_rms
2529
-
2530
- if fake_apply_win_fct:
2531
- # Scale noise_rms as if a spatial tapering had been applied.
2532
- noise_rms = noise_rms * self.meta.win_fct_power ** .5
2533
-
2534
- if sefd_jansky:
2535
- lamb = const.c.value / self.freqs
2536
- fov = (self.meta.shape[0] * self.meta.res) ** 2
2537
- jy2k = ((1e-26 * lamb ** 2) / (2 * const.k_B.value)) / fov
2538
- noise_rms = jy2k[:, None] * noise_rms
2619
+ noise_complex_std = self._noise_complex_std_per_unit_weight(
2620
+ sefd, time, n_pol_averaged=n_pol_averaged,
2621
+ sefd_jansky=sefd_jansky,
2622
+ fake_apply_win_fct=fake_apply_win_fct,
2623
+ )
2624
+ component_std = noise_complex_std / np.sqrt(2)
2539
2625
 
2540
2626
  rng = np.random.default_rng(rng)
2541
2627
  w = self.get() ** 0.5
@@ -2554,11 +2640,13 @@ class CartWeightCube(CartDataCube):
2554
2640
 
2555
2641
  hs = (self.data.shape[0], len(idx1))
2556
2642
  noise_data = np.zeros_like(self.data, dtype=np.complex128)
2557
- noise_data[:, idx1] = noise_rms / w[:, idx1] * \
2643
+ noise_data[:, idx1] = component_std / w[:, idx1] * \
2558
2644
  (rng.standard_normal(hs) + 1j * rng.standard_normal(hs))
2559
2645
  noise_data[:, idx2] = np.conj(noise_data[:, idx1])
2560
2646
  else:
2561
- noise_data = noise_rms / w * (rng.standard_normal(s) + 1j * rng.standard_normal(s))
2647
+ noise_data = component_std / w * (
2648
+ rng.standard_normal(s) + 1j * rng.standard_normal(s)
2649
+ )
2562
2650
 
2563
2651
  weights = self.new_with_data(self.data * f)
2564
2652
 
@@ -2570,37 +2658,31 @@ class CartWeightCube(CartDataCube):
2570
2658
 
2571
2659
  return noise_cube
2572
2660
 
2573
- def get_noise_std_cube(self, sefd, time, fake_apply_win_fct=False):
2661
+ def get_noise_std_cube(self, sefd, time, fake_apply_win_fct=False,
2662
+ n_pol_averaged=2):
2574
2663
  """The *expected* per-mode thermal-noise standard deviation (not a
2575
2664
  realization -- see :meth:`simulate_noise` for that).
2576
2665
 
2577
2666
  Args:
2578
- sefd: system-equivalent flux density, in Jy.
2667
+ sefd: single-polarization station SEFD, in Jy.
2579
2668
  time: total integration time, in seconds.
2580
2669
  fake_apply_win_fct (bool): scale as if a spatial taper were applied.
2670
+ n_pol_averaged: number of independent polarization products averaged
2671
+ into the visibility cube (one for XX/YY, two for Stokes I).
2581
2672
 
2582
2673
  Returns:
2583
2674
  NoiseStdCube: the per-mode noise standard deviation.
2584
2675
  """
2585
- df = self.meta.chan_width
2586
- int_time = self.meta.int_time
2587
2676
  total_time = self.meta.total_time
2588
2677
  f = time / total_time
2589
2678
 
2590
- noise_rms = np.atleast_1d(sefd / (2 * df * int_time * f) ** 0.5)
2591
-
2592
- lamb = const.c.value / self.freqs
2593
- fov = (self.meta.shape[0] * self.meta.res) ** 2
2594
- jy2k = ((1e-26 * lamb ** 2) / (2 * const.k_B.value)) / fov
2595
- noise_rms = (jy2k * noise_rms)[:, None]
2596
-
2679
+ noise_rms = self._noise_complex_std_per_unit_weight(
2680
+ sefd, time, n_pol_averaged=n_pol_averaged,
2681
+ fake_apply_win_fct=fake_apply_win_fct,
2682
+ )
2597
2683
  w = self.get() ** 0.5
2598
2684
  noise_rms = noise_rms / w
2599
2685
 
2600
- if fake_apply_win_fct:
2601
- # Scale noise_rms as if a spatial tapering had been applied.
2602
- noise_rms = noise_rms * self.meta.win_fct_power ** .5
2603
-
2604
2686
  weights = self.new_with_data(self.data * f)
2605
2687
 
2606
2688
  noise_cube = NoiseStdCube(noise_rms, self.uu, self.vv, self.freqs.copy(),
@@ -234,11 +234,12 @@ def nearest_positive_definite(A, maxtries=10):
234
234
  nearest_postive_definite = nearest_positive_definite
235
235
 
236
236
 
237
- def get_samples(X, n, K, complex_type=True, nearest_pd=True, method='eigh'):
237
+ def get_samples(X, n, K, complex_type=True, nearest_pd=True, method='eigh', rng=None):
238
238
  """Draw ``n`` Gaussian realizations with covariance ``K``.
239
239
 
240
240
  ``X`` supplies the mean-vector length. The result has shape
241
- ``(len(X), n)`` and is complex unless ``complex_type=False``. Raises
241
+ ``(len(X), n)`` and is complex unless ``complex_type=False``. ``rng`` is a
242
+ seed, a :class:`numpy.random.Generator`, or None for a fresh draw. Raises
242
243
  :class:`numpy.linalg.LinAlgError` if K is not finite; with
243
244
  ``nearest_pd=True``, a non-positive-definite matrix is repaired first.
244
245
  """
@@ -246,7 +247,7 @@ def get_samples(X, n, K, complex_type=True, nearest_pd=True, method='eigh'):
246
247
  raise np.linalg.LinAlgError('covariance contains non-finite values')
247
248
  if nearest_pd and not is_positive_definite(K):
248
249
  K = nearest_positive_definite(K)
249
- rg = np.random.default_rng()
250
+ rg = np.random.default_rng(rng)
250
251
  n_draws = 2 * n if complex_type else n
251
252
  draws = rg.multivariate_normal(
252
253
  np.zeros_like(X).squeeze(), K, n_draws, method=method).T
@@ -55,10 +55,12 @@ class MLGPRForegroundFitter(fgfit.AbstractForegroundFitter):
55
55
 
56
56
  if self.config.kern.noise.use_simulated_noise_cube:
57
57
  if self.config.kern.noise.use_sefd_freqs_estimate:
58
- sefd = noise_cube.estimate_freqs_sefd(sefd_poly_fit_deg=1)
58
+ sefd = noise_cube.estimate_freqs_sefd(
59
+ sefd_poly_fit_deg=1, n_pol_averaged=2,
60
+ )
59
61
  print(f'Using simulated noise with mean SEFD={sefd.mean():.1f} Jy')
60
62
  else:
61
- sefd = noise_cube.estimate_sefd()
63
+ sefd = noise_cube.estimate_sefd(n_pol_averaged=2)
62
64
  print(f'Using simulated noise with SEFD={sefd:.1f} Jy')
63
65
 
64
66
  w = noise_cube.weights.copy()
@@ -67,12 +69,16 @@ class MLGPRForegroundFitter(fgfit.AbstractForegroundFitter):
67
69
  sefd_filter_n_bins=sefd_filter_n_bins)
68
70
 
69
71
  if self.config.kern.noise.use_noise_std:
70
- noise_cube = w.get_noise_std_cube(sefd, noise_cube.meta.total_time,
71
- fake_apply_win_fct=True)
72
+ noise_cube = w.get_noise_std_cube(
73
+ sefd, noise_cube.meta.total_time,
74
+ fake_apply_win_fct=True, n_pol_averaged=2,
75
+ )
72
76
  noise_cube.weights = w
73
77
  else:
74
- noise_cube = w.simulate_noise(sefd, noise_cube.meta.total_time,
75
- fake_apply_win_fct=True)
78
+ noise_cube = w.simulate_noise(
79
+ sefd, noise_cube.meta.total_time,
80
+ fake_apply_win_fct=True, n_pol_averaged=2,
81
+ )
76
82
 
77
83
  return noise_cube
78
84
 
@@ -16,7 +16,7 @@ Sensitivity models can be used independently of a UV simulation::
16
16
 
17
17
  freqs = np.arange(50, 80, .2) * 1e6
18
18
  telescope = obssimu.Telescope.from_name('nenufar')
19
- stokes_i_sefd = telescope.get_i_sefd(freqs)
19
+ single_pol_sefd = telescope.get_sefd(freqs)
20
20
 
21
21
  For an array with an available station layout, configure the declination, hour
22
22
  angle range, and time resolution before choosing a gridding strategy::
@@ -34,7 +34,10 @@ angle range, and time resolution before choosing a gridding strategy::
34
34
  fov_deg=telescope.fov,
35
35
  min_weight=1,
36
36
  )
37
- noise_std = coverage.get_noise_std_cube(total_time_sec=100 * 3600)
37
+ noise_std = coverage.get_noise_std_cube(
38
+ total_time_sec=100 * 3600,
39
+ n_pol_averaged=2,
40
+ )
38
41
 
39
42
  The same :class:`SimuGridded` object provides a power-spectrum estimator whose
40
43
  frequency range, UV limits, primary beam, and default weights match the
@@ -67,6 +70,7 @@ wavelengths, angles in degrees unless a parameter explicitly ends in
67
70
 
68
71
  import itertools
69
72
  import os
73
+ import warnings
70
74
  from enum import Enum, auto
71
75
  from importlib.resources import files as resource_files
72
76
  from typing import ClassVar
@@ -231,7 +235,7 @@ class Telescope:
231
235
  """
232
236
 
233
237
  def get_i_sefd(self, freq):
234
- """Return the Stokes-I SEFD in Jy.
238
+ """Return the effective Stokes-I SEFD in Jy (deprecated).
235
239
 
236
240
  Args:
237
241
  freq (np.ndarray or float): Frequency in Hz.
@@ -239,6 +243,12 @@ class Telescope:
239
243
  Returns:
240
244
  ndarray or float: Stokes-I SEFD in Jy.
241
245
  """
246
+ warnings.warn(
247
+ 'get_i_sefd() is deprecated; use get_sefd() and specify '
248
+ 'n_pol_averaged instead.',
249
+ DeprecationWarning,
250
+ stacklevel=2,
251
+ )
242
252
 
243
253
  return 1 / np.sqrt(2) * self.get_sefd(freq)
244
254
 
@@ -317,7 +327,6 @@ class DEx(Telescope):
317
327
  fov = 120
318
328
  du = 1
319
329
  only_drift_mode = True
320
- pb_name = name
321
330
  coord_type = CoordinateType.ENU
322
331
  location = EarthLocation(lon=116.67081524 * units.deg, lat=-26.70122102627586 * units.deg,
323
332
  height=0.0 * units.m)
@@ -333,6 +342,19 @@ class DEx(Telescope):
333
342
  self.sep_antenna = sep_antenna
334
343
  self.n_antenna_side = n_antenna_side
335
344
 
345
+ @property
346
+ def pb_name(self):
347
+ """Dipole beam name, following the configured antenna separation.
348
+
349
+ :meth:`get_sefd` models one element as an aperture of diameter
350
+ ``sep_antenna`` (see :meth:`Telescope.get_dipole_aeff`), so the beam
351
+ uses the same aperture. The tapering matches the other dipole arrays
352
+ here (``a12_lba``, ``ovro_lwa``). Unlike every other telescope the
353
+ element geometry is a constructor argument, so this cannot be a fixed
354
+ class attribute.
355
+ """
356
+ return f'ant_{self.sep_antenna}_1.1_gaussian'
357
+
336
358
  def get_stat_pos(self):
337
359
  """Generate ENU antenna positions for the square layout.
338
360
 
@@ -373,17 +395,31 @@ class SkaLow(Telescope):
373
395
  """Return the packaged SKA-Low AA4 ENU station layout."""
374
396
  return os.path.join(INSTRU_DIR, 'ska1_low_enu_statpos.data')
375
397
 
376
- def get_sefd(self, freq, tsys_sky=60):
377
- """The interpolated single-polarization SKA-Low SEFD in Jy."""
378
- # SKA LFAA station specification, arXiv:2003.12744, p. 22.
379
- freqs_spec = np.array([50, 80, 110, 140, 160, 220]) * 1e6
380
- a_eff_over_tsys_spec = 1 * np.array([0.14, 0.46, 1.04, 1.15, 1.2, 1.2])
381
- def t_sky_fct(freqs): return tsys_sky * (3e8 / freqs) ** 2.55
382
- a_eff_fct = scipy.interpolate.interp1d(
383
- freqs_spec, a_eff_over_tsys_spec * t_sky_fct(freqs_spec), kind='slinear',
384
- bounds_error=False, fill_value='extrapolate')
398
+ # AAVS2-based SKAO station-sensitivity lookup at zenith, effectively
399
+ # averaged over cold-sky LST 0-8 h. XX and YY are averaged over time
400
+ # independently following ska-ost-senscalc, then stored in a packaged CSV.
401
+ _sefd_table = np.genfromtxt(
402
+ os.path.join(INSTRU_DIR, 'ska_low_aavs2_sefd.csv'),
403
+ delimiter=',',
404
+ names=True,
405
+ )
406
+ _sefd_freqs = _sefd_table['frequency_mhz'] * 1e6
407
+ _sefd_xx_interpolator = scipy.interpolate.UnivariateSpline(
408
+ _sefd_freqs, _sefd_table['sefd_xx_jy'], s=0,
409
+ )
410
+ _sefd_yy_interpolator = scipy.interpolate.UnivariateSpline(
411
+ _sefd_freqs, _sefd_table['sefd_yy_jy'], s=0,
412
+ )
413
+
414
+ def get_sefd(self, freq):
415
+ """The interpolated single-polarization-equivalent SKA-Low SEFD in Jy."""
416
+ sefd_xx = self._sefd_xx_interpolator(freq)
417
+ sefd_yy = self._sefd_yy_interpolator(freq)
385
418
 
386
- return 2 * const.k_B.value * 1e26 * t_sky_fct(freq) / a_eff_fct(freq)
419
+ # ska-ost-senscalc defines SEFD_I = hypot(SEFD_XX, SEFD_YY) / 2.
420
+ # Returning the equivalent single-polarization value keeps
421
+ # get_noise_std_cube(..., n_pol_averaged=2) consistent with SEFD_I.
422
+ return np.hypot(sefd_xx, sefd_yy) / np.sqrt(2)
387
423
 
388
424
 
389
425
  class SkaLowAAstar(SkaLow):
@@ -1083,11 +1119,17 @@ class SimuGridded:
1083
1119
  return np.atleast_1d(self.telescope_simu.telescop.get_sefd(self.weights.freqs))
1084
1120
 
1085
1121
  def get_i_sefd(self):
1086
- """The Stokes-I SEFD across the simulated frequencies.
1122
+ """The effective Stokes-I SEFD across frequencies (deprecated).
1087
1123
 
1088
1124
  Returns:
1089
1125
  ndarray: Stokes-I SEFD in Jy.
1090
1126
  """
1127
+ warnings.warn(
1128
+ 'get_i_sefd() is deprecated; use get_sefd() and specify '
1129
+ 'n_pol_averaged instead.',
1130
+ DeprecationWarning,
1131
+ stacklevel=2,
1132
+ )
1091
1133
 
1092
1134
  return 1 / np.sqrt(2) * self.get_sefd()
1093
1135
 
@@ -1135,7 +1177,8 @@ class SimuGridded:
1135
1177
 
1136
1178
  return pspec.PowerSpectraCart(eor, ps_conf, pb)
1137
1179
 
1138
- def get_noise_std_cube(self, total_time_sec, sefd=None, min_weight=1):
1180
+ def get_noise_std_cube(self, total_time_sec, sefd=None, min_weight=1,
1181
+ n_pol_averaged=2):
1139
1182
  """Estimate the thermal-noise standard deviation in every UV cell.
1140
1183
 
1141
1184
  A window recorded during gridding is included through its equivalent
@@ -1146,21 +1189,26 @@ class SimuGridded:
1146
1189
 
1147
1190
  Args:
1148
1191
  total_time_sec: Target total integration time in seconds.
1149
- sefd: Optional Stokes-I SEFD in Jy, scalar or one value per
1150
- frequency. By default use the telescope model.
1192
+ sefd: Optional single-polarization station SEFD in Jy, scalar or
1193
+ one value per frequency. By default use the telescope model.
1151
1194
  min_weight: Remove cells whose weight is below this value.
1195
+ n_pol_averaged: number of independent polarization products averaged
1196
+ into the visibility cube (one for XX/YY, two for Stokes I).
1152
1197
 
1153
1198
  Returns:
1154
1199
  ps_eor.datacube.NoiseStdCube: Real standard deviation per frequency
1155
1200
  and UV cell.
1156
1201
  """
1157
1202
  if sefd is None:
1158
- sefd = self.get_i_sefd()
1203
+ sefd = self.get_sefd()
1159
1204
 
1160
1205
  fake_apply_win_fct = 'PEWINFCT' in self.weights.meta
1161
1206
 
1162
- noise_std = self.weights.get_noise_std_cube(sefd, total_time_sec,
1163
- fake_apply_win_fct=fake_apply_win_fct)
1207
+ noise_std = self.weights.get_noise_std_cube(
1208
+ sefd, total_time_sec,
1209
+ fake_apply_win_fct=fake_apply_win_fct,
1210
+ n_pol_averaged=n_pol_averaged,
1211
+ )
1164
1212
  noise_std.filter_min_weight(min_weight)
1165
1213
 
1166
1214
  return noise_std
@@ -37,9 +37,10 @@ def _bind_signal_kern(kern, uv_bins, mean_fmhz):
37
37
  'expected UVScaledKernel, VAEKernTorch, or a sum of those')
38
38
 
39
39
 
40
- def get_multi_samples(X, ns, Ks):
40
+ def get_multi_samples(X, ns, Ks, rng=None):
41
41
  """Draw ns[i] realizations from each per-uv-bin covariance Ks[i]."""
42
- return [get_samples(X, n, K) for n, K in zip(ns, Ks, strict=False)]
42
+ rng = np.random.default_rng(rng)
43
+ return [get_samples(X, n, K, rng=rng) for n, K in zip(ns, Ks, strict=False)]
43
44
 
44
45
 
45
46
  def _contains_product(kern):
@@ -95,28 +96,28 @@ class SimuMultiGPCube:
95
96
  simulated data can be compared with, or added to, that cube."""
96
97
  return SimuMultiGPCube(cube, **kwargs)
97
98
 
98
- def get_from_kern(self, kern):
99
+ def get_from_kern(self, kern, rng=None):
99
100
  """Cube drawn from a kernel's covariance.
100
101
 
101
102
  Takes a UVScaledKernel, VAEKernTorch, or a sum of those; binding it to
102
103
  this geometry is done here, so it may be passed unbound.
103
104
  """
104
105
  fmhz = self.template.freqs * 1e-6
105
- return self._draw(self._K_from_kern(kern, fmhz))
106
+ return self._draw(self._K_from_kern(kern, fmhz), rng=rng)
106
107
 
107
- def get_from_K(self, K):
108
+ def get_from_K(self, K, rng=None):
108
109
  """Cube drawn from an explicit (n_bins, n_freq, n_freq) covariance."""
109
- return self._draw(K)
110
+ return self._draw(K, rng=rng)
110
111
 
111
- def get_from_ps3d(self, k_mean, ps3d):
112
+ def get_from_ps3d(self, k_mean, ps3d, rng=None):
112
113
  """Cube drawn from a spherically averaged 21-cm power spectrum.
113
114
 
114
115
  ps3d is delta(k) on the k_mean grid. Requires a simulator built with
115
116
  :meth:`new`, since the normalization needs res and n_pix.
116
117
  """
117
- return self._draw(self._K_from_ps(k_mean, ps3d, self.template.freqs))
118
+ return self._draw(self._K_from_ps(k_mean, ps3d, self.template.freqs), rng=rng)
118
119
 
119
- def get_noise(self, variance):
120
+ def get_noise(self, variance, rng=None):
120
121
  """White-noise cube of constant variance.
121
122
 
122
123
  Use this to make the noise cube a fit needs;
@@ -125,9 +126,9 @@ class SimuMultiGPCube:
125
126
  """
126
127
  n_freq = len(self.template.freqs)
127
128
  K = variance * np.array([np.eye(n_freq)] * len(self.uv_bins))
128
- return self.get_from_K(K)
129
+ return self.get_from_K(K, rng=rng)
129
130
 
130
- def get_epochs_from_kern(self, kern, epoch_times):
131
+ def get_epochs_from_kern(self, kern, epoch_times, rng=None):
131
132
  """One cube per epoch, with the cross-epoch coherence a TimeKron implies.
132
133
 
133
134
  Takes a TimeKron expression and epoch times in days. Sums are drawn
@@ -135,7 +136,10 @@ class SimuMultiGPCube:
135
136
  coherent epochs. Expressions containing a product are drawn from their
136
137
  full joint epoch-frequency covariance, since a product of sums is not
137
138
  separable component by component.
139
+
140
+ ``rng`` is a seed, a :class:`numpy.random.Generator`, or None.
138
141
  """
142
+ rng = np.random.default_rng(rng)
139
143
  comps = list(_iter_leaf_kernels(kern))
140
144
  if not all(isinstance(c, TimeKron) for c in comps):
141
145
  raise TypeError('get_epochs_from_kern expects a TimeKron or a sum of TimeKrons')
@@ -151,7 +155,7 @@ class SimuMultiGPCube:
151
155
  K_joint, joint_x = self._K_from_time_kern(kern, fmhz, times)
152
156
  n_f = len(fmhz)
153
157
  for K_i, idx in zip(K_joint, idxs, strict=False):
154
- draw = get_samples(joint_x, int(np.sum(idx)), K_i)
158
+ draw = get_samples(joint_x, int(np.sum(idx)), K_i, rng=rng)
155
159
  for t, cube in enumerate(cubes):
156
160
  cube.data[:, idx] = draw[t * n_f:(t + 1) * n_f]
157
161
  return cubes
@@ -182,7 +186,8 @@ class SimuMultiGPCube:
182
186
 
183
187
  for K_i, idx in zip(K_f, idxs, strict=False):
184
188
  n = int(np.sum(idx))
185
- Z = {r: get_samples(fmhz, n, K_i) for r in active} # independent (n_f, n) draws
189
+ Z = {r: get_samples(fmhz, n, K_i, rng=rng)
190
+ for r in active} # independent (n_f, n) draws
186
191
  for t in range(n_t):
187
192
  for r in active:
188
193
  if abs(L_t[t, r]) > 1e-12:
@@ -205,12 +210,13 @@ class SimuMultiGPCube:
205
210
  with torch.no_grad():
206
211
  return kern(x, x).to_dense().numpy(), joint_x
207
212
 
208
- def _draw(self, K):
213
+ def _draw(self, K, rng=None):
214
+ rng = np.random.default_rng(rng)
209
215
  c = self.template.new_with_data(np.zeros_like(self.template.data))
210
216
  fmhz = c.freqs * 1e-6
211
217
  idxs = get_uv_bin_masks(c.ru, self.uv_bins)
212
218
  for idx, Ki in zip(idxs, K, strict=False):
213
- c.data[:, idx] = get_samples(fmhz, np.sum(idx), Ki)
219
+ c.data[:, idx] = get_samples(fmhz, np.sum(idx), Ki, rng=rng)
214
220
  return c
215
221
 
216
222
  def _K_from_kern(self, kern, fmhz):
@@ -1194,8 +1194,10 @@ def simu_noise_img(uv_simu_h5, total_time_hour, out_filename, min_weight):
1194
1194
  print(f'Simulating thermal noise with mean SEFD = {uv_simu.get_sefd().mean()} Jy ...')
1195
1195
  print(f'{len(freqs)} frequency channels between {freqs.min() * 1e-6:.1f} and '
1196
1196
  f'{freqs.max() * 1e-6:.1f} MHz')
1197
- noise_cube = uv_simu.weights.simulate_noise(uv_simu.get_sefd(), total_time_hour * 3600,
1198
- hermitian=False)
1197
+ noise_cube = uv_simu.weights.simulate_noise(
1198
+ uv_simu.get_sefd(), total_time_hour * 3600,
1199
+ hermitian=False, n_pol_averaged=2,
1200
+ )
1199
1201
  noise_cube.filter_min_weight(min_weight, replace=True)
1200
1202
  noise_img = noise_cube.image()
1201
1203
  print(f'RMS noise @ mid frequency {noise_img.data[len(freqs) // 2].std()} Kelvin')
@@ -1219,7 +1221,7 @@ def simu_noise_img(uv_simu_h5, total_time_hour, out_filename, min_weight):
1219
1221
  @click.option('--filter_kpar_min', help='Filter mode below kpar_min', default=None, type=float)
1220
1222
  @click.option('--filter_wedge_theta', help='Filter mode below the wedge line', default=0,
1221
1223
  type=float)
1222
- @click.option('--sefd', help='System Equivalent Flux Density (Jy)', default=None, type=float)
1224
+ @click.option('--sefd', help='Single-polarization station SEFD (Jy)', default=None, type=float)
1223
1225
  @click.option('--n_incoherent_avg', help='Number of incoherent averaging', default=1, type=int)
1224
1226
  @click.option('--min_weight', help='Minimum number of visibilities in a uv-cell', default=10,
1225
1227
  type=int, show_default=True)
@@ -1254,7 +1256,10 @@ def simu_noise_ps(uv_simu_h5, total_time_hour, out_filename, kmin, kmax, nks,
1254
1256
 
1255
1257
  print(f'{len(freqs)} frequency channels between {freqs.min() * 1e-6:.1f} and '
1256
1258
  f'{freqs.max() * 1e-6:.1f} MHz')
1257
- noise_std = uv_simu.get_noise_std_cube(total_time_hour * 3600, sefd=sefd, min_weight=min_weight)
1259
+ noise_std = uv_simu.get_noise_std_cube(
1260
+ total_time_hour * 3600, sefd=sefd,
1261
+ min_weight=min_weight, n_pol_averaged=2,
1262
+ )
1258
1263
  pss = {}
1259
1264
  pss['ps'] = ps_gen.get_ps(noise_std)
1260
1265
  pss['ps2d'] = ps_gen.get_ps2d(noise_std)
@@ -1316,7 +1321,12 @@ def simu_noise_ps_zrange(uv_simu_h5, total_time_hour, k, out_filename, bandwidth
1316
1321
  simu_gridded = obssimu.SimuGridded.load(uv_simu_h5)
1317
1322
  z_max = np.round(psutil.freq_to_z(simu_gridded.weights.freqs.min() + bandwidth / 2 * 1e6), 1)
1318
1323
  z_min = np.round(psutil.freq_to_z(simu_gridded.weights.freqs.max() - bandwidth / 2 * 1e6), 1)
1324
+ # arange's stop is exclusive, so the + delta_z is there to keep z_max. It
1325
+ # also admits one step past it whenever (z_max - z_min) is not a multiple of
1326
+ # delta_z, and that slice lands outside the coverage: an empty frequency
1327
+ # selection, and no delay modes to take a minimum over.
1319
1328
  zs = np.arange(z_min, z_max + delta_z, delta_z)
1329
+ zs = zs[zs <= z_max + 1e-9]
1320
1330
 
1321
1331
  k_bin = [(1 - dk_over_k / 2) * k, (1 + dk_over_k / 2) * k]
1322
1332
 
@@ -1334,11 +1344,15 @@ def simu_noise_ps_zrange(uv_simu_h5, total_time_hour, k, out_filename, bandwidth
1334
1344
  ps_gen = uv_simu_z.get_ps_gen(filter_kpar_min, filter_wedge_theta)
1335
1345
 
1336
1346
  print(f'Redshift z = {z}')
1337
- print(f'Simulating thermal noise with mean SEFD = {uv_simu_z.get_sefd().mean():.1f} Jy ...')
1347
+ sefd = uv_simu_z.get_sefd()
1348
+ print(f'Simulating thermal noise with mean SEFD = {sefd.mean():.1f} Jy ...')
1338
1349
  print(f'{len(freqs)} frequency channels between {freqs.min() * 1e-6:.1f} and '
1339
1350
  f'{freqs.max() * 1e-6:.1f} MHz')
1340
1351
 
1341
- noise_std = uv_simu_z.get_noise_std_cube(total_time_hour * 3600, min_weight=min_weight)
1352
+ noise_std = uv_simu_z.get_noise_std_cube(
1353
+ total_time_hour * 3600, sefd=sefd,
1354
+ min_weight=min_weight, n_pol_averaged=2,
1355
+ )
1342
1356
 
1343
1357
  ps3d = ps_gen.get_ps3d(k_bin, noise_std)
1344
1358
  if n_incoherent_avg > 1:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ps_eor"
7
- version = "1.0"
7
+ version = "1.2"
8
8
  description = "Power Spectra generation for 21-cm experiments and Foreground modeling/removal"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes