Coverage for python / lsst / pipe / tasks / peekExposure.py: 23%

418 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 00:08 +0000

1# This file is part of pipe_tasks. 

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

21from __future__ import annotations 

22 

23__all__ = [ 

24 "PeekExposureTaskConfig", 

25 "PeekExposureTask", 

26] 

27 

28from typing import Any 

29 

30import astropy 

31import numpy as np 

32import numpy.typing as npt 

33 

34import lsst.afw.display as afwDisplay 

35import lsst.afw.geom as afwGeom 

36import lsst.afw.image as afwImage 

37import lsst.afw.math as afwMath 

38import lsst.afw.table as afwTable 

39import lsst.daf.base as dafBase 

40import lsst.geom as geom 

41import lsst.pex.config as pexConfig 

42import lsst.pipe.base as pipeBase 

43from lsst.afw.detection import Psf 

44from lsst.afw.geom.ellipses import Quadrupole 

45from lsst.afw.image import ImageD 

46from lsst.afw.table import SourceTable 

47from lsst.geom import Box2I, Extent2I, LinearTransform, Point2D, Point2I, SpherePoint, arcseconds, degrees 

48from lsst.meas.algorithms import SourceDetectionTask, SubtractBackgroundTask 

49from lsst.meas.algorithms.installGaussianPsf import InstallGaussianPsfTask 

50from lsst.meas.base import IdGenerator, SingleFrameMeasurementTask 

51from lsst.meas.extensions import shapeHSM # noqa: F401 

52 

53IDX_SENTINEL = -99999 

54 

55# Adding obs_lsst to the table file isn't possible, so hard code this because 

56# importing it from obs_lsst isn't possible without a deferred import, which 

57# isn't possible because of the tests and the build order. 

58FILTER_DELIMITER = "~" 

59 

60 

61def isDispersedExp(exp): 

62 """Check if an exposure is dispersed. 

63 

64 Note this is copied from `atmospec.utils.isDispersedExp` to avoid a 

65 circular import. 

66 

67 Parameters 

68 ---------- 

69 exp : `lsst.afw.image.Exposure` 

70 The exposure. 

71 

72 Returns 

73 ------- 

74 isDispersed : `bool` 

75 Whether it is a dispersed image or not. 

76 """ 

77 filterFullName = exp.filter.physicalLabel 

78 if FILTER_DELIMITER not in filterFullName: 

79 raise RuntimeError(f"Error parsing filter name {filterFullName}") 

80 filt, grating = filterFullName.split(FILTER_DELIMITER) 

81 if grating.upper().startswith('EMPTY'): 

82 return False 

83 return True 

84 

85 

86def _estimateMode(data: npt.NDArray[np.float64], frac: float = 0.5) -> float: 

87 """Estimate the mode of a 1d distribution. 

88 

89 Finds the smallest interval containing the fraction ``frac`` of the data, 

90 then takes the median of the values in that interval. 

91 

92 Parameters 

93 ---------- 

94 data : array-like 

95 1d array of data values 

96 frac : float, optional 

97 Fraction of data to include in the mode interval. Default is 0.5. 

98 

99 Returns 

100 ------- 

101 mode : float 

102 Estimated mode of the data. 

103 """ 

104 

105 data = data[np.isfinite(data)] 

106 if len(data) == 0: 

107 return np.nan 

108 elif len(data) == 1: 

109 return data[0] 

110 

111 data = np.sort(data) 

112 interval = int(np.ceil(frac * len(data))) 

113 spans = data[interval:] - data[:-interval] 

114 start = np.argmin(spans) 

115 return np.median(data[start: start + interval]) 

116 

117 

118def _bearingToUnitVector( 

119 wcs: afwGeom.SkyWcs, 

120 bearing: geom.Angle, 

121 imagePoint: geom.Point2D, 

122 skyPoint: geom.SpherePoint | None = None, 

123) -> geom.Extent2D: 

124 """Compute unit vector along given bearing at given point in the sky. 

125 

126 Parameters 

127 ---------- 

128 wcs : `lsst.afw.geom.SkyWcs` 

129 World Coordinate System of image. 

130 bearing : `lsst.geom.Angle` 

131 Bearing (angle North of East) at which to compute unit vector. 

132 imagePoint : `lsst.geom.Point2D` 

133 Point in the image. 

134 skyPoint : `lsst.geom.SpherePoint`, optional 

135 Point in the sky. 

136 

137 Returns 

138 ------- 

139 unitVector : `lsst.geom.Extent2D` 

140 Unit vector in the direction of bearing. 

141 """ 

142 if skyPoint is None: 

143 skyPoint = wcs.pixelToSky(imagePoint) 

144 dpt = wcs.skyToPixel(skyPoint.offset(bearing, 1e-4 * degrees)) - imagePoint 

145 return dpt / dpt.computeNorm() 

146 

147 

148def roseVectors(wcs: afwGeom.SkyWcs, imagePoint: geom.Point2D, parAng: geom.Angle | None = None) -> dict: 

149 """Compute unit vectors in the N/W and optionally alt/az directions. 

150 

151 Parameters 

152 ---------- 

153 wcs : `lsst.afw.geom.SkyWcs` 

154 World Coordinate System of image. 

155 imagePoint : `lsst.geom.Point2D` 

156 Point in the image 

157 parAng : `lsst.geom.Angle`, optional 

158 Parallactic angle (position angle of zenith measured East from North) 

159 (default: None) 

160 

161 Returns 

162 ------- 

163 unitVectors : `dict` of `lsst.geom.Extent2D` 

164 Unit vectors in the N, W, alt, and az directions. 

165 """ 

166 ncp = SpherePoint(0 * degrees, 90 * degrees) # North Celestial Pole 

167 skyPoint = wcs.pixelToSky(imagePoint) 

168 bearing = skyPoint.bearingTo(ncp) 

169 

170 out = dict() 

171 out["N"] = _bearingToUnitVector(wcs, bearing, imagePoint, skyPoint=skyPoint) 

172 out["W"] = _bearingToUnitVector(wcs, bearing + 90 * degrees, imagePoint, skyPoint=skyPoint) 

173 

174 if parAng is not None: 

175 out["alt"] = _bearingToUnitVector(wcs, bearing - parAng, imagePoint, skyPoint=skyPoint) 

176 out["az"] = _bearingToUnitVector(wcs, bearing - parAng + 90 * degrees, imagePoint, skyPoint=skyPoint) 

