Coverage for tests/test_io_utils.py: 96%
152 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 09:10 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 09:10 +0000
1# This file is part of meas_extensions_scarlet.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""Tests for the helpers in ``lsst.meas.extensions.scarlet.io.utils``."""
24import unittest
25import warnings
27import lsst.meas.extensions.scarlet as mes
28import lsst.scarlet.lite as scl
29import lsst.utils.tests
30import numpy as np
31from lsst.afw.table import SourceCatalog, SourceTable
32from lsst.meas.extensions.scarlet.io.model_data import LsstScarletModelData
33from lsst.meas.extensions.scarlet.io.source_data import IsolatedSourceData
34from lsst.pipe.base import NoWorkFound
36import pipeline
37from scenes import SCENES
40class TestUpdateCatalogFootprints(lsst.utils.tests.TestCase):
41 """Tests for the empty-input guard in
42 ``lsst.meas.extensions.scarlet.io.updateCatalogFootprints``.
43 """
45 @staticmethod
46 def _empty_catalog():
47 # The empty-input guard returns/raises before the catalog is
48 # touched, so a bare minimal-schema catalog is enough.
49 return SourceCatalog(SourceTable.make(SourceTable.makeMinimalSchema()))
51 def test_update_catalog_footprints_empty_raises(self):
52 """``updateCatalogFootprints`` raises ``NoWorkFound`` when the
53 model data has neither blends nor isolated sources.
55 ``NoWorkFound`` is a ``lsst.pipe.base`` control-flow exception:
56 raising it short-circuits the quantum on empty input. The guard
57 previously *returned* the exception instead of raising it, so the
58 empty-input case slipped past every caller. Regression test for
59 finding C-2 of the ``audits/audit-2026-05-05.md`` audit.
60 """
61 modelData = LsstScarletModelData()
62 self.assertEqual(len(modelData.blends), 0)
63 self.assertEqual(len(modelData.isolated), 0)
65 with self.assertRaises(NoWorkFound):
66 mes.io.updateCatalogFootprints(
67 modelData, self._empty_catalog(), band="r"
68 )
70 def test_update_catalog_footprints_isolated_only_returns(self):
71 """With isolated sources but no blends, the function returns
72 ``None`` without raising — there is nothing to hydrate.
74 Pins the branch adjacent to the C-2 guard: an isolated-only
75 model (which happens for fields with only u-band images) is a
76 valid no-op, not an empty-input error.
77 """
78 isolated = {
79 1: IsolatedSourceData(
80 span_array=np.ones((3, 3), dtype=np.float32),
81 origin=(0, 0),
82 peak=(1, 1),
83 )
84 }
85 modelData = LsstScarletModelData(isolated=isolated)
86 self.assertEqual(len(modelData.blends), 0)
88 result = mes.io.updateCatalogFootprints(
89 modelData, self._empty_catalog(), band="r"
90 )
91 self.assertIsNone(result)
94class TestScarletModelToLsstScarletModel(lsst.utils.tests.TestCase):
95 """Tests for ``scarlet_model_to_lsst_scarlet_model``.
97 The converter wraps a base ``ScarletModelData`` in an
98 ``LsstScarletModelData``. Previously it dropped the input's
99 ``metadata`` and substituted ``None``, which then crashed the
100 ``_to_1_0_1`` migration on any subsequent round-trip (see finding
101 IO-3 of ``audits/audit-2026-05-05.md``).
102 """
104 @staticmethod
105 def _base_model(metadata):
106 return scl.io.ScarletModelData(blends={}, metadata=metadata)
108 def test_propagates_metadata_when_present(self):
109 """The returned ``LsstScarletModelData`` carries the same
110 ``metadata`` dict as the source, not ``None``.
111 """
112 source_metadata = {"bands": ("g", "r"), "model_psf": "placeholder"}
113 result = mes.io.utils.scarlet_model_to_lsst_scarlet_model(
114 self._base_model(source_metadata)
115 )
116 self.assertEqual(result.metadata, source_metadata)
118 def test_defaults_to_empty_dict_not_none(self):
119 """When the source's ``metadata`` is ``None``, the converter
120 substitutes an empty dict so the ``_to_1_0_1`` migration sees
121 a real dict it can call ``setdefault`` on.
122 """
123 result = mes.io.utils.scarlet_model_to_lsst_scarlet_model(
124 self._base_model(None)
125 )
126 self.assertEqual(result.metadata, {})
129class TestScarletModelDelegate(lsst.utils.tests.TestCase):
130 """Tests for
131 ``lsst.meas.extensions.scarlet.io.utils.ScarletModelDelegate.handleParameters``.
133 The delegate hooks ``LsstScarletModelData`` into Butler's
134 parameter-application pipeline. The base ``StorageClassDelegate``
135 annotates ``parameters`` as ``Mapping[str, Any] | None`` and
136 treats ``None``/``{}`` as "no parameters" — its dispatch path in
137 ``daf_butler.datastore.generic_base.post_process_get`` already
138 short-circuits with ``if assemblerParams:``, so the bug below is
139 latent in current usage, but in-memory-datastore and
140 disassembled-composite reads pass ``None``/``{}`` straight
141 through, and the delegate must agree with the base class on
142 those values.
143 """
145 @staticmethod
146 def _model_with_blends():
147 # ``handleParameters`` only inspects the keys of
148 # ``inMemoryDataset.blends`` (to slice the dict), so the
149 # values can be arbitrary sentinels — no need to build real
150 # ScarletBlendData objects.
151 return LsstScarletModelData(
152 blends={1: "blend-1", 2: "blend-2", 3: "blend-3"},
153 )
155 @staticmethod
156 def _delegate():
157 # ``StorageClassDelegate.__init__`` requires a
158 # ``StorageClass`` argument, but ``handleParameters`` never
159 # consults it; a sentinel is enough to construct the
160 # delegate in isolation from a real Butler.
161 from unittest.mock import Mock
162 return mes.io.ScarletModelDelegate(storageClass=Mock())
164 def test_handleParameters_none_returns_unchanged(self):
165 """``parameters=None`` returns the dataset unchanged.
167 Regression test for finding IO-6 of the
168 ``audits/audit-2026-05-05.md`` audit. The bug was
169 ``"blend_id" in None`` raising ``TypeError`` — the base-class
170 contract permits ``None`` and treats it as "no parameters",
171 so the delegate must do the same.
172 """
173 model = self._model_with_blends()
174 delegate = self._delegate()
176 result = delegate.handleParameters(model, None)
178 self.assertIs(result, model)
179 self.assertEqual(set(result.blends.keys()), {1, 2, 3})
181 def test_handleParameters_empty_dict_returns_unchanged(self):
182 """``parameters={}`` also returns unchanged.
184 Mirrors ``StorageClassDelegate.handleParameters`` whose
185 ``if parameters:`` guard treats the empty dict as "no
186 parameters" rather than as "unsupported parameters". The
187 pre-fix delegate raised ``ValueError("Unsupported parameters:
188 {}")`` here because the ``elif parameters is not None`` branch
189 fired on an empty dict. Companion to the ``None`` case under
190 finding IO-6 of ``audits/audit-2026-05-05.md``.
191 """
192 model = self._model_with_blends()
193 delegate = self._delegate()
195 result = delegate.handleParameters(model, {})
197 self.assertIs(result, model)
198 self.assertEqual(set(result.blends.keys()), {1, 2, 3})
200 def test_handleParameters_blend_id_filters(self):
201 """``parameters={'blend_id': ...}`` keeps only the requested
202 blends.
204 Pins the filtering branch under finding IO-6 of the
205 ``audits/audit-2026-05-05.md`` audit: the no-parameter fixes
206 above must not regress the actual partial-load path.
207 """
208 model = self._model_with_blends()
209 delegate = self._delegate()
211 result = delegate.handleParameters(model, {"blend_id": 2})
213 self.assertIs(result, model)
214 self.assertEqual(set(result.blends.keys()), {2})
215 # Iterable forms also work.
216 model = self._model_with_blends()
217 result = delegate.handleParameters(model, {"blend_id": [1, 3]})
218 self.assertEqual(set(result.blends.keys()), {1, 3})
220 def test_handleParameters_unsupported_raises(self):
221 """Non-empty parameters without ``blend_id`` raise
222 ``ValueError``.
224 Pins the rejection branch under finding IO-6 of
225 ``audits/audit-2026-05-05.md`` so a future relaxation of the
226 no-parameter case does not silently start accepting
227 unrecognized keys.
228 """
229 model = self._model_with_blends()
230 delegate = self._delegate()
232 with self.assertRaises(ValueError) as cm:
233 delegate.handleParameters(model, {"something_else": 42})
234 self.assertIn("something_else", str(cm.exception))
237class TestMonochromaticDataToScarletDeprecation(lsst.utils.tests.TestCase):
238 """Coverage retention for the deprecated
239 ``monochromaticDataToScarlet`` (scheduled for removal after v31).
241 The function is no longer called by any production code path in
242 this package, but its public-API contract is preserved for external
243 callers until removal. These tests keep the safety net by
244 exercising it directly on a synthetic blend.
245 """
247 @staticmethod
248 def _toy_blend_data():
249 # One factorized component, distinct (y, x) peak so any silent
250 # axis swap would be loud. Bands chosen so the round-trip
251 # exercises the real-band → ("dummy",) projection.
252 bands = ("g", "r", "i")
253 h, w = 6, 8
254 origin = (10, 20)
255 peak = (12, 25)
256 spectrum = np.array([1.0, 2.0, 3.0], dtype=np.float32)
257 morph = np.ones((h, w), dtype=np.float32)
258 component_data = scl.io.ScarletFactorizedComponentData(
259 origin=origin,
260 peak=peak,
261 spectrum=spectrum,
262 morph=morph,
263 )
264 source_data = scl.io.ScarletSourceData(components=[component_data])
265 blend_data = scl.io.ScarletBlendData(
266 origin=origin,
267 shape=(h, w),
268 sources={42: source_data},
269 )
270 return bands, blend_data, peak
272 def test_monochromatic_data_to_scarlet_emits_future_warning(self):
273 """``monochromaticDataToScarlet`` emits a ``FutureWarning``
274 flagging the migration to
275 ``ScarletBlendData.minimal_data_to_blend(...)[band]``.
277 Pinned so the deprecation stays visible until the function is
278 removed after v31.
279 """
280 bands, blend_data, _peak = self._toy_blend_data()
281 bbox = scl.Box(blend_data.shape, origin=blend_data.origin)
282 observation = scl.Observation.empty(
283 bands=("dummy",),
284 psfs=np.ones((1, 5, 5), dtype=np.float32),
285 model_psf=np.ones((1, 5, 5), dtype=np.float32),
286 bbox=bbox,
287 dtype=np.float32,
288 )
290 with self.assertWarns(FutureWarning):
291 blend = mes.io.monochromaticDataToScarlet(
292 blendData=blend_data, bandIndex=1, observation=observation,
293 )
295 self.assertEqual(len(blend.sources), 1)
297 def test_monochromatic_band_constants_emit_future_warning(self):
298 """Module-level ``monochromaticBand`` / ``monochromaticBands``
299 access fires a ``FutureWarning`` and resolves to the original
300 ``"dummy"`` placeholder.
302 Pinned alongside the function deprecation so the constants and
303 the function are removed together after v31.
304 """
305 from lsst.meas.extensions.scarlet.io import utils as io_utils
307 with self.assertWarns(FutureWarning):
308 self.assertEqual(io_utils.monochromaticBand, "dummy")
309 with self.assertWarns(FutureWarning):
310 self.assertEqual(io_utils.monochromaticBands, ("dummy",))
312 def test_monochromatic_data_to_scarlet_preserves_factorized_peak(self):
313 """``monochromaticDataToScarlet`` returns a source whose
314 component peak matches the persisted ``(y, x)``.
316 The deprecated function is the only remaining write-target for
317 the legacy ``("dummy",)`` per-band reconstruction; pinning the
318 peak guards against regressions in the
319 ``FactorizedComponent`` rebuild path while it lives.
320 """
321 bands, blend_data, peak = self._toy_blend_data()
322 bbox = scl.Box(blend_data.shape, origin=blend_data.origin)
323 observation = scl.Observation.empty(
324 bands=("dummy",),
325 psfs=np.ones((1, 5, 5), dtype=np.float32),
326 model_psf=np.ones((1, 5, 5), dtype=np.float32),
327 bbox=bbox,
328 dtype=np.float32,
329 )
331 with warnings.catch_warnings():
332 warnings.simplefilter("ignore", FutureWarning)
333 blend = mes.io.monochromaticDataToScarlet(
334 blendData=blend_data, bandIndex=1, observation=observation,
335 )
337 self.assertEqual(blend.sources[0].components[0].peak, peak)
340class TestLoadBlend(lsst.utils.tests.TestCase):
341 """Tests for ``loadBlend``.
343 ``loadBlend`` reconstructs a single per-blend scarlet ``Blend``
344 object from a persisted ``ScarletBlendData`` and a multiband
345 coadd. The legacy signature took ``model_psf`` directly and
346 computed per-band PSFs from the coadd; the new signature accepts
347 the full ``modelData`` and uses its already-fit PSFs verbatim,
348 which is both a more faithful round-trip and the only path that
349 works against modern persistence (legacy fields like
350 ``psf_center`` were dropped from ``ScarletBlendData`` during the
351 scarlet_lite refactor).
352 """
354 def _bundle(self):
355 # ``pipeline.deblend`` is memoized per (scene, config) so this
356 # is effectively a free lookup after the first invocation.
357 return pipeline.deblend(
358 pipeline.deconvolve(
359 pipeline.detect(pipeline.build_image(SCENES["multi-blend"]))
360 )
361 )
363 def _leaf_blend(self, modelData):
364 # Return the first leaf ``ScarletBlendData`` (i.e. the first
365 # child of the first hierarchical parent). ``loadBlend``'s
366 # contract is per-leaf, not per-parent.
367 for parent_blend in modelData.blends.values(): 367 ↛ 371line 367 didn't jump to line 371 because the loop on line 367 didn't complete
368 for child in parent_blend.children.values(): 368 ↛ 367line 368 didn't jump to line 367 because the loop on line 368 didn't complete
369 if isinstance(child, scl.io.ScarletBlendData): 369 ↛ 368line 369 didn't jump to line 368 because the condition on line 369 was always true
370 return child
371 self.fail("multi-blend scene unexpectedly produced no leaf blends")
373 def test_loadBlend_modelData_uses_metadata_model_psf(self):
374 """``loadBlend(..., modelData=...)`` builds an observation
375 whose ``model_psf`` equals ``modelData.metadata['model_psf']``.
377 The previous signature derived its PSFs from the coadd at the
378 blend's ``psf_center``, which both required attributes
379 ``ScarletBlendData`` no longer carries and re-derived a PSF
380 that may not match the one used during the fit. Pinning the
381 observation's ``model_psf`` to the metadata value guards the
382 intended round-trip.
383 """
384 bundle = self._bundle()
385 modelData = bundle.result.scarletModelData
386 blendData = self._leaf_blend(modelData)
388 blend, _ = mes.io.loadBlend(
389 blendData, mCoadd=bundle.image.mCoadd, modelData=modelData,
390 )
392 np.testing.assert_array_equal(
393 blend.observation.model_psf[0],
394 modelData.metadata["model_psf"],
395 )
397 def test_loadBlend_model_psf_emits_future_warning(self):
398 """Passing ``model_psf`` emits a ``FutureWarning`` flagging the
399 removal scheduled for v31.
401 The legacy path may still raise downstream (modern
402 ``ScarletBlendData`` lacks the ``psf_center`` attribute it
403 once required), so the assertion only pins the warning — any
404 post-warning exception is swallowed.
405 """
406 bundle = self._bundle()
407 modelData = bundle.result.scarletModelData
408 blendData = self._leaf_blend(modelData)
409 model_psf = modelData.metadata["model_psf"]
411 with self.assertWarns(FutureWarning):
412 try:
413 mes.io.loadBlend(
414 blendData,
415 model_psf=model_psf,
416 mCoadd=bundle.image.mCoadd,
417 )
418 except Exception:
419 pass
421 def test_loadBlend_requires_psf_source(self):
422 """Calling ``loadBlend`` with ``mCoadd`` but neither
423 ``modelData`` nor ``model_psf`` raises ``ValueError``.
425 Pins the precondition that some PSF source has to be passed,
426 rather than silently constructing a degenerate observation.
427 """
428 bundle = self._bundle()
429 modelData = bundle.result.scarletModelData
430 blendData = self._leaf_blend(modelData)
432 with self.assertRaises(ValueError):
433 mes.io.loadBlend(blendData, mCoadd=bundle.image.mCoadd)
435 def test_loadBlend_requires_mCoadd(self):
436 """Calling ``loadBlend`` without ``mCoadd`` raises
437 ``ValueError``.
439 ``mCoadd`` has no default value at the API level (the
440 signature uses ``None`` only to support the legacy positional
441 order); omitting it should be a loud error rather than an
442 ``AttributeError`` deep inside the observation construction.
443 """
444 bundle = self._bundle()
445 modelData = bundle.result.scarletModelData
446 blendData = self._leaf_blend(modelData)
448 with self.assertRaises(ValueError):
449 mes.io.loadBlend(blendData, modelData=modelData)
452def setup_module(module):
453 lsst.utils.tests.init()
456class MemoryTester(lsst.utils.tests.MemoryTestCase):
457 pass
460if __name__ == "__main__": 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 lsst.utils.tests.init()
462 unittest.main()