Skip to content

Commit 550bc46

Browse files
authored
Toast improvements (#1505)
* Make coadd stats robust against bad wafer obs * Update SOFocalplane HDF5 API to conform to upstream toast changes. * Add support for save / load of HKManager to hdf5 * Fixes and unit test for serializing HKManager to HDF5 * Add downsample method to HKManager * Add demodulation / downsample test to HKManager test
1 parent ec9528f commit 550bc46

File tree

5 files changed

+254
-38
lines changed

5 files changed

+254
-38
lines changed

sotodlib/toast/hkmanager.py

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# Copyright (c) 2025-2025 Simons Observatory.
22
# Full license can be found in the top level "LICENSE" file.
33

4+
import copy
45
from collections.abc import MutableMapping
56

67
import numpy as np
78
from scipy.interpolate import CubicSpline, PchipInterpolator
89

910
from toast.utils import Logger
11+
from toast.io.hdf_utils import load_meta_object, save_meta_object
1012

1113
from ..io import hkdb
1214

@@ -49,8 +51,8 @@ class HKManager(MutableMapping):
4951

5052
def __init__(
5153
self,
52-
comm,
53-
timestamps,
54+
comm=None,
55+
timestamps=None,
5456
site_root=None,
5557
site_db=None,
5658
site_fields=None,
@@ -63,8 +65,12 @@ def __init__(
6365
self._internal = dict()
6466
self._aliases = dict()
6567
self._stamps = timestamps
66-
self._start_time = timestamps[0]
67-
self._stop_time = timestamps[-1]
68+
if timestamps is not None:
69+
self._start_time = timestamps[0]
70+
self._stop_time = timestamps[-1]
71+
elif site_root is not None or plat_root is not None:
72+
msg = "If loading site or platform data, timestamps are required"
73+
raise RuntimeError(msg)
6874
rank = 0
6975
if comm is not None:
7076
rank = comm.rank
@@ -194,13 +200,118 @@ def __repr__(self):
194200

195201
def __eq__(self, other):
196202
log = Logger.get()
197-
if self._internal != other._internal:
198-
log.verbose(f" data {self._internal} != {other._internal}")
203+
if set(self._internal.keys()) != set(other._internal.keys()):
204+
log.verbose(f" keys {self._internal} != {other._internal}")
199205
return False
206+
for k, v in self._internal.items():
207+
try:
208+
if not np.allclose(v, other._internal[k]):
209+
log.verbose(f" data array {v} != {other._internal[k]}")
210+
return False
211+
except Exception:
212+
if self._internal[k] != other._internal[k]:
213+
log.verbose(f" data value {v} != {other._internal[k]}")
214+
return False
200215
if self._aliases != other._aliases:
201216
log.verbose(f" aliases {self._aliases} != {other._aliases}")
202217
return False
203218
return True
204219

205220
def __ne__(self, other):
206221
return not self.__eq__(other)
222+
223+
def save_hdf5(self, handle, obs, **kwargs):
224+
"""Save the HKManager object to an HDF5 file.
225+
226+
Args:
227+
handle (h5py.Group): The group to populate.
228+
obs (Observation): The parent observation.
229+
230+
Returns:
231+
None
232+
233+
"""
234+
gcomm = obs.comm.comm_group
235+
if (gcomm is None) or (gcomm.rank == 0):
236+
# The rank zero process should always be writing
237+
if handle is None:
238+
raise RuntimeError("HDF5 group is not open on the root process")
239+
if handle is not None:
240+
tdata = handle.create_dataset(
241+
"timestamps", self._stamps.shape, dtype=self._stamps.dtype
242+
)
243+
hslices = [slice(0, x) for x in self._stamps.shape]
244+
hslices = tuple(hslices)
245+
tdata.write_direct(self._stamps, hslices, hslices)
246+
del tdata
247+
save_meta_object(handle, "data", self._internal)
248+
save_meta_object(handle, "aliases", self._aliases)
249+
250+
def load_hdf5(self, handle, obs, **kwargs):
251+
"""Load the HKManager object from an HDF5 file.
252+
253+
Args:
254+
handle (h5py.Group): The group containing noise model.
255+
obs (Observation): The parent observation.
256+
257+
Returns:
258+
None
259+
260+
"""
261+
gcomm = obs.comm.comm_group
262+
if (gcomm is None) or (gcomm.rank == 0):
263+
# The rank zero process should always be reading
264+
if handle is None:
265+
raise RuntimeError("HDF5 group is not open on the root process")
266+
stamps = None
267+
data = None
268+
aliases = None
269+
if handle is not None:
270+
tdata = handle["timestamps"]
271+
stamps = np.copy(tdata[:])
272+
data = load_meta_object(handle["data"])
273+
aliases = load_meta_object(handle["aliases"])
274+
del tdata
275+
if gcomm is not None:
276+
stamps = gcomm.bcast(stamps, root=0)
277+
data = gcomm.bcast(data, root=0)
278+
aliases = gcomm.bcast(aliases, root=0)
279+
280+
self._stamps = stamps
281+
self._start_time = stamps[0]
282+
self._stop_time = stamps[-1]
283+
self._internal = data
284+
self._aliases = aliases
285+
286+
def duplicate(self):
287+
"""Create a copy of the HKManager"""
288+
new_hk = HKManager()
289+
new_hk._stamps = np.copy(self._stamps)
290+
new_hk._start_time = new_hk._stamps[0]
291+
new_hk._stop_time = new_hk._stamps[-1]
292+
new_hk._internal = copy.deepcopy(self._internal)
293+
new_hk._aliases = copy.deepcopy(self._aliases)
294+
return new_hk
295+
296+
def downsample(self, demod_times):
297+
"""Downsample the HKManager
298+
299+
The demodulation operator with downsample any observation
300+
attributes with a `downsample()` method. For this class
301+
that operation is trivial, since it just involves replacing
302+
the timestamps.
303+
304+
Args:
305+
demod_times (array): The new downsampled timestamps.
306+
307+
Returns:
308+
(HKManager): The downsampled HKManager.
309+
310+
"""
311+
new_hk = HKManager()
312+
new_hk._stamps = demod_times
313+
new_hk._start_time = new_hk._stamps[0]
314+
new_hk._stop_time = new_hk._stamps[-1]
315+
new_hk._internal = copy.deepcopy(self._internal)
316+
new_hk._aliases = copy.deepcopy(self._aliases)
317+
return new_hk

sotodlib/toast/instrument.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
import astropy.units as u
1212
from astropy.table import QTable
13+
14+
import toast
1315
from toast.instrument import Focalplane, GroundSite, Telescope
1416
from toast.utils import Logger, name_UID
1517
from toast.weather import SimWeather
@@ -366,11 +368,13 @@ def get_par_float(ddata, key, default):
366368
)
367369
# Get noise parameters. If detector-specific entries are
368370
# absent, use band averages
369-
net_corr = get_par_float(
370-
det_data, "NET_corr", band_data["NET_corr"]
371+
net_corr = get_par_float(det_data, "NET_corr", band_data["NET_corr"])
372+
net = (
373+
get_par_float(det_data, "NET", band_data["NET"])
374+
* 1.0e-6
375+
* u.K
376+
* u.s**0.5
371377
)
372-
net = get_par_float(det_data, "NET", band_data["NET"]) \
373-
* 1.0e-6 * u.K * u.s**0.5
374378
if apply_net_corr:
375379
net *= net_corr
376380
net_corr = 1.0
@@ -388,8 +392,9 @@ def get_par_float(ddata, key, default):
388392
bandcenters.append(0.5 * (lower + upper))
389393
bandwidths.append(upper - lower)
390394

391-
meta["platescale"] = hw.data["telescopes"][meta["telescope"]]["platescale"] \
392-
* u.deg / u.mm
395+
meta["platescale"] = (
396+
hw.data["telescopes"][meta["telescope"]]["platescale"] * u.deg / u.mm
397+
)
393398

394399
detdata = QTable(
395400
[
@@ -471,6 +476,29 @@ def get_par_float(ddata, key, default):
471476
sample_rate=sample_rate,
472477
)
473478

479+
@classmethod
480+
def _load_hdf5(
481+
cls, handle, comm=None, detectors=None, file_det_sets=None, **kwargs
482+
):
483+
"""Load simulated focalplane from HDF5.
484+
485+
Although SOFocalplane inherits from toast.instrument.Focalplane, it
486+
essentially is just a custom constructor and the actual content is
487+
stored in the Focalplane base class. When loading data we just
488+
dispatch to the base class method.
489+
"""
490+
return toast.instrument.Focalplane._load_hdf5(
491+
handle,
492+
comm=comm,
493+
detectors=detectors,
494+
file_det_sets=file_det_sets,
495+
**kwargs,
496+
)
497+
498+
def _save_hdf5(self, handle, comm=None, **kwargs):
499+
"""Save to HDF5 - dispatch to base class method."""
500+
return super()._save_hdf5(handle, comm=comm, **kwargs)
501+
474502

475503
def update_creation_time(det_data, creation_time):
476504
"""Update the readout_id column of a focalplane with a new creation time.

sotodlib/toast/io/load_book.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# Copyright (c) 2020-2023 Simons Observatory.
22
# Full license can be found in the top level "LICENSE" file.
3-
"""Tools for loading Level-3 Book format data.
4-
5-
"""
3+
"""Tools for loading Level-3 Book format data."""
64

75
import os
86
import sys
@@ -251,9 +249,9 @@ def _unpack_frames(self, buffer):
251249
"el_enc"
252250
]
253251
if "flags" in ancil:
254-
self.obs.shared[self.obs_fields["shared_flags"]].data[
255-
off : off + n
256-
] = np.array(ancil["flags"]).astype(np.uint8)
252+
self.obs.shared[self.obs_fields["shared_flags"]].data[off : off + n] = (
253+
np.array(ancil["flags"]).astype(np.uint8)
254+
)
257255

258256
self.obs.shared[self.obs_fields["boresight_azel"]].data[
259257
off : off + n, :
@@ -265,9 +263,9 @@ def _unpack_frames(self, buffer):
265263
] = t3g.from_g3_quats(frm["qboresight_radec"])
266264

267265
if "hwp_enc" in ancil:
268-
self.obs.shared[self.obs_fields["hwp_angle"]].data[
269-
off : off + n
270-
] = np.array(ancil["hwp_enc"]).astype(np.float64)
266+
self.obs.shared[self.obs_fields["hwp_angle"]].data[off : off + n] = (
267+
np.array(ancil["hwp_enc"]).astype(np.float64)
268+
)
271269

272270
if "corotation_enc" in ancil:
273271
self.obs.shared[self.obs_fields["corotator_angle"]].data[
@@ -290,12 +288,12 @@ def _unpack_frames(self, buffer):
290288
# print(f"DBG LOAD frame {off}:{off+n} signal = {frm['signal'].data[:5,:]}")
291289
# print(f"LOAD frame {off}:{off+n} signal = {frm['signal'].data[detmap,:]}")
292290
for idet in range(len(detmap)):
293-
self.obs.detdata[self.obs_fields["det_data"]][
294-
idet, off : off + n
295-
] = frm["signal"].data[detmap[idet], :]
296-
self.obs.detdata[self.obs_fields["det_flags"]][
297-
idet, off : off + n
298-
] = np.array(frm["flags"].data[detmap[idet], :], dtype=np.uint8)
291+
self.obs.detdata[self.obs_fields["det_data"]][idet, off : off + n] = (
292+
frm["signal"].data[detmap[idet], :]
293+
)
294+
self.obs.detdata[self.obs_fields["det_flags"]][idet, off : off + n] = (
295+
np.array(frm["flags"].data[detmap[idet], :], dtype=np.uint8)
296+
)
299297
if "signal_units" in frm and frm["signal_units"] is not None:
300298
# We have some unit information
301299
# print(f"Frame units = {frm['signal_units']}")
@@ -589,11 +587,12 @@ def read_book(
589587
# print(f"Loading focalplane {fp_file}", flush=True)
590588
if os.path.isfile(fp_file):
591589
# We have a file, load it
592-
focalplane = toast.instrument.Focalplane(sample_rate=sample_rate)
593590
with toast.io.H5File(
594591
fp_file, "r", comm=comm.comm_group, force_serial=True
595592
) as f:
596-
focalplane.load_hdf5(f.handle, comm=comm.comm_group)
593+
focalplane = toast.instrument.Focalplane.load_hdf5(
594+
f.handle, comm=comm.comm_group
595+
)
597596
update_creation_time(focalplane.detector_data, creation_time)
598597
use_nominal = False
599598
else:

sotodlib/toast/scripts/so_coadd.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ def get_weights(args, obs):
8686
cl_bb = spec["cl_BB"].to_value(u.uK**2)
8787
high_mean = np.mean(cl_bb[ell_high])
8888
low_mean = np.mean(cl_bb[ell_slc])
89+
if np.isnan(low_mean) or np.isnan(high_mean) or low_mean == 0:
90+
continue
8991
noise[oindex] = low_mean
9092
weights[oindex] = high_mean / low_mean
9193
print(

0 commit comments

Comments
 (0)