Coverage for python/lsst/summit/utils/utils.py: 17%

307 statements  

« prev     ^ index     » next       coverage.py v7.2.4, created at 2023-04-29 04:49 -0700

1# This file is part of summit_utils. 

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 os 

23import numpy as np 

24import logging 

25from scipy.ndimage import gaussian_filter 

26import lsst.afw.image as afwImage 

27import lsst.afw.detection as afwDetect 

28import lsst.afw.math as afwMath 

29import lsst.daf.base as dafBase 

30import lsst.geom as geom 

31import lsst.pipe.base as pipeBase 

32import lsst.utils.packages as packageUtils 

33from lsst.daf.butler.cli.cliLog import CliLog 

34import datetime 

35from dateutil.tz import gettz 

36 

37from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER 

38from lsst.obs.lsst.translators.latiss import AUXTEL_LOCATION 

39 

40from astro_metadata_translator import ObservationInfo 

41from astropy.coordinates import SkyCoord, AltAz 

42from astropy.coordinates.earth import EarthLocation 

43import astropy.units as u 

44from astropy.time import Time 

45 

46from .astrometry.utils import genericCameraHeaderToWcs 

47 

48__all__ = ["SIGMATOFWHM", 

49 "FWHMTOSIGMA", 

50 "EFD_CLIENT_MISSING_MSG", 

51 "GOOGLE_CLOUD_MISSING_MSG", 

52 "AUXTEL_LOCATION", 

53 "countPixels", 

54 "quickSmooth", 

55 "argMax2d", 

56 "getImageStats", 

57 "detectObjectsInExp", 

58 "humanNameForCelestialObject", 

59 "getFocusFromHeader", 

60 "dayObsIntToString", 

61 "dayObsSeqNumToVisitId", 

62 "setupLogging", 

63 "getCurrentDayObs_datetime", 

64 "getCurrentDayObs_int", 

65 "getCurrentDayObs_humanStr", 

66 "getSite", 

67 "getExpPositionOffset", 

68 "starTrackerFileToExposure", 

69 "getAirmassSeeingCorrection", 

70 "getFilterSeeingCorrection", 

71 "getCdf", 

72 "getQuantiles", 

73 "digitizeData", 

74 ] 

75 

76 

77SIGMATOFWHM = 2.0*np.sqrt(2.0*np.log(2.0)) 

78FWHMTOSIGMA = 1/SIGMATOFWHM 

79 

80EFD_CLIENT_MISSING_MSG = ('ImportError: lsst_efd_client not found. Please install with:\n' 

81 ' pip install lsst-efd-client') 

82 

83GOOGLE_CLOUD_MISSING_MSG = ('ImportError: Google cloud storage not found. Please install with:\n' 

84 ' pip install google-cloud-storage') 

85 

86 

87def countPixels(maskedImage, maskPlane): 

88 """Count the number of pixels in an image with a given mask bit set. 

89 

90 Parameters 

91 ---------- 

92 maskedImage : `lsst.afw.image.MaskedImage` 

93 The masked image, 

94 maskPlane : `str` 

95 The name of the bitmask. 

96 

97 Returns 

98 ------- 

99 count : `int`` 

100 The number of pixels in with the selected mask bit 

101 """ 

102 bit = maskedImage.mask.getPlaneBitMask(maskPlane) 

103 return len(np.where(np.bitwise_and(maskedImage.mask.array, bit))[0]) 

104 

105 

106def quickSmooth(data, sigma=2): 

107 """Perform a quick smoothing of the image. 

108 

109 Not to be used for scientific purposes, but improves the stretch and 

110 visual rendering of low SNR against the sky background in cutouts. 

111 

112 Parameters 

113 ---------- 

114 data : `np.array` 

115 The image data to smooth 

116 sigma : `float`, optional 

117 The size of the smoothing kernel. 

118 

119 Returns 

120 ------- 

121 smoothData : `np.array` 

122 The smoothed data 

123 """ 

124 kernel = [sigma, sigma] 

125 smoothData = gaussian_filter(data, kernel, mode='constant') 

126 return smoothData 

127 

128 

129def argMax2d(array): 

