from pathlib import Path
from collections import namedtuple
from copy import copy
from functools import lru_cache
from numbers import Real

import astropy.io.fits as fits
import batoid
import galsim
import numpy as np
import warnings
import yaml

from .utils import _node_to_grid, _fits_cache, attach_attr, ensure_data_dir


BendingMode = namedtuple(
    "BendingMode",
    "zk R_outer R_inner x y z dzdx dzdy d2zdxy"
)


RealizedBend = namedtuple(
    "RealizedBend",
    "zk grid"
)


@lru_cache(maxsize=2)
def m1m3_fea_nodes(fea_dir):
    """Return FEA node positions and mirror flags for M1M3.

    For fea_legacy, there are 5256 nodes, 3474 in M1 and 1782 in M3.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.

    Returns
    -------
    bx, by : ndarray
        Node x and y positions in meters in M1M3 coordinate system.
    idx1, idx3 : ndarray
        Boolean arrays indicating which nodes are in M1 and M3, respectively.
    """
    data = fits.getdata(Path(fea_dir) / "M1M3_1um_156_grid.fits.gz")
    idx = data[:, 0]
    bx = data[:, 1]  # (5256,)
    by = data[:, 2]
    idx1 = (idx == 1)
    idx3 = (idx == 3)
    bx.flags.writeable = False
    by.flags.writeable = False
    idx1.flags.writeable = False
    idx3.flags.writeable = False
    return bx, by, idx1, idx3


@lru_cache(maxsize=4)
def m1m3_grid_xy(bend_dir):
    """Return M1M3 bending mode grid x and y positions.

    These 1d arrays are the rectangular grid coordinates of the bending mode
    grids.  There are typically 204 points in each direction.

    Parameters
    ----------
    bend_dir : str
        Directory containing the bending mode files.

    Returns
    -------
    m1_grid_xy, m3_grid_xy : ndarray
        M1M3 grid x and y positions in meters in M1M3 coordinate system.
    """
    with open(Path(bend_dir) / "bend.yaml") as f:
        config = yaml.safe_load(f)
    m1_grid_xy = fits.getdata(Path(bend_dir) / config['M1']['grid']['coords'])
    m3_grid_xy = fits.getdata(Path(bend_dir) / config['M3']['grid']['coords'])
    m1_grid_xy.flags.writeable = False
    m3_grid_xy.flags.writeable = False
    return m1_grid_xy, m3_grid_xy


@lru_cache(maxsize=2)
def m2_fea_nodes(fea_dir):
    """Return FEA node positions and mirror flags for M2.

    For fea_legacy, there are 15984 M2 FEA nodes.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.

    Returns
    -------
    bx, by : ndarray
        Node x and y positions in meters in M2 coordinate system.
    """
    data = fits.getdata(Path(fea_dir) / "M2_1um_grid.fits.gz")
    bx = data[:, 1]  # meters
    by = data[:, 2]
    bx.flags.writeable = False
    by.flags.writeable = False
    return bx, by


@lru_cache(maxsize=4)
def m2_grid_xy(bend_dir):
    """Return M2 bending mode grid x and y positions.

    These 1d arrays are the rectangular grid coordinates of the bending mode
    grids.  There are typically 204 points in each direction.

    Parameters
    ----------
    bend_dir : str
        Directory containing the bending mode files.

    Returns
    -------
    m2_grid_xy : ndarray
        M2 grid x and y positions in meters in M2 coordinate system.
    """
    with open(Path(bend_dir) / "bend.yaml") as f:
        config = yaml.safe_load(f)
    m2_grid_xy = fits.getdata(Path(bend_dir) / config['M2']['grid']['coords'])
    m2_grid_xy.flags.writeable = False
    return m2_grid_xy


@lru_cache(maxsize=16)
def m1m3_gravity(fea_dir, optic, zenith):
    """Return M1M3 gravity displacement.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.
    optic : batoid.Optic
        Optic before finite-element analysis (FEA) or active optics system
        (AOS) perturbations are applied.
    zenith : galsim.Angle
        Zenith angle

    Returns
    -------
    dz : ndarray
        M1M3 gravity displacement along z direction in meters for FEA nodes.
    """
    # zdata = zenith print-through, hdata = horizon print-through
    # shape = (5256, 3)
    # - Each row is one of 5256 FEA nodes.
    # - Columns are dx, dy, dz in M1M3 CS.
    zdata = _fits_cache(fea_dir, "M1M3_dxdydz_zenith.fits.gz")
    hdata = _fits_cache(fea_dir, "M1M3_dxdydz_horizon.fits.gz")

    if zenith is None:
        return np.zeros_like(zdata[:,2])

    dxyz = (
        zdata * np.cos(zenith) +
        hdata * np.sin(zenith)
    )
    dz = dxyz[:,2]

    # Interpolate these node displacements into z-displacements at
    # original node x/y positions.
    bx, by, idx1, idx3 = m1m3_fea_nodes(fea_dir)

    # M1
    zRef = optic['M1'].surface.sag(bx[idx1], by[idx1])
    zpRef = optic['M1'].surface.sag(
        (bx+dxyz[:, 0])[idx1],
        (by+dxyz[:, 1])[idx1]
    )
    dz[idx1] += zRef - zpRef

    # M3
    zRef = optic['M3'].surface.sag(bx[idx3], by[idx3])
    zpRef = optic['M3'].surface.sag(
        (bx+dxyz[:, 0])[idx3],
        (by+dxyz[:, 1])[idx3]
    )
    dz[idx3] += zRef - zpRef

    # Subtract PTT
    # This kinda makes sense for M1, but why for combined M1M3?
    zBasis = galsim.zernike.zernikeBasis(
        3, bx, by, R_outer=4.18, R_inner=2.558
    )
    coefs, *_ = np.linalg.lstsq(zBasis.T, dxyz[:, 2], rcond=None)
    zern = galsim.zernike.Zernike(coefs, R_outer=4.18, R_inner=2.558)
    dz -= zern(bx, by)

    dz.flags.writeable = False
    return dz


@lru_cache(maxsize=16)
def m1m3_temperature(fea_dir, TBulk, TxGrad, TyGrad, TzGrad, TrGrad):
    """Return M1M3 temperature displacement.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.
    TBulk : float
        Bulk temperature in C.
    TxGrad : float
        Temperature gradient in x direction in C / m (?)
    TyGrad : float
        Temperature gradient in y direction in C / m (?)
    TzGrad : float
        Temperature gradient in z direction in C / m (?)
    TrGrad : float
        Temperature gradient in r direction in C / m (?)

    Returns
    -------
    dz : ndarray
        M1M3 temperature displacement along z direction in meters for FEA nodes.
    """
    tbdz, txdz, tydz, tzdz, trdz = _fits_cache(
        fea_dir,
        "M1M3_thermal_FEA.fits.gz"
    )

    out = TBulk * tbdz
    out += TxGrad * txdz
    out += TyGrad * tydz
    out += TzGrad * tzdz
    out += TrGrad * trdz
    out *= 1e-6
    out.flags.writeable = False
    return out


