Coverage for python/lsst/summit/utils/utils.py: 17%
382 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-09 05:01 -0700
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-09 05:01 -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/>.
22import datetime
23import logging
24import os
25from collections.abc import Iterable
26from typing import Union
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
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
54from .astrometry.utils import genericCameraHeaderToWcs
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]
95SIGMATOFWHM = 2.0 * np.sqrt(2.0 * np.log(2.0))
96FWHMTOSIGMA = 1 / SIGMATOFWHM
98EFD_CLIENT_MISSING_MSG = (
99 "ImportError: lsst_efd_client not found. Please install with:\n" " pip install lsst-efd-client"
100)
102GOOGLE_CLOUD_MISSING_MSG = (
103 "ImportError: Google cloud storage not found. Please install with:\n"
104 " pip install google-cloud-storage"
105)
108def countPixels(maskedImage: afwImage.MaskedImage, maskPlane: str) -> int:
109 """Count the number of pixels in an image with a given mask bit set.
111 Parameters
112 ----------
113 maskedImage : `lsst.afw.image.MaskedImage`
114 The masked image,
115 maskPlane : `str`
116 The name of the bitmask.
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])
127def quickSmooth(data: np.ndarray[float], sigma: float = 2) -> np.ndarray[float]:
128 """Perform a quick smoothing of the image.
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.
133 Parameters
134 ----------
135 data : `np.array`
136 The image data to smooth
137 sigma : `float`, optional
138 The size of the smoothing kernel.
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
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.
153 If its not unique, returns a list of the other locations containing the
154 maximum value, e.g. returns
156 (12, 34), False, [(56,78), (910, 1112)]
158 Parameters
159 ----------
160 array : `np.array`
161 The data
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
178 return maxCoords[0], uniqueMaximum, maxCoords[1:]
181def dayObsIntToString(dayObs: int) -> str:
182 """Convert an integer dayObs to a dash-delimited string.
184 e.g. convert the hard to read 20210101 to 2021-01-01
186 Parameters
187 ----------
188 dayObs : `int`
189 The dayObs.
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]])
202def dayObsSeqNumToVisitId(dayObs: int, seqNum: int) -> int:
203 """Get the visit id for a given dayObs/seqNum.
205 Parameters
206 ----------
207 dayObs : `int`
208 The dayObs.
209 seqNum : `int`
210 The seqNum.
212 Returns
213 -------
214 visitId : `int`
215 The visitId.
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}")
227def getImageStats(exp: afwImage.Exposure) -> pipeBase.Struct:
228 """Calculate a grab-bag of stats for an image. Must remain fast.
230 Parameters
231 ----------
232 exp : `lsst.afw.image.Exposure`
233 The input exposure.
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()
243 vi = exp.visitInfo
244 expTime = vi.exposureTime
245 md = exp.getMetadata()
247 obj = vi.object
248 mjd = vi.getDate().get()
249 result.object = obj
250 result.mjd = mjd
252 fullFilterString = exp.filter.physicalLabel
253 filt = fullFilterString.split(FILTER_DELIMITER)[0]
254 grating = fullFilterString.split(FILTER_DELIMITER)[1]
256 airmass = vi.getBoresightAirmass()
257 rotangle = vi.getBoresightRotAngle().asDegrees()
259 azAlt = vi.getBoresightAzAlt()
260 az = azAlt[0].asDegrees()
261 el = azAlt[1].asDegrees()
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")
272 data = exp.image.array
273 result.maxValue = np.max(data)
275 peak, uniquePeak, otherPeaks = argMax2d(data)
276 result.maxPixelLocation = peak
277 result.multipleMaxPixels = uniquePeak
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)
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)
292 result.clippedMean = mean
293 result.clippedStddev = std
295 return result
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.
303 Return the footPrintSet for the objects in a preferably-postISR exposure.
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.
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
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)
330 exp.image += median # add back in to leave background unchanged
331 return footPrintSet
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.
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.
356 Returns
357 -------
358 fluxes : `list` of `float`
359 The fluxes for each footprint.
361 Raises
362 ------
363 TypeError : raise for unsupported types.
364 """
365 median = 0
366 if subtractImageMedian:
367 median = np.nanmedian(parentImage.array)
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)
384 return np.array([fluxFromFootprint(fp, parentImage, backgroundValue=median) for fp in footprints])
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.
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.
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)
416def humanNameForCelestialObject(objName: str) -> list[str]:
417 """Returns a list of all human names for obj, or [] if none are found.
419 Parameters
420 ----------
421 objName : `str`
422 The/a name of the object.
424 Returns
425 -------
426 names : `list` of `str`
427 The names found for the object
428 """
429 from astroquery.simbad import Simbad
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
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.
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
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
477def getFocusFromHeader(exp: afwImage.Exposure) -> float | None:
478 """Get the raw focus value from the header.
480 Parameters
481 ----------
482 exp : `lsst.afw.image.exposure`
483 The exposure.
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
496def checkStackSetup() -> None:
497 """Check which weekly tag is being used and which local packages are setup.
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.
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)
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}")
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)
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)")
538def setupLogging(longlog: bool = False) -> None:
539 """Setup logging in the same way as one would get from pipetask run.
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)
550def getCurrentDayObs_datetime() -> datetime.date:
551 """Get the current day_obs - the observatory rolls the date over at UTC-12
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
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"))
567def getCurrentDayObs_humanStr() -> str:
568 """Return the current dayObs as a string in the form '2022-04-28'"""
569 return dayObsIntToString(getCurrentDayObs_int())
572def getExpRecordAge(expRecord: dafButler.DimensionRecord) -> float:
573 """Get the time, in seconds, since the end of exposure.
575 Parameters
576 ----------
577 expRecord : `lsst.daf.butler.DimensionRecord`
578 The exposure record.
580 Returns
581 -------
582 age : `float`
583 The age of the exposure, in seconds.
584 """
585 return -1 * (expRecord.timespan.end - Time.now()).sec
588def getSite() -> str:
589 """Returns where the code is running.
591 Returns
592 -------
593 location : `str`
594 One of:
595 ['tucson', 'summit', 'base', 'staff-rsp', 'rubin-devl', 'jenkins',
596 'usdf-k8s']
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"
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"
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"
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"
636 # we have failed
637 raise ValueError("Location could not be determined")
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.
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.
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.
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)
690 refractionKwargs = {}
691 if doCorrectRefraction:
692 # wavelength is never supplied in the visitInfo so always take this
693 wavelength = wavelength * u.nm
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
704 if temperatureOverride:
705 temperature = temperatureOverride
706 else:
707 temperature = visitInfo.weather.getAirTemperature()
708 temperature = temperature * u.deg_C
710 if relativeHumidityOverride:
711 relativeHumidity = relativeHumidityOverride
712 else:
713 relativeHumidity = visitInfo.weather.getHumidity() / 100.0 # this is in percent
714 relativeHumidity = relativeHumidity
716 refractionKwargs = dict(
717 pressure=pressure, temperature=temperature, relative_humidity=relativeHumidity, obswl=wavelength
718 )
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)
725 obsAltAz = skyLocation.transform_to(altAz)
726 alt = geom.Angle(obsAltAz.alt.degree, geom.degrees)
727 az = geom.Angle(obsAltAz.az.degree, geom.degrees)
729 return alt, az
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.
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.
747 Values are given as exp1-exp2.
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``.
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 """
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."
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]
803 ra1 = exp1.visitInfo.boresightRaDec[0]
804 ra2 = exp2.visitInfo.boresightRaDec[0]
805 dec1 = exp1.visitInfo.boresightRaDec[1]
806 dec2 = exp2.visitInfo.boresightRaDec[1]
808 p1 = exp1.visitInfo.boresightRaDec
809 p2 = exp2.visitInfo.boresightRaDec
811 angular_offset = p1.separation(p2).asArcseconds()
812 deltaPixels = angular_offset / pixScaleArcSec
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 )
822 return ret
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.
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.
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}")
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()
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
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
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}")
882 return exp
885def obsInfoToDict(obsInfo: ObservationInfo) -> dict:
886 """Convert an ObservationInfo to a dict.
888 Parameters
889 ----------
890 obsInfo : `astro_metadata_translator.ObservationInfo`
891 The ObservationInfo to convert.
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()}
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.
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.
910 Parameters
911 ----------
912 field : `str`
913 The name of the field
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")
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
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
943 return "_".join(fieldParts[:-1]), fieldNum
946def getAirmassSeeingCorrection(airmass: float) -> float:
947 """Get the correction factor for seeing due to airmass.
949 Parameters
950 ----------
951 airmass : `float`
952 The airmass, greater than or equal to 1.
954 Returns
955 -------
956 correctionFactor : `float`
957 The correction factor to apply to the seeing.
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)
968def getFilterSeeingCorrection(filterName: str) -> float:
969 """Get the correction factor for seeing due to a filter.
971 Parameters
972 ----------
973 filterName : `str`
974 The name of the filter, e.g. 'SDSSg_65mm'.
976 Returns
977 -------
978 correctionFactor : `float`
979 The correction factor to apply to the seeing.
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}")
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.
1006 If the input data is all nan, then the output cdf will be nan as well as
1007 the min and max values.
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.
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))
1032 minVal = np.floor(np.nanmin(flatData))
1033 maxVal = np.ceil(np.nanmax(flatData)) + 1.0
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
1041 nBins = np.clip(int(maxVal) - int(minVal), 1, nBinsMax)
1043 hist, binEdges = np.histogram(flatData, bins=nBins, range=(int(minVal), int(maxVal)))
1045 cdf = (scale * np.cumsum(hist) / size).astype(np.int64)
1046 return cdf, minVal, maxVal
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.
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.
1060 If all elements of ``data`` are nan then the output ``boundaries`` will
1061 also all be ``nan`` to keep the interface consistent.
1063 Parameters
1064 ----------
1065 data : `np.array`
1066 The input image data.
1067 nColors : `int`
1068 The number of intervals to distribute data into.
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)])
1086 scale = (maxVal - minVal) / len(cdf)
1088 boundaries = np.asarray([np.argmax(cdf >= i) * scale + minVal for i in range(nColors)] + [maxVal])
1090 return boundaries
1093def digitizeData(data: np.ndarray, nColors: int = 256) -> np.ndarray[int]:
1094 """
1095 Scale data into nColors using its cumulative distribution function.
1097 Parameters
1098 ----------
1099 data : `np.array`
1100 The input image data.
1101 nColors : `int`
1102 The number of intervals to distribute data into.
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]
1115def getBboxAround(centroid: geom.Point, boxSize: int, exp: afwImage.Exposure) -> geom.Box2I:
1116 """Get a bbox centered on a point, clipped to the exposure.
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.
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.
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
1141def bboxToMatplotlibRectanle(bbox: geom.Box2I | geom.Box2D) -> matplotlib.patches.Rectangle:
1142 """Convert a bbox to a matplotlib Rectangle for plotting.
1144 Parameters
1145 ----------
1146 results : `lsst.geom.Box2I` or `lsst.geom.Box2D`
1147 The bbox to convert.
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)