From 35bb1e572694895587641daf501f4ca1a330d49c Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Sat, 26 Sep 2026 07:04:41 +0200 Subject: [PATCH 1/3] catalog: metacal bitmasks use FITS format K, not I or the dead FLAGS_ key NGMIX_MCAL_FLAGS carries bit 30 (shapepipe#854's absent-measurement flag) alongside the native fitter bits. Format I truncates it to int16 and silently zeroes bit 30 on write, in both the FITS and the HDF5 branch of write_shape_catalog (the HDF5 path reuses the FITS column's array). The per-type NGMIX_FLAGS_{1P,1M,2P,2M,NOSHEAR} columns had the same problem from the other direction: their format entry was keyed as "FLAGS_{suffix}", which never matches the real column name "{prefix}_FLAGS_{suffix}", so the writer's float64 default masked the dead key rather than narrowing anything. Both parameter files now key and format all six metacal bitmasks the same way, at K, so they round- trip exactly and survive JointCat's optional memory-reduction pass (which only downcasts int32/float64, not int64). NGMIX_MCAL_TYPES_FAIL stays at I: it is a count in [0, 5], not a bitmask. Adds a round-trip test parametrized over both parameter files, FITS and HDF5, and reduce_mem on/off, checking 0, bit 30 alone, and bit 30 with a native bit together. Co-Authored-By: Claude Opus 5.5 --- scripts/calibration/params.py | 5 +- src/sp_validation/catalog.py | 6 ++ .../tests/test_catalog_flag_roundtrip.py | 63 +++++++++++++++++++ workflow/image_sims/params_im_sim.py | 5 +- 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 src/sp_validation/tests/test_catalog_flag_roundtrip.py diff --git a/scripts/calibration/params.py b/scripts/calibration/params.py index ea871119..f20d3a1b 100644 --- a/scripts/calibration/params.py +++ b/scripts/calibration/params.py @@ -143,7 +143,6 @@ "NUMBER", "IMAFLAGS_ISO", "FLAGS", - "NGMIX_MCAL_FLAGS", "NGMIX_MCAL_TYPES_FAIL", "N_EPOCH", "NGMIX_N_EPOCH", @@ -152,6 +151,8 @@ add_cols_pre_cal_format["TILE_ID"] = "A7" add_cols_pre_cal_format["NUMBER"] = "J" +# Metacal bitmasks need bit 30 and native fitter bits, including after merging. +add_cols_pre_cal_format["NGMIX_MCAL_FLAGS"] = "K" # Create key names for metacal information prefix = "NGMIX" @@ -162,7 +163,7 @@ add_cols_pre_cal.append(f"{prefix}_{center}_{suffix}") for suffix in suffixes: - add_cols_pre_cal_format[f"FLAGS_{suffix}"] = "I" + add_cols_pre_cal_format[f"{prefix}_FLAGS_{suffix}"] = "K" # Catalog parameters diff --git a/src/sp_validation/catalog.py b/src/sp_validation/catalog.py index e39d2076..40d4d418 100644 --- a/src/sp_validation/catalog.py +++ b/src/sp_validation/catalog.py @@ -439,6 +439,12 @@ def write_shape_catalog( Write catalogue with galaxy shapes = shear estimates. + @sc [label:convention] metacal-flag-width + The data and image-simulation parameter files supply FITS ``K`` for + metacal bitmasks so bit 30 and native fitter flags survive FITS/HDF5 + output and optional ``JointCat`` memory reduction. + The number of failed metacal types is a count in [0, 5], not a bitmask. + Parameters ---------- output_path : str diff --git a/src/sp_validation/tests/test_catalog_flag_roundtrip.py b/src/sp_validation/tests/test_catalog_flag_roundtrip.py new file mode 100644 index 00000000..02e27580 --- /dev/null +++ b/src/sp_validation/tests/test_catalog_flag_roundtrip.py @@ -0,0 +1,63 @@ +"""Comprehensive catalogues preserve metacal failure bits exactly.""" + +import runpy +from pathlib import Path + +import h5py +import numpy as np +import pytest +from astropy.io import fits + +from sp_validation.catalog import write_shape_catalog +from sp_validation.catalog_builders import JointCat + +ROOT = Path(__file__).resolve().parents[3] + + +@pytest.mark.parametrize( + "params_path", + ["scripts/calibration/params.py", "workflow/image_sims/params_im_sim.py"], + ids=["data", "image-sims"], +) +@pytest.mark.parametrize("extension", [".fits", ".hdf5"]) +@pytest.mark.parametrize("reduce_mem", [False, True]) +def test_metacal_flags_roundtrip(tmp_path, params_path, extension, reduce_mem): + """Contract metacal-flag-width: neither output nor merging loses flag bits. + + Bit 30 marks an absent measurement; bit 3 alongside it must also survive. + Float64 inputs match ShapePipe's final catalogue. A float32 downcast loses + bit 3, while int16 and int8 conversions lose bit 30. + """ + with np.printoptions(): + params = runpy.run_path(str(ROOT / params_path)) + flags = np.array([0, 2**30, 2**30 | 8], dtype=np.float64) + bit_columns = ["NGMIX_MCAL_FLAGS"] + [ + f"NGMIX_FLAGS_{suffix}" for suffix in ("NOSHEAR", "1P", "1M", "2P", "2M") + ] + columns = {name: flags for name in bit_columns} + columns["NGMIX_MCAL_TYPES_FAIL"] = np.array([0, 5, 1]) + assert set(columns) <= set(params["add_cols_pre_cal"]) + path = tmp_path / f"comprehensive{extension}" + write_shape_catalog( + str(path), + np.zeros(3), + np.zeros(3), + np.ones(3), + add_cols=columns, + add_cols_format=params["add_cols_pre_cal_format"], + ) + if extension == ".fits": + written = fits.getdata(path, 1) + else: + with h5py.File(path, "r") as catalog: + written = catalog["data"][:] + + builder = JointCat() + builder._params["reduce_mem"] = reduce_mem + for name, expected in columns.items(): + values = written[name] + np.testing.assert_array_equal(values, expected, err_msg=name) + if name in bit_columns: + assert values.dtype.kind == "i" and values.dtype.itemsize == 8, name + reduced = values.astype(builder.dtype_out(name, values.dtype)) + np.testing.assert_array_equal(reduced, expected, err_msg=name) diff --git a/workflow/image_sims/params_im_sim.py b/workflow/image_sims/params_im_sim.py index 1cd395c6..c210dedf 100644 --- a/workflow/image_sims/params_im_sim.py +++ b/workflow/image_sims/params_im_sim.py @@ -148,7 +148,6 @@ for key in ( "NUMBER", "FLAGS", - "NGMIX_MCAL_FLAGS", "NGMIX_MCAL_TYPES_FAIL", "N_EPOCH", "NGMIX_N_EPOCH", @@ -157,6 +156,8 @@ add_cols_pre_cal_format["TILE_ID"] = "A7" add_cols_pre_cal_format["NUMBER"] = "J" +# Metacal bitmasks need bit 30 and native fitter bits, including after merging. +add_cols_pre_cal_format["NGMIX_MCAL_FLAGS"] = "K" # Create key names for metacal information prefix = "NGMIX" @@ -167,7 +168,7 @@ add_cols_pre_cal.append(f"{prefix}_{center}_{suffix}") for suffix in suffixes: - add_cols_pre_cal_format[f"FLAGS_{suffix}"] = "I" + add_cols_pre_cal_format[f"{prefix}_FLAGS_{suffix}"] = "K" # Catalog parameters From d4c12baf65eafa21aa7cba2732fc3455dcba793c Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 28 Sep 2026 05:06:28 +0200 Subject: [PATCH 2/3] catalog: metacal bitmasks as int32; reduce_mem never narrows integers ShapePipe sets bit 2**30 in the metacal flags for a missing measurement, which needs 32 bits. The parameter files now write NGMIX_MCAL_FLAGS and the five NGMIX_FLAGS_* columns as FITS J (int32). JointCat.dtype_out with reduce_mem narrowed every int32 column outside a keep-list to int8, silently wrapping any value above 127. It now only narrows float64 to float32 (RA/Dec excepted) and leaves integer columns alone. Co-Authored-By: Claude Opus 5.5 --- scripts/calibration/params.py | 6 ++--- src/sp_validation/catalog.py | 6 ++--- src/sp_validation/catalog_builders.py | 25 +++++++------------ .../tests/test_catalog_flag_roundtrip.py | 22 +++++++++++++--- workflow/image_sims/params_im_sim.py | 6 ++--- 5 files changed, 37 insertions(+), 28 deletions(-) diff --git a/scripts/calibration/params.py b/scripts/calibration/params.py index f20d3a1b..621d1a4b 100644 --- a/scripts/calibration/params.py +++ b/scripts/calibration/params.py @@ -151,8 +151,8 @@ add_cols_pre_cal_format["TILE_ID"] = "A7" add_cols_pre_cal_format["NUMBER"] = "J" -# Metacal bitmasks need bit 30 and native fitter bits, including after merging. -add_cols_pre_cal_format["NGMIX_MCAL_FLAGS"] = "K" +# Metacal bitmasks: ShapePipe sets bit 30 for no measurement, so they need 32 bits. +add_cols_pre_cal_format["NGMIX_MCAL_FLAGS"] = "J" # Create key names for metacal information prefix = "NGMIX" @@ -163,7 +163,7 @@ add_cols_pre_cal.append(f"{prefix}_{center}_{suffix}") for suffix in suffixes: - add_cols_pre_cal_format[f"{prefix}_FLAGS_{suffix}"] = "K" + add_cols_pre_cal_format[f"{prefix}_FLAGS_{suffix}"] = "J" # Catalog parameters diff --git a/src/sp_validation/catalog.py b/src/sp_validation/catalog.py index 40d4d418..a3b5551a 100644 --- a/src/sp_validation/catalog.py +++ b/src/sp_validation/catalog.py @@ -440,9 +440,9 @@ def write_shape_catalog( Write catalogue with galaxy shapes = shear estimates. @sc [label:convention] metacal-flag-width - The data and image-simulation parameter files supply FITS ``K`` for - metacal bitmasks so bit 30 and native fitter flags survive FITS/HDF5 - output and optional ``JointCat`` memory reduction. + The data and image-simulation parameter files supply FITS ``J`` (int32) + for metacal bitmasks, so ShapePipe's no-measurement bit 30 and the ngmix + fitter bits survive FITS/HDF5 output; ``JointCat`` never narrows integers. The number of failed metacal types is a count in [0, 5], not a bitmask. Parameters diff --git a/src/sp_validation/catalog_builders.py b/src/sp_validation/catalog_builders.py index 78dc7838..b6abafc0 100644 --- a/src/sp_validation/catalog_builders.py +++ b/src/sp_validation/catalog_builders.py @@ -437,26 +437,19 @@ def dtype_out(self, name, dtype_in): output dtype """ - # Specify columns for which original (high-precision) format - # needs to be kept and not reduced to lower precision - cols_keep_dtype = [ - "RA", - "Dec", - "FLAGS", - "IMAFLAGS_ISO", - "NUMBER", - ] if dtype_in.kind == "U": # Transform unicode to string of equal length return np.dtype(f"S{dtype_in.itemsize // 4}") - if self._params["reduce_mem"] == False: - return dtype_in - elif name not in cols_keep_dtype: - if dtype_in.kind == "f" and dtype_in.itemsize == 8: - return np.float32 - if dtype_in.kind == "i" and dtype_in.itemsize == 4: - return np.int8 + # reduce_mem narrows float64 to float32, except coordinates. Integer + # columns (bitmasks, IDs, counts) are never narrowed. + if ( + self._params["reduce_mem"] + and dtype_in.kind == "f" + and dtype_in.itemsize == 8 + and name not in ("RA", "Dec") + ): + return np.dtype(np.float32) return dtype_in diff --git a/src/sp_validation/tests/test_catalog_flag_roundtrip.py b/src/sp_validation/tests/test_catalog_flag_roundtrip.py index 02e27580..580d3f64 100644 --- a/src/sp_validation/tests/test_catalog_flag_roundtrip.py +++ b/src/sp_validation/tests/test_catalog_flag_roundtrip.py @@ -25,8 +25,8 @@ def test_metacal_flags_roundtrip(tmp_path, params_path, extension, reduce_mem): """Contract metacal-flag-width: neither output nor merging loses flag bits. Bit 30 marks an absent measurement; bit 3 alongside it must also survive. - Float64 inputs match ShapePipe's final catalogue. A float32 downcast loses - bit 3, while int16 and int8 conversions lose bit 30. + Float64 inputs match ShapePipe's final catalogue. Bit 30 needs int32: + int16 or int8 storage drops it, and a float32 downcast drops bit 3. """ with np.printoptions(): params = runpy.run_path(str(ROOT / params_path)) @@ -58,6 +58,22 @@ def test_metacal_flags_roundtrip(tmp_path, params_path, extension, reduce_mem): values = written[name] np.testing.assert_array_equal(values, expected, err_msg=name) if name in bit_columns: - assert values.dtype.kind == "i" and values.dtype.itemsize == 8, name + assert values.dtype.kind == "i" and values.dtype.itemsize == 4, name reduced = values.astype(builder.dtype_out(name, values.dtype)) np.testing.assert_array_equal(reduced, expected, err_msg=name) + + +@pytest.mark.parametrize("reduce_mem", [False, True]) +def test_reduce_mem_never_narrows_integers(reduce_mem): + """reduce_mem narrows float64 (except RA/Dec) and leaves integers intact.""" + builder = JointCat() + builder._params["reduce_mem"] = reduce_mem + for dtype in (">i4", "i8", "f8")) == np.dtype(">f8") + expected = np.float32 if reduce_mem else np.dtype(">f8") + assert builder.dtype_out("NGMIX_G1_NOSHEAR", np.dtype(">f8")) == expected diff --git a/workflow/image_sims/params_im_sim.py b/workflow/image_sims/params_im_sim.py index c210dedf..9eae8585 100644 --- a/workflow/image_sims/params_im_sim.py +++ b/workflow/image_sims/params_im_sim.py @@ -156,8 +156,8 @@ add_cols_pre_cal_format["TILE_ID"] = "A7" add_cols_pre_cal_format["NUMBER"] = "J" -# Metacal bitmasks need bit 30 and native fitter bits, including after merging. -add_cols_pre_cal_format["NGMIX_MCAL_FLAGS"] = "K" +# Metacal bitmasks: ShapePipe sets bit 30 for no measurement, so they need 32 bits. +add_cols_pre_cal_format["NGMIX_MCAL_FLAGS"] = "J" # Create key names for metacal information prefix = "NGMIX" @@ -168,7 +168,7 @@ add_cols_pre_cal.append(f"{prefix}_{center}_{suffix}") for suffix in suffixes: - add_cols_pre_cal_format[f"{prefix}_FLAGS_{suffix}"] = "K" + add_cols_pre_cal_format[f"{prefix}_FLAGS_{suffix}"] = "J" # Catalog parameters From d7109b773076585e4dbfba4a25f5f3abc50d3891 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Mon, 28 Sep 2026 17:24:37 +0200 Subject: [PATCH 3/3] catalog: justify int32 metacal flags by ngmix bits 0-15, not bit 30 The flag columns stay J: ngmix's ZERO_DOF (2**15) overflows a signed 16-bit I. Tests round-trip 0, 2**12, 2**15, a combination and all 16 bits instead of 2**30. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uemfjv9ybCwtKtprksZbVY --- scripts/calibration/params.py | 2 +- src/sp_validation/catalog.py | 5 +++-- .../tests/test_catalog_flag_roundtrip.py | 17 +++++++++-------- workflow/image_sims/params_im_sim.py | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/scripts/calibration/params.py b/scripts/calibration/params.py index 621d1a4b..7f6d7c41 100644 --- a/scripts/calibration/params.py +++ b/scripts/calibration/params.py @@ -151,7 +151,7 @@ add_cols_pre_cal_format["TILE_ID"] = "A7" add_cols_pre_cal_format["NUMBER"] = "J" -# Metacal bitmasks: ShapePipe sets bit 30 for no measurement, so they need 32 bits. +# Metacal bitmasks hold ngmix flag bits 0-15; bit 15 overflows signed 16-bit I. add_cols_pre_cal_format["NGMIX_MCAL_FLAGS"] = "J" # Create key names for metacal information diff --git a/src/sp_validation/catalog.py b/src/sp_validation/catalog.py index a3b5551a..76022243 100644 --- a/src/sp_validation/catalog.py +++ b/src/sp_validation/catalog.py @@ -441,8 +441,9 @@ def write_shape_catalog( @sc [label:convention] metacal-flag-width The data and image-simulation parameter files supply FITS ``J`` (int32) - for metacal bitmasks, so ShapePipe's no-measurement bit 30 and the ngmix - fitter bits survive FITS/HDF5 output; ``JointCat`` never narrows integers. + for metacal bitmasks, so all ngmix flag bits (0-15; ``ZERO_DOF`` = 2**15 + overflows a signed 16-bit ``I``) survive FITS/HDF5 output; ``JointCat`` + never narrows integers. The number of failed metacal types is a count in [0, 5], not a bitmask. Parameters diff --git a/src/sp_validation/tests/test_catalog_flag_roundtrip.py b/src/sp_validation/tests/test_catalog_flag_roundtrip.py index 580d3f64..ea3cac97 100644 --- a/src/sp_validation/tests/test_catalog_flag_roundtrip.py +++ b/src/sp_validation/tests/test_catalog_flag_roundtrip.py @@ -24,25 +24,26 @@ def test_metacal_flags_roundtrip(tmp_path, params_path, extension, reduce_mem): """Contract metacal-flag-width: neither output nor merging loses flag bits. - Bit 30 marks an absent measurement; bit 3 alongside it must also survive. - Float64 inputs match ShapePipe's final catalogue. Bit 30 needs int32: - int16 or int8 storage drops it, and a float32 downcast drops bit 3. + Values are ngmix flag bits: none, LM_FUNC_NOTFINITE (2**12), ZERO_DOF + (2**15), and every bit 0-15 at once. Float64 inputs match ShapePipe's final + catalogue. Bit 15 overflows signed int16, and int8 wraps everything above + 127, so the columns must stay int32. """ with np.printoptions(): params = runpy.run_path(str(ROOT / params_path)) - flags = np.array([0, 2**30, 2**30 | 8], dtype=np.float64) + flags = np.array([0, 2**12, 2**15, 2**15 | 2**12 | 8, 2**16 - 1], dtype=np.float64) bit_columns = ["NGMIX_MCAL_FLAGS"] + [ f"NGMIX_FLAGS_{suffix}" for suffix in ("NOSHEAR", "1P", "1M", "2P", "2M") ] columns = {name: flags for name in bit_columns} - columns["NGMIX_MCAL_TYPES_FAIL"] = np.array([0, 5, 1]) + columns["NGMIX_MCAL_TYPES_FAIL"] = np.array([0, 1, 5, 2, 5]) assert set(columns) <= set(params["add_cols_pre_cal"]) path = tmp_path / f"comprehensive{extension}" write_shape_catalog( str(path), - np.zeros(3), - np.zeros(3), - np.ones(3), + np.zeros(5), + np.zeros(5), + np.ones(5), add_cols=columns, add_cols_format=params["add_cols_pre_cal_format"], ) diff --git a/workflow/image_sims/params_im_sim.py b/workflow/image_sims/params_im_sim.py index 9eae8585..b7db399e 100644 --- a/workflow/image_sims/params_im_sim.py +++ b/workflow/image_sims/params_im_sim.py @@ -156,7 +156,7 @@ add_cols_pre_cal_format["TILE_ID"] = "A7" add_cols_pre_cal_format["NUMBER"] = "J" -# Metacal bitmasks: ShapePipe sets bit 30 for no measurement, so they need 32 bits. +# Metacal bitmasks hold ngmix flag bits 0-15; bit 15 overflows signed 16-bit I. add_cols_pre_cal_format["NGMIX_MCAL_FLAGS"] = "J" # Create key names for metacal information