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

302 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-11 10:56 +0000

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="fit", 

105 allowed={ 

106 # TODO: probably want an "auto" mode 

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

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

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

110 } 

111 ) 

112 rotationAngleMethod = pexConfig.ChoiceField( 

113 dtype=str, 

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

115 "SPECTRACTOR_COMPUTE_ROTATION_ANGLE internally.", 

116 default="disperser", 

117 allowed={ 

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

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

120 # holograms want disperser. 

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

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

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

124 } 

125 ) 

126 doDeconvolveSpectrum = pexConfig.Field( 

127 dtype=bool, 

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

129 "SPECTRACTOR_DECONVOLUTION_PSF2D internally.", 

130 default=False, 

131 ) 

132 doFullForwardModelDeconvolution = pexConfig.Field( 

133 dtype=bool, 

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

135 "SPECTRACTOR_DECONVOLUTION_FFM internally.", 

136 default=True, 

137 ) 

138 deconvolutionSigmaClip = pexConfig.Field( 

139 dtype=float, 

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

141 "SPECTRACTOR_DECONVOLUTION_SIGMA_CLIP internally.", 

142 default=100, 

143 ) 

144 doSubtractBackground = pexConfig.Field( 

145 dtype=bool, 

146 doc="Subtract the background with Spectractor? " 

147 "SPECTRACTOR_BACKGROUND_SUBTRACTION internally.", 

148 default=True, 

149 ) 

150 rebin = pexConfig.Field( 

151 dtype=int, 

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

153 "CCD_REBIN internally.", 

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

155 ) 

156 xWindow = pexConfig.Field( 

157 dtype=int, 

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

159 "XWINDOW internally.", 

160 default=100, 

161 ) 

162 yWindow = pexConfig.Field( 

163 dtype=int, 

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

165 "('exact, wcs')" 

166 "YWINDOW internally.", 

167 default=100, 

168 ) 

169 xWindowRotated = pexConfig.Field( 

170 dtype=int, 

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

172 "Ignored if rotationAngleMethod=False" 

173 "XWINDOW_ROT internally.", 

174 default=50, 

175 ) 

176 yWindowRotated = pexConfig.Field( 

177 dtype=int, 

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

179 "Ignored if rotationAngleMethod=False" 

180 "YWINDOW_ROT internally.", 

181 default=50, 

182 ) 

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

184 dtype=float, 

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

186 "PIXSHIFT_PRIOR internally.", 

187 default=5, 

188 check=lambda x: x > 0, 

189 ) 

190 doFilterRotatedImage = pexConfig.Field( 

191 dtype=bool, 

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

193 "ROT_PREFILTER internally.", 

194 default=True, 

195 ) 

196 imageRotationSplineOrder = pexConfig.Field( 

197 dtype=int, 

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

199 "ROT_ORDER internally.", 

200 default=5, 

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

202 ) 

203 rotationAngleMin = pexConfig.Field( 

204 dtype=float, 

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

206 "ROT_ANGLE_MIN internally.", 

207 default=-10, 

208 ) 

209 rotationAngleMax = pexConfig.Field( 

210 dtype=float, 

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

212 "ROT_ANGLE_MAX internally.", 

213 default=10, 

214 ) 

215 plotLineWidth = pexConfig.Field( 

216 dtype=float, 

217 doc="Line width parameter for plotting. " 

218 "LINEWIDTH internally.", 

219 default=2, 

220 ) 

221 verbose = pexConfig.Field( 

222 dtype=bool, 

223 doc="Set verbose mode? " 

224 "VERBOSE internally.", 

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

226 ) 

227 spectractorDebugMode = pexConfig.Field( 

228 dtype=bool, 

229 doc="Set spectractor debug mode? " 

230 "DEBUG internally.", 

231 default=True, 

232 ) 

233 spectractorDebugLogging = pexConfig.Field( 

234 dtype=bool, 

235 doc="Set spectractor debug logging? " 

236 "DEBUG_LOGGING internally.", 

237 default=False 

238 ) 

