Skip to content

Commit a7ac67c

Browse files
authored
Update hi l2 systematic uncertainty calculation (#3287)
* Update hi l2 systematic uncertainty calculation * Fix test broken by updates by doing additional mocking * Fix location of where map systematic err is calculated
1 parent 1bb13aa commit a7ac67c

2 files changed

Lines changed: 98 additions & 13 deletions

File tree

imap_processing/hi/hi_l2.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,16 @@
2929
"counts",
3030
"exposure_factor",
3131
"bg_rate",
32+
"bg_rate_sys_err",
3233
"obs_date",
3334
}
3435
HELIO_FRAME_VARS_TO_PROJECT = SC_FRAME_VARS_TO_PROJECT | {"energy_sc"}
3536
# TODO: is an exposure time weighted average for obs_date appropriate?
36-
FULL_EXPOSURE_TIME_AVERAGE_SET = {"bg_rate", "obs_date", "energy_sc"}
37+
FULL_EXPOSURE_TIME_AVERAGE_SET = {"bg_rate", "bg_rate_sys_err", "obs_date", "energy_sc"}
38+
39+
# Calibration systematic uncertainty as a fraction of intensity.
40+
# This represents the current calibration uncertainty (22%).
41+
CALIBRATION_UNCERTAINTY_FRACTION = 0.22
3742

3843

