Simulated Satellite Scan Strategies

In [1]:
# Load common tools for all lessons
import sys
sys.path.insert(0, "..")
from lesson_tools import (
    fake_focalplane
)

# Capture C++ output in the jupyter cells
%reload_ext wurlitzer

Overview

A generic satellite scanning strategy can be described in terms of precession and spin axes, the opening angles about each of them, and the rates of rotation. The precession axis itself is typically oriented in the anti-sun direction and that orientation must be updated either in steps or with a continuous slewing motion. Here is a cartoon (drawn from an old, public LiteBIRD talk) showing a sketch of these angles: Cartoon of LiteBIRD scanning

For a real satellite experiment such as Planck or LiteBIRD, there are many custom details, such as simulating repointing maneuvers, simulating any lost time due to cooler cycling, nutation effects, etc. Those are contained in classes for the specific experiments. The tools built in to the core TOAST package are intended for rough simulations to study things like scan strategy choices.

TOD Class for Simulations

In the introductory lesson we saw the use of a TOD derived class providing things like telescope pointing. Here we introduce the TODSatellite class which serves that purpose for generic satellite simulations.

In [2]:
import numpy as np

import toast
from toast.todmap import (
    slew_precession_axis,
    TODSatellite
)
In [3]:
# Default Comm (one group for this example)

comm = toast.Comm()
In [4]:
# Create our fake focalplane

fp = fake_focalplane()

detnames = list(sorted(fp.keys()))
detquat = {x: fp[x]["quat"] for x in detnames}
In [5]:
# Scan parameters (made up, not physically motivated)

samplerate = 10.0
precperiod = 90.0
precangle = 45.0
spinperiod = 1.0
spinangle = 45.0
In [6]:
# We can simulate a simplistic HWP

hwprpm = 6.0
In [7]:
# Number of samples

nsamples = 100
In [8]:
# Instantiate a TOD

tod = TODSatellite(
    comm.comm_group, 
    detquat, 
    nsamples, 
    firstsamp=0,
    firsttime=0.0,
    rate=samplerate,
    spinperiod=spinperiod,
    spinangle=spinangle,
    precperiod=precperiod,
    precangle=precangle,
    coord="E",
    hwprpm=hwprpm
)
In [9]:
# The TOD constructor above specifies the scan parameters, but the boresight
# simulation is not done until we set the location of the precession axis as a
# function of time.  There is a reason for this delayed construction.  The data
# distribution occurs during the above construction, and we might want to only
# simulate the precession axis motion for our local data.  In reality this is
# cheap enough to do on one process and distribute during the construction.

qprec = np.empty(4 * tod.local_samples[1], dtype=np.float64).reshape((-1, 4))

deg_per_day = 1.0

slew_precession_axis(
    qprec,
    firstsamp=tod.local_samples[0],
    samplerate=samplerate,
    degday=deg_per_day,
)

tod.set_prec_axis(qprec=qprec)
In [10]:
# Now we can read from this TOD object

print("TOD timestampes = {} ...".format(tod.read_times()[:5]))
print("TOD boresight = \n{} ...".format(tod.read_boresight()[:5,:]))
for d in detnames:
    print("TOD detector {} = {} ...".format(d, tod.read(detector=d, n=5)))
    print("TOD detector {} flags = {} ...".format(d, tod.read_flags(detector=d, n=5)))
    print("TOD detector {} pointing = {} ...".format(d, tod.read_pntg(detector=d, n=5)))
TOD timestampes = [0.  0.1 0.2 0.3 0.4] ...
TOD boresight = 
[[ 0.5         0.5        -0.5         0.5       ]
 [ 0.50372461  0.50002202 -0.49626167  0.49996385]
 [ 0.50743541  0.50002989 -0.49250974  0.49991357]
 [ 0.51113228  0.50002363 -0.4887443   0.49984914]
 [ 0.51481514  0.50000321 -0.48496547  0.49977059]] ...
TOD detector 0A = [0. 0. 0. 0. 0.] ...
TOD detector 0A flags = [0 0 0 0 0] ...
TOD detector 0A pointing = [[ 0.65328148  0.27059805 -0.27059805  0.65328148]
 [ 0.656731    0.26919305 -0.26715812  0.65181749]
 [ 0.66016234  0.26778026 -0.26371103  0.65033523]
 [ 0.66357541  0.26635974 -0.26025687  0.64883474]
 [ 0.66697012  0.26493151 -0.25679575  0.64731607]] ...
