Coverage for python/lsst/atmospec/processStar.py: 27%

312 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-30 06:31 -0700

1# This file is part of atmospec. 

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 

22__all__ = ['ProcessStarTask', 'ProcessStarTaskConfig'] 

23 

24import os 

25import numpy as np 

26import matplotlib.pyplot as plt 

27 

28import lsstDebug 

29import lsst.afw.image as afwImage 

30import lsst.geom as geom 

31from lsst.ip.isr import IsrTask 

32import lsst.pex.config as pexConfig 

33import lsst.pipe.base as pipeBase 

34import lsst.pipe.base.connectionTypes as cT 

35from lsst.pipe.base.task import TaskError 

36 

37from lsst.utils import getPackageDir 

38from lsst.pipe.tasks.characterizeImage import CharacterizeImageTask 

39from lsst.meas.algorithms import ReferenceObjectLoader, MagnitudeLimit 

40from lsst.meas.astrom import AstrometryTask, FitAffineWcsTask 

41 

42import lsst.afw.detection as afwDetect 

43 

44from .spectraction import SpectractorShim 

45from .utils import getLinearStagePosition, isDispersedExp, getFilterAndDisperserFromExp 

46 

47COMMISSIONING = False # allows illegal things for on the mountain usage. 

48 

49# TODO: 

50# Sort out read noise and gain 

51# remove dummy image totally 

52# talk to Jeremy about turning the image beforehand and giving new coords 

53# deal with not having ambient temp 

54# Gen3ification 

55# astropy warning for units on save 

56# but actually just remove all manual saves entirely, I think? 

57# Make SED persistable 

58# Move to QFM for star finding failover case 

59# Remove old cruft functions 

60# change spectractions run method to be ~all kwargs with *,... 

61 

62 

63class ProcessStarTaskConnections(pipeBase.PipelineTaskConnections, 

64 dimensions=("instrument", "visit", "detector")): 

65 inputExp = cT.Input( 

66 name="icExp", 

67 doc="Image-characterize output exposure", 

68 storageClass="ExposureF", 

69 dimensions=("instrument", "visit", "detector"), 

70 multiple=False, 

71 ) 

72 inputCentroid = cT.Input( 

73 name="atmospecCentroid", 

74 doc="The main star centroid in yaml format.", 

75 storageClass="StructuredDataDict", 

76 dimensions=("instrument", "visit", "detector"), 

77 multiple=False, 

78 ) 

79 spectractorSpectrum = cT.Output( 

80 name="spectractorSpectrum", 

81 doc="The Spectractor output spectrum.", 

82 storageClass="SpectractorSpectrum", 

83 dimensions=("instrument", "visit", "detector"), 

84 ) 

85 spectractorImage = cT.Output( 

86 name="spectractorImage", 

87 doc="The Spectractor output image.", 

88 storageClass="SpectractorImage", 

89 dimensions=("instrument", "visit", "detector"), 

90 ) 

91 

92 def __init__(self, *, config=None): 

93 super().__init__(config=config) 

94 

95 

96class ProcessStarTaskConfig(pipeBase.PipelineTaskConfig, 

97 pipelineConnections=ProcessStarTaskConnections): 

98 """Configuration parameters for ProcessStarTask.""" 

99 # Spectractor parameters: 

100 targetCentroidMethod = pexConfig.ChoiceField( 

101 dtype=str, 

102 doc="Method to get target centroid. " 

103 "SPECTRACTOR_FIT_TARGET_CENTROID internally.", 

104 default="auto", 

105 allowed={ 

106 # note that although this config option controls 

107 # SPECTRACTOR_FIT_TARGET_CENTROID, it doesn't map there directly, 

108 # because Spectractor only has the concepts of guess, fit and wcs, 

109 # and it calls "exact" "guess" internally, so that's remapped. 

110 "auto": "If the upstream astrometric fit succeeded, and therefore" 

111 " the centroid is an exact one, use that as an ``exact`` value," 

112 " otherwise tell Spectractor to ``fit`` the centroid", 

113 "exact": "Use a given input value as source of truth.", 

114 "fit": "Fit a 2d Moffat model to the target.", 

115 "WCS": "Use the target's catalog location and the image's wcs.", 

116 } 

117 ) 

118 rotationAngleMethod = pexConfig.ChoiceField( 

119 dtype=str, 

120 doc="Method used to get the image rotation angle. " 

121 "SPECTRACTOR_COMPUTE_ROTATION_ANGLE internally.", 

122 default="disperser", 

123 allowed={ 

124 # XXX MFL: probably need to use setDefaults to set this based on 

125 # the disperser. I think Ronchi gratings want hessian and the 

126 # holograms want disperser. 

127 "False": "Do not rotate the image.", 

128 "disperser": "Use the disperser angle geometry as specified in the disperser definition.", 

129 "hessian": "Compute the angle from the image using a Hessian transform.", 

130 } 

131 ) 

132 doDeconvolveSpectrum = pexConfig.Field( 

133 dtype=bool, 

134 doc="Deconvolve the spectrogram with a simple 2D PSF analysis? " 

135 "SPECTRACTOR_DECONVOLUTION_PSF2D internally.", 

136 default=False, 

137 ) 

