Skip to content

Simulated Ground Telescopes

This tutorial focuses on simulating data from a ground-based telescope. We first create a fake telescope with a synthetic focalplane located in Chile. Then we create a synthetic observing schedule and use that to scan the sky. Later notebooks make use of helper functions that create generic focalplanes, but in this example we use low-level functions to customize things a bit more.

In [1]:
# Optionally change logging level
import os
os.environ["TOAST_LOGLEVEL"] = "INFO"
# This is needed before importing toast, and should
# match the value passed to the '-t' option of %toast
os.environ["OMP_NUM_THREADS"] = "4"
In [2]:
# TOAST interactive startup
import toast.interactive
%load_ext toast.interactive
In [3]:
%toast -p 1 -t 4 -a
TOAST INFO: Using 1 processes with 4 threads each.
In [4]:
# Built-in modules
import sys
import os
import re
import datetime
import shutil

# External modules
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.image import imread
import astropy.units as u
from astropy.table import Row, QTable
import healpy as hp

# TOAST
import toast
import toast.schedule_sim_ground
from toast.instrument_sim import plot_focalplane
from toast.tests import helpers
from toast.observation import default_values as defaults

# Display inline plots
%matplotlib inline
from IPython.display import Image
from IPython.display import display
In [5]:
# MPI communicator
world, procs, rank = toast.mpi.get_world()
comm = helpers.create_comm(world, single_group=True)
In [6]:
# Output directory for this tutorial
topdir = "out_sim_ground"
if rank == 0 and os.path.exists(topdir):
    shutil.rmtree(topdir)
out_dir = helpers.create_outdir(world, topdir=topdir)

Helper Functions

Here are a few functions we will use later in the notebook.

In [7]:
def plot_dets(obs, d_start=0, d_end=None, s_start=0, s_end=None, view=None, signal=defaults.det_data):
    """Plot some detectors in an observation.
    
    Args:
        obs (Observation):  The observation
        d_start (int):  The starting local detector index to plot.
        d_end (int): The local detector index limit to plot.
        s_start (int):  The starting sample index to plot.
        s_end (int):  The sample index limit to plot
        view (str):  The optional intervals to overplot.
        signal (str):  The detdata name to plot.
    """
    slc = slice(s_start, s_end, 1)

    fig = plt.figure(dpi=100, figsize=(18, 12))
    ax = fig.add_subplot(2, 1, 1, aspect="auto")
    plt.gca().set_prop_cycle(None)
    for idet, det in enumerate(obs.select_local_detectors(flagmask=defaults.det_mask_nonscience)):
        if idet < d_start:
            continue
        if d_end is not None and idet >= d_end:
            continue
        ax.plot(
            obs.shared[defaults.times].data[slc], 
            obs.detdata[signal][det, slc], 
            '-',
            label=det,
        )
    ax.legend(loc="best")
    
    ax = fig.add_subplot(2, 1, 2, aspect="auto")
    
    if view is not None:
        inview = np.zeros_like(obs.shared[defaults.shared_flags].data[slc])
        begin = [x.first for x in obs.intervals[view]]
        end = [x.last+1 for x in obs.intervals[view]]
        for b, e in zip(begin, end):
            inview[b:e] = 1
        ax.plot(
            obs.shared[defaults.times].data[slc], 
            inview, 
            '-',
            color="red",
            label=f"View {view}",
        )
    ax.plot(
        obs.shared[defaults.times].data[slc], 
        obs.shared[defaults.shared_flags].data[slc], 
        '-',
        color="black",
        label="Shared Flags",
    )
    
    plt.gca().set_prop_cycle(None)
    for idet, det in enumerate(obs.select_local_detectors(flagmask=defaults.det_mask_nonscience)):
        if idet < d_start:
            continue
        if d_end is not None and idet >= d_end:
            continue
        ax.plot(
            obs.shared[defaults.times].data[slc], 
            obs.detdata[defaults.det_flags][det, slc], 
            '-',
            label=det,
        )
    ax.legend(loc="best")    
    plt.show()
    plt.close()
In [8]:
def plot_scanning(obs, s_start=0, s_end=None):
    slc = slice(s_start, s_end, 1)
    times = obs.shared[defaults.times].data[slc]
    az = obs.shared[defaults.azimuth].data[slc]
    el = obs.shared[defaults.elevation].data[slc]
    
    fig = plt.figure(dpi=100, figsize=(18, 12))
    ax = fig.add_subplot(2, 1, 1, aspect="auto")
    ax.plot(times, az, label="Azimuth")
    ax.set_xlabel("Posix Timestamps")
    ax.set_ylabel("Azimuth")
    ax = fig.add_subplot(2, 1, 2, aspect="auto")
    ax.plot(times, el, label="Elevation")
    ax.set_xlabel("Posix Timestamps")
    ax.set_ylabel("Elevation")
    plt.show()
    plt.close()
In [9]:
def plot_noise_model(model, model_fit=None, d_start=0, d_end=None):
    fig = plt.figure(dpi=100, figsize=(18, 12))
    ax = fig.add_subplot(1, 1, 1)
    plt.gca().set_prop_cycle(None)
    plot_max = 0
    plot_min = 1e100
    for idet, det in enumerate(model.detectors):
        if idet < d_start:
            continue
        if d_end is not None and idet >= d_end:
            continue
        freq = model.freq(det).to_value(u.Hz)
        psd = model.psd(det).to_value(u.K**2 * u.s)
        plot_min = min(plot_min, np.amin(psd))
        plot_max = max(plot_max, np.amax(psd))
        ax.loglog(
            freq,
            psd,
            label=det,
        )
    if model_fit is not None:
        # Also plot the fit
        plt.gca().set_prop_cycle(None)
        for idet, det in enumerate(model.detectors):
            if idet < d_start:
                continue
            if d_end is not None and idet >= d_end:
                continue
            freq = model_fit.freq(det)
            psd = model_fit.psd(det)
            ax.loglog(
                freq.to_value(u.Hz),
                psd.to_value(u.K**2 * u.s),
                label=f"{det} Fit",
            )
    freq = model.freq(model.detectors[0])
    
    ax.set_xlim(freq[0].to_value(u.Hz), freq[-1].to_value(u.Hz))
    ax.set_ylim(0.9 * plot_min, 1.1 * plot_max)
    ax.set_xlabel("Frequency [Hz]")
    ax.set_ylabel("PSD [K$^2$ / Hz]")
    ax.legend(loc="best")
    plt.show()
    plt.close()