177 

178 return out 

179 

180 

181def plotRose( 

182 display: afwDisplay.Display, 

183 wcs: afwGeom.SkyWcs, 

184 imagePoint: geom.Point2D, 

185 parAng: geom.Angle | None = None, 

186 len: float = 50, 

187) -> None: 

188 """Display unit vectors along N/W and optionally alt/az directions. 

189 

190 Parameters 

191 ---------- 

192 display : `lsst.afw.display.Display` 

193 Display on which to render rose. 

194 wcs : `lsst.afw.geom.SkyWcs` 

195 World Coordinate System of image. 

196 imagePoint : `lsst.geom.Point2D` 

197 Point in the image at which to render rose. 

198 parAng : `lsst.geom.Angle`, optional 

199 Parallactic angle (position angle of zenith measured East from North) 

200 (default: None) 

201 len : `float`, optional 

202 Length of the rose vectors (default: 50) 

203 """ 

204 unitVectors = roseVectors(wcs, imagePoint, parAng=parAng) 

205 colors = dict(N="r", W="r", alt="g", az="g") 

206 for name, unitVector in unitVectors.items(): 

207 display.line([imagePoint, imagePoint + len * unitVector], ctype=colors[name]) 

208 display.dot(name, *(imagePoint + 1.6 * len * unitVector), ctype=colors[name]) 

209 

210 

211class DonutPsf(Psf): 

212 def __init__(self, size: float, outerRad: float, innerRad: float): 

213 Psf.__init__(self, isFixed=True) 

214 self.size = size 

215 self.outerRad = outerRad 

216 self.innerRad = innerRad 

217 self.dimensions = Extent2I(size, size) 

218 

219 def __deepcopy__(self, memo: Any | None = None) -> DonutPsf: 

220 return DonutPsf(self.size, self.outerRad, self.innerRad) 

221 

222 def resized(self, width: float, height: float) -> DonutPsf: 

223 assert width == height 

224 return DonutPsf(width, self.outerRad, self.innerRad) 

225 

226 def _doComputeKernelImage( 

227 self, position: Point2D | None = None, color: afwImage.Color | None = None 

228 ) -> ImageD: 

229 bbox = self.computeBBox(self.getAveragePosition()) 

230 img = ImageD(bbox, 0.0) 

231 x, y = np.ogrid[bbox.minY: bbox.maxY + 1, bbox.minX: bbox.maxX + 1] 

232 rsqr = x**2 + y**2 

233 w = (rsqr < self.outerRad**2) & (rsqr > self.innerRad**2) 

234 img.array[w] = 1.0 

235 img.array /= np.sum(img.array) 

236 return img 

237 

238 def _doComputeBBox(self, position: Point2D | None = None, color: afwImage.Color | None = None) -> Box2I: 

239 return Box2I(Point2I(-self.dimensions / 2), self.dimensions) 

240 

241 def _doComputeShape( 

242 self, position: Point2D | None = None, color: afwImage.Color | None = None 

243 ) -> Quadrupole: 

244 Ixx = self.outerRad**4 - self.innerRad**4 

245 Ixx /= self.outerRad**2 - self.innerRad**2 

246 return Quadrupole(Ixx, Ixx, 0.0) 

247 

248 def _doComputeApertureFlux( 

249 self, radius: float, position: Point2D | None = None, color: afwImage.Color | None = None 

250 ) -> float: 

251 return 1 - np.exp(-0.5 * (radius / self.sigma) ** 2) 

252 

253 def __eq__(self, rhs: object) -> bool: 

254 if isinstance(rhs, DonutPsf): 

255 return self.size == rhs.size and self.outerRad == rhs.outerRad and self.innerRad == rhs.innerRad 

256 return False 

257 

258 

259class PeekTaskConfig(pexConfig.Config): 

260 """Config class for the PeekTask.""" 

261 

262 installPsf = pexConfig.ConfigurableField( 

263 target=InstallGaussianPsfTask, 

264 doc="Install a PSF model", 

265 ) # type: ignore 

266 doInstallPsf: pexConfig.Field[bool] = pexConfig.Field( 

267 dtype=bool, 

268 default=True, 

269 doc="Install a PSF model?", 

270 ) 

271 background = pexConfig.ConfigurableField( 

272 target=SubtractBackgroundTask, 

273 doc="Estimate background", 

274 ) # type: ignore 

275 detection = pexConfig.ConfigurableField(target=SourceDetectionTask, doc="Detect sources") # type: ignore 

276 measurement = pexConfig.ConfigurableField( 

277 target=SingleFrameMeasurementTask, doc="Measure sources" 

278 ) # type: ignore 

279 defaultBinSize: pexConfig.Field[int] = pexConfig.Field( 

280 dtype=int, 

281 default=1, 

282 doc="Default binning factor for exposure (often overridden).", 

283 ) 

284 

285 def setDefaults(self) -> None: 

286 super().setDefaults() 

287 # Configure to be aggressively fast. 

288 self.detection.thresholdValue = 5.0 

289 self.detection.includeThresholdMultiplier = 10.0 

290 self.detection.reEstimateBackground = False 

291 self.detection.doTempLocalBackground = False 

292 self.measurement.doReplaceWithNoise = False 

293 self.detection.minPixels = 40 

294 self.installPsf.fwhm = 5.0 

295 self.installPsf.width = 21 

296 # minimal set of measurements 

297 self.measurement.plugins.names = [ 

298 "base_PixelFlags", 

299 "base_SdssCentroid", 

300 "ext_shapeHSM_HsmSourceMoments", 

301 "base_GaussianFlux", 

302 "base_PsfFlux", 

303 "base_CircularApertureFlux", 

304 ] 

305 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments" 

306 

307 

308class PeekTask(pipeBase.Task): 

309 """Peek at exposure to quickly detect and measure both the brightest source 

310 in the image, and a set of sources representative of the exposure's overall 

311 image quality. 

312 

313 Optionally bins image and then: 

314 - installs a simple PSF model 

315 - measures and subtracts the background 

316 - detects sources 

317 - measures sources 

318 

319 Designed to be quick at the expense of primarily completeness, but also to 

320 a lesser extent accuracy. 

321 """ 

322 

323 ConfigClass = PeekTaskConfig 

324 config: PeekTaskConfig 

325 installPsf: InstallGaussianPsfTask 

326 background: SubtractBackgroundTask 