TOD detector 0B = [0. 0. 0. 0. 0.] ...
TOD detector 0B flags = [0 0 0 0 0] ...
TOD detector 0B pointing = [[ 0.65328148 -0.27059805  0.27059805  0.65328148]
 [ 0.65472717 -0.27403071  0.27199525  0.64981388]
 [ 0.65615451 -0.27745603  0.27338459  0.64632831]
 [ 0.65756345 -0.2808739   0.27476605  0.64282484]
 [ 0.65895396 -0.28428423  0.27613957  0.6393036 ]] ...
TOD detector 1A = [0. 0. 0. 0. 0.] ...
TOD detector 1A flags = [0 0 0 0 0] ...
TOD detector 1A pointing = [[ 0.50501286  0.50501286 -0.49493637  0.49493637]
 [ 0.50869961  0.50503451 -0.4911607   0.4949    ]
 [ 0.51237241  0.50504188 -0.48737156  0.49484964]
 [ 0.51603116  0.50503497 -0.48356907  0.49478528]
 [ 0.51967576  0.50501376 -0.47975332  0.49470694]] ...
TOD detector 1B = [0. 0. 0. 0. 0.] ...
TOD detector 1B flags = [0 0 0 0 0] ...
TOD detector 1B pointing = [[ 7.14196038e-01 -1.30104261e-18 -1.11022302e-16  6.99945726e-01]
 [ 7.16818276e-01 -2.59161549e-03  2.64408538e-03  6.97250208e-01]
 [ 7.19420549e-01 -5.18346747e-03  5.28779671e-03  6.94535272e-01]
 [ 7.22002785e-01 -7.77548483e-03  7.93106150e-03  6.91800997e-01]
 [ 7.24564909e-01 -1.03675965e-02  1.05738073e-02  6.89047458e-01]] ...
TOD detector 2A = [0. 0. 0. 0. 0.] ...
TOD detector 2A flags = [0 0 0 0 0] ...
TOD detector 2A pointing = [[ 0.65417834  0.27764851 -0.26352011  0.6523183 ]
 [ 0.65759801  0.27622036 -0.26005044  0.65087705]
 [ 0.6609995   0.27478423 -0.25657382  0.64941756]
 [ 0.66438269  0.27334016 -0.25309032  0.64793988]
 [ 0.6677475   0.2718882  -0.24960006  0.64644404]] ...
TOD detector 2B = [0. 0. 0. 0. 0.] ...
TOD detector 2B flags = [0 0 0 0 0] ...
TOD detector 2B pointing = [[ 0.65890108 -0.26624679  0.27492183  0.64759555]
 [ 0.6603093  -0.26967473  0.27635614  0.64412301]
 [ 0.66169902 -0.27309543  0.27778248  0.64063265]
 [ 0.66307019 -0.27650882  0.2792008   0.63712456]
 [ 0.66442277 -0.27991479  0.28061107  0.63359886]] ...
TOD detector 3A = [0. 0. 0. 0. 0.] ...
TOD detector 3A flags = [0 0 0 0 0] ...
TOD detector 3A pointing = [[ 0.49309224  0.50181874 -0.49813049  0.50685699]
 [ 0.49683581  0.50180832 -0.49441092  0.50685345]
 [ 0.50056576  0.50178371 -0.49067781  0.50683559]
 [ 0.50428199  0.5017449  -0.48693124  0.50680341]
 [ 0.50798438  0.50169191 -0.48317134  0.50675689]] ...
TOD detector 3B = [0. 0. 0. 0. 0.] ...
TOD detector 3B flags = [0 0 0 0 0] ...
TOD detector 3B pointing = [[ 0.7035083   0.00617057  0.00617057  0.71063346]
 [ 0.70614804  0.00351609  0.0087982   0.70800083]
 [ 0.70876811  0.00086122  0.01142528  0.70534849]
 [ 0.71136844 -0.00179399  0.01405174  0.70267651]
 [ 0.71394896 -0.00444945  0.01667751  0.69998497]] ...
TOD detector 4A = [0. 0. 0. 0. 0.] ...
TOD detector 4A flags = [0 0 0 0 0] ...
TOD detector 4A pointing = [[ 0.49493637  0.49493637 -0.50501286  0.50501286]
 [ 0.49869846  0.49495875 -0.50131225  0.50497694]
 [ 0.50244687  0.49496713 -0.4975979   0.50492673]
 [ 0.50618151  0.49496151 -0.49386991  0.50486225]
 [ 0.50990226  0.49494189 -0.49012838  0.5047835 ]] ...
