diff --git a/imap_processing/glows/l2/glows_l2_data.py b/imap_processing/glows/l2/glows_l2_data.py index 06dff9f7d..e90e22048 100644 --- a/imap_processing/glows/l2/glows_l2_data.py +++ b/imap_processing/glows/l2/glows_l2_data.py @@ -370,10 +370,20 @@ def __init__( """ active_flags = np.array(pipeline_settings.active_bad_time_flags, dtype=float) + # Apply sunrise/sunset offsets to extend the night region around + # is_night transitions before selecting good blocks. + flags = self.apply_is_night_offsets( + l1b_dataset["flags"].data, + is_night_idx=GlowsConstants.IS_NIGHT_FLAG_IDX, + sunrise_offset=int(pipeline_settings.sunrise_offset), + sunset_offset=int(pipeline_settings.sunset_offset), + ) + flags_da = xr.DataArray(flags, dims=l1b_dataset["flags"].dims) + # Select the good blocks (i.e. epoch values) according to the flags. Drop any # bad blocks before processing. good_data = l1b_dataset.isel( - epoch=self.return_good_times(l1b_dataset["flags"], active_flags) + epoch=self.return_good_times(flags_da, active_flags) ) # TODO: bad angle filter # TODO: filter bad bins out. Needs to happen here while everything is still @@ -533,6 +543,98 @@ def return_good_times(flags: xr.DataArray, active_flags: NDArray) -> NDArray: good_times = np.where(np.all(flags[:, active_flags == 1] == 1, axis=1))[0] return good_times + @staticmethod + def apply_is_night_offsets( + flags: np.ndarray, + is_night_idx: int, + sunrise_offset: int, + sunset_offset: int, + ) -> np.ndarray: + """ + Apply sunrise/sunset offsets to is_night transitions. + + Per algorithm doc v4.4.7, Sec. 3.9.1, item 2 (raw is_night: 1=night, 0=day): + + sunset_offset applies at both transitions: + >0: night shortens by N at each end (first N night epochs at sunset become + day; last N night epochs before sunrise become day) + <0: night extends by |N| at each end + + sunrise_offset is an additional adjustment at sunrise (is_night 1->0) only: + >0: night extends N histograms past the raw sunrise transition + <0: night shortens by |N| before the raw sunrise transition + + In the processed flags array: 0 = bad (night), 1 = good (day). + + Parameters + ---------- + flags : numpy.ndarray + Flags array with shape (n_epochs, FLAG_LENGTH), 0=bad, 1=good. + is_night_idx : int + Column index of the is_night flag in the flags array. + sunrise_offset : int + Additional histogram shift at the sunrise (is_night 1->0) transition. + sunset_offset : int + Histogram shift applied at both the sunset and sunrise transitions. + + Returns + ------- + numpy.ndarray + Returns the original flags array if no offsets are applied, + otherwise returns a modified copy. + + Notes + ----- + Algorithm doc v4.4.7, Sec. 3.9.1, item 2 + is_night: 1 = daytime (good), 0 = night (bad) + """ + # If sunrise_offset=0 and sunset_offset=0 then no corrections are needed + # relative to is_night transition set onboard. + if sunrise_offset == 0 and sunset_offset == 0: + return flags + + flags_with_offsets = flags.copy() + + is_night_col = flags[:, is_night_idx] + n = flags.shape[0] + diff = np.diff(is_night_col.astype(int)) + sunset_index = np.where(diff == -1)[0] + sunrise_index = np.where(diff == 1)[0] + + if sunrise_offset > 0: + # Night (flag = 0) extends by sunrise_offset relative + # to is_night 0 -> 1 transition. + for i in sunrise_index: + flags_with_offsets[ + i + 1 : min(n, i + 1 + sunrise_offset), is_night_idx + ] = 0 + + elif sunrise_offset < 0: + # Night (flag = 0) shortens by sunrise_offset relative + # to is_night 0 -> 1 transition. + for i in sunrise_index: + flags_with_offsets[ + max(0, i + 1 + sunrise_offset) : i + 1, is_night_idx + ] = 1 + + if sunset_offset > 0: + # Night (flag = 0) shortens by sunset_offset relative + # to is_night 1 -> 0 transition. + for i in sunset_index: + flags_with_offsets[ + i + 1 : min(n, i + 1 + sunset_offset), is_night_idx + ] = 1 + + elif sunset_offset < 0: + # Night (flag = 0) extends by sunset_offset relative + # to is_night 1 -> 0 transition. + for i in sunset_index: + flags_with_offsets[ + max(0, i + 1 + sunset_offset) : i + 1, is_night_idx + ] = 0 + + return flags_with_offsets + def compute_position_angle(self) -> float: """ Compute the position angle based on the instrument mounting. diff --git a/imap_processing/glows/utils/constants.py b/imap_processing/glows/utils/constants.py index 13821f70a..08714e363 100644 --- a/imap_processing/glows/utils/constants.py +++ b/imap_processing/glows/utils/constants.py @@ -65,12 +65,15 @@ class GlowsConstants: Fill value for histogram bins (65535 for uint16) STANDARD_BIN_COUNT: int Standard number of bins per histogram (3600) + IS_NIGHT_FLAG_IDX: int + Index of the is_night flag in the bad-time flags array (0-indexed) """ SUBSECOND_LIMIT: int = 2_000_000 SCAN_CIRCLE_ANGULAR_RADIUS: float = 75.0 HISTOGRAM_FILLVAL: int = 65535 STANDARD_BIN_COUNT: int = 3600 + IS_NIGHT_FLAG_IDX: int = 6 @dataclass diff --git a/imap_processing/tests/glows/test_glows_l2_data.py b/imap_processing/tests/glows/test_glows_l2_data.py index 1a9b27656..7937b46e9 100644 --- a/imap_processing/tests/glows/test_glows_l2_data.py +++ b/imap_processing/tests/glows/test_glows_l2_data.py @@ -346,6 +346,47 @@ def test_filter_good_times(): assert np.array_equal(good_times, expected_good_times) +@pytest.mark.parametrize( + "sunrise_offset, sunset_offset, expected_is_night", + [ + # sunrise>0 extends at sunrise; sunset>0 shortens at sunset + (1, 1, [1, 1, 1, 1, 0, 0, 0, 1]), + # sunrise<0 shortens at sunrise; sunset>0 shortens at sunset + (-1, 1, [1, 1, 1, 1, 0, 1, 1, 1]), + # sunrise>0 extends at sunrise; sunset<0 extends at sunset + (1, -1, [1, 1, 0, 0, 0, 0, 0, 1]), + # sunrise<0 shortens at sunrise; sunset<0 extends at sunset + (-1, -1, [1, 1, 0, 0, 0, 1, 1, 1]), + # zero offsets: no change + (0, 0, [1, 1, 1, 0, 0, 0, 1, 1]), + ], +) +def test_apply_is_night_offsets(sunrise_offset, sunset_offset, expected_is_night): + """Test apply_is_night_offsets function.""" + + # Setup: epochs 0-2 day, 3-5 night, 6-7 day (processed flags: 0=night, 1=day). + flags = np.ones((8, 17), dtype=float) + flags[3:6, 6] = 0 # epochs 3-5 are night + original_flags = flags.copy() + + result = HistogramL2.apply_is_night_offsets( + flags, + is_night_idx=6, + sunrise_offset=sunrise_offset, + sunset_offset=sunset_offset, + ) + + assert np.array_equal(result[:, 6], np.array(expected_is_night, dtype=float)) + + if sunrise_offset == 0 and sunset_offset == 0: + # No offsets: original array returned as-is (no copy) + assert result is flags + else: + # Offsets applied: result is a copy, original flags are unchanged + assert result is not flags + assert np.array_equal(flags, original_flags) + + # ── spin_angle tests ────────────────────────────────────────────────────────── @@ -396,7 +437,8 @@ def test_compute_position_angle(): def l1b_dataset_full(): """Minimal L1B dataset with all variables required by HistogramL2. - Two epochs, four bins, 17 flags (all good). + Two epochs, four bins, 17 flags. Both epochs are daytime (is_night=1). + All other flags are 1 (good). """ n_epochs, n_bins, n_angle_flags, n_time_flags = 2, 4, 4, 17 fillval = GlowsConstants.HISTOGRAM_FILLVAL @@ -406,6 +448,9 @@ def l1b_dataset_full(): spin_angle = np.tile(np.linspace(0, 270, n_bins), (n_epochs, 1)) histogram_flag_array = np.zeros((n_epochs, n_angle_flags, n_bins), dtype=np.uint8) + # All flags good (1). Index 6 is is_night: 1 = daytime (good). + flags = np.ones((n_epochs, n_time_flags), dtype=float) + return xr.Dataset( { "histogram": (["epoch", "bins"], histogram), @@ -417,10 +462,7 @@ def l1b_dataset_full(): histogram_flag_array, ), "number_of_bins_per_histogram": (["epoch"], [n_bins, n_bins]), - "flags": ( - ["epoch", "flag_index"], - np.ones((n_epochs, n_time_flags)), - ), + "flags": (["epoch", "flag_index"], flags), "filter_temperature_average": (["epoch"], [20.0, 21.0]), "hv_voltage_average": (["epoch"], [1000.0, 1000.0]), "pulse_length_average": (["epoch"], [5.0, 5.0]),