138 doFullForwardModelDeconvolution = pexConfig.Field( 

139 dtype=bool, 

140 doc="Deconvolve the spectrogram with full forward model? " 

141 "SPECTRACTOR_DECONVOLUTION_FFM internally.", 

142 default=True, 

143 ) 

144 deconvolutionSigmaClip = pexConfig.Field( 

145 dtype=float, 

146 doc="Sigma clipping level for the deconvolution when fitting the full forward model? " 

147 "SPECTRACTOR_DECONVOLUTION_SIGMA_CLIP internally.", 

148 default=100, 

149 ) 

150 doSubtractBackground = pexConfig.Field( 

151 dtype=bool, 

152 doc="Subtract the background with Spectractor? " 

153 "SPECTRACTOR_BACKGROUND_SUBTRACTION internally.", 

154 default=True, 

155 ) 

156 rebin = pexConfig.Field( 

157 dtype=int, 

158 doc="Rebinning factor to use on the input image, in pixels. " 

159 "CCD_REBIN internally.", 

160 default=2, # TODO Change to 1 once speed issues are resolved 

161 ) 

162 xWindow = pexConfig.Field( 

163 dtype=int, 

164 doc="Window x size to search for the target object. Ignored if targetCentroidMethod in ('exact, wcs')" 

165 "XWINDOW internally.", 

166 default=100, 

167 ) 

168 yWindow = pexConfig.Field( 

169 dtype=int, 

170 doc="Window y size to search for the targeted object. Ignored if targetCentroidMethod in " 

171 "('exact, wcs')" 

172 "YWINDOW internally.", 

173 default=100, 

174 ) 

175 xWindowRotated = pexConfig.Field( 

176 dtype=int, 

177 doc="Window x size to search for the target object in the rotated image. " 

178 "Ignored if rotationAngleMethod=False" 

179 "XWINDOW_ROT internally.", 

180 default=50, 

181 ) 

182 yWindowRotated = pexConfig.Field( 

183 dtype=int, 

184 doc="Window y size to search for the target object in the rotated image. " 

185 "Ignored if rotationAngleMethod=False" 

186 "YWINDOW_ROT internally.", 

187 default=50, 

188 ) 

189 pixelShiftPrior = pexConfig.Field( 189 ↛ exitline 189 didn't jump to the function exit

190 dtype=float, 

191 doc="Prior on the reliability of the centroid estimate in pixels. " 

192 "PIXSHIFT_PRIOR internally.", 

193 default=5, 

194 check=lambda x: x > 0, 

195 ) 

196 doFilterRotatedImage = pexConfig.Field( 

197 dtype=bool, 

198 doc="Apply a filter to the rotated image? If not True, this creates residuals and correlated noise. " 

199 "ROT_PREFILTER internally.", 

200 default=True, 

201 ) 

202 imageRotationSplineOrder = pexConfig.Field( 

203 dtype=int, 

204 doc="Order of the spline used when rotating the image. " 

205 "ROT_ORDER internally.", 

206 default=5, 

207 # XXX min value of 3 for allowed range, max 5 

208 ) 

209 rotationAngleMin = pexConfig.Field( 

210 dtype=float, 

211 doc="In the Hessian analysis to compute the rotation angle, cut all angles below this, in degrees. " 

212 "ROT_ANGLE_MIN internally.", 

213 default=-10, 

214 ) 

215 rotationAngleMax = pexConfig.Field( 

216 dtype=float, 

217 doc="In the Hessian analysis to compute rotation angle, cut all angles above this, in degrees. " 

218 "ROT_ANGLE_MAX internally.", 

219 default=10, 

220 ) 

221 plotLineWidth = pexConfig.Field( 

222 dtype=float, 

223 doc="Line width parameter for plotting. " 

224 "LINEWIDTH internally.", 

225 default=2, 

226 ) 

227 verbose = pexConfig.Field( 

228 dtype=bool, 

229 doc="Set verbose mode? " 

230 "VERBOSE internally.", 

231 default=True, # sets INFO level logging in Spectractor 

232 ) 

233 spectractorDebugMode = pexConfig.Field( 

234 dtype=bool, 

235 doc="Set spectractor debug mode? " 

236 "DEBUG internally.", 

237 default=True, 

238 ) 

239 spectractorDebugLogging = pexConfig.Field( 

240 dtype=bool, 

241 doc="Set spectractor debug logging? " 

242 "DEBUG_LOGGING internally.", 

243 default=False 

244 ) 

245 doDisplay = pexConfig.Field( 

246 dtype=bool, 

247 doc="Display plots, for example when running in a notebook? " 

248 "DISPLAY internally.", 

249 default=True 

250 ) 

251 lambdaMin = pexConfig.Field( 

252 dtype=int, 

253 doc="Minimum wavelength for spectral extraction (in nm). " 

254 "LAMBDA_MIN internally.", 

255 default=300 

256 ) 

257 lambdaMax = pexConfig.Field( 

258 dtype=int, 

259 doc=" maximum wavelength for spectrum extraction (in nm). " 

260 "LAMBDA_MAX internally.", 

261 default=1100 

262 ) 

263 lambdaStep = pexConfig.Field( 

264 dtype=float, 

265 doc="Step size for the wavelength array (in nm). " 

266 "LAMBDA_STEP internally.", 

267 default=1, 

268 ) 

