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

200 statements  

« prev     ^ index     » next       coverage.py v7.1.0, created at 2023-02-15 18:59 -0800

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 

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] 

37 

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 

48 

49import astropy 

50import astropy.units as u 

51from astropy.coordinates import SkyCoord, Distance 

52 

53 

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

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

56 

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. 

60 

61 Parameters 

62 ---------- 

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

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

65 

66 gainDict : `dict` of `float` 

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

68 

69 invertGains : `bool` 

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

71 

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 

81 

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 

90 

91 return flat 

92 

93 

94def argMaxNd(array): 

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

96 

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) 

101 

102 

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

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

105 

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. 

109 

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. 

113 

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. 

117 

118 Visually, for a range: 

119 

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

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

122 

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

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

125 """ 

126 

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

139 

140 if integers: 

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

142 return points 

143 

144 

145def isExposureTrimmed(exp): 

146 det = exp.getDetector() 

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

148 return True 

149 return False 

150 

151 

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

153 """XXX doctring here 

154 

155 Trim identically in all direction for convenience""" 

156 if isExposureTrimmed(rawExp): 

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

158 

159 det = rawExp.getDetector() 

160 

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 

168 

169 

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

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

172 

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 

176 

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

178 

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 

181 

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. 

185 

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 

191 

192 Parameters 

193 ---------- 

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

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

196 

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

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

199 

200 correctionType : `str` or `None` 

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

202 

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

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

205 

206 overscanBorderSize : `int` 

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

208 

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) 

216 

217 if correctionType is not None and rawExpForNoiseCalc is None: 

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

219 

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 

227 

228 if correctionType is None: 

229 gains[amp.getName()] = basicGain 

230 continue 

231 

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

233 sigma = getAmpReadNoiseFromRawExp(rawExpForNoiseCalc, ampNum, overscanBorderSize) 

234 

235 if correctionType == 'simple': 

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

237 gains[amp.getName()] = simpleGain 

238 

239 elif correctionType == 'full': 

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

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

242 

243 positiveSolution = (root + mu)/denom 

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

245 

246 gains[amp.getName()] = positiveSolution 

247 

248 return gains 

249 

250 

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

252 """Rotate an exposure by nDegrees clockwise. 

253 

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 

264 

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 

271 

272 if not logger: 

273 logger = logging.getLogger(__name__) 

274 

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 

279 

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) 

286 

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

288 transformP2toP2 = afwGeom.makeTransform(affineRotTransform) 

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

290 

291 rotatedExp = warper.warpExposure(rotatedWcs, exp) 

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

293 return rotatedExp 

294 

295 

296def airMassFromRawMetadata(md): 

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

298 

299 Parameters 

300 ---------- 

301 md : `Mapping` 

302 The raw header. 

303 

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 

317 

318 

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

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

321 

322 Parameters 

323 ---------- 

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

325 Exposure with fitted WCS. 

326 

327 target : `str` 

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

329 

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. 

335 

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

344 

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) 

353 

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 

364 

365 if not targetLocation: 

366 return None 

367 

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) 

371 

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

373 return pixCoord 

374 

375 

376def simbadLocationForTarget(target): 

377 """Get the target location from Simbad. 

378 

379 Parameters 

380 ---------- 

381 target : `str` 

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

383 

384 Returns 

385 ------- 

386 targetLocation : `lsst.geom.SpherePoint` 

387 Nominal location of the target object, uncorrected for 

388 proper motion and parallax. 

389 

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 

398 

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

404 

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 

413 

414 

415def vizierLocationForTarget(exp, target, doMotionCorrection): 

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

417 

418 Parameters 

419 ---------- 

420 target : `str` 

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

422 

423 Returns 

424 ------- 

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

426 Location of the target object, optionally corrected for 

427 proper motion and parallax. 

428 

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 

438 

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 

444 

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

452 

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 

459 

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 

465 

466 

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 

476 

477 

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 

500 

501 

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