Coverage for python/lsst/atmospec/utils.py: 10%
200 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-18 02:28 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-18 02:28 -0700
1# This file is part of atmospec.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22__all__ = [
23 "argMaxNd",
24 "gainFromFlatPair",
25 "getAmpReadNoiseFromRawExp",
26 "getLinearStagePosition",
27 "getSamplePoints",
28 "getTargetCentroidFromWcs",
29 "isDispersedDataId",
30 "isDispersedExp",
31 "isExposureTrimmed",
32 "makeGainFlat",
33 "rotateExposure",
34 "simbadLocationForTarget",
35 "vizierLocationForTarget",
36]
38import logging
39import numpy as np
40import lsst.afw.math as afwMath
41import lsst.afw.image as afwImage
42import lsst.afw.geom as afwGeom
43import lsst.geom as geom
44import lsst.daf.butler as dafButler
45from astro_metadata_translator import ObservationInfo
46# from lsst.afw.cameraGeom import PIXELS, FOCAL_PLANE XXX remove if unneeded
47from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER
49import astropy
50import astropy.units as u
51from astropy.coordinates import SkyCoord, Distance
54def makeGainFlat(exposure, gainDict, invertGains=False):
55 """Given an exposure, make a flat from the gains.
57 Construct an exposure where the image array data contains the gain of the
58 pixel, such that dividing by (or mutiplying by) the image will convert
59 an image from ADU to electrons.
61 Parameters
62 ----------
63 detectorExposure : `lsst.afw.image.exposure`
64 The template exposure for which the flat is to be made.
66 gainDict : `dict` of `float`
67 A dict of the amplifiers' gains, keyed by the amp names.
69 invertGains : `bool`
70 Gains are specified in inverted units and should be applied as such.
72 Returns
73 -------
74 gainFlat : `lsst.afw.image.exposure`
75 The gain flat
76 """
77 flat = exposure.clone()
78 detector = flat.getDetector()
79 ampNames = set(list(a.getName() for a in detector))
80 assert set(gainDict.keys()) == ampNames
82 for amp in detector:
83 bbox = amp.getBBox()
84 if invertGains:
85 flat[bbox].maskedImage.image.array[:, :] = 1./gainDict[amp.getName()]
86 else:
87 flat[bbox].maskedImage.image.array[:, :] = gainDict[amp.getName()]
88 flat.maskedImage.mask[:] = 0x0
89 flat.maskedImage.variance[:] = 0.0
91 return flat
94def argMaxNd(array):
95 """Get the index of the max value of an array.
97 If there are multiple occurences of the maximum value
98 just return the first.
99 """
100 return np.unravel_index(np.argmax(array, axis=None), array.shape)
103def getSamplePoints(start, stop, nSamples, includeEndpoints=False, integers=False):
104 """Get the locations of the coordinates to use to sample a range evenly
106 Divide a range up and return the coordinated to use in order to evenly
107 sample the range. If asking for integers, rounded values are returned,
108 rather than int-truncated ones.
110 If not including the endpoints, divide the (stop-start) range into nSamples
111 and return the midpoint of each section, thus leaving a sectionLength/2 gap
112 between the first/last samples and the range start/end.
114 If including the endpoints, the first and last points will be
115 start, stop respectively, and other points will be the endpoints of the
116 remaining nSamples-1 sections.
118 Visually, for a range:
120 |--*--|--*--|--*--|--*--| return * if not including end points, n=4
121 |-*-|-*-|-*-|-*-|-*-|-*-| return * if not including end points, n=6
123 *-----*-----*-----*-----* return * if we ARE including end points, n=4
124 *---*---*---*---*---*---* return * if we ARE including end points, n=6
125 """
127 if not includeEndpoints:
128 r = (stop-start)/(2*nSamples)
129 points = [((2*pointNum+1)*r) for pointNum in range(nSamples)]
130 else:
131 if nSamples <= 1:
132 raise RuntimeError('nSamples must be >= 2 if including endpoints')
133 if nSamples == 2:
134 points = [start, stop]
135 else:
136 r = (stop-start)/(nSamples-1)
137 points = [start]
138 points.extend([((pointNum)*r) for pointNum in range(1, nSamples)])
140 if integers:
141 return [int(x) for x in np.round(points)]
142 return points
145def isExposureTrimmed(exp):
146 det = exp.getDetector()
147 if exp.getDimensions() == det.getBBox().getDimensions():
148 return True
149 return False
152def getAmpReadNoiseFromRawExp(rawExp, ampNum, nOscanBorderPix=0):
153 """XXX doctring here
155 Trim identically in all direction for convenience"""
156 if isExposureTrimmed(rawExp):
157 raise RuntimeError('Got an assembled exposure instead of a raw one')
159 det = rawExp.getDetector()
161 amp = det[ampNum]
162 if nOscanBorderPix == 0:
163 noise = np.std(rawExp[amp.getRawHorizontalOverscanBBox()].image.array)
164 else:
165 b = nOscanBorderPix # line length limits :/
166 noise = np.std(rawExp[amp.getRawHorizontalOverscanBBox()].image.array[b:-b, b:-b])
167 return noise
170def gainFromFlatPair(flat1, flat2, correctionType=None, rawExpForNoiseCalc=None, overscanBorderSize=0):
171 """Calculate the gain from a pair of flats.
173 The basic premise is 1/g = <(I1 - I2)^2/(I1 + I2)>
174 Corrections for the variable QE and the read-noise are then made
175 following the derivation in Robert's forthcoming book, which gets
177 1/g = <(I1 - I2)^2/(I1 + I2)> - 1/mu(sigma^2 - 1/2g^2)
179 If you are lazy, see below for the solution.
180 https://www.wolframalpha.com/input/?i=solve+1%2Fy+%3D+c+-+(1%2Fm)*(s^2+-+1%2F(2y^2))+for+y
182 where mu is the average signal level, and sigma is the
183 amplifier's readnoise. The way the correction is applied depends on
184 the value supplied for correctionType.
186 correctionType is one of [None, 'simple' or 'full']
187 None : uses the 1/g = <(I1 - I2)^2/(I1 + I2)> formula
188 'simple' : uses the gain from the None method for the 1/2g^2 term
189 'full' : solves the full equation for g, discarding the non-physical
190 solution to the resulting quadratic
192 Parameters
193 ----------
194 flat1 : `lsst.afw.image.exposure`
195 The first of the postISR assembled, overscan-subtracted flat pairs
197 flat2 : `lsst.afw.image.exposure`
198 The second of the postISR assembled, overscan-subtracted flat pairs
200 correctionType : `str` or `None`
201 The correction applied, one of [None, 'simple', 'full']
203 rawExpForNoiseCalc : `lsst.afw.image.exposure`
204 A raw (un-assembled) image from which to measure the noise
206 overscanBorderSize : `int`
207 The number of pixels to crop from the overscan region in all directions
209 Returns
210 -------
211 gainDict : `dict`
212 Dictionary of the amplifier gains, keyed by ampName
213 """
214 if correctionType not in [None, 'simple', 'full']:
215 raise RuntimeError("Unknown correction type %s" % correctionType)
217 if correctionType is not None and rawExpForNoiseCalc is None:
218 raise RuntimeError("Must supply rawFlat if performing correction")
220 gains = {}
221 det = flat1.getDetector()
222 for ampNum, amp in enumerate(det):
223 i1 = flat1[amp.getBBox()].image.array
224 i2 = flat2[amp.getBBox()].image.array
225 const = np.mean((i1 - i2)**2 / (i1 + i2))
226 basicGain = 1. / const
228 if correctionType is None:
229 gains[amp.getName()] = basicGain
230 continue
232 mu = (np.mean(i1) + np.mean(i2)) / 2.
233 sigma = getAmpReadNoiseFromRawExp(rawExpForNoiseCalc, ampNum, overscanBorderSize)
235 if correctionType == 'simple':
236 simpleGain = 1/(const - (1/mu)*(sigma**2 - (1/2*basicGain**2)))
237 gains[amp.getName()] = simpleGain
239 elif correctionType == 'full':
240 root = np.sqrt(mu**2 - 2*mu*const + 2*sigma**2)
241 denom = (2*const*mu - 2*sigma**2)
243 positiveSolution = (root + mu)/denom
244 negativeSolution = (mu - root)/denom # noqa: F841 unused, but the other solution
246 gains[amp.getName()] = positiveSolution
248 return gains
251def rotateExposure(exp, nDegrees, kernelName='lanczos4', logger=None):
252 """Rotate an exposure by nDegrees clockwise.
254 Parameters
255 ----------
256 exp : `lsst.afw.image.exposure.Exposure`
257 The exposure to rotate
258 nDegrees : `float`
259 Number of degrees clockwise to rotate by
260 kernelName : `str`
261 Name of the warping kernel, used to instantiate the warper.
262 logger : `logging.Logger`
263 Logger for logging warnings
265 Returns
266 -------
267 rotatedExp : `lsst.afw.image.exposure.Exposure`
268 A copy of the input exposure, rotated by nDegrees
269 """
270 nDegrees = nDegrees % 360
272 if not logger:
273 logger = logging.getLogger(__name__)
275 wcs = exp.getWcs()
276 if not wcs:
277 logger.warning("Can't rotate exposure without a wcs - returning exp unrotated")
278 return exp.clone() # return a clone so it's always returning a copy as this is what default does
280 warper = afwMath.Warper(kernelName)
281 if isinstance(exp, afwImage.ExposureU):
282 # TODO: remove once this bug is fixed - DM-20258
283 logger.info('Converting ExposureU to ExposureF due to bug')
284 logger.info('Remove this workaround after DM-20258')
285 exp = afwImage.ExposureF(exp, deep=True)
287 affineRotTransform = geom.AffineTransform.makeRotation(nDegrees*geom.degrees)
288 transformP2toP2 = afwGeom.makeTransform(affineRotTransform)
289 rotatedWcs = afwGeom.makeModifiedWcs(transformP2toP2, wcs, False)
291 rotatedExp = warper.warpExposure(rotatedWcs, exp)
292 # rotatedExp.setXY0(geom.Point2I(0, 0)) # TODO: check no longer required
293 return rotatedExp
296def airMassFromRawMetadata(md):
297 """Calculate the visit's airmass from the raw header information.
299 Parameters
300 ----------
301 md : `Mapping`
302 The raw header.
304 Returns
305 -------
306 airmass : `float`
307 Returns the airmass, or 0.0 if the calculation fails.
308 Zero was chosen as it is an obviously unphysical value, but means
309 that calling code doesn't have to test if None, as numeric values can
310 be used more easily in place.
311 """
312 try:
313 obsInfo = ObservationInfo(md, subset={"boresight_airmass"})
314 except Exception:
315 return 0.0
316 return obsInfo.boresight_airmass
319def getTargetCentroidFromWcs(exp, target, doMotionCorrection=True, logger=None):
320 """Get the target's centroid, given an exposure with fitted WCS.
322 Parameters
323 ----------
324 exp : `lsst.afw.exposure.Exposure`
325 Exposure with fitted WCS.
327 target : `str`
328 The name of the target, e.g. 'HD 55852'
330 doMotionCorrection : `bool`, optional
331 Correct for proper motion and parallax if possible.
332 This requires the object is found in Vizier rather than Simbad.
333 If that is not possible, a warning is logged, and the uncorrected
334 centroid is returned.
336 Returns
337 -------
338 pixCoord : `tuple` of `float`, or `None`
339 The pixel (x, y) of the target's centroid, or None if the object
340 is not found.
341 """
342 if logger is None:
343 logger = logging.getLogger(__name__)
345 resultFrom = None
346 targetLocation = None
347 # try vizier, but it is slow, unreliable, and
348 # many objects are found but have no Hipparcos entries
349 try:
350 targetLocation = vizierLocationForTarget(exp, target, doMotionCorrection=doMotionCorrection)
351 resultFrom = 'vizier'
352 logger.info("Target location for %s retrieved from Vizier", target)
354 # fail over to simbad - it has ~every target, but no proper motions
355 except ValueError:
356 try:
357 logger.warning("Target %s not found in Vizier, failing over to try Simbad", target)
358 targetLocation = simbadLocationForTarget(target)
359 resultFrom = 'simbad'
360 logger.info("Target location for %s retrieved from Simbad", target)
361 except ValueError as inst: # simbad found zero or several results for target
362 logger.warning("%s", inst.args[0])
363 return None
365 if not targetLocation:
366 return None
368 if doMotionCorrection and resultFrom == 'simbad':
369 logger.warning("Failed to apply motion correction because %s was"
370 " only found in Simbad, not Vizier/Hipparcos", target)
372 pixCoord = exp.getWcs().skyToPixel(targetLocation)
373 return pixCoord
376def simbadLocationForTarget(target):
377 """Get the target location from Simbad.
379 Parameters
380 ----------
381 target : `str`
382 The name of the target, e.g. 'HD 55852'
384 Returns
385 -------
386 targetLocation : `lsst.geom.SpherePoint`
387 Nominal location of the target object, uncorrected for
388 proper motion and parallax.
390 Raises
391 ------
392 ValueError
393 If object not found, or if multiple entries for the object are found.
394 """
395 # do not import at the module level - tests crash due to a race
396 # condition with directory creation
397 from astroquery.simbad import Simbad
399 obj = Simbad.query_object(target)
400 if not obj:
401 raise ValueError(f"Found failed to find {target} in simbad!")
402 if len(obj) != 1:
403 raise ValueError(f"Found {len(obj)} simbad entries for {target}!")
405 raStr = obj[0]['RA']
406 decStr = obj[0]['DEC']
407 skyLocation = SkyCoord(raStr, decStr, unit=(u.hourangle, u.degree), frame='icrs')
408 raRad, decRad = skyLocation.ra.rad, skyLocation.dec.rad
409 ra = geom.Angle(raRad)
410 dec = geom.Angle(decRad)
411 targetLocation = geom.SpherePoint(ra, dec)
412 return targetLocation
415def vizierLocationForTarget(exp, target, doMotionCorrection):
416 """Get the target location from Vizier optionally correction motion.
418 Parameters
419 ----------
420 target : `str`
421 The name of the target, e.g. 'HD 55852'
423 Returns
424 -------
425 targetLocation : `lsst.geom.SpherePoint` or `None`
426 Location of the target object, optionally corrected for
427 proper motion and parallax.
429 Raises
430 ------
431 ValueError
432 If object not found in Hipparcos2 via Vizier.
433 This is quite common, even for bright objects.
434 """
435 # do not import at the module level - tests crash due to a race
436 # condition with directory creation
437 from astroquery.vizier import Vizier
439 result = Vizier.query_object(target) # result is an empty table list for an unknown target
440 try:
441 star = result['I/311/hip2']
442 except TypeError: # if 'I/311/hip2' not in result (result doesn't allow easy checking without a try)
443 raise ValueError
445 epoch = "J1991.25"
446 coord = SkyCoord(ra=star[0]['RArad']*u.Unit(star['RArad'].unit),
447 dec=star[0]['DErad']*u.Unit(star['DErad'].unit),
448 obstime=epoch,
449 pm_ra_cosdec=star[0]['pmRA']*u.Unit(star['pmRA'].unit), # NB contains cosdec already
450 pm_dec=star[0]['pmDE']*u.Unit(star['pmDE'].unit),
451 distance=Distance(parallax=star[0]['Plx']*u.Unit(star['Plx'].unit)))
453 if doMotionCorrection:
454 expDate = exp.getInfo().getVisitInfo().getDate()
455 obsTime = astropy.time.Time(expDate.get(expDate.EPOCH), format='jyear', scale='tai')
456 newCoord = coord.apply_space_motion(new_obstime=obsTime)
457 else:
458 newCoord = coord
460 raRad, decRad = newCoord.ra.rad, newCoord.dec.rad
461 ra = geom.Angle(raRad)
462 dec = geom.Angle(decRad)
463 targetLocation = geom.SpherePoint(ra, dec)
464 return targetLocation
467def isDispersedExp(exp):
468 """Check if an exposure is dispersed."""
469 filterFullName = exp.getFilter().physicalLabel
470 if FILTER_DELIMITER not in filterFullName:
471 raise RuntimeError(f"Error parsing filter name {filterFullName}")
472 filt, grating = filterFullName.split(FILTER_DELIMITER)
473 if grating.upper().startswith('EMPTY'):
474 return False
475 return True
478def isDispersedDataId(dataId, butler):
479 """Check if a dataId corresponds to a dispersed image."""
480 if isinstance(butler, dafButler.Butler):
481 assert 'day_obs' in dataId or 'exposure.day_obs' in dataId, f'failed to find day_obs in {dataId}'
482 assert 'seq_num' in dataId or 'exposure.seq_num' in dataId, f'failed to find seq_num in {dataId}'
483 seq_num = dataId['seq_num'] if 'seq_num' in dataId else dataId['exposure.seq_num']
484 day_obs = dataId['day_obs'] if 'day_obs' in dataId else dataId['exposure.day_obs']
485 where = "exposure.day_obs=day_obs AND exposure.seq_num=seq_num"
486 expRecords = butler.registry.queryDimensionRecords("exposure", where=where,
487 bind={'day_obs': day_obs,
488 'seq_num': seq_num})
489 expRecords = set(expRecords)
490 assert len(expRecords) == 1, f'Found more than one exposure record for {dataId}'
491 filterFullName = expRecords.pop().physical_filter
492 else:
493 raise RuntimeError(f'Expected a butler, got {type(butler)}')
494 if FILTER_DELIMITER not in filterFullName:
495 raise RuntimeError(f"Error parsing filter name {filterFullName}")
496 filt, grating = filterFullName.split(FILTER_DELIMITER)
497 if grating.upper().startswith('EMPTY'):
498 return False
499 return True
502def getLinearStagePosition(exp):
503 md = exp.getMetadata()
504 linearStagePosition = 115 # this seems to be the rough zero-point for some reason
505 if 'LINSPOS' in md:
506 position = md['LINSPOS'] # linear stage position in mm from CCD, larger->further from CCD
507 if position is not None:
508 linearStagePosition += position
509 return linearStagePosition