Fake Telescope

We create just a small number of detectors here since we are running this notebook serially. If you use more processes you can increase the number of detectors on the focalplane. First create a Site for telescope:

In [10]:
site = toast.instrument.GroundSite("atacama", "-22:57:30", "-67:47:10", 5200.0 * u.meter)

Now we will create a focalplane consisting of three rhombus wafers packed into a hexagon.

In [11]:
fp_fwhm = 30.0 * u.arcmin

focalplane = toast.instrument_sim.fake_rhombihex_focalplane(
    n_pix_rhombus=16,
    width=8.0 * u.degree,
    gap=0 * u.radian,
    sample_rate=10.0 * u.Hz,
    epsilon=0.0,
    fwhm=fp_fwhm,
    bandcenter=150 * u.GHz,
    bandwidth=20 * u.GHz,
    psd_net=300.0 * u.uK * np.sqrt(1 * u.second),
    psd_fmin=1.0e-5 * u.Hz,
    psd_alpha=1.0,
    psd_fknee=0.05 * u.Hz,
    fwhm_sigma=0.0 * u.arcmin,
    bandcenter_sigma=0 * u.GHz,
    bandwidth_sigma=0 * u.GHz,
    random_seed=123456,
)
fov = focalplane.field_of_view
In [12]:
# Look at the table of detector properties
focalplane.detector_data
Out[12]:
QTable length=96
namequatpol_leakagepsi_polgammafwhmpsd_fminpsd_fkneepsd_alphapsd_netbandcenterbandwidthpixeluidpol_anglepol_efficiency
radradarcminHzHzK s(1/2)GHzGHzrad
str8float64[4]float64float64float64float64float64float64float64float64float64float64str3int64float64float64
D00A-1500.02001386586279049 .. 0.79320375386013130.00.0-4.452053168094260530.01e-050.051.00.0003150.020.0D00136955851.30753336348702651.0
D00B-150-0.009275041274340132 .. 0.13115332680903880.01.5707963267948966-2.881256841299364430.01e-050.051.00.0003150.020.0D0033238715202.8783296902819231.0
D01A-1500.015568089372652699 .. 0.79335514850758170.00.0-4.45156470925109730.01e-050.051.00.0003150.020.0D0121243320961.30802182233019031.0
D01B-150-0.004813451805250895 .. 0.130960883406850910.01.5707963267948966-2.88076838245620130.01e-050.051.00.0003150.020.0D0137174167632.8788181491250871.0
D02A-1500.03418395592059292 .. 0.96542113637630970.00.01.046222159966298230.01e-050.051.00.0003150.020.0D0219594924840.52262338436799931.0
D02B-1500.015896303235677027 .. 0.50009553811126840.01.5707963267948966-3.66616682041839330.01e-050.051.00.0003150.020.0D0241780923912.0934197111628951.0
D03A-1500.011119429212146394 .. 0.79339898340916540.00.01.832108323905771430.01e-050.051.00.0003150.020.0D0335335112851.30850954830747271.0
D03B-150-0.0003580213601739584 .. 0.130750876408747580.01.5707963267948966-2.880280656478918330.01e-050.051.00.0003150.020.0D0318494383572.87930587510236971.0
D04A-1500.02596050249383932 .. 0.96565748249904330.00.01.046710089353618830.01e-050.051.00.0003150.020.0D0410615875190.52311131375531991.0
................................................
D43B-1500.015900702369006526 .. -0.50003945723826240.01.57079632679489660.524086237441277830.01e-050.051.00.0003150.020.0D4325677307311.0476850130395761.0
D44A-1500.013806037417543458 .. -0.13067742704206570.00.06.02187358452191330.01e-050.051.00.0003150.020.0D4426241659250.26228705294062451.0
D44B-1500.03418738756527553 .. -0.79295254963669260.01.57079632679489661.309484604137222630.01e-050.051.00.0003150.020.0D4437519155741.8330833797355211.0
D45A-150-0.0048134518052508945 .. -0.130960883406850830.00.06.02236103604599530.01e-050.051.00.0003150.020.0D4541902588220.26277450446470631.0
D45B-1500.015568089372652694 .. -0.79335514850758180.01.57079632679489661.309972055661303330.01e-050.051.00.0003150.020.0D4540736644161.83357083125960241.0
D46A-150-0.011703188293103173 .. 0.25817924389708350.00.05.23696314721328830.01e-050.051.00.0003150.020.0D4613235124432.6189692692217941.0
D46B-1500.01589630323567702 .. -0.5000955381112680.01.57079632679489660.524574166828598430.01e-050.051.00.0003150.020.0D4632493613161.0481729424268971.0
D47A-150-0.009275041274340132 .. -0.131153326809038740.00.06.02284949488915830.01e-050.051.00.0003150.020.0D4710109782680.263262963307871.0
D47B-1500.020013865862790487 .. -0.79320375386013130.01.57079632679489661.310460514504468330.01e-050.051.00.0003150.020.0D4718756970241.83405929010276641.0
In [13]:
# Make a plot of this focalplane layout.
detpolcol = {
    x: "red" if re.match(r".*A-.*", x) is not None else "blue" for x in focalplane.detectors
}

if rank == 0:
    plot_focalplane(
        focalplane=focalplane,
        width=1.2 * fov,
        height=1.2 * fov,
        show_labels=True,
        pol_color=detpolcol
    )
No description has been provided for this image

Atmospheric Monitoring

In order to provide a channel to monitor the atmospheric water content, we add a single detector at the boresight whose bandpass is centered on the water line at 183GHz. We make a copy of the previous detector table and construct a new focalplane.