TOD detector 4B = [0. 0. 0. 0. 0.] ...
TOD detector 4B flags = [0 0 0 0 0] ...
TOD detector 4B pointing = [[ 6.99945726e-01  1.30104261e-18 -1.11022302e-16  7.14196038e-01]
 [ 7.02621751e-01 -2.64437272e-03  2.59132230e-03  7.11553911e-01]
 [ 7.05278207e-01 -5.28897433e-03  5.18226587e-03  7.08891968e-01]
 [ 7.07915019e-01 -7.93373230e-03  7.77275966e-03  7.06210285e-01]
 [ 7.10532113e-01 -1.05785741e-02  1.03627326e-02  7.03508937e-01]] ...
TOD detector 5A = [0. 0. 0. 0. 0.] ...
TOD detector 5A flags = [0 0 0 0 0] ...
TOD detector 5A pointing = [[ 0.50181874  0.49309224 -0.50685699  0.49813049]
 [ 0.50556168  0.49314706 -0.50313781  0.49806195]
 [ 0.50929075  0.49318794 -0.49940483  0.49797932]
 [ 0.51300585  0.49321485 -0.49565816  0.49788261]
 [ 0.51670688  0.49322781 -0.49189789  0.49777183]] ...
TOD detector 5B = [0. 0. 0. 0. 0.] ...
TOD detector 5B flags = [0 0 0 0 0] ...
TOD detector 5B pointing = [[ 0.7035083  -0.00617057 -0.00617057  0.71063346]
 [ 0.70619373 -0.00877846 -0.00358917  0.70795514]
 [ 0.70885948 -0.01138641 -0.00100798  0.7052571 ]
 [ 0.71150548 -0.01399435  0.00157293  0.70253942]
 [ 0.71413167 -0.01660221  0.0041535   0.69980217]] ...
TOD detector 6A = [0. 0. 0. 0. 0.] ...
TOD detector 6A flags = [0 0 0 0 0] ...
TOD detector 6A pointing = [[ 0.65890108  0.26624679 -0.27492183  0.64759555]
 [ 0.66234515  0.26487916 -0.2714774   0.64609439]
 [ 0.66577088  0.26350386 -0.26802568  0.64457512]
 [ 0.66917818  0.26212094 -0.26456677  0.64303779]
 [ 0.67256696  0.26073044 -0.26110078  0.64148243]] ...
TOD detector 6B = [0. 0. 0. 0. 0.] ...
TOD detector 6B flags = [0 0 0 0 0] ...
TOD detector 6B pointing = [[ 0.65417834 -0.27764851  0.26352011  0.6523183 ]
 [ 0.65564659 -0.28105089  0.26489422  0.64882123]
 [ 0.65709647 -0.28444573  0.26626067  0.64530622]
 [ 0.65852793 -0.28783293  0.26761942  0.64177334]
 [ 0.65994092 -0.2912124   0.26897045  0.63822271]] ...

Notice that the signal data for all detectors is zero. For simulated TOD classes, there is no data to "read". Instead, simulated timestreams are constructed and stored in the tod.cache member variable.

Low Resolution Example

Imagine the case of a satellite telescope with detector beams that are a 5 degrees FWHM. We'll use a healpix resolution of NSIDE = 32 (approximately 2 degrees) for this example. Let's use made-up angles for the spin and precession angles of 40 and 50 degrees, respectively:

\begin{align} \alpha & = 50^{\circ}\\ \beta & = 40^{\circ}\\ \omega_{\alpha} & = \text{precession rate}\\ \omega_{\beta} & = \text{spin rate}\\ \end{align}

When computing the precession rate, we want the precession motion to be slow enough so that the speed of the boresight on the sky does not vary enough to change our effective beams. The speed variation on the sky due to precession is

\begin{align} v_{\text{min}} & = \beta \cdot \omega_{\beta} - \alpha \cdot \omega_{\alpha}\\ v_{\text{max}} & = \beta \cdot \omega_{\beta} + \alpha \cdot \omega_{\alpha}\\ v_{\text{diff}} & = v_{\text{max}} - v_{\text{min}} = 2 \alpha \omega_{\alpha} \end{align}

This change, integrated over a sample, must be a small fraction (here called "$X$") of the beam FWHM:

