Coverage for python/lsst/summit/utils/utils.py: 17%
291 statements
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-21 03:00 -0700
« prev ^ index » next coverage.py v7.2.3, created at 2023-04-21 03:00 -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 ]
74SIGMATOFWHM = 2.0*np.sqrt(2.0*np.log(2.0))
75FWHMTOSIGMA = 1/SIGMATOFWHM
77EFD_CLIENT_MISSING_MSG = ('ImportError: lsst_efd_client not found. Please install with:\n'
78 ' pip install lsst-efd-client')
80GOOGLE_CLOUD_MISSING_MSG = ('ImportError: Google cloud storage not found. Please install with:\n'
81 ' pip install google-cloud-storage')
84def countPixels(maskedImage, maskPlane):
85 """Count the number of pixels in an image with a given mask bit set.
87 Parameters
88 ----------
89 maskedImage : `lsst.afw.image.MaskedImage`
90 The masked image,
91 maskPlane : `str`
92 The name of the bitmask.
94 Returns
95 -------
96 count : `int``
97 The number of pixels in with the selected mask bit
98 """
99 bit = maskedImage.mask.getPlaneBitMask(maskPlane)
100 return len(np.where(np.bitwise_and(maskedImage.mask.array, bit))[0])
103def quickSmooth(data, sigma=2):
104 """Perform a quick smoothing of the image.
106 Not to be used for scientific purposes, but improves the stretch and
107 visual rendering of low SNR against the sky background in cutouts.
109 Parameters
110 ----------
111 data : `np.array`
112 The image data to smooth
113 sigma : `float`, optional
114 The size of the smoothing kernel.
116 Returns
117 -------
118 smoothData : `np.array`
119 The smoothed data
120 """
121 kernel = [sigma, sigma]
122 smoothData = gaussian_filter(data, kernel, mode='constant')
123 return smoothData
126def argMax2d(array):
127 """Get the index of the max value of an array and whether it's unique.
129 If its not unique, returns a list of the other locations containing the
130 maximum value, e.g. returns
132 (12, 34), False, [(56,78), (910, 1112)]
134 Parameters
135 ----------
136 array : `np.array`
137 The data
139 Returns
140 -------
141 maxLocation : `tuple`
142 The coords of the first instance of the max value
143 unique : `bool`
144 Whether it's the only location
145 otherLocations : `list` of `tuple`
146 List of the other max values' locations, empty if False
147 """
148 uniqueMaximum = False
149 maxCoords = np.where(array == np.max(array))
150 maxCoords = [coord for coord in zip(*maxCoords)] # list of coords as tuples
151 if len(maxCoords) == 1: # single unambiguous value
152 uniqueMaximum = True
154 return maxCoords[0], uniqueMaximum, maxCoords[1:]
157def dayObsIntToString(dayObs):
158 """Convert an integer dayObs to a dash-delimited string.
160 e.g. convert the hard to read 20210101 to 2021-01-01
162 Parameters
163 ----------
164 dayObs : `int`
165 The dayObs.
167 Returns
168 -------
169 dayObs : `str`
170 The dayObs as a string.
171 """
172 assert isinstance(dayObs, int)
173 dStr = str(dayObs)
174 assert len(dStr) == 8
175 return '-'.join([dStr[0:4], dStr[4:6], dStr[6:8]])
178def dayObsSeqNumToVisitId(dayObs, seqNum):
179 """Get the visit id for a given dayObs/seqNum.
181 Parameters
182 ----------
183 dayObs : `int`
184 The dayObs.
185 seqNum : `int`
186 The seqNum.
188 Returns
189 -------
190 visitId : `int`
191 The visitId.
193 Notes
194 -----
195 TODO: Remove this horrible hack once DM-30948 makes this possible
196 programatically/via the butler.
197 """
198 if dayObs < 19700101 or dayObs > 35000101:
199 raise ValueError(f'dayObs value {dayObs} outside plausible range')
200 return int(f"{dayObs}{seqNum:05}")
203def getImageStats(exp):
204 """Calculate a grab-bag of stats for an image. Must remain fast.
206 Parameters
207 ----------
208 exp : `lsst.afw.image.Exposure`
209 The input exposure.
211 Returns
212 -------
213 stats : `lsst.pipe.base.Struct`
214 A container with attributes containing measurements and statistics
215 for the image.
216 """
217 result = pipeBase.Struct()
219 vi = exp.visitInfo
220 expTime = vi.exposureTime
221 md = exp.getMetadata()
223 obj = vi.object
224 mjd = vi.getDate().get()
225 result.object = obj
226 result.mjd = mjd
228 fullFilterString = exp.filter.physicalLabel
229 filt = fullFilterString.split(FILTER_DELIMITER)[0]
230 grating = fullFilterString.split(FILTER_DELIMITER)[1]
232 airmass = vi.getBoresightAirmass()
233 rotangle = vi.getBoresightRotAngle().asDegrees()
235 azAlt = vi.getBoresightAzAlt()
236 az = azAlt[0].asDegrees()
237 el = azAlt[1].asDegrees()
239 result.expTime = expTime
240 result.filter = filt
241 result.grating = grating
242 result.airmass = airmass
243 result.rotangle = rotangle
244 result.az = az
245 result.el = el
246 result.focus = md.get('FOCUSZ')
248 data = exp.image.array
249 result.maxValue = np.max(data)
251 peak, uniquePeak, otherPeaks = argMax2d(data)
252 result.maxPixelLocation = peak
253 result.multipleMaxPixels = uniquePeak
255 result.nBadPixels = countPixels(exp.maskedImage, 'BAD')
256 result.nSatPixels = countPixels(exp.maskedImage, 'SAT')
257 result.percentile99 = np.percentile(data, 99)
258 result.percentile9999 = np.percentile(data, 99.99)
260 sctrl = afwMath.StatisticsControl()
261 sctrl.setNumSigmaClip(5)
262 sctrl.setNumIter(2)
263 statTypes = afwMath.MEANCLIP | afwMath.STDEVCLIP
264 stats = afwMath.makeStatistics(exp.maskedImage, statTypes, sctrl)
265 std, stderr = stats.getResult(afwMath.STDEVCLIP)
266 mean, meanerr = stats.getResult(afwMath.MEANCLIP)
268 result.clippedMean = mean
269 result.clippedStddev = std
271 return result
274def detectObjectsInExp(exp, nSigma=10, nPixMin=10, grow=0):
275 """Quick and dirty object detection for an expsure.
277 Return the footPrintSet for the objects in a preferably-postISR exposure.
279 Parameters
280 ----------
281 exp : `lsst.afw.image.Exposure`
282 The exposure to detect objects in.
283 nSigma : `float`
284 The number of sigma for detection.
285 nPixMin : `int`
286 The minimum number of pixels in an object for detection.
287 grow : `int`
288 The number of pixels to grow the footprint by after detection.
290 Returns
291 -------
292 footPrintSet : `lsst.afw.detection.FootprintSet`
293 The set of footprints in the image.
294 """
295 median = np.nanmedian(exp.image.array)
296 exp.image -= median
298 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV)
299 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin)
300 if grow > 0:
301 isotropic = True
302 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic)
304 exp.image += median # add back in to leave background unchanged
305 return footPrintSet
308def humanNameForCelestialObject(objName):
309 """Returns a list of all human names for obj, or [] if none are found.
311 Parameters
312 ----------
313 objName : `str`
314 The/a name of the object.
316 Returns
317 -------
318 names : `list` of `str`
319 The names found for the object
320 """
321 from astroquery.simbad import Simbad
322 results = []
323 try:
324 simbadResult = Simbad.query_objectids(objName)
325 for row in simbadResult:
326 if row['ID'].startswith('NAME'):
327 results.append(row['ID'].replace('NAME ', ''))
328 return results
329 except Exception:
330 return [] # same behavior as for found but un-named objects
333def _getAltAzZenithsFromSeqNum(butler, dayObs, seqNumList):
334 """Get the alt, az and zenith angle for the seqNums of a given dayObs.
336 Parameters
337 ----------
338 butler : `lsst.daf.butler.Butler`
339 The butler to query.
340 dayObs : `int`
341 The dayObs.
342 seqNumList : `list` of `int`
343 The seqNums for which to return the alt, az and zenith
345 Returns
346 -------
347 azimuths : `list` of `float`
348 List of the azimuths for each seqNum
349 elevations : `list` of `float`
350 List of the elevations for each seqNum
351 zeniths : `list` of `float`
352 List of the zenith angles for each seqNum
353 """
354 azimuths, elevations, zeniths = [], [], []
355 for seqNum in seqNumList:
356 md = butler.get('raw.metadata', day_obs=dayObs, seq_num=seqNum, detector=0)
357 obsInfo = ObservationInfo(md)
358 alt = obsInfo.altaz_begin.alt.value
359 az = obsInfo.altaz_begin.az.value
360 elevations.append(alt)
361 zeniths.append(90-alt)
362 azimuths.append(az)
363 return azimuths, elevations, zeniths
366def getFocusFromHeader(exp):
367 """Get the raw focus value from the header.
369 Parameters
370 ----------
371 exp : `lsst.afw.image.exposure`
372 The exposure.
374 Returns
375 -------
376 focus : `float` or `None`
377 The focus value if found, else ``None``.
378 """
379 md = exp.getMetadata()
380 if 'FOCUSZ' in md:
381 return md['FOCUSZ']
382 return None
385def checkStackSetup():
386 """Check which weekly tag is being used and which local packages are setup.
388 Designed primarily for use in notbooks/observing, this prints the weekly
389 tag(s) are setup for lsst_distrib, and lists any locally setup packages and
390 the path to each.
392 Notes
393 -----
394 Uses print() instead of logger messages as this should simply print them
395 without being vulnerable to any log messages potentially being diverted.
396 """
397 packages = packageUtils.getEnvironmentPackages(include_all=True)
399 lsstDistribHashAndTags = packages['lsst_distrib'] # looks something like 'g4eae7cb9+1418867f (w_2022_13)'
400 lsstDistribTags = lsstDistribHashAndTags.split()[1]
401 if len(lsstDistribTags.split()) == 1:
402 tag = lsstDistribTags.replace('(', '')
403 tag = tag.replace(')', '')
404 print(f"You are running {tag} of lsst_distrib")
405 else: # multiple weekly tags found for lsst_distrib!
406 print(f'The version of lsst_distrib you have is compatible with: {lsstDistribTags}')
408 localPackages = []
409 localPaths = []
410 for package, tags in packages.items():
411 if tags.startswith('LOCAL:'):
412 path = tags.split('LOCAL:')[1]
413 path = path.split('@')[0] # don't need the git SHA etc
414 localPaths.append(path)
415 localPackages.append(package)
417 if localPackages:
418 print("\nLocally setup packages:")
419 print("-----------------------")
420 maxLen = max(len(package) for package in localPackages)
421 for package, path in zip(localPackages, localPaths):
422 print(f"{package:<{maxLen}s} at {path}")
423 else:
424 print("\nNo locally setup packages (using a vanilla stack)")
427def setupLogging(longlog=False):
428 """Setup logging in the same way as one would get from pipetask run.
430 Code that isn't run through the butler CLI defaults to WARNING level
431 messages and no logger names. This sets the behaviour to follow whatever
432 the pipeline default is, currently
433 <logger_name> <level>: <message> e.g.
434 lsst.isr INFO: Masking defects.
435 """
436 CliLog.initLog(longlog=longlog)
439def getCurrentDayObs_datetime():
440 """Get the current day_obs - the observatory rolls the date over at UTC-12
442 Returned as datetime.date(2022, 4, 28)
443 """
444 utc = gettz("UTC")
445 nowUtc = datetime.datetime.now().astimezone(utc)
446 offset = datetime.timedelta(hours=-12)
447 dayObs = (nowUtc + offset).date()
448 return dayObs
451def getCurrentDayObs_int():
452 """Return the current dayObs as an int in the form 20220428
453 """
454 return int(getCurrentDayObs_datetime().strftime("%Y%m%d"))
457def getCurrentDayObs_humanStr():
458 """Return the current dayObs as a string in the form '2022-04-28'
459 """
460 return dayObsIntToString(getCurrentDayObs_int())
463def getSite():
464 """Returns where the code is running.
466 Returns
467 -------
468 location : `str`
469 One of ['tucson', 'summit', 'base', 'staff-rsp', 'rubin-devl']
471 Raises
472 ------
473 ValueError
474 Raised if location cannot be determined.
475 """
476 # All nublado instances guarantee that EXTERNAL_URL is set and uniquely
477 # identifies it.
478 location = os.getenv('EXTERNAL_INSTANCE_URL', "")
479 if location == "https://tucson-teststand.lsst.codes": 479 ↛ 480line 479 didn't jump to line 480, because the condition on line 479 was never true
480 return 'tucson'
481 elif location == "https://summit-lsp.lsst.codes": 481 ↛ 482line 481 didn't jump to line 482, because the condition on line 481 was never true
482 return 'summit'
483 elif location == "https://base-lsp.lsst.codes": 483 ↛ 484line 483 didn't jump to line 484, because the condition on line 483 was never true
484 return 'base'
485 elif location == "https://usdf-rsp.slac.stanford.edu": 485 ↛ 486line 485 didn't jump to line 486, because the condition on line 485 was never true
486 return 'staff-rsp'
488 # if no EXTERNAL_URL, try HOSTNAME to see if we're on the dev nodes
489 # it is expected that this will be extensible to SLAC
490 hostname = os.getenv('HOSTNAME', "")
491 if hostname.startswith('sdfrome'): 491 ↛ 492line 491 didn't jump to line 492, because the condition on line 491 was never true
492 return 'rubin-devl'
494 # we have failed
495 raise ValueError('Location could not be determined')
498def getAltAzFromSkyPosition(skyPos, visitInfo, doCorrectRefraction=False,
499 wavelength=500.0,
500 pressureOverride=None,
501 temperatureOverride=None,
502 relativeHumidityOverride=None,
503 ):
504 """Get the alt/az from the position on the sky and the time and location
505 of the observation.
507 The temperature, pressure and relative humidity are taken from the
508 visitInfo by default, but can be individually overridden as needed. It
509 should be noted that the visitInfo never contains a nominal wavelength, and
510 so this takes a default value of 500nm.
512 Parameters
513 ----------
514 skyPos : `lsst.geom.SpherePoint`
515 The position on the sky.
516 visitInfo : `lsst.afw.image.VisitInfo`
517 The visit info containing the time of the observation.
518 doCorrectRefraction : `bool`, optional
519 Correct for the atmospheric refraction?
520 wavelength : `float`, optional
521 The nominal wavelength in nanometers (e.g. 500.0), as a float.
522 pressureOverride : `float`, optional
523 The pressure, in bars (e.g. 0.770), to override the value supplied in
524 the visitInfo, as a float.
525 temperatureOverride : `float`, optional
526 The temperature, in Celsius (e.g. 10.0), to override the value supplied
527 in the visitInfo, as a float.
528 relativeHumidityOverride : `float`, optional
529 The relativeHumidity in the range 0..1 (i.e. not as a percentage), to
530 override the value supplied in the visitInfo, as a float.
532 Returns
533 -------
534 alt : `lsst.geom.Angle`
535 The altitude.
536 az : `lsst.geom.Angle`
537 The azimuth.
538 """
539 skyLocation = SkyCoord(skyPos.getRa().asRadians(), skyPos.getDec().asRadians(), unit=u.rad)
540 long = visitInfo.observatory.getLongitude()
541 lat = visitInfo.observatory.getLatitude()
542 ele = visitInfo.observatory.getElevation()
543 earthLocation = EarthLocation.from_geodetic(long.asDegrees(), lat.asDegrees(), ele)
545 refractionKwargs = {}
546 if doCorrectRefraction:
547 # wavelength is never supplied in the visitInfo so always take this
548 wavelength = wavelength * u.nm
550 if pressureOverride:
551 pressure = pressureOverride
552 else:
553 pressure = visitInfo.weather.getAirPressure()
554 # ObservationInfos (which are the "source of truth" use pascals) so
555 # convert from pascals to bars
556 pressure /= 100000.0
557 pressure = pressure*u.bar
559 if temperatureOverride:
560 temperature = temperatureOverride
561 else:
562 temperature = visitInfo.weather.getAirTemperature()
563 temperature = temperature*u.deg_C
565 if relativeHumidityOverride:
566 relativeHumidity = relativeHumidityOverride
567 else:
568 relativeHumidity = visitInfo.weather.getHumidity() / 100.0 # this is in percent
569 relativeHumidity = relativeHumidity*u.deg_C
571 refractionKwargs = dict(pressure=pressure,
572 temperature=temperature,
573 relative_humidity=relativeHumidity,
574 obswl=wavelength)
576 # must go via astropy.Time because dafBase.dateTime.DateTime contains
577 # the timezone, but going straight to visitInfo.date.toPython() loses this.
578 obsTime = Time(visitInfo.date.toPython(), scale='tai')
579 altAz = AltAz(obstime=obsTime,
580 location=earthLocation,
581 **refractionKwargs)
583 obsAltAz = skyLocation.transform_to(altAz)
584 alt = geom.Angle(obsAltAz.alt.degree, geom.degrees)
585 az = geom.Angle(obsAltAz.az.degree, geom.degrees)
587 return alt, az
590def getExpPositionOffset(exp1, exp2, useWcs=True, allowDifferentPlateScales=False):
591 """Get the change in sky position between two exposures.
593 Given two exposures, calculate the offset on the sky between the images.
594 If useWcs then use the (fitted or unfitted) skyOrigin from their WCSs, and
595 calculate the alt/az from the observation times, otherwise use the nominal
596 values in the exposures' visitInfos. Note that if using the visitInfo
597 values that for a given pointing the ra/dec will be ~identical, regardless
598 of whether astrometric fitting has been performed.
600 Values are given as exp1-exp2.
602 Parameters
603 ----------
604 exp1 : `lsst.afw.image.Exposure`
605 The first exposure.
606 exp2 : `lsst.afw.image.Exposure`
607 The second exposure.
608 useWcs : `bool`
609 Use the WCS for the ra/dec and alt/az if True, else use the nominal/
610 boresight values from the exposures' visitInfos.
611 allowDifferentPlateScales : `bool`, optional
612 Use to disable checking that plate scales are the same. Generally,
613 differing plate scales would indicate an error, but where blind-solving
614 has been undertaken during commissioning plate scales can be different
615 enough to warrant setting this to ``True``.
617 Returns
618 -------
619 offsets : `lsst.pipe.base.Struct`
620 A struct containing the offsets:
621 ``deltaRa``
622 The diference in ra (`lsst.geom.Angle`)
623 ``deltaDec``
624 The diference in dec (`lsst.geom.Angle`)
625 ``deltaAlt``
626 The diference in alt (`lsst.geom.Angle`)
627 ``deltaAz``
628 The diference in az (`lsst.geom.Angle`)
629 ``deltaPixels``
630 The diference in pixels (`float`)
631 """
633 wcs1 = exp1.getWcs()
634 wcs2 = exp2.getWcs()
635 pixScaleArcSec = wcs1.getPixelScale().asArcseconds()
636 if not allowDifferentPlateScales:
637 assert np.isclose(pixScaleArcSec, wcs2.getPixelScale().asArcseconds()), \
638 "Pixel scales in the exposures differ."
640 if useWcs:
641 p1 = wcs1.getSkyOrigin()
642 p2 = wcs2.getSkyOrigin()
643 alt1, az1 = getAltAzFromSkyPosition(p1, exp1.getInfo().getVisitInfo())
644 alt2, az2 = getAltAzFromSkyPosition(p2, exp2.getInfo().getVisitInfo())
645 ra1 = p1[0]
646 ra2 = p2[0]
647 dec1 = p1[1]
648 dec2 = p2[1]
649 else:
650 az1 = exp1.visitInfo.boresightAzAlt[0]
651 az2 = exp2.visitInfo.boresightAzAlt[0]
652 alt1 = exp1.visitInfo.boresightAzAlt[1]
653 alt2 = exp2.visitInfo.boresightAzAlt[1]
655 ra1 = exp1.visitInfo.boresightRaDec[0]
656 ra2 = exp2.visitInfo.boresightRaDec[0]
657 dec1 = exp1.visitInfo.boresightRaDec[1]
658 dec2 = exp2.visitInfo.boresightRaDec[1]
660 p1 = exp1.visitInfo.boresightRaDec
661 p2 = exp2.visitInfo.boresightRaDec
663 angular_offset = p1.separation(p2).asArcseconds()
664 deltaPixels = angular_offset / pixScaleArcSec
666 ret = pipeBase.Struct(deltaRa=(ra1-ra2).wrapNear(geom.Angle(0.0)),
667 deltaDec=dec1-dec2,
668 deltaAlt=alt1-alt2,
669 deltaAz=(az1-az2).wrapNear(geom.Angle(0.0)),
670 deltaPixels=deltaPixels
671 )
673 return ret
676def starTrackerFileToExposure(filename, logger=None):
677 """Read the exposure from the file and set the wcs from the header.
679 Parameters
680 ----------
681 filename : `str`
682 The full path to the file.
683 logger : `logging.Logger`, optional
684 The logger to use for errors, created if not supplied.
686 Returns
687 -------
688 exp : `lsst.afw.image.Exposure`
689 The exposure.
690 """
691 if not logger:
692 logger = logging.getLogger(__name__)
693 exp = afwImage.ExposureF(filename)
694 try:
695 wcs = genericCameraHeaderToWcs(exp)
696 exp.setWcs(wcs)
697 except Exception as e:
698 logger.warning(f"Failed to set wcs from header: {e}")
700 # for some reason the date isn't being set correctly
701 # DATE-OBS is present in the original header, but it's being
702 # stripped out and somehow not set (plus it doesn't give the midpoint
703 # of the exposure), so set it manually from the midpoint here
704 try:
705 md = exp.getMetadata()
706 begin = datetime.datetime.fromisoformat(md['DATE-BEG'])
707 end = datetime.datetime.fromisoformat(md['DATE-END'])
708 duration = end - begin
709 mid = begin + duration/2
710 newTime = dafBase.DateTime(mid.isoformat(), dafBase.DateTime.Timescale.TAI)
711 newVi = exp.visitInfo.copyWith(date=newTime)
712 exp.info.setVisitInfo(newVi)
713 except Exception as e:
714 logger.warning(f"Failed to set date from header: {e}")
716 return exp
719def obsInfoToDict(obsInfo):
720 """Convert an ObservationInfo to a dict.
722 Parameters
723 ----------
724 obsInfo : `astro_metadata_translator.ObservationInfo`
725 The ObservationInfo to convert.
727 Returns
728 -------
729 obsInfoDict : `dict`
730 The ObservationInfo as a dict.
731 """
732 return {prop: getattr(obsInfo, prop) for prop in obsInfo.all_properties.keys()}
735def getFieldNameAndTileNumber(field, warn=True, logger=None):
736 """Get the tile name and number of an observed field.
738 It is assumed to always be appended, with an underscore, to the rest of the
739 field name. Returns the name and number as a tuple, or the name unchanged
740 if no tile number is found.
742 Parameters
743 ----------
744 field : `str`
745 The name of the field
747 Returns
748 -------
749 fieldName : `str`
750 The name of the field without the trailing tile number, if present.
751 tileNum : `int`
752 The number of the tile, as an integer, or ``None`` if not found.
753 """
754 if warn and not logger:
755 logger = logging.getLogger('lsst.summit.utils.utils.getFieldNameAndTileNumber')
757 if '_' not in field:
758 if warn:
759 logger.warning(f"Field {field} does not contain an underscore,"
760 " so cannot determine the tile number.")
761 return field, None
763 try:
764 fieldParts = field.split("_")
765 fieldNum = int(fieldParts[-1])
766 except ValueError:
767 if warn:
768 logger.warning(f"Field {field} does not contain only an integer after the final underscore"
769 " so cannot determine the tile number.")
770 return field, None
772 return "_".join(fieldParts[:-1]), fieldNum
775def getAirmassSeeingCorrection(airmass):
776 """Get the correction factor for seeing due to airmass.
778 Parameters
779 ----------
780 airmass : `float`
781 The airmass, greater than or equal to 1.
783 Returns
784 -------
785 correctionFactor : `float`
786 The correction factor to apply to the seeing.
788 Raises
789 ------
790 ValueError raised for unphysical airmasses.
791 """
792 if airmass < 1:
793 raise ValueError(f"Invalid airmass: {airmass}")
794 return airmass**(-0.6)
797def getFilterSeeingCorrection(filterName):
798 """Get the correction factor for seeing due to a filter.
800 Parameters
801 ----------
802 filterName : `str`
803 The name of the filter, e.g. 'SDSSg_65mm'.
805 Returns
806 -------
807 correctionFactor : `float`
808 The correction factor to apply to the seeing.
810 Raises
811 ------
812 ValueError raised for unknown filters.
813 """
814 match filterName:
815 case 'SDSSg_65mm':
816 return (477./500.)**0.2
817 case 'SDSSr_65mm':
818 return (623./500.)**0.2
819 case 'SDSSi_65mm':
820 return (762./500.)**0.2
821 case _:
822 raise ValueError(f"Unknown filter name: {filterName}")