1414
1515
1616def compute_beam_basis (
17- beam_list : list [ UVBeam ] ,
17+ beam_list ,
1818 freq : float ,
1919 threshold : float = 1e-12 ,
20- polarized : bool = False ,
21- ) -> tuple [list [UVBeam ], np .ndarray ]:
22- """Decompose a set of UVBeams into an SVD eigenbeam basis.
23-
24- Each beam is first interpolated onto a common frequency grid to keep memory
25- usage predictable, then stacked into a matrix and SVD'd. The basis is
26- truncated at the first component whose normalised singular value drops below
27- ``threshold``.
28-
29- The decomposition satisfies::
30-
31- beam_i(az, za, freq) ≈ sum_k coeffs[i, k] * eigenbeams[k](az, za, freq)
32-
33- so ``eigenbeams`` can be passed directly as ``beam_list`` to
34- ``CPUSimulationEngine.simulate`` and ``coeffs`` as ``beam_coeffs``.
35-
36- Parameters
37- ----------
38- beam_list : list of UVBeam
39- Input beams to decompose. All beams must share the same spatial grid
40- (``Nax1``, ``Nax2``) and ``beam_type`` (``efield`` or ``power``).
41- freq : float
42- Frequency in Hz at which every beam is evaluated before the SVD.
43- threshold : float, optional
44- Normalised singular value cutoff. Basis components ``k`` with
45- ``s[k] / s[0] < threshold`` are discarded. Default ``1e-3``.
46- polarized : bool, optional
47- If ``True``, the full Jones matrix (all ``Naxes_vec`` and ``Nfeeds``
48- components) is included in the SVD. If ``False``, only the first
49- component ``data_array[0, 0, 0, ...]`` is used, matching the scalar
50- beam path in the simulator. Default ``False``.
51-
52- Returns
53- -------
54- eigenbeams : list of UVBeam
55- The ``K`` truncated eigenbeam objects. Each has the same metadata and
56- spatial/frequency grid as the interpolated input beams.
57- coeffs : np.ndarray
58- Per-antenna coefficients of shape ``(N_beams, K)``. These are the
59- values to pass as ``beam_coeffs`` to the simulator.
60-
61- Notes
62- -----
63- The function allocates two large intermediate arrays simultaneously: the
64- full stacked beam matrix ``B`` of shape ``(N_beams, N_flat)`` and the ``Vh``
65- factor of the same shape. Peak memory is therefore roughly
66- ``2 * N_beams * N_flat * itemsize``. Choosing a sparse ``freq_grid``
67- directly reduces ``N_flat``.
68-
69- Only ``efield`` beams have been tested; ``power`` beams should work but
70- the resulting eigenbeams will have real-valued ``data_array`` only if all
71- inputs are real.
20+ ):
21+ """
7222 """
7323 if len (beam_list ) == 0 :
7424 raise ValueError ("beam_list must contain at least one beam." )
7525
7626 n_beams = len (beam_list )
7727 freq_grid = np .atleast_1d (freq )
7828
79- # ------------------------------------------------------------------
80- # Step 1: Interpolate every beam to the common frequency grid.
81- # ------------------------------------------------------------------
82- logger .info (
83- f"Interpolating { n_beams } beams to { len (freq_grid )} frequencies "
84- f"({ freq_grid [0 ]/ 1e6 :.1f} –{ freq_grid [- 1 ]/ 1e6 :.1f} MHz)."
85- )
8629 interp_beams = []
8730 for idx , beam in enumerate (beam_list ):
8831 interp_beams .append (
8932 beam .interp (freq_array = freq_grid , new_object = True )
9033 )
91- logger .debug (f" Interpolated beam { idx + 1 } /{ n_beams } ." )
9234
93- # ------------------------------------------------------------------
94- # Step 2: Extract the relevant slice of data_array and flatten.
95- #
96- # data_array shape: (Naxes_vec, 1, Nfeeds, Nfreqs, Nax1, Nax2)
97- #
98- # Polarized → use axes_vec and feeds: data_array[:, 0, :, ...]
99- # flat shape per beam: Naxes_vec * Nfeeds * Nfreqs * Nax1 * Nax2
100- # Unpolarized → match evaluate_beam scalar path: data_array[0, 0, 0, ...]
101- # flat shape per beam: Nfreqs * Nax1 * Nax2
102- # ------------------------------------------------------------------
10335 ref = interp_beams [0 ]
10436
105- if polarized :
106- # Shape of the slice we care about: (Naxes_vec, Nfeeds, Nfreqs, Nax1, Nax2)
107- slice_shape = ref .data_array [:, 0 , :, :, :, :].shape
108- flat_vecs = np .array (
109- [b .data_array [:, 0 , :, :, :, :].ravel () for b in interp_beams ]
110- ) # (N_beams, Naxes_vec * Nfeeds * Nfreqs * Nax1 * Nax2)
111- else :
112- # Shape: (Nfreqs, Nax1, Nax2)
113- slice_shape = ref .data_array [0 , 0 , 0 , :, :, :].shape
114- flat_vecs = np .array (
115- [b .data_array [0 , 0 , 0 , :, :, :].ravel () for b in interp_beams ]
116- ) # (N_beams, Nfreqs * Nax1 * Nax2)
117-
118- logger .info (
119- f"Beam matrix shape: { flat_vecs .shape } "
120- f"({ flat_vecs .nbytes / 1024 ** 2 :.1f} MB)."
121- )
37+ # Shape of the slice we care about:
38+ # (Naxes_vec, Nfeeds, Nfreqs, Nax1) for healpix beams
39+ # (Naxes_vec, Nfeeds, Nfreqs, Nax1, Nax2) for gridded beams
40+ slice_shape = ref .data_array [:, :, 0 ].shape
41+ flat_vecs = np .array (
42+ [b .data_array [:, :, 0 ].ravel () for b in interp_beams ]
43+ ) # (N_beams, Naxes_vec * Nfeeds * Nfreqs * Nax1 * Nax2)
12244
12345 # ------------------------------------------------------------------
12446 # Step 3: SVD.
12547 # B = U @ diag(s) @ Vh → beam_i ≈ sum_k (U[i,k]*s[k]) * Vh[k]
12648 # ------------------------------------------------------------------
127- logger .info ("Computing SVD..." )
12849 U , s , Vh = np .linalg .svd (flat_vecs , full_matrices = False )
12950
13051 # ------------------------------------------------------------------
13152 # Step 4: Truncate at normalised singular value threshold.
13253 # ------------------------------------------------------------------
13354 s_norm = s / s [0 ]
13455 K = int (np .sum (s_norm >= threshold ))
135- logger .info (
136- f"Retaining { K } /{ len (s )} basis components "
137- f"(threshold={ threshold } , "
138- f"min retained s_norm={ s_norm [K - 1 ]:.3e} )."
139- )
14056
14157 U_k = U [:, :K ] # (N_beams, K)
14258 s_k = s [:K ] # (K,)
14359 Vh_k = Vh [:K , :] # (K, N_flat)
14460
14561 # ------------------------------------------------------------------
14662 # Step 5: Compute per-antenna coefficients.
147- # coeffs [i, k] = U[i, k] * s[k] so that flat_vecs ≈ coeffs @ Vh_k
63+ # beam_coefs [i, k] = U[i, k] * s[k] so that flat_vecs ≈ beam_coefs @ Vh_k
14864 # ------------------------------------------------------------------
149- coeffs = U_k * s_k [None , :] # (N_beams, K)
150-
65+ beam_coefs = U_k * s_k [None , :] # (N_beams, K)
66+
15167 # ------------------------------------------------------------------
15268 # Step 6: Build eigenbeam UVBeam objects by copying reference metadata
15369 # and replacing data_array with the reshaped Vh rows.
@@ -156,16 +72,7 @@ def compute_beam_basis(
15672 for k in range (K ):
15773 eb = ref .copy ()
15874 eigenbeam_slice = Vh_k [k ].reshape (slice_shape )
159-
160- if polarized :
161- # Restore the dropped size-1 axis: (Naxes_vec, 1, Nfeeds, Nfreqs, Nax1, Nax2)
162- eb .data_array = eigenbeam_slice [:, np .newaxis , :, :, :, :]
163- else :
164- # Restore to full data_array shape; fill non-primary components with zeros.
165- eb .data_array = np .zeros_like (ref .data_array )
166- eb .data_array [0 , 0 , 0 , :, :, :] = eigenbeam_slice
167-
75+ eb .data_array = eigenbeam_slice [:, :, np .newaxis ]
16876 eigenbeams .append (eb )
16977
170- logger .info (f"Basis computation complete: { K } eigenbeams." )
171- return eigenbeams , coeffs
78+ return eigenbeams , beam_coefs
0 commit comments