130 """Get the index of the max value of an array and whether it's unique. 

131 

132 If its not unique, returns a list of the other locations containing the 

133 maximum value, e.g. returns 

134 

135 (12, 34), False, [(56,78), (910, 1112)] 

136 

137 Parameters 

138 ---------- 

139 array : `np.array` 

140 The data 

141 

142 Returns 

143 ------- 

144 maxLocation : `tuple` 

145 The coords of the first instance of the max value 

146 unique : `bool` 

147 Whether it's the only location 

148 otherLocations : `list` of `tuple` 

149 List of the other max values' locations, empty if False 

150 """ 

151 uniqueMaximum = False 

152 maxCoords = np.where(array == np.max(array)) 

153 maxCoords = [coord for coord in zip(*maxCoords)] # list of coords as tuples 

154 if len(maxCoords) == 1: # single unambiguous value 

155 uniqueMaximum = True 

156 

157 return maxCoords[0], uniqueMaximum, maxCoords[1:] 

158 

159 

160def dayObsIntToString(dayObs): 

161 """Convert an integer dayObs to a dash-delimited string. 

162 

163 e.g. convert the hard to read 20210101 to 2021-01-01 

164 

165 Parameters 

166 ---------- 

167 dayObs : `int` 

168 The dayObs. 

169 

170 Returns 

171 ------- 

172 dayObs : `str` 

173 The dayObs as a string. 

174 """ 

175 assert isinstance(dayObs, int) 

176 dStr = str(dayObs) 

177 assert len(dStr) == 8 

178 return '-'.join([dStr[0:4], dStr[4:6], dStr[6:8]]) 

179 

180 

181def dayObsSeqNumToVisitId(dayObs, seqNum): 

182 """Get the visit id for a given dayObs/seqNum. 

183 

184 Parameters 

185 ---------- 

186 dayObs : `int` 

187 The dayObs. 

188 seqNum : `int` 

189 The seqNum. 

190 

191 Returns 

192 ------- 

193 visitId : `int` 

194 The visitId. 

195 

196 Notes 

197 ----- 

198 TODO: Remove this horrible hack once DM-30948 makes this possible 

199 programatically/via the butler. 

200 """ 

201 if dayObs < 19700101 or dayObs > 35000101: 

202 raise ValueError(f'dayObs value {dayObs} outside plausible range') 

203 return int(f"{dayObs}{seqNum:05}") 

204 

205 

206def getImageStats(exp): 

207 """Calculate a grab-bag of stats for an image. Must remain fast. 

208 

209 Parameters 

210 ---------- 

211 exp : `lsst.afw.image.Exposure` 

212 The input exposure. 

213 

214 Returns 

215 ------- 

216 stats : `lsst.pipe.base.Struct` 

217 A container with attributes containing measurements and statistics 

218 for the image. 

219 """ 

220 result = pipeBase.Struct() 

221 

222 vi = exp.visitInfo 

223 expTime = vi.exposureTime 

224 md = exp.getMetadata() 

225 

226 obj = vi.object 

227 mjd = vi.getDate().get() 

228 result.object = obj 

229 result.mjd = mjd 

230 

231 fullFilterString = exp.filter.physicalLabel 

232 filt = fullFilterString.split(FILTER_DELIMITER)[0] 

233 grating = fullFilterString.split(FILTER_DELIMITER)[1] 

234 

235 airmass = vi.getBoresightAirmass() 

236 rotangle = vi.getBoresightRotAngle().asDegrees() 

237 

238 azAlt = vi.getBoresightAzAlt() 

239 az = azAlt[0].asDegrees() 

240 el = azAlt[1].asDegrees() 

241 

242 result.expTime = expTime 

243 result.filter = filt 

244 result.grating = grating 

245 result.airmass = airmass 

246 result.rotangle = rotangle 

247 result.az = az 

248 result.el = el 

249 result.focus = md.get('FOCUSZ') 

250 

251 data = exp.image.array 

252 result.maxValue = np.max(data) 

253 

254 peak, uniquePeak, otherPeaks = argMax2d(data) 

255 result.maxPixelLocation = peak 

256 result.multipleMaxPixels = uniquePeak 

257 

258 result.nBadPixels = countPixels(exp.maskedImage, 'BAD') 

259 result.nSatPixels = countPixels(exp.maskedImage, 'SAT') 

