Skip to content

LAT pointing model and ffp updates #1216

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
48 changes: 35 additions & 13 deletions sotodlib/coords/fp_containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,18 @@ def dist(self):
return np.linalg.norm(self.diff, axis=1)
return np.linalg.norm(self.diff[:, :2], axis=1)

@property
def padded(self):
return self.tot_weight is None

@classmethod
def empty(cls, template, stream_id, wafer_slot, n_aman):
if template is None:
raise TypeError("template must be an instance of Template, not None")
full_fp = np.full(template.fp.shape + (n_aman,), np.nan)
tot_weight = np.zeros(len(template.det_ids))
tot_weight = np.zeros((len(template.det_ids), 2))
avg_fp = np.full_like(template.fp, np.nan)
weight = np.zeros(len(template.det_ids))
weight = np.zeros((len(template.det_ids), 2))
transformed = template.fp.copy()
center = template.center.copy()
center_transformed = template.center.copy()
Expand Down Expand Up @@ -219,6 +223,9 @@ def map_by_det_id(self, aman):
srt = np.argsort(aman.det_info.det_id[msk])
xi = aman.pointing.xi[msk][srt][mapping]
eta = aman.pointing.eta[msk][srt][mapping]
r2 = np.nan + np.zeros_like(eta)
if "r2" in aman.pointing:
r2 = aman.pointing.R2[msk][srt][mapping]
Comment on lines +227 to +228
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"r2" and "R2"?

if "polarization" in aman:
# name of field just a placeholder for now
gamma = aman.polarization.polang[msk][srt][mapping]
Expand All @@ -227,12 +234,13 @@ def map_by_det_id(self, aman):
else:
gamma = np.full(len(xi), np.nan)
fp = np.column_stack((xi, eta, gamma))
return fp, template_msk
return fp, r2, template_msk

def add_fp(self, i, fp, weights, template_msk):
if self.full_fp is None or self.tot_weight is None:
raise ValueError("full_fp or tot_weight not initialized")
self.full_fp[template_msk, :, i] = fp * weights[..., None]
self.full_fp[template_msk, :, i] = fp * weights[:, 0][..., None]
weights = np.nan_to_num(weights)
self.tot_weight[template_msk] += weights

