limTOD 1.3.0__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.
limTOD/HPW_filter.py ADDED
@@ -0,0 +1,785 @@
1
+ import logging
2
+ from typing import Any, List, Optional, Sequence, SupportsFloat, Tuple, Union, cast
3
+
4
+ import numpy as np
5
+ from scipy import signal
6
+ from scipy.linalg import solve, LinAlgError
7
+ from limTOD.simulator import truncate_stacked_beam, generate_sky2sys_projection
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def get_filtfilt_matrix(n_samples: int, b: np.ndarray, a: np.ndarray) -> np.ndarray:
13
+ """
14
+ More accurate matrix representation of filtfilt operation.
15
+ """
16
+
17
+ # Create matrix by applying filtfilt to each standard basis vector
18
+ H = np.zeros((n_samples, n_samples))
19
+
20
+ for i in range(n_samples):
21
+ e_i = np.zeros(n_samples)
22
+ e_i[i] = 1.0
23
+ H[:, i] = signal.filtfilt(b, a, e_i)
24
+
25
+ return H
26
+
27
+ def HP_filter_TOD(n_samples: int, dtime: float, cutoff_freq: float = 0.001,
28
+ filter_order: int = 4,
29
+ preserve_dc: bool = False) -> np.ndarray:
30
+ """
31
+ Apply high-pass Butterworth filter to the TOD.
32
+ Parameters:
33
+ -----------
34
+ n_samples : int
35
+ Number of samples in the TOD
36
+ dtime : float
37
+ Time interval between samples in seconds
38
+ cutoff_freq : float, default=0.001 Hz
39
+ Cutoff frequency for high-pass filter in unit of Hz
40
+ filter_order : int, default=4
41
+ Order of the Butterworth filter (typical range: 2-8)
42
+ Higher order = sharper cutoff but more edge effects
43
+ preserve_dc : bool, default=False
44
+ If True, add back the DC projection so the filter has unit gain
45
+ at ℓ=0 while still rejecting drifts between DC and the cutoff.
46
+ Constructed as H' = H (I - P) + P with P = J/n (the DC
47
+ projection). Useful only when the per-chunk mean is dominated
48
+ by sky (low-noise regime). In the realistic 1/f regime the
49
+ chunk mean is dominated by drift realisation, so preserve_dc
50
+ would let drift DC leak into the recovered map — keep the
51
+ default False unless you've verified this for your noise model.
52
+
53
+ Returns:
54
+ --------
55
+ HP_operator : array-like, shape (n_time, n_params)
56
+ High-pass filtered system temperature operator
57
+
58
+ """
59
+ # Design a high-pass Butterworth filter
60
+ fs = 1.0 / dtime
61
+ nyquist = fs / 2.0
62
+ normalized_cutoff = cutoff_freq / nyquist # Normalized cutoff frequency for high-pass filter
63
+
64
+ # Validate normalized cutoff frequency
65
+ if normalized_cutoff <= 0:
66
+ raise ValueError(f"Cutoff frequency must be positive. Got cutoff_freq={cutoff_freq} Hz")
67
+ if normalized_cutoff >= 1:
68
+ raise ValueError(
69
+ f"Cutoff frequency ({cutoff_freq} Hz) must be less than Nyquist frequency ({nyquist} Hz). "
70
+ f"Normalized cutoff = {normalized_cutoff:.3f} >= 1.0"
71
+ )
72
+
73
+ b, a = signal.butter(filter_order, normalized_cutoff, btype='high', analog=False)
74
+
75
+ H_exact = get_filtfilt_matrix(n_samples, b, a) # Exact matrix representation of filtfilt operation
76
+ if preserve_dc:
77
+ # H' x = H (I - P) x + P x = H (x - mean(x) 1) + mean(x) 1.
78
+ # Constants pass through unchanged; everything faster than the
79
+ # Butterworth cutoff is still attenuated.
80
+ P = np.ones((n_samples, n_samples)) / n_samples
81
+ H_exact = H_exact @ (np.eye(n_samples) - P) + P
82
+ return H_exact
83
+
84
+
85
+ def wiener_filter_map(
86
+ TOD: np.ndarray,
87
+ operator: np.ndarray,
88
+ noise_variance: Optional[Union[float, np.floating, np.ndarray]] = None,
89
+ prior_inv_cov: Optional[Union[float, np.ndarray]] = None,
90
+ guess: Optional[np.ndarray] = None,
91
+ regularization: float = 1e-12,
92
+ return_full_cov: bool = False,
93
+ rolling_variance: bool = True,
94
+ ) -> Union[Tuple[np.ndarray, np.ndarray], Tuple[np.ndarray, np.ndarray, np.ndarray]]:
95
+ """
96
+ Apply Wiener filtering for mapmaking from time-ordered data.
97
+
98
+ The Wiener filter solves: (A^T N^-1 A + S^-1)^-1 A^T N^-1 d
99
+ where A is the operator, N is noise covariance, S is signal covariance, d is data
100
+
101
+ Parameters:
102
+ -----------
103
+ TOD : array-like, shape (n_time,)
104
+ Time-ordered data to be mapped
105
+ operator : array-like, shape (n_time, n_pixels)
106
+ Pointing/beam operator mapping sky pixels to TOD samples
107
+ noise_variance : float or array-like, optional
108
+ Noise variance. If None, estimated from TOD
109
+ prior_inv_cov : float or array-like, optional
110
+ Inverse of Prior covariance for the parameters. If None, uses uninformative prior
111
+ regularization : float, default=1e-12
112
+ Regularization parameter to ensure matrix invertibility
113
+
114
+ Returns:
115
+ --------
116
+ sky_map : array, shape (n_pixels,)
117
+ Reconstructed sky map
118
+ uncertainty : array, shape (n_pixels,)
119
+ Per-pixel uncertainty (diagonal of covariance matrix)
120
+ """
121
+
122
+ # Convert inputs to numpy arrays
123
+ TOD = np.asarray(TOD)
124
+ operator = np.asarray(operator)
125
+
126
+ n_time, n_pixels = operator.shape
127
+
128
+ # Estimate noise variance if not provided
129
+ if noise_variance is None:
130
+ # Simple estimate: variance of high-pass filtered residuals
131
+ residual = TOD - operator @ np.linalg.pinv(operator) @ TOD
132
+ if rolling_variance:
133
+ # Cap the window at the TOD length: with the fixed window of 100
134
+ # samples, shorter TODs used to produce a truncated variance
135
+ # vector and an opaque matmul shape error downstream.
136
+ window_size = min(100, n_time)
137
+ half_window = window_size // 2
138
+
139
+ # Pad with first N and last N samples (reflected)
140
+ # This provides smoother boundaries than just repeating edge value
141
+ left_pad = residual[:half_window][::-1] # First N samples, reversed
142
+ right_pad = residual[-half_window:][::-1] # Last N samples, reversed
143
+ padded_residual = np.concatenate([left_pad, residual, right_pad])
144
+
145
+ # Apply rolling window to squared residuals
146
+ noise_variance = np.convolve(
147
+ padded_residual**2,
148
+ np.ones(window_size)/window_size,
149
+ mode='valid'
150
+ )
151
+
152
+ # Trim to exact length if needed
153
+ if len(noise_variance) > len(residual):
154
+ # Take the middle portion
155
+ excess = len(noise_variance) - len(residual)
156
+ start = excess // 2
157
+ noise_variance = noise_variance[start:start+len(residual)]
158
+ else:
159
+ noise_variance = np.var(residual)
160
+ logger.info("Estimated noise variance: %.6f", noise_variance)
161
+
162
+
163
+ # Create noise inverse covariance matrix (assume diagonal)
164
+ if np.isscalar(noise_variance):
165
+ N_inv = np.eye(n_time) / cast(float, noise_variance)
166
+ else:
167
+ noise_variance = np.asarray(noise_variance)
168
+ if len(noise_variance) != n_time:
169
+ raise ValueError(
170
+ f"noise_variance has length {len(noise_variance)} but the TOD "
171
+ f"has {n_time} samples"
172
+ )
173
+ # Convert to dense diagonal matrix for consistent matrix operations
174
+ N_inv = np.diag(1.0 / noise_variance)
175
+
176
+ # Create signal inverse covariance matrix
177
+ if prior_inv_cov is None:
178
+ S_inv: np.ndarray = np.zeros((n_pixels, n_pixels)) # Uninformative prior
179
+ elif np.isscalar(prior_inv_cov):
180
+ S_inv = np.eye(n_pixels) * cast(float, prior_inv_cov)
181
+ elif cast(np.ndarray, prior_inv_cov).ndim == 1:
182
+ # Convert to dense diagonal matrix for consistent matrix operations
183
+ S_inv = np.diag(np.asarray(prior_inv_cov))
184
+ elif cast(np.ndarray, prior_inv_cov).ndim == 2:
185
+ S_inv = np.asarray(prior_inv_cov)
186
+ else:
187
+ raise ValueError("prior_inv_cov must be a scalar, 1D array, or 2D array.")
188
+
189
+ if guess is None:
190
+ guess = np.zeros(n_pixels)
191
+ # Only override prior if none was provided
192
+ if prior_inv_cov is None:
193
+ S_inv = np.zeros((n_pixels, n_pixels)) # Uninformative prior
194
+ else:
195
+ guess = np.asarray(guess)
196
+ if len(guess) != n_pixels:
197
+ raise ValueError("Length of guess must match number of pixels.")
198
+
199
+ # Compute Wiener filter components
200
+ # If float type, transpose; if complex, conjugate transpose
201
+ if np.iscomplexobj(operator):
202
+ AtN = operator.conj().T @ N_inv # A^H N^-1
203
+ else:
204
+ AtN = operator.T @ N_inv # A^T N^-1
205
+ AtNA = AtN @ operator # A^dagger N^-1 A
206
+
207
+ # Add signal prior and regularization
208
+ covariance_inv = AtNA + S_inv + regularization * np.eye(n_pixels)
209
+
210
+ # Right-hand side: A^T N^-1 d + S^-1 mu
211
+ rhs = AtN @ TOD + S_inv @ guess
212
+
213
+ posterior_cov = None
214
+ try:
215
+ # Solve the linear system: (A^T N^-1 A + S^-1) x = A^T N^-1 d + S^-1 mu
216
+ sky_map = solve(covariance_inv, rhs, assume_a='pos')
217
+
218
+ # Compute uncertainties (diagonal of posterior covariance)
219
+ try:
220
+ posterior_cov = np.linalg.inv(covariance_inv)
221
+ uncertainty = np.sqrt(np.diag(posterior_cov))
222
+ except (LinAlgError, np.linalg.LinAlgError):
223
+ logger.warning(
224
+ "Could not compute full covariance matrix; using diagonal approximation."
225
+ )
226
+ uncertainty = 1.0 / np.sqrt(np.diag(covariance_inv))
227
+
228
+ except (LinAlgError, np.linalg.LinAlgError) as e:
229
+ logger.warning("Linear algebra error: %s; falling back to pseudo-inverse solution.", e)
230
+ sky_map = np.linalg.pinv(operator) @ TOD
231
+ uncertainty = np.ones(n_pixels) * np.nan
232
+
233
+ if return_full_cov:
234
+ # posterior_cov stayed None on the degraded paths (inv failure or the
235
+ # pseudo-inverse fallback) — returning it unbound used to NameError.
236
+ if posterior_cov is None:
237
+ raise np.linalg.LinAlgError(
238
+ "return_full_cov=True but the posterior covariance could not "
239
+ "be computed (the normal-equations matrix is numerically "
240
+ "singular); rerun with return_full_cov=False or increase "
241
+ "regularization/priors."
242
+ )
243
+ return sky_map, uncertainty, posterior_cov
244
+ else:
245
+ return sky_map, uncertainty
246
+
247
+
248
+
249
+
250
+ # Alternative simplified version for quick mapmaking
251
+ def simple_wiener_map(
252
+ TOD: np.ndarray,
253
+ operator: np.ndarray,
254
+ noise_var: Optional[Union[float, np.floating]] = None,
255
+ ) -> np.ndarray:
256
+ """
257
+ Simplified Wiener filter assuming uninformative signal prior.
258
+ Equivalent to: (A^T A + lambda*I)^-1 A^T d
259
+ """
260
+ if noise_var is None:
261
+ # Estimate from residuals
262
+ residual = TOD - operator @ np.linalg.pinv(operator) @ TOD
263
+ noise_var = np.var(residual)
264
+
265
+ AtA = operator.T @ operator
266
+ regularization = noise_var * 1e-6 # Small regularization
267
+
268
+ # Regularized normal equation
269
+ lhs = AtA + regularization * np.eye(AtA.shape[0])
270
+ rhs = operator.T @ TOD
271
+
272
+ sky_map = np.linalg.solve(lhs, rhs)
273
+
274
+ return sky_map
275
+
276
+
277
+
278
+ class HPW_mapmaking:
279
+ """
280
+ Map-making class for Time-Ordered Data (TOD) using high-pass filtering and Wiener filtering.
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ *,
286
+ beam_map: np.ndarray,
287
+ LST_deg_list_group: Union[np.ndarray, Sequence[np.ndarray]],
288
+ lat_deg: float,
289
+ azimuth_deg_list_group: Union[np.ndarray, Sequence[np.ndarray]],
290
+ elevation_deg_list_group: Union[np.ndarray, Sequence[np.ndarray]],
291
+ selfrot_deg_list_group: Optional[Union[np.ndarray, Sequence[np.ndarray]]] = None,
292
+ threshold: float = 0.01,
293
+ Tsys_others_operator_group: Optional[Sequence[np.ndarray]] = None,
294
+ nside_hires: Optional[int] = None,
295
+ nside_target: Optional[int] = None,
296
+ beam_truncate_frac_thres: Optional[float] = None
297
+ ) -> None:
298
+ """
299
+ Initialize the HPW_mapmaking class.
300
+
301
+ Parameters:
302
+ beam_map : array
303
+ The Healpix map of the beam pattern for a single frequency.
304
+ Input map can be:
305
+ a single array is considered I,
306
+ array with 3 rows:[I,Q,U]
307
+ array with 4 rows:[I,Q,U,V]
308
+
309
+ LST_deg_list_group : a LST list or a list of LST lists corresponding to each TOD in TOD_group.
310
+ e.g. [LST_deg_list_1, LST_deg_list_2, ...]
311
+ Note that it can be generated by limTOD.simulator.generate_LSTs_deg function. For example:
312
+ LST_deg_list = generate_LSTs_deg(
313
+ ant_latitude_deg,
314
+ ant_longitude_deg,
315
+ ant_height_m,
316
+ time_list,
317
+ start_time_utc=start_time_utc,
318
+ )
319
+
320
+ lat_deg : float
321
+ The latitude of the observation site in degrees.
322
+
323
+ azimuth_deg_list_group : an azimuth array or a list of azimuth lists corresponding to each TOD in TOD_group.
324
+ e.g. [azimuth_deg_list_1, azimuth_deg_list_2, ...]
325
+
326
+ elevation_deg_list_group : an elevation array or a list of elevation lists corresponding to each TOD in TOD_group.
327
+ e.g. [elevation_deg_list_1, elevation_deg_list_2, ...]
328
+
329
+ threshold : float
330
+ The threshold to cut off the fractional beam response np.abs(beam[pixel])/beam_max, default is 0.01.
331
+ e.g., if threshold=0.01, only pixels with beam response larger than 1% of the maximum will be considered.
332
+ Note that this is the threshold for singling out pixels.
333
+
334
+ Tsys_others_operator_group : an array or a list of arrays, optional
335
+ The operator for other system temperature components (e.g., Trec and Tdiode) mapping to TOD.
336
+
337
+ nside_hires : int, optional
338
+ If provided, upgrade the beam map to this nside before processing.
339
+ This can help improve accuracy when the beam is narrow.
340
+
341
+ beam_truncate_frac_thres : float, optional
342
+ The fractional threshold value for beam truncation.
343
+ If specified, set all pixels with values below this fraction of the maximum pixel value to zero.
344
+ If None, use the other key word "threshold" as the value.
345
+
346
+ Note the difference between "threshold" and "beam_truncate_frac_thres":
347
+ "threshold" is used to determine which pixels to include in the mapmaking process based on their beam response.
348
+ "beam_truncate_frac_thres" is used to truncate the beam map itself before processing.
349
+
350
+ """
351
+ self.nside_hires = nside_hires
352
+ self.nside_target = nside_target
353
+
354
+ if beam_truncate_frac_thres is None:
355
+ beam_truncate_frac_thres = threshold
356
+
357
+ # If LST_deg_list_group[0] is a list, flatten it.
358
+ if isinstance(LST_deg_list_group[0], (list, np.ndarray)):
359
+ self.num_tods = len(LST_deg_list_group)
360
+ if not (self.num_tods == len(azimuth_deg_list_group) == len(elevation_deg_list_group)):
361
+ raise ValueError(
362
+ "Length of LST_deg_list_group, azimuth_deg_list_group, "
363
+ "elevation_deg_list_group must be the same."
364
+ )
365
+ LST_deg_list = np.concatenate(LST_deg_list_group)
366
+ azimuth_deg_list = np.concatenate(azimuth_deg_list_group)
367
+ elevation_deg_list = np.concatenate(elevation_deg_list_group)
368
+ if selfrot_deg_list_group is not None:
369
+ selfrot_deg_list = np.concatenate(selfrot_deg_list_group)
370
+ else:
371
+ selfrot_deg_list = np.zeros_like(LST_deg_list)
372
+ else:
373
+ self.num_tods = 1
374
+ # In this branch the groups are flat per-sample arrays (their
375
+ # first element is a scalar); the casts only narrow the static
376
+ # Union type and are identity operations at runtime.
377
+ LST_deg_list = cast(np.ndarray, LST_deg_list_group)
378
+ azimuth_deg_list = cast(np.ndarray, azimuth_deg_list_group)
379
+ elevation_deg_list = cast(np.ndarray, elevation_deg_list_group)
380
+ if selfrot_deg_list_group is not None:
381
+ selfrot_deg_list = cast(np.ndarray, selfrot_deg_list_group)
382
+ else:
383
+ selfrot_deg_list = np.zeros_like(LST_deg_list)
384
+
385
+ if beam_map.ndim == 1:
386
+ self.npol = 1
387
+ elif beam_map.ndim == 2:
388
+ self.npol = beam_map.shape[0]
389
+ else:
390
+ raise ValueError("beam_map must be a 1D or 2D array.")
391
+
392
+ if Tsys_others_operator_group is not None:
393
+ # Canonicalize to a list of per-TOD 2D operators: the docstring
394
+ # accepts "an array or a list of arrays", but a bare 2D array
395
+ # used to crash ([0] indexed a row) and the single-TOD
396
+ # concatenation mishandled the list form.
397
+ if isinstance(Tsys_others_operator_group, np.ndarray) and Tsys_others_operator_group.ndim == 2:
398
+ Tsys_others_operator_group = [Tsys_others_operator_group]
399
+ if len(Tsys_others_operator_group) != self.num_tods:
400
+ raise ValueError(
401
+ f"Tsys_others_operator_group has {len(Tsys_others_operator_group)} "
402
+ f"entries but there are {self.num_tods} TODs."
403
+ )
404
+ self.Tsys_others = True
405
+ self.n_params_others = Tsys_others_operator_group[0].shape[1]
406
+ else:
407
+ self.Tsys_others = False
408
+ self.n_params_others = 0
409
+
410
+ horizontal_mask = None # Not used in current implementation, but can be added as a feature later if needed.
411
+
412
+ self.pixel_indices = truncate_stacked_beam(
413
+ beam_map, LST_deg_list, lat_deg, azimuth_deg_list, elevation_deg_list, selfrot_deg_list,
414
+ horizontal_mask=horizontal_mask,
415
+ threshold=threshold,
416
+ nside_hires=self.nside_hires,
417
+ nside_target=self.nside_target
418
+ )
419
+
420
+ self.num_pixels = len(self.pixel_indices)
421
+ self.nsky_params = self.npol * self.num_pixels
422
+
423
+
424
+ if self.num_tods > 1:
425
+
426
+ # List[np.ndarray] here (one operator per TOD); a single stacked
427
+ # ndarray in the num_tods == 1 branch below — hence Any.
428
+ self.Tsys_operators: Any = []
429
+
430
+ for i in range(self.num_tods):
431
+ LST_deg_list_i = LST_deg_list_group[i]
432
+ azimuth_deg_list_i = azimuth_deg_list_group[i]
433
+ elevation_deg_list_i = elevation_deg_list_group[i]
434
+
435
+ selfrot_deg_list = np.zeros_like(LST_deg_list_i) if selfrot_deg_list_group is None else selfrot_deg_list_group[i]
436
+
437
+ sky_operator_i = generate_sky2sys_projection(
438
+ beam_map, LST_deg_list_i, lat_deg, azimuth_deg_list_i, elevation_deg_list_i, selfrot_deg_list,
439
+ self.pixel_indices,
440
+ nside_hires=self.nside_hires,
441
+ nside_target=self.nside_target,
442
+ normalize_beam=False,
443
+ horizontal_mask=horizontal_mask,
444
+ truncate_frac_thres=beam_truncate_frac_thres
445
+ )
446
+ if Tsys_others_operator_group is not None:
447
+ other_operators = [np.zeros_like(item) for item in Tsys_others_operator_group]
448
+ other_operators[i] = Tsys_others_operator_group[i]
449
+ Tsys_operator_i = np.concatenate([sky_operator_i] + other_operators, axis=1)
450
+ else:
451
+ Tsys_operator_i = sky_operator_i
452
+
453
+ self.Tsys_operators.append(Tsys_operator_i)
454
+
455
+ else:
456
+ sky_operators = generate_sky2sys_projection(
457
+ beam_map, LST_deg_list, lat_deg, azimuth_deg_list, elevation_deg_list, selfrot_deg_list,
458
+ self.pixel_indices,
459
+ horizontal_mask=horizontal_mask,
460
+ normalize_beam=False,
461
+ nside_hires=self.nside_hires,
462
+ nside_target=self.nside_target,
463
+ truncate_frac_thres=beam_truncate_frac_thres
464
+ )
465
+ if Tsys_others_operator_group is not None:
466
+ self.Tsys_operators = np.concatenate(
467
+ [sky_operators] + list(Tsys_others_operator_group), axis=1
468
+ )
469
+ else:
470
+ self.Tsys_operators = sky_operators
471
+
472
+ def _filter_and_stack(
473
+ self,
474
+ TOD_group: Union[np.ndarray, Sequence[np.ndarray]],
475
+ dtime: float,
476
+ cutoff_freq_group: Optional[Union[float, Sequence[float]]],
477
+ gain_group: Union[float, np.ndarray, Sequence[Union[float, np.ndarray]]],
478
+ known_injection_group: Optional[Union[np.ndarray, Sequence[np.ndarray]]],
479
+ filter_order: int,
480
+ preserve_dc: bool,
481
+ use_high_pass: bool,
482
+ ) -> Tuple[np.ndarray, np.ndarray]:
483
+ """Calibrate, optionally high-pass filter, and stack every TOD and
484
+ its system operator into one overall linear system.
485
+
486
+ Returns ``(HP_cal_TOD_overall, HP_Tsys_operator_overall)`` and
487
+ records the per-TOD filter matrices in ``self.HP_exact``.
488
+ """
489
+
490
+ def make_tod_filter(n_samples: int, cutoff_freq: Optional[float]) -> np.ndarray:
491
+ if not use_high_pass:
492
+ return np.eye(n_samples)
493
+ if cutoff_freq is None:
494
+ raise ValueError("cutoff_freq_group must be provided when use_high_pass=True.")
495
+ return HP_filter_TOD(
496
+ n_samples,
497
+ dtime,
498
+ cutoff_freq=cutoff_freq,
499
+ filter_order=filter_order,
500
+ preserve_dc=preserve_dc,
501
+ )
502
+
503
+ self.HP_exact: List[np.ndarray] = []
504
+ if self.num_tods > 1:
505
+
506
+ for i in range(self.num_tods):
507
+ # The multi-TOD path requires per-TOD sequences; the casts
508
+ # narrow the scalar-or-sequence Unions (identity at runtime).
509
+ cutoff_freq = None if cutoff_freq_group is None else cast(Sequence[float], cutoff_freq_group)[i]
510
+ hp_filter_mat = make_tod_filter(len(TOD_group[i]), cutoff_freq)
511
+ self.HP_exact.append(hp_filter_mat)
512
+ calibrated_TOD_i = np.asarray(TOD_group[i]) / cast(Sequence[Any], gain_group)[i]
513
+ if known_injection_group is not None:
514
+ calibrated_TOD_i -= known_injection_group[i]
515
+ hp_cal_TOD_i = hp_filter_mat @ calibrated_TOD_i
516
+ hp_Tsys_operator_i = hp_filter_mat @ self.Tsys_operators[i]
517
+
518
+ if i == 0:
519
+ HP_Tsys_operator_overall = hp_Tsys_operator_i
520
+ HP_cal_TOD_overall = hp_cal_TOD_i
521
+ else:
522
+ HP_Tsys_operator_overall = np.concatenate([HP_Tsys_operator_overall, hp_Tsys_operator_i])
523
+ HP_cal_TOD_overall = np.concatenate([HP_cal_TOD_overall, hp_cal_TOD_i])
524
+
525
+ elif self.num_tods == 1:
526
+ TOD = TOD_group if isinstance(TOD_group, np.ndarray) and TOD_group.ndim == 1 else TOD_group[0]
527
+ cutoff_freq = cutoff_freq_group if isinstance(cutoff_freq_group, (int, float)) else (
528
+ None if cutoff_freq_group is None else cutoff_freq_group[0]
529
+ )
530
+ gain = gain_group if isinstance(gain_group, (int, float)) else gain_group[0]
531
+ calibrated_TOD = np.asarray(TOD) / gain
532
+ if known_injection_group is not None:
533
+ known_injection = known_injection_group if isinstance(known_injection_group, np.ndarray) and known_injection_group.ndim == 1 else known_injection_group[0]
534
+ calibrated_TOD -= known_injection
535
+ hp_filter_mat = make_tod_filter(len(TOD), cutoff_freq)
536
+ HP_cal_TOD_overall = hp_filter_mat @ calibrated_TOD
537
+ HP_Tsys_operator_overall = hp_filter_mat @ self.Tsys_operators
538
+
539
+ return HP_cal_TOD_overall, HP_Tsys_operator_overall
540
+
541
+ def _build_priors(
542
+ self,
543
+ Tsky_prior_mean: Optional[np.ndarray],
544
+ Tsys_other_prior_mean_group: Optional[Sequence[np.ndarray]],
545
+ Tsky_prior_inv_cov_diag: Optional[np.ndarray],
546
+ Tsys_other_prior_inv_cov_group: Optional[Sequence[np.ndarray]],
547
+ ) -> Tuple[np.ndarray, np.ndarray]:
548
+ """Assemble the joint prior mean vector and prior inverse-covariance
549
+ matrix over (sky pixels + other-Tsys parameters). Requires
550
+ ``self.nparams`` to be set."""
551
+ Tsys_prior_mean = np.zeros(self.nparams)
552
+ if Tsky_prior_mean is not None:
553
+ if len(Tsky_prior_mean) != self.nsky_params:
554
+ raise ValueError("Length of Tsky_prior_mean must match number of sky parameters.")
555
+ Tsys_prior_mean[:self.nsky_params] = Tsky_prior_mean
556
+ counter = self.nsky_params
557
+ if Tsys_other_prior_mean_group is not None:
558
+ if not self.Tsys_others:
559
+ raise ValueError("Tsys_others_operator must be provided in initialization if Tsys_other_prior_mean_group is provided.")
560
+ if len(Tsys_other_prior_mean_group) != self.num_tods:
561
+ raise ValueError("Length of Tsys_other_prior_mean_group must match number of TODs.")
562
+ for Tsys_other_prior_mean_i in Tsys_other_prior_mean_group:
563
+ Tsys_prior_mean[counter:counter+len(Tsys_other_prior_mean_i)] = Tsys_other_prior_mean_i
564
+ counter += len(Tsys_other_prior_mean_i)
565
+
566
+ Tsys_prior_inv_cov = np.zeros((self.nparams, self.nparams))
567
+ if Tsky_prior_inv_cov_diag is not None:
568
+ Tsky_prior_inv_cov_diag = np.asarray(Tsky_prior_inv_cov_diag).reshape(-1) # flatten
569
+ if len(Tsky_prior_inv_cov_diag) != self.nsky_params:
570
+ raise ValueError("Length of Tsky_prior_inv_cov_diag must match number of sky parameters.")
571
+ Tsys_prior_inv_cov[:self.nsky_params, :self.nsky_params] = np.diag(Tsky_prior_inv_cov_diag)
572
+
573
+ counter = self.nsky_params
574
+ if Tsys_other_prior_inv_cov_group is not None:
575
+ if not self.Tsys_others:
576
+ raise ValueError("Tsys_others_operator must be provided in initialization if Tsys_other_prior_inv_cov_group is provided.")
577
+ if len(Tsys_other_prior_inv_cov_group) != self.num_tods:
578
+ raise ValueError("Length of Tsys_other_prior_inv_cov_group must match number of TODs.")
579
+
580
+ for Tsys_other_prior_inv_cov_i in Tsys_other_prior_inv_cov_group:
581
+ # Branch on THIS element's ndim: checking group[0] here used to
582
+ # misroute mixed 1D/2D groups, silently np.diag-ing a 2D
583
+ # covariance and discarding its off-diagonal entries.
584
+ Tsys_other_prior_inv_cov_i = np.asarray(Tsys_other_prior_inv_cov_i)
585
+ if Tsys_other_prior_inv_cov_i.ndim == 1:
586
+ n_others = len(Tsys_other_prior_inv_cov_i)
587
+ Tsys_prior_inv_cov[counter:counter+n_others, counter:counter+n_others] = np.diag(Tsys_other_prior_inv_cov_i)
588
+ counter += n_others
589
+ elif Tsys_other_prior_inv_cov_i.ndim == 2:
590
+ n_others = Tsys_other_prior_inv_cov_i.shape[0]
591
+ Tsys_prior_inv_cov[counter:counter+n_others, counter:counter+n_others] = Tsys_other_prior_inv_cov_i
592
+ counter += n_others
593
+ else:
594
+ raise ValueError("Each element in Tsys_other_prior_inv_cov_group must be a 1D or 2D array.")
595
+
596
+ return Tsys_prior_mean, Tsys_prior_inv_cov
597
+
598
+ def _normalize_noise_variance(
599
+ self,
600
+ noise_variance: Optional[Union[float, np.ndarray, List[Union[float, np.ndarray]], Tuple[Union[float, np.ndarray], ...]]],
601
+ TOD_group: Union[np.ndarray, Sequence[np.ndarray]],
602
+ n_overall: int,
603
+ ) -> Optional[Union[float, np.ndarray]]:
604
+ """Normalise per-TOD noise_variance into the 1D/scalar/None form
605
+ wiener_filter_map expects. Accepts None / scalar / 1D array /
606
+ list-of-(scalar|1D-array)."""
607
+ nv = noise_variance
608
+ if isinstance(nv, (list, tuple)):
609
+ if len(nv) != self.num_tods:
610
+ raise ValueError(
611
+ f"noise_variance list length {len(nv)} != num_tods {self.num_tods}"
612
+ )
613
+ tod_lengths = [len(TOD_group[i]) for i in range(self.num_tods)] \
614
+ if self.num_tods > 1 else [n_overall]
615
+ pieces: List[np.ndarray] = []
616
+ for i, nv_i in enumerate(nv):
617
+ if np.isscalar(nv_i):
618
+ pieces.append(np.full(tod_lengths[i], float(cast(SupportsFloat, nv_i))))
619
+ else:
620
+ nv_i = np.asarray(nv_i, dtype=float)
621
+ if nv_i.shape != (tod_lengths[i],):
622
+ raise ValueError(
623
+ f"noise_variance[{i}] shape {nv_i.shape} != ({tod_lengths[i]},)"
624
+ )
625
+ pieces.append(nv_i)
626
+ nv = np.concatenate(pieces)
627
+ return nv
628
+
629
+ def __call__(
630
+ self,
631
+ *,
632
+ TOD_group: Union[np.ndarray, Sequence[np.ndarray]],
633
+ dtime: float,
634
+ cutoff_freq_group: Optional[Union[float, Sequence[float]]] = None,
635
+ gain_group: Optional[Union[float, np.ndarray, Sequence[Union[float, np.ndarray]]]] = None,
636
+ known_injection_group: Optional[Union[np.ndarray, Sequence[np.ndarray]]] = None,
637
+ Tsky_prior_mean: Optional[np.ndarray] = None,
638
+ Tsky_prior_inv_cov_diag: Optional[np.ndarray] = None,
639
+ Tsys_other_prior_mean_group: Optional[Sequence[np.ndarray]] = None,
640
+ Tsys_other_prior_inv_cov_group: Optional[Sequence[np.ndarray]] = None,
641
+ noise_variance: Optional[Union[float, np.ndarray, List[Union[float, np.ndarray]], Tuple[Union[float, np.ndarray], ...]]] = None,
642
+ regularization: float = 1e-12,
643
+ return_full_cov: bool = False,
644
+ filter_order: int = 4,
645
+ preserve_dc: bool = False,
646
+ use_high_pass: bool = False,
647
+ ) -> Union[
648
+ Tuple[np.ndarray, np.ndarray],
649
+ Tuple[np.ndarray, np.ndarray, List[np.ndarray], List[np.ndarray]],
650
+ ]:
651
+ """
652
+ TOD_group : a TOD array or a list of TOD arrays at the same frequency channel.
653
+ e.g. [TOD_1, TOD_2, ...]
654
+
655
+ gain_group : a gain array or a list of gain arrays corresponding to each TOD in TOD_group.
656
+ e.g. [gain_1, gain_2, ...]
657
+ gain_i can be a single value (constant gain) or an array with the same length as TOD_i.
658
+ If None, assumed to be 1. (i.e., TOD is already calibrated)
659
+
660
+ dtime : float
661
+ Time interval between samples in seconds.
662
+
663
+ cutoff_freq_group : list of float, optional
664
+ Cutoff frequency for high-pass filter in Hz. Required when
665
+ use_high_pass is True; ignored when use_high_pass is False.
666
+
667
+ known_injection_group : a list of known system temperature components to be subtracted from Tsys (calibrated TOD), each element corresponding to each TOD in TOD_group.
668
+ e.g. [mu_1, mu_2, ...]
669
+ A concrete example in MeerKLASS, mu_i can be time sequence of constant noise diode temperature, if we do not take it as a parameter.
670
+
671
+ Tsky_prior_mean : array, optional
672
+ Prior mean for the sky temperature map, the shape is (npol, num_pixels) for multi-polarization maps, or (num_pixels,) for single polarization map.
673
+ If None, assumed to be zero.
674
+
675
+ Tsky_prior_inv_cov_diag : array, optional
676
+ Diagonal of the prior inverse covariance for the sky temperature map, the shape can be:
677
+ (num_pixels,) : single polarization map.
678
+ (npol, num_pixels) : multi-polarization map.
679
+ If None, assumed to be uninformative prior (zero, i.e., infinite prior variance).
680
+
681
+ Tsys_other_prior_mean_group : a list of prior means for other system temperature components, each element corresponding to each TOD in TOD_group.
682
+ e.g. [Tsys_other_prior_mean_1, Tsys_other_prior_mean_2, ...]
683
+ If None, assumed to be zero.
684
+
685
+ noise_variance : float, 1D array, or list of (float | 1D array), optional
686
+ Per-sample noise variance. Three forms accepted:
687
+ * None (default): auto-estimate from the residual TOD - operator @ pinv(operator) @ TOD
688
+ via a 100-sample rolling window. NOTE: this estimate can be heavily biased
689
+ low when the operator does not span the projectable signal subspace —
690
+ it conflates un-projectable signal with noise, which mis-weights data
691
+ in the Wiener filter. Provide an explicit value when possible.
692
+ * scalar: uniform variance applied to every concatenated sample.
693
+ * 1D array of length sum(len(TOD_i)): per-sample variances over the
694
+ concatenated TOD ordering.
695
+ * list of length num_tods, each entry a scalar or 1D array of length
696
+ len(TOD_i): per-TOD variance, concatenated internally.
697
+
698
+ Tsys_other_prior_inv_cov_group : a list of prior inverse covariances for other system temperature components, each element corresponding to each TOD in TOD_group.
699
+ e.g. [Tsys_other_prior_inv_cov_1, Tsys_other_prior_inv_cov_2, ...]
700
+ The shape of each element can be:
701
+ (num_other_params,) : Diagonal of the inverse covariance matrix.
702
+ (num_other_params, num_other_params) : Full inverse covariance matrix.
703
+ But all elements must have the same shape.
704
+ If None, assumed to be uninformative prior (zero, i.e., infinite prior variance).
705
+
706
+ use_high_pass : bool, default=False
707
+ If True, apply the Butterworth high-pass filter to the TOD and
708
+ system operator. If False, use the identity matrix and solve the
709
+ unfiltered map-making problem.
710
+
711
+
712
+ Returns:
713
+ --------
714
+ sky_estimation : array, the shape is (npol, num_pixels) for multi-polarization maps, or (num_pixels,) for single polarization map.
715
+ Reconstructed sky map(s).
716
+ sky_uncertainty : array, the shape is (npol, num_pixels) for multi-polarization maps, or (num_pixels,) for single polarization map.
717
+ Uncertainty map(s) (diagonal of covariance matrix).
718
+ Tsys_others_estimation_group : list of arrays, each with shape (num_other_params,)
719
+ Reconstructed other system temperature components for each TOD, only returned if Tsys_others_operator is provided.
720
+ Tsys_others_uncertainty_group : list of arrays, each with shape (num_other_params,)
721
+ Per-parameter uncertainty (diagonal of covariance matrix) for other system temperature components, only returned if Tsys_others_operator is provided.
722
+ """
723
+
724
+ if gain_group is None:
725
+ gain_group = [1.0]*self.num_tods
726
+
727
+ HP_cal_TOD_overall, HP_Tsys_operator_overall = self._filter_and_stack(
728
+ TOD_group, dtime, cutoff_freq_group, gain_group,
729
+ known_injection_group, filter_order, preserve_dc, use_high_pass,
730
+ )
731
+
732
+ # # Debug: print the shape of the overall operator
733
+ self.nparams = HP_Tsys_operator_overall.shape[1]
734
+
735
+ Tsys_prior_mean, Tsys_prior_inv_cov = self._build_priors(
736
+ Tsky_prior_mean, Tsys_other_prior_mean_group,
737
+ Tsky_prior_inv_cov_diag, Tsys_other_prior_inv_cov_group,
738
+ )
739
+
740
+ nv = self._normalize_noise_variance(
741
+ noise_variance, TOD_group, len(HP_cal_TOD_overall)
742
+ )
743
+
744
+ # Apply Wiener filter with the overall operator
745
+ result = wiener_filter_map(
746
+ HP_cal_TOD_overall,
747
+ HP_Tsys_operator_overall,
748
+ noise_variance=nv, # explicit if provided, else auto-estimated inside
749
+ prior_inv_cov=Tsys_prior_inv_cov,
750
+ guess=Tsys_prior_mean,
751
+ regularization=regularization,
752
+ return_full_cov=return_full_cov,
753
+ )
754
+ # wiener_filter_map returns a 3-tuple when return_full_cov=True; the
755
+ # posterior covariance is appended to this method's return values
756
+ # (a 2-name unpacking here used to crash on that flag).
757
+ if return_full_cov:
758
+ estmation, uncertainty, posterior_cov = cast(
759
+ Tuple[np.ndarray, np.ndarray, np.ndarray], result
760
+ )
761
+ else:
762
+ estmation, uncertainty = cast(Tuple[np.ndarray, np.ndarray], result)
763
+ posterior_cov = None
764
+
765
+ sky_estimation = estmation[:self.nsky_params]
766
+ sky_uncertainty = uncertainty[:self.nsky_params]
767
+
768
+ if self.npol > 1:
769
+ sky_estimation = sky_estimation.reshape(self.npol, self.num_pixels)
770
+ sky_uncertainty = sky_uncertainty.reshape(self.npol, self.num_pixels)
771
+
772
+ outputs: Tuple[Any, ...] = (sky_estimation, sky_uncertainty)
773
+ if self.Tsys_others:
774
+ Tsys_others_estimation_group = []
775
+ Tsys_others_uncertainty_group = []
776
+ counter = self.nsky_params
777
+ for i in range(self.num_tods):
778
+ Tsys_others_estimation_group.append(estmation[counter:counter+self.n_params_others])
779
+ Tsys_others_uncertainty_group.append(uncertainty[counter:counter+self.n_params_others])
780
+ counter += self.n_params_others
781
+ outputs = outputs + (Tsys_others_estimation_group, Tsys_others_uncertainty_group)
782
+ if return_full_cov:
783
+ outputs = outputs + (posterior_cov,)
784
+ return outputs
785
+