@lru_cache(maxsize=16)
def m1m3_lut(fea_dir, zenith, error, seed):
    """Return M1M3 LUT forces.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.
    zenith : galsim.Angle
        Zenith angle.
    error : float
        Fractional error to apply to LUT forces.
    seed : int
        Random seed to use for applying error.

    Returns
    -------
    out : ndarray
        M1M3 LUT forces in Newtons.
    """
    if zenith is None:
        return np.zeros(256)

    from scipy.interpolate import interp1d
    data = _fits_cache(fea_dir, "M1M3_LUT.fits.gz")

    LUT_force = interp1d(data[0], data[1:])(zenith.deg)

    if error != 0.0:
        # Get current forces so we can rebalance after applying random error
        z_force = np.sum(LUT_force[:156])
        y_force = np.sum(LUT_force[156:])

        rng = np.random.default_rng(seed)
        LUT_force *= rng.uniform(1-error, 1+error, size=len(LUT_force))

        # Balance forces by adjusting means in 2 ranges
        LUT_force[:156] -= np.mean(LUT_force[:156]) - z_force
        LUT_force[156:] -= np.mean(LUT_force[156:]) - y_force

        # # Balance forces by manipulating these 2 actuators
        # LUT_force[155] = z_force - np.sum(LUT_force[:155])
        # LUT_force[-1] = y_force - np.sum(LUT_force[156:-1])

    # Why are we computing and then subtracting u0?
    zf = _fits_cache(fea_dir, "M1M3_force_zenith.fits.gz")
    hf = _fits_cache(fea_dir, "M1M3_force_horizon.fits.gz")
    u0 = zf * np.cos(zenith)
    u0 += hf * np.sin(zenith)

    out = LUT_force - u0
    out.flags.writeable = False
    return out


@lru_cache(maxsize=2)
def det_height_maps(height_map_dir):
    """Return detector height maps.

        Parameters
    ----------
    height_map_dir : str
        Directory containing the height map file.

    Returns
    -------
    out : dict
        Dictionary of det -> height map (as batoid.Bicubic).  Keys can either be
        the detector number of name.
    """
    hdul = fits.open(Path(height_map_dir) / "ccd_height_map.fits.gz")
    out = {}
    for hdu in hdul:
        detnum = hdu.header.get("DETNUM")
        detname = hdu.header.get("DETNAME")
        xmin = hdu.header.get("XMIN")
        xmax = hdu.header.get("XMAX")
        ymin = hdu.header.get("YMIN")
        ymax = hdu.header.get("YMAX")
        xs = np.linspace(xmin, xmax, hdu.data.shape[1])
        ys = np.linspace(ymin, ymax, hdu.data.shape[0])
        zs = hdu.data
        # Convert mm -> meters
        xs *= 1e-3
        ys *= 1e-3
        zs *= 1e-3
        surf = batoid.Bicubic(xs, ys, zs)
        out[detnum] = surf
        out[detname] = surf
    return out


def m1m3_force_to_surface(fea_dir, forces):
    """Convert M1M3 forces to surface displacements.

    Input is generally 256-element vector of forces, output is generally a
    5256-element vector of surface displacements.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.
    forces : ndarray
        M1M3 forces in Newtons.

    Returns
    -------
    out : ndarray
        M1M3 surface z-displacements at FEA node positions in meters.
    """
    # Note this 256-element influence matrix is different from the 156 element
    # z-force only influence matrix associated with the bending modes.
    G = _fits_cache(fea_dir, "M1M3_influence_256.fits.gz")
    out = np.dot(G, forces)
    out.flags.writeable = False
    return out


def transform_zernike(zernike, R_outer, R_inner):
    """Transform Zernike polynomial to new outer and inner radii.

    Parameters
    ----------
    zernike : galsim.zernike.Zernike
        Zernike polynomial to transform.
    R_outer : float
        New outer radius.
    R_inner : float
        New inner radius.

    Returns
    -------
    ret : galsim.zernike.Zernike
        Zernike polynomial with new outer and inner radii.
    """
    xy = zernike._coef_array_xy
    ret = galsim.zernike.Zernike.__new__(galsim.zernike.Zernike)
    ret._coef_array_xy = xy
    ret.R_outer = R_outer
    ret.R_inner = R_inner
    return ret


def _load_mirror_bend(bend_dir, inds, config):
    """Load bending modes for a single mirror.

    Bending modes are parameterized by a sum of a Zernike polynomial and a grid.

    Parameters
    ----------
    bend_dir : str
        Directory containing the bending mode files.
    inds : list of int
        List indicating the ordering of the bending modes to use.
    config : dict
        Configuration dictionary for this mirror.

    Returns
    -------
    modes : BendingMode
        Bending modes for this mirror.
    """
    zk = fits.getdata(Path(bend_dir) / config['zk']['file'])
    grid = fits.getdata(Path(bend_dir) / config['grid']['file'])
    coords = fits.getdata(Path(bend_dir) / config['grid']['coords'])
    inds = np.array(inds)
    zk = zk[inds]
    grid = grid[:, inds]
    zk.flags.writeable = False
    grid.flags.writeable = False
    coords.flags.writeable = False
    return BendingMode(
        zk, config['zk']['R_outer'], config['zk'].get('R_inner', 0.0),
        coords[0], coords[1],
        grid[0], grid[1], grid[2], grid[3]
    )


@lru_cache(maxsize=4)
def load_bend(bend_dir, inds):
    """Load bending modes for all mirrors.

    Parameters
    ----------
    bend_dir : str
        Directory containing the bending mode files.
    inds : list of int
        List indicating the ordering of the bending modes to use.

    Returns
    -------
    modes : dict
        Dictionary of bending modes for each mirror.
    """
    with open(Path(bend_dir) / "bend.yaml") as f:
        config = yaml.safe_load(f)
    m1 = _load_mirror_bend(bend_dir, inds, config['M1'])
    m2 = _load_mirror_bend(bend_dir, inds, config['M2'])
    m3 = _load_mirror_bend(bend_dir, inds, config['M3'])
    return dict(M1=m1, M2=m2, M3=m3)


