Coverage for python/lsst/summit/utils/utils.py: 17%
307 statements
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-26 03:43 -0700
« prev ^ index » next coverage.py v7.2.6, created at 2023-05-26 03:43 -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 os
23import numpy as np
24import logging
25from scipy.ndimage import gaussian_filter
26import lsst.afw.image as afwImage
27import lsst.afw.detection as afwDetect
28import lsst.afw.math as afwMath
29import lsst.daf.base as dafBase
30import lsst.geom as geom
31import lsst.pipe.base as pipeBase
32import lsst.utils.packages as packageUtils
33from lsst.daf.butler.cli.cliLog import CliLog
34import datetime
35from dateutil.tz import gettz
37from lsst.obs.lsst.translators.lsst import FILTER_DELIMITER
38from lsst.obs.lsst.translators.latiss import AUXTEL_LOCATION
40from astro_metadata_translator import ObservationInfo
41from astropy.coordinates import SkyCoord, AltAz
42from astropy.coordinates.earth import EarthLocation
43import astropy.units as u
44from astropy.time import Time
46from .astrometry.utils import genericCameraHeaderToWcs
48__all__ = ["SIGMATOFWHM",
49 "FWHMTOSIGMA",
50 "EFD_CLIENT_MISSING_MSG",
51 "GOOGLE_CLOUD_MISSING_MSG",
52 "AUXTEL_LOCATION",
53 "countPixels",
54 "quickSmooth",
55 "argMax2d",
56 "getImageStats",
57 "detectObjectsInExp",
58 "humanNameForCelestialObject",
59 "getFocusFromHeader",
60 "dayObsIntToString",
61 "dayObsSeqNumToVisitId",
62 "setupLogging",
63 "getCurrentDayObs_datetime",
64 "getCurrentDayObs_int",
65 "getCurrentDayObs_humanStr",
66 "getSite",
67 "getExpPositionOffset",
68 "starTrackerFileToExposure",
69 "getAirmassSeeingCorrection",
70 "getFilterSeeingCorrection",
71 "getCdf",
72 "getQuantiles",
73 "digitizeData",
74 ]
77SIGMATOFWHM = 2.0*np.sqrt(2.0*np.log(2.0))
78FWHMTOSIGMA = 1/SIGMATOFWHM
80EFD_CLIENT_MISSING_MSG = ('ImportError: lsst_efd_client not found. Please install with:\n'
81 ' pip install lsst-efd-client')
83GOOGLE_CLOUD_MISSING_MSG = ('ImportError: Google cloud storage not found. Please install with:\n'
84 ' pip install google-cloud-storage')
87def countPixels(maskedImage, maskPlane):
88 """Count the number of pixels in an image with a given mask bit set.
90 Parameters
91 ----------
92 maskedImage : `lsst.afw.image.MaskedImage`
93 The masked image,
94 maskPlane : `str`
95 The name of the bitmask.
97 Returns
98 -------
99 count : `int``
100 The number of pixels in with the selected mask bit
101 """
102 bit = maskedImage.mask.getPlaneBitMask(maskPlane)
103 return len(np.where(np.bitwise_and(maskedImage.mask.array, bit))[0])
106def quickSmooth(data, sigma=2):
107 """Perform a quick smoothing of the image.
109 Not to be used for scientific purposes, but improves the stretch and
110 visual rendering of low SNR against the sky background in cutouts.
112 Parameters
113 ----------
114 data : `np.array`
115 The image data to smooth
116 sigma : `float`, optional
117 The size of the smoothing kernel.
119 Returns
120 -------
121 smoothData : `np.array`
122 The smoothed data
123 """
124 kernel = [sigma, sigma]
125 smoothData = gaussian_filter(data, kernel, mode='constant')
126 return smoothData
129def argMax2d(array):
130 """Get the index of the max value of an array and whether it's unique.
132 If its not unique, returns a list of the other locations containing the
133 maximum value, e.g. returns
135 (12, 34), False, [(56,78), (910, 1112)]
137 Parameters
138 ----------
139 array : `np.array`
140 The data
142 Returns
143 -------
144 maxLocation : `tuple`
145 The coords of the first instance of the max value
146 unique : `bool`
147 Whether it's the only location
148 otherLocations : `list` of `tuple`
149 List of the other max values' locations, empty if False
150 """
151 uniqueMaximum = False
152 maxCoords = np.where(array == np.max(array))
153 maxCoords = [coord for coord in zip(*maxCoords)] # list of coords as tuples
154 if len(maxCoords) == 1: # single unambiguous value
155 uniqueMaximum = True
157 return maxCoords[0], uniqueMaximum, maxCoords[1:]
160def dayObsIntToString(dayObs):
161 """Convert an integer dayObs to a dash-delimited string.
163 e.g. convert the hard to read 20210101 to 2021-01-01
165 Parameters
166 ----------
167 dayObs : `int`
168 The dayObs.
170 Returns
171 -------
172 dayObs : `str`
173 The dayObs as a string.
174 """
175 assert isinstance(dayObs, int)
176 dStr = str(dayObs)
177 assert len(dStr) == 8
178 return '-'.join([dStr[0:4], dStr[4:6], dStr[6:8]])
181def dayObsSeqNumToVisitId(dayObs, seqNum):
182 """Get the visit id for a given dayObs/seqNum.
184 Parameters
185 ----------
186 dayObs : `int`
187 The dayObs.
188 seqNum : `int`
189 The seqNum.
191 Returns
192 -------
193 visitId : `int`
194 The visitId.
196 Notes
197 -----
198 TODO: Remove this horrible hack once DM-30948 makes this possible
199 programatically/via the butler.
200 """
201 if dayObs < 19700101 or dayObs > 35000101:
202 raise ValueError(f'dayObs value {dayObs} outside plausible range')
203 return int(f"{dayObs}{seqNum:05}")
206def getImageStats(exp):
207 """Calculate a grab-bag of stats for an image. Must remain fast.
209 Parameters
210 ----------
211 exp : `lsst.afw.image.Exposure`
212 The input exposure.
214 Returns
215 -------
216 stats : `lsst.pipe.base.Struct`
217 A container with attributes containing measurements and statistics
218 for the image.
219 """
220 result = pipeBase.Struct()
222 vi = exp.visitInfo
223 expTime = vi.exposureTime
224 md = exp.getMetadata()
226 obj = vi.object
227 mjd = vi.getDate().get()
228 result.object = obj
229 result.mjd = mjd
231 fullFilterString = exp.filter.physicalLabel
232 filt = fullFilterString.split(FILTER_DELIMITER)[0]
233 grating = fullFilterString.split(FILTER_DELIMITER)[1]
235 airmass = vi.getBoresightAirmass()
236 rotangle = vi.getBoresightRotAngle().asDegrees()
238 azAlt = vi.getBoresightAzAlt()
239 az = azAlt[0].asDegrees()
240 el = azAlt[1].asDegrees()
242 result.expTime = expTime
243 result.filter = filt
244 result.grating = grating
245 result.airmass = airmass
246 result.rotangle = rotangle
247 result.az = az
248 result.el = el
249 result.focus = md.get('FOCUSZ')
251 data = exp.image.array
252 result.maxValue = np.max(data)
254 peak, uniquePeak, otherPeaks = argMax2d(data)
255 result.maxPixelLocation = peak
256 result.multipleMaxPixels = uniquePeak
258 result.nBadPixels = countPixels(exp.maskedImage, 'BAD')
259 result.nSatPixels = countPixels(exp.maskedImage, 'SAT')
260 result.percentile99 = np.percentile(data, 99)
261 result.percentile9999 = np.percentile(data, 99.99)
263 sctrl = afwMath.StatisticsControl()
264 sctrl.setNumSigmaClip(5)
265 sctrl.setNumIter(2)
266 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP
267 stats = afwMath.makeStatistics(exp.maskedImage, statTypes, sctrl)
268 std, stderr = stats.getResult(afwMath.STDEVCLIP)
269 mean, meanerr = stats.getResult(afwMath.MEANCLIP)
271 result.clippedMean = mean
272 result.clippedStddev = std
274 return result
277def detectObjectsInExp(exp, nSigma=10, nPixMin=10, grow=0):
278 """Quick and dirty object detection for an expsure.
280 Return the footPrintSet for the objects in a preferably-postISR exposure.
282 Parameters
283 ----------
284 exp : `lsst.afw.image.Exposure`
285 The exposure to detect objects in.
286 nSigma : `float`
287 The number of sigma for detection.
288 nPixMin : `int`
289 The minimum number of pixels in an object for detection.
290 grow : `int`
291 The number of pixels to grow the footprint by after detection.
293 Returns
294 -------
295 footPrintSet : `lsst.afw.detection.FootprintSet`
296 The set of footprints in the image.
297 """
298 median = np.nanmedian(exp.image.array)
299 exp.image -= median
301 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV)
302 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin)
303 if grow > 0:
304 isotropic = True
305 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic)
307 exp.image += median # add back in to leave background unchanged
308 return footPrintSet
311def humanNameForCelestialObject(objName):
312 """Returns a list of all human names for obj, or [] if none are found.
314 Parameters
315 ----------
316 objName : `str`
317 The/a name of the object.
319 Returns
320 -------
321 names : `list` of `str`
322 The names found for the object
323 """
324 from astroquery.simbad import Simbad
325 results = []
326 try:
327 simbadResult = Simbad.query_objectids(objName)
328 for row in simbadResult:
329 if row['ID'].startswith('NAME'):
330 results.append(row['ID'].replace('NAME ', ''))
331 return results
332 except Exception:
333 return [] # same behavior as for found but un-named objects
336def _getAltAzZenithsFromSeqNum(butler, dayObs, seqNumList):
337 """Get the alt, az and zenith angle for the seqNums of a given dayObs.
339 Parameters
340 ----------
341 butler : `lsst.daf.butler.Butler`
342 The butler to query.
343 dayObs : `int`
344 The dayObs.
345 seqNumList : `list` of `int`
346 The seqNums for which to return the alt, az and zenith
348 Returns
349 -------
350 azimuths : `list` of `float`
351 List of the azimuths for each seqNum
352 elevations : `list` of `float`
353 List of the elevations for each seqNum
354 zeniths : `list` of `float`
355 List of the zenith angles for each seqNum
356 """
357 azimuths, elevations, zeniths = [], [], []
358 for seqNum in seqNumList:
359 md = butler.get('raw.metadata', day_obs=dayObs, seq_num=seqNum, detector=0)
360 obsInfo = ObservationInfo(md)
361 alt = obsInfo.altaz_begin.alt.value
362 az = obsInfo.altaz_begin.az.value
363 elevations.append(alt)
364 zeniths.append(90-alt)
365 azimuths.append(az)
366 return azimuths, elevations, zeniths
369def getFocusFromHeader(exp):
370 """Get the raw focus value from the header.
372 Parameters
373 ----------
374 exp : `lsst.afw.image.exposure`
375 The exposure.
377 Returns
378 -------
379 focus : `float` or `None`
380 The focus value if found, else ``None``.
381 """
382 md = exp.getMetadata()
383 if 'FOCUSZ' in md:
384 return md['FOCUSZ']
385 return None
388def checkStackSetup():
389 """Check which weekly tag is being used and which local packages are setup.
391 Designed primarily for use in notbooks/observing, this prints the weekly
392 tag(s) are setup for lsst_distrib, and lists any locally setup packages and
393 the path to each.
395 Notes
396 -----
397 Uses print() instead of logger messages as this should simply print them
398 without being vulnerable to any log messages potentially being diverted.
399 """
400 packages = packageUtils.getEnvironmentPackages(include_all=True)
402 lsstDistribHashAndTags = packages['lsst_distrib'] # looks something like 'g4eae7cb9+1418867f (w_2022_13)'
403 lsstDistribTags = lsstDistribHashAndTags.split()[1]
404 if len(lsstDistribTags.split()) == 1:
405 tag = lsstDistribTags.replace('(', '')
406 tag = tag.replace(')', '')
407 print(f"You are running {tag} of lsst_distrib")
408 else: # multiple weekly tags found for lsst_distrib!
409 print(f'The version of lsst_distrib you have is compatible with: {lsstDistribTags}')
411 localPackages = []
412 localPaths = []
413 for package, tags in packages.items():
414 if tags.startswith('LOCAL:'):
415 path = tags.split('LOCAL:')[1]
416 path = path.split('@')[0] # don't need the git SHA etc
417 localPaths.append(path)
418 localPackages.append(package)
420 if localPackages:
421 print("\nLocally setup packages:")
422 print("-----------------------")
423 maxLen = max(len(package) for package in localPackages)
424 for package, path in zip(localPackages, localPaths):
425 print(f"{package:<{maxLen}s} at {path}")
426 else:
427 print("\nNo locally setup packages (using a vanilla stack)")
430def setupLogging(longlog=False):
431 """Setup logging in the same way as one would get from pipetask run.
433 Code that isn't run through the butler CLI defaults to WARNING level
434 messages and no logger names. This sets the behaviour to follow whatever
435 the pipeline default is, currently
436 <logger_name> <level>: <message> e.g.
437 lsst.isr INFO: Masking defects.
438 """
439 CliLog.initLog(longlog=longlog)
442def getCurrentDayObs_datetime():
443 """Get the current day_obs - the observatory rolls the date over at UTC-12
445 Returned as datetime.date(2022, 4, 28)
446 """
447 utc = gettz("UTC")
448 nowUtc = datetime.datetime.now().astimezone(utc)
449 offset = datetime.timedelta(hours=-12)
450 dayObs = (nowUtc + offset).date()
451 return dayObs
454def getCurrentDayObs_int():
455 """Return the current dayObs as an int in the form 20220428
456 """
457 return int(getCurrentDayObs_datetime().strftime("%Y%m%d"))
460def getCurrentDayObs_humanStr():
461 """Return the current dayObs as a string in the form '2022-04-28'
462 """
463 return dayObsIntToString(getCurrentDayObs_int())
466def getSite():
467 """Returns where the code is running.
469 Returns
470 -------
471 location : `str`
472 One of ['tucson', 'summit', 'base', 'staff-rsp', 'rubin-devl']
474 Raises
475 ------
476 ValueError
477 Raised if location cannot be determined.
478 """
479 # All nublado instances guarantee that EXTERNAL_URL is set and uniquely
480 # identifies it.
481 location = os.getenv('EXTERNAL_INSTANCE_URL', "")
482 if location == "https://tucson-teststand.lsst.codes": 482 ↛ 483line 482 didn't jump to line 483, because the condition on line 482 was never true
483 return 'tucson'
484 elif location == "https://summit-lsp.lsst.codes": 484 ↛ 485line 484 didn't jump to line 485, because the condition on line 484 was never true
485 return 'summit'
486 elif location == "https://base-lsp.lsst.codes": 486 ↛ 487line 486 didn't jump to line 487, because the condition on line 486 was never true
487 return 'base'
488 elif location == "https://usdf-rsp.slac.stanford.edu": 488 ↛ 489line 488 didn't jump to line 489, because the condition on line 488 was never true
489 return 'staff-rsp'
491 # if no EXTERNAL_URL, try HOSTNAME to see if we're on the dev nodes
492 # it is expected that this will be extensible to SLAC
493 hostname = os.getenv('HOSTNAME', "")
494 if hostname.startswith('sdfrome'): 494 ↛ 495line 494 didn't jump to line 495, because the condition on line 494 was never true
495 return 'rubin-devl'
497 # we have failed
498 raise ValueError('Location could not be determined')
501def getAltAzFromSkyPosition(skyPos, visitInfo, doCorrectRefraction=False,
502 wavelength=500.0,
503 pressureOverride=None,
504 temperatureOverride=None,
505 relativeHumidityOverride=None,
506 ):
507 """Get the alt/az from the position on the sky and the time and location
508 of the observation.
510 The temperature, pressure and relative humidity are taken from the
511 visitInfo by default, but can be individually overridden as needed. It
512 should be noted that the visitInfo never contains a nominal wavelength, and
513 so this takes a default value of 500nm.
515 Parameters
516 ----------
517 skyPos : `lsst.geom.SpherePoint`
518 The position on the sky.
519 visitInfo : `lsst.afw.image.VisitInfo`
520 The visit info containing the time of the observation.
521 doCorrectRefraction : `bool`, optional
522 Correct for the atmospheric refraction?
523 wavelength : `float`, optional
524 The nominal wavelength in nanometers (e.g. 500.0), as a float.
525 pressureOverride : `float`, optional
526 The pressure, in bars (e.g. 0.770), to override the value supplied in
527 the visitInfo, as a float.
528 temperatureOverride : `float`, optional
529 The temperature, in Celsius (e.g. 10.0), to override the value supplied
530 in the visitInfo, as a float.
531 relativeHumidityOverride : `float`, optional
532 The relativeHumidity in the range 0..1 (i.e. not as a percentage), to
533 override the value supplied in the visitInfo, as a float.
535 Returns
536 -------
537 alt : `lsst.geom.Angle`
538 The altitude.
539 az : `lsst.geom.Angle`
540 The azimuth.
541 """
542 skyLocation = SkyCoord(skyPos.getRa().asRadians(), skyPos.getDec().asRadians(), unit=u.rad)
543 long = visitInfo.observatory.getLongitude()
544 lat = visitInfo.observatory.getLatitude()
545 ele = visitInfo.observatory.getElevation()
546 earthLocation = EarthLocation.from_geodetic(long.asDegrees(), lat.asDegrees(), ele)
548 refractionKwargs = {}
549 if doCorrectRefraction:
550 # wavelength is never supplied in the visitInfo so always take this
551 wavelength = wavelength * u.nm
553 if pressureOverride:
554 pressure = pressureOverride
555 else:
556 pressure = visitInfo.weather.getAirPressure()
557 # ObservationInfos (which are the "source of truth" use pascals) so
558 # convert from pascals to bars
559 pressure /= 100000.0
560 pressure = pressure*u.bar
562 if temperatureOverride:
563 temperature = temperatureOverride
564 else:
565 temperature = visitInfo.weather.getAirTemperature()
566 temperature = temperature*u.deg_C
568 if relativeHumidityOverride:
569 relativeHumidity = relativeHumidityOverride
570 else:
571 relativeHumidity = visitInfo.weather.getHumidity() / 100.0 # this is in percent
572 relativeHumidity = relativeHumidity*u.deg_C
574 refractionKwargs = dict(pressure=pressure,
575 temperature=temperature,
576 relative_humidity=relativeHumidity,
577 obswl=wavelength)
579 # must go via astropy.Time because dafBase.dateTime.DateTime contains
580 # the timezone, but going straight to visitInfo.date.toPython() loses this.
581 obsTime = Time(visitInfo.date.toPython(), scale='tai')
582 altAz = AltAz(obstime=obsTime,
583 location=earthLocation,
584 **refractionKwargs)
586 obsAltAz = skyLocation.transform_to(altAz)
587 alt = geom.Angle(obsAltAz.alt.degree, geom.degrees)
588 az = geom.Angle(obsAltAz.az.degree, geom.degrees)
590 return alt, az
593def getExpPositionOffset(exp1, exp2, useWcs=True, allowDifferentPlateScales=False):
594 """Get the change in sky position between two exposures.
596 Given two exposures, calculate the offset on the sky between the images.
597 If useWcs then use the (fitted or unfitted) skyOrigin from their WCSs, and
598 calculate the alt/az from the observation times, otherwise use the nominal
599 values in the exposures' visitInfos. Note that if using the visitInfo
600 values that for a given pointing the ra/dec will be ~identical, regardless
601 of whether astrometric fitting has been performed.
603 Values are given as exp1-exp2.
605 Parameters
606 ----------
607 exp1 : `lsst.afw.image.Exposure`
608 The first exposure.
609 exp2 : `lsst.afw.image.Exposure`
610 The second exposure.
611 useWcs : `bool`
612 Use the WCS for the ra/dec and alt/az if True, else use the nominal/
613 boresight values from the exposures' visitInfos.
614 allowDifferentPlateScales : `bool`, optional
615 Use to disable checking that plate scales are the same. Generally,
616 differing plate scales would indicate an error, but where blind-solving
617 has been undertaken during commissioning plate scales can be different
618 enough to warrant setting this to ``True``.
620 Returns
621 -------
622 offsets : `lsst.pipe.base.Struct`
623 A struct containing the offsets:
624 ``deltaRa``
625 The diference in ra (`lsst.geom.Angle`)
626 ``deltaDec``
627 The diference in dec (`lsst.geom.Angle`)
628 ``deltaAlt``
629 The diference in alt (`lsst.geom.Angle`)
630 ``deltaAz``
631 The diference in az (`lsst.geom.Angle`)
632 ``deltaPixels``
633 The diference in pixels (`float`)
634 """
636 wcs1 = exp1.getWcs()
637 wcs2 = exp2.getWcs()
638 pixScaleArcSec = wcs1.getPixelScale().asArcseconds()
639 if not allowDifferentPlateScales:
640 assert np.isclose(pixScaleArcSec, wcs2.getPixelScale().asArcseconds()), \
641 "Pixel scales in the exposures differ."
643 if useWcs:
644 p1 = wcs1.getSkyOrigin()
645 p2 = wcs2.getSkyOrigin()
646 alt1, az1 = getAltAzFromSkyPosition(p1, exp1.getInfo().getVisitInfo())
647 alt2, az2 = getAltAzFromSkyPosition(p2, exp2.getInfo().getVisitInfo())
648 ra1 = p1[0]
649 ra2 = p2[0]
650 dec1 = p1[1]
651 dec2 = p2[1]
652 else:
653 az1 = exp1.visitInfo.boresightAzAlt[0]
654 az2 = exp2.visitInfo.boresightAzAlt[0]
655 alt1 = exp1.visitInfo.boresightAzAlt[1]
656 alt2 = exp2.visitInfo.boresightAzAlt[1]
658 ra1 = exp1.visitInfo.boresightRaDec[0]
659 ra2 = exp2.visitInfo.boresightRaDec[0]
660 dec1 = exp1.visitInfo.boresightRaDec[1]
661 dec2 = exp2.visitInfo.boresightRaDec[1]
663 p1 = exp1.visitInfo.boresightRaDec
664 p2 = exp2.visitInfo.boresightRaDec
666 angular_offset = p1.separation(p2).asArcseconds()
667 deltaPixels = angular_offset / pixScaleArcSec
669 ret = pipeBase.Struct(deltaRa=(ra1-ra2).wrapNear(geom.Angle(0.0)),
670 deltaDec=dec1-dec2,
671 deltaAlt=alt1-alt2,
672 deltaAz=(az1-az2).wrapNear(geom.Angle(0.0)),
673 deltaPixels=deltaPixels
674 )
676 return ret
679def starTrackerFileToExposure(filename, logger=None):
680 """Read the exposure from the file and set the wcs from the header.
682 Parameters
683 ----------
684 filename : `str`
685 The full path to the file.
686 logger : `logging.Logger`, optional
687 The logger to use for errors, created if not supplied.
689 Returns
690 -------
691 exp : `lsst.afw.image.Exposure`
692 The exposure.
693 """
694 if not logger:
695 logger = logging.getLogger(__name__)
696 exp = afwImage.ExposureF(filename)
697 try:
698 wcs = genericCameraHeaderToWcs(exp)
699 exp.setWcs(wcs)
700 except Exception as e:
701 logger.warning(f"Failed to set wcs from header: {e}")
703 # for some reason the date isn't being set correctly
704 # DATE-OBS is present in the original header, but it's being
705 # stripped out and somehow not set (plus it doesn't give the midpoint
706 # of the exposure), so set it manually from the midpoint here
707 try:
708 md = exp.getMetadata()
709 begin = datetime.datetime.fromisoformat(md['DATE-BEG'])
710 end = datetime.datetime.fromisoformat(md['DATE-END'])
711 duration = end - begin
712 mid = begin + duration/2
713 newTime = dafBase.DateTime(mid.isoformat(), dafBase.DateTime.Timescale.TAI)
714 newVi = exp.visitInfo.copyWith(date=newTime)
715 exp.info.setVisitInfo(newVi)
716 except Exception as e:
717 logger.warning(f"Failed to set date from header: {e}")
719 return exp
722def obsInfoToDict(obsInfo):
723 """Convert an ObservationInfo to a dict.
725 Parameters
726 ----------
727 obsInfo : `astro_metadata_translator.ObservationInfo`
728 The ObservationInfo to convert.
730 Returns
731 -------
732 obsInfoDict : `dict`
733 The ObservationInfo as a dict.
734 """
735 return {prop: getattr(obsInfo, prop) for prop in obsInfo.all_properties.keys()}
738def getFieldNameAndTileNumber(field, warn=True, logger=None):
739 """Get the tile name and number of an observed field.
741 It is assumed to always be appended, with an underscore, to the rest of the
742 field name. Returns the name and number as a tuple, or the name unchanged
743 if no tile number is found.
745 Parameters
746 ----------
747 field : `str`
748 The name of the field
750 Returns
751 -------
752 fieldName : `str`
753 The name of the field without the trailing tile number, if present.
754 tileNum : `int`
755 The number of the tile, as an integer, or ``None`` if not found.
756 """
757 if warn and not logger:
758 logger = logging.getLogger('lsst.summit.utils.utils.getFieldNameAndTileNumber')
760 if '_' not in field:
761 if warn:
762 logger.warning(f"Field {field} does not contain an underscore,"
763 " so cannot determine the tile number.")
764 return field, None
766 try:
767 fieldParts = field.split("_")
768 fieldNum = int(fieldParts[-1])
769 except ValueError:
770 if warn:
771 logger.warning(f"Field {field} does not contain only an integer after the final underscore"
772 " so cannot determine the tile number.")
773 return field, None
775 return "_".join(fieldParts[:-1]), fieldNum
778def getAirmassSeeingCorrection(airmass):
779 """Get the correction factor for seeing due to airmass.
781 Parameters
782 ----------
783 airmass : `float`
784 The airmass, greater than or equal to 1.
786 Returns
787 -------
788 correctionFactor : `float`
789 The correction factor to apply to the seeing.
791 Raises
792 ------
793 ValueError raised for unphysical airmasses.
794 """
795 if airmass < 1:
796 raise ValueError(f"Invalid airmass: {airmass}")
797 return airmass**(-0.6)
800def getFilterSeeingCorrection(filterName):
801 """Get the correction factor for seeing due to a filter.
803 Parameters
804 ----------
805 filterName : `str`
806 The name of the filter, e.g. 'SDSSg_65mm'.
808 Returns
809 -------
810 correctionFactor : `float`
811 The correction factor to apply to the seeing.
813 Raises
814 ------
815 ValueError raised for unknown filters.
816 """
817 match filterName:
818 case 'SDSSg_65mm':
819 return (477./500.)**0.2
820 case 'SDSSr_65mm':
821 return (623./500.)**0.2
822 case 'SDSSi_65mm':
823 return (762./500.)**0.2
824 case _:
825 raise ValueError(f"Unknown filter name: {filterName}")
828def getCdf(data, scale):
829 """Return an approximate cumulative distribution function scaled to
830 the [0, scale] range.
832 Parameters
833 ----------
834 data : `np.array`
835 The input data.
836 scale : `int`
837 The scaling range of the output.
839 Returns
840 -------
841 cdf : `np.array` of `int`
842 A monotonically increasing sequence that represents a scaled
843 cumulative distribution function, starting with the value at
844 minVal, then at (minVal + 1), and so on.
845 minVal : `float`
846 An integer smaller than the minimum value in the input data.
847 maxVal : `float`
848 An integer larger than the maximum value in the input data.
849 """
850 flatData = data.ravel()
851 size = flatData.size - np.count_nonzero(np.isnan(flatData))
853 minVal = np.floor(np.nanmin(flatData))
854 maxVal = np.ceil(np.nanmax(flatData)) + 1.0
856 hist, binEdges = np.histogram(
857 flatData, bins=int(maxVal - minVal), range=(minVal, maxVal)
858 )
860 cdf = (scale*np.cumsum(hist)/size).astype(np.int64)
861 return cdf, minVal, maxVal
864def getQuantiles(data, nColors):
865 """Get a set of boundaries that equally distribute data into
866 nColors intervals. The output can be used to make a colormap
867 of nColors colors.
869 This is equivalent to using the numpy function:
870 np.quantile(data, np.linspace(0, 1, nColors + 1))
871 but with a coarser precision, yet sufficient for our use case.
872 This implementation gives a speed-up.
874 Parameters
875 ----------
876 data : `np.array`
877 The input image data.
878 nColors : `int`
879 The number of intervals to distribute data into.
881 Returns
882 -------
883 boundaries: `list` of `float`
884 A monotonically increasing sequence of size (nColors + 1).
885 These are the edges of nColors intervals.
886 """
887 cdf, minVal, maxVal = getCdf(data, nColors)
888 boundaries = np.asarray(
889 [np.argmax(cdf >= i) + minVal for i in range(nColors)] + [maxVal]
890 )
891 return boundaries
894def digitizeData(data, nColors=256):
895 """
896 Scale data into nColors using its cumulative distribution function.
898 Parameters
899 ----------
900 data : `np.array`
901 The input image data.
902 nColors : `int`
903 The number of intervals to distribute data into.
905 Returns
906 -------
907 data: `np.array` of `int`
908 Scaled data in the [0, nColors - 1] range.
909 """
910 cdf, minVal, maxVal = getCdf(data, nColors - 1)
911 bins = np.floor((data - minVal)).astype(np.int64)
912 return cdf[bins]