260 result.percentile99 = np.percentile(data, 99) 

261 result.percentile9999 = np.percentile(data, 99.99) 

262 

263 sctrl = afwMath.StatisticsControl() 

264 sctrl.setNumSigmaClip(5) 

265 sctrl.setNumIter(2) 

266 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP 

267 stats = afwMath.makeStatistics(exp.maskedImage, statTypes, sctrl) 

268 std, stderr = stats.getResult(afwMath.STDEVCLIP) 

269 mean, meanerr = stats.getResult(afwMath.MEANCLIP) 

270 

271 result.clippedMean = mean 

272 result.clippedStddev = std 

273 

274 return result 

275 

276 

277def detectObjectsInExp(exp, nSigma=10, nPixMin=10, grow=0): 

278 """Quick and dirty object detection for an expsure. 

279 

280 Return the footPrintSet for the objects in a preferably-postISR exposure. 

281 

282 Parameters 

283 ---------- 

284 exp : `lsst.afw.image.Exposure` 

285 The exposure to detect objects in. 

286 nSigma : `float` 

287 The number of sigma for detection. 

288 nPixMin : `int` 

289 The minimum number of pixels in an object for detection. 

290 grow : `int` 

291 The number of pixels to grow the footprint by after detection. 

292 

293 Returns 

294 ------- 

295 footPrintSet : `lsst.afw.detection.FootprintSet` 

296 The set of footprints in the image. 

297 """ 

298 median = np.nanmedian(exp.image.array) 

299 exp.image -= median 

300 

301 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV) 

302 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin) 

303 if grow > 0: 

304 isotropic = True 

305 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic) 

306 

307 exp.image += median # add back in to leave background unchanged 

308 return footPrintSet 

309 

310 

311def humanNameForCelestialObject(objName): 

312 """Returns a list of all human names for obj, or [] if none are found. 

313 

314 Parameters 

315 ---------- 

316 objName : `str` 

317 The/a name of the object. 

318 

319 Returns 

320 ------- 

321 names : `list` of `str` 

322 The names found for the object 

323 """ 

324 from astroquery.simbad import Simbad 

325 results = [] 

326 try: 

327 simbadResult = Simbad.query_objectids(objName) 

328 for row in simbadResult: 

329 if row['ID'].startswith('NAME'): 

330 results.append(row['ID'].replace('NAME ', '')) 

331 return results 

332 except Exception: 

333 return [] # same behavior as for found but un-named objects 

334 

335 

336def _getAltAzZenithsFromSeqNum(butler, dayObs, seqNumList): 

337 """Get the alt, az and zenith angle for the seqNums of a given dayObs. 

338 

339 Parameters 

340 ---------- 

341 butler : `lsst.daf.butler.Butler` 

342 The butler to query. 

343 dayObs : `int` 

344 The dayObs. 

345 seqNumList : `list` of `int` 

346 The seqNums for which to return the alt, az and zenith 

347 

348 Returns 

349 ------- 

350 azimuths : `list` of `float` 

351 List of the azimuths for each seqNum 

352 elevations : `list` of `float` 

353 List of the elevations for each seqNum 

354 zeniths : `list` of `float` 

355 List of the zenith angles for each seqNum 

356 """ 

357 azimuths, elevations, zeniths = [], [], [] 

358 for seqNum in seqNumList: 

359 md = butler.get('raw.metadata', day_obs=dayObs, seq_num=seqNum, detector=0) 

360 obsInfo = ObservationInfo(md) 

361 alt = obsInfo.altaz_begin.alt.value 

362 az = obsInfo.altaz_begin.az.value 

363 elevations.append(alt) 

364 zeniths.append(90-alt) 

365 azimuths.append(az) 

366 return azimuths, elevations, zeniths 

367 

368 

369def getFocusFromHeader(exp): 

370 """Get the raw focus value from the header. 

371 

372 Parameters 

373 ---------- 

374 exp : `lsst.afw.image.exposure` 

375 The exposure. 

376 

377 Returns 

378 ------- 

379 focus : `float` or `None` 

380 The focus value if found, else ``None``. 

381 """ 

382 md = exp.getMetadata() 

383 if 'FOCUSZ' in md: 

