diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..80eedba --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +filterwarnings = + ignore:'soft_unicode' has been renamed to 'soft_str' + ignore:`np.object` is a deprecated alias for the builtin `object` + diff --git a/sparkles/core.py b/sparkles/core.py index 804f99f..867168e 100644 --- a/sparkles/core.py +++ b/sparkles/core.py @@ -34,6 +34,7 @@ from .roll_optimize import RollOptimizeMixin + CACHE = {} FILEDIR = Path(__file__).parent @@ -1300,3 +1301,30 @@ def check_fid_count(self): if self.n_fid != typical_n_fid: msg = f'{obs_type} requested {self.n_fid} fids but {typical_n_fid} is typical' self.add_message('caution', msg) + + @classmethod + def from_ocat(cls, obsid, t_ccd=-5, man_angle=5, date=None, roll=None, **kwargs): + """Return an AcaReviewTable object using OCAT to specify key information. + + :param obsid: obsid + :param t_ccd: ACA CCD temperature (degrees C) + :param man_angle: maneuver angle (degrees) + :param date: observation date (for proper motion and ACA offset projection) + :param roll: roll angle (degrees) + :param **kwargs: additional keyword args to update or override params from + yoshi for call to get_aca_catalog() + + :returns: AcaReviewTable object + """ + from proseco import get_aca_catalog + from .yoshi import get_yoshi_params_from_ocat, convert_yoshi_to_proseco_params + + params_yoshi = get_yoshi_params_from_ocat(obsid, obs_date=date) + if roll is not None: + params_yoshi['roll_targ'] = roll + params_proseco = convert_yoshi_to_proseco_params( + **params_yoshi, obsid=obsid, man_angle=man_angle, t_ccd=t_ccd) + params_proseco.update(kwargs) + aca = get_aca_catalog(**params_proseco) + acar = cls(aca) + return acar diff --git a/sparkles/tests/test_yoshi.py b/sparkles/tests/test_yoshi.py new file mode 100644 index 0000000..6ff1b67 --- /dev/null +++ b/sparkles/tests/test_yoshi.py @@ -0,0 +1,146 @@ +import numpy as np +import agasc +import pytest +from Quaternion import Quat +from mica.archive.tests.test_cda import HAS_WEB_SERVICES +from sparkles.core import ACAReviewTable + +from sparkles.yoshi import ( + get_yoshi_params_from_ocat, + run_one_yoshi, + convert_yoshi_to_proseco_params, +) + + +@pytest.fixture(autouse=True) +def do_not_use_agasc_supplement(monkeypatch): + """Do not use AGASC supplement in any test""" + monkeypatch.setenv(agasc.SUPPLEMENT_ENABLED_ENV, "False") + + +@pytest.mark.skipif(not HAS_WEB_SERVICES, reason="No web services available") +def test_run_one_yoshi(): + """Regression test a single run for a real obsid""" + request = { + "obsid": 20562, + "chip_id": 3, + "chipx": 970.0, + "chipy": 975.0, + "dec_targ": 66.35, + "detector": "ACIS-I", + "dither_y": 8, + "dither_z": 8, + "focus_offset": 0, + "man_angle": 175.0, + "obs_date": "2019:174:00:32:23.789", + "offset_y": -2.3, + "offset_z": 3.0, + "ra_targ": 239.06125, + "roll_targ": 197.12, + "sim_offset": 0, + "t_ccd": -9.1, + } + + expected = { + "ra_aca": 238.96459762180638, + "dec_aca": 66.400811774068146, + "roll_aca": 197.20855489084187, + "n_critical": 2, + "n_warning": 1, + "n_caution": 0, + "n_info": 1, + "P2": 1.801521445484349, + "guide_count": 3.8577301426624357, + } + + actual = run_one_yoshi(**request, dyn_bgd_n_faint=0) + + for key in expected: + val = expected[key] + val2 = actual[key] + if isinstance(val, float): + assert np.isclose(val, val2, atol=1e-3) + else: + assert val == val2 + + +@pytest.mark.skipif(not HAS_WEB_SERVICES, reason="No web services available") +def test_get_params(): + params = get_yoshi_params_from_ocat(obsid=8008, obs_date="2022:001") + exp = { + "chip_id": 3, + "chipx": 970.0, + "chipy": 975.0, + "dec_targ": 2.3085194444444443, + "detector": "ACIS-I", + "dither_y": 8, + "dither_z": 8, + "focus_offset": 0, + "obs_date": "2022:001:00:00:00.000", + "offset_y": 0.0, + "offset_z": np.ma.masked, + "ra_targ": 149.91616666666664, + "roll_targ": 62.01050867568485, + "sim_offset": 0, + } + assert_dict_equal(params, exp) + + params_proseco = convert_yoshi_to_proseco_params( + **params, obsid=8008, t_ccd=-10, man_angle=5.0 + ) + exp_proseco = { + "att": Quat([0.15017923, 0.49292814, 0.83025727, 0.21246392]), + "date": "2022:001:00:00:00.000", + "detector": "ACIS-I", + "dither": (8, 8), + "focus_offset": 0, + "man_angle": 5.0, + "n_acq": 8, + "n_fid": 3, + "n_guide": 5, + "obsid": 8008, + "sim_offset": 0, + "t_ccd": -10, + } + assert_dict_equal(params_proseco, exp_proseco) + + +def assert_dict_equal(dict1, dict2): + assert dict2.keys() == dict1.keys() + for key in dict2: + val = dict1[key] + val_exp = dict2[key] + if isinstance(val, float): + assert np.isclose(val, val_exp, atol=1e-8) + elif val_exp is np.ma.masked: + assert val is np.ma.masked + elif isinstance(val_exp, Quat): + assert str(val) == str(val_exp) + else: + assert val == val_exp + + +@pytest.mark.skipif(not HAS_WEB_SERVICES, reason="No web services available") +def test_acar_from_ocat(monkeypatch): + """Get an AcaReviewTable with minimal information filling in rest from OCAT""" + monkeypatch.setenv(agasc.SUPPLEMENT_ENABLED_ENV, "False") + + acar = ACAReviewTable.from_ocat(obsid=8008, date="2022:001", t_ccd=-10, n_acq=6) + assert acar.obsid == 8008 + assert acar.date == "2022:001:00:00:00.000" + assert len(acar.acqs) == 6 + exp = [ + "idx slot id type mag yang zang row col ", + "--- ---- -------- ---- ----- ------- ------- ------ ------", + " 1 0 1 FID 7.00 919.8 -844.2 -178.7 -164.1", + " 2 1 5 FID 7.00 -1828.2 1053.8 374.1 216.2", + " 3 2 6 FID 7.00 385.8 1697.8 -70.8 346.0", + " 4 3 31983336 BOT 8.64 878.2 -1622.7 -171.0 -320.8", + " 5 4 31075368 BOT 9.13 50.8 737.3 -3.8 152.6", + " 6 5 32374896 BOT 9.17 2007.9 -2050.0 -400.2 -408.3", + " 7 6 31075128 BOT 9.35 -317.2 1185.7 70.1 242.6", + " 8 7 31463496 BOT 9.46 2028.6 1371.5 -402.1 281.9", + " 9 0 31076560 ACQ 9.70 -939.8 -368.7 194.3 -69.3", + ] + cols = ("idx", "slot", "id", "type", "mag", "yang", "zang") + assert acar[cols].pformat_all() == exp diff --git a/sparkles/yoshi.py b/sparkles/yoshi.py new file mode 100644 index 0000000..727d704 --- /dev/null +++ b/sparkles/yoshi.py @@ -0,0 +1,258 @@ +import numpy as np +import numpy.ma +from chandra_aca.transform import calc_aca_from_targ +from chandra_aca.drift import get_aca_offsets +from mica.archive.cda import get_ocat_web, get_ocat_local +from Ska.Sun import nominal_roll +from cxotime import CxoTime + + +def get_yoshi_params_from_ocat(obsid, obs_date=None, web_ocat=True): + """ + For an obsid in the OCAT, fetch params from OCAT and define a few defaults + for the standard info needed to get an ACA attitude and run + yoshi / proseco / sparkles. + + :param obsid: obsid + :param obs_date: intended date. If None, use the date from the OCAT if possible + else use current date. + :param web_ocat: use the web version of the OCAT (uses get_ocat_local if False) + :returns: dictionary of target parameters/keywords from OCAT. Can be used with + convert_yoshi_to_proseco_params . + """ + + if web_ocat: + ocat = get_ocat_web(obsid=obsid) + else: + ocat = get_ocat_local(obsid=obsid) + + if obs_date is None and ocat["start_date"] is not numpy.ma.masked: + # If obsid has a defined start_date then use that. Otherwise if obsid is + # not assigned an LTS bin then start_date will be masked and we fall + # through to CxoTime(None) which is NOW. + obs_date = CxoTime(ocat["start_date"]).date + else: + obs_date = CxoTime(obs_date).date + + targ = { + "obs_date": obs_date, + "detector": ocat["instr"], + "ra_targ": ocat["ra"], + "dec_targ": ocat["dec"], + "offset_y": ocat["y_off"], + "offset_z": ocat["z_off"], + } + + # Leaving focus offset as not-implemented + targ["focus_offset"] = 0 + + # For sim offsets, leave default at 0 unless explicit in OCAT + # OCAT entry is in mm + targ["sim_offset"] = 0 + if ocat["z_sim"] is not numpy.ma.masked: + targ["sim_offset"] = ocat["z_sim"] * 397.7225924607 + + # Could use get_target_aimpoint but that needs icxc if not on HEAD + # and we don't need to care that much for future observations. + aimpoints = { + "ACIS-I": (970.0, 975.0, 3), + "ACIS-S": (210.0, 520.0, 7), + "HRC-S": (2195.0, 8915.0, 2), + "HRC-I": (7590.0, 7745.0, 0), + } + chip_x, chip_y, chip_id = aimpoints[ocat["instr"]] + targ.update({"chipx": chip_x, "chipy": chip_y, "chip_id": chip_id}) + + # Nominal roll is not quite the same targ and aca but don't care. + targ["roll_targ"] = nominal_roll(ocat["ra"], ocat["dec"], obs_date) + + # Set dither from defaults and override if defined in OCAT + if ocat["instr"].startswith("ACIS"): + targ.update({"dither_y": 8, "dither_z": 8}) + else: + targ.update({"dither_y": 20, "dither_z": 20}) + + if ocat["dither"] == "Y": + targ.update( + {"dither_y": ocat["y_amp"] * 3600, "dither_z": ocat["z_amp"] * 3600} + ) + if ocat["dither"] == "N": + targ.update({"dither_y": 0, "dither_z": 0}) + + return targ + + +def run_one_yoshi( + *, + obsid, + detector, + chipx, + chipy, + chip_id, + ra_targ, + dec_targ, + roll_targ, + offset_y, + offset_z, + sim_offset, + focus_offset, + dither_y, + dither_z, + obs_date, + t_ccd, + man_angle, + **kwargs +): + """ + Run proseco and sparkles for an observation request in a roll/temperature/man_angle + scenario. + :param obsid: obsid + :param detector: detector (ACIS-I|ACIS-S|HRC-I|HRC-S) + :param chipx: chipx from zero-offset aimpoint table entry for obsid + :param chipy: chipy from zero-offset aimpoint table entry for obsid + :param chip_id: chip_id from zero-offset aimpoint table entry for obsid + :param ra_targ: target RA (degrees) + :param dec_targ: target Dec (degrees) + :param roll_targ: target Roll (degrees) + :param offset_y: target offset_y (arcmin) + :param offset_z: target offset_z (arcmin) + :param sim_offset: SIM Z offset (steps) + :param focus_offset: SIM focus offset (steps) + :param dither_y: Y amplitude dither (arcsec) + :param dither_z: Z amplitude dither (arcsec) + :param obs_date: observation date (for proper motion and ACA offset projection) + :param t_ccd: ACA CCD temperature (degrees C) + :param man_angle: maneuver angle (degrees) + :param **kwargs: additional keyword args to update or override params from + yoshi for call to get_aca_catalog() + :returns: dictionary of (ra_aca, dec_aca, roll_aca, + n_critical, n_warning, n_caution, n_info, + P2, guide_count) + """ + from proseco import get_aca_catalog + + params = convert_yoshi_to_proseco_params( + obsid, + detector, + chipx, + chipy, + chip_id, + ra_targ, + dec_targ, + roll_targ, + offset_y, + offset_z, + sim_offset, + focus_offset, + dither_y, + dither_z, + obs_date, + t_ccd, + man_angle, + ) + + # Update or override params from yoshi for call to get_aca_catalog + params.update(kwargs) + + aca = get_aca_catalog(**params) + acar = aca.get_review_table() + acar.run_aca_review() + q_aca = aca.att + + # Get values for report + report = { + "ra_aca": q_aca.ra, + "dec_aca": q_aca.dec, + "roll_aca": q_aca.roll, + "n_critical": len(acar.messages == "critical"), + "n_warning": len(acar.messages == "warning"), + "n_caution": len(acar.messages == "caution"), + "n_info": len(acar.messages == "info"), + "P2": -np.log10(acar.acqs.calc_p_safe()), + "guide_count": acar.guide_count, + } + + return report + + +def convert_yoshi_to_proseco_params( + obsid, + detector, + chipx, + chipy, + chip_id, + ra_targ, + dec_targ, + roll_targ, + offset_y, + offset_z, + sim_offset, + focus_offset, + dither_y, + dither_z, + obs_date, + t_ccd, + man_angle, +): + """ + Convert yoshi parameters to equivalent proseco arguments + + :param obsid: obsid (used only for labeling) + :param detector: detector (ACIS-I|ACIS-S|HRC-I|HRC-S) + :param chipx: chipx from zero-offset aimpoint table entry for obsid + :param chipy: chipy from zero-offset aimpoint table entry for obsid + :param chip_id: chip_id from zero-offset aimpoint table entry for obsid + :param ra_targ: target RA (degrees) + :param dec_targ: target Dec (degrees) + :param roll_targ: target Roll (degrees) + :param offset_y: target offset_y (arcmin) + :param offset_z: target offset_z (arcmin) + :param sim_offset: SIM Z offset (steps) + :param focus_offset: SIM focus offset (steps) + :param dither_y: Y amplitude dither (arcsec) + :param dither_z: Z amplitude dither (arcsec) + :param obs_date: observation date (for proper motion and ACA offset projection) + :param t_ccd: ACA CCD temperature (degrees C) + :param man_angle: maneuver angle (degrees) + :returns: dictionary of keyword arguments for proseco + + """ + if offset_y is np.ma.masked: + offset_y = 0.0 + if offset_z is np.ma.masked: + offset_z = 0.0 + if sim_offset is np.ma.masked: + sim_offset = 0.0 + if focus_offset is np.ma.masked: + focus_offset = 0.0 + + # Calculate dynamic offsets using the supplied temperature. + aca_offset_y, aca_offset_z = get_aca_offsets( + detector, chip_id, chipx, chipy, obs_date, t_ccd + ) + + # Get the ACA quaternion using target offsets and dynamic offsets. + # Note that calc_aca_from_targ expects target offsets in degrees and obs is now in arcmin + q_aca = calc_aca_from_targ( + (ra_targ, dec_targ, roll_targ), + (offset_y / 60.0) + (aca_offset_y / 3600.0), + (offset_z / 60.0) + (aca_offset_z / 3600.0), + ) + + # Get keywords for proseco + out = dict( + obsid=obsid, + att=q_aca, + man_angle=man_angle, + date=obs_date, + t_ccd=t_ccd, + dither=(dither_y, dither_z), + detector=detector, + sim_offset=sim_offset, + focus_offset=focus_offset, + n_acq=8, + n_guide=5, + n_fid=3, + ) + + return out