269 spectralOrder = pexConfig.ChoiceField( 

270 dtype=int, 

271 doc="The spectral order to extract. " 

272 "SPEC_ORDER internally.", 

273 default=1, 

274 allowed={ 

275 1: "The first order spectrum in the positive y direction", 

276 -1: "The first order spectrum in the negative y direction", 

277 2: "The second order spectrum in the positive y direction", 

278 -2: "The second order spectrum in the negative y direction", 

279 } 

280 ) 

281 signalWidth = pexConfig.Field( # TODO: change this to be set wrt the focus/seeing, i.e. FWHM from imChar 

282 dtype=int, 

283 doc="Half transverse width of the signal rectangular window in pixels. " 

284 "PIXWIDTH_SIGNAL internally.", 

285 default=40, 

286 ) 

287 backgroundDistance = pexConfig.Field( 

288 dtype=int, 

289 doc="Distance from dispersion axis to analyse the background in pixels. " 

290 "PIXDIST_BACKGROUND internally.", 

291 default=140, 

292 ) 

293 backgroundWidth = pexConfig.Field( 

294 dtype=int, 

295 doc="Transverse width of the background rectangular window in pixels. " 

296 "PIXWIDTH_BACKGROUND internally.", 

297 default=40, 

298 ) 

299 backgroundBoxSize = pexConfig.Field( 

300 dtype=int, 

301 doc="Box size for sextractor evaluation of the background. " 

302 "PIXWIDTH_BOXSIZE internally.", 

303 default=20, 

304 ) 

305 backgroundOrder = pexConfig.Field( 

306 dtype=int, 

307 doc="The order of the polynomial background to fit in the transverse direction. " 

308 "BGD_ORDER internally.", 

309 default=1, 

310 ) 

311 psfType = pexConfig.ChoiceField( 

312 dtype=str, 

313 doc="The PSF model type to use. " 

314 "PSF_TYPE internally.", 

315 default="Moffat", 

316 allowed={ 

317 "Moffat": "A Moffat function", 

318 "MoffatGauss": "A Moffat plus a Gaussian" 

319 } 

320 ) 

321 psfPolynomialOrder = pexConfig.Field( 

322 dtype=int, 

323 doc="The order of the polynomials to model wavelength dependence of the PSF shape parameters. " 

324 "PSF_POLY_ORDER internally.", 

325 default=2 

326 ) 

327 psfRegularization = pexConfig.Field( 

328 dtype=float, 

329 doc="Regularisation parameter for the chisq minimisation to extract the spectrum. " 

330 "PSF_FIT_REG_PARAM internally.", 

331 default=1, 

332 # XXX allowed range strictly positive 

333 ) 

334 psfTransverseStepSize = pexConfig.Field( 

335 dtype=int, 

336 doc="Step size in pixels for the first transverse PSF1D fit. " 

337 "PSF_PIXEL_STEP_TRANSVERSE_FIT internally.", 

338 default=50, 

339 ) 

340 psfFwhmClip = pexConfig.Field( 

341 dtype=float, 

342 doc="PSF is not evaluated outside a region larger than max(signalWidth, psfFwhmClip*fwhm) pixels. " 

343 "PSF_FWHM_CLIP internally.", 

344 default=2, 

345 ) 

346 calibBackgroundOrder = pexConfig.Field( 

347 dtype=int, 

348 doc="Order of the background polynomial to fit. " 

349 "CALIB_BGD_ORDER internally.", 

350 default=3, 

351 ) 

352 calibPeakWidth = pexConfig.Field( 

353 dtype=int, 

354 doc="Half-range to look for local extrema in pixels around tabulated line values. " 

355 "CALIB_PEAK_WIDTH internally.", 

356 default=7 

357 ) 

358 calibBackgroundWidth = pexConfig.Field( 

359 dtype=int, 

360 doc="Size of the peak sides to use to fit spectrum base line. " 

361 "CALIB_BGD_WIDTH internally.", 

362 default=15, 

363 ) 

364 calibSavgolWindow = pexConfig.Field( 

365 dtype=int, 

366 doc="Window size for the savgol filter in pixels. " 

367 "CALIB_SAVGOL_WINDOW internally.", 

368 default=5, 

369 ) 

370 calibSavgolOrder = pexConfig.Field( 

371 dtype=int, 

372 doc="Polynomial order for the savgol filter. " 

373 "CALIB_SAVGOL_ORDER internally.", 

374 default=2, 

375 ) 

376 offsetFromMainStar = pexConfig.Field( 

377 dtype=int, 

378 doc="Number of pixels from the main star's centroid to start extraction", 

379 default=100 

380 ) 

381 spectrumLengthPixels = pexConfig.Field( 

382 dtype=int, 

383 doc="Length of the spectrum in pixels", 

384 default=5000 

385 ) 

386 # ProcessStar own parameters 

387 isr = pexConfig.ConfigurableField( 

388 target=IsrTask, 

389 doc="Task to perform instrumental signature removal", 

390 ) 

391 charImage = pexConfig.ConfigurableField( 

392 target=CharacterizeImageTask, 

393 doc="""Task to characterize a science exposure: 

394 - detect sources, usually at high S/N 

395 - estimate the background, which is subtracted from the image and returned as field "background" 

396 - estimate a PSF model, which is added to the exposure 

397 - interpolate over defects and cosmic rays, updating the image, variance and mask planes 

398 """, 

399 ) 