\begin{align} \frac{2 \alpha \omega_{\alpha}}{f_{\text{sample}}} & = X \cdot \text{FWHM}\\ f_{\text{sample}} & = \frac{2 \alpha \omega_{\alpha}}{X \cdot \text{FWHM}}\\ \end{align}

The speed of the boresight on the sky in degrees per second due to the spin axis motion is

$$v_{bore} = \alpha \cdot \omega_{\alpha} \cdot \frac{1}{60}$$

If we want to have 3 hits per pixel with two degree pixels (2/3 degree per second), this gives us

\begin{align} v_{bore} = \frac{2}{3} & = \alpha \cdot \omega_{\alpha} \cdot \frac{1}{60}\\ \omega_{\alpha} & = \frac{60}{3 \cdot \alpha} = 0.8\;\text{RPM} \end{align}

If we assume five percent for our "$X$" fraction above, then this in turn forces our sample rate to be:

$$f_{\text{sample}} = \frac{2 \cdot 50 \cdot 0.8}{0.05 \cdot 3.0 \cdot 60} = 8.9\;\text{Hz}$$

The precession rate is slower than the spin rate. The spin rate above corresponds to a period of 1.25 minutes. We choose a precession period 20 times longer (25 minutes). We will assume a very simple satellite motion where the precession axis slews continuously in the anti-sun direction.

NOTE: For the serial example in the next cell, we have artificially decreased the sample rate to 0.5 Hz and the resolution to NSIDE=16. This is so that this small example fits into reasonable RAM while still covering the sky.

In [11]:
# Scan parameters

alpha = 50.0      # precession opening angle, degrees
beta = 45.0       # spin opening angle, degrees
p_alpha = 25.0    # precession period, minutes
p_beta = 1.25     # spin period, minutes
samplerate = 0.5  # sample rate, Hz
hwprpm = 5.0      # HWP rotation in RPM
nside = 16        # Healpix NSIDE
In [12]:
# We will use one observation per day, with no gaps in between, and
# run for one year.

obs_samples = int(24 * 3600.0 * samplerate) - 1
nobs = 366
In [13]:
# Slew the precession axis so that it completes one circle

deg_per_day = 360.0 / nobs
In [14]:
# Create distributed data

data = toast.Data(comm)
In [15]:
# Append observations

for ob in range(nobs):
    obsname = "{:03d}".format(ob)
    obsfirst = ob * (obs_samples + 1)
    obsstart = 24 * 3600.0
    tod = TODSatellite(
        comm.comm_group, 
        detquat, 
        obs_samples, 
        firstsamp=obsfirst,
        firsttime=obsstart,
        rate=samplerate,
        spinperiod=p_beta,
        spinangle=beta,
        precperiod=p_alpha,
        precangle=alpha,
        coord="E",
        hwprpm=hwprpm
    )
    qprec = np.empty(4 * tod.local_samples[1], dtype=np.float64).reshape((-1, 4))
    slew_precession_axis(
        qprec,
        firstsamp=obsfirst,
        samplerate=samplerate,
        degday=deg_per_day,
    )
    tod.set_prec_axis(qprec=qprec)
    obs = dict()
    obs["tod"] = tod
    data.obs.append(obs)

Now that we have simulated our scan strategy, we can make a simple hit map to visualize this

In [16]:
from toast.todmap import (
    OpPointingHpix,
    OpAccumDiag
)
from toast.map import (
    DistPixels
)
In [17]:
# Make a simple pointing matrix

pointing = OpPointingHpix(nside=nside, nest=True, mode="IQU")
pointing.exec(data)
In [18]:
# Construct a distributed map to store the hit map

npix = 12 * nside**2

hits = DistPixels(
    data,
    nnz=1,
    dtype=np.int64,
)
hits.data.fill(0)
In [19]:
# Accumulate the hit map locally

build_hits = OpAccumDiag(hits=hits)
build_hits.exec(data)
In [20]:
# Reduce the map across processes (a No-op in this case)

hits.allreduce()
In [21]:
# Write out the map

hitsfile = "simscan_satellite_hits.fits"
hits.write_healpix_fits(hitsfile)
In [22]:
# Plot the map.  If we were running on multiple processes, then
# only rank zero would do this...

import healpy as hp
import matplotlib.pyplot as plt
%matplotlib inline

hitdata = hp.read_map(hitsfile, nest=True)
hp.mollview(hitdata, xsize=800, nest=True, cmap="cool", min=0)
plt.show()
NSIDE = 16
ORDERING = NESTED in fits file
INDXSCHM = IMPLICIT
In [ ]: