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

274 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-01 11:22 +0000

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

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

500 of the observation. 

501 

502 Parameters 

503 ---------- 

504 skyPos : `lsst.geom.SpherePoint` 

505 The position on the sky. 

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

507 The visit info containing the time of the observation. 

508 

509 Returns 

510 ------- 

511 alt : `lsst.geom.Angle` 

512 The altitude. 

513 az : `lsst.geom.Angle` 

514 The azimuth. 

515 """ 

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

517 long = visitInfo.observatory.getLongitude() 

518 lat = visitInfo.observatory.getLatitude() 

519 ele = visitInfo.observatory.getElevation() 

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

521 

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

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

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

525 altAz = AltAz(obstime=obsTime, location=earthLocation) 

526 

527 obsAltAz = skyLocation.transform_to(altAz) 

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

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

530 

531 return alt, az 

532 

533 

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

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

536 

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

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

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

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

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

542 of whether astrometric fitting has been performed. 

543 

544 Values are given as exp1-exp2. 

545 

546 Parameters 

547 ---------- 

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

549 The first exposure. 

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

551 The second exposure. 

552 useWcs : `bool` 

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

554 boresight values from the exposures' visitInfos. 

555 allowDifferentPlateScales : `bool`, optional 

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

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

558 has been undertaken during commissioning plate scales can be different 

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

560 

561 Returns 

562 ------- 

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

564 A struct containing the offsets: 

565 ``deltaRa`` 

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

567 ``deltaDec`` 

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

569 ``deltaAlt`` 

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

571 ``deltaAz`` 

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

573 ``deltaPixels`` 

574 The diference in pixels (`float`) 

575 """ 

576 

577 wcs1 = exp1.getWcs() 

578 wcs2 = exp2.getWcs() 

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

580 if not allowDifferentPlateScales: 

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

582 "Pixel scales in the exposures differ." 

583 

584 if useWcs: 

585 p1 = wcs1.getSkyOrigin() 

586 p2 = wcs2.getSkyOrigin() 

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

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

589 ra1 = p1[0] 

590 ra2 = p2[0] 

591 dec1 = p1[1] 

592 dec2 = p2[1] 

593 else: 

594 az1 = exp1.visitInfo.boresightAzAlt[0] 

595 az2 = exp2.visitInfo.boresightAzAlt[0] 

596 alt1 = exp1.visitInfo.boresightAzAlt[1] 

597 alt2 = exp2.visitInfo.boresightAzAlt[1] 

598 

599 ra1 = exp1.visitInfo.boresightRaDec[0] 

600 ra2 = exp2.visitInfo.boresightRaDec[0] 

601 dec1 = exp1.visitInfo.boresightRaDec[1] 

602 dec2 = exp2.visitInfo.boresightRaDec[1] 

603 

604 p1 = exp1.visitInfo.boresightRaDec 

605 p2 = exp2.visitInfo.boresightRaDec 

606 

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

608 deltaPixels = angular_offset / pixScaleArcSec 

609 

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

611 deltaDec=dec1-dec2, 

612 deltaAlt=alt1-alt2, 

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

614 deltaPixels=deltaPixels 

615 ) 

616 

617 return ret 

618 

619 

620def starTrackerFileToExposure(filename, logger=None): 

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

622 

623 Parameters 

624 ---------- 

625 filename : `str` 

626 The full path to the file. 

627 logger : `logging.Logger`, optional 

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

629 

630 Returns 

631 ------- 

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

633 The exposure. 

634 """ 

635 if not logger: 

636 logger = logging.getLogger(__name__) 

637 exp = afwImage.ExposureF(filename) 

638 try: 

639 wcs = genericCameraHeaderToWcs(exp) 

640 exp.setWcs(wcs) 

641 except Exception as e: 

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

643 

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

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

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

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

648 try: 

649 md = exp.getMetadata() 

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

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

652 duration = end - begin 

653 mid = begin + duration/2 

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

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

656 exp.info.setVisitInfo(newVi) 

657 except Exception as e: 

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

659 

660 return exp 

661 

662 

663def obsInfoToDict(obsInfo): 

664 """Convert an ObservationInfo to a dict. 

665 

666 Parameters 

667 ---------- 

668 obsInfo : `astro_metadata_translator.ObservationInfo` 

669 The ObservationInfo to convert. 

670 

671 Returns 

672 ------- 

673 obsInfoDict : `dict` 

674 The ObservationInfo as a dict. 

675 """ 

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

677 

678 

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

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

681 

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

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

684 if no tile number is found. 

685 

686 Parameters 

687 ---------- 

688 field : `str` 

689 The name of the field 

690 

691 Returns 

692 ------- 

693 fieldName : `str` 

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

695 tileNum : `int` 

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

697 """ 

698 if warn and not logger: 

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

700 

701 if '_' not in field: 

702 if warn: 

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

704 " so cannot determine the tile number.") 

705 return field, None 

706 

707 try: 

708 fieldParts = field.split("_") 

709 fieldNum = int(fieldParts[-1]) 

710 except ValueError: 

711 if warn: 

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

713 " so cannot determine the tile number.") 

714 return field, None 

715 

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

717 

718 

719def getAirmassSeeingCorrection(airmass): 

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

721 

722 Parameters 

723 ---------- 

724 airmass : `float` 

725 The airmass, greater than or equal to 1. 

726 

727 Returns 

728 ------- 

729 correctionFactor : `float` 

730 The correction factor to apply to the seeing. 

731 

732 Raises 

733 ------ 

734 ValueError raised for unphysical airmasses. 

735 """ 

736 if airmass < 1: 

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

738 return airmass**(-0.6) 

739 

740 

741def getFilterSeeingCorrection(filterName): 

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

743 

744 Parameters 

745 ---------- 

746 filterName : `str` 

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

748 

749 Returns 

750 ------- 

751 correctionFactor : `float` 

752 The correction factor to apply to the seeing. 

753 

754 Raises 

755 ------ 

756 ValueError raised for unknown filters. 

757 """ 

758 match filterName: 

759 case 'SDSSg_65mm': 

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

761 case 'SDSSr_65mm': 

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

763 case 'SDSSi_65mm': 

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

765 case _: 

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