Coverage for python/lsst/atmospec/processStar.py: 19%
364 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-16 03:09 -0700
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-16 03:09 -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/>.
22__all__ = ['ProcessStarTask', 'ProcessStarTaskConfig']
24import os
25import numpy as np
26import matplotlib.pyplot as plt
27from matplotlib.backends.backend_pdf import PdfPages
28from importlib import reload
29import time
31import lsstDebug
32import lsst.afw.image as afwImage
33import lsst.geom as geom
34from lsst.ip.isr import IsrTask
35import lsst.pex.config as pexConfig
36import lsst.pipe.base as pipeBase
37import lsst.pipe.base.connectionTypes as cT
38from lsst.pipe.base.task import TaskError
40from lsst.utils import getPackageDir
41from lsst.pipe.tasks.characterizeImage import CharacterizeImageTask
42from lsst.meas.algorithms import LoadIndexedReferenceObjectsTask, MagnitudeLimit
43from lsst.meas.astrom import AstrometryTask, FitAffineWcsTask
45import lsst.afw.detection as afwDetect
47from .spectraction import SpectractorShim
48from .utils import getTargetCentroidFromWcs, getLinearStagePosition
50COMMISSIONING = False # allows illegal things for on the mountain usage.
52# TODO:
53# Sort out read noise and gain
54# remove dummy image totally
55# talk to Jeremy about turning the image beforehand and giving new coords
56# deal with not having ambient temp
57# Gen3ification
58# astropy warning for units on save
59# but actually just remove all manual saves entirely, I think?
60# Make SED persistable
61# Move to QFM for star finding failover case
62# Remove old cruft functions
63# change spectractions run method to be ~all kwargs with *,...
66class ProcessStarTaskConnections(pipeBase.PipelineTaskConnections,
67 dimensions=("instrument", "visit", "detector")):
68 inputExp = cT.Input(
69 name="icExp",
70 doc="Image-characterize output exposure",
71 storageClass="ExposureF",
72 dimensions=("instrument", "visit", "detector"),
73 multiple=False,
74 )
75 inputCentroid = cT.Input(
76 name="atmospecCentroid",
77 doc="The main star centroid in yaml format.",
78 storageClass="StructuredDataDict",
79 dimensions=("instrument", "visit", "detector"),
80 multiple=False,
81 )
82 spectractorSpectrum = cT.Output(
83 name="spectractorSpectrum",
84 doc="The Spectractor output spectrum.",
85 storageClass="SpectractorSpectrum",
86 dimensions=("instrument", "visit", "detector"),
87 )
88 spectractorImage = cT.Output(
89 name="spectractorImage",
90 doc="The Spectractor output image.",
91 storageClass="SpectractorImage",
92 dimensions=("instrument", "visit", "detector"),
93 )
94 spectraction = cT.Output(
95 name="spectraction",
96 doc="The Spectractor output image.",
97 storageClass="Spectraction",
98 dimensions=("instrument", "visit", "detector"),
99 )
101 def __init__(self, *, config=None):
102 super().__init__(config=config)
105class ProcessStarTaskConfig(pipeBase.PipelineTaskConfig,
106 pipelineConnections=ProcessStarTaskConnections):
107 """Configuration parameters for ProcessStarTask."""
109 isr = pexConfig.ConfigurableField(
110 target=IsrTask,
111 doc="Task to perform instrumental signature removal",
112 )
113 charImage = pexConfig.ConfigurableField(
114 target=CharacterizeImageTask,
115 doc="""Task to characterize a science exposure:
116 - detect sources, usually at high S/N
117 - estimate the background, which is subtracted from the image and returned as field "background"
118 - estimate a PSF model, which is added to the exposure
119 - interpolate over defects and cosmic rays, updating the image, variance and mask planes
120 """,
121 )
122 doWrite = pexConfig.Field(
123 dtype=bool,
124 doc="Write out the results?",
125 default=True,
126 )
127 mainSourceFindingMethod = pexConfig.ChoiceField(
128 doc="Which attribute to prioritize when selecting the main source object",
129 dtype=str,
130 default="BRIGHTEST",
131 allowed={
132 "BRIGHTEST": "Select the brightest object with roundness > roundnessCut",
133 "ROUNDEST": "Select the roundest object with brightness > fluxCut",
134 }
135 )
136 mainStarRoundnessCut = pexConfig.Field(
137 dtype=float,
138 doc="Value of ellipticity above which to reject the brightest object."
139 " Ignored if mainSourceFindingMethod == BRIGHTEST",
140 default=0.2
141 )
142 mainStarFluxCut = pexConfig.Field(
143 dtype=float,
144 doc="Object flux below which to reject the roundest object."
145 " Ignored if mainSourceFindingMethod == ROUNDEST",
146 default=1e7
147 )
148 mainStarNpixMin = pexConfig.Field(
149 dtype=int,
150 doc="Minimum number of pixels for object detection of main star",
151 default=10
152 )
153 mainStarNsigma = pexConfig.Field(
154 dtype=int,
155 doc="nSigma for detection of main star",
156 default=200 # the m=0 is very bright indeed, and we don't want to detect much spectrum
157 )
158 mainStarGrow = pexConfig.Field(
159 dtype=int,
160 doc="Number of pixels to grow by when detecting main star. This"
161 " encourages the spectrum to merge into one footprint, but too much"
162 " makes everything round, compromising mainStarRoundnessCut's"
163 " effectiveness",
164 default=5
165 )
166 mainStarGrowIsotropic = pexConfig.Field(
167 dtype=bool,
168 doc="Grow main star's footprint isotropically?",
169 default=False
170 )
171 aperture = pexConfig.Field(
172 dtype=int,
173 doc="Width of the aperture to use in pixels",
174 default=250
175 )
176 spectrumLengthPixels = pexConfig.Field(
177 dtype=int,
178 doc="Length of the spectrum in pixels",
179 default=5000
180 )
181 offsetFromMainStar = pexConfig.Field(
182 dtype=int,
183 doc="Number of pixels from the main star's centroid to start extraction",
184 default=100
185 )
186 dispersionDirection = pexConfig.ChoiceField(
187 doc="Direction along which the image is dispersed",
188 dtype=str,
189 default="y",
190 allowed={
191 "x": "Dispersion along the serial direction",
192 "y": "Dispersion along the parallel direction",
193 }
194 )
195 spectralOrder = pexConfig.ChoiceField(
196 doc="Direction along which the image is dispersed",
197 dtype=str,
198 default="+1",
199 allowed={
200 "+1": "Use the m+1 spectrum",
201 "-1": "Use the m-1 spectrum",
202 "both": "Use both spectra",
203 }
204 )
205 binning = pexConfig.Field(
206 dtype=int,
207 doc="Bin the input image by this factor",
208 default=4
209 )
210 doFlat = pexConfig.Field(
211 dtype=bool,
212 doc="Flatfield the image?",
213 default=True
214 )
215 doCosmics = pexConfig.Field(
216 dtype=bool,
217 doc="Repair cosmic rays?",
218 default=True
219 )
220 doDisplayPlots = pexConfig.Field(
221 dtype=bool,
222 doc="Matplotlib show() the plots, so they show up in a notebook or X window",
223 default=False
224 )
225 doSavePlots = pexConfig.Field(
226 dtype=bool,
227 doc="Save matplotlib plots to output rerun?",
228 default=False
229 )
230 spectractorDebugMode = pexConfig.Field(
231 dtype=bool,
232 doc="Set debug mode for Spectractor",
233 default=True
234 )
235 spectractorDebugLogging = pexConfig.Field( # TODO: tie this to the task debug level?
236 dtype=bool,
237 doc="Set debug logging for Spectractor",
238 default=False
239 )
240 forceObjectName = pexConfig.Field(
241 dtype=str,
242 doc="A supplementary name for OBJECT. Will be forced to apply to ALL visits, so this should only"
243 " ONLY be used for immediate commissioning debug purposes. All long term fixes should be"
244 " supplied as header fix-up yaml files.",
245 default=""
246 )
247 referenceFilterOverride = pexConfig.Field(
248 dtype=str,
249 doc="Which filter in the reference catalog to match to?",
250 default="phot_g_mean"
251 )
253 def setDefaults(self):
254 self.isr.doWrite = False
255 self.charImage.doWriteExposure = False
257 self.charImage.doApCorr = False
258 self.charImage.doMeasurePsf = False
259 self.charImage.repair.cosmicray.nCrPixelMax = 100000
260 self.charImage.repair.doCosmicRay = False
261 if self.charImage.doMeasurePsf:
262 self.charImage.measurePsf.starSelector['objectSize'].signalToNoiseMin = 10.0
263 self.charImage.measurePsf.starSelector['objectSize'].fluxMin = 5000.0
264 self.charImage.detection.includeThresholdMultiplier = 3
265 self.isr.overscan.fitType = 'MEDIAN_PER_ROW'
268class ProcessStarTask(pipeBase.PipelineTask):
269 """Task for the spectral extraction of single-star dispersed images.
271 For a full description of how this tasks works, see the run() method.
272 """
274 ConfigClass = ProcessStarTaskConfig
275 _DefaultName = "processStar"
277 def __init__(self, *, butler=None, **kwargs):
278 # TODO: rename psfRefObjLoader to refObjLoader
279 super().__init__(**kwargs)
280 self.makeSubtask("isr")
281 self.makeSubtask("charImage", butler=butler, refObjLoader=None)
283 self.debug = lsstDebug.Info(__name__)
284 if self.debug.enabled:
285 self.log.info("Running with debug enabled...")
286 # If we're displaying, test it works and save displays for later.
287 # It's worth testing here as displays are flaky and sometimes
288 # can't be contacted, and given processing takes a while,
289 # it's a shame to fail late due to display issues.
290 if self.debug.display:
291 try:
292 import lsst.afw.display as afwDisp
293 afwDisp.setDefaultBackend(self.debug.displayBackend)
294 afwDisp.Display.delAllDisplays()
295 # pick an unlikely number to be safe xxx replace this
296 self.disp1 = afwDisp.Display(987, open=True)
298 im = afwImage.ImageF(2, 2)
299 im.array[:] = np.ones((2, 2))
300 self.disp1.mtv(im)
301 self.disp1.erase()
302 afwDisp.setDefaultMaskTransparency(90)
303 except NameError:
304 self.debug.display = False
305 self.log.warn('Failed to setup/connect to display! Debug display has been disabled')
307 if self.debug.notHeadless:
308 pass # other backend options can go here
309 else: # this stop windows popping up when plotting. When headless, use 'agg' backend too
310 plt.interactive(False)
312 self.config.validate()
313 self.config.freeze()
315 def findObjects(self, exp, nSigma=None, grow=0):
316 """Find the objects in a postISR exposure."""
317 nPixMin = self.config.mainStarNpixMin
318 if not nSigma:
319 nSigma = self.config.mainStarNsigma
320 if not grow:
321 grow = self.config.mainStarGrow
322 isotropic = self.config.mainStarGrowIsotropic
324 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV)
325 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin)
326 if grow > 0:
327 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic)
328 return footPrintSet
330 def _getEllipticity(self, shape):
331 """Calculate the ellipticity given a quadrupole shape.
333 Parameters
334 ----------
335 shape : `lsst.afw.geom.ellipses.Quadrupole`
336 The quadrupole shape
338 Returns
339 -------
340 ellipticity : `float`
341 The magnitude of the ellipticity
342 """
343 ixx = shape.getIxx()
344 iyy = shape.getIyy()
345 ixy = shape.getIxy()
346 ePlus = (ixx - iyy) / (ixx + iyy)
347 eCross = 2*ixy / (ixx + iyy)
348 return (ePlus**2 + eCross**2)**0.5
350 def getRoundestObject(self, footPrintSet, parentExp, fluxCut=1e-15):
351 """Get the roundest object brighter than fluxCut from a footPrintSet.
353 Parameters
354 ----------
355 footPrintSet : `lsst.afw.detection.FootprintSet`
356 The set of footprints resulting from running detection on parentExp
358 parentExp : `lsst.afw.image.exposure`
359 The parent exposure for the footprint set.
361 fluxCut : `float`
362 The flux, below which, sources are rejected.
364 Returns
365 -------
366 source : `lsst.afw.detection.Footprint`
367 The winning footprint from the input footPrintSet
368 """
369 self.log.debug("ellipticity\tflux/1e6\tcentroid")
370 sourceDict = {}
371 for fp in footPrintSet.getFootprints():
372 shape = fp.getShape()
373 e = self._getEllipticity(shape)
374 flux = fp.getSpans().flatten(parentExp.image.array, parentExp.image.getXY0()).sum()
375 self.log.debug("%.4f\t%.2f\t%s"%(e, flux/1e6, str(fp.getCentroid())))
376 if flux > fluxCut:
377 sourceDict[e] = fp
379 return sourceDict[sorted(sourceDict.keys())[0]]
381 def getBrightestObject(self, footPrintSet, parentExp, roundnessCut=1e9):
382 """Get the brightest object rounder than the cut from a footPrintSet.
384 Parameters
385 ----------
386 footPrintSet : `lsst.afw.detection.FootprintSet`
387 The set of footprints resulting from running detection on parentExp
389 parentExp : `lsst.afw.image.exposure`
390 The parent exposure for the footprint set.
392 roundnessCut : `float`
393 The ellipticity, above which, sources are rejected.
395 Returns
396 -------
397 source : `lsst.afw.detection.Footprint`
398 The winning footprint from the input footPrintSet
399 """
400 self.log.debug("ellipticity\tflux\tcentroid")
401 sourceDict = {}
402 for fp in footPrintSet.getFootprints():
403 shape = fp.getShape()
404 e = self._getEllipticity(shape)
405 flux = fp.getSpans().flatten(parentExp.image.array, parentExp.image.getXY0()).sum()
406 self.log.debug("%.4f\t%.2f\t%s"%(e, flux/1e6, str(fp.getCentroid())))
407 if e < roundnessCut:
408 sourceDict[flux] = fp
410 return sourceDict[sorted(sourceDict.keys())[-1]]
412 def findMainSource(self, exp):
413 """Return the x,y of the brightest or roundest object in an exposure.
415 Given a postISR exposure, run source detection on it, and return the
416 centroid of the main star. Depending on the task configuration, this
417 will either be the roundest object above a certain flux cutoff, or
418 the brightest object which is rounder than some ellipticity cutoff.
420 Parameters
421 ----------
422 exp : `afw.image.Exposure`
423 The postISR exposure in which to find the main star
425 Returns
426 -------
427 x, y : `tuple` of `float`
428 The centroid of the main star in the image
430 Notes
431 -----
432 Behavior of this method is controlled by many task config params
433 including, for the detection stage:
434 config.mainStarNpixMin
435 config.mainStarNsigma
436 config.mainStarGrow
437 config.mainStarGrowIsotropic
439 And post-detection, for selecting the main source:
440 config.mainSourceFindingMethod
441 config.mainStarFluxCut
442 config.mainStarRoundnessCut
443 """
444 # TODO: probably replace all this with QFM
445 fpSet = self.findObjects(exp)
446 if self.config.mainSourceFindingMethod == 'ROUNDEST':
447 source = self.getRoundestObject(fpSet, exp, fluxCut=self.config.mainStarFluxCut)
448 elif self.config.mainSourceFindingMethod == 'BRIGHTEST':
449 source = self.getBrightestObject(fpSet, exp,
450 roundnessCut=self.config.mainStarRoundnessCut)
451 else:
452 # should be impossible as this is a choice field, but still
453 raise RuntimeError("Invalid source finding method "
454 f"selected: {self.config.mainSourceFindingMethod}")
455 return source.getCentroid()
457 def updateMetadata(self, exp, **kwargs):
458 md = exp.getMetadata()
459 vi = exp.getInfo().getVisitInfo()
461 ha = vi.getBoresightHourAngle().asDegrees()
462 airmass = vi.getBoresightAirmass()
464 md['HA'] = ha
465 md.setComment('HA', 'Hour angle of observation start')
467 md['AIRMASS'] = airmass
468 md.setComment('AIRMASS', 'Airmass at observation start')
470 if 'centroid' in kwargs:
471 centroid = kwargs['centroid']
472 else:
473 centroid = (None, None)
475 md['OBJECTX'] = centroid[0]
476 md.setComment('OBJECTX', 'x pixel coordinate of object centroid')
478 md['OBJECTY'] = centroid[1]
479 md.setComment('OBJECTY', 'y pixel coordinate of object centroid')
481 exp.setMetadata(md)
483 def runQuantum(self, butlerQC, inputRefs, outputRefs):
484 inputs = butlerQC.get(inputRefs)
486 inputs['dataIdDict'] = inputRefs.inputExp.dataId.byName()
488 outputs = self.run(**inputs)
489 butlerQC.put(outputs, outputRefs)
491 def run(self, *, inputExp, inputCentroid, dataIdDict):
492 starNames = self.loadStarNames()
494 overrideDict = {'SAVE': False,
495 'OBS_NAME': 'AUXTEL',
496 'DEBUG': self.config.spectractorDebugMode,
497 'DEBUG_LOGGING': self.config.spectractorDebugLogging,
498 'DISPLAY': self.config.doDisplayPlots,
499 'CCD_REBIN': self.config.binning,
500 'VERBOSE': 0,
501 # 'CCD_IMSIZE': 4000}
502 }
503 supplementDict = {'CALLING_CODE': 'LSST_DM',
504 'STAR_NAMES': starNames}
506 # anything that changes between dataRefs!
507 resetParameters = {}
508 # TODO: look at what to do with config option doSavePlots
510 # TODO: think if this is the right place for this
511 # probably wants to go in spectraction.py really
512 linearStagePosition = getLinearStagePosition(inputExp)
513 overrideDict['DISTANCE2CCD'] = linearStagePosition
515 target = inputExp.getMetadata()['OBJECT']
516 if self.config.forceObjectName:
517 self.log.info(f"Forcing target name from {target} to {self.config.forceObjectName}")
518 target = self.config.forceObjectName
520 if target in ['FlatField position', 'Park position', 'Test', 'NOTSET']:
521 raise ValueError(f"OBJECT set to {target} - this is not a celestial object!")
523 packageDir = getPackageDir('atmospec')
524 configFilename = os.path.join(packageDir, 'config', 'auxtel.ini')
526 spectractor = SpectractorShim(configFile=configFilename,
527 paramOverrides=overrideDict,
528 supplementaryParameters=supplementDict,
529 resetParameters=resetParameters)
531 if 'astrometricMatch' in inputCentroid:
532 centroid = inputCentroid['centroid']
533 else: # it's a raw tuple
534 centroid = inputCentroid # TODO: put this support in the docstring
536 spectraction = spectractor.run(inputExp, *centroid, target)
538 self.log.info("Finished processing %s" % (dataIdDict))
540 self.makeResultPickleable(spectraction)
542 return pipeBase.Struct(spectractorSpectrum=spectraction.spectrum,
543 spectractorImage=spectraction.image,
544 spectraction=spectraction)
546 def runDataRef(self, dataRef):
547 """Run the ProcessStarTask on a ButlerDataRef for a single exposure.
549 Runs isr to get the postISR exposure from the dataRef and passes this
550 to the run() method.
552 Parameters
553 ----------
554 dataRef : `daf.persistence.butlerSubset.ButlerDataRef`
555 Butler reference of the detector and exposure ID
556 """
557 t0 = time.time()
558 butler = dataRef.getButler()
559 dataId = dataRef.dataId
560 self.log.info("Processing %s" % (dataRef.dataId))
562 if COMMISSIONING:
563 from lsst.rapid.analysis.bestEffort import BestEffortIsr # import here because not part of DM
564 # TODO: some evidence suggests that CR repair is *significantly*
565 # degrading spectractor performance investigate this for the
566 # default task config as well as ensuring that it doesn't run here
567 # if it does turn out to be problematic.
568 bestEffort = BestEffortIsr(butler=dataRef.getButler())
569 exposure = bestEffort.getExposure(dataId)
570 else:
571 if butler.datasetExists('postISRCCD', dataId):
572 exposure = butler.get('postISRCCD', dataId)
573 self.log.info("Loaded postISRCCD from disk")
574 else:
575 exposure = self.isr.runDataRef(dataRef).exposure
576 self.updateMetadata(exposure)
577 butler.put(exposure, 'postISRCCD', dataId)
579 if butler.datasetExists('icExp', dataId) and butler.datasetExists('icSrc', dataId):
580 exposure = butler.get('icExp', dataId)
581 icSrc = butler.get('icSrc', dataId)
582 self.log.info("Loaded icExp and icSrc from disk")
583 else:
584 charRes = self.charImage.runDataRef(dataRef=dataRef, exposure=exposure, doUnpersist=False)
585 exposure = charRes.exposure
586 icSrc = charRes.sourceCat
587 butler.put(exposure, 'icExp', dataId)
588 butler.put(icSrc, 'icSrc', dataId)
590 if butler.datasetExists('calexp', dataId):
591 exposure = butler.get('calexp', dataId)
592 self.log.info("Loaded calexp from disk")
593 md = exposure.getMetadata()
594 sourceCentroid = (md['OBJECTX'], md['OBJECTY']) # set in saved md if previous fit succeeded
595 else:
596 astromResult = self.runAstrometry(butler, exposure, icSrc)
597 if astromResult and astromResult.scatterOnSky.asArcseconds() < 1:
598 target = exposure.getMetadata()['OBJECT']
599 sourceCentroid = getTargetCentroidFromWcs(exposure, target, logger=self.log)
600 else:
601 sourceCentroid = self.findMainSource(exposure)
602 self.log.warn("Astrometric fit failed, failing over to source-finding centroid")
603 self.log.info(f"Centroid of main star at: {sourceCentroid!r}")
605 self.updateMetadata(exposure, centroid=sourceCentroid)
606 butler.put(exposure, 'calexp', dataId)
608 if self.debug.display and 'raw' in self.debug.displayItems:
609 self.disp1.mtv(exposure)
610 self.disp1.dot('x', sourceCentroid[0], sourceCentroid[1], size=100)
611 self.log.info("Showing full postISR image")
612 self.log.info(f"Centroid of main star at: {sourceCentroid}")
613 self.pause()
615 outputRoot = dataRef.getUri(datasetType='spectractorOutputRoot', write=True)
616 if not os.path.exists(outputRoot):
617 os.makedirs(outputRoot)
618 if not os.path.exists(outputRoot):
619 raise RuntimeError(f"Failed to create output dir {outputRoot}")
620 expId = dataRef.dataId['expId']
622 result = self.runGen2(exposure, outputRoot, expId, sourceCentroid)
623 self.log.info("Finished processing %s" % (dataRef.dataId))
625 result.dataId = dataId
626 self.makeResultPickleable(result)
627 butler.put(result, 'spectraction', dataId)
629 t1 = time.time() - t0
630 self.log.info(f'Successfully ran to completion in {t1:.1f}s for {dataId}')
632 return result
634 def makeResultPickleable(self, result):
635 """Remove unpicklable components from the output"""
636 result.image.target.build_sed = None
637 result.spectrum.target.build_sed = None
638 result.image.target.sed = None
639 result.spectrum.disperser.load_files = None
640 result.image.disperser.load_files = None
642 result.spectrum.disperser.N_fit = None
643 result.spectrum.disperser.N_interp = None
644 result.spectrum.disperser.ratio_order_2over1 = None
645 result.spectrum.disperser.theta = None
647 def runAstrometry(self, butler, exp, icSrc):
648 refObjLoaderConfig = LoadIndexedReferenceObjectsTask.ConfigClass()
649 refObjLoaderConfig.ref_dataset_name = 'gaia_dr2_20191105'
650 refObjLoaderConfig.pixelMargin = 1000
651 refObjLoader = LoadIndexedReferenceObjectsTask(butler=butler, config=refObjLoaderConfig)
653 astromConfig = AstrometryTask.ConfigClass()
654 astromConfig.wcsFitter.retarget(FitAffineWcsTask)
655 astromConfig.referenceSelector.doMagLimit = True
656 magLimit = MagnitudeLimit()
657 magLimit.minimum = 1
658 magLimit.maximum = 15
659 astromConfig.referenceSelector.magLimit = magLimit
660 astromConfig.referenceSelector.magLimit.fluxField = "phot_g_mean_flux"
661 astromConfig.matcher.maxRotationDeg = 5.99
662 astromConfig.matcher.maxOffsetPix = 3000
663 astromConfig.sourceSelector['matcher'].minSnr = 10
664 solver = AstrometryTask(config=astromConfig, refObjLoader=refObjLoader)
666 # TODO: Change this to doing this the proper way
667 referenceFilterName = self.config.referenceFilterOverride
668 referenceFilterLabel = afwImage.FilterLabel(physical=referenceFilterName, band=referenceFilterName)
669 originalFilterLabel = exp.getFilter() # there's a better way of doing this with the task I think
670 exp.setFilter(referenceFilterLabel)
672 try:
673 astromResult = solver.run(sourceCat=icSrc, exposure=exp)
674 exp.setFilter(originalFilterLabel)
675 except (RuntimeError, TaskError):
676 self.log.warn("Solver failed to run completely")
677 exp.setFilter(originalFilterLabel)
678 return None
680 scatter = astromResult.scatterOnSky.asArcseconds()
681 if scatter < 1:
682 return astromResult
683 else:
684 self.log.warn("Failed to find an acceptable match")
685 return None
687 def pause(self):
688 if self.debug.pauseOnDisplay:
689 input("Press return to continue...")
690 return
692 def loadStarNames(self):
693 starNameFile = os.path.join(getPackageDir('atmospec'), 'data', 'starNames.txt')
694 with open(starNameFile, 'r') as f:
695 lines = f.readlines()
696 return [line.strip() for line in lines]
698 def runGen2(self, exp, spectractorOutputRoot, expId, sourceCentroid):
699 """Calculate the wavelength calibrated 1D spectrum from a postISRCCD.
701 An outline of the steps in the processing is as follows:
702 * Source extraction - find the objects in image
703 * Process sources to find the x,y of the main star
705 * Given the centroid, the dispersion direction, and the order(s),
706 calculate the spectrum's bounding box
708 * (Rotate the image such that the dispersion direction is vertical
709 TODO: DM-18138)
711 * Create an initial dispersion relation object from the geometry
712 or alternative bootstrapping method
714 * Apply an initial flatfielding - TODO: DM-18141
716 * Find and interpolate over cosmics if necessary - TODO: DM-18140
718 * Perform an initial spectral extraction, depending on selected method
719 * Fit a background model and subtract
720 * Perform row-wise fits for extraction
721 * TODO: DM-18136 for doing a full-spectrum fit with PSF model
723 * Given knowledge of features in the spectrum, find lines in the
724 measured spectrum and re-fit to refine the dispersion relation
725 * Reflatfield the image with the refined dispersion relation
727 Parameters
728 ----------
729 exp : `afw.image.Exposure`
730 The postISR exposure in which to find the main star
732 Returns
733 -------
734 spectrum : `lsst.atmospec.spectrum` - TODO: DM-18133
735 The wavelength-calibrated 1D stellar spectrum
736 """
737 reload(plt) # reset matplotlib color cycles when multiprocessing
738 pdfPath = os.path.join(spectractorOutputRoot, 'plots.pdf')
739 starNames = self.loadStarNames()
741 if True:
742 overrideDict = {'SAVE': False,
743 'OBS_NAME': 'AUXTEL',
744 'DEBUG': self.config.spectractorDebugMode,
745 'DEBUG_LOGGING': self.config.spectractorDebugLogging,
746 'DISPLAY': self.config.doDisplayPlots,
747 'CCD_REBIN': self.config.binning,
748 'VERBOSE': 0,
749 # 'CCD_IMSIZE': 4000}
750 }
751 supplementDict = {'CALLING_CODE': 'LSST_DM',
752 'STAR_NAMES': starNames}
754 # anything that changes between dataRefs!
755 resetParameters = {}
756 if self.config.doSavePlots:
757 resetParameters['LSST_SAVEFIGPATH'] = spectractorOutputRoot
758 else:
759 overrideDict = {}
760 supplementDict = {}
762 # TODO: think if this is the right place for this
763 # probably wants to go in spectraction.py really
764 linearStagePosition = getLinearStagePosition(exp)
765 overrideDict['DISTANCE2CCD'] = linearStagePosition
767 target = exp.getMetadata()['OBJECT']
768 if self.config.forceObjectName:
769 self.log.info(f"Forcing target name from {target} to {self.config.forceObjectName}")
770 target = self.config.forceObjectName
772 if target in ['FlatField position', 'Park position', 'Test', 'NOTSET']:
773 raise ValueError(f"OBJECT set to {target} - this is not a celestial object!")
775 packageDir = getPackageDir('atmospec')
776 configFilename = os.path.join(packageDir, 'config', 'auxtel.ini')
778 if self.config.doDisplayPlots: # no pdfpages backend - isn't compatible with display-as-you-go
779 spectractor = SpectractorShim(configFile=configFilename,
780 paramOverrides=overrideDict,
781 supplementaryParameters=supplementDict,
782 resetParameters=resetParameters)
783 result = spectractor.run(exp, *sourceCentroid, target, spectractorOutputRoot)
784 else:
785 try: # need a try here so that the context manager always exits cleanly so plots always written
786 with PdfPages(pdfPath) as pdf: # TODO: Doesn't the try need to be inside the with?!
787 resetParameters['PdfPages'] = pdf
788 spectractor = SpectractorShim(configFile=configFilename,
789 paramOverrides=overrideDict,
790 supplementaryParameters=supplementDict,
791 resetParameters=resetParameters)
793 result = spectractor.run(exp, *sourceCentroid, target, spectractorOutputRoot)
794 except Exception as e:
795 self.log.warn(f"Caught exception {e}, passing here so pdf can be written to {pdfPath}")
796 result = None
797 return result
799 def flatfield(self, exp, disp):
800 """Placeholder for wavelength dependent flatfielding: TODO: DM-18141
802 Will probably need a dataRef, as it will need to be retrieving flats
803 over a range. Also, it will be somewhat complex, so probably needs
804 moving to its own task"""
805 self.log.warn("Flatfielding not yet implemented")
806 return exp
808 def repairCosmics(self, exp, disp):
809 self.log.warn("Cosmic ray repair not yet implemented")
810 return exp
812 def measureSpectrum(self, exp, sourceCentroid, spectrumBBox, dispersionRelation):
813 """Perform the spectral extraction, given a source location and exp."""
815 self.extraction.initialise(exp, sourceCentroid, spectrumBBox, dispersionRelation)
817 # xxx this method currently doesn't return an object - fix this
818 spectrum = self.extraction.getFluxBasic()
820 return spectrum
822 def calcSpectrumBBox(self, exp, centroid, aperture, order='+1'):
823 """Calculate the bbox for the spectrum, given the centroid.
825 XXX Longer explanation here, inc. parameters
826 TODO: Add support for order = "both"
827 """
828 extent = self.config.spectrumLengthPixels
829 halfWidth = aperture//2
830 translate_y = self.config.offsetFromMainStar
831 sourceX = centroid[0]
832 sourceY = centroid[1]
834 if(order == '-1'):
835 translate_y = - extent - self.config.offsetFromMainStar
837 xStart = sourceX - halfWidth
838 xEnd = sourceX + halfWidth - 1
839 yStart = sourceY + translate_y
840 yEnd = yStart + extent - 1
842 xEnd = min(xEnd, exp.getWidth()-1)
843 yEnd = min(yEnd, exp.getHeight()-1)
844 yStart = max(yStart, 0)
845 xStart = max(xStart, 0)
846 assert (xEnd > xStart) and (yEnd > yStart)
848 self.log.debug('(xStart, xEnd) = (%s, %s)'%(xStart, xEnd))
849 self.log.debug('(yStart, yEnd) = (%s, %s)'%(yStart, yEnd))
851 bbox = geom.Box2I(geom.Point2I(xStart, yStart), geom.Point2I(xEnd, yEnd))
852 return bbox
854 # def calcRidgeLine(self, footprint):
855 # ridgeLine = np.zeros(self.footprint.length)
856 # for
858 # return ridgeLine