diff --git a/scopesim/effects/psfs/__init__.py b/scopesim/effects/psfs/__init__.py index ab887b23d..64ede5175 100644 --- a/scopesim/effects/psfs/__init__.py +++ b/scopesim/effects/psfs/__init__.py @@ -7,3 +7,4 @@ GaussianDiffractionPSF) from .semianalytical import AnisocadoConstPSF from .discrete import FieldConstantPSF, FieldVaryingPSF +from .tiptop import TipTopPSF diff --git a/scopesim/effects/psfs/tiptop.py b/scopesim/effects/psfs/tiptop.py new file mode 100644 index 000000000..93356a283 --- /dev/null +++ b/scopesim/effects/psfs/tiptop.py @@ -0,0 +1,465 @@ +# -*- coding: utf-8 -*- +"""A PSF effect that queries the TipTop AO simulation service. + +TipTop (Neichel et al. 2021) is ESO's analytical AO PSF simulator. This +module wraps it via the `tiptop_ipy `_ +client package, which talks to a TipTop web service (by default the +University of Vienna server, ``https://tiptop.univie.ac.at/api``). + +Every generated PSF is cached on disk, so each unique configuration +(instrument config + wavelength + grid) costs exactly one server call, ever. +""" + +import hashlib +from typing import ClassVar +from pathlib import Path + +import numpy as np +from astropy import units as u +from astropy.io import fits + +from ...optics.fov import FieldOfView +from ...utils import from_currsys, quantify, get_logger +from . import PSF +from .discrete import _rescale_kernel, _cutout_kernel + +logger = get_logger(__name__) + +MAX_PSF_SIDE = 1024 +"""Hard upper limit on the requested PSF grid side length [pixel].""" + + +class TipTopPSF(PSF): + """Field-constant AO PSF generated by the TipTop service. + + Works for any AO system TipTop can model: pass the name of any + tiptop_ipy instrument template (``MAVIS``, ``METIS``, ``MICADO_SCAO``, + ``MORFEO``, ``ERIS``, ``HARMONI_SCAO``, ... — see + ``tiptop_ipy.TipTop.list_instruments()``) or the path to a custom + TipTop ``.ini`` file, plus any parameter `overrides` on top. + + One PSF is generated per wavelength band and cached on disk. The + wavelength is resolved in this order: + + 1. ``wave_dict`` + ``filter_name``: an explicit mapping of filter names + to effective wavelengths [um] and the current filter (typically + ``"!OBS.filter_name"``). Works for any filter naming scheme; each + filter band triggers at most one server call, after which the PSF + is read from the cache. + 2. ``filter_name`` alone: the filter's effective wavelength is looked + up on the SVO filter service — either a generic name known to + ScopeSim (``V``, ``J``, ``Ks``, ...; see + ``ter_curves_utils.FILTER_DEFAULTS``) or a fully qualified SVO + identifier such as ``"Paranal/HAWKI.Ks"``. + 3. ``wavelength``: an explicit wavelength [um], or a filter name / + SVO identifier resolved as in 2. + 4. The mean wavelength of the current FieldOfView. + + The PSF is generated on-axis (a single science source at zenith offset + zero) on a grid of at most 1024x1024 pixels. + + Parameters + ---------- + instrument : str, optional + Name of a tiptop_ipy instrument template (e.g. ``"MAVIS"``, + ``"MICADO_SCAO"``, ``"ERIS"``). Either this or `ini_file` is + required. + ini_file : str, optional + Path to a custom TipTop ``.ini`` configuration file. + wave_dict : dict, optional + Mapping of filter name to effective wavelength [um]. Use this when + the instrument's filter names are not generic photometric bands. + filter_name : str, optional + Filter name (or bang-string, e.g. ``"!OBS.filter_name"``). Used as + key into `wave_dict` if that is given, otherwise resolved through + the SVO filter service (generic band names or full SVO + identifiers). + wavelength : float or str, optional + [um] Explicit wavelength, used if neither `wave_dict` nor + `filter_name` is given. A string is resolved like `filter_name`. + fov_pix : int + [pixel] Side length of the generated PSF grid. Values above 1024 + are clipped to 1024. Default is 512. + pixel_scale : float + [arcsec] Pixel scale of the generated PSF grid. Default is half the + detector pixel scale (``"!INST.pixel_scale"`` / 2), i.e. Nyquist + sampling of the detector. + cache_dir : str + Directory for cached PSF files. Default is ``"./tiptop_psf_cache"``. + overrides : dict, optional + Nested dict ``{section: {key: value}}`` of TipTop configuration + overrides, applied on top of the template / ini file. A value of + ``None`` (yaml ``null``) removes the key; ``{section: None}`` + removes the whole section. + server : str, optional + TipTop server name (``"univie"``, ``"eso"``) or full URL. Default + is tiptop_ipy's default (the University of Vienna server). + timeout : int + [s] Server timeout per PSF generation. Default 600. + + Examples + -------- + MAVIS MCAO with an explicit filter-to-wavelength mapping:: + + effects: + - name: mavis_tiptop_psf + class: TipTopPSF + kwargs: + instrument: MAVIS + filter_name: "!OBS.filter_name" + wave_dict: {V: 0.545, R: 0.641, I: 0.798} + fov_pix: 1024 + pixel_scale: 0.00368 + cache_dir: "./tiptop_psf_cache" + + MICADO SCAO, resolving generic filter names (J, H, Ks, ...) through + the SVO filter service:: + + - name: micado_tiptop_psf + class: TipTopPSF + kwargs: + instrument: MICADO_SCAO + filter_name: "!OBS.filter_name" + pixel_scale: 0.002 # 4 mas imaging mode / 2 + + MICADO behind MORFEO (MCAO):: + + - name: morfeo_tiptop_psf + class: TipTopPSF + kwargs: + instrument: MORFEO + filter_name: "!OBS.filter_name" + pixel_scale: 0.002 + overrides: + telescope: {glFocusOnNGS: False} + sources_Focus: null + sensor_Focus: null + + (The Focus-sensor overrides work around the stock MORFEO template + crashing TIPTOP 1.4.0 on the current servers; drop them once the + server-side TIPTOP handles ``glFocusOnNGS`` again.) + + METIS SCAO from python, with a config override:: + + from scopesim.effects import TipTopPSF + psf = TipTopPSF(instrument="METIS", + wave_dict={"Lp": 3.79, "Mp": 4.66, "N2": 11.24}, + filter_name="!OBS.filter_name", + pixel_scale=0.00547, + overrides={"telescope": {"ZenithAngle": 30.0}}) + + Note that the METIS detector pixel scale (5.47 mas at L/M) already + oversamples the ELT diffraction limit at these wavelengths + (lambda/2D = 10 mas at L), so requesting the kernel at the detector + scale loses no information -- and the TipTop server rejects much finer + grids for this configuration. + + """ + + required_keys = set() + z_order: ClassVar[tuple[int, ...]] = (42, 652) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + params = { + "instrument": None, + "ini_file": None, + "wave_dict": None, + "filter_name": None, + "wavelength": None, + "fov_pix": 512, + "pixel_scale": None, + "cache_dir": "./tiptop_psf_cache", + "overrides": None, + "server": None, + "timeout": 600, + } + self.meta.update(params) + self.meta.update(kwargs) + self.convolution_classes = FieldOfView + + if (self.meta["instrument"] is None + and self.meta["ini_file"] is None): + raise ValueError( + "TipTopPSF needs either `instrument` (a tiptop_ipy template " + "name) or `ini_file` (path to a TipTop .ini file)") + + # one kernel per wavelength, filled lazily + self._kernels = {} # {wavelength [um]: 2D ndarray} + self._kernel_pixel_scales = {} # {wavelength [um]: [arcsec]} + + # ------------------------------------------------------------------ + # wavelength and grid resolution + # ------------------------------------------------------------------ + + def _resolve_wavelength(self, fov=None): + """Return the PSF wavelength [um] for the current configuration.""" + wave_dict = self.meta["wave_dict"] + filter_name = from_currsys(self.meta["filter_name"], self.cmds) + + if wave_dict is not None: + if filter_name is None: + raise ValueError( + "TipTopPSF has `wave_dict` but `filter_name` resolved " + "to None") + try: + return float(wave_dict[filter_name]) + except KeyError: + raise KeyError( + f"filter '{filter_name}' not found in TipTopPSF " + f"wave_dict: {sorted(wave_dict)}") from None + + if filter_name is not None: + return self._wave_from_filter(filter_name) + + if self.meta["wavelength"] is not None: + wave = from_currsys(self.meta["wavelength"], self.cmds) + if isinstance(wave, str): + return self._wave_from_filter(wave) + return float(quantify(wave, u.um).value) + + if fov is not None and getattr(fov, "wavelength", None) is not None: + return float(quantify(fov.wavelength, u.um).value) + + raise ValueError( + "TipTopPSF could not resolve a wavelength: give `wave_dict` " + "and/or `filter_name`, or `wavelength`, or apply the effect to " + "a FieldOfView") + + @staticmethod + def _wave_from_filter(filter_name): + """Effective wavelength [um] of a filter, via the SVO service. + + Accepts either a generic band name known to ScopeSim ("V", "J", + "Ks", ...) or a fully qualified SVO identifier such as + "Paranal/HAWKI.Ks". + """ + from .. import ter_curves_utils as tu + + if "/" in filter_name: + wave, trans = tu.download_svo_filter(filter_name, + return_style="quantity") + eff_wave = (wave * trans).sum() / trans.sum() + return float(eff_wave.to_value(u.um)) + + if filter_name in tu.FILTER_DEFAULTS: + eff_wave = tu.get_filter_effective_wavelength(filter_name) + return float(quantify(eff_wave, u.um).value) + + raise KeyError( + f"filter '{filter_name}' is neither a generic band name " + f"({sorted(tu.FILTER_DEFAULTS)}) nor an SVO identifier " + "('observatory/instrument.filter'). Give the wavelength " + "explicitly via `wave_dict` or `wavelength` instead.") + + def _grid_parameters(self): + """Return (n_pix, pixel_scale [arcsec]) of the generated grid.""" + n_pix = int(from_currsys(self.meta["fov_pix"], self.cmds)) + if n_pix > MAX_PSF_SIDE: + logger.warning( + "TipTopPSF fov_pix=%d exceeds the maximum of %d; clipping.", + n_pix, MAX_PSF_SIDE) + n_pix = MAX_PSF_SIDE + if n_pix < 2: + raise ValueError(f"TipTopPSF fov_pix={n_pix} is too small") + + pixel_scale = self.meta["pixel_scale"] + if pixel_scale is None: + pixel_scale = from_currsys("!INST.pixel_scale", self.cmds) / 2. + else: + pixel_scale = float(from_currsys(pixel_scale, self.cmds)) + + return n_pix, pixel_scale + + # ------------------------------------------------------------------ + # caching and generation + # ------------------------------------------------------------------ + + def _make_connection(self, wavelength, n_pix, pixel_scale): + """Build the configured tiptop_ipy connection (no server contact).""" + try: + from tiptop_ipy import TipTop + except ImportError as err: + raise ImportError( + "TipTopPSF requires the optional dependency tiptop_ipy: " + "pip install tiptop-ipy") from err + + ini_file = from_currsys(self.meta["ini_file"], self.cmds) + instrument = from_currsys(self.meta["instrument"], self.cmds) + if ini_file is not None: + conn = TipTop(ini_file=ini_file) + else: + conn = TipTop(instrument=instrument) + + # a single on-axis science source at the requested wavelength + conn["sources_science", "Wavelength"] = [wavelength * 1e-6] + conn["sources_science", "Zenith"] = [0.0] + conn["sources_science", "Azimuth"] = [0.0] + conn["sensor_science", "PixelScale"] = pixel_scale * 1000. # [mas] + conn["sensor_science", "FieldOfView"] = n_pix + + overrides = self.meta["overrides"] or {} + for section, params in overrides.items(): + if params is None: + # yaml `section: null` removes the whole section + conn._config.pop(section, None) + continue + for key, value in params.items(): + if value is None: + # yaml `key: null` removes the key from the config + conn[section].pop(key, None) + else: + conn[section, key] = value + + return conn + + def _cache_path(self, conn, wavelength): + """Return the cache file path for the current configuration.""" + cache_dir = Path(from_currsys(self.meta["cache_dir"], self.cmds)) + key = hashlib.sha256(conn.ini_contents.encode()).hexdigest()[:16] + name = self.meta["instrument"] or Path(self.meta["ini_file"]).stem + fname = f"tiptop_{name}_{wavelength * 1000:.0f}nm_{key}.fits" + return cache_dir / fname + + def _generate_psf_file(self, conn, path, wavelength, n_pix, pixel_scale): + """Query the TipTop server and write the PSF to the cache file.""" + server = self.meta["server"] + if server is not None: + from tiptop_ipy.utils import set_server + set_server(server) + + logger.info("TipTopPSF: querying TipTop server for %s at %.3f um " + "(%dx%d pix @ %.2f mas)", self.meta["instrument"], + wavelength, n_pix, n_pix, pixel_scale * 1000) + timeout = int(from_currsys(self.meta["timeout"], self.cmds)) + try: + result = conn.generate_psf(timeout=timeout) + except Exception as err: + raise RuntimeError( + f"TipTop failed for instrument={self.meta['instrument']} " + f"at {wavelength:.3f} um ({n_pix}x{n_pix} pix @ " + f"{pixel_scale * 1000:.2f} mas): {err}\n" + "If this is a server-side shape/broadcast error, the " + "requested science grid is outside what TipTop can " + "compute for this configuration -- try a coarser " + "`pixel_scale` (e.g. the detector pixel scale instead of " + "half of it) or a smaller `fov_pix`.") from err + + kernel = np.asarray(result.psf, dtype=np.float64) + if kernel.ndim == 3: + kernel = kernel[0] + kernel[kernel < 0] = 0. + kernel /= kernel.sum() + + hdu = fits.ImageHDU(kernel.astype(np.float32)) + hdu.header["WAVE0"] = (wavelength, "[um] wavelength of this PSF") + hdu.header["CDELT1"] = pixel_scale + hdu.header["CDELT2"] = pixel_scale + hdu.header["CUNIT1"] = "arcsec" + hdu.header["CUNIT2"] = "arcsec" + hdu.header["CRPIX1"] = kernel.shape[1] / 2 + 0.5 + hdu.header["CRPIX2"] = kernel.shape[0] / 2 + 0.5 + hdu.header["CRVAL1"] = 0.0 + hdu.header["CRVAL2"] = 0.0 + strehls = getattr(result, "strehl", None) + if strehls is not None and len(strehls): + hdu.header["STREHL"] = (float(strehls[0]), "TipTop Strehl ratio") + fwhms = getattr(result, "fwhm", None) + if fwhms is not None and len(fwhms): + hdu.header["FWHM"] = (float(fwhms[0]), "[mas] TipTop FWHM") + + primary = fits.PrimaryHDU() + primary.header["ORIGIN"] = "TipTop via ScopeSim TipTopPSF" + primary.header["INSTRUME"] = str(self.meta["instrument"]) + for line in conn.ini_contents.splitlines(): + primary.header.add_comment(line[:70]) + + path.parent.mkdir(parents=True, exist_ok=True) + fits.HDUList([primary, hdu]).writeto(path, overwrite=True) + logger.info("TipTopPSF: cached PSF at %s", path) + + def _load_kernel(self, wavelength): + """Load (or generate) the kernel for one wavelength.""" + if wavelength in self._kernels: + return self._kernels[wavelength] + + n_pix, pixel_scale = self._grid_parameters() + conn = self._make_connection(wavelength, n_pix, pixel_scale) + path = self._cache_path(conn, wavelength) + + if not path.exists(): + self._generate_psf_file(conn, path, wavelength, n_pix, + pixel_scale) + else: + logger.info("TipTopPSF: using cached PSF %s", path) + + with fits.open(path) as hdul: + kernel = hdul[1].data.astype(np.float64) + kernel_scale = float(hdul[1].header["CDELT1"]) + + kernel /= kernel.sum() + self._kernels[wavelength] = kernel + self._kernel_pixel_scales[wavelength] = kernel_scale + return kernel + + # ------------------------------------------------------------------ + # PSF interface + # ------------------------------------------------------------------ + + def get_kernel(self, fov): + """Return the PSF kernel for the given FieldOfView.""" + wavelength = self._resolve_wavelength(fov) + kernel = self._load_kernel(wavelength) + kernel_scale = self._kernel_pixel_scales[wavelength] + + if isinstance(fov, FieldOfView): + fov_pixel_scale = fov.header["CDELT1"] * u.Unit( + fov.header.get("CUNIT1", "deg")).to(u.arcsec) + fov_header = fov.header + else: + fov_pixel_scale = float(fov) + fov_header = None + + pix_ratio = kernel_scale / fov_pixel_scale + if abs(pix_ratio - 1) > self.meta["flux_accuracy"]: + kernel = _rescale_kernel(kernel, pix_ratio) + + if (fov_header is not None + and ((fov_header["NAXIS1"] < kernel.shape[1]) + or (fov_header["NAXIS2"] < kernel.shape[0]))): + kernel_header = fits.Header() + kernel_header["NAXIS1"] = kernel.shape[1] + kernel_header["NAXIS2"] = kernel.shape[0] + kernel_header["CDELT1"] = fov_pixel_scale + kernel_header["CDELT2"] = fov_pixel_scale + kernel_header["CUNIT1"] = "arcsec" + kernel_header["CUNIT2"] = "arcsec" + kernel_header["CRPIX1"] = kernel.shape[1] / 2 + kernel_header["CRPIX2"] = kernel.shape[0] / 2 + kernel_header["CRVAL1"] = 0.0 + kernel_header["CRVAL2"] = 0.0 + kernel = _cutout_kernel(kernel, fov_header, + kernel_header=kernel_header) + + kernel = kernel / kernel.sum() + return kernel + + @property + def strehl_ratio(self): + """Strehl ratio of the most recently generated kernel, if known.""" + for wavelength in self._kernels: + n_pix, pixel_scale = self._grid_parameters() + conn = self._make_connection(wavelength, n_pix, pixel_scale) + path = self._cache_path(conn, wavelength) + if path.exists(): + with fits.open(path) as hdul: + if "STREHL" in hdul[1].header: + return hdul[1].header["STREHL"] + return None + + def __str__(self) -> str: + """Return str(self).""" + msg = (f"{self.__class__.__name__}: \"{self.display_name}\"\n" + f"- instrument: {self.meta['instrument']}\n" + f"- cache_dir: {self.meta['cache_dir']}") + return msg diff --git a/scopesim/tests/tests_effects/test_TipTopPSF.py b/scopesim/tests/tests_effects/test_TipTopPSF.py new file mode 100644 index 000000000..aee2cb52f --- /dev/null +++ b/scopesim/tests/tests_effects/test_TipTopPSF.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +"""Tests for the TipTopPSF effect. + +The TipTop server is never contacted: the tiptop_ipy query function is +monkeypatched to return a synthetic Gaussian PSF. +""" + +import numpy as np +import pytest +from astropy.io import fits + +from scopesim.effects import TipTopPSF +from scopesim.effects.psfs import tiptop as tiptop_module + + +PIXEL_SCALE = 0.004 # [arcsec] +N_PIX = 128 + + +class FakeResult: + """Mimics tiptop_ipy.TipTopResult for a single on-axis PSF.""" + + def __init__(self, n_pix): + x = np.arange(n_pix) - n_pix / 2 + 0.5 + xx, yy = np.meshgrid(x, x) + self.psf = np.exp(-(xx**2 + yy**2) / (2 * 3.0**2))[None, :, :] + self.strehl = np.array([0.42]) + self.fwhm = np.array([12.3]) + + +@pytest.fixture(name="mock_server") +def fixture_mock_server(monkeypatch): + """Replace the server query with a local Gaussian PSF factory.""" + calls = [] + + def fake_generate(self, conn, path, wavelength, n_pix, pixel_scale): + calls.append(wavelength) + result = FakeResult(n_pix) + kernel = result.psf[0] / result.psf[0].sum() + hdu = fits.ImageHDU(kernel.astype(np.float32)) + hdu.header["WAVE0"] = wavelength + hdu.header["CDELT1"] = pixel_scale + hdu.header["CDELT2"] = pixel_scale + hdu.header["CUNIT1"] = "arcsec" + hdu.header["CUNIT2"] = "arcsec" + hdu.header["STREHL"] = float(result.strehl[0]) + path.parent.mkdir(parents=True, exist_ok=True) + fits.HDUList([fits.PrimaryHDU(), hdu]).writeto(path, overwrite=True) + + monkeypatch.setattr(TipTopPSF, "_generate_psf_file", fake_generate) + return calls + + +def make_psf(tmp_path, **kwargs): + params = { + "instrument": "MAVIS", + "wavelength": 0.55, + "fov_pix": N_PIX, + "pixel_scale": PIXEL_SCALE, + "cache_dir": str(tmp_path / "cache"), + } + params.update(kwargs) + return TipTopPSF(**params) + + +class TestInit: + def test_initialises_without_server_contact(self, tmp_path): + psf = make_psf(tmp_path) + assert isinstance(psf, TipTopPSF) + assert not (tmp_path / "cache").exists() + + def test_throws_without_instrument_or_ini(self): + with pytest.raises(ValueError): + TipTopPSF(wavelength=0.55) + + def test_clips_fov_to_max(self, tmp_path, mock_server): + psf = make_psf(tmp_path, fov_pix=4096) + n_pix, _ = psf._grid_parameters() + assert n_pix == tiptop_module.MAX_PSF_SIDE + + +class TestGetKernel: + def test_returns_normalised_kernel(self, tmp_path, mock_server): + psf = make_psf(tmp_path) + kernel = psf.get_kernel(PIXEL_SCALE) + assert kernel.sum() == pytest.approx(1, rel=1e-6) + assert kernel.shape == (N_PIX, N_PIX) + + def test_generates_only_once_per_wavelength(self, tmp_path, mock_server): + psf = make_psf(tmp_path) + psf.get_kernel(PIXEL_SCALE) + psf.get_kernel(PIXEL_SCALE) + assert len(mock_server) == 1 + + def test_second_instance_uses_disk_cache(self, tmp_path, mock_server): + psf1 = make_psf(tmp_path) + kernel1 = psf1.get_kernel(PIXEL_SCALE) + psf2 = make_psf(tmp_path) + kernel2 = psf2.get_kernel(PIXEL_SCALE) + assert len(mock_server) == 1 + assert np.allclose(kernel1, kernel2) + + def test_rescales_to_fov_pixel_scale(self, tmp_path, mock_server): + psf = make_psf(tmp_path) + kernel = psf.get_kernel(2 * PIXEL_SCALE) + assert kernel.shape[0] == pytest.approx(N_PIX / 2, abs=1) + assert kernel.sum() == pytest.approx(1, rel=1e-6) + + def test_strehl_ratio_from_cache(self, tmp_path, mock_server): + psf = make_psf(tmp_path) + psf.get_kernel(PIXEL_SCALE) + assert psf.strehl_ratio == pytest.approx(0.42) + + +class TestWaveDict: + def test_filter_name_resolves_wavelength(self, tmp_path, mock_server): + psf = make_psf(tmp_path, wavelength=None, + wave_dict={"V": 0.545, "R": 0.641}, + filter_name="V") + psf.get_kernel(PIXEL_SCALE) + assert mock_server == [0.545] + + def test_unknown_filter_raises(self, tmp_path, mock_server): + psf = make_psf(tmp_path, wavelength=None, + wave_dict={"V": 0.545}, filter_name="K") + with pytest.raises(KeyError): + psf.get_kernel(PIXEL_SCALE) + + def test_one_call_per_filter(self, tmp_path, mock_server): + for filter_name in ("V", "R", "V", "R"): + psf = make_psf(tmp_path, wavelength=None, + wave_dict={"V": 0.545, "R": 0.641}, + filter_name=filter_name) + psf.get_kernel(PIXEL_SCALE) + assert sorted(mock_server) == [0.545, 0.641] + + +class TestSvoFilterResolution: + """Filter names without a wave_dict resolve through the SVO service. + + The SVO lookups are monkeypatched: no network access. + """ + + @pytest.fixture(name="mock_svo", autouse=True) + def fixture_mock_svo(self, monkeypatch): + import astropy.units as u + from scopesim.effects import ter_curves_utils as tu + + def fake_eff_wave(filter_name): + assert filter_name in tu.FILTER_DEFAULTS + return 2.16 * u.um + + def fake_download(filter_name, return_style="synphot"): + wave = np.array([3.5, 3.8, 4.1]) * u.um + trans = np.array([0.0, 1.0, 0.0]) * u.dimensionless_unscaled + return wave, trans + + monkeypatch.setattr(tu, "get_filter_effective_wavelength", + fake_eff_wave) + monkeypatch.setattr(tu, "download_svo_filter", fake_download) + + def test_generic_band_name(self, tmp_path, mock_server): + psf = make_psf(tmp_path, instrument="MICADO_SCAO", + wavelength=None, filter_name="Ks") + psf.get_kernel(PIXEL_SCALE) + assert mock_server == [pytest.approx(2.16)] + + def test_svo_identifier(self, tmp_path, mock_server): + psf = make_psf(tmp_path, instrument="METIS", + wavelength=None, filter_name="Paranal/METIS.Lp") + psf.get_kernel(PIXEL_SCALE) + assert mock_server == [pytest.approx(3.8)] + + def test_wavelength_string_resolves_as_filter(self, tmp_path, + mock_server): + psf = make_psf(tmp_path, instrument="MORFEO", wavelength="Ks") + psf.get_kernel(PIXEL_SCALE) + assert mock_server == [pytest.approx(2.16)] + + def test_unresolvable_name_raises_with_guidance(self, tmp_path, + mock_server): + psf = make_psf(tmp_path, instrument="METIS", + wavelength=None, filter_name="Lp") + with pytest.raises(KeyError, match="wave_dict"): + psf.get_kernel(PIXEL_SCALE) + + def test_wave_dict_beats_svo(self, tmp_path, mock_server): + psf = make_psf(tmp_path, instrument="METIS", wavelength=None, + wave_dict={"Lp": 3.79}, filter_name="Lp") + psf.get_kernel(PIXEL_SCALE) + assert mock_server == [pytest.approx(3.79)] + + +class TestOverrides: + """Overrides can set, remove keys, and remove whole sections. + + Only the connection object is built -- no server contact. + """ + + def test_set_and_remove(self, tmp_path): + psf = make_psf(tmp_path, instrument="MORFEO", overrides={ + "telescope": {"glFocusOnNGS": False, "extraErrorLoNm": None}, + "sources_Focus": None, + "sensor_Focus": None, + }) + conn = psf._make_connection(2.16, 512, PIXEL_SCALE) + assert conn["telescope", "glFocusOnNGS"] is False + assert "extraErrorLoNm" not in conn["telescope"] + assert "sources_Focus" not in conn.sections + assert "sensor_Focus" not in conn.sections + + def test_overrides_change_the_cache_key(self, tmp_path): + psf1 = make_psf(tmp_path, instrument="MORFEO") + psf2 = make_psf(tmp_path, instrument="MORFEO", + overrides={"telescope": {"ZenithAngle": 0.0}}) + conn1 = psf1._make_connection(2.16, 512, PIXEL_SCALE) + conn2 = psf2._make_connection(2.16, 512, PIXEL_SCALE) + assert psf1._cache_path(conn1, 2.16) != psf2._cache_path(conn2, 2.16)