diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b308c3ab..25a86325 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -32,7 +32,7 @@ } } } - + }, "features": { "ghcr.io/devcontainers/features/github-cli:1": {} diff --git a/README.rst b/README.rst index 56526b54..26b15f75 100755 --- a/README.rst +++ b/README.rst @@ -6,6 +6,9 @@ pyscope |License| |Zenodo| |PyPI Version| |PyPI Python Versions| |PyPI Downloads| |Astropy| |GitHub CI| |Code Coverage| |Documentation Status| |Codespaces Status| |pre-commit| |Black| |isort| |Donate| +.. image:: https://github.com/WWGolay/pyscope/blob/main/docs/source/images/pyscope_logo_white.png + :alt: pyscope logo + This is the repository for `pyscope `_, a pure-Python package for robotic scheduling, operation, and control of small optical telescopes. diff --git a/coverage.xml b/coverage.xml index 5459e86b..09e336fc 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -11,16 +11,16 @@ - - - - + - + + + + @@ -188,7 +188,7 @@ - + @@ -2305,7 +2305,7 @@ - + @@ -2909,31 +2909,30 @@ - - - + + - - + + - - + + - + - - - - - - - + + + + + + + @@ -2947,676 +2946,673 @@ - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + - - - - - + + + + + + + - - - - - + + + - - - - - - - - - - - - - + + + + + + + + + + + + - - + - - - - - + + + + + - - - + + + - - - - - - + + + + - - - - + + + + - + - - - + + + + + - - - - - - - - + + + + + + + - - + + - - + + + + - - - + + + - + + + - - - - - + + + - - - - - + + + + - + + - - - - - - - - + + + + + + + + - - - - - - + + + + + + - - - - + + - - + + + + - - - + + + - + - - + + - - + + + + - + - - - - - + + + - - - - - + + + + + + - - - - - - + + + + + + + + - - - - - - - - + + + + + + - - - - + - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - + - - - - - - - - - - - - + + + + + + + - - - - - - - + + + + + + + - + - + - - + + - + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - + + + - + + + + + + - - - + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + - - + + + + + + + + - - - - - - - - - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + + - - - - - - - - + + + + - - - - + + + + + + + - - - - - - - - - + + + + + + + + + + - + + - - - - - - - + + + + + + + + - - - - + + + + - - - + - - - + + + - - - - + + + - - + - + + + + + + - + - - + + - - + - + + - - - - - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - + + + + + - - + + + + - + - - + + - - - - - + + + - + - - - - + + + - + - - + + + + + - + - - + - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + @@ -3627,64 +3623,68 @@ + - - - + + - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - + @@ -3702,616 +3702,621 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + + - - - - - + + - - - - - - - + + + - - - - - - - - - - + + + + + + + + + + + - - + + + + + + + + - - - - - + + + + + - - - - - - - + + - - + + - - - - - + + + + + - - + + - - - - - + + + + + - + + + - - - - - - - + + + + + - - + + - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + + - - - - + + - - - + + + - - - + + + - - - + + + - + + + + + - - + + - - - - - - - + + + + + + - - + - - - + + + + + + - - - - - - + + + - - - + + + - - - - + + + + - - - + + + - - - - + + + + - - - + + + - - - - - - + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - + + + - + + + + + + - - + + - - - + + + - - + + - - - - - - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + + + + - - + + - - - - - - + + + + + + - - - + + + - - - - + + + + - - - - - + + + + - - - - - + + + - - - + + + - - - + + + - - - + + + - - - - - - - + + + + + + + - - - + + + - - - + + + - + + - - + + + + - - - + + + - - - - + + + + - - - + + + - - - - - - + + + - - - + + + - - - + + + - - - + + + - - - - + + + + + - - - + + + - + + + + + - - + + - - - - - - + + + + + + - - - - - + + + + - - - - - - - + + - + - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -4466,7 +4471,7 @@ - + @@ -4507,12 +4512,14 @@ - + + - - - + + + + @@ -5216,7 +5223,7 @@ - + @@ -5233,10 +5240,13 @@ + + + - + @@ -5270,39 +5280,44 @@ - + + + + + + + + + + + - - - - + + - - - - - + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + @@ -5458,7 +5473,107 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5571,455 +5686,1190 @@ - + - + - - - - - - - - - - - - - - - - - + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - - - - - - + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - + + + - + + - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + - + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - + + - - - - - + + + + + + + + + - - - - - - - - + + + - - + - + + - - - - - - - + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + - + + - - - - - - - - - - - + + + + + + + + + + + + + + + + - + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - - + + - + - - - - - - - - - - - - - - - - - + + + + - - - - - - - + + + + + + + - - - - - - + + - - + - - - - - - - - - - + + + + + + + + + - - + + + + - - - + + + - - - - - - - - - - + + + + + + + + - - - - - + + + + + + + + - + + + - + - - - - - - - - - - - - + + + + + + + + + - - + + + - - - + - - - - - - + + - - - - - - - - - - - - - - - - + + + + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6045,103 +6895,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -6327,6 +7080,13 @@ + + + + + + + @@ -6334,7 +7094,7 @@ - + @@ -6346,19 +7106,38 @@ - + - + - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -6368,15 +7147,13 @@ - - + + - - @@ -6389,11 +7166,15 @@ + + + + @@ -6407,862 +7188,904 @@ - - + - + - - - - - - - + - - + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + - + + + - - - - + + + + - - - - - - + + + - - - - + + - - - - - + + + + + + + - - - - - - - + + + + + - + + + - - - + + + + + + + + - - - - - - - + + + + - - - - + + + + - - + + + - - - + + + + + - + + - + + + - + - + + + - + - + + + - + - - - - - + + + - - - - - - - + + + + + + - + - - - - - - - - - - - - - + + + + + - + - - - + + + + + - - + - - + + + - - + + + + + + - - - - + + + + + + + + + - - - + + - + - - - - + - - - + - - - - - - - - - - - - - - + + + + + + + + + + - - + + + - - - - + - - + + + + + + + + + + + + + + - - - - + + + - - + + - + - - + + - - - + + - + - - - + + - + - - - + + + + - - - - - + - - - - - - - - - - - - + + + + + + + + + + + - - - - + + + - - - + + + + - - - - - - + + + - + - - - + + + + - - - + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - + + + - - - - + + - - - - - - - + + + + + + + - - - - - + + + + - - - - - + + + + + - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + - - - - - + + + + + - - - - - - - - - - - + + + + + + + + + - - - + + + + + + - - - - - + + + + - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + - - - - - - - - + + + + + + + - + - - - - + + + + - - - - - - + + + + + + + - - + - - - - - + + - - - - - - + + + + + - - - - + + + + - - - - - - - - - + + + + + + + + + + + + + + - - - - + + - - - - - - - - + + + + + + - - + - + + + - - + - - + + - - - - - + + + + + - - - - - - + + + + + + + + + - - + - - - - - - - - + + + + + + + + + + - - - - - - + + + + + + + - - - + + - - - - + + + + - - - - + + + - - - - - - - + + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + - - - - - - + - - + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + - - - - - - + - - - - - + + + - + + + + + + - + - + + - - - - - - - - - - - - - - - - - - + + + + + + + + - - - - - + + + + + + + + + + + + - - - + + + + + + + + - - - + + + + + + + + - - - + + + + + - - + + - - + + - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + - - - - - + + + + + + + + + + + + + + - + + - - + + + - - - - - - + + + + + + @@ -7270,163 +8093,158 @@ - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - - + + + + + - - - - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + - - - + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + @@ -7448,210 +8266,208 @@ - - - - - + + + + + + + - - - - - - + + + + + + - - - + + + + - - - + + - - - - + + - - - - - - + + + + - - + - - - + - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - - + + + + + + - - + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - + + - - - - - - + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + - - + + - + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - - + - - + - - + + + + - - + + - - - + - - - @@ -7659,33 +8475,27 @@ + - + - - + - - - - - - - + @@ -7693,16 +8503,14 @@ - - + + - - + - @@ -7710,193 +8518,102 @@ + - - + + + + + + - + + + + + + + + + + + + + + + - + + + + + + + + + - - + - - + + - - - + + - - - + - + - + - - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -8961,46 +9678,46 @@ - - + - + - - - - + + + + - + - + - - + + - - + + - - - + + + + @@ -9070,9 +9787,31 @@ + + + + + + + + + + + + + + + - + + + + + + + + diff --git a/docs/source/images/old/pyscope_banner.png b/docs/source/_static/old/pyscope_banner.png similarity index 100% rename from docs/source/images/old/pyscope_banner.png rename to docs/source/_static/old/pyscope_banner.png diff --git a/docs/source/images/old/pyscope_transparent.svg b/docs/source/_static/old/pyscope_transparent.svg similarity index 100% rename from docs/source/images/old/pyscope_transparent.svg rename to docs/source/_static/old/pyscope_transparent.svg diff --git a/docs/source/images/pyscope_logo.png b/docs/source/_static/pyscope_logo.png similarity index 100% rename from docs/source/images/pyscope_logo.png rename to docs/source/_static/pyscope_logo.png diff --git a/docs/source/images/pyscope_logo.svg b/docs/source/_static/pyscope_logo.svg similarity index 100% rename from docs/source/images/pyscope_logo.svg rename to docs/source/_static/pyscope_logo.svg diff --git a/docs/source/images/pyscope_logo_small.svg b/docs/source/_static/pyscope_logo_small.svg similarity index 100% rename from docs/source/images/pyscope_logo_small.svg rename to docs/source/_static/pyscope_logo_small.svg diff --git a/docs/source/images/pyscope_logo_small_gray.svg b/docs/source/_static/pyscope_logo_small_gray.svg similarity index 100% rename from docs/source/images/pyscope_logo_small_gray.svg rename to docs/source/_static/pyscope_logo_small_gray.svg diff --git a/docs/source/images/pyscope_logo_white.png b/docs/source/_static/pyscope_logo_white.png similarity index 100% rename from docs/source/images/pyscope_logo_white.png rename to docs/source/_static/pyscope_logo_white.png diff --git a/docs/source/images/pyscope_logo_white.svg b/docs/source/_static/pyscope_logo_white.svg similarity index 100% rename from docs/source/images/pyscope_logo_white.svg rename to docs/source/_static/pyscope_logo_white.svg diff --git a/docs/source/images/pyscope_logo_small.png b/docs/source/images/pyscope_logo_small.png deleted file mode 100644 index 47e3b77d..00000000 Binary files a/docs/source/images/pyscope_logo_small.png and /dev/null differ diff --git a/docs/source/images/pyscope_logo_small_gray.png b/docs/source/images/pyscope_logo_small_gray.png deleted file mode 100644 index ac4724a3..00000000 Binary files a/docs/source/images/pyscope_logo_small_gray.png and /dev/null differ diff --git a/pyscope/__init__.py b/pyscope/__init__.py index ebfe222a..dbca7c10 100644 --- a/pyscope/__init__.py +++ b/pyscope/__init__.py @@ -76,7 +76,7 @@ import logging -__version__ = "0.1.5" +__version__ = "0.1.6" from . import utils from . import observatory diff --git a/pyscope/observatory/observatory.py b/pyscope/observatory/observatory.py index 0f9b2341..aafee518 100644 --- a/pyscope/observatory/observatory.py +++ b/pyscope/observatory/observatory.py @@ -1156,12 +1156,13 @@ def save_last_image( do_fwhm=False, overwrite=False, custom_header=None, + history=None, **kwargs, ): """Saves the current image""" logger.debug( - f"Observatory.save_last_image({filename}, {frametyp}, {do_wcs}, {do_fwhm}, {overwrite}, {custom_header}, {kwargs}) called" + f"Observatory.save_last_image({filename}, {frametyp}, {do_wcs}, {do_fwhm}, {overwrite}, {custom_header}, {history}, {kwargs}) called" ) if not self.camera.ImageReady: @@ -1217,6 +1218,12 @@ def save_last_image( if custom_header is not None: hdr.update(custom_header) + if history is not None: + if type(history) is str: + history = [history] + for hist in history: + hdr["HISTORY"] = hist + hdu = fits.PrimaryHDU(self.camera.ImageArray, header=hdr) hdu.writeto(filename, overwrite=overwrite) diff --git a/pyscope/reduction/calib_images.py b/pyscope/reduction/calib_images.py index daa737f2..84ddd6ba 100644 --- a/pyscope/reduction/calib_images.py +++ b/pyscope/reduction/calib_images.py @@ -11,7 +11,6 @@ from ..observatory import AstrometryNetWCS from .ccd_calib import ccd_calib - logger = logging.getLogger(__name__) diff --git a/pyscope/telrun/__init__.py b/pyscope/telrun/__init__.py index 6f173cda..e9b17611 100644 --- a/pyscope/telrun/__init__.py +++ b/pyscope/telrun/__init__.py @@ -13,10 +13,13 @@ from .init_dirs import init_telrun_dir, init_remote_dir from .mk_mosaic_schedule import mk_mosaic_schedule from .rst import rst -from .schedtel import schedtel, plot_schedule_gantt, parse_sch_file +from . import sch +from . import schedtab +from .schedtel import schedtel, plot_schedule_gantt, plot_schedule_sky from .startup import start_telrun, start_syncfiles -from .summary_report import summary_report +from . import reports from .syncfiles import syncfiles +from .telrun_block import TelrunBlock from .telrun_operator import TelrunOperator __all__ = [ @@ -25,13 +28,15 @@ "init_remote_dir", "mk_mosaic_schedule", "rst", + "sch", + "schedtab", "schedtel", "plot_schedule_gantt", - "parse_sch_file", "start_telrun", "start_syncfiles", - "summary_report", + "reports", "syncfiles", + "TelrunBlock", "TelrunOperator", "TelrunException", ] diff --git a/pyscope/telrun/summary_report.py b/pyscope/telrun/reports.py similarity index 99% rename from pyscope/telrun/summary_report.py rename to pyscope/telrun/reports.py index 46eb618b..4c8659cf 100644 --- a/pyscope/telrun/summary_report.py +++ b/pyscope/telrun/reports.py @@ -401,4 +401,10 @@ def summary_report_cli( ) +@click.command() +def schedule_report_cli(): + pass + + summary_report = summary_report_cli.callback +schedule_report = schedule_report_cli.callback diff --git a/pyscope/telrun/sch.py b/pyscope/telrun/sch.py new file mode 100644 index 00000000..cf013a85 --- /dev/null +++ b/pyscope/telrun/sch.py @@ -0,0 +1,1085 @@ +import datetime +import logging +import re +import shlex + +import astroplan +import numpy as np +from astropy import coordinates as coord +from astropy import time as astrotime +from astropy import units as u +from astroquery import mpc + +from pyscope import __version__ + +logger = logging.getLogger(__name__) + + +def read( + filename, + location=None, + t0=None, + default_title="Pyscope Observation", + default_observers="default", + default_code="aaa", + default_nonsidereal=False, + default_priority=1, + default_repositioning=(0, 0), + default_shutter_state=True, + default_readout=0, + default_binning=(1, 1), + default_frame_position=(0, 0), + default_frame_size=(0, 0), + default_nexp=1, + default_do_not_interrupt=False, +): + possible_keys = { + "tit": "title", + "obs": "observer", + "cod": "code", + "bl": "block", + "ty": "type", + "ba": "backend", + "dates": "datestart", + "datee": "dateend", + "so": "source", + "ta": "source", + "obj": "source", + "na": "source", + "ra": "ra", + "ri": "ra", + "de": "dec", + "no": "nonsidereal", + "non_": "nonsidereal", + "non-": "nonsidereal", + "pm_r": "pm_ra_cosdec", + "pm_d": "pm_dec", + "file": "filename", + "prio": "priority", + "repo": "repositioning", + "sh": "shutter_state", + "read": "readout", + "bi": "binning", + "frame_p": "frame_position", + "frame_s": "frame_size", + "u": "utstart", + "st": "utstart", + "ca": "cadence", + "sc": "schederr", + "n_": "nexp", + "ne": "nexp", + "repe": "nexp", + "do": "do_not_interrupt", + "filt": "filter", + "exp": "exposures", + "du": "exposures", + "dw": "exposures", + "tim": "exposures", + "l": "exposures", + "com": "comment", + } + + with open(filename, "r") as f: + raw_lines = f.readlines() + + # Remove equal signs, quotes, blank lines, etc + lines = [] + for line in raw_lines: + logger.debug(f"Parsing line: {line}") + line = line.replace("=", " ") + line = line.replace(": ", " ") + line = line.replace(";", ",") + line = line.replace(", ", ",") + line = line.replace("(", '"') + line = line.replace(")", '"') + line = line.replace("[", '"') + line = line.replace("]", '"') + line = line.replace("{", '"') + line = line.replace("}", '"') + line = line.replace("<", '"') + line = line.replace(">", '"') + line = line.replace("'", '"') + line = line.replace("`", '"') + line = line.replace("‘", '"') + line = line.replace("’", '"') + lines.append(line) + + # From: https://stackoverflow.com/questions/28401547/how-to-remove-comments-from-a-string + lines = [re.sub(r"(?m)^ *#.*\n?", "", line) for line in lines] + lines = [re.sub(r"(?m)^ *!.*\n?", "", line) for line in lines] + lines = [re.sub(r"(?m)^ *%.*\n?", "", line) for line in lines] + lines = [line.split("#")[0] for line in lines] # Remove comments + lines = [line.split("!")[0] for line in lines] # Remove comments + lines = [line.split("%")[0] for line in lines] # Remove comments + + lines = [line.replace("\n", "") for line in lines] # Remove line breaks + lines = [ + " ".join(line.split()) for line in lines + ] # Remove multiple, trailing, leading whitespace + lines = [line for line in lines if line != ""] # Remove empty lines + + # From: https://stackoverflow.com/questions/35544325/python-convert-entire-string-to-lowercase-except-for-substrings-in-quotes + lines = [ + re.sub(r'\b(? 1: + logger.error( + f"Keyword {key} matches multiple possible keywords: {value_matches}, removing line {line_number}: {line}" + ) + lines.remove(line) + continue + elif len(value_matches) == 0: + logger.error( + f"Keyword {key} does not match any possible keywords: {possible_keys.values()}, removing line {line_number}: {line}" + ) + lines.remove(line) + continue + new_line.update({value_matches[0]: line[key]}) + lines[line_number] = new_line + + # Look for title, observers, code, datestart, and dateend keywords + title_matches = [] + line_matches = [] + for line_number, line in enumerate(lines): + if "title" in line.keys(): + title_matches.append(line["title"]) + line_matches.append(line_number) + if len(line.keys()) > 1: + logger.warning( + f"Multiple keywords found on title line {line_number}, ignoring all but title: {line}" + ) + lines = [ + line + for line_number, line in enumerate(lines) + if line_number not in line_matches + ] + + if len(title_matches) > 1: + logger.warning(f"Multiple titles found: {title_matches}, using first") + title = title_matches[0] + elif len(title_matches) == 1: + title = title_matches[0] + else: + logger.warning("No title found, using parsing function default") + title = default_title + + observers = [] + line_matches = [] + for line_number, line in enumerate(lines): + if "observer" in line.keys(): + observers.append(line["observer"]) + line_matches.append(line_number) + if len(line.keys()) > 1: + logger.warning( + f"Multiple keywords found on observer line {line_number}, ignoring all but observer: {line}" + ) + lines = [ + line + for line_number, line in enumerate(lines) + if line_number not in line_matches + ] + if len(observers) == 0: + logger.warning("No observers found, using parsing function default") + observers = default_observers + + code_matches = [] + line_matches = [] + for line_number, line in enumerate(lines): + if "code" in line.keys(): + code_matches.append(line["code"]) + line_matches.append(line_number) + if len(line.keys()) > 1: + logger.warning( + f"Multiple keywords found on code line {line_number}, ignoring all but code: {line}" + ) + lines = [ + line + for line_number, line in enumerate(lines) + if line_number not in line_matches + ] + if len(code_matches) > 1: + logger.warning(f"Multiple codes found: {code_matches}, using first") + code = code_matches[0] + elif len(code_matches) == 1: + code = code_matches[0] + else: + logger.warning("No code found, using parsing function default") + code = default_code + + datestart_matches = [] + line_matches = [] + for line_number, line in enumerate(lines): + if "datestart" in line.keys(): + datestart_matches.append(line["datestart"]) + line_matches.append(line_number) + if len(line.keys()) > 1: + logger.warning( + f"Multiple keywords found on datestart line {line_number}, ignoring all but datestart: {line}" + ) + lines = [ + line + for line_number, line in enumerate(lines) + if line_number not in line_matches + ] + if len(datestart_matches) > 1: + logger.warning(f"Multiple datestarts found: {datestart_matches}, using first") + datestart = astrotime.Time( + datestart_matches[0], + format="isot", + ) + elif len(datestart_matches) == 1: + datestart = astrotime.Time( + datestart_matches[0], + format="isot", + ) + else: + logger.info("No datestart found") + datestart = None + + dateend_matches = [] + line_matches = [] + for line_number, line in enumerate(lines): + if "dateend" in line.keys(): + dateend_matches.append(line["dateend"]) + line_matches.append(line_number) + if len(line.keys()) > 1: + logger.warning( + f"Multiple keywords found on dateend line {line_number}, ignoring all but dateend: {line}" + ) + lines = [ + line + for line_number, line in enumerate(lines) + if line_number not in line_matches + ] + if len(dateend_matches) > 1: + logger.warning(f"Multiple dateends found: {dateend_matches}, using first") + dateend = astrotime.Time( + dateend_matches[0], + format="isot", + ) + elif len(dateend_matches) == 1: + dateend = astrotime.Time( + dateend_matches[0], + format="isot", + ) + else: + logger.info("No dateend found") + dateend = None + + if datestart is not None: + if dateend is not None: + if datestart.jd > dateend.jd: + logger.error(f"datestart must be before dateend, ignoring") + datestart = None + dateend = None + else: + if datestart.jd > astrotime.Time.now().jd: + logger.exception(f"datestart must be before now, exiting") + return + else: + if dateend is not None: + if dateend.jd < astrotime.Time.now().jd: + logger.exception( + f"dateend must be after now, renaming file to .sch.old and exiting" + ) + os.rename(filename, filename.replace(".sch", ".sch.old")) + return + + # Look for block keywords and collapse into single line + new_lines = [] + i = 0 + while i < len(lines): + if "block" in lines[i].keys(): + if len(lines[i].keys()) > 1: + logger.warning( + f"Multiple keywords found on block line {line_number}, ignoring all but block: {line}" + ) + if lines[i]["block"].startswith("s"): + if i >= len(lines) - 2: + logger.error( + f"Block cannot start with s on last line, ignoring: {line}" + ) + continue + new_line = dict() + j = i + 1 + while j < len(lines): + if "block" not in lines[j].keys(): + new_line.update(lines[j]) + j += 1 + elif "block" in lines[j].keys(): + if lines[j]["block"].startswith("e"): + break + else: + logger.error( + f"Block on line {line_number} not ended properly, ignoring: {line}" + ) + j = len(lines) + else: + logger.error( + f"Block end never found for block starting on line {line_number}, ignoring: {line}" + ) + i = j + 1 + continue + new_lines.append(new_line) + i = j + 1 + continue + elif lines[i]["block"].startswith("e"): + logger.error( + f"Block cannot start with e on line {line_number}, ignoring: {line}" + ) + continue + else: + logger.error( + f"Block must start with s or e on line {line_number}, ignoring: {line}" + ) + continue + else: + new_lines.append(lines[i]) + i += 1 + lines = new_lines + + # If no t0 or location, remove nonsidereal lines + if t0 is None or location is None: + lines = [ + line for line in lines if "nonsidereal" not in line.keys() + ] # Remove nonsidereal lines + + prior_filters = None + prior_exposures = None + + # Parse each line and place into ObservingBlock + blocks = [] + + for line_number, line in enumerate(lines): + # Parse source + source_name = "" + if "source" in line.keys(): + source_name = line["source"] + + # Parse ra and dec + ra = None + dec = None + if "ra" in line.keys() and "dec" in line.keys(): + ra = line["ra"] + dec = line["dec"] + if len(ra.split(":")) == 3: + split_ra = ra.split(":") + ra = f"{split_ra[0]}h{split_ra[1]}m{split_ra[2]}s" + + # Parse nonsidereal + nonsidereal = default_nonsidereal + if "nonsidereal" in line.keys(): + if ( + line["nonsidereal"].startswith("t") + or line["nonsidereal"].startswith("1") + or line["nonsidereal"].startswith("y") + ): + nonsidereal = True + elif ( + line["nonsidereal"].startswith("f") + or line["nonsidereal"].startswith("0") + or line["nonsidereal"].startswith("n") + ): + nonsidereal = False + else: + logger.warning( + f"nonsidereal flag must be true or false on line {line_number}, using parsing function default ({default_nonsidereal}): {line}" + ) + + # Parse pm_ra_cosdec and pm_dec + pm_ra_cosdec = 0 * u.arcsec / u.hour + pm_dec = 0 * u.arcsec / u.hour + if "pm_ra_cosdec" in line.keys() and "pm_dec" in line.keys() and nonsidereal: + pm_ra_cosdec = float(line["pm_ra_cosdec"]) * u.arcsec / u.hour + pm_dec = float(line["pm_dec"]) * u.arcsec / u.hour + elif ( + "pm_ra_cosdec" in line.keys() + and "pm_dec" in line.keys() + and not nonsidereal + ): + logger.warning("Proper motions found on non-nonsidereal line, ignoring") + elif ( + "pm_ra_cosdec" not in line.keys() + and "pm_dec" not in line.keys() + and nonsidereal + and source_name is not None + ): + logger.info( + f"No proper motions found on line {line_number}, will attempt MPC lookup: {source_name}" + ) + try: + ephemerides = mpc.MPC.get_ephemeris( + target=source_name, + location=location, + start=t0.isot, + number=1, + step=1 * u.second, + proper_motion="sky", + ) + ra = ephemerides["RA"][0] + dec = ephemerides["Dec"][0] + pm_ra_cosdec = ephemerides["dRA cos(Dec)"][0] * u.arcsec / u.hour + pm_dec = ephemerides["dDec"][0] * u.arcsec / u.hour + except Exception as e1: + try: + logger.warning( + f"Failed to find proper motions for {source_name} on line {line_number}, trying to find proper motions using astropy.coordinates.get_body: {e1}" + ) + pos_l = coord.get_body( + source_name, t0 - 10 * u.minute, location=location + ) + pos_m = coord.get_body(source_name, t0, location=location) + pos_h = coord.get_body( + source_name, t0 + 10 * u.minute, location=location + ) + ra = pos_m.ra.to_string("hourangle", sep="hms", precision=3) + dec = pos_m.dec.to_string("deg", sep="dms", precision=2) + pm_ra_cosdec = ( + ( + pos_h.ra * np.cos(pos_h.dec.rad) + - pos_l.ra * np.cos(pos_l.dec.rad) + ) + / (pos_h.obstime - pos_l.obstime) + ).to(u.arcsec / u.hour) + pm_dec = ( + (pos_h.dec - pos_l.dec) / (pos_h.obstime - pos_l.obstime) + ).to(u.arcsec / u.hour) + except Exception as e2: + logger.warning( + f"Failed to find proper motions for {source_name} on line {line_number}, skipping: {e2}" + ) + continue + elif ( + "pm_ra_cosdec" not in line.keys() + and "pm_dec" not in line.keys() + and nonsidereal + and source_name is None + ): + logger.warning( + f"No proper motions found on line {line_number} and no source name found for MPC lookup, ignoring nonsidereal: {line}" + ) + elif ( + "pm_ra_cosdec" not in line.keys() + and "pm_dec" in line.keys() + and nonsidereal + ): + logger.warning( + f"Missing proper motion pm_ra_cosdec on line {line_number}, assuming 0: {line}" + ) + elif ( + "pm_ra_cosdec" in line.keys() + and "pm_dec" not in line.keys() + and nonsidereal + ): + logger.warning( + f"Missing proper motion pm_dec on line {line_number}, assuming 0: {line}" + ) + + # Parse source if not already parsed by pm lookup + if source_name is None and ra is None and dec is None: + logger.warning( + f"No source or coordinates found on line {line_number}, skipping: {line}" + ) + continue + elif None not in (ra, dec): + obj = coord.SkyCoord( + ra, + dec, + unit=(u.hourangle, u.deg), + pm_ra_cosdec=pm_ra_cosdec, + pm_dec=pm_dec, + ) + if source_name is None: + source_name = obj.to_string("hmsdms") + elif source_name is not None: + try: + obj = coord.SkyCoord.from_name(source_name) + except Exception as e: + logger.warning( + f"Failed to parse source name on line {line_number}, skipping: {e}" + ) + continue + else: + logger.warning( + f"Failed to parse source on line {line_number}, skipping: {line}" + ) + continue + + # Parse filename + fname = "" + if "filename" in line.keys(): + fname = line["filename"] + # Parse priority + priority = default_priority + if "priority" in line.keys(): + priority = int(line["priority"]) + + # Parse repositioning + repositioning = default_repositioning + if "repositioning" in line.keys(): + if ( + line["repositioning"].startswith("t") + or line["repositioning"].startswith("1") + or line["repositioning"].startswith("y") + ): + repositioning = (-1, -1) + elif ( + line["repositioning"].startswith("f") + or line["repositioning"].startswith("0") + or line["repositioning"].startswith("n") + ): + repositioning = (0, 0) + elif ( + line["repositioning"].split("x")[0].isnumeric() + and line["repositioning"].split("x")[1].isnumeric() + ): + repositioning = ( + int(line["repositioning"].split("x")[0]), + int(line["repositioning"].split("x")[1]), + ) + elif ( + line["repositioning"].split(",")[0].isnumeric() + and line["repositioning"].split(",")[1].isnumeric() + ): + repositioning = ( + int(line["repositioning"].split(",")[0]), + int(line["repositioning"].split(",")[1]), + ) + else: + logger.warning( + f"repositioning flag must be true, false, or integers split with an x on line {line_number}, setting to parsing function default ({default_repositioning}): {line}" + ) + + # Parse shutter state + shutter_state = default_shutter_state + if "shutter_state" in line.keys(): + if ( + line["shutter_state"].startswith("o") + or line["shutter_state"].startswith("t") + or line["shutter_state"].startswith("1") + or line["shutter_state"].startswith("y") + ): + shutter_state = True + elif ( + line["shutter_state"].startswith("c") + or line["shutter_state"].startswith("f") + or line["shutter_state"].startswith("0") + or line["shutter_state"].startswith("n") + ): + shutter_state = False + else: + logger.warning( + f"shutter_state flag must be open or closed on line {line_number}, setting to parsing function default ({default_shutter_state}): {line}" + ) + + # Parse readout + readout = default_readout + if "readout" in line.keys(): + readout = int(line["readout"]) + + # Parse binning + binning = default_binning + if "binning" in line.keys(): + if ( + line["binning"].split("x")[0].isnumeric() + and line["binning"].split("x")[1].isnumeric() + ): + binning = ( + int(line["binning"].split("x")[0]), + int(line["binning"].split("x")[1]), + ) + elif ( + line["binning"].split(",")[0].isnumeric() + and line["binning"].split(",")[1].isnumeric() + ): + binning = ( + int(line["binning"].split(",")[0]), + int(line["binning"].split(",")[1]), + ) + else: + logger.warning( + f"binning must be integers split with an x on line {line_number}, setting to parsing function default ({default_binning}): {line}" + ) + + # Parse frame position + frame_position = default_frame_position + if "frame_position" in line.keys(): + if ( + line["frame_position"].split("x")[0].isnumeric() + and line["frame_position"].split("x")[1].isnumeric() + ): + frame_position = ( + int(line["frame_position"].split("x")[0]), + int(line["frame_position"].split("x")[1]), + ) + elif ( + line["frame_position"].split(",")[0].isnumeric() + and line["frame_position"].split(",")[1].isnumeric() + ): + frame_position = ( + int(line["frame_position"].split(",")[0]), + int(line["frame_position"].split(",")[1]), + ) + else: + logger.warning( + f"frame_position must be integers split with a comma on line {line_number}, setting to parsing function default ({default_frame_position}): {line}" + ) + + # Parse frame size + frame_size = default_frame_size + if "frame_size" in line.keys(): + if ( + line["frame_size"].split("x")[0].isnumeric() + and line["frame_size"].split("x")[1].isnumeric() + ): + frame_size = ( + int(line["frame_size"].split("x")[0]), + int(line["frame_size"].split("x")[1]), + ) + elif ( + line["frame_size"].split(",")[0].isnumeric() + and line["frame_size"].split(",")[1].isnumeric() + ): + frame_size = ( + int(line["frame_size"].split(",")[0]), + int(line["frame_size"].split(",")[1]), + ) + else: + logger.warning( + f"frame_size must be integers split with a comma or x on line {line_number}, setting to parsing function default ({default_frame_size}): {line}" + ) + + # Get utstart, cadence, schederr + utstart = None + if "utstart" in line.keys(): + check_day = False + if "t" not in line["utstart"]: + line["utstart"] = f"{t0.isot.split('T')[0]}T{line['utstart']}" + check_day = True + utstart = astrotime.Time( + line["utstart"].upper(), format="isot", scale="utc" + ) + if check_day: + if utstart.jd < t0.jd: + utstart += 1 * u.day + + cadence = None + if "cadence" in line.keys(): + h, m, s = line["cadence"].split(":") + cadence = astrotime.TimeDelta( + datetime.timedelta(hours=int(h), minutes=int(m), seconds=float(s)), + format="datetime", + ) + + if utstart is None: + logger.error( + f"Must specify utstart if cadence is specified on line {line_number}, skipping: {line}" + ) + continue + + schederr = None + if "schederr" in line.keys(): + h, m, s = line["schederr"].split(":") + schederr = astrotime.TimeDelta( + datetime.timedelta(hours=int(h), minutes=int(m), seconds=float(s)), + format="datetime", + ) + + if utstart is None and schederr is not None: + logger.error( + f"Must specify utstart if schederr is specified on line {line_number}, skipping: {line}" + ) + continue + elif utstart is not None and schederr is None: + logger.info( + f"Assuming schederr is 60 seconds on line {line_number}: {line}" + ) + schederr = 60 * u.second + + # Get exposure behavior keywords + nexp = default_nexp + if "nexp" in line.keys(): + nexp = int(line["nexp"]) + + do_not_interrupt = default_do_not_interrupt + if "do_not_interrupt" in line.keys(): + if ( + line["do_not_interrupt"].startswith("t") + or line["do_not_interrupt"].startswith("1") + or line["do_not_interrupt"].startswith("y") + ): + do_not_interrupt = True + elif ( + line["do_not_interrupt"].startswith("f") + or line["do_not_interrupt"].startswith("0") + or line["do_not_interrupt"].startswith("n") + ): + do_not_interrupt = False + else: + logger.warning( + f"do_not_interrupt flag must be true or false on line {line_number}, setting to parsing function default ({default_do_not_interrupt}): {line}" + ) + + # Get comment + comment = "" + if "comment" in line.keys(): + comment = line["comment"] + + # Get filters + filters = [] + if "filter" in line.keys(): + filters = line["filter"].split(",") + prior_filters = filters + elif prior_filters is not None: + filters = prior_filters + else: + filters = [] + prior_filters = None + + # Get exposures + exposures = [] + if "exposures" in line.keys(): + exposures = [float(e) for e in line["exposures"].split(",")] + prior_exposures = exposures + elif prior_exposures is not None: + exposures = prior_exposures + else: + exposures = [] + prior_exposures = None + + # Expand exposures or filters to match length of the other if either length is one + if len(exposures) == 1 and len(filters) > 1: + exposures = exposures * len(filters) + elif len(filters) == 1 and len(exposures) > 1: + filters = filters * len(exposures) + + # Sanity Check 1: matching number of filters and exposures + if len(filters) != len(exposures) and len(filters) != 0: + logger.error( + f"Number of filters ({len(filters)}) does not match number of exposures ({len(exposures)}) on line {line_number}, skipping: {line}" + ) + continue + + # Sanity Check 2: do_not_interrupt and cadence don't both appear: + if do_not_interrupt and cadence is not None: + logger.error( + f"do_not_interrupt and cadence cannot both be specified on line {line_number}, skipping: {line}" + ) + continue + + # Sanity Check 3: if cadence is specified, verify it exceeds exposure time + # times number of exposures times number of filters + if cadence is not None: + if cadence.to(u.second).value < np.sum(exposures) * nexp * len(filters): + logger.warning( + f"Cadence ({cadence}) is less than total exposure time ({np.sum(exposures) * nexp * len(filters)}) on line {line_number}, setting cadence to total exposure time: {line}" + ) + cadence = np.sum(exposures) * nexp * len(filters) + + for i in range(len(filters)): + filt = filters[i] + exp = exposures[i] + constraints = None + + if do_not_interrupt: + loop_max = 1 + temp_dur = exp * nexp * u.second + temp_nexp = nexp + else: + loop_max = nexp + temp_dur = exp * u.second + temp_nexp = 1 + + if utstart is not None: + if cadence is not None: + constraint_cadence = cadence + else: + constraint_cadence = temp_dur + + constraints = [ + [ + astroplan.constraints.TimeConstraint( + min=( + utstart + + (i + j * len(filters)) * constraint_cadence + - schederr + ), + max=( + utstart + + (i + j * len(filters)) * constraint_cadence + + schederr + ), + ) + ] + for j in range(loop_max) + ] + else: + constraints = [None for j in range(loop_max)] + + for j in range(loop_max): + if loop_max > 1 and fname != "": + final_fname = f"{fname}_{j}" + else: + final_fname = f"{fname}" + blocks.append( + astroplan.ObservingBlock( + target=obj, + duration=temp_dur, + priority=priority, + name=source_name, + configuration={ + "observer": observers, + "code": code, + "title": title, + "filename": final_fname, + "type": "light", + "backend": 0, + "filter": filt, + "exposure": exp, + "nexp": temp_nexp, + "repositioning": repositioning, + "shutter_state": shutter_state, + "readout": readout, + "binning": binning, + "frame_position": frame_position, + "frame_size": frame_size, + "pm_ra_cosdec": pm_ra_cosdec, + "pm_dec": pm_dec, + "comment": comment, + "sch": filename.split("/")[-1].split(".")[0], + "ID": astrotime.Time.now(), + "status": "U", + "message": "Unscheduled", + "sched_time": None, + }, + constraints=constraints[j], + ) + ) + logger.debug( + f"""Created ObservingBlock: {blocks[-1].target}, + {blocks[-1].duration}, {blocks[-1].priority}, + {blocks[-1].name}, {blocks[-1].constraints}, + {blocks[-1].configuration}""" + ) + + return blocks + + +def write(observing_blocks, filename=None): + if type(observing_blocks) is not list: + observing_blocks = [observing_blocks] + + codes = [] + for block in observing_blocks: + if type(block) is not astroplan.scheduling.ObservingBlock: + logger.exception("observing_blocks must be a list of ObservingBlocks") + return + codes.append(block.configuration["code"]) + + unique_codes = list(set(codes)) + + time_now = astrotime.Time.now().strftime("%Y-%m-%d_%H:%M:%S") + + for unique_code in unique_codes: + blocks = [ + block + for block in observing_blocks + if block.configuration["code"] == unique_code + ] + + if [block.configuration["title"] for block in blocks].count( + blocks[0].configuration["title"] + ) != len(blocks): + logger.warning( + f"Title must be the same for all blocks with the same code {unique_code}, setting all titles to first title ({blocks[0].configuration['title']})" + ) + blocks = [ + block.configuration.update("title", blocks[0].configuration["title"]) + for block in blocks + ] + + if [block.configuration["observer"] for block in blocks].count( + blocks[0].configuration["observer"] + ) != len(blocks): + logger.warning( + f"Observer must be the same for all blocks with the same code {unique_code}, setting all observers to first observer ({blocks[0].configuration['observer']})" + ) + blocks = [ + block.configuration.update( + "observer", blocks[0].configuration["observer"] + ) + for block in blocks + ] + + if filename is None: + filename = f"{unique_code}_{time_now}.sch" + elif len(unique_codes) > 1: + filename = filename.replace(".sch", f"_{unique_code}_{time_now}.sch") + else: + filename = filename + + with open(filename, "w") as f: + f.write(f"# {len(blocks)} Blocks\n") + f.write(f"# Written {time_now}\n") + f.write(f"# By pyscope version {__version__}\n") + f.write("\n") + + f.write('title "{0}"\n'.format(blocks[0].configuration["title"])) + if type(blocks[0].configuration["observer"]) is not list: + observers = [blocks[0].configuration["observer"]] + else: + observers = blocks[0].configuration["observer"] + for observer in observers: + f.write(f'observer "{observer}"\n') + f.write(f"code {unique_code}\n") + f.write("\n") + + for block in blocks: + write_string = "block start\n" + try: + if block.name != "": + write_string += f'source "{block.name}"\n' + except: + pass + write_string += f"ra {block.target.ra.to_string('hourangle', sep='hms', precision=4)}\n" + write_string += ( + f"dec {block.target.dec.to_string('deg', sep='dms', precision=3)}\n" + ) + try: + write_string += f"priority {block.priority}\n" + except: + pass + try: + if block.configuration["filename"] != "": + write_string += 'filename "{0}"\n'.format( + block.configuration["filename"] + ) + except: + pass + try: + if ( + block.configuration["pm_ra_cosdec"].value != 0 + or block.configuration["pm_dec"].value != 0 + ): + write_string += f"nonsidereal true\n" + write_string += f"pm_ra_cosdec {block.configuration['pm_ra_cosdec'].to(u.arcsec/u.hour).value}\n" + write_string += f"pm_dec {block.configuration['pm_dec'].to(u.arcsec/u.hour).value}\n" + else: + write_string += f"nonsidereal false\n" + except: + pass + try: + if block.configuration["shutter_state"]: + write_string += f"shutter_state open\n" + else: + write_string += f"shutter_state closed\n" + except: + pass + write_string += f"exposure {block.configuration['exposure']}\n" + write_string += f"nexp {block.configuration['nexp']}\n" + try: + if block.configuration["do_not_interrupt"]: + write_string += f"do_not_interrupt true\n" + else: + write_string += f"do_not_interrupt false\n" + except: + pass + try: + write_string += f"readout {block.configuration['readout']}\n" + except: + pass + try: + write_string += f"binning {block.configuration['binning'][0]}x{block.configuration['binning'][1]}\n" + except: + pass + write_string += f"filter {block.configuration['filter']}\n" + try: + if block.configuration["repositioning"] is True: + write_string += f"repositioning true\n" + elif type(block.configuration["repositioning"]) is tuple: + write_string += f"repositioning {block.configuration['repositioning'][0]}x{block.configuration['repositioning'][1]}\n" + except: + pass + try: + write_string += f"frame_position {block.configuration['frame_position'][0]}x{block.configuration['frame_position'][1]}\n" + except: + pass + try: + write_string += f"frame_size {block.configuration['frame_size'][0]}x{block.configuration['frame_size'][1]}\n" + except: + pass + try: + write_string += f"utstart {block.start_time.isot}\n" + except: + try: + if block.constraints is not None: + if type(block.constraints) is not list: + block.constraints = [block.constraints] + for ( + constraint + ) in ( + block.constraints + ): # TODO: Add in support for all constraints + possible_min_times = [] + possible_max_times = [] + if type(constraint) is astroplan.TimeConstraint: + possible_min_times.append(constraint.min) + possible_max_times.append(constraint.max) + min_time_idx = np.argmax( + [time.jd for time in possible_min_times] + ) + max_time_idx = np.argmin( + [time.jd for time in possible_max_times] + ) + min_time = possible_min_times[min_time_idx] + max_time = possible_max_times[max_time_idx] + mid_time = astrotime.Time( + (min_time.jd + max_time.jd) / 2, format="jd" + ) + error_time = round( + astrotime.TimeDelta( + (max_time.jd - min_time.jd) / 2, format="jd" + ).sec, + 3, + ) + err_hours = int(error_time / 3600) + err_minutes = int((error_time - err_hours * 3600) / 60) + err_seconds = ( + error_time - err_hours * 3600 - err_minutes * 60 + ) + write_string += f"utstart {mid_time.isot}\n" + write_string += f"schederr {err_hours:02.0f}:{err_minutes:02.0f}:{err_seconds:02.3f}\n" + except: + pass + try: + if block.configuration["comment"] != "": + write_string += 'comment "{comment} -- written by pyscope v{version}"\n'.format( + comment=block.configuration["comment"], version=__version__ + ) + else: + write_string += f'comment "written by pyscope v{__version__}"\n' + except: + write_string += f'comment "written by pyscope v{__version__}"\n' + + f.write(write_string + "block end\n\n") + f.write("\n") diff --git a/pyscope/telrun/schedtab.py b/pyscope/telrun/schedtab.py new file mode 100644 index 00000000..590d767a --- /dev/null +++ b/pyscope/telrun/schedtab.py @@ -0,0 +1,796 @@ +import configparser +import itertools +import logging +import os + +import astroplan +import numpy as np +from astropy import coordinates as coord +from astropy import table +from astropy import time as astrotime +from astropy import units as u + +logger = logging.getLogger(__name__) + + +def blocks_to_table(observing_blocks): + """Convert a list of observing blocks to an astropy table. + + Parameters + ---------- + observing_blocks : list + A list of observing blocks. + + Returns + ------- + table : astropy.table.Table + An astropy table containing the observing blocks. + """ + + t = table.Table(masked=True) + + unscheduled_blocks_mask = np.array( + [ + type(block) is astroplan.ObservingBlock and block.start_time is None + for block in observing_blocks + ] + ) + + open_slots_mask = np.array( + [type(block) is astroplan.Slot for block in observing_blocks] + ) + + # Populate simple columns + t["name"] = [ + block.name + if type(block) is astroplan.ObservingBlock + else "TransitionBlock" + if type(block) is astroplan.TransitionBlock + else "EmptyBlock" + for block in observing_blocks + ] + + t["start_time"] = astrotime.Time( + np.ma.array( + [ + block.start.jd + if type(block) is astroplan.Slot + else 0 + if block.start_time is None + else block.start_time.jd + for block in observing_blocks + ], + mask=unscheduled_blocks_mask, + ), + format="jd", + ) + + t["end_time"] = astrotime.Time( + np.ma.array( + [ + block.end.jd + if type(block) is astroplan.Slot + else 0 + if block.end_time is None + else block.end_time.jd + for block in observing_blocks + ], + mask=unscheduled_blocks_mask, + ), + format="jd", + ) + + t["target"] = coord.SkyCoord( + [ + block.target.to_string("hmsdms") + if type(block) is astroplan.ObservingBlock + else "0h0m0.0s -90d0m0.0s" + for block in observing_blocks + ] + ) + + t["priority"] = np.ma.array( + [ + block.priority if type(block) is astroplan.ObservingBlock else 0 + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + temp_list = [ + block.configuration["observer"] + if type(block) is astroplan.ObservingBlock + else [""] + for block in observing_blocks + ] + t["observer"] = np.ma.array( + temp_list, mask=_mask_expander(temp_list, open_slots_mask) + ) + + t["code"] = np.ma.array( + [ + block.configuration["code"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["title"] = np.ma.array( + [ + block.configuration["title"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["filename"] = np.ma.array( + [ + block.configuration["filename"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["type"] = np.ma.array( + [ + block.configuration["type"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["backend"] = np.ma.array( + [ + block.configuration["backend"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["filter"] = np.ma.array( + [ + block.configuration["filter"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["exposure"] = np.ma.array( + [ + block.configuration["exposure"] + if type(block) is astroplan.ObservingBlock + else 0 + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["nexp"] = np.ma.array( + [ + block.configuration["nexp"] + if type(block) is astroplan.ObservingBlock + else 0 + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + temp_list = [ + block.configuration["repositioning"] + if type(block) is astroplan.ObservingBlock + else (0, 0) + for block in observing_blocks + ] + t["repositioning"] = np.ma.array( + temp_list, mask=_mask_expander(temp_list, open_slots_mask) + ) + + t["shutter_state"] = np.ma.array( + [ + block.configuration["shutter_state"] + if type(block) is astroplan.ObservingBlock + else False + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["readout"] = np.ma.array( + [ + block.configuration["readout"] + if type(block) is astroplan.ObservingBlock + else 0 + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + temp_list = [ + block.configuration["binning"] + if type(block) is astroplan.ObservingBlock + else (1, 1) + for block in observing_blocks + ] + t["binning"] = np.ma.array( + temp_list, mask=_mask_expander(temp_list, open_slots_mask) + ) + + temp_list = [ + block.configuration["frame_position"] + if type(block) is astroplan.ObservingBlock + else (0, 0) + for block in observing_blocks + ] + t["frame_position"] = np.ma.array( + temp_list, mask=_mask_expander(temp_list, open_slots_mask) + ) + + temp_list = [ + block.configuration["frame_size"] + if type(block) is astroplan.ObservingBlock + else (0, 0) + for block in observing_blocks + ] + t["frame_size"] = np.ma.array( + temp_list, mask=_mask_expander(temp_list, open_slots_mask) + ) + + t["pm_ra_cosdec"] = np.ma.array( + [ + block.configuration["pm_ra_cosdec"].to(u.arcsec / u.hour).value + if type(block) is astroplan.ObservingBlock + else 0 + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["pm_dec"] = np.ma.array( + [ + block.configuration["pm_dec"].to(u.arcsec / u.hour).value + if type(block) is astroplan.ObservingBlock + else 0 + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["comment"] = np.ma.array( + [ + block.configuration["comment"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["sch"] = np.ma.array( + [ + block.configuration["sch"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["ID"] = np.ma.array( + [ + block.configuration["ID"] + if type(block) is astroplan.ObservingBlock + else astrotime.Time(0, format="jd") + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["status"] = np.ma.array( + [ + block.configuration["status"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["message"] = np.ma.array( + [ + block.configuration["message"] + if type(block) is astroplan.ObservingBlock + else "" + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + t["sched_time"] = np.ma.array( + [ + block.configuration["sched_time"] + if type(block) is astroplan.ObservingBlock + else astrotime.Time(0, format="jd") + for block in observing_blocks + ], + mask=open_slots_mask, + ) + + # Turn the constraints into a list of dicts + constraints = np.full( + ( + len(observing_blocks), + np.max( + [ + len(block.constraints) + if type(block) is astroplan.ObservingBlock + else 0 + for block in observing_blocks + ] + ), + ), + dict(), + ) + for block_num, block in enumerate(observing_blocks): + constraint_list = np.full( + np.max( + [ + len(block.constraints) + if type(block) is astroplan.ObservingBlock + else 0 + for block in observing_blocks + ] + ), + dict(), + ) + if type(block) is astroplan.ObservingBlock: + for constraint_num, constraint in enumerate(block.constraints): + if type(constraint) is astroplan.TimeConstraint: + constraint_dict = { + "type": "TimeConstraint", + "min": ( + constraint.min.isot if constraint.min is not None else None + ), + "max": ( + constraint.max.isot if constraint.max is not None else None + ), + } + elif type(constraint) is astroplan.AtNightConstraint: + constraint_dict = { + "type": "AtNightConstraint", + "max_solar_altitude": constraint.max_solar_altitude.to( + u.deg + ).value + if constraint.max_solar_altitude is not None + else 0, + } + elif type(constraint) is astroplan.AltitudeConstraint: + constraint_dict = { + "type": "AltitudeConstraint", + "min": constraint.min.to(u.deg).value + if constraint.min is not None + else 0, + "max": constraint.max.to(u.deg).value + if constraint.max is not None + else 90, + "boolean_constraint": constraint.boolean_constraint, + } + elif type(constraint) is astroplan.AirmassConstraint: + constraint_dict = { + "type": "AirmassConstraint", + "min": constraint.min if constraint.min is not None else 0, + "max": constraint.max if constraint.max is not None else 100, + "boolean_constraint": constraint.boolean_constraint + if constraint.boolean_constraint is not None + else False, + } + elif type(constraint) is astroplan.MoonSeparationConstraint: + constraint_dict = { + "type": "MoonSeparationConstraint", + "min": constraint.min.to(u.deg).value + if constraint.min is not None + else 0, + "max": constraint.max.to(u.deg).value + if constraint.max is not None + else 360, + } + elif constraint is None: + continue + else: + logger.warning( + f"Constraint {constraint} is not supported and will be ignored" + ) + continue + constraint_list[constraint_num] = constraint_dict + constraints[block_num] = constraint_list + + t["constraints"] = np.ma.array( + constraints, mask=_mask_expander(constraints, open_slots_mask) + ) + + return t + + +def table_to_blocks(table): + blocks = [] + for row in table: + # parse the constraints + constraints = [] + for constraint in row["constraints"]: + try: + if constraint["type"] == "TimeConstraint": + constraints.append( + astroplan.TimeConstraint( + min=astrotime.Time(constraint["min"]), + max=astrotime.Time(constraint["max"]), + ) + ) + elif constraint["type"] == "AtNightConstraint": + constraints.append( + astroplan.AtNightConstraint( + max_solar_altitude=constraint["max_solar_altitude"] * u.deg + ) + ) + elif constraint["type"] == "AltitudeConstraint": + constraints.append( + astroplan.AltitudeConstraint( + min=constraint["min"] * u.deg, + max=constraint["max"] * u.deg, + boolean_constraint=constraint["boolean_constraint"], + ) + ) + elif constraint["type"] == "AirmassConstraint": + constraints.append( + astroplan.AirmassConstraint( + min=constraint["min"], + max=constraint["max"], + boolean_constraint=constraint["boolean_constraint"], + ) + ) + elif constraint["type"] == "MoonSeparationConstraint": + constraints.append( + astroplan.MoonSeparationConstraint( + min=constraint["min"] * u.deg, + max=constraint["max"] * u.deg, + ) + ) + else: + logger.warning("Only time constraints are currently supported") + continue + except: + constraints.append(None) + + if row["ID"] is None: + row["ID"] = astrotime.Time.now() + + blocks.append( + astroplan.ObservingBlock( + target=astroplan.FixedTarget(row["target"]), + duration=row["exposure"] * row["nexp"] * u.second, + priority=row["priority"], + name=row["name"], + configuration={ + "observer": row["observer"], + "code": row["code"], + "title": row["title"], + "filename": row["filename"], + "type": row["type"], + "backend": row["backend"], + "filter": row["filter"], + "exposure": row["exposure"], + "nexp": row["nexp"], + "repositioning": row["repositioning"], + "shutter_state": row["shutter_state"], + "readout": row["readout"], + "binning": row["binning"], + "frame_position": row["frame_position"], + "frame_size": row["frame_size"], + "pm_ra_cosdec": row["pm_ra_cosdec"], + "pm_dec": row["pm_dec"], + "comment": row["comment"], + "sch": row["sch"], + "ID": row["ID"], + "status": row["status"], + "message": row["message"], + "sched_time": row["sched_time"], + }, + constraints=constraints, + ) + ) + + return blocks + + +def validate(schedule_table, observatory=None): + logger.info("Validating the schedule table") + + if observatory is None: + logger.info( + "No observatory was specified, so validation will only check for basic formatting errors." + ) + + convert_to_blocks = False + if type(schedule_table) is list: + logger.info("Converting list of blocks to astropy table") + schedule_table = blocks_to_table(schedule_table) + convert_to_blocks = True + + assert ( + type(schedule_table) is table.Table + ), "schedule_table must be an astropy table" + + # Check for required columns + required_columns = [ + "name", + "start_time", + "end_time", + "target", + "priority", + "observer", + "code", + "title", + "filename", + "type", + "backend", + "filter", + "exposure", + "nexp", + "repositioning", + "shutter_state", + "readout", + "binning", + "frame_position", + "frame_size", + "pm_ra_cosdec", + "pm_dec", + "comment", + "sch", + "ID", + "status", + "message", + "sched_time", + "constraints", + ] + for column in required_columns: + if column not in schedule_table.columns: + logger.error(f"Column {column} is missing") + raise ValueError(f"Column {column} is missing") + + # Check dtypes + for colname in schedule_table.colnames: + column = schedule_table[colname] + match colname: + case ( + "name" + | "observer" + | "code" + | "title" + | "filename" + | "type" + | "filter" + | "comment" + | "sch" + | "status" + | "message" + ): + if not np.issubdtype(column.dtype, np.dtype("U")): + logger.error( + f"Column '{column.name}' must be of type str, not {column.dtype}" + ) + raise ValueError( + f"Column '{column.name}' must be of type str, not {column.dtype}" + ) + case ("start_time" | "end_time" | "sched_time"): + if type(column) is not astrotime.Time: + logger.error( + f"Column '{column.name}' must be of type astropy.time.Time, not {type(column)}" + ) + raise ValueError( + f"Column '{column.name}' must be of type astropy.time.Time, not {type(column)}" + ) + case ("target"): + if type(column) is not coord.SkyCoord: + logger.error( + f"Column '{column.name}' must be of type astropy.coordinates.SkyCoord, not {type(column)}" + ) + raise ValueError( + f"Column '{column.name}' must be of type astropy.coordinates.SkyCoord, not {type(column)}" + ) + case ( + "priority" + | "nexp" + | "readout" + | "frame_position" + | "frame_size" + | "binning" + | "repositioning" + ): + if not np.issubdtype(column.dtype, np.dtype("int64")): + logger.error( + f"Column '{column.name}' must be of type int64, not {column.dtype}" + ) + raise ValueError( + f"Column '{column.name}' must be of type int64, not {column.dtype}" + ) + case ("exposure" | "pm_ra_cosdec" | "pm_dec"): + if not np.issubdtype(column.dtype, np.dtype("float64")): + logger.error( + f"Column '{column.name}' must be of type float64, not {column.dtype}" + ) + raise ValueError( + f"Column '{column.name}' must be of type float64, not {column.dtype}" + ) + case ("shutter_state"): + if column.dtype != bool: + logger.error( + f"Column '{column.name}' must be of type bool, not {column.dtype}" + ) + raise ValueError( + f"Column '{column.name}' must be of type bool, not {column.dtype}" + ) + + # Check ID column + for row in schedule_table: + if ( + row["ID"] is None + and row["name"] != "TransitionBlock" + and row["name"] != "EmptyBlock" + ): + row["ID"] = astrotime.Time.now() + logger.info(f"Assigned {row['ID']} to row {row.index}") + + if observatory is not None: + logger.info("Performing observatory-specific validation") + + for row in schedule_table: + logger.info(f"Validating row {row.index}") + + if row["name"] == "TransitionBlock" or row["name"] == "EmptyBlock": + logger.info(f"Skipping validation of {row['name']}") + continue + + # Check if target is observable at start time + altaz_obj = observatory.get_object_altaz( + obj=row["target"], + t=row["start_time"], + ) + if altaz_obj.alt < observatory.min_altitude: + logger.error("Target is not observable at start time") + row["status"] = "I" # Invalid + row["message"] = "Target is not observable at start time" + continue + + # Check if source is observable at end time + altaz_obj = observatory.get_object_altaz( + obj=row["target"], + t=row["end_time"], + ) + if altaz_obj.alt < observatory.min_altitude: + logger.error("Target is not observable at end time") + row["status"] = "I" # Invalid + row["message"] = "Target is not observable at end time" + continue + + # Check filter + if len(observatory.filters) == 0: + logger.info("No filters available, no filter check performed") + elif row["filter"] not in observatory.filters: + logger.error("Requested filter is not available") + row["status"] = "I" # Invalid + row["message"] = "Requested filter is not available" + continue + + # Check exposure time + try: + current_cam_state = observatory.camera.Connected + observatory.camera.Connected = True + if row["exposure"].to(u.second).value > observatory.camera.ExposureMax: + logger.error("Exposure time exceeds maximum") + row["status"] = "I" # Invalid + row["message"] = "Exposure time exceeds maximum" + continue + elif ( + row["exposure"].to(u.second).value < observatory.camera.ExposureMin + ): + logger.error("Exposure time is below minimum") + row["status"] = "I" # Invalid + row["message"] = "Exposure time is below minimum" + continue + observatory.camera.Connected = current_cam_state + except: + logger.warning( + "Exposure time range check failed because the driver is not available" + ) + + # Check repositioning, frame position, and frame size + try: + current_cam_state = observatory.camera.Connected + observatory.camera.Connected = True + if ( + ( + row["repositioning"][0] > observatory.camera.CameraXSize + or row["repositioning"][1] > observatory.camera.CameraYSize + ) + and row["repositioning"] != [0, 0] + and row["repositioning"] != [None, None] + ): + logger.error("Repositioning coordinates exceed camera size") + row["status"] = "I" # Invalid + row["message"] = "Repositioning coordinates exceed camera size" + if ( + ( + row["frame_position"][0] + row["frame_size"][0] + > observatory.camera.CameraXSize + or row["frame_position"][1] + row["frame_size"][1] + > observatory.camera.CameraYSize + ) + and row["frame_position"] != [0, 0] + and row["frame_size"] != [0, 0] + ): + logger.error("Frame position and size exceed camera size") + row["status"] = "I" # Invalid + row["message"] = "Frame position and size exceed camera size" + observatory.camera.Connected = current_cam_state + except: + logger.warning( + "Repositioning check failed because the driver is not available" + ) + + # Check readout + try: + current_cam_state = observatory.camera.Connected + observatory.camera.Connected = True + if row["readout"] >= len(observatory.camera.ReadoutModes): + logger.error("Readout mode not available") + row["status"] = "I" # Invalid + row["message"] = "Readout mode not available" + observatory.camera.Connected = current_cam_state + except: + logger.warning( + "Readout mode check failed because the driver is not available" + ) + + # Check binning + try: + current_cam_state = observatory.camera.Connected + observatory.camera.Connected = True + if row["binning"][0] > observatory.camera.MaxBinX: + logger.error("Binning exceeds maximum in X") + row["status"] = "I" # Invalid + row["message"] = "Binning exceeds maximum in X" + if row["binning"][1] > observatory.camera.MaxBinY: + logger.error("Binning exceeds maximum in Y") + row["status"] = "I" + row["message"] = "Binning exceeds maximum in Y" + if ( + row["binning"][0] != row["binning"][1] + and not observatory.camera.CanAsymmetricBin + ): + logger.error("Binning must be square") + row["status"] = "I" + row["message"] = "Binning must be square" + observatory.camera.Connected = current_cam_state + except: + logger.warning( + "Binning check failed because the driver is not available" + ) + + if convert_to_blocks: + logger.info("Converting astropy table back to list of blocks") + schedule_table = table_to_blocks(schedule_table) + return schedule_table + + +def _mask_expander(arr, mask): + return np.array([[mask[i]] * len(arr[i]) for i in range(len(arr))]).ravel() diff --git a/pyscope/telrun/schedtel.py b/pyscope/telrun/schedtel.py index 36cc3917..e9ebb4c3 100644 --- a/pyscope/telrun/schedtel.py +++ b/pyscope/telrun/schedtel.py @@ -3,17 +3,16 @@ import json import logging import os -import shlex +import zoneinfo import astroplan import click import cmcrameri as ccm import matplotlib.dates as mdates import matplotlib.pyplot as plt -import pytz -import smplotlib import timezonefinder from astropy import coordinates as coord +from astropy import table from astropy import time as astrotime from astropy import units as u from astroquery import mpc @@ -21,19 +20,58 @@ from .. import utils from ..observatory import Observatory +from . import sch, schedtab logger = logging.getLogger(__name__) -@click.command() +@click.command( + epilog="""Check out the documentation at + https://pyscope.readthedocs.io/en/latest/ + for more information.""" +) @click.option( "-c", "--catalog", - type=click.Path(resolve_path=True), - default="schedules/schedule.cat", + type=click.Path(exists=True, resolve_path=True, dir_okay=False, readable=True), + help="""The catalog of .sch files to be scheduled. The catalog can be a + single .sch file or a .cat file containing a list of .sch files. If no + catalog is provided, then the function searches for a schedule.cat file + in the $OBSERVATORY_HOME/schedules/ directory, then searches + in the current working directory.""", +) +@click.option( + "-q", + "--queue", + type=click.Path( + exists=True, resolve_path=True, dir_okay=False, readable=True, writable=True + ), + help="""A queue table of requested observations (.ecsv). If a catalog is provided + and -t is set, then the catalog is parsed and the queue is updated with those entries. + If no catalog is provided, the queue is scheduled. WARNING: If a catalog is provided, + then existing scheduled entries in the queue will be overwritten. If no catalog is provided, + then all entries in the queue (including previously scheduled entries) will be re-scheduled.""", +) +@click.option( + "-ao", + "--add-only", + "add_only", + is_flag=True, + default=False, show_default=True, - help="A path to a .cat file containing a list of .sch files, a single .sch file, or a list of ObservingBlocks to be scheduled.", + help="""If a catalog and a queue are provided, then only add the catalog + entries to the queue without scheduling them. By default, the catalog is + parsed, scheduled, and the queue is updated with the scheduled entries.""", ) +# TODO: Add option to update an existing schedule table +# @click.option( +# "-es", +# "--existing-schedule", +# "existing_schedule", +# type=click.Path(exists=True, resolve_path=True, dir_okay=False, readable=True, +# writable=True), +# help="""An existing schedule table (.ecsv) to be updated.""" +# ) @click.option( "-i", "--ignore-order", @@ -41,32 +79,47 @@ is_flag=True, default=False, show_default=True, - help="Ignore the order of the .sch files in the catalog, acting as if there is only one .sch file. By default, the .sch files are scheduled one at a time.", + help="""Ignore the order of the .sch files in the catalog, + acting as if there is only one .sch file. By default, the + .sch files are scheduled one at a time.""", ) @click.option( "-d", "--date", - type=click.DateTime(formats=["%m-%d-%Y"]), + type=click.DateTime(formats=["%Y-%m-%d"]), default=None, show_default=False, - help="The local date at the observatory of the night to be scheduled. By default, the current date at the observatory location is used.", + help="""The local date at the observatory of the night to be scheduled. + By default, the current date at the observatory location is used.""", +) +@click.option( + "-l", + "--length", + type=click.IntRange(min=1, clamp=True), + default=1, + show_default=True, + help="""The length of the schedule [days].""", ) @click.option( "-o", "--observatory", - type=click.Path(resolve_path=True), - default="./config/observatory.cfg", - show_default=True, - help=("The configuration file of the observatory that will execute this schedule"), + "observatory", + type=click.Path(exists=True, resolve_path=True, dir_okay=False, readable=True), + help="""The observatory configuration file. If no observatory configuration + file is provided, then the function searches for an observatory.cfg file + in the $OBSERVATORY_HOME/config/ directory, then searches in the current working + directory.""", ) @click.option( "-m", "--max-altitude", "max_altitude", - type=click.FloatRange(max=90), + type=click.FloatRange(min=-90, max=90, clamp=True), default=-12, show_default=True, - help="The maximum altitude of the Sun for the night [degrees]. Civil twilight is -6, nautical twilight is -12, and astronomical twilight is -18.", + help="""The maximum altitude of the Sun for the night [degrees]. + Civil twilight is -6, nautical twilight is -12, and astronomical + twilight is -18.""", ) @click.option( "-e", @@ -74,20 +127,24 @@ type=click.FloatRange(min=0, max=90, clamp=True), default=30, show_default=True, - help='The minimum elevation of all targets [degrees]. This is a "boolean" constraint; that is, there is no understanding of where it is closer to ideal to observe the target. To implement a preference for higher elevation targets, the airmass constraint should be used.', + help="""The minimum elevation of all targets [degrees]. This is a + 'boolean' constraint; that is, there is no understanding of where + it is closer to ideal to observe the target. To implement a preference + for higher elevation targets, the airmass constraint should be used.""", ) @click.option( "-a", "--airmass", - type=click.FloatRange(min=1), + type=click.FloatRange(min=1, clamp=True), default=3, show_default=True, - help="The maximum airmass of all targets. If not specified, the airmass is not constrained.", + help="""The maximum airmass of all targets.""", ) @click.option( - "-c", + "-ms", "--moon-separation", - type=click.FloatRange(min=0), + "moon_separation", + type=click.FloatRange(min=0, clamp=True), default=30, show_default=True, help="The minimum angular separation between the Moon and all targets [degrees].", @@ -96,53 +153,88 @@ "-s", "--scheduler", nargs=2, - type=(click.Path(exists=True, resolve_path=True), str), + type=( + click.Path(exists=True, resolve_path=True, dir_okay=False, executable=True), + str, + ), default=("", ""), show_default=False, - help=( - "The filepath to and name of an astroplan.Scheduler custom sub-class. By default, the astroplan.PriorityScheduler is used." - ), + help="""The filepath to and name of an astroplan.Scheduler + custom sub-class. By default, the astroplan.PriorityScheduler is used.""", ) @click.option( - "-g", + "-gt", "--gap-time", "gap_time", - type=click.FloatRange(min=0), + type=click.FloatRange(min=0, clamp=True), default=60, show_default=True, - help="The maximumum length of time a transition between ObservingBlocks could take [seconds].", + help="""The maximum length of time a transition between + ObservingBlocks could take [seconds].""", ) @click.option( "-r", "--resolution", - type=click.FloatRange(min=0), + type=click.FloatRange(min=1, clamp=True), default=5, show_default=True, - help="The time resolution of the schedule [seconds].", + help="""The time resolution of the schedule [seconds].""", +) +@click.option( + "-nf", + "--name-format", + "name_format", + type=str, + default="{code}_{sch}_{ra}_{dec}_{start_time}", + show_default=True, + help="""The format of the scheduled image name. The format + is a string that can include any column from the schedule table or + the configuration dictionary. + """, ) @click.option( "-f", "--filename", - type=click.Path(), + type=click.Path(resolve_path=True, dir_okay=False, writable=True), default=None, show_default=False, - help="The output file name. The file name is formatted with the UTC date of the first observation in the schedule. By default, it is placed in the current working directory, but if a path is specified, the file will be placed there. WARNING: If the file already exists, it will be overwritten.", + help="""The output file name. The file name is formatted with the + UTC date of the first observation in the schedule. By default, + it is placed in the current working directory, but if a path is specified, + the file will be placed there. WARNING: If the file already exists, + it will be overwritten.""", ) @click.option( "-t", - "--telrun", + "--telrun-execute", + "telrun_execute", is_flag=True, default=False, show_default=True, - help="Places the output file in ./schedules/ or in the directory specified by the $TELRUN_EXECUTE environment variable. Causes -f/--filename to be ignored. WARNING: If the file already exists, it will be overwritten.", + help="""Places the output file in specified by the $TELRUN_EXECUTE environment + variable. If not defined, then the $OBSERVATORY_HOME/schedules/ directory is used. + If neither are defined, then ./schedules/ is used. WARNING: If the file already exists, + it will be overwritten.""", ) @click.option( "-p", "--plot", - type=click.IntRange(1, 3), + type=click.IntRange(1, 3, clamp=True), default=None, show_default=False, - help="Plots the schedule. 1: plots the schedule as a Gantt chart. 2: plots the schedule by target with airmass. 3: plots the schedule on a sky chart.", + help="""Plots the schedule. The argument specifies the type of plot: + 1: Gantt chart. + 2: target with airmass. + 3: sky chart.""", +) +@click.option( + "-y", + "--yes", + is_flag=True, + default=False, + show_default=True, + help="""Automatically answer yes to any questions. This will write the schedule + to file even if there are unscheduled or invalid blocks.""", ) @click.option( "-q", "--quiet", is_flag=True, default=False, show_default=True, help="Quiet output" @@ -152,22 +244,28 @@ ) @click.version_option() def schedtel_cli( - catalog, - ignore_order, - date, - observatory, - max_altitude, - elevation, - airmass, - moon_separation, - scheduler, - gap_time, - resolution, - filename, - telrun, - plot, - quiet, - verbose, + catalog=None, + queue=None, + add_only=False, + # existing_schedule=None, # TODO + ignore_order=False, + date=None, + length=1, + observatory=None, + max_altitude=-12, + elevation=30, + airmass=3, + moon_separation=30, + scheduler=("", ""), + gap_time=60, + resolution=5, + name_format="{code}_{sch}_{ra}_{dec}_{start_time}", + filename=None, + telrun=False, + plot=None, + yes=True, + quiet=False, + verbose=0, ): # Set up logging if quiet: @@ -198,84 +296,225 @@ def schedtel_cli( logger.debug(f"quiet: {quiet}") logger.debug(f"verbose: {verbose}") - blocks = [] + # Set the schedule time + sched_time = astrotime.Time.now() + + # Define the observatory + if observatory is None: + try: + observatory = os.environ.get("OBSERVATORY_HOME") + "/config/observatory.cfg" + logger.info( + "No observatory provided, using observatory.cfg from $OBSERVATORY_HOME environment variable" + ) + except: + observatory = os.getcwd() + "/observatory.cfg" + logger.info( + "No observatory provided, using observatory.cfg from current working directory" + ) + + logger.info("Parsing the observatory config") + if type(observatory) is str: + obs_cfg = configparser.ConfigParser() + obs_cfg.read(observatory) + slew_rate = obs_cfg.getfloat("scheduling", "slew_rate") * u.deg / u.second + instrument_reconfig_times = json.loads( + obs_cfg.get("scheduling", "instrument_reconfig_times") + ) + observatory = astroplan.Observer( + location=coord.EarthLocation( + lon=obs_cfg.get("site", "longitude"), + lat=obs_cfg.get("site", "latitude"), + ) + ) + obs_lon = observatory.location.lon + obs_lat = observatory.location.lat + elif type(observatory) is Observatory: + obs_lon = observatory.observatory_location.lon + obs_lat = observatory.observatory_location.lat + slew_rate = observatory.slew_rate * u.deg / u.second + instrument_reconfig_times = observatory.instrument_reconfig_times + elif type(observatory) is astroplan.Observer: + obs_lon = observatory.location.lon + obs_lat = observatory.location.lat + slew_rate = observatory.slew_rate * u.deg / u.second + instrument_reconfig_times = observatory.instrument_reconfig_times + else: + logger.error( + "Observatory must be, a string, Observatory object, or astroplan.Observer object." + ) + return + + # Schedule + tz = timezonefinder.TimezoneFinder().timezone_at(lng=obs_lon.deg, lat=obs_lat.deg) + tz = zoneinfo.ZoneInfo(tz) + logger.debug(f"tz = {tz}") + + if date is None: + logger.debug("Using current date at observatory location") + date = datetime.datetime.now() + else: + date = datetime.datetime.strptime(date, "%Y-%m-%d") + date = datetime.datetime(date.year, date.month, date.day, 12, 0, 0, tzinfo=tz) + + t0 = astrotime.Time( + datetime.datetime(date.year, date.month, date.day, 12, 0, 0, tzinfo=tz), + format="datetime", + ) + t1 = t0 + length * u.day + logger.info("Schedule time range: %s to %s (UTC)" % (t0.iso, t1.iso)) + + # TODO: Add option to update an existing schedule table + schedule = astroplan.Schedule(t0, t1) + + block_groups = [] + + if catalog is None and queue is None: + try: + catalog = os.environ.get("OBSERVATORY_HOME") + "/schedules/schedule.cat" + logger.info( + "No catalog provided, using schedule.cat from $OBSERVATORY_HOME environment variable" + ) + except: + catalog = os.getcwd() + "/schedule.cat" + logger.info( + "No catalog provided, using schedule.cat from current working directory" + ) if os.path.isfile(catalog): logger.debug(f"catalog is a file") if catalog.endswith(".cat"): with open(catalog, "r") as f: sch_files = f.read().splitlines() + sch_files = ["/".join(catalog.split("/")[:-1]) + "/" + f for f in sch_files] for f in sch_files: if not os.path.isfile(f): - logger.error(f"File {f} in catalog {catalog} does not exist.") - return - blocks.append(parse_sch_file(f)) + logger.error( + f"File {f} in catalog {catalog} does not exist, skipping." + ) + continue + try: + block_groups.append( + sch.read( + f, + location=coord.EarthLocation( + lon=obs_lon, + lat=obs_lat, + ), + t0=t0, + ) + ) + except Exception as e: + logger.error( + f"File {f} in catalog {catalog} is not a valid .sch file, skipping: {e}" + ) + continue elif catalog.endswith(".sch"): - blocks.append(parse_sch_file(catalog)) + try: + block_groups.append(sch.read(catalog)) + except Exception as e: + logger.error(f"File {catalog} is not a valid .sch file: {e}") - elif type(catalog) in (list, tuple, iter): + elif type(catalog) is list: logger.debug(f"catalog is a list") for block in catalog: - if type(block) in (list, tuple, iter): + if type(block) is list: for b in block: if type(b) is not astroplan.ObservingBlock: logger.error( - f"Object {b} in catalog {catalog} is not an astroplan.ObservingBlock." + f"Object {b} in catalog {catalog} is not an astroplan.ObservingBlock, skipping." ) - return - blocks.append(block) + continue + block_groups.append(block) elif type(block) is astroplan.ObservingBlock: - blocks.append([block]) + block_groups.append([block]) else: logger.error( - f"Object {block} in catalog {catalog} is not an astroplan.ObservingBlock." + f"Object {block} in catalog {catalog} is not an astroplan.ObservingBlock, skipping." ) - return + continue + else: + logger.error( + f"Catalog {catalog} is not a valid .cat file or list of astroplan.ObservingBlocks." + ) + + if add_only and not ignore_order: + logger.error( + "Option -ao/--add-only requires option -i/--ignore-order, setting to True" + ) + ignore_order = True if ignore_order: - blocks = [ - [blocks[i][j] for i in range(len(blocks)) for j in range(len(blocks[i]))] + logger.info("Ignoring order of .sch files in catalog") + block_groups = [ + [ + block_groups[i][j] + for i in range(len(block_groups)) + for j in range(len(block_groups[i])) + ] ] - # Define the observatory - logger.info("Parsing the observatory") - if type(observatory) is not astroplan.Observer: - if type(observatory) is str: - obs_cfg = configparser.ConfigParser() - obs_cfg.read(observatory) - obs_long = obs_cfg.getfloat("site", "longitude") - obs_lat = obs_cfg.getfloat("site", "latitude") - obs_height = obs_cfg.getfloat("site", "elevation") - slew_rate = obs_cfg.getfloat("scheduling", "slew_rate") * u.deg / u.second - instrument_reconfiguration_times = json.loads( - obs_cfg.get("scheduling", "instrument_reconfiguration_times") - ) - instrument_name = obs_cfg.get("site", "instrument_name") - observatory = astroplan.Observer( - location=coord.EarthLocation( - lon=obs_long * u.deg, lat=obs_lat * u.deg, height=obs_height * u.m - ) - ) - elif type(observatory) is Observatory: - obs_long = observatory.observatory_location.lon.deg - obs_lat = observatory.observatory_location.lat.deg - obs_height = observatory.observatory_location.height.m - slew_rate = observatory.slew_rate * u.deg / u.second - instrument_reconfiguration_times = ( - observatory.instrument_reconfiguration_times - ) - instrument_name = observatory.instrument_name - observatory = astroplan.Observer(location=observatory.observatory_location) + # Add IDs to ObservingBlocks without them + logger.info("Adding IDs to ObservingBlocks") + for i in range(len(block_groups)): + for j in range(len(block_groups[i])): + try: + block_groups[i][j].configuration["ID"] + except: + block_groups[i][j].configuration["ID"] = astrotime.Time.now() + + previously_queued_blocks = None + if queue is not None and len(block_groups) > 0: + if add_only: + logger.info("Adding catalog to queue without scheduling") + + # Only add the catalog to the queue without scheduling + + return else: - raise TypeError( - "Observatory must be, a string, Observatory object, or astroplan.Observer object." - ) + logger.info("Reading queue") + # Load all blocks from the queue + queue_blocks = schedtab.table_to_blocks(queue) + + # Mark scheduled as X expired + for block in queue_blocks: + if block.configuration["status"] != "U": + block.configuration["status"] = "X" + block.configuration["message"] = "Expired" + previously_queued_blocks = queue_blocks + + elif queue is not None and len(block_groups) == 0: + # Load all blocks from the queue + queue_blocks = schedtab.table_to_blocks(queue) + # Scheduled and unscheduled blocks get scheduled + for block in queue_blocks: + if block.configuration["status"] in ("S", "U"): + block.configuration["status"] = "S" + block.configuration["message"] = "Scheduled" + else: + block.configuration["status"] = "X" + block.configuration["message"] = "Expired" + block_groups = [queue_blocks] + + elif queue is None and len(block_groups) > 0: + # Schedule the blocks in the catalog, but don't write to the queue + logger.info("No queue provided") + elif add_only: + logger.error("Missing catalog or queue for option -ao/--add-only, exiting") + return + else: + logger.error("No catalog or queue provided") + return + + # Add sched_time to all blocks + for i in range(len(block_groups)): + for j in range(len(block_groups[i])): + block_groups[i][j].configuration["sched_time"] = sched_time # Constraints logger.info("Defining global constraints") global_constraints = [ - astroplan.AltitudeConstraint(min=elevation * u.deg), astroplan.AtNightConstraint(max_solar_altitude=max_altitude * u.deg), + astroplan.AltitudeConstraint(min=elevation * u.deg), astroplan.AirmassConstraint(max=airmass, boolean_constraint=False), astroplan.MoonSeparationConstraint(min=moon_separation * u.deg), ] @@ -283,39 +522,18 @@ def schedtel_cli( # Transitioner logger.info("Defining transitioner") transitioner = astroplan.Transitioner( - slew_rate, instrument_reconfiguration_times=instrument_reconfiguration_times - ) - - # Schedule - tz = timezonefinder.TimezoneFinder().timezone_at(lng=lon.deg, lat=lat.deg) - tz = zoneinfo.ZoneInfo(tz) - logger.debug(f"tz = {tz}") - - if date is None: - logger.debug("Using current date at observatory location") - date = datetime.datetime.now() - else: - date = datetime.datetime.strptime(date, "%Y-%m-%d") - date = datetime.datetime(date.year, date.month, date.day, 12, 0, 0, tzinfo=tz) - - t0 = astrotime.Time( - datetime.datetime(date.year, date.month, date.day, 12, 0, 0, tzinfo=tz), - format="datetime", + slew_rate, instrument_reconfig_times=instrument_reconfig_times ) - t1 = t0 + 1 * u.day - logger.info("Schedule time range: %s to %s" % (t0.iso, t1.iso)) - - schedule = astroplan.Schedule(t0, t1) # Scheduler - if scheduler[0] == "": + if scheduler == ("", ""): logger.info("Using default scheduler: astroplan.PriorityScheduler") schedule_handler = astroplan.PriorityScheduler( constraints=global_constraints, observer=observatory, transitioner=transitioner, gap_time=gap_time * u.second, - time_resolution=time_resolution * u.second, + time_resolution=resolution * u.second, ) else: logger.info(f"Using custom scheduler: {scheduler[0]}") @@ -335,62 +553,225 @@ def schedtel_cli( observer=observatory, transitioner=transitioner, gap_time=gap_time * u.second, - time_resolution=time_resolution * u.second, + time_resolution=resolution * u.second, ) logger.info("Scheduling ObservingBlocks") - for i in range(len(blocks)): - logger.debug("Block group %i of i" % (i + 1, len(blocks))) - schedule_handler(blocks[i], schedule) + for i in range(len(block_groups)): + logger.debug("Block group %i of %i" % (i + 1, len(block_groups))) + schedule_handler(block_groups[i], schedule) + + # Flatten block_groups for comparison with scheduled ObservingBlocks + all_blocks = [block for block_group in block_groups for block in block_group] + + # Get scheduled ObservingBlocks + scheduled_blocks = [ + slot.block + for slot in schedule.slots + if isinstance(slot.block, astroplan.ObservingBlock) + ] + transition_blocks = [ + slot.block + for slot in schedule.slots + if isinstance(slot.block, astroplan.TransitionBlock) + ] + unscheduled_slots = [slot for slot in schedule.slots if not slot.occupied] - # Generate schedule table - schedule_table = schedule.to_table(show_transitions=False, show_unused=False) + # Update ephem for non-sidereal targets, update object types, set filenames + for block_number, block in enumerate(scheduled_blocks): + block.configuration["status"] = "S" + block.configuration["message"] = "Scheduled" - # Update ephem for non-sidereal targets - for row in schedule_table: if ( - row["configuration"]["pm_ra_cosdec"].value != 0 - or row["configuration"]["pm_dec"].value != 0 + block.configuration["pm_ra_cosdec"].value != 0 + or block.configuration["pm_dec"].value != 0 ): - logger.info("Updating ephemeris for %s at scheduled time" % row["target"]) - ephemerides = mpc.MPC.get_ephemeris( - target=row["target"], - location=observatory.location, - start=row["start time (UTC)"], - number=1, - proper_motion="sky", + logger.info("Updating ephemeris for %s at scheduled time" % block.name) + try: + ephemerides = mpc.MPC.get_ephemeris( + target=block.name, + location=observatory.location, + start=block.start_time, + number=1, + proper_motion="sky", + ) + new_ra = ephemerides["RA"][0] + new_dec = ephemerides["Dec"][0] + block.target = astroplan.FixedTarget( + coord.SkyCoord(ra=new_ra, dec=new_dec) + ) + block.configuration["pm_ra_cosdec"] = ( + ephemerides["dRA cos(Dec)"][0] * u.arcsec / u.hour + ) + block.configuration["pm_dec"] = ( + ephemerides["dDec"][0] * u.arcsec / u.hour + ) + except Exception as e1: + try: + logger.warning( + f"Failed to find proper motions for {block.name}, trying to find proper motions using astropy.coordinates.get_body" + ) + pos_l = coord.get_body( + block.name, + block.start_time - 10 * u.minute, + location=observatory.location, + ) + pos_m = coord.get_body( + block.name, + (block.start_time + block.end_time) / 2, + location=location, + ) + pos_h = coord.get_body( + block.name, + block.end_time + 10 * u.minute, + location=observatory.location, + ) + new_ra = pos_m.ra + new_dec = pos_m.dec + block.target = astroplan.FixedTarget( + coord.SkyCoord(ra=new_ra, dec=new_dec) + ) + block.configuration["pm_ra_cosdec"] = ( + ( + pos_h.ra * np.cos(pos_h.dec.rad) + - pos_l.ra * np.cos(pos_l.dec.rad) + ) + / (pos_h.obstime - pos_l.obstime) + ).to(u.arcsec / u.hour) + block.configuration["pm_dec"] = ( + (pos_h.dec - pos_l.dec) / (pos_h.obstime - pos_l.obstime) + ).to(u.arcsec / u.hour) + except Exception as e2: + logger.warning( + f"Failed to find proper motions for {block.name}, keeping old ephemerides" + ) + + if block.configuration["filename"] == "": + block.configuration["filename"] = name_format.format( + index=block_number, + target=block.target.to_string("hmsdms").replace(" ", "_"), + start_time=block.start_time.isot.replace(":", "").split(".")[0], + end_time=block.end_time.isot.replace(":", "").split(".")[0], + duration="%i" % block.duration.to(u.second).value, + ra=block.target.ra.to_string(sep="hms").replace(" ", "_"), + dec=block.target.dec.to_string(sep="dms").replace(" ", "_"), + observer=block.configuration["observer"], + code=block.configuration["code"], + title=block.configuration["title"], + type=block.configuration["type"], + backend=block.configuration["backend"], + exposure=block.configuration["exposure"], + nexp=block.configuration["nexp"], + repositioning=block.configuration["repositioning"], + shutter_state=block.configuration["shutter_state"], + readout=block.configuration["readout"], + binning=block.configuration["binning"], + frame_position=block.configuration["frame_position"], + frame_size=block.configuration["frame_size"], + pm_ra_cosdec=block.configuration["pm_ra_cosdec"], + pm_dec=block.configuration["pm_dec"], + comment=block.configuration["comment"], + sch=block.configuration["sch"], + ID=block.configuration["ID"], + status=block.configuration["status"], + message=block.configuration["message"], + sched_time=block.configuration["sched_time"], ) - row["ra"] = ephemerides["RA"][0] - row["dec"] = ephemerides["DEC"][0] - row["configuration"]["pm_ra_cosdec"] = ( - ephemerides["dRA cos(Dec)"][0] * u.arcsec / u.hour + + # Report unscheduled or invalid blocks, and report sch files that were + # complete, partially scheduled, or not scheduled at all + + # First find unscheduled blocks + # TODO: report reason for unscheduled blocks, use is_observable + unscheduled_blocks = [ + block + for block in all_blocks + if block.configuration["ID"] + not in [b.configuration["ID"] for b in scheduled_blocks] + ] + + if type(observatory) is Observatory: + validated_blocks = schedtab.validate(scheduled_blocks, observatory=observatory) + else: + validated_blocks = schedtab.validate(scheduled_blocks) + + # Then find invalid blocks + invalid_blocks = [ + block for block in validated_blocks if block.configuration["status"] == "I" + ] + + if len(unscheduled_blocks) > 0 or len(invalid_blocks) > 0: + logger.warning("There are unscheduled or invalid blocks") + logger.warning("Unscheduled blocks:") + for block in unscheduled_blocks: + logger.warning( + f""" + ID = {block.configuration["ID"]} + Title = {block.configuration["title"]} + Target = {block.target.to_string("hmsdms")} + sch = {block.configuration["sch"]} + Status = {block.configuration["status"]} + + """ ) - row["configuration"]["pm_dec"] = ephemerides["dDec"][0] * u.arcsec / u.hour + logger.warning("\nInvalid blocks:") + for block in invalid_blocks: + logger.warning( + f""" + ID = {block.configuration["ID"]} + Title = {block.configuration["title"]} + Target = {block.target.to_string("hmsdms")} + sch = {block.configuration["sch"]} + Status = {block.configuration["status"]} + Message = {block.configuration["message"]} + + """ + ) + if not yes: + if click.confirm("Continue?"): + pass + else: + return - # Re-assign filenames - name_dict = {} - for i in range(len(schedule_table)): - name = schedule_table[i]["configuration"]["obscode"] + # Scheduled, unscheduled, invalid, transition blocks and unscheduled slots - if name in name_dict: - name_dict[name] += 1 - else: - name_dict[name] = 0 + # Blocks to be placed in an execution schedule + exec_blocks = scheduled_blocks + transition_blocks + invalid_blocks + if queue is None: + logger.info("No queue provided, including unscheduled in execution schedule") + logger.info("Note that these blocks will not actually be executed") + exec_blocks += unscheduled_blocks + elif queue is not None: + exec_blocks.sort(key=lambda x: x.configuration["ID"]) + exec_table = schedtab.blocks_to_table(exec_blocks) - name += ( - ("%3.3g" % date.strftime("%j")) + "_" + ("%4.4g" % name_dict[name]) + ".fts" - ) - schedule_table[i]["configuration"]["filename"] = name + # Blocks to be placed back in the queue + queue_blocks = scheduled_blocks + unscheduled_blocks + + # If queue and catalog are not none, this carries unscheduled blocks + # and all other blocks marked as expired "X" + if previously_queued_blocks is not None: + queue_blocks += previously_queued_blocks - # Write the telrun.ecsv file + queue_blocks.sort(key=lambda x: x.configuration["ID"]) + queue_table = schedtab.blocks_to_table(queue_blocks) + + exec_blocks.sort(key=lambda x: x.configuration["ID"]) + exec_blocks = exec_blocks + unscheduled_slots + exec_table = schedtab.blocks_to_table(exec_blocks) + + # TODO: Report observing statistics (time used, transition, unscheduled, etc.) + # reports.pre_exec_report(exec_table) + + # Write the schedule to file logger.info("Writing schedule to file") if filename is None or telrun: - first_time = astrotime.Time( - schedule_table[0]["start time (UTC)"], format="iso", scale="utc" - ).strftime("%m-%d-%Y") + first_time = exec_table[0]["start_time"].strftime("%Y%m%d_%H%M%S") filename = "telrun_" + first_time + ".ecsv" + write_queue = False if telrun: + write_queue = True try: path = os.environ.get("TELRUN_EXECUTE") logger.info( @@ -398,126 +779,79 @@ def schedtel_cli( % path ) except: - path = os.getcwd() + "/schedules/" - logger.info("-t/--telrun flag set, writing schedule to %s" % path) + try: + path = os.environ.get("OBSERVATORY_HOME") + "/schedules/" + logger.info( + "-t/--telrun flag set, writing schedule to %s from $OBSERVATORY_HOME environment variable" + % path + ) + except: + path = os.getcwd() + "/schedules/" + logger.info( + "-t/--telrun flag set, writing schedule to %s from current working directory" + % path + ) if not os.path.isdir(path): - logger.exception(f"Path {path} does not exist.") - return + path = os.getcwd() + "/" + logger.warning( + f"Path {path} does not exist, writing to current working directory instead: {path}" + ) else: + write_queue = False path = os.getcwd() + "/" logger.info("-t/--telrun flag not set, writing schedule to %s" % path) - schedule_table.write(path + filename, format="ascii.ecsv", overwrite=True) + logger.info("If queue was provided, it will not be written to file") + + exec_table.write(path + filename, overwrite=True) + + # If a queue was passed, update the queue if --telrun is set + # and the file was written to the expected location + if queue is not None: + if write_queue: + logger.info("Writing queue to file") + queue_table.write(queue, overwrite=True) + else: + logger.info("Not writing queue to file") + else: + logger.info("No queue provided") # Plot the schedule ax = None match plot: case 1: # Gantt chart logger.info("Plotting schedule as a Gantt chart") - ax = plot_schedule_gantt(schedule, observatory, name=instrument_name) - return schedule_table, ax + fig, ax = plot_schedule_gantt(schedule, observatory) + return exec_table, fig, ax case 2: # Plot by target w/ altitude logger.info("Plotting schedule by target with airmass") ax = astroplan.plots.plot_schedule_airmass(schedule) plt.legend() - return schedule_table, ax + return exec_table, fig, ax case 3: # Sky chart logger.info("Plotting schedule on a sky chart") - objects = [] - times = [] - for i in range(len(schedule_table)): - if schedule_table[i]["ra"] not in [obj.ra.dms for obj in objects]: - objects.append( - coord.SkyCoord( - ra=schedule_table[i]["ra"], dec=schedule_table[i]["dec"] - ) - ) - times.append( - [ - ( - astrotime.Time( - schedule_table[i]["start time (UTC)"], - format="iso", - scale="utc", - ) - + astrotime.Time( - schedule_table[i]["end time (UTC)"], - format="iso", - scale="utc", - ) - ) - / 2 - ] - ) - elif schedule_table[i]["dec"] != [obj.ra.dms for obj in objects].index( - schedule_table[i]["ra"] - ): - objects.append( - coord.SkyCoord( - ra=schedule_table[i]["ra"], dec=schedule_table[i]["dec"] - ) - ) - times.append( - [ - ( - astrotime.Time( - schedule_table[i]["start time (UTC)"], - format="iso", - scale="utc", - ) - + astrotime.Time( - schedule_table[i]["end time (UTC)"], - format="iso", - scale="utc", - ) - ) - / 2 - ] - ) - else: - times[ - [obj.dec.dms for obj in objects].index(schedule_table[i]["dec"]) - ].append( - ( - astrotime.Time( - schedule_table[i]["start time (UTC)"], - format="iso", - scale="utc", - ) - + astrotime.Time( - schedule_table[i]["end time (UTC)"], - format="iso", - scale="utc", - ) - ) - / 2 - ) - - for i in range(len(objects)): - ax = astroplan.plot_sky( - astroplan.FixedTarget(objects[i]), - observatory, - times[i], - style_kwargs={ - "color": ccm.batlow(i / (len(objects) - 1)), - "label": objects[i].to_string("hmsdms"), - }, - ) - plt.legend() - return schedule_table, ax + ax = plot_schedule_sky(schedule, observatory) + return exec_table, fig, ax case _: logger.info("No plot requested") - pass - return schedule_table + return exec_table -@click.command() -@click.argument("schedule_table", type=click.Path(exists=True)) -@click.argument("observatory", type=click.Path(exists=True)) -@click.option("-n", "--name", type=str, default="", help="Name of observatory.") -@click.version_option(version="0.1.0") -@click.help_option("-h", "--help") -def plot_schedule_gantt_cli(schedule_table, observatory, name): +@click.command( + epilog="""Check out the documentation at + https://pyscope.readthedocs.io/en/latest/ + for more information.""" +) +@click.argument( + "schedule_table", + type=click.Path(exists=True, resolve_path=True, dir_okay=False, readable=True), +) +@click.argument( + "observatory", + type=click.Path(exists=True, resolve_path=True, dir_okay=False, readable=True), +) +@click.version_option() +def plot_schedule_gantt_cli(schedule_table, observatory): if type(schedule_table) is not table.Table: schedule_table = table.Table.read(schedule_table, format="ascii.ecsv") @@ -530,11 +864,9 @@ def plot_schedule_gantt_cli(schedule_table, observatory, name): lat=obs_cfg.getfloat("location", "latitude") * u.deg, height=obs_cfg.getfloat("location", "elevation") * u.m, ) - name = obs_cfg.get("site", "instrument_name") observatory = astroplan.Observer(location=location) elif type(observatory) is Observatory: location = observatory.observatory_location - name = observatory.instrument_name observatory = astroplan.Observer(location=observatory.observatory_location) else: raise TypeError( @@ -542,11 +874,8 @@ def plot_schedule_gantt_cli(schedule_table, observatory, name): ) else: location = observatory.location - name = observatory.name - obscodes = np.unique( - [block["configuration"]["obscode"] for block in schedule_table] - ) + obscodes = np.unique([block["configuration"]["code"] for block in schedule_table]) date = astrotime.Time( schedule_table[0]["start time (UTC)"], format="iso", scale="utc" @@ -573,7 +902,7 @@ def plot_schedule_gantt_cli(schedule_table, observatory, name): plot_blocks = [ block for block in schedule_table - if block["configuration"]["obscode"] == obscodes[i] + if block["configuration"]["code"] == obscodes[i] ] for block in plot_blocks: @@ -685,316 +1014,104 @@ def plot_schedule_gantt_cli(schedule_table, observatory, name): ) ax.grid() - return ax - - -def parse_sch_file(filename, location=None, t0=None): - with open(filename, "r") as f: - raw_lines = f.readlines() - - # Remove equal signs, quotes, and blank lines - lines = [] - for line in raw_lines: - logger.debug(f"Parsing line: {line}") - line = line.replace("=", " ") - line = line.replace("`", "'") - line = line.replace('"', "'") - line = line.replace("‘", "'") - line = line.replace("’", "'") - - lines = [line.split("#")[0] for line in lines] # Remove comments - lines = [line.split("!")[0] for line in lines] # Remove comments - lines = [line.replace("\n", "") for line in lines] # Remove line breaks - " ".join( - mystring.split() - ) # Remove multiple spaces, trailing and leading whitespace - lines = [line for line in lines if line != ""] # Remove empty lines - lines = [line.lower() for line in lines] # Lower case - - # Look for title, observer keywords - title = "" - for line in lines: - title = _get_keyvalue(line, "tit", start_line=True) - if title is not None: - lines.remove(line) - break - - observer = "" - for line in lines: - val += _get_keyvalue(line, "obs", start_line=True) - if val is not None: - lines.remove(line) - observer += val + "," - - code = filename[:3] - - prior_filters = None - prior_exposures = None - - # Parse each line and place into ObservingBlock - blocks = [] - - for line in lines: - # Required keywords - target_name = _get_keyvalue(line, ("tar", "sou", "obj", "nam")) - ra = _get_keyvalue(line, "ra") - dec = _get_keyvalue(line, "dec") - nonsidereal = _get_flag(line, "non") - - if None not in (ra, dec): - obj = coord.SkyCoord(ra, dec, unit=(u.hourangle, u.deg)) - if target_name is None: - target_name = obj.to_string("hmsdms") - obj = astroplan.FixedTarget(name=nm, coord=obj) - pm_ra_cosdec = telrun.observing_block_config["pm_ra_cosdec"] - pm_dec = telrun.observing_block_config["pm_dec"] - elif target_name != None and not nonsidereal: - obj = coord.SkyCoord.from_name(target_name) - obj = astroplan.FixedTarget(name=target, coord=obj) - pm_ra_cosdec = telrun.observing_block_config["pm_ra_cosdec"] - pm_dec = telrun.observing_block_config["pm_dec"] - elif target_name != None and nonsidereal: - ephemerides = mpc.MPC.get_ephemeris( - target=target_name, - location=location, - start=t0 + 0.5 * u.day, - number=1, - proper_motion="sky", - ) - ra = ephemerides["RA"][0] - dec = ephemerides["DEC"][0] - pm_ra_cosdec = ephemerides["dRA cos(Dec)"][0] * u.arcsec / u.hour - pm_dec = ephemerides["dDec"][0] * u.arcsec / u.hour - obj = coord.SkyCoord(ra, dec, pm_ra_cosdec=pm_ra_cosdec, pm_dec=pm_dec) - obj = astroplan.FixedTarget(name=target_name, coord=obj) - else: - pass # TODO: allow for sources with no coordinates, i.e. scheduling darks/flats - - # Get non-iterating, non-inheriting keywords - name = _get_keyvalue(line, "file") - if name is None: - name = telrun.observing_block_config["filename"] - - priority = _get_keyvalue(line, "pri") - priority = int(priority) if priority is not None else 1 + return fig, ax - repositioning = _get_keyvalue(line, "repo") - if repositioning is not None: - repositioning = ( - int(repositioning.split(",")[0]), - int(repositioning.split(",")[1]), - ) - else: - repositioning = telrun.observing_block_config["repositioning"] - shutter_state = _get_keyvalue(line, "shu", boolean=True) - if shutter_state is None: - shutter_state = telrun.observing_block_config["shutter_state"] - - readout = _get_keyvalue(line, "rea") - if readout is not None: - readout = int(readout) - else: - readout = telrun.observing_block_config["readout"] - - binning = _get_keyvalue(line, "bin") - if binning is not None: - binning = (int(binning.split("x")[0]), int(binning.split("x")[1])) - else: - binning = telrun.observing_block_config["binning"] - - frame_position = _get_keyvalue(line, "frame_p") - if frame_position is not None: - frame_position = ( - int(frame_position.split(",")[0]), - int(frame_position.split(",")[1]), - ) - else: - frame_position = telrun.observing_block_config["frame_position"] - - frame_size = _get_keyvalue(line, "frame_s") - if frame_size is not None: - frame_size = (int(frame_size.split(",")[0]), int(frame_size.split(",")[1])) - else: - frame_size = telrun.observing_block_config["frame_size"] - - comment = _get_keyvalue(line, "com") - if comment is None: - comment = telrun.observing_block_config["comment"] - - # Get timing keywords - utstart = _get_keyvalue(line, "uts", "sta") - if utstart is not None: - utstart = astrotime.Time(utstart, format="isot", scale="utc") - - cadence = _get_keyvalue(line, "cad") - if cadence is not None: - cadence = astrotime.TimeDelta( - datetime.time(*[int(c) for c in cadence.split(":")]), format="datetime" +@click.command( + epilog="""Check out the documentation at + https://pyscope.readthedocs.io/en/latest/ + for more information.""" +) +@click.argument( + "schedule_table", + type=click.Path(exists=True, resolve_path=True, dir_okay=False, readable=True), +) +@click.argument( + "observatory", + type=click.Path(exists=True, resolve_path=True, dir_okay=False, readable=True), +) +@click.version_option() +def plot_schedule_sky_cli(): + objects = [] + times = [] + for i in range(len(schedule_table)): + if schedule_table[i]["ra"] not in [obj.ra.dms for obj in objects]: + objects.append( + coord.SkyCoord(ra=schedule_table[i]["ra"], dec=schedule_table[i]["dec"]) ) - - if utstart is None: - raise ValueError("Must specify utstart if cadence is specified") - - schederr = _get_keyvalue(line, "sch") - if schederr is not None: - schederr = astrotime.TimeDelta( - datetime.time(*[int(s) for s in schederr.split(":")]), format="datetime" + times.append( + [ + ( + astrotime.Time( + schedule_table[i]["start time (UTC)"], + format="iso", + scale="utc", + ) + + astrotime.Time( + schedule_table[i]["end time (UTC)"], + format="iso", + scale="utc", + ) + ) + / 2 + ] ) - - if utstart is None and schederr is not None: - raise ValueError("Must specify utstart if schederr is specified") - elif utstart is not None and schederr is None: - schederr = 60 * u.second - - # Get exposure behavior keywords - n_exp = _get_keyvalue(line, ("n_e", "nex", "repe")) - if n_exp is not None: - n_exp = int(n_exp) - else: - n_exp = telrun.observing_block_config["n_exp"] - - do_not_interrupt = _get_flag(line, ("don", "do_", "do-")) - if do_not_interrupt is None: - do_not_interrupt = telrun.observing_block_config["do_not_interrupt"] - - # Get iterating, inheriting keywords - filters = _get_keyvalue(line, "filt") - if filters is not None: - filters = filters.split(",") - prior_filters = filters - elif prior_filters is not None: - filters = prior_filters - else: - filters = telrun.observing_block_config["filters"] - prior_filters = None - - exposures = _get_keyvalue(line, "exp") - if exposures is not None: - exposures = [float(e) for e in exposures.split(",")] - prior_exposures = exposures - elif prior_exposures is not None: - exposures = prior_exposures - else: - exposures = telrun.observing_block_config["exposures"] - prior_exposures = None - - # Sanity Check 1: matching number of filters and exposures - if len(filters) != len(exposures) and len(filters) != 0: - raise ValueError("Number of filters must match number of exposures") - - # Sanity Check 2: do_not_interrupt and cadence don't both appear: - if do_not_interrupt is not None and cadence is not None: - raise ValueError( - "Cannot specify do_not_interrupt and cadence simultaneously" + elif schedule_table[i]["dec"] != [obj.ra.dms for obj in objects].index( + schedule_table[i]["ra"] + ): + objects.append( + coord.SkyCoord(ra=schedule_table[i]["ra"], dec=schedule_table[i]["dec"]) ) - - # Sanity Check 3: if cadence is specified, verify it exceeds exposure time - # times number of exposures times number of filters - if cadence is not None: - if cadence < np.sum(exposures) * n_exp * len(filters): - raise ValueError("Cadence must be greater than total exposure time") - - for i in range(len(filters)): - filt = filters[i] - exp = exposures[i] - constraints = None - - if do_not_interrupt: - loop_max = 1 - temp_dur = exp * n_exp * u.second - temp_n_exp = n_exp - else: - loop_max = n_exp - temp_dur = exp * u.second - temp_n_exp = 1 - - if utstart is not None: - if cadence is not None: - constraint_cadence = cadence - else: - constraint_cadence = temp_dur - - constraints = [ - [ - astroplan.constraints.TimeConstraint( - utstart + (i + j * len(i)) * constraint_cadence - schederr, - utstart + (i + j * len(i)) * constraint_cadence + schederr, + times.append( + [ + ( + astrotime.Time( + schedule_table[i]["start time (UTC)"], + format="iso", + scale="utc", + ) + + astrotime.Time( + schedule_table[i]["end time (UTC)"], + format="iso", + scale="utc", ) - ] - for j in range(loop_max) + ) + / 2 ] - - for j in range(loop_max): - blocks.append( - astroplan.ObservingBlock( - target=obj, - duration=temp_dur, - priority=priority, - name=target_name, - configuration={ - "observer": observer, - "code": code, - "title": title, - "filename": name, - "filter": filt, - "exposure": exp, - "n_exp": temp_n_exp, - "do_not_interrupt": do_not_interrupt, - "repositioning": repositioning, - "shutter_state": shutter_state, - "readout": readout, - "binning": binning, - "frame_position": frame_position, - "frame_size": frame_size, - "pm_ra_cosdec": pm_ra_cosdec, - "pm_dec": pm_dec, - "comment": comment, - "status": "N", - "message": "unprocessed", - }, - constraints=constraints[j], + ) + else: + times[ + [obj.dec.dms for obj in objects].index(schedule_table[i]["dec"]) + ].append( + ( + astrotime.Time( + schedule_table[i]["start time (UTC)"], + format="iso", + scale="utc", + ) + + astrotime.Time( + schedule_table[i]["end time (UTC)"], + format="iso", + scale="utc", ) ) - logger.debug( - f"""Created ObservingBlock: {blocks[-1].target}, - {blocks[-1].duration}, {blocks[-1].priority}, - {blocks[-1].name}, {blocks[-1].constraints}, - {blocks[-1].configuration}""" - ) - - return blocks - - -def _get_keyvalue(line, keyword, start_line=False, boolean=False): - if start_line: - if line.lower().startswith(keyword.lower()): - if not boolean: - return shlex.split(line)[1] - else: - if shlex.split(line)[1].lower().startswith(("t", "y", "1")): - return True - elif shlex.split(line)[1].lower().startswith(("f", "n", "0")): - return False - else: - return None - else: - return None - - for i in range(len(line)): - if line[i:].lower().startswith(keyword.lower()): - return shlex.split(line[i:])[1] - else: - return None - - -def _get_flag(line, keyword): - for i in range(len(line)): - if line[i:].lower().startswith(keyword.lower()): - return True + / 2 + ) - return False + for i in range(len(objects)): + ax = astroplan.plot_sky( + astroplan.FixedTarget(objects[i]), + observatory, + times[i], + style_kwargs={ + "color": ccm.batlow(i / (len(objects) - 1)), + "label": objects[i].to_string("hmsdms"), + }, + ) + plt.legend() + return fig, ax schedtel = schedtel_cli.callback plot_schedule_gantt = plot_schedule_gantt_cli.callback +plot_schedule_sky = plot_schedule_sky_cli.callback diff --git a/pyscope/telrun/telrun_block.py b/pyscope/telrun/telrun_block.py new file mode 100644 index 00000000..af22bbc6 --- /dev/null +++ b/pyscope/telrun/telrun_block.py @@ -0,0 +1,7 @@ +import astroplan + +# convenience class that wraps astroplan.ObservingBlock + + +class TelrunBlock(astroplan.ObservingBlock): + pass diff --git a/pyscope/telrun/telrun_operator.py b/pyscope/telrun/telrun_operator.py index 154b8c03..5a887468 100644 --- a/pyscope/telrun/telrun_operator.py +++ b/pyscope/telrun/telrun_operator.py @@ -6,6 +6,7 @@ import threading import tkinter as tk import tkinter.ttk as ttk +from io import StringIO from tkinter import font import astroplan @@ -17,32 +18,10 @@ from astroquery import mpc from ..observatory import Observatory -from . import TelrunException +from . import TelrunException, schedtab logger = logging.getLogger(__name__) -observing_block_config = { - "observer": "pyScope Observer", - "code": "pso", - "title": "pyScope Observation", - "filename": "", - "filter": "", - "exposure": 0, - "n_exp": 1, - "do_not_interrupt": False, - "repositioning": (None, None), - "shutter_state": True, - "readout": 0, - "binning": (1, 1), - "frame_position": (0, 0), - "frame_size": (0, 0), - "pm_ra_cosdec": u.Quantity(0, u.arcsec / u.hour), - "pm_dec": u.Quantity(0, u.arcsec / u.hour), - "comment": "", - "status": "N", - "message": "unprocessed", -} - class TelrunOperator: def __init__(self, config_path="./config/", gui=True, **kwargs): @@ -582,13 +561,16 @@ def execute_schedule(self, schedule, *args): logger.info("Validating observing blocks...") for i in range(len(schedule)): logger.debug("Validating block %i of %i" % (i + 1, len(schedule))) - block = self._block_validation(schedule[i]) - if block is None: - logger.warning( + try: + block = validate_ob(schedule[i]) + except Exception as e: + logger.exception( "Block %i of %i is invalid, removing from schedule" % (i + 1, len(schedule)) ) + logger.exception(e) block["configuration"]["status"] = "F" + block["configuration"]["message"] = str(e) schedule[i] = block self._schedule = schedule @@ -857,11 +839,24 @@ def execute_block(self, *args, **kwargs): ) return - val_block = self._block_validation(block) - if val_block is None: - logger.warning("Block failed validation, skipping...") - return ("F", "Block failed validation", block) - else: + # Logging setup for writing to FITS headers + # From: https://stackoverflow.com/questions/31999627/storing-logger-messages-in-a-string + str_output = StringIO() + str_handler = logging.StreamHandler(str_output) + str_handler.setLevel(logging.INFO) + str_formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + str_handler.setFormatter(str_formatter) + logger.addHandler(str_handler) + + try: + val_block = validate_ob(block) + except Exception as e: + logger.exception("Block failed validation, skipping...") + logger.exception(e) + return ("F", f"Block failed validation: {e}", block) + logger.info("Block passed validation, continuing...") block = val_block @@ -1194,22 +1189,59 @@ def execute_block(self, *args, **kwargs): or block["configuration"]["pm_dec"].value != 0 ): logger.info("Non-zero proper motion specified, updating ephemeris...") - ephemerides = mpc.MPC.get_ephemeris( - target=block["target"], - location=self.observatory.observatory_location, - start=block["start time (UTC)"], - number=1, - proper_motion="sky", - ) - block["ra"] = ephemerides["RA"][0] - block["dec"] = ephemerides["DEC"][0] - block["configuration"]["pm_ra_cosdec"] = ( - ephemerides["dRA cos(Dec)"][0] * u.arcsec / u.hour - ) - block["configuration"]["pm_dec"] = ( - ephemerides["dDec"][0] * u.arcsec / u.hour - ) - logger.info("Ephemeris updates") + try: + ephemerides = mpc.MPC.get_ephemeris( + target=block["name"], + location=self.observatory.observatory_location, + start=block["start time (UTC)"], + number=1, + proper_motion="sky", + ) + block["ra"] = ephemerides["RA"][0] + block["dec"] = ephemerides["Dec"][0] + block["configuration"]["pm_ra_cosdec"] = ( + ephemerides["dRA cos(Dec)"][0] * u.arcsec / u.hour + ) + block["configuration"]["pm_dec"] = ( + ephemerides["dDec"][0] * u.arcsec / u.hour + ) + except Exception as e1: + try: + logger.warning( + f"Failed to find proper motions for {row['name']} on line {line_number}, trying to find proper motions using astropy.coordinates.get_body: {e1}" + ) + pos_l = coord.get_body( + block["name"], + block["start time (UTC)"] - 10 * u.minute, + location=self.observatory.observatory_location, + ) + pos_m = coord.get_body( + block["name"], block["start time (UTC)"], location=location + ) + pos_h = coord.get_body( + block["name"], + block["start time (UTC)"] + 10 * u.minute, + location=self.observatory.observatory_location, + ) + block["ra"] = pos_m.ra.to_string( + "hourangle", sep="hms", precision=3 + ) + block["dec"] = pos_m.dec.to_string("deg", sep="dms", precision=2) + block["configuration"]["pm_ra_cosdec"] = ( + ( + pos_h.ra * np.cos(pos_h.dec.rad) + - pos_l.ra * np.cos(pos_l.dec.rad) + ) + / (pos_h.obstime - pos_l.obstime) + ).to(u.arcsec / u.hour) + block["configuration"]["pm_dec"] = ( + (pos_h.dec - pos_l.dec) / (pos_h.obstime - pos_l.obstime) + ).to(u.arcsec / u.hour) + except Exception as e2: + logger.warning( + f"Failed to find proper motions for {block['name']}, keeping old ephemerides: {e2}" + ) + logger.info("Ephemeris updated") target = coord.SkyCoord( ra=block["ra"].hourangle, dec=block["dec"].deg, unit=(u.hourangle, u.deg) @@ -1669,9 +1701,9 @@ def execute_block(self, *args, **kwargs): } # Start exposures - for i in range(block["configuration"]["n_exp"]): + for i in range(block["configuration"]["nexp"]): logger.info( - "Beginning exposure %i of %i" % (i + 1, block["configuration"]["n_exp"]) + "Beginning exposure %i of %i" % (i + 1, block["configuration"]["nexp"]) ) logger.info( "Starting %4.4g second exposure..." % block["configuration"]["exposure"] @@ -1692,7 +1724,7 @@ def execute_block(self, *args, **kwargs): self._camera_status = "Idle" # Append integer to filename if multiple exposures - if block["configuration"]["n_exp"] > 1: + if block["configuration"]["nexp"] > 1: block["configuration"]["filename"] = ( block["configuration"]["filename"] + "_%i" % i ) @@ -1707,10 +1739,12 @@ def execute_block(self, *args, **kwargs): logger.info( "Current filter in wcs filters, attempting WCS solve..." ) + hist = str_output.getvalue().split("\n") save_success = self.observatory.save_last_image( self.images_path + block["configuration"]["filename"] + ".tmp", frametyp=block["configuration"]["shutter_state"], custom_header=custom_header, + history=hist, ) self._wcs_threads.append( threading.Thread( @@ -1729,17 +1763,21 @@ def execute_block(self, *args, **kwargs): logger.info( "Current filter not in wcs filters, skipping WCS solve..." ) + hist = str_output.getvalue().split("\n") save_success = self.observatory.save_last_image( self.images_path + block["configuration"]["filename"], frametyp=block["configuration"]["shutter_state"], custom_header=custom_header, + history=hist, ) else: logger.info("No filter wheel, attempting WCS solve...") + hist = str_output.getvalue().split("\n") save_success = self.observatory.save_last_image( self.images_path + block["configuration"]["filename"] + ".tmp", frametyp=block["configuration"]["shutter_state"], custom_header=custom_header, + history=hist, ) self._wcs_threads.append( threading.Thread( @@ -1756,10 +1794,10 @@ def execute_block(self, *args, **kwargs): self._wcs_threads[-1].start() # If multiple exposures, update filename as a list - if block["configuration"]["n_exp"] > 1: + if block["configuration"]["nexp"] > 1: block["configuration"]["filename"] = [ block["configuration"]["filename"] + "_%i" % i - for i in range(block["configuration"]["n_exp"]) + for i in range(block["configuration"]["nexp"]) ] # Set block status to done @@ -1829,185 +1867,6 @@ def _is_process_complete(self, timeout, event): def _terminate(self): self.observatory.shutdown() - @staticmethod - def _block_validation(block): - try: - # Check for all required config vals, fill in defaults if not present - block["configuration"]["observer"] = block["configuration"].get( - "observer", observing_block_config["observer"] - ) - block["configuration"]["code"] = block["configuration"].get( - "code", observing_block_config["code"] - ) - block["configuration"]["title"] = block["configuration"].get( - "title", observing_block_config["title"] - ) - block["configuration"]["filename"] = block["configuration"].get( - "filename", observing_block_config["filename"] - ) - block["configuration"]["filter"] = block["configuration"].get( - "filter", observing_block_config["filter"] - ) - block["configuration"]["exposure"] = block["configuration"].get( - "exposure", observing_block_config["exposure"] - ) - block["configuration"]["n_exp"] = block["configuration"].get( - "n_exp", observing_block_config["n_exp"] - ) - block["configuration"]["do_not_interrupt"] = block["configuration"].get( - "do_not_interrupt", observing_block_config["do_not_interrupt"] - ) - block["configuration"]["repositioning"] = block["configuration"].get( - "repositioning", observing_block_config["repositioning"] - ) - block["configuration"]["shutter_state"] = block["configuration"].get( - "shutter_state", observing_block_config["shutter_state"] - ) - block["configuration"]["readout"] = block["configuration"].get( - "readout", observing_block_config["readout"] - ) - block["configuration"]["binning"] = block["configuration"].get( - "binning", observing_block_config["binning"] - ) - block["configuration"]["frame_position"] = block["configuration"].get( - "frame_position", observing_block_config["frame_position"] - ) - block["configuration"]["frame_size"] = block["configuration"].get( - "frame_size", observing_block_config["frame_size"] - ) - block["configuration"]["pm_ra_cosdec"] = block["configuration"].get( - "pm_ra_cosdec", observing_block_config["pm_ra_cosdec"] - ) - block["configuration"]["pm_dec"] = block["configuration"].get( - "pm_dec", observing_block_config["pm_dec"] - ) - block["configuration"]["comment"] = block["configuration"].get( - "comment", observing_block_config["comment"] - ) - block["configuration"]["status"] = block["configuration"].get( - "status", observing_block_config["status"] - ) - block["configuration"]["message"] = block["configuration"].get( - "message", observing_block_config["message"] - ) - - # Input validation - block["target"] = str(block["target"]) - block["start time (UTC)"] = astrotime.Time( - block["start time (UTC)"], format="iso", scale="utc" - ) - block["end time (UTC)"] = astrotime.Time( - block["end time (UTC)"], format="iso", scale="utc" - ) - block["duration (minutes)"] = float(block["duration (minutes)"]) - block["ra"] = coord.Longitude(block["ra"]) - block["dec"] = coord.Latitude(block["dec"]) - - block["configuration"]["observer"] = str(block["configuration"]["observer"]) - block["configuration"]["code"] = str(block["configuration"]["code"]) - block["configuration"]["title"] = str(block["configuration"]["title"]) - - block["configuration"]["filename"] = str(block["configuration"]["filename"]) - if block["configuration"]["filename"] == "": - name = block["configuration"]["code"] + "_" - if block["target"] != "": - name += block["target"].replace(" ", "-") + "_" - # name += block['configuration']['exposure'] + 's_' - # name += 'FILT-'+block['configuration']['filter'] + '_' - # name += 'BIN-'+block['configuration']['binning'] + '_' - # name += 'READ-'+block['configuration']['readout'] + '_' - name += ( - block["start time (UTC)"].datetime.strftime("%Y-%m-%dT%H:%M:%S") - + ".fts" - ) - block["configuration"]["filename"] = name - if block["configuration"]["filename"].split(".")[-1] not in ( - "fts", - "fits", - "fit", - ): - block["configuration"]["filename"].split(".")[0] + ".fts" - - block["configuration"]["filter"] = str(block["configuration"]["filter"]) - - block["configuration"]["exposure"] = float( - block["configuration"]["exposure"] - ) - block["configuration"]["n_exp"] = int(block["configuration"]["n_exp"]) - block["configuration"]["do_not_interrupt"] = bool( - block["configuration"]["do_not_interrupt"] - ) - if do_not_interrupt: - if ( - 60 * block["duration (minutes)"] - < block["configuration"]["exposure"] - * block["configuration"]["n_exp"] - ): - logger.error("Insufficient time to complete exposures allocated.") - return None - else: - if ( - block["configuration"]["exposure"] - != 60 * block["duration (minutes)"] - or block["configuration"]["n_exp"] != 1 - ): - logger.error( - "n_exp must be 1 and exposure must be equal to duration if do_not_interrupt is False." - ) - return None - - block["configuration"]["repositioning"][0] = ( - int(block["configuration"]["repositioning"][0]) - if block["configuration"]["repositioning"][0] is not None - else None - ) - block["configuration"]["repositioning"][1] = ( - int(block["configuration"]["repositioning"][1]) - if block["configuration"]["repositioning"][1] is not None - else None - ) - - block["configuration"]["shutter_state"] = bool( - block["configuration"]["shutter_state"] - ) - block["configuration"]["readout"] = int(block["configuration"]["readout"]) - - block["configuration"]["binning"][0] = int( - block["configuration"]["binning"][0] - ) - block["configuration"]["binning"][1] = int( - block["configuration"]["binning"][1] - ) - - block["configuration"]["frame_position"][0] = int( - block["configuration"]["frame_position"][0] - ) - block["configuration"]["frame_position"][1] = int( - block["configuration"]["frame_position"][1] - ) - - block["configuration"]["frame_size"][0] = int( - block["configuration"]["frame_size"][0] - ) - block["configuration"]["frame_size"][1] = int( - block["configuration"]["frame_size"][1] - ) - - block["configuration"]["pm_ra_cosdec"] = u.Quantity( - block["configuration"]["pm_ra_cosdec"], unit=u.arcsec / u.hour - ) - block["configuration"]["pm_dec"] = u.Quantity( - block["configuration"]["pm_dec"], unit=u.arcsec / u.hour - ) - block["configuration"]["comment"] = str(block["configuration"]["comment"]) - block["configuration"]["status"] = str(block["configuration"]["status"]) - block["configuration"]["message"] = str(block["configuration"]["message"]) - - return block - - except: - return None - @property def do_periodic_autofocus(self): return self._do_periodic_autofocus @@ -2535,7 +2394,7 @@ def _update(self): self._telrun.schedule[i]["configuration"]["filename"], self._telrun.schedule[i]["configuration"]["filter"], self._telrun.schedule[i]["configuration"]["exposure"], - self._telrun.schedule[i]["configuration"]["n_exp"], + self._telrun.schedule[i]["configuration"]["nexp"], self._telrun.schedule[i]["configuration"]["do_not_interrupt"], self._telrun.schedule[i]["configuration"]["repositioning"][0] + "," @@ -2841,7 +2700,7 @@ def build_gui(self): self.filename = rows.add_row("Filename:") self.filter = rows.add_row("Filter:") self.exposure = rows.add_row("Exposure:") - self.n_exp = rows.add_row("N Exp:") + self.nexp = rows.add_row("N Exp:") self.do_not_interrupt = rows.add_row("Do Not Interrupt:") self.respositioning = rows.add_row("Respositioning:") self.shutter_state = rows.add_row("Shutter State:") @@ -2868,7 +2727,7 @@ def update(self, block): self.filename.set("") self.filter.set("") self.exposure.set("") - self.n_exp.set("") + self.nexp.set("") self.do_not_interrupt.set("") self.respositioning.set("") self.shutter_state.set("") @@ -2893,7 +2752,7 @@ def update(self, block): self.filename.set(block["filename"]) self.filter.set(block["filter"]) self.exposure.set(str(block["exposure"])) - self.n_exp.set(str(block["n_exp"])) + self.nexp.set(str(block["nexp"])) self.do_not_interrupt.set(str(block["do_not_interrupt"])) self.respositioning.set( str(block["respositioning"][0]) + "x" + str(block["respositioning"][1]) diff --git a/tests/reference/saved_observatory.cfg b/tests/reference/saved_observatory.cfg index ed1516b2..00d29f4b 100644 --- a/tests/reference/saved_observatory.cfg +++ b/tests/reference/saved_observatory.cfg @@ -73,4 +73,5 @@ driver_2 = [scheduling] slew_rate = 2.0 +instrument_reconfig_times = {} instrument_reconfiguration_times = {} diff --git a/tests/reference/simulator_observatory.cfg b/tests/reference/simulator_observatory.cfg index 4beeed95..fc587f2e 100644 --- a/tests/reference/simulator_observatory.cfg +++ b/tests/reference/simulator_observatory.cfg @@ -144,4 +144,4 @@ driver_2 = # degrees/second slew_rate = 2 -instrument_reconfiguration_times = +instrument_reconfig_times = {} diff --git a/tests/reference/test_sch.sch b/tests/reference/test_sch.sch new file mode 100644 index 00000000..27afd26c --- /dev/null +++ b/tests/reference/test_sch.sch @@ -0,0 +1,64 @@ +title ="this is a TEST" # only one title is allowed +observer: {wgolay@cfa.harvard.edu} +obs (wgolay@uiowa.edu) # can have multiple observers +code wgolay # whitespace ignored, only one code is allowed +datestart 2017-08-17 # only one datestart is allowed, valid range +dateend 2024-12-31 # only one dateend is allowed, valid range + +# this is a comment +! this is a comment too +% another type of comment + + +block start + source test + ra = 12:34:56.7 + dec = -12:34:56.7 + readout 0 + repositioning true + exposure 1 + filter i,g,r + nexp 3 +utstart 2024-01-01T12:34:56.7 cad 00:10:00 sc 00:02:00.0 +block end + + +block s + +source Algol +filename "Algol" +binning 2x2 +frame_p 10,10 +frame_s 100,100 +filt l +exposure 10 +comment "this is a comment" + + block e + + +block sta +source "C/2003 A2" non_sidereal 1 # this is a comment +priority 10 +repeat 5 +do-not-interrupt 1 +filt l +exp 10 +block e + + + + + + +source Algol filt = r exp: 10 + + + + + + +block star +sou "Jupiter" non-sidereal true +priority 11 filt l exp 1 +block e diff --git a/tests/reference/test_schedtel.cat b/tests/reference/test_schedtel.cat new file mode 100644 index 00000000..9d9ebb1e --- /dev/null +++ b/tests/reference/test_schedtel.cat @@ -0,0 +1 @@ +test_schedtel.sch diff --git a/tests/reference/test_schedtel.sch b/tests/reference/test_schedtel.sch new file mode 100644 index 00000000..b4ca8cbf --- /dev/null +++ b/tests/reference/test_schedtel.sch @@ -0,0 +1,10 @@ +title "schedtel test" +observer wgolay@cfa.harvard.edu +code wgolay +datestart 2017-08-17 +dateend 2024-12-31 + +ra 00h00m00s dec 88d00m00s exposure 60 filter i,g,r nexp 5 +ra 01h00m00s dec 88d00m00s exposure 60 filter r do_not_interrupt true nexp 5 +ra 02h00m00s dec 88d00m00s exposure 60 filter r non_sidereal true pm_ra_cosdec 1 pm_dec -1 +ra 03:00:00 dec 88:00:00 exposure 60 filter r utstart 04:00:00 schederr 00:30:00 diff --git a/tests/telrun/test_sch.py b/tests/telrun/test_sch.py new file mode 100644 index 00000000..8c8027f2 --- /dev/null +++ b/tests/telrun/test_sch.py @@ -0,0 +1,25 @@ +import pytest +from astropy import coordinates as coord +from astropy import time + +from pyscope.telrun import sch + + +def test_sch(tmp_path): + read_sched = sch.read( + "tests/reference/test_sch.sch", + location=coord.EarthLocation.of_site("VLA"), + t0=time.Time.now(), + ) + + assert len(read_sched) == 13 + + sch.write(read_sched, str(tmp_path) + "test.sch") + + read_sched = sch.read( + str(tmp_path) + "test.sch", + location=coord.EarthLocation.of_site("VLA"), + t0=time.Time.now(), + ) + + assert len(read_sched) == 17 diff --git a/tests/telrun/test_schedtel.py b/tests/telrun/test_schedtel.py index e69de29b..1b6cc3ca 100644 --- a/tests/telrun/test_schedtel.py +++ b/tests/telrun/test_schedtel.py @@ -0,0 +1,14 @@ +import pytest + +from pyscope.telrun import schedtel + + +def test_schedtel(tmp_path): + catalog = "./tests/reference/test_schedtel.cat" + observatory = "./tests/reference/simulator_observatory.cfg" + + schedule = schedtel(catalog=catalog, observatory=observatory) + + +if __name__ == "__main__": + test_schedtel("")