From f04b7a9d6f147b9505bc34b5f4ca6eaad6a025b8 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Mon, 27 Apr 2026 16:58:18 -0600 Subject: [PATCH 1/9] Add function for getting full logical source description --- imap_processing/ena_maps/utils/naming.py | 287 ++++++++++++++++-- imap_processing/tests/ena_maps/test_naming.py | 56 ++++ 2 files changed, 317 insertions(+), 26 deletions(-) diff --git a/imap_processing/ena_maps/utils/naming.py b/imap_processing/ena_maps/utils/naming.py index 191239d2c..340480d1f 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -231,51 +231,240 @@ def build_map_var_catdesc(self, support_var_name: str) -> str | None: else: return None - def build_catdesc(self, quantity_text: str) -> str: + + def _get_instrument_str(self, full: bool = False) -> str: """ - Convert the MapDescriptor instance to a human-readable CATDESC string. + Get formatted instrument name string. Parameters ---------- - quantity_text : str - Text describing the variable that will be formatted into the CATDESC. + full : bool, optional + If True, return full format (e.g., "IMAP-Hi"). + If False, return short format (e.g., "Hi"). Default is False. Returns ------- str - Information in descriptor converted to SPDF CATDESC attribute. This - is normally used for plot titles and should be under about 80 characters. + Formatted instrument name. + """ + instrument_base = self.instrument.name.split("_")[0] + if instrument_base in ("IDEX", "GLOWS"): + return f"IMAP-{instrument_base}" if full else instrument_base + return f"IMAP-{instrument_base.title()}" if full else instrument_base.title() + + def _get_sensor_str(self, full: bool = False) -> str: + """ + Get formatted sensor string. + + Parameters + ---------- + full : bool, optional + If True, return full format (e.g., "45 degree sensor"). + If False, return short format (e.g., "45"). Default is False. + + Returns + ------- + str + Formatted sensor string. + """ + if self.sensor == "combined": + return "combined sensor" if full else " Combined" + elif self.sensor in ("45", "90"): + return f"{self.sensor} degree sensor" if full else str(self.sensor) + elif self.sensor: + return f"sensor {self.sensor}" if full else str(self.sensor) + return "" + + def _get_species_str(self, full: bool = False) -> str: + """ + Get formatted species string. + + Parameters + ---------- + full : bool, optional + If True, return full format (e.g., "Hydrogen"). + If False, return short format (e.g., "H"). Default is False. + + Returns + ------- + str + Formatted species string. + """ + if full: + species_names = { + "h": "Hydrogen", + "he": "Helium", + "o": "Oxygen", + "uv": "UV", + } + return species_names.get(self.species.lower(), self.species.title()) + return "UV" if self.species == "uv" else self.species.title() + + def _parse_principal_data(self) -> tuple[str, str]: + """ + Parse principal_data and return (data_type, extras) tuple. + + Returns + ------- + tuple[str, str] + A tuple of (data_type, extras) parsed from principal_data. """ - instrument = self.instrument.name.split("_")[0] - if instrument not in ("IDEX", "GLOWS"): - instrument = instrument.title() - sensor = " Combined" if self.sensor == "combined" else self.sensor - species = "UV" if self.species == "uv" else self.species.title() m = re.match( r"^(drt|ena|int|isn|spx)(?:(?<=spx)\d+)?([^-_\s]*)$", self.principal_data ) - if m.group(1) == "isn": - species = "ISN " + species - extras = m.group(2) - coord = self.coordinate_system.upper() - frame = { + return m.group(1), m.group(2) + + def _get_frame_str(self, full: bool = False) -> str: + """ + Get formatted frame string. + + Parameters + ---------- + full : bool, optional + If True, return full format (e.g., "heliospheric"). + If False, return short format (e.g., "Helio"). Default is False. + + Returns + ------- + str + Formatted frame string. + """ + if full: + return INERTIAL_FRAME_LONG_NAMES[self.frame_descriptor] + return { "hf": "Helio", "hk": "Helio Kin", "sf": "SC", }[self.frame_descriptor] - survival = "Surv Corr" if self.survival_corrected == "sp" else "No Surv Corr" + + def _get_survival_str(self, full: bool = False) -> str: + """ + Get formatted survival correction string. + + Parameters + ---------- + full : bool, optional + If True, return full format (e.g., "with survival probability correction"). + If False, return short format (e.g., "Surv Corr"). Default is False. + + Returns + ------- + str + Formatted survival correction string. + """ + if full: + if self.survival_corrected == "sp": + return "with survival probability correction" + return "with no survival correction" + return "Surv Corr" if self.survival_corrected == "sp" else "No Surv Corr" + + def _get_spin_phase_str(self, full: bool = False) -> str: + """ + Get formatted spin phase string. + + Parameters + ---------- + full : bool, optional + If True, return full format (e.g., "full spin"). + If False, return short format (e.g., "Full Spin"). Default is False. + + Returns + ------- + str + Formatted spin phase string. + """ + if full: + return { + "full": "full spin", + "ram": "ram", + "anti": "anti-ram", + }.get(self.spin_phase.lower(), self.spin_phase) spin_phase = self.spin_phase.title() - if spin_phase == "Full": - spin_phase = "Full Spin" + return "Full Spin" if spin_phase == "Full" else spin_phase + + def _get_resolution_str(self, full: bool = False) -> str: + """ + Get formatted resolution string. + + Parameters + ---------- + full : bool, optional + If True, return full format (e.g., "rectangular 2 degree"). + If False, return short format (e.g., "2 deg"). Default is False. + + Returns + ------- + str + Formatted resolution string. + """ m = re.match(r"^(\d+)deg|nside(\d+)", self.resolution_str) - resolution = f"{m.group(1)} deg" if m.group(1) else f"NSide {m.group(2)}" + if full: + if m.group(1): + return f"rectangular {m.group(1)} degree" + return f"HEALPix nside {m.group(2)}" + return f"{m.group(1)} deg" if m.group(1) else f"NSide {m.group(2)}" + + def _get_duration_str(self, full: bool = False) -> str: + """ + Get formatted duration string. + + Parameters + ---------- + full : bool, optional + If True, return full format (e.g., "6 months"). + If False, return short format (e.g., "6 Mon"). Default is False. + + Returns + ------- + str + Formatted duration string. + """ if isinstance(self.duration, int): - duration = f"{self.duration} Day" - else: - m = re.match(r"^(\d+)(.*)$", self.duration) - duration = f"{m.group(1)} {m.group(2).title()}" - if duration.endswith("Mo"): - duration += "n" + return f"{self.duration} days" if full else f"{self.duration} Day" + + m = re.match(r"^(\d+)(.*)$", self.duration) + num = int(m.group(1)) + unit = m.group(2).lower() + + if full: + if unit == "yr": + return f"{num} year" if num == 1 else f"{num} years" + elif unit == "mo": + return f"{num} month" if num == 1 else f"{num} months" + return f"{num} {unit}" + + duration = f"{num} {m.group(2).title()}" + return duration + "n" if duration.endswith("Mo") else duration + + + def build_catdesc(self, quantity_text: str) -> str: + """ + Convert the MapDescriptor instance to a human-readable CATDESC string. + + Parameters + ---------- + quantity_text : str + Text describing the variable that will be formatted into the CATDESC. + + Returns + ------- + str + Information in descriptor converted to SPDF CATDESC attribute. This + is normally used for plot titles and should be under about 80 characters. + """ + instrument = self._get_instrument_str(full=False) + sensor = self._get_sensor_str(full=False) + species = self._get_species_str(full=False) + data_type, extras = self._parse_principal_data() + if data_type == "isn": + species = "ISN " + species + coord = self.coordinate_system.upper() + frame = self._get_frame_str(full=False) + survival = self._get_survival_str(full=False) + spin_phase = self._get_spin_phase_str(full=False) + resolution = self._get_resolution_str(full=False) + duration = self._get_duration_str(full=False) + catdesc = ( f"IMAP {instrument}{sensor} {species} {quantity_text}, {coord} " f"{frame} Frame, {survival}, {spin_phase}, {resolution}, {duration}" @@ -290,6 +479,52 @@ def build_catdesc(self, quantity_text: str) -> str: break return catdesc + def to_logical_source_description(self) -> str: + """ + Convert the MapDescriptor instance to a Logical_source_description string. + + Returns + ------- + str + A full description suitable for the Logical_source_description + global CDF attribute. + """ + instrument = self._get_instrument_str(full=True) + sensor = self._get_sensor_str(full=True) + species = self._get_species_str(full=True) + data_type, _ = self._parse_principal_data() + quantity = { + "drt": "Dust Rate", + "ena": "ENA Intensity", + "int": "Intensity", + "isn": "Rate", + "spx": "Spectral Index", + }[data_type] + + # Handle special species cases + if data_type == "isn": + species = f"Interstellar Neutral {species}" + elif data_type == "drt": + # Dust rate maps don't have a species + species = "" + + frame = self._get_frame_str(full=True) + survival = self._get_survival_str(full=True) + spin_phase = self._get_spin_phase_str(full=True) + duration = self._get_duration_str(full=True) + resolution = self._get_resolution_str(full=True) + + # Build the full description + sensor_part = f" {sensor}" if sensor else "" + species_part = f"{species} " if species else "" + description = ( + f"{instrument} Instrument Level-2{sensor_part} map of {species_part}" + f"{quantity} in the {frame} frame {survival} in the " + f"{spin_phase} direction over {duration} on {resolution} tiling." + ) + + return description + @property def principal_data_var(self) -> str: """ diff --git a/imap_processing/tests/ena_maps/test_naming.py b/imap_processing/tests/ena_maps/test_naming.py index cc32debf5..d54b6f369 100644 --- a/imap_processing/tests/ena_maps/test_naming.py +++ b/imap_processing/tests/ena_maps/test_naming.py @@ -505,3 +505,59 @@ def test_build_map_var_catdesc_more_vars( def test_principal_data_var(self, descriptor_str, expected_principal_data_var): md = MapDescriptor.from_string(descriptor_str) assert md.principal_data_var == expected_principal_data_var + + @pytest.mark.parametrize( + "descriptor_str, expected_description", + [ + ( + "h45-ena-h-hf-nsp-full-hae-2deg-6mo", + "IMAP-Hi Instrument Level-2 45 degree sensor map of Hydrogen " + "ENA Intensity in the heliospheric frame with no survival correction " + "in the full spin direction over 6 months on rectangular 2 degree " + "tiling.", + ), + ( + "hic-ena-h-hf-sp-ram-hae-nside64-1yr", + "IMAP-Hi Instrument Level-2 combined sensor map of Hydrogen " + "ENA Intensity in the heliospheric frame with survival probability " + "correction in the ram direction over 1 year on HEALPix nside 64 " + "tiling.", + ), + ( + "u90-ena-h-hf-nsp-full-hae-nside128-6mo", + "IMAP-Ultra Instrument Level-2 90 degree sensor map of Hydrogen " + "ENA Intensity in the heliospheric frame with no survival correction " + "in the full spin direction over 6 months on HEALPix nside 128 tiling.", + ), + ( + "ilo-isn-h-sf-nsp-ram-hae-2deg-3mo", + "IMAP-Lo Instrument Level-2 map of Interstellar Neutral Hydrogen " + "Rate in the spacecraft frame with no survival correction " + "in the ram direction over 3 months on rectangular 2 degree tiling.", + ), + ( + "glx-int-uv-hf-nsp-full-hae-2deg-6mo", + "IMAP-GLOWS Instrument Level-2 map of UV Intensity " + "in the heliospheric frame with no survival correction " + "in the full spin direction over 6 months on rectangular 2 degree " + "tiling.", + ), + ( + "idx-drt-dust-hf-nsp-full-hae-nside32-1yr", + "IMAP-IDEX Instrument Level-2 map of Dust Rate " + "in the heliospheric frame with no survival correction " + "in the full spin direction over 1 year on HEALPix nside 32 tiling.", + ), + ( + "u45-ena-he-hk-sp-anti-hae-4deg-2mo", + "IMAP-Ultra Instrument Level-2 45 degree sensor map of Helium " + "ENA Intensity in the heliocentric kinetic frame with survival " + "probability correction in the anti-ram direction over 2 months " + "on rectangular 4 degree tiling.", + ), + ], + ) + def test_to_logical_source_description(self, descriptor_str, expected_description): + md = MapDescriptor.from_string(descriptor_str) + actual_description = md.to_logical_source_description() + assert actual_description == expected_description From c07915a2de474e258a84f24e7ec9728b536c43c8 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 09:55:09 -0600 Subject: [PATCH 2/9] Use new logical_source_description function to populate ENA map attribute --- imap_processing/ena_maps/ena_maps.py | 5 ++++- imap_processing/ultra/l2/ultra_l2.py | 22 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/imap_processing/ena_maps/ena_maps.py b/imap_processing/ena_maps/ena_maps.py index e2416542f..e4658501a 100644 --- a/imap_processing/ena_maps/ena_maps.py +++ b/imap_processing/ena_maps/ena_maps.py @@ -1498,11 +1498,14 @@ def build_cdf_dataset( # noqa: PLR0912 # Now set global attributes map_attrs = cdf_attrs.get_global_attributes(f"imap_{instrument}_{level}_enamap") map_attrs["Spacing_degrees"] = str(self.spacing_deg) - for key in ["Data_type", "Logical_source", "Logical_source_description"]: + for key in ["Data_type", "Logical_source"]: map_attrs[key] = map_attrs[key].format( descriptor=descriptor, sensor=sensor, ) + # Use the MapDescriptor to generate the Logical_source_description + md = naming.MapDescriptor.from_string(descriptor) + map_attrs["Logical_source_description"] = md.to_logical_source_description() # Always add the following attributes to the map map_attrs.update( { diff --git a/imap_processing/ultra/l2/ultra_l2.py b/imap_processing/ultra/l2/ultra_l2.py index 2bdce61d8..e93b12e69 100644 --- a/imap_processing/ultra/l2/ultra_l2.py +++ b/imap_processing/ultra/l2/ultra_l2.py @@ -861,7 +861,7 @@ def ultra_l2( # Get the global attributes, and then fill the sensor, tiling, etc. in the # format-able strings. map_attrs.update(cdf_attrs.get_global_attributes("imap_ultra_l2_enamap")) - for key in ["Data_type", "Logical_source", "Logical_source_description"]: + for key in ["Data_type", "Logical_source"]: map_attrs[key] = map_attrs[key].format( sensor=ultra_sensor_number, tiling=output_map_structure.tiling_type.value.lower(), @@ -875,6 +875,26 @@ def ultra_l2( else f"nside{output_map_structure.nside}" ), inertial_frame_short_name=inertial_frame, + ) + # Use the MapDescriptor to generate the Logical_source_description + if descriptor is not None: + md = MapDescriptor.from_string(descriptor) + map_attrs["Logical_source_description"] = md.to_logical_source_description() + else: + map_attrs["Logical_source_description"] = map_attrs[ + "Logical_source_description" + ].format( + sensor=ultra_sensor_number, + tiling=output_map_structure.tiling_type.value.lower(), + duration=map_duration, + resolution_string=( + f"{output_map_structure.spacing_deg:.0f}deg" + if ( + output_map_structure.tiling_type + is ena_maps.SkyTilingType.RECTANGULAR + ) + else f"nside{output_map_structure.nside}" + ), inertial_frame_long_name=inertial_frame_long_name, ) From 7f415a87432e20ab4c278f7fdc1cc4bf7f82b4d0 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 10:05:29 -0600 Subject: [PATCH 3/9] Remove some private functions that don't make sense --- imap_processing/ena_maps/utils/naming.py | 95 ++++++++++++++++++++---- 1 file changed, 82 insertions(+), 13 deletions(-) diff --git a/imap_processing/ena_maps/utils/naming.py b/imap_processing/ena_maps/utils/naming.py index 340480d1f..ed1fc30b6 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -452,16 +452,51 @@ def build_catdesc(self, quantity_text: str) -> str: Information in descriptor converted to SPDF CATDESC attribute. This is normally used for plot titles and should be under about 80 characters. """ - instrument = self._get_instrument_str(full=False) - sensor = self._get_sensor_str(full=False) - species = self._get_species_str(full=False) + # Instrument name (e.g., "Hi", "Ultra", "GLOWS") + instrument_base = self.instrument.name.split("_")[0] + instrument = ( + instrument_base + if instrument_base in ("IDEX", "GLOWS") + else instrument_base.title() + ) + + # Sensor (e.g., " Combined", "45", "") + if self.sensor == "combined": + sensor = " Combined" + elif self.sensor: + sensor = str(self.sensor) + else: + sensor = "" + + # Species (e.g., "H", "He", "UV") + species = "UV" if self.species == "uv" else self.species.title() + data_type, extras = self._parse_principal_data() + + # Quantity (e.g., "Inten", "Rate", "Spectral") + quantity = { + "drt": "Rate", + "ena": "Inten", + "int": "Inten", + "isn": "Rate", + "spx": "Spectral", + }[data_type] + if data_type == "isn": species = "ISN " + species + coord = self.coordinate_system.upper() - frame = self._get_frame_str(full=False) - survival = self._get_survival_str(full=False) - spin_phase = self._get_spin_phase_str(full=False) + + # Frame (e.g., "Helio", "SC", "Helio Kin") + frame = {"hf": "Helio", "hk": "Helio Kin", "sf": "SC"}[self.frame_descriptor] + + # Survival correction + survival = "Surv Corr" if self.survival_corrected == "sp" else "No Surv Corr" + + # Spin phase (e.g., "Full Spin", "Ram", "Anti") + spin_phase = self.spin_phase.title() + spin_phase = "Full Spin" if spin_phase == "Full" else spin_phase + resolution = self._get_resolution_str(full=False) duration = self._get_duration_str(full=False) @@ -489,17 +524,38 @@ def to_logical_source_description(self) -> str: A full description suitable for the Logical_source_description global CDF attribute. """ - instrument = self._get_instrument_str(full=True) - sensor = self._get_sensor_str(full=True) - species = self._get_species_str(full=True) + # Instrument name (e.g., "IMAP-Hi", "IMAP-Ultra", "IMAP-GLOWS") + instrument_base = self.instrument.name.split("_")[0] + instrument = ( + f"IMAP-{instrument_base}" + if instrument_base in ("IDEX", "GLOWS") + else f"IMAP-{instrument_base.title()}" + ) + + # Sensor (e.g., "45 degree sensor", "combined sensor", "") + if self.sensor == "combined": + sensor = "combined sensor" + elif self.sensor in ("45", "90"): + sensor = f"{self.sensor} degree sensor" + elif self.sensor: + sensor = f"sensor {self.sensor}" + else: + sensor = "" + + # Species (e.g., "Hydrogen", "Helium", "UV") + species_names = {"h": "Hydrogen", "he": "Helium", "o": "Oxygen", "uv": "UV"} + species = species_names.get(self.species.lower(), self.species.title()) + data_type, _ = self._parse_principal_data() + + # Quantity (e.g., "ENA Intensity", "Rate", "Dust Rate") quantity = { "drt": "Dust Rate", "ena": "ENA Intensity", "int": "Intensity", "isn": "Rate", "spx": "Spectral Index", - }[data_type] + }[data_type] # Handle special species cases if data_type == "isn": @@ -508,9 +564,22 @@ def to_logical_source_description(self) -> str: # Dust rate maps don't have a species species = "" - frame = self._get_frame_str(full=True) - survival = self._get_survival_str(full=True) - spin_phase = self._get_spin_phase_str(full=True) + # Frame (e.g., "heliospheric", "spacecraft", "heliocentric kinetic") + frame = INERTIAL_FRAME_LONG_NAMES[self.frame_descriptor] + + # Survival correction + if self.survival_corrected == "sp": + survival = "with survival probability correction" + else: + survival = "with no survival correction" + + # Spin phase (e.g., "full spin", "ram", "anti-ram") + spin_phase = { + "full": "full spin", + "ram": "ram", + "anti": "anti-ram", + }.get(self.spin_phase.lower(), self.spin_phase) + duration = self._get_duration_str(full=True) resolution = self._get_resolution_str(full=True) From f1d5192d8737ba52dfcc89982beb3c0ce4246140 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 10:10:06 -0600 Subject: [PATCH 4/9] Revert some catdesc changes --- imap_processing/ena_maps/utils/naming.py | 27 ++++-------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/imap_processing/ena_maps/utils/naming.py b/imap_processing/ena_maps/utils/naming.py index ed1fc30b6..64e53bc1e 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -452,23 +452,10 @@ def build_catdesc(self, quantity_text: str) -> str: Information in descriptor converted to SPDF CATDESC attribute. This is normally used for plot titles and should be under about 80 characters. """ - # Instrument name (e.g., "Hi", "Ultra", "GLOWS") - instrument_base = self.instrument.name.split("_")[0] - instrument = ( - instrument_base - if instrument_base in ("IDEX", "GLOWS") - else instrument_base.title() - ) - - # Sensor (e.g., " Combined", "45", "") - if self.sensor == "combined": - sensor = " Combined" - elif self.sensor: - sensor = str(self.sensor) - else: - sensor = "" - - # Species (e.g., "H", "He", "UV") + instrument = self.instrument.name.split("_")[0] + if instrument not in ("IDEX", "GLOWS"): + instrument = instrument.title() + sensor = " Combined" if self.sensor == "combined" else self.sensor species = "UV" if self.species == "uv" else self.species.title() data_type, extras = self._parse_principal_data() @@ -486,14 +473,8 @@ def build_catdesc(self, quantity_text: str) -> str: species = "ISN " + species coord = self.coordinate_system.upper() - - # Frame (e.g., "Helio", "SC", "Helio Kin") frame = {"hf": "Helio", "hk": "Helio Kin", "sf": "SC"}[self.frame_descriptor] - - # Survival correction survival = "Surv Corr" if self.survival_corrected == "sp" else "No Surv Corr" - - # Spin phase (e.g., "Full Spin", "Ram", "Anti") spin_phase = self.spin_phase.title() spin_phase = "Full Spin" if spin_phase == "Full" else spin_phase From bda391b0b47b052afe133152390ff5943500620f Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 12:01:34 -0600 Subject: [PATCH 5/9] Fix hi tests broken by updated logical source description --- imap_processing/tests/hi/test_hi_l2.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/imap_processing/tests/hi/test_hi_l2.py b/imap_processing/tests/hi/test_hi_l2.py index 42a274511..aa43cfd95 100644 --- a/imap_processing/tests/hi/test_hi_l2.py +++ b/imap_processing/tests/hi/test_hi_l2.py @@ -175,7 +175,9 @@ def test_hi_l2( # Check some global attributes assert l2_dataset.attrs["Data_type"].startswith(f"L2_{descriptor_str}") assert l2_dataset.attrs["Logical_source"] == f"imap_hi_l2_{descriptor_str}" - assert "Hi90" in l2_dataset.attrs["Logical_source_description"] + assert l2_dataset.attrs["Logical_source_description"].startswith( + "IMAP-Hi Instrument Level-2" + ) assert len(l2_dataset.data_vars) == 16 np.testing.assert_array_equal( From 234c8cab957a7b97192c49f49bc7bbc925cc6a39 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 15:26:32 -0600 Subject: [PATCH 6/9] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- imap_processing/ena_maps/ena_maps.py | 17 +++++++++++++++-- imap_processing/ena_maps/utils/naming.py | 7 +++++++ imap_processing/ultra/l2/ultra_l2.py | 8 +++++--- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/imap_processing/ena_maps/ena_maps.py b/imap_processing/ena_maps/ena_maps.py index e4658501a..437c6310b 100644 --- a/imap_processing/ena_maps/ena_maps.py +++ b/imap_processing/ena_maps/ena_maps.py @@ -1504,8 +1504,21 @@ def build_cdf_dataset( # noqa: PLR0912 sensor=sensor, ) # Use the MapDescriptor to generate the Logical_source_description - md = naming.MapDescriptor.from_string(descriptor) - map_attrs["Logical_source_description"] = md.to_logical_source_description() + # when possible, but preserve previous behavior for non-descriptor + # strings so later validation still raises the intended errors. + try: + md = naming.MapDescriptor.from_string(descriptor) + except ValueError: + map_attrs["Logical_source_description"] = map_attrs[ + "Logical_source_description" + ].format( + descriptor=descriptor, + sensor=sensor, + ) + else: + map_attrs["Logical_source_description"] = ( + md.to_logical_source_description() + ) # Always add the following attributes to the map map_attrs.update( { diff --git a/imap_processing/ena_maps/utils/naming.py b/imap_processing/ena_maps/utils/naming.py index 64e53bc1e..370e9f7b1 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -312,6 +312,13 @@ def _parse_principal_data(self) -> tuple[str, str]: m = re.match( r"^(drt|ena|int|isn|spx)(?:(?<=spx)\d+)?([^-_\s]*)$", self.principal_data ) + if not m: + raise ValueError( + "Invalid principal_data format: " + f"{self.principal_data}. Expected one of 'drt', 'ena', 'int', " + "'isn', or 'spx' optionally followed by digits and trailing " + "non-separator text." + ) return m.group(1), m.group(2) def _get_frame_str(self, full: bool = False) -> str: diff --git a/imap_processing/ultra/l2/ultra_l2.py b/imap_processing/ultra/l2/ultra_l2.py index e93b12e69..eda170821 100644 --- a/imap_processing/ultra/l2/ultra_l2.py +++ b/imap_processing/ultra/l2/ultra_l2.py @@ -876,10 +876,12 @@ def ultra_l2( ), inertial_frame_short_name=inertial_frame, ) - # Use the MapDescriptor to generate the Logical_source_description + # Use the previously parsed MapDescriptor to generate the + # Logical_source_description if descriptor is not None: - md = MapDescriptor.from_string(descriptor) - map_attrs["Logical_source_description"] = md.to_logical_source_description() + map_attrs[ + "Logical_source_description" + ] = map_descriptor.to_logical_source_description() else: map_attrs["Logical_source_description"] = map_attrs[ "Logical_source_description" From 41e713ff05e8545807a5e19f59a2ff8d02764376 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 15:38:01 -0600 Subject: [PATCH 7/9] Adjustments based on copilot feedback --- imap_processing/ena_maps/utils/naming.py | 13 +++++++- imap_processing/tests/ena_maps/test_naming.py | 31 ++++++++++--------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/imap_processing/ena_maps/utils/naming.py b/imap_processing/ena_maps/utils/naming.py index 370e9f7b1..b2fcec6ca 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -405,6 +405,11 @@ def _get_resolution_str(self, full: bool = False) -> str: Formatted resolution string. """ m = re.match(r"^(\d+)deg|nside(\d+)", self.resolution_str) + if not m: + raise ValueError( + f"Invalid resolution_str format: {self.resolution_str}. " + "Expected format like '2deg' or 'nside32'." + ) if full: if m.group(1): return f"rectangular {m.group(1)} degree" @@ -555,6 +560,9 @@ def to_logical_source_description(self) -> str: # Frame (e.g., "heliospheric", "spacecraft", "heliocentric kinetic") frame = INERTIAL_FRAME_LONG_NAMES[self.frame_descriptor] + # Coordinate system (e.g., "HAE", "GCS") + coord = self.coordinate_system.upper() + # Survival correction if self.survival_corrected == "sp": survival = "with survival probability correction" @@ -572,12 +580,15 @@ def to_logical_source_description(self) -> str: resolution = self._get_resolution_str(full=True) # Build the full description + # Order matches descriptor: instrument-sensor, quantity, species, frame, + # survival, spin_phase, coord, resolution, duration sensor_part = f" {sensor}" if sensor else "" species_part = f"{species} " if species else "" description = ( f"{instrument} Instrument Level-2{sensor_part} map of {species_part}" f"{quantity} in the {frame} frame {survival} in the " - f"{spin_phase} direction over {duration} on {resolution} tiling." + f"{spin_phase} direction in {coord} coordinates on {resolution} " + f"tiling over {duration}." ) return description diff --git a/imap_processing/tests/ena_maps/test_naming.py b/imap_processing/tests/ena_maps/test_naming.py index d54b6f369..6f39dd18f 100644 --- a/imap_processing/tests/ena_maps/test_naming.py +++ b/imap_processing/tests/ena_maps/test_naming.py @@ -512,48 +512,51 @@ def test_principal_data_var(self, descriptor_str, expected_principal_data_var): ( "h45-ena-h-hf-nsp-full-hae-2deg-6mo", "IMAP-Hi Instrument Level-2 45 degree sensor map of Hydrogen " - "ENA Intensity in the heliospheric frame with no survival correction " - "in the full spin direction over 6 months on rectangular 2 degree " - "tiling.", + "ENA Intensity in the heliospheric frame with no survival " + "correction in the full spin direction in HAE coordinates on " + "rectangular 2 degree tiling over 6 months.", ), ( "hic-ena-h-hf-sp-ram-hae-nside64-1yr", "IMAP-Hi Instrument Level-2 combined sensor map of Hydrogen " - "ENA Intensity in the heliospheric frame with survival probability " - "correction in the ram direction over 1 year on HEALPix nside 64 " - "tiling.", + "ENA Intensity in the heliospheric frame with survival " + "probability correction in the ram direction in HAE coordinates " + "on HEALPix nside 64 tiling over 1 year.", ), ( "u90-ena-h-hf-nsp-full-hae-nside128-6mo", "IMAP-Ultra Instrument Level-2 90 degree sensor map of Hydrogen " - "ENA Intensity in the heliospheric frame with no survival correction " - "in the full spin direction over 6 months on HEALPix nside 128 tiling.", + "ENA Intensity in the heliospheric frame with no survival " + "correction in the full spin direction in HAE coordinates on " + "HEALPix nside 128 tiling over 6 months.", ), ( "ilo-isn-h-sf-nsp-ram-hae-2deg-3mo", "IMAP-Lo Instrument Level-2 map of Interstellar Neutral Hydrogen " "Rate in the spacecraft frame with no survival correction " - "in the ram direction over 3 months on rectangular 2 degree tiling.", + "in the ram direction in HAE coordinates on rectangular 2 degree " + "tiling over 3 months.", ), ( "glx-int-uv-hf-nsp-full-hae-2deg-6mo", "IMAP-GLOWS Instrument Level-2 map of UV Intensity " "in the heliospheric frame with no survival correction " - "in the full spin direction over 6 months on rectangular 2 degree " - "tiling.", + "in the full spin direction in HAE coordinates on rectangular " + "2 degree tiling over 6 months.", ), ( "idx-drt-dust-hf-nsp-full-hae-nside32-1yr", "IMAP-IDEX Instrument Level-2 map of Dust Rate " "in the heliospheric frame with no survival correction " - "in the full spin direction over 1 year on HEALPix nside 32 tiling.", + "in the full spin direction in HAE coordinates on HEALPix " + "nside 32 tiling over 1 year.", ), ( "u45-ena-he-hk-sp-anti-hae-4deg-2mo", "IMAP-Ultra Instrument Level-2 45 degree sensor map of Helium " "ENA Intensity in the heliocentric kinetic frame with survival " - "probability correction in the anti-ram direction over 2 months " - "on rectangular 4 degree tiling.", + "probability correction in the anti-ram direction in HAE " + "coordinates on rectangular 4 degree tiling over 2 months.", ), ], ) From 75083eb6219d224d3b7cbbafbfdbebde66688d94 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:49:01 +0000 Subject: [PATCH 8/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- imap_processing/ena_maps/ena_maps.py | 4 +--- imap_processing/ultra/l2/ultra_l2.py | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/imap_processing/ena_maps/ena_maps.py b/imap_processing/ena_maps/ena_maps.py index 437c6310b..a31454f0b 100644 --- a/imap_processing/ena_maps/ena_maps.py +++ b/imap_processing/ena_maps/ena_maps.py @@ -1516,9 +1516,7 @@ def build_cdf_dataset( # noqa: PLR0912 sensor=sensor, ) else: - map_attrs["Logical_source_description"] = ( - md.to_logical_source_description() - ) + map_attrs["Logical_source_description"] = md.to_logical_source_description() # Always add the following attributes to the map map_attrs.update( { diff --git a/imap_processing/ultra/l2/ultra_l2.py b/imap_processing/ultra/l2/ultra_l2.py index eda170821..2080ad6aa 100644 --- a/imap_processing/ultra/l2/ultra_l2.py +++ b/imap_processing/ultra/l2/ultra_l2.py @@ -879,9 +879,9 @@ def ultra_l2( # Use the previously parsed MapDescriptor to generate the # Logical_source_description if descriptor is not None: - map_attrs[ - "Logical_source_description" - ] = map_descriptor.to_logical_source_description() + map_attrs["Logical_source_description"] = ( + map_descriptor.to_logical_source_description() + ) else: map_attrs["Logical_source_description"] = map_attrs[ "Logical_source_description" From 0bbf71c6d882d6cc3bc19cccc527d64b7e2020b9 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Mon, 13 Jul 2026 13:09:10 -0600 Subject: [PATCH 9/9] PR feedback --- imap_processing/ena_maps/utils/naming.py | 18 ++++-------------- imap_processing/tests/ena_maps/test_naming.py | 4 ++-- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/imap_processing/ena_maps/utils/naming.py b/imap_processing/ena_maps/utils/naming.py index b2fcec6ca..f883a962e 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -231,7 +231,6 @@ def build_map_var_catdesc(self, support_var_name: str) -> str | None: else: return None - def _get_instrument_str(self, full: bool = False) -> str: """ Get formatted instrument name string. @@ -308,6 +307,9 @@ def _parse_principal_data(self) -> tuple[str, str]: ------- tuple[str, str] A tuple of (data_type, extras) parsed from principal_data. + data_type is one of ["drt", "ena", "int", "isn", "spx"], + extras is any extension on the principal data portion of the descriptor + string. For example, "isnnbkgnd" would return ("isn", "nbkgnd"). """ m = re.match( r"^(drt|ena|int|isn|spx)(?:(?<=spx)\d+)?([^-_\s]*)$", self.principal_data @@ -448,7 +450,6 @@ def _get_duration_str(self, full: bool = False) -> str: duration = f"{num} {m.group(2).title()}" return duration + "n" if duration.endswith("Mo") else duration - def build_catdesc(self, quantity_text: str) -> str: """ Convert the MapDescriptor instance to a human-readable CATDESC string. @@ -472,15 +473,6 @@ def build_catdesc(self, quantity_text: str) -> str: data_type, extras = self._parse_principal_data() - # Quantity (e.g., "Inten", "Rate", "Spectral") - quantity = { - "drt": "Rate", - "ena": "Inten", - "int": "Inten", - "isn": "Rate", - "spx": "Spectral", - }[data_type] - if data_type == "isn": species = "ISN " + species @@ -520,7 +512,7 @@ def to_logical_source_description(self) -> str: # Instrument name (e.g., "IMAP-Hi", "IMAP-Ultra", "IMAP-GLOWS") instrument_base = self.instrument.name.split("_")[0] instrument = ( - f"IMAP-{instrument_base}" + f"{instrument_base}" if instrument_base in ("IDEX", "GLOWS") else f"IMAP-{instrument_base.title()}" ) @@ -697,7 +689,6 @@ def parse_instrument_descriptor( if instrument_descriptor.endswith("c"): sensor = "combined" instrument_short_name = instrument_descriptor[:-2] - instrument = MappableInstrumentShortName(instrument_short_name) else: # Do regex to get the instrument and sensor: # The first 1 or 3 characters are the instrument short name @@ -714,7 +705,6 @@ def parse_instrument_descriptor( # If sensor is 3 digits, convert to int elif sensor_match and len(sensor_match) == 3: sensor = int(sensor_match) - instrument = MappableInstrumentShortName(instrument_short_name) instrument = MappableInstrumentShortName(instrument_short_name) return instrument, sensor diff --git a/imap_processing/tests/ena_maps/test_naming.py b/imap_processing/tests/ena_maps/test_naming.py index 6f39dd18f..58b0f1f3a 100644 --- a/imap_processing/tests/ena_maps/test_naming.py +++ b/imap_processing/tests/ena_maps/test_naming.py @@ -539,14 +539,14 @@ def test_principal_data_var(self, descriptor_str, expected_principal_data_var): ), ( "glx-int-uv-hf-nsp-full-hae-2deg-6mo", - "IMAP-GLOWS Instrument Level-2 map of UV Intensity " + "GLOWS Instrument Level-2 map of UV Intensity " "in the heliospheric frame with no survival correction " "in the full spin direction in HAE coordinates on rectangular " "2 degree tiling over 6 months.", ), ( "idx-drt-dust-hf-nsp-full-hae-nside32-1yr", - "IMAP-IDEX Instrument Level-2 map of Dust Rate " + "IDEX Instrument Level-2 map of Dust Rate " "in the heliospheric frame with no survival correction " "in the full spin direction in HAE coordinates on HEALPix " "nside 32 tiling over 1 year.",