@lru_cache(maxsize=16*3)
def realize_bend(bend_dir, dof, inds, mirror):
    """Realize bending modes for a single mirror.

    Applies given amount of bending of each mode to mirror.

    Parameters
    ----------
    bend_dir : str
        Directory containing the bending mode files.
    dof : ndarray
        Degrees of freedom for this mirror.
    inds : list of int
        List indicating the ordering of the bending modes to use.
    mirror : str
        Name of mirror.

    Returns
    -------
    modes : RealizedBend
        Realized bending modes for this mirror.
    """
    modes = load_bend(bend_dir, inds)[mirror]
    dof = np.array(dof)
    zk = galsim.zernike.Zernike(
        np.tensordot(dof, modes.zk, axes=1),
        R_outer=modes.R_outer,
        R_inner=modes.R_inner
    )
    z = np.tensordot(dof, modes.z, axes=1)
    dzdx = np.tensordot(dof, modes.dzdx, axes=1)
    dzdy = np.tensordot(dof, modes.dzdy, axes=1)
    d2zdxy = np.tensordot(dof, modes.d2zdxy, axes=1)
    grid = np.stack([z, dzdx, dzdy, d2zdxy])
    grid.flags.writeable = False
    return RealizedBend(zk, grid)


@lru_cache(maxsize=16)
def m2_gravity(fea_dir, zenith):
    """Return M2 gravity displacement.

    There are typically 15984 M2 FEA nodes.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.
    zenith : galsim.Angle
        Zenith angle.

    Returns
    -------
    dz : ndarray
        M2 gravity displacement along z direction in meters for FEA nodes.
    """
    data = _fits_cache(fea_dir, "M2_GT_FEA.fits.gz")
    if zenith is None:
        return np.zeros_like(data[0])

    zdz, hdz = data[0:2]

    out = zdz * (np.cos(zenith) - 1)
    out += hdz * np.sin(zenith)
    out *= 1e-6  # micron -> meters
    out.flags.writeable = False
    return out


@lru_cache(maxsize=16)
def m2_temperature(fea_dir, TzGrad, TrGrad):
    """Return M2 temperature displacement.

    There are typically 15984 M2 FEA nodes.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.
    TzGrad : float
        Temperature gradient in z direction in C / m (?)
    TrGrad : float
        Temperature gradient in r direction in C / m (?)

    Returns
    -------
    dz : ndarray
        M2 temperature displacement along z direction in meters for FEA nodes.
    """
    data = _fits_cache(fea_dir, "M2_GT_FEA.fits.gz")
    tzdz, trdz = data[2:4]

    out = TzGrad * tzdz
    out += TrGrad * trdz
    out *= 1e-6
    out.flags.writeable = False
    return out


@lru_cache(maxsize=16)
def LSSTCam_gravity(fea_dir, zenith, rotation):
    """Compute LSSTCam surface displacements due to gravity.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.
    zenith : galsim.Angle
        Zenith angle.
    rotation : galsim.Angle
        Camera rotator angle.

    Returns
    -------
    camera_gravity_zk : dict
        Dictionary of Zernike coefficients for each camera surface.
    """
    if zenith is None:
        return None

    camera_gravity_zk = {}
    cam_data = [
        ('L1S1', 'L1_entrance'),
        ('L1S2', 'L1_exit'),
        ('L2S1', 'L2_entrance'),
        ('L2S2', 'L2_exit'),
        ('L3S1', 'L3_entrance'),
        ('L3S2', 'L3_exit')
    ]
    for tname, bname in cam_data:
        data = _fits_cache(fea_dir, tname+"zer.fits.gz")
        grav_zk = data[0, 3:] * (np.cos(zenith) - 1)
        grav_zk += (
            data[1, 3:] * np.cos(rotation) +
            data[2, 3:] * np.sin(rotation)
        ) * np.sin(zenith)

        # remap Andy -> Noll Zernike indices
        zIdxMapping = [
            1, 3, 2, 5, 4, 6, 8, 9, 7, 10, 13, 14, 12, 15, 11, 19, 18, 20,
            17, 21, 16, 25, 24, 26, 23, 27, 22, 28
        ]
        grav_zk = grav_zk[[x - 1 for x in zIdxMapping]]
        grav_zk *= 1e-3  # mm -> m
        # tsph -> batoid 0-index offset
        grav_zk = np.concatenate([[0], grav_zk])
        camera_gravity_zk[bname] = grav_zk
    return camera_gravity_zk


@lru_cache(maxsize=16)
def LSSTCam_temperature(fea_dir, TBulk):
    """Compute LSSTCam surface displacements due to temperature.

    Parameters
    ----------
    fea_dir : str
        Directory containing the FEA files.
    TBulk : float
        Camera temperature in C.

    Returns
    -------
    camera_temperature_zk : dict
        Dictionary of Zernike coefficients for each camera surface.
    """
    camera_temperature_zk = {}
    cam_data = [
        ('L1S1', 'L1_entrance'),
        ('L1S2', 'L1_exit'),
        ('L2S1', 'L2_entrance'),
        ('L2S2', 'L2_exit'),
        ('L3S1', 'L3_entrance'),
        ('L3S2', 'L3_exit')
    ]
    for tname, bname in cam_data:
        data = _fits_cache(fea_dir, tname+"zer.fits.gz")
        # subtract pre-compensated grav...
        TBulk = np.clip(
            TBulk,
            np.min(data[3:, 2])+0.001,
            np.max(data[3:, 2])-0.001
        )
        fidx = np.interp(TBulk, data[3:, 2], np.arange(len(data[3:, 2])))+3
        idx = int(np.floor(fidx))
        whi = fidx - idx
        wlo = 1 - whi
        temp_zk = wlo * data[idx, 3:] + whi * data[idx+1, 3:]

        # subtract reference temperature zk (0 deg C is idx=5)
        temp_zk -= data[5, 3:]

        # remap Andy -> Noll Zernike indices
        zIdxMapping = [
            1, 3, 2, 5, 4, 6, 8, 9, 7, 10, 13, 14, 12, 15, 11, 19, 18, 20,
            17, 21, 16, 25, 24, 26, 23, 27, 22, 28
        ]
        temp_zk = temp_zk[[x - 1 for x in zIdxMapping]]
        temp_zk *= 1e-3  # mm -> m
        # tsph -> batoid 0-index offset
        temp_zk = np.concatenate([[0], temp_zk])
        camera_temperature_zk[bname] = temp_zk