400 doWrite = pexConfig.Field( 

401 dtype=bool, 

402 doc="Write out the results?", 

403 default=True, 

404 ) 

405 doFlat = pexConfig.Field( 

406 dtype=bool, 

407 doc="Flatfield the image?", 

408 default=True 

409 ) 

410 doCosmics = pexConfig.Field( 

411 dtype=bool, 

412 doc="Repair cosmic rays?", 

413 default=True 

414 ) 

415 doDisplayPlots = pexConfig.Field( 

416 dtype=bool, 

417 doc="Matplotlib show() the plots, so they show up in a notebook or X window", 

418 default=False 

419 ) 

420 doSavePlots = pexConfig.Field( 

421 dtype=bool, 

422 doc="Save matplotlib plots to output rerun?", 

423 default=False 

424 ) 

425 forceObjectName = pexConfig.Field( 

426 dtype=str, 

427 doc="A supplementary name for OBJECT. Will be forced to apply to ALL visits, so this should only" 

428 " ONLY be used for immediate commissioning debug purposes. All long term fixes should be" 

429 " supplied as header fix-up yaml files.", 

430 default="" 

431 ) 

432 referenceFilterOverride = pexConfig.Field( 

433 dtype=str, 

434 doc="Which filter in the reference catalog to match to?", 

435 default="phot_g_mean" 

436 ) 

437 

438 def setDefaults(self): 

439 self.isr.doWrite = False 

440 self.charImage.doWriteExposure = False 

441 

442 self.charImage.doApCorr = False 

443 self.charImage.doMeasurePsf = False 

444 self.charImage.repair.cosmicray.nCrPixelMax = 100000 

445 self.charImage.repair.doCosmicRay = False 

446 if self.charImage.doMeasurePsf: 

447 self.charImage.measurePsf.starSelector['objectSize'].signalToNoiseMin = 10.0 

448 self.charImage.measurePsf.starSelector['objectSize'].fluxMin = 5000.0 

449 self.charImage.detection.includeThresholdMultiplier = 3 

450 self.isr.overscan.fitType = 'MEDIAN_PER_ROW' 

451 

452 

453class ProcessStarTask(pipeBase.PipelineTask): 

454 """Task for the spectral extraction of single-star dispersed images. 

455 

456 For a full description of how this tasks works, see the run() method. 

457 """ 

458 

459 ConfigClass = ProcessStarTaskConfig 

460 _DefaultName = "processStar" 

461 

462 def __init__(self, *, butler=None, **kwargs): 

463 # TODO: rename psfRefObjLoader to refObjLoader 

464 super().__init__(**kwargs) 

465 self.makeSubtask("isr") 

466 self.makeSubtask("charImage", butler=butler, refObjLoader=None) 

467 

468 self.debug = lsstDebug.Info(__name__) 

469 if self.debug.enabled: 

470 self.log.info("Running with debug enabled...") 

471 # If we're displaying, test it works and save displays for later. 

472 # It's worth testing here as displays are flaky and sometimes 

473 # can't be contacted, and given processing takes a while, 

474 # it's a shame to fail late due to display issues. 

475 if self.debug.display: 

476 try: 

477 import lsst.afw.display as afwDisp 

478 afwDisp.setDefaultBackend(self.debug.displayBackend) 

479 afwDisp.Display.delAllDisplays() 

480 # pick an unlikely number to be safe xxx replace this 

481 self.disp1 = afwDisp.Display(987, open=True) 

482 

483 im = afwImage.ImageF(2, 2) 

484 im.array[:] = np.ones((2, 2)) 

485 self.disp1.mtv(im) 

486 self.disp1.erase() 

487 afwDisp.setDefaultMaskTransparency(90) 

488 except NameError: 

489 self.debug.display = False 

490 self.log.warn('Failed to setup/connect to display! Debug display has been disabled') 

491 

492 if self.debug.notHeadless: 

493 pass # other backend options can go here 

494 else: # this stop windows popping up when plotting. When headless, use 'agg' backend too 

495 plt.interactive(False) 

496 

497 self.config.validate() 

498 self.config.freeze() 

499 

500 def findObjects(self, exp, nSigma=None, grow=0): 

501 """Find the objects in a postISR exposure.""" 

502 nPixMin = self.config.mainStarNpixMin 

503 if not nSigma: 

504 nSigma = self.config.mainStarNsigma 

505 if not grow: 

506 grow = self.config.mainStarGrow 

507 isotropic = self.config.mainStarGrowIsotropic 

508 

509 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV) 

510 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin) 

511 if grow > 0: 

512 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic) 

513 return footPrintSet 

514 

515 def _getEllipticity(self, shape): 

516 """Calculate the ellipticity given a quadrupole shape. 

517 

518 Parameters 

519 ---------- 

520 shape : `lsst.afw.geom.ellipses.Quadrupole` 

521 The quadrupole shape 

522 

523 Returns 

524 ------- 

525 ellipticity : `float` 

526 The magnitude of the ellipticity 

527 """ 

528 ixx = shape.getIxx() 

529 iyy = shape.getIyy() 

530 ixy = shape.getIxy() 

531 ePlus = (ixx - iyy) / (ixx + iyy) 

532 eCross = 2*ixy / (ixx + iyy) 

533 return (ePlus**2 + eCross**2)**0.5 

534 

