Coverage for python/lsst/meas/astrom/refit_pointing.py: 85%

130 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-23 01:46 -0700

1# This file is part of meas_astrom. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22from __future__ import annotations 

23 

24__all__ = ("RefitPointingConfig", "RefitPointingTask", "NoVisitWcs") 

25 

26import math 

27 

28import numpy as np 

29 

30from lsst.geom import ( 

31 Angle, 

32 Box2D, 

33 Point2D, 

34 SpherePoint, 

35 SphereTransform, 

36 arcseconds, 

37 degrees, 

38 radians, 

39) 

40from lsst.pex.config import Config, Field 

41from lsst.pipe.base import AlgorithmError, Struct, Task 

42from lsst.obs.base.visit_geometry import VisitGeometry 

43from lsst.obs.base.utils import createInitialSkyWcsFromBoresight 

44from lsst.sphgeom import ConvexPolygon 

45 

46 

47class NoVisitWcs(AlgorithmError): 

48 """Exception raised when there are no WCSs for any detectors in a visit.""" 

49 

50 @property 

51 def metadata(self): 

52 return {} 

53 

54 

55class RefitPointingConfig(Config): 

56 grid_spacing = Field[float]( 

57 doc=( 

58 "Spacing (in pixels) between grid points used to evaluate the WCS when fitting the pointing. " 

59 "This can be a very sparse grid (there are only three degrees of freedom). " 

60 "If the spacing does not divide the detector bounding box evenly, it is decreased slightly." 

61 ), 

62 dtype=float, 

63 default=512.0, 

64 ) 

65 rejection_threshold = Field( 

66 doc=( 

67 "If the distance between the target WCS position and the position predicted by the camera " 

68 "geometry after refitting the pointing using just one detector exceeds this value (in " 

69 "arcseconds) at any point on the pointing-fit grid, that detector is rejected from the pointing " 

70 "fit. The quantity this threshold is applied to is saved in the wcs_detector_pointing_residual " 

71 "column." 

72 ), 

73 dtype=float, 

74 default=10.0, 

75 ) 

76 nulling_threshold = Field( 

77 doc=( 

78 "If the distance between the target WCS position and the position predicted by the camera " 

79 "geometry after refitting the pointing using all detectors exceeds this value (in arcseconds) " 

80 "at any point on the pointing-fit grid, that detector's WCS is set to None in the catalog. " 

81 "The quantity this threshold is applied to is saved in the wcs_visit_pointing_residual column." 

82 ), 

83 dtype=float, 

84 default=60.0, 

85 ) 

86 schema_prefix = Field( 

87 doc="Prefix for all schema fields.", 

88 dtype=str, 

89 default="", 

90 ) 

91 fallback_region_padding = Field( 

92 doc=( 

93 "Padding to add (in pixels) to the regions of detectors for which only a " 

94 "pointing + camera geometry WCS is available." 

95 ), 

96 dtype=int, 

97 default=50, 

98 ) 

99 

100 

101class RefitPointingTask(Task): 

102 """A task that uses the available WCSs of the detectors in a visit to 

103 re-fit the pointing for that visit and compute new visit regions for the butler. 

104 """ 

105 

106 _DefaultName = "refitPointing" 

107 ConfigClass = RefitPointingConfig 

108 

109 def __init__(self, config=None, *, schema, **kwargs): 

110 super().__init__(config, **kwargs) 

111 self._detector_pointing_residual_key = schema.addField( 

112 self.config.schema_prefix + "wcs_detector_pointing_residual", 

113 type="Angle", 

114 doc=( 

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." 

118 ), 

119 ) 

120 self._visit_pointing_residual_key = schema.addField( 

121 self.config.schema_prefix + "wcs_visit_pointing_residual", 

122 type="Angle", 

123 doc=( 

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." 

127 ), 

128 ) 

129 self._rejected_key = schema.addField( 

130 self.config.schema_prefix + "wcs_detector_pointing_rejected", 

131 type="Flag", 

132 doc=( 

133 "Flag set if this detector was rejected from the pointing fit due to its " 

134 "wcs_detector_pointing_residual value." 

135 ), 

136 ) 

137 self._rejection_threshold = self.config.rejection_threshold * arcseconds 