239 doDisplay = pexConfig.Field( 

240 dtype=bool, 

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

242 "DISPLAY internally.", 

243 default=True 

244 ) 

245 lambdaMin = pexConfig.Field( 

246 dtype=int, 

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

248 "LAMBDA_MIN internally.", 

249 default=300 

250 ) 

251 lambdaMax = pexConfig.Field( 

252 dtype=int, 

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

254 "LAMBDA_MAX internally.", 

255 default=1100 

256 ) 

257 lambdaStep = pexConfig.Field( 

258 dtype=float, 

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

260 "LAMBDA_STEP internally.", 

261 default=1, 

262 ) 

263 spectralOrder = pexConfig.ChoiceField( 

264 dtype=int, 

265 doc="The spectral order to extract. " 

266 "SPEC_ORDER internally.", 

267 default=1, 

268 allowed={ 

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

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

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

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

273 } 

274 ) 

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

276 dtype=int, 

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

278 "PIXWIDTH_SIGNAL internally.", 

279 default=40, 

280 ) 

281 backgroundDistance = pexConfig.Field( 

282 dtype=int, 

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

284 "PIXDIST_BACKGROUND internally.", 

285 default=140, 

286 ) 

287 backgroundWidth = pexConfig.Field( 

288 dtype=int, 

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

290 "PIXWIDTH_BACKGROUND internally.", 

291 default=40, 

292 ) 

293 backgroundBoxSize = pexConfig.Field( 

294 dtype=int, 

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

296 "PIXWIDTH_BOXSIZE internally.", 

297 default=20, 

298 ) 

299 backgroundOrder = pexConfig.Field( 

300 dtype=int, 

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

302 "BGD_ORDER internally.", 

303 default=1, 

304 ) 

305 psfType = pexConfig.ChoiceField( 

306 dtype=str, 

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

308 "PSF_TYPE internally.", 

309 default="Moffat", 

310 allowed={ 

311 "Moffat": "A Moffat function", 

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

313 } 

314 ) 

315 psfPolynomialOrder = pexConfig.Field( 

316 dtype=int, 

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

318 "PSF_POLY_ORDER internally.", 

319 default=2 

320 ) 

321 psfRegularization = pexConfig.Field( 

322 dtype=float, 

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

324 "PSF_FIT_REG_PARAM internally.", 

325 default=1, 

326 # XXX allowed range strictly positive 

327 ) 

328 psfTransverseStepSize = pexConfig.Field( 

329 dtype=int, 

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

331 "PSF_PIXEL_STEP_TRANSVERSE_FIT internally.", 

332 default=50, 

333 ) 

334 psfFwhmClip = pexConfig.Field( 

335 dtype=float, 

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

337 "PSF_FWHM_CLIP internally.", 

338 default=2, 

339 ) 

340 calibBackgroundOrder = pexConfig.Field( 

341 dtype=int, 

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

343 "CALIB_BGD_ORDER internally.", 

344 default=3, 

345 ) 

346 calibPeakWidth = pexConfig.Field( 

347 dtype=int, 

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

349 "CALIB_PEAK_WIDTH internally.", 

350 default=7 

351 ) 

352 calibBackgroundWidth = pexConfig.Field( 

353 dtype=int, 

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

355 "CALIB_BGD_WIDTH internally.", 

356 default=15, 

357 ) 

358 calibSavgolWindow = pexConfig.Field( 

359 dtype=int, 

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

361 "CALIB_SAVGOL_WINDOW internally.", 

362 default=5, 

363 ) 

364 calibSavgolOrder = pexConfig.Field( 

365 dtype=int, 

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

367 "CALIB_SAVGOL_ORDER internally.", 

368 default=2, 

369 ) 

370 offsetFromMainStar = pexConfig.Field( 

371 dtype=int, 

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

373 default=100 

374 ) 

375 spectrumLengthPixels = pexConfig.Field( 

376 dtype=int, 

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

378 default=5000 

379 ) 