535 def getRoundestObject(self, footPrintSet, parentExp, fluxCut=1e-15): 

536 """Get the roundest object brighter than fluxCut from a footPrintSet. 

537 

538 Parameters 

539 ---------- 

540 footPrintSet : `lsst.afw.detection.FootprintSet` 

541 The set of footprints resulting from running detection on parentExp 

542 

543 parentExp : `lsst.afw.image.exposure` 

544 The parent exposure for the footprint set. 

545 

546 fluxCut : `float` 

547 The flux, below which, sources are rejected. 

548 

549 Returns 

550 ------- 

551 source : `lsst.afw.detection.Footprint` 

552 The winning footprint from the input footPrintSet 

553 """ 

554 self.log.debug("ellipticity\tflux/1e6\tcentroid") 

555 sourceDict = {} 

556 for fp in footPrintSet.getFootprints(): 

557 shape = fp.getShape() 

558 e = self._getEllipticity(shape) 

559 flux = fp.getSpans().flatten(parentExp.image.array, parentExp.image.getXY0()).sum() 

560 self.log.debug("%.4f\t%.2f\t%s"%(e, flux/1e6, str(fp.getCentroid()))) 

561 if flux > fluxCut: 

562 sourceDict[e] = fp 

563 

564 return sourceDict[sorted(sourceDict.keys())[0]] 

565 

566 def getBrightestObject(self, footPrintSet, parentExp, roundnessCut=1e9): 

567 """Get the brightest object rounder than the cut from a footPrintSet. 

568 

569 Parameters 

570 ---------- 

571 footPrintSet : `lsst.afw.detection.FootprintSet` 

572 The set of footprints resulting from running detection on parentExp 

573 

574 parentExp : `lsst.afw.image.exposure` 

575 The parent exposure for the footprint set. 

576 

577 roundnessCut : `float` 

578 The ellipticity, above which, sources are rejected. 

579 

580 Returns 

581 ------- 

582 source : `lsst.afw.detection.Footprint` 

583 The winning footprint from the input footPrintSet 

584 """ 

585 self.log.debug("ellipticity\tflux\tcentroid") 

586 sourceDict = {} 

587 for fp in footPrintSet.getFootprints(): 

588 shape = fp.getShape() 

589 e = self._getEllipticity(shape) 

590 flux = fp.getSpans().flatten(parentExp.image.array, parentExp.image.getXY0()).sum() 

591 self.log.debug("%.4f\t%.2f\t%s"%(e, flux/1e6, str(fp.getCentroid()))) 

592 if e < roundnessCut: 

593 sourceDict[flux] = fp 

594 

595 return sourceDict[sorted(sourceDict.keys())[-1]] 

596 

597 def findMainSource(self, exp): 

598 """Return the x,y of the brightest or roundest object in an exposure. 

599 

600 Given a postISR exposure, run source detection on it, and return the 

601 centroid of the main star. Depending on the task configuration, this 

602 will either be the roundest object above a certain flux cutoff, or 

603 the brightest object which is rounder than some ellipticity cutoff. 

604 

605 Parameters 

606 ---------- 

607 exp : `afw.image.Exposure` 

608 The postISR exposure in which to find the main star 

609 

610 Returns 

611 ------- 

612 x, y : `tuple` of `float` 

613 The centroid of the main star in the image 

614 

615 Notes 

616 ----- 

617 Behavior of this method is controlled by many task config params 

618 including, for the detection stage: 

619 config.mainStarNpixMin 

620 config.mainStarNsigma 

621 config.mainStarGrow 

622 config.mainStarGrowIsotropic 

623 

624 And post-detection, for selecting the main source: 

625 config.mainSourceFindingMethod 

626 config.mainStarFluxCut 

627 config.mainStarRoundnessCut 

628 """ 

629 # TODO: probably replace all this with QFM 

630 fpSet = self.findObjects(exp) 

631 if self.config.mainSourceFindingMethod == 'ROUNDEST': 

632 source = self.getRoundestObject(fpSet, exp, fluxCut=self.config.mainStarFluxCut) 

633 elif self.config.mainSourceFindingMethod == 'BRIGHTEST': 

634 source = self.getBrightestObject(fpSet, exp, 

635 roundnessCut=self.config.mainStarRoundnessCut) 

636 else: 

637 # should be impossible as this is a choice field, but still 

638 raise RuntimeError("Invalid source finding method " 

639 f"selected: {self.config.mainSourceFindingMethod}") 

640 return source.getCentroid() 

641 

642 def updateMetadata(self, exp, **kwargs): 

643 """Update an exposure's metadata with set items from the visit info. 

644 

645 Spectractor expects many items, like the hour angle and airmass, to be 

646 in the metadata, so pull them out of the visit info etc and put them 

647 into the main metadata. Also updates the metadata with any supplied 

648 kwargs. 

649 

650 Parameters 

651 ---------- 

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

653 The exposure to update. 

654 **kwargs : `dict` 

655 The items to add. 

656 """ 

657 md = exp.getMetadata() 

658 vi = exp.getInfo().getVisitInfo() 

659 

660 ha = vi.getBoresightHourAngle().asDegrees() 

661 airmass = vi.getBoresightAirmass() 

662 

663 md['HA'] = ha 

664 md.setComment('HA', 'Hour angle of observation start') 

665 

666 md['AIRMASS'] = airmass 