138 self._nulling_threshold = self.config.nulling_threshold * arcseconds 

139 

140 def run(self, *, catalog, camera): 

141 """Re-fit the pointing from the WCSs in a visit. 

142 

143 Parameters 

144 ---------- 

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` 

150 Camera geometry. 

151 

152 Returns 

153 ------- 

154 results : `lsst.pipe.base.Struct` 

155 A struct with the following attributes: 

156 

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. 

163 

164 Raises 

165 ------ 

166 NoVisitWcs 

167 Raised if ``catalog`` is empty or if there are no WCSs for any 

168 detectors. 

169 """ 

170 if not catalog: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

171 raise NoVisitWcs("No detector rows in visit catalog.") 

172 boresight, orientation = self._fit_pointing(catalog, camera) 

173 if (visit_info := catalog[0].getVisitInfo()) is not None: 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 old_boresight = visit_info.getBoresightRaDec() 

175 offset = old_boresight.separation(boresight) 

176 self.log.info( 

177 "Re-fit pointing is %s, orientation=%0.2f deg (%0.2g deg from the original boresight).", 

178 boresight, 

179 orientation.asDegrees(), 

180 offset.asDegrees(), 

181 ) 

182 else: 

183 self.log.info("Re-fit pointing is %s, orientation=%0.2f deg.", boresight, orientation.asDegrees()) 

184 self._null_bad(catalog) 

185 regions = self._make_visit_geometry(boresight, orientation, catalog, camera) 

186 return Struct( 

187 boresight=boresight, 

188 orientation=orientation, 

189 catalog=catalog, 

190 regions=regions, 

191 ) 

192 

193 def _fit_pointing(self, catalog, camera): 

194 """Fit the pointing for a visit from the detectors in that visit that 

195 have a fitted WCS. 

196 

197 Parameters 

198 ---------- 

199 catalog : `lsst.afw.table.ExposureCatalog` 

200 A catalog of per-detector records for the visit. 

201 camera : `lsst.afw.cameraGeom.Camera` 

202 Camera geometry. 

203 

204 Returns 

205 ------- 

206 boresight : `lsst.geom.SpherePoint` 

207 New boresight location. 

208 orientation : `lsst.geom.Angle` 

209 New orientation angle. 

210 """ 

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() 

219 # We call the WCSs that were actually fit to the stars the "true" 

220 # WCSs. 

221 target_wcs = record.getWcs() 

222 if target_wcs is None: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true

223 continue 

224 try: 

225 detector = camera[detector_id] 

226 except LookupError: 

227 self.log.warning("Detector %d has no camera geometry; skipping it.", detector_id) 

228 continue 

229 if start_boresight is None: 

230 # We just need some semi-arbitrary point on the sky that lets 

231 # extract the camera geometry part of a raw WCS. Might be 

232 # helpful to have it in the right hemisphere, but otherwise it 

233 # shouldn't matter. 

234 start_boresight = target_wcs.pixelToSky(Point2D(0.0, 0.0)) 

235 # Make a raw-like WCS at the arbitrary boresight and orientation. 

236 start_wcs = createInitialSkyWcsFromBoresight( 

237 start_boresight, start_orientation, detector=detector 

238 ) 

239 # Make a grid of positions for the detector and map them to the sky 

240 # via both the true WCS and the arbitrary raw-like one, but in 

241 # xyz unit-vector form. 

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), 

246 axis=1, 

247 ) 

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), 

251 axis=1, 

252 ) 

253 # Fit the pointing using just the grid for this detector to see if 

254 # the residuals are any good; they won't be if the target WCS is 

255 # bonkers and makes the detector non-rectangular on the sky. 

256 detector_transform = SphereTransform.fit_unit_vectors( 

257 start_xyz[detector_id], 

258 target_xyz[detector_id], 

259 ) 

260 detector_pointing_residual = self._compute_pointing_residual( 

261 detector_transform, start_xyz[detector_id], target_xyz[detector_id] 

262 ) 

263 record.set(self._detector_pointing_residual_key, detector_pointing_residual) 

264 if detector_pointing_residual > self._rejection_threshold: 

265 record.set(self._rejected_key, True) 