3944
# =============================================================================
@@ -373,7 +378,14 @@ def calculate_all_rates_and_intensities(
373378
# Drop any esa_energy_step_label that may have been re-added
374379
map_ds = map_ds.drop_vars(["esa_energy_step_label"], errors="ignore")
375380

376-
# Step 6: Clean up intermediate variables
381+
# Step 6: Add calibration systematic uncertainty in quadrature with the
382+
# background-associated systematic. This is a percentage of the intensity.
383+
logger.debug("Adding calibration systematic uncertainty")
384+
bg_sys_err = map_ds["ena_intensity_sys_err"]
385+
calib_sys_err = CALIBRATION_UNCERTAINTY_FRACTION * map_ds["ena_intensity"]
386+
map_ds["ena_intensity_sys_err"] = np.sqrt(bg_sys_err**2 + calib_sys_err**2)
387+
388+
# Step 7: Clean up intermediate variables
377389
map_ds = cleanup_intermediate_variables(map_ds)
378390

379391
return map_ds
@@ -464,14 +476,11 @@ def calculate_ena_intensity(
464476
map_ds["ena_signal_rate_stat_unc"] / flux_conversion_divisor
465477
)
466478

467-
# Ignore numpy divide by zero and zero/zero warnings. Setting pixels with
468-
# zero exposure time to NaN is the correct behavior.
469-
with np.errstate(divide="ignore", invalid="ignore"):
470-
map_ds["ena_intensity_sys_err"] = (
471-
np.sqrt(map_ds["bg_rate"] * map_ds["exposure_factor"])
472-
/ map_ds["exposure_factor"]
473-
/ flux_conversion_divisor
474-
)
479+
# Convert the exposure-time weighted average of background rate systematic
480+
# uncertainty from rate to intensity units.
481+
map_ds["ena_intensity_sys_err"] = (
482+
map_ds["bg_rate_sys_err"] / flux_conversion_divisor
483+
)
475484

476485
# Combine calibration products using proper weighted averaging
477486
# as described in Hi Algorithm Document Section 3.1.2
@@ -775,6 +784,7 @@ def cleanup_intermediate_variables(dataset: xr.Dataset) -> xr.Dataset:
775784
# Remove the intermediate variables from the map
776785
potential_vars = [
777786
"bg_rate",
787+
"bg_rate_sys_err",
778788
"energy_sc",
779789
"ena_signal_rates",
780790
"ena_signal_rate_stat_unc",

imap_processing/tests/hi/test_hi_l2.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from imap_processing.ena_maps.ena_maps import HealpixSkyMap, RectangularSkyMap
1212
from imap_processing.ena_maps.utils.naming import MapDescriptor
1313
from imap_processing.hi.hi_l2 import (
14+
CALIBRATION_UNCERTAINTY_FRACTION,
1415
_calculate_improved_stat_variance,
1516
calculate_all_rates_and_intensities,
1617
calculate_ena_intensity,
@@ -184,7 +185,6 @@ def test_hi_l2(
184185
write_cdf(l2_dataset, istp=True)
185186

186187

187-
@pytest.mark.external_test_data
188188
@patch(
189189
"imap_processing.ena_maps.ena_maps.RectangularSkyMap.build_cdf_dataset",
190190
autospec=True,
@@ -195,11 +195,12 @@ def test_hi_l2_uses_descriptor_to_setup_map(
195195
mock_create_sky_map_from_psets,
196196
mock_calculate_all_rates_and_intensities,
197197
mock_map_build_cdf_dataset,
198-
hi_l1_test_data_path,
199198
):
200-
pset_path = hi_l1_test_data_path / "imap_hi_l1c_45sensor-pset_20250415_v999.cdf"
199+
"""Test that hi_l2 uses the descriptor to set up the map correctly."""
200+
pset_path = "fake_pset.cdf" # Not used due to mocking
201201
descriptor_str = "h90-ena-h-sf-nsp-full-hnu-2deg-3mo"
202202
rect_map = MapDescriptor.from_string(descriptor_str).to_empty_map()
203+
203204
# create_sky_map_from_psets returns a dict with spin_phase key
204205
mock_create_sky_map_from_psets.return_value = {"full": rect_map}
205206
# calculate_all_rates_and_intensities modifies and returns the map data
@@ -1236,6 +1237,7 @@ def test_cleanup_intermediate_variables():
12361237
ds = xr.Dataset(
12371238
{
12381239
"bg_rate": xr.DataArray([1, 2, 3], dims=["x"]),
1240+
"bg_rate_sys_err": xr.DataArray([0.1, 0.2, 0.3], dims=["x"]),
12391241
"energy_sc": xr.DataArray([4, 5, 6], dims=["x"]),
12401242
"ena_signal_rates": xr.DataArray([7, 8, 9], dims=["x"]),
12411243
"ena_signal_rate_stat_unc": xr.DataArray([0.1, 0.2, 0.3], dims=["x"]),
@@ -1248,6 +1250,7 @@ def test_cleanup_intermediate_variables():
12481250

12491251
# Intermediate variables should be removed
12501252
assert "bg_rate" not in result
1253+
assert "bg_rate_sys_err" not in result
12511254
assert "energy_sc" not in result
12521255
assert "ena_signal_rates" not in result
12531256
assert "ena_signal_rate_stat_unc" not in result
@@ -1664,3 +1667,75 @@ def test_combine_maps_handles_nan_sys_err(mock_sky_map_for_combine):
16641667
expected_sys_err_valid,
16651668
decimal=10,
16661669
)
1670+
1671+
1672+
# =============================================================================
1673+
# SYSTEMATIC UNCERTAINTY ALGORITHM TESTS
1674+
# =============================================================================
1675+
1676+
1677+
def test_calculate_ena_intensity_uses_bg_rate_sys_err(
1678+
ena_intensity_map_ds, anc_path_dict
1679+
):
1680+
"""Test that calculate_ena_intensity uses bg_rate_sys_err field.
1681+
1682+
The systematic uncertainty should be computed from the exposure-time
1683+
weighted average of bg_rate_sys_err, converted from rate to intensity.
1684+
This replaces the old algorithm that computed sqrt(bg_counts)/exposure.
1685+
"""
1686+
descriptor_str = "h90-ena-h-sf-nsp-full-gcs-6deg-3mo"
1687+
map_descriptor = MapDescriptor.from_string(descriptor_str)
1688+
1689+
result_ds = calculate_ena_intensity(
1690+
ena_intensity_map_ds, anc_path_dict, map_descriptor
1691+
)
1692+
1693+
# Verify ena_intensity_sys_err was calculated
1694+
assert "ena_intensity_sys_err" in result_ds
1695+
1696+
# The sys_err should be based on bg_rate_sys_err / (geometric_factor * energy)
1697+
# After combine_calibration_products, it's combined in quadrature across cal prods
1698+
# We just verify it's finite and positive where expected
1699+
sys_err = result_ds["ena_intensity_sys_err"]
1700+
assert np.all(sys_err.values[np.isfinite(sys_err.values)] >= 0)
1701+
1702+
1703+
def test_calculate_all_rates_adds_calibration_systematic(
1704+
mock_map_dataset_for_rates, anc_path_dict
1705+
):
1706+
"""Test that calculate_all_rates_and_intensities adds calibration systematic.
1707+
1708+
The final systematic uncertainty should include both:
1709+
1. Background-associated systematic (from bg_rate_sys_err)
1710+
2. Calibration systematic (22% of intensity)
1711+
1712+
These should be combined in quadrature.
1713+
"""
1714+
descriptor = MapDescriptor.from_string("h90-ena-h-sf-nsp-full-gcs-6deg-3mo")
1715+
1716+
result_ds = calculate_all_rates_and_intensities(
1717+
mock_map_dataset_for_rates,
1718+
anc_path_dict,
1719+
descriptor,
1720+
)
1721+
1722+
# Verify ena_intensity_sys_err includes calibration systematic
1723+
assert "ena_intensity_sys_err" in result_ds
1724+
1725+
# The sys_err should be larger than CALIBRATION_UNCERTAINTY_FRACTION * intensity
1726+
# because it includes both bg systematic and calibration systematic in quadrature
1727+
intensity = result_ds["ena_intensity"]
1728+
sys_err = result_ds["ena_intensity_sys_err"]
1729+
1730+
# Minimum expected sys_err is 22% of intensity (if bg systematic were zero)
1731+
min_expected = CALIBRATION_UNCERTAINTY_FRACTION * np.abs(intensity)
1732+
1733+
# Where both values are finite and intensity > 0, sys_err should be >= min_expected
1734+
valid_mask = np.isfinite(sys_err.values) & np.isfinite(intensity.values)
1735+
valid_mask &= intensity.values > 0
1736+
if np.any(valid_mask):
1737+
np.testing.assert_array_less(
1738+
min_expected.values[valid_mask] - 1e-10, # small tolerance
1739+
sys_err.values[valid_mask],
1740+
err_msg="Sys err should include calibration systematic (22% of intensity)",
1741+
)

0 commit comments

Comments
 (0)