327 detection: SourceDetectionTask 

328 measurement: SingleFrameMeasurementTask 

329 _DefaultName = "peek" 

330 

331 def __init__(self, schema: Any | None = None, **kwargs: Any): 

332 super().__init__(**kwargs) 

333 

334 if schema is None: 

335 schema = SourceTable.makeMinimalSchema() 

336 self.schema = schema 

337 

338 self.makeSubtask("installPsf") 

339 self.makeSubtask("background") 

340 self.makeSubtask("detection", schema=self.schema) 

341 self.algMetadata = dafBase.PropertyList() 

342 self.makeSubtask("measurement", schema=self.schema, algMetadata=self.algMetadata) 

343 

344 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct: 

345 """Peek at exposure. 

346 

347 Parameters 

348 ---------- 

349 exposure : `lsst.afw.image.Exposure` 

350 Exposure at which to peek. 

351 binSize : `int`, optional 

352 Binning factor for exposure. Default is None, which will use the 

353 default binning factor from the config. 

354 

355 Returns 

356 ------- 

357 result : `pipeBase.Struct` 

358 Result of peeking. 

359 Struct containing: 

360 - sourceCat : `lsst.afw.table.SourceCatalog` 

361 Source catalog from the binned exposure. 

362 """ 

363 if binSize is None: 

364 binSize = self.config.defaultBinSize 

365 

366 if binSize != 1: 

367 mi = exposure.getMaskedImage() 

368 binned = afwMath.binImage(mi, binSize) 

369 exposure.setMaskedImage(binned) 

370 

371 if self.config.doInstallPsf: 

372 self.installPsf.run(exposure=exposure) 

373 

374 self.background.run(exposure) 

375 

376 idGenerator = IdGenerator() 

377 sourceIdFactory = idGenerator.make_table_id_factory() 

378 table = SourceTable.make(self.schema, sourceIdFactory) 

379 table.setMetadata(self.algMetadata) 

380 sourceCat = self.detection.run(table=table, exposure=exposure, doSmooth=True).sources 

381 

382 self.measurement.run(measCat=sourceCat, exposure=exposure, exposureId=idGenerator.catalog_id) 

383 

384 return pipeBase.Struct( 

385 sourceCat=sourceCat, 

386 ) 

387 

388 

389class PeekDonutTaskConfig(pexConfig.Config): 

390 """Config class for the PeekDonutTask.""" 

391 

392 peek = pexConfig.ConfigurableField( 

393 target=PeekTask, 

394 doc="Peek configuration", 

395 ) # type: ignore 

396 resolution = pexConfig.Field( 

397 dtype=float, 

398 default=16.0, 

399 doc="Target number of pixels for a binned donut", 

400 ) # type: ignore 

401 binSizeMax = pexConfig.Field( 

402 dtype=int, 

403 default=10, 

404 doc="Maximum binning factor for donut mode", 

405 ) # type: ignore 

406 

407 def setDefaults(self) -> None: 

408 super().setDefaults() 

409 # Donuts are big even when binned. 

410 self.peek.installPsf.fwhm = 10.0 

411 self.peek.installPsf.width = 31 

412 # Use DonutPSF if not overridden 

413 self.peek.doInstallPsf = False 

414 

415 

416class PeekDonutTask(pipeBase.Task): 

417 """Peek at a donut exposure. 

418 

419 The main modification for donuts is to aggressively bin the image to reduce 

420 the size of sources (donuts) from ~100 pixels or more to ~10 pixels. This 

421 greatly increases the speed and detection capabilities of PeekTask with 

422 little loss of accuracy for centroids. 

423 """ 

424 

425 ConfigClass = PeekDonutTaskConfig 

426 config: PeekDonutTaskConfig 

427 peek: PeekTask 

428 _DefaultName = "peekDonut" 

429 

430 def __init__(self, config: Any, **kwargs: Any): 

431 super().__init__(config=config, **kwargs) 

432 self.makeSubtask("peek") 

433 

434 def run( 

435 self, exposure: afwImage.Exposure, donutDiameter: float, binSize: int | None = None 

436 ) -> pipeBase.Struct: 

437 """Peek at donut exposure. 

438 

439 Parameters 

440 ---------- 

441 exposure : `lsst.afw.image.Exposure` 

442 Exposure at which to peek. 

443 donutDiameter : `float` 

444 Donut diameter in pixels. 

445 binSize : `int`, optional 

446 Binning factor for exposure. Default is None, which will use the 

447 resolution config value to determine the binSize. 

448 

449 Returns 

450 ------- 

451 result : `pipeBase.Struct` 

452 Result of donut peeking. 

453 Struct containing: 

454 - mode : `str` 

455 Peek mode that was run. 

456 - binSize : `int` 

457 Binning factor used. 

458 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

459 Source catalog from the binned exposure. 

460 """ 

461 if binSize is None: 

462 binSize = int( 

463 np.floor( 

464 np.clip( 

465 donutDiameter / self.config.resolution, 

466 1, 

467 self.config.binSizeMax, 

468 ) 

469 ) 

470 ) 

471 binnedDonutDiameter = donutDiameter / binSize 

472 psf = DonutPsf( 

473 binnedDonutDiameter * 1.5, binnedDonutDiameter * 0.5, binnedDonutDiameter * 0.5 * 0.3525 

474 ) 

475 

476 # Note that SourceDetectionTask will convolve with a _Gaussian 

477 # approximation to the PSF_ anyway, so we don't really need to be 

478 # precise with the PSF unless this changes. PSFs that approach the 

479 # size of the image, however, can cause problems with the detection 

480 # convolution algorithm, so we limit the size. 

481 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius() 

482 factor = 8 * sigma / (min(exposure.getDimensions()) / binSize) 

483 

484 if factor > 1: 

485 psf = DonutPsf( 

486 binnedDonutDiameter * 1.5 / factor, 

487 binnedDonutDiameter * 0.5 / factor, 

488 binnedDonutDiameter * 0.5 * 0.3525 / factor, 

489 ) 

490 exposure.setPsf(psf) 

491 

492 peekResult = self.peek.run(exposure, binSize) 

493 

494 return pipeBase.Struct( 

495 mode="donut", 

496 binSize=binSize, 

497 binnedSourceCat=peekResult.sourceCat, 

498 ) 

499 

500 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray: 

