From 5e8fb0b1d4dd264c09ebe096ae92f92bc4c8042d Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Fri, 25 Sep 2026 11:55:27 -0600 Subject: [PATCH 01/13] init commit for updated culling # Conflicts: # imap_processing/tests/ultra/unit/test_lookup_utils.py # imap_processing/tests/ultra/unit/test_ultra_l1b.py # imap_processing/ultra/l1b/lookup_utils.py --- .../tests/ultra/unit/test_l1c_lookup_utils.py | 84 +++++++++++++++++++ imap_processing/ultra/l1b/extendedspin.py | 1 + imap_processing/ultra/l1c/l1c_lookup_utils.py | 62 ++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py b/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py index d2149d4ea2..0ee81f2caf 100644 --- a/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py +++ b/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py @@ -1,10 +1,15 @@ +from unittest import mock + import astropy_healpix.healpy as hp import numpy as np +import pandas as pd import pytest import xarray as xr +from imap_processing import imap_module_directory from imap_processing.ultra.l1c.l1c_lookup_utils import ( calculate_accepted_pixels, + get_de_product_name, get_scattering_thresholds_for_energy, get_spacecraft_pointing_lookup_tables, get_static_deadtime_ratios, @@ -12,6 +17,8 @@ mask_below_fwhm_scattering_threshold, ) +TEST_PATH = imap_module_directory / "tests" / "ultra" / "data" / "l1" + @pytest.mark.external_test_data def test_get_spacecraft_pointing_lookup_tables(ancillary_files): @@ -202,3 +209,80 @@ def test_calculate_accepted_pixels_restrict_fov(ancillary_files): expected_accepted_px = in_restricted_fov(mock_theta, mock_phi, 45) expected_accepted_px[:, :, :outside_inds] = False assert np.array_equal(expected_accepted_px, valid_spun_pixels) + + +def test_get_de_product_name_no_repoint(): + """Tests function get_de_product_name when the lookup is missing the repoint.""" + ancillary_files = { + "l1c-45sensor-de-product-lookup": TEST_PATH + / "imap_ultra_l1c-45sensor-culling-config_20251001_v001.csv" + } + with mock.patch( + "imap_processing.ultra.l1c.l1c_lookup_utils.pd.read_csv" + ) as mock_read_csv: + mock_read_csv.return_value = pd.DataFrame( + { + "repointing_id_start": [1, 2], + "repointing_id_end": [3, 4], + "de_product": [ + "imap_ultra_l1b_45sensor-de", + "imap_ultra_l1b_45sensor-priority-1-de", + ], + } + ) + with pytest.raises(ValueError, match="No DE product found for repoint ID 0"): + get_de_product_name("repoint00000", 45, ancillary_files) + + +def test_get_de_product_name_multiple_products(): + """Tests function get_de_product_name when the lookup is ambiguous.""" + ancillary_files = { + "l1c-45sensor-de-product-lookup": TEST_PATH + / "imap_ultra_l1c-45sensor-culling-config_20251001_v001.csv" + } + with mock.patch( + "imap_processing.ultra.l1c.l1c_lookup_utils.pd.read_csv" + ) as mock_read_csv: + mock_read_csv.return_value = pd.DataFrame( + { + "repointing_id_start": [2, 2], + "repointing_id_end": [3, 4], + "de_product": [ + "imap_ultra_l1b_45sensor-de", + "imap_ultra_l1b_45sensor-priority-1-de", + ], + } + ) + with pytest.raises(ValueError, match="Multiple DE products found"): + get_de_product_name("repoint00002", 45, ancillary_files) + + +def test_get_de_product_name(): + """Tests function get_de_product_name.""" + ancillary_files = { + "l1c-45sensor-de-product-lookup": TEST_PATH + / "imap_ultra_l1c-45sensor-culling-config_20251001_v001.csv" + } + with mock.patch( + "imap_processing.ultra.l1c.l1c_lookup_utils.pd.read_csv" + ) as mock_read_csv: + mock_read_csv.return_value = pd.DataFrame( + { + "repointing_id_start": [0, 2, 4], + "repointing_id_end": [1, 4, np.nan], + "de_product": [ + "imap_ultra_l1b_45sensor-de", + "imap_ultra_l1b_45sensor-priority-1-de", + "imap_ultra_l1b_45sensor-priority-2-de", + ], + } + ) + # Test with a repoint in the future. Should return the priority 2 de product + # since the last repoint range does not have an end and should be assumed to + # cover all future repoints. + de_product = get_de_product_name("repoint00100", 45, ancillary_files) + assert de_product == "imap_ultra_l1b_45sensor-priority-2-de" + + # Test with valid repoint that falls in the second range. + de_product = get_de_product_name("repoint00003", 45, ancillary_files) + assert de_product == "imap_ultra_l1b_45sensor-priority-1-de" diff --git a/imap_processing/ultra/l1b/extendedspin.py b/imap_processing/ultra/l1b/extendedspin.py index b69348f422..99ea6ea07b 100644 --- a/imap_processing/ultra/l1b/extendedspin.py +++ b/imap_processing/ultra/l1b/extendedspin.py @@ -87,6 +87,7 @@ def calculate_extendedspin( spin_tbin_edges = get_binned_spins_edges( spin, spin_period, spin_starttime, spin_bin_size ) + # Calculate goodtime quality flags. # The culling algorithms should be called in the following order # 1. Low voltage diff --git a/imap_processing/ultra/l1c/l1c_lookup_utils.py b/imap_processing/ultra/l1c/l1c_lookup_utils.py index 341c5de8e9..e91dc629ab 100644 --- a/imap_processing/ultra/l1c/l1c_lookup_utils.py +++ b/imap_processing/ultra/l1c/l1c_lookup_utils.py @@ -513,3 +513,65 @@ def build_energy_bins( energy_bin_geometric_means = np.sqrt(energy_bin_edges[:-1] * energy_bin_edges[1:]) return intervals, energy_midpoints, energy_bin_geometric_means + + +def get_de_product_name(repoint: str, sensor: int, ancillary_files: dict) -> str: + """ + Get the name of the de product to use for processing. + + This will be either the raw de product or a priority 1-4 de product, depending on + the pointing and data level. + + Note: Currently the lookup tables are identical between ultra45 and ultra90, + but this function accounts for the possibility of them being different in the + future. + + Parameters + ---------- + repoint : str + The repointing ID in the format "repointXXXXX" where XXXXX is the repointing + number. + sensor : int + Sensor number, either 45 or 90. + ancillary_files : dict + Ancillary files containing the lookup tables to determine which DE product + to use based on the repointing ID. + + Returns + ------- + de_product_name : str + Name of the de product to use for processing. + """ + # load the lookup table. + # The lookup table will have columns for repointing_id_start, repointing_id_end, + # and de_product. If repointing_id_end is NaN that indicates that the de_product + # should be used for all repoint IDs greater than or equal to repointing_id_start. + file_name = f"l1c-{sensor}sensor-de-product-lookup" + de_lookup = pd.read_csv(ancillary_files[file_name]) + repoint_id = int(repoint.replace("repoint", "")) + # Filter the dataset to find where the current repoint ID falls within the + # repointing_id_start and repointing_id_end range. OR if repointing_id_end is NaN, + # then just check if repoint_id is greater than or equal to repointing_id_start + repoint_row = de_lookup[ + (de_lookup["repointing_id_start"] <= repoint_id) + & ( + (de_lookup["repointing_id_end"] > repoint_id) + | (pd.isna(de_lookup["repointing_id_end"])) + ) + ] + if repoint_row.empty: + raise ValueError( + f"No DE product found for repoint ID {repoint_id} in {file_name}" + ) + if len(repoint_row) > 1: + raise ValueError( + f"Multiple DE products found for repoint ID {repoint_id} using " + f"ancillary file {file_name}. Check that the " + f"repointing_id_start and repointing_id_end values are correct" + f" and not overlapping." + ) + product = repoint_row["de_product"].values[0] + logger.info( + f"Using DE product {product} for repoint ID {repoint_id} based on lookup table" + ) + return product From ee6d8300d2f42c9fa6bbd6282f252e96573081a7 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Tue, 29 Sep 2026 11:27:11 -0600 Subject: [PATCH 02/13] fix tests --- imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py index 56a5bf456b..a6c4ba1516 100644 --- a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py +++ b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py @@ -744,10 +744,6 @@ def test_flag_high_energy(): assert not np.any(quality_flags[:, 2]) -@mock.patch( - "imap_processing.ultra.l1b.ultra_l1b_culling.UltraConstants.HIGH_ENERGY_CULL_CHANNEL", - 4, -) @pytest.mark.external_test_data def test_validate_high_energy_cull(setup_repoint_47_data): """Validate that high energy spins are correctly flagged""" From c7ef0dd306f8d7d081d8cf75c017a36e298ae863 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Tue, 29 Sep 2026 11:47:19 -0600 Subject: [PATCH 03/13] move back helper to reduce pr changes --- .../tests/ultra/unit/test_l1c_lookup_utils.py | 81 ------------------- imap_processing/ultra/l1c/l1c_lookup_utils.py | 62 -------------- 2 files changed, 143 deletions(-) diff --git a/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py b/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py index 0ee81f2caf..2546ddf150 100644 --- a/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py +++ b/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py @@ -1,15 +1,11 @@ -from unittest import mock - import astropy_healpix.healpy as hp import numpy as np -import pandas as pd import pytest import xarray as xr from imap_processing import imap_module_directory from imap_processing.ultra.l1c.l1c_lookup_utils import ( calculate_accepted_pixels, - get_de_product_name, get_scattering_thresholds_for_energy, get_spacecraft_pointing_lookup_tables, get_static_deadtime_ratios, @@ -209,80 +205,3 @@ def test_calculate_accepted_pixels_restrict_fov(ancillary_files): expected_accepted_px = in_restricted_fov(mock_theta, mock_phi, 45) expected_accepted_px[:, :, :outside_inds] = False assert np.array_equal(expected_accepted_px, valid_spun_pixels) - - -def test_get_de_product_name_no_repoint(): - """Tests function get_de_product_name when the lookup is missing the repoint.""" - ancillary_files = { - "l1c-45sensor-de-product-lookup": TEST_PATH - / "imap_ultra_l1c-45sensor-culling-config_20251001_v001.csv" - } - with mock.patch( - "imap_processing.ultra.l1c.l1c_lookup_utils.pd.read_csv" - ) as mock_read_csv: - mock_read_csv.return_value = pd.DataFrame( - { - "repointing_id_start": [1, 2], - "repointing_id_end": [3, 4], - "de_product": [ - "imap_ultra_l1b_45sensor-de", - "imap_ultra_l1b_45sensor-priority-1-de", - ], - } - ) - with pytest.raises(ValueError, match="No DE product found for repoint ID 0"): - get_de_product_name("repoint00000", 45, ancillary_files) - - -def test_get_de_product_name_multiple_products(): - """Tests function get_de_product_name when the lookup is ambiguous.""" - ancillary_files = { - "l1c-45sensor-de-product-lookup": TEST_PATH - / "imap_ultra_l1c-45sensor-culling-config_20251001_v001.csv" - } - with mock.patch( - "imap_processing.ultra.l1c.l1c_lookup_utils.pd.read_csv" - ) as mock_read_csv: - mock_read_csv.return_value = pd.DataFrame( - { - "repointing_id_start": [2, 2], - "repointing_id_end": [3, 4], - "de_product": [ - "imap_ultra_l1b_45sensor-de", - "imap_ultra_l1b_45sensor-priority-1-de", - ], - } - ) - with pytest.raises(ValueError, match="Multiple DE products found"): - get_de_product_name("repoint00002", 45, ancillary_files) - - -def test_get_de_product_name(): - """Tests function get_de_product_name.""" - ancillary_files = { - "l1c-45sensor-de-product-lookup": TEST_PATH - / "imap_ultra_l1c-45sensor-culling-config_20251001_v001.csv" - } - with mock.patch( - "imap_processing.ultra.l1c.l1c_lookup_utils.pd.read_csv" - ) as mock_read_csv: - mock_read_csv.return_value = pd.DataFrame( - { - "repointing_id_start": [0, 2, 4], - "repointing_id_end": [1, 4, np.nan], - "de_product": [ - "imap_ultra_l1b_45sensor-de", - "imap_ultra_l1b_45sensor-priority-1-de", - "imap_ultra_l1b_45sensor-priority-2-de", - ], - } - ) - # Test with a repoint in the future. Should return the priority 2 de product - # since the last repoint range does not have an end and should be assumed to - # cover all future repoints. - de_product = get_de_product_name("repoint00100", 45, ancillary_files) - assert de_product == "imap_ultra_l1b_45sensor-priority-2-de" - - # Test with valid repoint that falls in the second range. - de_product = get_de_product_name("repoint00003", 45, ancillary_files) - assert de_product == "imap_ultra_l1b_45sensor-priority-1-de" diff --git a/imap_processing/ultra/l1c/l1c_lookup_utils.py b/imap_processing/ultra/l1c/l1c_lookup_utils.py index e91dc629ab..341c5de8e9 100644 --- a/imap_processing/ultra/l1c/l1c_lookup_utils.py +++ b/imap_processing/ultra/l1c/l1c_lookup_utils.py @@ -513,65 +513,3 @@ def build_energy_bins( energy_bin_geometric_means = np.sqrt(energy_bin_edges[:-1] * energy_bin_edges[1:]) return intervals, energy_midpoints, energy_bin_geometric_means - - -def get_de_product_name(repoint: str, sensor: int, ancillary_files: dict) -> str: - """ - Get the name of the de product to use for processing. - - This will be either the raw de product or a priority 1-4 de product, depending on - the pointing and data level. - - Note: Currently the lookup tables are identical between ultra45 and ultra90, - but this function accounts for the possibility of them being different in the - future. - - Parameters - ---------- - repoint : str - The repointing ID in the format "repointXXXXX" where XXXXX is the repointing - number. - sensor : int - Sensor number, either 45 or 90. - ancillary_files : dict - Ancillary files containing the lookup tables to determine which DE product - to use based on the repointing ID. - - Returns - ------- - de_product_name : str - Name of the de product to use for processing. - """ - # load the lookup table. - # The lookup table will have columns for repointing_id_start, repointing_id_end, - # and de_product. If repointing_id_end is NaN that indicates that the de_product - # should be used for all repoint IDs greater than or equal to repointing_id_start. - file_name = f"l1c-{sensor}sensor-de-product-lookup" - de_lookup = pd.read_csv(ancillary_files[file_name]) - repoint_id = int(repoint.replace("repoint", "")) - # Filter the dataset to find where the current repoint ID falls within the - # repointing_id_start and repointing_id_end range. OR if repointing_id_end is NaN, - # then just check if repoint_id is greater than or equal to repointing_id_start - repoint_row = de_lookup[ - (de_lookup["repointing_id_start"] <= repoint_id) - & ( - (de_lookup["repointing_id_end"] > repoint_id) - | (pd.isna(de_lookup["repointing_id_end"])) - ) - ] - if repoint_row.empty: - raise ValueError( - f"No DE product found for repoint ID {repoint_id} in {file_name}" - ) - if len(repoint_row) > 1: - raise ValueError( - f"Multiple DE products found for repoint ID {repoint_id} using " - f"ancillary file {file_name}. Check that the " - f"repointing_id_start and repointing_id_end values are correct" - f" and not overlapping." - ) - product = repoint_row["de_product"].values[0] - logger.info( - f"Using DE product {product} for repoint ID {repoint_id} based on lookup table" - ) - return product From 3cdb2949ae002f26092dd3c7e043e2b765b50b47 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Tue, 29 Sep 2026 14:48:35 -0600 Subject: [PATCH 04/13] more edits from testing --- imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py b/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py index 2546ddf150..d2149d4ea2 100644 --- a/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py +++ b/imap_processing/tests/ultra/unit/test_l1c_lookup_utils.py @@ -3,7 +3,6 @@ import pytest import xarray as xr -from imap_processing import imap_module_directory from imap_processing.ultra.l1c.l1c_lookup_utils import ( calculate_accepted_pixels, get_scattering_thresholds_for_energy, @@ -13,8 +12,6 @@ mask_below_fwhm_scattering_threshold, ) -TEST_PATH = imap_module_directory / "tests" / "ultra" / "data" / "l1" - @pytest.mark.external_test_data def test_get_spacecraft_pointing_lookup_tables(ancillary_files): From fadc16f2c95e6dd688f5c86eb2b2a63cba9cb14e Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Wed, 30 Sep 2026 14:40:56 -0600 Subject: [PATCH 05/13] more updates to match bobs code; --- .../ultra/unit/test_ultra_l1b_culling.py | 120 ++++++++---------- imap_processing/ultra/constants.py | 7 - imap_processing/ultra/l1b/extendedspin.py | 7 +- imap_processing/ultra/l1b/lookup_utils.py | 12 +- .../ultra/l1b/ultra_l1b_culling.py | 8 +- 5 files changed, 64 insertions(+), 90 deletions(-) diff --git a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py index a6c4ba1516..41e8ea9649 100644 --- a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py +++ b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py @@ -55,13 +55,21 @@ ) +# Get the ULTRA 90 extended spin config for this pointing +repoint = "repoint00383" +REPOINT_383_SPIN_CONFIG = ExtendedSpinConfig.from_csv( + TEST_PATH / "imap_ultra_l1b-90sensor-extendedspin-config_20251001_v001.csv", repoint +) + + @pytest.fixture -def setup_repoint_47_data(): - """Fixture to set up data for validation test using repoint 47.""" - de_df = pd.read_csv(TEST_PATH / "de_test_data_repoint00047.csv") +def setup_repoint_383_data(): + """Fixture to set up data for validation test using repoint 383 (ULTRA 90).""" + # The priority config for repoint 383 is p1, so only the priority 1 de is used. + de_df = pd.read_csv(TEST_PATH / "p1de_test_data_repoint00383.csv") de_ds = xr.Dataset( { - "de_event_met": ("epoch", de_df.event_times.values), + "de_event_met": ("epoch", de_df.de_event_met.values), "event_times": ("epoch", de_df.event_times.values), "energy_spacecraft": ("epoch", de_df.energy_spacecraft.values), "quality_outliers": ("epoch", de_df.quality_outliers.values), @@ -69,7 +77,7 @@ def setup_repoint_47_data(): "ebin": ("epoch", de_df.ebin.values), } ) - xspin = pd.read_csv(TEST_PATH / "extendedspin_test_data_repoint00047.csv") + xspin = pd.read_csv(TEST_PATH / "extendedspin_test_data_repoint00383.csv") spin_bin_size = UltraConstants.SPIN_BIN_SIZE spin_tbin_edges = get_binned_spins_edges( xspin.spin_number.values, @@ -79,6 +87,7 @@ def setup_repoint_47_data(): ) de_datasets = {"p0": de_ds, "p1": de_ds} + spin_config = REPOINT_383_SPIN_CONFIG # Get the energy ranges energy_ranges = get_binned_energy_ranges(build_energy_bins()[0]) @@ -511,13 +520,13 @@ def test_get_energy_and_spin_dependent_rejection_mask(): def test_validate_voltage_cull(): """Validate that low voltage spins are correctly flagged""" # read test data from csv files - xspin = pd.read_csv(TEST_PATH / "extendedspin_test_data_repoint00047.csv") + xspin = pd.read_csv(TEST_PATH / "extendedspin_test_data_repoint00383.csv") validation_low_voltage_qf = np.loadtxt( - TEST_PATH / "voltage_culling_results_repoint00047.csv", + TEST_PATH / "voltage_culling_results_repoint00383_v1.csv", delimiter=",", dtype=np.uint16, ) - status_df = pd.read_csv(TEST_PATH / "status_test_data_repoint00047.csv") + status_df = pd.read_csv(TEST_PATH / "status_test_data_repoint00383.csv") # build the status dataset including the variables needed for the low voltage flag status_ds = xr.Dataset( { @@ -528,14 +537,13 @@ def test_validate_voltage_cull(): ) # Use constants from the code to ensure consistency with the actual culling code spin_bin_size = UltraConstants.SPIN_BIN_SIZE - lv_threshold = 3000 spin_tbin_edges = get_binned_spins_edges( xspin.spin_number.values, xspin.spin_period.values, xspin.spin_start_time.values, spin_bin_size, ) - lv_flags = flag_low_voltage(spin_tbin_edges, status_ds, lv_threshold) + lv_flags = flag_low_voltage(spin_tbin_edges, status_ds) assert np.array_equal(lv_flags, validation_low_voltage_qf) @@ -721,8 +729,8 @@ def test_flag_high_energy(): de_counts_summary, spin_tbin_edges, energy_range_edges, - None, cull_thresholds, + None, combine_spin_bin_radius=0, ) @@ -745,29 +753,18 @@ def test_flag_high_energy(): @pytest.mark.external_test_data -def test_validate_high_energy_cull(setup_repoint_47_data): +def test_validate_high_energy_cull(setup_repoint_383_data): """Validate that high energy spins are correctly flagged""" - # Mock thresholds to match the test data (I used fake ones to create more - # complexity) - mock_thresholds = np.array([0.05, 1.5, 0.6, 119.2, 0.2]) * 20 expected_qf = pd.read_csv( - TEST_PATH / "validate_high_energy_culling_results_repoint00047_v2.csv" + TEST_PATH / "validate_high_energy_culling_results_repoint00383_v1.csv" ).to_numpy() - de_datasets, _, spin_tbin_edges, _, _ = setup_repoint_47_data - - # Get the energy ranges - energy_ranges = np.array([4.2, 9.4425, 21.2116, 47.2388, 105.202, 316.335]) - de_counts_summary = get_valid_de_count_summary( - de_datasets, energy_ranges, spin_tbin_edges, REPOINT_47_SPIN_CONFIG - ) - high_energy_combined_spin_bin_radius = 3 + _, _, spin_tbin_edges, energy_ranges, de_counts_summary = setup_repoint_383_data e_flags = flag_high_energy( de_counts_summary, spin_tbin_edges, energy_ranges, + REPOINT_383_SPIN_CONFIG.energy_thresholds, None, - mock_thresholds, - combine_spin_bin_radius=high_energy_combined_spin_bin_radius, ) np.testing.assert_array_equal(e_flags, ~expected_qf.astype(bool)) @@ -903,23 +900,15 @@ def test_get_poisson_stats(): @pytest.mark.external_test_data -def test_validate_stat_cull(setup_repoint_47_data): +def test_validate_stat_cull(setup_repoint_383_data): """Validate that statistical-outlier quality flags match expected results.""" # read test data from csv files results_df = pd.read_csv( - TEST_PATH / "validate_stat_culling_results_repoint00047_v3.csv" + TEST_PATH / "validate_stat_culling_results_repoint00383_v1.csv" ) - de_datasets, _, spin_tbin_edges, energy_ranges, de_counts_summary = ( - setup_repoint_47_data - ) - - # Create a mask of flagged events to test that the stat cull algorithm - # properly ignores these. The test data was created using this exact mask as well. + _, _, spin_tbin_edges, energy_ranges, de_counts_summary = setup_repoint_383_data + # No spin bins flagged by previous steps mask = np.zeros((len(energy_ranges) - 1, len(spin_tbin_edges) - 1), dtype=bool) - mask[0:2, 0:2] = ( - True # This will mark the first 2 energy bins and first 2 spin bins as flagged - ) - # ignored in the statistics calculation and flagging. flags, con, it, std = flag_statistical_outliers( de_counts_summary, spin_tbin_edges, energy_ranges, mask ) @@ -956,38 +945,34 @@ def test_get_binned_energy_ranges(): @pytest.mark.external_test_data -def test_validate_upstream_ion_cull(setup_repoint_47_data): +@pytest.mark.parametrize( + "version, channels", + [ + (1, UltraConstants.UPSTREAM_ION_ENERGY_CHANNELS_1), + (2, UltraConstants.UPSTREAM_ION_ENERGY_CHANNELS_2), + ], +) +def test_validate_upstream_ion_cull(setup_repoint_383_data, version, channels): """Validate that upstream ion quality flags match expected results.""" # read test data from csv files expected_results = pd.read_csv( - TEST_PATH / "validate_upstream_ion_1_culling_results_repoint00047_v1.csv" + TEST_PATH + / f"validate_upstream_ion_{version}_culling_results_repoint00383_v1.csv" ).to_numpy() - de_datasets, _, spin_tbin_edges, energy_ranges, de_counts_summary = ( - setup_repoint_47_data - ) + _, _, spin_tbin_edges, energy_ranges, de_counts_summary = setup_repoint_383_data + # No spin bins flagged by previous steps mask = np.zeros((len(energy_ranges) - 1, len(spin_tbin_edges) - 1), dtype=bool) - mask[0:2, 0:2] = ( - True # This will mark the first 2 energy bins and first 2 spin bins as flagged - ) - - flags = flag_upstream_ion( - de_counts_summary, - energy_ranges, - mask, - UltraConstants.UPSTREAM_ION_ENERGY_CHANNELS_1, - ) - # Combine the flags with the mask to get the final expected results since the - # masked bins should be flagged as well. - results = flags | mask + flags = flag_upstream_ion(de_counts_summary, energy_ranges, mask, channels) + # The upstream ion flags are energy independent, so they apply to every energy + # range. + results = np.broadcast_to(flags, mask.shape) np.testing.assert_array_equal(results, ~expected_results.astype(bool)) @pytest.mark.external_test_data -def test_upstream_ion_cull_invalid_channels(setup_repoint_47_data): +def test_upstream_ion_cull_invalid_channels(setup_repoint_383_data): """Validate upstream ion error handling.""" - de_datasets, _, spin_tbin_edges, energy_ranges, de_counts_summary = ( - setup_repoint_47_data - ) + _, _, spin_tbin_edges, energy_ranges, de_counts_summary = setup_repoint_383_data mask = np.zeros((len(energy_ranges) - 1, len(spin_tbin_edges) - 1), dtype=bool) with pytest.raises( ValueError, @@ -1003,23 +988,18 @@ def test_upstream_ion_cull_invalid_channels(setup_repoint_47_data): @pytest.mark.external_test_data -def test_validate_spectral_cull(setup_repoint_47_data): +def test_validate_spectral_cull(setup_repoint_383_data): """Validate that spectral flags match expected results.""" # read test data from csv files expected_results = pd.read_csv( - TEST_PATH / "validate_spectral_culling_results_repoint00047_v1.csv" + TEST_PATH / "validate_spectral_culling_results_repoint00383_v1.csv" ).to_numpy() - de_datasets, xspin, spin_tbin_edges, energy_ranges, de_counts_summary = ( - setup_repoint_47_data - ) - mask = np.zeros((len(energy_ranges) - 1, len(spin_tbin_edges) - 1), dtype=bool) - mask[0:2, 0:2] = ( - True # This will mark the first 2 energy bins and first 2 spin bins as flagged - ) + _, _, _, energy_ranges, de_counts_summary = setup_repoint_383_data flags = flag_spectral_events( de_counts_summary, energy_ranges, UltraConstants.SPECTRAL_ENERGY_CHANNELS, ) - results = flags | mask + # The spectral flags are energy independent, so they apply to every energy range. + results = np.broadcast_to(flags, expected_results.shape) np.testing.assert_array_equal(results, ~expected_results.astype(bool)) diff --git a/imap_processing/ultra/constants.py b/imap_processing/ultra/constants.py index af6814e0d5..ab93f218dc 100644 --- a/imap_processing/ultra/constants.py +++ b/imap_processing/ultra/constants.py @@ -207,13 +207,6 @@ class UltraConstants: # Angle threshold in radians for ULTRA 45 degree culling. # This is only needed for ULTRA 45 since Earth may be in the FOV. EARTH_ANGLE_45_THRESHOLD = np.radians(15) - # An array of energy thresholds to use for culling. Each one corresponds to - # the number of energy bins used. - # n_bins=len(PSET_ENERGY_BIN_EDGES)[BASE_CULL_EBIN:] // N_CULL_EBINS - # an error will be raised if this does not match n_bins - HIGH_ENERGY_CULL_THRESHOLDS = ( - np.array([4.0, 2.0, 1.20, 0.45, 0.1, 0.1]) * SPIN_BIN_SIZE - ) # Use the channel defined below to determine which spins are contaminated HIGH_ENERGY_CULL_CHANNEL = 5 # For the high energy cull, we want to combine spin bins because an SEP event is diff --git a/imap_processing/ultra/l1b/extendedspin.py b/imap_processing/ultra/l1b/extendedspin.py index 99ea6ea07b..b92e293ebe 100644 --- a/imap_processing/ultra/l1b/extendedspin.py +++ b/imap_processing/ultra/l1b/extendedspin.py @@ -66,7 +66,8 @@ def calculate_extendedspin( status_dataset = dict_datasets[f"imap_ultra_l1b_{instrument_id}sensor-status"] # Use the raw de for the spin level quantities since it contains all events. # The energy dependent culling selects its de dataset per energy range. - de_dataset = de_datasets["p0"] + # TODO use priority 1. + de_dataset = de_datasets["p1"] extendedspin_dict = {} rates_qf, spin, energy_bin_geometric_mean, n_sigma_per_energy = flag_rates( @@ -113,14 +114,12 @@ def calculate_extendedspin( instrument_id, ) - # Calculate the high energy quality flags - energy_thresholds = UltraConstants.HIGH_ENERGY_CULL_THRESHOLDS high_energy_qf = flag_high_energy( de_counts_summary, spin_tbin_edges, energy_ranges, + extendedspin_conf.thresholds, voltage_qf, - energy_thresholds, ) # For the following culls, mask the spins that have already been flagged for # low voltage diff --git a/imap_processing/ultra/l1b/lookup_utils.py b/imap_processing/ultra/l1b/lookup_utils.py index 278e98408a..c62ba4dbd8 100644 --- a/imap_processing/ultra/l1b/lookup_utils.py +++ b/imap_processing/ultra/l1b/lookup_utils.py @@ -736,15 +736,17 @@ def from_csv(cls, config_file_path: str, repointing: str) -> "ExtendedSpinConfig f" per pointing." ) config = filtered_df.iloc[0] - # Get the column names that contain "cullThresh" + # Get the column names that contain "cullThresh" and sort them ascending. thresh_colnames = sorted( [col for col in df.columns if "cullThresh" in col], key=lambda threshold: int(threshold.split("_")[-1]), ) - e_thresholds = np.array([config[col] for col in thresh_colnames]) - # The config provides thresholds for bins 0-4; bin 5 (> - # UltraConstands.MAX_ENERGY_THRESHOLD keV) reuses the bin 4 threshold - e_thresholds = np.append(e_thresholds, e_thresholds[-1]) + # The thresholds are counts per spin bin of UltraConstants.SPIN_BIN_SIZE (20) + # spins, so they are used as is. If SPIN_BIN_SIZE changes, the thresholds in + # the config file need to be updated for the new bin size. + thresholds = np.array([config[col] for col in thresh_colnames]) + # Duplicate the last energy threshold + thresholds = np.append(thresholds, thresholds[-1]) date = datetime.datetime.strptime(config["date"], "%m/%d/%y") return cls( diff --git a/imap_processing/ultra/l1b/ultra_l1b_culling.py b/imap_processing/ultra/l1b/ultra_l1b_culling.py index e0b2d31d61..40ed9fbd74 100644 --- a/imap_processing/ultra/l1b/ultra_l1b_culling.py +++ b/imap_processing/ultra/l1b/ultra_l1b_culling.py @@ -688,8 +688,8 @@ def flag_high_energy( de_counts_summary: np.ndarray, spin_tbin_edges: NDArray, energy_ranges: NDArray, + energy_thresholds: NDArray, mask: NDArray = None, - energy_thresholds: np.ndarray = UltraConstants.HIGH_ENERGY_CULL_THRESHOLDS, combine_spin_bin_radius: int = UltraConstants.HIGH_ENERGY_COMBINED_SPIN_BIN_RADIUS, ) -> NDArray: """ @@ -704,13 +704,13 @@ def flag_high_energy( Edges of the spin time bins. energy_ranges : numpy.ndarray Array of energy range edges. + energy_thresholds : numpy.ndarray + Array of count thresholds for flagging high energy events corresponding to + each energy range. mask : numpy.ndarray, optional Mask indicating which events to consider for high energy flagging (e.g., after low voltage culling). True indicates the spin bins that should NOT be considered for high energy flagging. - energy_thresholds : numpy.ndarray - Array of count thresholds for flagging high energy events corresponding to - each energy range. combine_spin_bin_radius : int Number of spin bins to combine on either side of the current bin to get a smoother estimate of the counts per bin (see ``combine_de_counts_summary``). From a7b2f3c23da9a4b07ea11c71992ab9efdba72195 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Wed, 30 Sep 2026 14:57:31 -0600 Subject: [PATCH 06/13] add bobs new changes to config file # Conflicts: # imap_processing/tests/ultra/unit/test_lookup_utils.py # imap_processing/ultra/l1b/lookup_utils.py --- imap_processing/ultra/l1b/lookup_utils.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/imap_processing/ultra/l1b/lookup_utils.py b/imap_processing/ultra/l1b/lookup_utils.py index c62ba4dbd8..078167d278 100644 --- a/imap_processing/ultra/l1b/lookup_utils.py +++ b/imap_processing/ultra/l1b/lookup_utils.py @@ -691,7 +691,7 @@ def get_de_product_name(repoint: str, sensor: int, ancillary_files: dict) -> str class ExtendedSpinConfig: """Pointing dependent l1b culling configurations.""" - energy_thresholds: np.ndarray # energy thresholds for culling + energy_thresholds: np.ndarray # energy thresholds for culling (counts per 20-spin bin) voltage_threshold: float # voltage threshold for culling date: datetime.datetime # Date when configuration changed priority: str # Which de product to use priority 1-4 de or raw de. e.g. p0-p4 @@ -744,9 +744,10 @@ def from_csv(cls, config_file_path: str, repointing: str) -> "ExtendedSpinConfig # The thresholds are counts per spin bin of UltraConstants.SPIN_BIN_SIZE (20) # spins, so they are used as is. If SPIN_BIN_SIZE changes, the thresholds in # the config file need to be updated for the new bin size. - thresholds = np.array([config[col] for col in thresh_colnames]) - # Duplicate the last energy threshold - thresholds = np.append(thresholds, thresholds[-1]) + e_thresholds = np.array([config[col] for col in thresh_colnames]) + # The config provides thresholds for bins 0-4; bin 5 (> + # UltraConstands.MAX_ENERGY_THRESHOLD keV) reuses the bin 4 threshold + e_thresholds = np.append(e_thresholds, e_thresholds[-1]) date = datetime.datetime.strptime(config["date"], "%m/%d/%y") return cls( From 4f94d315ea6f4a8e558f2982e6a75b704c30c168 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Wed, 30 Sep 2026 15:13:38 -0600 Subject: [PATCH 07/13] remove unused flag --- .../ultra/unit/test_ultra_l1b_culling.py | 23 +++++++++++-------- imap_processing/ultra/constants.py | 4 ---- imap_processing/ultra/l1b/extendedspin.py | 6 +++-- .../ultra/l1b/ultra_l1b_culling.py | 7 +++--- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py index 41e8ea9649..2752a4775a 100644 --- a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py +++ b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py @@ -87,7 +87,6 @@ def setup_repoint_383_data(): ) de_datasets = {"p0": de_ds, "p1": de_ds} - spin_config = REPOINT_383_SPIN_CONFIG # Get the energy ranges energy_ranges = get_binned_energy_ranges(build_energy_bins()[0]) @@ -96,7 +95,7 @@ def setup_repoint_383_data(): de_datasets, energy_ranges, spin_tbin_edges, - REPOINT_47_SPIN_CONFIG, + REPOINT_383_SPIN_CONFIG, 90, ) @@ -402,7 +401,8 @@ def test_flag_low_voltage(test_data): spin_tbin_edges = get_binned_spins_edges( spins, spin_period, spin_starttime, spin_bin_size ) - quality_flags = flag_low_voltage(spin_tbin_edges, mock_status_dataset) + v_threshold = REPOINT_383_SPIN_CONFIG.voltage_threshold + quality_flags = flag_low_voltage(spin_tbin_edges, mock_status_dataset, v_threshold) # There should be an extra bin edge for the last bin to indicate the end of the last # spin bin @@ -415,7 +415,7 @@ def test_flag_low_voltage(test_data): # Set only the first spin to be below threshold mock_status_dataset["rightdeflection_v"].data[1:] += 5000 mock_status_dataset["leftdeflection_v"].data[1:] += 5000 - quality_flags = flag_low_voltage(spin_tbin_edges, mock_status_dataset) + quality_flags = flag_low_voltage(spin_tbin_edges, mock_status_dataset, v_threshold) # Check that only the first spin is flagged for low voltage assert np.all(quality_flags[0]) # The rest should not be flagged @@ -441,7 +441,9 @@ def test_flag_low_voltage_incomplete_bins(test_data): spin_tbin_edges = get_binned_spins_edges( spins, spin_period, spin_starttime, spin_bin_size ) - quality_flags = flag_low_voltage(spin_tbin_edges, mock_status_dataset) + quality_flags = flag_low_voltage( + spin_tbin_edges, mock_status_dataset, REPOINT_383_SPIN_CONFIG.voltage_threshold + ) # check quality flag assert quality_flags.shape == (n_spins // spin_bin_size,) @@ -543,7 +545,9 @@ def test_validate_voltage_cull(): xspin.spin_start_time.values, spin_bin_size, ) - lv_flags = flag_low_voltage(spin_tbin_edges, status_ds) + lv_flags = flag_low_voltage( + spin_tbin_edges, status_ds, REPOINT_47_SPIN_CONFIG.voltage_threshold + ) assert np.array_equal(lv_flags, validation_low_voltage_qf) @@ -621,7 +625,7 @@ def test_get_valid_de_count_summary_valid_events(): {"p0": de_dataset, "p1": de_dataset}, energy_range_edges, spin_tbin_edges, - REPOINT_47_SPIN_CONFIG, + REPOINT_383_SPIN_CONFIG, 90, ) @@ -663,7 +667,6 @@ def test_get_valid_de_count_summary_ultra45(mock_spkezr): "ebin": ("epoch", np.full(len(energy), 10)), } ) - # ensure that all events fail the earth angle check by setting a very large # keepout angle keepout_angle = np.radians(360) @@ -671,7 +674,7 @@ def test_get_valid_de_count_summary_ultra45(mock_spkezr): {"p0": de_dataset, "p1": de_dataset}, energy_range_edges, spin_tbin_edges, - REPOINT_47_SPIN_CONFIG, + REPOINT_383_SPIN_CONFIG, 45, keepout_angle, ) @@ -718,6 +721,7 @@ def test_flag_high_energy(): start=0, stop=len(energy) + 1, step=4 ) # create spin bins of 4 seconds de_datasets = {"p0": de_dataset, "p1": de_dataset} + de_counts_summary = get_valid_de_count_summary( de_datasets, energy_range_edges, @@ -798,6 +802,7 @@ def test_flag_statistical_outliers(): start=0, stop=len(energy) + 1, step=spin_step ) # create spin bins of 7 seconds de_datasets = {"p0": de_dataset, "p1": de_dataset} + de_counts_summary = get_valid_de_count_summary( de_datasets, energy_range_edges, diff --git a/imap_processing/ultra/constants.py b/imap_processing/ultra/constants.py index ab93f218dc..9251133498 100644 --- a/imap_processing/ultra/constants.py +++ b/imap_processing/ultra/constants.py @@ -247,7 +247,3 @@ class UltraConstants: # When True, applies the scattering rejection mask based on the FWHM thresholds # to the L1C fine energy bin maps. APPLY_SCATTERING_REJECTION_L1C: bool = False - - # When true, only use raw direct events for culling in extendedspin.py. Otherwise, - # refer to the priority specified in the extendedspin-config ancillary file. - L1B_USE_RAW_DE_ONLY = False diff --git a/imap_processing/ultra/l1b/extendedspin.py b/imap_processing/ultra/l1b/extendedspin.py index b92e293ebe..745b278d64 100644 --- a/imap_processing/ultra/l1b/extendedspin.py +++ b/imap_processing/ultra/l1b/extendedspin.py @@ -98,7 +98,9 @@ def calculate_extendedspin( # 5. Spectral cull # 6. Statistical outliers (energy dependent) - voltage_qf = flag_low_voltage(spin_tbin_edges, status_dataset) + voltage_qf = flag_low_voltage( + spin_tbin_edges, status_dataset, extendedspin_conf.voltage_threshold + ) # Get energy bins used at l1c intervals, _, _ = build_energy_bins() # Get the energy ranges @@ -118,7 +120,7 @@ def calculate_extendedspin( de_counts_summary, spin_tbin_edges, energy_ranges, - extendedspin_conf.thresholds, + extendedspin_conf.energy_thresholds, voltage_qf, ) # For the following culls, mask the spins that have already been flagged for diff --git a/imap_processing/ultra/l1b/ultra_l1b_culling.py b/imap_processing/ultra/l1b/ultra_l1b_culling.py index 40ed9fbd74..e2f757c3f2 100644 --- a/imap_processing/ultra/l1b/ultra_l1b_culling.py +++ b/imap_processing/ultra/l1b/ultra_l1b_culling.py @@ -631,7 +631,7 @@ def count_rejected_events_per_spin( def flag_low_voltage( spin_tbin_edges: NDArray, status_dataset: xr.Dataset, - voltage_threshold: float = UltraConstants.LOW_VOLTAGE_CULL_THRESHOLD, + voltage_threshold: float, ) -> NDArray: """ Flag low voltage events. @@ -1102,10 +1102,9 @@ def get_valid_de_count_summary( # By default, use priority 1 de unless it's the last energy bin and the # config is p0 or if the config is set to use raw de only. de = de_datasets["p1"] - if UltraConstants.L1B_USE_RAW_DE_ONLY or ( - i == n_energy_ranges - 1 and priority_conf == "p0" - ): + if i == n_energy_ranges - 1 and priority_conf == "p0": de = de_datasets["p0"] + valid_outliers = de["quality_outliers"].values == 0 valid_scattering = de["quality_scattering"].values == 0 # TODO what about species non-proton? For those psets dont cull based on From 25c14974b801137027413bcaea870ca013d3890a Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Thu, 1 Oct 2026 13:39:38 -0600 Subject: [PATCH 08/13] update based on feedback from claude where code differs --- .../ultra/unit/test_ultra_l1b_culling.py | 15 ++++----- imap_processing/ultra/l1b/extendedspin.py | 23 +++++++------- .../ultra/l1b/ultra_l1b_culling.py | 31 ++++++++++--------- 3 files changed, 35 insertions(+), 34 deletions(-) diff --git a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py index 2752a4775a..fa724fb53b 100644 --- a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py +++ b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py @@ -455,15 +455,16 @@ def test_expand_bin_flags_to_spins(caplog): spin_bin_size = 5 n_spins = 12 # Mock the shape of binned quality flags for 12 spins and a bin size of 5 - binned_qf = np.full((n_spins // spin_bin_size), 1) + binned_qf = np.array([1, 2]) quality_flags = expand_bin_flags_to_spins(n_spins, binned_qf, spin_bin_size) # Check the size assert quality_flags.shape == (n_spins,) - # The first 10 spins should be flagged since they fall into the first two bins - assert np.all(quality_flags[:10] == 1) - # The last 2 spins should not be flagged since they fall into the last incomplete - # bin - assert np.all(quality_flags[10:] == 0) + # The first 10 spins should have the flags of the two complete bins + assert np.all(quality_flags[:5] == 1) + assert np.all(quality_flags[5:10] == 2) + # The last 2 spins fall into the incomplete bin and should inherit the flag of + # the last complete bin + assert np.all(quality_flags[10:] == 2) binned_qf = np.full((n_spins // spin_bin_size) + 1, 1) # test that a warning is logged when there are incomplete bins found expand_bin_flags_to_spins(n_spins, binned_qf, spin_bin_size) @@ -581,7 +582,7 @@ def test_get_valid_earth_angle_events(mock_spkezr): vde = np.sqrt(np.sum(de_dps_velocity**2, 1)) uv = de_dps_velocity / vde[:, np.newaxis] local_uv = np.array([upos, yax, zax]) @ np.transpose(-uv) - expected_flags = local_uv[0, :] < np.cos(earth_angle_threshold) + expected_flags = np.abs(local_uv[0, :]) < np.cos(earth_angle_threshold) actual_flags = get_valid_earth_angle_events(de_dataset, earth_angle_threshold) np.testing.assert_array_equal(actual_flags, expected_flags) diff --git a/imap_processing/ultra/l1b/extendedspin.py b/imap_processing/ultra/l1b/extendedspin.py index 745b278d64..b03b88e0c9 100644 --- a/imap_processing/ultra/l1b/extendedspin.py +++ b/imap_processing/ultra/l1b/extendedspin.py @@ -64,25 +64,24 @@ def calculate_extendedspin( aux_dataset = dict_datasets[f"imap_ultra_l1a_{instrument_id}sensor-aux"] rates_dataset = dict_datasets[f"imap_ultra_l1a_{instrument_id}sensor-rates"] status_dataset = dict_datasets[f"imap_ultra_l1b_{instrument_id}sensor-status"] - # Use the raw de for the spin level quantities since it contains all events. + # Use the priority 1 de for the spin level quantities since it contains all events. # The energy dependent culling selects its de dataset per energy range. - # TODO use priority 1. - de_dataset = de_datasets["p1"] + priority_1_de_dataset = de_datasets["p1"] extendedspin_dict = {} rates_qf, spin, energy_bin_geometric_mean, n_sigma_per_energy = flag_rates( - de_dataset["spin"].values, - de_dataset["energy"].values, + priority_1_de_dataset["spin"].values, + priority_1_de_dataset["energy"].values, ) count_rates, _, _counts, _ = get_energy_histogram( - de_dataset["spin"].values, de_dataset["energy"].values + priority_1_de_dataset["spin"].values, priority_1_de_dataset["energy"].values ) attitude_qf, spin_rates, spin_period, spin_starttime = flag_attitude( - de_dataset["spin"].values, aux_dataset + priority_1_de_dataset["spin"].values, aux_dataset ) # TODO: We will add to this later - hk_qf = flag_hk(de_dataset["spin"].values) - inst_qf = flag_imap_instruments(de_dataset["spin"].values) + hk_qf = flag_hk(priority_1_de_dataset["spin"].values) + inst_qf = flag_imap_instruments(priority_1_de_dataset["spin"].values) spin_bin_size = UltraConstants.SPIN_BIN_SIZE spin_tbin_edges = get_binned_spins_edges( @@ -157,9 +156,9 @@ def calculate_extendedspin( # Track rejected events in each spin based on # quality flags in de l1b data. rejected_counts = count_rejected_events_per_spin( - de_dataset["spin"].values, - de_dataset["quality_scattering"].values, - de_dataset["quality_outliers"].values, + priority_1_de_dataset["spin"].values, + priority_1_de_dataset["quality_scattering"].values, + priority_1_de_dataset["quality_outliers"].values, ) # These will be the coordinates. diff --git a/imap_processing/ultra/l1b/ultra_l1b_culling.py b/imap_processing/ultra/l1b/ultra_l1b_culling.py index e2f757c3f2..a4f4b06488 100644 --- a/imap_processing/ultra/l1b/ultra_l1b_culling.py +++ b/imap_processing/ultra/l1b/ultra_l1b_culling.py @@ -667,7 +667,7 @@ def flag_low_voltage( low_voltage_times = status_dataset["shcoarse"].data[low_voltage_inds] # For each low voltage time, find the corresponding spin time lv_spin_inds = np.atleast_1d( - np.searchsorted(spin_tbin_edges, low_voltage_times, side="right") - 1 + np.searchsorted(spin_tbin_edges[:-1], low_voltage_times, side="right") - 1 ) # Ensure that the indices are within the valid range of spin groups valid_bin_inds = (lv_spin_inds >= 0) & (lv_spin_inds < spin_bin_size) @@ -1171,16 +1171,18 @@ def combine_de_counts_summary( A 2D array of counts per energy range and spin bin for valid events, with counts combined across spin bins. """ - # Pad array along the spin bin axis to ensure sliding_window_view returns - # an array of the correct shape. + # Pad array along the spin bin axis with NaN so that sliding_window_view returns + # an array of the correct shape. To be consistent with the ULTRA IT + # implementation, the padded values are ignored in the mean, so edge bins are + # averaged over only the bins that exist. counts_padded = np.pad( - de_counts_summary, + de_counts_summary.astype(float), ((0, 0), (combine_spin_bin_radius, combine_spin_bin_radius)), - mode="edge", + constant_values=np.nan, ) window_size = combine_spin_bin_radius * 2 + 1 windows = sliding_window_view(counts_padded, window_shape=window_size, axis=1) - return np.mean(windows, axis=-1) + return np.nanmean(windows, axis=-1) def get_valid_earth_angle_events( @@ -1201,8 +1203,8 @@ def get_valid_earth_angle_events( Returns ------- valid_earth_angle_events : NDArray - A boolean array indicating which events have Earth angle greater than the - specified threshold. + A boolean array indicating which events have an angle greater than the + specified threshold from both the Earth and anti-Earth directions. """ velocity_dps_sc = de_dataset_subset["velocity_dps_sc"].values # Use the mean event time to compute the Earth unit vector since the spacecraft @@ -1229,13 +1231,9 @@ def get_valid_earth_angle_events( ) # shape (n_events, 3) # Get cos(theta) between each particle look direction and Earth direction cos_sep = np.dot(unit_look_dirs, earth_unit_vector) # shape (n_events,) - # Clip cos_sep to the valid range of [-1, 1] to avoid numerical issues with arccos - cos_sep = np.clip(cos_sep, -1.0, 1.0) - sep_angle = np.arccos(cos_sep) - # An event is valid if the separation angle between the particle look - # direction and Earth direction is greater than the Earth angle limit - # (i.e., the Earth is outside the field of view). - return sep_angle > earth_ang_45 + # An event is valid if the particle look direction is more than the Earth angle + # limit away from both the Earth direction and the anti-Earth direction. + return np.abs(cos_sep) < np.cos(earth_ang_45) def get_energy_range_flags(energy_ranges_edges: NDArray) -> NDArray: @@ -1403,5 +1401,8 @@ def expand_bin_flags_to_spins( ) repeated_flags = repeated_flags[:n_spins] quality_flags[: len(repeated_flags)] = repeated_flags + # Spins in an incomplete trailing bin inherit the flag of the last complete bin. + if len(repeated_flags) < n_spins: + quality_flags[len(repeated_flags) :] = binned_quality_flags[-1] return quality_flags From 3cf8427c660d826caf5d8c2062c3ea59eb8ad3c1 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Thu, 1 Oct 2026 14:04:54 -0600 Subject: [PATCH 09/13] revase --- imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py index fa724fb53b..3764fab407 100644 --- a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py +++ b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py @@ -675,7 +675,7 @@ def test_get_valid_de_count_summary_ultra45(mock_spkezr): {"p0": de_dataset, "p1": de_dataset}, energy_range_edges, spin_tbin_edges, - REPOINT_383_SPIN_CONFIG, + REPOINT_47_SPIN_CONFIG, 45, keepout_angle, ) From 5936d6cc7e3e2a86b48cd649e6726a02a6b73734 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Thu, 1 Oct 2026 15:17:30 -0600 Subject: [PATCH 10/13] fix the keepout angle testss --- imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py index 3764fab407..bc6202b904 100644 --- a/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py +++ b/imap_processing/tests/ultra/unit/test_ultra_l1b_culling.py @@ -670,7 +670,7 @@ def test_get_valid_de_count_summary_ultra45(mock_spkezr): ) # ensure that all events fail the earth angle check by setting a very large # keepout angle - keepout_angle = np.radians(360) + keepout_angle = np.radians(90) counts = get_valid_de_count_summary( {"p0": de_dataset, "p1": de_dataset}, energy_range_edges, From c254f9fe78222bcc0402c1359797b803932ff1de Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Fri, 2 Oct 2026 10:52:04 -0600 Subject: [PATCH 11/13] add comment about why im clipping the spin bin edges --- imap_processing/ultra/l1b/ultra_l1b_culling.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/imap_processing/ultra/l1b/ultra_l1b_culling.py b/imap_processing/ultra/l1b/ultra_l1b_culling.py index a4f4b06488..119e2055c7 100644 --- a/imap_processing/ultra/l1b/ultra_l1b_culling.py +++ b/imap_processing/ultra/l1b/ultra_l1b_culling.py @@ -666,6 +666,9 @@ def flag_low_voltage( low_voltage_times = status_dataset["shcoarse"].data[low_voltage_inds] # For each low voltage time, find the corresponding spin time + # Remove the last bin edge of spin_tbin_edges so any trailing spins + # (not assigned a bin) end up in the last bin. This ensures that + # a low voltage event during a trailing spin is flagged. lv_spin_inds = np.atleast_1d( np.searchsorted(spin_tbin_edges[:-1], low_voltage_times, side="right") - 1 ) From b10a850d487db95a0cf7709b438eea6591823265 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Fri, 2 Oct 2026 15:34:32 -0600 Subject: [PATCH 12/13] add test data to external test data list --- imap_processing/tests/external_test_data_config.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/imap_processing/tests/external_test_data_config.py b/imap_processing/tests/external_test_data_config.py index 8ab7c04af5..d6295a213d 100644 --- a/imap_processing/tests/external_test_data_config.py +++ b/imap_processing/tests/external_test_data_config.py @@ -153,6 +153,15 @@ ("validate_upstream_ion_1_culling_results_repoint00047_v1.csv", "ultra/data/l1/"), ("validate_spectral_culling_results_repoint00047_v1.csv", "ultra/data/l1/"), ("de_test_data_repoint00047.csv", "ultra/data/l1/"), + ("extendedspin_test_data_repoint00383.csv", "ultra/data/l1/"), + ("p1de_test_data_repoint00383.csv", "ultra/data/l1/"), + ("status_test_data_repoint00383.csv", "ultra/data/l1/"), + ("voltage_culling_results_repoint00383_v1.csv", "ultra/data/l1/"), + ("validate_high_energy_culling_results_repoint00383_v1.csv", "ultra/data/l1/"), + ("validate_stat_culling_results_repoint00383_v1.csv", "ultra/data/l1/"), + ("validate_spectral_culling_results_repoint00383_v1.csv", "ultra/data/l1/"), + ("validate_upstream_ion_1_culling_results_repoint00383_v1.csv", "ultra/data/l1/"), + ("validate_upstream_ion_2_culling_results_repoint00383_v1.csv", "ultra/data/l1/"), ("FM45_UltraFM45Extra_TV_Tests_2024-01-22T0930_20240122T093008.CCSDS", "ultra/data/l0/"), ("ultra45_raw_sc_rawnrgevnt_19840122_00.csv", "ultra/data/l0/"), ("ultra45_raw_sc_enaphxtofhnrgimg_FM45_UltraFM45Extra_TV_Tests_2024-01-22T0930_20240122T093008.csv", From d00cffb18bcf411725ba6eabb76e291f0f15c2e4 Mon Sep 17 00:00:00 2001 From: Luisa Coakley Date: Fri, 2 Oct 2026 15:41:17 -0600 Subject: [PATCH 13/13] reformat --- imap_processing/ultra/l1b/lookup_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/imap_processing/ultra/l1b/lookup_utils.py b/imap_processing/ultra/l1b/lookup_utils.py index 078167d278..a60eff5e1f 100644 --- a/imap_processing/ultra/l1b/lookup_utils.py +++ b/imap_processing/ultra/l1b/lookup_utils.py @@ -691,7 +691,8 @@ def get_de_product_name(repoint: str, sensor: int, ancillary_files: dict) -> str class ExtendedSpinConfig: """Pointing dependent l1b culling configurations.""" - energy_thresholds: np.ndarray # energy thresholds for culling (counts per 20-spin bin) + energy_thresholds: np.ndarray # energy thresholds for culling + # (counts per 20-spin bin) voltage_threshold: float # voltage threshold for culling date: datetime.datetime # Date when configuration changed priority: str # Which de product to use priority 1-4 de or raw de. e.g. p0-p4