Coverage for tests/test_io_utils.py: 96%

152 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-23 08:41 +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/>. 

21 

22"""Tests for the helpers in ``lsst.meas.extensions.scarlet.io.utils``.""" 

23 

24import unittest 

25import warnings 

26 

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 

35 

36import pipeline 

37from scenes import SCENES 

38 

39 

40class TestUpdateCatalogFootprints(lsst.utils.tests.TestCase): 

41 """Tests for the empty-input guard in 

42 ``lsst.meas.extensions.scarlet.io.updateCatalogFootprints``. 

43 """ 

44 

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())) 

50 

51 def test_update_catalog_footprints_empty_raises(self): 

52 """``updateCatalogFootprints`` raises ``NoWorkFound`` when the 

53 model data has neither blends nor isolated sources. 

54 

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) 

64 

65 with self.assertRaises(NoWorkFound): 

66 mes.io.updateCatalogFootprints( 

67 modelData, self._empty_catalog(), band="r" 

68 ) 

69 

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. 

73 

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) 

87 

88 result = mes.io.updateCatalogFootprints( 

89 modelData, self._empty_catalog(), band="r" 

90 ) 

91 self.assertIsNone(result) 

92 

93 

94class TestScarletModelToLsstScarletModel(lsst.utils.tests.TestCase): 

95 """Tests for ``scarlet_model_to_lsst_scarlet_model``. 

96 

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 """ 

103 

104 @staticmethod 

105 def _base_model(metadata): 

106 return scl.io.ScarletModelData(blends={}, metadata=metadata) 

107 

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) 

117 

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, {}) 

127 

128 

129class TestScarletModelDelegate(lsst.utils.tests.TestCase): 

130 """Tests for 

131 ``lsst.meas.extensions.scarlet.io.utils.ScarletModelDelegate.handleParameters``. 

132 

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 """ 

144 

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 ) 

154 

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()) 

163 

164 def test_handleParameters_none_returns_unchanged(self): 

165 """``parameters=None`` returns the dataset unchanged. 

166 

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() 

175 

176 result = delegate.handleParameters(model, None) 

177 

178 self.assertIs(result, model) 

179 self.assertEqual(set(result.blends.keys()), {1, 2, 3}) 

180 

181 def test_handleParameters_empty_dict_returns_unchanged(self): 

182 """``parameters={}`` also returns unchanged. 

183 

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() 

194 

195 result = delegate.handleParameters(model, {}) 

196 

197 self.assertIs(result, model) 

198 self.assertEqual(set(result.blends.keys()), {1, 2, 3}) 

199 

200 def test_handleParameters_blend_id_filters(self): 

201 """``parameters={'blend_id': ...}`` keeps only the requested 

202 blends. 

203 

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() 

210 

211 result = delegate.handleParameters(model, {"blend_id": 2}) 

212 

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}) 

219 

220 def test_handleParameters_unsupported_raises(self): 

221 """Non-empty parameters without ``blend_id`` raise 

222 ``ValueError``. 

223 

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() 

231 

232 with self.assertRaises(ValueError) as cm: 

233 delegate.handleParameters(model, {"something_else": 42}) 

234 self.assertIn("something_else", str(cm.exception)) 

235 

236 

237class TestMonochromaticDataToScarletDeprecation(lsst.utils.tests.TestCase): 

238 """Coverage retention for the deprecated 

239 ``monochromaticDataToScarlet`` (scheduled for removal after v31). 

240 

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 """ 

246 

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 

271 

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]``. 

276 

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 ) 

289 

290 with self.assertWarns(FutureWarning): 

291 blend = mes.io.monochromaticDataToScarlet( 

292 blendData=blend_data, bandIndex=1, observation=observation, 

293 ) 

294 

295 self.assertEqual(len(blend.sources), 1) 

296 

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. 

301 

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 

306 

307 with self.assertWarns(FutureWarning): 

308 self.assertEqual(io_utils.monochromaticBand, "dummy") 

309 with self.assertWarns(FutureWarning): 

310 self.assertEqual(io_utils.monochromaticBands, ("dummy",)) 

311 

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)``. 

315 

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 ) 

330 

331 with warnings.catch_warnings(): 

332 warnings.simplefilter("ignore", FutureWarning) 

333 blend = mes.io.monochromaticDataToScarlet( 

334 blendData=blend_data, bandIndex=1, observation=observation, 

335 ) 

336 

337 self.assertEqual(blend.sources[0].components[0].peak, peak) 

338 

339 

340class TestLoadBlend(lsst.utils.tests.TestCase): 

341 """Tests for ``loadBlend``. 

342 

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 """ 

353 

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 ) 

362 

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") 

372 

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']``. 

376 

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) 

387 

388 blend, _ = mes.io.loadBlend( 

389 blendData, mCoadd=bundle.image.mCoadd, modelData=modelData, 

390 ) 

391 

392 np.testing.assert_array_equal( 

393 blend.observation.model_psf[0], 

394 modelData.metadata["model_psf"], 

395 ) 

396 

397 def test_loadBlend_model_psf_emits_future_warning(self): 

398 """Passing ``model_psf`` emits a ``FutureWarning`` flagging the 

399 removal scheduled for v31. 

400 

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"] 

410 

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 

420 

421 def test_loadBlend_requires_psf_source(self): 

422 """Calling ``loadBlend`` with ``mCoadd`` but neither 

423 ``modelData`` nor ``model_psf`` raises ``ValueError``. 

424 

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) 

431 

432 with self.assertRaises(ValueError): 

433 mes.io.loadBlend(blendData, mCoadd=bundle.image.mCoadd) 

434 

435 def test_loadBlend_requires_mCoadd(self): 

436 """Calling ``loadBlend`` without ``mCoadd`` raises 

437 ``ValueError``. 

438 

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) 

447 

448 with self.assertRaises(ValueError): 

449 mes.io.loadBlend(blendData, modelData=modelData) 

450 

451 

452def setup_module(module): 

453 lsst.utils.tests.init() 

454 

455 

456class MemoryTester(lsst.utils.tests.MemoryTestCase): 

457 pass 

458 

459 

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()