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

378 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-01 05:37 -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 datetime 

23import logging 

24import os 

25from typing import Iterable 

26 

27import astropy.units as u 

28import numpy as np 

29from astro_metadata_translator import ObservationInfo 

30from astropy.coordinates import AltAz, SkyCoord 

31from astropy.coordinates.earth import EarthLocation 

32from astropy.time import Time 

33from dateutil.tz import gettz 

34from matplotlib.patches import Rectangle 

35from scipy.ndimage import gaussian_filter 

36 

37import lsst.afw.detection as afwDetect 

38import lsst.afw.image as afwImage 

39import lsst.afw.math as afwMath 

40import lsst.daf.base as dafBase 

41import lsst.geom as geom 

42import lsst.pipe.base as pipeBase 

43import lsst.utils.packages as packageUtils 

44from lsst.afw.coord import Weather 

45from lsst.afw.detection import Footprint, FootprintSet 

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

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

48from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER 

49 

50from .astrometry.utils import genericCameraHeaderToWcs 

51 

52__all__ = [ 

53 "SIGMATOFWHM", 

54 "FWHMTOSIGMA", 

55 "EFD_CLIENT_MISSING_MSG", 

56 "GOOGLE_CLOUD_MISSING_MSG", 

57 "AUXTEL_LOCATION", 

58 "countPixels", 

59 "quickSmooth", 

60 "argMax2d", 

61 "dayObsIntToString", 

62 "dayObsSeqNumToVisitId", 

63 "getImageStats", 

64 "detectObjectsInExp", 

65 "fluxesFromFootprints", 

66 "fluxFromFootprint", 

67 "humanNameForCelestialObject", 

68 "getFocusFromHeader", 

69 "checkStackSetup", 

70 "setupLogging", 

71 "getCurrentDayObs_datetime", 

72 "getCurrentDayObs_int", 

73 "getCurrentDayObs_humanStr", 

74 "getExpRecordAge", 

75 "getSite", 

76 "getAltAzFromSkyPosition", 

77 "getExpPositionOffset", 

78 "starTrackerFileToExposure", 

79 "obsInfoToDict", 

80 "getFieldNameAndTileNumber", 

81 "getAirmassSeeingCorrection", 

82 "getFilterSeeingCorrection", 

83 "getCdf", 

84 "getQuantiles", 

85 "digitizeData", 

86 "getBboxAround", 

87 "bboxToMatplotlibRectanle", 

88] 

89 

90 

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

92FWHMTOSIGMA = 1 / SIGMATOFWHM 

93 

94EFD_CLIENT_MISSING_MSG = ( 

95 "ImportError: lsst_efd_client not found. Please install with:\n" " pip install lsst-efd-client" 

96) 

97 

98GOOGLE_CLOUD_MISSING_MSG = ( 

99 "ImportError: Google cloud storage not found. Please install with:\n" 

100 " pip install google-cloud-storage" 

101) 

102 

103 

104def countPixels(maskedImage, maskPlane): 

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

106 

107 Parameters 

108 ---------- 

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

110 The masked image, 

111 maskPlane : `str` 

112 The name of the bitmask. 

113 

114 Returns 

115 ------- 