384 return md['FOCUSZ'] 

385 return None 

386 

387 

388def checkStackSetup(): 

389 """Check which weekly tag is being used and which local packages are setup. 

390 

391 Designed primarily for use in notbooks/observing, this prints the weekly 

392 tag(s) are setup for lsst_distrib, and lists any locally setup packages and 

393 the path to each. 

394 

395 Notes 

396 ----- 

397 Uses print() instead of logger messages as this should simply print them 

398 without being vulnerable to any log messages potentially being diverted. 

399 """ 

400 packages = packageUtils.getEnvironmentPackages(include_all=True) 

401 

402 lsstDistribHashAndTags = packages['lsst_distrib'] # looks something like 'g4eae7cb9+1418867f (w_2022_13)' 

403 lsstDistribTags = lsstDistribHashAndTags.split()[1] 

404 if len(lsstDistribTags.split()) == 1: 

405 tag = lsstDistribTags.replace('(', '') 

406 tag = tag.replace(')', '') 

407 print(f"You are running {tag} of lsst_distrib") 

408 else: # multiple weekly tags found for lsst_distrib! 

409 print(f'The version of lsst_distrib you have is compatible with: {lsstDistribTags}') 

410 

411 localPackages = [] 

412 localPaths = [] 

413 for package, tags in packages.items(): 

414 if tags.startswith('LOCAL:'): 

415 path = tags.split('LOCAL:')[1] 

416 path = path.split('@')[0] # don't need the git SHA etc 

417 localPaths.append(path) 

418 localPackages.append(package) 

419 

420 if localPackages: 

421 print("\nLocally setup packages:") 

422 print("-----------------------") 

423 maxLen = max(len(package) for package in localPackages) 

424 for package, path in zip(localPackages, localPaths): 

425 print(f"{package:<{maxLen}s} at {path}") 

426 else: 

427 print("\nNo locally setup packages (using a vanilla stack)") 

428 

429 

430def setupLogging(longlog=False): 

431 """Setup logging in the same way as one would get from pipetask run. 

432 

433 Code that isn't run through the butler CLI defaults to WARNING level 

434 messages and no logger names. This sets the behaviour to follow whatever 

435 the pipeline default is, currently 

436 <logger_name> <level>: <message> e.g. 

437 lsst.isr INFO: Masking defects. 

438 """ 

439 CliLog.initLog(longlog=longlog) 

440 

441 

442def getCurrentDayObs_datetime(): 

443 """Get the current day_obs - the observatory rolls the date over at UTC-12 

444 

445 Returned as datetime.date(2022, 4, 28) 

446 """ 

447 utc = gettz("UTC") 

448 nowUtc = datetime.datetime.now().astimezone(utc) 

449 offset = datetime.timedelta(hours=-12) 

450 dayObs = (nowUtc + offset).date() 

451 return dayObs 

452 

453 

454def getCurrentDayObs_int(): 

455 """Return the current dayObs as an int in the form 20220428 

456 """ 

457 return int(getCurrentDayObs_datetime().strftime("%Y%m%d")) 

458 

459 

460def getCurrentDayObs_humanStr(): 

461 """Return the current dayObs as a string in the form '2022-04-28' 

462 """ 

463 return dayObsIntToString(getCurrentDayObs_int()) 

464 

465 

466def getSite(): 

467 """Returns where the code is running. 

468 

469 Returns 

470 ------- 

471 location : `str` 

472 One of ['tucson', 'summit', 'base', 'staff-rsp', 'rubin-devl'] 

473 

474 Raises 

475 ------ 

476 ValueError 

477 Raised if location cannot be determined. 

478 """ 

479 # All nublado instances guarantee that EXTERNAL_URL is set and uniquely 

480 # identifies it. 

481 location = os.getenv('EXTERNAL_INSTANCE_URL', "") 

482 if location == "https://tucson-teststand.lsst.codes": 482 ↛ 483line 482 didn't jump to line 483, because the condition on line 482 was never true

483 return 'tucson' 

484 elif location == "https://summit-lsp.lsst.codes": 484 ↛ 485line 484 didn't jump to line 485, because the condition on line 484 was never true

485 return 'summit' 

