diff --git a/starcheck/src/lib/Ska/Starcheck/Obsid.pm b/starcheck/src/lib/Ska/Starcheck/Obsid.pm index 357d3567..a6aae9cd 100644 --- a/starcheck/src/lib/Ska/Starcheck/Obsid.pm +++ b/starcheck/src/lib/Ska/Starcheck/Obsid.pm @@ -310,10 +310,31 @@ sub set_maneuver { ################################################################################## my $self = shift; my $mm = shift; + my $ps = shift; my $n = 1; my $c; my $found; + my $dot_obsid = $self->{dot_obsid}; + # If the date is before ORViewer-DOT (promoted end of August 2017), get the ER obsids + # from the processing summary if the $dot_obsid does not begin with a number + # The last command from a pre-ORViewer-DOT looks to be 2017:240 so this is padded + # by a few days. + if ($dot_obsid !~ /^\d/ && ($self->{date} lt '2017:250:00:00:00.000')) { + # The processing summary has lines that look like this + # 'P080200 CAL 2017:013:03:00:49.827 2017:013:03:00:59.827 000:00:00:10.000 OBSID = 50385 {Perigee Attitude} + # For each line like that, I want to extract the obsid as the up to 5 digit number after OBSID and I + # want to extract the "dot_obsid" as the first 5 characters of the line (P0802 in this case) + foreach my $ps_line (@$ps) { + if ($ps_line =~ /^\s*(\S{5}).*OBSID\s*=\s*(\d{1,5})/) { + if ($1 eq $dot_obsid) { + $dot_obsid = $2; + last; + } + } + } + } + while ($c = find_command($self, "MP_TARGQUAT", $n++)) { $found = 0; foreach my $m (@{$mm}) { @@ -321,7 +342,7 @@ sub set_maneuver { # where manvr_dest is either the final_obsid of a maneuver or the eventual destination obsid # of a segmented maneuver - if ( ($manvr_obsid eq $self->{dot_obsid}) + if ( ($manvr_obsid eq $dot_obsid) && abs($m->{q1} - $c->{Q1}) < 1e-7 && abs($m->{q2} - $c->{Q2}) < 1e-7 && abs($m->{q3} - $c->{Q3}) < 1e-7) @@ -383,6 +404,11 @@ sub set_maneuver { sprintf("Did not find match in maneuvers for MP_TARGQUAT at $c->{date}\n") unless ($found); + unless ($found) { + # throw error and quit + exit(1); + } + } } @@ -1029,6 +1055,45 @@ sub check_for_srdcs { } } +############################################################################################# +sub check_planets{ +############################################################################################# + my $self = shift; + my $c = find_command($self, 'MP_STARCAT'); + $self->{planet_full_mitigation} = 0; + my $bright_data = call_python("utils.run_sparkles_planet_checks", + [ $self->{'proseco_args'} ]); + + if (exists $bright_data->{planet_full_mitigation}) { + $self->{planet_full_mitigation} = $bright_data->{planet_full_mitigation} ? 1 : 0; + } + + # Get an ordered list of star IDs from the catalog for easy lookup. + my @cat_star_ids = (); + if (defined $c) { + for my $i (1 .. 16) { + if ($c->{"TYPE$i"} ne 'NUL') { + push @cat_star_ids, ($c->{"GS_ID$i"} // '---'); + } + } + } + + for my $warn_type (qw(warn fyi orange_warn yellow_warn)) { + if (exists $bright_data->{$warn_type}) { + for my $warn (@{ $bright_data->{$warn_type} }) { + # If the warning has an 'idx', replace it with the actual star ID. + if ($warn =~ /idx (\d+)/) { + my $idx = $1; + my $star_id = $cat_star_ids[$idx] // '---'; + $warn =~ s/idx \d+/STAR_ID=$star_id/; + } + push @{ $self->{$warn_type} }, "$warn\n"; + } + } + } +} + + ############################################################################################# sub check_sim_position { ############################################################################################# @@ -1109,7 +1174,7 @@ sub check_star_catalog { my $is_er = ($self->{obsid} =~ /^\d+$/ && $self->{obsid} >= $ER_MIN_OBSID); my $min_guide = $is_science ? 5 : 6; # Minimum number of each object type my $min_acq = $is_science ? 4 : 5; - my $min_fid = 3; + my $min_fid = ($self->{planet_full_mitigation}) ? 2 : 3; ######################################################################## my @warn = (); @@ -1157,7 +1222,6 @@ sub check_star_catalog { # Global checks on star/fid numbers # ACA-005 ACA-006 ACA-007 ACA-008 ACA-044 - push @warn, "Too Few Fid Lights\n" if (@{ $self->{fid} } < $min_fid && $is_science); push @warn, "Too Many Fid Lights\n" if ( (@{ $self->{fid} } > 0 && $is_er) @@ -3120,6 +3184,8 @@ sub proseco_args { %proseco_args = ( obsid => $self->{obsid}, date => $targ_cmd->{stop_date}, + duration => $self->{obs_tstop} - $self->{obs_tstart}, + target_name => ($self->{TARGET_NAME}) ? $self->{TARGET_NAME} : $self->{SS_OBJECT}, att => [ 0 + $targ_cmd->{q1}, 0 + $targ_cmd->{q2}, @@ -3143,7 +3209,6 @@ sub proseco_args { n_fid => scalar(@fid_ids), acq_indexes => \@acq_indexes ); - return \%proseco_args; } diff --git a/starcheck/src/starcheck.pl b/starcheck/src/starcheck.pl index ec96d609..63f88aed 100755 --- a/starcheck/src/starcheck.pl +++ b/starcheck/src/starcheck.pl @@ -69,6 +69,7 @@ 'run_start_time=s', 'maude!', 'max_obsids:i', + 'save_pkl=s', ) || exit(1); usage(1) @@ -437,7 +438,7 @@ $obs{$obsid}->set_obsid(\%guidesumm); # Commanded obsid $obs{$obsid}->set_target(); $obs{$obsid}->set_star_catalog(); - $obs{$obsid}->set_maneuver($mm); + $obs{$obsid}->set_maneuver($mm, \@ps); $obs{$obsid}->set_files( $STARCHECK, $backstop, @@ -642,6 +643,7 @@ sub json_obsids { # Get the args that proseco would want $obs{$obsid}->{'proseco_args'} = $obs{$obsid}->proseco_args(); $obs{$obsid}->set_proseco_probs_and_check_P2(); + $obs{$obsid}->check_planets(); $obs{$obsid}->check_star_catalog($or{$obsid}, $par{vehicle}); $obs{$obsid}->check_sim_position(@sim_trans) unless $par{vehicle}; $obs{$obsid}->check_momentum_unload(\@bs); @@ -650,6 +652,10 @@ sub json_obsids { $obs{$obsid}->check_for_srdcs(\@bs); } + if ($par{save_pkl}) { + call_python("utils.save_proseco_catalogs", [ $par{save_pkl} ]); + } + # Make sure there is only one star catalog per obsid warning("More than one star catalog assigned to Obsid $obsid\n") if ($obs{$obsid}->find_command('MP_STARCAT', 2)); diff --git a/starcheck/tests/test_utils.py b/starcheck/tests/test_utils.py index 90ee0d2a..81f0b3b2 100644 --- a/starcheck/tests/test_utils.py +++ b/starcheck/tests/test_utils.py @@ -1,4 +1,123 @@ -from starcheck.utils import check_hot_pix +import pickle + +from starcheck import utils +from starcheck.utils import ( + check_hot_pix, + get_and_collect_proseco_catalog, + get_proseco_catalog, + run_sparkles_planet_checks, + save_proseco_catalogs, +) + +# Real proseco_args from obsid 28777 in JUN2325_A (HRC-I, 3 fids, 8 acq/5 guide) +_OBS_28777_ARGS = { + "obsid": 28777, + "att": [0.142378962865, -0.842210948602, 0.4728311264, 0.216424755738], + "date": "2025:174:00:42:44.586", + "detector": "HRC-I", + "dither_acq": [20.0014963044938, 20.0014963044938], + "dither_guide": [20.0014963044938, 20.0014963044938], + "fid_ids": [8, 9, 10], + "include_halfws_acq": [160, 160, 120, 140, 100, 160, 160, 100], + "include_ids_acq": [ + 331222504, + 331224640, + 260576048, + 331223656, + 260571904, + 260580432, + 260575280, + 260575376, + ], + "include_ids_guide": [331222504, 331224640, 260576048, 331223656, 260571904], + "man_angle": 122.221322321489, + "n_acq": 8, + "n_fid": 3, + "n_guide": 5, + "sim_offset": 0, + "t_ccd_acq": -4.72435604571444, + "t_ccd_guide": -4.53268231435102, + "duration": 20000.0, + "target_name": "SN 1941C", +} + + +def test_get_proseco_catalog_returns_expected_acq_stars(): + """get_proseco_catalog returns a catalog containing all commanded acq star IDs.""" + aca = get_proseco_catalog(**_OBS_28777_ARGS) + acq_ids = set(aca.acqs["id"]) + for star_id in _OBS_28777_ARGS["include_ids_acq"]: + assert star_id in acq_ids + + +def test_get_and_collect_proseco_catalog_accumulates(): + """get_and_collect_proseco_catalog stores the catalog in _proseco_catalogs keyed by obsid.""" + utils._proseco_catalogs.clear() + aca = get_and_collect_proseco_catalog(_OBS_28777_ARGS) + assert _OBS_28777_ARGS["obsid"] in utils._proseco_catalogs + assert utils._proseco_catalogs[_OBS_28777_ARGS["obsid"]] is aca + utils._proseco_catalogs.clear() + + +# Venus bad case adapted from sparkles test_venus_bad (att/date where Venus is on CCD) +_VENUS_BAD_ARGS = { + "obsid": 18696, + "att": [-0.54152552, 0.17005146, -0.10308105, 0.81682734], + "man_angle": 90, + "date": "2017:010:06:57:57.000", + "t_ccd_acq": -10.0, + "t_ccd_guide": -10.0, + "dither_acq": [7.9992, 7.9992], + "dither_guide": [7.9992, 7.9992], + "detector": "ACIS-I", + "sim_offset": 0, + "n_acq": 8, + "n_guide": 5, + "n_fid": 3, + "fid_ids": [1, 2, 3], + "include_ids_acq": [], + "include_halfws_acq": [], + "include_ids_guide": [], + "duration": 30000.0, + "target_name": "Venus", +} + + +def test_run_sparkles_planet_checks_venus_bad(): + """run_sparkles_planet_checks produces critical warnings and sets planet_full_mitigation + for an attitude where Venus is on the CCD and mitigation requirements are not met.""" + result = run_sparkles_planet_checks(_VENUS_BAD_ARGS) + assert result["planet_full_mitigation"] is True + assert "Need 5 guide stars on side of CCD opposite bright object." in result["warn"] + assert "Need 2 fid lights on side of CCD opposite bright object." in result["warn"] + assert "Bright object tracks too close to CCD boundary row=0." in result["warn"] + assert "Full mitigation OBO checks failed." in result["warn"] + assert "Venus on CCD. (mag -5.0 to -2.9)." in result["fyi"] + assert result["orange_warn"] == [] + assert result["yellow_warn"] == [] + + +def test_run_sparkles_planet_checks_no_planet(): + """run_sparkles_planet_checks returns no warnings and planet_full_mitigation False + for a normal observation with no nearby bright planet.""" + result = run_sparkles_planet_checks(_OBS_28777_ARGS) + assert result["planet_full_mitigation"] is False + assert result["warn"] == [] + assert result["orange_warn"] == [] + assert result["yellow_warn"] == [] + + +def test_save_proseco_catalogs(tmp_path): + """save_proseco_catalogs writes _proseco_catalogs to a readable pickle file.""" + utils._proseco_catalogs.clear() + get_and_collect_proseco_catalog(_OBS_28777_ARGS) + out_path = tmp_path / "catalogs.pkl" + save_proseco_catalogs(out_path) + assert out_path.exists() + with open(out_path, "rb") as f: + loaded = pickle.load(f) + assert _OBS_28777_ARGS["obsid"] in loaded + utils._proseco_catalogs.clear() def test_check_dynamic_hot_pix(): diff --git a/starcheck/utils.py b/starcheck/utils.py index 40bedc25..f58444c3 100644 --- a/starcheck/utils.py +++ b/starcheck/utils.py @@ -1,5 +1,6 @@ import logging import os +import pickle import warnings from pathlib import Path @@ -584,3 +585,131 @@ def vehicle_filter_backstop(backstop_file, outfile): ] # Write the filtered commands to the output file write_backstop(filtered_cmds, outfile) + + +# Fiducial light ID offsets for different detectors, used to translate +# starcheck global IDs to proseco per-detector IDs. +FID_OFFSET = {"HRC-I": 6, "HRC-S": 10, "ACIS-I": 0, "ACIS-S": 0} + + +def get_proseco_catalog(**kw): + """ + Build a full proseco ACA catalog from starcheck observation parameters. + + Translates fid light IDs from starcheck's global numbering (ACIS 1-6, + HRC-I 7-10, HRC-S 11-14) to proseco's per-detector numbering by + subtracting detector-specific offsets. + + :param **kw: keyword arguments matching the proseco_args structure from + Obsid.proseco_args(). + :returns: proseco ACATable catalog + """ + # Define the set of valid arguments for proseco.get_aca_catalog + PROSECO_ARGS = { + "obsid", + "att", + "duration", + "target_name", + "date", + "n_acq", + "n_guide", + "man_angle", + "t_ccd_acq", + "t_ccd_guide", + "dither_acq", + "dither_guide", + "include_ids_acq", + "include_halfws_acq", + "detector", + "sim_offset", + "include_ids_guide", + "include_ids_fid", + "n_fid", + "focus_offset", + "monitors", + } + + # Create the args dict by picking only the valid keys from kwargs + args = {key: kw[key] for key in PROSECO_ARGS if key in kw} + + # --- Handle transformations and required arguments --- + + # Translate fid_ids from starcheck global to proseco per-detector + fid_offset = FID_OFFSET.get(kw["detector"], 0) + fid_ids = np.array(kw["fid_ids"]) - fid_offset + args["include_ids_fid"] = list(fid_ids) + args["n_fid"] = len(fid_ids) + + # Ensure required types and values + args["obsid"] = int(kw["obsid"]) + args["att"] = Quaternion.normalize(kw["att"]) + args["dither_acq"] = ACABox(kw["dither_acq"]) + args["dither_guide"] = ACABox(kw["dither_guide"]) + args["focus_offset"] = 0 + + aca = get_aca_catalog(**args) + return aca + + +# Module-level accumulator for proseco catalogs; populated by get_and_collect_proseco_catalog. +_proseco_catalogs = {} + + +def get_and_collect_proseco_catalog(proseco_args): + """ + Build a proseco catalog, store it in _proseco_catalogs by obsid, and return it. + + :param proseco_args: dict of observation parameters (obsid, att, etc.) + :returns: proseco ACATable catalog + """ + aca = get_proseco_catalog(**proseco_args) + _proseco_catalogs[proseco_args["obsid"]] = aca + return aca + + +def run_sparkles_planet_checks(proseco_args): + """ + Run sparkles planet checks and return categorized warnings. + + Builds a proseco catalog for the observation, runs sparkles planet checks, + and returns warnings sorted by severity. Also accumulates the catalog in + _proseco_catalogs. + + :param proseco_args: dict of observation parameters (same format as + Obsid.proseco_args() in the Perl code) + :returns: dict with keys: + - 'warn' (list[str]): critical-level warnings + - 'orange_warn' (list[str]): caution-level warnings + - 'yellow_warn' (list[str]): warning-level messages + - 'fyi' (list[str]): informational messages + - 'planet_full_mitigation' (bool): True if full planet mitigation + applies, allowing 2 fid lights instead of the usual 3 + """ + from sparkles.checks import get_planet_check_data + from sparkles.messages import MessagesList + + aca = get_and_collect_proseco_catalog(proseco_args) + acar = aca.get_review_table() + planet_check_data = get_planet_check_data(acar) + msgs = MessagesList(planet_check_data["messages"]) + return { + "warn": [w["text"] for w in msgs == "critical"], + "orange_warn": [w["text"] for w in msgs == "caution"], + "yellow_warn": [w["text"] for w in msgs == "warning"], + "fyi": [w["text"] for w in msgs == "info"], + "planet_full_mitigation": planet_check_data["planet_full_mitigation"], + } + + +def save_proseco_catalogs(path): + """ + Write the accumulated proseco catalogs to a pickle file. + + Pickles the module-level _proseco_catalogs dict (keyed by obsid) to path. + Intended to be called at the end of a starcheck run to persist catalogs + for downstream use. + + :param path: file path to write the pickle + """ + with open(path, "wb") as f: + pickle.dump(_proseco_catalogs, f)