Coverage for python/lsst/atmospec/utils.py: 10%
208 statements
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-14 17:12 -0700
« prev ^ index » next coverage.py v6.4.2, created at 2022-07-14 17:12 -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/>.
22import numpy as np
23import lsst.afw.math as afwMath
24import lsst.afw.image as afwImage
25import lsst.log as lsstLog
26import lsst.afw.geom as afwGeom
27import lsst.geom as geom
28import lsst.daf.persistence as dafPersist
29import lsst.daf.butler as dafButler
30# from lsst.afw.cameraGeom import PIXELS, FOCAL_PLANE XXX remove if unneeded
31from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER
32from lsst.obs.lsst.translators.latiss import AUXTEL_LOCATION
34import astropy
35import astropy.units as u
36from astropy.time import Time
37from astropy.coordinates import SkyCoord, AltAz, Distance
40def makeGainFlat(exposure, gainDict, invertGains=False):
41 """Given an exposure, make a flat from the gains.
43 Construct an exposure where the image array data contains the gain of the
44 pixel, such that dividing by (or mutiplying by) the image will convert
45 an image from ADU to electrons.
47 Parameters
48 ----------
49 detectorExposure : `lsst.afw.image.exposure`
50 The template exposure for which the flat is to be made.
52 gainDict : `dict` of `float`
53 A dict of the amplifiers' gains, keyed by the amp names.
55 invertGains : `bool`
56 Gains are specified in inverted units and should be applied as such.
58 Returns
59 -------
60 gainFlat : `lsst.afw.image.exposure`
61 The gain flat
62 """
63 flat = exposure.clone()
64 detector = flat.getDetector()
65 ampNames = set(list(a.getName() for a in detector))
66 assert set(gainDict.keys()) == ampNames
68 for amp in detector:
69 bbox = amp.getBBox()
70 if invertGains:
71 flat[bbox].maskedImage.image.array[:, :] = 1./gainDict[amp.getName()]
72 else:
73 flat[bbox].maskedImage.image.array[:, :] = gainDict[amp.getName()]
74 flat.maskedImage.mask[:] = 0x0
75 flat.maskedImage.variance[:] = 0.0
77 return flat
80def argMaxNd(array):
81 """Get the index of the max value of an array.
83 If there are multiple occurences of the maximum value
84 just return the first.
85 """
86 return np.unravel_index(np.argmax(array, axis=None), array.shape)
89def getSamplePoints(start, stop, nSamples, includeEndpoints=False, integers=False):
90 """Get the locations of the coordinates to use to sample a range evenly
92 Divide a range up and return the coordinated to use in order to evenly
93 sample the range. If asking for integers, rounded values are returned,
94 rather than int-truncated ones.
96 If not including the endpoints, divide the (stop-start) range into nSamples
97 and return the midpoint of each section, thus leaving a sectionLength/2 gap
98 between the first/last samples and the range start/end.
100 If including the endpoints, the first and last points will be
101 start, stop respectively, and other points will be the endpoints of the
102 remaining nSamples-1 sections.
104 Visually, for a range:
106 |--*--|--*--|--*--|--*--| return * if not including end points, n=4
107 |-*-|-*-|-*-|-*-|-*-|-*-| return * if not including end points, n=6
109 *-----*-----*-----*-----* return * if we ARE including end points, n=4
110 *---*---*---*---*---*---* return * if we ARE including end points, n=6
111 """
113 if not includeEndpoints:
114 r = (stop-start)/(2*nSamples)
115 points = [((2*pointNum+1)*r) for pointNum in range(nSamples)]
116 else:
117 if nSamples <= 1:
118 raise RuntimeError('nSamples must be >= 2 if including endpoints')
119 if nSamples == 2:
120 points = [start, stop]
121 else:
122 r = (stop-start)/(nSamples-1)
123 points = [start]
124 points.extend([((pointNum)*r) for pointNum in range(1, nSamples)])
126 if integers:
127 return [int(x) for x in np.round(points)]
128 return points
131def isExposureTrimmed(exp):
132 det = exp.getDetector()
133 if exp.getDimensions() == det.getBBox().getDimensions():
134 return True
135 return False
138def getAmpReadNoiseFromRawExp(rawExp, ampNum, nOscanBorderPix=0):
139 """XXX doctring here
141 Trim identically in all direction for convenience"""
142 if isExposureTrimmed(rawExp):
143 raise RuntimeError('Got an assembled exposure instead of a raw one')
145 det = rawExp.getDetector()
147 amp = det[ampNum]
148 if nOscanBorderPix == 0:
149 noise = np.std(rawExp[amp.getRawHorizontalOverscanBBox()].image.array)
150 else:
151 b = nOscanBorderPix # line length limits :/
152 noise = np.std(rawExp[amp.getRawHorizontalOverscanBBox()].image.array[b:-b, b:-b])
153 return noise
156def gainFromFlatPair(flat1, flat2, correctionType=None, rawExpForNoiseCalc=None, overscanBorderSize=0):
157 """Calculate the gain from a pair of flats.
159 The basic premise is 1/g = <(I1 - I2)^2/(I1 + I2)>
160 Corrections for the variable QE and the read-noise are then made
161 following the derivation in Robert's forthcoming book, which gets
163 1/g = <(I1 - I2)^2/(I1 + I2)> - 1/mu(sigma^2 - 1/2g^2)
165 If you are lazy, see below for the solution.
166 https://www.wolframalpha.com/input/?i=solve+1%2Fy+%3D+c+-+(1%2Fm)*(s^2+-+1%2F(2y^2))+for+y
168 where mu is the average signal level, and sigma is the
169 amplifier's readnoise. The way the correction is applied depends on
170 the value supplied for correctionType.
172 correctionType is one of [None, 'simple' or 'full']
173 None : uses the 1/g = <(I1 - I2)^2/(I1 + I2)> formula
174 'simple' : uses the gain from the None method for the 1/2g^2 term
175 'full' : solves the full equation for g, discarding the non-physical
176 solution to the resulting quadratic
178 Parameters
179 ----------
180 flat1 : `lsst.afw.image.exposure`
181 The first of the postISR assembled, overscan-subtracted flat pairs
183 flat2 : `lsst.afw.image.exposure`
184 The second of the postISR assembled, overscan-subtracted flat pairs
186 correctionType : `str` or `None`
187 The correction applied, one of [None, 'simple', 'full']
189 rawExpForNoiseCalc : `lsst.afw.image.exposure`
190 A raw (un-assembled) image from which to measure the noise
192 overscanBorderSize : `int`
193 The number of pixels to crop from the overscan region in all directions
195 Returns
196 -------
197 gainDict : `dict`
198 Dictionary of the amplifier gains, keyed by ampName
199 """
200 if correctionType not in [None, 'simple', 'full']:
201 raise RuntimeError("Unknown correction type %s" % correctionType)
203 if correctionType is not None and rawExpForNoiseCalc is None:
204 raise RuntimeError("Must supply rawFlat if performing correction")
206 gains = {}
207 det = flat1.getDetector()
208 for ampNum, amp in enumerate(det):
209 i1 = flat1[amp.getBBox()].image.array
210 i2 = flat2[amp.getBBox()].image.array
211 const = np.mean((i1 - i2)**2 / (i1 + i2))
212 basicGain = 1. / const
214 if correctionType is None:
215 gains[amp.getName()] = basicGain
216 continue
218 mu = (np.mean(i1) + np.mean(i2)) / 2.
219 sigma = getAmpReadNoiseFromRawExp(rawExpForNoiseCalc, ampNum, overscanBorderSize)
221 if correctionType == 'simple':
222 simpleGain = 1/(const - (1/mu)*(sigma**2 - (1/2*basicGain**2)))
223 gains[amp.getName()] = simpleGain
225 elif correctionType == 'full':
226 root = np.sqrt(mu**2 - 2*mu*const + 2*sigma**2)
227 denom = (2*const*mu - 2*sigma**2)
229 positiveSolution = (root + mu)/denom
230 negativeSolution = (mu - root)/denom # noqa: F841 unused, but the other solution
232 gains[amp.getName()] = positiveSolution
234 return gains
237def rotateExposure(exp, nDegrees, kernelName='lanczos4', logger=None):
238 """Rotate an exposure by nDegrees clockwise.
240 Parameters
241 ----------
242 exp : `lsst.afw.image.exposure.Exposure`
243 The exposure to rotate
244 nDegrees : `float`
245 Number of degrees clockwise to rotate by
246 kernelName : `str`
247 Name of the warping kernel, used to instantiate the warper.
248 logger : `lsst.log.Log`
249 Logger for logging warnings
251 Returns
252 -------
253 rotatedExp : `lsst.afw.image.exposure.Exposure`
254 A copy of the input exposure, rotated by nDegrees
255 """
256 nDegrees = nDegrees % 360
258 if not logger:
259 logger = lsstLog.getLogger('atmospec.utils')
261 wcs = exp.getWcs()
262 if not wcs:
263 logger.warn("Can't rotate exposure without a wcs - returning exp unrotated")
264 return exp.clone() # return a clone so it's always returning a copy as this is what default does
266 warper = afwMath.Warper(kernelName)
267 if isinstance(exp, afwImage.ExposureU):
268 # TODO: remove once this bug is fixed - DM-20258
269 logger.info('Converting ExposureU to ExposureF due to bug')
270 logger.info('Remove this workaround after DM-20258')
271 exp = afwImage.ExposureF(exp, deep=True)
273 affineRotTransform = geom.AffineTransform.makeRotation(nDegrees*geom.degrees)
274 transformP2toP2 = afwGeom.makeTransform(affineRotTransform)
275 rotatedWcs = afwGeom.makeModifiedWcs(transformP2toP2, wcs, False)
277 rotatedExp = warper.warpExposure(rotatedWcs, exp)
278 # rotatedExp.setXY0(geom.Point2I(0, 0)) # TODO: check no longer required
279 return rotatedExp
282def airMassFromRawMetadata(md):
283 """Calculate the visit's airmass from the raw header information.
285 Returns the airmass, or 0 if the calculation fails.
286 Zero was chosen as it is an obviously unphysical value, but means
287 that calling code doesn't have to test if None, as numeric values can
288 be used more easily in place."""
289 time = Time(md['DATE-OBS'])
290 if md['RASTART'] is not None and md['DECSTART'] is not None:
291 skyLocation = SkyCoord(md['RASTART'], md['DECSTART'], unit=u.deg)
292 elif md['RA'] is not None and md['DEC'] is not None:
293 skyLocation = SkyCoord(md['RA'], md['DEC'], unit=u.deg)
294 else:
295 return 0
296 altAz = AltAz(obstime=time, location=AUXTEL_LOCATION)
297 observationAltAz = skyLocation.transform_to(altAz)
298 return observationAltAz.secz.value
301def getTargetCentroidFromWcs(exp, target, doMotionCorrection=True, logger=None):
302 """Get the target's centroid, given an exposure with fitted WCS.
304 Parameters
305 ----------
306 exp : `lsst.afw.exposure.Exposure`
307 Exposure with fitted WCS.
309 target : `str`
310 The name of the target, e.g. 'HD 55852'
312 doMotionCorrection : `bool`, optional
313 Correct for proper motion and parallax if possible.
314 This requires the object is found in Vizier rather than Simbad.
315 If that is not possible, a warning is logged, and the uncorrected
316 centroid is returned.
318 Returns
319 -------
320 pixCoord : `tuple` of `float`, or `None`
321 The pixel (x, y) of the target's centroid, or None if the object
322 is not found.
323 """
324 if logger is None:
325 logger = lsstLog.Log.getDefaultLogger()
327 resultFrom = None
328 targetLocation = None
329 # try vizier, but it is slow, unreliable, and
330 # many objects are found but have no Hipparcos entries
331 try:
332 targetLocation = vizierLocationForTarget(exp, target, doMotionCorrection=doMotionCorrection)
333 resultFrom = 'vizier'
334 logger.info(f"Target location for {target} retrieved from Vizier")
336 # fail over to simbad - it has ~every target, but no proper motions
337 except ValueError:
338 try:
339 logger.warn(f"Target {target} not found in Vizier, failing over to try Simbad")
340 targetLocation = simbadLocationForTarget(target)
341 resultFrom = 'simbad'
342 logger.info(f"Target location for {target} retrieved from Simbad")
343 except ValueError as inst: # simbad found zero or several results for target
344 msg = inst.args[0]
345 logger.warn(msg)
346 return None
348 if not targetLocation:
349 return None
351 if doMotionCorrection and resultFrom == 'simbad':
352 logger.warn(f"Failed to apply motion correction because {target} was"
353 " only found in Simbad, not Vizier/Hipparcos")
355 pixCoord = exp.getWcs().skyToPixel(targetLocation)
356 return pixCoord
359def simbadLocationForTarget(target):
360 """Get the target location from Simbad.
362 Parameters
363 ----------
364 target : `str`
365 The name of the target, e.g. 'HD 55852'
367 Returns
368 -------
369 targetLocation : `lsst.geom.SpherePoint`
370 Nominal location of the target object, uncorrected for
371 proper motion and parallax.
373 Raises
374 ------
375 ValueError
376 If object not found, or if multiple entries for the object are found.
377 """
378 # do not import at the module level - tests crash due to a race
379 # condition with directory creation
380 from astroquery.simbad import Simbad
382 obj = Simbad.query_object(target)
383 if not obj:
384 raise ValueError(f"Found failed to find {target} in simbad!")
385 if len(obj) != 1:
386 raise ValueError(f"Found {len(obj)} simbad entries for {target}!")
388 raStr = obj[0]['RA']
389 decStr = obj[0]['DEC']
390 skyLocation = SkyCoord(raStr, decStr, unit=(u.hourangle, u.degree), frame='icrs')
391 raRad, decRad = skyLocation.ra.rad, skyLocation.dec.rad
392 ra = geom.Angle(raRad)
393 dec = geom.Angle(decRad)
394 targetLocation = geom.SpherePoint(ra, dec)
395 return targetLocation
398def vizierLocationForTarget(exp, target, doMotionCorrection):
399 """Get the target location from Vizier optionally correction motion.
401 Parameters
402 ----------
403 target : `str`
404 The name of the target, e.g. 'HD 55852'
406 Returns
407 -------
408 targetLocation : `lsst.geom.SpherePoint` or `None`
409 Location of the target object, optionally corrected for
410 proper motion and parallax.
412 Raises
413 ------
414 ValueError
415 If object not found in Hipparcos2 via Vizier.
416 This is quite common, even for bright objects.
417 """
418 # do not import at the module level - tests crash due to a race
419 # condition with directory creation
420 from astroquery.vizier import Vizier
422 result = Vizier.query_object(target) # result is an empty table list for an unknown target
423 try:
424 star = result['I/311/hip2']
425 except TypeError: # if 'I/311/hip2' not in result (result doesn't allow easy checking without a try)
426 raise ValueError
428 epoch = "J1991.25"
429 coord = SkyCoord(ra=star[0]['RArad']*u.Unit(star['RArad'].unit),
430 dec=star[0]['DErad']*u.Unit(star['DErad'].unit),
431 obstime=epoch,
432 pm_ra_cosdec=star[0]['pmRA']*u.Unit(star['pmRA'].unit), # NB contains cosdec already
433 pm_dec=star[0]['pmDE']*u.Unit(star['pmDE'].unit),
434 distance=Distance(parallax=star[0]['Plx']*u.Unit(star['Plx'].unit)))
436 if doMotionCorrection:
437 expDate = exp.getInfo().getVisitInfo().getDate()
438 obsTime = astropy.time.Time(expDate.get(expDate.EPOCH), format='jyear', scale='tai')
439 newCoord = coord.apply_space_motion(new_obstime=obsTime)
440 else:
441 newCoord = coord
443 raRad, decRad = newCoord.ra.rad, newCoord.dec.rad
444 ra = geom.Angle(raRad)
445 dec = geom.Angle(decRad)
446 targetLocation = geom.SpherePoint(ra, dec)
447 return targetLocation
450def isDispersedExp(exp):
451 """Check if an exposure is dispersed."""
452 filterFullName = exp.getFilter().physicalLabel
453 if FILTER_DELIMITER not in filterFullName:
454 raise RuntimeError(f"Error parsing filter name {filterFullName}")
455 filt, grating = filterFullName.split(FILTER_DELIMITER)
456 if grating.upper().startswith('EMPTY'):
457 return False
458 return True
461def isDispersedDataId(dataId, butler):
462 """Check if a dataId corresponds to a dispersed image."""
463 if isinstance(butler, dafButler.Butler):
464 assert 'day_obs' in dataId or 'exposure.day_obs' in dataId, f'failed to find day_obs in {dataId}'
465 assert 'seq_num' in dataId or 'exposure.seq_num' in dataId, f'failed to find seq_num in {dataId}'
466 seq_num = dataId['seq_num'] if 'seq_num' in dataId else dataId['exposure.seq_num']
467 day_obs = dataId['day_obs'] if 'day_obs' in dataId else dataId['exposure.day_obs']
468 where = "exposure.day_obs=day_obs AND exposure.seq_num=seq_num"
469 expRecords = butler.registry.queryDimensionRecords("exposure", where=where,
470 bind={'day_obs': day_obs,
471 'seq_num': seq_num})
472 expRecords = set(expRecords)
473 assert len(expRecords) == 1, f'Found more than one exposure record for {dataId}'
474 filterFullName = expRecords.pop().physical_filter
476 elif isinstance(butler, dafPersist.Butler):
477 filterFullName = butler.queryMetadata('raw', 'filter', **dataId)[0]
478 else:
479 raise RuntimeError(f'Expected a butler, got {type(butler)}')
480 if FILTER_DELIMITER not in filterFullName:
481 raise RuntimeError(f"Error parsing filter name {filterFullName}")
482 filt, grating = filterFullName.split(FILTER_DELIMITER)
483 if grating.upper().startswith('EMPTY'):
484 return False
485 return True
488def getLinearStagePosition(exp):
489 md = exp.getMetadata()
490 linearStagePosition = 115 # this seems to be the rough zero-point for some reason
491 if 'LINSPOS' in md:
492 position = md['LINSPOS'] # linear stage position in mm from CCD, larger->further from CCD
493 if position is not None:
494 linearStagePosition += position
495 return linearStagePosition