In [14]:
det_props = QTable(focalplane.detector_data)
In [15]:
# Copy the last row into a dictionary
atm_det = {x: det_props[-1][x] for x in det_props.colnames}
print(atm_det)
{'name': np.str_('D47B-150'), 'quat': array([ 0.02001387,  0.03313076,  0.60772494, -0.79320375]), 'pol_leakage': np.float64(0.0), 'psi_pol': <Quantity 1.57079633 rad>, 'gamma': <Quantity 1.31046051 rad>, 'fwhm': <Quantity 30. arcmin>, 'psd_fmin': <Quantity 1.e-05 Hz>, 'psd_fknee': <Quantity 0.05 Hz>, 'psd_alpha': np.float64(1.0), 'psd_net': <Quantity 0.0003 K s(1/2)>, 'bandcenter': <Quantity 150. GHz>, 'bandwidth': <Quantity 20. GHz>, 'pixel': np.str_('D47'), 'uid': np.int64(1875697024), 'pol_angle': <Quantity 1.83405929 rad>, 'pol_efficiency': np.float64(1.0)}
In [16]:
# Modify the atmosphere detector properties
atm_det["name"] = "ATM0"
atm_det["quat"] = np.array([0.0, 0.0, 0.0, 1.0])
atm_det["bandcenter"] = 183.0 * u.GHz
atm_det["bandwidth"] = 20.0 * u.GHz
det_props.add_row(atm_det)
In [17]:
# Build a new focalplane with the updated table
full_fp = toast.instrument.Focalplane(
    detector_data=det_props,
    sample_rate=focalplane.sample_rate,
)
In [18]:
detpolcol = {
    x: "red" if re.match(r".*A-.*", x) is not None else "blue" for x in full_fp.detectors
}

if rank == 0:
    plot_focalplane(
        focalplane=full_fp,
        width=1.2 * fov,
        height=1.2 * fov,
        show_labels=True,
        pol_color=detpolcol
    )
No description has been provided for this image

We can check the top-hat bandpasses in this Focalplane. We just look at the first normal detector and the last detector which is the atmosphere monitor.

In [19]:
if rank == 0:
    fig = plt.figure(dpi=100, figsize=(12, 6))
    ax = fig.add_subplot(1, 1, 1, aspect="auto")
    for det in [full_fp.detectors[0], full_fp.detectors[-1]]:
        freq = full_fp.bandpass.freqs(det)
        bpass = full_fp.bandpass.bandpass(det)
        ax.plot(
            freq, 
            bpass, 
            '-',
            label=det,
        )
    ax.set_xlim(100e9, 250e9)
    ax.legend(loc="best")
    plt.show()
    plt.close()
No description has been provided for this image

Finally we build our telescope with this updated focalplane and Site

In [20]:
telescope = toast.instrument.Telescope("telescope", focalplane=full_fp, site=site)

Simulated Observing Schedule

Now that we have a telescope, we create an observing schedule.

In [21]:
schedule = None

if rank == 0:
    tdir = out_dir
    if tdir is None:
        tdir = tempfile.mkdtemp()

    sch_file = os.path.join(tdir, "ground_schedule.txt")
    toast.schedule_sim_ground.run_scheduler(
        opts=[
            "--site-name",
            telescope.site.name,
            "--telescope",
            telescope.name,
            "--site-lon",
            "{}".format(telescope.site.earthloc.lon.to_value(u.degree)),
            "--site-lat",
            "{}".format(telescope.site.earthloc.lat.to_value(u.degree)),
            "--site-alt",
            "{}".format(telescope.site.earthloc.height.to_value(u.meter)),
            "--patch",
            "bossn,1,-180,15,-140,2",
            "--start",
            "2025-02-21 00:00:00",
            "--stop",
            "2025-02-23 00:00:00",
            "--out",
            sch_file,
            "--equalize-time",
            "--patch-coord",
            "C",
            "--el-min",
            "40",
            "--el-max",
            "70",
            "--sun-el-max",
            "90",
            "--sun-avoidance-angle",
            "30",
            "--moon-avoidance-angle",
            "0",
            "--ces-max-time",
            "36000",
            "--fp-radius",
            "0",
            "--boresight-angle-step",
            "180",
            "--boresight-angle-time",
            "1440",
            "--time-step-s",
            "600",
            "--lock-az-range",
            "--elevations",
            "40,50,60,70",
        ]
    )
    schedule = toast.schedule.GroundSchedule()
    schedule.read(sch_file)
    if out_dir is None:
        shutil.rmtree(tdir)
if world is not None:
    schedule = world.bcast(schedule, root=0)
WARNING: 2 of the observing elevations are too high for 'bossn': [60. 70.] > 52.04 deg
TOAST INFO: Adding patch "bossn"
TOAST INFO: Rectangular format
TOAST INFO: Creating 'out_sim_ground'
TOAST INFO: Loading schedule from out_sim_ground/ground_schedule.txt
TOAST INFO: Loaded 4 scans from out_sim_ground/ground_schedule.txt totaling 14.7 hours.

Simulated Observing

Now we use this schedule to create some fake observing with our telescope. This will generate the data containers with boresight pointing, but the detector data is still zero.

In [22]:
# Start with an empty data container
data = toast.Data(comm)
In [23]:
# Populate observations according to the schedule and telescope.
sim_ground = toast.ops.SimGround(
    telescope=telescope,
    weather="atacama",
    detset_key="pixel",
    schedule=schedule,
    median_weather=True, # no longer random weather, but less chance of an outlier
)
sim_ground.apply(data)
In [24]:
# Print out the result.
data.info()
Data distributed over 1 processes in 1 groups

<Observation
  name = 'bossn-0-0'
  uid = '155265126'  group has 1 processes
  telescope = <Telescope 'telescope': uid = 1274491784, site = <GroundSite 'atacama' : uid = 2187432677, lon = -67.7861111111111 deg, lat = -22.958333333333332 deg, alt = 5199.999999999725 m, weather = <SimWeather : 'atacama', year = 2025, month = 1, hour = 5, site UID = 2187432677, realization = 0, median = True)>, focalplane = <Focalplane: 97 detectors, sample_rate = 10.0 Hz, FOV = 9.637999122261855 deg, detectors = [D00A-150 .. ATM0]>>
  session = <Session 'bossn-0-0': uid = 1556607182, start = 2025-02-21 03:28:00+00:00, end = 2025-02-21 06:52:59.900000+00:00>
  scan_el = 40.0 deg
  scan_min_el = 0.6981317007977318 rad
  scan_max_el = 0.6981317007977318 rad
  scan_min_az = 0.7527248312891583 rad
  scan_max_az = 1.1727223995218548 rad
  123000 total samples (123000 local)
  shared:  <SharedDataManager
    times (column): shape=(np.int64(123000),), dtype=float64
    position (column): shape=(np.int64(123000), 3), dtype=float64
    velocity (column): shape=(np.int64(123000), 3), dtype=float64
    azimuth (column): shape=(np.int64(123000),), dtype=float64
    elevation (column): shape=(np.int64(123000),), dtype=float64
    boresight_azel (column): shape=(np.int64(123000), 4), dtype=float64
    boresight_radec (column): shape=(np.int64(123000), 4), dtype=float64
    flags (column): shape=(np.int64(123000),), dtype=uint8>
  detdata:  <DetDataManager 97 local detectors, 123000 samples
    signal: shape=(97, np.int64(123000)), dtype=float64, units='K'
    flags: shape=(97, np.int64(123000)), dtype=uint8, units=''>
  intervals:  <IntervalsManager 13 lists
  throw_leftright: 312 intervals
  throw_rightleft: 312 intervals
  throw: 624 intervals
  scan_leftright: 312 intervals
  turn_leftright: 312 intervals
  scan_rightleft: 312 intervals
  turn_rightleft: 311 intervals
  elnod: 0 intervals
  scanning: 624 intervals
  turnaround: 623 intervals
  sun_up: 0 intervals
  sun_close: 0 intervals>