486 elif location == "https://base-lsp.lsst.codes": 486 ↛ 487line 486 didn't jump to line 487, because the condition on line 486 was never true

487 return 'base' 

488 elif location == "https://usdf-rsp.slac.stanford.edu": 488 ↛ 489line 488 didn't jump to line 489, because the condition on line 488 was never true

489 return 'staff-rsp' 

490 

491 # if no EXTERNAL_URL, try HOSTNAME to see if we're on the dev nodes 

492 # it is expected that this will be extensible to SLAC 

493 hostname = os.getenv('HOSTNAME', "") 

494 if hostname.startswith('sdfrome'): 494 ↛ 495line 494 didn't jump to line 495, because the condition on line 494 was never true

495 return 'rubin-devl' 

496 

497 # we have failed 

498 raise ValueError('Location could not be determined') 

499 

500 

501def getAltAzFromSkyPosition(skyPos, visitInfo, doCorrectRefraction=False, 

502 wavelength=500.0, 

503 pressureOverride=None, 

504 temperatureOverride=None, 

505 relativeHumidityOverride=None, 

506 ): 

507 """Get the alt/az from the position on the sky and the time and location 

508 of the observation. 

509 

510 The temperature, pressure and relative humidity are taken from the 

511 visitInfo by default, but can be individually overridden as needed. It 

512 should be noted that the visitInfo never contains a nominal wavelength, and 

513 so this takes a default value of 500nm. 

514 

515 Parameters 

516 ---------- 

517 skyPos : `lsst.geom.SpherePoint` 

518 The position on the sky. 

519 visitInfo : `lsst.afw.image.VisitInfo` 

520 The visit info containing the time of the observation. 

521 doCorrectRefraction : `bool`, optional 

522 Correct for the atmospheric refraction? 

523 wavelength : `float`, optional 

524 The nominal wavelength in nanometers (e.g. 500.0), as a float. 

525 pressureOverride : `float`, optional 

526 The pressure, in bars (e.g. 0.770), to override the value supplied in 

527 the visitInfo, as a float. 

528 temperatureOverride : `float`, optional 

529 The temperature, in Celsius (e.g. 10.0), to override the value supplied 

530 in the visitInfo, as a float. 

531 relativeHumidityOverride : `float`, optional 

532 The relativeHumidity in the range 0..1 (i.e. not as a percentage), to 

533 override the value supplied in the visitInfo, as a float. 

534 

535 Returns 

536 ------- 

537 alt : `lsst.geom.Angle` 

538 The altitude. 

539 az : `lsst.geom.Angle` 

540 The azimuth. 

541 """ 

542 skyLocation = SkyCoord(skyPos.getRa().asRadians(), skyPos.getDec().asRadians(), unit=u.rad) 

543 long = visitInfo.observatory.getLongitude() 

544 lat = visitInfo.observatory.getLatitude() 

545 ele = visitInfo.observatory.getElevation() 

546 earthLocation = EarthLocation.from_geodetic(long.asDegrees(), lat.asDegrees(), ele) 

547 

548 refractionKwargs = {} 

549 if doCorrectRefraction: 

550 # wavelength is never supplied in the visitInfo so always take this 

551 wavelength = wavelength * u.nm 

552 

553 if pressureOverride: 

554 pressure = pressureOverride 

555 else: 

556 pressure = visitInfo.weather.getAirPressure() 

557 # ObservationInfos (which are the "source of truth" use pascals) so 

558 # convert from pascals to bars 

559 pressure /= 100000.0 

560 pressure = pressure*u.bar 

561 

562 if temperatureOverride: 

563 temperature = temperatureOverride 

564 else: 

565 temperature = visitInfo.weather.getAirTemperature() 

566 temperature = temperature*u.deg_C 

567 

568 if relativeHumidityOverride: 

569 relativeHumidity = relativeHumidityOverride 

570 else: 

571 relativeHumidity = visitInfo.weather.getHumidity() / 100.0 # this is in percent 

572 relativeHumidity = relativeHumidity*u.deg_C 

573 

574 refractionKwargs = dict(pressure=pressure, 

575 temperature=temperature, 

576 relative_humidity=relativeHumidity, 

577 obswl=wavelength) 

578 

