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

622 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-13 02:47 -0800

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/>. 

21from __future__ import annotations 

22 

23import datetime 

24import logging 

25import os 

26from collections.abc import Iterable, Sequence 

27from dataclasses import dataclass 

28from typing import TYPE_CHECKING, Any 

29 

30import astropy.units as u 

31import matplotlib 

32import numpy as np 

33import numpy.typing as npt 

34import statsmodels.api as sm # type: ignore[import-untyped] 

35from astro_metadata_translator import ObservationInfo 

36from astropy.coordinates import AltAz, EarthLocation, SkyCoord, get_sun 

37from astropy.stats import mad_std 

38from astropy.time import Time 

39from dateutil.tz import gettz 

40from deprecated.sphinx import deprecated 

41from matplotlib.patches import Rectangle 

42from scipy.ndimage import gaussian_filter 

43from sklearn.linear_model import LinearRegression, RANSACRegressor # type: ignore[import-untyped] 

44 

45import lsst.afw.detection as afwDetect 

46import lsst.afw.detection as afwDetection 

47import lsst.afw.image as afwImage 

48import lsst.afw.math as afwMath 

49import lsst.daf.base as dafBase 

50import lsst.daf.butler as dafButler 

51import lsst.geom as geom 

52import lsst.obs.lsst.translators 

53import lsst.pipe.base as pipeBase 

54import lsst.utils.packages as packageUtils 

55from lsst.afw.coord import Weather 

56from lsst.afw.detection import Footprint, FootprintSet 

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

58from lsst.obs.lsst import Latiss, LsstCam, LsstComCam, LsstComCamSim 

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

60from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER, SIMONYI_LOCATION 

61 

62from .astrometry.utils import genericCameraHeaderToWcs 

63 

64if TYPE_CHECKING: 

65 from lsst.afw.cameraGeom import Camera 

66 

67__all__ = [ 

68 "SIGMATOFWHM", 

69 "FWHMTOSIGMA", 

70 "EFD_CLIENT_MISSING_MSG", 

71 "GOOGLE_CLOUD_MISSING_MSG", 

72 "AUXTEL_LOCATION", 

73 "countPixels", 

74 "quickSmooth", 

75 "argMax2d", 

76 "dayObsIntToString", 

77 "dayObsSeqNumToVisitId", 

78 "getImageStats", 

79 "detectObjectsInExp", 

80 "fluxesFromFootprints", 

81 "fluxFromFootprint", 

82 "humanNameForCelestialObject", 

83 "getFocusFromHeader", 

84 "checkStackSetup", 

85 "setupLogging", 

86 "getCurrentDayObs_datetime", 

87 "getCurrentDayObs_int", 

88 "getCurrentDayObs_humanStr", 

89 "getExpRecordAge", 

90 "getSite", 

91 "getAltAzFromSkyPosition", 

92 "getExpPositionOffset", 

93 "starTrackerFileToExposure", 

94 "obsInfoToDict", 

95 "getFieldNameAndTileNumber", 

96 "getAirmassSeeingCorrection", 

97 "getFilterSeeingCorrection", # deprecated 

98 "getBandpassSeeingCorrection", 

99 "getCdf", 

100 "getQuantiles", 

101 "digitizeData", 

102 "getBboxAround", 

103 "bboxToMatplotlibRectanle", 

104 "computeExposureId", 

105 "computeCcdExposureId", 

106 "getDetectorIds", 

107 "getImageArray", 

108 "getSunAngle", 

109 "RobustFitter", 

110] 

111 

112 

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

114FWHMTOSIGMA = 1 / SIGMATOFWHM 

115 

116EFD_CLIENT_MISSING_MSG = ( 

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

118) 

119 

120GOOGLE_CLOUD_MISSING_MSG = ( 

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

122 " pip install google-cloud-storage" 

123) 

124 

125 

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

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

128 

129 Parameters 

130 ---------- 

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

132 The masked image, 

133 maskPlane : `str` 

134 The name of the bitmask. 

135 

136 Returns 

137 ------- 

