Coverage for python/lsst/summit/utils/utils.py: 16%
378 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-13 04:55 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-04-13 04:55 -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 typing import Iterable
27import astropy.units as u
28import numpy as np
29from astro_metadata_translator import ObservationInfo
30from astropy.coordinates import AltAz, SkyCoord
31from astropy.coordinates.earth import EarthLocation
32from astropy.time import Time
33from dateutil.tz import gettz
34from matplotlib.patches import Rectangle
35from scipy.ndimage import gaussian_filter
37import lsst.afw.detection as afwDetect
38import lsst.afw.image as afwImage
39import lsst.afw.math as afwMath
40import lsst.daf.base as dafBase
41import lsst.geom as geom
42import lsst.pipe.base as pipeBase
43import lsst.utils.packages as packageUtils
44from lsst.afw.coord import Weather
45from lsst.afw.detection import Footprint, FootprintSet
46from lsst.daf.butler.cli.cliLog import CliLog
47from lsst.obs.lsst.translators.latiss import AUXTEL_LOCATION
48from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER
50from .astrometry.utils import genericCameraHeaderToWcs
52__all__ = [
53 "SIGMATOFWHM",
54 "FWHMTOSIGMA",
55 "EFD_CLIENT_MISSING_MSG",
56 "GOOGLE_CLOUD_MISSING_MSG",
57 "AUXTEL_LOCATION",
58 "countPixels",
59 "quickSmooth",
60 "argMax2d",
61 "dayObsIntToString",
62 "dayObsSeqNumToVisitId",
63 "getImageStats",
64 "detectObjectsInExp",
65 "fluxesFromFootprints",
66 "fluxFromFootprint",
67 "humanNameForCelestialObject",
68 "getFocusFromHeader",
69 "checkStackSetup",
70 "setupLogging",
71 "getCurrentDayObs_datetime",
72 "getCurrentDayObs_int",
73 "getCurrentDayObs_humanStr",
74 "getExpRecordAge",
75 "getSite",
76 "getAltAzFromSkyPosition",
77 "getExpPositionOffset",
78 "starTrackerFileToExposure",
79 "obsInfoToDict",
80 "getFieldNameAndTileNumber",
81 "getAirmassSeeingCorrection",
82 "getFilterSeeingCorrection",
83 "getCdf",
84 "getQuantiles",
85 "digitizeData",
86 "getBboxAround",
87 "bboxToMatplotlibRectanle",
88]
91SIGMATOFWHM = 2.0 * np.sqrt(2.0 * np.log(2.0))
92FWHMTOSIGMA = 1 / SIGMATOFWHM
94EFD_CLIENT_MISSING_MSG = (
95 "ImportError: lsst_efd_client not found. Please install with:\n" " pip install lsst-efd-client"
96)
98GOOGLE_CLOUD_MISSING_MSG = (
99 "ImportError: Google cloud storage not found. Please install with:\n"
100 " pip install google-cloud-storage"
101)
104def countPixels(maskedImage, maskPlane):
105 """Count the number of pixels in an image with a given mask bit set.
107 Parameters
108 ----------
109 maskedImage : `lsst.afw.image.MaskedImage`
110 The masked image,
111 maskPlane : `str`
112 The name of the bitmask.
114 Returns
115 -------
116 count : `int``
117 The number of pixels in with the selected mask bit
118 """
119 bit = maskedImage.mask.getPlaneBitMask(maskPlane)
120 return len(np.where(np.bitwise_and(maskedImage.mask.array, bit))[0])
123def quickSmooth(data, sigma=2):
124 """Perform a quick smoothing of the image.
126 Not to be used for scientific purposes, but improves the stretch and
127 visual rendering of low SNR against the sky background in cutouts.
129 Parameters
130 ----------
131 data : `np.array`
132 The image data to smooth
133 sigma : `float`, optional
134 The size of the smoothing kernel.
136 Returns
137 -------
138 smoothData : `np.array`
139 The smoothed data
140 """
141 kernel = [sigma, sigma]
142 smoothData = gaussian_filter(data, kernel, mode="constant")
143 return smoothData
146def argMax2d(array):
147 """Get the index of the max value of an array and whether it's unique.
149 If its not unique, returns a list of the other locations containing the
150 maximum value, e.g. returns
152 (12, 34), False, [(56,78), (910, 1112)]
154 Parameters
155 ----------
156 array : `np.array`
157 The data
159 Returns
160 -------
161 maxLocation : `tuple`
162 The coords of the first instance of the max value
163 unique : `bool`
164 Whether it's the only location
165 otherLocations : `list` of `tuple`
166 List of the other max values' locations, empty if False
167 """
168 uniqueMaximum = False
169 maxCoords = np.where(array == np.max(array))
170 maxCoords = [coord for coord in zip(*maxCoords)] # list of coords as tuples
171 if len(maxCoords) == 1: # single unambiguous value
172 uniqueMaximum = True
174 return maxCoords[0], uniqueMaximum, maxCoords[1:]
177def dayObsIntToString(dayObs):
178 """Convert an integer dayObs to a dash-delimited string.
180 e.g. convert the hard to read 20210101 to 2021-01-01
182 Parameters
183 ----------
184 dayObs : `int`
185 The dayObs.
187 Returns
188 -------
189 dayObs : `str`
190 The dayObs as a string.
191 """
192 assert isinstance(dayObs, int)
193 dStr = str(dayObs)
194 assert len(dStr) == 8
195 return "-".join([dStr[0:4], dStr[4:6], dStr[6:8]])
198def dayObsSeqNumToVisitId(dayObs, seqNum):
199 """Get the visit id for a given dayObs/seqNum.
201 Parameters
202 ----------
203 dayObs : `int`
204 The dayObs.
205 seqNum : `int`
206 The seqNum.
208 Returns
209 -------
210 visitId : `int`
211 The visitId.
213 Notes
214 -----
215 TODO: Remove this horrible hack once DM-30948 makes this possible
216 programatically/via the butler.
217 """
218 if dayObs < 19700101 or dayObs > 35000101:
219 raise ValueError(f"dayObs value {dayObs} outside plausible range")
220 return int(f"{dayObs}{seqNum:05}")
223def getImageStats(exp):
224 """Calculate a grab-bag of stats for an image. Must remain fast.
226 Parameters
227 ----------
228 exp : `lsst.afw.image.Exposure`
229 The input exposure.
231 Returns
232 -------
233 stats : `lsst.pipe.base.Struct`
234 A container with attributes containing measurements and statistics
235 for the image.
236 """
237 result = pipeBase.Struct()
239 vi = exp.visitInfo
240 expTime = vi.exposureTime
241 md = exp.getMetadata()
243 obj = vi.object
244 mjd = vi.getDate().get()
245 result.object = obj
246 result.mjd = mjd
248 fullFilterString = exp.filter.physicalLabel
249 filt = fullFilterString.split(FILTER_DELIMITER)[0]
250 grating = fullFilterString.split(FILTER_DELIMITER)[1]
252 airmass = vi.getBoresightAirmass()
253 rotangle = vi.getBoresightRotAngle().asDegrees()
255 azAlt = vi.getBoresightAzAlt()
256 az = azAlt[0].asDegrees()
257 el = azAlt[1].asDegrees()
259 result.expTime = expTime
260 result.filter = filt
261 result.grating = grating
262 result.airmass = airmass
263 result.rotangle = rotangle
264 result.az = az
265 result.el = el
266 result.focus = md.get("FOCUSZ")
268 data = exp.image.array
269 result.maxValue = np.max(data)
271 peak, uniquePeak, otherPeaks = argMax2d(data)
272 result.maxPixelLocation = peak
273 result.multipleMaxPixels = uniquePeak
275 result.nBadPixels = countPixels(exp.maskedImage, "BAD")
276 result.nSatPixels = countPixels(exp.maskedImage, "SAT")
277 result.percentile99 = np.percentile(data, 99)
278 result.percentile9999 = np.percentile(data, 99.99)
280 sctrl = afwMath.StatisticsControl()
281 sctrl.setNumSigmaClip(5)
282 sctrl.setNumIter(2)
283 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP
284 stats = afwMath.makeStatistics(exp.maskedImage, statTypes, sctrl)
285 std, stderr = stats.getResult(afwMath.STDEVCLIP)
286 mean, meanerr = stats.getResult(afwMath.MEANCLIP)
288 result.clippedMean = mean
289 result.clippedStddev = std
291 return result
294def detectObjectsInExp(exp, nSigma=10, nPixMin=10, grow=0):
295 """Quick and dirty object detection for an exposure.
297 Return the footPrintSet for the objects in a preferably-postISR exposure.
299 Parameters
300 ----------
301 exp : `lsst.afw.image.Exposure`
302 The exposure to detect objects in.
303 nSigma : `float`
304 The number of sigma for detection.
305 nPixMin : `int`
306 The minimum number of pixels in an object for detection.
307 grow : `int`
308 The number of pixels to grow the footprint by after detection.
310 Returns
311 -------
312 footPrintSet : `lsst.afw.detection.FootprintSet`
313 The set of footprints in the image.
314 """
315 median = np.nanmedian(exp.image.array)
316 exp.image -= median
318 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV)
319 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin)
320 if grow > 0:
321 isotropic = True
322 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic)
324 exp.image += median # add back in to leave background unchanged
325 return footPrintSet
328def fluxesFromFootprints(footprints, parentImage, subtractImageMedian=False):
329 """Calculate the flux from a set of footprints, given the parent image,
330 optionally subtracting the whole-image median from each pixel as a very
331 rough background subtraction.
333 Parameters
334 ----------
335 footprints : `lsst.afw.detection.FootprintSet` or
336 `lsst.afw.detection.Footprint` or
337 `iterable` of `lsst.afw.detection.Footprint`
338 The footprints to measure.
339 parentImage : `lsst.afw.image.Image`
340 The parent image.
341 subtractImageMedian : `bool`, optional
342 Subtract a whole-image median from each pixel in the footprint when
343 summing as a very crude background subtraction. Does not change the
344 original image.
346 Returns
347 -------
348 fluxes : `list` of `float`
349 The fluxes for each footprint.
351 Raises
352 ------
353 TypeError : raise for unsupported types.
354 """
355 median = 0
356 if subtractImageMedian:
357 median = np.nanmedian(parentImage.array)
359 # poor person's single dispatch
360 badTypeMsg = (
361 "This function works with FootprintSets, single Footprints, and iterables of Footprints. "
362 f"Got {type(footprints)}: {footprints}"
363 )
364 if isinstance(footprints, FootprintSet):
365 footprints = footprints.getFootprints()
366 elif isinstance(footprints, Iterable):
367 if not isinstance(footprints[0], Footprint):
368 raise TypeError(badTypeMsg)
369 elif isinstance(footprints, Footprint):
370 footprints = [footprints]
371 else:
372 raise TypeError(badTypeMsg)
374 return np.array([fluxFromFootprint(fp, parentImage, backgroundValue=median) for fp in footprints])
377def fluxFromFootprint(footprint, parentImage, backgroundValue=0):
378 """Calculate the flux from a footprint, given the parent image, optionally
379 subtracting a single value from each pixel as a very rough background
380 subtraction, e.g. the image median.
382 Parameters
383 ----------
384 footprint : `lsst.afw.detection.Footprint`
385 The footprint to measure.
386 parentImage : `lsst.afw.image.Image`
387 Image containing the footprint.
388 backgroundValue : `bool`, optional
389 The value to subtract from each pixel in the footprint when summing
390 as a very crude background subtraction. Does not change the original
391 image.
393 Returns
394 -------
395 flux : `float`
396 The flux in the footprint
397 """
398 if backgroundValue: # only do the subtraction if non-zero for speed
399 xy0 = parentImage.getBBox().getMin()
400 return footprint.computeFluxFromArray(parentImage.array - backgroundValue, xy0)
401 return footprint.computeFluxFromImage(parentImage)
404def humanNameForCelestialObject(objName):
405 """Returns a list of all human names for obj, or [] if none are found.
407 Parameters
408 ----------
409 objName : `str`
410 The/a name of the object.
412 Returns
413 -------
414 names : `list` of `str`
415 The names found for the object
416 """
417 from astroquery.simbad import Simbad
419 results = []
420 try:
421 simbadResult = Simbad.query_objectids(objName)
422 for row in simbadResult:
423 if row["ID"].startswith("NAME"):
424 results.append(row["ID"].replace("NAME ", ""))
425 return results
426 except Exception:
427 return [] # same behavior as for found but un-named objects
430def _getAltAzZenithsFromSeqNum(butler, dayObs, seqNumList):
431 """Get the alt, az and zenith angle for the seqNums of a given dayObs.
433 Parameters
434 ----------
435 butler : `lsst.daf.butler.Butler`
436 The butler to query.
437 dayObs : `int`
438 The dayObs.
439 seqNumList : `list` of `int`
440 The seqNums for which to return the alt, az and zenith
442 Returns
443 -------
444 azimuths : `list` of `float`
445 List of the azimuths for each seqNum
446 elevations : `list` of `float`
447 List of the elevations for each seqNum
448 zeniths : `list` of `float`
449 List of the zenith angles for each seqNum
450 """
451 azimuths, elevations, zeniths = [], [], []
452 for seqNum in seqNumList:
453 md = butler.get("raw.metadata", day_obs=dayObs, seq_num=seqNum, detector=0)
454 obsInfo = ObservationInfo(md)
455 alt = obsInfo.altaz_begin.alt.value
456 az = obsInfo.altaz_begin.az.value
457 elevations.append(alt)
458 zeniths.append(90 - alt)
459 azimuths.append(az)
460 return azimuths, elevations, zeniths
463def getFocusFromHeader(exp):
464 """Get the raw focus value from the header.
466 Parameters
467 ----------
468 exp : `lsst.afw.image.exposure`
469 The exposure.
471 Returns
472 -------
473 focus : `float` or `None`
474 The focus value if found, else ``None``.
475 """
476 md = exp.getMetadata()
477 if "FOCUSZ" in md:
478 return md["FOCUSZ"]
479 return None
482def checkStackSetup():
483 """Check which weekly tag is being used and which local packages are setup.
485 Designed primarily for use in notbooks/observing, this prints the weekly
486 tag(s) are setup for lsst_distrib, and lists any locally setup packages and
487 the path to each.
489 Notes
490 -----
491 Uses print() instead of logger messages as this should simply print them
492 without being vulnerable to any log messages potentially being diverted.
493 """
494 packages = packageUtils.getEnvironmentPackages(include_all=True)
496 lsstDistribHashAndTags = packages["lsst_distrib"] # looks something like 'g4eae7cb9+1418867f (w_2022_13)'
497 lsstDistribTags = lsstDistribHashAndTags.split()[1]
498 if len(lsstDistribTags.split()) == 1:
499 tag = lsstDistribTags.replace("(", "")
500 tag = tag.replace(")", "")
501 print(f"You are running {tag} of lsst_distrib")
502 else: # multiple weekly tags found for lsst_distrib!
503 print(f"The version of lsst_distrib you have is compatible with: {lsstDistribTags}")
505 localPackages = []
506 localPaths = []
507 for package, tags in packages.items():
508 if tags.startswith("LOCAL:"):
509 path = tags.split("LOCAL:")[1]
510 path = path.split("@")[0] # don't need the git SHA etc
511 localPaths.append(path)
512 localPackages.append(package)
514 if localPackages:
515 print("\nLocally setup packages:")
516 print("-----------------------")
517 maxLen = max(len(package) for package in localPackages)
518 for package, path in zip(localPackages, localPaths):
519 print(f"{package:<{maxLen}s} at {path}")
520 else:
521 print("\nNo locally setup packages (using a vanilla stack)")
524def setupLogging(longlog=False):
525 """Setup logging in the same way as one would get from pipetask run.
527 Code that isn't run through the butler CLI defaults to WARNING level
528 messages and no logger names. This sets the behaviour to follow whatever
529 the pipeline default is, currently
530 <logger_name> <level>: <message> e.g.
531 lsst.isr INFO: Masking defects.
532 """
533 CliLog.initLog(longlog=longlog)
536def getCurrentDayObs_datetime():
537 """Get the current day_obs - the observatory rolls the date over at UTC-12
539 Returned as datetime.date(2022, 4, 28)
540 """
541 utc = gettz("UTC")
542 nowUtc = datetime.datetime.now().astimezone(utc)
543 offset = datetime.timedelta(hours=-12)
544 dayObs = (nowUtc + offset).date()
545 return dayObs
548def getCurrentDayObs_int():
549 """Return the current dayObs as an int in the form 20220428"""
550 return int(getCurrentDayObs_datetime().strftime("%Y%m%d"))
553def getCurrentDayObs_humanStr():
554 """Return the current dayObs as a string in the form '2022-04-28'"""
555 return dayObsIntToString(getCurrentDayObs_int())
558def getExpRecordAge(expRecord):
559 """Get the time, in seconds, since the end of exposure.
561 Parameters
562 ----------
563 expRecord : `lsst.daf.butler.DimensionRecord`
564 The exposure record.
566 Returns
567 -------
568 age : `float`
569 The age of the exposure, in seconds.
570 """
571 return -1 * (expRecord.timespan.end - Time.now()).sec
574def getSite():
575 """Returns where the code is running.
577 Returns
578 -------
579 location : `str`
580 One of:
581 ['tucson', 'summit', 'base', 'staff-rsp', 'rubin-devl', 'jenkins',
582 'usdf-k8s']
584 Raises
585 ------
586 ValueError
587 Raised if location cannot be determined.
588 """
589 # All nublado instances guarantee that EXTERNAL_URL is set and uniquely
590 # identifies it.
591 location = os.getenv("EXTERNAL_INSTANCE_URL", "")
592 if location == "https://tucson-teststand.lsst.codes": 592 ↛ 593line 592 didn't jump to line 593, because the condition on line 592 was never true
593 return "tucson"
594 elif location == "https://summit-lsp.lsst.codes": 594 ↛ 595line 594 didn't jump to line 595, because the condition on line 594 was never true
595 return "summit"
596 elif location == "https://base-lsp.lsst.codes": 596 ↛ 597line 596 didn't jump to line 597, because the condition on line 596 was never true
597 return "base"
598 elif location == "https://usdf-rsp.slac.stanford.edu": 598 ↛ 599line 598 didn't jump to line 599, because the condition on line 598 was never true
599 return "staff-rsp"
601 # if no EXTERNAL_URL, try HOSTNAME to see if we're on the dev nodes
602 # it is expected that this will be extensible to SLAC
603 hostname = os.getenv("HOSTNAME", "")
604 if hostname.startswith("sdfrome"): 604 ↛ 605line 604 didn't jump to line 605, because the condition on line 604 was never true
605 return "rubin-devl"
607 jenkinsHome = os.getenv("JENKINS_HOME", "")
608 if jenkinsHome != "": 608 ↛ 612line 608 didn't jump to line 612, because the condition on line 608 was never false
609 return "jenkins"
611 # we're probably inside a k8s pod doing rapid analysis work at this point
612 location = os.getenv("RAPID_ANALYSIS_LOCATION", "")
613 if location == "TTS":
614 return "tucson"
615 if location == "BTS":
616 return "base"
617 if location == "SUMMIT":
618 return "summit"
619 if location == "USDF":
620 return "usdf-k8s"
622 # we have failed
623 raise ValueError("Location could not be determined")
626def getAltAzFromSkyPosition(
627 skyPos,
628 visitInfo,
629 doCorrectRefraction=False,
630 wavelength=500.0,
631 pressureOverride=None,
632 temperatureOverride=None,
633 relativeHumidityOverride=None,
634):
635 """Get the alt/az from the position on the sky and the time and location
636 of the observation.
638 The temperature, pressure and relative humidity are taken from the
639 visitInfo by default, but can be individually overridden as needed. It
640 should be noted that the visitInfo never contains a nominal wavelength, and
641 so this takes a default value of 500nm.
643 Parameters
644 ----------
645 skyPos : `lsst.geom.SpherePoint`
646 The position on the sky.
647 visitInfo : `lsst.afw.image.VisitInfo`
648 The visit info containing the time of the observation.
649 doCorrectRefraction : `bool`, optional
650 Correct for the atmospheric refraction?
651 wavelength : `float`, optional
652 The nominal wavelength in nanometers (e.g. 500.0), as a float.
653 pressureOverride : `float`, optional
654 The pressure, in bars (e.g. 0.770), to override the value supplied in
655 the visitInfo, as a float.
656 temperatureOverride : `float`, optional
657 The temperature, in Celsius (e.g. 10.0), to override the value supplied
658 in the visitInfo, as a float.
659 relativeHumidityOverride : `float`, optional
660 The relativeHumidity in the range 0..1 (i.e. not as a percentage), to
661 override the value supplied in the visitInfo, as a float.
663 Returns
664 -------
665 alt : `lsst.geom.Angle`
666 The altitude.
667 az : `lsst.geom.Angle`
668 The azimuth.
669 """
670 skyLocation = SkyCoord(skyPos.getRa().asRadians(), skyPos.getDec().asRadians(), unit=u.rad)
671 long = visitInfo.observatory.getLongitude()
672 lat = visitInfo.observatory.getLatitude()
673 ele = visitInfo.observatory.getElevation()
674 earthLocation = EarthLocation.from_geodetic(long.asDegrees(), lat.asDegrees(), ele)
676 refractionKwargs = {}
677 if doCorrectRefraction:
678 # wavelength is never supplied in the visitInfo so always take this
679 wavelength = wavelength * u.nm
681 if pressureOverride:
682 pressure = pressureOverride
683 else:
684 pressure = visitInfo.weather.getAirPressure()
685 # ObservationInfos (which are the "source of truth" use pascals) so
686 # convert from pascals to bars
687 pressure /= 100000.0
688 pressure = pressure * u.bar
690 if temperatureOverride:
691 temperature = temperatureOverride
692 else:
693 temperature = visitInfo.weather.getAirTemperature()
694 temperature = temperature * u.deg_C
696 if relativeHumidityOverride:
697 relativeHumidity = relativeHumidityOverride
698 else:
699 relativeHumidity = visitInfo.weather.getHumidity() / 100.0 # this is in percent
700 relativeHumidity = relativeHumidity
702 refractionKwargs = dict(
703 pressure=pressure, temperature=temperature, relative_humidity=relativeHumidity, obswl=wavelength
704 )
706 # must go via astropy.Time because dafBase.dateTime.DateTime contains
707 # the timezone, but going straight to visitInfo.date.toPython() loses this.
708 obsTime = Time(visitInfo.date.toPython(), scale="tai")
709 altAz = AltAz(obstime=obsTime, location=earthLocation, **refractionKwargs)
711 obsAltAz = skyLocation.transform_to(altAz)
712 alt = geom.Angle(obsAltAz.alt.degree, geom.degrees)
713 az = geom.Angle(obsAltAz.az.degree, geom.degrees)
715 return alt, az
718def getExpPositionOffset(exp1, exp2, useWcs=True, allowDifferentPlateScales=False):
719 """Get the change in sky position between two exposures.
721 Given two exposures, calculate the offset on the sky between the images.
722 If useWcs then use the (fitted or unfitted) skyOrigin from their WCSs, and
723 calculate the alt/az from the observation times, otherwise use the nominal
724 values in the exposures' visitInfos. Note that if using the visitInfo
725 values that for a given pointing the ra/dec will be ~identical, regardless
726 of whether astrometric fitting has been performed.
728 Values are given as exp1-exp2.
730 Parameters
731 ----------
732 exp1 : `lsst.afw.image.Exposure`
733 The first exposure.
734 exp2 : `lsst.afw.image.Exposure`
735 The second exposure.
736 useWcs : `bool`
737 Use the WCS for the ra/dec and alt/az if True, else use the nominal/
738 boresight values from the exposures' visitInfos.
739 allowDifferentPlateScales : `bool`, optional
740 Use to disable checking that plate scales are the same. Generally,
741 differing plate scales would indicate an error, but where blind-solving
742 has been undertaken during commissioning plate scales can be different
743 enough to warrant setting this to ``True``.
745 Returns
746 -------
747 offsets : `lsst.pipe.base.Struct`
748 A struct containing the offsets:
749 ``deltaRa``
750 The diference in ra (`lsst.geom.Angle`)
751 ``deltaDec``
752 The diference in dec (`lsst.geom.Angle`)
753 ``deltaAlt``
754 The diference in alt (`lsst.geom.Angle`)
755 ``deltaAz``
756 The diference in az (`lsst.geom.Angle`)
757 ``deltaPixels``
758 The diference in pixels (`float`)
759 """
761 wcs1 = exp1.getWcs()
762 wcs2 = exp2.getWcs()
763 pixScaleArcSec = wcs1.getPixelScale().asArcseconds()
764 if not allowDifferentPlateScales:
765 assert np.isclose(
766 pixScaleArcSec, wcs2.getPixelScale().asArcseconds()
767 ), "Pixel scales in the exposures differ."
769 if useWcs:
770 p1 = wcs1.getSkyOrigin()
771 p2 = wcs2.getSkyOrigin()
772 alt1, az1 = getAltAzFromSkyPosition(p1, exp1.getInfo().getVisitInfo())
773 alt2, az2 = getAltAzFromSkyPosition(p2, exp2.getInfo().getVisitInfo())
774 ra1 = p1[0]
775 ra2 = p2[0]
776 dec1 = p1[1]
777 dec2 = p2[1]
778 else:
779 az1 = exp1.visitInfo.boresightAzAlt[0]
780 az2 = exp2.visitInfo.boresightAzAlt[0]
781 alt1 = exp1.visitInfo.boresightAzAlt[1]
782 alt2 = exp2.visitInfo.boresightAzAlt[1]
784 ra1 = exp1.visitInfo.boresightRaDec[0]
785 ra2 = exp2.visitInfo.boresightRaDec[0]
786 dec1 = exp1.visitInfo.boresightRaDec[1]
787 dec2 = exp2.visitInfo.boresightRaDec[1]
789 p1 = exp1.visitInfo.boresightRaDec
790 p2 = exp2.visitInfo.boresightRaDec
792 angular_offset = p1.separation(p2).asArcseconds()
793 deltaPixels = angular_offset / pixScaleArcSec
795 ret = pipeBase.Struct(
796 deltaRa=(ra1 - ra2).wrapNear(geom.Angle(0.0)),
797 deltaDec=dec1 - dec2,
798 deltaAlt=alt1 - alt2,
799 deltaAz=(az1 - az2).wrapNear(geom.Angle(0.0)),
800 deltaPixels=deltaPixels,
801 )
803 return ret
806def starTrackerFileToExposure(filename, logger=None):
807 """Read the exposure from the file and set the wcs from the header.
809 Parameters
810 ----------
811 filename : `str`
812 The full path to the file.
813 logger : `logging.Logger`, optional
814 The logger to use for errors, created if not supplied.
816 Returns
817 -------
818 exp : `lsst.afw.image.Exposure`
819 The exposure.
820 """
821 if not logger:
822 logger = logging.getLogger(__name__)
823 exp = afwImage.ExposureF(filename)
824 try:
825 wcs = genericCameraHeaderToWcs(exp)
826 exp.setWcs(wcs)
827 except Exception as e:
828 logger.warning(f"Failed to set wcs from header: {e}")
830 # for some reason the date isn't being set correctly
831 # DATE-OBS is present in the original header, but it's being
832 # stripped out and somehow not set (plus it doesn't give the midpoint
833 # of the exposure), so set it manually from the midpoint here
834 try:
835 newArgs = {} # dict to unpack into visitInfo.copyWith - fill it with whatever needs to be replaced
836 md = exp.getMetadata()
838 begin = datetime.datetime.fromisoformat(md["DATE-BEG"])
839 end = datetime.datetime.fromisoformat(md["DATE-END"])
840 duration = end - begin
841 mid = begin + duration / 2
842 newTime = dafBase.DateTime(mid.isoformat(), dafBase.DateTime.Timescale.TAI)
843 newArgs["date"] = newTime
845 # AIRPRESS is being set as PRESSURE so afw doesn't pick it up
846 # once we're using the butler for data we will just set it to take
847 # PRESSURE in the translator instead of this
848 weather = exp.visitInfo.getWeather()
849 oldPressure = weather.getAirPressure()
850 if not np.isfinite(oldPressure):
851 pressure = md.get("PRESSURE")
852 if pressure is not None:
853 logger.info("Patching the weather info using the PRESSURE header keyword")
854 newWeather = Weather(weather.getAirTemperature(), pressure, weather.getHumidity())
855 newArgs["weather"] = newWeather
857 if newArgs:
858 newVi = exp.visitInfo.copyWith(**newArgs)
859 exp.info.setVisitInfo(newVi)
860 except Exception as e:
861 logger.warning(f"Failed to set date from header: {e}")
863 return exp
866def obsInfoToDict(obsInfo):
867 """Convert an ObservationInfo to a dict.
869 Parameters
870 ----------
871 obsInfo : `astro_metadata_translator.ObservationInfo`
872 The ObservationInfo to convert.
874 Returns
875 -------
876 obsInfoDict : `dict`
877 The ObservationInfo as a dict.
878 """
879 return {prop: getattr(obsInfo, prop) for prop in obsInfo.all_properties.keys()}
882def getFieldNameAndTileNumber(field, warn=True, logger=None):
883 """Get the tile name and number of an observed field.
885 It is assumed to always be appended, with an underscore, to the rest of the
886 field name. Returns the name and number as a tuple, or the name unchanged
887 if no tile number is found.
889 Parameters
890 ----------
891 field : `str`
892 The name of the field
894 Returns
895 -------
896 fieldName : `str`
897 The name of the field without the trailing tile number, if present.
898 tileNum : `int`
899 The number of the tile, as an integer, or ``None`` if not found.
900 """
901 if warn and not logger:
902 logger = logging.getLogger("lsst.summit.utils.utils.getFieldNameAndTileNumber")
904 if "_" not in field:
905 if warn:
906 logger.warning(
907 f"Field {field} does not contain an underscore," " so cannot determine the tile number."
908 )
909 return field, None
911 try:
912 fieldParts = field.split("_")
913 fieldNum = int(fieldParts[-1])
914 except ValueError:
915 if warn:
916 logger.warning(
917 f"Field {field} does not contain only an integer after the final underscore"
918 " so cannot determine the tile number."
919 )
920 return field, None
922 return "_".join(fieldParts[:-1]), fieldNum
925def getAirmassSeeingCorrection(airmass):
926 """Get the correction factor for seeing due to airmass.
928 Parameters
929 ----------
930 airmass : `float`
931 The airmass, greater than or equal to 1.
933 Returns
934 -------
935 correctionFactor : `float`
936 The correction factor to apply to the seeing.
938 Raises
939 ------
940 ValueError raised for unphysical airmasses.
941 """
942 if airmass < 1:
943 raise ValueError(f"Invalid airmass: {airmass}")
944 return airmass ** (-0.6)
947def getFilterSeeingCorrection(filterName):
948 """Get the correction factor for seeing due to a filter.
950 Parameters
951 ----------
952 filterName : `str`
953 The name of the filter, e.g. 'SDSSg_65mm'.
955 Returns
956 -------
957 correctionFactor : `float`
958 The correction factor to apply to the seeing.
960 Raises
961 ------
962 ValueError raised for unknown filters.
963 """
964 match filterName:
965 case "SDSSg_65mm":
966 return (474.41 / 500.0) ** 0.2
967 case "SDSSr_65mm":
968 return (628.47 / 500.0) ** 0.2
969 case "SDSSi_65mm":
970 return (769.51 / 500.0) ** 0.2
971 case "SDSSz_65mm":
972 return (871.45 / 500.0) ** 0.2
973 case "SDSSy_65mm":
974 return (986.8 / 500.0) ** 0.2
975 case _:
976 raise ValueError(f"Unknown filter name: {filterName}")
979def getCdf(data, scale, nBinsMax=300_000):
980 """Return an approximate cumulative distribution function scaled to
981 the [0, scale] range.
983 If the input data is all nan, then the output cdf will be nan as well as
984 the min and max values.
986 Parameters
987 ----------
988 data : `np.array`
989 The input data.
990 scale : `int`
991 The scaling range of the output.
992 nBinsMax : `int`, optional
993 Maximum number of bins to use.
995 Returns
996 -------
997 cdf : `np.array` of `int`
998 A monotonically increasing sequence that represents a scaled
999 cumulative distribution function, starting with the value at
1000 minVal, then at (minVal + 1), and so on.
1001 minVal : `float`
1002 An integer smaller than the minimum value in the input data.
1003 maxVal : `float`
1004 An integer larger than the maximum value in the input data.
1005 """
1006 flatData = data.ravel()
1007 size = flatData.size - np.count_nonzero(np.isnan(flatData))
1009 minVal = np.floor(np.nanmin(flatData))
1010 maxVal = np.ceil(np.nanmax(flatData)) + 1.0
1012 if np.isnan(minVal) or np.isnan(maxVal):
1013 # if either the min or max are nan, then the data is all nan as we're
1014 # using nanmin and nanmax. Given this, we can't calculate a cdf, so
1015 # return nans for all values
1016 return np.nan, np.nan, np.nan
1018 nBins = np.clip(int(maxVal) - int(minVal), 1, nBinsMax)
1020 hist, binEdges = np.histogram(flatData, bins=nBins, range=(int(minVal), int(maxVal)))
1022 cdf = (scale * np.cumsum(hist) / size).astype(np.int64)
1023 return cdf, minVal, maxVal
1026def getQuantiles(data, nColors):
1027 """Get a set of boundaries that equally distribute data into
1028 nColors intervals. The output can be used to make a colormap of nColors
1029 colors.
1031 This is equivalent to using the numpy function:
1032 np.nanquantile(data, np.linspace(0, 1, nColors + 1))
1033 but with a coarser precision, yet sufficient for our use case. This
1034 implementation gives a significant speed-up. In the case of large
1035 ranges, np.nanquantile is used because it is more memory efficient.
1037 If all elements of ``data`` are nan then the output ``boundaries`` will
1038 also all be ``nan`` to keep the interface consistent.
1040 Parameters
1041 ----------
1042 data : `np.array`
1043 The input image data.
1044 nColors : `int`
1045 The number of intervals to distribute data into.
1047 Returns
1048 -------
1049 boundaries: `list` of `float`
1050 A monotonically increasing sequence of size (nColors + 1). These are
1051 the edges of nColors intervals.
1052 """
1053 if (np.nanmax(data) - np.nanmin(data)) > 300_000:
1054 # Use slower but memory efficient nanquantile
1055 logger = logging.getLogger(__name__)
1056 logger.warning("Data range is very large; using slower quantile code.")
1057 boundaries = np.nanquantile(data, np.linspace(0, 1, nColors + 1))
1058 else:
1059 cdf, minVal, maxVal = getCdf(data, nColors)
1060 if np.isnan(minVal): # cdf calculation has failed because all data is nan
1061 return np.asarray([np.nan for _ in range(nColors)])
1063 scale = (maxVal - minVal) / len(cdf)
1065 boundaries = np.asarray([np.argmax(cdf >= i) * scale + minVal for i in range(nColors)] + [maxVal])
1067 return boundaries
1070def digitizeData(data, nColors=256):
1071 """
1072 Scale data into nColors using its cumulative distribution function.
1074 Parameters
1075 ----------
1076 data : `np.array`
1077 The input image data.
1078 nColors : `int`
1079 The number of intervals to distribute data into.
1081 Returns
1082 -------
1083 data: `np.array` of `int`
1084 Scaled data in the [0, nColors - 1] range.
1085 """
1086 cdf, minVal, maxVal = getCdf(data, nColors - 1)
1087 scale = (maxVal - minVal) / len(cdf)
1088 bins = np.floor((data * scale - minVal)).astype(np.int64)
1089 return cdf[bins]
1092def getBboxAround(centroid, boxSize, exp):
1093 """Get a bbox centered on a point, clipped to the exposure.
1095 If the bbox would extend beyond the bounds of the exposure it is clipped to
1096 the exposure, resulting in a non-square bbox.
1098 Parameters
1099 ----------
1100 centroid : `lsst.geom.Point`
1101 The source centroid.
1102 boxsize : `int`
1103 The size of the box to centre around the centroid.
1104 exp : `lsst.afw.image.Exposure`
1105 The exposure, so the bbox can be clipped to not overrun the bounds.
1107 Returns
1108 -------
1109 bbox : `lsst.geom.Box2I`
1110 The bounding box, centered on the centroid unless clipping to the
1111 exposure causes it to be non-square.
1112 """
1113 bbox = geom.BoxI().makeCenteredBox(centroid, geom.Extent2I(boxSize, boxSize))
1114 bbox = bbox.clippedTo(exp.getBBox())
1115 return bbox
1118def bboxToMatplotlibRectanle(bbox):
1119 """Convert a bbox to a matplotlib Rectangle for plotting.
1121 Parameters
1122 ----------
1123 results : `lsst.geom.Box2I` or `lsst.geom.Box2D`
1124 The bbox to convert.
1126 Returns
1127 -------
1128 rectangle : `bool`
1129 The rectangle.
1130 """
1131 ll = bbox.minX, bbox.minY
1132 width, height = bbox.getDimensions()
1133 return Rectangle(ll, width, height)