class LSSTBuilder:
    def __init__(
        self,
        fiducial,
        fea_dir="fea_legacy",
        bend_dir="bend",
        ccd_height_map_dir="ccd_height_map",
        use_m1m3_modes=None,
        use_m2_modes=None,
        dof_coord_system=None,
        flip_m1m3_bending_modes=False,
        flip_m2_bending_modes=None,
        dof_angle_units=None,
    ):
        """Create a Simony Survey Telescope with LSSTCam camera builder.

        Parameters
        ----------
        fiducial : batoid.Optic
            Optic before finite-element analysis (FEA) or active optics system
            (AOS) perturbations are applied.
        fea_dir : str
            Directory containing the FEA files.
        bend_dir : str
            Directory containing the bending mode files.
        use_m1m3_modes : list of int, optional
            List indicating the ordering of the bending modes to use for M1M3.
            For example, [9, 0, 4] would mean use the 9th, 0th, and 4th modes
            from the bending mode directory, but map these to the 10th, 11th,
            and 12th indices of the AOS degrees-of-freedom (The 0th through 9th
            indices are always reserved for the rigid body degrees of freedom of
            M2 and the camera).  If None, then use the modes in the order
            specified in the bending mode directory.
        use_m2_modes : list of int, optional
            List indicating the ordering of the bending modes to use for M2.
            Note that the M2 bending mode indices always come immediately after
            the M1M3 bending mode indices.  So if use_m1m3_modes is [9, 0, 4]
            and use_m2_modes is [4, 2], then the 10th, 11th, and 12th AOS
            degrees of freedom will be the 9th, 0th, and 4th M1M3 bending modes,
            and the 13th and 14th AOS degrees of freedom will be the 4th and 2nd
            M2 bending modes.  If None, then use the modes in the order
            specified in the bending mode directory.
        dof_coord_system : {"ZCS", "OCS"}, optional
            Coordinate system for the AOS hexapod degrees of freedom. "ZCS"
            means Zemax Coordinate System, "OCS" means Optical Coordinate
            System. See https://sitcomtn-003.lsst.io/ for details. If None, then
            defaults to "ZCS", but warns that this will change to "OCS" in
            a future release.
        flip_m1m3_bending_modes : bool, optional
            Whether to flip the sign of the M1M3 bending modes. This is needed
            to match the convention used in the Rubin AOS. Default is False.
        flip_m2_bending_modes : bool, optional
            Whether to flip the sign of the M2 bending modes. This is needed
            to match the convention used in the Rubin AOS. Default is True.
        dof_angle_units : {"arcsec", "degree"}, optional
            Units for the AOS DOF angles. Default is "arcsec".
        """
        # Number of FEA nodes and actuators is inferred from content of fea_dir.
        # Number of bending modes is inferred from content of bend_dir.
        # These can effect the sizes of intermediate numpy arrays, and also the
        # required inputs for the with_aos_dof and with_*_forces methods.
        self.fiducial = fiducial
        self.fea_dir = ensure_data_dir(fea_dir)
        self.bend_dir = ensure_data_dir(bend_dir)
        self._ccd_height_map_dir_raw = ccd_height_map_dir
        self._ccd_height_map_dir = None

        if use_m1m3_modes is None:
            with open(self.bend_dir / "bend.yaml") as f:
                config = yaml.safe_load(f)
                use_m1m3_modes = config['use_m1m3_modes']
        self.use_m1m3_modes = use_m1m3_modes
        self.m1m3_dof_indices = slice(10, 10+len(self.use_m1m3_modes))

        if use_m2_modes is None:
            with open(self.bend_dir / "bend.yaml") as f:
                config = yaml.safe_load(f)
                use_m2_modes = config['use_m2_modes']
        self.use_m2_modes = use_m2_modes
        self.m2_dof_indices = slice(
            self.m1m3_dof_indices.stop,
            self.m1m3_dof_indices.stop+len(self.use_m2_modes),
        )

        if dof_coord_system is None:
            warnings.warn(
                "Default dof_coord_system will change from ZCS to OCS in a future"
                " release. Please explicitly set dof_coord_system to avoid this"
                " warning.",
                FutureWarning
            )
            dof_coord_system = "ZCS"
        if dof_coord_system not in ["ZCS", "OCS"]:
            raise ValueError("Invalid dof_coord_system")
        self.dof_coord_system = dof_coord_system

        if flip_m2_bending_modes is None:
            warnings.warn(
                "Default flip_m2_bending_modes will change from True to False in a"
                " future release. Please explicitly set flip_m2_bending_modes to avoid"
                " this warning.",
                FutureWarning
            )
            flip_m2_bending_modes = True
        self.flip_m1m3_bending_modes = flip_m1m3_bending_modes
        self.flip_m2_bending_modes = flip_m2_bending_modes

        if dof_angle_units is None:
            warnings.warn(
                "Default dof_angle_units will change from arcsec to degree in a"
                " future release. Please explicitly set dof_angle_units to avoid"
                " this warning.",
                FutureWarning
            )
            dof_angle_units = "arcsec"
        if dof_angle_units not in ["arcsec", "degree"]:
            raise ValueError("Invalid dof_angle_units")
        self.dof_angle_units = dof_angle_units

        if 'LSST.LSSTCamera' in self.fiducial.itemDict:
            self.cam_name = 'LSSTCamera'
        elif 'ComCam.ComCam' in self.fiducial.itemDict:  # Older version
            self.cam_name = 'ComCam.ComCam'
        elif 'RubinComCam.ComCam' in self.fiducial.itemDict:
            self.cam_name = 'RubinComCam.ComCam'
        else:
            raise ValueError("Unsupported optic")

        # "Input" variables.
        self.rtp = None

        self.m1m3_zenith = None
        self.m1m3_TBulk = 0.0
        self.m1m3_TxGrad = 0.0
        self.m1m3_TyGrad = 0.0
        self.m1m3_TzGrad = 0.0
        self.m1m3_TrGrad = 0.0
        self.m1m3_lut_zenith = None
        self.m1m3_lut_error = 0.0
        self.m1m3_lut_seed = 1
        self.m1m3_extra_forces = np.zeros(256)

        self.m2_zenith = None
        self.m2_TzGrad = 0.0
        self.m2_TrGrad = 0.0

        self.camera_zenith = None
        self.camera_rotation = None
        self.camera_TBulk = None

        self.dof = np.zeros(10+len(self.use_m1m3_modes)+len(self.use_m2_modes))
        self.extra_zk = None
        self.extra_zk_eps = None

    @property
    def ccd_height_map_dir(self):
        if self._ccd_height_map_dir is None:
            self._ccd_height_map_dir = ensure_data_dir(self._ccd_height_map_dir_raw)
        return self._ccd_height_map_dir

    def with_rtp(self, rtp):
        """Return new LSSTBuilder that includes a given camera rotation by RTP.

        Parameters
        ----------
        rtp : float
            Camera rotation in radians.

        Returns
        -------
        ret : LSSTBuilder
            New builder with camera rotation applied.
        """
        ret = copy(self)
        ret.rtp = rtp
        return ret

    @attach_attr(
        _req_params={"zenith":galsim.Angle}
    )
    def with_m1m3_gravity(self, zenith):
        """Return new LSSTBuilder that includes gravitational flexure of M1M3.

        Parameters
        ----------
        zenith : float
            Zenith angle in radians

        Returns
        -------
        ret : LSSTBuilder
            New builder with M1M3 gravitation flexure applied.
        """
        if isinstance(zenith, Real):
            zenith = zenith * galsim.radians

        ret = copy(self)
        ret.m1m3_zenith = zenith
        return ret

    @attach_attr(
        _req_params={
            "m1m3_TBulk":float,
        },
        _opt_params={
            "m1m3_TxGrad":float,
            "m1m3_TyGrad":float,
            "m1m3_TzGrad":float,
            "m1m3_TrGrad":float,
        }
    )
    def with_m1m3_temperature(
        self,
        m1m3_TBulk,
        m1m3_TxGrad=0.0,
        m1m3_TyGrad=0.0,
        m1m3_TzGrad=0.0,
        m1m3_TrGrad=0.0,
    ):
        """Return new LSSTBuilder that includes temperature flexure of M1M3.

        Parameters
        ----------
        m1m3_TBulk : float
            Bulk temperature in C.
        m1m3_TxGrad : float, optional
            Temperature gradient in x direction in C / m (?)
        m1m3_TyGrad : float, optional
            Temperature gradient in y direction in C / m (?)
        m1m3_TzGrad : float, optional
            Temperature gradient in z direction in C / m (?)
        m1m3_TrGrad : float, optional
            Temperature gradient in r direction in C / m (?)

        Returns
        -------
        ret : LSSTBuilder
            New builder with M1M3 temperature flexure applied.
        """
        ret = copy(self)
        ret.m1m3_TBulk = m1m3_TBulk
        ret.m1m3_TxGrad = m1m3_TxGrad
        ret.m1m3_TyGrad = m1m3_TyGrad
        ret.m1m3_TzGrad = m1m3_TzGrad
        ret.m1m3_TrGrad = m1m3_TrGrad
        return ret

    @attach_attr(
        _req_params={
            "zenith":galsim.Angle,
        },
        _opt_params={
            "error":float,
            "seed":int,
        }
    )
    def with_m1m3_lut(self, zenith, error=0.0, seed=1):
        """Return new LSSTBuilder that includes LUT perturbations of M1M3.

        Parameters
        ----------
        zenith : float
            Zenith angle in radians
        error : float, optional
            Fractional error to apply to LUT forces.

        Returns
        -------
        ret : LSSTBuilder
            New builder with M1M3 LUT applied.
        """
        if isinstance(zenith, Real):
            zenith = zenith * galsim.radians

        ret = copy(self)
        ret.m1m3_lut_zenith = zenith
        ret.m1m3_lut_error = error
        ret.m1m3_lut_seed = seed
        return ret

    @attach_attr(
        _req_params={"zenith":galsim.Angle},
    )
    def with_m2_gravity(self, zenith):
        """Return new LSSTBuilder that includes gravitational flexure of M2.

        Parameters
        ----------
        zenith : float
            Zenith angle in radians

        Returns
        -------
        ret : LSSTBuilder
            New builder with M2 gravitation flexure applied.
        """
        if isinstance(zenith, Real):
            zenith = zenith * galsim.radians

        ret = copy(self)
        ret.m2_zenith = zenith
        return ret

    @attach_attr(
        _opt_params={
            "m2_TzGrad":float,
            "m2_TrGrad":float,
        }
    )
    def with_m2_temperature(
        self,
        m2_TzGrad=0.0,
        m2_TrGrad=0.0,
    ):
        """Return new LSSTBuilder that includes temperature flexure of M2.

        Parameters
        ----------
        m2_TzGrad : float, optional
            Temperature gradient in z direction in C / m (?)
        m2_TrGrad : float, optional
            Temperature gradient in r direction in C / m (?)

        Returns
        -------
        ret : LSSTBuilder
            New builder with M2 temperature flexure applied.
        """
        ret = copy(self)
        ret.m2_TzGrad = m2_TzGrad
        ret.m2_TrGrad = m2_TrGrad
        return ret

    @attach_attr(
        _req_params={
            "zenith":galsim.Angle,
            "rotation":galsim.Angle,
        },
    )
    def with_camera_gravity(self, zenith, rotation):
        """Return new LSSTBuilder that includes gravitational flexure of camera.

        Parameters
        ----------
        zenith : float
            Zenith angle in radians
        rotation : float
            Rotation angle in radians

        Returns
        -------
        ret : LSSTBuilder
            New builder with camera gravitation flexure applied.
        """
        if isinstance(zenith, Real):
            zenith = zenith * galsim.radians
        if isinstance(rotation, Real):
            rotation = rotation * galsim.radians

        ret = copy(self)
        ret.camera_zenith = zenith
        ret.camera_rotation = rotation
        return ret

    @attach_attr(
        _req_params={
            "camera_TBulk":float,
        },
    )
    def with_camera_temperature(self, camera_TBulk):
        """Return new LSSTBuilder that includes temperature flexure of camera.

        Parameters
        ----------
        camera_TBulk : float
            Camera temperature in C

        Returns
        -------
        ret : LSSTBuilder
            New builder with camera temperature flexure applied.
        """
        ret = copy(self)
        ret.camera_TBulk = camera_TBulk
        return ret

    @attach_attr(
        _req_params={
            "dof":None,
        },
    )
    def with_aos_dof(self, dof):
        """Return new LSSTBuilder that includes specified AOS degrees of freedom

        Parameters
        ----------
        dof : ndarray (50,)
            AOS degrees of freedom.
            0,1,2 are M2 z,x,y in micron
            3,4 are M2 rot around x, y in self.dof_angle_units
            5,6,7 are camera z,x,y in micron
            8,9 are camera rot around x, y in self.dof_angle_units
            The next len(use_m1m3_modes) are M1M3 bending modes in micron
            The next len(use_m2_modes) are M2 bending modes in micron

        Returns
        -------
        ret : LSSTBuilder
            New builder with specified AOS DOF.
        """
        assert len(dof) == 10+len(self.use_m1m3_modes)+len(self.use_m2_modes)
        ret = copy(self)
        ret.dof = dof.copy()
        return ret

    @attach_attr(
        _opt_params={
            "dof":None,
            "dx":float,
            "dy":float,
            "dz":float,
            "rx":galsim.Angle,
            "ry":galsim.Angle,
        },
    )
    def with_m2_rigid(
        self, dof=None, dx=None, dy=None, dz=None, rx=None, ry=None
    ):
        """Return new LSSTBuilder that includes specified M2 rigid body DOF

        Note that you cannot specify both dof and dx,dy,dz,rx,ry.
        Values specified here overwrite M2 rigid body values specified in
        with_aos_dof, but not other degrees of freedom.

        Parameters
        ----------
        dof : ndarray (5,), optional
            0,1,2 are M2 z,x,y in micron
            3,4 are M2 rot around x, y in self.dof_angle_units
        dx, dy, dz : float
            M2 displacement in micron
        rx, ry : galsim.Angle
            M2 rotation around x, y

        Returns
        -------
        ret : LSSTBuilder
            New builder with specified M2 rigid body DOF.
        """
        ret = copy(self)
        # Explicitly make a copy of ret.dof so we don't modify the original
        ret.dof = ret.dof.copy()
        if (
            any([x is not None for x in [dx, dy, dz, rx, ry]])
            and dof is not None
        ):
            raise ValueError("Cannot specify both dof and dx,dy,dz,rx,ry")
        if dof is not None:
            ret.dof[:5] = dof
        else:
            if dz is not None:
                ret.dof[0] = dz
            if dx is not None:
                ret.dof[1] = dx
            if dy is not None:
                ret.dof[2] = dy
            if rx is not None:
                if self.dof_angle_units == "arcsec":
                    ret.dof[3] = rx.deg*3600
                else:
                    ret.dof[3] = rx.deg
            if ry is not None:
                if self.dof_angle_units == "arcsec":
                    ret.dof[4] = ry.deg*3600
                else:
                    ret.dof[4] = ry.deg
        return ret


    @attach_attr(
        _opt_params={
            "dof":None,
            "dx":float,
            "dy":float,
            "dz":float,
            "rx":galsim.Angle,
            "ry":galsim.Angle,
        },
    )
    def with_camera_rigid(
        self, dof=None, dx=None, dy=None, dz=None, rx=None, ry=None
    ):
        """Return new LSSTBuilder that includes specified camera rigid body DOF

        Note that you cannot specify both dof and dx,dy,dz,rx,ry.
        Values specified here overwrite camera rigid body values specified in
        with_aos_dof, but not other degrees of freedom.

        Parameters
        ----------
        dof : ndarray (5,), optional
            0,1,2 are camera z,x,y in micron
            3,4 are camera rot around x, y in self.dof_angle_units
        dx, dy, dz : float
            camera displacement in micron
        rx, ry : galsim.Angle
            camera rotation around x, y

        Returns
        -------
        ret : LSSTBuilder
            New builder with specified camera rigid body DOF.
        """
        ret = copy(self)
        # Explicitly make a copy of ret.dof so we don't modify the original
        ret.dof = ret.dof.copy()
        if (
            any([x is not None for x in [dx, dy, dz, rx, ry]])
            and dof is not None
        ):
            raise ValueError("Cannot specify both dof and dx,dy,dz,rx,ry")
        if dof is not None:
            ret.dof[5:10] = dof
        else:
            if dz is not None:
                ret.dof[5] = dz
            if dx is not None:
                ret.dof[6] = dx
            if dy is not None:
                ret.dof[7] = dy
            if rx is not None:
                if self.dof_angle_units == "arcsec":
                    ret.dof[8] = rx.deg*3600
                else:
                    ret.dof[8] = rx.deg
            if ry is not None:
                if self.dof_angle_units == "arcsec":
                    ret.dof[9] = ry.deg*3600
                else:
                    ret.dof[9] = ry.deg
        return ret

    @attach_attr(
        _req_params={
            "dof":None,
        },
    )
    def with_m1m3_bend(self, dof):
        """Return new LSSTBuilder that includes specified M1M3 bending modes

        Parameters
        ----------
        dof : ndarray (len(use_m1m3_modes),)
            M1M3 bending modes in micron

        Returns
        -------
        ret : LSSTBuilder
            New builder with specified M1M3 bending modes.
        """
        ret = copy(self)
        # Explicitly make a copy of ret.dof so we don't modify the original
        ret.dof = ret.dof.copy()
        ret.dof[self.m1m3_dof_indices] = dof
        return ret

    @attach_attr(
        _req_params={
            "dof":None,
        },
    )
    def with_m2_bend(self, dof):
        """Return new LSSTBuilder that includes specified M2 bending modes

        Parameters
        ----------
        dof : ndarray (len(use_m2_modes),)
            M2 bending modes in micron

        Returns
        -------
        ret : LSSTBuilder
            New builder with specified M2 bending modes.
        """
        ret = copy(self)
        # Explicitly make a copy of ret.dof so we don't modify the original
        ret.dof = ret.dof.copy()
        ret.dof[self.m2_dof_indices] = dof
        return ret

    @attach_attr(
        _req_params={
            "zk":None,
            "eps":float,
        },
    )
    def with_extra_zk(self, zk, eps):
        """Return new LSSTBuilder that includes specified constant Zernike phase
        screen at entrance pupil.

        Parameters
        ----------
        zk : ndarray
            Zernike coefficients of phases to add at entrance pupil in meters.
        eps : float
            Use annular Zernikes with this fractional obscuration.

        Returns
        -------
        ret : LSSTBuilder
            New builder with specified extra Zernike phases.
        """
        ret = copy(self)
        ret.extra_zk = zk
        ret.extra_zk_eps = eps
        return ret

    @attach_attr(
        _req_params={
            "forces":None,
        },
    )
    def with_m1m3_extra_forces(self, forces):
        """Return new LSSTBuilder that includes specified M1M3 extra forces

        Parameters
        ----------
        forces : ndarray (256,)
            M1M3 extra forces in N

        Returns
        -------
        ret : LSSTBuilder
            New builder with specified M1M3 extra forces.

        Notes
        -----
        Will _not_ include any effects of ccd height map.
        """
        # Todo: should be able to index these by actuator ID too.
        ret = copy(self)
        ret.m1m3_extra_forces = forces
        return ret

    @lru_cache(maxsize=1)
    def build(self):
        """Build the optic.

        Returns
        -------
        optic : batoid.Optic
            The perturbed optic.
        """
        optic = self.fiducial
        optic = self._apply_rtp(optic)
        optic = self._apply_phase(optic)
        optic = self._apply_rigid_body_perturbations(optic)
        optic = self._apply_M1M3_surface_perturbations(optic)
        optic = self._apply_M2_surface_perturbations(optic)
        optic = self._apply_camera_surface_perturbations(optic)
        return optic

    @lru_cache(maxsize=5)  # Keep modest
    def build_det(self, det):
        """Build the optic for a specific detector.

        Parameters
        ----------
        det : str or int
            The detector number of name.

        Returns
        -------
        optic : batoid.Optic
            The perturbed optic, including the ccd height map.
        """
        optic = self.build()
        optic = self._apply_detector_perturbations(optic, det)
        return optic

    def _apply_detector_perturbations(self, optic, det):
        det_surfs = det_height_maps(self.ccd_height_map_dir)
        optic = optic.withPerturbedSurface(
            "Detector",
            det_surfs[det],
        )
        return optic

    def _apply_rtp(self, optic):
        if self.rtp is None:
            return optic
        optic = optic.withLocallyRotatedOptic(self.cam_name, batoid.RotZ(self.rtp))
        return optic

    def _apply_phase(self, optic):
        if self.extra_zk is None:
            return optic
        optic = optic.withInsertedOptic(
            before="M1",
            item=batoid.OPDScreen(
                name='Screen',
                surface=batoid.Plane(),
                screen=batoid.Zernike(
                    self.extra_zk,
                    R_outer=4.18,
                    R_inner=self.extra_zk_eps*4.18
                ),
                coordSys=optic.stopSurface.coordSys,
                obscuration=optic['M1'].obscuration,
            )
        )
        return optic

    def _apply_rigid_body_perturbations(self, optic):
        dof = self.dof.copy()
        if self.dof_coord_system == "ZCS":
            # Flip x, z and rotations about x, z.
            dof[0] = -dof[0]
            dof[1] = -dof[1]
            dof[3] = -dof[3]
            dof[5] = -dof[5]
            dof[6] = -dof[6]
            dof[8] = -dof[8]

        if np.any(dof[0:3]):
            optic = optic.withGloballyShiftedOptic(
                "M2",
                np.array([dof[1], dof[2], dof[0]])*1e-6
            )

        angle_factor = 1./3600 if self.dof_angle_units == "arcsec" else 1.0
        if np.any(dof[3:5]):
            rx = batoid.RotX(np.deg2rad(dof[3]*angle_factor))
            ry = batoid.RotY(np.deg2rad(dof[4]*angle_factor))
            optic = optic.withLocallyRotatedOptic(
                "M2",
                rx @ ry
            )

        if np.any(dof[5:8]):
            optic = optic.withGloballyShiftedOptic(
                self.cam_name,
                np.array([dof[6], dof[7], dof[5]])*1e-6
            )

        if np.any(dof[8:10]):
            rx = batoid.RotX(np.deg2rad(dof[8]*angle_factor))
            ry = batoid.RotY(np.deg2rad(dof[9]*angle_factor))
            optic = optic.withLocallyRotatedOptic(
                self.cam_name,
                rx @ ry
            )

        return optic

    def _apply_M1M3_surface_perturbations(self, optic):
        dof = self.dof
        # Collect gravity/temperature perturbations
        # be sure to make a copy here!
        m1m3_fea = np.array(m1m3_gravity(
            self.fea_dir, self.fiducial, self.m1m3_zenith
        ))
        m1m3_fea += m1m3_temperature(
            self.fea_dir,
            self.m1m3_TBulk,
            self.m1m3_TxGrad,
            self.m1m3_TyGrad,
            self.m1m3_TzGrad,
            self.m1m3_TrGrad
        )

        m1m3_forces = np.array(m1m3_lut(
            self.fea_dir,
            self.m1m3_lut_zenith,
            self.m1m3_lut_error,
            self.m1m3_lut_seed
        ))
        m1m3_forces += self.m1m3_extra_forces

        m1m3_fea += m1m3_force_to_surface(
            self.fea_dir,
            m1m3_forces
        )

        bx, by, idx1, idx3 = m1m3_fea_nodes(self.fea_dir)
        if np.any(m1m3_fea):
            # Decompose into independent annular Zernikes+grid on M1 and M3
            m1_fea = m1m3_fea[idx1]
            m3_fea = m1m3_fea[idx3]

            zBasis1 = galsim.zernike.zernikeBasis(
                28, bx[idx1], by[idx1], R_outer=4.18, R_inner=2.558
            )
            zBasis3 = galsim.zernike.zernikeBasis(
                28, bx[idx3], by[idx3], R_outer=2.508, R_inner=0.55
            )
            m1_zk, *_ = np.linalg.lstsq(zBasis1.T, m1_fea, rcond=None)
            m3_zk, *_ = np.linalg.lstsq(zBasis3.T, m3_fea, rcond=None)
            m1_zk = galsim.zernike.Zernike(m1_zk, R_outer=4.18, R_inner=2.558)
            m3_zk = galsim.zernike.Zernike(m3_zk, R_outer=2.508, R_inner=0.55)
            m1_fea -= m1_zk(bx[idx1], by[idx1])
            m3_fea -= m3_zk(bx[idx3], by[idx3])

            m1_grid = _node_to_grid(
                bx[idx1], by[idx1], m1_fea, m1m3_grid_xy(self.bend_dir)[0]
            )
            m3_grid = _node_to_grid(
                bx[idx3], by[idx3], m3_fea, m1m3_grid_xy(self.bend_dir)[1]
            )
        else:
            m1_nx = m1m3_grid_xy(self.bend_dir)[0].shape[1]
            m3_nx = m1m3_grid_xy(self.bend_dir)[1].shape[1]
            m1_grid = np.zeros((4, m1_nx, m1_nx))
            m3_grid = np.zeros((4, m3_nx, m3_nx))
            m1_zk = galsim.zernike.Zernike(
                np.zeros(29), R_outer=4.18, R_inner=2.558
            )
            m3_zk = galsim.zernike.Zernike(
                np.zeros(29), R_outer=2.508, R_inner=0.55
            )

        # Fold in M1M3 bending modes
        if np.any(dof[self.m1m3_dof_indices]):
            bend1 = realize_bend(
                self.bend_dir,
                tuple(dof[self.m1m3_dof_indices]),
                tuple(self.use_m1m3_modes),
                "M1"
            )
            bend3 = realize_bend(
                self.bend_dir,
                tuple(dof[self.m1m3_dof_indices]),
                tuple(self.use_m1m3_modes),
                "M3"
            )

            if self.flip_m1m3_bending_modes:
                bend1 = RealizedBend(-bend1.zk, -bend1.grid)
                bend3 = RealizedBend(-bend3.zk, -bend3.grid)

            m1_zk += transform_zernike(bend1.zk, R_outer=4.18, R_inner=2.558)
            m3_zk += transform_zernike(bend3.zk, R_outer=2.508, R_inner=0.55)

            m1_grid += bend1.grid
            m3_grid += bend3.grid

        # Apply to M1
        components = []
        if np.any(m1_zk.coef):
            components.append(
                batoid.Zernike(
                    m1_zk.coef, m1_zk.R_outer, m1_zk.R_inner
                )
            )
        if np.any(m1_grid):
            components.append(
                batoid.Bicubic(
                    *m1m3_grid_xy(self.bend_dir)[0],
                    *m1_grid,
                    nanpolicy='zero'
                )
            )
        if components:
            if len(components) >= 2:
                perturbation = batoid.Sum(components)
            else:
                perturbation = components[0]
            optic = optic.withPerturbedSurface(
                'M1',
                perturbation
            )

        # Apply to M3
        components = []
        if np.any(m3_zk.coef):
            components.append(
                batoid.Zernike(
                    m3_zk.coef, m3_zk.R_outer, m3_zk.R_inner
                )
            )
        if np.any(m3_grid):
            components.append(
                batoid.Bicubic(
                    *m1m3_grid_xy(self.bend_dir)[1],
                    *m3_grid,
                    nanpolicy='zero'
                )
            )
        if components:
            if len(components) >= 2:
                perturbation = batoid.Sum(components)
            else:
                perturbation = components[0]
            optic = optic.withPerturbedSurface(
                'M3',
                perturbation
            )
        return optic

    def _apply_M2_surface_perturbations(self, optic):
        dof = self.dof
        # Collect gravity/temperature perturbations
        # be sure to make a copy here!
        m2_fea = np.array(m2_gravity(
            self.fea_dir, self.m2_zenith
        ))
        m2_fea += m2_temperature(
            self.fea_dir,
            self.m2_TzGrad,
            self.m2_TrGrad
        )
        # m2_fea += m2_lut(
        #     self.fea_dir,
        #     self.m2_lut_zenith,
        # )

        bx, by = m2_fea_nodes(self.fea_dir)
        if np.any(m2_fea):
            # Decompose into annular Zernikes+grid on M2
            zBasis2 = galsim.zernike.zernikeBasis(
                28, bx, by, R_outer=1.71, R_inner=0.9
            )
            m2_zk, *_ = np.linalg.lstsq(zBasis2.T, m2_fea, rcond=None)
            m2_zk = galsim.zernike.Zernike(m2_zk, R_outer=1.71, R_inner=0.9)
            m2_fea -= m2_zk(bx, by)

            m2_grid = _node_to_grid(
                bx, by, m2_fea, m2_grid_xy(self.bend_dir)
            )
        else:
            m2_nx = m2_grid_xy(self.bend_dir).shape[1]
            m2_grid = np.zeros((4, m2_nx, m2_nx))
            m2_zk = galsim.zernike.Zernike(
                np.zeros(29), R_outer=1.71, R_inner=0.9
            )

        # Fold in M2 bending modes
        if np.any(dof[self.m2_dof_indices]):
            bend2 = realize_bend(
                self.bend_dir,
                tuple(dof[self.m2_dof_indices]),
                tuple(self.use_m2_modes),
                "M2"
            )

            if self.flip_m2_bending_modes:
                # The original files had the opposite sign convention, so we actually
                # only flip if flip_m2_bending_modes is False!
                pass
            else:
                bend2 = RealizedBend(-bend2.zk, -bend2.grid)

            m2_zk += transform_zernike(bend2.zk, R_outer=1.71, R_inner=0.9)
            m2_grid += bend2.grid

        # Apply to M2
        components = []
        if np.any(m2_zk.coef):
            components.append(
                batoid.Zernike(
                    m2_zk.coef, m2_zk.R_outer, m2_zk.R_inner
                )
            )
        if np.any(m2_grid):
            components.append(
                batoid.Bicubic(
                    *m2_grid_xy(self.bend_dir),
                    *m2_grid,
                    nanpolicy='zero'
                )
            )
        if components:
            if len(components) >= 2:
                perturbation = batoid.Sum(components)
            else:
                perturbation = components[0]
            optic = optic.withPerturbedSurface(
                'M2',
                perturbation
            )
        return optic

    def _apply_camera_surface_perturbations(self, optic):
        if self.camera_zenith is None:
            zen = None
            rot = None
        else:
            zen = self.camera_zenith
            rot = self.camera_rotation
        TBulk = self.camera_TBulk

        for tname, bname, radius in [
            ('L1S1', 'L1_entrance', 0.775),
            ('L1S2', 'L1_exit', 0.775),
            ('L2S1', 'L2_entrance', 0.551),
            ('L2S2', 'L2_exit', 0.551),
            ('L3S1', 'L3_entrance', 0.361),
            ('L3S2', 'L3_exit', 0.361)
        ]:
            data = _fits_cache(self.fea_dir, tname+"zer.fits.gz")
            zk = np.zeros(28)
            if zen is not None:
                zk += data[0, 3:] * (np.cos(zen) - 1)
                zk += (
                    data[1, 3:] * np.cos(rot) +
                    data[2, 3:] * np.sin(rot)
                ) * np.sin(zen)

            if TBulk is not None:
                TBulk1 = np.clip(
                    TBulk,
                    np.min(data[3:, 2])+0.001,
                    np.max(data[3:, 2])-0.001
                )
                fidx = np.interp(
                    TBulk1, data[3:, 2], np.arange(len(data[3:, 2]))
                )+3
                idx = int(np.floor(fidx))
                whi = fidx - idx
                wlo = 1 - whi
                zk += wlo * data[idx, 3:] + whi * data[idx+1, 3:]

                # subtract reference temperature zk (0 deg C is idx=5)
                zk -= data[5, 3:]

            # remap Andy -> Noll Zernike indices
            zIdxMapping = [
                1, 3, 2, 5, 4, 6, 8, 9, 7, 10, 13, 14, 12, 15, 11, 19, 18, 20,
                17, 21, 16, 25, 24, 26, 23, 27, 22, 28
            ]
            zk = zk[[x - 1 for x in zIdxMapping]]
            zk *= 1e-3  # mm -> m
            # tsph -> batoid 0-index offset
            zk = np.concatenate([[0], zk])

            # Now need to flip x and z for Zemax -> batoid
            for j in range(1, 29):
                n, m = galsim.zernike.noll_to_zern(j)
                if (n+(m>=0)) % 2 == 0:  # antisymmetric in x
                    zk[j] *= -1
            zk = -zk

            if np.any(zk):
                optic = optic.withPerturbedSurface(
                    bname,
                    batoid.Zernike(zk, R_outer=radius)
                )

        return optic

# Attach GalSim config attributes to use when constructing LSSTBuilder
# Don't actually require `fiducial`, since that's handled manually in the
# ImSim config.
LSSTBuilder._req_params = {}
LSSTBuilder._single_params = {}
LSSTBuilder._opt_params = {
    "fea_dir":str,
    "bend_dir":str,
    "ccd_height_map_dir":str,
    "use_m1m3_modes":None,
    "use_m2_modes":None,
    "dof_coord_system":str,
    "flip_m1m3_bending_modes":bool,
    "flip_m2_bending_modes":bool,
    "dof_angle_units":str,
}
# Ignore with_* args in the ImSim config dict
LSSTBuilder._ignore_params = {
    k[5:]:None for k in dir(LSSTBuilder) if k.startswith("with_")
}
