Coverage for python / lsst / atmospec / processStar.py: 28%
335 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 00:15 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-15 00:15 +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/>.
22__all__ = ['ProcessStarTask', 'ProcessStarTaskConfig']
24import os
25import shutil
26import numpy as np
27import matplotlib.pyplot as plt
29import lsstDebug
30import lsst.afw.image as afwImage
31import lsst.geom as geom
32from lsst.ip.isr import IsrTaskLSST
33import lsst.pex.config as pexConfig
34from lsst.pex.config import FieldValidationError
35import lsst.pipe.base as pipeBase
36import lsst.pipe.base.connectionTypes as cT
37from lsst.pipe.base.task import TaskError
39from lsst.utils import getPackageDir
40from lsst.pipe.tasks.characterizeImage import CharacterizeImageTask
41from lsst.meas.algorithms import ReferenceObjectLoader
42from lsst.meas.astrom import AstrometryTask, FitAffineWcsTask
44import lsst.afw.detection as afwDetect
46from .spectraction import SpectractorShim
47from .utils import getLinearStagePosition, isDispersedExp, getFilterAndDisperserFromExp
49COMMISSIONING = False # allows illegal things for on the mountain usage.
51# TODO:
52# Sort out read noise and gain
53# remove dummy image totally
54# talk to Jeremy about turning the image beforehand and giving new coords
55# deal with not having ambient temp
56# Gen3ification
57# astropy warning for units on save
58# but actually just remove all manual saves entirely, I think?
59# Make SED persistable
60# Move to QFM for star finding failover case
61# Remove old cruft functions
62# change spectractions run method to be ~all kwargs with *,...
65class ProcessStarTaskConnections(pipeBase.PipelineTaskConnections,
66 dimensions=("instrument", "visit", "detector")):
67 inputExp = cT.Input(
68 name="icExp",
69 doc="Image-characterize output exposure",
70 storageClass="ExposureF",
71 dimensions=("instrument", "visit", "detector"),
72 multiple=False,
73 )
74 inputCentroid = cT.Input(
75 name="atmospecCentroid",
76 doc="The main star centroid in yaml format.",
77 storageClass="StructuredDataDict",
78 dimensions=("instrument", "visit", "detector"),
79 multiple=False,
80 )
81 spectractorSpectrum = cT.Output(
82 name="spectractorSpectrum",
83 doc="The Spectractor output spectrum.",
84 storageClass="SpectractorSpectrum",
85 dimensions=("instrument", "visit", "detector"),
86 )
87 spectractorImage = cT.Output(
88 name="spectractorImage",
89 doc="The Spectractor output image.",
90 storageClass="SpectractorImage",
91 dimensions=("instrument", "visit", "detector"),
92 )
93 spectrumForwardModelFitParameters = cT.Output(
94 name="spectrumForwardModelFitParameters",
95 doc="The full forward model fit parameters.",
96 storageClass="SpectractorFitParameters",
97 dimensions=("instrument", "visit", "detector"),
98 )
99 spectrumLibradtranFitParameters = cT.Output(
100 name="spectrumLibradtranFitParameters",
101 doc="The fitted Spectractor atmospheric parameters from fitting the atmosphere with libradtran"
102 " on the spectrum.",
103 storageClass="SpectractorFitParameters",
104 dimensions=("instrument", "visit", "detector"),
105 )
106 spectrogramLibradtranFitParameters = cT.Output(
107 name="spectrogramLibradtranFitParameters",
108 doc="The fitted Spectractor atmospheric parameters from fitting the atmosphere with libradtran"
109 " directly on the spectrogram.",
110 storageClass="SpectractorFitParameters",
111 dimensions=("instrument", "visit", "detector"),
112 )
114 def __init__(self, *, config=None):
115 super().__init__(config=config)
116 if not config.doFullForwardModelDeconvolution:
117 self.outputs.remove("spectrumForwardModelFitParameters")
118 if not config.doFitAtmosphere:
119 self.outputs.remove("spectrumLibradtranFitParameters")
120 if not config.doFitAtmosphereOnSpectrogram:
121 self.outputs.remove("spectrogramLibradtranFitParameters")
124class ProcessStarTaskConfig(pipeBase.PipelineTaskConfig,
125 pipelineConnections=ProcessStarTaskConnections):
126 """Configuration parameters for ProcessStarTask."""
127 # Spectractor parameters:
128 targetCentroidMethod = pexConfig.ChoiceField(
129 dtype=str,
130 doc="Method to get target centroid. "
131 "SPECTRACTOR_FIT_TARGET_CENTROID internally.",
132 default="auto",
133 allowed={
134 # note that although this config option controls
135 # SPECTRACTOR_FIT_TARGET_CENTROID, it doesn't map there directly,
136 # because Spectractor only has the concepts of guess, fit and wcs,
137 # and it calls "exact" "guess" internally, so that's remapped.
138 "auto": "If the upstream astrometric fit succeeded, and therefore"
139 " the centroid is an exact one, use that as an ``exact`` value,"
140 " otherwise tell Spectractor to ``fit`` the centroid",
141 "exact": "Use a given input value as source of truth.",
142 "fit": "Fit a 2d Moffat model to the target.",
143 "WCS": "Use the target's catalog location and the image's wcs.",
144 }
145 )
146 rotationAngleMethod = pexConfig.ChoiceField(
147 dtype=str,
148 doc="Method used to get the image rotation angle. "
149 "SPECTRACTOR_COMPUTE_ROTATION_ANGLE internally.",
150 default="disperser",
151 allowed={
152 # XXX MFL: probably need to use setDefaults to set this based on
153 # the disperser. I think Ronchi gratings want hessian and the
154 # holograms want disperser.
155 "False": "Do not rotate the image.",
156 "disperser": "Use the disperser angle geometry as specified in the disperser definition.",
157 "hessian": "Compute the angle from the image using a Hessian transform.",
158 }
159 )
160 doDeconvolveSpectrum = pexConfig.Field(
161 dtype=bool,
162 doc="Deconvolve the spectrogram with a simple 2D PSF analysis? "
163 "SPECTRACTOR_DECONVOLUTION_PSF2D internally.",
164 default=True,
165 )
166 doFullForwardModelDeconvolution = pexConfig.Field(
167 dtype=bool,
168 doc="Deconvolve the spectrogram with full forward model? "
169 "SPECTRACTOR_DECONVOLUTION_FFM internally.",
170 default=True,
171 )
172 deconvolutionSigmaClip = pexConfig.Field(
173 dtype=float,
174 doc="Sigma clipping level for the deconvolution when fitting the full forward model? "
175 "SPECTRACTOR_DECONVOLUTION_SIGMA_CLIP internally.",
176 default=100,
177 )
178 doSubtractBackground = pexConfig.Field(
179 dtype=bool,
180 doc="Subtract the background with Spectractor? "
181 "SPECTRACTOR_BACKGROUND_SUBTRACTION internally.",
182 default=True,
183 )
184 rebin = pexConfig.Field(
185 dtype=int,
186 doc="Rebinning factor to use on the input image, in pixels. "
187 "CCD_REBIN internally.",
188 default=2, # TODO Change to 1 once speed issues are resolved
189 )
190 xWindow = pexConfig.Field(
191 dtype=int,
192 doc="Window x size to search for the target object. Ignored if targetCentroidMethod in ('exact, wcs')"
193 "XWINDOW internally.",
194 default=150,
195 )
196 yWindow = pexConfig.Field(
197 dtype=int,
198 doc="Window y size to search for the targeted object. Ignored if targetCentroidMethod in "
199 "('exact, wcs')"
200 "YWINDOW internally.",
201 default=150,
202 )
203 xWindowRotated = pexConfig.Field(
204 dtype=int,
205 doc="Window x size to search for the target object in the rotated image. "
206 "Ignored if rotationAngleMethod=False"
207 "XWINDOW_ROT internally.",
208 default=50,
209 )
210 yWindowRotated = pexConfig.Field(
211 dtype=int,
212 doc="Window y size to search for the target object in the rotated image. "
213 "Ignored if rotationAngleMethod=False"
214 "YWINDOW_ROT internally.",
215 default=50,
216 )
217 pixelShiftPrior = pexConfig.Field(
218 dtype=float,
219 doc="Prior on the reliability of the centroid estimate in pixels. "
220 "PIXSHIFT_PRIOR internally.",
221 default=5,
222 check=lambda x: x > 0,
223 )
224 doFilterRotatedImage = pexConfig.Field(
225 dtype=bool,
226 doc="Apply a filter to the rotated image? If not True, this creates residuals and correlated noise. "
227 "ROT_PREFILTER internally.",
228 default=True,
229 )
230 imageRotationSplineOrder = pexConfig.Field(
231 dtype=int,
232 doc="Order of the spline used when rotating the image. "
233 "ROT_ORDER internally.",
234 default=5,
235 # XXX min value of 3 for allowed range, max 5
236 )
237 rotationAngleMin = pexConfig.Field(
238 dtype=float,
239 doc="In the Hessian analysis to compute the rotation angle, cut all angles below this, in degrees. "
240 "ROT_ANGLE_MIN internally.",
241 default=-10,
242 )
243 rotationAngleMax = pexConfig.Field(
244 dtype=float,
245 doc="In the Hessian analysis to compute rotation angle, cut all angles above this, in degrees. "
246 "ROT_ANGLE_MAX internally.",
247 default=10,
248 )
249 plotLineWidth = pexConfig.Field(
250 dtype=float,
251 doc="Line width parameter for plotting. "
252 "LINEWIDTH internally.",
253 default=2,
254 )
255 verbose = pexConfig.Field(
256 dtype=bool,
257 doc="Set verbose mode? "
258 "VERBOSE internally.",
259 default=True, # sets INFO level logging in Spectractor
260 )
261 spectractorDebugMode = pexConfig.Field(
262 dtype=bool,
263 doc="Set spectractor debug mode? "
264 "DEBUG internally.",
265 default=True,
266 )
267 spectractorDebugLogging = pexConfig.Field(
268 dtype=bool,
269 doc="Set spectractor debug logging? "
270 "DEBUG_LOGGING internally.",
271 default=False
272 )
273 doDisplay = pexConfig.Field(
274 dtype=bool,
275 doc="Display plots, for example when running in a notebook? "
276 "DISPLAY internally.",
277 default=True
278 )
279 lambdaMin = pexConfig.Field(
280 dtype=int,
281 doc="Minimum wavelength for spectral extraction (in nm). "
282 "LAMBDA_MIN internally.",
283 default=300
284 )
285 lambdaMax = pexConfig.Field(
286 dtype=int,
287 doc=" maximum wavelength for spectrum extraction (in nm). "
288 "LAMBDA_MAX internally.",
289 default=1100
290 )
291 lambdaStep = pexConfig.Field(
292 dtype=float,
293 doc="Step size for the wavelength array (in nm). "
294 "LAMBDA_STEP internally.",
295 default=1,
296 )
297 spectralOrder = pexConfig.ChoiceField(
298 dtype=int,
299 doc="The spectral order to extract. "
300 "SPEC_ORDER internally.",
301 default=1,
302 allowed={
303 1: "The first order spectrum in the positive y direction",
304 -1: "The first order spectrum in the negative y direction",
305 2: "The second order spectrum in the positive y direction",
306 -2: "The second order spectrum in the negative y direction",
307 }
308 )
309 signalWidth = pexConfig.Field( # TODO: change this to be set wrt the focus/seeing, i.e. FWHM from imChar
310 dtype=int,
311 doc="Half transverse width of the signal rectangular window in pixels. "
312 "PIXWIDTH_SIGNAL internally.",
313 default=40,
314 )
315 backgroundDistance = pexConfig.Field(
316 dtype=int,
317 doc="Distance from dispersion axis to analyse the background in pixels. "
318 "PIXDIST_BACKGROUND internally.",
319 default=140,
320 )
321 backgroundWidth = pexConfig.Field(
322 dtype=int,
323 doc="Transverse width of the background rectangular window in pixels. "
324 "PIXWIDTH_BACKGROUND internally.",
325 default=40,
326 )
327 backgroundBoxSize = pexConfig.Field(
328 dtype=int,
329 doc="Box size for sextractor evaluation of the background. "
330 "PIXWIDTH_BOXSIZE internally.",
331 default=20,
332 )
333 backgroundOrder = pexConfig.Field(
334 dtype=int,
335 doc="The order of the polynomial background to fit in the transverse direction. "
336 "BGD_ORDER internally.",
337 default=1,
338 )
339 psfType = pexConfig.ChoiceField(
340 dtype=str,
341 doc="The PSF model type to use. "
342 "PSF_TYPE internally.",
343 default="Moffat",
344 allowed={
345 "Moffat": "A Moffat function",
346 "MoffatGauss": "A Moffat plus a Gaussian"
347 }
348 )
349 psfPolynomialOrder = pexConfig.Field(
350 dtype=int,
351 doc="The order of the polynomials to model wavelength dependence of the PSF shape parameters. "
352 "PSF_POLY_ORDER internally.",
353 default=2
354 )
355 psfRegularization = pexConfig.Field(
356 dtype=float,
357 doc="Regularisation parameter for the chisq minimisation to extract the spectrum. "
358 "PSF_FIT_REG_PARAM internally.",
359 default=1,
360 # XXX allowed range strictly positive
361 )
362 psfTransverseStepSize = pexConfig.Field(
363 dtype=int,
364 doc="Step size in pixels for the first transverse PSF1D fit. "
365 "PSF_PIXEL_STEP_TRANSVERSE_FIT internally.",
366 default=10,
367 )
368 psfFwhmClip = pexConfig.Field(
369 dtype=float,
370 doc="PSF is not evaluated outside a region larger than max(signalWidth, psfFwhmClip*fwhm) pixels. "
371 "PSF_FWHM_CLIP internally.",
372 default=2,
373 )
374 calibBackgroundOrder = pexConfig.Field(
375 dtype=int,
376 doc="Order of the background polynomial to fit. "
377 "CALIB_BGD_ORDER internally.",
378 default=3,
379 )
380 calibPeakWidth = pexConfig.Field(
381 dtype=int,
382 doc="Half-range to look for local extrema in pixels around tabulated line values. "
383 "CALIB_PEAK_WIDTH internally.",
384 default=7
385 )
386 calibBackgroundWidth = pexConfig.Field(
387 dtype=int,
388 doc="Size of the peak sides to use to fit spectrum base line. "
389 "CALIB_BGD_WIDTH internally.",
390 default=15,
391 )
392 calibSavgolWindow = pexConfig.Field(
393 dtype=int,
394 doc="Window size for the savgol filter in pixels. "
395 "CALIB_SAVGOL_WINDOW internally.",
396 default=5,
397 )
398 calibSavgolOrder = pexConfig.Field(
399 dtype=int,
400 doc="Polynomial order for the savgol filter. "
401 "CALIB_SAVGOL_ORDER internally.",
402 default=2,
403 )
404 transmissionSystematicError = pexConfig.Field(
405 dtype=float,
406 doc="The systematic error on the instrumental transmission. OBS_TRANSMISSION_SYSTEMATICS internally",
407 default=0.005
408 )
409 instrumentTransmissionOverride = pexConfig.Field(
410 dtype=str,
411 doc="File to use for the full instrumental transmission. Must be located in the"
412 " $SPECTRACTOR_DIR/spectractor/simulation/AuxTelThroughput/ directory."
413 " OBS_FULL_INSTRUMENT_TRANSMISSON internally.",
414 default="multispectra_holo4_003_HD142331_AuxTel_throughput.txt"
415 )
416 offsetFromMainStar = pexConfig.Field(
417 dtype=int,
418 doc="Number of pixels from the main star's centroid to start extraction",
419 default=100
420 )
421 spectrumLengthPixels = pexConfig.Field(
422 dtype=int,
423 doc="Length of the spectrum in pixels",
424 default=5000
425 )
426 # ProcessStar own parameters
427 isr = pexConfig.ConfigurableField(
428 target=IsrTaskLSST,
429 doc="Task to perform instrumental signature removal",
430 )
431 charImage = pexConfig.ConfigurableField(
432 target=CharacterizeImageTask,
433 doc="""Task to characterize a science exposure:
434 - detect sources, usually at high S/N
435 - estimate the background, which is subtracted from the image and returned as field "background"
436 - estimate a PSF model, which is added to the exposure
437 - interpolate over defects and cosmic rays, updating the image, variance and mask planes
438 """,
439 )
440 doWrite = pexConfig.Field(
441 dtype=bool,
442 doc="Write out the results?",
443 default=True,
444 )
445 doFlat = pexConfig.Field(
446 dtype=bool,
447 doc="Flatfield the image?",
448 default=True
449 )
450 doCosmics = pexConfig.Field(
451 dtype=bool,
452 doc="Repair cosmic rays?",
453 default=True
454 )
455 doDisplayPlots = pexConfig.Field(
456 dtype=bool,
457 doc="Matplotlib show() the plots, so they show up in a notebook or X window",
458 default=False
459 )
460 doSavePlots = pexConfig.Field(
461 dtype=bool,
462 doc="Save matplotlib plots to output rerun?",
463 default=False
464 )
465 forceObjectName = pexConfig.Field(
466 dtype=str,
467 doc="A supplementary name for OBJECT. Will be forced to apply to ALL visits, so this should only"
468 " ONLY be used for immediate commissioning debug purposes. All long term fixes should be"
469 " supplied as header fix-up yaml files.",
470 default=""
471 )
472 referenceFilterOverride = pexConfig.Field(
473 dtype=str,
474 doc="Which filter in the reference catalog to match to?",
475 default="phot_g_mean"
476 )
477 # This is a post-processing function in Spectractor and therefore isn't
478 # controlled by its top-level function, and thus doesn't map to a
479 # spectractor.parameters ALL_CAPS config option
480 doFitAtmosphere = pexConfig.Field(
481 dtype=bool,
482 doc="Use uvspec to fit the atmosphere? Requires the binary to be available.",
483 default=False
484 )
485 # This is a post-processing function in Spectractor and therefore isn't
486 # controlled by its top-level function, and thus doesn't map to a
487 # spectractor.parameters ALL_CAPS config option
488 doFitAtmosphereOnSpectrogram = pexConfig.Field(
489 dtype=bool,
490 doc="Experimental option to use uvspec to fit the atmosphere directly on the spectrogram?"
491 " Requires the binary to be available.",
492 default=False
493 )
495 def setDefaults(self):
496 self.charImage.doWriteExposure = False
498 self.charImage.doApCorr = False
499 self.charImage.doMeasurePsf = False
500 self.charImage.repair.cosmicray.nCrPixelMax = 100000
501 self.charImage.repair.doCosmicRay = False
502 if self.charImage.doMeasurePsf:
503 self.charImage.measurePsf.starSelector['objectSize'].signalToNoiseMin = 10.0
504 self.charImage.measurePsf.starSelector['objectSize'].fluxMin = 5000.0
505 self.charImage.detection.includeThresholdMultiplier = 3
507 def validate(self):
508 super().validate()
509 uvspecPath = shutil.which('uvspec')
510 if uvspecPath is None and self.doFitAtmosphere is True:
511 raise FieldValidationError(self.__class__.doFitAtmosphere, self, "uvspec is not in the path,"
512 " but doFitAtmosphere is True.")
513 if uvspecPath is None and self.doFitAtmosphereOnSpectrogram is True:
514 raise FieldValidationError(self.__class__.doFitAtmosphereOnSpectrogram, self, "uvspec is not in"
515 " the path, but doFitAtmosphere is True.")
518class ProcessStarTask(pipeBase.PipelineTask):
519 """Task for the spectral extraction of single-star dispersed images.
521 For a full description of how this tasks works, see the run() method.
522 """
524 ConfigClass = ProcessStarTaskConfig
525 _DefaultName = "processStar"
527 def __init__(self, **kwargs):
528 super().__init__(**kwargs)
529 self.makeSubtask("isr")
530 self.makeSubtask("charImage")
532 self.debug = lsstDebug.Info(__name__)
533 if self.debug.enabled:
534 self.log.info("Running with debug enabled...")
535 # If we're displaying, test it works and save displays for later.
536 # It's worth testing here as displays are flaky and sometimes
537 # can't be contacted, and given processing takes a while,
538 # it's a shame to fail late due to display issues.
539 if self.debug.display:
540 try:
541 import lsst.afw.display as afwDisp
542 afwDisp.setDefaultBackend(self.debug.displayBackend)
543 afwDisp.Display.delAllDisplays()
544 # pick an unlikely number to be safe xxx replace this
545 self.disp1 = afwDisp.Display(987, open=True)
547 im = afwImage.ImageF(2, 2)
548 im.array[:] = np.ones((2, 2))
549 self.disp1.mtv(im)
550 self.disp1.erase()
551 afwDisp.setDefaultMaskTransparency(90)
552 except NameError:
553 self.debug.display = False
554 self.log.warning('Failed to setup/connect to display! Debug display has been disabled')
556 if self.debug.notHeadless:
557 pass # other backend options can go here
558 else: # this stop windows popping up when plotting. When headless, use 'agg' backend too
559 plt.interactive(False)
561 self.config.validate()
562 self.config.freeze()
564 def findObjects(self, exp, nSigma=None, grow=0):
565 """Find the objects in a postISR exposure."""
566 nPixMin = self.config.mainStarNpixMin
567 if not nSigma:
568 nSigma = self.config.mainStarNsigma
569 if not grow:
570 grow = self.config.mainStarGrow
571 isotropic = self.config.mainStarGrowIsotropic
573 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV)
574 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin)
575 if grow > 0:
576 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic)
577 return footPrintSet
579 def _getEllipticity(self, shape):
580 """Calculate the ellipticity given a quadrupole shape.
582 Parameters
583 ----------
584 shape : `lsst.afw.geom.ellipses.Quadrupole`
585 The quadrupole shape
587 Returns
588 -------
589 ellipticity : `float`
590 The magnitude of the ellipticity
591 """
592 ixx = shape.getIxx()
593 iyy = shape.getIyy()
594 ixy = shape.getIxy()
595 ePlus = (ixx - iyy) / (ixx + iyy)
596 eCross = 2*ixy / (ixx + iyy)
597 return (ePlus**2 + eCross**2)**0.5
599 def getRoundestObject(self, footPrintSet, parentExp, fluxCut=1e-15):
600 """Get the roundest object brighter than fluxCut from a footPrintSet.
602 Parameters
603 ----------
604 footPrintSet : `lsst.afw.detection.FootprintSet`
605 The set of footprints resulting from running detection on parentExp
607 parentExp : `lsst.afw.image.exposure`
608 The parent exposure for the footprint set.
610 fluxCut : `float`
611 The flux, below which, sources are rejected.
613 Returns
614 -------
615 source : `lsst.afw.detection.Footprint`
616 The winning footprint from the input footPrintSet
617 """
618 self.log.debug("ellipticity\tflux/1e6\tcentroid")
619 sourceDict = {}
620 for fp in footPrintSet.getFootprints():
621 shape = fp.getShape()
622 e = self._getEllipticity(shape)
623 flux = fp.getSpans().flatten(parentExp.image.array, parentExp.image.getXY0()).sum()
624 self.log.debug("%.4f\t%.2f\t%s"%(e, flux/1e6, str(fp.getCentroid())))
625 if flux > fluxCut:
626 sourceDict[e] = fp
628 return sourceDict[sorted(sourceDict.keys())[0]]
630 def getBrightestObject(self, footPrintSet, parentExp, roundnessCut=1e9):
631 """Get the brightest object rounder than the cut from a footPrintSet.
633 Parameters
634 ----------
635 footPrintSet : `lsst.afw.detection.FootprintSet`
636 The set of footprints resulting from running detection on parentExp
638 parentExp : `lsst.afw.image.exposure`
639 The parent exposure for the footprint set.
641 roundnessCut : `float`
642 The ellipticity, above which, sources are rejected.
644 Returns
645 -------
646 source : `lsst.afw.detection.Footprint`
647 The winning footprint from the input footPrintSet
648 """
649 self.log.debug("ellipticity\tflux\tcentroid")
650 sourceDict = {}
651 for fp in footPrintSet.getFootprints():
652 shape = fp.getShape()
653 e = self._getEllipticity(shape)
654 flux = fp.getSpans().flatten(parentExp.image.array, parentExp.image.getXY0()).sum()
655 self.log.debug("%.4f\t%.2f\t%s"%(e, flux/1e6, str(fp.getCentroid())))
656 if e < roundnessCut:
657 sourceDict[flux] = fp
659 return sourceDict[sorted(sourceDict.keys())[-1]]
661 def findMainSource(self, exp):
662 """Return the x,y of the brightest or roundest object in an exposure.
664 Given a postISR exposure, run source detection on it, and return the
665 centroid of the main star. Depending on the task configuration, this
666 will either be the roundest object above a certain flux cutoff, or
667 the brightest object which is rounder than some ellipticity cutoff.
669 Parameters
670 ----------
671 exp : `afw.image.Exposure`
672 The postISR exposure in which to find the main star
674 Returns
675 -------
676 x, y : `tuple` of `float`
677 The centroid of the main star in the image
679 Notes
680 -----
681 Behavior of this method is controlled by many task config params
682 including, for the detection stage:
683 config.mainStarNpixMin
684 config.mainStarNsigma
685 config.mainStarGrow
686 config.mainStarGrowIsotropic
688 And post-detection, for selecting the main source:
689 config.mainSourceFindingMethod
690 config.mainStarFluxCut
691 config.mainStarRoundnessCut
692 """
693 # TODO: probably replace all this with QFM
694 fpSet = self.findObjects(exp)
695 if self.config.mainSourceFindingMethod == 'ROUNDEST':
696 source = self.getRoundestObject(fpSet, exp, fluxCut=self.config.mainStarFluxCut)
697 elif self.config.mainSourceFindingMethod == 'BRIGHTEST':
698 source = self.getBrightestObject(fpSet, exp,
699 roundnessCut=self.config.mainStarRoundnessCut)
700 else:
701 # should be impossible as this is a choice field, but still
702 raise RuntimeError("Invalid source finding method "
703 f"selected: {self.config.mainSourceFindingMethod}")
704 return source.getCentroid()
706 def updateMetadata(self, exp, **kwargs):
707 """Update an exposure's metadata with set items from the visit info.
709 Spectractor expects many items, like the hour angle and airmass, to be
710 in the metadata, so pull them out of the visit info etc and put them
711 into the main metadata. Also updates the metadata with any supplied
712 kwargs.
714 Parameters
715 ----------
716 exp : `lsst.afw.image.Exposure`
717 The exposure to update.
718 **kwargs : `dict`
719 The items to add.
720 """
721 md = exp.getMetadata()
722 vi = exp.getInfo().getVisitInfo()
724 ha = vi.getBoresightHourAngle().asDegrees()
725 airmass = vi.getBoresightAirmass()
727 md['HA'] = ha
728 md.setComment('HA', 'Hour angle of observation start')
730 md['AIRMASS'] = airmass
731 md.setComment('AIRMASS', 'Airmass at observation start')
733 if 'centroid' in kwargs:
734 centroid = kwargs['centroid']
735 else:
736 centroid = (None, None)
738 md['OBJECTX'] = centroid[0]
739 md.setComment('OBJECTX', 'x pixel coordinate of object centroid')
741 md['OBJECTY'] = centroid[1]
742 md.setComment('OBJECTY', 'y pixel coordinate of object centroid')
744 exp.setMetadata(md)
746 def runQuantum(self, butlerQC, inputRefs, outputRefs):
747 inputs = butlerQC.get(inputRefs)
748 inputs['dataIdDict'] = inputRefs.inputExp.dataId
750 outputs = self.run(**inputs)
751 butlerQC.put(outputs, outputRefs)
753 def getNormalizedTargetName(self, target):
754 """Normalize the name of the target.
756 All targets which start with 'spec:' are converted to the name of the
757 star without the leading 'spec:'. Any objects with mappings defined in
758 data/nameMappings.txt are converted to the mapped name.
760 Parameters
761 ----------
762 target : `str`
763 The name of the target.
765 Returns
766 -------
767 normalizedTarget : `str`
768 The normalized name of the target.
769 """
770 target = target.replace('spec:', '')
772 nameMappingsFile = os.path.join(getPackageDir('atmospec'), 'data', 'nameMappings.txt')
773 names, mappedNames = np.loadtxt(nameMappingsFile, dtype=str, unpack=True)
774 assert len(names) == len(mappedNames)
775 conversions = {name: mapped for name, mapped in zip(names, mappedNames)}
777 if target in conversions.keys():
778 converted = conversions[target]
779 self.log.info(f"Converted target name {target} to {converted}")
780 return converted
781 return target
783 def _getSpectractorTargetSetting(self, inputCentroid):
784 """Calculate the value to set SPECTRACTOR_FIT_TARGET_CENTROID to.
786 Parameters
787 ----------
788 inputCentroid : `dict`
789 The `atmospecCentroid` dict, as received in the task input data.
791 Returns
792 -------
793 centroidMethod : `str`
794 The value to set SPECTRACTOR_FIT_TARGET_CENTROID to.
795 """
797 # if mode is auto and the astrometry worked then it's an exact
798 # centroid, and otherwise we fit, as per docs on this option.
799 if self.config.targetCentroidMethod == 'auto':
800 if inputCentroid['astrometricMatch'] is True:
801 self.log.info("Auto centroid is using exact centroid for target from the astrometry")
802 return 'guess' # this means exact
803 else:
804 self.log.info("Auto centroid is using FIT in Spectractor to get the target centroid")
805 return 'fit' # this means exact
807 # this is just renaming the config parameter because guess sounds like
808 # an instruction, and really we're saying to take this as given.
809 if self.config.targetCentroidMethod == 'exact':
810 return 'guess'
812 # all other options fall through
813 return self.config.targetCentroidMethod
815 def run(self, *, inputExp, inputCentroid, dataIdDict):
816 if not isDispersedExp(inputExp):
817 raise RuntimeError(f"Exposure is not a dispersed image {dataIdDict}")
818 starNames = self.loadStarNames()
820 overrideDict = {
821 # normal config parameters
822 'SPECTRACTOR_FIT_TARGET_CENTROID': self._getSpectractorTargetSetting(inputCentroid),
823 'SPECTRACTOR_COMPUTE_ROTATION_ANGLE': self.config.rotationAngleMethod,
824 'SPECTRACTOR_DECONVOLUTION_PSF2D': self.config.doDeconvolveSpectrum,
825 'SPECTRACTOR_DECONVOLUTION_FFM': self.config.doFullForwardModelDeconvolution,
826 'SPECTRACTOR_DECONVOLUTION_SIGMA_CLIP': self.config.deconvolutionSigmaClip,
827 'SPECTRACTOR_BACKGROUND_SUBTRACTION': self.config.doSubtractBackground,
828 'CCD_REBIN': self.config.rebin,
829 'XWINDOW': self.config.xWindow,
830 'YWINDOW': self.config.yWindow,
831 'XWINDOW_ROT': self.config.xWindowRotated,
832 'YWINDOW_ROT': self.config.yWindowRotated,
833 'PIXSHIFT_PRIOR': self.config.pixelShiftPrior,
834 'ROT_PREFILTER': self.config.doFilterRotatedImage,
835 'ROT_ORDER': self.config.imageRotationSplineOrder,
836 'ROT_ANGLE_MIN': self.config.rotationAngleMin,
837 'ROT_ANGLE_MAX': self.config.rotationAngleMax,
838 'LINEWIDTH': self.config.plotLineWidth,
839 'VERBOSE': self.config.verbose,
840 'DEBUG': self.config.spectractorDebugMode,
841 'DEBUG_LOGGING': self.config.spectractorDebugLogging,
842 'DISPLAY': self.config.doDisplay,
843 'LAMBDA_MIN': self.config.lambdaMin,
844 'LAMBDA_MAX': self.config.lambdaMax,
845 'LAMBDA_STEP': self.config.lambdaStep,
846 'SPEC_ORDER': self.config.spectralOrder,
847 'PIXWIDTH_SIGNAL': self.config.signalWidth,
848 'PIXDIST_BACKGROUND': self.config.backgroundDistance,
849 'PIXWIDTH_BACKGROUND': self.config.backgroundWidth,
850 'PIXWIDTH_BOXSIZE': self.config.backgroundBoxSize,
851 'BGD_ORDER': self.config.backgroundOrder,
852 'PSF_TYPE': self.config.psfType,
853 'PSF_POLY_ORDER': self.config.psfPolynomialOrder,
854 'PSF_FIT_REG_PARAM': self.config.psfRegularization,
855 'PSF_PIXEL_STEP_TRANSVERSE_FIT': self.config.psfTransverseStepSize,
856 'PSF_FWHM_CLIP': self.config.psfFwhmClip,
857 'CALIB_BGD_ORDER': self.config.calibBackgroundOrder,
858 'CALIB_PEAK_WIDTH': self.config.calibPeakWidth,
859 'CALIB_BGD_WIDTH': self.config.calibBackgroundWidth,
860 'CALIB_SAVGOL_WINDOW': self.config.calibSavgolWindow,
861 'CALIB_SAVGOL_ORDER': self.config.calibSavgolOrder,
862 'OBS_TRANSMISSION_SYSTEMATICS': self.config.transmissionSystematicError,
863 'OBS_FULL_INSTRUMENT_TRANSMISSON': self.config.instrumentTransmissionOverride,
865 # Hard-coded parameters
866 'OBS_NAME': 'AUXTEL',
867 'CCD_IMSIZE': 4000, # short axis - we trim the CCD to square
868 'CCD_MAXADU': 170000, # XXX need to set this from camera value
869 'CCD_GAIN': 1.1, # set programatically later, this is default nominal value
870 'OBS_NAME': 'AUXTEL',
871 'OBS_ALTITUDE': 2.66299616375123, # XXX get this from / check with utils value
872 'OBS_LATITUDE': -30.2446389756252, # XXX get this from / check with utils value
873 'OBS_EPOCH': "J2000.0",
874 'OBS_CAMERA_DEC_FLIP_SIGN': 1,
875 'OBS_CAMERA_RA_FLIP_SIGN': 1,
876 'OBS_SURFACE': 9636,
877 'PAPER': False,
878 'SAVE': False,
879 'DISTANCE2CCD_ERR': 0.4,
881 # Parameters set programatically
882 'LAMBDAS': np.arange(self.config.lambdaMin,
883 self.config.lambdaMax,
884 self.config.lambdaStep),
885 'CALIB_BGD_NPARAMS': self.config.calibBackgroundOrder + 1,
887 # Parameters set elsewhere
888 # OBS_CAMERA_ROTATION
889 # DISTANCE2CCD
890 }
892 supplementDict = {'CALLING_CODE': 'LSST_DM',
893 'STAR_NAMES': starNames}
895 # anything that changes between dataRefs!
896 resetParameters = {}
897 # TODO: look at what to do with config option doSavePlots
899 # TODO: think if this is the right place for this
900 # probably wants to go in spectraction.py really
901 linearStagePosition = getLinearStagePosition(inputExp)
902 _, grating = getFilterAndDisperserFromExp(inputExp)
903 if grating == 'holo4_003':
904 # the hologram is sealed with a 4 mm window and this is how
905 # spectractor handles this, so while it's quite ugly, do this to
906 # keep the behaviour the same for now.
907 linearStagePosition += 4 # hologram is sealed with a 4 mm window
908 overrideDict['DISTANCE2CCD'] = linearStagePosition
910 target = inputExp.visitInfo.object
911 target = self.getNormalizedTargetName(target)
912 if self.config.forceObjectName:
913 self.log.info(f"Forcing target name from {target} to {self.config.forceObjectName}")
914 target = self.config.forceObjectName
916 if target in ['FlatField position', 'Park position', 'Test', 'NOTSET']:
917 raise ValueError(f"OBJECT set to {target} - this is not a celestial object!")
919 packageDir = getPackageDir('atmospec')
920 configFilename = os.path.join(packageDir, 'config', 'auxtel.ini')
922 spectractor = SpectractorShim(configFile=configFilename,
923 paramOverrides=overrideDict,
924 supplementaryParameters=supplementDict,
925 resetParameters=resetParameters)
927 if 'astrometricMatch' in inputCentroid:
928 centroid = inputCentroid['centroid']
929 else: # it's a raw tuple
930 centroid = inputCentroid # TODO: put this support in the docstring
932 spectraction = spectractor.run(inputExp, *centroid, target,
933 self.config.doFitAtmosphere,
934 self.config.doFitAtmosphereOnSpectrogram)
936 self.log.info("Finished processing %s" % (dataIdDict))
938 return pipeBase.Struct(
939 spectractorSpectrum=spectraction.spectrum,
940 spectractorImage=spectraction.image,
941 spectrumForwardModelFitParameters=spectraction.spectrumForwardModelFitParameters,
942 spectrumLibradtranFitParameters=spectraction.spectrumLibradtranFitParameters,
943 spectrogramLibradtranFitParameters=spectraction.spectrogramLibradtranFitParameters
944 )
946 def runAstrometry(self, butler, exp, icSrc):
947 refObjLoaderConfig = ReferenceObjectLoader.ConfigClass()
948 refObjLoaderConfig.pixelMargin = 1000
949 # TODO: needs to be an Input Connection
950 refObjLoader = ReferenceObjectLoader(config=refObjLoaderConfig)
952 astromConfig = AstrometryTask.ConfigClass()
953 astromConfig.wcsFitter.retarget(FitAffineWcsTask)
955 # Use magnitude limits for the reference catalog
956 astromConfig.referenceSelector.doMagLimit = True
957 astromConfig.referenceSelector.magLimit.minimum = 1
958 astromConfig.referenceSelector.magLimit.maximum = 15
959 astromConfig.referenceSelector.magLimit.fluxField = "phot_g_mean_flux"
960 astromConfig.matcher.maxRotationDeg = 5.99
961 astromConfig.matcher.maxOffsetPix = 3000
963 # Use a SNR limit for the science catalog
964 astromConfig.sourceSelector["science"].doSignalToNoise = True
965 astromConfig.sourceSelector["science"].signalToNoise.minimum = 10
966 astromConfig.sourceSelector["science"].signalToNoise.fluxField = "slot_PsfFlux_instFlux"
967 astromConfig.sourceSelector["science"].signalToNoise.errField = "slot_PsfFlux_instFluxErr"
968 astromConfig.sourceSelector["science"].doRequirePrimary = False
969 astromConfig.sourceSelector["science"].doIsolated = False
971 solver = AstrometryTask(config=astromConfig, refObjLoader=refObjLoader)
973 # TODO: Change this to doing this the proper way
974 referenceFilterName = self.config.referenceFilterOverride
975 referenceFilterLabel = afwImage.FilterLabel(physical=referenceFilterName, band=referenceFilterName)
976 originalFilterLabel = exp.getFilter() # there's a better way of doing this with the task I think
977 exp.setFilter(referenceFilterLabel)
979 try:
980 astromResult = solver.run(sourceCat=icSrc, exposure=exp)
981 exp.setFilter(originalFilterLabel)
982 except (RuntimeError, TaskError):
983 self.log.warning("Solver failed to run completely")
984 exp.setFilter(originalFilterLabel)
985 return None
987 scatter = astromResult.scatterOnSky.asArcseconds()
988 if scatter < 1:
989 return astromResult
990 else:
991 self.log.warning("Failed to find an acceptable match")
992 return None
994 def pause(self):
995 if self.debug.pauseOnDisplay:
996 input("Press return to continue...")
997 return
999 def loadStarNames(self):
1000 """Get the objects which should be treated as stars which do not begin
1001 with HD.
1003 Spectractor treats all objects which start HD as stars, and all which
1004 don't as calibration objects, e.g. arc lamps or planetary nebulae.
1005 Adding items to data/starNames.txt will cause them to be treated as
1006 regular stars.
1008 Returns
1009 -------
1010 starNames : `list` of `str`
1011 The list of all objects to be treated as stars despite not starting
1012 with HD.
1013 """
1014 starNameFile = os.path.join(getPackageDir('atmospec'), 'data', 'starNames.txt')
1015 with open(starNameFile, 'r') as f:
1016 lines = f.readlines()
1017 return [line.strip() for line in lines]
1019 def flatfield(self, exp, disp):
1020 """Placeholder for wavelength dependent flatfielding: TODO: DM-18141
1022 Will probably need a dataRef, as it will need to be retrieving flats
1023 over a range. Also, it will be somewhat complex, so probably needs
1024 moving to its own task"""
1025 self.log.warning("Flatfielding not yet implemented")
1026 return exp
1028 def repairCosmics(self, exp, disp):
1029 self.log.warning("Cosmic ray repair not yet implemented")
1030 return exp
1032 def measureSpectrum(self, exp, sourceCentroid, spectrumBBox, dispersionRelation):
1033 """Perform the spectral extraction, given a source location and exp."""
1035 self.extraction.initialise(exp, sourceCentroid, spectrumBBox, dispersionRelation)
1037 # xxx this method currently doesn't return an object - fix this
1038 spectrum = self.extraction.getFluxBasic()
1040 return spectrum
1042 def calcSpectrumBBox(self, exp, centroid, aperture, order='+1'):
1043 """Calculate the bbox for the spectrum, given the centroid.
1045 XXX Longer explanation here, inc. parameters
1046 TODO: Add support for order = "both"
1047 """
1048 extent = self.config.spectrumLengthPixels
1049 halfWidth = aperture//2
1050 translate_y = self.config.offsetFromMainStar
1051 sourceX = centroid[0]
1052 sourceY = centroid[1]
1054 if order == '-1':
1055 translate_y = - extent - self.config.offsetFromMainStar
1057 xStart = sourceX - halfWidth
1058 xEnd = sourceX + halfWidth - 1
1059 yStart = sourceY + translate_y
1060 yEnd = yStart + extent - 1
1062 xEnd = min(xEnd, exp.getWidth()-1)
1063 yEnd = min(yEnd, exp.getHeight()-1)
1064 yStart = max(yStart, 0)
1065 xStart = max(xStart, 0)
1066 assert (xEnd > xStart) and (yEnd > yStart)
1068 self.log.debug('(xStart, xEnd) = (%s, %s)'%(xStart, xEnd))
1069 self.log.debug('(yStart, yEnd) = (%s, %s)'%(yStart, yEnd))
1071 bbox = geom.Box2I(geom.Point2I(xStart, yStart), geom.Point2I(xEnd, yEnd))
1072 return bbox