From a8733c57d4bc62ef93ba9299ae69f0b943d175ff Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Mon, 27 Apr 2026 16:58:18 -0600 Subject: [PATCH 1/8] Add function for getting full logical source description --- imap_processing/ena_maps/utils/naming.py | 306 ++++++++++++++++-- imap_processing/tests/ena_maps/test_naming.py | 56 ++++ 2 files changed, 336 insertions(+), 26 deletions(-) diff --git a/imap_processing/ena_maps/utils/naming.py b/imap_processing/ena_maps/utils/naming.py index d8aa7b6d9e..79c16a197e 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -173,53 +173,267 @@ def to_string(self) -> str: ] ) - def to_catdesc(self) -> 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 + ---------- + 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 ) - quantity = { + return m.group(1), m.group(2) + + def _get_quantity_str(self, data_type: str, full: bool = False) -> str: + """ + Get formatted quantity string based on data type. + + Parameters + ---------- + data_type : str + The data type code (e.g., "ena", "isn", "drt"). + full : bool, optional + If True, return full format (e.g., "ENA Intensity"). + If False, return short format (e.g., "Inten"). Default is False. + + Returns + ------- + str + Formatted quantity string. + """ + if full: + return { + "drt": "Dust Rate", + "ena": "ENA Intensity", + "int": "Intensity", + "isn": "Rate", + "spx": "Spectral Index", + }[data_type] + return { "drt": "Rate", "ena": "Inten", "int": "Inten", "isn": "Rate", "spx": "Spectral", - }[m.group(1)] - if m.group(1) == "isn": - species = "ISN " + species - extras = m.group(2) - coord = self.coordinate_system.upper() - frame = { + }[data_type] + + 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 to_catdesc(self) -> str: + """ + Convert the MapDescriptor instance to a human-readable CATDESC string. + + 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() + quantity = self._get_quantity_str(data_type, full=False) + 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}, {coord} " f"{frame} Frame, {survival}, {spin_phase}, {resolution}, {duration}" @@ -234,6 +448,46 @@ def to_catdesc(self) -> 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 = self._get_quantity_str(data_type, full=True) + + # 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 0c5d03ffbb..ea4735d537 100644 --- a/imap_processing/tests/ena_maps/test_naming.py +++ b/imap_processing/tests/ena_maps/test_naming.py @@ -394,3 +394,59 @@ def test_to_catdesc(self, descriptor_str, expected_catdesc): 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 a8ffd42a5e7ffcb3fce49eb7be95120bb8f63661 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 09:55:09 -0600 Subject: [PATCH 2/8] 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 6072b34505..8f26d92401 100644 --- a/imap_processing/ena_maps/ena_maps.py +++ b/imap_processing/ena_maps/ena_maps.py @@ -1491,11 +1491,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 b67aa30181..31c8eaa859 100644 --- a/imap_processing/ultra/l2/ultra_l2.py +++ b/imap_processing/ultra/l2/ultra_l2.py @@ -749,7 +749,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(), @@ -763,6 +763,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 bd5b0e4307d29877a1f67fa9c72dd79780ef1ff3 Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 10:05:29 -0600 Subject: [PATCH 3/8] Remove some private functions that don't make sense --- imap_processing/ena_maps/utils/naming.py | 271 ++++++++--------------- 1 file changed, 88 insertions(+), 183 deletions(-) diff --git a/imap_processing/ena_maps/utils/naming.py b/imap_processing/ena_maps/utils/naming.py index 79c16a197e..301478b1b0 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -173,74 +173,6 @@ def to_string(self) -> str: ] ) - def _get_instrument_str(self, full: bool = False) -> str: - """ - Get formatted instrument name string. - - Parameters - ---------- - 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 - 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. @@ -255,107 +187,6 @@ def _parse_principal_data(self) -> tuple[str, str]: ) return m.group(1), m.group(2) - def _get_quantity_str(self, data_type: str, full: bool = False) -> str: - """ - Get formatted quantity string based on data type. - - Parameters - ---------- - data_type : str - The data type code (e.g., "ena", "isn", "drt"). - full : bool, optional - If True, return full format (e.g., "ENA Intensity"). - If False, return short format (e.g., "Inten"). Default is False. - - Returns - ------- - str - Formatted quantity string. - """ - if full: - return { - "drt": "Dust Rate", - "ena": "ENA Intensity", - "int": "Intensity", - "isn": "Rate", - "spx": "Spectral Index", - }[data_type] - return { - "drt": "Rate", - "ena": "Inten", - "int": "Inten", - "isn": "Rate", - "spx": "Spectral", - }[data_type] - - 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] - - 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() - return "Full Spin" if spin_phase == "Full" else spin_phase - def _get_resolution_str(self, full: bool = False) -> str: """ Get formatted resolution string. @@ -420,17 +251,51 @@ def to_catdesc(self) -> 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 = self._get_quantity_str(data_type, full=False) + + # 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) @@ -458,11 +323,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 = self._get_quantity_str(data_type, full=True) + + # Quantity (e.g., "ENA Intensity", "Rate", "Dust Rate") + quantity = { + "drt": "Dust Rate", + "ena": "ENA Intensity", + "int": "Intensity", + "isn": "Rate", + "spx": "Spectral Index", + }[data_type] # Handle special species cases if data_type == "isn": @@ -471,9 +363,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 f52499c5c4f6b34f3bc873d32f20e6b18989ba8f Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 10:10:06 -0600 Subject: [PATCH 4/8] 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 301478b1b0..7184d20f81 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -251,23 +251,10 @@ def to_catdesc(self) -> 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() @@ -285,14 +272,8 @@ def to_catdesc(self) -> 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 aa44603f8cbbbf0b9e30e61f5ef87c850fd6649b Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 12:01:34 -0600 Subject: [PATCH 5/8] 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 81db20e041..68b459808e 100644 --- a/imap_processing/tests/hi/test_hi_l2.py +++ b/imap_processing/tests/hi/test_hi_l2.py @@ -174,7 +174,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) == 15 np.testing.assert_array_equal( From e9aa4185e1413156e7ee0482f4117edc1d34e0cf Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 15:26:32 -0600 Subject: [PATCH 6/8] 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 8f26d92401..9a2c65c960 100644 --- a/imap_processing/ena_maps/ena_maps.py +++ b/imap_processing/ena_maps/ena_maps.py @@ -1497,8 +1497,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 7184d20f81..2376fafa56 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -185,6 +185,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_resolution_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 31c8eaa859..91f3e6341d 100644 --- a/imap_processing/ultra/l2/ultra_l2.py +++ b/imap_processing/ultra/l2/ultra_l2.py @@ -764,10 +764,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 6173bfc93fd234b7f4a1d9bb6843c8614505694e Mon Sep 17 00:00:00 2001 From: Tim Plummer Date: Tue, 28 Apr 2026 15:38:01 -0600 Subject: [PATCH 7/8] 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 2376fafa56..5b5a088085 100644 --- a/imap_processing/ena_maps/utils/naming.py +++ b/imap_processing/ena_maps/utils/naming.py @@ -210,6 +210,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" @@ -354,6 +359,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" @@ -371,12 +379,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 ea4735d537..ab68edcca2 100644 --- a/imap_processing/tests/ena_maps/test_naming.py +++ b/imap_processing/tests/ena_maps/test_naming.py @@ -401,48 +401,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 ed4bba310fe1203fddc8560652a7f38405a70bce 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/8] [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 9a2c65c960..36e903587b 100644 --- a/imap_processing/ena_maps/ena_maps.py +++ b/imap_processing/ena_maps/ena_maps.py @@ -1509,9 +1509,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 91f3e6341d..f7cec2e41e 100644 --- a/imap_processing/ultra/l2/ultra_l2.py +++ b/imap_processing/ultra/l2/ultra_l2.py @@ -767,9 +767,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"