579 # must go via astropy.Time because dafBase.dateTime.DateTime contains 

580 # the timezone, but going straight to visitInfo.date.toPython() loses this. 

581 obsTime = Time(visitInfo.date.toPython(), scale='tai') 

582 altAz = AltAz(obstime=obsTime, 

583 location=earthLocation, 

584 **refractionKwargs) 

585 

586 obsAltAz = skyLocation.transform_to(altAz) 

587 alt = geom.Angle(obsAltAz.alt.degree, geom.degrees) 

588 az = geom.Angle(obsAltAz.az.degree, geom.degrees) 

589 

590 return alt, az 

591 

592 

593def getExpPositionOffset(exp1, exp2, useWcs=True, allowDifferentPlateScales=False): 

594 """Get the change in sky position between two exposures. 

595 

596 Given two exposures, calculate the offset on the sky between the images. 

597 If useWcs then use the (fitted or unfitted) skyOrigin from their WCSs, and 

598 calculate the alt/az from the observation times, otherwise use the nominal 

599 values in the exposures' visitInfos. Note that if using the visitInfo 

600 values that for a given pointing the ra/dec will be ~identical, regardless 

601 of whether astrometric fitting has been performed. 

602 

603 Values are given as exp1-exp2. 

604 

605 Parameters 

606 ---------- 

607 exp1 : `lsst.afw.image.Exposure` 

608 The first exposure. 

609 exp2 : `lsst.afw.image.Exposure` 

610 The second exposure. 

611 useWcs : `bool` 

612 Use the WCS for the ra/dec and alt/az if True, else use the nominal/ 

613 boresight values from the exposures' visitInfos. 

614 allowDifferentPlateScales : `bool`, optional 

615 Use to disable checking that plate scales are the same. Generally, 

616 differing plate scales would indicate an error, but where blind-solving 

617 has been undertaken during commissioning plate scales can be different 

618 enough to warrant setting this to ``True``. 

619 

620 Returns 

621 ------- 

622 offsets : `lsst.pipe.base.Struct` 

623 A struct containing the offsets: 

624 ``deltaRa`` 

625 The diference in ra (`lsst.geom.Angle`) 

626 ``deltaDec`` 

627 The diference in dec (`lsst.geom.Angle`) 

628 ``deltaAlt`` 

629 The diference in alt (`lsst.geom.Angle`) 

630 ``deltaAz`` 

631 The diference in az (`lsst.geom.Angle`) 

632 ``deltaPixels`` 

633 The diference in pixels (`float`) 

634 """ 

635 

636 wcs1 = exp1.getWcs() 

637 wcs2 = exp2.getWcs() 

638 pixScaleArcSec = wcs1.getPixelScale().asArcseconds() 

639 if not allowDifferentPlateScales: 

640 assert np.isclose(pixScaleArcSec, wcs2.getPixelScale().asArcseconds()), \ 

641 "Pixel scales in the exposures differ." 

642 

643 if useWcs: 

644 p1 = wcs1.getSkyOrigin() 

645 p2 = wcs2.getSkyOrigin() 

646 alt1, az1 = getAltAzFromSkyPosition(p1, exp1.getInfo().getVisitInfo()) 

647 alt2, az2 = getAltAzFromSkyPosition(p2, exp2.getInfo().getVisitInfo()) 

648 ra1 = p1[0] 

649 ra2 = p2[0] 

650 dec1 = p1[1] 

651 dec2 = p2[1] 

652 else: 

653 az1 = exp1.visitInfo.boresightAzAlt[0] 

654 az2 = exp2.visitInfo.boresightAzAlt[0] 

655 alt1 = exp1.visitInfo.boresightAzAlt[1] 

656 alt2 = exp2.visitInfo.boresightAzAlt[1] 

657 

658 ra1 = exp1.visitInfo.boresightRaDec[0] 

659 ra2 = exp2.visitInfo.boresightRaDec[0] 

660 dec1 = exp1.visitInfo.boresightRaDec[1] 

661 dec2 = exp2.visitInfo.boresightRaDec[1] 

662 

663 p1 = exp1.visitInfo.boresightRaDec 

664 p2 = exp2.visitInfo.boresightRaDec 

665 

666 angular_offset = p1.separation(p2).asArcseconds() 