501 """Perform any filtering on the source catalog. 

502 

503 Parameters 

504 ---------- 

505 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

506 Source catalog from the binned exposure. 

507 

508 Returns 

509 ------- 

510 goodSourceMask : `numpy.ndarray` 

511 Boolean array indicating which sources are good. 

512 """ 

513 # Perform any filtering on the source catalog 

514 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool) 

515 return goodSourceMask 

516 

517 

518class PeekPhotoTaskConfig(pexConfig.Config): 

519 """Config class for the PeekPhotoTask.""" 

520 

521 peek = pexConfig.ConfigurableField( 

522 target=PeekTask, 

523 doc="Peek configuration", 

524 ) # type: ignore 

525 binSize: pexConfig.Field[int] = pexConfig.Field( 

526 dtype=int, 

527 default=2, 

528 doc="Binning factor for exposure", 

529 ) 

530 

531 def setDefaults(self) -> None: 

532 super().setDefaults() 

533 # Use a lower detection threshold in photo mode to go a bit fainter. 

534 self.peek.detection.includeThresholdMultiplier = 1.0 

535 self.peek.detection.thresholdValue = 10.0 

536 self.peek.detection.minPixels = 10 

537 

538 

539class PeekPhotoTask(pipeBase.Task): 

540 """Peek at a photo (imaging) exposure. 

541 

542 For photo mode, we keep a relatively small detection threshold value, so we 

543 can detect faint sources to use for image quality assessment. 

544 """ 

545 

546 ConfigClass = PeekPhotoTaskConfig 

547 config: PeekPhotoTaskConfig 

548 peek: PeekTask 

549 _DefaultName = "peekPhoto" 

550 

551 def __init__(self, config: Any, **kwargs: Any): 

552 super().__init__(config=config, **kwargs) 

553 self.makeSubtask("peek") 

554 

555 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct: 

556 """Peek at donut exposure. 

557 

558 Parameters 

559 ---------- 

560 exposure : `lsst.afw.image.Exposure` 

561 Exposure at which to peek. 

562 binSize : `int`, optional 

563 Binning factor for exposure. Default is None, which will use the 

564 binning factor from the config. 

565 

566 Returns 

567 ------- 

568 result : `pipeBase.Struct` 

569 Result of photo peeking. 

570 Struct containing: 

571 - mode : `str` 

572 Peek mode that was run. 

573 - binSize : `int` 

574 Binning factor used. 

575 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

576 Source catalog from the binned exposure. 

577 """ 

578 if binSize is None: 

579 binSize = self.config.binSize 

580 

581 peekResult = self.peek.run(exposure, binSize) 

582 

583 return pipeBase.Struct( 

584 mode="photo", 

585 binSize=binSize, 

586 binnedSourceCat=peekResult.sourceCat, 

587 ) 

588 

589 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray: 

590 """Perform any filtering on the source catalog. 

591 

592 Parameters 

593 ---------- 

594 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

595 Source catalog from the binned exposure. 

596 

597 Returns 

598 ------- 

599 goodSourceMask : `numpy.ndarray` 

600 Boolean array indicating which sources are good. 

601 """ 

602 # Perform any filtering on the source catalog 

603 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool) 

604 return goodSourceMask 

605 

606 

607class PeekSpecTaskConfig(pexConfig.Config): 

608 """Config class for the PeekSpecTask.""" 

609 

610 peek = pexConfig.ConfigurableField( 

611 target=PeekTask, 

612 doc="Peek configuration", 

613 ) # type: ignore 

614 binSize: pexConfig.Field[int] = pexConfig.Field( 

615 dtype=int, 

616 default=2, 

617 doc="binning factor for exposure", 

618 ) 

619 maxFootprintAspectRatio: pexConfig.Field[int] = pexConfig.Field( 

620 dtype=float, 

621 default=10.0, 

622 doc="Maximum detection footprint aspect ratio to consider as 0th order" " (non-dispersed) light.", 

623 ) 

624 

625 def setDefaults(self) -> None: 

626 super().setDefaults() 

627 # Use bright threshold 

628 self.peek.detection.includeThresholdMultiplier = 1.0 

629 self.peek.detection.thresholdValue = 500.0 

630 # Use a large radius aperture flux for spectra to better identify the 

631 # brightest source, which for spectra often has a saturated core. 

632 self.peek.measurement.slots.apFlux = "base_CircularApertureFlux_70_0" 

633 # Also allow a larger distance to peak for centroiding in case there's 

634 # a relatively large saturated region. 

635 self.peek.measurement.plugins["base_SdssCentroid"].maxDistToPeak = 15.0 

636 

637 

638class PeekSpecTask(pipeBase.Task): 

639 """Peek at a spectroscopic exposure. 

640 

641 For spec mode, we dramatically increase the detection threshold to avoid 

642 creating blends with the long spectra objects that appear in these images. 

643 We also change the default aperture flux slot to a larger aperture, which 

644 helps overcome challenges with lost flux in the interpolated cores of 

645 saturated objects. 

646 """ 

647 

648 ConfigClass = PeekSpecTaskConfig 

649 config: PeekSpecTaskConfig 

650 peek: PeekTask 

651 _DefaultName = "peekSpec" 

652 

653 def __init__(self, config: Any, **kwargs: Any): 

654 super().__init__(config=config, **kwargs) 

655 self.makeSubtask("peek") 

656 

657 def run(self, exposure: afwImage.Exposure, binSize: int | None = None) -> pipeBase.Struct: 

658 """Peek at spectroscopic exposure. 

659 

660 Parameters 

661 ---------- 

662 exposure : `lsst.afw.image.Exposure` 

663 Exposure at which to peek. 

664 binSize : `int`, optional 

665 Binning factor for exposure. Default is None, which will use the 

666 binning factor from the config. 

667 

668 Returns 

669 ------- 

670 result : `pipeBase.Struct` 

671 Result of spec peeking. 

672 Struct containing: 

673 - mode : `str` 

674 Peek mode that was run. 

675 - binSize : `int` 

676 Binning factor used. 

677 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

678 Source catalog from the binned exposure. 

679 """ 

680 if binSize is None: 

681 binSize = self.config.binSize 

682 

683 peekResult = self.peek.run(exposure, binSize) 

684 

685 return pipeBase.Struct( 

686 mode="spec", 

687 binSize=binSize, 

688 binnedSourceCat=peekResult.sourceCat, 

689 ) 

690 

691 def getGoodSources(self, binnedSourceCat: afwTable.SourceCatalog) -> np.ndarray: 

