diff --git a/doc/versionHistory.rst b/doc/versionHistory.rst index b4718979..285f322a 100644 --- a/doc/versionHistory.rst +++ b/doc/versionHistory.rst @@ -6,6 +6,15 @@ Version History ################## +.. _lsst.ts.wep-10.5.0: + +------------- +10.5.0 +------------- + +* Added ``defocalOffset`` and ``batoidOffsetValue`` attributes to the ``Image`` class. +* Offset values in ``Image`` class now override ``Instrument`` defaults when not None. + .. _lsst.ts.wep-10.4.1: ------------- diff --git a/policy/instruments/ComCam.yaml b/policy/instruments/ComCam.yaml index fb7c12d8..0ca0f80c 100644 --- a/policy/instruments/ComCam.yaml +++ b/policy/instruments/ComCam.yaml @@ -7,7 +7,7 @@ imports: policy:instruments/LsstCam.yaml # Apply these overrides name: LsstComCam batoidModelName: ComCam_{band} -batoidOffsetOptic: ComCam +batoidOffsetOptic: Detector maskParams: M1: diff --git a/policy/instruments/LsstCam.yaml b/policy/instruments/LsstCam.yaml index 94420b5c..210c7a11 100644 --- a/policy/instruments/LsstCam.yaml +++ b/policy/instruments/LsstCam.yaml @@ -20,6 +20,8 @@ wavelength: z: 866.8e-9 y: 973.9e-9 batoidModelName: LSST_{band} # name used to load the Batoid model +batoidOffsetOptic: Detector # Element in Batoid model offset for defocus +batoidOffsetValue: 1.5e-3 # Size of offset in Batoid model, in meters maskParams: # center and radius are in meters, theta in degrees M1: diff --git a/python/lsst/ts/wep/estimation/danish.py b/python/lsst/ts/wep/estimation/danish.py index 7705b0cd..44cb4482 100644 --- a/python/lsst/ts/wep/estimation/danish.py +++ b/python/lsst/ts/wep/estimation/danish.py @@ -152,8 +152,9 @@ def _estimateSingleZk( zkStart = np.pad(zkStart, (4, 0)) offAxisCoeff = instrument.getOffAxisCoeff( *image.fieldAngle, - image.defocalType, - image.bandLabel, + defocalType=image.defocalType, + batoidOffsetValue=image.batoidOffsetValue, + band=image.bandLabel, jmaxIntrinsic=jmax, return4Up=False, ) diff --git a/python/lsst/ts/wep/estimation/wfAlgorithm.py b/python/lsst/ts/wep/estimation/wfAlgorithm.py index 95bc2520..7e49e510 100644 --- a/python/lsst/ts/wep/estimation/wfAlgorithm.py +++ b/python/lsst/ts/wep/estimation/wfAlgorithm.py @@ -267,6 +267,16 @@ def estimateZk( saveHistory, ) + # If either image has defocal offset, override default instrument value + offsets = [ + img.defocalOffset + for img in (I1, I2) + if img is not None and img.defocalOffset is not None + ] + if len(offsets) > 0: + instrument = instrument.copy() + instrument.defocalOffset = np.mean(offsets) + # Get the intrinsic Zernikes? if startWithIntrinsic or returnWfDev: zkIntrinsicI1 = instrument.getIntrinsicZernikes( diff --git a/python/lsst/ts/wep/image.py b/python/lsst/ts/wep/image.py index 3a7f1bce..475cf74f 100644 --- a/python/lsst/ts/wep/image.py +++ b/python/lsst/ts/wep/image.py @@ -67,6 +67,30 @@ class Image: Note these shifts must be in the global CCS (see the note on coordinate systems above). (the default is an empty array, i.e. no blends) + mask : np.ndarray, optional + The image source mask that is 1 for source pixels and 0 otherwise. + Mask creation is meant to be handled by the ImageMapper class. + (the default is None) + maskBlends : np.ndarray, optional + The image blend mask that is 1 for blend pixels and 0 otherwise. + Mask creation is meant to be handled by the ImageMapper class. + (the default is None) + maskBackground : np.ndarray, optional + The image background mask that is 1 for background pixels and 0 + otherwise. Mask creation is meant to be handled by the ImageMapper + class. + (the default is None) + defocalOffset : float, optional + The defocal offset of the detector when this image was taken (or + the equivalent offset if some other element, such as M2, was offset). + If not None, this value will override the default value in the + instrument when using the ImageMapper. + (the default is None) + batoidOffsetValue : float, optional + The offset of batoidOffsetOptic used to calculate off-axis + coefficients. If not None, this value will override the default value + in the instrument when using the ImageMapper. + (the default is None) """ def __init__( @@ -77,6 +101,11 @@ def __init__( bandLabel: Union[BandLabel, str] = BandLabel.REF, planeType: Union[PlaneType, str] = PlaneType.Image, blendOffsets: Union[np.ndarray, tuple, list, None] = None, + mask: Optional[np.ndarray] = None, + maskBlends: Optional[np.ndarray] = None, + maskBackground: Optional[np.ndarray] = None, + defocalOffset: Optional[float] = None, + batoidOffsetValue: Optional[float] = None, ) -> None: self.image = image self.fieldAngle = fieldAngle # type: ignore @@ -84,11 +113,11 @@ def __init__( self.bandLabel = bandLabel # type: ignore self.planeType = planeType # type: ignore self.blendOffsets = blendOffsets # type: ignore - - # Set all mask variables - self._mask = None - self._maskBlends = None - self._maskBackground = None + self.mask = mask + self.maskBlends = maskBlends + self.maskBackground = maskBackground + self.defocalOffset = defocalOffset + self.batoidOffsetValue = batoidOffsetValue @property def image(self) -> np.ndarray: @@ -162,7 +191,7 @@ def defocalType(self, value: Union[DefocalType, str]) -> None: TypeError The provided value is not a DefocalType Enum or string. """ - if isinstance(value, str) or isinstance(value, DefocalType): + if isinstance(value, (str, DefocalType)): self._defocalType = DefocalType(value) else: raise TypeError( @@ -193,7 +222,7 @@ def bandLabel(self, value: Union[BandLabel, str, None]) -> None: """ if value is None or value == "": self._bandLabel = BandLabel.REF - elif isinstance(value, str) or isinstance(value, BandLabel): + elif isinstance(value, (str, BandLabel)): self._bandLabel = BandLabel(value) else: raise TypeError( @@ -220,7 +249,7 @@ def planeType(self, value: Union[PlaneType, str]) -> None: TypeError The provided value is not a PlaneType Enum or string. """ - if isinstance(value, str) or isinstance(value, PlaneType): + if isinstance(value, (str, PlaneType)): self._planeType = PlaneType(value) else: raise TypeError( @@ -377,6 +406,57 @@ def masks(self) -> tuple: """Return (self.mask, self.maskBlends, self.maskBackground).""" return (self.mask, self.maskBlends, self.maskBackground) + @property + def defocalOffset(self) -> Union[float, None]: + """Defocal offset of the detector when this image was taken. + + If some other element was offset, such as M2, this is the equivalent + detector offset. If not None, this value will override the default + value in the instrument when using the ImageMapper. + """ + return self._defocalOffset + + @defocalOffset.setter + def defocalOffset(self, value: Optional[float]) -> None: + """Set the defocal offset of the detector when this image was taken. + + If some other element was offset, such as M2, this is the equivalent + detector offset. If not None, this value will override the default + value in the instrument when using the ImageMapper. + + Parameters + ---------- + value : float or None + """ + if value is not None: + value = np.abs(float(value)) + self._defocalOffset = value + + @property + def batoidOffsetValue(self) -> Union[float, None]: + """Offset of batoidOffsetOptic used to calculate off-axis coefficients. + + If not None, this value will override the default value in the + instrument when using the ImageMapper. + """ + return self._batoidOffsetValue + + @batoidOffsetValue.setter + def batoidOffsetValue(self, value: Optional[float]) -> None: + """Set the batoidOffsetValue. + + This is the offset of the batoidOffsetOptic used to calculate the + off-axis coefficients for ImageMapper. If not None, this value will + override the default value in the instrument when using ImageMapper. + + Parameters + ---------- + value : float or None + """ + if value is not None: + value = float(value) + self._batoidOffsetValue = value + def copy(self) -> Self: """Return a copy of the DonutImage object. diff --git a/python/lsst/ts/wep/imageMapper.py b/python/lsst/ts/wep/imageMapper.py index 088f40e5..21173cff 100644 --- a/python/lsst/ts/wep/imageMapper.py +++ b/python/lsst/ts/wep/imageMapper.py @@ -158,6 +158,7 @@ def _constructForwardMap( offAxisCoeff = self.instrument.getOffAxisCoeff( *image.fieldAngle, image.defocalType, + image.batoidOffsetValue, image.bandLabel, jmaxIntrinsic=len(zkCoeff) + 3, ) diff --git a/python/lsst/ts/wep/instrument.py b/python/lsst/ts/wep/instrument.py index e5f1a804..a4a5f8d4 100644 --- a/python/lsst/ts/wep/instrument.py +++ b/python/lsst/ts/wep/instrument.py @@ -21,6 +21,7 @@ __all__ = ["Instrument"] +from copy import deepcopy from functools import lru_cache from pathlib import Path from typing import Optional, Tuple, Union @@ -29,6 +30,7 @@ import numpy as np from lsst.ts.wep.utils import BandLabel, DefocalType, EnumDict, mergeConfigWithFile from scipy.optimize import minimize_scalar +from typing_extensions import Self class Instrument: @@ -173,8 +175,8 @@ def clearCaches(self) -> None: self.getBatoidModel.cache_clear() self._getIntrinsicZernikesCached.cache_clear() self._getIntrinsicZernikesTACached.cache_clear() + self.calcEffDefocalOffset.cache_clear() self._focalLengthBatoid = None - self._defocalOffsetBatoid = None @property def name(self) -> str: @@ -319,52 +321,8 @@ def defocalOffset(self) -> float: """The defocal offset in meters.""" if self._defocalOffset is not None: return self._defocalOffset - elif self._defocalOffsetBatoid is not None: - return self._defocalOffsetBatoid elif self.batoidModelName is not None and self._batoidOffsetValue is not None: - # Load the model and wavelength info - batoidModel = self.getBatoidModel() - offsetOptic = self.batoidOffsetOptic - eps = batoidModel.pupilObscuration - wavelength = self.wavelength[BandLabel.REF] - batoidOffsetValue = self.batoidOffsetValue - - # Calculate dZ4 for the optic - shift = np.array([0, 0, batoidOffsetValue]) - dZ4optic = batoid.zernike( - batoidModel.withLocallyShiftedOptic(offsetOptic, +shift), - *np.zeros(2), - wavelength, - eps=eps, - jmax=4, - nx=128, - )[4] - - # Define a function to calculate dZ4 for an offset detector - def dZ4det(offset): - return batoid.zernike( - batoidModel.withLocallyShiftedOptic("Detector", [0, 0, offset]), - *np.zeros(2), - wavelength, - eps=eps, - jmax=4, - nx=128, - )[4] - - # Calculate the equivalent detector offset - result = minimize_scalar( - lambda offset: np.abs((dZ4det(offset) - dZ4optic) / dZ4optic), - bounds=(-0.1, 0.1), - ) - if not result.success or result.fun > 1e-3: - raise RuntimeError( - "Calculating defocalOffset from batoidOffsetValue failed." - ) - - # Save the calculated offset - self._defocalOffsetBatoid = np.abs(result.x) - - return self._defocalOffsetBatoid + return self.calcEffDefocalOffset() else: raise ValueError( "There is currently no defocalOffset set. " @@ -461,8 +419,8 @@ def refBand(self, value: Union[BandLabel, str, None]) -> None: # Clear relevant caches self._getIntrinsicZernikesCached.cache_clear() self._getIntrinsicZernikesTACached.cache_clear() + self.calcEffDefocalOffset.cache_clear() self._focalLengthBatoid = None - self._defocalOffsetBatoid = None @property def wavelength(self) -> EnumDict: @@ -500,7 +458,7 @@ def wavelength(self, value: Union[float, dict, None]) -> None: raise TypeError("wavelength must be a float, dictionary, or None.") # Save wavelength info in a BandLabel EnumDict - if isinstance(value, dict) or isinstance(value, EnumDict): + if isinstance(value, (dict, EnumDict)): value = EnumDict(BandLabel, value) try: value[BandLabel.REF] = value[self.refBand] @@ -518,8 +476,8 @@ def wavelength(self, value: Union[float, dict, None]) -> None: # Clear relevant caches self._getIntrinsicZernikesCached.cache_clear() self._getIntrinsicZernikesTACached.cache_clear() + self.calcEffDefocalOffset.cache_clear() self._focalLengthBatoid = None - self._defocalOffsetBatoid = None @property def batoidModelName(self) -> Union[str, None]: @@ -577,13 +535,18 @@ def batoidModelName(self, value: Optional[str]) -> None: self.getBatoidModel.cache_clear() self._getIntrinsicZernikesCached.cache_clear() self._getIntrinsicZernikesTACached.cache_clear() + self.calcEffDefocalOffset.cache_clear() self._focalLengthBatoid = None - self._defocalOffsetBatoid = None @property def batoidOffsetOptic(self) -> Union[str, None]: """The optic that is offset in the Batoid model.""" - return self._batoidOffsetOptic + if self.batoidModelName is None: + return None + elif self._batoidOffsetOptic is None: + return "Detector" + else: + return self._batoidOffsetOptic @batoidOffsetOptic.setter def batoidOffsetOptic(self, value: Union[str, None]) -> None: @@ -618,12 +581,17 @@ def batoidOffsetOptic(self, value: Union[str, None]) -> None: # Clear relevant caches self._getIntrinsicZernikesTACached.cache_clear() - self._defocalOffsetBatoid = None + self.calcEffDefocalOffset.cache_clear() @property def batoidOffsetValue(self) -> Union[float, None]: """Amount in meters the optic is offset in the Batoid model.""" - return self._batoidOffsetValue + if self.batoidModelName is None: + return None + elif self._batoidOffsetValue is None and self.batoidOffsetOptic == "Detector": + return self.defocalOffset + else: + return self._batoidOffsetValue @batoidOffsetValue.setter def batoidOffsetValue(self, value: Union[float, None]) -> None: @@ -651,7 +619,63 @@ def batoidOffsetValue(self, value: Union[float, None]) -> None: # Clear relevant caches self._getIntrinsicZernikesTACached.cache_clear() - self._defocalOffsetBatoid = None + self.calcEffDefocalOffset.cache_clear() + + @lru_cache(10) + def calcEffDefocalOffset(self, batoidOffsetValue: Optional[float] = None) -> float: + """Calculate effective detector offset corresponding to Batoid offset. + + Uses the Batoid model and Z4 ratios to determine which detector offset + creates the same defocus as the batoidOffsetValue. + + Parameters + ---------- + batoidOffsetValue : float or None, optional + The offset of self.batoidOffsetOptic in meters. If None, + self.batoidOffsetValue is used. (the default is None) + + Returns + ------- + float + The equivalent detector offset in meters (always positive). + """ + # Load the model and wavelength info + batoidModel = self.getBatoidModel() + offsetOptic = self.batoidOffsetOptic + eps = batoidModel.pupilObscuration + wavelength = self.wavelength[BandLabel.REF] + if batoidOffsetValue is None: + batoidOffsetValue = self.batoidOffsetValue + + # Calculate dZ4 for the optic + shift = np.array([0, 0, batoidOffsetValue]) + dZ4optic = batoid.zernike( + batoidModel.withLocallyShiftedOptic(offsetOptic, +shift), + *np.zeros(2), + wavelength, + eps=eps, + jmax=4, + nx=128, + )[4] + + # Define a function to calculate dZ4 for an offset detector + def dZ4det(offset): + return batoid.zernike( + batoidModel.withLocallyShiftedOptic("Detector", [0, 0, offset]), + *np.zeros(2), + wavelength, + eps=eps, + jmax=4, + nx=128, + )[4] + + # Calculate the equivalent detector offset + result = minimize_scalar( + lambda offset: np.abs(dZ4det(offset) - dZ4optic), + bounds=[-0.1, 0.1], + ) + + return np.abs(result.x) @lru_cache(10) def getBatoidModel( @@ -789,6 +813,7 @@ def _getIntrinsicZernikesTACached( xAngle: float, yAngle: float, defocalType: DefocalType, + batoidOffsetValue: float, band: Union[BandLabel, str], jmax: int, ) -> np.ndarray: @@ -803,6 +828,9 @@ def _getIntrinsicZernikesTACached( defocalType : DefocalType or str The DefocalType Enum or corresponding string, specifying which side of focus to model. + batoidOffsetValue : float or None + The offset of the batoidOffsetOptic used to calculate the off-axis + coefficients. If None, then self.batoidOffsetOptic is used. band : BandLabel or str The BandLabel Enum or corresponding string, specifying which batoid model to load. Only relevant if self.batoidModelName @@ -833,11 +861,17 @@ def _getIntrinsicZernikesTACached( if batoidModel is None: return np.zeros(jmax + 1) - # Offset the focal plane - defocalType = DefocalType(defocalType) - defocalSign = +1 if defocalType == DefocalType.Extra else -1 - offset = [0, 0, defocalSign * self.defocalOffset] - batoidModel = batoidModel.withLocallyShiftedOptic("Detector", offset) + # Get the Batoid offset + if batoidOffsetValue is None: + defocalType = DefocalType(defocalType) + defocalSign = +1 if defocalType == DefocalType.Extra else -1 + offset = [0, 0, defocalSign * self.batoidOffsetValue] + else: + offset = [0, 0, batoidOffsetValue] + + # Offset the optic + optic = self.batoidOffsetOptic + shiftedModel = batoidModel.withLocallyShiftedOptic(optic, offset) # Get the wavelength if len(self.wavelength) > 1: @@ -847,7 +881,7 @@ def _getIntrinsicZernikesTACached( # Get the off-axis model Zernikes in wavelengths zkIntrinsic = batoid.zernikeTA( - batoidModel, + shiftedModel, *np.deg2rad([xAngle, yAngle]), wavelength, jmax=jmax, @@ -866,7 +900,8 @@ def getOffAxisCoeff( self, xAngle: float, yAngle: float, - defocalType: DefocalType, + defocalType: Optional[DefocalType] = None, + batoidOffsetValue: Optional[float] = None, band: Union[BandLabel, str] = BandLabel.REF, jmax: int = 78, jmaxIntrinsic: int = 78, @@ -880,9 +915,13 @@ def getOffAxisCoeff( The x-component of the field angle in degrees. yAngle : float The y-component of the field angle in degrees. - defocalType : DefocalType or str + defocalType : DefocalType or str, optional The DefocalType Enum or corresponding string, specifying which side - of focus to model. + of focus to model. (the default is None) + batoidOffsetValue : float or None, optional + The offset of the batoidOffsetOptic used to calculate the off-axis + coefficients. If None, then self.batoidOffsetOptic is used. + (the default is None) band : BandLabel or str, optional The BandLabel Enum or corresponding string, specifying which batoid model to load. Only relevant if self.batoidModelName @@ -905,12 +944,23 @@ def getOffAxisCoeff( ------- np.ndarray The Zernike coefficients in meters, for Noll indices >= 4 + + Raises + ------ + ValueError + If defocalType and batoidOffsetValue are both None. """ + if defocalType is None and batoidOffsetValue is None: + raise ValueError( + "You must provide either defocalType or batoidOffsetValue." + ) + # Get zernikeTA zkTA = self._getIntrinsicZernikesTACached( xAngle, yAngle, defocalType, + batoidOffsetValue, band, jmax, ) @@ -1060,3 +1110,13 @@ def createImageGrid(self, nPixels: int) -> Tuple[np.ndarray, np.ndarray]: uImage, vImage = np.meshgrid(grid, grid) return uImage, vImage + + def copy(self) -> Self: + """Return a copy of the Instrument object. + + Returns + ------- + Instrument + A deep copy of self. + """ + return deepcopy(self) diff --git a/python/lsst/ts/wep/task/cutOutDonutsBase.py b/python/lsst/ts/wep/task/cutOutDonutsBase.py index add58749..31d962b1 100644 --- a/python/lsst/ts/wep/task/cutOutDonutsBase.py +++ b/python/lsst/ts/wep/task/cutOutDonutsBase.py @@ -36,6 +36,7 @@ from lsst.fgcmcal.utilities import lookupStaticCalibrations from lsst.geom import Point2D, degrees from lsst.pipe.base import connectionTypes +from lsst.ts.wep import Instrument from lsst.ts.wep.task.donutStamp import DonutStamp from lsst.ts.wep.task.donutStamps import DonutStamps from lsst.ts.wep.utils import ( @@ -117,8 +118,7 @@ class CutOutDonutsBaseTaskConfig( doc="Path to a instrument configuration file to override the instrument " + "configuration. If begins with 'policy:' the path will be understood as " + "relative to the ts_wep policy directory. If not provided, the default " - + "instrument for the camera will be loaded, and the defocal offset will " - + "be determined from the focusZ value in the exposure header.", + + "instrument for the camera will be loaded.", dtype=str, optional=True, ) @@ -307,16 +307,16 @@ def cutOutStamps(self, exposure, donutCatalog, defocalType, cameraName): # Run background subtraction self.subtractBackground.run(exposure=exposure).background - # Get the offset - offset = getOffsetFromExposure(exposure, cameraName, defocalType) - # Load the instrument - instrument = getTaskInstrument( - cameraName, - detectorName, - offset, - self.instConfigFile, - ) + if self.instConfigFile is None: + instrument = getTaskInstrument(cameraName, detectorName) + else: + instrument = Instrument(configFile=self.instConfigFile) + + # Set the Batoid offset + offset = getOffsetFromExposure(exposure, cameraName, defocalType) + instrument.batoidOffsetValue = offset / 1e3 # mm -> m + instrument.defocalOffset = instrument.calcEffDefocalOffset() # Create the image template for the detector template = createTemplateForDetector( @@ -452,8 +452,9 @@ def cutOutStamps(self, exposure, donutCatalog, defocalType, cameraName): detector_name=detectorName, cam_name=cameraName, defocal_type=defocalType.value, - # Save defocal offset in mm. - defocal_distance=instrument.defocalOffset * 1e3, + # Save defocal offsets in mm. + detector_offset=instrument.defocalOffset * 1e3, # m -> mm + optic_offset=instrument.batoidOffsetValue * 1e3, # m -> mm bandpass=bandLabel, archive_element=linear_wcs, ) @@ -470,9 +471,12 @@ def cutOutStamps(self, exposure, donutCatalog, defocalType, cameraName): stampsMetadata["DFC_TYPE"] = np.array( [defocalType.value] * catalogLength, dtype=str ) - stampsMetadata["DFC_DIST"] = np.array( + stampsMetadata["DET_DZ"] = np.array( [instrument.defocalOffset * 1e3] * catalogLength ) + stampsMetadata["OPTIC_DZ"] = np.array( + [instrument.batoidOffsetValue * 1e3] * catalogLength + ) # Save the centroid values stampsMetadata["CENT_X"] = np.array(finalXCentList) stampsMetadata["CENT_Y"] = np.array(finalYCentList) diff --git a/python/lsst/ts/wep/task/donutStamp.py b/python/lsst/ts/wep/task/donutStamp.py index 352c9ff3..4907dd34 100644 --- a/python/lsst/ts/wep/task/donutStamp.py +++ b/python/lsst/ts/wep/task/donutStamp.py @@ -60,9 +60,14 @@ class DonutStamp(AbstractStamp): defocal_type : `str` Defocal state of the stamp. "extra" or "intra" are allowed values. - defocal_distance : `float` + detector_offset : `float` Defocal offset of the detector in mm. If the detector was not actually shifted, this should be the equivalent detector offset. + optic_offset : `float` + The real offset applied to an optic element to capture defocused + images, in mm. For LSSTCam, this corresponds to detector_offset, + for LSST full-array and ComCam this corresponds to the camera offset, + and for AuxTel this corresponds to the M2 offset. detector_name : `str` CCD where the donut is found cam_name : `str` @@ -86,18 +91,23 @@ class DonutStamp(AbstractStamp): centroid_position: lsst.geom.Point2D blend_centroid_positions: np.ndarray defocal_type: str - defocal_distance: float + detector_offset: float + optic_offset: float detector_name: str cam_name: str bandpass: str archive_element: Optional[afwTable.io.Persistable] = None wep_im: Image = field(init=False) + # Legacy attribute to avoid errors (will be deleted in post-init) + defocal_distance: Optional[float] = None + def __post_init__(self): """ This method sets up the WEP Image after initialization because we need to use the parameters set in the original `__init__`. """ + delattr(self, "defocal_distance") self._setWepImage() @classmethod @@ -138,6 +148,45 @@ def factory(cls, stamp_im, metadata, index, archive_element=None): else: blend_centroid_positions = np.array([["nan"], ["nan"]], dtype=float).T + # Get the defocal type + # "DFC_TYPE" stands for defocal type in string form. + defocal_type = metadata.getArray("DFC_TYPE")[index] + if defocal_type not in ["intra", "extra"]: + raise ValueError(f"defocal_type {defocal_type} not supported.") + + # Get the Camera name + # "CAM_NAME" stands for camera name + cam_name = metadata.getArray("CAM_NAME")[index] + + # Get the detector offset and optic offset info (see class docstring + # for the difference in these numbers). All values in mm + try: + detector_offset = ( + metadata.getArray("DET_DZ")[index] + if metadata.getArray("DET_DZ") is not None + else 1.5 + ) + optic_offset = ( + metadata.getArray("OPTIC_DZ")[index] + if metadata.getArray("OPTIC_DZ") is not None + else 1.5 + ) + # This might fail for old stamps, in which case we use these hard-coded + # defaults for AuxTel and LSSTCam+ + except KeyError: + if cam_name == "LATISS": + detector_offset = 34.7 + if defocal_type == "extra": + optic_offset = +0.8 + else: + optic_offset = -0.8 + else: + detector_offset = 1.5 + if defocal_type == "extra": + optic_offset = +1.5 + else: + optic_offset = -1.5 + return cls( stamp_im=stamp_im, archive_element=archive_element, @@ -155,17 +204,10 @@ def factory(cls, stamp_im, metadata, index, archive_element=None): detector_name=metadata.getArray("DET_NAME")[index], # "CAM_NAME" stands for camera name cam_name=metadata.getArray("CAM_NAME")[index], - # "DFC_TYPE" stands for defocal type in string form. - # Need to convert to DefocalType - defocal_type=metadata.getArray("DFC_TYPE")[index], - # "DFC_DIST" stands for defocal distance - # If this is an old version of the stamps without a defocal - # distance set this to default value of 1.5 mm. - defocal_distance=( - metadata.getArray("DFC_DIST")[index] - if metadata.get("DFC_DIST") is not None - else 1.5 - ), + # Set the defocal offset info + defocal_type=defocal_type, + detector_offset=detector_offset, + optic_offset=optic_offset, # "BANDPASS" stands for the exposure bandpass # If this is an old version of the stamps without bandpass # information then an empty string ("") will be set as default. @@ -288,6 +330,8 @@ def _setWepImage(self): defocalType=self.defocal_type, bandLabel=self.bandpass, blendOffsets=blendOffsets, + defocalOffset=self.detector_offset / 1e3, # mm -> m + batoidOffsetValue=self.optic_offset / 1e3, # mm -> m ) self.wep_im = wepImage diff --git a/python/lsst/ts/wep/task/donutStamps.py b/python/lsst/ts/wep/task/donutStamps.py index a5e52c6e..6fc460cb 100644 --- a/python/lsst/ts/wep/task/donutStamps.py +++ b/python/lsst/ts/wep/task/donutStamps.py @@ -53,8 +53,10 @@ def _refresh_metadata(self): self.metadata["CAM_NAME"] = [cam for cam in cam_names] defocal_types = self.getDefocalTypes() self.metadata["DFC_TYPE"] = [dfc for dfc in defocal_types] - defocal_distances = self.getDefocalDistances() - self.metadata["DFC_DIST"] = [dfc_dist for dfc_dist in defocal_distances] + detector_offsets = self.getDetectorOffsets() + self.metadata["DET_DZ"] = [offset for offset in detector_offsets] + optic_offsets = self.getOpticOffsets() + self.metadata["OPTIC_DZ"] = [offset for offset in optic_offsets] bandpasses = self.getBandpasses() self.metadata["BANDPASS"] = [bandpass for bandpass in bandpasses] @@ -157,16 +159,27 @@ def getDefocalTypes(self): """ return [stamp.defocal_type for stamp in self] - def getDefocalDistances(self): + def getDetectorOffsets(self): """ - Get the defocal distance for each stamp. + Get the detector offset for each stamp. Returns ------- list [float] - Defocal distances for each stamp in mm. + Detector offset for each stamp, in mm. """ - return [stamp.defocal_distance for stamp in self] + return [stamp.detector_offset for stamp in self] + + def getOpticOffsets(self): + """ + Get the optic offset for each stamp. + + Returns + ------- + list [float] + Optic offset for each stamp, in mm. + """ + return [stamp.optic_offset for stamp in self] def getBandpasses(self): """ diff --git a/python/lsst/ts/wep/task/estimateZernikesBase.py b/python/lsst/ts/wep/task/estimateZernikesBase.py index 3f619c15..106846df 100644 --- a/python/lsst/ts/wep/task/estimateZernikesBase.py +++ b/python/lsst/ts/wep/task/estimateZernikesBase.py @@ -26,6 +26,7 @@ import lsst.pex.config as pexConfig import lsst.pipe.base as pipeBase import numpy as np +from lsst.ts.wep import Instrument from lsst.ts.wep.estimation import WfAlgorithm, WfAlgorithmFactory, WfEstimator from lsst.ts.wep.task.donutStamps import DonutStamps from lsst.ts.wep.utils import ( @@ -142,15 +143,6 @@ def estimateFromPairs( for i, (donutExtra, donutIntra) in enumerate( zip(donutStampsExtra, donutStampsIntra) ): - # Determine and set the defocal offset - defocalOffset = np.mean( - [ - donutExtra.defocal_distance, - donutIntra.defocal_distance, - ] - ) - wfEstimator.instrument.defocalOffset = defocalOffset / 1e3 # m -> mm - # Estimate Zernikes zk = wfEstimator.estimateZk(donutExtra.wep_im, donutIntra.wep_im) zkList.append(zk) @@ -192,10 +184,6 @@ def estimateFromIndivStamps( zkList = [] histories = dict() for i, donutExtra in enumerate(donutStampsExtra): - # Determine and set the defocal offset - defocalOffset = donutExtra.defocal_distance - wfEstimator.instrument.defocalOffset = defocalOffset / 1e3 - # Estimate Zernikes zk = wfEstimator.estimateZk(donutExtra.wep_im) zkList.append(zk) @@ -204,10 +192,6 @@ def estimateFromIndivStamps( # this is just an empty dictionary) histories[f"extra{i}"] = convertHistoryToMetadata(wfEstimator.history) for i, donutIntra in enumerate(donutStampsIntra): - # Determine and set the defocal offset - defocalOffset = donutIntra.defocal_distance - wfEstimator.instrument.defocalOffset = defocalOffset / 1e3 - # Estimate Zernikes zk = wfEstimator.estimateZk(donutIntra.wep_im) zkList.append(zk) @@ -245,12 +229,10 @@ def run( # Get the instrument camName = donutStampsExtra[0].cam_name detectorName = donutStampsExtra[0].detector_name - instrument = getTaskInstrument( - camName, - detectorName, - None, - self.config.instConfigFile, - ) + if self.config.instConfigFile is None: + instrument = getTaskInstrument(camName, detectorName) + else: + instrument = Instrument(configFile=self.config.instConfigFile) # Create the wavefront estimator wfEst = WfEstimator( diff --git a/python/lsst/ts/wep/task/generateDonutDirectDetectTask.py b/python/lsst/ts/wep/task/generateDonutDirectDetectTask.py index 5a5e194d..bf451c75 100644 --- a/python/lsst/ts/wep/task/generateDonutDirectDetectTask.py +++ b/python/lsst/ts/wep/task/generateDonutDirectDetectTask.py @@ -31,6 +31,7 @@ import numpy as np import pandas as pd from lsst.fgcmcal.utilities import lookupStaticCalibrations +from lsst.ts.wep import Instrument from lsst.ts.wep.task.donutQuickMeasurementTask import DonutQuickMeasurementTask from lsst.ts.wep.task.donutSourceSelectorTask import DonutSourceSelectorTask from lsst.ts.wep.utils import ( @@ -216,16 +217,16 @@ def run(self, exposure, camera): # true in the rotated DVCS coordinate system) defocalType = DefocalType.Extra - # Get the offset - offset = getOffsetFromExposure(exposure, camName, defocalType) - # Load the instrument - instrument = getTaskInstrument( - camName, - detectorName, - offset, - self.config.instConfigFile, - ) + if self.config.instConfigFile is None: + instrument = getTaskInstrument(camName, detectorName) + else: + instrument = Instrument(configFile=self.config.instConfigFile) + + # Set the Batoid offset + offset = getOffsetFromExposure(exposure, camName, defocalType) + instrument.batoidOffsetValue = offset / 1e3 # mm -> m + instrument.defocalOffset = instrument.calcEffDefocalOffset() # Create the image template for the detector template = createTemplateForDetector( diff --git a/python/lsst/ts/wep/utils/plotUtils.py b/python/lsst/ts/wep/utils/plotUtils.py index c8463fbc..aba90c20 100644 --- a/python/lsst/ts/wep/utils/plotUtils.py +++ b/python/lsst/ts/wep/utils/plotUtils.py @@ -393,7 +393,7 @@ def plotMapperResiduals( # Determine the defocal offset offset = -1 if defocalType == "intra" else +1 - offset *= instrument.defocalOffset + offset *= instrument.batoidOffsetValue # Create the Batoid RayVector nrad = 50 @@ -407,24 +407,26 @@ def plotMapperResiduals( naz=naz, ) - # Get the normalized pupil coordinates - uPupil = (rays.x - rays.x.mean()) / mapper.instrument.radius - vPupil = (rays.y - rays.y.mean()) / mapper.instrument.radius + # Get normalized pupil coordinates + pupilRays = optic.stopSurface.interact(rays.copy()) + uPupil = pupilRays.x / instrument.radius + vPupil = pupilRays.y / instrument.radius # Map to focal plane using the offAxis model uImage, vImage, *_ = mapper._constructForwardMap( uPupil, vPupil, - mapper.instrument.getIntrinsicZernikes(*angle, band, jmax=22), + instrument.getIntrinsicZernikes(*angle, band, jmax=28), Image(np.zeros((1, 1)), angle, defocalType, band), ) # Convert normalized image coordinates to meters - xImage = uImage * mapper.instrument.donutRadius * mapper.instrument.pixelSize - yImage = vImage * mapper.instrument.donutRadius * mapper.instrument.pixelSize + xImage = uImage * instrument.donutRadius * instrument.pixelSize + yImage = vImage * instrument.donutRadius * instrument.pixelSize # Trace to the focal plane with Batoid - optic.withLocallyShiftedOptic("Detector", [0, 0, offset]).trace(rays) + optic = optic.withLocallyShiftedOptic(instrument.batoidOffsetOptic, [0, 0, offset]) + optic.trace(rays) # Calculate the centered ray coordinates chief = batoid.RayVector.fromStop( @@ -434,7 +436,7 @@ def plotMapperResiduals( wavelength=mapper.instrument.wavelength[band], dirCos=dirCos, ) - optic.withLocallyShiftedOptic("Detector", [0, 0, offset]).trace(chief) + optic.trace(chief) xRay = rays.x - chief.x yRay = rays.y - chief.y diff --git a/python/lsst/ts/wep/utils/taskUtils.py b/python/lsst/ts/wep/utils/taskUtils.py index c7c74bd2..26e5d3b5 100644 --- a/python/lsst/ts/wep/utils/taskUtils.py +++ b/python/lsst/ts/wep/utils/taskUtils.py @@ -229,7 +229,7 @@ def getOffsetFromExposure( Returns ------- float - The offset in mm + The offset in mm (absolute value) Raises ------ @@ -260,79 +260,40 @@ def getOffsetFromExposure( else: raise ValueError(f"defocalType {defocalType} not supported.") - return offset + return np.abs(offset) def getTaskInstrument( camName: str, detectorName: str, - offset: Union[float, None] = None, - instConfigFile: Union[str, None] = None, ) -> Instrument: """Get the instrument to use for the task. - The camera name is used to load a default instrument, and then the - defocalOffset is override using the offset value, if provided. - If instConfigFile is provided, that file is used instead of camName - to load the instrument, and offset is only used if instConfigFile - does not contain information for calculating the defocalOffset. - Parameters ---------- camName : str The name of the camera detectorName : str The name of the detector. - offset : float or None, optional - The true offset for the exposure in mm. For LSSTCam this corresponds - to the offset of the detector, while for AuxTel it corresponds to the - offset of M2. (the default is None) - instConfigFile : str or None - An instrument config file to override the default instrument - for the camName. If begins with "policy:", this path is understood - as relative to the ts_wep policy directory. - (the default is None) Returns ------- Instrument The instrument object """ - # Load the starting instrument - if instConfigFile is None: - if camName == "LSSTCam": - camera = LsstCam().getCamera() - if camera[detectorName].getType() == DetectorType.WAVEFRONT: - instrument = Instrument(configFile="policy:instruments/LsstCam.yaml") - else: - instrument = Instrument(configFile="policy:instruments/LsstFamCam.yaml") - elif camName in ["LSSTComCam", "LSSTComCamSim"]: - instrument = Instrument(configFile="policy:instruments/ComCam.yaml") - elif camName == "LATISS": - instrument = Instrument(configFile="policy:instruments/AuxTel.yaml") - else: - raise ValueError(f"No default instrument for camera {camName}") - overrideOffset = True - else: - instrument = Instrument(configFile=instConfigFile) - try: - instrument.defocalOffset - except ValueError: - overrideOffset = True + # Return the default instrument for this task config + if camName == "LSSTCam": + camera = LsstCam().getCamera() + if camera[detectorName].getType() == DetectorType.WAVEFRONT: + return Instrument(configFile="policy:instruments/LsstCam.yaml") else: - overrideOffset = False - - if offset is None or not overrideOffset: - # We're done! - return instrument - - # Override the defocalOffset - if instrument.batoidOffsetOptic is None: - instrument.defocalOffset = offset / 1e3 + return Instrument(configFile="policy:instruments/LsstFamCam.yaml") + elif camName in ["LSSTComCam", "LSSTComCamSim"]: + return Instrument(configFile="policy:instruments/ComCam.yaml") + elif camName == "LATISS": + return Instrument(configFile="policy:instruments/AuxTel.yaml") else: - instrument.batoidOffsetValue = offset / 1e3 - - return instrument + raise ValueError(f"No default instrument for camera {camName}") def createTemplateForDetector( diff --git a/tests/estimation/test_tie.py b/tests/estimation/test_tie.py index 380fc6a9..b9461010 100644 --- a/tests/estimation/test_tie.py +++ b/tests/estimation/test_tie.py @@ -243,6 +243,23 @@ def testSingleDonut(self): with self.assertRaises(ValueError): tie.estimateZk(intra) + def testDefocalOffsetPropagation(self): + """Test that image-level defocal offsets propagate. + + We will do this by setting defocal offsets to 0 and making + sure an error is raised. + """ + zkTrue, intra, extra = forwardModelPair() + intra.defocalOffset = 0 + extra.defocalOffset = 0 + + tie = TieAlgorithm() + try: + tie.estimateZk(intra, extra) + raise RuntimeError("This should have raised an error!") + except Exception: + pass + if __name__ == "__main__": # Do the unit test diff --git a/tests/task/test_donutStamp.py b/tests/task/test_donutStamp.py index 0900f0fc..6fad1fc6 100644 --- a/tests/task/test_donutStamp.py +++ b/tests/task/test_donutStamp.py @@ -67,7 +67,8 @@ def _makeStamps(self, nStamps, stampSize, testDefaults=False): dfcTypes = [DefocalType.Extra.value] * nStamps halfStampIdx = int(nStamps / 2) dfcTypes[:halfStampIdx] = [DefocalType.Intra.value] * halfStampIdx - dfcDists = np.ones(nStamps) * 1.25 + detectorOffsets = np.ones(nStamps) * 1.25 + opticOffsets = np.ones(nStamps) * 1.25 bandpass = ["r"] * nStamps metadata = PropertyList() @@ -79,7 +80,8 @@ def _makeStamps(self, nStamps, stampSize, testDefaults=False): metadata["CAM_NAME"] = camNames metadata["DFC_TYPE"] = dfcTypes if testDefaults is False: - metadata["DFC_DIST"] = dfcDists + metadata["DET_DZ"] = detectorOffsets + metadata["OPTIC_DZ"] = opticOffsets metadata["BLEND_CX"] = blendCentX metadata["BLEND_CY"] = blendCentY metadata["BANDPASS"] = bandpass @@ -120,8 +122,8 @@ def testFactory(self): self.assertEqual(defocalType, DefocalType.Intra.value) else: self.assertEqual(defocalType, DefocalType.Extra.value) - defocalDist = donutStamp.defocal_distance - self.assertEqual(defocalDist, 1.25) + self.assertEqual(donutStamp.detector_offset, 1.25) + self.assertEqual(donutStamp.optic_offset, 1.25) bandpass = donutStamp.bandpass self.assertEqual(bandpass, "r") @@ -140,9 +142,6 @@ def testFactoryMetadataDefaults(self): donutStamp = DonutStamp.factory( self.testDefaultStamps[i], self.testDefaultMetadata, i ) - defocalDist = donutStamp.defocal_distance - # Test default metadata distance of 1.5 mm - self.assertEqual(defocalDist, 1.5) # Test blend centroids arrays are nans np.testing.assert_array_equal( donutStamp.blend_centroid_positions, @@ -191,7 +190,8 @@ def testCalcFieldXY(self): lsst.geom.Point2D(2047.5, 2001.5), np.array([[], []]).T, DefocalType.Extra.value, - 1.5e-3, + 1.5, + 1.5, "R22_S11", "LSSTCam", "r", @@ -218,6 +218,7 @@ def testCalcFieldXY(self): np.array([[20], [20]]).T, DefocalType.Extra.value, 0.0, + 0.0, detName, "LSSTCam", "r", @@ -233,7 +234,8 @@ def testMakeMask(self): lsst.geom.Point2D(2047.5, 2001.5), np.array([[], []]).T, DefocalType.Extra.value, - 1.5e-3, + 1.5, + 1.5, "R22_S11", "LSSTCam", "r", @@ -285,7 +287,8 @@ def _testWepImage(raft, sensor): center, center + offsets, DefocalType.Extra.value, - 1.5e-3, + 1.5, + -3, detName, "LSSTCam", "r", @@ -328,6 +331,10 @@ def _testWepImage(raft, sensor): self.assertEqual(wepImage.defocalType, DefocalType.Extra) self.assertEqual(wepImage.bandLabel, BandLabel.LSST_R) + # Test the offsets + self.assertEqual(wepImage.defocalOffset, 1.5e-3) + self.assertEqual(wepImage.batoidOffsetValue, -3e-3) + # Test all the CWFSs for raftName in ["R00", "R04", "R40", "R44"]: for sensorName in ["SW0", "SW1"]: diff --git a/tests/task/test_donutStamps.py b/tests/task/test_donutStamps.py index 58d6ac48..d220bd7a 100644 --- a/tests/task/test_donutStamps.py +++ b/tests/task/test_donutStamps.py @@ -163,10 +163,17 @@ def testGetDefocalTypes(self): [DefocalType.Extra.value] * int((self.nStamps - halfStampIdx)), ) - def testGetDefocalDistances(self): - defocalDistances = self.donutStamps.getDefocalDistances() + def testGetDetectorOffsets(self): + detectorOffsets = self.donutStamps.getDetectorOffsets() for idx in range(self.nStamps): - self.assertEqual(defocalDistances[idx], 1.5) + self.assertEqual(detectorOffsets[idx], 1.5) + + def testGetOpticOffsets(self): + opticOffsets = self.donutStamps.getOpticOffsets() + defocalTypes = self.donutStamps.getDefocalTypes() + for idx in range(self.nStamps): + sign = +1 if defocalTypes[idx] == "extra" else -1 + self.assertEqual(opticOffsets[idx], sign * 1.5) def testGetBandpass(self): bandpasses = self.donutStamps.getBandpasses() diff --git a/tests/test_imageMapper.py b/tests/test_imageMapper.py index 37f8bda1..8c3a32b3 100644 --- a/tests/test_imageMapper.py +++ b/tests/test_imageMapper.py @@ -626,7 +626,11 @@ def testBatoidRaytraceResiduals(self): # Function that maps config to required precision (% of pixel size) def maxPercent(**kwargs): - if "Lsst" in instConfig and model == "onAxis": + if "AuxTel" in instConfig and model != "offAxis": + # Shifting M2 also generates spherical aberration, + # which isn't compensated by paraxial and on-axis + return 150 + elif "Lsst" in instConfig and model == "onAxis": return 25 else: return 10 @@ -648,7 +652,9 @@ def maxPercent(**kwargs): inst = mapper.instrument # Determine the defocal offset - offset = -inst.defocalOffset if dfType == "intra" else inst.defocalOffset + offsetOptic = inst.batoidOffsetOptic + dfSign = -1 if dfType == "intra" else +1 + offset = [0, 0, dfSign * inst.batoidOffsetValue] # Loop over each band for band in inst.wavelength: @@ -683,7 +689,7 @@ def maxPercent(**kwargs): yImage = vImage * inst.donutRadius * inst.pixelSize # Trace to the focal plane with Batoid - optic.withLocallyShiftedOptic("Detector", [0, 0, offset]).trace(rays) + optic.withLocallyShiftedOptic(offsetOptic, offset).trace(rays) # Calculate the centered ray coordinates chief = batoid.RayVector.fromStop( @@ -693,7 +699,7 @@ def maxPercent(**kwargs): wavelength=mapper.instrument.wavelength[band], dirCos=dirCos, ) - optic.withLocallyShiftedOptic("Detector", [0, 0, offset]).trace(chief) + optic.withLocallyShiftedOptic(offsetOptic, offset).trace(chief) xRay = rays.x - chief.x yRay = rays.y - chief.y diff --git a/tests/test_instrument.py b/tests/test_instrument.py index 439ff985..a9cfe531 100644 --- a/tests/test_instrument.py +++ b/tests/test_instrument.py @@ -130,6 +130,17 @@ def testGetOffAxisCoeff(self): close = np.isclose(inst.getOffAxisCoeff(0, 0, "intra"), intrZk, atol=0) self.assertTrue(np.all(~close)) + # Check that swapping the sign of the offset flips Z4 + z4one = inst.getOffAxisCoeff(1, 1, "extra", jmax=4) + z4two = inst.getOffAxisCoeff( + 1, + 1, + "extra", + jmax=4, + batoidOffsetValue=-inst.batoidOffsetValue, + ) + self.assertTrue(np.isclose(z4one[0], -z4two[0], rtol=1e-4)) + def testBadMaskParams(self): with self.assertRaises(TypeError): Instrument(maskParams="bad") @@ -207,10 +218,20 @@ def testPullFromBatoid(self): self.assertTrue(np.isclose(inst.defocalOffset, lsst.defocalOffset, rtol=1e-3)) def testDefocalOffsetCalculation(self): + # Load AuxTel inst = Instrument("policy:instruments/AuxTel.yaml") + + # Test the method + self.assertTrue(np.isclose(inst.calcEffDefocalOffset(), 34.94e-3, rtol=1e-3)) + + # Test auto-attribute inst.batoidOffsetValue = 0.8e-3 self.assertTrue(np.isclose(inst.defocalOffset, 34.94e-3, rtol=1e-3)) + # Also test LsstCam + inst = Instrument("policy:instruments/LsstCam.yaml") + self.assertTrue(np.isclose(inst.calcEffDefocalOffset(), inst.defocalOffset)) + def testImports(self): # Get LSST and ComCam instruments lsst = Instrument("policy:instruments/LsstCam.yaml") diff --git a/tests/utils/test_taskUtils.py b/tests/utils/test_taskUtils.py index 87fa903a..be019c0f 100644 --- a/tests/utils/test_taskUtils.py +++ b/tests/utils/test_taskUtils.py @@ -142,6 +142,10 @@ def assertInstEqual(inst1, inst2): # Test the defaults assertInstEqual(getTaskInstrument("LSSTCam", "R00_SW0"), Instrument()) + assertInstEqual( + getTaskInstrument("LSSTCam", "R22_S11"), + Instrument(configFile="policy:instruments/LsstFamCam.yaml"), + ) assertInstEqual( getTaskInstrument("LSSTComCam", "R22_S11"), Instrument(configFile="policy:instruments/ComCam.yaml"), @@ -151,26 +155,10 @@ def assertInstEqual(inst1, inst2): Instrument(configFile="policy:instruments/AuxTel.yaml"), ) - # Test override config file - assertInstEqual( - getTaskInstrument( - "LSSTCam", "R40_SW1", instConfigFile="policy:instruments/AuxTel.yaml" - ), - Instrument(configFile="policy:instruments/AuxTel.yaml"), - ) - - # Test override defocal offset (in mm) - inst = Instrument() - inst.defocalOffset = 1.234e-3 - assertInstEqual( - getTaskInstrument("LSSTCam", "R04_SW1", offset=1.234), - inst, - ) - with self.assertRaises(ValueError): getTaskInstrument("fake", None) - # Test LsstFamCam + # Test LsstFamCam batoidOffsetOptic famcam = getTaskInstrument("LSSTCam", "R22_S01") self.assertEqual(famcam.batoidOffsetOptic, "LSSTCamera")