138 count : `int`` 

139 The number of pixels in with the selected mask bit 

140 """ 

141 bit = maskedImage.mask.getPlaneBitMask(maskPlane) 

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

143 

144 

145def quickSmooth(data: npt.NDArray[np.float64], sigma: float = 2) -> npt.NDArray[np.float64]: 

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

147 

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

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

150 

151 Parameters 

152 ---------- 

153 data : `np.array` 

154 The image data to smooth 

155 sigma : `float`, optional 

156 The size of the smoothing kernel. 

157 

158 Returns 

159 ------- 

160 smoothData : `np.array` 

161 The smoothed data 

162 """ 

163 kernel = [sigma, sigma] 

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

165 return smoothData 

166 

167 

168def argMax2d(array: npt.NDArray[np.float64]) -> tuple[tuple[float, float], bool, list[tuple[float, float]]]: 

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

170 

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

172 maximum value, e.g. returns 

173 

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

175 

176 Parameters 

177 ---------- 

178 array : `np.array` 

179 The data 

180 

181 Returns 

182 ------- 

183 maxLocation : `tuple` 

184 The coords of the first instance of the max value 

185 unique : `bool` 

186 Whether it's the only location 

187 otherLocations : `list` of `tuple` 

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

189 """ 

190 uniqueMaximum = False 

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

192 # list of coords as tuples 

193 listMaxCoords = [(float(coord[0]), float(coord[1])) for coord in zip(*maxCoords)] 

194 if len(listMaxCoords) == 1: # single unambiguous value 

195 uniqueMaximum = True 

196 

197 return listMaxCoords[0], uniqueMaximum, listMaxCoords[1:] 

198 

199 

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

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

202 

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

204 

205 Parameters 

206 ---------- 

207 dayObs : `int` 

208 The dayObs. 

209 

210 Returns 

211 ------- 

212 dayObs : `str` 

213 The dayObs as a string. 

214 """ 

215 assert isinstance(dayObs, int) 

216 dStr = str(dayObs) 

217 assert len(dStr) == 8 

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

219 

220 

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

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

223 

224 Parameters 

225 ---------- 

226 dayObs : `int` 

227 The dayObs. 

228 seqNum : `int` 

229 The seqNum. 

230 

231 Returns 

232 ------- 

233 visitId : `int` 

234 The visitId. 

235 

236 Notes 

237 ----- 

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

239 programatically/via the butler. 

240 """ 

241 if dayObs < 19700101 or dayObs > 35000101: 

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

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

244 

245 

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

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

248 

249 Parameters 

250 ---------- 

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

252 The input exposure. 

253 

254 Returns 

255 ------- 

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

257 A container with attributes containing measurements and statistics 

258 for the image. 

259 """ 

260 result = pipeBase.Struct() 

261 

262 vi = exp.visitInfo 

263 expTime = vi.exposureTime 

264 md = exp.getMetadata() 

265 

266 obj = vi.object 

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

268 result.object = obj 

269 result.mjd = mjd 

270 

271 fullFilterString = exp.filter.physicalLabel 

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

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

274 

275 airmass = vi.getBoresightAirmass() 

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

277 

278 azAlt = vi.getBoresightAzAlt() 

279 az = azAlt[0].asDegrees() 

280 el = azAlt[1].asDegrees() 

281 

282 result.expTime = expTime 

283 result.filter = filt 

284 result.grating = grating 

285 result.airmass = airmass 

286 result.rotangle = rotangle 

287 result.az = az 

288 result.el = el 

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

290 

291 data = exp.image.array 

292 result.maxValue = np.max(data) 

293 

294 peak, uniquePeak, otherPeaks = argMax2d(data) 

295 result.maxPixelLocation = peak 

296 result.multipleMaxPixels = uniquePeak 

297 

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

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

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

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

302 

303 sctrl = afwMath.StatisticsControl() 

304 sctrl.setNumSigmaClip(5) 

305 sctrl.setNumIter(2) 

306 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP 

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

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

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

310 

311 result.clippedMean = mean 

312 result.clippedStddev = std 

313 

314 return result 

315 

316 

317def detectObjectsInExp( 

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

319) -> afwDetect.FootprintSet: 

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

321 

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

323 

324 Parameters 

325 ---------- 

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

327 The exposure to detect objects in. 

328 nSigma : `float` 

329 The number of sigma for detection. 

330 nPixMin : `int` 

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

332 grow : `int` 

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

334 

335 Returns 

336 ------- 

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

338 The set of footprints in the image. 

339 """ 

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

341 exp.image -= median 

342 

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

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

345 if grow > 0: 

346 isotropic = True 

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

348 

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

350 return footPrintSet 

351 

352 

353def fluxesFromFootprints( 

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

355 parentImage: afwImage.Image, 

356 subtractImageMedian: bool = False, 

357) -> npt.NDArray[np.float64]: 

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

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

360 rough background subtraction. 

361 

362 Parameters 

363 ---------- 

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

365 `lsst.afw.detection.Footprint` or 

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

367 The footprints to measure. 

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

369 The parent image. 

370 subtractImageMedian : `bool`, optional 

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

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

373 original image. 

374 

375 Returns 

376 ------- 

377 fluxes : `list` of `float` 

378 The fluxes for each footprint. 

379 

380 Raises 

381 ------ 

382 TypeError : raise for unsupported types. 

383 """ 

384 median = 0 

385 if subtractImageMedian: 

386 median = np.nanmedian(parentImage.array) 

387 

388 # poor person's single dispatch 

389 badTypeMsg = ( 

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

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

392 ) 

393 if isinstance(footprints, FootprintSet): 

394 footprints = footprints.getFootprints() 

395 elif isinstance(footprints, Sequence): 

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

397 raise TypeError(badTypeMsg) 

398 elif isinstance(footprints, Footprint): 

399 footprints = [footprints] 

400 else: 

401 raise TypeError(badTypeMsg) 

402 

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

404 

405 

406def fluxFromFootprint( 

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

408) -> float: 

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

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

411 subtraction, e.g. the image median. 

412 

413 Parameters 

414 ---------- 

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

416 The footprint to measure. 

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

418 Image containing the footprint. 

419 backgroundValue : `bool`, optional 

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

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

422 image. 

423 

424 Returns 

425 ------- 

426 flux : `float` 

427 The flux in the footprint 

428 """ 

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

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

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

432 return footprint.computeFluxFromImage(parentImage) 

433 

434 

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

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

437 

438 Parameters 

439 ---------- 

440 objName : `str` 

441 The/a name of the object. 

442 

443 Returns 

444 ------- 

445 names : `list` of `str` 

446 The names found for the object 

447 """ 

448 from astroquery.simbad import Simbad 

449 

450 results = [] 

451 try: 

452 simbadResult = Simbad.query_objectids(objName) 

453 for row in simbadResult: 

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

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

456 return results 

457 except Exception: 

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

459 

460 

461def _getAltAzZenithsFromSeqNum( 

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

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

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

465 

466 Parameters 

467 ---------- 

468 butler : `lsst.daf.butler.Butler` 

469 The butler to query. 

470 dayObs : `int` 

471 The dayObs. 

472 seqNumList : `list` of `int` 

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

474 

475 Returns 

476 ------- 

477 azimuths : `list` of `float` 

478 List of the azimuths for each seqNum 

479 elevations : `list` of `float` 

480 List of the elevations for each seqNum 

481 zeniths : `list` of `float` 

482 List of the zenith angles for each seqNum 

483 """ 

484 azimuths, elevations, zeniths = [], [], [] 

485 for seqNum in seqNumList: 

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

487 obsInfo = ObservationInfo(md) 

488 alt = obsInfo.altaz_begin.alt.value 

489 az = obsInfo.altaz_begin.az.value 

490 elevations.append(alt) 

491 zeniths.append(90 - alt) 

492 azimuths.append(az) 

493 return azimuths, elevations, zeniths 

494 

495 

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

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

498 

499 Parameters 

500 ---------- 

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

502 The exposure. 

503 

504 Returns 

505 ------- 

506 focus : `float` or `None` 

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

508 """ 

509 md = exp.getMetadata() 

510 if "FOCUSZ" in md: 

511 return md["FOCUSZ"] 

512 return None 

513 

514 

515def checkStackSetup() -> None: 

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

517 

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

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

520 the path to each. 

521 

522 Notes 

523 ----- 

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

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

526 """ 

527 packages = packageUtils.getEnvironmentPackages(include_all=True) 

528 

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

530 lsstDistribTags = lsstDistribHashAndTags.split()[1] 

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

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

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

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

535 else: # multiple weekly tags found for lsst_distrib! 

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

537 

538 localPackages = [] 

539 localPaths = [] 

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

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

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

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

544 localPaths.append(path) 

545 localPackages.append(package) 

546 

547 if localPackages: 

548 print("\nLocally setup packages:") 

549 print("-----------------------") 

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

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

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

553 else: 

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

555 

556 

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

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

559 

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

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

562 the pipeline default is, currently 

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

564 lsst.isr INFO: Masking defects. 

565 """ 

566 CliLog.initLog(longlog=longlog) 

567 

568 

569def getCurrentDayObs_datetime() -> datetime.date: 

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

571 

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

573 """ 

574 utc = gettz("UTC") 

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

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

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

578 return dayObs 

579 

580 

581def getCurrentDayObs_int() -> int: 

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

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

584 

585 

586def getCurrentDayObs_humanStr() -> str: 

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

588 return dayObsIntToString(getCurrentDayObs_int()) 

589 

590 

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

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

593 

594 Parameters 

595 ---------- 

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

597 The exposure record. 

598 

599 Returns 

600 ------- 

601 age : `float` 

602 The age of the exposure, in seconds. 

603 """ 

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

605 

606 

607def getSite() -> str: 

608 """Returns where the code is running. 

609 

610 Returns 

611 ------- 

612 location : `str` 

613 One of: 

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

615 'usdf-k8s'] 

616 If the location cannot be determined "UNKOWN" is returned. 

617 

618 """ 

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

620 # identifies it. 

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

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

623 return "tucson" 

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

625 return "summit" 

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

627 return "base" 

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

629 return "staff-rsp" 

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

631 return "staff-rsp" # we don't care this is the dev RSP, it's basically the same env wrt paths etc 

632 

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

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

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

636 interactiveNodes = ("sdfrome", "sdfiana") 

637 if hostname.startswith(interactiveNodes): 637 ↛ 638line 637 didn't jump to line 638 because the condition on line 637 was never true

638 return "rubin-devl" 

639 elif hostname == "htcondor.ls.lsst.org": 639 ↛ 640line 639 didn't jump to line 640 because the condition on line 639 was never true

640 return "base" 

641 elif hostname == "htcondor.cp.lsst.org": 641 ↛ 642line 641 didn't jump to line 642 because the condition on line 641 was never true

642 return "summit" 

643 

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

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

646 return "jenkins" 

647 

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

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

650 if location == "TTS": 

651 return "tucson" 

652 if location == "BTS": 

653 return "base" 

654 if location == "SUMMIT": 

655 return "summit" 

656 if location == "USDF": 

657 return "usdf-k8s" 

658 

659 # we have failed 

660 return "UNKOWN" 

661 

662 

663def getAltAzFromSkyPosition( 

664 skyPos: geom.SpherePoint, 

665 visitInfo: afwImage.VisitInfo, 

666 doCorrectRefraction: bool = False, 

667 wavelength: float = 500.0, 

668 pressureOverride: float | None = None, 

669 temperatureOverride: float | None = None, 

670 relativeHumidityOverride: float | None = None, 

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

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

673 of the observation. 

674 

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

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

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

678 so this takes a default value of 500nm. 

679 

680 Parameters 

681 ---------- 

682 skyPos : `lsst.geom.SpherePoint` 

683 The position on the sky. 

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

685 The visit info containing the time of the observation. 

686 doCorrectRefraction : `bool`, optional 

687 Correct for the atmospheric refraction? 

688 wavelength : `float`, optional 

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

690 pressureOverride : `float`, optional 

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

692 the visitInfo, as a float. 

693 temperatureOverride : `float`, optional 

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

695 in the visitInfo, as a float. 

696 relativeHumidityOverride : `float`, optional 

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

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

699 

700 Returns 

701 ------- 

702 alt : `lsst.geom.Angle` 

703 The altitude. 

704 az : `lsst.geom.Angle` 

705 The azimuth. 

706 """ 

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

708 long = visitInfo.observatory.getLongitude() 

709 lat = visitInfo.observatory.getLatitude() 

710 ele = visitInfo.observatory.getElevation() 

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

712 

713 refractionKwargs = {} 

714 if doCorrectRefraction: 

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

716 wavelength = wavelength * u.nm 

717 

718 if pressureOverride: 

719 pressure = pressureOverride 

720 else: 

721 pressure = visitInfo.weather.getAirPressure() 

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

723 # convert from pascals to bars 

724 pressure /= 100000.0 

725 pressure = pressure * u.bar 

726 

727 if temperatureOverride: 

728 temperature = temperatureOverride 

729 else: 

730 temperature = visitInfo.weather.getAirTemperature() 

731 temperature = temperature * u.deg_C 

732 

733 if relativeHumidityOverride: 

734 relativeHumidity = relativeHumidityOverride 

735 else: 

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

737 relativeHumidity = relativeHumidity 

738 

739 refractionKwargs = dict( 

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

741 ) 

742 

743 obsTime = visitInfo.date.toAstropy() 

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

745 

746 obsAltAz = skyLocation.transform_to(altAz) 

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

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

749 

750 return alt, az 

751 

752 

753def getExpPositionOffset( 

754 exp1: afwImage.Exposure, 

755 exp2: afwImage.Exposure, 

756 useWcs: bool = True, 

757 allowDifferentPlateScales: bool = False, 

758) -> pipeBase.Struct: 

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

760 

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

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

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

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

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

766 of whether astrometric fitting has been performed. 

767 

768 Values are given as exp1-exp2. 

769 

770 Parameters 

771 ---------- 

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

773 The first exposure. 

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

775 The second exposure. 

776 useWcs : `bool` 

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

778 boresight values from the exposures' visitInfos. 

779 allowDifferentPlateScales : `bool`, optional 

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

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

782 has been undertaken during commissioning plate scales can be different 

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

784 

785 Returns 

786 ------- 

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

788 A struct containing the offsets: 

789 ``deltaRa`` 

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

791 ``deltaDec`` 

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

793 ``deltaAlt`` 

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

795 ``deltaAz`` 

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

797 ``deltaPixels`` 

798 The diference in pixels (`float`) 

799 """ 

800 

801 wcs1 = exp1.getWcs() 

802 wcs2 = exp2.getWcs() 

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

804 if not allowDifferentPlateScales: 

805 assert np.isclose( 

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

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

808 

809 if useWcs: 

810 p1 = wcs1.getSkyOrigin() 

811 p2 = wcs2.getSkyOrigin() 

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

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

814 ra1 = p1[0] 

815 ra2 = p2[0] 

816 dec1 = p1[1] 

817 dec2 = p2[1] 

818 else: 

819 az1 = exp1.visitInfo.boresightAzAlt[0] 

820 az2 = exp2.visitInfo.boresightAzAlt[0] 

821 alt1 = exp1.visitInfo.boresightAzAlt[1] 

822 alt2 = exp2.visitInfo.boresightAzAlt[1] 

823 

824 ra1 = exp1.visitInfo.boresightRaDec[0] 

825 ra2 = exp2.visitInfo.boresightRaDec[0] 

826 dec1 = exp1.visitInfo.boresightRaDec[1] 

827 dec2 = exp2.visitInfo.boresightRaDec[1] 

828 

829 p1 = exp1.visitInfo.boresightRaDec 

830 p2 = exp2.visitInfo.boresightRaDec 

831 

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

833 deltaPixels = angular_offset / pixScaleArcSec 

834 

835 ret = pipeBase.Struct( 

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

837 deltaDec=dec1 - dec2, 

838 deltaAlt=alt1 - alt2, 

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

840 deltaPixels=deltaPixels, 

841 ) 

842 

843 return ret 

844 

845 

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

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

848 

849 Parameters 

850 ---------- 

851 filename : `str` 

852 The full path to the file. 

853 logger : `logging.Logger`, optional 

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

855 

856 Returns 

857 ------- 

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

859 The exposure. 

860 """ 

861 if not logger: 

862 logger = logging.getLogger(__name__) 

863 exp = afwImage.ExposureF(filename) 

864 try: 

865 wcs = genericCameraHeaderToWcs(exp) 

866 exp.setWcs(wcs) 

867 except Exception as e: 

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

869 

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

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

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

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

874 try: 

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

876 md = exp.getMetadata() 

877 

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

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

880 duration = end - begin 

881 mid = begin + duration / 2 

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

883 newArgs["date"] = newTime 

884 

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

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

887 # PRESSURE in the translator instead of this 

888 weather = exp.visitInfo.getWeather() 

889 oldPressure = weather.getAirPressure() 

890 if not np.isfinite(oldPressure): 

891 pressure = md.get("PRESSURE") 

892 if pressure is not None: 

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

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

895 newArgs["weather"] = newWeather 

896 

897 if newArgs: 

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

899 exp.info.setVisitInfo(newVi) 

900 except Exception as e: 

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

902 

903 return exp 

904 

905 

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

907 """Convert an ObservationInfo to a dict. 

908 

909 Parameters 

910 ---------- 

911 obsInfo : `astro_metadata_translator.ObservationInfo` 

912 The ObservationInfo to convert. 

913 

914 Returns 

915 ------- 

916 obsInfoDict : `dict` 

917 The ObservationInfo as a dict. 

918 """ 

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

920 

921 

922def getFieldNameAndTileNumber( 

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

924) -> tuple[str, int | None]: 

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

926 

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

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

929 if no tile number is found. 

930 

931 Parameters 

932 ---------- 

933 field : `str` 

934 The name of the field 

935 

936 Returns 

937 ------- 

938 fieldName : `str` 

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

940 tileNum : `int` 

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

942 """ 

943 if warn and not logger: 

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

945 

946 if "_" not in field: 

947 if warn and logger is not None: 

948 logger.warning( 

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

950 ) 

951 return field, None 

952 

953 try: 

954 fieldParts = field.split("_") 

955 fieldNum = int(fieldParts[-1]) 

956 except ValueError: 

957 if warn and logger is not None: 

958 logger.warning( 

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

960 " so cannot determine the tile number." 

961 ) 

962 return field, None 

963 

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

965 

966 

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

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

969 

970 Parameters 

971 ---------- 

972 airmass : `float` 

973 The airmass, greater than or equal to 1. 

974 

975 Returns 

976 ------- 

977 correctionFactor : `float` 

978 The correction factor to apply to the seeing. 

979 

980 Raises 

981 ------ 

982 ValueError raised for unphysical airmasses. 

983 """ 

984 if airmass < 1: 

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

986 return airmass ** (-0.6) 

987 

988 

989@deprecated( 

990 reason=". Will be removed after v28.0.", 

991 version="v27.0", 

992 category=FutureWarning, 

993) 

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

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

996 

997 Parameters 

998 ---------- 

999 filterName : `str` 

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

1001 

1002 Returns 

1003 ------- 

1004 correctionFactor : `float` 

1005 The correction factor to apply to the seeing. 

1006 

1007 Raises 

1008 ------ 

1009 ValueError raised for unknown filters. 

1010 """ 

1011 return getBandpassSeeingCorrection(filterName) 

1012 

1013 

1014def getBandpassSeeingCorrection(filterName: str) -> float: 

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

1016 

1017 Parameters 

1018 ---------- 

1019 filterName : `str` 

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

1021 

1022 Returns 

1023 ------- 

1024 correctionFactor : `float` 

1025 The correction factor to apply to the seeing. 

1026 

1027 Raises 

1028 ------ 

1029 ValueError raised for unknown filters. 

1030 """ 

1031 match filterName: 

1032 case "SDSSg_65mm": # LATISS 

1033 return (474.41 / 500.0) ** 0.2 

1034 case "SDSSr_65mm": # LATISS 

1035 return (628.47 / 500.0) ** 0.2 

1036 case "SDSSi_65mm": # LATISS 

1037 return (769.51 / 500.0) ** 0.2 

1038 case "SDSSz_65mm": # LATISS 

1039 return (871.45 / 500.0) ** 0.2 

1040 case "SDSSy_65mm": # LATISS 

1041 return (986.8 / 500.0) ** 0.2 

1042 case "u_02": # ComCam 

1043 return (370.697 / 500.0) ** 0.2 

1044 case "g_01": # ComCam 

1045 return (476.359 / 500.0) ** 0.2 

1046 case "r_03": # ComCam 

1047 return (619.383 / 500.0) ** 0.2 

1048 case "i_06": # ComCam 

1049 return (754.502 / 500.0) ** 0.2 

1050 case "z_03": # ComCam 

1051 return (866.976 / 500.0) ** 0.2 

1052 case "y_04": # ComCam 

1053 return (972.713 / 500.0) ** 0.2 

1054 case "u_24": # LSSTCam 

1055 return (365.1397 / 500.0) ** 0.2 

1056 case "g_6": # LSSTCam 

1057 return (475.6104 / 500.0) ** 0.2 

1058 case "r_57": # LSSTCam 

1059 return (618.6055 / 500.0) ** 0.2 

1060 case "i_39": # LSSTCam 

1061 return (753.5490 / 500.0) ** 0.2 

1062 case "z_20": # LSSTCam 

1063 return (868.0935 / 500.0) ** 0.2 

1064 case "y_10": # LSSTCam 

1065 return (972.5979 / 500.0) ** 0.2 

1066 case _: 

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

1068 

1069 

1070def getCdf(data: np.ndarray, scale: int, nBinsMax: int = 300_000) -> tuple[np.ndarray | float, float, float]: 

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

1072 the [0, scale] range. 

1073 

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

1075 the min and max values. 

1076 

1077 Parameters 

1078 ---------- 

1079 data : `np.array` 

1080 The input data. 

1081 scale : `int` 

1082 The scaling range of the output. 

1083 nBinsMax : `int`, optional 

1084 Maximum number of bins to use. 

1085 

1086 Returns 

1087 ------- 

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

1089 A monotonically increasing sequence that represents a scaled 

1090 cumulative distribution function, starting with the value at 

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

1092 minVal : `float` 

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

1094 maxVal : `float` 

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

1096 """ 

1097 flatData = data.ravel() 

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

1099 

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

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

1102 

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

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

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

1106 # return nans for all values 

1107 return np.nan, np.nan, np.nan 

1108 

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

1110 

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

1112 

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

1114 return cdf, minVal, maxVal 

1115 

1116 

1117def getQuantiles(data: npt.NDArray[np.float64], nColors: int) -> npt.NDArray[np.float64]: 

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

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

1120 colors. 

1121 

1122 This is equivalent to using the numpy function: 

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

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

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

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

1127 

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

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

1130 

1131 Parameters 

1132 ---------- 

1133 data : `np.array` 

1134 The input image data. 

1135 nColors : `int` 

1136 The number of intervals to distribute data into. 

1137 

1138 Returns 

1139 ------- 

1140 boundaries: `list` of `float` 

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

1142 the edges of nColors intervals. 

1143 """ 

1144 dataRange = np.nanmax(data) - np.nanmin(data) 

1145 if dataRange > 300_000: 

1146 # Use slower but memory efficient nanquantile 

1147 logger = logging.getLogger(__name__) 

1148 logger.warning(f"Data range is very large ({dataRange}); using slower quantile code.") 

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

1150 else: 

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

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

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

1154 assert isinstance(cdf, np.ndarray), "cdf is not an np.ndarray" 

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

1156 

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

1158 

1159 return boundaries 

1160 

1161 

1162def digitizeData(data: npt.NDArray[np.float64], nColors: int = 256) -> npt.NDArray[np.integer]: 

1163 """ 

1164 Scale data into nColors using its cumulative distribution function. 

1165 

1166 Parameters 

1167 ---------- 

1168 data : `np.array` 

1169 The input image data. 

1170 nColors : `int` 

1171 The number of intervals to distribute data into. 

1172 

1173 Returns 

1174 ------- 

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

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

1177 """ 

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

1179 assert isinstance(cdf, np.ndarray), "cdf is not an np.ndarray" 

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

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

1182 return cdf[bins] 

1183 

1184 

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

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

1187 

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

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

1190 

1191 Parameters 

1192 ---------- 

1193 centroid : `lsst.geom.Point` 

1194 The source centroid. 

1195 boxsize : `int` 

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

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

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

1199 

1200 Returns 

1201 ------- 

1202 bbox : `lsst.geom.Box2I` 

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

1204 exposure causes it to be non-square. 

1205 """ 

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

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

1208 return bbox 

1209 

1210 

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

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

1213 

1214 Parameters 

1215 ---------- 

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

1217 The bbox to convert. 

1218 

1219 Returns 

1220 ------- 

1221 rectangle : `matplotlib.patches.Rectangle` 

1222 The rectangle. 

1223 """ 

1224 ll = bbox.minX, bbox.minY 

1225 width, height = bbox.getDimensions() 

1226 return Rectangle(ll, width, height) 

1227 

1228 

1229def computeExposureId(instrument: str, controller: str, dayObs: int, seqNum: int) -> int: 

1230 instrument = instrument.lower() 

1231 if instrument == "latiss": 

1232 return lsst.obs.lsst.translators.LatissTranslator.compute_exposure_id(dayObs, seqNum, controller) 

1233 elif instrument == "lsstcomcam": 

1234 return lsst.obs.lsst.translators.LsstComCamTranslator.compute_exposure_id(dayObs, seqNum, controller) 

1235 elif instrument == "lsstcomcamsim": 

1236 return lsst.obs.lsst.translators.LsstComCamSimTranslator.compute_exposure_id( 

1237 dayObs, seqNum, controller 

1238 ) 

1239 elif instrument == "lsstcam": 

1240 return lsst.obs.lsst.translators.LsstCamTranslator.compute_exposure_id(dayObs, seqNum, controller) 

1241 else: 

1242 raise ValueError("Unknown instrument {instrument}") 

1243 

1244 

1245def computeCcdExposureId(instrument: str, exposureId: int, detector: int) -> int: 

1246 instrument = instrument.lower() 

1247 if instrument == "latiss": 

1248 if detector != 0: 

1249 raise ValueError("Invalid detector {detector} for LATISS") 

1250 return lsst.obs.lsst.translators.LatissTranslator.compute_detector_exposure_id(exposureId, detector) 

1251 elif instrument == "lsstcomcam": 

1252 if detector < 0 or detector >= 9: 

1253 raise ValueError("Invalid detector {detector} for LSSTComCam") 

1254 return lsst.obs.lsst.translators.LsstComCamTranslator.compute_detector_exposure_id( 

1255 exposureId, detector 

1256 ) 

1257 elif instrument == "lsstcomcamsim": 

1258 if detector < 0 or detector >= 9: 

1259 raise ValueError("Invalid detector {detector} for LSSTComCamSim") 

1260 return lsst.obs.lsst.translators.LsstComCamSimTranslator.compute_detector_exposure_id( 

1261 exposureId, detector 

1262 ) 

1263 elif instrument == "lsstcam": 

1264 if detector < 0 or detector >= 205: 

1265 raise ValueError("Invalid detector {detector} for LSSTCam") 

1266 return lsst.obs.lsst.translators.LsstCamTranslator.compute_detector_exposure_id(exposureId, detector) 

1267 else: 

1268 raise ValueError("Unknown instrument {instrument}") 

1269 

1270 

1271def getDetectorIds(instrumentName: str) -> list[int]: 

1272 """Get a list of detector IDs for a given instrument. 

1273 

1274 Parameters 

1275 ---------- 

1276 instrumentName : `str` 

1277 The name of the instrument. 

1278 

1279 Returns 

1280 ------- 

1281 detectorIds : `list` of `int` 

1282 The list of detector IDs. 

1283 """ 

1284 camera = getCameraFromInstrumentName(instrumentName) 

1285 return [detector.getId() for detector in camera] 

1286 

1287 

1288def getCameraFromInstrumentName(instrumentName: str) -> Camera: 

1289 """Get the camera object given the instrument name (case insenstive). 

1290 

1291 Parameters 

1292 ---------- 

1293 instrumentName : `str` 

1294 The name of the instrument, e.g. "LATISS" or "LSSTCam". Case 

1295 insenstive. 

1296 

1297 Returns 

1298 ------- 

1299 camera: `lsst.afw.cameraGeom.Camera` 

1300 The camera object corresponding to the instrument name. 

1301 

1302 Raises 

1303 ------ 

1304 ValueError 

1305 If the instrument name is not supported. 

1306 """ 

1307 

1308 _instrument = instrumentName.lower() 

1309 

1310 match _instrument: 

1311 case "lsstcam": 

1312 camera = LsstCam.getCamera() 

1313 case "lsstcomcam": 

1314 camera = LsstComCam.getCamera() 

1315 case "lsstcomcamsim": 

1316 camera = LsstComCamSim.getCamera() 

1317 case "latiss": 

1318 camera = Latiss.getCamera() 

1319 case _: 

1320 raise ValueError(f"Unsupported instrument: {instrumentName}") 

1321 return camera 

1322 

1323 

1324def calcEclipticCoords(ra: float, dec: float) -> tuple[float, float]: 

1325 """Get the ecliptic coordinates of the specified ra and dec. 

1326 

1327 Transform J2000 (ra, dec), both in degrees, to 

1328 IAU1976 Ecliptic coordinates (also returning degrees). 

1329 

1330 Matches the results of: 

1331 

1332 from astropy.coordinates import SkyCoord, HeliocentricEclipticIAU76 

1333 from dataclasses import dataclass 

1334 import astropy.units as u 

1335 

1336 p = SkyCoord(ra=ra0*u.deg, dec=dec0*u.deg, distance=1*u.au, frame='hcrs') 

1337 ecl = p.transform_to(HeliocentricEclipticIAU76) 

1338 print(ecl.lon.value, ecl.lat.value) 

1339 

1340 except that it's fast. 

1341 

1342 Parameters 

1343 ---------- 

1344 ra : `float` 

1345 The right ascension, in degrees. 

1346 dec : `float` 

1347 The declination, in degrees. 

1348 

1349 Returns 

1350 ------- 

1351 lambda : `float` 

1352 The ecliptic longitude in degrees. 

1353 beta : `float` 

1354 The ecliptic latitude in degrees. 

1355 """ 

1356 

1357 ra, dec = np.deg2rad(ra), np.deg2rad(dec) 

1358 

1359 # IAU 1976 obliquity 

1360 epsilon = np.deg2rad(23.43929111111111) 

1361 cos_eps, sin_eps = np.cos(epsilon), np.sin(epsilon) 

1362 

1363 sra, cra = np.sin(ra), np.cos(ra) 

1364 sdec, cdec = np.sin(dec), np.cos(dec) 

1365 

1366 lambda_ = np.arctan2((sra * cos_eps + sdec / cdec * sin_eps), cra) 

1367 beta = np.arcsin(sdec * cos_eps - cdec * sin_eps * sra) 

1368 

1369 # normalize 

1370 if lambda_ < 0: 

1371 lambda_ += np.pi * 2 

1372 

1373 return (np.rad2deg(lambda_), np.rad2deg(beta)) 

1374 

1375 

1376def getImageArray( 

1377 inputData: np.ndarray | afwImage.Exposure | afwImage.Image | afwImage.MaskedImage, 

1378) -> np.ndarray: 

1379 """Get the image data from anything image-like. 

1380 

1381 Parameters 

1382 ---------- 

1383 inputData : `numpy.ndarray`, `lsst.afw.image.Exposure`, 

1384 `lsst.afw.image.Image`, or `lsst.afw.image.MaskedImage` 

1385 The input data. 

1386 Returns 

1387 ------- 

1388 imageData : `numpy.ndarray` 

1389 The image data. 

1390 Raises 

1391 ------ 

1392 TypeError 

1393 If the input data is not a numpy array, lsst.afw.image.Exposure, 

1394 lsst.afw.image.Image, or lsst.afw.image.MaskedImage. 

1395 """ 

1396 match inputData: 

1397 case np.ndarray(): 

1398 imageData = inputData 

1399 case afwImage.MaskedImage(): 

1400 imageData = inputData.image.array 

1401 case afwImage.Image(): 

1402 imageData = inputData.array 

1403 case afwImage.Exposure(): 

1404 imageData = inputData.image.array 

1405 case _: 

1406 raise TypeError( 

1407 "This function accepts numpy array, lsst.afw.image.Exposure components." 

1408 f" Got {type(inputData)}" 

1409 ) 

1410 return imageData 

1411 

1412 

1413def getSunAngle(time: Time | None = None) -> float: 

1414 """Get the angle of the sun to the horizon at the specified time. 

1415 

1416 If no time is specified, the current time is used. Positive numbers means 

1417 the sun is above the horizon, negative means it is below. 

1418 

1419 Returns 

1420 ------- 

1421 sun_alt : `float` 

1422 The angle of the sun to the horizon, in degrees. 

1423 """ 

1424 if time is None: 

1425 time = Time.now() 

1426 sun_altaz = get_sun(time).transform_to(AltAz(obstime=time, location=SIMONYI_LOCATION)) 

1427 return sun_altaz.alt.deg 

1428 

1429 

1430@dataclass(slots=True) 

1431class RobustFitResult: 

1432 slope: float 

1433 intercept: float 

1434 scatter: float 

1435 slopePValue: float 

1436 slopeStdErr: float 

1437 slopeTValue: float 

1438 interceptPValue: float 

1439 interceptStdErr: float 

1440 interceptTValue: float 

1441 outlierMask: np.ndarray 

1442 

1443 

1444class RobustFitter: 

1445 """ 

1446 Robust linear fit using RANSAC + OLS, reusable across datasets. 

1447 

1448 This class fits a robust linear trend to x and y using scikit-learn's 

1449 RANSAC for inlier detection, followed by an OLS fit on inliers to compute 

1450 slope, intercept, scatter, standard errors, t-values, and p-values. 

1451 Configuration (e.g., minSamples) is set at init. Call `fit()` per dataset. 

1452 Results are returned as a `RobustFitResult` and stored internally for later 

1453 access (e.g., plotting). 

1454 

1455 Parameters 

1456 ---------- 

1457 minSamples : `float`, optional 

1458 Minimum fraction of samples chosen randomly from the original data. 

1459 """ 

1460 

1461 def __init__(self, *, minSamples: float = 0.2) -> None: 

1462 self.minSamples = minSamples 

1463 self._clearState() 

1464 

1465 def _clearState(self) -> None: 

1466 """Reset stored fit state.""" 

1467 self.model: Any = None 

1468 self.olsModel: Any = None 

1469 self.outlierMask: np.ndarray | None = None 

1470 self.x: np.ndarray | None = None 

1471 self.y: np.ndarray | None = None 

1472 self.slope: float = np.nan 

1473 self.intercept: float = np.nan 

1474 self.ciSlope: tuple[float, float] = (np.nan, np.nan) 

1475 self.ciIntercept: tuple[float, float] = (np.nan, np.nan) 

1476 self.slopePValue = np.nan 

1477 self.slopeStdErr = np.nan 

1478 self.slopeTValue = np.nan 

1479 self.scatter = np.nan 

1480 

1481 @staticmethod 

1482 def _defaultResidualThreshold(y: np.ndarray) -> float: 

1483 """Compute default residual threshold from finite y values.""" 

1484 yFinite = np.asarray(y)[np.isfinite(y)] 

1485 if yFinite.size == 0: 

1486 raise ValueError("Cannot compute residual threshold: no finite y.") 

1487 return 1.5 * float(mad_std(yFinite)) 

1488 

1489 def fit( 

1490 self, 

1491 x: np.ndarray, 

1492 y: np.ndarray, 

1493 *, 

1494 residualThreshold: float | None = None, 

1495 randomState: int = 42, 

1496 ) -> RobustFitResult: 

1497 """ 

1498 Fit robust line to (x, y) using RANSAC + OLS on inliers. 

1499 

1500 Recomputes residual threshold each call if not provided. 

1501 

1502 Parameters 

1503 ---------- 

1504 x : array-like 

1505 Independent variable values. 

1506 y : array-like 

1507 Dependent variable values. 

1508 residualThreshold : float, optional 

1509 Residual threshold for inlier detection. If None, computed 

1510 as 1.5 × MAD of y. 

1511 randomState : int, optional 

1512 Random seed for RANSAC. 

1513 

1514 Returns 

1515 ------- 

1516 result : `RobustFitResult` 

1517 Best-fit parameters and statistics from the OLS inlier fit. 

1518 

1519 Raises 

1520 ------ 

1521 ValueError 

1522 If no finite x/y values are available for fitting. 

1523 """ 

1524 self._clearState() 

1525 

1526 x = np.asarray(x) 

1527 y = np.asarray(y) 

1528 

1529 finiteMask = np.isfinite(x) & np.isfinite(y) 

1530 if not finiteMask.any(): 

1531 raise ValueError("No finite x/y values to fit.") 

1532 

1533 X = x[finiteMask].reshape(-1, 1) 

1534 yFit = y[finiteMask] 

1535 

1536 if residualThreshold is None: 

1537 residualThreshold = self._defaultResidualThreshold(yFit) 

1538 

1539 ransac = RANSACRegressor( 

1540 estimator=LinearRegression(), 

1541 min_samples=self.minSamples, 

1542 residual_threshold=residualThreshold, 

1543 random_state=randomState, 

1544 ) 

1545 ransac.fit(X, yFit) 

1546 

1547 slope = float(ransac.estimator_.coef_[0]) 

1548 intercept = float(ransac.estimator_.intercept_) 

1549 

1550 self.x = x 

1551 self.y = y 

1552 

1553 outlierMask = np.ones_like(x, dtype=bool) 

1554 outlierMask[finiteMask] = ~ransac.inlier_mask_ 

1555 self.outlierMask = outlierMask 

1556 

1557 xIn = x[~outlierMask] 

1558 yIn = y[~outlierMask] 

1559 XDesign = sm.add_constant(xIn) 

1560 ols = sm.OLS(yIn, XDesign).fit() 

1561 ci = ols.conf_int(alpha=0.32) 

1562 ciIntercept = (float(ci[0][0]), float(ci[0][1])) 

1563 ciSlope = (float(ci[1][0]), float(ci[1][1])) 

1564 scatter = float(np.nanstd(ols.resid, ddof=1)) if ols.resid.size > 1 else np.nan 

1565 

1566 self.model = ransac 

1567 self.olsModel = ols 

1568 self.slope = slope 

1569 self.intercept = intercept 

1570 self.ciSlope = ciSlope 

1571 self.ciIntercept = ciIntercept 

1572 self.slopePValue = float(ols.pvalues[1]) if np.isfinite(ols.pvalues[1]) else np.nan 

1573 self.slopeStdErr = float(ols.bse[1]) if np.isfinite(ols.bse[1]) else np.nan 

1574 self.slopeTValue = float(ols.tvalues[1]) if np.isfinite(ols.tvalues[1]) else np.nan 

1575 self.scatter = scatter 

1576 

1577 return RobustFitResult( 

1578 slope=slope, 

1579 intercept=intercept, 

1580 scatter=scatter, 

1581 slopePValue=self.slopePValue, 

1582 slopeStdErr=self.slopeStdErr, 

1583 slopeTValue=self.slopeTValue, 

1584 interceptPValue=float(ols.pvalues[0]) if np.isfinite(ols.pvalues[0]) else np.nan, 

1585 interceptStdErr=float(ols.bse[0]) if np.isfinite(ols.bse[0]) else np.nan, 

1586 interceptTValue=float(ols.tvalues[0]) if np.isfinite(ols.tvalues[0]) else np.nan, 

1587 outlierMask=self.outlierMask, 

1588 ) 

1589 

1590 def reset(self) -> None: 

1591 """Clear any stored fit state.""" 

1592 self._clearState() 

1593 

1594 def plotBestFit(self, ax: matplotlib.axes.Axes, label=None, color=None, alphaBand=0.2, lw=2, nBins=5): 

1595 """Plot the best fit line, confidence interval, 

1596 and optionally scatter/binned data. 

1597 

1598 Parameters 

1599 ---------- 

1600 ax : matplotlib.axes.Axes, optional 

1601 The axes to plot on. If None, uses current axes. 

1602 label : str, optional 

1603 Label for the best fit line. 

1604 color : str or None, optional 

1605 Color for the fit line and confidence band. 

1606 alphaBand : float, optional 

1607 Alpha transparency for the confidence interval band. 

1608 lw : int, optional 

1609 Line width for the best fit line. 

1610 nBins : int, optional 

1611 Number of bins for binned statistics. 

1612 

1613 Returns 

1614 ------- 

1615 ax : matplotlib.axes.Axes 

1616 The axes with the plot. 

1617 """ 

1618 # Handle case where self.x contains only NaNs 

1619 if self.x is None or self.y is None or self.outlierMask is None or self.olsModel is None: 

1620 raise RuntimeError("Fit must be called before plotting.") 

1621 

1622 if np.isnan(self.x).all(): 

1623 raise ValueError("All x values are NaN; cannot plot best fit.") 

1624 

1625 xx = np.linspace(np.nanmin(self.x), np.nanmax(self.x), 200) 

1626 X_design = sm.add_constant(xx) 

1627 

1628 pred = self.olsModel.get_prediction(X_design) 

1629 summary_frame = pred.summary_frame(alpha=0.05) # 95% intervals 

1630 

1631 mean = summary_frame["mean"] 

1632 ci_lo = summary_frame["mean_ci_lower"] 

1633 ci_hi = summary_frame["mean_ci_upper"] 

1634 

1635 # Plot best fit 

1636 ax.plot(xx, mean, color=color, label=label, lw=lw) 

1637 # Plot confidence interval band 

1638 ax.fill_between(xx, ci_lo, ci_hi, color=color, alpha=alphaBand) 

1639 

1640 mask = self.outlierMask 

1641 xin, yin = self.x[~mask], self.y[~mask] # inliers 

1642 xout, _ = self.x[mask], self.y[mask] # outliers 

1643 

1644 # Bin data in nbins 

1645 if np.all(np.isnan(xout)): 

1646 # Skip binning if all values are NaN 

1647 pass 

1648 else: 

1649 bin_centers, means, stds, bin_counts = self.getBinnedData(xin, yin, nBins) 

1650 

1651 # Plot binned means and stds 

1652 ax.errorbar( 

1653 bin_centers, 

1654 means, 

1655 color=color or "black", 

1656 yerr=stds, 

1657 fmt="o", 

1658 capsize=4, 

1659 markersize=6, 

1660 alpha=0.7, 

1661 zorder=10, 

1662 ) 

1663 

1664 return ax 

1665 

1666 @staticmethod 

1667 def getBinnedData( 

1668 x: np.ndarray, y: np.ndarray, nbins: int 

1669 ) -> tuple[np.ndarray, list[float], list[float], list[int]]: 

1670 """Get binned statistics for x and y.""" 

1671 bins = np.linspace(np.nanmin(x), np.nanmax(x), nbins + 1) 

1672 bin_centers = 0.5 * (bins[:-1] + bins[1:]) 

1673 digitized = np.digitize(x, bins) - 1 

1674 means = [] 

1675 stds = [] 

1676 bin_counts = [] 

1677 for i in range(nbins): 

1678 bin_y = y[digitized == i] 

1679 if len(bin_y) > 0: 

1680 means.append(np.nanmedian(bin_y)) 

1681 if len(bin_y) > 1: 

1682 stds.append(np.nanstd(bin_y, ddof=1) / np.sqrt(len(bin_y))) 

1683 else: 

1684 stds.append(np.nan) 

1685 bin_counts.append(len(bin_y)) 

1686 else: 

1687 means.append(np.nan) 

1688 stds.append(np.nan) 

1689 bin_counts.append(0) 

1690 return bin_centers, means, stds, bin_counts