692 """Perform any filtering on the source catalog. 

693 

694 Parameters 

695 ---------- 

696 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

697 Source catalog from the binned exposure. 

698 

699 Returns 

700 ------- 

701 goodSourceMask : `numpy.ndarray` 

702 Boolean array indicating which sources are good. 

703 """ 

704 # Perform any filtering on the source catalog 

705 goodSourceMask = np.ones(len(binnedSourceCat), dtype=bool) 

706 fpShapes = [src.getFootprint().getShape() for src in binnedSourceCat] 

707 # Filter out likely spectrum detections 

708 goodSourceMask &= np.array( 

709 [sh.getIyy() < self.config.maxFootprintAspectRatio * sh.getIxx() for sh in fpShapes], 

710 dtype=np.bool_, 

711 ) 

712 return goodSourceMask 

713 

714 

715class PeekExposureTaskConfig(pexConfig.Config): 

716 """Config class for the PeekExposureTask.""" 

717 

718 donutThreshold: pexConfig.Field[float] = pexConfig.Field( 

719 dtype=float, 

720 default=50.0, 

721 doc="Size threshold in pixels for when to switch to donut mode.", 

722 ) 

723 doPhotoFallback: pexConfig.Field[bool] = pexConfig.Field( 

724 dtype=bool, 

725 default=True, 

726 doc="If True, fall back to photo mode if spec mode fails.", 

727 ) 

728 donut = pexConfig.ConfigurableField( 

729 target=PeekDonutTask, 

730 doc="PeekDonut task", 

731 ) # type: ignore 

732 photo = pexConfig.ConfigurableField( 

733 target=PeekPhotoTask, 

734 doc="PeekPhoto task", 

735 ) # type: ignore 

736 spec = pexConfig.ConfigurableField( 

737 target=PeekSpecTask, 

738 doc="PeekSpec task", 

739 ) # type: ignore 

740 

741 

742class PeekExposureTask(pipeBase.Task): 

743 """Peek at exposure to quickly detect and measure both the brightest 

744 source in the image, and a set of sources representative of the 

745 exposure's overall image quality. 

746 

747 Parameters 

748 ---------- 

749 config : `lsst.summit.utils.peekExposure.PeekExposureTaskConfig` 

750 Configuration for the task. 

751 display : `lsst.afw.display.Display`, optional 

752 For displaying the exposure and sources. 

753 

754 Notes 

755 ----- 

756 The basic philosophy of PeekExposureTask is to: 

757 1) Classify exposures based on metadata into 'donut', 'spec', or 'photo'. 

758 2) Run PeekTask on the exposure through a wrapper with class specific 

759 modifications. 

760 3) Try only to branch in the code based on the metadata, and not on the 

761 data itself. This avoids problematic discontinuities in measurements. 

762 

763 The main knobs we fiddle with based on the classification are: 

764 - Detection threshold 

765 - Minimum number of pixels for a detection 

766 - Binning of the image 

767 - Installed PSF size 

768 """ 

769 

770 ConfigClass = PeekExposureTaskConfig 

771 config: PeekExposureTaskConfig 

772 donut: PeekDonutTask 

773 photo: PeekPhotoTask 

774 spec: PeekSpecTask 

775 _DefaultName = "peekExposureTask" 

776 

777 def __init__(self, config: Any, *, display: Any = None, **kwargs: Any): 

778 super().__init__(config=config, **kwargs) 

779 

780 self.makeSubtask("donut") 

781 self.makeSubtask("photo") 

782 self.makeSubtask("spec") 

783 

784 self._display = display 

785 

786 def getDonutDiameter(self, exposure: afwImage.Exposure) -> float: 

787 """Estimate donut diameter from exposure metadata. 

788 

789 Parameters 

790 ---------- 

791 exposure : `lsst.afw.image.Exposure` 

792 Exposure to estimate donut diameter for. 

793 

794 Returns 

795 ------- 

796 donutDiameter : `float` 

797 Estimated donut diameter in pixels. 

798 """ 

799 visitInfo = exposure.getInfo().getVisitInfo() 

800 focusZ = visitInfo.focusZ 

801 instrumentLabel = visitInfo.instrumentLabel 

802 

803 match instrumentLabel: 

804 case "LATISS": 

805 focusZ *= 41 # magnification factor 

806 fratio = 18.0 

807 case "LSSTCam" | "LSSTComCam" | "LSSTComCamSim": 

808 fratio = 1.234 

809 case _: 

810 raise ValueError(f"Unknown instrument label: {instrumentLabel}") 

811 # AuxTel/ComCam/LSSTCam all have 10 micron pixels (= 10e-3 mm) 

812 donutDiameter = abs(focusZ) / fratio / 10e-3 

813 self.log.info(f"{focusZ=} mm") 

814 self.log.info(f"donutDiameter = {donutDiameter} pixels") 

815 return donutDiameter 

816 

817 def run( 

818 self, 

819 exposure: afwImage.Exposure, 

820 *, 

821 doDisplay: bool = False, 

822 doDisplayIndices: bool = False, 

823 mode: str = "auto", 

824 binSize: int | None = None, 

825 donutDiameter: float | None = None, 

826 ) -> pipeBase.Struct: 

