From 4ea0a3b603efa1629b4b86d2da0dd94aaa8295e6 Mon Sep 17 00:00:00 2001 From: Pablo Gomez Date: Mon, 9 Mar 2026 18:42:38 +0100 Subject: [PATCH 1/3] fix(prediction): resolve streaming FITS extension errors and support dict channel_combination Always resolve filter names from catalogue file paths for multi-extension streaming, setting fits_extensions to ["PRIMARY"] for source mosaics. Fixes "Extension CHANNEL_1 not found" errors when cutout-level extension names were passed to cutana for mosaic files. Also adds dict-form channel_combination support (e.g. {"VIS": [1,0,0]}) for order-independent channel mapping, preventing channel jumbling when streaming catalogues have different file ordering than training data. --- anomaly_match/utils/validate_config.py | 19 +- prediction_process_cutana.py | 109 +++++---- tests/unit/test_cutana_prediction.py | 293 +++++++++++++++++++++++++ 3 files changed, 373 insertions(+), 48 deletions(-) create mode 100644 tests/unit/test_cutana_prediction.py diff --git a/anomaly_match/utils/validate_config.py b/anomaly_match/utils/validate_config.py index c74df77..57334f0 100644 --- a/anomaly_match/utils/validate_config.py +++ b/anomaly_match/utils/validate_config.py @@ -294,8 +294,25 @@ def _format_constraints(): cc = cfg.normalisation.channel_combination fits_ext = cfg.normalisation.fits_extension + # Dict form channel_combination: keys are filter names, values are + # weight lists. Convert to numpy array for fitsbolt compatibility + # while preserving the original dict in the config for streaming. + if cc is not None and isinstance(cc, dict): + keys = list(cc.keys()) + cc_array = np.column_stack([np.array(cc[k]) for k in keys]) + inferred = cc_array.shape[0] + if inferred != cfg.normalisation.n_output_channels: + logger.info( + f"Setting n_output_channels to {inferred} " + f"from dict channel_combination ({keys})" + ) + cfg.normalisation.n_output_channels = inferred + # Store numpy array for fitsbolt, keep dict in config for streaming + cfg.normalisation.channel_combination = cc_array + cfg.normalisation.channel_combination_dict = cc + # Infer n_output_channels from channel_combination matrix if provided - if cc is not None and hasattr(cc, "shape") and len(cc.shape) == 2: + elif cc is not None and hasattr(cc, "shape") and len(cc.shape) == 2: inferred = cc.shape[0] if inferred != cfg.normalisation.n_output_channels: logger.info( diff --git a/prediction_process_cutana.py b/prediction_process_cutana.py index b248721..4af3d65 100644 --- a/prediction_process_cutana.py +++ b/prediction_process_cutana.py @@ -97,44 +97,33 @@ def evaluate_images_from_cutana( # Configure FITS extensions for cutana. # - # AnomalyMatch's fits_extension uses integer HDU indices (for multi-extension - # cutout files loaded by fitsbolt). Cutana operates on large mosaic tiles - # referenced in the catalogue — each source has separate FITS files per band. - # Cutana identifies bands by filter name (e.g. "VIS", "NIR-H") extracted from - # the file paths, so we must resolve integer indices to filter names here. - # - # NOTE: filter name extraction currently relies on Euclid naming conventions - # (via cutana.catalogue_preprocessor.extract_filter_name). If your catalogue - # uses non-Euclid file naming, set cfg.normalisation.fits_extension to - # explicit filter name strings instead of integer indices. + # AnomalyMatch's fits_extension uses integer HDU indices or cutout extension + # names (e.g. CHANNEL_1) for reading multi-extension cutout files via + # fitsbolt. Cutana streaming operates on large mosaic tiles referenced in + # the catalogue — each source has separate FITS files per band with a single + # PRIMARY HDU. These cutout-level extension values are NOT valid for the + # source mosaics, so for multi-extension streaming we always resolve filter + # names (e.g. "VIS", "NIR-H") from the catalogue file paths. fits_ext = cfg.normalisation.fits_extension if fits_ext is None: fits_ext = ["PRIMARY"] elif isinstance(fits_ext, (str, int)): fits_ext = [fits_ext] - # When fits_extension contains integers, resolve to filter names from the - # catalogue's fits_file_paths column. - has_integer_indices = any(isinstance(e, int) for e in fits_ext) - if has_integer_indices: - if len(fits_ext) > 1: - extension_names = _resolve_filter_names_from_catalogue( - cutana_sources_path, len(fits_ext) - ) - else: - # Single integer index (e.g. [0]) maps to the PRIMARY HDU - extension_names = ["PRIMARY"] - else: - extension_names = [str(e) for e in fits_ext] - - # Build selected_extensions for cutana. - # For multi-file catalogues (separate FITS per band), each file has only a - # PRIMARY HDU, so fits_extensions must be ["PRIMARY"]. The filter names go - # into channel_weights and selected_extensions for channel identification. - if has_integer_indices: - cutana_config.fits_extensions = ["PRIMARY"] + if len(fits_ext) > 1: + # Multi-extension: resolve filter names from catalogue file paths. + # fits_extension values (integer HDU indices like [1,2,3] or cutout + # extension names like ['CHANNEL_1','CHANNEL_2','CHANNEL_3']) are only + # meaningful for reading cutout files — they must not be passed to + # cutana as-is because source mosaics have different HDU structure. + extension_names = _resolve_filter_names_from_catalogue( + cutana_sources_path, len(fits_ext) + ) else: - cutana_config.fits_extensions = extension_names + extension_names = ["PRIMARY"] + + # Source mosaics have a single PRIMARY HDU per file. + cutana_config.fits_extensions = ["PRIMARY"] selected_extensions = [] for name in extension_names: @@ -146,9 +135,32 @@ def evaluate_images_from_cutana( # Channel combination must happen BEFORE normalisation (cutana's pipeline # ensures this) so that ZSCALE/ASINH see the same data shape as training. n_out = cfg.normalisation.n_output_channels - if cfg.normalisation.channel_combination is not None: - # Multi-extension: convert numpy matrix (n_out x n_in) to cutana dict - combo = cfg.normalisation.channel_combination + # Prefer the original dict form (preserved by validate_config when the user + # provides channel_combination as a dict). Falls back to the numpy array. + combo_dict = cfg.normalisation.channel_combination_dict + # DotMap subclasses dict, so empty auto-created DotMaps pass isinstance check. + # Only use combo_dict when it's a real non-empty dict set by validate_config. + combo = combo_dict if isinstance(combo_dict, dict) and len(combo_dict) > 0 else cfg.normalisation.channel_combination + if combo is not None and isinstance(combo, dict) and len(combo) > 0: + # Dict form: keys are filter names, values are weight lists. + # This is order-independent — no risk of channel jumbling when + # the streaming catalogue has a different file order than training. + missing = set(extension_names) - set(combo.keys()) + if missing: + raise ValueError( + f"channel_combination dict is missing keys for resolved filter names: " + f"{missing}. Dict keys: {list(combo.keys())}, " + f"resolved filters: {extension_names}" + ) + channel_weights = {} + for name in extension_names: + weights = combo[name] + channel_weights[name] = list(weights) if not isinstance(weights, list) else weights + cutana_config.channel_weights = channel_weights + elif combo is not None and hasattr(combo, "shape"): + # Numpy array form: columns are positionally mapped to extension_names + # (order depends on catalogue file path order — use dict form to avoid + # ambiguity when streaming and training catalogues differ). channel_weights = {} for j, ext_name in enumerate(extension_names): channel_weights[str(ext_name)] = combo[:, j].tolist() @@ -164,28 +176,31 @@ def evaluate_images_from_cutana( # Verify channel configuration consistency if len(extension_names) > 1: - combo = cfg.normalisation.channel_combination - n_in = combo.shape[1] if hasattr(combo, "shape") else len(extension_names) + if isinstance(combo, dict): + n_in = len(combo) + elif hasattr(combo, "shape"): + n_in = combo.shape[1] + else: + n_in = len(extension_names) if len(extension_names) != n_in: raise ValueError( f"Number of resolved filter names ({len(extension_names)}) does not match " f"channel_combination input dimension ({n_in}). " - f"Filter names: {extension_names}, matrix shape: {combo.shape}" + f"Filter names: {extension_names}" ) - if combo.shape[0] != n_out: + if isinstance(combo, dict): + lengths = {k: len(v) for k, v in combo.items()} + bad = {k: length for k, length in lengths.items() if length != n_out} + if bad: + raise ValueError( + f"channel_combination dict values must have length {n_out} " + f"(n_output_channels), but got: {bad}" + ) + elif hasattr(combo, "shape") and combo.shape[0] != n_out: raise ValueError( f"channel_combination output dimension ({combo.shape[0]}) does not match " f"n_output_channels ({n_out})" ) - # For non-diagonal matrices, verify all input channels contribute - # (a zero column means an extension is loaded but never used) - for j, ext_name in enumerate(extension_names): - col_sum = abs(combo[:, j]).sum() - if col_sum == 0: - logger.warning( - f"Extension '{ext_name}' (column {j}) has zero weight in " - f"channel_combination — this channel will be loaded but ignored" - ) logger.info( f"Channel configuration: {len(extension_names)} inputs -> {n_out} outputs, " f"filter order: {extension_names}" diff --git a/tests/unit/test_cutana_prediction.py b/tests/unit/test_cutana_prediction.py new file mode 100644 index 0000000..3200c77 --- /dev/null +++ b/tests/unit/test_cutana_prediction.py @@ -0,0 +1,293 @@ +# Copyright (c) European Space Agency, 2025. +# +# This file is subject to the terms and conditions defined in file 'LICENCE.txt', which +# is part of this source code package. No part of the package, including +# this file, may be copied, modified, propagated, or distributed except according to +# the terms contained in the file 'LICENCE.txt'. +"""Tests for cutana streaming prediction FITS extension resolution. + +Regression tests for the bug where string fits_extension values +(e.g. CHANNEL_1) were passed directly to cutana, causing +'Extension CHANNEL_1 not found' errors on Euclid source mosaics. +""" + +import numpy as np +import pandas as pd +import pytest + +from prediction_process_cutana import _resolve_filter_names_from_catalogue + +# Realistic Euclid file paths (matching the bug report) +EUCLID_FITS_PATHS = ( + "['/DATA01/mosaics/VIS/EUC_MER_BGSUB-MOSAIC-VIS_TILE102158888-96CC3C_20250730T105451.756715Z_00.00.fits', " + "'/DATA01/mosaics/NISP/EUC_MER_BGSUB-MOSAIC-NIR-Y_TILE102158888-7489F6_20250729T212617.293884Z_00.00.fits', " + "'/DATA01/mosaics/NISP/EUC_MER_BGSUB-MOSAIC-NIR-H_TILE102158888-92DFAD_20250729T213004.461695Z_00.00.fits']" +) + + +def _make_euclid_catalogue(path, fits_paths=EUCLID_FITS_PATHS, n=5): + """Create a minimal cutana catalogue with Euclid-style fits_file_paths.""" + df = pd.DataFrame( + { + "SourceID": [f"SRC_{i}" for i in range(n)], + "RA": [266.0 + i * 0.01 for i in range(n)], + "Dec": [65.0 + i * 0.01 for i in range(n)], + "fits_file_paths": [fits_paths] * n, + "diameter_arcsec": [1.0] * n, + } + ) + if str(path).endswith(".parquet"): + df.to_parquet(path, index=False) + else: + df.to_csv(path, index=False) + return path + + +class TestResolveFilterNamesFromCatalogue: + """Tests for _resolve_filter_names_from_catalogue.""" + + def test_resolves_euclid_filter_names_from_parquet(self, tmp_path): + cat = _make_euclid_catalogue(tmp_path / "sources.parquet") + names = _resolve_filter_names_from_catalogue(str(cat), n_extensions=3) + assert names == ["VIS", "NIR-Y", "NIR-H"] + + def test_resolves_euclid_filter_names_from_csv(self, tmp_path): + cat = _make_euclid_catalogue(tmp_path / "sources.csv") + names = _resolve_filter_names_from_catalogue(str(cat), n_extensions=3) + assert names == ["VIS", "NIR-Y", "NIR-H"] + + def test_resolves_from_directory(self, tmp_path): + _make_euclid_catalogue(tmp_path / "batch_000.parquet") + names = _resolve_filter_names_from_catalogue(str(tmp_path), n_extensions=3) + assert names == ["VIS", "NIR-Y", "NIR-H"] + + def test_extension_count_mismatch_raises(self, tmp_path): + cat = _make_euclid_catalogue(tmp_path / "sources.parquet") + with pytest.raises(ValueError, match="specifies 2 extensions"): + _resolve_filter_names_from_catalogue(str(cat), n_extensions=2) + + def test_non_euclid_paths_raise(self, tmp_path): + non_euclid = "['custom_band_a.fits', 'custom_band_b.fits']" + cat = _make_euclid_catalogue( + tmp_path / "sources.parquet", fits_paths=non_euclid, n=3 + ) + with pytest.raises(ValueError, match="Could not determine filter names"): + _resolve_filter_names_from_catalogue(str(cat), n_extensions=2) + + def test_empty_directory_raises(self, tmp_path): + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + with pytest.raises(FileNotFoundError): + _resolve_filter_names_from_catalogue(str(empty_dir), n_extensions=3) + + +class TestStreamingFitsExtensionConfig: + """Verify cutana config is always set to PRIMARY for streaming, + regardless of the fits_extension format provided by the user. + """ + + @pytest.fixture + def euclid_catalogue(self, tmp_path): + return str(_make_euclid_catalogue(tmp_path / "sources.parquet")) + + @pytest.fixture + def channel_combination(self): + return np.array([ + [1.0, 0.0, 0.0], + [0.0, 0.9, 0.0], + [0.0, 0.0, 0.9], + ]) + + @pytest.mark.parametrize( + "fits_extension", + [ + [1, 2, 3], + ["CHANNEL_1", "CHANNEL_2", "CHANNEL_3"], + ], + ids=["integer_indices", "string_channel_names"], + ) + def test_fits_extensions_always_primary( + self, euclid_catalogue, channel_combination, fits_extension + ): + """cutana_config.fits_extensions must be ['PRIMARY'] for all input formats.""" + import cutana + + cutana_config = cutana.get_default_config() + + # Replicate the config-building logic from evaluate_images_from_cutana + fits_ext = list(fits_extension) + + if len(fits_ext) > 1: + extension_names = _resolve_filter_names_from_catalogue( + euclid_catalogue, len(fits_ext) + ) + else: + extension_names = ["PRIMARY"] + + cutana_config.fits_extensions = ["PRIMARY"] + + selected_extensions = [] + for name in extension_names: + selected_extensions.append({"name": name, "ext": "PRIMARY"}) + cutana_config.selected_extensions = selected_extensions + + # Key assertions: cutana must never receive CHANNEL_1/2/3 or integer indices + assert cutana_config.fits_extensions == ["PRIMARY"] + assert extension_names == ["VIS", "NIR-Y", "NIR-H"] + for ext in cutana_config.selected_extensions: + assert ext["ext"] == "PRIMARY" + assert ext["name"] in ("VIS", "NIR-Y", "NIR-H") + + @pytest.mark.parametrize( + "fits_extension", + [ + [1, 2, 3], + ["CHANNEL_1", "CHANNEL_2", "CHANNEL_3"], + ], + ids=["integer_indices", "string_channel_names"], + ) + def test_channel_weights_use_filter_names( + self, euclid_catalogue, channel_combination, fits_extension + ): + """channel_weights keys must be resolved filter names, not cutout extension names.""" + fits_ext = list(fits_extension) + + extension_names = _resolve_filter_names_from_catalogue( + euclid_catalogue, len(fits_ext) + ) + + channel_weights = {} + for j, ext_name in enumerate(extension_names): + channel_weights[str(ext_name)] = channel_combination[:, j].tolist() + + assert set(channel_weights.keys()) == {"VIS", "NIR-Y", "NIR-H"} + assert "CHANNEL_1" not in channel_weights + assert channel_weights["VIS"] == [1.0, 0.0, 0.0] + + def test_single_extension_uses_primary(self): + """Single-extension case should use PRIMARY without catalogue resolution.""" + fits_ext = [1] + if len(fits_ext) > 1: + pytest.fail("Should not resolve for single extension") + extension_names = ["PRIMARY"] + assert extension_names == ["PRIMARY"] + + def test_none_extension_defaults_to_primary(self): + """None fits_extension should default to ['PRIMARY'].""" + fits_ext = None + if fits_ext is None: + fits_ext = ["PRIMARY"] + assert fits_ext == ["PRIMARY"] + assert len(fits_ext) == 1 + + +class TestDictChannelCombination: + """Tests for dict-form channel_combination support. + + Dict-form maps filter names to weight lists, making channel mapping + order-independent regardless of catalogue file path ordering. + """ + + @pytest.fixture + def euclid_catalogue(self, tmp_path): + return str(_make_euclid_catalogue(tmp_path / "sources.parquet")) + + @pytest.fixture + def combo_dict(self): + """Dict-form channel_combination: filter name -> output weights.""" + return { + "VIS": [1.0, 0.0, 0.0], + "NIR-Y": [0.0, 0.9, 0.0], + "NIR-H": [0.0, 0.0, 0.9], + } + + def test_dict_combo_produces_correct_channel_weights(self, euclid_catalogue, combo_dict): + """Dict channel_combination should map weights by filter name, not position.""" + extension_names = _resolve_filter_names_from_catalogue(euclid_catalogue, n_extensions=3) + + # Replicate the dict-form logic from evaluate_images_from_cutana + channel_weights = {} + for name in extension_names: + weights = combo_dict[name] + channel_weights[name] = list(weights) if not isinstance(weights, list) else weights + + assert channel_weights["VIS"] == [1.0, 0.0, 0.0] + assert channel_weights["NIR-Y"] == [0.0, 0.9, 0.0] + assert channel_weights["NIR-H"] == [0.0, 0.0, 0.9] + + def test_dict_combo_order_independent(self, euclid_catalogue): + """Dict-form should produce the same channel_weights regardless of dict key order.""" + extension_names = _resolve_filter_names_from_catalogue(euclid_catalogue, n_extensions=3) + + # Provide dict in reversed order compared to catalogue + combo_reversed = { + "NIR-H": [0.0, 0.0, 0.9], + "NIR-Y": [0.0, 0.9, 0.0], + "VIS": [1.0, 0.0, 0.0], + } + + channel_weights = {} + for name in extension_names: + weights = combo_reversed[name] + channel_weights[name] = list(weights) if not isinstance(weights, list) else weights + + # Weights should be correct regardless of dict ordering + assert channel_weights["VIS"] == [1.0, 0.0, 0.0] + assert channel_weights["NIR-Y"] == [0.0, 0.9, 0.0] + assert channel_weights["NIR-H"] == [0.0, 0.0, 0.9] + + def test_dict_combo_missing_filter_raises(self, euclid_catalogue): + """Dict missing a resolved filter name should raise ValueError.""" + extension_names = _resolve_filter_names_from_catalogue(euclid_catalogue, n_extensions=3) + + # Missing NIR-H + combo_incomplete = { + "VIS": [1.0, 0.0, 0.0], + "NIR-Y": [0.0, 0.9, 0.0], + } + + missing = set(extension_names) - set(combo_incomplete.keys()) + assert missing == {"NIR-H"} + + def test_validate_config_converts_dict_to_numpy(self): + """validate_config should convert dict channel_combination to numpy for fitsbolt.""" + combo_dict = { + "VIS": [1.0, 0.0, 0.0], + "NIR-Y": [0.0, 0.9, 0.0], + "NIR-H": [0.0, 0.0, 0.9], + } + + # Simulate what validate_config does with dict channel_combination + keys = list(combo_dict.keys()) + cc_array = np.column_stack([np.array(combo_dict[k]) for k in keys]) + + assert cc_array.shape == (3, 3) + # First column corresponds to first key (VIS) + np.testing.assert_array_equal(cc_array[:, 0], [1.0, 0.0, 0.0]) + + def test_validate_config_preserves_dict(self): + """validate_config should preserve the original dict as channel_combination_dict.""" + from dotmap import DotMap + + cfg = DotMap() + cfg.normalisation = DotMap() + cfg.normalisation.channel_combination = { + "VIS": [1.0, 0.0, 0.0], + "NIR-Y": [0.0, 0.9, 0.0], + "NIR-H": [0.0, 0.0, 0.9], + } + cfg.normalisation.n_output_channels = 3 + + cc = cfg.normalisation.channel_combination + if cc is not None and isinstance(cc, dict): + keys = list(cc.keys()) + cc_array = np.column_stack([np.array(cc[k]) for k in keys]) + cfg.normalisation.channel_combination = cc_array + cfg.normalisation.channel_combination_dict = cc + + # numpy array for fitsbolt + assert hasattr(cfg.normalisation.channel_combination, "shape") + assert cfg.normalisation.channel_combination.shape == (3, 3) + # original dict preserved + assert isinstance(cfg.normalisation.channel_combination_dict, dict) + assert set(cfg.normalisation.channel_combination_dict.keys()) == {"VIS", "NIR-Y", "NIR-H"} From 02d3f89e83b3701411410af2a89bd9097d2f5e04 Mon Sep 17 00:00:00 2001 From: Pablo Gomez Date: Mon, 9 Mar 2026 18:48:24 +0100 Subject: [PATCH 2/3] style: apply ruff formatting --- prediction_process_cutana.py | 10 ++++++---- tests/unit/test_cutana_prediction.py | 24 ++++++++++-------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/prediction_process_cutana.py b/prediction_process_cutana.py index 4af3d65..38bd7fd 100644 --- a/prediction_process_cutana.py +++ b/prediction_process_cutana.py @@ -116,9 +116,7 @@ def evaluate_images_from_cutana( # extension names like ['CHANNEL_1','CHANNEL_2','CHANNEL_3']) are only # meaningful for reading cutout files — they must not be passed to # cutana as-is because source mosaics have different HDU structure. - extension_names = _resolve_filter_names_from_catalogue( - cutana_sources_path, len(fits_ext) - ) + extension_names = _resolve_filter_names_from_catalogue(cutana_sources_path, len(fits_ext)) else: extension_names = ["PRIMARY"] @@ -140,7 +138,11 @@ def evaluate_images_from_cutana( combo_dict = cfg.normalisation.channel_combination_dict # DotMap subclasses dict, so empty auto-created DotMaps pass isinstance check. # Only use combo_dict when it's a real non-empty dict set by validate_config. - combo = combo_dict if isinstance(combo_dict, dict) and len(combo_dict) > 0 else cfg.normalisation.channel_combination + combo = ( + combo_dict + if isinstance(combo_dict, dict) and len(combo_dict) > 0 + else cfg.normalisation.channel_combination + ) if combo is not None and isinstance(combo, dict) and len(combo) > 0: # Dict form: keys are filter names, values are weight lists. # This is order-independent — no risk of channel jumbling when diff --git a/tests/unit/test_cutana_prediction.py b/tests/unit/test_cutana_prediction.py index 3200c77..79995e7 100644 --- a/tests/unit/test_cutana_prediction.py +++ b/tests/unit/test_cutana_prediction.py @@ -68,9 +68,7 @@ def test_extension_count_mismatch_raises(self, tmp_path): def test_non_euclid_paths_raise(self, tmp_path): non_euclid = "['custom_band_a.fits', 'custom_band_b.fits']" - cat = _make_euclid_catalogue( - tmp_path / "sources.parquet", fits_paths=non_euclid, n=3 - ) + cat = _make_euclid_catalogue(tmp_path / "sources.parquet", fits_paths=non_euclid, n=3) with pytest.raises(ValueError, match="Could not determine filter names"): _resolve_filter_names_from_catalogue(str(cat), n_extensions=2) @@ -92,11 +90,13 @@ def euclid_catalogue(self, tmp_path): @pytest.fixture def channel_combination(self): - return np.array([ - [1.0, 0.0, 0.0], - [0.0, 0.9, 0.0], - [0.0, 0.0, 0.9], - ]) + return np.array( + [ + [1.0, 0.0, 0.0], + [0.0, 0.9, 0.0], + [0.0, 0.0, 0.9], + ] + ) @pytest.mark.parametrize( "fits_extension", @@ -118,9 +118,7 @@ def test_fits_extensions_always_primary( fits_ext = list(fits_extension) if len(fits_ext) > 1: - extension_names = _resolve_filter_names_from_catalogue( - euclid_catalogue, len(fits_ext) - ) + extension_names = _resolve_filter_names_from_catalogue(euclid_catalogue, len(fits_ext)) else: extension_names = ["PRIMARY"] @@ -152,9 +150,7 @@ def test_channel_weights_use_filter_names( """channel_weights keys must be resolved filter names, not cutout extension names.""" fits_ext = list(fits_extension) - extension_names = _resolve_filter_names_from_catalogue( - euclid_catalogue, len(fits_ext) - ) + extension_names = _resolve_filter_names_from_catalogue(euclid_catalogue, len(fits_ext)) channel_weights = {} for j, ext_name in enumerate(extension_names): From 0692f509d5814cf9dccedcfefc11590b8d02fbe0 Mon Sep 17 00:00:00 2001 From: Pablo Gomez Date: Mon, 9 Mar 2026 18:51:55 +0100 Subject: [PATCH 3/3] chore: whitelist channel_combination_dict for vulture --- .vulture_whitelist.py | 1 + 1 file changed, 1 insertion(+) diff --git a/.vulture_whitelist.py b/.vulture_whitelist.py index af316bf..1e55154 100644 --- a/.vulture_whitelist.py +++ b/.vulture_whitelist.py @@ -65,3 +65,4 @@ # Image processing functions used in prediction scripts (root level, excluded from scan) process_single_wrapper # noqa - Used in prediction_utils.py, prediction_process_hdf5.py _.n_expected_channels # noqa - fitsbolt config attribute set dynamically +_.channel_combination_dict # noqa - Used in prediction_process_cutana.py (outside scan path)