From 732b0f4702a1ad1b081791dc3c592b5645d33a51 Mon Sep 17 00:00:00 2001 From: WGolay Date: Tue, 21 Nov 2023 17:39:21 -0500 Subject: [PATCH] Close #101 --- pyscope/telrun/__init__.py | 7 +- pyscope/telrun/sch.py | 341 +++++++++++++++++++++------------- pyscope/telrun/schedtel.py | 322 ++++++++++++++++++++++---------- tests/reference/test_sch.sch | 62 +++++++ tests/telrun/test_schedtel.py | 27 +++ 5 files changed, 531 insertions(+), 228 deletions(-) create mode 100644 tests/reference/test_sch.sch diff --git a/pyscope/telrun/__init__.py b/pyscope/telrun/__init__.py index fbab5dff..328e0d39 100644 --- a/pyscope/telrun/__init__.py +++ b/pyscope/telrun/__init__.py @@ -13,9 +13,9 @@ from .init_dirs import init_telrun_dir, init_remote_dir from .mk_mosaic_schedule import mk_mosaic_schedule from .rst import rst -from .sch import read_sch, write_sch +from . import sch from .validate_ob import validate_ob -from .schedtel import schedtel, plot_schedule_gantt +from .schedtel import schedtel, plot_schedule_gantt, plot_schedule_sky from .startup import start_telrun, start_syncfiles from .summary_report import summary_report from .syncfiles import syncfiles @@ -27,8 +27,7 @@ "init_remote_dir", "mk_mosaic_schedule", "rst", - "read_sch", - "write_sch", + "sch", "validate_ob", "schedtel", "plot_schedule_gantt", diff --git a/pyscope/telrun/sch.py b/pyscope/telrun/sch.py index e1a83296..d49eb009 100644 --- a/pyscope/telrun/sch.py +++ b/pyscope/telrun/sch.py @@ -1,7 +1,10 @@ +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 @@ -12,7 +15,7 @@ logger = logging.getLogger(__name__) -def read_sch( +def read( filename, location=None, t0=None, @@ -34,6 +37,7 @@ def read_sch( "ti": "title", "obs": "observer", "cod": "code", + "bl": "block", "so": "source", "ta": "source", "obj": "source", @@ -42,6 +46,8 @@ def read_sch( "ri": "ra", "de": "dec", "no": "nonsidereal", + "non_": "nonsidereal", + "non-": "nonsidereal", "pm_r": "pm_ra_cosdec", "pm_d": "pm_dec", "file": "filename", @@ -49,7 +55,7 @@ def read_sch( "repo": "repositioning", "sh": "shutter_state", "read": "readout", - "b": "binning", + "bi": "binning", "frame_p": "frame_position", "frame_s": "frame_size", "u": "utstart", @@ -76,91 +82,39 @@ def read_sch( 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("(", '"') + 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.split("%")[0] for line in lines] # Remove comments + lines = [line.replace("\n", "") for line in lines] # Remove line breaks lines = [ - " ".join(shlex.split(line)) for line in lines + " ".join(line.split()) for line in lines ] # Remove multiple, trailing, leading whitespace lines = [line for line in lines if line != ""] # Remove empty lines - lines = [line.lower() for line in lines] # Lower case - - for line in lines: - l = shlex.split(line) # parse nonsidereal as flag - for i in range(len(l)): - if l[i].startswith("no"): - if ( - l[i + 1].startswith("t") - and not l[i + 1][1:].startswith("i") - or l[i + 1].startswith("1") - or l[i + 1].startswith("y") - ): - line.replace(l[i], "nonsidereal true") - elif ( - l[i + 1].startswith("f") - and not l[i + 1][1:].startswith("i") - or l[i + 1].startswith("0") - or l[i + 1].startswith("n") - ): - line.replace(l[i], "nonsidereal false") - else: - line.replace(l[i], "nonsidereal true") - elif l[i].startswith("do"): # parse do_not_interrupt as flag - if ( - l[i + 1].startswith("t") - and not l[i + 1][1:].startswith("i") - or l[i + 1].startswith("1") - or l[i + 1].startswith("y") - ): - line.replace(l[i], "do_not_interrupt true") - elif ( - l[i + 1].startswith("f") - and not l[i + 1][1:].startswith("i") - or l[i + 1].startswith("0") - or l[i + 1].startswith("n") - ): - line.replace(l[i], "do_not_interrupt false") - else: - line.replace(l[i], "do_not_interrupt true") - elif l[i].startswith("repo"): # parse repositioning as flag - if ( - l[i + 1].startswith("t") - and not l[i + 1][1:].startswith("i") - or l[i + 1].startswith("1") - or l[i + 1].startswith("y") - ): - line.replace(l[i], "repositioning true") - elif ( - l[i + 1].startswith("f") - and not l[i + 1][1:].startswith("i") - or l[i + 1].startswith("0") - or l[i + 1].startswith("n") - ): - line.replace(l[i], "repositioning false") - elif ( - isnumeric(l[i + 1].split("x")[0]) - and isnumeric(l[i + 1].split("x")[1]) - ) or ( - isnumeric(l[i + 1].split(",")[0]) - and isnumeric(l[i + 1].split(",")[1]) - ): - continue - else: - line.replace(l[i], "repositioning true") + + # From: https://stackoverflow.com/questions/35544325/python-convert-entire-string-to-lowercase-except-for-substrings-in-quotes + lines = [ + re.sub(r'\b(? 1: + value_matches = list(set(value_matches)) + if len(value_matches) > 1: logger.error( f"Keyword {key} matches multiple possible keywords: {value_matches}, removing line {line_number}: {line}" ) lines.remove(line) - elif key.startswith(possible_key): - line[possible_keys[possible_key]] = line.pop(key) - else: + 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 keywords title_matches = [] - for line in lines: + 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.remove(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] @@ -211,27 +176,39 @@ def read_sch( title = default_title observers = [] - for line in lines: + 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.remove(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 = [] - for line in lines: + 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.remove(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] @@ -241,6 +218,65 @@ def read_sch( logger.warning("No code found, using parsing function default") code = default_code + # 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 @@ -263,9 +299,17 @@ def read_sch( # Parse nonsidereal nonsidereal = default_nonsidereal if "nonsidereal" in line.keys(): - if line["nonsidereal"] == "true": + if ( + line["nonsidereal"].startswith("t") + or line["nonsidereal"].startswith("1") + or line["nonsidereal"].startswith("y") + ): nonsidereal = True - elif line["nonsidereal"] == "false": + elif ( + line["nonsidereal"].startswith("f") + or line["nonsidereal"].startswith("0") + or line["nonsidereal"].startswith("n") + ): nonsidereal = False else: logger.warning( @@ -297,19 +341,43 @@ def read_sch( ephemerides = mpc.MPC.get_ephemeris( target=source_name, location=location, - start=t0 + 0.5 * u.day, + start=t0, number=1, proper_motion="sky", ) - except Exception as e: - logger.warning( - f"Failed to find proper motions for {source_name} on line {line_number}, skipping: {e}" - ) - continue - 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 + 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() @@ -335,11 +403,6 @@ def read_sch( logger.warning( f"Missing proper motion pm_dec on line {line_number}, assuming 0: {line}" ) - else: - logger.error( - f"Improper combination of proper motion keywords on line {line_number}, skipping: {line}" - ) - continue # Parse source if not already parsed by pm lookup if source_name is None and ra is None and dec is None: @@ -348,7 +411,13 @@ def read_sch( ) continue elif None not in (ra, dec): - obj = coord.SkyCoord(ra, dec, pm_ra_cosdec=pm_ra_cosdec, pm_dec=pm_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: @@ -378,9 +447,17 @@ def read_sch( # Parse repositioning repositioning = default_repositioning if "repositioning" in line.keys(): - if line["repositioning"] == "true": + if ( + line["repositioning"].startswith("t") + or line["repositioning"].startswith("1") + or line["repositioning"].startswith("y") + ): repositioning = True - elif line["repositioning"] == "false": + elif ( + line["repositioning"].startswith("f") + or line["repositioning"].startswith("0") + or line["repositioning"].startswith("n") + ): repositioning = False elif ( line["repositioning"].split("x")[0].isnumeric() @@ -441,7 +518,7 @@ def read_sch( int(line["binning"].split("x")[0]), int(line["binning"].split("x")[1]), ) - if ( + elif ( line["binning"].split(",")[0].isnumeric() and line["binning"].split(",")[1].isnumeric() ): @@ -505,12 +582,15 @@ def read_sch( # Get utstart, cadence, schederr utstart = None if "utstart" in line.keys(): - utstart = astrotime.Time(line["utstart"], format="isot", scale="utc") + utstart = astrotime.Time( + line["utstart"].upper(), format="isot", scale="utc" + ) cadence = None if "cadence" in line.keys(): + h, m, s = line["cadence"].split(":") cadence = astrotime.TimeDelta( - datetime.time(*[int(c) for c in line["cadence"].split(":")]), + datetime.timedelta(hours=int(h), minutes=int(m), seconds=float(s)), format="datetime", ) @@ -522,8 +602,9 @@ def read_sch( schederr = None if "schederr" in line.keys(): + h, m, s = line["schederr"].split(":") schederr = astrotime.TimeDelta( - datetime.time(*[int(s) for s in line["schederr"].split(":")]), + datetime.timedelta(hours=int(h), minutes=int(m), seconds=float(s)), format="datetime", ) @@ -568,25 +649,25 @@ def read_sch( comment = line["comment"] # Get filters - filters = telrun.observing_block_config["filter"] + filters = [] if "filter" in line.keys(): filters = line["filter"].split(",") prior_filters = filters elif prior_filters is not None: filters = prior_filters else: - filters = telrun.observing_block_config["filters"] + filters = [] prior_filters = None # Get exposures - exposures = telrun.observing_block_config["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 = telrun.observing_block_config["exposures"] + exposures = [] prior_exposures = None # Expand exposures or filters to match length of the other if either length is one @@ -603,7 +684,7 @@ def read_sch( continue # Sanity Check 2: do_not_interrupt and cadence don't both appear: - if do_not_interrupt is not None and cadence is not None: + 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}" ) @@ -612,7 +693,7 @@ def read_sch( # 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) * nexp * len(filters): + 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}" ) @@ -641,15 +722,21 @@ def read_sch( constraints = [ [ astroplan.constraints.TimeConstraint( - utstart + (i + j * len(i)) * constraint_cadence - schederr, - utstart + (i + j * len(i)) * constraint_cadence + schederr, + utstart + + (i + j * len(filters)) * constraint_cadence + - schederr, + 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: + if loop_max > 1 and fname != "": final_fname = f"{fname}_{j}" else: final_fname = f"{fname}" @@ -660,7 +747,7 @@ def read_sch( priority=priority, name=source_name, configuration={ - "observer": observer, + "observer": observers, "code": code, "title": title, "filename": final_fname, @@ -693,7 +780,7 @@ def read_sch( return blocks -def write_sch(observing_blocks, filename=None): +def write(observing_blocks, filename=None): if type(observing_blocks) is not list: observing_blocks = [observing_blocks] diff --git a/pyscope/telrun/schedtel.py b/pyscope/telrun/schedtel.py index 9cce7c99..0d1ba9b6 100644 --- a/pyscope/telrun/schedtel.py +++ b/pyscope/telrun/schedtel.py @@ -21,19 +21,25 @@ from .. import utils from ..observatory import Observatory -from . import read_sch +from . import sch, validate_ob 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", - 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.", + 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( "-i", @@ -42,32 +48,39 @@ 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( "-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", @@ -75,20 +88,23 @@ 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", "--moon-separation", - type=click.FloatRange(min=0), + type=click.FloatRange(min=0, clamp=True), default=30, show_default=True, help="The minimum angular separation between the Moon and all targets [degrees].", @@ -97,53 +113,66 @@ "-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", "--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( "-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", 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( "-q", "--quiet", is_flag=True, default=False, show_default=True, help="Quiet output" @@ -153,22 +182,22 @@ ) @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, + ignore_order=False, + date=None, + observatory=None, + max_altitude=-12, + elevation=30, + airmass=3, + moon_separation=30, + scheduler=("", ""), + gap_time=60, + resolution=5, + filename=None, + telrun=False, + plot=None, + quiet=False, + verbose=0, ): # Set up logging if quiet: @@ -201,6 +230,18 @@ def schedtel_cli( blocks = [] + if catalog 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"): @@ -208,38 +249,76 @@ def schedtel_cli( sch_files = f.read().splitlines() 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(read_sch(f)) + logger.error( + f"File {f} in catalog {catalog} does not exist, skipping." + ) + continue + try: + blocks.append(sch.read(f)) + 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(read_sch(catalog)) + try: + blocks.append(sch.read(catalog)) + except Exception as e: + logger.error(f"File {catalog} is not a valid .sch file: {e}") + return - elif type(catalog) in (list, tuple, iter): + elif type(catalog) in (list, tuple): logger.debug(f"catalog is a list") for block in catalog: - if type(block) in (list, tuple, iter): + if type(block) in (list, tuple): 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 + continue blocks.append(block) elif type(block) is astroplan.ObservingBlock: blocks.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 file or list.") + return + + for block in blocks: + for b in block: + try: + b = validate_ob(b) + except Exception as e: + logger.error( + f"Object {b} in catalog {catalog} is not a valid ObservingBlock, removing from schedule: {e}" + ) + block.remove(b) if ignore_order: + logger.info("Ignoring order of .sch files in catalog") blocks = [ [blocks[i][j] for i in range(len(blocks)) for j in range(len(blocks[i]))] ] # Define the observatory - logger.info("Parsing 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 not astroplan.Observer: if type(observatory) is str: obs_cfg = configparser.ConfigParser() @@ -268,9 +347,17 @@ def schedtel_cli( instrument_name = observatory.instrument_name observatory = astroplan.Observer(location=observatory.observatory_location) else: - raise TypeError( + logger.error( "Observatory must be, a string, Observatory object, or astroplan.Observer object." ) + return + else: + obs_long = observatory.location.lon.deg + obs_lat = observatory.location.lat.deg + obs_height = 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 # Constraints logger.info("Defining global constraints") @@ -354,41 +441,63 @@ def schedtel_cli( or row["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", - ) - row["ra"] = ephemerides["RA"][0] - row["dec"] = ephemerides["DEC"][0] - row["configuration"]["pm_ra_cosdec"] = ( - ephemerides["dRA cos(Dec)"][0] * u.arcsec / u.hour - ) - row["configuration"]["pm_dec"] = ephemerides["dDec"][0] * u.arcsec / u.hour - - # Re-assign filenames - name_dict = {} - for i in range(len(schedule_table)): - name = schedule_table[i]["configuration"]["code"] - - if name in name_dict: - name_dict[name] += 1 - else: - name_dict[name] = 0 - - name += ( - ("%3.3g" % date.strftime("%j")) + "_" + ("%4.4g" % name_dict[name]) + ".fts" - ) - schedule_table[i]["configuration"]["filename"] = name + try: + ephemerides = mpc.MPC.get_ephemeris( + target=row["name"], + location=observatory.location, + start=row["start time (UTC)"], + number=1, + proper_motion="sky", + ) + row["ra"] = ephemerides["RA"][0] + row["dec"] = ephemerides["Dec"][0] + row["configuration"]["pm_ra_cosdec"] = ( + ephemerides["dRA cos(Dec)"][0] * u.arcsec / u.hour + ) + row["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( + row["name"], + row["start time (UTC)"] - 10 * u.minute, + location=observatory.location, + ) + pos_m = coord.get_body( + row["name"], row["start time (UTC)"], location=location + ) + pos_h = coord.get_body( + row["name"], + row["start time (UTC)"] + 10 * u.minute, + location=observatory.location, + ) + row["ra"] = pos_m.ra.to_string("hourangle", sep="hms", precision=3) + row["dec"] = pos_m.dec.to_string("deg", sep="dms", precision=2) + row["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) + row["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 {row['name']} on line {line_number}, keeping old ephemerides: {e2}" + ) # Write the telrun.ecsv 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") + ).isot filename = "telrun_" + first_time + ".ecsv" if telrun: @@ -439,14 +548,23 @@ def schedtel_cli( return schedule_table, ax case _: logger.info("No plot requested") - pass return schedule_table -@click.command() -@click.argument("schedule_table", type=click.Path(exists=True)) -@click.argument("observatory", type=click.Path(exists=True)) +@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: @@ -614,9 +732,19 @@ def plot_schedule_gantt_cli(schedule_table, observatory): return ax -@click.command() -@click.argument("schedule_table", type=click.Path(exists=True)) -@click.argument("observatory", type=click.Path(exists=True)) +@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 = [] diff --git a/tests/reference/test_sch.sch b/tests/reference/test_sch.sch new file mode 100644 index 00000000..cee0c456 --- /dev/null +++ b/tests/reference/test_sch.sch @@ -0,0 +1,62 @@ +title ="this is a TEST" +observer: {wgolay@cfa.harvard.edu} +obs (wgolay@uiowa.edu) +code wgolay + +# 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/telrun/test_schedtel.py b/tests/telrun/test_schedtel.py index e69de29b..de26f2ea 100644 --- a/tests/telrun/test_schedtel.py +++ b/tests/telrun/test_schedtel.py @@ -0,0 +1,27 @@ +import logging + +import pytest +from astropy import coordinates as coord +from astropy import time + +from pyscope import logger + +# from pyscope.telrun import schedtel, plot_schedule_gantt, plot_schedule_sky +from pyscope.telrun import sch + + +def test_sch_read(): + logger.setLevel("INFO") + logger.addHandler(logging.StreamHandler()) + read_sched = sch.read( + "tests/reference/test_sch.sch", + location=coord.EarthLocation.of_site("VLA"), + t0=time.Time.now(), + ) + + for ob in read_sched: + print(ob.configuration["pm_ra_cosdec"], ob.configuration["pm_dec"]) + + +if __name__ == "__main__": + test_sch_read()