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

291 statements  

« prev     ^ index     » next       coverage.py v7.2.3, created at 2023-04-21 03:00 -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 ] 

72 

73 

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

75FWHMTOSIGMA = 1/SIGMATOFWHM 

76 

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

78 ' pip install lsst-efd-client') 

79 

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

81 ' pip install google-cloud-storage') 

82 

83 

84def countPixels(maskedImage, maskPlane): 

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

86 

87 Parameters 

88 ---------- 

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

90 The masked image, 

91 maskPlane : `str` 

92 The name of the bitmask. 

93 

94 Returns 

95 ------- 

96 count : `int`` 

97 The number of pixels in with the selected mask bit 

98 """ 

99 bit = maskedImage.mask.getPlaneBitMask(maskPlane) 

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

101 

102 

103def quickSmooth(data, sigma=2): 

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

105 

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

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

108 

109 Parameters 

110 ---------- 

111 data : `np.array` 

112 The image data to smooth 

113 sigma : `float`, optional 

114 The size of the smoothing kernel. 

115 

116 Returns 

117 ------- 

118 smoothData : `np.array` 

119 The smoothed data 

120 """ 

121 kernel = [sigma, sigma] 

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

123 return smoothData 

124 

125 

126def argMax2d(array): 

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

128 

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

130 maximum value, e.g. returns 

131 

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

133 

134 Parameters 

135 ---------- 

136 array : `np.array` 

137 The data 

138 

139 Returns 

140 ------- 

141 maxLocation : `tuple` 

142 The coords of the first instance of the max value 

143 unique : `bool` 

144 Whether it's the only location 

145 otherLocations : `list` of `tuple` 

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

147 """ 

148 uniqueMaximum = False 

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

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

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

152 uniqueMaximum = True 

153 

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

155 

156 

157def dayObsIntToString(dayObs): 

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

159 

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

161 

162 Parameters 

163 ---------- 

164 dayObs : `int` 

165 The dayObs. 

166 

167 Returns 

168 ------- 

169 dayObs : `str` 

170 The dayObs as a string. 

171 """ 

172 assert isinstance(dayObs, int) 

173 dStr = str(dayObs) 

174 assert len(dStr) == 8 

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

176 

177 

178def dayObsSeqNumToVisitId(dayObs, seqNum): 

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

180 

181 Parameters 

182 ---------- 

183 dayObs : `int` 

184 The dayObs. 

185 seqNum : `int` 

186 The seqNum. 

187 

188 Returns 

189 ------- 

190 visitId : `int` 

191 The visitId. 

192 

193 Notes 

194 ----- 

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

196 programatically/via the butler. 

197 """ 

198 if dayObs < 19700101 or dayObs > 35000101: 

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

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

201 

202 

203def getImageStats(exp): 

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

205 

206 Parameters 

207 ---------- 

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

209 The input exposure. 

210 

211 Returns 

212 ------- 

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

214 A container with attributes containing measurements and statistics 

215 for the image. 

216 """ 

217 result = pipeBase.Struct() 

218 

219 vi = exp.visitInfo 

220 expTime = vi.exposureTime 

221 md = exp.getMetadata() 

222 

223 obj = vi.object 

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

225 result.object = obj 

226 result.mjd = mjd 

227 

228 fullFilterString = exp.filter.physicalLabel 

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

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

231 

232 airmass = vi.getBoresightAirmass() 

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

234 

235 azAlt = vi.getBoresightAzAlt() 

236 az = azAlt[0].asDegrees() 

237 el = azAlt[1].asDegrees() 

238 

239 result.expTime = expTime 

240 result.filter = filt 

241 result.grating = grating 

242 result.airmass = airmass 

243 result.rotangle = rotangle 

244 result.az = az 

245 result.el = el 

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

247 

248 data = exp.image.array 

249 result.maxValue = np.max(data) 

250 

251 peak, uniquePeak, otherPeaks = argMax2d(data) 

252 result.maxPixelLocation = peak 

253 result.multipleMaxPixels = uniquePeak 

254 

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

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

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

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

259 

260 sctrl = afwMath.StatisticsControl() 

261 sctrl.setNumSigmaClip(5) 

262 sctrl.setNumIter(2) 

263 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP 

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

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

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

267 

268 result.clippedMean = mean 

269 result.clippedStddev = std 

270 

271 return result 

272 

273 

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

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

276 

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

278 

279 Parameters 

280 ---------- 

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

282 The exposure to detect objects in. 

283 nSigma : `float` 

284 The number of sigma for detection. 

285 nPixMin : `int` 

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

287 grow : `int` 

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

289 

290 Returns 

291 ------- 

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

293 The set of footprints in the image. 

294 """ 

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

296 exp.image -= median 

297 

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

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

300 if grow > 0: 

301 isotropic = True 

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

303 

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

305 return footPrintSet 

306 

307 

308def humanNameForCelestialObject(objName): 

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

310 

311 Parameters 

312 ---------- 

313 objName : `str` 

314 The/a name of the object. 

315 

316 Returns 

317 ------- 

318 names : `list` of `str` 

319 The names found for the object 

320 """ 

321 from astroquery.simbad import Simbad 

322 results = [] 

323 try: 

324 simbadResult = Simbad.query_objectids(objName) 

325 for row in simbadResult: 

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

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

328 return results 

329 except Exception: 

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

331 

332 

333def _getAltAzZenithsFromSeqNum(butler, dayObs, seqNumList): 

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

335 

336 Parameters 

337 ---------- 

338 butler : `lsst.daf.butler.Butler` 

339 The butler to query. 

340 dayObs : `int` 

341 The dayObs. 

342 seqNumList : `list` of `int` 

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

344 

345 Returns 

346 ------- 

347 azimuths : `list` of `float` 

348 List of the azimuths for each seqNum 

349 elevations : `list` of `float` 

350 List of the elevations for each seqNum 

351 zeniths : `list` of `float` 

352 List of the zenith angles for each seqNum 

353 """ 

354 azimuths, elevations, zeniths = [], [], [] 

355 for seqNum in seqNumList: 

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

357 obsInfo = ObservationInfo(md) 

358 alt = obsInfo.altaz_begin.alt.value 

359 az = obsInfo.altaz_begin.az.value 

360 elevations.append(alt) 

361 zeniths.append(90-alt) 

362 azimuths.append(az) 

363 return azimuths, elevations, zeniths 

364 

365 

366def getFocusFromHeader(exp): 

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

368 

369 Parameters 

370 ---------- 

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

372 The exposure. 

373 

374 Returns 

375 ------- 

376 focus : `float` or `None` 

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

378 """ 

379 md = exp.getMetadata() 

380 if 'FOCUSZ' in md: 

381 return md['FOCUSZ'] 

382 return None 

383 

384 

385def checkStackSetup(): 

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

387 

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

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

390 the path to each. 

391 

392 Notes 

393 ----- 

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

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

396 """ 

397 packages = packageUtils.getEnvironmentPackages(include_all=True) 

398 

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

400 lsstDistribTags = lsstDistribHashAndTags.split()[1] 

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

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

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

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

405 else: # multiple weekly tags found for lsst_distrib! 

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

407 

408 localPackages = [] 

409 localPaths = [] 

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

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

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

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

414 localPaths.append(path) 

415 localPackages.append(package) 

416 

417 if localPackages: 

418 print("\nLocally setup packages:") 

419 print("-----------------------") 

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

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

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

423 else: 

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

425 

426 

427def setupLogging(longlog=False): 

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

429 

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

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

432 the pipeline default is, currently 

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

434 lsst.isr INFO: Masking defects. 

435 """ 

436 CliLog.initLog(longlog=longlog) 

437 

438 

439def getCurrentDayObs_datetime(): 

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

441 

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

443 """ 

444 utc = gettz("UTC") 

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

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

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

448 return dayObs 

449 

450 

451def getCurrentDayObs_int(): 

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

453 """ 

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

455 

456 

457def getCurrentDayObs_humanStr(): 

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

459 """ 

460 return dayObsIntToString(getCurrentDayObs_int()) 

461 

462 

463def getSite(): 

464 """Returns where the code is running. 

465 

466 Returns 

467 ------- 

468 location : `str` 

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

470 

471 Raises 

472 ------ 

473 ValueError 

474 Raised if location cannot be determined. 

475 """ 

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

477 # identifies it. 

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

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

480 return 'tucson' 

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

482 return 'summit' 

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

484 return 'base' 

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

486 return 'staff-rsp' 

487 

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

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

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

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

492 return 'rubin-devl' 

493 

494 # we have failed 

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

496 

497 

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

499 wavelength=500.0, 

500 pressureOverride=None, 

501 temperatureOverride=None, 

502 relativeHumidityOverride=None, 

503 ): 

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

505 of the observation. 

506 

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

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

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

510 so this takes a default value of 500nm. 

511 

512 Parameters 

513 ---------- 

514 skyPos : `lsst.geom.SpherePoint` 

515 The position on the sky. 

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

517 The visit info containing the time of the observation. 

518 doCorrectRefraction : `bool`, optional 

519 Correct for the atmospheric refraction? 

520 wavelength : `float`, optional 

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

522 pressureOverride : `float`, optional 

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

524 the visitInfo, as a float. 

525 temperatureOverride : `float`, optional 

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

527 in the visitInfo, as a float. 

528 relativeHumidityOverride : `float`, optional 

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

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

531 

532 Returns 

533 ------- 

534 alt : `lsst.geom.Angle` 

535 The altitude. 

536 az : `lsst.geom.Angle` 

537 The azimuth. 

538 """ 

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

540 long = visitInfo.observatory.getLongitude() 

541 lat = visitInfo.observatory.getLatitude() 

542 ele = visitInfo.observatory.getElevation() 

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

544 

545 refractionKwargs = {} 

546 if doCorrectRefraction: 

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

548 wavelength = wavelength * u.nm 

549 

550 if pressureOverride: 

551 pressure = pressureOverride 

552 else: 

553 pressure = visitInfo.weather.getAirPressure() 

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

555 # convert from pascals to bars 

556 pressure /= 100000.0 

557 pressure = pressure*u.bar 

558 

559 if temperatureOverride: 

560 temperature = temperatureOverride 

561 else: 

562 temperature = visitInfo.weather.getAirTemperature() 

563 temperature = temperature*u.deg_C 

564 

565 if relativeHumidityOverride: 

566 relativeHumidity = relativeHumidityOverride 

567 else: 

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

569 relativeHumidity = relativeHumidity*u.deg_C 

570 

571 refractionKwargs = dict(pressure=pressure, 

572 temperature=temperature, 

573 relative_humidity=relativeHumidity, 

574 obswl=wavelength) 

575 

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

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

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

579 altAz = AltAz(obstime=obsTime, 

580 location=earthLocation, 

581 **refractionKwargs) 

582 

583 obsAltAz = skyLocation.transform_to(altAz) 

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

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

586 

587 return alt, az 

588 

589 

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

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

592 

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

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

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

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

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

598 of whether astrometric fitting has been performed. 

599 

600 Values are given as exp1-exp2. 

601 

602 Parameters 

603 ---------- 

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

605 The first exposure. 

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

607 The second exposure. 

608 useWcs : `bool` 

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

610 boresight values from the exposures' visitInfos. 

611 allowDifferentPlateScales : `bool`, optional 

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

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

614 has been undertaken during commissioning plate scales can be different 

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

616 

617 Returns 

618 ------- 

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

620 A struct containing the offsets: 

621 ``deltaRa`` 

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

623 ``deltaDec`` 

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

625 ``deltaAlt`` 

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

627 ``deltaAz`` 

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

629 ``deltaPixels`` 

630 The diference in pixels (`float`) 

631 """ 

632 

633 wcs1 = exp1.getWcs() 

634 wcs2 = exp2.getWcs() 

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

636 if not allowDifferentPlateScales: 

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

638 "Pixel scales in the exposures differ." 

639 

640 if useWcs: 

641 p1 = wcs1.getSkyOrigin() 

642 p2 = wcs2.getSkyOrigin() 

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

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

645 ra1 = p1[0] 

646 ra2 = p2[0] 

647 dec1 = p1[1] 

648 dec2 = p2[1] 

649 else: 

650 az1 = exp1.visitInfo.boresightAzAlt[0] 

651 az2 = exp2.visitInfo.boresightAzAlt[0] 

652 alt1 = exp1.visitInfo.boresightAzAlt[1] 

653 alt2 = exp2.visitInfo.boresightAzAlt[1] 

654 

655 ra1 = exp1.visitInfo.boresightRaDec[0] 

656 ra2 = exp2.visitInfo.boresightRaDec[0] 

657 dec1 = exp1.visitInfo.boresightRaDec[1] 

658 dec2 = exp2.visitInfo.boresightRaDec[1] 

659 

660 p1 = exp1.visitInfo.boresightRaDec 

661 p2 = exp2.visitInfo.boresightRaDec 

662 

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

664 deltaPixels = angular_offset / pixScaleArcSec 

665 

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

667 deltaDec=dec1-dec2, 

668 deltaAlt=alt1-alt2, 

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

670 deltaPixels=deltaPixels 

671 ) 

672 

673 return ret 

674 

675 

676def starTrackerFileToExposure(filename, logger=None): 

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

678 

679 Parameters 

680 ---------- 

681 filename : `str` 

682 The full path to the file. 

683 logger : `logging.Logger`, optional 

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

685 

686 Returns 

687 ------- 

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

689 The exposure. 

690 """ 

691 if not logger: 

692 logger = logging.getLogger(__name__) 

693 exp = afwImage.ExposureF(filename) 

694 try: 

695 wcs = genericCameraHeaderToWcs(exp) 

696 exp.setWcs(wcs) 

697 except Exception as e: 

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

699 

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

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

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

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

704 try: 

705 md = exp.getMetadata() 

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

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

708 duration = end - begin 

709 mid = begin + duration/2 

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

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

712 exp.info.setVisitInfo(newVi) 

713 except Exception as e: 

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

715 

716 return exp 

717 

718 

719def obsInfoToDict(obsInfo): 

720 """Convert an ObservationInfo to a dict. 

721 

722 Parameters 

723 ---------- 

724 obsInfo : `astro_metadata_translator.ObservationInfo` 

725 The ObservationInfo to convert. 

726 

727 Returns 

728 ------- 

729 obsInfoDict : `dict` 

730 The ObservationInfo as a dict. 

731 """ 

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

733 

734 

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

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

737 

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

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

740 if no tile number is found. 

741 

742 Parameters 

743 ---------- 

744 field : `str` 

745 The name of the field 

746 

747 Returns 

748 ------- 

749 fieldName : `str` 

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

751 tileNum : `int` 

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

753 """ 

754 if warn and not logger: 

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

756 

757 if '_' not in field: 

758 if warn: 

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

760 " so cannot determine the tile number.") 

761 return field, None 

762 

763 try: 

764 fieldParts = field.split("_") 

765 fieldNum = int(fieldParts[-1]) 

766 except ValueError: 

767 if warn: 

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

769 " so cannot determine the tile number.") 

770 return field, None 

771 

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

773 

774 

775def getAirmassSeeingCorrection(airmass): 

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

777 

778 Parameters 

779 ---------- 

780 airmass : `float` 

781 The airmass, greater than or equal to 1. 

782 

783 Returns 

784 ------- 

785 correctionFactor : `float` 

786 The correction factor to apply to the seeing. 

787 

788 Raises 

789 ------ 

790 ValueError raised for unphysical airmasses. 

791 """ 

792 if airmass < 1: 

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

794 return airmass**(-0.6) 

795 

796 

797def getFilterSeeingCorrection(filterName): 

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

799 

800 Parameters 

801 ---------- 

802 filterName : `str` 

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

804 

805 Returns 

806 ------- 

807 correctionFactor : `float` 

808 The correction factor to apply to the seeing. 

809 

810 Raises 

811 ------ 

812 ValueError raised for unknown filters. 

813 """ 

814 match filterName: 

815 case 'SDSSg_65mm': 

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

817 case 'SDSSr_65mm': 

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

819 case 'SDSSi_65mm': 

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

821 case _: 

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