From a355b07d26d9815a7d0ff445067a189c3c56f590 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 22 Sep 2026 09:50:45 -0600 Subject: [PATCH 1/3] Update pointing attitude job to use start date and minor version from cli input --- imap_processing/spice/pointing_frame.py | 46 +++++++++++++++---- .../tests/spice/test_pointing_frame.py | 42 +++++++++++++++-- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/imap_processing/spice/pointing_frame.py b/imap_processing/spice/pointing_frame.py index 9d3990fe40..2702bb120e 100644 --- a/imap_processing/spice/pointing_frame.py +++ b/imap_processing/spice/pointing_frame.py @@ -8,7 +8,7 @@ import numpy as np import spiceypy -from imap_data_access import SPICEFilePath +from imap_data_access.file_validation import Version from numpy.typing import NDArray from imap_processing.spice import IMAP_SC_ID @@ -19,6 +19,7 @@ et_to_utc, met_to_sclkticks, sct_to_et, + str_to_et, ) logger = logging.getLogger(__name__) @@ -35,7 +36,9 @@ ) -def generate_pointing_attitude_kernel(imap_attitude_cks: list[Path]) -> list[Path]: +def generate_pointing_attitude_kernel( + imap_attitude_cks: list[Path], start_date: str, minor_version: int +) -> list[Path]: """ Generate pointing attitude kernel from input IMAP CK kernel. @@ -44,13 +47,22 @@ def generate_pointing_attitude_kernel(imap_attitude_cks: list[Path]) -> list[Pat imap_attitude_cks : list[Path] List of the IMAP attitude kernels from which to generate pointing attitude. + start_date : str + Earliest date, in YYYYMMDD format, to cover with the pointing + attitude kernel. Only pointings fully covered by the input CKs + that start on or after this date are included. + minor_version : int + Minor version, from the batch command, to use for the output + pointing attitude kernel filename. Returns ------- pointing_kernel_path : list[Path] Location of the new pointing kernels. """ - pointing_segments = calculate_pointing_attitude_segments(imap_attitude_cks) + pointing_segments = calculate_pointing_attitude_segments( + imap_attitude_cks, start_date + ) if len(pointing_segments) == 0: raise ValueError("No Pointings covered by input dependencies.") @@ -61,15 +73,13 @@ def generate_pointing_attitude_kernel(imap_attitude_cks: list[Path]) -> list[Pat end_datetime = spiceypy.et2datetime( sct_to_et(pointing_segments[-1]["end_sclk_ticks"]) ) - # Use the last ck from sorted list to get the version number. I - # don't think this will be anything but 1. sorted_ck_paths = list(sorted(imap_attitude_cks, key=lambda x: x.name)) - spice_file = SPICEFilePath(sorted_ck_paths[-1].name) + version_str = str(Version(None, minor_version)).lstrip("v") pointing_kernel_path = ( sorted_ck_paths[-1].parent / f"imap_dps_" f"{start_datetime.strftime('%Y_%j')}_" f"{end_datetime.strftime('%Y_%j')}_" - f"{spice_file.spice_metadata['version']}.ah.bc" + f"{version_str}.ah.bc" ) write_pointing_frame_ck( pointing_kernel_path, pointing_segments, [p.name for p in imap_attitude_cks] @@ -176,6 +186,7 @@ def write_pointing_frame_ck( def calculate_pointing_attitude_segments( ck_paths: list[Path], + start_date: str, ) -> NDArray: """ Calculate the data for each segment of the DPS_FRAME attitude kernel. @@ -191,6 +202,10 @@ def calculate_pointing_attitude_segments( ---------- ck_paths : list[pathlib.Path] List of CK kernels to use to generate the pointing attitude kernel. + start_date : str + Earliest date, in YYYYMMDD format, to cover with the pointing + attitude kernel. Used as the lower bound when selecting pointings + that are fully covered by the input CKs. Returns ------- @@ -221,7 +236,7 @@ def calculate_pointing_attitude_segments( # to cover the time range of the new repoint table. # Get the coverage of the CK files storing the earliest start time and # latest end time. - et_start = np.inf + ck_start = np.inf et_end = -np.inf for ck_path in ck_paths: ck_cover = spiceypy.ckcov( @@ -234,12 +249,23 @@ def calculate_pointing_attitude_segments( f"{ck_path.name} covers time range: ({et_to_utc(individual_ck_start)}, " f"{et_to_utc(individual_ck_end)}) in {num_intervals} intervals." ) - et_start = min(et_start, individual_ck_start) + ck_start = min(ck_start, individual_ck_start) et_end = max(et_end, individual_ck_end) logger.info( f"CK kernels combined coverage range: " - f"{(et_to_utc(et_start), et_to_utc(et_end))}, " + f"{(et_to_utc(ck_start), et_to_utc(et_end))}, " + ) + + # The batch command's start_date limits (narrows) the coverage of the + # produced pointing attitude kernel. It is floored at the CK files' + # own coverage start so that a pointing is only ever selected if it is + # actually, fully covered by the input CKs. + requested_start_et = str_to_et(datetime.strptime(start_date, "%Y%m%d").isoformat()) + et_start = max(requested_start_et, ck_start) + logger.info( + f"Using coverage start date: {et_to_utc(et_start)} " + f"(requested start_date: {et_to_utc(requested_start_et)})" ) # Get data from the repoint table and convert to Pointings diff --git a/imap_processing/tests/spice/test_pointing_frame.py b/imap_processing/tests/spice/test_pointing_frame.py index 0a2105e38c..76ecd5e3c4 100644 --- a/imap_processing/tests/spice/test_pointing_frame.py +++ b/imap_processing/tests/spice/test_pointing_frame.py @@ -90,13 +90,14 @@ def test_generate_pointing_attitude_kernel( """Test coverage for generate_pointing_attitude_kernel function.""" start_date = "2024_111" end_date = "2024_222" - version = "02" + version = "002" mock_et2datetime.side_effect = [ datetime.strptime(date_str, "%Y_%j") for date_str in [start_date, end_date] ] - ck_path = Path(f"/bogus/file/path/imap_{start_date}_{end_date}_{version}.ah.bc") - pointing_ck_path = generate_pointing_attitude_kernel([ck_path])[0] + ck_path = Path(f"/bogus/file/path/imap_{start_date}_{end_date}_001.ah.bc") + pointing_ck_path = generate_pointing_attitude_kernel([ck_path], "20240420", 2)[0] assert pointing_ck_path.name == f"imap_dps_{start_date}_{end_date}_{version}.ah.bc" + mock_gen_attitude_segments.assert_called_once_with([ck_path], "20240420") # Verify that file is valid pointing_attitude kernel with imap-data-access spice_input = SPICEInput(pointing_ck_path.name) assert spice_input.source[0] == "pointing_attitude" @@ -111,7 +112,7 @@ def test_generate_pointing_attitude_kernel_no_pointings(mock_gen_attitude_segmen """Test when no pointings are covered by the input CK.""" ck_path = Path("/bogus/file/path/imap_2025_100_2025_101_001.ah.bc") with pytest.raises(ValueError, match="No Pointings covered"): - _ = generate_pointing_attitude_kernel([ck_path])[0] + _ = generate_pointing_attitude_kernel([ck_path], "20250101", 1)[0] @pytest.mark.parametrize( @@ -252,6 +253,7 @@ def test_calculate_pointing_attitude_segments( segment_data = calculate_pointing_attitude_segments( [spice_test_data_path / "imap_sim_ck_2hr_2secsampling_with_nutation.bc"], + "20000101", ) # Nick Dutton's MATLAB code result @@ -306,6 +308,7 @@ def test_multiple_pointings( segment_data = calculate_pointing_attitude_segments( [spice_test_data_path / "imap_sim_ck_2hr_2secsampling_with_nutation.bc"], + "20000101", ) # The way we defined the repoints, we expect two pointing segments @@ -317,3 +320,34 @@ def test_multiple_pointings( np.testing.assert_allclose( segment_data["end_sclk_ticks"], repoint_start_met[2:4] / TICK_DURATION ) + + +def test_calculate_pointing_attitude_segments_start_date_narrows_coverage( + spice_test_data_path, + furnish_pointing_frame_kernels, + use_fake_repoint_data_for_time, +): + """Tests that a start_date after the CK coverage excludes an otherwise + fully-covered pointing, confirming start_date can narrow coverage beyond + what the CK files themselves provide.""" + # Same single-pointing setup as test_calculate_pointing_attitude_segments, + # which is fully covered by the CK. + ck_met_start, ck_met_end = get_ck_met_coverage(furnish_pointing_frame_kernels[-1]) + use_fake_repoint_data_for_time( + np.array([ck_met_start - 10, ck_met_end - 1]), + np.array([ck_met_start + 1, ck_met_end + 10]), + ) + + # start_date is the calendar day after the CK's own coverage ends, so it + # should exclude the pointing even though the CK fully covers it. + ck_end_et = sct_to_et(ck_met_end / TICK_DURATION) + day_after_ck_end = spiceypy.et2utc(ck_end_et + 86400, "ISOC", 0)[:10].replace( + "-", "" + ) + + segment_data = calculate_pointing_attitude_segments( + [spice_test_data_path / "imap_sim_ck_2hr_2secsampling_with_nutation.bc"], + day_after_ck_end, + ) + + assert len(segment_data) == 0 From 8d9a4abbcb4af2747e6bce4977dad215b0fa410a Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 22 Sep 2026 09:51:24 -0600 Subject: [PATCH 2/3] Update spacecraft cli code to use input start date and minor version --- imap_processing/cli.py | 8 +++++++- imap_processing/tests/test_cli.py | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/imap_processing/cli.py b/imap_processing/cli.py index f0f510491d..280834b8ef 100644 --- a/imap_processing/cli.py +++ b/imap_processing/cli.py @@ -1803,8 +1803,14 @@ def do_processing( data_type=SPICESource.SPICE.value ) ah_paths = [path for path in spice_inputs if ".ah" in path.suffixes] + resolved_version = self._resolve_version(self.descriptor) + minor_version = ( + resolved_version.minor + if isinstance(resolved_version, Version) + else int(resolved_version.lstrip("v")) + ) pointing_kernel_paths = pointing_frame.generate_pointing_attitude_kernel( - ah_paths + ah_paths, self.start_date, minor_version ) processed_dataset.extend(pointing_kernel_paths) else: diff --git a/imap_processing/tests/test_cli.py b/imap_processing/tests/test_cli.py index 80678b7250..3c4a780e41 100644 --- a/imap_processing/tests/test_cli.py +++ b/imap_processing/tests/test_cli.py @@ -717,6 +717,9 @@ def test_spacecraft_pointing_kernel( instrument.process() assert mock_spacecraft_pointing.call_count == 1 + call_args = mock_spacecraft_pointing.call_args[0] + assert call_args[1] == "20240410" + assert call_args[2] == 5 @mock.patch("imap_processing.cli.ultra_l1a.ultra_l1a") From 6e65dcdfdd0e8bcb6826f853979eca22370b817f Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 22 Sep 2026 10:17:19 -0600 Subject: [PATCH 3/3] Copilot feedback --- imap_processing/cli.py | 10 +++ imap_processing/tests/test_cli.py | 108 ++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/imap_processing/cli.py b/imap_processing/cli.py index 280834b8ef..187043dbb7 100644 --- a/imap_processing/cli.py +++ b/imap_processing/cli.py @@ -1799,11 +1799,21 @@ def do_processing( datasets = list(quaternions.process_quaternions(input_files[0])) processed_dataset.extend(datasets) elif self.descriptor == "pointing-attitude": + if self.start_date is None: + raise ValueError( + "start_date must be provided for pointing-attitude processing." + ) spice_inputs = dependencies.get_file_paths( data_type=SPICESource.SPICE.value ) ah_paths = [path for path in spice_inputs if ".ah" in path.suffixes] resolved_version = self._resolve_version(self.descriptor) + if resolved_version is None: + raise ValueError( + "No version provided for pointing-attitude processing. " + "Provide a version for the 'pointing-attitude' descriptor in " + "the dependency JSON's version block, or a fallback --version." + ) minor_version = ( resolved_version.minor if isinstance(resolved_version, Version) diff --git a/imap_processing/tests/test_cli.py b/imap_processing/tests/test_cli.py index 3c4a780e41..7a2e8aeb5c 100644 --- a/imap_processing/tests/test_cli.py +++ b/imap_processing/tests/test_cli.py @@ -722,6 +722,114 @@ def test_spacecraft_pointing_kernel( assert call_args[2] == 5 +@mock.patch( + "imap_processing.cli.pointing_frame.generate_pointing_attitude_kernel", + autospec=True, +) +def test_spacecraft_pointing_kernel_version_from_dependency( + mock_spacecraft_pointing, mock_instrument_dependencies +): + """Test that a pointing-attitude entry in the dependency version map is + used, taking its minor version over the fallback --version.""" + + dependency_files_str = ( + '[{"type": "spice","files": ["naif0012.tls", ' + '"imap_sclk_0005.tsc", "imap_2024_100_2024_111_05.ah.bc"]}]' + ) + dependency_str = json.dumps( + { + "dependency": json.loads(dependency_files_str), + "version": { + "pointing-attitude": {"major_version": None, "minor_version": 7} + }, + } + ) + input_collection = ProcessingInputCollection() + input_collection.deserialize(dependency_files_str) + mocks = mock_instrument_dependencies + mocks["mock_query"].return_value = [{"file_path": "/path/to/file0"}] + mocks["mock_download"].return_value = "file0" + mock_spacecraft_pointing.return_value = [ + Path("imap_dps_2024_100_2024_111_007.ah.bc") + ] + mocks["mock_write_cdf"].side_effect = ["/path/to/file0"] + mocks["mock_pre_processing"].return_value = input_collection + + # Fallback --version ("v005") differs from the dependency version map's + # minor_version (7), so this confirms the batch-provided Version wins. + instrument = Spacecraft( + "l1a", "pointing-attitude", dependency_str, "20240410", "12345", "v005", False + ) + + instrument.process() + assert mock_spacecraft_pointing.call_count == 1 + call_args = mock_spacecraft_pointing.call_args[0] + assert call_args[1] == "20240410" + assert call_args[2] == 7 + + +@mock.patch( + "imap_processing.cli.pointing_frame.generate_pointing_attitude_kernel", + autospec=True, +) +def test_spacecraft_pointing_kernel_no_start_date( + mock_spacecraft_pointing, mock_instrument_dependencies +): + """Test coverage for cli.Spacecraft class when only repointing is provided""" + + dependency_str = ( + '[{"type": "spice","files": ["naif0012.tls", ' + '"imap_sclk_0005.tsc", "imap_2024_100_2024_111_05.ah.bc"]}]' + ) + input_collection = ProcessingInputCollection() + input_collection.deserialize(dependency_str) + mocks = mock_instrument_dependencies + mocks["mock_query"].return_value = [{"file_path": "/path/to/file0"}] + mocks["mock_download"].return_value = "file0" + mocks["mock_pre_processing"].return_value = input_collection + + # start_date is a valid CLI-only alternative to repointing, so it can be + # None here. + instrument = Spacecraft( + "l1a", "pointing-attitude", dependency_str, None, "12345", "v005", False + ) + + with pytest.raises(ValueError, match="start_date must be provided"): + instrument.process() + assert mock_spacecraft_pointing.call_count == 0 + + +@mock.patch( + "imap_processing.cli.pointing_frame.generate_pointing_attitude_kernel", + autospec=True, +) +def test_spacecraft_pointing_kernel_no_version( + mock_spacecraft_pointing, mock_instrument_dependencies +): + """Test coverage for cli.Spacecraft class when no version can be resolved""" + + dependency_str = ( + '[{"type": "spice","files": ["naif0012.tls", ' + '"imap_sclk_0005.tsc", "imap_2024_100_2024_111_05.ah.bc"]}]' + ) + input_collection = ProcessingInputCollection() + input_collection.deserialize(dependency_str) + mocks = mock_instrument_dependencies + mocks["mock_query"].return_value = [{"file_path": "/path/to/file0"}] + mocks["mock_download"].return_value = "file0" + mocks["mock_pre_processing"].return_value = input_collection + + # No version block for "pointing-attitude" in the dependency JSON, and no + # fallback --version, matches argparse's default of None. + instrument = Spacecraft( + "l1a", "pointing-attitude", dependency_str, "20240410", None, None, False + ) + + with pytest.raises(ValueError, match="No version provided"): + instrument.process() + assert mock_spacecraft_pointing.call_count == 0 + + @mock.patch("imap_processing.cli.ultra_l1a.ultra_l1a") def test_ultra_l1a(mock_ultra_l1a, mock_instrument_dependencies): """Test coverage for cli.Ultra class with l1a data level"""