667 md.setComment('AIRMASS', 'Airmass at observation start') 

668 

669 if 'centroid' in kwargs: 

670 centroid = kwargs['centroid'] 

671 else: 

672 centroid = (None, None) 

673 

674 md['OBJECTX'] = centroid[0] 

675 md.setComment('OBJECTX', 'x pixel coordinate of object centroid') 

676 

677 md['OBJECTY'] = centroid[1] 

678 md.setComment('OBJECTY', 'y pixel coordinate of object centroid') 

679 

680 exp.setMetadata(md) 

681 

682 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

683 inputs = butlerQC.get(inputRefs) 

684 

685 inputs['dataIdDict'] = inputRefs.inputExp.dataId.byName() 

686 

687 outputs = self.run(**inputs) 

688 butlerQC.put(outputs, outputRefs) 

689 

690 def getNormalizedTargetName(self, target): 

691 """Normalize the name of the target. 

692 

693 All targets which start with 'spec:' are converted to the name of the 

694 star without the leading 'spec:'. Any objects with mappings defined in 

695 data/nameMappings.txt are converted to the mapped name. 

696 

697 Parameters 

698 ---------- 

699 target : `str` 

700 The name of the target. 

701 

702 Returns 

703 ------- 

704 normalizedTarget : `str` 

705 The normalized name of the target. 

706 """ 

707 target = target.replace('spec:', '') 

708 

709 nameMappingsFile = os.path.join(getPackageDir('atmospec'), 'data', 'nameMappings.txt') 

710 names, mappedNames = np.loadtxt(nameMappingsFile, dtype=str, unpack=True) 

711 assert len(names) == len(mappedNames) 

712 conversions = {name: mapped for name, mapped in zip(names, mappedNames)} 

713 

714 if target in conversions.keys(): 

715 converted = conversions[target] 

716 self.log.info(f"Converted target name {target} to {converted}") 

717 return converted 

718 return target 

719 

720 def _getSpectractorTargetSetting(self, inputCentroid): 

721 """Calculate the value to set SPECTRACTOR_FIT_TARGET_CENTROID to. 

722 

723 Parameters 

724 ---------- 

725 inputCentroid : `dict` 

726 The `atmospecCentroid` dict, as received in the task input data. 

727 

728 Returns 

729 ------- 

730 centroidMethod : `str` 

731 The value to set SPECTRACTOR_FIT_TARGET_CENTROID to. 

732 """ 

733 

734 # if mode is auto and the astrometry worked then it's an exact 

735 # centroid, and otherwise we fit, as per docs on this option. 

736 if self.config.targetCentroidMethod == 'auto': 

737 if inputCentroid['astrometricMatch'] is True: 

738 self.log.info("Auto centroid is using exact centroid for target from the astrometry") 

739 return 'guess' # this means exact 

740 else: 

741 self.log.info("Auto centroid is using FIT in Spectractor to get the target centroid") 

742 return 'fit' # this means exact 

743 

744 # this is just renaming the config parameter because guess sounds like 

745 # an instruction, and really we're saying to take this as given. 

746 if self.config.targetCentroidMethod == 'exact': 

747 return 'guess' 

748 

749 # all other options fall through 

750 return self.config.targetCentroidMethod 

751 

752 def run(self, *, inputExp, inputCentroid, dataIdDict): 

753 if not isDispersedExp(inputExp): 

754 raise RuntimeError(f"Exposure is not a dispersed image {dataIdDict}") 

755 starNames = self.loadStarNames() 

756 

