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

382 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-11 05:38 -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 collections.abc import Iterable 

26from typing import Union 

27 

28import astropy.units as u 

29import matplotlib 

30import numpy as np 

31from astro_metadata_translator import ObservationInfo 

32from astropy.coordinates import AltAz, SkyCoord 

33from astropy.coordinates.earth import EarthLocation 

34from astropy.time import Time 

35from dateutil.tz import gettz 

36from matplotlib.patches import Rectangle 

37from scipy.ndimage import gaussian_filter 

38 

39import lsst.afw.detection as afwDetect 

40import lsst.afw.detection as afwDetection 

41import lsst.afw.image as afwImage 

42import lsst.afw.math as afwMath 

43import lsst.daf.base as dafBase 

44import lsst.daf.butler as dafButler 

45import lsst.geom as geom 

46import lsst.pipe.base as pipeBase 

47import lsst.utils.packages as packageUtils 

48from lsst.afw.coord import Weather 

49from lsst.afw.detection import Footprint, FootprintSet 

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

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

52from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER 

53 

54from .astrometry.utils import genericCameraHeaderToWcs 

55 

56__all__ = [ 

57 "SIGMATOFWHM", 

58 "FWHMTOSIGMA", 

59 "EFD_CLIENT_MISSING_MSG", 

60 "GOOGLE_CLOUD_MISSING_MSG", 

61 "AUXTEL_LOCATION", 

62 "countPixels", 

63 "quickSmooth", 

64 "argMax2d", 

65 "dayObsIntToString", 

66 "dayObsSeqNumToVisitId", 

67 "getImageStats", 

68 "detectObjectsInExp", 

69 "fluxesFromFootprints", 

70 "fluxFromFootprint", 

71 "humanNameForCelestialObject", 

72 "getFocusFromHeader", 

73 "checkStackSetup", 

74 "setupLogging", 

75 "getCurrentDayObs_datetime", 

76 "getCurrentDayObs_int", 

77 "getCurrentDayObs_humanStr", 

78 "getExpRecordAge", 

79 "getSite", 

80 "getAltAzFromSkyPosition", 

81 "getExpPositionOffset", 

82 "starTrackerFileToExposure", 

83 "obsInfoToDict", 

84 "getFieldNameAndTileNumber", 

85 "getAirmassSeeingCorrection", 

86 "getFilterSeeingCorrection", 

87 "getCdf", 

88 "getQuantiles", 

89 "digitizeData", 

90 "getBboxAround", 

91 "bboxToMatplotlibRectanle", 

92] 

93 

94 

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

96FWHMTOSIGMA = 1 / SIGMATOFWHM 

97 

98EFD_CLIENT_MISSING_MSG = ( 

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

100) 

101 

102GOOGLE_CLOUD_MISSING_MSG = ( 

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

104 " pip install google-cloud-storage" 

105) 

106 

107 

108def countPixels(maskedImage: afwImage.MaskedImage, maskPlane: str) -> int: 

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

110 

111 Parameters 

112 ---------- 

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

114 The masked image, 

115 maskPlane : `str` 

116 The name of the bitmask. 

117 

118 Returns 

119 ------- 