>
<Observation
  name = 'bossn-1-0'
  uid = '4081082561'  group has 1 processes
  telescope = <Telescope 'telescope': uid = 1274491784, site = <GroundSite 'atacama' : uid = 2187432677, lon = -67.7861111111111 deg, lat = -22.958333333333332 deg, alt = 5199.999999999725 m, weather = <SimWeather : 'atacama', year = 2025, month = 1, hour = 9, site UID = 2187432677, realization = 0, median = True)>, focalplane = <Focalplane: 97 detectors, sample_rate = 10.0 Hz, FOV = 9.637999122261855 deg, detectors = [D00A-150 .. ATM0]>>
  session = <Session 'bossn-1-0': uid = 1559153153, start = 2025-02-21 07:19:40+00:00, end = 2025-02-21 11:15:39.900000+00:00>
  scan_el = 50.0 deg
  scan_min_el = 0.8726646259971648 rad
  scan_max_el = 0.8726646259971648 rad
  scan_min_az = 5.2850290943673786 rad
  scan_max_az = 5.952622360653136 rad
  141600 total samples (141600 local)
  shared:  <SharedDataManager
    times (column): shape=(np.int64(141600),), dtype=float64
    position (column): shape=(np.int64(141600), 3), dtype=float64
    velocity (column): shape=(np.int64(141600), 3), dtype=float64
    azimuth (column): shape=(np.int64(141600),), dtype=float64
    elevation (column): shape=(np.int64(141600),), dtype=float64
    boresight_azel (column): shape=(np.int64(141600), 4), dtype=float64
    boresight_radec (column): shape=(np.int64(141600), 4), dtype=float64
    flags (column): shape=(np.int64(141600),), dtype=uint8>
  detdata:  <DetDataManager 97 local detectors, 141600 samples
    signal: shape=(97, np.int64(141600)), dtype=float64, units='K'
    flags: shape=(97, np.int64(141600)), dtype=uint8, units=''>
  intervals:  <IntervalsManager 13 lists
  throw_leftright: 271 intervals
  throw_rightleft: 271 intervals
  throw: 542 intervals
  scan_leftright: 271 intervals
  turn_leftright: 271 intervals
  scan_rightleft: 271 intervals
  turn_rightleft: 270 intervals
  elnod: 0 intervals
  scanning: 542 intervals
  turnaround: 541 intervals
  sun_up: 1 intervals
  sun_close: 0 intervals>
>
<Observation
  name = 'bossn-2-0'
  uid = '2809478596'  group has 1 processes
  telescope = <Telescope 'telescope': uid = 1274491784, site = <GroundSite 'atacama' : uid = 2187432677, lon = -67.7861111111111 deg, lat = -22.958333333333332 deg, alt = 5199.999999999725 m, weather = <SimWeather : 'atacama', year = 2025, month = 1, hour = 5, site UID = 2187432677, realization = 0, median = True)>, focalplane = <Focalplane: 97 detectors, sample_rate = 10.0 Hz, FOV = 9.637999122261855 deg, detectors = [D00A-150 .. ATM0]>>
  session = <Session 'bossn-2-0': uid = 483923066, start = 2025-02-22 03:24:00+00:00, end = 2025-02-22 06:48:59.900000+00:00>
  scan_el = 40.0 deg
  scan_min_el = 0.6981317007977318 rad
  scan_max_el = 0.6981317007977318 rad
  scan_min_az = 0.7527248312891583 rad
  scan_max_az = 1.1727223995218548 rad
  123000 total samples (123000 local)
  shared:  <SharedDataManager
    times (column): shape=(np.int64(123000),), dtype=float64
    position (column): shape=(np.int64(123000), 3), dtype=float64
    velocity (column): shape=(np.int64(123000), 3), dtype=float64
    azimuth (column): shape=(np.int64(123000),), dtype=float64
    elevation (column): shape=(np.int64(123000),), dtype=float64
    boresight_azel (column): shape=(np.int64(123000), 4), dtype=float64
    boresight_radec (column): shape=(np.int64(123000), 4), dtype=float64
    flags (column): shape=(np.int64(123000),), dtype=uint8>
  detdata:  <DetDataManager 97 local detectors, 123000 samples
    signal: shape=(97, np.int64(123000)), dtype=float64, units='K'
    flags: shape=(97, np.int64(123000)), dtype=uint8, units=''>
  intervals:  <IntervalsManager 13 lists
  throw_leftright: 312 intervals
  throw_rightleft: 312 intervals
  throw: 624 intervals
  scan_leftright: 312 intervals
  turn_leftright: 312 intervals
  scan_rightleft: 312 intervals
  turn_rightleft: 311 intervals
  elnod: 0 intervals
  scanning: 624 intervals
  turnaround: 623 intervals
  sun_up: 0 intervals
  sun_close: 0 intervals>