827 """ 

828 Parameters 

829 ---------- 

830 exposure : `lsst.afw.image.Exposure` 

831 Exposure at which to peek. 

832 doDisplay : `bool`, optional 

833 Display the exposure and sources? Default False. (Requires 

834 display to have been passed to task constructor) 

835 doDisplayIndices : `bool`, optional 

836 Display the source indices? Default False. (Requires display to 

837 have been passed to task constructor) 

838 mode : {'auto', 'donut', 'spec', 'photo'}, optional 

839 Mode to run in. Default 'auto'. 

840 binSize : `int`, optional 

841 Binning factor for exposure. Default is None, which let's subtasks 

842 control rebinning directly. 

843 donutDiameter : `float`, optional 

844 Donut diameter in pixels. Default is None, which will estimate the 

845 donut diameter from the exposure metadata. 

846 

847 Returns 

848 ------- 

849 result : `pipeBase.Struct` 

850 Result of the peek. 

851 Struct containing: 

852 - mode : `str` 

853 Peek mode that was run. 

854 - binSize : `int` 

855 Binning factor used. 

856 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

857 Source catalog from the binned exposure. 

858 - table : `astropy.table.Table` 

859 Curated source table in unbinned coordinates. 

860 - brightestIdx : `int` 

861 Index of brightest source in source catalog. 

862 - brightestCentroid : `lsst.geom.Point2D` 

863 Brightest source centroid in unbinned pixel coords. 

864 - brightestPixelShape : `lsst.afw.geom.Quadrupole` 

865 Brightest source shape in unbinned pixel coords. 

866 - brightestEquatorialShape : `lsst.afw.geom.Quadrupole` 

867 Brightest source shape in equitorial coordinates (arcsec). 

868 - brightestAltAzShape : `lsst.afw.geom.Quadrupole` 

869 Brightest source shape in alt/az coordinates (arcsec). 

870 - psfPixelShape : `lsst.afw.geom.Quadrupole` 

871 Estimated PSF shape in unbinned pixel coords. 

872 - psfEquatorialShape : `lsst.afw.geom.Quadrupole` 

873 Estimated PSF shape in equitorial coordinates (arcsec). 

874 - psfAltAzShape : `lsst.afw.geom.Quadrupole` 

875 Estimated PSF shape in alt/az coordinates (arcsec). 

876 - pixelMedian : `float` 

877 Median estimate of entire image. 

878 - pixelMode : `float` 

879 Mode estimate of entire image. 

880 """ 

881 # Make a copy so the original image is unmodified. 

882 exposure = exposure.clone() 

883 try: 

884 result = self._run(exposure, doDisplay, doDisplayIndices, mode, binSize, donutDiameter) 

885 except Exception as e: 

886 self.log.warning(f"Peek failed: {e}") 

887 result = pipeBase.Struct( 

888 mode="failed", 

889 binSize=0, 

890 binnedSourceCat=None, 

891 table=None, 

892 brightestIdx=0, 

893 brightestCentroid=Point2D(np.nan, np.nan), 

894 brightestPixelShape=Quadrupole(np.nan, np.nan, np.nan), 

895 brightestEquatorialShape=Quadrupole(np.nan, np.nan, np.nan), 

896 brightestAltAzShape=Quadrupole(np.nan, np.nan, np.nan), 

897 psfPixelShape=Quadrupole(np.nan, np.nan, np.nan), 

898 psfEquatorialShape=Quadrupole(np.nan, np.nan, np.nan), 

899 psfAltAzShape=Quadrupole(np.nan, np.nan, np.nan), 

900 pixelMedian=np.nan, 

901 pixelMode=np.nan, 

902 ) 

903 return result 

904 

905 def _run( 

906 self, 

907 exposure: afwImage.Exposure, 

908 doDisplay: bool, 

909 doDisplayIndices: bool, 

910 mode: str, 

911 binSize: int | None, 

912 donutDiameter: float | None, 

913 ) -> pipeBase.Struct: 

914 """The actual run method, called by run().""" 

915 # If image is ~large, then use a subsampling of the image for 

916 # speedy median/mode estimates. 

917 arr = exposure.getMaskedImage().getImage().array 

918 sampling = 1 

919 if arr.size > 250_000: 

920 sampling = int(np.floor(np.sqrt(arr.size / 250_000))) 

921 pixelMedian = np.nanmedian(arr[::sampling, ::sampling]) 

922 pixelMode = _estimateMode(arr[::sampling, ::sampling]) 

923 

924 if donutDiameter is None: 

925 donutDiameter = self.getDonutDiameter(exposure) 

926 

927 mode, binSize, binnedSourceCat = self.runPeek(exposure, mode, donutDiameter, binSize) 

928 

929 table = self.transformTable(binSize, binnedSourceCat) 

930 

931 match mode: 

932 case "donut": 

933 goodSourceMask = self.donut.getGoodSources(binnedSourceCat) 

934 case "spec": 

935 goodSourceMask = self.spec.getGoodSources(binnedSourceCat) 

936 case "photo": 

937 goodSourceMask = self.photo.getGoodSources(binnedSourceCat) 

938 

939 # prepare output variables 

940 maxFluxIdx, brightCentroid, brightShape = self.getBrightest(binnedSourceCat, binSize, goodSourceMask) 

941 psfShape = self.getPsfShape(binnedSourceCat, binSize, goodSourceMask) 

942 

943 equatorialShapes, altAzShapes = self.transformShapes([brightShape, psfShape], exposure, binSize) 

944 

945 if doDisplay: 

946 self.updateDisplay(exposure, binSize, binnedSourceCat, maxFluxIdx, doDisplayIndices) 

947 

948 return pipeBase.Struct( 

949 mode=mode, 

950 binSize=binSize, 

951 binnedSourceCat=binnedSourceCat, 

952 table=table, 

953 brightestIdx=maxFluxIdx, 

954 brightestCentroid=brightCentroid, 

955 brightestPixelShape=brightShape, 

956 brightestEquatorialShape=equatorialShapes[0], 

957 brightestAltAzShape=altAzShapes[0], 

958 psfPixelShape=psfShape, 

959 psfEquatorialShape=equatorialShapes[1], 

960 psfAltAzShape=altAzShapes[1], 

961 pixelMedian=pixelMedian, 

962 pixelMode=pixelMode, 

963 ) 

964 

965 def runPeek( 

966 self, 

967 exposure: afwImage.Exposure, 

968 mode: str, 

969 donutDiameter: float, 

970 binSize: int | None = None, 

971 ) -> tuple[str, int, afwTable.SourceCatalog]: 

972 """Classify exposure and run appropriate PeekTask wrapper. 

973 

974 Parameters 

975 ---------- 

976 exposure : `lsst.afw.image.Exposure` 

977 Exposure to peek. 

978 mode : {'auto', 'donut', 'spec', 'photo'} 

979 Mode to run in. 

980 donutDiameter : `float` 

981 Donut diameter in pixels. 

982 binSize : `int`, optional 

983 Binning factor for exposure. Default is None, which let's subtasks 

984 control rebinning directly. 

985 

986 Returns 

987 ------- 

988 result : `pipeBase.Struct` 

989 Result of the peek. 

990 Struct containing: 

991 - mode : `str` 

992 Peek mode that was run. 

993 - binSize : `int` 

994 Binning factor used. 

995 - binnedSourceCat : `lsst.afw.table.SourceCatalog` 

996 Source catalog from the binned exposure. 

997 """ 

998 if mode == "auto": 

999 # Note, no attempt to handle dispersed donuts. They'll default to 

1000 # donut mode. 