def save(self, f, db_info, group):
Expand All @@ -242,9 +250,10 @@ def save(self, f, db_info, group):
("xi", np.float32),
("eta", np.float32),
("gamma", np.float32),
("measured", np.bool_),
]
fpout = np.fromiter(
zip(self.det_ids, *(self.transformed.T)), dtype=outdt, count=ndets
zip(self.det_ids, *(self.transformed.T), np.ones(len(self.det_ids), dtype=bool)*(self.padded)), dtype=outdt, count=ndets
)
write_dataset(
metadata.ResultSet.from_friend(fpout),
Expand All @@ -269,6 +278,7 @@ def save(self, f, db_info, group):
("eta_m", np.float32),
("gamma_m", np.float32),
("weights", np.float32),
("r2", np.float32),
("n_point", np.int8),
("n_gamma", np.int8),
]
Expand All @@ -277,7 +287,7 @@ def save(self, f, db_info, group):
self.det_ids,
*(self.transformed.T),
*(self.avg_fp.T),
self.weights,
*(self.weights.T),
self.n_point,
self.n_gamma,
),
Expand Down Expand Up @@ -305,6 +315,8 @@ def save(self, f, db_info, group):
def load(cls, group):
stream_id = group.name.split("/")[-1]
fp_full = read_dataset(group.file, f"{group.name}/focal_plane_full")
if fp_full.keys is None:
raise ValueError("fp_full somehow has no keys")
det_ids = fp_full["dets:det_id"]
avg_fp = np.column_stack(
(
Expand All @@ -313,7 +325,10 @@ def load(cls, group):
np.array(fp_full["gamma_m"]),
)
)
weights = fp_full["weights"]
# For backwards compatibility
weights = np.array(fp_full["weights"])
if "r2" in fp_full.keys:
weights = np.column_stack((weights, np.array(fp_full["r2"])))
transformed = np.column_stack(
(
np.array(fp_full["xi_t"]),
Expand Down Expand Up @@ -367,6 +382,11 @@ def __post_init__(self):
self.center, self.transform_fullcm.affine, self.transform_fullcm.shift
)

@property
def num_fps(self):
fps = [fp for fp in self.focal_planes if fp.tot_weight is not None]
return len(fps)

@classmethod
def from_pointing_cfg(cls, pointing_cfg):
name = pointing_cfg["tube_slot"]
Expand Down Expand Up @@ -544,9 +564,9 @@ def plot_ufm(focal_plane, plot_dir):

def plot_ot(ot, plot_dir):
fig, (ax1, ax2) = plt.subplots(1, 2, constrained_layout=True)
dists = [fp.dist[fp.isfinite] * 180 * 60 * 60 / np.pi for fp in ot.focal_planes]
xis = [fp.transformed[fp.isfinite, 0] for fp in ot.focal_planes]
etas = [fp.transformed[fp.isfinite, 1] for fp in ot.focal_planes]
dists = [fp.dist[fp.isfinite] * 180 * 60 * 60 / np.pi for fp in ot.focal_planes if fp.tot_weight is not None]
xis = [fp.transformed[fp.isfinite, 0] for fp in ot.focal_planes if fp.tot_weight is not None]
etas = [fp.transformed[fp.isfinite, 1] for fp in ot.focal_planes if fp.tot_weight is not None]

# Plot the radial dist
r = np.sqrt(np.hstack(xis) ** 2 + np.hstack(etas) ** 2)
Expand Down Expand Up @@ -633,21 +653,23 @@ def plot_receiver(receiver, plot_dir):
xlims, ylims = receiver.lims

fig, axs_all = plt.subplots(
4, 3, sharex="col", sharey="row", constrained_layout=True
len(valid_ids), 3, sharex="col", sharey="row", constrained_layout=True
)
axs = axs_all.flat
axs[0].set_title("Xi")
axs[1].set_title("Eta")
axs[2].set_title("Gamma")
cf = None
for i in range(4):
for i in range(len(valid_ids)):
for j in range(3):
axs[3 * i + j].set_aspect("equal")
axs[3 * i + j].set_xlim(xlims)
axs[3 * i + j].set_ylim(ylims)
for fp in receiver.focal_planes:
if fp.tot_weight is None:
continue
msk = (fp.template.id_strs == valid_ids[i]) * fp.isfinite
if np.sum(msk) == 0:
if np.sum(msk) < 3:
continue
diff = fp.diff * 180 * 60 * 60 / np.pi
cf = axs[3 * i + 0].tricontourf(
Expand Down
152 changes: 122 additions & 30 deletions sotodlib/coords/pointing_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,28 +72,33 @@ def apply_pointing_model(tod, pointing_model=None, ancil=None,
AxisManager: the corrected boresight.

"""
if pointing_model is None and 'pointing_model' not in tod:
logger.warning('No pointing_model found -- applying basic model.')
assert wrap in (None, 'boresight'), \
'When using naive pointing model, wrap=... not supported'
if pointing_model is None and "pointing_model" not in tod:
logger.warning("No pointing_model found -- applying basic model.")
assert wrap in (
None,
"boresight",
), "When using naive pointing model, wrap=... not supported"
return apply_basic_pointing_model(tod)

pointing_model = _valid_arg(pointing_model, 'pointing_model',
src=tod)
ancil = _valid_arg(ancil, 'ancil', src=tod)
pointing_model = _valid_arg(pointing_model, "pointing_model", src=tod)
ancil = _valid_arg(ancil, "ancil", src=tod)

# Encoder values, to radians.
vers = pointing_model['version']
tel_type = vers.split('_')[0]
if tel_type == 'sat':
if pointing_model is None:
raise ValueError("No pointing_model specified")
if "version" not in pointing_model:
raise ValueError("Pointing model version not specified")
vers = pointing_model["version"]
tel_type = vers.split("_")[0]
if tel_type == "sat":
boresight = apply_pointing_model_sat(vers, pointing_model, tod, ancil)
elif tel_type == 'lat':
boresight = apply_pointing_model_lat(vers, pointing_model, tod, ancil)
elif tel_type == "lat":
boresight = apply_pointing_model_lat(vers, pointing_model, ancil)
else:
raise ValueError(f'Unimplemented pointing model "{vers}"')

if wrap is None:
wrap = 'boresight'
wrap = "boresight"
if wrap is not False:
if wrap in tod._fields:
del tod[wrap]
Expand All @@ -115,8 +120,74 @@ def apply_pointing_model_sat(vers, params, tod, ancil):
raise ValueError(f'Unimplemented pointing model "{vers}"')


def apply_pointing_model_lat(vers, tod, pointing_model, ancil):
raise ValueError(f'Unimplemented pointing model "{vers}"')
def apply_pointing_model_lat(vers, params, ancil):
az, el, roll = _get_lat_enc_radians(ancil)

if vers == 'lat_naive':
return _new_boresight(ancil.samps, az=az, el=el, roll=roll)

if vers == "lat_v1":
az1, el1, roll1 = model_lat_v1(params, az, el, roll)
return _new_boresight(ancil.samps, az=az1, el=el1, roll=roll1)

else:
raise ValueError(f'Unimplemented pointing model "{vers}"')


#
# LAT model(s)
#
def model_lat_v1(params, az, el, roll):
"""Applies pointing model to (az, el, roll).

Args:
params: AxisManager (or dict) of pointing parameters.
az, el, roll: naive horizon coordinates, in radians, of the
boresight.

The implemented model parameters are:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On naming of the parameters ... here are some things that I like about the SAT model naming:

  • (xi, eta) coordinate pairs are of the form something_{xi,eta}0. The 0 indicates it's a single number, rather than a vector. The xi, eta last means you can easily just say something, to mean the pair / the whole rotation.
  • Encoder offsets are called "enc_offset_{axis}".

I'm on board with "cr" for the corotator.

I left some comments in the model about how the various (xi, eta) pairs should be described, and whether or not that means you apply (xi, eta) or (-xi, -eta) in the model... it bears a bit of thought, in case we ever want to describe this carefully to someone. The specific problem I have with the current param descriptions is that "the offset between A and B" is ambiguous to me ... it doesn't have a specific direction attached to it (whereas "the position of A relative to B" does).


- rx_{xi, eta}_offset: The offset between the LATR center and the axis
of the corotator.
- cr_offset: The corotator encoder offset.
- el_{xi, eta}_offset: The offset between the elevation exis and the
corotator axis.
- mir_{xi, eta}_offset: The offset between the mirror's axis and the elevation
axis (ie: a tilt in the mirrors).
- az_offset: The azimuth encoder offset.
- el_offset: The elevation encoder offset.
"""
_p = dict(param_defaults['lat_v1'])
if isinstance(params, dict):
_p.update(params)
else:
_p.update({k: params[k] for k in params._fields.keys()})
params, _p = _p, None

for k, v in params.items():
if k == 'version':
continue
if k not in param_defaults['lat_v1'] and v != 0.:
raise ValueError(f'Handling of model param "{k}" is not implemented.')

cr = el - roll - np.deg2rad(60)
q_enc = quat.rotation_lonlat(
-1 * (az.copy() + params["az_offset"]), el.copy() + params["el_offset"]
)
q_mir = quat.rotation_xieta(params["mir_xi_offset"], params["mir_eta_offset"])
q_el_roll = quat.euler(2, el.copy() + params["el_offset"] - np.deg2rad(60))
q_tel = quat.rotation_xieta(params["el_xi_offset"], params["el_eta_offset"])
q_cr_roll = quat.euler(2, -1 * cr - params["cr_offset"])
q_rx = quat.rotation_xieta(params["rx_xi_offset"], params["rx_eta_offset"])
Comment on lines +177 to +181
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So with regards to q_mir, q_tel, q_rx. The coordinates that go into these are all offsets, in a sense. But not in the same way as encoder offsets are... so I might drop that word in favor of some other descriptive token.

But more importantly we should decide whether we want to apply them as rotation_xieta(xi, eta) or rotation_xieta(-xi, -eta).

Consider the combo q_cr_roll * q_rx. If q_rx = rotation_xieta(+xi, +eta), then you're saying "where (xi, eta) represents a shift applied to the focal plane, prior to rotating by the corotator angle." If you use (-xi, -eta), then you can say "(xi, eta) represents the position in the focal plane which that remains fixed under corotation."

It's equivalent, mathematically. But the signs of the parameters change. So it's really a choice of how you want to be able to describe this, in words, in code comments and supporting writeups, going forward. I think it's nice to be able to say that second thing -- "xi, eta is a position in the focal plane where some thing happens". This is the rationale for the docstring re-wording above. But there is probably a reasonable opposite view.

(In the current SAT parametrization I think these might be mixed ... fp_rot seems to the "position of" convention but fp_offset uses the "shift applied" convention. Something to consider for v2...)

new_az, el, roll = (
quat.decompose_lonlat(q_enc * q_mir * q_el_roll * q_tel * q_cr_roll * q_rx)
* np.array([-1, 1, 1])[..., None]
)

change = ((new_az - az) + np.pi) % (2 * np.pi) - np.pi
az = az.copy() + change

return az, el, roll


#
Expand All @@ -126,19 +197,6 @@ def apply_pointing_model_lat(vers, tod, pointing_model, ancil):
# sat_v1: you can expand v1, as long as new params don't do anything
# if their value is zero (and that should be the registered default).

defaults_sat_v1 = {
'enc_offset_az': 0.,
'enc_offset_el': 0.,
'enc_offset_boresight': 0.,
'fp_offset_xi0': 0.,
'fp_offset_eta0': 0.,
'fp_rot_xi0': 0.,
'fp_rot_eta0': 0.,
'az_rot': 0.,
'base_tilt_cos': 0.,
'base_tilt_sin': 0.,
}

def model_sat_v1(params, az, el, roll):
"""Applies pointing model to (az, el, roll).

Expand All @@ -161,7 +219,7 @@ def model_sat_v1(params, az, el, roll):
- az_rot: Dimensionless parameter describing a linear dependence of Az on El.

"""
_p = dict(defaults_sat_v1)
_p = dict(param_defaults['sat_v1'])
if isinstance(params, dict):
_p.update(params)
else:
Expand All @@ -171,7 +229,7 @@ def model_sat_v1(params, az, el, roll):
for k, v in params.items():
if k == 'version':
continue
if k not in defaults_sat_v1 and v != 0.:
if k not in param_defaults['sat_v1'] and v != 0.:
raise ValueError(f'Handling of model param "{k}" is not implemented.')

# Construct offsetted encoders.
Expand Down Expand Up @@ -205,6 +263,31 @@ def model_sat_v1(params, az, el, roll):


# Support functions
param_defaults={
'lat_v1' : {
'az_offset': 0,
'el_offset': 0,
'cr_offset': 0,
'el_xi_offset': 0,
'el_eta_offset': 0,
'rx_xi_offset': 0,
'rx_eta_offset': 0,
'mir_xi_offset': 0,
'mir_eta_offset': 0,
},
'sat_v1' : {
'enc_offset_az': 0.,
'enc_offset_el': 0.,
'enc_offset_boresight': 0.,
'fp_offset_xi0': 0.,
'fp_offset_eta0': 0.,
'fp_rot_xi0': 0.,
'fp_rot_eta0': 0.,
'az_rot': 0.,
'base_tilt_cos': 0.,
'base_tilt_sin': 0.
}
}

def _new_boresight(samps, az=None, el=None, roll=None):
boresight = core.AxisManager(samps)
Expand All @@ -219,6 +302,15 @@ def _get_sat_enc_radians(ancil):
ancil.el_enc * DEG,
-ancil.boresight_enc * DEG)

def _get_lat_enc_radians(ancil):
az = ancil.az_enc * DEG
el = ancil.el_enc * DEG
if "roll_enc" in ancil:
roll = ancil.roll_enc * DEG
else:
roll = (ancil.el_enc - ancil.corotator_enc - 60) * DEG
return (az, el, roll)

def get_base_tilt_q(c, s):
"""Returns the quaternion rotation that applies base tilt, taking
vectors in the platforms horizon coordinates to vectors in the
Expand Down
Loading