Coverage for python/lsst/atmospec/utils.py: 10%

208 statements  

« prev     ^ index     » next       coverage.py v6.4, created at 2022-05-26 12:22 +0000

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/>. 

21 

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 

33 

34import astropy 

35import astropy.units as u 

36from astropy.time import Time 

37from astropy.coordinates import SkyCoord, AltAz, Distance 

38 

39 

40def makeGainFlat(exposure, gainDict, invertGains=False): 

41 """Given an exposure, make a flat from the gains. 

42 

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. 

46 

47 Parameters 

48 ---------- 

49 detectorExposure : `lsst.afw.image.exposure` 

50 The template exposure for which the flat is to be made. 

51 

52 gainDict : `dict` of `float` 

53 A dict of the amplifiers' gains, keyed by the amp names. 

54 

55 invertGains : `bool` 

56 Gains are specified in inverted units and should be applied as such. 

57 

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 

67 

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 

76 

77 return flat 

78 

79 

80def argMaxNd(array): 

81 """Get the index of the max value of an array. 

82 

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) 

87 

88 

89def getSamplePoints(start, stop, nSamples, includeEndpoints=False, integers=False): 

90 """Get the locations of the coordinates to use to sample a range evenly 

91 

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. 

95 

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. 

99 

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. 

103 

104 Visually, for a range: 

105 

106 |--*--|--*--|--*--|--*--| return * if not including end points, n=4 

107 |-*-|-*-|-*-|-*-|-*-|-*-| return * if not including end points, n=6 

108 

109 *-----*-----*-----*-----* return * if we ARE including end points, n=4 

110 *---*---*---*---*---*---* return * if we ARE including end points, n=6 

111 """ 

112 

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)]) 

125 

126 if integers: 

127 return [int(x) for x in np.round(points)] 

128 return points 

129 

130 

131def isExposureTrimmed(exp): 

132 det = exp.getDetector() 

133 if exp.getDimensions() == det.getBBox().getDimensions(): 

134 return True 

135 return False 

136 

137 

138def getAmpReadNoiseFromRawExp(rawExp, ampNum, nOscanBorderPix=0): 

139 """XXX doctring here 

140 

141 Trim identically in all direction for convenience""" 

142 if isExposureTrimmed(rawExp): 

143 raise RuntimeError('Got an assembled exposure instead of a raw one') 

144 

145 det = rawExp.getDetector() 

146 

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 

154 

155 

156def gainFromFlatPair(flat1, flat2, correctionType=None, rawExpForNoiseCalc=None, overscanBorderSize=0): 

157 """Calculate the gain from a pair of flats. 

158 

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 

162 

163 1/g = <(I1 - I2)^2/(I1 + I2)> - 1/mu(sigma^2 - 1/2g^2) 

164 

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 

167 

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. 

171 

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 

177 

178 Parameters 

179 ---------- 

180 flat1 : `lsst.afw.image.exposure` 

181 The first of the postISR assembled, overscan-subtracted flat pairs 

182 

183 flat2 : `lsst.afw.image.exposure` 

184 The second of the postISR assembled, overscan-subtracted flat pairs 

185 

186 correctionType : `str` or `None` 

187 The correction applied, one of [None, 'simple', 'full'] 

188 

189 rawExpForNoiseCalc : `lsst.afw.image.exposure` 

190 A raw (un-assembled) image from which to measure the noise 

191 

192 overscanBorderSize : `int` 

193 The number of pixels to crop from the overscan region in all directions 

194 

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) 

202 

203 if correctionType is not None and rawExpForNoiseCalc is None: 

204 raise RuntimeError("Must supply rawFlat if performing correction") 

205 

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 

213 

214 if correctionType is None: 

215 gains[amp.getName()] = basicGain 

216 continue 

217 

218 mu = (np.mean(i1) + np.mean(i2)) / 2. 

219 sigma = getAmpReadNoiseFromRawExp(rawExpForNoiseCalc, ampNum, overscanBorderSize) 

220 

221 if correctionType == 'simple': 

222 simpleGain = 1/(const - (1/mu)*(sigma**2 - (1/2*basicGain**2))) 

223 gains[amp.getName()] = simpleGain 

224 

225 elif correctionType == 'full': 

226 root = np.sqrt(mu**2 - 2*mu*const + 2*sigma**2) 

227 denom = (2*const*mu - 2*sigma**2) 

228 

229 positiveSolution = (root + mu)/denom 

230 negativeSolution = (mu - root)/denom # noqa: F841 unused, but the other solution 

231 

232 gains[amp.getName()] = positiveSolution 

233 

234 return gains 

235 

236 

237def rotateExposure(exp, nDegrees, kernelName='lanczos4', logger=None): 

238 """Rotate an exposure by nDegrees clockwise. 

239 

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 

250 

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 

257 

258 if not logger: 

259 logger = lsstLog.getLogger('atmospec.utils') 

260 

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 

265 

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) 

272 

273 affineRotTransform = geom.AffineTransform.makeRotation(nDegrees*geom.degrees) 

274 transformP2toP2 = afwGeom.makeTransform(affineRotTransform) 

275 rotatedWcs = afwGeom.makeModifiedWcs(transformP2toP2, wcs, False) 

276 

277 rotatedExp = warper.warpExposure(rotatedWcs, exp) 

278 # rotatedExp.setXY0(geom.Point2I(0, 0)) # TODO: check no longer required 

279 return rotatedExp 

280 

281 

282def airMassFromRawMetadata(md): 

283 """Calculate the visit's airmass from the raw header information. 

284 

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 

299 

300 

301def getTargetCentroidFromWcs(exp, target, doMotionCorrection=True, logger=None): 

302 """Get the target's centroid, given an exposure with fitted WCS. 

303 

304 Parameters 

305 ---------- 

306 exp : `lsst.afw.exposure.Exposure` 

307 Exposure with fitted WCS. 

308 

309 target : `str` 

310 The name of the target, e.g. 'HD 55852' 

311 

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. 

317 

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() 

326 

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") 

335 

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 

347 

348 if not targetLocation: 

349 return None 

350 

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") 

354 

355 pixCoord = exp.getWcs().skyToPixel(targetLocation) 

356 return pixCoord 

357 

358 

359def simbadLocationForTarget(target): 

360 """Get the target location from Simbad. 

361 

362 Parameters 

363 ---------- 

364 target : `str` 

365 The name of the target, e.g. 'HD 55852' 

366 

367 Returns 

368 ------- 

369 targetLocation : `lsst.geom.SpherePoint` 

370 Nominal location of the target object, uncorrected for 

371 proper motion and parallax. 

372 

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 

381 

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}!") 

387 

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 

396 

397 

398def vizierLocationForTarget(exp, target, doMotionCorrection): 

399 """Get the target location from Vizier optionally correction motion. 

400 

401 Parameters 

402 ---------- 

403 target : `str` 

404 The name of the target, e.g. 'HD 55852' 

405 

406 Returns 

407 ------- 

408 targetLocation : `lsst.geom.SpherePoint` or `None` 

409 Location of the target object, optionally corrected for 

410 proper motion and parallax. 

411 

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 

421 

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 

427 

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))) 

435 

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 

442 

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 

448 

449 

450def isDispersedExp(exp): 

451 """Check if an exposure is dispersed.""" 

452 filterFullName = exp.getFilterLabel().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 

459 

460 

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 

475 

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 

486 

487 

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