diff --git a/pyproject.toml b/pyproject.toml index 57774442..35fa1fab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "ipywidgets", "jupyter-app-launcher", "matplotlib", + "papermill", "pandas", "photutils >=1.9", "pydantic >=2", @@ -178,4 +179,6 @@ filterwarnings = [ 'ignore:RADECSYS=:', # photutils changed the name of a function again 'ignore:The make_gaussian_sources_image function is deprecated', + # papermill is using deprecated jupyter paths + 'ignore:Jupyter is migrating its paths to use standard platformdirs:DeprecationWarning', ] diff --git a/stellarphot/notebooks/run-launcher.ipynb b/stellarphot/notebooks/run-launcher.ipynb index 041c0e4d..a831bf6d 100644 --- a/stellarphot/notebooks/run-launcher.ipynb +++ b/stellarphot/notebooks/run-launcher.ipynb @@ -2,80 +2,38 @@ "cells": [ { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "e5f2fc8f-b893-400e-8fb7-4dd8e871334f", "metadata": {}, "outputs": [], "source": [ "import ipywidgets as ipw\n", - "import papermill as pm\n", - "\n", - "from ipyautoui.custom import FileChooser\n", "\n", - "from stellarphot.gui_tools import FitsOpener\n", - "from stellarphot.settings.custom_widgets import Confirm\n", - "from stellarphot.settings import PhotometryRunSettings" + "from stellarphot.settings.custom_widgets import Kaboodle" ] }, { "cell_type": "code", - "execution_count": null, - "id": "2389ff77-c8ac-4bad-afb7-79cdd5c0f365", - "metadata": {}, - "outputs": [], - "source": [ - "class Kaboodle(ipw.VBox):\n", - " def __init__(self, *args, **kwargs):\n", - " super().__init__(*args, **kwargs)\n", - " self.fo = FitsOpener(title=\"Choose any image in the folder of images to do photometry on that contains the object of interest\")\n", - " self.info_box = ipw.HTML()\n", - " self.run_output = ipw.Output()\n", - " self.confirm = Confirm(message=\"Is this correct?\")\n", - " self.children = (self.fo.file_chooser, self.info_box, self.confirm, self.run_output)\n", - " self.fo.file_chooser.observe(self._file_chosen, \"_value\")\n", - " self.confirm.observe(self._confirmation, \"value\")\n", - " self.run_settings = None\n", - "\n", - " def _file_chosen(self, change=None):\n", - " self.run_settings = PhotometryRunSettings(\n", - " directory_with_images=self.fo.path.parent, \n", - " object_of_interest=self.fo.header['object']\n", - " )\n", - " self.info_box.value = \"

\" + self.info_message + \"
Is this correct?\" + \"

\"\n", - " self.confirm.show()\n", - "\n", - " @property\n", - " def info_message(self):\n", - " return (f\"Photomery will be done on all images of the object \"\n", - " f\"'{self.run_settings.object_of_interest}' in the \"\n", - " f\"folder '{self.run_settings.directory_with_images}'\")\n", - " \n", - " def _confirmation(self, change=None):\n", - " if change[\"new\"]:\n", - " # User said yes\n", - "\n", - " # Update informational message\n", - " self.info_box.value = \"

\" + self.info_message + \"
Photometry is running...\" + \"

\"\n", - " with self.run_output:\n", - " pm.execute_notebook(\n", - " \"stellarphot/notebooks/photometry_runner.ipynb\",\n", - " \"MEEEPhonk.ipynb\",\n", - " parameters=self.run_settings.model_dump(mode=\"json\")\n", - " )\n", - " else:\n", - " # User said no, so reset to initial state.\n", - " self.fo.file_chooser.reset()\n", - " self.info_box.value = \"\"\n", - " self.run_settings = None\n", - " " - ] - }, - { - "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "c5eed8a2-7533-4411-8f6e-303b854f4e3e", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "415ea731dc1d42279fcce2ff8a718672", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Kaboodle(children=(FileChooser(path='/Users/mattcraig/development/astronomy/stellarphot/stellarphot/notebooks'…" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "kab = Kaboodle()\n", "kab" diff --git a/stellarphot/settings/custom_widgets.py b/stellarphot/settings/custom_widgets.py index dd3b8340..0c6ed7a2 100644 --- a/stellarphot/settings/custom_widgets.py +++ b/stellarphot/settings/custom_widgets.py @@ -14,7 +14,9 @@ class StrEnum(str, Enum): import ipywidgets as ipw +import papermill as pm import traitlets as tr +from astropy.utils.data import get_pkg_data_filename from camel_converter import to_snake from ipyautoui.autoobject import AutoObject from ipyautoui.custom.iterable import ItemBox @@ -25,10 +27,12 @@ class StrEnum(str, Enum): Observatory, PartialPhotometrySettings, PassbandMap, + PhotometryRunSettings, PhotometryWorkingDirSettings, SavedSettings, ui_generator, ) +from stellarphot.settings.fits_opener import FitsOpener __all__ = ["ChooseOrMakeNew", "Confirm", "SettingWithTitle"] @@ -985,3 +989,71 @@ def save_wd(_=None): raise ValueError( f"The widget {setting_widget} is not a recognized type of widget." ) + + +class PhotometryRunner(ipw.VBox): + def __init__( + self, photometry_notebook_name="photometry_run.ipynb", *args, **kwargs + ): + super().__init__(*args, **kwargs) + self.photometry_notebook_name = photometry_notebook_name + self.fo = FitsOpener( + title=( + "Choose any image in the folder of images to do photometry on that " + "contains the object of interest" + ) + ) + self.info_box = ipw.HTML() + self.run_output = ipw.Output() + self.confirm = Confirm(message="Is this correct?") + self.children = ( + self.fo.file_chooser, + self.info_box, + self.confirm, + self.run_output, + ) + self.fo.file_chooser.observe(self._file_chosen, "_value") + self.confirm.observe(self._confirmation, "value") + self.run_settings = None + + def _file_chosen(self, _): + self.run_settings = PhotometryRunSettings( + directory_with_images=self.fo.path.parent, + object_of_interest=self.fo.header["object"], + ) + self.info_box.value = ( + "

" + self.info_message + "
Is this correct?" + "

" + ) + self.confirm.show() + + @property + def info_message(self): + return ( + f"Photometry will be done on all images of the object " + f"'{self.run_settings.object_of_interest}' in the " + f"folder '{self.run_settings.directory_with_images}'" + ) + + def _confirmation(self, change=None): + if change["new"]: + # User said yes + + # Update informational message + self.info_box.value = ( + "

" + self.info_message + "
Photometry is running..." + "

" + ) + template_nb = get_pkg_data_filename( + "photometry_runner.ipynb", package="stellarphot.notebooks" + ) + print(template_nb) + with self.run_output: + pm.execute_notebook( + template_nb, + self.photometry_notebook_name, + parameters=self.run_settings.model_dump(mode="json"), + ) + else: + # User said no, so reset to initial state. + self.fo.file_chooser.reset() + self.info_box.value = "" + self.run_settings = None diff --git a/stellarphot/settings/tests/test_photometry_runner.py b/stellarphot/settings/tests/test_photometry_runner.py new file mode 100644 index 00000000..7f0e01f8 --- /dev/null +++ b/stellarphot/settings/tests/test_photometry_runner.py @@ -0,0 +1,100 @@ +import os +from pathlib import Path + +import ipywidgets as ipw +import pytest +from astropy.io import fits + +from stellarphot.settings import ( + PhotometrySettings, + PhotometryWorkingDirSettings, + settings_files, +) +from stellarphot.settings.custom_widgets import PhotometryRunner +from stellarphot.settings.tests.test_models import DEFAULT_PHOTOMETRY_SETTINGS + + +# See test_settings_file.TestSavedSettings for a detailed description of what the +# following fixture does. In brief, it patches the settings_files.PlatformDirs class +# so that the user_data_dir method returns the temporary directory. +@pytest.fixture(autouse=True) +def fake_settings_dir(mocker, tmp_path): + mocker.patch.object( + settings_files.PlatformDirs, "user_data_dir", tmp_path / "stellarphot" + ) + + +class TestPhotometryRunner: + + # This auto-used fixture changes the working directory to the temporary directory + # and then changes back to the original directory after the test is done. + @pytest.fixture(autouse=True) + def change_to_tmp_dir(self, tmp_path): + original_dir = os.getcwd() + os.chdir(tmp_path) + # Yielding here is important. It means that when the test is done, the remainder + # of the function will be executed. This is important because the test is run in + # a temporary directory and we want to change back to the original directory + # when the test is done. + yield + os.chdir(original_dir) + + def test_photometry_runner_creation(self): + # This test simply makes sure we can create the object + photometry_runner = PhotometryRunner() + assert isinstance(photometry_runner, ipw.Box) + + @pytest.mark.parametrize("do_photometry", [True, False]) + def test_photometry_runner_with_valid_settings(self, do_photometry): + # This test makes sure that the PhotometryRunner widget can be used + # to *start* a photometry run. It does not check the actual photometry + # or the results of the photometry run, except to ensure that the expected + # files are created. + # Make a settings + phot_settings = PhotometrySettings.model_validate(DEFAULT_PHOTOMETRY_SETTINGS) + + # Save a copy to the working directory + wd_settings = PhotometryWorkingDirSettings() + wd_settings.save(phot_settings) + + # Make sure a source list file exists + sources = Path(phot_settings.source_location_settings.source_list_file) + sources.touch() + + # Make sure a fake fits file exists + fake_fits = Path("fake.fits") + fits_data = fits.PrimaryHDU(data=[[1, 2], [3, 4]]) + object_name = "Fake Object" + fits_data.header["object"] = object_name + fits_data.writeto(fake_fits) + + photometry_runner = PhotometryRunner() + # Select a fits file + photometry_runner.fo.file_chooser.reset(".", fake_fits.name) + photometry_runner.fo.file_chooser._apply_selection() + photometry_runner.fo.file_chooser.value = fake_fits + + # Check that the message in the information box is as expected + assert "Photometry will be done" in photometry_runner.info_box.value + assert object_name in photometry_runner.info_box.value + + if do_photometry: + # Run the photometry + photometry_runner.confirm._yes.click() + assert "Photometry is running" in photometry_runner.info_box.value + + # Check that the expected file was created, in this case just the notebook. + # No photometry is actually done because the source is empty and the "image" + # has no stars. + assert Path(photometry_runner.photometry_notebook_name).exists() + + # Make sure we do get the error we expect, which is generated when + # the source list is read. It is an empty file, not actually an ecsv. + with open(photometry_runner.photometry_notebook_name) as f: + notebook_text = f.read() + assert "InconsistentTableError" in notebook_text + else: + # Cancel the photometry + photometry_runner.confirm._no.click() + + assert photometry_runner.info_box.value == ""