1001 if donutDiameter > self.config.donutThreshold: 

1002 mode = "donut" 

1003 else: 

1004 if exposure.getInfo().getVisitInfo().instrumentLabel == "LATISS": 

1005 # only LATISS images *can* be dispersed, and isDispersedExp 

1006 # only works cleanly for LATISS 

1007 mode = "spec" if isDispersedExp(exposure) else "photo" 

1008 else: 

1009 mode = "photo" 

1010 

1011 match mode: 

1012 case "donut": 

1013 result = self.donut.run(exposure, donutDiameter, binSize=binSize) 

1014 binSizeOut = result.binSize 

1015 case "spec": 

1016 result = self.spec.run(exposure, binSize=binSize) 

1017 binSizeOut = result.binSize 

1018 if len(result.binnedSourceCat) == 0: 

1019 self.log.warning("No sources found in spec mode.") 

1020 if self.config.doPhotoFallback: 

1021 self.log.warning("Falling back to photo mode.") 

1022 # Note that spec.run already rebinned the image, 

1023 # so we don't need to do it again. 

1024 result = self.photo.run(exposure, binSize=1) 

1025 case "photo": 

1026 result = self.photo.run(exposure, binSize=binSize) 

1027 binSizeOut = result.binSize 

1028 case _: 

1029 raise ValueError(f"Unknown mode {mode}") 

1030 return result.mode, binSizeOut, result.binnedSourceCat 

1031 

1032 def transformTable(self, binSize: int, binnedSourceCat: afwTable.SourceCatalog) -> astropy.table.Table: 

1033 """Make an astropy table from the source catalog but with 

1034 transformations back to the original unbinned coordinates. 

1035 

1036 Since there's some ambiguity in the apFlux apertures when binning, 

1037 we'll only populate the table with the slots columns (slot_apFlux 

1038 doesn't indicate an aperture radius). For simplicity, do the same for 

1039 centroids and shapes too. 

1040 

1041 And since we're only copying over the slots_* columns, we remove the 

1042 "slots_" part of the column names and lowercase the first remaining 

1043 letter. 

1044 

1045 Parameters 

1046 ---------- 

1047 binSize : `int` 

1048 Binning factor used. 

1049 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

1050 Source catalog from the binned exposure. 

1051 

1052 Returns 

1053 ------- 

1054 table : `astropy.table.Table` 

1055 Curated source table in unbinned coordinates. 

1056 """ 

1057 table = binnedSourceCat.asAstropy() 

1058 cols = [n for n in table.colnames if n.startswith("slot")] 

1059 table = table[cols] 

1060 if "slot_Centroid_x" in cols: 

1061 table["slot_Centroid_x"] = binSize * table["slot_Centroid_x"] + (binSize - 1) / 2 

1062 table["slot_Centroid_y"] = binSize * table["slot_Centroid_y"] + (binSize - 1) / 2 

1063 if "slot_Shape_x" in cols: 

1064 table["slot_Shape_x"] = binSize * table["slot_Shape_x"] + (binSize - 1) / 2 

1065 table["slot_Shape_y"] = binSize * table["slot_Shape_y"] + (binSize - 1) / 2 

1066 table["slot_Shape_xx"] *= binSize**2 

1067 table["slot_Shape_xy"] *= binSize**2 

1068 table["slot_Shape_yy"] *= binSize**2 

1069 # area and npixels are just confusing when binning, so remove. 

1070 if "slot_PsfFlux_area" in cols: 

1071 del table["slot_PsfFlux_area"] 

1072 if "slot_PsfFlux_npixels" in cols: 

1073 del table["slot_PsfFlux_npixels"] 

1074 

1075 table.rename_columns( 

1076 [n for n in table.colnames if n.startswith("slot_")], 

1077 [n[5:6].lower() + n[6:] for n in table.colnames if n.startswith("slot_")], 

1078 ) 

1079 

1080 return table 

1081 

1082 def getBrightest( 

1083 self, binnedSourceCat: afwTable.SourceCatalog, binSize: int, goodSourceMask: npt.NDArray[np.bool_] 

1084 ) -> tuple[int, geom.Point2D, afwGeom.Quadrupole]: 

1085 """Find the brightest source in the catalog. 

1086 

1087 Parameters 

1088 ---------- 

1089 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

1090 Source catalog from the binned exposure. 

1091 binSize : `int` 

1092 Binning factor used. 

1093 goodSourceMask : `numpy.ndarray` 

1094 Boolean array indicating which sources are good. 

1095 

1096 Returns 

1097 ------- 

1098 maxFluxIdx : `int` 

1099 Index of the brightest source in the catalog. 

1100 brightCentroid : `lsst.geom.Point2D` 

1101 Centroid of the brightest source (unbinned coords). 

1102 brightShape : `lsst.afw.geom.Quadrupole` 

1103 Shape of the brightest source (unbinned coords). 

1104 """ 

1105 fluxes = np.array([source.getApInstFlux() for source in binnedSourceCat]) 

1106 idxs = np.arange(len(binnedSourceCat)) 

1107 

1108 good = goodSourceMask & np.isfinite(fluxes) 

1109 

1110 if np.sum(good) == 0: 

1111 maxFluxIdx = IDX_SENTINEL 

1112 brightCentroid = Point2D(np.nan, np.nan) 

1113 brightShape = Quadrupole(np.nan, np.nan, np.nan) 

1114 return maxFluxIdx, brightCentroid, brightShape 

1115 

1116 fluxes = fluxes[good] 

1117 idxs = idxs[good] 

1118 maxFluxIdx = idxs[np.nanargmax(fluxes)] 

1119 brightest = binnedSourceCat[maxFluxIdx] 

1120 

1121 # Convert binned coordinates back to original unbinned 

1122 # coordinates 

1123 brightX, brightY = brightest.getCentroid() 

1124 brightX = binSize * brightX + (binSize - 1) / 2 

1125 brightY = binSize * brightY + (binSize - 1) / 2 

1126 brightCentroid = Point2D(brightX, brightY) 

1127 brightIXX = brightest.getIxx() * binSize**2 

1128 brightIXY = brightest.getIxy() * binSize**2 

1129 brightIYY = brightest.getIyy() * binSize**2 

1130 brightShape = Quadrupole(brightIXX, brightIYY, brightIXY) 

1131 

1132 return maxFluxIdx, brightCentroid, brightShape 

1133 