>
<Observation
  name = 'bossn-3-0'
  uid = '536255313'  group has 1 processes
  telescope = <Telescope 'telescope': uid = 1274491784, site = <GroundSite 'atacama' : uid = 2187432677, lon = -67.7861111111111 deg, lat = -22.958333333333332 deg, alt = 5199.999999999725 m, weather = <SimWeather : 'atacama', year = 2025, month = 1, hour = 9, site UID = 2187432677, realization = 0, median = True)>, focalplane = <Focalplane: 97 detectors, sample_rate = 10.0 Hz, FOV = 9.637999122261855 deg, detectors = [D00A-150 .. ATM0]>>
  session = <Session 'bossn-3-0': uid = 940106265, start = 2025-02-22 07:15:40+00:00, end = 2025-02-22 11:11:39.900000+00:00>
  scan_el = 50.0 deg
  scan_min_el = 0.8726646259971648 rad
  scan_max_el = 0.8726646259971648 rad
  scan_min_az = 5.2850290943673786 rad
  scan_max_az = 5.952622360653136 rad
  141600 total samples (141600 local)
  shared:  <SharedDataManager
    times (column): shape=(np.int64(141600),), dtype=float64
    position (column): shape=(np.int64(141600), 3), dtype=float64
    velocity (column): shape=(np.int64(141600), 3), dtype=float64
    azimuth (column): shape=(np.int64(141600),), dtype=float64
    elevation (column): shape=(np.int64(141600),), dtype=float64
    boresight_azel (column): shape=(np.int64(141600), 4), dtype=float64
    boresight_radec (column): shape=(np.int64(141600), 4), dtype=float64
    flags (column): shape=(np.int64(141600),), dtype=uint8>
  detdata:  <DetDataManager 97 local detectors, 141600 samples
    signal: shape=(97, np.int64(141600)), dtype=float64, units='K'
    flags: shape=(97, np.int64(141600)), dtype=uint8, units=''>
  intervals:  <IntervalsManager 13 lists
  throw_leftright: 271 intervals
  throw_rightleft: 271 intervals
  throw: 542 intervals
  scan_leftright: 271 intervals
  turn_leftright: 271 intervals
  scan_rightleft: 271 intervals
  turn_rightleft: 270 intervals
  elnod: 0 intervals
  scanning: 542 intervals
  turnaround: 541 intervals
  sun_up: 1 intervals
  sun_close: 0 intervals>
>

In [25]:
if rank == 0:
    plot_scanning(data.obs[0], s_start=0, s_end=2000)
No description has been provided for this image

Simulated Detector Data

Now we will simulate several components of our detector data. Before doing that, we set up some operators that compute our detector pointing and response on the sky (the "pointing matrix"). In TOAST, the pointing matrix is split into the pixelization of detector samples and the Stokes weights (response to I/Q/U/V on the sky).

Detector Pointing

There are 3 types of operators that define the detector pointing. The first is the geometric offset from the boresight coordinate frame to the detector coordinate frame. In the simplest case this is just a quaternion for each detector (stored in the focalplane table). Once the geometric detector pointing is computed, the pixelization on the sky is specified by a separate operator. Finally, the response of the detector to incoming Stokes parameters is given by another operator.

In [26]:
# Geometric detector pointing from boresight frame to detector frame.  We define these
# for both horizontal and equatorial coordinates, since we need the detector pointing
# in horizontal coordinates for atmosphere simulation below.

det_point_azel = toast.ops.PointingDetectorSimple(
    boresight=defaults.boresight_azel,
    quats="quats_azel"
)
det_point_radec = toast.ops.PointingDetectorSimple(
    boresight=defaults.boresight_radec,
    quats="quats_radec"
)
In [27]:
# Pixelization.  Choose a coarse pixelization for this exercise since there is
# a small patch and only a few detectors.

nside = 256
pixels_radec = toast.ops.PixelsHealpix(
    nside=nside,
    nest=True,
    detector_pointing=det_point_radec,
)
In [28]:
# Stokes weights.  This just uses focalplane table properties to treat each detector
# as a linear polarizer with possibly some cross-polar response.

weights_azel = toast.ops.StokesWeights(
    mode="IQU",
    detector_pointing=det_point_azel,
)

weights_radec = toast.ops.StokesWeights(
    mode="IQU",
    detector_pointing=det_point_radec,
)

Pixel Distribution

When working with sky data for both simulations and mapmaking, each process only stores pixels which are "hit" by the local detectors on that process. Computing this "pixel distribution" requires passing through the pointing. Normally this is done without saving the detector pointing (for memory considerations). In this notebook, we just compute the full detector pointing once at the beginning and save it.

In [29]:
pixels_radec.apply(data)
weights_radec.apply(data)
In [30]:
pix_dist = toast.ops.BuildPixelDistribution(
    pixel_dist="pixel_dist",
    pixel_pointing=pixels_radec,
)
pix_dist.apply(data)

Synthetic Sky

In order to have some kind of sky signal in our data, we generate a fake sky and scan that into timestreams.

In [31]:
input_map_file = os.path.join(out_dir, "fake_sky.fits")
if rank == 0:
    c_ell = helpers.fetch_nominal_cmb_cls(
        out_file=os.path.join(os.path.dirname(out_dir), "cl_nominal.txt")
    )
    input_map_ring = hp.synfast(c_ell, nside, fwhm=fp_fwhm.to_value(u.radian))
    input_map = 1.0e-6 * hp.reorder(input_map_ring, inp="RING", out="NEST")
    hp.write_map(input_map_file, input_map, nest=True)
    hp.mollview(input_map[0], nest=True, min=-0.01, max=0.01)
    hp.gnomview(
        input_map[0], nest=True, min=-0.01, max=0.01, rot=(200.156, 8.466), reso=4.0, xsize=1600
    )
    hp.mollview(input_map[1], nest=True, min=-0.0002, max=0.0002)
    hp.gnomview(
        input_map[1], nest=True, min=-0.0002, max=0.0002, rot=(200.156, 8.466), reso=4.0, xsize=1600
    )
    hp.mollview(input_map[2], nest=True, min=-0.0002, max=0.0002)
    hp.gnomview(
        input_map[2], nest=True, min=-0.0002, max=0.0002, rot=(200.156, 8.466), reso=4.0, xsize=1600
    )
setting the output map dtype to [dtype('float64'), dtype('float64'), dtype('float64')]
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
In [32]:
# Scan the map
scan_map = toast.ops.ScanHealpixMap(
    file=input_map_file,
    pixel_pointing=pixels_radec,
    stokes_weights=weights_radec,
)
scan_map.apply(data)
TOAST INFO: Pixel data in out_sim_ground/fake_sky.fits does not have TUNIT1 key.  Assuming 'K'.
In [33]:
# Plot the last few detectors
if rank == 0:
    plot_dets(data.obs[0], d_start=90, d_end=None, s_start=0, s_end=2000, view="scanning")
No description has been provided for this image