757 overrideDict = { 

758 # normal config parameters 

759 'SPECTRACTOR_FIT_TARGET_CENTROID': self._getSpectractorTargetSetting(inputCentroid), 

760 'SPECTRACTOR_COMPUTE_ROTATION_ANGLE': self.config.rotationAngleMethod, 

761 'SPECTRACTOR_DECONVOLUTION_PSF2D': self.config.doDeconvolveSpectrum, 

762 'SPECTRACTOR_DECONVOLUTION_FFM': self.config.doFullForwardModelDeconvolution, 

763 'SPECTRACTOR_DECONVOLUTION_SIGMA_CLIP': self.config.deconvolutionSigmaClip, 

764 'SPECTRACTOR_BACKGROUND_SUBTRACTION': self.config.doSubtractBackground, 

765 'CCD_REBIN': self.config.rebin, 

766 'XWINDOW': self.config.xWindow, 

767 'YWINDOW': self.config.yWindow, 

768 'XWINDOW_ROT': self.config.xWindowRotated, 

769 'YWINDOW_ROT': self.config.yWindowRotated, 

770 'PIXSHIFT_PRIOR': self.config.pixelShiftPrior, 

771 'ROT_PREFILTER': self.config.doFilterRotatedImage, 

772 'ROT_ORDER': self.config.imageRotationSplineOrder, 

773 'ROT_ANGLE_MIN': self.config.rotationAngleMin, 

774 'ROT_ANGLE_MAX': self.config.rotationAngleMax, 

775 'LINEWIDTH': self.config.plotLineWidth, 

776 'VERBOSE': self.config.verbose, 

777 'DEBUG': self.config.spectractorDebugMode, 

778 'DEBUG_LOGGING': self.config.spectractorDebugLogging, 

779 'DISPLAY': self.config.doDisplay, 

780 'LAMBDA_MIN': self.config.lambdaMin, 

781 'LAMBDA_MAX': self.config.lambdaMax, 

782 'LAMBDA_STEP': self.config.lambdaStep, 

783 'SPEC_ORDER': self.config.spectralOrder, 

784 'PIXWIDTH_SIGNAL': self.config.signalWidth, 

785 'PIXDIST_BACKGROUND': self.config.backgroundDistance, 

786 'PIXWIDTH_BACKGROUND': self.config.backgroundWidth, 

787 'PIXWIDTH_BOXSIZE': self.config.backgroundBoxSize, 

788 'BGD_ORDER': self.config.backgroundOrder, 

789 'PSF_TYPE': self.config.psfType, 

790 'PSF_POLY_ORDER': self.config.psfPolynomialOrder, 

791 'PSF_FIT_REG_PARAM': self.config.psfRegularization, 

792 'PSF_PIXEL_STEP_TRANSVERSE_FIT': self.config.psfTransverseStepSize, 

793 'PSF_FWHM_CLIP': self.config.psfFwhmClip, 

794 'CALIB_BGD_ORDER': self.config.calibBackgroundOrder, 

795 'CALIB_PEAK_WIDTH': self.config.calibPeakWidth, 

796 'CALIB_BGD_WIDTH': self.config.calibBackgroundWidth, 

797 'CALIB_SAVGOL_WINDOW': self.config.calibSavgolWindow, 

798 'CALIB_SAVGOL_ORDER': self.config.calibSavgolOrder, 

799 

800 # Hard-coded parameters 

801 'OBS_NAME': 'AUXTEL', 

802 'CCD_IMSIZE': 4000, # short axis - we trim the CCD to square 

803 'CCD_MAXADU': 170000, # XXX need to set this from camera value 

804 'CCD_GAIN': 1.1, # set programatically later, this is default nominal value 

805 'OBS_NAME': 'AUXTEL', 

806 'OBS_ALTITUDE': 2.66299616375123, # XXX get this from / check with utils value 

807 'OBS_LATITUDE': -30.2446389756252, # XXX get this from / check with utils value 

808 'OBS_DIAMETER': 1.20, 

809 'OBS_EPOCH': "J2000.0", 

810 'OBS_CAMERA_DEC_FLIP_SIGN': 1, 

811 'OBS_CAMERA_RA_FLIP_SIGN': 1, 

812 'OBS_SURFACE': np.pi * 1.2 ** 2 / 4., 

813 'PAPER': False, 

814 'SAVE': False, 

815 'DISTANCE2CCD_ERR': 0.4, 

816 

817 # Parameters set programatically 

818 'LAMBDAS': np.arange(self.config.lambdaMin, 

819 self.config.lambdaMax, 

820 self.config.lambdaStep), 

821 'CALIB_BGD_NPARAMS': self.config.calibBackgroundOrder + 1, 

822 

823 # Parameters set elsewhere 

824 # OBS_CAMERA_ROTATION 

825 # DISTANCE2CCD 

826 } 

827 

828 supplementDict = {'CALLING_CODE': 'LSST_DM', 

829 'STAR_NAMES': starNames} 

830 

831 # anything that changes between dataRefs! 

832 resetParameters = {} 

833 # TODO: look at what to do with config option doSavePlots 

834 

835 # TODO: think if this is the right place for this 

836 # probably wants to go in spectraction.py really 

837 linearStagePosition = getLinearStagePosition(inputExp) 

838 _, grating = getFilterAndDisperserFromExp(inputExp) 

839 if grating == 'holo4_003': 

840 # the hologram is sealed with a 4 mm window and this is how 

841 # spectractor handles this, so while it's quite ugly, do this to 

842 # keep the behaviour the same for now. 

843 linearStagePosition += 4 # hologram is sealed with a 4 mm window 

844 overrideDict['DISTANCE2CCD'] = linearStagePosition 

845 

846 target = inputExp.visitInfo.object 

847 target = self.getNormalizedTargetName(target) 

848 if self.config.forceObjectName: 

849 self.log.info(f"Forcing target name from {target} to {self.config.forceObjectName}") 

850 target = self.config.forceObjectName 

851 

852 if target in ['FlatField position', 'Park position', 'Test', 'NOTSET']: 

853 raise ValueError(f"OBJECT set to {target} - this is not a celestial object!") 

854 

855 packageDir = getPackageDir('atmospec') 

856 configFilename = os.path.join(packageDir, 'config', 'auxtel.ini') 

857 

858 spectractor = SpectractorShim(configFile=configFilename, 

859 paramOverrides=overrideDict, 

860 supplementaryParameters=supplementDict, 

861 resetParameters=resetParameters) 

862 

863 if 'astrometricMatch' in inputCentroid: 

864 centroid = inputCentroid['centroid'] 

865 else: # it's a raw tuple 

866 centroid = inputCentroid # TODO: put this support in the docstring 

867 

868 spectraction = spectractor.run(inputExp, *centroid, target) 

869 

870 self.log.info("Finished processing %s" % (dataIdDict)) 

871 

872 return pipeBase.Struct(spectractorSpectrum=spectraction.spectrum, 

873 spectractorImage=spectraction.image, 

874 spectraction=spectraction) 

875 

876 def runAstrometry(self, butler, exp, icSrc): 