380 # ProcessStar own parameters 

381 isr = pexConfig.ConfigurableField( 

382 target=IsrTask, 

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

384 ) 

385 charImage = pexConfig.ConfigurableField( 

386 target=CharacterizeImageTask, 

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

388 - detect sources, usually at high S/N 

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

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

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

392 """, 

393 ) 

394 doWrite = pexConfig.Field( 

395 dtype=bool, 

396 doc="Write out the results?", 

397 default=True, 

398 ) 

399 doFlat = pexConfig.Field( 

400 dtype=bool, 

401 doc="Flatfield the image?", 

402 default=True 

403 ) 

404 doCosmics = pexConfig.Field( 

405 dtype=bool, 

406 doc="Repair cosmic rays?", 

407 default=True 

408 ) 

409 doDisplayPlots = pexConfig.Field( 

410 dtype=bool, 

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

412 default=False 

413 ) 

414 doSavePlots = pexConfig.Field( 

415 dtype=bool, 

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

417 default=False 

418 ) 

419 forceObjectName = pexConfig.Field( 

420 dtype=str, 

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

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

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

424 default="" 

425 ) 

426 referenceFilterOverride = pexConfig.Field( 

427 dtype=str, 

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

429 default="phot_g_mean" 

430 ) 

431 

432 def setDefaults(self): 

433 self.isr.doWrite = False 

434 self.charImage.doWriteExposure = False 

435 

436 self.charImage.doApCorr = False 

437 self.charImage.doMeasurePsf = False 

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

439 self.charImage.repair.doCosmicRay = False 

440 if self.charImage.doMeasurePsf: 

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

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

443 self.charImage.detection.includeThresholdMultiplier = 3 

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

445 

446 

447class ProcessStarTask(pipeBase.PipelineTask): 

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

449 

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

451 """ 

452 

453 ConfigClass = ProcessStarTaskConfig 

454 _DefaultName = "processStar" 

455 

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

457 # TODO: rename psfRefObjLoader to refObjLoader 

458 super().__init__(**kwargs) 

459 self.makeSubtask("isr") 

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

461 

462 self.debug = lsstDebug.Info(__name__) 

463 if self.debug.enabled: 

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

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

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

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

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

469 if self.debug.display: 

470 try: 

471 import lsst.afw.display as afwDisp 

472 afwDisp.setDefaultBackend(self.debug.displayBackend) 

473 afwDisp.Display.delAllDisplays() 

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

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

476 

477 im = afwImage.ImageF(2, 2) 

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

479 self.disp1.mtv(im) 

480 self.disp1.erase() 

481 afwDisp.setDefaultMaskTransparency(90) 

482 except NameError: 

483 self.debug.display = False 

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

485 

486 if self.debug.notHeadless: 

487 pass # other backend options can go here 

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

489 plt.interactive(False) 

490 

491 self.config.validate() 

492 self.config.freeze() 

493 

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

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

496 nPixMin = self.config.mainStarNpixMin 

497 if not nSigma: 

498 nSigma = self.config.mainStarNsigma 

499 if not grow: 

500 grow = self.config.mainStarGrow 

501 isotropic = self.config.mainStarGrowIsotropic 

502 

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

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

505 if grow > 0: 

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

507 return footPrintSet 

508 

509 def _getEllipticity(self, shape): 

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

511 

512 Parameters 

513 ---------- 

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

515 The quadrupole shape 

516 

517 Returns 

518 ------- 

519 ellipticity : `float` 

520 The magnitude of the ellipticity 

521 """ 

522 ixx = shape.getIxx() 

523 iyy = shape.getIyy() 

524 ixy = shape.getIxy() 

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

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

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

528 

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

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

531 

532 Parameters 

533 ---------- 

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

535 The set of footprints resulting from running detection on parentExp 

536 

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

538 The parent exposure for the footprint set. 

539 

540 fluxCut : `float` 

541 The flux, below which, sources are rejected. 

542 

543 Returns 

544 ------- 

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

546 The winning footprint from the input footPrintSet 

547 """ 

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

549 sourceDict = {} 

550 for fp in footPrintSet.getFootprints(): 

551 shape = fp.getShape() 

552 e = self._getEllipticity(shape) 

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

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

555 if flux > fluxCut: 

556 sourceDict[e] = fp 

557 

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

559 

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

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

562 

563 Parameters 

564 ---------- 

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

566 The set of footprints resulting from running detection on parentExp 

567 

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

569 The parent exposure for the footprint set. 

570 

571 roundnessCut : `float` 

572 The ellipticity, above which, sources are rejected. 

573 

574 Returns 

575 ------- 

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

577 The winning footprint from the input footPrintSet 

578 """ 

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

580 sourceDict = {} 

581 for fp in footPrintSet.getFootprints(): 

582 shape = fp.getShape() 

583 e = self._getEllipticity(shape) 

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

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

586 if e < roundnessCut: 

587 sourceDict[flux] = fp 

588 

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

590 

591 def findMainSource(self, exp): 

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

593 

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

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

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

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

598 

599 Parameters 

600 ---------- 

601 exp : `afw.image.Exposure` 

602 The postISR exposure in which to find the main star 

603 

604 Returns 

605 ------- 

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

607 The centroid of the main star in the image 

608 

609 Notes 

610 ----- 

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

612 including, for the detection stage: 

613 config.mainStarNpixMin 

614 config.mainStarNsigma 

615 config.mainStarGrow 

616 config.mainStarGrowIsotropic 

617 

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

619 config.mainSourceFindingMethod 

620 config.mainStarFluxCut 

621 config.mainStarRoundnessCut 

622 """ 

623 # TODO: probably replace all this with QFM 

624 fpSet = self.findObjects(exp) 

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

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

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

628 source = self.getBrightestObject(fpSet, exp, 

629 roundnessCut=self.config.mainStarRoundnessCut) 

630 else: 

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

632 raise RuntimeError("Invalid source finding method " 

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

634 return source.getCentroid() 

635 

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

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

638 

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

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

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

642 kwargs. 

643 

644 Parameters 

645 ---------- 

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

647 The exposure to update. 

648 **kwargs : `dict` 

649 The items to add. 

650 """ 

651 md = exp.getMetadata() 

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

653 

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

655 airmass = vi.getBoresightAirmass() 

656 

657 md['HA'] = ha 

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

659 

660 md['AIRMASS'] = airmass 

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

662 

663 if 'centroid' in kwargs: 

664 centroid = kwargs['centroid'] 

665 else: 

666 centroid = (None, None) 

667 

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

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

670 

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

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

673 

674 exp.setMetadata(md) 

675 

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

677 inputs = butlerQC.get(inputRefs) 

678 

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

680 

681 outputs = self.run(**inputs) 

682 butlerQC.put(outputs, outputRefs) 

683 

684 def getNormalizedTargetName(self, target): 

685 """Normalize the name of the target. 

686 

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

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

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

690 

691 Parameters 

692 ---------- 

693 target : `str` 

694 The name of the target. 

695 

696 Returns 

697 ------- 

698 normalizedTarget : `str` 

699 The normalized name of the target. 

700 """ 

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

702 

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

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

705 assert len(names) == len(mappedNames) 

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

707 

708 if target in conversions.keys(): 

709 converted = conversions[target] 

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

711 return converted 

712 return target 

713 

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

715 if not isDispersedExp(inputExp): 

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

717 starNames = self.loadStarNames() 

718 

719 overrideDict = { 

720 # normal config parameters 

721 'SPECTRACTOR_FIT_TARGET_CENTROID': ('guess' if self.config.targetCentroidMethod == 'exact' 

722 else self.config.targetCentroidMethod), 

723 'SPECTRACTOR_COMPUTE_ROTATION_ANGLE': self.config.rotationAngleMethod, 

724 'SPECTRACTOR_DECONVOLUTION_PSF2D': self.config.doDeconvolveSpectrum, 

725 'SPECTRACTOR_DECONVOLUTION_FFM': self.config.doFullForwardModelDeconvolution, 

726 'SPECTRACTOR_DECONVOLUTION_SIGMA_CLIP': self.config.deconvolutionSigmaClip, 

727 'SPECTRACTOR_BACKGROUND_SUBTRACTION': self.config.doSubtractBackground, 

728 'CCD_REBIN': self.config.rebin, 

729 'XWINDOW': self.config.xWindow, 

730 'YWINDOW': self.config.yWindow, 

731 'XWINDOW_ROT': self.config.xWindowRotated, 

732 'YWINDOW_ROT': self.config.yWindowRotated, 

733 'PIXSHIFT_PRIOR': self.config.pixelShiftPrior, 

734 'ROT_PREFILTER': self.config.doFilterRotatedImage, 

735 'ROT_ORDER': self.config.imageRotationSplineOrder, 

736 'ROT_ANGLE_MIN': self.config.rotationAngleMin, 

737 'ROT_ANGLE_MAX': self.config.rotationAngleMax, 

738 'LINEWIDTH': self.config.plotLineWidth, 

739 'VERBOSE': self.config.verbose, 

740 'DEBUG': self.config.spectractorDebugMode, 

741 'DEBUG_LOGGING': self.config.spectractorDebugLogging, 

742 'DISPLAY': self.config.doDisplay, 

743 'LAMBDA_MIN': self.config.lambdaMin, 

744 'LAMBDA_MAX': self.config.lambdaMax, 

745 'LAMBDA_STEP': self.config.lambdaStep, 

746 'SPEC_ORDER': self.config.spectralOrder, 

747 'PIXWIDTH_SIGNAL': self.config.signalWidth, 

748 'PIXDIST_BACKGROUND': self.config.backgroundDistance, 

749 'PIXWIDTH_BACKGROUND': self.config.backgroundWidth, 

750 'PIXWIDTH_BOXSIZE': self.config.backgroundBoxSize, 

751 'BGD_ORDER': self.config.backgroundOrder, 

752 'PSF_TYPE': self.config.psfType, 

753 'PSF_POLY_ORDER': self.config.psfPolynomialOrder, 

754 'PSF_FIT_REG_PARAM': self.config.psfRegularization, 

755 'PSF_PIXEL_STEP_TRANSVERSE_FIT': self.config.psfTransverseStepSize, 

756 'PSF_FWHM_CLIP': self.config.psfFwhmClip, 

757 'CALIB_BGD_ORDER': self.config.calibBackgroundOrder, 

758 'CALIB_PEAK_WIDTH': self.config.calibPeakWidth, 

759 'CALIB_BGD_WIDTH': self.config.calibBackgroundWidth, 

760 'CALIB_SAVGOL_WINDOW': self.config.calibSavgolWindow, 

761 'CALIB_SAVGOL_ORDER': self.config.calibSavgolOrder, 

762 

763 # Hard-coded parameters 

764 'OBS_NAME': 'AUXTEL', 

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

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

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

768 'OBS_NAME': 'AUXTEL', 

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

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

771 'OBS_DIAMETER': 1.20, 

772 'OBS_EPOCH': "J2000.0", 

773 'OBS_CAMERA_DEC_FLIP_SIGN': 1, 

774 'OBS_CAMERA_RA_FLIP_SIGN': 1, 

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

776 'PAPER': False, 

777 'SAVE': False, 

778 'DISTANCE2CCD_ERR': 0.4, 

779 

780 # Parameters set programatically 

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

782 self.config.lambdaMax, 

783 self.config.lambdaStep), 

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

785 

786 # Parameters set elsewhere 

787 # OBS_CAMERA_ROTATION 

788 # DISTANCE2CCD 

789 } 

790 

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

792 'STAR_NAMES': starNames} 

793 

794 # anything that changes between dataRefs! 

795 resetParameters = {} 

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

797 

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

799 # probably wants to go in spectraction.py really 

800 linearStagePosition = getLinearStagePosition(inputExp) 

801 _, grating = getFilterAndDisperserFromExp(inputExp) 

802 if grating == 'holo4_003': 

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

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

805 # keep the behaviour the same for now. 

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

807 overrideDict['DISTANCE2CCD'] = linearStagePosition 

808 

809 target = inputExp.visitInfo.object 

810 target = self.getNormalizedTargetName(target) 

811 if self.config.forceObjectName: 

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

813 target = self.config.forceObjectName 

814 

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

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

817 

818 packageDir = getPackageDir('atmospec') 

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

820 

821 spectractor = SpectractorShim(configFile=configFilename, 

822 paramOverrides=overrideDict, 

823 supplementaryParameters=supplementDict, 

824 resetParameters=resetParameters) 

825 

826 if 'astrometricMatch' in inputCentroid: 

827 centroid = inputCentroid['centroid'] 

828 else: # it's a raw tuple 

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

830 

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

832 

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

834 

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

836 spectractorImage=spectraction.image, 

837 spectraction=spectraction) 

838 

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

840 refObjLoaderConfig = ReferenceObjectLoader.ConfigClass() 

841 refObjLoaderConfig.pixelMargin = 1000 

842 # TODO: needs to be an Input Connection 

843 refObjLoader = ReferenceObjectLoader(config=refObjLoaderConfig) 

844 

845 astromConfig = AstrometryTask.ConfigClass() 

846 astromConfig.wcsFitter.retarget(FitAffineWcsTask) 

847 astromConfig.referenceSelector.doMagLimit = True 

848 magLimit = MagnitudeLimit() 

849 magLimit.minimum = 1 

850 magLimit.maximum = 15 

851 astromConfig.referenceSelector.magLimit = magLimit 

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

853 astromConfig.matcher.maxRotationDeg = 5.99 

854 astromConfig.matcher.maxOffsetPix = 3000 

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

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

857 

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

859 referenceFilterName = self.config.referenceFilterOverride 

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

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

862 exp.setFilter(referenceFilterLabel) 

863 

864 try: 

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

866 exp.setFilter(originalFilterLabel) 

867 except (RuntimeError, TaskError): 

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

869 exp.setFilter(originalFilterLabel) 

870 return None 

871 

872 scatter = astromResult.scatterOnSky.asArcseconds() 

873 if scatter < 1: 

874 return astromResult 

875 else: 

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

877 return None 

878 

879 def pause(self): 

880 if self.debug.pauseOnDisplay: 

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

882 return 

883 

884 def loadStarNames(self): 

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

886 with HD. 

887 

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

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

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

891 regular stars. 

892 

893 Returns 

894 ------- 

895 starNames : `list` of `str` 

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

897 with HD. 

898 """ 

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

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

901 lines = f.readlines() 

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

903 

904 def flatfield(self, exp, disp): 

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

906 

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

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

909 moving to its own task""" 

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

911 return exp 

912 

913 def repairCosmics(self, exp, disp): 

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

915 return exp 

916 

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

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

919 

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

921 

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

923 spectrum = self.extraction.getFluxBasic() 

924 

925 return spectrum 

926 

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

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

929 

930 XXX Longer explanation here, inc. parameters 

931 TODO: Add support for order = "both" 

932 """ 

933 extent = self.config.spectrumLengthPixels 

934 halfWidth = aperture//2 

935 translate_y = self.config.offsetFromMainStar 

936 sourceX = centroid[0] 

937 sourceY = centroid[1] 

938 

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

940 translate_y = - extent - self.config.offsetFromMainStar 

941 

942 xStart = sourceX - halfWidth 

943 xEnd = sourceX + halfWidth - 1 

944 yStart = sourceY + translate_y 

945 yEnd = yStart + extent - 1 

946 

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

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

949 yStart = max(yStart, 0) 

950 xStart = max(xStart, 0) 

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

952 

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

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

955 

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

957 return bbox