Instrumental Noise

We create a trivial noise model using nominal parameters from the focalplane table and then use this noise model to simulate timestreams.

In [34]:
nominal_noise = toast.ops.DefaultNoiseModel()
nominal_noise.apply(data)
In [35]:
# Plot this nominal noise model for the last few detectors
if rank == 0:
    plot_noise_model(
        data.obs[0][nominal_noise.noise_model],
        model_fit=None,
        d_start=50,
        d_end=None
    )
No description has been provided for this image
In [36]:
sim_noise = toast.ops.SimNoise(
    noise_model=nominal_noise.noise_model,
)
sim_noise.apply(data)
In [37]:
# Plot the last few detectors
if rank == 0:
    plot_dets(data.obs[0], d_start=90, d_end=None, s_start=0, s_end=2000, view="scanning")
No description has been provided for this image

We can see that the "atmospheric monitor" detector does not look much different here, since we have only simulated detector noise.

Ground Pickup

The ground and environment around the telescope may produce different loading as a function of azimuth as the telescope scans. This kind of signal is one of several possible "scan synchronous signals".

In [38]:
ground_pickup = toast.ops.SimScanSynchronousSignal(
    detector_pointing=det_point_azel,
    scale=0.001 * u.K,
    stokes_weights=weights_azel,
)
ground_pickup.apply(data)
In [39]:
# Plot the last few detectors
if rank == 0:
    plot_dets(data.obs[0], d_start=90, d_end=None, s_start=0, s_end=2000, view="scanning")
No description has been provided for this image

Simulated Atmosphere

Now we will simulate a 3D atmospheric slab moving in front of the telescope and integrate each detector along the line of site and over its bandpass. In order to do this, we have to define an operator which knows how to compute detector pointing in Az/El coordinates. This just uses the boresight pointing and the detector quaternion rotations from the boresight to compute detector pointing.

In [40]:
sim_atm = toast.ops.SimAtmosphere(
    detector_pointing=det_point_azel,
    add_loading=True,
    lmin_center=0.001 * u.m,
    lmin_sigma=0.0001 * u.m,
    lmax_center=1.0 * u.m,
    lmax_sigma=0.1 * u.m,
    xstep=20 * u.m,
    ystep=20 * u.m,
    zstep=20 * u.m,
    zmax=200 * u.m,
    gain=4e-5,
    wind_dist=1000 * u.m,
)
sim_atm.apply(data)
In [41]:
# Plot the last few normal detectors
if rank == 0:
    plot_dets(data.obs[0], d_start=90, d_end=96, s_start=0, s_end=2000, view="scanning")
No description has been provided for this image
In [42]:
# Plot the atmosphere monitor
if rank == 0:
    plot_dets(data.obs[0], d_start=96, d_end=None, s_start=0, s_end=2000, view="scanning")
No description has been provided for this image

Here we see that the atmospheric monitor has substantially more power from the 183GHz water line.

Map Making

First we want to flag the atmosphere monitor channel so that it is not considered "science" data for mapmaking purposes.

In [43]:
for ob in data.obs:
    ob.update_local_detector_flags({"ATM0": defaults.det_mask_processing})

Filtering

Since we are using the template regression / destriping mapmaker below, we want to do minimal filtering of the timestreams.

In [44]:
toast.ops.CommonModeFilter(
    redistribute=False,
    regress=True,
).apply(data)

Noise Estimation

The original noise estimate (used above to simulate instrument noise) only captures the nominal readout and detector noise sources. For map making we will treat the timestream as noise-dominated and estimate the noise model directly from the timestream. First, create a raw, binned estimate of the PSD in each detector:

In [45]:
# Estimate noise
estim = toast.ops.NoiseEstim(
    out_model="noise_estimate",
    lagmax=100,
    nbin_psd=32,
    nsum=1,
)
estim.apply(data)

This raw estimate can produce undesired effects if we use it directly in mapmaking. Instead, we first fit an analytic 1/f noise model to these.