1134 def getPsfShape( 

1135 self, binnedSourceCat: afwTable.SourceCatalog, binSize: int, goodSourceMask: npt.NDArray[np.bool_] 

1136 ) -> afwGeom.Quadrupole: 

1137 """Estimate the modal PSF shape from the sources. 

1138 

1139 Parameters 

1140 ---------- 

1141 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

1142 Source catalog from the binned exposure. 

1143 binSize : `int` 

1144 Binning factor used. 

1145 goodSourceMask : `numpy.ndarray` 

1146 Boolean array indicating which sources are good. 

1147 

1148 Returns 

1149 ------- 

1150 psfShape : `lsst.afw.geom.Quadrupole` 

1151 Estimated PSF shape (unbinned coords). 

1152 """ 

1153 fluxes = np.array([source.getApInstFlux() for source in binnedSourceCat]) 

1154 idxs = np.arange(len(binnedSourceCat)) 

1155 

1156 good = goodSourceMask & np.isfinite(fluxes) 

1157 

1158 if np.sum(good) == 0: 

1159 return Quadrupole(np.nan, np.nan, np.nan) 

1160 

1161 fluxes = fluxes[good] 

1162 idxs = idxs[good] 

1163 

1164 psfIXX = _estimateMode(np.array([source.getIxx() for source in binnedSourceCat])[goodSourceMask]) 

1165 psfIYY = _estimateMode(np.array([source.getIyy() for source in binnedSourceCat])[goodSourceMask]) 

1166 psfIXY = _estimateMode(np.array([source.getIxy() for source in binnedSourceCat])[goodSourceMask]) 

1167 

1168 return Quadrupole( 

1169 psfIXX * binSize**2, 

1170 psfIYY * binSize**2, 

1171 psfIXY * binSize**2, 

1172 ) 

1173 

1174 def transformShapes( 

1175 self, shapes: afwGeom.Quadrupole, exposure: afwImage.Exposure, binSize: int 

1176 ) -> tuple[list[afwGeom.Quadrupole], list[afwGeom.Quadrupole]]: 

1177 """Transform shapes from x/y pixel coordinates to equitorial and 

1178 horizon coordinates. 

1179 

1180 Parameters 

1181 ---------- 

1182 shapes : `list` of `lsst.afw.geom.Quadrupole` 

1183 List of shapes (in pixel coordinates) to transform. 

1184 exposure : `lsst.afw.image.Exposure` 

1185 Exposure containing WCS and VisitInfo for transformation. 

1186 binSize : `int` 

1187 Binning factor used. 

1188 

1189 Returns 

1190 ------- 

1191 equatorialShapes : `list` of `lsst.afw.geom.Quadrupole` 

1192 List of shapes transformed to equitorial (North and West) 

1193 coordinates. Units are arcseconds. 

1194 altAzShapes : `list` of `lsst.afw.geom.Quadrupole` 

1195 List of shapes transformed to alt/az coordinates. Units are 

1196 arcseconds. 

1197 """ 

1198 pt = Point2D(np.array([*exposure.getBBox().getCenter()]) / binSize) 

1199 wcs = exposure.wcs 

1200 visitInfo = exposure.info.getVisitInfo() 

1201 parAngle = visitInfo.boresightParAngle 

1202 

1203 equatorialShapes = [] 

1204 altAzShapes = [] 

1205 for shape in shapes: 

1206 if wcs is None: 

1207 equatorialShapes.append(Quadrupole(np.nan, np.nan, np.nan)) 

1208 altAzShapes.append(Quadrupole(np.nan, np.nan, np.nan)) 

1209 continue 

1210 # The WCS transforms to N (dec) and E (ra), but we want N and W to 

1211 # conform with weak-lensing conventions. So we flip the [0] 

1212 # component of the transformation. 

1213 neTransform = wcs.linearizePixelToSky(pt, arcseconds).getLinear() 

1214 nwTransform = LinearTransform(np.array([[-1, 0], [0, 1]]) @ neTransform.getMatrix()) 

1215 equatorialShapes.append(shape.transform(nwTransform)) 

1216 

1217 # To get from N/W to alt/az, we need to additionally rotate by the 

1218 # parallactic angle. 

1219 rot = LinearTransform.makeRotation(parAngle).getMatrix() 

1220 aaTransform = LinearTransform(nwTransform.getMatrix() @ rot) 

1221 altAzShapes.append(shape.transform(aaTransform)) 

1222 

1223 return equatorialShapes, altAzShapes 

1224 

1225 def updateDisplay( 

1226 self, 

1227 exposure: afwImage.Exposure, 

1228 binSize: int, 

1229 binnedSourceCat: afwTable.SourceCatalog, 

1230 maxFluxIdx: int, 

1231 doDisplayIndices: bool, 

1232 ) -> None: 

1233 """Update the afwDisplay with the exposure and sources. 

1234 

1235 Parameters 

1236 ---------- 

1237 exposure : `lsst.afw.image.Exposure` 

1238 Exposure to peek. 

1239 binSize : `int` 

1240 Binning factor used. 

1241 binnedSourceCat : `lsst.afw.table.SourceCatalog` 

1242 Source catalog from the binned exposure. 

1243 maxFluxIdx : `int` 

1244 Index of the brightest source in the catalog. 

1245 doDisplayIndices : `bool` 

1246 Display the source indices? 

1247 """ 

1248 if self._display is None: 

1249 raise RuntimeError("Display failed as no display provided during init()") 

1250 

1251 visitInfo = exposure.info.getVisitInfo() 

1252 self._display.mtv(exposure) 

1253 wcs = exposure.wcs 

1254 if wcs is not None: 

1255 plotRose( 

1256 self._display, 

1257 wcs, 

1258 Point2D(200 / binSize, 200 / binSize), 

1259 parAng=visitInfo.boresightParAngle, 

1260 len=100 / binSize, 

1261 ) 

1262 

1263 for idx, source in enumerate(binnedSourceCat): 

1264 x, y = source.getCentroid() 

1265 sh = source.getShape() 

1266 self._display.dot(sh, x, y) 

1267 if doDisplayIndices: 

1268 self._display.dot(str(idx), x, y) 

1269 

1270 if maxFluxIdx != IDX_SENTINEL: 

1271 self._display.dot( 

1272 "+", 

1273 *binnedSourceCat[maxFluxIdx].getCentroid(), 

1274 ctype=afwDisplay.RED, 

1275 size=10, 

1276 )