109 def __init__(self, config=None, *, schema, **kwargs):
112 self.config.schema_prefix +
"wcs_detector_pointing_residual",
115 "Maximum difference (on the pointing-fit grid) between the target WCS position and "
116 "the position predicted by camera geometry, after re-pointing using the target WCS "
117 "for this detector only."
121 self.config.schema_prefix +
"wcs_visit_pointing_residual",
124 "Maximum difference (on the pointing-fit grid) between the target WCS position and "
125 "the position predicted by camera geometry, after re-pointing using the target WCS "
126 "of all non-rejected detectors in the visit."
130 self.config.schema_prefix +
"wcs_detector_pointing_rejected",
133 "Flag set if this detector was rejected from the pointing fit due to its "
134 "wcs_detector_pointing_residual value."
140 def run(self, *, catalog, camera):
141 """Re-fit the pointing from the WCSs in a visit.
145 catalog : `lsst.afw.table.ExposureCatalog`
146 A catalog of per-detector records for the visit. Columns with WCS
147 diagnostics are updatd in-place, and WCSs may be set to `None` if
148 they do not satisfy the `~RefitPointingConfig.nulling_threshold`.
149 camera : `lsst.afw.cameraGeom.Camera`
154 results : `lsst.pipe.base.Struct`
155 A struct with the following attributes:
157 - boresight (`lsst.geom.SpherePoint`): new boresight location
158 - orientation (`lsst.geom.Angle`): new orientation angle
159 - catalog (`lsst.afw.table.ExposureCatalog`): the same catalog that
160 was passed in, after modification in-place.
161 - regions (`lsst.obs.base.VisitGeometry`): updated regions for the
162 visit and all detectors.
167 Raised if ``catalog`` is empty or if there are no WCSs for any
171 raise NoVisitWcs(
"No detector rows in visit catalog.")
173 if (visit_info := catalog[0].getVisitInfo())
is not None:
174 old_boresight = visit_info.getBoresightRaDec()
175 offset = old_boresight.separation(boresight)
177 "Re-fit pointing is %s, orientation=%0.2f deg (%0.2g deg from the original boresight).",
179 orientation.asDegrees(),
183 self.log.info(
"Re-fit pointing is %s, orientation=%0.2f deg.", boresight, orientation.asDegrees())
188 orientation=orientation,
194 """Fit the pointing for a visit from the detectors in that visit that
199 catalog : `lsst.afw.table.ExposureCatalog`
200 A catalog of per-detector records for the visit.
201 camera : `lsst.afw.cameraGeom.Camera`
206 boresight : `lsst.geom.SpherePoint`
207 New boresight location.
208 orientation : `lsst.geom.Angle`
209 New orientation angle.
211 start_boresight: SpherePoint |
None =
None
212 start_orientation = 0.0 * degrees
213 start_y_axis_point: SpherePoint |
None =
None
214 detectors_kept: list[int] = []
215 start_xyz: dict[int, np.ndarray] = {}
216 target_xyz: dict[int, np.ndarray] = {}
217 for record
in catalog:
218 detector_id = record.getId()
221 target_wcs = record.getWcs()
222 if target_wcs
is None:
225 detector = camera[detector_id]
227 self.log.warning(
"Detector %d has no camera geometry; skipping it.", detector_id)
229 if start_boresight
is None:
234 start_boresight = target_wcs.pixelToSky(
Point2D(0.0, 0.0))
236 start_wcs = createInitialSkyWcsFromBoresight(
237 start_boresight, start_orientation, detector=detector
242 pixel_x, pixel_y = self.
_make_grid(detector, self.config.grid_spacing)
243 start_ra, start_dec = start_wcs.pixelToSkyArray(pixel_x, pixel_y)
244 start_xyz[detector_id] = np.stack(
245 SpherePoint.toUnitXYZ(longitude=start_ra, latitude=start_dec, units=radians),
248 target_ra, target_dec = target_wcs.pixelToSkyArray(pixel_x, pixel_y)
249 target_xyz[detector_id] = np.stack(
250 SpherePoint.toUnitXYZ(longitude=target_ra, latitude=target_dec, units=radians),
256 detector_transform = SphereTransform.fit_unit_vectors(
257 start_xyz[detector_id],
258 target_xyz[detector_id],
261 detector_transform, start_xyz[detector_id], target_xyz[detector_id]
266 if not detectors_kept:
268 start_boresight =
None
270 'Dropping detector %d with detector pointing residual %0.2g" from pointing fit.',
272 detector_pointing_residual.asArcseconds(),
275 detectors_kept.append(detector_id)
276 if not detectors_kept:
279 for record
in catalog:
281 raise NoVisitWcs(
"No valid target WCSs were left after rejection.")
284 transform = SphereTransform.fit_unit_vectors(
285 np.concatenate([start_xyz[i]
for i
in detectors_kept]),
286 np.concatenate([target_xyz[i]
for i
in detectors_kept]),
290 for record
in catalog:
291 detector_id = record.getId()
292 if detector_id
not in start_xyz:
296 transform, start_xyz[detector_id], target_xyz[detector_id]
301 boresight = transform(start_boresight)
305 start_y_axis_point = start_boresight.offset(90 * degrees, 1.0 * degrees)
306 transformed_y_axis_point = transform(start_y_axis_point)
307 orientation =
Angle(90, degrees) - boresight.bearingTo(transformed_y_axis_point)
308 if camera.getFocalPlaneParity():
309 raise NotImplementedError(
"Cameras with focal plane parity flips are not yet supported.")
310 return boresight, orientation
335 """Create new sky regions for the visit and its detectors.
339 boresight : `lsst.geom.SpherePoint`
340 New boresight location.
341 orientation : `lsst.geom.Angle`
342 New orientation angle.
343 catalog : `lsst.afw.table.ExposureCatalog`
344 A catalog of per-detector records for the visit with WCSs
345 A repointed raw-like WCS will be used for any detectors not in the
346 catalog or for which the catalog record does not have a WCS.
347 camera : `lsst.afw.cameraGeom.Camera`
352 regions : `lsst.obs.base.visit_geometry.VisitGeometry`
353 Updated regions for the visit and all detectors in the camera.
355 detector_regions: dict[int, ConvexPolygon] = {}
357 for detector
in camera:
359 if (record := catalog.find(detector.getId()))
is not None:
360 wcs = record.getWcs()
361 pixel_bbox =
Box2D(detector.getBBox())
363 wcs = createInitialSkyWcsFromBoresight(boresight, orientation, detector)
364 pixel_bbox.grow(self.config.fallback_region_padding)
365 corners = wcs.pixelToSky(pixel_bbox.getCorners())
366 vertices = [sp.getVector()
for sp
in corners]
368 all_vertices.extend(vertices)
369 visit_region = ConvexPolygon.convexHull(all_vertices)
370 return VisitGeometry(
371 boresight_ra=boresight.getRa().asDegrees(),
372 boresight_dec=boresight.getDec().asDegrees(),
373 orientation=orientation.asDegrees(),
374 visit_region=visit_region,
375 detector_regions=detector_regions,
378 def _make_grid(self, detector, spacing) -> tuple[np.ndarray, np.ndarray]:
379 pixel_bbox =
Box2D(detector.getBBox())
380 n_x = math.ceil(pixel_bbox.width / spacing)
381 n_y = math.ceil(pixel_bbox.height / spacing)
384 xs = np.linspace(pixel_bbox.x.min, pixel_bbox.x.max, n_x + 1)
385 ys = np.linspace(pixel_bbox.y.min, pixel_bbox.y.max, n_y + 1)
386 x, y = np.meshgrid(xs, ys)
387 return x.ravel(), y.ravel()