Coverage for python/lsst/atmospec/utils.py: 10%
239 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-25 03:14 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-25 03:14 -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__ = [
23 "argMaxNd",
24 "gainFromFlatPair",
25 "getAmpReadNoiseFromRawExp",
26 "getLinearStagePosition",
27 "getFilterAndDisperserFromExp",
28 "getSamplePoints",
29 "getTargetCentroidFromWcs",
30 "isDispersedDataId",
31 "isDispersedExp",
32 "isExposureTrimmed",
33 "makeGainFlat",
34 "rotateExposure",
35 "simbadLocationForTarget",
36 "vizierLocationForTarget",
37]
39import logging
40import numpy as np
41import sys
42import lsst.afw.math as afwMath
43import lsst.afw.image as afwImage
44from lsst.ctrl.mpexec import SimplePipelineExecutor
45import lsst.afw.geom as afwGeom
46import lsst.geom as geom
47import lsst.daf.butler as dafButler
48from astro_metadata_translator import ObservationInfo
49import lsst.pex.config as pexConfig
50from lsst.pipe.base import Pipeline
51from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER
53import astropy
54import astropy.units as u
55from astropy.coordinates import SkyCoord, Distance
58def makeGainFlat(exposure, gainDict, invertGains=False):
59 """Given an exposure, make a flat from the gains.
61 Construct an exposure where the image array data contains the gain of the
62 pixel, such that dividing by (or mutiplying by) the image will convert
63 an image from ADU to electrons.
65 Parameters
66 ----------
67 detectorExposure : `lsst.afw.image.exposure`
68 The template exposure for which the flat is to be made.
70 gainDict : `dict` of `float`
71 A dict of the amplifiers' gains, keyed by the amp names.
73 invertGains : `bool`
74 Gains are specified in inverted units and should be applied as such.
76 Returns
77 -------
78 gainFlat : `lsst.afw.image.exposure`
79 The gain flat
80 """
81 flat = exposure.clone()
82 detector = flat.getDetector()
83 ampNames = set(list(a.getName() for a in detector))
84 assert set(gainDict.keys()) == ampNames
86 for amp in detector:
87 bbox = amp.getBBox()
88 if invertGains:
89 flat[bbox].maskedImage.image.array[:, :] = 1./gainDict[amp.getName()]
90 else:
91 flat[bbox].maskedImage.image.array[:, :] = gainDict[amp.getName()]
92 flat.maskedImage.mask[:] = 0x0
93 flat.maskedImage.variance[:] = 0.0
95 return flat
98def argMaxNd(array):
99 """Get the index of the max value of an array.
101 If there are multiple occurences of the maximum value
102 just return the first.
103 """
104 return np.unravel_index(np.argmax(array, axis=None), array.shape)
107def getSamplePoints(start, stop, nSamples, includeEndpoints=False, integers=False):
108 """Get the locations of the coordinates to use to sample a range evenly
110 Divide a range up and return the coordinated to use in order to evenly
111 sample the range. If asking for integers, rounded values are returned,
112 rather than int-truncated ones.
114 If not including the endpoints, divide the (stop-start) range into nSamples
115 and return the midpoint of each section, thus leaving a sectionLength/2 gap
116 between the first/last samples and the range start/end.
118 If including the endpoints, the first and last points will be
119 start, stop respectively, and other points will be the endpoints of the
120 remaining nSamples-1 sections.
122 Visually, for a range:
124 |--*--|--*--|--*--|--*--| return * if not including end points, n=4
125 |-*-|-*-|-*-|-*-|-*-|-*-| return * if not including end points, n=6
127 *-----*-----*-----*-----* return * if we ARE including end points, n=4
128 *---*---*---*---*---*---* return * if we ARE including end points, n=6
129 """
131 if not includeEndpoints:
132 r = (stop-start)/(2*nSamples)
133 points = [((2*pointNum+1)*r) for pointNum in range(nSamples)]
134 else:
135 if nSamples <= 1:
136 raise RuntimeError('nSamples must be >= 2 if including endpoints')
137 if nSamples == 2:
138 points = [start, stop]
139 else:
140 r = (stop-start)/(nSamples-1)
141 points = [start]
142 points.extend([((pointNum)*r) for pointNum in range(1, nSamples)])
144 if integers:
145 return [int(x) for x in np.round(points)]
146 return points
149def isExposureTrimmed(exp):
150 det = exp.getDetector()
151 if exp.getDimensions() == det.getBBox().getDimensions():
152 return True
153 return False
156def getAmpReadNoiseFromRawExp(rawExp, ampNum, nOscanBorderPix=0):
157 """XXX doctring here
159 Trim identically in all direction for convenience"""
160 if isExposureTrimmed(rawExp):
161 raise RuntimeError('Got an assembled exposure instead of a raw one')
163 det = rawExp.getDetector()
165 amp = det[ampNum]
166 if nOscanBorderPix == 0:
167 noise = np.std(rawExp[amp.getRawHorizontalOverscanBBox()].image.array)
168 else:
169 b = nOscanBorderPix # line length limits :/
170 noise = np.std(rawExp[amp.getRawHorizontalOverscanBBox()].image.array[b:-b, b:-b])
171 return noise
174def gainFromFlatPair(flat1, flat2, correctionType=None, rawExpForNoiseCalc=None, overscanBorderSize=0):
175 """Calculate the gain from a pair of flats.
177 The basic premise is 1/g = <(I1 - I2)^2/(I1 + I2)>
178 Corrections for the variable QE and the read-noise are then made
179 following the derivation in Robert's forthcoming book, which gets
181 1/g = <(I1 - I2)^2/(I1 + I2)> - 1/mu(sigma^2 - 1/2g^2)
183 If you are lazy, see below for the solution.
184 https://www.wolframalpha.com/input/?i=solve+1%2Fy+%3D+c+-+(1%2Fm)*(s^2+-+1%2F(2y^2))+for+y
186 where mu is the average signal level, and sigma is the
187 amplifier's readnoise. The way the correction is applied depends on
188 the value supplied for correctionType.
190 correctionType is one of [None, 'simple' or 'full']
191 None : uses the 1/g = <(I1 - I2)^2/(I1 + I2)> formula
192 'simple' : uses the gain from the None method for the 1/2g^2 term
193 'full' : solves the full equation for g, discarding the non-physical
194 solution to the resulting quadratic
196 Parameters
197 ----------
198 flat1 : `lsst.afw.image.exposure`
199 The first of the postISR assembled, overscan-subtracted flat pairs
201 flat2 : `lsst.afw.image.exposure`
202 The second of the postISR assembled, overscan-subtracted flat pairs
204 correctionType : `str` or `None`
205 The correction applied, one of [None, 'simple', 'full']
207 rawExpForNoiseCalc : `lsst.afw.image.exposure`
208 A raw (un-assembled) image from which to measure the noise
210 overscanBorderSize : `int`
211 The number of pixels to crop from the overscan region in all directions
213 Returns
214 -------
215 gainDict : `dict`
216 Dictionary of the amplifier gains, keyed by ampName
217 """
218 if correctionType not in [None, 'simple', 'full']:
219 raise RuntimeError("Unknown correction type %s" % correctionType)
221 if correctionType is not None and rawExpForNoiseCalc is None:
222 raise RuntimeError("Must supply rawFlat if performing correction")
224 gains = {}
225 det = flat1.getDetector()
226 for ampNum, amp in enumerate(det):
227 i1 = flat1[amp.getBBox()].image.array
228 i2 = flat2[amp.getBBox()].image.array
229 const = np.mean((i1 - i2)**2 / (i1 + i2))
230 basicGain = 1. / const
232 if correctionType is None:
233 gains[amp.getName()] = basicGain
234 continue
236 mu = (np.mean(i1) + np.mean(i2)) / 2.
237 sigma = getAmpReadNoiseFromRawExp(rawExpForNoiseCalc, ampNum, overscanBorderSize)
239 if correctionType == 'simple':
240 simpleGain = 1/(const - (1/mu)*(sigma**2 - (1/2*basicGain**2)))
241 gains[amp.getName()] = simpleGain
243 elif correctionType == 'full':
244 root = np.sqrt(mu**2 - 2*mu*const + 2*sigma**2)
245 denom = (2*const*mu - 2*sigma**2)
247 positiveSolution = (root + mu)/denom
248 negativeSolution = (mu - root)/denom # noqa: F841 unused, but the other solution
250 gains[amp.getName()] = positiveSolution
252 return gains
255def rotateExposure(exp, nDegrees, kernelName='lanczos4', logger=None):
256 """Rotate an exposure by nDegrees clockwise.
258 Parameters
259 ----------
260 exp : `lsst.afw.image.exposure.Exposure`
261 The exposure to rotate
262 nDegrees : `float`
263 Number of degrees clockwise to rotate by
264 kernelName : `str`
265 Name of the warping kernel, used to instantiate the warper.
266 logger : `logging.Logger`
267 Logger for logging warnings
269 Returns
270 -------
271 rotatedExp : `lsst.afw.image.exposure.Exposure`
272 A copy of the input exposure, rotated by nDegrees
273 """
274 nDegrees = nDegrees % 360
276 if not logger:
277 logger = logging.getLogger(__name__)
279 wcs = exp.getWcs()
280 if not wcs:
281 logger.warning("Can't rotate exposure without a wcs - returning exp unrotated")
282 return exp.clone() # return a clone so it's always returning a copy as this is what default does
284 warper = afwMath.Warper(kernelName)
285 if isinstance(exp, afwImage.ExposureU):
286 # TODO: remove once this bug is fixed - DM-20258
287 logger.info('Converting ExposureU to ExposureF due to bug')
288 logger.info('Remove this workaround after DM-20258')
289 exp = afwImage.ExposureF(exp, deep=True)
291 affineRotTransform = geom.AffineTransform.makeRotation(nDegrees*geom.degrees)
292 transformP2toP2 = afwGeom.makeTransform(affineRotTransform)
293 rotatedWcs = afwGeom.makeModifiedWcs(transformP2toP2, wcs, False)
295 rotatedExp = warper.warpExposure(rotatedWcs, exp)
296 # rotatedExp.setXY0(geom.Point2I(0, 0)) # TODO: check no longer required
297 return rotatedExp
300def airMassFromRawMetadata(md):
301 """Calculate the visit's airmass from the raw header information.
303 Parameters
304 ----------
305 md : `Mapping`
306 The raw header.
308 Returns
309 -------
310 airmass : `float`
311 Returns the airmass, or 0.0 if the calculation fails.
312 Zero was chosen as it is an obviously unphysical value, but means
313 that calling code doesn't have to test if None, as numeric values can
314 be used more easily in place.
315 """
316 try:
317 obsInfo = ObservationInfo(md, subset={"boresight_airmass"})
318 except Exception:
319 return 0.0
320 return obsInfo.boresight_airmass
323def getTargetCentroidFromWcs(exp, target, doMotionCorrection=True, logger=None):
324 """Get the target's centroid, given an exposure with fitted WCS.
326 Parameters
327 ----------
328 exp : `lsst.afw.exposure.Exposure`
329 Exposure with fitted WCS.
331 target : `str`
332 The name of the target, e.g. 'HD 55852'
334 doMotionCorrection : `bool`, optional
335 Correct for proper motion and parallax if possible.
336 This requires the object is found in Vizier rather than Simbad.
337 If that is not possible, a warning is logged, and the uncorrected
338 centroid is returned.
340 Returns
341 -------
342 pixCoord : `tuple` of `float`, or `None`
343 The pixel (x, y) of the target's centroid, or None if the object
344 is not found.
345 """
346 if logger is None:
347 logger = logging.getLogger(__name__)
349 resultFrom = None
350 targetLocation = None
351 # try vizier, but it is slow, unreliable, and
352 # many objects are found but have no Hipparcos entries
353 try:
354 targetLocation = vizierLocationForTarget(exp, target, doMotionCorrection=doMotionCorrection)
355 resultFrom = 'vizier'
356 logger.info("Target location for %s retrieved from Vizier", target)
358 # fail over to simbad - it has ~every target, but no proper motions
359 except ValueError:
360 try:
361 logger.warning("Target %s not found in Vizier, failing over to try Simbad", target)
362 targetLocation = simbadLocationForTarget(target)
363 resultFrom = 'simbad'
364 logger.info("Target location for %s retrieved from Simbad", target)
365 except ValueError as inst: # simbad found zero or several results for target
366 logger.warning("%s", inst.args[0])
367 return None
369 if not targetLocation:
370 return None
372 if doMotionCorrection and resultFrom == 'simbad':
373 logger.warning("Failed to apply motion correction because %s was"
374 " only found in Simbad, not Vizier/Hipparcos", target)
376 pixCoord = exp.getWcs().skyToPixel(targetLocation)
377 return pixCoord
380def simbadLocationForTarget(target):
381 """Get the target location from Simbad.
383 Parameters
384 ----------
385 target : `str`
386 The name of the target, e.g. 'HD 55852'
388 Returns
389 -------
390 targetLocation : `lsst.geom.SpherePoint`
391 Nominal location of the target object, uncorrected for
392 proper motion and parallax.
394 Raises
395 ------
396 ValueError
397 If object not found, or if multiple entries for the object are found.
398 """
399 # do not import at the module level - tests crash due to a race
400 # condition with directory creation
401 from astroquery.simbad import Simbad
403 obj = Simbad.query_object(target)
404 if not obj:
405 raise ValueError(f"Failed to find {target} in simbad!")
406 if len(obj) != 1:
407 raise ValueError(f"Found {len(obj)} simbad entries for {target}!")
409 raStr = obj[0]['RA']
410 decStr = obj[0]['DEC']
411 skyLocation = SkyCoord(raStr, decStr, unit=(u.hourangle, u.degree), frame='icrs')
412 raRad, decRad = skyLocation.ra.rad, skyLocation.dec.rad
413 ra = geom.Angle(raRad)
414 dec = geom.Angle(decRad)
415 targetLocation = geom.SpherePoint(ra, dec)
416 return targetLocation
419def vizierLocationForTarget(exp, target, doMotionCorrection):
420 """Get the target location from Vizier optionally correction motion.
422 Parameters
423 ----------
424 target : `str`
425 The name of the target, e.g. 'HD 55852'
427 Returns
428 -------
429 targetLocation : `lsst.geom.SpherePoint` or `None`
430 Location of the target object, optionally corrected for
431 proper motion and parallax.
433 Raises
434 ------
435 ValueError
436 If object not found in Hipparcos2 via Vizier.
437 This is quite common, even for bright objects.
438 """
439 # do not import at the module level - tests crash due to a race
440 # condition with directory creation
441 from astroquery.vizier import Vizier
443 result = Vizier.query_object(target) # result is an empty table list for an unknown target
444 try:
445 star = result['I/311/hip2']
446 except TypeError: # if 'I/311/hip2' not in result (result doesn't allow easy checking without a try)
447 raise ValueError
449 epoch = "J1991.25"
450 coord = SkyCoord(ra=star[0]['RArad']*u.Unit(star['RArad'].unit),
451 dec=star[0]['DErad']*u.Unit(star['DErad'].unit),
452 obstime=epoch,
453 pm_ra_cosdec=star[0]['pmRA']*u.Unit(star['pmRA'].unit), # NB contains cosdec already
454 pm_dec=star[0]['pmDE']*u.Unit(star['pmDE'].unit),
455 distance=Distance(parallax=star[0]['Plx']*u.Unit(star['Plx'].unit)))
457 if doMotionCorrection:
458 expDate = exp.getInfo().getVisitInfo().getDate()
459 obsTime = astropy.time.Time(expDate.get(expDate.EPOCH), format='jyear', scale='tai')
460 newCoord = coord.apply_space_motion(new_obstime=obsTime)
461 else:
462 newCoord = coord
464 raRad, decRad = newCoord.ra.rad, newCoord.dec.rad
465 ra = geom.Angle(raRad)
466 dec = geom.Angle(decRad)
467 targetLocation = geom.SpherePoint(ra, dec)
468 return targetLocation
471def isDispersedExp(exp):
472 """Check if an exposure is dispersed.
474 Parameters
475 ----------
476 exp : `lsst.afw.image.Exposure`
477 The exposure.
479 Returns
480 -------
481 isDispersed : `bool`
482 Whether it is a dispersed image or not.
483 """
484 filterFullName = exp.filter.physicalLabel
485 if FILTER_DELIMITER not in filterFullName:
486 raise RuntimeError(f"Error parsing filter name {filterFullName}")
487 filt, grating = filterFullName.split(FILTER_DELIMITER)
488 if grating.upper().startswith('EMPTY'):
489 return False
490 return True
493def isDispersedDataId(dataId, butler):
494 """Check if a dataId corresponds to a dispersed image.
496 Parameters
497 ----------
498 dataId : `dict`
499 The dataId.
500 butler : `lsst.daf.butler.Butler`
501 The butler.
503 Returns
504 -------
505 isDispersed : `bool`
506 Whether it is a dispersed image or not.
507 """
508 if isinstance(butler, dafButler.Butler):
509 # TODO: DM-38265 Need to make this work with DataCoords
510 assert 'day_obs' in dataId or 'exposure.day_obs' in dataId, f'failed to find day_obs in {dataId}'
511 assert 'seq_num' in dataId or 'exposure.seq_num' in dataId, f'failed to find seq_num in {dataId}'
512 seq_num = dataId['seq_num'] if 'seq_num' in dataId else dataId['exposure.seq_num']
513 day_obs = dataId['day_obs'] if 'day_obs' in dataId else dataId['exposure.day_obs']
514 where = "exposure.day_obs=day_obs AND exposure.seq_num=seq_num"
515 expRecords = butler.registry.queryDimensionRecords("exposure", where=where,
516 bind={'day_obs': day_obs,
517 'seq_num': seq_num})
518 expRecords = set(expRecords)
519 assert len(expRecords) == 1, f'Found more than one exposure record for {dataId}'
520 filterFullName = expRecords.pop().physical_filter
521 else:
522 raise RuntimeError(f'Expected a butler, got {type(butler)}')
523 if FILTER_DELIMITER not in filterFullName:
524 raise RuntimeError(f"Error parsing filter name {filterFullName}")
525 filt, grating = filterFullName.split(FILTER_DELIMITER)
526 if grating.upper().startswith('EMPTY'):
527 return False
528 return True
531def getLinearStagePosition(exp):
532 """Get the linear stage position.
534 Parameters
535 ----------
536 exp : `lsst.afw.image.Exposure`
537 The exposure.
539 Returns
540 -------
541 position : `float`
542 The position of the linear stage, in mm.
543 """
544 md = exp.getMetadata()
545 linearStagePosition = 115 # this seems to be the rough zero-point for some reason
546 if 'LINSPOS' in md:
547 position = md['LINSPOS'] # linear stage position in mm from CCD, larger->further from CCD
548 if position is not None:
549 linearStagePosition += position
550 return linearStagePosition
553def getFilterAndDisperserFromExp(exp):
554 """Get the filter and disperser from an exposure.
556 Parameters
557 ----------
558 exp : `lsst.afw.image.Exposure`
559 The exposure.
561 Returns
562 -------
563 filter, disperser : `tuple` of `str`
564 The filter and the disperser names, as strings.
565 """
566 filterFullName = exp.getFilter().physicalLabel
567 if FILTER_DELIMITER not in filterFullName:
568 filt = filterFullName
569 grating = exp.getInfo().getMetadata()['GRATING']
570 else:
571 filt, grating = filterFullName.split(FILTER_DELIMITER)
572 return filt, grating
575def runNotebook(dataId, outputCollection, taskConfigs={}, configOptions={}, embargo=False):
576 """Run the ProcessStar pipeline for a single dataId, writing to the
577 specified output collection.
579 This is a convenience function to allow running single dataIds in notebooks
580 so that plots can be inspected easily. This is not designed for bulk data
581 reductions.
583 Parameters
584 ----------
585 dataId : `dict`
586 The dataId to run.
587 outputCollection : `str`, optional
588 Output collection name.
589 taskConfigs : `dict` [`lsst.pipe.base.PipelineTaskConfig`], optional
590 Dictionary of config config classes. The key of the ``taskConfigs``
591 dict is the relevant task label. The value of ``taskConfigs``
592 is a task config object to apply. See notes for ignored items.
593 configOptions : `dict` [`dict`], optional
594 Dictionary of individual config options. The key of the
595 ``configOptions`` dict is the relevant task label. The value
596 of ``configOptions`` is another dict that contains config
597 key/value overrides to apply.
598 embargo : `bool`, optional
599 Use the embargo repo?
601 Returns
602 -------
603 spectraction : `lsst.atmospec.spectraction.Spectraction`
604 The extracted spectraction object.
606 Notes
607 -----
608 Any ConfigurableInstances in supplied task config overrides will be
609 ignored. Currently (see DM-XXXXX) this causes a RecursionError.
610 """
611 def makeQuery(dataId):
612 dayObs = dataId['day_obs'] if 'day_obs' in dataId else dataId['exposure.day_obs']
613 seqNum = dataId['seq_num'] if 'seq_num' in dataId else dataId['exposure.seq_num']
614 queryString = (f"exposure.day_obs={dayObs} AND "
615 f"exposure.seq_num={seqNum} AND "
616 "instrument='LATISS'")
618 return queryString
619 repo = "LATISS" if not embargo else "/repo/embargo"
621 # TODO: use LATISS_DEFAULT_COLLECTIONS here?
622 butler = SimplePipelineExecutor.prep_butler(repo,
623 inputs=['LATISS/raw/all',
624 'refcats',
625 'LATISS/calib',
626 ],
627 output=outputCollection)
629 pipeline = Pipeline.fromFile("${ATMOSPEC_DIR}/pipelines/processStar.yaml")
631 for taskName, configClass in taskConfigs.items():
632 for option, value in configClass.items():
633 # connections require special treatment
634 if isinstance(value, configClass.ConnectionsConfigClass):
635 for connectionOption, connectionValue in value.items():
636 pipeline.addConfigOverride(taskName,
637 f'{option}.{connectionOption}',
638 connectionValue)
639 # ConfigurableInstance has to be done with .retarget()
640 elif not isinstance(value, pexConfig.ConfigurableInstance):
641 pipeline.addConfigOverride(taskName, option, value)
643 for taskName, configDict in configOptions.items():
644 for option, value in configDict.items():
645 # ConfigurableInstance has to be done with .retarget()
646 if not isinstance(value, pexConfig.ConfigurableInstance):
647 pipeline.addConfigOverride(taskName, option, value)
649 query = makeQuery(dataId)
650 executor = SimplePipelineExecutor.from_pipeline(pipeline,
651 where=query,
652 root=repo,
653 butler=butler)
655 logging.basicConfig(level=logging.INFO, stream=sys.stdout)
656 quanta = executor.run()
658 # quanta is just a plain list, but the items know their names, so rather
659 # than just taking the third item and relying on that being the position in
660 # the pipeline we get the item by name
661 processStarQuantum = [q for q in quanta if q.taskName == 'lsst.atmospec.processStar.ProcessStarTask'][0]
662 dataRef = processStarQuantum.outputs['spectractorSpectrum'][0]
663 result = butler.get(dataRef)
664 return result