Coverage for python/lsst/atmospec/processStar.py: 27%
312 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-01 03:28 -0700
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-01 03:28 -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
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
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
42import lsst.afw.detection as afwDetect
44from .spectraction import SpectractorShim
45from .utils import getLinearStagePosition, isDispersedExp, getFilterAndDisperserFromExp
47COMMISSIONING = False # allows illegal things for on the mountain usage.
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 *,...
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 )
92 def __init__(self, *, config=None):
93 super().__init__(config=config)
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 )
438 def setDefaults(self):
439 self.isr.doWrite = False
440 self.charImage.doWriteExposure = False
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'
453class ProcessStarTask(pipeBase.PipelineTask):
454 """Task for the spectral extraction of single-star dispersed images.
456 For a full description of how this tasks works, see the run() method.
457 """
459 ConfigClass = ProcessStarTaskConfig
460 _DefaultName = "processStar"
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)
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)
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')
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)
497 self.config.validate()
498 self.config.freeze()
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
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
515 def _getEllipticity(self, shape):
516 """Calculate the ellipticity given a quadrupole shape.
518 Parameters
519 ----------
520 shape : `lsst.afw.geom.ellipses.Quadrupole`
521 The quadrupole shape
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
535 def getRoundestObject(self, footPrintSet, parentExp, fluxCut=1e-15):
536 """Get the roundest object brighter than fluxCut from a footPrintSet.
538 Parameters
539 ----------
540 footPrintSet : `lsst.afw.detection.FootprintSet`
541 The set of footprints resulting from running detection on parentExp
543 parentExp : `lsst.afw.image.exposure`
544 The parent exposure for the footprint set.
546 fluxCut : `float`
547 The flux, below which, sources are rejected.
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
564 return sourceDict[sorted(sourceDict.keys())[0]]
566 def getBrightestObject(self, footPrintSet, parentExp, roundnessCut=1e9):
567 """Get the brightest object rounder than the cut from a footPrintSet.
569 Parameters
570 ----------
571 footPrintSet : `lsst.afw.detection.FootprintSet`
572 The set of footprints resulting from running detection on parentExp
574 parentExp : `lsst.afw.image.exposure`
575 The parent exposure for the footprint set.
577 roundnessCut : `float`
578 The ellipticity, above which, sources are rejected.
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
595 return sourceDict[sorted(sourceDict.keys())[-1]]
597 def findMainSource(self, exp):
598 """Return the x,y of the brightest or roundest object in an exposure.
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.
605 Parameters
606 ----------
607 exp : `afw.image.Exposure`
608 The postISR exposure in which to find the main star
610 Returns
611 -------
612 x, y : `tuple` of `float`
613 The centroid of the main star in the image
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
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()
642 def updateMetadata(self, exp, **kwargs):
643 """Update an exposure's metadata with set items from the visit info.
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.
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()
660 ha = vi.getBoresightHourAngle().asDegrees()
661 airmass = vi.getBoresightAirmass()
663 md['HA'] = ha
664 md.setComment('HA', 'Hour angle of observation start')
666 md['AIRMASS'] = airmass
667 md.setComment('AIRMASS', 'Airmass at observation start')
669 if 'centroid' in kwargs:
670 centroid = kwargs['centroid']
671 else:
672 centroid = (None, None)
674 md['OBJECTX'] = centroid[0]
675 md.setComment('OBJECTX', 'x pixel coordinate of object centroid')
677 md['OBJECTY'] = centroid[1]
678 md.setComment('OBJECTY', 'y pixel coordinate of object centroid')
680 exp.setMetadata(md)
682 def runQuantum(self, butlerQC, inputRefs, outputRefs):
683 inputs = butlerQC.get(inputRefs)
685 inputs['dataIdDict'] = inputRefs.inputExp.dataId.byName()
687 outputs = self.run(**inputs)
688 butlerQC.put(outputs, outputRefs)
690 def getNormalizedTargetName(self, target):
691 """Normalize the name of the target.
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.
697 Parameters
698 ----------
699 target : `str`
700 The name of the target.
702 Returns
703 -------
704 normalizedTarget : `str`
705 The normalized name of the target.
706 """
707 target = target.replace('spec:', '')
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)}
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
720 def _getSpectractorTargetSetting(self, inputCentroid):
721 """Calculate the value to set SPECTRACTOR_FIT_TARGET_CENTROID to.
723 Parameters
724 ----------
725 inputCentroid : `dict`
726 The `atmospecCentroid` dict, as received in the task input data.
728 Returns
729 -------
730 centroidMethod : `str`
731 The value to set SPECTRACTOR_FIT_TARGET_CENTROID to.
732 """
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
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'
749 # all other options fall through
750 return self.config.targetCentroidMethod
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()
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,
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,
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,
823 # Parameters set elsewhere
824 # OBS_CAMERA_ROTATION
825 # DISTANCE2CCD
826 }
828 supplementDict = {'CALLING_CODE': 'LSST_DM',
829 'STAR_NAMES': starNames}
831 # anything that changes between dataRefs!
832 resetParameters = {}
833 # TODO: look at what to do with config option doSavePlots
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
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
852 if target in ['FlatField position', 'Park position', 'Test', 'NOTSET']:
853 raise ValueError(f"OBJECT set to {target} - this is not a celestial object!")
855 packageDir = getPackageDir('atmospec')
856 configFilename = os.path.join(packageDir, 'config', 'auxtel.ini')
858 spectractor = SpectractorShim(configFile=configFilename,
859 paramOverrides=overrideDict,
860 supplementaryParameters=supplementDict,
861 resetParameters=resetParameters)
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
868 spectraction = spectractor.run(inputExp, *centroid, target)
870 self.log.info("Finished processing %s" % (dataIdDict))
872 return pipeBase.Struct(spectractorSpectrum=spectraction.spectrum,
873 spectractorImage=spectraction.image,
874 spectraction=spectraction)
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)
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)
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)
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
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
916 def pause(self):
917 if self.debug.pauseOnDisplay:
918 input("Press return to continue...")
919 return
921 def loadStarNames(self):
922 """Get the objects which should be treated as stars which do not begin
923 with HD.
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.
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]
941 def flatfield(self, exp, disp):
942 """Placeholder for wavelength dependent flatfielding: TODO: DM-18141
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
950 def repairCosmics(self, exp, disp):
951 self.log.warn("Cosmic ray repair not yet implemented")
952 return exp
954 def measureSpectrum(self, exp, sourceCentroid, spectrumBBox, dispersionRelation):
955 """Perform the spectral extraction, given a source location and exp."""
957 self.extraction.initialise(exp, sourceCentroid, spectrumBBox, dispersionRelation)
959 # xxx this method currently doesn't return an object - fix this
960 spectrum = self.extraction.getFluxBasic()
962 return spectrum
964 def calcSpectrumBBox(self, exp, centroid, aperture, order='+1'):
965 """Calculate the bbox for the spectrum, given the centroid.
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]
976 if(order == '-1'):
977 translate_y = - extent - self.config.offsetFromMainStar
979 xStart = sourceX - halfWidth
980 xEnd = sourceX + halfWidth - 1
981 yStart = sourceY + translate_y
982 yEnd = yStart + extent - 1
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)
990 self.log.debug('(xStart, xEnd) = (%s, %s)'%(xStart, xEnd))
991 self.log.debug('(yStart, yEnd) = (%s, %s)'%(yStart, yEnd))
993 bbox = geom.Box2I(geom.Point2I(xStart, yStart), geom.Point2I(xEnd, yEnd))
994 return bbox