120 count : `int`` 

121 The number of pixels in with the selected mask bit 

122 """ 

123 bit = maskedImage.mask.getPlaneBitMask(maskPlane) 

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

125 

126 

127def quickSmooth(data: np.ndarray[float], sigma: float = 2) -> np.ndarray[float]: 

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

129 

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

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

132 

133 Parameters 

134 ---------- 

135 data : `np.array` 

136 The image data to smooth 

137 sigma : `float`, optional 

138 The size of the smoothing kernel. 

139 

140 Returns 

141 ------- 

142 smoothData : `np.array` 

143 The smoothed data 

144 """ 

145 kernel = [sigma, sigma] 

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

147 return smoothData 

148 

149 

150def argMax2d(array: np.array) -> tuple[tuple[int, int], bool, list[tuple[int, int]]]: 

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

152 

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

154 maximum value, e.g. returns 

155 

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

157 

158 Parameters 

159 ---------- 

160 array : `np.array` 

161 The data 

162 

163 Returns 

164 ------- 

165 maxLocation : `tuple` 

166 The coords of the first instance of the max value 

167 unique : `bool` 

168 Whether it's the only location 

169 otherLocations : `list` of `tuple` 

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

171 """ 

172 uniqueMaximum = False 

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

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

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

176 uniqueMaximum = True 

177 

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

179 

180 

181def dayObsIntToString(dayObs: int) -> str: 

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

183 

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

185 

186 Parameters 

187 ---------- 

188 dayObs : `int` 

189 The dayObs. 

190 

191 Returns 

192 ------- 

193 dayObs : `str` 

194 The dayObs as a string. 

195 """ 

196 assert isinstance(dayObs, int) 

197 dStr = str(dayObs) 

198 assert len(dStr) == 8 

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

200 

201 

202def dayObsSeqNumToVisitId(dayObs: int, seqNum: int) -> int: 

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

204 

205 Parameters 

206 ---------- 

207 dayObs : `int` 

208 The dayObs. 

209 seqNum : `int` 

210 The seqNum. 

211 

212 Returns 

213 ------- 

214 visitId : `int` 

215 The visitId. 

216 

217 Notes 

218 ----- 

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

220 programatically/via the butler. 

221 """ 

222 if dayObs < 19700101 or dayObs > 35000101: 

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

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

225 

226 

227def getImageStats(exp: afwImage.Exposure) -> pipeBase.Struct: 

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

229 

230 Parameters 

231 ---------- 

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

233 The input exposure. 

234 

235 Returns 

236 ------- 

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

238 A container with attributes containing measurements and statistics 

239 for the image. 

240 """ 

241 result = pipeBase.Struct() 

242 

243 vi = exp.visitInfo 

244 expTime = vi.exposureTime 

245 md = exp.getMetadata() 

246 

247 obj = vi.object 

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

249 result.object = obj 

250 result.mjd = mjd 

251 

252 fullFilterString = exp.filter.physicalLabel 

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

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

255 

256 airmass = vi.getBoresightAirmass() 

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

258 

259 azAlt = vi.getBoresightAzAlt() 

260 az = azAlt[0].asDegrees() 

261 el = azAlt[1].asDegrees() 

262 

263 result.expTime = expTime 

264 result.filter = filt 

265 result.grating = grating 

266 result.airmass = airmass 

267 result.rotangle = rotangle 

268 result.az = az 

269 result.el = el 

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

271 

272 data = exp.image.array 

273 result.maxValue = np.max(data) 

274 

275 peak, uniquePeak, otherPeaks = argMax2d(data) 

276 result.maxPixelLocation = peak 

277 result.multipleMaxPixels = uniquePeak 

278 

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

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

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

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

283 

284 sctrl = afwMath.StatisticsControl() 

285 sctrl.setNumSigmaClip(5) 

286 sctrl.setNumIter(2) 

287 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP 

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

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

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

291 

292 result.clippedMean = mean 

293 result.clippedStddev = std 

294 

295 return result 

296 

297 

298def detectObjectsInExp( 

299 exp: afwImage.Exposure, nSigma: float = 10, nPixMin: int = 10, grow: int = 0 

300) -> afwDetect.FootprintSet: 

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

302 

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

304 

305 Parameters 

306 ---------- 

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

308 The exposure to detect objects in. 

309 nSigma : `float` 

310 The number of sigma for detection. 

311 nPixMin : `int` 

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

313 grow : `int` 

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

315 

316 Returns 

317 ------- 

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

319 The set of footprints in the image. 

320 """ 

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

322 exp.image -= median 

323 

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

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

326 if grow > 0: 

327 isotropic = True 

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

329 

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

331 return footPrintSet 

332 

333 

334def fluxesFromFootprints( 

335 footprints: afwDetect.FootprintSet | afwDetect.Footprint | Iterable[afwDetect.Footprint], 

336 parentImage: afwImage.Image, 

337 subtractImageMedian=False, 

338) -> np.ndarray[float]: 

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

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

341 rough background subtraction. 

342 

343 Parameters 

344 ---------- 

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

346 `lsst.afw.detection.Footprint` or 

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

348 The footprints to measure. 

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

350 The parent image. 

351 subtractImageMedian : `bool`, optional 

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

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

354 original image. 

355 

356 Returns 

357 ------- 

358 fluxes : `list` of `float` 

359 The fluxes for each footprint. 

360 

361 Raises 

362 ------ 

363 TypeError : raise for unsupported types. 

364 """ 

365 median = 0 

366 if subtractImageMedian: 

367 median = np.nanmedian(parentImage.array) 

368 

369 # poor person's single dispatch 

370 badTypeMsg = ( 

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

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

373 ) 

374 if isinstance(footprints, FootprintSet): 

375 footprints = footprints.getFootprints() 

376 elif isinstance(footprints, Iterable): 

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

378 raise TypeError(badTypeMsg) 

379 elif isinstance(footprints, Footprint): 

380 footprints = [footprints] 

381 else: 

382 raise TypeError(badTypeMsg) 

383 

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

385 

386 

387def fluxFromFootprint( 

388 footprint: afwDetection.Footprint, parentImage: afwImage.Image, backgroundValue: float = 0 

389) -> float: 

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

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

392 subtraction, e.g. the image median. 

393 

394 Parameters 

395 ---------- 

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

397 The footprint to measure. 

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

399 Image containing the footprint. 

400 backgroundValue : `bool`, optional 

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

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

403 image. 

404 

405 Returns 

406 ------- 

407 flux : `float` 

408 The flux in the footprint 

409 """ 

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

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

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

413 return footprint.computeFluxFromImage(parentImage) 

414 

415 

416def humanNameForCelestialObject(objName: str) -> list[str]: 

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

418 

419 Parameters 

420 ---------- 

421 objName : `str` 

422 The/a name of the object. 

423 

424 Returns 

425 ------- 

426 names : `list` of `str` 

427 The names found for the object 

428 """ 

429 from astroquery.simbad import Simbad 

430 

431 results = [] 

432 try: 

433 simbadResult = Simbad.query_objectids(objName) 

434 for row in simbadResult: 

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

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

437 return results 

438 except Exception: 

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

440 

441 

442def _getAltAzZenithsFromSeqNum( 

443 butler: dafButler.Butler, dayObs: int, seqNumList: list[int] 

444) -> tuple[list[float], list[float], list[float]]: 

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

446 

447 Parameters 

448 ---------- 

449 butler : `lsst.daf.butler.Butler` 

450 The butler to query. 

451 dayObs : `int` 

452 The dayObs. 

453 seqNumList : `list` of `int` 

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

455 

456 Returns 

457 ------- 

458 azimuths : `list` of `float` 

459 List of the azimuths for each seqNum 

460 elevations : `list` of `float` 

461 List of the elevations for each seqNum 

462 zeniths : `list` of `float` 

463 List of the zenith angles for each seqNum 

464 """ 

465 azimuths, elevations, zeniths = [], [], [] 

466 for seqNum in seqNumList: 

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

468 obsInfo = ObservationInfo(md) 

469 alt = obsInfo.altaz_begin.alt.value 

470 az = obsInfo.altaz_begin.az.value 

471 elevations.append(alt) 

472 zeniths.append(90 - alt) 

473 azimuths.append(az) 

474 return azimuths, elevations, zeniths 

475 

476 

477def getFocusFromHeader(exp: afwImage.Exposure) -> float | None: 

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

479 

480 Parameters 

481 ---------- 

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

483 The exposure. 

484 

485 Returns 

486 ------- 

487 focus : `float` or `None` 

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

489 """ 

490 md = exp.getMetadata() 

491 if "FOCUSZ" in md: 

492 return md["FOCUSZ"] 

493 return None 

494 

495 

496def checkStackSetup() -> None: 

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

498 

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

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

501 the path to each. 

502 

503 Notes 

504 ----- 

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

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

507 """ 

508 packages = packageUtils.getEnvironmentPackages(include_all=True) 

509 

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

511 lsstDistribTags = lsstDistribHashAndTags.split()[1] 

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

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

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

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

516 else: # multiple weekly tags found for lsst_distrib! 

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

518 

519 localPackages = [] 

520 localPaths = [] 

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

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

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

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

525 localPaths.append(path) 

526 localPackages.append(package) 

527 

528 if localPackages: 

529 print("\nLocally setup packages:") 

530 print("-----------------------") 

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

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

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

534 else: 

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

536 

537 

538def setupLogging(longlog: bool = False) -> None: 

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

540 

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

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

543 the pipeline default is, currently 

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

545 lsst.isr INFO: Masking defects. 

546 """ 

547 CliLog.initLog(longlog=longlog) 

548 

549 

550def getCurrentDayObs_datetime() -> datetime.date: 

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

552 

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

554 """ 

555 utc = gettz("UTC") 

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

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

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

559 return dayObs 

560 

561 

562def getCurrentDayObs_int() -> int: 

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

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

565 

566 

567def getCurrentDayObs_humanStr() -> str: 

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

569 return dayObsIntToString(getCurrentDayObs_int()) 

570 

571 

572def getExpRecordAge(expRecord: dafButler.DimensionRecord) -> float: 

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

574 

575 Parameters 

576 ---------- 

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

578 The exposure record. 

579 

580 Returns 

581 ------- 

582 age : `float` 

583 The age of the exposure, in seconds. 

584 """ 

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

586 

587 

588def getSite() -> str: 

589 """Returns where the code is running. 

590 

591 Returns 

592 ------- 

593 location : `str` 

594 One of: 

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

596 'usdf-k8s'] 

597 

598 Raises 

599 ------ 

600 ValueError 

601 Raised if location cannot be determined. 

602 """ 

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

604 # identifies it. 

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

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

607 return "tucson" 

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

609 return "summit" 

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

611 return "base" 

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

613 return "staff-rsp" 

614 

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

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

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

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

619 return "rubin-devl" 

620 

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

622 if jenkinsHome != "": 622 ↛ 626line 622 didn't jump to line 626, because the condition on line 622 was always true

623 return "jenkins" 

624 

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

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

627 if location == "TTS": 

628 return "tucson" 

629 if location == "BTS": 

630 return "base" 

631 if location == "SUMMIT": 

632 return "summit" 

633 if location == "USDF": 

634 return "usdf-k8s" 

635 

636 # we have failed 

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

638 

639 

640def getAltAzFromSkyPosition( 

641 skyPos: geom.SpherePoint, 

642 visitInfo: afwImage.VisitInfo, 

643 doCorrectRefraction: bool = False, 

644 wavelength: float = 500.0, 

645 pressureOverride: float | None = None, 

646 temperatureOverride: float | None = None, 

647 relativeHumidityOverride: float | None = None, 

648) -> tuple[geom.Angle, geom.Angle]: 

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

650 of the observation. 

651 

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

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

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

655 so this takes a default value of 500nm. 

656 

657 Parameters 

658 ---------- 

659 skyPos : `lsst.geom.SpherePoint` 

660 The position on the sky. 

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

662 The visit info containing the time of the observation. 

663 doCorrectRefraction : `bool`, optional 

664 Correct for the atmospheric refraction? 

665 wavelength : `float`, optional 

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

667 pressureOverride : `float`, optional 

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

669 the visitInfo, as a float. 

670 temperatureOverride : `float`, optional 

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

672 in the visitInfo, as a float. 

673 relativeHumidityOverride : `float`, optional 

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

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

676 

677 Returns 

678 ------- 

679 alt : `lsst.geom.Angle` 

680 The altitude. 

681 az : `lsst.geom.Angle` 

682 The azimuth. 

683 """ 

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

685 long = visitInfo.observatory.getLongitude() 

686 lat = visitInfo.observatory.getLatitude() 

687 ele = visitInfo.observatory.getElevation() 

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

689 

690 refractionKwargs = {} 

691 if doCorrectRefraction: 

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

693 wavelength = wavelength * u.nm 

694 

695 if pressureOverride: 

696 pressure = pressureOverride 

697 else: 

698 pressure = visitInfo.weather.getAirPressure() 

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

700 # convert from pascals to bars 

701 pressure /= 100000.0 

702 pressure = pressure * u.bar 

703 

704 if temperatureOverride: 

705 temperature = temperatureOverride 

706 else: 

707 temperature = visitInfo.weather.getAirTemperature() 

708 temperature = temperature * u.deg_C 

709 

710 if relativeHumidityOverride: 

711 relativeHumidity = relativeHumidityOverride 

712 else: 

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

714 relativeHumidity = relativeHumidity 

715 

716 refractionKwargs = dict( 

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

718 ) 

719 

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

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

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

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

724 

725 obsAltAz = skyLocation.transform_to(altAz) 

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

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

728 

729 return alt, az 

730 

731 

732def getExpPositionOffset( 

733 exp1: afwImage.Exposure, 

734 exp2: afwImage.Exposure, 

735 useWcs: bool = True, 

736 allowDifferentPlateScales: bool = False, 

737) -> pipeBase.Struct: 

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

739 

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

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

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

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

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

745 of whether astrometric fitting has been performed. 

746 

747 Values are given as exp1-exp2. 

748 

749 Parameters 

750 ---------- 

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

752 The first exposure. 

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

754 The second exposure. 

755 useWcs : `bool` 

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

757 boresight values from the exposures' visitInfos. 

758 allowDifferentPlateScales : `bool`, optional 

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

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

761 has been undertaken during commissioning plate scales can be different 

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

763 

764 Returns 

765 ------- 

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

767 A struct containing the offsets: 

768 ``deltaRa`` 

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

770 ``deltaDec`` 

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

772 ``deltaAlt`` 

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

774 ``deltaAz`` 

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

776 ``deltaPixels`` 

777 The diference in pixels (`float`) 

778 """ 

779 

780 wcs1 = exp1.getWcs() 

781 wcs2 = exp2.getWcs() 

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

783 if not allowDifferentPlateScales: 

784 assert np.isclose( 

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

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

787 

788 if useWcs: 

789 p1 = wcs1.getSkyOrigin() 

790 p2 = wcs2.getSkyOrigin() 

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

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

793 ra1 = p1[0] 

794 ra2 = p2[0] 

795 dec1 = p1[1] 

796 dec2 = p2[1] 

797 else: 

798 az1 = exp1.visitInfo.boresightAzAlt[0] 

799 az2 = exp2.visitInfo.boresightAzAlt[0] 

800 alt1 = exp1.visitInfo.boresightAzAlt[1] 

801 alt2 = exp2.visitInfo.boresightAzAlt[1] 

802 

803 ra1 = exp1.visitInfo.boresightRaDec[0] 

804 ra2 = exp2.visitInfo.boresightRaDec[0] 

805 dec1 = exp1.visitInfo.boresightRaDec[1] 

806 dec2 = exp2.visitInfo.boresightRaDec[1] 

807 

808 p1 = exp1.visitInfo.boresightRaDec 

809 p2 = exp2.visitInfo.boresightRaDec 

810 

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

812 deltaPixels = angular_offset / pixScaleArcSec 

813 

814 ret = pipeBase.Struct( 

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

816 deltaDec=dec1 - dec2, 

817 deltaAlt=alt1 - alt2, 

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

819 deltaPixels=deltaPixels, 

820 ) 

821 

822 return ret 

823 

824 

825def starTrackerFileToExposure(filename: str, logger: logging.Logger | None = None) -> afwImage.Exposure: 

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

827 

828 Parameters 

829 ---------- 

830 filename : `str` 

831 The full path to the file. 

832 logger : `logging.Logger`, optional 

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

834 

835 Returns 

836 ------- 

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

838 The exposure. 

839 """ 

840 if not logger: 

841 logger = logging.getLogger(__name__) 

842 exp = afwImage.ExposureF(filename) 

843 try: 

844 wcs = genericCameraHeaderToWcs(exp) 

845 exp.setWcs(wcs) 

846 except Exception as e: 

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

848 

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

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

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

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

853 try: 

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

855 md = exp.getMetadata() 

856 

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

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

859 duration = end - begin 

860 mid = begin + duration / 2 

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

862 newArgs["date"] = newTime 

863 

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

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

866 # PRESSURE in the translator instead of this 

867 weather = exp.visitInfo.getWeather() 

868 oldPressure = weather.getAirPressure() 

869 if not np.isfinite(oldPressure): 

870 pressure = md.get("PRESSURE") 

871 if pressure is not None: 

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

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

874 newArgs["weather"] = newWeather 

875 

876 if newArgs: 

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

878 exp.info.setVisitInfo(newVi) 

879 except Exception as e: 

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

881 

882 return exp 

883 

884 

885def obsInfoToDict(obsInfo: ObservationInfo) -> dict: 

886 """Convert an ObservationInfo to a dict. 

887 

888 Parameters 

889 ---------- 

890 obsInfo : `astro_metadata_translator.ObservationInfo` 

891 The ObservationInfo to convert. 

892 

893 Returns 

894 ------- 

895 obsInfoDict : `dict` 

896 The ObservationInfo as a dict. 

897 """ 

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

899 

900 

901def getFieldNameAndTileNumber( 

902 field: str, warn: bool = True, logger: logging.Logger | None = None 

903) -> tuple[str, int]: 

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

905 

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

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

908 if no tile number is found. 

909 

910 Parameters 

911 ---------- 

912 field : `str` 

913 The name of the field 

914 

915 Returns 

916 ------- 

917 fieldName : `str` 

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

919 tileNum : `int` 

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

921 """ 

922 if warn and not logger: 

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

924 

925 if "_" not in field: 

926 if warn: 

927 logger.warning( 

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

929 ) 

930 return field, None 

931 

932 try: 

933 fieldParts = field.split("_") 

934 fieldNum = int(fieldParts[-1]) 

935 except ValueError: 

936 if warn: 

937 logger.warning( 

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

939 " so cannot determine the tile number." 

940 ) 

941 return field, None 

942 

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

944 

945 

946def getAirmassSeeingCorrection(airmass: float) -> float: 

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

948 

949 Parameters 

950 ---------- 

951 airmass : `float` 

952 The airmass, greater than or equal to 1. 

953 

954 Returns 

955 ------- 

956 correctionFactor : `float` 

957 The correction factor to apply to the seeing. 

958 

959 Raises 

960 ------ 

961 ValueError raised for unphysical airmasses. 

962 """ 

963 if airmass < 1: 

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

965 return airmass ** (-0.6) 

966 

967 

968def getFilterSeeingCorrection(filterName: str) -> float: 

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

970 

971 Parameters 

972 ---------- 

973 filterName : `str` 

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

975 

976 Returns 

977 ------- 

978 correctionFactor : `float` 

979 The correction factor to apply to the seeing. 

980 

981 Raises 

982 ------ 

983 ValueError raised for unknown filters. 

984 """ 

985 match filterName: 

986 case "SDSSg_65mm": 

987 return (474.41 / 500.0) ** 0.2 

988 case "SDSSr_65mm": 

989 return (628.47 / 500.0) ** 0.2 

990 case "SDSSi_65mm": 

991 return (769.51 / 500.0) ** 0.2 

992 case "SDSSz_65mm": 

993 return (871.45 / 500.0) ** 0.2 

994 case "SDSSy_65mm": 

995 return (986.8 / 500.0) ** 0.2 

996 case _: 

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

998 

999 

1000def getCdf( 

1001 data: np.ndarray, scale: int, nBinsMax: int = 300_000 

1002) -> tuple[Union[np.ndarray[int], float], float, float]: 

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

1004 the [0, scale] range. 

1005 

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

1007 the min and max values. 

1008 

1009 Parameters 

1010 ---------- 

1011 data : `np.array` 

1012 The input data. 

1013 scale : `int` 

1014 The scaling range of the output. 

1015 nBinsMax : `int`, optional 

1016 Maximum number of bins to use. 

1017 

1018 Returns 

1019 ------- 

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

1021 A monotonically increasing sequence that represents a scaled 

1022 cumulative distribution function, starting with the value at 

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

1024 minVal : `float` 

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

1026 maxVal : `float` 

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

1028 """ 

1029 flatData = data.ravel() 

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

1031 

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

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

1034 

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

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

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

1038 # return nans for all values 

1039 return np.nan, np.nan, np.nan 

1040 

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

1042 

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

1044 

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

1046 return cdf, minVal, maxVal 

1047 

1048 

1049def getQuantiles(data: np.ndarray, nColors: int) -> list[float]: 

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

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

1052 colors. 

1053 

1054 This is equivalent to using the numpy function: 

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

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

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

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

1059 

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

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

1062 

1063 Parameters 

1064 ---------- 

1065 data : `np.array` 

1066 The input image data. 

1067 nColors : `int` 

1068 The number of intervals to distribute data into. 

1069 

1070 Returns 

1071 ------- 

1072 boundaries: `list` of `float` 

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

1074 the edges of nColors intervals. 

1075 """ 

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

1077 # Use slower but memory efficient nanquantile 

1078 logger = logging.getLogger(__name__) 

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

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

1081 else: 

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

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

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

1085 

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

1087 

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

1089 

1090 return boundaries 

1091 

1092 

1093def digitizeData(data: np.ndarray, nColors: int = 256) -> np.ndarray[int]: 

1094 """ 

1095 Scale data into nColors using its cumulative distribution function. 

1096 

1097 Parameters 

1098 ---------- 

1099 data : `np.array` 

1100 The input image data. 

1101 nColors : `int` 

1102 The number of intervals to distribute data into. 

1103 

1104 Returns 

1105 ------- 

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

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

1108 """ 

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

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

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

1112 return cdf[bins] 

1113 

1114 

1115def getBboxAround(centroid: geom.Point, boxSize: int, exp: afwImage.Exposure) -> geom.Box2I: 

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

1117 

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

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

1120 

1121 Parameters 

1122 ---------- 

1123 centroid : `lsst.geom.Point` 

1124 The source centroid. 

1125 boxsize : `int` 

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

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

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

1129 

1130 Returns 

1131 ------- 

1132 bbox : `lsst.geom.Box2I` 

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

1134 exposure causes it to be non-square. 

1135 """ 

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

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

1138 return bbox 

1139 

1140 

1141def bboxToMatplotlibRectanle(bbox: geom.Box2I | geom.Box2D) -> matplotlib.patches.Rectangle: 

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

1143 

1144 Parameters 

1145 ---------- 

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

1147 The bbox to convert. 

1148 

1149 Returns 

1150 ------- 

1151 rectangle : `matplotlib.patches.Rectangle` 

1152 The rectangle. 

1153 """ 

1154 ll = bbox.minX, bbox.minY 

1155 width, height = bbox.getDimensions() 

1156 return Rectangle(ll, width, height)