In [46]:
# Compute a 1/f fit to this
noise_fitter = toast.ops.FitNoiseModel(
    noise_model=estim.out_model,
    out_model="fit_noise_model",
)
noise_fitter.apply(data)
/home/runner/conda/envs/docs/lib/python3.14/site-packages/toast/ops/noise_model.py:157: OptimizeWarning: Covariance of the parameters could not be estimated
  params, params_cov = curve_fit(
In [47]:
# Plot this nominal noise model for the last few detectors
if rank == 0:
    plot_noise_model(
        data.obs[0][estim.out_model],
        model_fit=data.obs[0][noise_fitter.out_model],
        d_start=90,
        d_end=None
    )
No description has been provided for this image

Binning Operator

A central piece of the mapmaking is the "binning" of timestreams into maps. This process accumulates the "noise weighted map" and then multiplies this by the diagonal pixel covariance:

$$ \text{Binned Map} = \left(P^T N^{-1} P\right)^{-1} P^T N^{-1} d $$

You can see from this that in addition to the input timestream data we need the estimated noise model and the pointing matrix.

In [48]:
# Set up binning operator for solving
binner = toast.ops.BinMap(
    pixel_dist=pix_dist.pixel_dist,
    pixel_pointing=pixels_radec,
    stokes_weights=weights_radec,
    noise_model=noise_fitter.out_model,
)

Template Matrix

The TOAST mapmaker is a "generalized destriper" that solves for "template amplitudes". These templates represent anything in the time ordered data which is not fixed on the sky or simply white noise. For this example, we will use 2 templates. One to model the scan synchronous signal and one to model the 1/f noise (including the atmosphere).

In [49]:
# The Offset template models 1/f noise as a stepwise function, which
# is the same as a "classic" destriper.

tmpl_offset = toast.templates.Offset(
    times=defaults.times,
    noise_model=noise_fitter.out_model,
    step_time=1.0 * u.second,
)
In [50]:
# Build a template matrix with our templates.

tmatrix = toast.ops.TemplateMatrix(
    templates=[tmpl_offset],
)

Making the Map

Now we are ready to instantiate the mapmaker operator. Note that if we do not specify the template matrix, then this will just produce a binned map.

In [51]:
map_maker = toast.ops.MapMaker(
    name="mapmaker",
    binning=binner,
    template_matrix=tmatrix,
    solve_rcond_threshold=1.0e-1,
    map_rcond_threshold=1.0e-1,
    iter_min=200,
    iter_max=300,
    write_hits=True,
    write_map=True,
    write_binmap=True,
    write_cov=False,
    write_invcov=False,
    write_rcond=True,
    output_dir=out_dir,
    keep_solver_products=True, # We set this to True so we can plot solved template amplitudes later
)
map_maker.apply(data)
TOAST INFO: SolveAmplitudes begin building flags for solver
TOAST INFO: SolveAmplitudes  finished flag building in 0.15 s
TOAST INFO: SolveAmplitudes begin build of solver covariance
TOAST INFO: SolveAmplitudes begin build of rcond flags
TOAST INFO: SolveAmplitudes  finished build of solver covariance in 2.19 s
TOAST INFO: SolveAmplitudes Solver flags cut 6391200 / 50803200 = 12.58% of samples
TOAST INFO: SolveAmplitudes begin RHS calculation
TOAST INFO: SolveAmplitudes  finished RHS calculation in 26.39 s
TOAST INFO: SolveAmplitudes begin PCG solver
TOAST INFO: MapMaker initial residual = 7.366501080428588e+17 in 1.36 s
TOAST INFO: MapMaker iteration    0, relative residual = 5.753299e-04 in 1.36 s
TOAST INFO: MapMaker iteration    1, relative residual = 3.375542e-05 in 1.40 s
TOAST INFO: MapMaker iteration    2, relative residual = 1.002765e-05 in 1.39 s
TOAST INFO: MapMaker iteration    3, relative residual = 4.859911e-06 in 1.41 s
TOAST INFO: MapMaker iteration    4, relative residual = 3.649384e-06 in 1.40 s
TOAST INFO: MapMaker iteration    5, relative residual = 2.062101e-06 in 1.40 s
TOAST INFO: MapMaker iteration    6, relative residual = 1.209712e-06 in 1.40 s
TOAST INFO: MapMaker iteration    7, relative residual = 7.146790e-07 in 1.44 s
TOAST INFO: MapMaker iteration    8, relative residual = 4.436549e-07 in 1.40 s
TOAST INFO: MapMaker iteration    9, relative residual = 3.278180e-07 in 1.41 s
TOAST INFO: MapMaker iteration   10, relative residual = 2.520138e-07 in 1.44 s
TOAST INFO: MapMaker iteration   11, relative residual = 1.926063e-07 in 1.39 s
TOAST INFO: MapMaker iteration   12, relative residual = 1.558319e-07 in 1.39 s
TOAST INFO: MapMaker iteration   13, relative residual = 1.278334e-07 in 1.39 s
TOAST INFO: MapMaker iteration   14, relative residual = 1.053911e-07 in 1.39 s
TOAST INFO: MapMaker iteration   15, relative residual = 8.723645e-08 in 1.40 s
TOAST INFO: MapMaker iteration   16, relative residual = 7.420054e-08 in 1.39 s
TOAST INFO: MapMaker iteration   17, relative residual = 5.610068e-08 in 1.39 s
TOAST INFO: MapMaker iteration   18, relative residual = 4.309993e-08 in 1.39 s
TOAST INFO: MapMaker iteration   19, relative residual = 3.380879e-08 in 1.40 s
TOAST INFO: MapMaker iteration   20, relative residual = 2.414850e-08 in 1.44 s
TOAST INFO: MapMaker iteration   21, relative residual = 1.799819e-08 in 1.40 s
TOAST INFO: MapMaker iteration   22, relative residual = 1.475350e-08 in 1.42 s
TOAST INFO: MapMaker iteration   23, relative residual = 1.054825e-08 in 1.45 s
TOAST INFO: MapMaker iteration   24, relative residual = 7.458997e-09 in 1.40 s
TOAST INFO: MapMaker iteration   25, relative residual = 5.313749e-09 in 1.40 s
TOAST INFO: MapMaker iteration   26, relative residual = 3.850183e-09 in 1.40 s
TOAST INFO: MapMaker iteration   27, relative residual = 2.749888e-09 in 1.40 s
TOAST INFO: MapMaker iteration   28, relative residual = 2.153863e-09 in 1.39 s
TOAST INFO: MapMaker iteration   29, relative residual = 1.779066e-09 in 1.40 s
TOAST INFO: MapMaker iteration   30, relative residual = 1.528306e-09 in 1.40 s
TOAST INFO: MapMaker iteration   31, relative residual = 1.277837e-09 in 1.40 s
TOAST INFO: MapMaker iteration   32, relative residual = 1.024035e-09 in 1.40 s
TOAST INFO: MapMaker iteration   33, relative residual = 8.051121e-10 in 1.60 s
TOAST INFO: MapMaker iteration   34, relative residual = 5.778308e-10 in 1.47 s
TOAST INFO: MapMaker iteration   35, relative residual = 4.168448e-10 in 1.53 s
TOAST INFO: MapMaker iteration   36, relative residual = 3.133783e-10 in 1.46 s
TOAST INFO: MapMaker iteration   37, relative residual = 2.347452e-10 in 1.41 s
TOAST INFO: MapMaker iteration   38, relative residual = 1.659294e-10 in 1.41 s
TOAST INFO: MapMaker iteration   39, relative residual = 1.253176e-10 in 1.41 s
TOAST INFO: MapMaker iteration   40, relative residual = 9.613123e-11 in 1.41 s
TOAST INFO: MapMaker iteration   41, relative residual = 6.763826e-11 in 1.39 s
TOAST INFO: MapMaker iteration   42, relative residual = 5.126151e-11 in 1.39 s
TOAST INFO: MapMaker iteration   43, relative residual = 3.747734e-11 in 1.39 s
TOAST INFO: MapMaker iteration   44, relative residual = 2.547787e-11 in 1.39 s
TOAST INFO: MapMaker iteration   45, relative residual = 1.734751e-11 in 1.39 s
TOAST INFO: MapMaker iteration   46, relative residual = 1.170763e-11 in 1.43 s
TOAST INFO: MapMaker iteration   47, relative residual = 8.190330e-12 in 1.39 s
TOAST INFO: MapMaker iteration   48, relative residual = 5.816466e-12 in 1.40 s
TOAST INFO: MapMaker iteration   49, relative residual = 4.088661e-12 in 1.44 s
TOAST INFO: MapMaker iteration   50, relative residual = 2.946903e-12 in 1.39 s
TOAST INFO: MapMaker iteration   51, relative residual = 2.158453e-12 in 1.39 s
TOAST INFO: MapMaker iteration   52, relative residual = 1.552652e-12 in 1.39 s
TOAST INFO: MapMaker iteration   53, relative residual = 1.125020e-12 in 1.39 s
TOAST INFO: MapMaker iteration   54, relative residual = 8.148085e-13 in 1.39 s
TOAST INFO: MapMaker PCG converged after   54 iterations and 78.94 s
TOAST INFO: SolveAmplitudes  finished solver in 78.94 s
TOAST INFO: MapMaker  finished template amplitude solve in 107.67 s
TOAST INFO: MapMaker begin build of final binning covariance
TOAST INFO: MapMaker  finished build of final covariance in 1.63 s
TOAST INFO: Wrote out_sim_ground/mapmaker_hits.fits in 0.01 s
TOAST INFO: Wrote out_sim_ground/mapmaker_rcond.fits in 0.01 s
TOAST INFO: MapMaker begin map binning
TOAST INFO: MapMaker  finished binning in 4.49 s
TOAST INFO: Wrote out_sim_ground/mapmaker_binmap.fits in 0.02 s
TOAST INFO: MapMaker begin apply template amplitudes
TOAST INFO: MapMaker  finished apply template amplitudes in 0.36 s
TOAST INFO: MapMaker begin final map binning
TOAST INFO: MapMaker  finished final binning in 4.44 s
TOAST INFO: Wrote out_sim_ground/mapmaker_map.fits in 0.02 s
TOAST INFO: MapMaker  finished output write in 0.02 s

Now plot the output maps.

In [52]:
# The output filenames will be based on the name of the mapmaker operator
out_root = os.path.join(out_dir, map_maker.name)
In [53]:
# Helper functions to plot all the maps

def plot_maps(
    root,
    range_I=(-0.01, 0.01),
    range_Q=(-0.0002, 0.0002),
    range_U=(-0.0002, 0.0002),
    max_hits=1000,
    truth=None
):
    cmap = "viridis"
    gnomres = 8.0
    gnomrot = (199.5, 8.3)
    xsize = 800
    
    hits_file = f"{root}_hits.fits"
    rcond_file = f"{root}_rcond.fits"
    binmap_file = f"{root}_binmap.fits"
    map_file = f"{root}_map.fits"

    # Load hits
    hits = hp.read_map(hits_file, field=None, nest=True)
    goodhits = hits > 0
    badhits = np.logical_not(goodhits)

    # Load rcond
    rcond = hp.read_map(rcond_file, field=None, nest=True)
    rcond[badhits] = hp.UNSEEN

    # Maps
    maps = hp.read_map(map_file, field=None, nest=True)
    binmaps = hp.read_map(binmap_file, field=None, nest=True)
    resid = None
    resid_bin = None
    if truth is not None:
        truth_maps = hp.read_map(truth, field=None, nest=True)
        resid = list()
        resid_bin = list()
        for i in range(3):
            resid.append(np.array(maps[i]) - truth_maps[i])
            resid_bin.append(np.array(binmaps[i]) - truth_maps[i])
    for i in range(3):
        maps[i][badhits] = hp.UNSEEN
        binmaps[i][badhits] = hp.UNSEEN
        if truth is not None:
            truth_maps[i][badhits] = hp.UNSEEN
            resid[i][badhits] = hp.UNSEEN
            resid_bin[i][badhits] = hp.UNSEEN

    # Plot hits and rcond
    fig = plt.figure(dpi=100, figsize=(18, 12))
    hp.gnomview(
        map=hits,
        fig=fig.number,
        sub=(1, 2, 1),
        rot=gnomrot,
        xsize=xsize,
        reso=gnomres,
        nest=True,
        cmap=cmap,
        min=0,
        max=max_hits,
        title="Hits",
    )
    hp.gnomview(
        map=rcond,
        fig=fig.number,
        sub=(1, 2, 2),
        rot=gnomrot,
        xsize=xsize,
        reso=gnomres,
        nest=True,
        cmap=cmap,
        min=0,
        max=0.5,
        title="Inverse Condition Number",
    )
    plt.show()
    plt.close()

    # Plot maps
    
    plot_cols = 2
    if truth is not None:
        plot_cols = 4
    plot_rows = 3
    fig = plt.figure(dpi=100, figsize=(18, 18))
    counter = 1
    for row, (stokes, rng) in enumerate([("I", range_I), ("Q", range_Q), ("U", range_U)]):
        for mps, res, name in [(maps, resid, "Destriped"), (binmaps, resid_bin, "Binned")]:
            hp.gnomview(
                map=mps[row],
                fig=fig.number,
                sub=(plot_rows, plot_cols, counter),
                rot=gnomrot,
                xsize=xsize,
                reso=gnomres,
                nest=True,
                cmap=cmap,
                min=rng[0],
                max=rng[1],
                title=f"{name} Stokes {stokes}",
            )
            counter += 1
            if truth is not None:
                hp.gnomview(
                    map=res[row],
                    fig=fig.number,
                    sub=(plot_rows, plot_cols, counter),
                    rot=gnomrot,
                    xsize=xsize,
                    reso=gnomres,
                    nest=True,
                    cmap=cmap,
                    min=rng[0],
                    max=rng[1],
                    title=f"{name} Stokes {stokes} Minus Input",
                )
                counter += 1
    plt.show()
    plt.close()
In [54]:
plot_maps(out_root, truth=input_map_file)
No description has been provided for this image
No description has been provided for this image

The solved template amplitudes will usually have degeneracies (for example, the ground pickup across one left-right scan could also be interpreted as just 1/f noise). However, we are just concerned with capturing the degrees of freedom of the relevant non-sky signal content so that it does not contaminate the map. This is something to keep in mind as we plot the solved template amplitudes.

In [55]:
# Write solved offset amplitudes
oamps = data[f"{map_maker.name}_solve_amplitudes"][tmpl_offset.name]
oroot = os.path.join(out_dir, f"{map_maker.name}_offset")
tmpl_offset.write(oamps, oroot)

if rank == 0:
    for ob in data.obs:
        toast.templates.offset.plot(
            f"{oroot}_{ob.name}.h5",
            compare={x: ob.detdata[defaults.det_data][x, :] for x in ob.local_detectors},
            out=f"{oroot}_{ob.name}",
            xlim=(0, 1000),
        )
In [ ]: