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