Coverage for python/lsst/summit/utils/utils.py: 19%
274 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-31 04:33 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-31 04:33 -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):
499 """Get the alt/az from the position on the sky and the time and location
500 of the observation.
502 Parameters
503 ----------
504 skyPos : `lsst.geom.SpherePoint`
505 The position on the sky.
506 visitInfo : `lsst.afw.image.VisitInfo`
507 The visit info containing the time of the observation.
509 Returns
510 -------
511 alt : `lsst.geom.Angle`
512 The altitude.
513 az : `lsst.geom.Angle`
514 The azimuth.
515 """
516 skyLocation = SkyCoord(skyPos.getRa().asRadians(), skyPos.getDec().asRadians(), unit=u.rad)
517 long = visitInfo.observatory.getLongitude()
518 lat = visitInfo.observatory.getLatitude()
519 ele = visitInfo.observatory.getElevation()
520 earthLocation = EarthLocation.from_geodetic(long.asDegrees(), lat.asDegrees(), ele)
522 # must go via astropy.Time because dafBase.dateTime.DateTime contains
523 # the timezone, but going straight to visitInfo.date.toPython() loses this.
524 obsTime = Time(visitInfo.date.toPython(), scale='tai')
525 altAz = AltAz(obstime=obsTime, location=earthLocation)
527 obsAltAz = skyLocation.transform_to(altAz)
528 alt = geom.Angle(obsAltAz.alt.degree, geom.degrees)
529 az = geom.Angle(obsAltAz.az.degree, geom.degrees)
531 return alt, az
534def getExpPositionOffset(exp1, exp2, useWcs=True, allowDifferentPlateScales=False):
535 """Get the change in sky position between two exposures.
537 Given two exposures, calculate the offset on the sky between the images.
538 If useWcs then use the (fitted or unfitted) skyOrigin from their WCSs, and
539 calculate the alt/az from the observation times, otherwise use the nominal
540 values in the exposures' visitInfos. Note that if using the visitInfo
541 values that for a given pointing the ra/dec will be ~identical, regardless
542 of whether astrometric fitting has been performed.
544 Values are given as exp1-exp2.
546 Parameters
547 ----------
548 exp1 : `lsst.afw.image.Exposure`
549 The first exposure.
550 exp2 : `lsst.afw.image.Exposure`
551 The second exposure.
552 useWcs : `bool`
553 Use the WCS for the ra/dec and alt/az if True, else use the nominal/
554 boresight values from the exposures' visitInfos.
555 allowDifferentPlateScales : `bool`, optional
556 Use to disable checking that plate scales are the same. Generally,
557 differing plate scales would indicate an error, but where blind-solving
558 has been undertaken during commissioning plate scales can be different
559 enough to warrant setting this to ``True``.
561 Returns
562 -------
563 offsets : `lsst.pipe.base.Struct`
564 A struct containing the offsets:
565 ``deltaRa``
566 The diference in ra (`lsst.geom.Angle`)
567 ``deltaDec``
568 The diference in dec (`lsst.geom.Angle`)
569 ``deltaAlt``
570 The diference in alt (`lsst.geom.Angle`)
571 ``deltaAz``
572 The diference in az (`lsst.geom.Angle`)
573 ``deltaPixels``
574 The diference in pixels (`float`)
575 """
577 wcs1 = exp1.getWcs()
578 wcs2 = exp2.getWcs()
579 pixScaleArcSec = wcs1.getPixelScale().asArcseconds()
580 if not allowDifferentPlateScales:
581 assert np.isclose(pixScaleArcSec, wcs2.getPixelScale().asArcseconds()), \
582 "Pixel scales in the exposures differ."
584 if useWcs:
585 p1 = wcs1.getSkyOrigin()
586 p2 = wcs2.getSkyOrigin()
587 alt1, az1 = getAltAzFromSkyPosition(p1, exp1.getInfo().getVisitInfo())
588 alt2, az2 = getAltAzFromSkyPosition(p2, exp2.getInfo().getVisitInfo())
589 ra1 = p1[0]
590 ra2 = p2[0]
591 dec1 = p1[1]
592 dec2 = p2[1]
593 else:
594 az1 = exp1.visitInfo.boresightAzAlt[0]
595 az2 = exp2.visitInfo.boresightAzAlt[0]
596 alt1 = exp1.visitInfo.boresightAzAlt[1]
597 alt2 = exp2.visitInfo.boresightAzAlt[1]
599 ra1 = exp1.visitInfo.boresightRaDec[0]
600 ra2 = exp2.visitInfo.boresightRaDec[0]
601 dec1 = exp1.visitInfo.boresightRaDec[1]
602 dec2 = exp2.visitInfo.boresightRaDec[1]
604 p1 = exp1.visitInfo.boresightRaDec
605 p2 = exp2.visitInfo.boresightRaDec
607 angular_offset = p1.separation(p2).asArcseconds()
608 deltaPixels = angular_offset / pixScaleArcSec
610 ret = pipeBase.Struct(deltaRa=(ra1-ra2).wrapNear(geom.Angle(0.0)),
611 deltaDec=dec1-dec2,
612 deltaAlt=alt1-alt2,
613 deltaAz=(az1-az2).wrapNear(geom.Angle(0.0)),
614 deltaPixels=deltaPixels
615 )
617 return ret
620def starTrackerFileToExposure(filename, logger=None):
621 """Read the exposure from the file and set the wcs from the header.
623 Parameters
624 ----------
625 filename : `str`
626 The full path to the file.
627 logger : `logging.Logger`, optional
628 The logger to use for errors, created if not supplied.
630 Returns
631 -------
632 exp : `lsst.afw.image.Exposure`
633 The exposure.
634 """
635 if not logger:
636 logger = logging.getLogger(__name__)
637 exp = afwImage.ExposureF(filename)
638 try:
639 wcs = genericCameraHeaderToWcs(exp)
640 exp.setWcs(wcs)
641 except Exception as e:
642 logger.warning(f"Failed to set wcs from header: {e}")
644 # for some reason the date isn't being set correctly
645 # DATE-OBS is present in the original header, but it's being
646 # stripped out and somehow not set (plus it doesn't give the midpoint
647 # of the exposure), so set it manually from the midpoint here
648 try:
649 md = exp.getMetadata()
650 begin = datetime.datetime.fromisoformat(md['DATE-BEG'])
651 end = datetime.datetime.fromisoformat(md['DATE-END'])
652 duration = end - begin
653 mid = begin + duration/2
654 newTime = dafBase.DateTime(mid.isoformat(), dafBase.DateTime.Timescale.TAI)
655 newVi = exp.visitInfo.copyWith(date=newTime)
656 exp.info.setVisitInfo(newVi)
657 except Exception as e:
658 logger.warning(f"Failed to set date from header: {e}")
660 return exp
663def obsInfoToDict(obsInfo):
664 """Convert an ObservationInfo to a dict.
666 Parameters
667 ----------
668 obsInfo : `astro_metadata_translator.ObservationInfo`
669 The ObservationInfo to convert.
671 Returns
672 -------
673 obsInfoDict : `dict`
674 The ObservationInfo as a dict.
675 """
676 return {prop: getattr(obsInfo, prop) for prop in obsInfo.all_properties.keys()}
679def getFieldNameAndTileNumber(field, warn=True, logger=None):
680 """Get the tile name and number of an observed field.
682 It is assumed to always be appended, with an underscore, to the rest of the
683 field name. Returns the name and number as a tuple, or the name unchanged
684 if no tile number is found.
686 Parameters
687 ----------
688 field : `str`
689 The name of the field
691 Returns
692 -------
693 fieldName : `str`
694 The name of the field without the trailing tile number, if present.
695 tileNum : `int`
696 The number of the tile, as an integer, or ``None`` if not found.
697 """
698 if warn and not logger:
699 logger = logging.getLogger('lsst.summit.utils.utils.getFieldNameAndTileNumber')
701 if '_' not in field:
702 if warn:
703 logger.warning(f"Field {field} does not contain an underscore,"
704 " so cannot determine the tile number.")
705 return field, None
707 try:
708 fieldParts = field.split("_")
709 fieldNum = int(fieldParts[-1])
710 except ValueError:
711 if warn:
712 logger.warning(f"Field {field} does not contain only an integer after the final underscore"
713 " so cannot determine the tile number.")
714 return field, None
716 return "_".join(fieldParts[:-1]), fieldNum
719def getAirmassSeeingCorrection(airmass):
720 """Get the correction factor for seeing due to airmass.
722 Parameters
723 ----------
724 airmass : `float`
725 The airmass, greater than or equal to 1.
727 Returns
728 -------
729 correctionFactor : `float`
730 The correction factor to apply to the seeing.
732 Raises
733 ------
734 ValueError raised for unphysical airmasses.
735 """
736 if airmass < 1:
737 raise ValueError(f"Invalid airmass: {airmass}")
738 return airmass**(-0.6)
741def getFilterSeeingCorrection(filterName):
742 """Get the correction factor for seeing due to a filter.
744 Parameters
745 ----------
746 filterName : `str`
747 The name of the filter, e.g. 'SDSSg_65mm'.
749 Returns
750 -------
751 correctionFactor : `float`
752 The correction factor to apply to the seeing.
754 Raises
755 ------
756 ValueError raised for unknown filters.
757 """
758 match filterName:
759 case 'SDSSg_65mm':
760 return (477./500.)**0.2
761 case 'SDSSr_65mm':
762 return (623./500.)**0.2
763 case 'SDSSi_65mm':
764 return (762./500.)**0.2
765 case _:
766 raise ValueError(f"Unknown filter name: {filterName}")