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