266 if not detectors_kept: 266 ↛ 268line 266 didn't jump to line 268 because the condition on line 266 was never true

267 # This was the first detector we saw; need to reset. 

268 start_boresight = None 

269 self.log.warning( 

270 'Dropping detector %d with detector pointing residual %0.2g" from pointing fit.', 

271 detector_id, 

272 detector_pointing_residual.asArcseconds(), 

273 ) 

274 continue 

275 detectors_kept.append(detector_id) 

276 if not detectors_kept: 276 ↛ 279line 276 didn't jump to line 279 because the condition on line 276 was never true

277 # Since we can't apply the nulling-threshold test, set all WCSs to 

278 # None. 

279 for record in catalog: 

280 record.setWcs(None) 

281 raise NoVisitWcs("No valid target WCSs were left after rejection.") 

282 # Fit the spherical rotation that maps the points in the arbitrary 

283 # start WCS to the target WCS, using all kept detectors. 

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]), 

287 ) 

288 # Compute and record the residuals for each detector with this 

289 # transform. 

290 for record in catalog: 

291 detector_id = record.getId() 

292 if detector_id not in start_xyz: 292 ↛ 294line 292 didn't jump to line 294 because the condition on line 292 was never true

293 # This detector already doesn't have a WCS. 

294 continue 

295 visit_pointing_residual = self._compute_pointing_residual( 

296 transform, start_xyz[detector_id], target_xyz[detector_id] 

297 ) 

298 record.set(self._visit_pointing_residual_key, visit_pointing_residual) 

299 # If we apply that same rotation to our arbitrary start boresight, we 

300 # get the boresight predicted by the target WCSs. 

301 boresight = transform(start_boresight) 

302 # If we apply that rotation to a point on the FIELD_ANGLE y-axis, we 

303 # can similarly recover the orientation angle predicted by the target 

304 # WCSs. 

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(): 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 raise NotImplementedError("Cameras with focal plane parity flips are not yet supported.") 

310 return boresight, orientation 

311 

312 def _compute_pointing_residual(self, transform, from_xyz, to_xyz): 

313 # Apply the transform to the start positions and subtract the 

314 # target positions (all in 3-vector space) to get the residual 

315 # 3-vectors. 

316 residual_vecs = np.dot(transform.matrix, from_xyz.transpose()).transpose() 

317 residual_vecs -= to_xyz 

318 # Compute the squared chord length of the residual vectors, find 

319 # the maximum of that over the grid (since everything else we do 

320 # is monotonic), then translate that into an angle. 

321 return 2.0 * np.arcsin(0.5 * np.sum(residual_vecs**2, axis=1).max() ** 0.5) * radians 

322 

323 def _null_bad(self, catalog): 

324 for record in catalog: 

325 visit_pointing_residual = record.get(self._visit_pointing_residual_key) 

326 if visit_pointing_residual > self._nulling_threshold: 

327 self.log.warning( 

328 'Setting WCS to None for detector %d with visit pointing residual %0.2g".', 

329 record.getId(), 

330 visit_pointing_residual.asArcseconds(), 

331 ) 

332 record.setWcs(None) 

333 

334 def _make_visit_geometry(self, boresight, orientation, catalog, camera): 

335 """Create new sky regions for the visit and its detectors. 

336 

337 Parameters 

338 ---------- 

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` 

348 Camera geometry. 

349 

350 Returns 

351 ------- 

352 regions : `lsst.obs.base.visit_geometry.VisitGeometry` 

353 Updated regions for the visit and all detectors in the camera. 

354 """ 

355 detector_regions: dict[int, ConvexPolygon] = {} 

356 all_vertices = [] 

357 for detector in camera: 

358 wcs = None 

359 if (record := catalog.find(detector.getId())) is not None: 359 ↛ 361line 359 didn't jump to line 361 because the condition on line 359 was always true

360 wcs = record.getWcs() 

361 pixel_bbox = Box2D(detector.getBBox()) 

362 if wcs is None or record.get(self._rejected_key): 

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] 

367 detector_regions[detector.getId()] = ConvexPolygon(vertices) 

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, 

376 ) 

377 

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) 

382 # We add one to the dimensions since there's a point at the min and max 

383 # in each dimension. 

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()