877 refObjLoaderConfig = ReferenceObjectLoader.ConfigClass() 

878 refObjLoaderConfig.pixelMargin = 1000 

879 # TODO: needs to be an Input Connection 

880 refObjLoader = ReferenceObjectLoader(config=refObjLoaderConfig) 

881 

882 astromConfig = AstrometryTask.ConfigClass() 

883 astromConfig.wcsFitter.retarget(FitAffineWcsTask) 

884 astromConfig.referenceSelector.doMagLimit = True 

885 magLimit = MagnitudeLimit() 

886 magLimit.minimum = 1 

887 magLimit.maximum = 15 

888 astromConfig.referenceSelector.magLimit = magLimit 

889 astromConfig.referenceSelector.magLimit.fluxField = "phot_g_mean_flux" 

890 astromConfig.matcher.maxRotationDeg = 5.99 

891 astromConfig.matcher.maxOffsetPix = 3000 

892 astromConfig.sourceSelector['matcher'].minSnr = 10 

893 solver = AstrometryTask(config=astromConfig, refObjLoader=refObjLoader) 

894 

895 # TODO: Change this to doing this the proper way 

896 referenceFilterName = self.config.referenceFilterOverride 

897 referenceFilterLabel = afwImage.FilterLabel(physical=referenceFilterName, band=referenceFilterName) 

898 originalFilterLabel = exp.getFilter() # there's a better way of doing this with the task I think 

899 exp.setFilter(referenceFilterLabel) 

900 

901 try: 

902 astromResult = solver.run(sourceCat=icSrc, exposure=exp) 

903 exp.setFilter(originalFilterLabel) 

904 except (RuntimeError, TaskError): 

905 self.log.warn("Solver failed to run completely") 

906 exp.setFilter(originalFilterLabel) 

907 return None 

908 

909 scatter = astromResult.scatterOnSky.asArcseconds() 

910 if scatter < 1: 

911 return astromResult 

912 else: 

913 self.log.warn("Failed to find an acceptable match") 

914 return None 

915 

916 def pause(self): 

917 if self.debug.pauseOnDisplay: 

918 input("Press return to continue...") 

919 return 

920 

921 def loadStarNames(self): 

922 """Get the objects which should be treated as stars which do not begin 

923 with HD. 

924 

925 Spectractor treats all objects which start HD as stars, and all which 

926 don't as calibration objects, e.g. arc lamps or planetary nebulae. 

927 Adding items to data/starNames.txt will cause them to be treated as 

928 regular stars. 

929 

930 Returns 

931 ------- 

932 starNames : `list` of `str` 

933 The list of all objects to be treated as stars despite not starting 

934 with HD. 

935 """ 

936 starNameFile = os.path.join(getPackageDir('atmospec'), 'data', 'starNames.txt') 

937 with open(starNameFile, 'r') as f: 

938 lines = f.readlines() 

939 return [line.strip() for line in lines] 

940 

941 def flatfield(self, exp, disp): 

942 """Placeholder for wavelength dependent flatfielding: TODO: DM-18141 

943 

944 Will probably need a dataRef, as it will need to be retrieving flats 

945 over a range. Also, it will be somewhat complex, so probably needs 

946 moving to its own task""" 

947 self.log.warn("Flatfielding not yet implemented") 

948 return exp 

949 

950 def repairCosmics(self, exp, disp): 

951 self.log.warn("Cosmic ray repair not yet implemented") 

952 return exp 

953 

954 def measureSpectrum(self, exp, sourceCentroid, spectrumBBox, dispersionRelation): 

955 """Perform the spectral extraction, given a source location and exp.""" 

956 

957 self.extraction.initialise(exp, sourceCentroid, spectrumBBox, dispersionRelation) 

958 

959 # xxx this method currently doesn't return an object - fix this 

960 spectrum = self.extraction.getFluxBasic() 

961 

962 return spectrum 

963 

964 def calcSpectrumBBox(self, exp, centroid, aperture, order='+1'): 

965 """Calculate the bbox for the spectrum, given the centroid. 

966 

967 XXX Longer explanation here, inc. parameters 

968 TODO: Add support for order = "both" 

969 """ 

970 extent = self.config.spectrumLengthPixels 

971 halfWidth = aperture//2 

972 translate_y = self.config.offsetFromMainStar 

973 sourceX = centroid[0] 

974 sourceY = centroid[1] 

975 

976 if(order == '-1'): 

977 translate_y = - extent - self.config.offsetFromMainStar 

978 

979 xStart = sourceX - halfWidth 

980 xEnd = sourceX + halfWidth - 1 

981 yStart = sourceY + translate_y 

982 yEnd = yStart + extent - 1 

983 

984 xEnd = min(xEnd, exp.getWidth()-1) 

985 yEnd = min(yEnd, exp.getHeight()-1) 

986 yStart = max(yStart, 0) 

987 xStart = max(xStart, 0) 

988 assert (xEnd > xStart) and (yEnd > yStart) 

989 

990 self.log.debug('(xStart, xEnd) = (%s, %s)'%(xStart, xEnd)) 

991 self.log.debug('(yStart, yEnd) = (%s, %s)'%(yStart, yEnd)) 

992 

993 bbox = geom.Box2I(geom.Point2I(xStart, yStart), geom.Point2I(xEnd, yEnd)) 

994 return bbox