{ "cells": [ { "cell_type": "markdown", "id": "fc6520b1", "metadata": {}, "source": [ "# Instruments\n", "\n", "While `phoptic` was primarily designed for use with the OPTICAM-MX instrument, it includes an interface for compatibility with other instruments. In this notebook, we will explore this interface.\n", "\n", "## Generating observations from a new instrument\n", "\n", "First, let's generate some observations that are not compatible with the instruments currently supported by `phoptic`. For the data, we'll generate a 1 s $U$-band exposure from an instrument that has a gain of 100 electrons/ADU. We'll set the time of the observation to 00:00 on January 1st, 2026, with a pointing of 0 RA, 0 DEC:" ] }, { "cell_type": "code", "execution_count": null, "id": "fa88898c", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:25.793398Z", "iopub.status.busy": "2026-01-23T11:20:25.793208Z", "iopub.status.idle": "2026-01-23T11:20:26.371250Z", "shell.execute_reply": "2026-01-23T11:20:26.370877Z" } }, "outputs": [], "source": [ "from pathlib import Path\n", "\n", "from photutils.datasets import make_100gaussians_image\n", "from astropy.io import fits\n", "import numpy as np\n", "\n", "\n", "out_dir = Path('out')\n", "\n", "image = make_100gaussians_image()\n", "\n", "hdu = fits.PrimaryHDU(image) # image data\n", "\n", "hdu.header['FILTER'] = 'U' # the observation filter\n", "hdu.header['BINNING'] = '1 1' # the image binning (i.e., 1x1)\n", "hdu.header['GAIN'] = 100. # the detector gain in adu/photon\n", "hdu.header['EXPTIME'] = 1. # the exposure time in seconds\n", "hdu.header['RA'] = '0' # the RA of the image in degrees\n", "hdu.header['DEC'] = '0' # the DEC of the image in degrees\n", "hdu.header[\"DATE-OBS\"] = f\"2026-01-01T00:00:00.000\" # observation timestamp\n", "hdu.header['INSTRUME'] = 'SYNTHETICAM' # name of the instrument\n", "hdu.header['RDNOISE'] = 0. # the instrument's read noise in electrons/pixel\n", "hdu.header['AIRMASS'] = 1. # the observation's airmass\n", "\n", "save_path = out_dir / 'data' / 'simple' # directory to which image will be saved\n", "\n", "# create save directory if it does not already exist\n", "if not save_path.is_dir():\n", " save_path.mkdir(parents=True)\n", "\n", "# save image\n", "file_path = save_path / 'image.fits.gz'\n", "hdu.writeto(\n", " file_path,\n", " overwrite=True,\n", " )" ] }, { "cell_type": "markdown", "id": "3695f8aa", "metadata": {}, "source": [ "Let's take a look at one of these images:" ] }, { "cell_type": "code", "execution_count": null, "id": "ed49c0f3", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:26.372624Z", "iopub.status.busy": "2026-01-23T11:20:26.372446Z", "iopub.status.idle": "2026-01-23T11:20:26.702940Z", "shell.execute_reply": "2026-01-23T11:20:26.702554Z" } }, "outputs": [], "source": [ "from astropy.visualization import simple_norm\n", "from matplotlib import pyplot as plt\n", "\n", "\n", "# get image data from file\n", "with fits.open(file_path) as hdul:\n", " data = np.asarray(hdul[0].data, dtype=np.float64)\n", "\n", "fig, ax = plt.subplots()\n", "\n", "im = ax.imshow(\n", " data,\n", " norm=simple_norm(data, stretch='log'),\n", " cmap='Greys',\n", " origin='lower',\n", " )\n", "\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "ac17ddc2", "metadata": {}, "source": [ "The image looks good.\n", "\n", "Let's see what happens when we try to use the OPTICAM-MX instrument to reduce these data. Before performing reduction with a particular instrument, it's a good idea to call the instrument's `run_checks()` method. This method takes a path to an image and runs a series of checks to ensure that the data are consistent with those produced by the instrument:" ] }, { "cell_type": "code", "execution_count": null, "id": "130e118a", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:26.704177Z", "iopub.status.busy": "2026-01-23T11:20:26.703905Z", "iopub.status.idle": "2026-01-23T11:20:28.432752Z", "shell.execute_reply": "2026-01-23T11:20:28.432439Z" } }, "outputs": [], "source": [ "import phoptic\n", "\n", "\n", "instrument = phoptic.OPTICAM_MX()\n", "instrument.run_checks(file_path)" ] }, { "cell_type": "markdown", "id": "3981e974", "metadata": {}, "source": [ "In this case, we can see that we have several errors, and one warning:\n", "- The first error is caused by `OPTICAM_MX` using a different header keyword for the image's exposure time. Typically, the keyword \"EXPTIME\" is used to identify the image's exposure time; this is the case for these data. However, `OPTICAM_MX` uses the \"EXPOSURE\" keyword instead.\n", "- The second error is again caused by a header keyword mismatch, this time for the timestamp keyword. Typically, the keyword \"DATE-OBS\" is used to identify the image's timestamp; this is the case for these data. However, `OPTICAM_MX` uses the \"UT\" keyword instead.\n", "- The third error is due to the data using the $U$-band filter, which is not supported by `OPTICAM_MX` and therefore has no corresponding pixel scale.\n", "- The final error is related to the second error: the Modified Julian Date (MJD) of the image cannot be inferred because of the timestamp keyword mismatch between these data and `OPTICAM_MX`.\n", "- The warning is due to these data not containing a \"DARKCURR\" keyword in the header. `phoptic` can use the dark current value (represented by the header keyword \"DARKCURR\") to infer and subtract the dark noise from the image. If this keyword does not exist, then dark noise can be removed using a `DarkNoiseCorrector` instance instead (see the [corrections tutorial](applying_corrections.ipynb) for more details). This warning can therefore be ignored in this case.\n", "\n", "There are also a number of other issues with using `OPTICAM_MX` that didn't result in any errors being raised. Let's look at `OPTICAM_MX` to see what the instrument represents:" ] }, { "cell_type": "code", "execution_count": null, "id": "8681a461", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.433902Z", "iopub.status.busy": "2026-01-23T11:20:28.433667Z", "iopub.status.idle": "2026-01-23T11:20:28.435916Z", "shell.execute_reply": "2026-01-23T11:20:28.435665Z" } }, "outputs": [], "source": [ "print(instrument)" ] }, { "cell_type": "markdown", "id": "9babaf8c", "metadata": {}, "source": [ "We can see that `OPTICAM_MX` has a predefined position, a dictionary of pixel scales for each camera, a read noise value, and a number of header keywords. Many of these parameters are likely not be representative of our new instrument. Notably, location and read noise values. The location of the instrument is used when applying Barycentric corrections, and the read noise is used to calculate the total photometric error. Our new instrument therefore needs to redefine many of these parameters for accurate results.\n", "\n", "There are two ways to define a new instrument. The simplest is to generate a configuration file template, edit the necessary values, and then use that configuration file to define a new instrument. This works well provided the instrument adheres to the conventional FITS header keywords (discussed in more detail below) and represents values in FITS format. If this is not the case, then you will need to define your instrument as a class that inherits from the `phoptic.Instrument` base class.\n", "\n", "## Defining an instrument from a configuration file\n", "\n", "In this case, our data use conventional FITS header keywords and all values are given in FITS format. Explicitly:\n", "- The corresponding filter is given by the \"FILTER\" keyword.\n", "- The image binning is given by the \"BINNING\" keyword.\n", "- The detector's gain value is given by the \"GAIN\" keyword in electrons/ADU.\n", "- The corresponding exposure time is given by the \"EXPTIME\" keyword in seconds.\n", "- The corresponding RA is given by the \"RA\" keyword.\n", "- The corresponding DEC is given by the \"DEC\" keyword.\n", "- The corresponding timestamp is given by the \"DATE-OBS\" keyword in FITS format (YYYY-MM-DDTHH:MM:SS.sss).\n", "\n", "If any of these values were given by different keywords or were in different units/formats, then it would be necessary to define the instrument using a custom class instead of from configuration file.\n", "\n", "To create a configuration file, it is suggested to generate and then edit a configuration template. Configuration templates can be generated using the `generate_instrument_json_template()` function. This function takes a directory path and writes an `instrument_template.json` file to that directory:" ] }, { "cell_type": "code", "execution_count": null, "id": "eca47b77", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.436675Z", "iopub.status.busy": "2026-01-23T11:20:28.436552Z", "iopub.status.idle": "2026-01-23T11:20:28.438361Z", "shell.execute_reply": "2026-01-23T11:20:28.438103Z" } }, "outputs": [], "source": [ "phoptic.generate_instrument_json_template(out_dir)" ] }, { "cell_type": "markdown", "id": "d61aa57a", "metadata": {}, "source": [ "Let's take a look at the contents of this template:" ] }, { "cell_type": "code", "execution_count": null, "id": "b4932189", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.439162Z", "iopub.status.busy": "2026-01-23T11:20:28.439057Z", "iopub.status.idle": "2026-01-23T11:20:28.441867Z", "shell.execute_reply": "2026-01-23T11:20:28.441582Z" } }, "outputs": [], "source": [ "import json\n", "\n", "\n", "with open(out_dir / 'instrument_template.json', 'r') as file:\n", " config_dict = json.load(file)\n", "\n", "for k, v in config_dict.items():\n", " print(k)\n", " print(v)\n", " print()" ] }, { "cell_type": "markdown", "id": "0bae2cb3", "metadata": {}, "source": [ "As we can see, the template lists a number of parameters that can be configured, including descriptions for each parameter. In practise, one would probably edit this template in their preferred text editor. For demonstrative purposes, however, I will edit the dictionary in this notebook.\n", "\n", "For this example, I'll ignore the location parameters (longitude, latitude, and height), as well as the read noise, since these values vary on an instrument-by-instrument basis. The only parameter that needs changing to prevent errors is `pixel_scales`, since the `exptime_kw` and `timestamp_kw` parameters of the template are already set to the conventional \"EXPTIME\" and \"DATE-OBS\" values, respectively:" ] }, { "cell_type": "code", "execution_count": null, "id": "89a606c9", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.442696Z", "iopub.status.busy": "2026-01-23T11:20:28.442550Z", "iopub.status.idle": "2026-01-23T11:20:28.444236Z", "shell.execute_reply": "2026-01-23T11:20:28.444013Z" } }, "outputs": [], "source": [ "config_dict['pixel_scales'] = {\n", " 'SYNTHETICAM': 1.0, # 1.0 arcsec/pixel\n", " }" ] }, { "cell_type": "markdown", "id": "42384d84", "metadata": {}, "source": [ "Let's save this edited config file under a new name:" ] }, { "cell_type": "code", "execution_count": null, "id": "f3cffb28", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.445073Z", "iopub.status.busy": "2026-01-23T11:20:28.444953Z", "iopub.status.idle": "2026-01-23T11:20:28.446903Z", "shell.execute_reply": "2026-01-23T11:20:28.446660Z" } }, "outputs": [], "source": [ "with open(out_dir / 'new_instrument_config.json', 'w') as file:\n", " json.dump(\n", " config_dict,\n", " file,\n", " indent=4,\n", " )" ] }, { "cell_type": "markdown", "id": "60a2848f", "metadata": {}, "source": [ "Now we can use this file to create our instrument using the `from_json()` method of `phoptic.Instrument`. This method takes either a path to a config JSON file, or a config can be passed directly:" ] }, { "cell_type": "code", "execution_count": null, "id": "eef74d7a", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.447745Z", "iopub.status.busy": "2026-01-23T11:20:28.447639Z", "iopub.status.idle": "2026-01-23T11:20:28.451020Z", "shell.execute_reply": "2026-01-23T11:20:28.450767Z" } }, "outputs": [], "source": [ "# using the saved config file\n", "new_instrument = phoptic.Instrument.from_json(file_path=out_dir / 'new_instrument_config.json')\n", "\n", "# using the config dictionary\n", "new_instrument_alt = phoptic.Instrument.from_json(config=config_dict)\n", "\n", "new_instrument == new_instrument_alt" ] }, { "cell_type": "code", "execution_count": null, "id": "f1216a82", "metadata": { "tags": [ "remove-cell" ] }, "outputs": [], "source": [ "assert new_instrument == new_instrument_alt" ] }, { "cell_type": "markdown", "id": "1a3f36ec", "metadata": {}, "source": [ "Let's take a look at our new instrument and check it's using the correct values:" ] }, { "cell_type": "code", "execution_count": null, "id": "0aa625a2", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.451858Z", "iopub.status.busy": "2026-01-23T11:20:28.451735Z", "iopub.status.idle": "2026-01-23T11:20:28.453696Z", "shell.execute_reply": "2026-01-23T11:20:28.453451Z" } }, "outputs": [], "source": [ "print(new_instrument)" ] }, { "cell_type": "markdown", "id": "a69d8972", "metadata": {}, "source": [ "Looks good!\n", "\n", "New let's run the instrument checks for this new instrument:" ] }, { "cell_type": "code", "execution_count": null, "id": "d09d402b", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.454517Z", "iopub.status.busy": "2026-01-23T11:20:28.454396Z", "iopub.status.idle": "2026-01-23T11:20:28.480060Z", "shell.execute_reply": "2026-01-23T11:20:28.479832Z" } }, "outputs": [], "source": [ "new_instrument.run_checks(file_path)" ] }, { "cell_type": "markdown", "id": "5624df97", "metadata": {}, "source": [ "No more errors! We still have a warning about the dark current keyword, but we can safely ignore this for now.\n", "\n", "As noted above, creating instruments from a configuration template works well provided the instrument follows the convetional FITS header structure. Now let's take a look at what to do if this is not the case:" ] }, { "cell_type": "code", "execution_count": null, "id": "5cc9d969", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.481270Z", "iopub.status.busy": "2026-01-23T11:20:28.481031Z", "iopub.status.idle": "2026-01-23T11:20:28.562542Z", "shell.execute_reply": "2026-01-23T11:20:28.562248Z" } }, "outputs": [], "source": [ "from astropy.time import Time\n", "\n", "image = make_100gaussians_image()\n", "\n", "hdu = fits.PrimaryHDU(image) # image data\n", "\n", "hdu.header['FILTER'] = 'I' # the observation filter\n", "hdu.header['BINNING'] = '2x2' # the image binning\n", "hdu.header['GAIN'] = 500. # the detector gain in adu/photon\n", "hdu.header['EXPOSURE'] = 1. # the exposure time in seconds\n", "hdu.header['RA'] = '12 30 49.4' # the RA of the image in degrees\n", "hdu.header['DEC'] = '+12 23 28.0' # the DEC of the image in degrees\n", "hdu.header['INSTRUME'] = 'SYNTHETICAM' # the instrument (synthetic camera)\n", "hdu.header['RDNOISE'] = 0. # the instrument's read noise in electrons/pixel\n", "hdu.header['AIRMASS'] = 1. # the observation's airmass\n", "\n", "\n", "fits_time = f\"2026-01-01T00:00:00.000\" # observation timestamp\n", "mjd_time = float(np.asarray(Time(fits_time, format='fits').mjd)) # convert time from FITS format to MJD\n", "hdu.header['DATE-OBS'] = mjd_time\n", "\n", "save_path = out_dir / 'data' / 'custom' # directory to which image will be saved\n", "\n", "# create save directory if it does not already exist\n", "if not save_path.is_dir():\n", " save_path.mkdir(parents=True)\n", "\n", "# save image\n", "new_file_path = save_path / 'image.fits.gz'\n", "hdu.writeto(\n", " new_file_path,\n", " overwrite=True,\n", " )" ] }, { "cell_type": "markdown", "id": "c436f9cc", "metadata": {}, "source": [ "These new data are very similar to the previous example, but replace the \"EXPTIME\" keyword with \"EXPOSURE\", and store the image's timestamp in MJD format instead of the conventional FITS format.\n", "\n", "Let's look at the header of this image:" ] }, { "cell_type": "code", "execution_count": null, "id": "7d25c66e", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.563625Z", "iopub.status.busy": "2026-01-23T11:20:28.563497Z", "iopub.status.idle": "2026-01-23T11:20:28.571624Z", "shell.execute_reply": "2026-01-23T11:20:28.571349Z" } }, "outputs": [], "source": [ "header = fits.getheader(new_file_path)\n", "print(repr(header))" ] }, { "cell_type": "markdown", "id": "beb2c96b", "metadata": {}, "source": [ "Predictably, these new data will fail with both `OPTICAM_MX` and our newly created instrument:" ] }, { "cell_type": "code", "execution_count": null, "id": "b3493c6c", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.572499Z", "iopub.status.busy": "2026-01-23T11:20:28.572377Z", "iopub.status.idle": "2026-01-23T11:20:28.597594Z", "shell.execute_reply": "2026-01-23T11:20:28.597332Z" } }, "outputs": [], "source": [ "instrument.run_checks(new_file_path)" ] }, { "cell_type": "code", "execution_count": null, "id": "f7ccbc3e", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.598471Z", "iopub.status.busy": "2026-01-23T11:20:28.598346Z", "iopub.status.idle": "2026-01-23T11:20:28.624266Z", "shell.execute_reply": "2026-01-23T11:20:28.623999Z" } }, "outputs": [], "source": [ "new_instrument.run_checks(new_file_path)" ] }, { "cell_type": "markdown", "id": "ebe22897", "metadata": {}, "source": [ "## Defining an instrument from the phoptic.Instrument base class\n", "\n", "To reduce these data, we cannot create a new instrument from a configuration file because the timestamps are not in FITS format. Instead, we need to define a custom class that inherits from `phoptic.Instrument` and implement a custom `get_mjd()` method:" ] }, { "cell_type": "code", "execution_count": null, "id": "a97b07bc", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.625220Z", "iopub.status.busy": "2026-01-23T11:20:28.625011Z", "iopub.status.idle": "2026-01-23T11:20:28.627177Z", "shell.execute_reply": "2026-01-23T11:20:28.626915Z" } }, "outputs": [], "source": [ "%%writefile out/custom_instrument.py\n", "\n", "import phoptic\n", "\n", "\n", "class CustomInstrument(phoptic.Instrument):\n", " \n", " def get_mjd(self, file=None, header=None):\n", " \n", " if header is None:\n", " header = file.get_header()\n", " \n", " return float(header[self.dateobs_kw])" ] }, { "cell_type": "markdown", "id": "90533bee", "metadata": {}, "source": [ "Due to compatibility issues, routines defined inside of IPython notebooks cannot be used by `multiprocessing` (used by `phoptic`). To overcome this issue, we have used the `%%writefile` magic command to write our `CustomInstrument` class to a temporary module called `custom_instrument.py` in our `out` directory. This is required in Python $\\geq$ 3.14.\n", "\n", "The `get_mjd()` method of an `Instrument` instance takes two inputs: `file` and `header`. `file` will take a [`phoptic.MEFSlice`](https://phoptic.readthedocs.io/en/latest/autoapi/phoptic/mef_slice/index.html#phoptic.mef_slice.MEFSlice) instance, while `header` will take an `astropy.io.fits.Header` instance. `phoptic`'s `MEFSlice` class is used internally to represent a single extension of a multi-extension FITS file (or the only extension of a standard FITS file). This allows `phoptic` to easily get the header of a FITS HDU by calling the `get_header()` method of `MEFSlice`. When called, `phoptic` will only define one of these parameters, depending on whether the image is already open or not, and so both parameters should assume default values (i.e., `None`). The value returned by `get_mjd()` should be the MJD of the image as a `float`.\n", "\n", "In this example, the MJD is given by the header, so it can be returned directly. If the header gives the timestamp in an alternative format, it may be convenient to use the [`astropy.time`](https://docs.astropy.org/en/stable/time/index.html) module to convert it to MJD.\n", "\n", "To instance this `CustomInstrument`, we can again generate and edit a configuration template:" ] }, { "cell_type": "code", "execution_count": null, "id": "428570b5", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.628029Z", "iopub.status.busy": "2026-01-23T11:20:28.627912Z", "iopub.status.idle": "2026-01-23T11:20:28.630904Z", "shell.execute_reply": "2026-01-23T11:20:28.630623Z" } }, "outputs": [], "source": [ "from out.custom_instrument import CustomInstrument\n", "\n", "with open(out_dir / 'instrument_template.json', 'r') as file:\n", " custom_config = json.load(file)\n", "\n", "custom_config['exptime_kw'] = 'EXPOSURE' # change EXPTIME keyword\n", "custom_config['pixel_scales'] = {\n", " 'SYNTHETICAM': 1.0, # 1.0 arcsec/pixel\n", " }\n", "\n", "custom_instrument = CustomInstrument.from_json(config=custom_config)\n", "\n", "print(custom_instrument)" ] }, { "cell_type": "code", "execution_count": null, "id": "178533e8", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.631862Z", "iopub.status.busy": "2026-01-23T11:20:28.631595Z", "iopub.status.idle": "2026-01-23T11:20:28.657601Z", "shell.execute_reply": "2026-01-23T11:20:28.657344Z" } }, "outputs": [], "source": [ "custom_instrument.run_checks(new_file_path)" ] }, { "cell_type": "markdown", "id": "ec75f029", "metadata": {}, "source": [ "Alternatively, we could define the instrument by hardcoding the required parameters, which is a bit more convenient for reusing this instrument many times:" ] }, { "cell_type": "code", "execution_count": null, "id": "f6bdd5ea", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.658515Z", "iopub.status.busy": "2026-01-23T11:20:28.658405Z", "iopub.status.idle": "2026-01-23T11:20:28.661955Z", "shell.execute_reply": "2026-01-23T11:20:28.661678Z" } }, "outputs": [], "source": [ "%%writefile out/custom_instrument_class.py\n", "from astropy.coordinates import EarthLocation\n", "from astropy.io import fits\n", "from astropy import units as u\n", "\n", "import phoptic\n", "\n", "\n", "__all__ = ['CustomInstrumentClass']\n", "\n", "\n", "class CustomInstrumentClass(phoptic.Instrument):\n", " \n", " def __init__(\n", " self,\n", " diameter=1.0 * u.m, # telescope aperture\n", " location = EarthLocation.from_geodetic(\n", " lon=0.0, # observatory longitude in degrees\n", " lat=0.0, # observatory East latitude in degrees\n", " height=0.0, # observatory height in meters\n", " ),\n", " pixel_scales = {\n", " 'SYNTHETICAM': 1.0,\n", " },\n", " exptime_kw = 'EXPOSURE',\n", " ):\n", " \n", " super().__init__(\n", " diameter=diameter,\n", " location=location,\n", " pixel_scales=pixel_scales,\n", " exptime_kw=exptime_kw,\n", " )\n", " \n", " def get_mjd(\n", " self,\n", " file=None,\n", " header=None,\n", " ):\n", " \n", " if header is None:\n", " header = file.get_header()\n", " \n", " return float(header[self.dateobs_kw])" ] }, { "cell_type": "code", "execution_count": null, "id": "fd97b458", "metadata": {}, "outputs": [], "source": [ "from out.custom_instrument_class import CustomInstrumentClass\n", "\n", "custom_instrument_class = CustomInstrumentClass()\n", "\n", "print(custom_instrument_class)" ] }, { "cell_type": "code", "execution_count": null, "id": "ff0f5ce3", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.662888Z", "iopub.status.busy": "2026-01-23T11:20:28.662757Z", "iopub.status.idle": "2026-01-23T11:20:28.688334Z", "shell.execute_reply": "2026-01-23T11:20:28.688034Z" } }, "outputs": [], "source": [ "custom_instrument_class.run_checks(new_file_path)" ] }, { "cell_type": "markdown", "id": "3ccde946", "metadata": {}, "source": [ "While creating custom instrument classes is more advanced than editing a configuration template, it is far more configurable. Additionally, instrument classes only need to be created once, and can then be reused indefinitely (unless the instrument changes the format of its data products). If you write a class for an instrument, we encourage you to consider opening a [pull request](https://github.com/OPTICAM-instrument/phoptic/pulls) to merge your instrument into `phoptic`.\n", "\n", "## Gotchas\n", "\n", "Of course, not all instruments operate in the same way and may not have some of the keywords assumed by `phoptic`. In these cases, values can be faked by creating methods that return fixed values, or infer values from other key words.\n", "\n", "### Example: missing keyword\n", "\n", "As an example, let's imagine we have an instrument that does not include a \"BINNING\" keyword in its data products. Assuming all images will have the same binning value, we can fix this by returning a fixed binning value for all images:" ] }, { "cell_type": "code", "execution_count": null, "id": "cbae0d74", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.689176Z", "iopub.status.busy": "2026-01-23T11:20:28.689057Z", "iopub.status.idle": "2026-01-23T11:20:28.691750Z", "shell.execute_reply": "2026-01-23T11:20:28.691537Z" } }, "outputs": [], "source": [ "%%writefile out/instrument_without_binning.py\n", "import phoptic\n", "\n", "\n", "class InstrumentWithoutBinning(phoptic.Instrument):\n", " \n", " def get_binning(self, file=None, header=None):\n", " \n", " return '1x1'" ] }, { "cell_type": "code", "execution_count": null, "id": "cb4311d9", "metadata": {}, "outputs": [], "source": [ "from out.instrument_without_binning import InstrumentWithoutBinning\n", "\n", "instrument_without_binning = InstrumentWithoutBinning.from_json(config=config_dict)\n", "\n", "print(instrument_without_binning)" ] }, { "cell_type": "markdown", "id": "e7e62995", "metadata": {}, "source": [ "All methods of an `Instrument` take two parameters: `file` and `header`. When an `Instument`'s methods are called, only one of `file` or `header` are passed, so custom methods should set defaults for each parameter and define the method to return the same value regardless of which parameter is defined. In this example, however, neither parameter is needed.\n", "\n", "Now let's generate an image without a binning keyword:" ] }, { "cell_type": "code", "execution_count": null, "id": "2648b650", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.692617Z", "iopub.status.busy": "2026-01-23T11:20:28.692465Z", "iopub.status.idle": "2026-01-23T11:20:28.725875Z", "shell.execute_reply": "2026-01-23T11:20:28.725547Z" } }, "outputs": [], "source": [ "image = make_100gaussians_image()\n", "\n", "hdu = fits.PrimaryHDU(image) # image data\n", "\n", "hdu.header['FILTER'] = 'U'\n", "hdu.header['GAIN'] = 1.\n", "hdu.header['EXPTIME'] = 1.\n", "hdu.header['RA'] = '12 30 49.4'\n", "hdu.header['DEC'] = '+12 23 28.0'\n", "hdu.header['DATE-OBS'] = f\"2026-01-01T00:00:00.000\"\n", "hdu.header['INSTRUME'] = 'SYNTHETICAM'\n", "hdu.header['RDNOISE'] = 0.\n", "hdu.header['AIRMASS'] = 0.\n", "\n", "new_file_path = out_dir / 'data' / 'no_binnning'\n", "hdu.writeto(\n", " new_file_path,\n", " overwrite=True,\n", " )" ] }, { "cell_type": "markdown", "id": "d5015fd9", "metadata": {}, "source": [ "When we check a previous instrument that would otherwise work:" ] }, { "cell_type": "code", "execution_count": null, "id": "6db9e360", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.727038Z", "iopub.status.busy": "2026-01-23T11:20:28.726830Z", "iopub.status.idle": "2026-01-23T11:20:28.731126Z", "shell.execute_reply": "2026-01-23T11:20:28.730886Z" } }, "outputs": [], "source": [ "new_instrument.run_checks(new_file_path)" ] }, { "cell_type": "markdown", "id": "d803fad0", "metadata": {}, "source": [ "We see that an error is raised. With our new instrument, however:" ] }, { "cell_type": "code", "execution_count": null, "id": "0c8fcb5f", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.731970Z", "iopub.status.busy": "2026-01-23T11:20:28.731859Z", "iopub.status.idle": "2026-01-23T11:20:28.735394Z", "shell.execute_reply": "2026-01-23T11:20:28.735164Z" } }, "outputs": [], "source": [ "instrument_without_binning.run_checks(new_file_path)" ] }, { "cell_type": "markdown", "id": "6d24c633", "metadata": {}, "source": [ "We don't get any errors.\n", "\n", "However, this will cause issues if the binning changes between images. In this case, the binning information should be inferred from other keywords (e.g., `NAXIS1` and `NAXIS2`).\n", "\n", "For example, if your instrument has a resolution of 2048x2048, then you can infer the binning mode by dividing the instrument's resolution by the image resolution:" ] }, { "cell_type": "code", "execution_count": null, "id": "13589094", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.736183Z", "iopub.status.busy": "2026-01-23T11:20:28.736080Z", "iopub.status.idle": "2026-01-23T11:20:28.738800Z", "shell.execute_reply": "2026-01-23T11:20:28.738583Z" } }, "outputs": [], "source": [ "%%writefile out/instrument_with_inferred_binning.py\n", "import phoptic\n", "\n", "\n", "class InstrumentWithInferredBinning(phoptic.Instrument):\n", " \n", " def get_binning(self, file=None, header=None):\n", " \n", " if header is None:\n", " header = file.get_header()\n", " \n", " x_size = int(header['NAXIS1'])\n", " y_size = int(header['NAXIS2'])\n", " \n", " # divide instrument resolution by image resolution\n", " x_binning = 2048 // x_size\n", " y_binning = 2048 // y_size\n", " \n", " return f'{x_binning}x{y_binning}'\n" ] }, { "cell_type": "code", "execution_count": null, "id": "600e9224", "metadata": { "execution": { "iopub.execute_input": "2026-01-23T11:20:28.739623Z", "iopub.status.busy": "2026-01-23T11:20:28.739503Z", "iopub.status.idle": "2026-01-23T11:20:28.743447Z", "shell.execute_reply": "2026-01-23T11:20:28.743183Z" } }, "outputs": [], "source": [ "from out.instrument_with_inferred_binning import InstrumentWithInferredBinning\n", "\n", "\n", "instrument_with_inferred_binning = InstrumentWithInferredBinning.from_json(config=config_dict)\n", "instrument_with_inferred_binning.run_checks(new_file_path)" ] }, { "cell_type": "markdown", "id": "5ca778a9", "metadata": {}, "source": [ "The above can be extended to more-or-less any case in which an instrument does not include required keywords in its data products." ] } ], "metadata": { "kernelspec": { "display_name": "opticam7", "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.14.6" } }, "nbformat": 4, "nbformat_minor": 5 }