From 3aff4592a0a2ad1ed472606bf44d2589635ddd2e Mon Sep 17 00:00:00 2001 From: Jesse Mapel <jmapel@usgs.gov> Date: Thu, 2 Jun 2022 14:31:43 -0700 Subject: [PATCH] Adds CassiniIssNac/Wac IsisLabel NaifData Driver (#470) * stuff * Fixes cassini iss wac/nac drivers and adds tests for isis label naif spice * Address warnings in tests * Remove print statement * Removed notebooks/notebook changes * Added caching to focal length property in cassiniIss IsisLabel NaifSpice * Addressed failing tests Co-authored-by: acpaquette <acp263@nau.edu> --- ale/base/data_isis.py | 26 ++- ale/drivers/co_drivers.py | 199 ++++++++++++++++++ ale/util.py | 18 +- .../data/isds/cassiniiss_isis_isd.json | 4 +- tests/pytests/test_cassini_drivers.py | 90 +++++++- 5 files changed, 320 insertions(+), 17 deletions(-) diff --git a/ale/base/data_isis.py b/ale/base/data_isis.py index 92d6b08..ec49ef7 100644 --- a/ale/base/data_isis.py +++ b/ale/base/data_isis.py @@ -62,7 +62,7 @@ def parse_table(table_label, data): 'Real' : 'f'} # Parse the binary data - fields = table_label.getlist('Field') + fields = table_label.getall('Field') results = {field['Name']:[] for field in fields} offset = 0 for record in range(table_label['Records']): @@ -200,7 +200,11 @@ class IsisSpice(): Instrument pointing table """ if not hasattr(self, "_inst_pointing_table"): - for table in self.label.getlist('Table'): + tables = [] + if "Table" in self.label: + tables = self.label.getall('Table') + + for table in tables: if table['Name'] == 'InstrumentPointing': binary_data = read_table_data(table, self._file) self._inst_pointing_table = parse_table(table, binary_data) @@ -220,7 +224,11 @@ class IsisSpice(): Body orientation table """ if not hasattr(self, "_body_orientation_table"): - for table in self.label.getlist('Table'): + tables = [] + if "Table" in self.label: + tables = self.label.getall('Table') + + for table in tables: if table['Name'] == 'BodyRotation': binary_data = read_table_data(table, self._file) self._body_orientation_table = parse_table(table, binary_data) @@ -240,7 +248,11 @@ class IsisSpice(): Instrument position table """ if not hasattr(self, "_inst_position_table"): - for table in self.label.getlist('Table'): + tables = [] + if "Table" in self.label: + tables = self.label.getall('Table') + + for table in tables: if table['Name'] == 'InstrumentPosition': binary_data = read_table_data(table, self._file) self._inst_position_table = parse_table(table, binary_data) @@ -260,7 +272,11 @@ class IsisSpice(): Sun position table """ if not hasattr(self, "_sun_position_table"): - for table in self.label.getlist('Table'): + tables = [] + if "Table" in self.label: + tables = self.label.getall('Table') + + for table in tables: if table['Name'] == 'SunPosition': binary_data = read_table_data(table, self._file) self._sun_position_table = parse_table(table, binary_data) diff --git a/ale/drivers/co_drivers.py b/ale/drivers/co_drivers.py index 311c25f..261d295 100644 --- a/ale/drivers/co_drivers.py +++ b/ale/drivers/co_drivers.py @@ -110,6 +110,188 @@ wac_filter_to_focal_length = { ("T3","IRP90"):201.07 } +class CassiniIssIsisLabelNaifSpiceDriver(Framer, IsisLabel, NaifSpice, RadialDistortion, Driver): + + @property + def instrument_id(self): + """ + Returns an instrument id for unquely identifying the instrument, but often + also used to be piped into Spice Kernels to acquire instrument kernel (IK) NAIF IDs. + Therefore they use the same NAIF ID asin bods2c calls. Expects instrument_id to be + defined from a mixin class. This should return a string containing either 'ISSNA' or + 'ISSWA' + + Returns + ------- + : str + instrument id + """ + return id_lookup[super().instrument_id] + + @property + def spacecraft_name(self): + """ + Spacecraft name used in various Spice calls to acquire + ephemeris data. + + Returns + ------- + : str + Name of the spacecraft + """ + return 'CASSINI' + + @property + def sensor_name(self): + """ + Returns the name of the instrument + + Returns + ------- + : str + Name of the sensor + """ + return name_lookup[super().instrument_id] + + @property + def ephemeris_start_time(self): + """ + Returns the start and stop ephemeris times for the image. + + Returns + ------- + : float + start time + """ + return spice.str2et(self.utc_start_time.strftime("%Y-%m-%d %H:%M:%S.%f"))[0] + + @property + def center_ephemeris_time(self): + """ + Returns the starting ephemeris time as the ssi framers center is the + start. + + Returns + ------- + : double + Center ephemeris time for an image + """ + center_time = self.ephemeris_start_time + (self.exposure_duration / 2.0) + return center_time + + @property + def odtk(self): + """ + The radial distortion coeffs are not defined in the ik kernels, instead + they are defined in the ISS Data User Guide (Knowles). Therefore, we + manually specify the codes here. + Expects instrument_id to be defined. This should be a string containing either + CASSINI_ISS_WAC or CASSINI_ISIS_NAC + + Returns + ------- + : list<float> + radial distortion coefficients + """ + if self.instrument_id == 'CASSINI_ISS_WAC': + # WAC + return [0, float('-6.2e-5'), 0] + elif self.instrument_id == 'CASSINI_ISS_NAC': + # NAC + return [0, float('-8e-6'), 0] + + @property + def focal_length(self): + """ + NAC uses multiple filter pairs, each filter combination has a different focal length. + NAIF's Cassini kernels do not contain focal lengths for NAC filters and + so we aquired updated NAC filter data from ISIS's IAK kernel. + + """ + # default focal defined by IAK kernel + if not hasattr(self, "_focal_length"): + try: + default_focal_len = super(CassiniIssPds3LabelNaifSpiceDriver, self).focal_length + except: + default_focal_len = float(spice.gdpool('INS{}_DEFAULT_FOCAL_LENGTH'.format(self.ikid), 0, 2)[0]) + + filters = tuple(self.label["IsisCube"]["BandBin"]['FilterName'].split("/")) + + if self.instrument_id == "CASSINI_ISS_NAC": + self._focal_length = nac_filter_to_focal_length.get(filters, default_focal_len) + + elif self.instrument_id == "CASSINI_ISS_WAC": + self._focal_length = wac_filter_to_focal_length.get(filters, default_focal_len) + return self._focal_length + + @property + def _original_naif_sensor_frame_id(self): + """ + Original sensor frame ID as defined in Cassini's IK kernel. This + is the frame ID you want to default to for WAC. For NAC, this Frame ID + sits between J2000 and an extra 180 rotation since NAC was mounted + upside down. + + Returns + ------- + : int + sensor frame code from NAIF's IK kernel + """ + return self.ikid + + @property + def sensor_frame_id(self): + """ + Overwrite sensor frame id to return fake frame ID for NAC representing a + mounting point with a 180 degree rotation. ID was taken from ISIS's IAK + kernel for Cassini. This is because NAC requires an extra rotation not + in NAIF's Cassini kernels. Wac does not require an extra rotation so + we simply return original sensor frame id for Wac. + + Returns + ------- + : int + NAIF's Wac sensor frame ID, or ALE's Nac sensor frame ID + """ + if self.instrument_id == "CASSINI_ISS_NAC": + return 14082360 + elif self.instrument_id == "CASSINI_ISS_WAC": + return 14082361 + + @property + def frame_chain(self): + """ + Construct the initial frame chain using the original sensor_frame_id + obtained from the ikid. Then tack on the ISIS iak rotation. + + Returns + ------- + : Object + Custom Cassini ALE Frame Chain object for rotation computation and application + """ + if not hasattr(self, '_frame_chain'): + + try: + # Call frinfo to check if the ISIS iak has been loaded with the + # additional reference frame. Otherwise, Fail and add it manually + _ = spice.frinfo(self.sensor_frame_id) + self._frame_chain = super().frame_chain + except spice.utils.exceptions.NotFoundError as e: + self._frame_chain = FrameChain.from_spice(sensor_frame=self._original_naif_sensor_frame_id, + target_frame=self.target_frame_id, + center_ephemeris_time=self.center_ephemeris_time, + ephemeris_times=self.ephemeris_time,) + + rotation = ConstantRotation([[0, 0, 1, 0]], self.sensor_frame_id, self._original_naif_sensor_frame_id) + + self._frame_chain.add_edge(rotation=rotation) + + return self._frame_chain + + @property + def sensor_model_version(self): + return 1 + class CassiniIssPds3LabelNaifSpiceDriver(Framer, Pds3Label, NaifSpice, RadialDistortion, Driver): """ Cassini mixin class for defining Spice calls. @@ -374,3 +556,20 @@ class CassiniIssIsisLabelIsisSpiceDriver(Framer, IsisLabel, IsisSpice, NoDistort : float """ return self.inst_position_table['SpkTableStartTime'] + + @property + def focal_length(self): + """ + The focal length of the instrument + Expects naif_keywords to be defined. This should be a dict containing + Naif keyworkds from the label. + Expects ikid to be defined. This should be the integer Naif ID code + for the instrument. + + Returns + ------- + float : + The focal length in millimeters + """ + filters = self.label["IsisCube"]["BandBin"]['FilterName'].split("/") + return self.naif_keywords.get('INS{}_{}_{}_FOCAL_LENGTH'.format(self.ikid, filters[0], filters[1]), None) diff --git a/ale/util.py b/ale/util.py index 6460aab..96bb2fc 100644 --- a/ale/util.py +++ b/ale/util.py @@ -559,7 +559,7 @@ def get_isis_mission_translations(isis_data): """ mission_translation_file = read_pvl(os.path.join(isis_data, "base", "translations", "MissionName2DataDir.trn")) # For some reason this file takes the form [value, key] for mission name -> data dir - lookup = [l[::-1] for l in mission_translation_file["MissionName"].getlist("Translation")] + lookup = [l[::-1] for l in mission_translation_file["MissionName"].getall("Translation")] return dict(lookup) @@ -693,15 +693,19 @@ def search_isis_db(dbobj, labelobj, isis_data): # Flag is set when kernels encapsulating the entire image time is found full_match = False - for selection in dbobj.getlist("Selection"): - files = selection.getlist("File") + for selection in dbobj.getall("Selection"): + files = selection.getall("File") + if not files: + raise Exception(f"No File found in {selection}") # selection criteria - matches = selection.getlist("Match") - times = selection.getlist("Time") + matches = [] + if "Match" in selection: + matches = selection.getall("Match") - if not files: - raise Exception(f"No File found in {selection}") + times = [] + if "Time" in selection: + times = selection.getall("Time") files = [path.join(*file) if isinstance(file, list) else file for file in files] diff --git a/tests/pytests/data/isds/cassiniiss_isis_isd.json b/tests/pytests/data/isds/cassiniiss_isis_isd.json index d45e51c..9dda856 100644 --- a/tests/pytests/data/isds/cassiniiss_isis_isd.json +++ b/tests/pytests/data/isds/cassiniiss_isis_isd.json @@ -125,7 +125,7 @@ "detector_sample_summing": 1, "detector_line_summing": 1, "focal_length_model": { - "focal_length": null + "focal_length": 2003.09 }, "detector_center": { "line": 512.5, @@ -198,4 +198,4 @@ ], "reference_frame": 1 } -} \ No newline at end of file +} diff --git a/tests/pytests/test_cassini_drivers.py b/tests/pytests/test_cassini_drivers.py index 1770df5..4207aa4 100644 --- a/tests/pytests/test_cassini_drivers.py +++ b/tests/pytests/test_cassini_drivers.py @@ -10,7 +10,7 @@ from unittest.mock import PropertyMock, patch import json from conftest import get_image_label, get_image_kernels, get_isd, convert_kernels, compare_dicts, get_table_data -from ale.drivers.co_drivers import CassiniIssPds3LabelNaifSpiceDriver +from ale.drivers.co_drivers import CassiniIssPds3LabelNaifSpiceDriver, CassiniIssIsisLabelNaifSpiceDriver @pytest.fixture() def test_kernels(scope="module", autouse=True): @@ -27,7 +27,8 @@ def test_load_pds(test_kernels): isd_str = ale.loads(label_file, props={'kernels': test_kernels}) isd_obj = json.loads(isd_str) print(json.dumps(isd_obj, indent=2)) - assert compare_dicts(isd_obj, compare_dict) == [] + x = compare_dicts(isd_obj, compare_dict) + assert x == [] def test_load_isis(): label_file = get_image_label("N1702360370_1", label_type="isis3") @@ -40,7 +41,18 @@ def test_load_isis(): isd_str = ale.loads(label_file) isd_obj = json.loads(isd_str) print(json.dumps(isd_obj, indent=2)) - assert compare_dicts(isd_obj, compare_dict) == [] + x = compare_dicts(isd_obj, compare_dict) + assert x == [] + +def test_load_isis_naif(test_kernels): + label_file = get_image_label("N1702360370_1") + compare_dict = get_isd("cassiniiss") + + isd_str = ale.loads(label_file, props={"kernels": test_kernels}) + isd_obj = json.loads(isd_str) + print(json.dumps(isd_obj, indent=2)) + x = compare_dicts(isd_obj, compare_dict) + assert x == [] # ========= Test cassini pds3label and naifspice driver ========= class test_cassini_pds3_naif(unittest.TestCase): @@ -130,3 +142,75 @@ class test_cassini_pds3_naif(unittest.TestCase): frame_chain = self.driver.frame_chain assert len(frame_chain.nodes()) == 0 from_spice.assert_called_with(center_ephemeris_time=2.4, ephemeris_times=[2.4], nadir=False, sensor_frame=14082360, target_frame=-800) + +# ========= Test cassini isislabel and naifspice driver ========= +class test_cassini_isis_naif(unittest.TestCase): + + def setUp(self): + label = get_image_label("N1702360370_1", "isis3") + self.driver = CassiniIssIsisLabelNaifSpiceDriver(label) + + def test_instrument_id(self): + assert self.driver.instrument_id == "CASSINI_ISS_NAC" + + def test_spacecraft_name(self): + assert self.driver.spacecraft_name == "CASSINI" + + def test_sensor_name(self): + assert self.driver.sensor_name == "Imaging Science Subsystem Narrow Angle Camera" + + def test_ephemeris_start_time(self): + with patch('ale.drivers.co_drivers.spice.str2et', return_value=[12345]) as str2et: + assert self.driver.ephemeris_start_time == 12345 + str2et.assert_called_with('2011-12-12 05:02:19.773000') + + def test_center_ephemeris_time(self): + with patch('ale.drivers.co_drivers.spice.str2et', return_value=[12345]) as str2et: + print(self.driver.exposure_duration) + assert self.driver.center_ephemeris_time == 12347.3 + str2et.assert_called_with('2011-12-12 05:02:19.773000') + + def test_odtk(self): + assert self.driver.odtk == [0, -8e-06, 0] + + def test_focal_length(self): + # This value isn't used for anything in the test, as it's only used for the + # default focal length calculation if the filter can't be found. + with patch('ale.drivers.co_drivers.spice.gdpool', return_value=[10.0]) as gdpool, \ + patch('ale.base.data_naif.spice.bods2c', return_value=-12345) as bods2c: + assert self.driver.focal_length == 2003.09 + + def test_sensor_model_version(self): + assert self.driver.sensor_model_version == 1 + + def test_sensor_frame_id(self): + assert self.driver.sensor_frame_id == 14082360 + + @patch('ale.transformation.FrameChain.from_spice', return_value=ale.transformation.FrameChain()) + def test_custom_frame_chain(self, from_spice): + with patch('ale.drivers.co_drivers.spice.bods2c', return_value=-12345) as bods2c, \ + patch('ale.drivers.co_drivers.CassiniIssIsisLabelNaifSpiceDriver.target_frame_id', \ + new_callable=PropertyMock) as target_frame_id, \ + patch('ale.drivers.co_drivers.CassiniIssIsisLabelNaifSpiceDriver.ephemeris_start_time', \ + new_callable=PropertyMock) as ephemeris_start_time: + ephemeris_start_time.return_value = .1 + target_frame_id.return_value = -800 + frame_chain = self.driver.frame_chain + assert len(frame_chain.nodes()) == 2 + assert 14082360 in frame_chain.nodes() + assert -12345 in frame_chain.nodes() + from_spice.assert_called_with(center_ephemeris_time=2.4000000000000004, ephemeris_times=[2.4000000000000004], sensor_frame=-12345, target_frame=-800) + + @patch('ale.transformation.FrameChain.from_spice', return_value=ale.transformation.FrameChain()) + def test_custom_frame_chain_iak(self, from_spice): + with patch('ale.drivers.co_drivers.spice.bods2c', return_value=-12345) as bods2c, \ + patch('ale.drivers.co_drivers.CassiniIssIsisLabelNaifSpiceDriver.target_frame_id', \ + new_callable=PropertyMock) as target_frame_id, \ + patch('ale.drivers.co_drivers.CassiniIssIsisLabelNaifSpiceDriver.ephemeris_start_time', \ + new_callable=PropertyMock) as ephemeris_start_time, \ + patch('ale.drivers.co_drivers.spice.frinfo', return_value=True) as frinfo: + ephemeris_start_time.return_value = .1 + target_frame_id.return_value = -800 + frame_chain = self.driver.frame_chain + assert len(frame_chain.nodes()) == 0 + from_spice.assert_called_with(center_ephemeris_time=2.4000000000000004, ephemeris_times=[2.4000000000000004], nadir=False, sensor_frame=14082360, target_frame=-800, ) -- GitLab