From bcaa7e959d2ed20787e73d1f2cd7d5d456e366ae Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Fri, 17 Jul 2026 14:54:44 -0400 Subject: [PATCH 1/9] Fix bad docs on DetectCoaddSourceTask return value. --- python/lsst/pipe/tasks/multiBand.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/python/lsst/pipe/tasks/multiBand.py b/python/lsst/pipe/tasks/multiBand.py index 7608510a5..0da2b434e 100644 --- a/python/lsst/pipe/tasks/multiBand.py +++ b/python/lsst/pipe/tasks/multiBand.py @@ -313,10 +313,13 @@ def run(self, exposure, idFactory, expId, patchInfo=None): result : `lsst.pipe.base.Struct` Results as a struct with attributes: - ``sources`` + ``outputSources`` Catalog of detections (`lsst.afw.table.SourceCatalog`). - ``backgrounds`` + ``outputBackgrounds`` List of backgrounds (`list`). + ``outputExposure`` + The background-subtracted coadd image, with its mask plane + updated to include detections. """ if self.config.forceExactBinning: exposure = self._cropToExactBinning(exposure, patchInfo) From d5d802fd0db6937916f6879569acf584f8d37529 Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Fri, 17 Jul 2026 15:10:22 -0400 Subject: [PATCH 2/9] Move error annotation before butler.put. Adding error information to metadata doesn't do much if we write it to disk first. --- python/lsst/pipe/tasks/multiBand.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/lsst/pipe/tasks/multiBand.py b/python/lsst/pipe/tasks/multiBand.py index 0da2b434e..d36dbf3f6 100644 --- a/python/lsst/pipe/tasks/multiBand.py +++ b/python/lsst/pipe/tasks/multiBand.py @@ -277,13 +277,13 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs): if maskName in exposure.mask.getMaskPlaneDict().keys(): detectedMask = exposure.mask.getMaskPlane(maskName) exposure.mask.clearMaskPlane(detectedMask) - butlerQC.put(exposure, outputRefs.outputExposure) error = AnnotatedPartialOutputsError.annotate( e, self, exposure, log=self.log, ) + butlerQC.put(exposure, outputRefs.outputExposure) raise error from e butlerQC.put(outputs, outputRefs) From 500508882f044e1b87a937e04713b2f98acd7afc Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Fri, 17 Jul 2026 15:33:47 -0400 Subject: [PATCH 3/9] Update DetectCoaddSourcesTask to work with lsst.images. --- python/lsst/pipe/tasks/multiBand.py | 123 +++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 13 deletions(-) diff --git a/python/lsst/pipe/tasks/multiBand.py b/python/lsst/pipe/tasks/multiBand.py index d36dbf3f6..298ef6b3f 100644 --- a/python/lsst/pipe/tasks/multiBand.py +++ b/python/lsst/pipe/tasks/multiBand.py @@ -23,6 +23,9 @@ "MeasureMergedCoaddSourcesConfig", "MeasureMergedCoaddSourcesTask", ] +import dataclasses + +import astropy.units import numpy as np from lsst.geom import Extent2I @@ -34,7 +37,10 @@ PipelineTaskConnections ) import lsst.pipe.base.connectionTypes as cT -from lsst.pex.config import Field, ConfigurableField, ChoiceField +from lsst.pex.config import Field, ChoiceField, ConfigurableField +from lsst.images import Mask, get_legacy_deep_coadd_mask_planes +from lsst.images.cells import CellCoadd +from lsst.images.fields import field_from_legacy_background from lsst.meas.algorithms import ( DynamicDetectionTask, ExceedsMaxVarianceScaleError, @@ -93,13 +99,13 @@ class DetectCoaddSourcesConnections(PipelineTaskConnections, storageClass="SourceCatalog", ) exposure = cT.Input( - doc="Exposure on which detections are to be performed. ", + doc="Exposure on which detections are to be performed (if useCellCoadd=False). ", name="{inputCoaddName}Coadd", storageClass="ExposureF", dimensions=("tract", "patch", "band", "skymap") ) exposure_cells = cT.Input( - doc="Exposure on which detections are to be performed. ", + doc="Exposure on which detections are to be performed (if useCellCoadd=True). ", name="{inputCoaddName}CoaddCell", storageClass="MultipleCellCoadd", dimensions=("tract", "patch", "band", "skymap"), @@ -133,11 +139,17 @@ def __init__(self, *, config=None): super().__init__(config=config) assert isinstance(config, DetectCoaddSourcesConfig) + if config.imageType == "future": + if config.useCellCoadds: + self.exposure_cells = dataclasses.replace(self.exposure_cells, storageClass="CellCoadd") + else: + self.exposure = dataclasses.replace(self.exposure, storageClass="CellCoadd") + self.outputExposure = dataclasses.replace(self.outputExposure, storageClass="CellCoadd") + del self.outputBackgrounds if config.useCellCoadds: del self.exposure else: del self.exposure_cells - if not self.config.forceExactBinning: del self.skyMap if self.config.writeOnlyBackgrounds: @@ -179,6 +191,45 @@ class DetectCoaddSourcesConfig(PipelineTaskConfig, pipelineConnections=DetectCoa "backgrounds from multiple patches as input." ) ) + imageType = ChoiceField( + "Which image type to use for the input and output coadd. " + "This option only directly affects connection storage classes and hence 'runQuantum'; the 'run' " + "method behavior is determined by which type is actually passed in.", + allowed={ + "legacy": ( + "Read a lsst.cell_coadds.MultipleCellCoadd (if useCellCoadd) or " + "lsst.afw.image.Exposure (if not useCellCoadd) and write an lsst.afw.image.Exposure." + ), + "future": ( + "Read and write lsst.images.cells.CellCoadd (useCellCoadd just " + "sets which of 'exposure' or 'exposure_cells' will be used for inputs). " + "The 'outputBackground' is deleted, writeEmptyBackgrounds is ignored, and " + "writeOnlyBackgrounds=True is invalid." + ), + }, + dtype=str, + optional=False, + default="legacy", + ) + backgroundName = Field( + "Name of the subtracted background, to be stored with the image when the input and " + "output images are lsst.images.cells.CellCoadd.", + dtype=str, + default="object", + ) + backgroundDescription = Field( + "Description of the subtracted background, to be stored with the image when the input and " + "output images are lsst.images.cells.CellCoadd.", + dtype=str, + default=( + "Background subtracted from the image when generating the Object catalog. " + "This intentionally oversubtracts the background to reduce blending and ensure " + "scattered light is subtracted. " + "Restoring this background does not restore all original backgrounds, " + "as the coadd was built from background-subtracted visit images; in most " + "cases this background term is actually quite small." + ), + ) def setDefaults(self): super().setDefaults() @@ -194,6 +245,16 @@ def setDefaults(self): # many bands as are defined", rather than the default of zero). self.idGenerator.packer.n_bands = None + def validate(self): + super().validate() + if self.imageType == "future": + if self.doScaleVariance: + raise ValueError("doScaleVariance=True is not compatible with imageType='future'") + if self.forceExactBinning: + raise ValueError("forceExactBinning=True is not compatible with imageType='future'") + if self.writeOnlyBackgrounds: + raise ValueError("writeOnlyBackgrounds=True is not compatible with imageType='future'") + class DetectCoaddSourcesTask(PipelineTask): """Detect sources on a single filter coadd. @@ -245,7 +306,13 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs): if self.config.useCellCoadds: multiple_cell_coadd = inputs.pop("exposure_cells") - exposure = multiple_cell_coadd.stitch().asExposure() + match self.config.imageType: + case "legacy": + exposure = multiple_cell_coadd.stitch().asExposure() + case "future": + exposure = multiple_cell_coadd # conversion deferred to run(). + case _: + raise AssertionError(f"Invalid choice {self.config.imageType!r} for imageType.") else: exposure = inputs.pop("exposure") @@ -272,11 +339,20 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs): ) as e: if self.config.writeEmptyBackgrounds: butlerQC.put(self._makeEmptyBackground(exposure, patchInfo), outputRefs.outputBackgrounds) - # Detection failed, so clear any leftover the detected mask planes. - for maskName in ["DETECTED", "DETECTED_NEGATIVE"]: - if maskName in exposure.mask.getMaskPlaneDict().keys(): - detectedMask = exposure.mask.getMaskPlane(maskName) - exposure.mask.clearMaskPlane(detectedMask) + # Detection failed, so clear any leftover detected mask planes. + if isinstance(exposure, CellCoadd): + # If we passed a CellCoadd in, it won't be modified until 'run' + # is about to exit, and hence it can't have picked up a + # DETECTED_NEGATIVE plane, which can only be temporary here. + # But it might have a preexisting DETECTED plane (i.e. a union + # of the DETECTED plane from the warps) that we'd still want to + # clear. + exposure.mask.clear("DETECTED") + else: + for maskName in ["DETECTED", "DETECTED_NEGATIVE"]: + if maskName in exposure.mask.getMaskPlaneDict().keys(): + detectedMask = exposure.mask.getMaskPlane(maskName) + exposure.mask.clearMaskPlane(detectedMask) error = AnnotatedPartialOutputsError.annotate( e, self, @@ -297,7 +373,7 @@ def run(self, exposure, idFactory, expId, patchInfo=None): Parameters ---------- - exposure : `lsst.afw.image.Exposure` + exposure : `lsst.afw.image.Exposure` or `lsst.images.cells.CellCoadd`. Exposure on which to detect (may be background-subtracted and scaled, depending on configuration). idFactory : `lsst.afw.table.IdFactory` @@ -319,11 +395,20 @@ def run(self, exposure, idFactory, expId, patchInfo=None): List of backgrounds (`list`). ``outputExposure`` The background-subtracted coadd image, with its mask plane - updated to include detections. + updated to include detections. This will have the same type + as ``exposure``. """ + cell_coadd = None + if isinstance(exposure, CellCoadd): + cell_coadd = exposure + exposure = cell_coadd.to_legacy() if self.config.forceExactBinning: + if cell_coadd is not None: + raise ValueError("forceExactBinning=True is not compatible with CellCoadd inputs") exposure = self._cropToExactBinning(exposure, patchInfo) if self.config.doScaleVariance: + if cell_coadd is not None: + raise ValueError("doScaleVariance=True is not compatible with CellCoadd inputs") varScale = self.scaleVariance.run(exposure.maskedImage) exposure.getMetadata().add("VARIANCE_SCALE", varScale) backgrounds = afwMath.BackgroundList() @@ -338,7 +423,19 @@ def run(self, exposure, idFactory, expId, patchInfo=None): # inability to persist empty BackgroundList. emptyBg = self._makeEmptyBackground(exposure, patchInfo) backgrounds.append(emptyBg) - + if cell_coadd is not None: + cell_coadd.image.array[...] = exposure.image.array + cell_coadd.mask = Mask.from_legacy( + exposure.mask, plane_map=get_legacy_deep_coadd_mask_planes() + ).view(sky_projection=cell_coadd.sky_projection) + if backgrounds: + cell_coadd.backgrounds.add( + self.config.backgroundName, + field_from_legacy_background(backgrounds, unit=astropy.units.nJy), + self.config.backgroundDescription, + is_subtracted=True, + ) + exposure = cell_coadd return Struct(outputSources=sources, outputBackgrounds=backgrounds, outputExposure=exposure) def _cropToExactBinning(self, exposure, patchInfo): From 0c3f574ec0f25c7d3166dd6a76ea876cfa45bc1f Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Sun, 19 Jul 2026 14:36:20 -0400 Subject: [PATCH 4/9] Update DeblendCoaddSourcesTask to work with lsst.images. --- .../pipe/tasks/deblendCoaddSourcesPipeline.py | 103 +++++++++++++++--- 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/python/lsst/pipe/tasks/deblendCoaddSourcesPipeline.py b/python/lsst/pipe/tasks/deblendCoaddSourcesPipeline.py index 6c8646fd8..d1cc63727 100644 --- a/python/lsst/pipe/tasks/deblendCoaddSourcesPipeline.py +++ b/python/lsst/pipe/tasks/deblendCoaddSourcesPipeline.py @@ -21,17 +21,21 @@ __all__ = ["DeblendCoaddSourcesMultiConfig", "DeblendCoaddSourcesMultiTask"] +import dataclasses + import numpy as np from lsst.pipe.base import PipelineTask, PipelineTaskConfig, PipelineTaskConnections import lsst.pipe.base.connectionTypes as cT -from lsst.pex.config import ConfigurableField, Field +from lsst.pex.config import ChoiceField, ConfigurableField, Field from lsst.meas.base import SkyMapIdGeneratorConfig from lsst.meas.extensions.scarlet import ScarletDeblendTask import lsst.afw.image as afwImage import lsst.afw.table as afwTable +import lsst.images as imgs +from lsst.images.cells import CellCoadd from .coaddBase import reorderRefs @@ -132,13 +136,16 @@ def __init__(self, *, config=None): super().__init__(config=config) del self.fluxCatalogs del self.templateCatalogs - - if config: - if config.useCellCoadds: - del self.coadds - else: - del self.coadds_cell - del self.backgrounds + if self.config.imageType == "future": + self.coadds = dataclasses.replace(self.coadds, storageClass="CellCoadd") + self.deconvolvedCoadds = dataclasses.replace(self.deconvolvedCoadds, storageClass="MaskedImageV2") + del self.coadds_cell + del self.backgrounds + elif self.config.useCellCoadds: + del self.coadds + else: + del self.coadds_cell + del self.backgrounds class DeblendCoaddSourcesMultiConfig(PipelineTaskConfig, @@ -152,6 +159,25 @@ class DeblendCoaddSourcesMultiConfig(PipelineTaskConfig, doc="Task to deblend an images in multiple bands" ) idGenerator = SkyMapIdGeneratorConfig.make_field() + imageType = ChoiceField( + "Which image type to expect for the input coadds. " + "This option only directly affects connection storage classes and hence 'runQuantum'; the 'run' " + "method behavior is determined by which type is actually passed in.", + allowed={ + "legacy": ( + "Read a lsst.cell_coadds.MultipleCellCoadd via 'coadds_cells` and restore 'background' " + "(if useCellCoadd) or lsst.afw.image.Exposure via `coadds` (if not useCellCoadd), and read " + "lsst.afw.image.Exposure via 'deconvolvedCoadds'." + ), + "future": ( + "Read lsst.images.cells.CellCoadd via 'coadds' and lsst.images.MaskedImage via " + "'deconvolvedCoadds'. The useCellCoadds options is ignored." + ), + }, + dtype=str, + optional=False, + default="legacy", + ) class DeblendCoaddSourcesMultiTask(PipelineTask): @@ -181,17 +207,25 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs): inputs = butlerQC.get(inputRefs) bands = [dRef.dataId["band"] for dRef in deconvolvedRefs] mergedDetections = inputs.pop("mergedDetections") - if self.config.useCellCoadds: - exposures = [mcc.stitch().asExposure() for mcc in inputs.pop("coadds_cell")] - backgrounds = inputs.pop("backgrounds") - for exposure, background in zip(exposures, backgrounds): - exposure.image -= background.getImage() - coadds = exposures - else: - coadds = inputs.pop("coadds") + match self.config.imageType: + case "legacy": + if self.config.useCellCoadds: + exposures = [mcc.stitch().asExposure() for mcc in inputs.pop("coadds_cell")] + backgrounds = inputs.pop("backgrounds") + for exposure, background in zip(exposures, backgrounds): + exposure.image -= background.getImage() + coadds = exposures + coaddRefs = inputRefs.coadds_cell + else: + coadds = inputs.pop("coadds") + coaddRefs = inputRefs.coadds + case "future": + coadds = inputs.pop("coadds") # conversion deferred to run(). + coaddRefs = inputRefs.coadds + case _: + raise AssertionError(f"Invalid choice {self.config.imageType!r} for imageType.") # Ensure that the coadd bands and deconvolved coadd bands match - coaddRefs = inputRefs.coadds_cell if self.config.useCellCoadds else inputRefs.coadds coaddBands = [dRef.dataId["band"] for dRef in coaddRefs] if bands != coaddBands: self.log.error("Coadd bands %s != deconvolved coadd bands %s", bands, coaddBands) @@ -215,12 +249,47 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs): butlerQC.put(outputs, outputRefs) def run(self, coadds, bands, mergedDetections, deconvolvedCoadds, idFactory): + """Deblend coadds from multiple bands together. + + Parameters + ---------- + coadds : `list` [`lsst.afw.image.Exposure` | \ + `lsst.images.cells.CellCoadd`] + Coadds to deblend. + bands : `list` [`str`] + Names or the bands for ``coadds`` (zip-iteration compatible). + mergedDetections : `lsst.afw.table.SourceCatalog` + Input catalog of detections, already merged across bands. + deconvolvedCoadds : `list` [`lsst.afw.image.Exposure` | \ + `lsst.images.MaskedImage`] + Deconvolved versions of ``coadds`` (zip-iteration compatible). + idFactory : `lsst.afw.table.IdFactory` + Factory used to generate output source IDs. + + Returns + ------- + struct : `lsst.pipe.base.Struct` + Unmodified outputs of the ``multibandDeblend`` subtask. + """ + coadds = [c.to_legacy() if isinstance(c, CellCoadd) else c for c in coadds] + deconvolvedCoadds = [self._coerceDeconvolvedInput(d, c) for d, c in zip(deconvolvedCoadds, coadds)] sources = self._makeSourceCatalog(mergedDetections, idFactory) multiExposure = afwImage.MultibandExposure.fromExposures(bands, coadds) mDeconvolved = afwImage.MultibandExposure.fromExposures(bands, deconvolvedCoadds) result = self.multibandDeblend.run(multiExposure, mDeconvolved, sources) return result + def _coerceDeconvolvedInput( + self, deconvolved: afwImage.Exposure | imgs.MaskedImage, coadd: afwImage.Exposure + ) -> afwImage.Exposure: + if isinstance(deconvolved, imgs.MaskedImage): + deconvolved = afwImage.Exposure( + maskedImage=deconvolved.to_legacy(plane_map=imgs.get_legacy_deep_coadd_mask_planes()), + exposureInfo=coadd.getInfo(), + dtype=deconvolved.image.array.dtype, + ) + return deconvolved + def _makeSourceCatalog(self, mergedDetections, idFactory): # There may be gaps in the mergeDet catalog, which will cause the # source ids to be inconsistent. So we update the id factory From 2f0c916aea8bab5906fb4ab03d00fff6470fb631 Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Mon, 20 Jul 2026 11:45:00 -0400 Subject: [PATCH 5/9] Update MeasureMergedCoaddSourcesTask to work with lsst.images. Unlike other changes so far on this ticket, this one only modifies 'runQuantum'/connections and leaves 'run' only supporting Exposure as input. Fixing that would make everything a lot messier unless we do a lot of work, like making cell-coadd aperture corrections a true BoundedField and/or dropping MultipleCellCoadd as a supported connection type (RFC-1193). --- python/lsst/pipe/tasks/multiBand.py | 61 ++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/python/lsst/pipe/tasks/multiBand.py b/python/lsst/pipe/tasks/multiBand.py index 298ef6b3f..86a5a7332 100644 --- a/python/lsst/pipe/tasks/multiBand.py +++ b/python/lsst/pipe/tasks/multiBand.py @@ -38,6 +38,7 @@ ) import lsst.pipe.base.connectionTypes as cT from lsst.pex.config import Field, ChoiceField, ConfigurableField +from lsst.cell_coadds import MultipleCellCoadd from lsst.images import Mask, get_legacy_deep_coadd_mask_planes from lsst.images.cells import CellCoadd from lsst.images.fields import field_from_legacy_background @@ -699,7 +700,11 @@ def __init__(self, *, config=None): if not config.doWriteMatchesDenormalized: del self.denormMatches - if config.useCellCoadds: + if self.config.imageType == "future": + self.exposure = dataclasses.replace(self.exposure, storageClass="CellCoadd") + del self.exposure_cells + del self.background + elif self.config.useCellCoadds: del self.exposure else: del self.exposure_cells @@ -793,6 +798,23 @@ class MeasureMergedCoaddSourcesConfig(PipelineTaskConfig, doc="Should be set to True if fake sources have been inserted into the input data." ) idGenerator = SkyMapIdGeneratorConfig.make_field() + imageType = ChoiceField( + "Which image type to expect for the input coadd. " + "This option only directly affects connection storage classes and hence 'runQuantum'; the 'run' " + "method behavior is determined by which type is actually passed in.", + allowed={ + "legacy": ( + "Read a lsst.cell_coadds.MultipleCellCoadd via 'exposure_cells` and restore 'background' " + "(if useCellCoadd) or lsst.afw.image.Exposure via `exposure` (if not useCellCoadd)." + ), + "future": ( + "Read lsst.images.cells.CellCoadd via the 'exposure' connection. useCellCoadd is ignored." + ), + }, + dtype=str, + optional=False, + default="legacy", + ) @property def refObjLoader(self): @@ -903,26 +925,37 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs): config=self.config.refObjLoader, log=self.log) self.match.setRefObjLoader(refObjLoader) - - if self.config.useCellCoadds: - multiple_cell_coadd = inputs.pop("exposure_cells") - stitched_coadd = multiple_cell_coadd.stitch() + if self.config.imageType == "future": + coadd = inputs.pop("exposure") + band = inputRefs.exposure.dataId["band"] + # Instead of going directly from lsst.images.cells.CellCoadd to + # Exposure, it's cleaner for now to go through MultipleCellCoadd + # because the apCorrMap and ccdInputs need special handling - the + # cell-based versions can't be attached to Exposure. Eventually + # we'll rewrite the lower-level code to use the lsst.images + # equivalents natively. + coadd = coadd.to_legacy_cell_coadd() + elif self.config.useCellCoadds: + coadd = inputs.pop("exposure_cells") + band = inputRefs.exposure_cells.dataId["band"] + else: + coadd = inputs.pop("exposure") + band = inputRefs.exposure.dataId["band"] + if isinstance(coadd, MultipleCellCoadd): + stitched_coadd = coadd.stitch() exposure = stitched_coadd.asExposure() - background = inputs.pop("background") - exposure.image -= background.getImage() - + if self.config.imageType == "legacy": + background = inputs.pop("background") + exposure.image -= background.getImage() ccdInputs = stitched_coadd.ccds apCorrMap = stitched_coadd.ap_corr_map - band = inputRefs.exposure_cells.dataId["band"] else: - exposure = inputs.pop("exposure") - # Set psfcache - # move this to run after gen2 deprecation + exposure = coadd + # Set psfcache only when we don't have a cell-based coadd. exposure.getPsf().setCacheCapacity(self.config.psfCache) ccdInputs = exposure.getInfo().getCoaddInputs().ccds apCorrMap = exposure.getInfo().getApCorrMap() - band = inputRefs.exposure.dataId["band"] # Get unique integer ID for IdFactory and RNG seeds; only the latter # should really be used as the IDs all come from the input catalog. @@ -1016,7 +1049,7 @@ def run(self, exposure, sources, skyInfo, exposureId, ccdInputs=None, Parameters ---------- - exposure : `lsst.afw.exposure.Exposure` + exposure : `lsst.afw.image.Exposure` The input exposure on which measurements are to be performed. sources : `lsst.afw.table.SourceCatalog` A catalog built from the results of merged detections, or From 8ca1fce86e28f0e7596aa9e113c3bc46957082cb Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Mon, 20 Jul 2026 13:49:44 -0400 Subject: [PATCH 6/9] Update multiprofit base tasks to work with lsst.images. This only touches the connection/runQuantum level; run still only works on lsst.afw.image.Exposure. --- python/lsst/pipe/tasks/fit_coadd_multiband.py | 30 +++++++++++++++++-- python/lsst/pipe/tasks/fit_coadd_psf.py | 29 ++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/python/lsst/pipe/tasks/fit_coadd_multiband.py b/python/lsst/pipe/tasks/fit_coadd_multiband.py index ca4f03a0c..ab8ae1b64 100644 --- a/python/lsst/pipe/tasks/fit_coadd_multiband.py +++ b/python/lsst/pipe/tasks/fit_coadd_multiband.py @@ -34,6 +34,7 @@ import lsst.pipe.base.connectionTypes as cT import astropy.table +import dataclasses from abc import ABC, abstractmethod from pydantic import Field from pydantic.dataclasses import dataclass @@ -210,7 +211,11 @@ def __init__(self, *, config=None): if config.drop_psf_connection: del self.models_psf - if config.use_cell_coadds: + if config.image_type == "future": + self.coadds = dataclasses.replace(self.coadds, storageClass="CellCoadd") + del self.coadds_cell + del self.backgrounds + elif config.use_cell_coadds: del self.coadds else: del self.coadds_cell @@ -316,6 +321,23 @@ class CoaddMultibandFitBaseConfig( default=False, ) idGenerator = SkyMapIdGeneratorConfig.make_field() + image_type = pexConfig.ChoiceField( + "Which image type to expect for the input coadd. " + "This option only directly affects connection storage classes and hence 'runQuantum'; the 'run' " + "method behavior is determined by which type is actually passed in.", + allowed={ + "legacy": ( + "Read a lsst.cell_coadds.MultipleCellCoadd via 'coadds_cell` and restore 'background' " + "(if use_cell_coadd) or lsst.afw.image.Exposure via `coadds` (if not use_cell_coadd)." + ), + "future": ( + "Read lsst.images.cells.CellCoadd via the 'coadds' connection. use_cell_coadd is ignored." + ), + }, + dtype=str, + optional=False, + default="legacy", + ) def get_band_sets(self): """Get the set of bands required by the fit_coadd_multiband subtask. @@ -352,7 +374,7 @@ class CoaddMultibandFitBase: def build_catexps(self, butlerQC, inputRefs, inputs) -> list[CatalogExposureInputs]: id_tp = self.config.idGenerator.apply(butlerQC.quantum.dataId).catalog_id # This is a roundabout way of ensuring all inputs get sorted and matched - if self.config.use_cell_coadds: + if self.config.use_cell_coadds and self.config.image_type == "legacy": keys = ["cats_meas", "coadds_cell", "backgrounds"] else: keys = ["cats_meas", "coadds"] @@ -365,7 +387,9 @@ def build_catexps(self, butlerQC, inputRefs, inputs) -> list[CatalogExposureInpu for key, (refs, objs) in input_refs_objs.items() } cats = inputs_sorted["cats_meas"] - if self.config.use_cell_coadds: + if self.config.image_type == "future": + exps = {data_id: coadd.to_legacy() for data_id, coadd in inputs_sorted["coadds"].items()} + elif self.config.use_cell_coadds: exps = {} for data_id, background in inputs_sorted["backgrounds"].items(): mcc = inputs_sorted["coadds_cell"][data_id] diff --git a/python/lsst/pipe/tasks/fit_coadd_psf.py b/python/lsst/pipe/tasks/fit_coadd_psf.py index 9a54b288e..0bbde0fc2 100644 --- a/python/lsst/pipe/tasks/fit_coadd_psf.py +++ b/python/lsst/pipe/tasks/fit_coadd_psf.py @@ -31,6 +31,7 @@ import lsst.pipe.base as pipeBase import lsst.pipe.base.connectionTypes as cT +import dataclasses from abc import ABC, abstractmethod from pydantic.dataclasses import dataclass @@ -93,7 +94,11 @@ def __init__(self, *, config=None): if config is None: return - if config.use_cell_coadds: + if config.image_type == "future": + self.coadd = dataclasses.replace(self.coadd, storageClass="CellCoadd") + del self.coadd_cell + del self.background + elif config.use_cell_coadds: del self.coadd else: del self.coadd_cell @@ -168,6 +173,23 @@ class CoaddPsfFitConfig( doc="Task to fit PSF models for a single coadd", ) idGenerator = SkyMapIdGeneratorConfig.make_field() + image_type = pexConfig.ChoiceField( + "Which image type to expect for the input coadd. " + "This option only directly affects connection storage classes and hence 'runQuantum'; the 'run' " + "method behavior is determined by which type is actually passed in.", + allowed={ + "legacy": ( + "Read a lsst.cell_coadds.MultipleCellCoadd via 'coadd_cell` and restore 'background' " + "(if use_cell_coadd) or lsst.afw.image.Exposure via `coadd` (if not use_cell_coadd)." + ), + "future": ( + "Read lsst.images.cells.CellCoadd via the 'coadd' connection. use_cell_coadd is ignored." + ), + }, + dtype=str, + optional=False, + default="legacy", + ) class CoaddPsfFitTask(pipeBase.PipelineTask): @@ -191,7 +213,10 @@ def runQuantum(self, butlerQC, inputRefs, outputRefs): id_tp = self.config.idGenerator.apply(butlerQC.quantum.dataId).catalog_id dataId = inputRefs.cat_meas.dataId - if self.config.use_cell_coadds: + if self.config.image_type == "future": + coaddDataRef = inputRefs.coadd + exposure = inputs.pop('coadd').to_legacy() + elif self.config.use_cell_coadds: coaddDataRef = inputRefs.coadd_cell multiple_cell_coadd = inputs.pop('coadd_cell') background = inputs.pop('background') From e95fdaf4ad63ce46b47afd7013ca6fd6fdcaf4d1 Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Thu, 23 Jul 2026 15:51:18 -0400 Subject: [PATCH 7/9] Warn instead of failing when a coadd lacks PixelFlags planes. In the long term, it would be better to un-configure those PixelFlags columns, but that changes the schema and hence isn't viable for something we want to backport to v30. --- python/lsst/pipe/tasks/multiBand.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/python/lsst/pipe/tasks/multiBand.py b/python/lsst/pipe/tasks/multiBand.py index 86a5a7332..1a7c9a707 100644 --- a/python/lsst/pipe/tasks/multiBand.py +++ b/python/lsst/pipe/tasks/multiBand.py @@ -1093,6 +1093,8 @@ def run(self, exposure, sources, skyInfo, exposureId, ccdInputs=None, for maskPlane in self.config.measurement.plugins["base_PixelFlags"].masksFpCenter: exposure.mask.addMaskPlane(maskPlane) + self._ensureMaskPlanes() + self.measurement.run(sources, exposure, exposureId=exposureId) if self.config.doApCorr: @@ -1146,3 +1148,22 @@ def run(self, exposure, sources, skyInfo, exposureId, ccdInputs=None, results.outputSources = sources return results + + def _ensureMaskPlanes(self): + """Ensure the global mask dictionary has all of the mask planes + needed for PixelFlags algorithms. + + When mask planes are added, this essentially guarantees that the + corresponding PixelFlags columns will be wholly False, and usually + we'd prefer to remove them from the configuration. But those config + changes imply a schema changes, and that's not always viable (e.g. on + a release branch). + """ + needed = set(self.measurement.plugins["base_PixelFlags"].config.masksFpCenter) + needed.update(self.measurement.plugins["base_PixelFlags"].config.masksFpAnywhere) + existing = afwImage.MaskX().getMaskPlaneDict().keys() + for plane in sorted(needed - existing): + self.log.warning( + "Adding mask plane %r with no pixel set to satisfy PixelFlags configuration.", plane + ) + afwImage.MaskX.addMaskPlane(plane) From 186478ce820fcf4253a46185f257f3e333d20dcf Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Tue, 4 Aug 2026 10:58:39 -0400 Subject: [PATCH 8/9] Stop reading coadd PhotoCalibs in healsparse map-making. --- python/lsst/pipe/tasks/healSparseMapping.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/lsst/pipe/tasks/healSparseMapping.py b/python/lsst/pipe/tasks/healSparseMapping.py index e576fe844..6f74a5ace 100644 --- a/python/lsst/pipe/tasks/healSparseMapping.py +++ b/python/lsst/pipe/tasks/healSparseMapping.py @@ -27,6 +27,7 @@ "ConsolidateHealSparsePropertyMapTask"] from collections import defaultdict +import astropy.units import esutil import warnings import numbers @@ -706,8 +707,9 @@ def run(self, sky_map, tract, band, coadd_dict, input_map_dict, visit_summary_di band, tract, patch) continue - coadd_photo_calib = coadd_dict[patch].get(component="photoCalib") - coadd_zeropoint = 2.5*np.log10(coadd_photo_calib.getInstFluxAtZeroMagnitude()) + # LSST coadds are now always in nJy, and the lsst.images formats + # don't even have a PhotoCalib anymore. + coadd_zeropoint = float((1.0 * astropy.units.nJy).to_value(astropy.units.ABmag)) # Crop input_map to the inner polygon of the patch poly_vertices = patch_info.getInnerSkyPolygon(tract_info.getWcs()).getVertices() From d17bba1671d0a299427981d5aa1ea117e10f5a66 Mon Sep 17 00:00:00 2001 From: Jim Bosch Date: Wed, 12 Aug 2026 12:58:00 -0400 Subject: [PATCH 9/9] Make the deep_coadd background docstring a module-level constant. --- python/lsst/pipe/tasks/multiBand.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/python/lsst/pipe/tasks/multiBand.py b/python/lsst/pipe/tasks/multiBand.py index 1a7c9a707..da96230b1 100644 --- a/python/lsst/pipe/tasks/multiBand.py +++ b/python/lsst/pipe/tasks/multiBand.py @@ -21,6 +21,7 @@ __all__ = ["DetectCoaddSourcesConfig", "DetectCoaddSourcesTask", "MeasureMergedCoaddSourcesConfig", "MeasureMergedCoaddSourcesTask", + "DEEP_COADD_BACKGROUND_DOCSTRING", ] import dataclasses @@ -91,6 +92,18 @@ ############################################################################################################## + +# The default for DetectCoaddSourcesConfig.backgroundDescription: +DEEP_COADD_BACKGROUND_DOCSTRING = ( + "Background subtracted from the image when generating the Object catalog. " + "This intentionally oversubtracts the background to reduce blending and ensure " + "scattered light is subtracted. " + "Restoring this background does not restore all original backgrounds, " + "as the coadd was built from background-subtracted visit images; in most " + "cases this background term is actually quite small " +) + + class DetectCoaddSourcesConnections(PipelineTaskConnections, dimensions=("tract", "patch", "band", "skymap"), defaultTemplates={"inputCoaddName": "deep", "outputCoaddName": "deep"}): @@ -222,14 +235,7 @@ class DetectCoaddSourcesConfig(PipelineTaskConfig, pipelineConnections=DetectCoa "Description of the subtracted background, to be stored with the image when the input and " "output images are lsst.images.cells.CellCoadd.", dtype=str, - default=( - "Background subtracted from the image when generating the Object catalog. " - "This intentionally oversubtracts the background to reduce blending and ensure " - "scattered light is subtracted. " - "Restoring this background does not restore all original backgrounds, " - "as the coadd was built from background-subtracted visit images; in most " - "cases this background term is actually quite small." - ), + default=DEEP_COADD_BACKGROUND_DOCSTRING, ) def setDefaults(self):