diff --git a/METIS/METIS_IMG_LM.yaml b/METIS/METIS_IMG_LM.yaml index 0a3ff8da..a8679bf6 100644 --- a/METIS/METIS_IMG_LM.yaml +++ b/METIS/METIS_IMG_LM.yaml @@ -79,6 +79,9 @@ effects: class: FieldConstantPSF kwargs: filename: "!OBS.psf_file" + psf_name: "!OBS.psf_name" + psf_path: "METIS_psfs/" + filename_format: "METIS_psfs/PSF_{}.fits" wave_key: "WAVELENG" bkg_width: -1 diff --git a/METIS/METIS_IMG_N.yaml b/METIS/METIS_IMG_N.yaml index 59cc2534..7adc500d 100644 --- a/METIS/METIS_IMG_N.yaml +++ b/METIS/METIS_IMG_N.yaml @@ -72,6 +72,9 @@ effects: class: FieldConstantPSF kwargs: filename: "!OBS.psf_file" + psf_name: "!OBS.psf_name" + psf_path: "METIS_psfs/" + filename_format: "METIS_psfs/PSF_{}.fits" wave_key: "WAVELENG" bkg_width: -1 diff --git a/METIS/METIS_LMS.yaml b/METIS/METIS_LMS.yaml index 3623d6f4..6a3c7641 100644 --- a/METIS/METIS_LMS.yaml +++ b/METIS/METIS_LMS.yaml @@ -42,6 +42,9 @@ effects: class: FieldConstantPSF kwargs: filename: "!OBS.psf_file" + psf_name: "!OBS.psf_name" + psf_path: "METIS_psfs/" + filename_format: "METIS_psfs/PSF_{}.fits" wave_key: "WAVELENG" bkg_width: -1 diff --git a/METIS/METIS_WCU.yaml b/METIS/METIS_WCU.yaml index e5d367fe..870e9356 100644 --- a/METIS/METIS_WCU.yaml +++ b/METIS/METIS_WCU.yaml @@ -36,7 +36,8 @@ effects: pupil_masks: names: [APP-LMS, APP-LM, CLS-LMS, CLS-LM, CLS-N, PPS-LMS, PPS-LM, PPS-N, PPS-CFO2, RLS-LMS, RLS-LM, SPM-LMS, SPM-LM, SPM-N, open] transmissions: [0.6098, 0.6312, 0.5879, 0.6073, 0.5795, 0.7342, 0.7509, 0.7429, 0.6170, 0.4362, 0.4476, 0.6098, 0.6312, 0.6076, 0.8201] - current_mask: "open" + current_mask: "!OBS.pupil_mask" + message: "\nPupil mask has been changed; don't forget to update the PSF:\n\t metis['psf'].update(pupil_mask=\"%s\")" - name: wcu_fits_keywords description: FITS keywords specific to the WCU diff --git a/METIS/code/reformat_metis_psfs.py b/METIS/code/reformat_metis_psfs.py new file mode 100644 index 00000000..ccf9dc8b --- /dev/null +++ b/METIS/code/reformat_metis_psfs.py @@ -0,0 +1,74 @@ +"""Reformat RvB's PSF cubes for use in scopesim + +- scopesim uses multiextension fits files with a single PSF image per extension +- CUNIT3 "micrometer" replaced by "um" + +Author: Oliver Czoske +Date: 2025-11-24 +""" +import sys +from pathlib import Path +import numpy as np +from astropy.io import fits +from astropy.wcs import WCS + +def reformat_file(infile): + """Convert the file `infile`""" + with fits.open(infile) as hdul: + nwave = hdul[0].data.shape[0] + crval = hdul[0].header["CRVAL3"] + cdelt = hdul[0].header["CDELT3"] + # Fix some header keywords + if hdul[0].header["CUNIT3"] == "micrometer": + hdul[0].header["CUNIT3"] = "um" + else: + print("What?", hdul[0].header["CUNIT3"]) + if "CTYPE1" not in hdul[0].header or hdul[0].header["CTYPE1"] == "": + hdul[0].header["CTYPE1"] = "LINEAR" + hdul[0].header["CTYPE2"] = "LINEAR" + + wavelengths = np.exp(crval + cdelt * np.arange(1, nwave+1)) + + wcs = WCS(hdul[0].header).sub(2) + phdu = fits.PrimaryHDU() + phdu.header["FILETYPE"] = "Point Spread Functions" + phdu.header["AUTHOR"] = "Roy van Boekel, Oliver Czoske" + phdu.header["DATE"] = "2025-11-24" + phdu.header["ORIGDATE"] = "2025-10-30" + phdu.header["ORIGFILE"] = (Path(infile).name, "original file name") + phdu.header["COLDSTOP"] = (hdul[0].header["COLDSTOP"], "name cold pupil mask") + if "WCU" in infile: + phdu.header["ELTMASK"] = False + wcustr = "_WCU" + else: + phdu.header["ELTMASK"] = True + wcustr = "" + phdu.header["PUPILPS"] = (hdul[0].header["PUPILPS"], "[mm] pupil model pixel size") + phdu.header["NPIXFFT"] = (hdul[0].header["NPIXFFT"], "number of pixels used in FFT") + phdu.header["PUPILPS"] = (hdul[0].header["OSAMP"], "over-sampling factor") + phdu.header["PSIZEPUP"] = (hdul[0].header["PSIZEPUP"], "[mm] pixel size in pupil image") + outhdul = fits.HDUList([phdu]) + if "IMG" in infile: + substr = "IMG" + elif "LMS" in infile: + substr = "LMS" + else: + raise ValueError("Unknown subsystem") + outfile = f"psfs/PSF_{substr}_{phdu.header['COLDSTOP']}{wcustr}.fits" + for i in range(nwave): + psfimg = hdul[0].data[i,] + hdr = wcs.to_header() + hdr['WAVELENG'] = (wavelengths[i], "[um] Wavelength of PSF image") + hdr['WAVEUNIT'] = 'um' + hdr['EXTNAME'] = f"PSF_{wavelengths[i]:.2f}um" + + hdu = fits.ImageHDU(data=psfimg, header=hdr) + outhdul.append(hdu) + outhdul.writeto(outfile, overwrite=True) + + +if __name__ == "__main__": + infilelist = sys.argv[1:] + + for thefile in infilelist: + reformat_file(thefile) diff --git a/METIS/default.yaml b/METIS/default.yaml index 610596a7..ecba7e1e 100644 --- a/METIS/default.yaml +++ b/METIS/default.yaml @@ -63,6 +63,7 @@ properties: ra: 0.0 dec: 0.0 # These defaults are overruled by the individual modes: + psf_name: none filter_name: open nd_filter_name: open @@ -257,7 +258,9 @@ mode_yamls: - METIS_DET_IMG_LM.yaml properties: ins_mode: IMG_LM # use as FITS header keyword - psf_file: PSF_SCAO_9mag_06seeing.fits # REPLACE! + pupil_mask: PPS-LM + psf_file: none + psf_name: "!OBS.pupil_mask" filter_name: Lp nd_filter_name: open slit: false @@ -276,7 +279,9 @@ mode_yamls: - METIS_DET_IMG_N_GeoSnap.yaml properties: ins_mode: IMG_N # use as FITS header keyword - psf_file: PSF_SCAO_9mag_06seeing.fits # REPLACE! + pupil_mask: PPS-N + psf_file: none + psf_name: "!OBS.pupil_mask" filter_name: N2 nd_filter_name: open slit: false @@ -298,7 +303,9 @@ mode_yamls: - METIS_DET_IMG_LM.yaml properties: ins_mode: LSS_L # use as FITS header keyword - psf_file: PSF_LM_9mag_06seeing.fits # REPLACE! + pupil_mask: PPS-CFO2 + psf_file: none + psf_name: "!OBS.pupil_mask" trace_file: TRACE_LSS_L.fits efficiency_file: TER_grating_L.fits slit: C-38_1 @@ -322,7 +329,9 @@ mode_yamls: - METIS_DET_IMG_LM.yaml properties: ins_mode: LSS_M # use as FITS header keyword - psf_file: PSF_LM_9mag_06seeing.fits # REPLACE! + pupil_mask: PPS-CFO2 + psf_file: none + psf_name: "!OBS.pupil_mask" trace_file: TRACE_LSS_M.fits efficiency_file: TER_grating_M.fits slit: C-38_1 @@ -346,7 +355,9 @@ mode_yamls: - METIS_DET_IMG_N_GeoSnap.yaml properties: ins_mode: LSS_N # use as FITS header keyword - psf_file: PSF_N_9mag_06seeing.fits # REPLACE! + pupil_mask: PPS-CFO2 + psf_file: none + psf_name: "!OBS.pupil_mask" trace_file: TRACE_LSS_N.fits efficiency_file: TER_grating_N.fits slit: D-57_1 @@ -371,7 +382,9 @@ mode_yamls: - METIS_DET_IFU.yaml properties: ins_mode: LMS # use as FITS header keyword - psf_file: PSF_LM_9mag_06seeing.fits # REPLACE! + pupil_mask: PPS-LMS + psf_file: none + psf_name: "!OBS.pupil_mask" slit: false adc: false trace_file: TRACE_LMS.fits @@ -390,6 +403,9 @@ mode_yamls: - METIS_DET_IFU.yaml properties: ins_mode: LMS # use as FITS header keyword + pupil_mask: PPS-LMS + psf_file: none + psf_name: "!OBS.pupil_mask" slit: false adc: false detector_readout_mode: slow diff --git a/METIS/docs/example_notebooks/demos/demo_metis_wcu_psfs.ipynb b/METIS/docs/example_notebooks/demos/demo_metis_wcu_psfs.ipynb new file mode 100644 index 00000000..0f1c3d5a --- /dev/null +++ b/METIS/docs/example_notebooks/demos/demo_metis_wcu_psfs.ipynb @@ -0,0 +1,311 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8d5514ce-43fa-4bd2-a28e-c3a9842e4165", + "metadata": {}, + "source": [ + "This notebook is targeted at the AIT team who use Scopesim to simulate observations with the warm calibration unit (WCU). It demonstrates how to select point-spread functions that correspond to a pupil-mask that is inserted in the optical path. This is an interplay between two effects in ScopeSim, an instance of `PupilMaskWheel` and an instance of `FieldConstantPSF`. Note that the instrument configuration for ScopeSim currently has a single pupil-mask wheel which is placed in the WCU. This differs from the real instrument, which will have a pupil-mask wheel in both imager arms as well as one in the common fore optics (CFO), and means that the functionality presented here can only be used with the WCU modes. The sky modes still use the SCAO PSFs as they always did. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ae5f7199-2437-424f-97d4-4b1e61a657cb", + "metadata": {}, + "outputs": [], + "source": [ + "import scopesim as sim" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58b77812-09c7-4f64-879c-1f1bd12f144e", + "metadata": {}, + "outputs": [], + "source": [ + "# Edit this path if you have a custom install directory, otherwise comment it out.\n", + "sim.link_irdb(\"../../../../\")\n", + "\n", + "# If you haven't got the instrument packages yet, uncomment the following line.\n", + "# sim.download_packages([\"METIS\", \"ELT\", \"Armazones\"])" + ] + }, + { + "cell_type": "markdown", + "id": "2f3ae712-7d2d-40a7-a9e5-ce2cdd1c8244", + "metadata": {}, + "source": [ + "By default Scopesim loads one of the PPS masks with their corresponding PSF. The PSF files are stored on the Astar server in Vienna; they are automatically downloaded if they are not available locally (either in the download cache folder or in the subdirectory `./METIS_psfs`). For imaging in the LM band, do" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea0cf94f-c640-4cac-989a-42139ee847c4", + "metadata": {}, + "outputs": [], + "source": [ + "cmd = sim.UserCommands(use_instrument=\"METIS\", set_modes=[\"wcu_img_lm\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53ab2313-8e70-4704-abe2-9b08d0b84a05", + "metadata": {}, + "outputs": [], + "source": [ + "metis = sim.OpticalTrain(cmd)" + ] + }, + { + "cell_type": "markdown", + "id": "bd436e2a-d681-4d31-bb5f-c7d3e1a11085", + "metadata": {}, + "source": [ + "We can inspect the two effects to check that they agree and to find out the location of the psf file that is used." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4f5fa778-2bbd-4902-9f8f-1747f7796c2b", + "metadata": {}, + "outputs": [], + "source": [ + "print(metis['pupil_masks'])\n", + "print(metis['psf'])" + ] + }, + { + "cell_type": "markdown", + "id": "79c3328c-7e31-406e-b8e9-7592347fee85", + "metadata": {}, + "source": [ + "Change the WCU focal-plane mask to a pinhole to check that the correct PSF is used." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf70057b-a7f0-428a-a13e-299480a04252", + "metadata": {}, + "outputs": [], + "source": [ + "metis['wcu_source'].set_fpmask(\"pinhole_lm\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "afd70da3-9969-45e1-b3e0-91d6b946fbe1", + "metadata": {}, + "outputs": [], + "source": [ + "metis.observe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "beed9131-f049-4b52-8838-76497337022d", + "metadata": {}, + "outputs": [], + "source": [ + "from matplotlib import pyplot as plt\n", + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "260599fb-d096-4a38-afc0-3ea3ace9cf7c", + "metadata": {}, + "outputs": [], + "source": [ + "implane = metis.image_planes[0].data\n", + "plt.imshow(implane - np.median(implane), norm='log', origin='lower')\n", + "plt.xlim(900, 1150)\n", + "plt.ylim(900, 1150);" + ] + }, + { + "cell_type": "markdown", + "id": "b660de0c-2155-4376-a545-a9ca8aff9589", + "metadata": {}, + "source": [ + "## Changing the pupil mask\n", + "The pupil mask can be changed using the `change_mask` method of the pupil mask effect. Unfortunately, the PSF is not changed automatically due to a short-coming in ScopeSim (effects do not talk to each other...). ScopeSim does tell you, however, what you need to do:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "422cb6c4-6c66-4710-8b19-14c2715cb205", + "metadata": {}, + "outputs": [], + "source": [ + "metis['pupil_masks'].change_mask(\"APP-LM\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f491a53-a9fb-4bfa-b286-fe629358de8c", + "metadata": {}, + "outputs": [], + "source": [ + "metis['psf'].update(pupil_mask=\"APP-LM\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "99f68e8b-ab60-497f-b6d3-ceffa59044ec", + "metadata": {}, + "outputs": [], + "source": [ + "print(metis['pupil_masks'])\n", + "print(metis['psf'])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25d678f0-b4b6-44d5-b20a-0ed3e11b885f", + "metadata": {}, + "outputs": [], + "source": [ + "metis.observe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "df24914f-94c7-494f-8482-850671078ef6", + "metadata": {}, + "outputs": [], + "source": [ + "implane = metis.image_planes[0].data\n", + "plt.imshow(implane - np.median(implane), norm='log', origin='lower')\n", + "plt.xlim(900, 1150)\n", + "plt.ylim(900, 1150);" + ] + }, + { + "cell_type": "markdown", + "id": "355fe981-8c52-4c2e-b820-7058c2ed61df", + "metadata": {}, + "source": [ + "For each of the PPS PSFs there is a `WCU` variant, which does not include the ELT spiders:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af23ffe2-7cc0-4118-96d3-c4394d7e3cbb", + "metadata": {}, + "outputs": [], + "source": [ + "metis['pupil_masks'].change_mask(\"PPS-LM\")\n", + "metis['psf'].update(pupil_mask=\"PPS-LM_WCU\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a10db4e3-389d-44e0-8bb6-25140a1bcbd1", + "metadata": {}, + "outputs": [], + "source": [ + "metis.observe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a51fd43d-c331-4852-b8ad-9b0987e23cc1", + "metadata": {}, + "outputs": [], + "source": [ + "implane = metis.image_planes[0].data\n", + "plt.imshow(implane - np.median(implane), norm='log', origin='lower')\n", + "plt.xlim(900, 1150)\n", + "plt.ylim(900, 1150);" + ] + }, + { + "cell_type": "markdown", + "id": "6e5c474e-2455-4cae-baec-8ee1bf2f1569", + "metadata": {}, + "source": [ + "## A problem with spectroscopy\n", + "The PSF files (as provided by Roy van Boekel) have many extensions with the PSF computed at various wavelengths. This has uncovered a problem in the spectroscopic modes which results in a number of dark lines and pixels at wavelengths where Scopesim transitions from one PSF extension to the next (the segmentation in wavelength is evident by the ample output in the simulation below). This will hopefully be fixed soon." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e79a958-2c6e-4b08-85f2-95cd3b1b5db3", + "metadata": {}, + "outputs": [], + "source": [ + "cmd = sim.UserCommands(use_instrument=\"METIS\", set_modes=[\"wcu_lss_n\"])\n", + "metis = sim.OpticalTrain(cmd)\n", + "metis['wcu_source'].set_fpmask(\"pinhole_n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5eba59f2-216e-4954-9b9c-d265d208758e", + "metadata": {}, + "outputs": [], + "source": [ + "metis.observe()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d2e363dc-8eac-472e-9c13-8a169cf8532c", + "metadata": {}, + "outputs": [], + "source": [ + "implane = metis.image_planes[0].data\n", + "plt.imshow(implane, origin='lower');" + ] + }, + { + "cell_type": "markdown", + "id": "750b71d3-2438-4fad-b221-305386f7af4c", + "metadata": {}, + "source": [ + "Note that the trace of the pinhole is not visible in this figure due to the high background. Subtracting a WCU_OFF simulation (after setting `metis['wcu_source'].set_bb_aperture(0.)`) would make it visible." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/METIS/tests/test_psfs.py b/METIS/tests/test_psfs.py new file mode 100644 index 00000000..26ee0039 --- /dev/null +++ b/METIS/tests/test_psfs.py @@ -0,0 +1,28 @@ +"""Tests PSFs in WCU modes + +These tests check that the OpticalTrains have the correct PSFs +for the default pupil masks in the WCU modes +""" +# pylint: disable=missing-class-docstring +# pylint: disable=missing-function-docstring + +import os +import pytest + +import scopesim +PKGS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +scopesim.rc.__config__["!SIM.file.local_packages_path"] = PKGS_DIR + +class TestModes: + @pytest.mark.parametrize("themode, themask", + [("wcu_img_lm", "PPS-LM"), + ("wcu_img_n", "PPS-N"), + ("wcu_lss_l", "PPS-CFO2"), + ("wcu_lss_m", "PPS-CFO2"), + ("wcu_lss_n", "PPS-CFO2"), + ("wcu_lms", "PPS-LMS")]) + def test_wcu_modes_use_correct_default_psf(self, themode, themask): + cmd = scopesim.UserCommands(use_instrument="METIS", + set_modes=[themode]) + metis = scopesim.OpticalTrain(cmd) + assert metis['psf'].meta['psf_name'] == themask