667 deltaPixels = angular_offset / pixScaleArcSec 

668 

669 ret = pipeBase.Struct(deltaRa=(ra1-ra2).wrapNear(geom.Angle(0.0)), 

670 deltaDec=dec1-dec2, 

671 deltaAlt=alt1-alt2, 

672 deltaAz=(az1-az2).wrapNear(geom.Angle(0.0)), 

673 deltaPixels=deltaPixels 

674 ) 

675 

676 return ret 

677 

678 

679def starTrackerFileToExposure(filename, logger=None): 

680 """Read the exposure from the file and set the wcs from the header. 

681 

682 Parameters 

683 ---------- 

684 filename : `str` 

685 The full path to the file. 

686 logger : `logging.Logger`, optional 

687 The logger to use for errors, created if not supplied. 

688 

689 Returns 

690 ------- 

691 exp : `lsst.afw.image.Exposure` 

692 The exposure. 

693 """ 

694 if not logger: 

695 logger = logging.getLogger(__name__) 

696 exp = afwImage.ExposureF(filename) 

697 try: 

698 wcs = genericCameraHeaderToWcs(exp) 

699 exp.setWcs(wcs) 

700 except Exception as e: 

701 logger.warning(f"Failed to set wcs from header: {e}") 

702 

703 # for some reason the date isn't being set correctly 

704 # DATE-OBS is present in the original header, but it's being 

705 # stripped out and somehow not set (plus it doesn't give the midpoint 

706 # of the exposure), so set it manually from the midpoint here 

707 try: 

708 md = exp.getMetadata() 

709 begin = datetime.datetime.fromisoformat(md['DATE-BEG']) 

710 end = datetime.datetime.fromisoformat(md['DATE-END']) 

711 duration = end - begin 

712 mid = begin + duration/2 

713 newTime = dafBase.DateTime(mid.isoformat(), dafBase.DateTime.Timescale.TAI) 

714 newVi = exp.visitInfo.copyWith(date=newTime) 

715 exp.info.setVisitInfo(newVi) 

716 except Exception as e: 

717 logger.warning(f"Failed to set date from header: {e}") 

718 

719 return exp 

720 

721 

722def obsInfoToDict(obsInfo): 

723 """Convert an ObservationInfo to a dict. 

724 

725 Parameters 

726 ---------- 

727 obsInfo : `astro_metadata_translator.ObservationInfo` 

728 The ObservationInfo to convert. 

729 

730 Returns 

731 ------- 

732 obsInfoDict : `dict` 

733 The ObservationInfo as a dict. 

734 """ 

735 return {prop: getattr(obsInfo, prop) for prop in obsInfo.all_properties.keys()} 

736 

737 

738def getFieldNameAndTileNumber(field, warn=True, logger=None): 

739 """Get the tile name and number of an observed field. 

740 

741 It is assumed to always be appended, with an underscore, to the rest of the 

742 field name. Returns the name and number as a tuple, or the name unchanged 

743 if no tile number is found. 

744 

745 Parameters 

746 ---------- 

747 field : `str` 

748 The name of the field 

749 

750 Returns 

751 ------- 

752 fieldName : `str` 

753 The name of the field without the trailing tile number, if present. 

754 tileNum : `int` 

755 The number of the tile, as an integer, or ``None`` if not found. 

756 """ 

757 if warn and not logger: 

758 logger = logging.getLogger('lsst.summit.utils.utils.getFieldNameAndTileNumber') 

759 

760 if '_' not in field: 

761 if warn: 

762 logger.warning(f"Field {field} does not contain an underscore," 

763 " so cannot determine the tile number.") 

764 return field, None 

765 

766 try: 

767 fieldParts = field.split("_") 

768 fieldNum = int(fieldParts[-1]) 

769 except ValueError: 

770 if warn: 

771 logger.warning(f"Field {field} does not contain only an integer after the final underscore" 

772 " so cannot determine the tile number.") 

773 return field, None 

774 

775 return "_".join(fieldParts[:-1]), fieldNum 

776 

777 

778def getAirmassSeeingCorrection(airmass): 