116 count : `int`` 

117 The number of pixels in with the selected mask bit 

118 """ 

119 bit = maskedImage.mask.getPlaneBitMask(maskPlane) 

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

121 

122 

123def quickSmooth(data, sigma=2): 

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

125 

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

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

128 

129 Parameters 

130 ---------- 

131 data : `np.array` 

132 The image data to smooth 

133 sigma : `float`, optional 

134 The size of the smoothing kernel. 

135 

136 Returns 

137 ------- 

138 smoothData : `np.array` 

139 The smoothed data 

140 """ 

141 kernel = [sigma, sigma] 

142 smoothData = gaussian_filter(data, kernel, mode="constant") 

143 return smoothData 

144 

145 

146def argMax2d(array): 

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

148 

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

150 maximum value, e.g. returns 

151 

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

153 

154 Parameters 

155 ---------- 

156 array : `np.array` 

157 The data 

158 

159 Returns 

160 ------- 

161 maxLocation : `tuple` 

162 The coords of the first instance of the max value 

163 unique : `bool` 

164 Whether it's the only location 

165 otherLocations : `list` of `tuple` 

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

167 """ 

168 uniqueMaximum = False 

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

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

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

172 uniqueMaximum = True 

173 

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

175 

176 

177def dayObsIntToString(dayObs): 

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

179 

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

181 

182 Parameters 

183 ---------- 

184 dayObs : `int` 

185 The dayObs. 

186 

187 Returns 

188 ------- 

189 dayObs : `str` 

190 The dayObs as a string. 

191 """ 

192 assert isinstance(dayObs, int) 

193 dStr = str(dayObs) 

194 assert len(dStr) == 8 

195 return "-".join([dStr[0:4], dStr[4:6], dStr[6:8]]) 

196 

197 

198def dayObsSeqNumToVisitId(dayObs, seqNum): 

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

200 

201 Parameters 

202 ---------- 

203 dayObs : `int` 

204 The dayObs. 

205 seqNum : `int` 

206 The seqNum. 

207 

208 Returns 

209 ------- 

210 visitId : `int` 

211 The visitId. 

212 

213 Notes 

214 ----- 

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

216 programatically/via the butler. 

217 """ 

218 if dayObs < 19700101 or dayObs > 35000101: 

219 raise ValueError(f"dayObs value {dayObs} outside plausible range") 

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

221 

222 

223def getImageStats(exp): 

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

225 

226 Parameters 

227 ---------- 

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

229 The input exposure. 

230 

231 Returns 

232 ------- 

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

234 A container with attributes containing measurements and statistics 

235 for the image. 

236 """ 

237 result = pipeBase.Struct() 

238 

239 vi = exp.visitInfo 

240 expTime = vi.exposureTime 

241 md = exp.getMetadata() 

242 

243 obj = vi.object 

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

245 result.object = obj 

246 result.mjd = mjd 

247 

248 fullFilterString = exp.filter.physicalLabel 

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

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

251 

252 airmass = vi.getBoresightAirmass() 

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

254 

255 azAlt = vi.getBoresightAzAlt() 

256 az = azAlt[0].asDegrees() 

257 el = azAlt[1].asDegrees() 

258 

259 result.expTime = expTime 

260 result.filter = filt 

261 result.grating = grating 

262 result.airmass = airmass 

263 result.rotangle = rotangle 

264 result.az = az 

265 result.el = el 

266 result.focus = md.get("FOCUSZ") 

267 

268 data = exp.image.array 

269 result.maxValue = np.max(data) 

270 

271 peak, uniquePeak, otherPeaks = argMax2d(data) 

272 result.maxPixelLocation = peak 

273 result.multipleMaxPixels = uniquePeak 

274 

275 result.nBadPixels = countPixels(exp.maskedImage, "BAD") 

276 result.nSatPixels = countPixels(exp.maskedImage, "SAT") 

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

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

279 

280 sctrl = afwMath.StatisticsControl() 

281 sctrl.setNumSigmaClip(5) 

282 sctrl.setNumIter(2) 

283 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP 

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

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

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

287 

288 result.clippedMean = mean 

289 result.clippedStddev = std 

290 

291 return result 

292 

293 

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

295 """Quick and dirty object detection for an exposure. 

296 

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

298 

299 Parameters 

300 ---------- 

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

302 The exposure to detect objects in. 

303 nSigma : `float` 

304 The number of sigma for detection. 

305 nPixMin : `int` 

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

307 grow : `int` 

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

309 

310 Returns 

311 ------- 

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

313 The set of footprints in the image. 

314 """ 

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

316 exp.image -= median 

317 

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

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

320 if grow > 0: 

321 isotropic = True 

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

323 

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

325 return footPrintSet 

326 

327 

328def fluxesFromFootprints(footprints, parentImage, subtractImageMedian=False): 

329 """Calculate the flux from a set of footprints, given the parent image, 

330 optionally subtracting the whole-image median from each pixel as a very 

331 rough background subtraction. 

332 

333 Parameters 

334 ---------- 

335 footprints : `lsst.afw.detection.FootprintSet` or 

336 `lsst.afw.detection.Footprint` or 

337 `iterable` of `lsst.afw.detection.Footprint` 

338 The footprints to measure. 

339 parentImage : `lsst.afw.image.Image` 

340 The parent image. 

341 subtractImageMedian : `bool`, optional 

342 Subtract a whole-image median from each pixel in the footprint when 

343 summing as a very crude background subtraction. Does not change the 

344 original image. 

345 

346 Returns 

347 ------- 

348 fluxes : `list` of `float` 

349 The fluxes for each footprint. 

350 

351 Raises 

352 ------ 

353 TypeError : raise for unsupported types. 

354 """ 

355 median = 0 

356 if subtractImageMedian: 

357 median = np.nanmedian(parentImage.array) 

358 

359 # poor person's single dispatch 

360 badTypeMsg = ( 

361 "This function works with FootprintSets, single Footprints, and iterables of Footprints. " 

362 f"Got {type(footprints)}: {footprints}" 

363 ) 

364 if isinstance(footprints, FootprintSet): 

365 footprints = footprints.getFootprints() 

366 elif isinstance(footprints, Iterable): 

367 if not isinstance(footprints[0], Footprint): 

368 raise TypeError(badTypeMsg) 

369 elif isinstance(footprints, Footprint): 

370 footprints = [footprints] 

371 else: 

372 raise TypeError(badTypeMsg) 

373 

374 return np.array([fluxFromFootprint(fp, parentImage, backgroundValue=median) for fp in footprints]) 

375 

376 

377def fluxFromFootprint(footprint, parentImage, backgroundValue=0): 

378 """Calculate the flux from a footprint, given the parent image, optionally 

379 subtracting a single value from each pixel as a very rough background 

380 subtraction, e.g. the image median. 

381 

382 Parameters 

383 ---------- 

384 footprint : `lsst.afw.detection.Footprint` 

385 The footprint to measure. 

386 parentImage : `lsst.afw.image.Image` 

387 Image containing the footprint. 

388 backgroundValue : `bool`, optional 

389 The value to subtract from each pixel in the footprint when summing 

390 as a very crude background subtraction. Does not change the original 

391 image. 

392 

393 Returns 

394 ------- 

395 flux : `float` 

396 The flux in the footprint 

397 """ 

398 if backgroundValue: # only do the subtraction if non-zero for speed 

399 xy0 = parentImage.getBBox().getMin() 

400 return footprint.computeFluxFromArray(parentImage.array - backgroundValue, xy0) 

401 return footprint.computeFluxFromImage(parentImage) 

402 

403 

404def humanNameForCelestialObject(objName): 

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

406 

407 Parameters 

408 ---------- 

409 objName : `str` 

410 The/a name of the object. 

411 

412 Returns 

413 ------- 

414 names : `list` of `str` 

415 The names found for the object 

416 """ 

417 from astroquery.simbad import Simbad 

418 

419 results = [] 

420 try: 

421 simbadResult = Simbad.query_objectids(objName) 

422 for row in simbadResult: 

423 if row["ID"].startswith("NAME"): 

424 results.append(row["ID"].replace("NAME ", "")) 

425 return results 

426 except Exception: 

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

428 

429 

430def _getAltAzZenithsFromSeqNum(butler, dayObs, seqNumList): 

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

432 

433 Parameters 

434 ---------- 

435 butler : `lsst.daf.butler.Butler` 

436 The butler to query. 

437 dayObs : `int` 

438 The dayObs. 

439 seqNumList : `list` of `int` 

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

441 

442 Returns 

443 ------- 

444 azimuths : `list` of `float` 

445 List of the azimuths for each seqNum 

446 elevations : `list` of `float` 

447 List of the elevations for each seqNum 

448 zeniths : `list` of `float` 

449 List of the zenith angles for each seqNum 

450 """ 

451 azimuths, elevations, zeniths = [], [], [] 

452 for seqNum in seqNumList: 

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

454 obsInfo = ObservationInfo(md) 

455 alt = obsInfo.altaz_begin.alt.value 

456 az = obsInfo.altaz_begin.az.value 

457 elevations.append(alt) 

458 zeniths.append(90 - alt) 

459 azimuths.append(az) 

460 return azimuths, elevations, zeniths 

461 

462 

463def getFocusFromHeader(exp): 

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

465 

466 Parameters 

467 ---------- 

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

469 The exposure. 

470 

471 Returns 

472 ------- 

473 focus : `float` or `None` 

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

475 """ 

476 md = exp.getMetadata() 

477 if "FOCUSZ" in md: 

478 return md["FOCUSZ"] 

479 return None 

480 

481 

482def checkStackSetup(): 

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

484 

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

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

487 the path to each. 

488 

489 Notes 

490 ----- 

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

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

493 """ 

494 packages = packageUtils.getEnvironmentPackages(include_all=True) 

495 

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

497 lsstDistribTags = lsstDistribHashAndTags.split()[1] 

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

499 tag = lsstDistribTags.replace("(", "") 

500 tag = tag.replace(")", "") 

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

502 else: # multiple weekly tags found for lsst_distrib! 

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

504 

505 localPackages = [] 

506 localPaths = [] 

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

508 if tags.startswith("LOCAL:"): 

509 path = tags.split("LOCAL:")[1] 

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

511 localPaths.append(path) 

512 localPackages.append(package) 

513 

514 if localPackages: 

515 print("\nLocally setup packages:") 

516 print("-----------------------") 

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

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

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

520 else: 

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

522 

523 

524def setupLogging(longlog=False): 

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

526 

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

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

529 the pipeline default is, currently 

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

531 lsst.isr INFO: Masking defects. 

532 """ 

533 CliLog.initLog(longlog=longlog) 

534 

535 

536def getCurrentDayObs_datetime(): 

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

538 

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

540 """ 

541 utc = gettz("UTC") 

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

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

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

545 return dayObs 

546 

547 

548def getCurrentDayObs_int(): 

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

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

551 

552 

553def getCurrentDayObs_humanStr(): 

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

555 return dayObsIntToString(getCurrentDayObs_int()) 

556 

557 

558def getExpRecordAge(expRecord): 

559 """Get the time, in seconds, since the end of exposure. 

560 

561 Parameters 

562 ---------- 

563 expRecord : `lsst.daf.butler.DimensionRecord` 

564 The exposure record. 

565 

566 Returns 

567 ------- 

568 age : `float` 

569 The age of the exposure, in seconds. 

570 """ 

571 return -1 * (expRecord.timespan.end - Time.now()).sec 

572 

573 

574def getSite(): 

575 """Returns where the code is running. 

576 

577 Returns 

578 ------- 

579 location : `str` 

580 One of: 

581 ['tucson', 'summit', 'base', 'staff-rsp', 'rubin-devl', 'jenkins', 

582 'usdf-k8s'] 

583 

584 Raises 

585 ------ 

586 ValueError 

587 Raised if location cannot be determined. 

588 """ 

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

590 # identifies it. 

591 location = os.getenv("EXTERNAL_INSTANCE_URL", "") 

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

593 return "tucson" 

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

595 return "summit" 

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

597 return "base" 

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

599 return "staff-rsp" 

600 

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

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

603 hostname = os.getenv("HOSTNAME", "") 

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

605 return "rubin-devl" 

606 

607 jenkinsHome = os.getenv("JENKINS_HOME", "") 

608 if jenkinsHome != "": 608 ↛ 612line 608 didn't jump to line 612, because the condition on line 608 was never false

609 return "jenkins" 

610 

611 # we're probably inside a k8s pod doing rapid analysis work at this point 

612 location = os.getenv("RAPID_ANALYSIS_LOCATION", "") 

613 if location == "TTS": 

614 return "tucson" 

615 if location == "BTS": 

616 return "base" 

617 if location == "SUMMIT": 

618 return "summit" 

619 if location == "USDF": 

620 return "usdf-k8s" 

621 

622 # we have failed 

623 raise ValueError("Location could not be determined") 

624 

625 

626def getAltAzFromSkyPosition( 

627 skyPos, 

628 visitInfo, 

629 doCorrectRefraction=False, 

630 wavelength=500.0, 

631 pressureOverride=None, 

632 temperatureOverride=None, 

633 relativeHumidityOverride=None, 

634): 

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

636 of the observation. 

637 

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

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

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

641 so this takes a default value of 500nm. 

642 

643 Parameters 

644 ---------- 

645 skyPos : `lsst.geom.SpherePoint` 

646 The position on the sky. 

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

648 The visit info containing the time of the observation. 

649 doCorrectRefraction : `bool`, optional 

650 Correct for the atmospheric refraction? 

651 wavelength : `float`, optional 

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

653 pressureOverride : `float`, optional 

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

655 the visitInfo, as a float. 

656 temperatureOverride : `float`, optional 

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

658 in the visitInfo, as a float. 

659 relativeHumidityOverride : `float`, optional 

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

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

662 

663 Returns 

664 ------- 

665 alt : `lsst.geom.Angle` 

666 The altitude. 

667 az : `lsst.geom.Angle` 

668 The azimuth. 

669 """ 

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

671 long = visitInfo.observatory.getLongitude() 

672 lat = visitInfo.observatory.getLatitude() 

673 ele = visitInfo.observatory.getElevation() 

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

675 

676 refractionKwargs = {} 

677 if doCorrectRefraction: 

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

679 wavelength = wavelength * u.nm 

680 

681 if pressureOverride: 

682 pressure = pressureOverride 

683 else: 

684 pressure = visitInfo.weather.getAirPressure() 

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

686 # convert from pascals to bars 

687 pressure /= 100000.0 

688 pressure = pressure * u.bar 

689 

690 if temperatureOverride: 

691 temperature = temperatureOverride 

692 else: 

693 temperature = visitInfo.weather.getAirTemperature() 

694 temperature = temperature * u.deg_C 

695 

696 if relativeHumidityOverride: 

697 relativeHumidity = relativeHumidityOverride 

698 else: 

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

700 relativeHumidity = relativeHumidity 

701 

702 refractionKwargs = dict( 

703 pressure=pressure, temperature=temperature, relative_humidity=relativeHumidity, obswl=wavelength 

704 ) 

705 

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

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

708 obsTime = Time(visitInfo.date.toPython(), scale="tai") 

709 altAz = AltAz(obstime=obsTime, location=earthLocation, **refractionKwargs) 

710 

711 obsAltAz = skyLocation.transform_to(altAz) 

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

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

714 

715 return alt, az 

716 

717 

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

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

720 

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

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

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

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

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

726 of whether astrometric fitting has been performed. 

727 

728 Values are given as exp1-exp2. 

729 

730 Parameters 

731 ---------- 

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

733 The first exposure. 

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

735 The second exposure. 

736 useWcs : `bool` 

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

738 boresight values from the exposures' visitInfos. 

739 allowDifferentPlateScales : `bool`, optional 

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

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

742 has been undertaken during commissioning plate scales can be different 

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

744 

745 Returns 

746 ------- 

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

748 A struct containing the offsets: 

749 ``deltaRa`` 

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

751 ``deltaDec`` 

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

753 ``deltaAlt`` 

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

755 ``deltaAz`` 

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

757 ``deltaPixels`` 

758 The diference in pixels (`float`) 

759 """ 

760 

761 wcs1 = exp1.getWcs() 

762 wcs2 = exp2.getWcs() 

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

764 if not allowDifferentPlateScales: 

765 assert np.isclose( 

766 pixScaleArcSec, wcs2.getPixelScale().asArcseconds() 

767 ), "Pixel scales in the exposures differ." 

768 

769 if useWcs: 

770 p1 = wcs1.getSkyOrigin() 

771 p2 = wcs2.getSkyOrigin() 

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

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

774 ra1 = p1[0] 

775 ra2 = p2[0] 

776 dec1 = p1[1] 

777 dec2 = p2[1] 

778 else: 

779 az1 = exp1.visitInfo.boresightAzAlt[0] 

780 az2 = exp2.visitInfo.boresightAzAlt[0] 

781 alt1 = exp1.visitInfo.boresightAzAlt[1] 

782 alt2 = exp2.visitInfo.boresightAzAlt[1] 

783 

784 ra1 = exp1.visitInfo.boresightRaDec[0] 

785 ra2 = exp2.visitInfo.boresightRaDec[0] 

786 dec1 = exp1.visitInfo.boresightRaDec[1] 

787 dec2 = exp2.visitInfo.boresightRaDec[1] 

788 

789 p1 = exp1.visitInfo.boresightRaDec 

790 p2 = exp2.visitInfo.boresightRaDec 

791 

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

793 deltaPixels = angular_offset / pixScaleArcSec 

794 

795 ret = pipeBase.Struct( 

796 deltaRa=(ra1 - ra2).wrapNear(geom.Angle(0.0)), 

797 deltaDec=dec1 - dec2, 

798 deltaAlt=alt1 - alt2, 

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

800 deltaPixels=deltaPixels, 

801 ) 

802 

803 return ret 

804 

805 

806def starTrackerFileToExposure(filename, logger=None): 

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

808 

809 Parameters 

810 ---------- 

811 filename : `str` 

812 The full path to the file. 

813 logger : `logging.Logger`, optional 

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

815 

816 Returns 

817 ------- 

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

819 The exposure. 

820 """ 

821 if not logger: 

822 logger = logging.getLogger(__name__) 

823 exp = afwImage.ExposureF(filename) 

824 try: 

825 wcs = genericCameraHeaderToWcs(exp) 

826 exp.setWcs(wcs) 

827 except Exception as e: 

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

829 

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

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

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

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

834 try: 

835 newArgs = {} # dict to unpack into visitInfo.copyWith - fill it with whatever needs to be replaced 

836 md = exp.getMetadata() 

837 

838 begin = datetime.datetime.fromisoformat(md["DATE-BEG"]) 

839 end = datetime.datetime.fromisoformat(md["DATE-END"]) 

840 duration = end - begin 

841 mid = begin + duration / 2 

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

843 newArgs["date"] = newTime 

844 

845 # AIRPRESS is being set as PRESSURE so afw doesn't pick it up 

846 # once we're using the butler for data we will just set it to take 

847 # PRESSURE in the translator instead of this 

848 weather = exp.visitInfo.getWeather() 

849 oldPressure = weather.getAirPressure() 

850 if not np.isfinite(oldPressure): 

851 pressure = md.get("PRESSURE") 

852 if pressure is not None: 

853 logger.info("Patching the weather info using the PRESSURE header keyword") 

854 newWeather = Weather(weather.getAirTemperature(), pressure, weather.getHumidity()) 

855 newArgs["weather"] = newWeather 

856 

857 if newArgs: 

858 newVi = exp.visitInfo.copyWith(**newArgs) 

859 exp.info.setVisitInfo(newVi) 

860 except Exception as e: 

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

862 

863 return exp 

864 

865 

866def obsInfoToDict(obsInfo): 

867 """Convert an ObservationInfo to a dict. 

868 

869 Parameters 

870 ---------- 

871 obsInfo : `astro_metadata_translator.ObservationInfo` 

872 The ObservationInfo to convert. 

873 

874 Returns 

875 ------- 

876 obsInfoDict : `dict` 

877 The ObservationInfo as a dict. 

878 """ 

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

880 

881 

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

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

884 

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

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

887 if no tile number is found. 

888 

889 Parameters 

890 ---------- 

891 field : `str` 

892 The name of the field 

893 

894 Returns 

895 ------- 

896 fieldName : `str` 

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

898 tileNum : `int` 

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

900 """ 

901 if warn and not logger: 

902 logger = logging.getLogger("lsst.summit.utils.utils.getFieldNameAndTileNumber") 

903 

904 if "_" not in field: 

905 if warn: 

906 logger.warning( 

907 f"Field {field} does not contain an underscore," " so cannot determine the tile number." 

908 ) 

909 return field, None 

910 

911 try: 

912 fieldParts = field.split("_") 

913 fieldNum = int(fieldParts[-1]) 

914 except ValueError: 

915 if warn: 

916 logger.warning( 

917 f"Field {field} does not contain only an integer after the final underscore" 

918 " so cannot determine the tile number." 

919 ) 

920 return field, None 

921 

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

923 

924 

925def getAirmassSeeingCorrection(airmass): 

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

927 

928 Parameters 

929 ---------- 

930 airmass : `float` 

931 The airmass, greater than or equal to 1. 

932 

933 Returns 

934 ------- 

935 correctionFactor : `float` 

936 The correction factor to apply to the seeing. 

937 

938 Raises 

939 ------ 

940 ValueError raised for unphysical airmasses. 

941 """ 

942 if airmass < 1: 

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

944 return airmass ** (-0.6) 

945 

946 

947def getFilterSeeingCorrection(filterName): 

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

949 

950 Parameters 

951 ---------- 

952 filterName : `str` 

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

954 

955 Returns 

956 ------- 

957 correctionFactor : `float` 

958 The correction factor to apply to the seeing. 

959 

960 Raises 

961 ------ 

962 ValueError raised for unknown filters. 

963 """ 

964 match filterName: 

965 case "SDSSg_65mm": 

966 return (474.41 / 500.0) ** 0.2 

967 case "SDSSr_65mm": 

968 return (628.47 / 500.0) ** 0.2 

969 case "SDSSi_65mm": 

970 return (769.51 / 500.0) ** 0.2 

971 case "SDSSz_65mm": 

972 return (871.45 / 500.0) ** 0.2 

973 case "SDSSy_65mm": 

974 return (986.8 / 500.0) ** 0.2 

975 case _: 

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

977 

978 

979def getCdf(data, scale, nBinsMax=300_000): 

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

981 the [0, scale] range. 

982 

983 If the input data is all nan, then the output cdf will be nan as well as 

984 the min and max values. 

985 

986 Parameters 

987 ---------- 

988 data : `np.array` 

989 The input data. 

990 scale : `int` 

991 The scaling range of the output. 

992 nBinsMax : `int`, optional 

993 Maximum number of bins to use. 

994 

995 Returns 

996 ------- 

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

998 A monotonically increasing sequence that represents a scaled 

999 cumulative distribution function, starting with the value at 

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

1001 minVal : `float` 

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

1003 maxVal : `float` 

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

1005 """ 

1006 flatData = data.ravel() 

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

1008 

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

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

1011 

1012 if np.isnan(minVal) or np.isnan(maxVal): 

1013 # if either the min or max are nan, then the data is all nan as we're 

1014 # using nanmin and nanmax. Given this, we can't calculate a cdf, so 

1015 # return nans for all values 

1016 return np.nan, np.nan, np.nan 

1017 

1018 nBins = np.clip(int(maxVal) - int(minVal), 1, nBinsMax) 

1019 

1020 hist, binEdges = np.histogram(flatData, bins=nBins, range=(int(minVal), int(maxVal))) 

1021 

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

1023 return cdf, minVal, maxVal 

1024 

1025 

1026def getQuantiles(data, nColors): 

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

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

1029 colors. 

1030 

1031 This is equivalent to using the numpy function: 

1032 np.nanquantile(data, np.linspace(0, 1, nColors + 1)) 

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

1034 implementation gives a significant speed-up. In the case of large 

1035 ranges, np.nanquantile is used because it is more memory efficient. 

1036 

1037 If all elements of ``data`` are nan then the output ``boundaries`` will 

1038 also all be ``nan`` to keep the interface consistent. 

1039 

1040 Parameters 

1041 ---------- 

1042 data : `np.array` 

1043 The input image data. 

1044 nColors : `int` 

1045 The number of intervals to distribute data into. 

1046 

1047 Returns 

1048 ------- 

1049 boundaries: `list` of `float` 

1050 A monotonically increasing sequence of size (nColors + 1). These are 

1051 the edges of nColors intervals. 

1052 """ 

1053 if (np.nanmax(data) - np.nanmin(data)) > 300_000: 

1054 # Use slower but memory efficient nanquantile 

1055 logger = logging.getLogger(__name__) 

1056 logger.warning("Data range is very large; using slower quantile code.") 

1057 boundaries = np.nanquantile(data, np.linspace(0, 1, nColors + 1)) 

1058 else: 

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

1060 if np.isnan(minVal): # cdf calculation has failed because all data is nan 

1061 return np.asarray([np.nan for _ in range(nColors)]) 

1062 

1063 scale = (maxVal - minVal) / len(cdf) 

1064 

1065 boundaries = np.asarray([np.argmax(cdf >= i) * scale + minVal for i in range(nColors)] + [maxVal]) 

1066 

1067 return boundaries 

1068 

1069 

1070def digitizeData(data, nColors=256): 

1071 """ 

1072 Scale data into nColors using its cumulative distribution function. 

1073 

1074 Parameters 

1075 ---------- 

1076 data : `np.array` 

1077 The input image data. 

1078 nColors : `int` 

1079 The number of intervals to distribute data into. 

1080 

1081 Returns 

1082 ------- 

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

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

1085 """ 

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

1087 scale = (maxVal - minVal) / len(cdf) 

1088 bins = np.floor((data * scale - minVal)).astype(np.int64) 

1089 return cdf[bins] 

1090 

1091 

1092def getBboxAround(centroid, boxSize, exp): 

1093 """Get a bbox centered on a point, clipped to the exposure. 

1094 

1095 If the bbox would extend beyond the bounds of the exposure it is clipped to 

1096 the exposure, resulting in a non-square bbox. 

1097 

1098 Parameters 

1099 ---------- 

1100 centroid : `lsst.geom.Point` 

1101 The source centroid. 

1102 boxsize : `int` 

1103 The size of the box to centre around the centroid. 

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

1105 The exposure, so the bbox can be clipped to not overrun the bounds. 

1106 

1107 Returns 

1108 ------- 

1109 bbox : `lsst.geom.Box2I` 

1110 The bounding box, centered on the centroid unless clipping to the 

1111 exposure causes it to be non-square. 

1112 """ 

1113 bbox = geom.BoxI().makeCenteredBox(centroid, geom.Extent2I(boxSize, boxSize)) 

1114 bbox = bbox.clippedTo(exp.getBBox()) 

1115 return bbox 

1116 

1117 

1118def bboxToMatplotlibRectanle(bbox): 

1119 """Convert a bbox to a matplotlib Rectangle for plotting. 

1120 

1121 Parameters 

1122 ---------- 

1123 results : `lsst.geom.Box2I` or `lsst.geom.Box2D` 

1124 The bbox to convert. 

1125 

1126 Returns 

1127 ------- 

1128 rectangle : `bool` 

1129 The rectangle. 

1130 """ 

1131 ll = bbox.minX, bbox.minY 

1132 width, height = bbox.getDimensions() 

1133 return Rectangle(ll, width, height)