779 """Get the correction factor for seeing due to airmass. 

780 

781 Parameters 

782 ---------- 

783 airmass : `float` 

784 The airmass, greater than or equal to 1. 

785 

786 Returns 

787 ------- 

788 correctionFactor : `float` 

789 The correction factor to apply to the seeing. 

790 

791 Raises 

792 ------ 

793 ValueError raised for unphysical airmasses. 

794 """ 

795 if airmass < 1: 

796 raise ValueError(f"Invalid airmass: {airmass}") 

797 return airmass**(-0.6) 

798 

799 

800def getFilterSeeingCorrection(filterName): 

801 """Get the correction factor for seeing due to a filter. 

802 

803 Parameters 

804 ---------- 

805 filterName : `str` 

806 The name of the filter, e.g. 'SDSSg_65mm'. 

807 

808 Returns 

809 ------- 

810 correctionFactor : `float` 

811 The correction factor to apply to the seeing. 

812 

813 Raises 

814 ------ 

815 ValueError raised for unknown filters. 

816 """ 

817 match filterName: 

818 case 'SDSSg_65mm': 

819 return (477./500.)**0.2 

820 case 'SDSSr_65mm': 

821 return (623./500.)**0.2 

822 case 'SDSSi_65mm': 

823 return (762./500.)**0.2 

824 case _: 

825 raise ValueError(f"Unknown filter name: {filterName}") 

826 

827 

828def getCdf(data, scale): 

829 """Return an approximate cumulative distribution function scaled to 

830 the [0, scale] range. 

831 

832 Parameters 

833 ---------- 

834 data : `np.array` 

835 The input data. 

836 scale : `int` 

837 The scaling range of the output. 

838 

839 Returns 

840 ------- 

841 cdf : `np.array` of `int` 

842 A monotonically increasing sequence that represents a scaled 

843 cumulative distribution function, starting with the value at 

844 minVal, then at (minVal + 1), and so on. 

845 minVal : `float` 

846 An integer smaller than the minimum value in the input data. 

847 maxVal : `float` 

848 An integer larger than the maximum value in the input data. 

849 """ 

850 flatData = data.ravel() 

851 size = flatData.size - np.count_nonzero(np.isnan(flatData)) 

852 

853 minVal = np.floor(np.nanmin(flatData)) 

854 maxVal = np.ceil(np.nanmax(flatData)) + 1.0 

855 

856 hist, binEdges = np.histogram( 

857 flatData, bins=int(maxVal - minVal), range=(minVal, maxVal) 

858 ) 

859 

860 cdf = (scale*np.cumsum(hist)/size).astype(np.int64) 

861 return cdf, minVal, maxVal 

862 

863 

864def getQuantiles(data, nColors): 

865 """Get a set of boundaries that equally distribute data into 

866 nColors intervals. The output can be used to make a colormap 

867 of nColors colors. 

868 

869 This is equivalent to using the numpy function: 

870 np.quantile(data, np.linspace(0, 1, nColors + 1)) 

871 but with a coarser precision, yet sufficient for our use case. 

872 This implementation gives a speed-up. 

873 

874 Parameters 

875 ---------- 

876 data : `np.array` 

877 The input image data. 

878 nColors : `int` 

879 The number of intervals to distribute data into. 

880 

881 Returns 

882 ------- 

883 boundaries: `list` of `float` 

884 A monotonically increasing sequence of size (nColors + 1). 

885 These are the edges of nColors intervals. 

886 """ 

887 cdf, minVal, maxVal = getCdf(data, nColors) 

888 boundaries = np.asarray( 

889 [np.argmax(cdf >= i) + minVal for i in range(nColors)] + [maxVal] 

890 ) 

891 return boundaries 

892 

893 

894def digitizeData(data, nColors=256): 

895 """ 

896 Scale data into nColors using its cumulative distribution function. 

897 

898 Parameters 

899 ---------- 

900 data : `np.array` 

901 The input image data. 

902 nColors : `int` 

903 The number of intervals to distribute data into. 

904 

905 Returns 

906 ------- 

907 data: `np.array` of `int` 

908 Scaled data in the [0, nColors - 1] range. 

909 """ 

910 cdf, minVal, maxVal = getCdf(data, nColors - 1) 

911 bins = np.floor((data - minVal)).astype(np.int64) 

912 return cdf[bins]