Coverage for tests/test_io_model_data.py: 96%
72 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:24 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 02:24 -0700
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 LsstScarletModelData schema migrations."""
24import copy
25import unittest
27import numpy as np
29import lsst.scarlet.lite as scl
30import lsst.utils.tests
31from lsst.meas.extensions.scarlet.io import model_data as model_data_module
32from lsst.meas.extensions.scarlet.io.model_data import (
33 CURRENT_SCHEMA,
34 MODEL_TYPE,
35 _to_1_0_0,
36 _to_1_0_1,
37 _to_1_0_2,
38)
41class TestModelDataMigrations(lsst.utils.tests.TestCase):
42 """Tests for the migration chain and schema constants in
43 ``lsst.meas.extensions.scarlet.io.model_data``.
45 Each migration function bumps the version and adds the keys
46 introduced at that schema step. A regression in any of them
47 silently corrupts deblend catalogs read from disk that were
48 written by an earlier release.
49 """
51 def test_to_1_0_0_adds_isolated_key(self):
52 """``_to_1_0_0`` adds ``isolated={}``, the ``model_type`` tag,
53 and the schema version to pre-schema data.
54 """
55 # Pre-schema data carries only the ``blends`` key inherited
56 # from scarlet_lite's ScarletModelData; no model_type,
57 # isolated, or version was emitted before 1.0.0.
58 pre = {"blends": {}}
59 result = _to_1_0_0(copy.deepcopy(pre))
60 self.assertEqual(result["isolated"], {})
61 self.assertEqual(result["version"], "1.0.0")
62 self.assertEqual(result["model_type"], MODEL_TYPE)
64 def test_to_1_0_1_adds_footprint_metadata(self):
65 """``_to_1_0_1`` adds ``metadata={"footprint": None}`` and the
66 schema version to 1.0.0 data that had no metadata at all.
67 """
68 # 1.0.0 data has isolated, model_type, version — but no
69 # metadata key; 1.0.1 introduced footprint metadata.
70 v1_0_0 = {
71 "blends": {},
72 "isolated": {},
73 "model_type": MODEL_TYPE,
74 "version": "1.0.0",
75 }
76 result = _to_1_0_1(copy.deepcopy(v1_0_0))
77 self.assertEqual(result["version"], "1.0.1")
78 self.assertEqual(result["metadata"], {"footprint": None})
80 def test_to_1_0_1_preserves_existing_metadata(self):
81 """When 1.0.0 data already carries a ``metadata`` dict (without
82 a ``footprint`` key), ``_to_1_0_1`` adds ``footprint=None``
83 and leaves the other keys intact.
84 """
85 # Pins the ``setdefault(...).setdefault(...)`` contract — a
86 # naive ``data["metadata"] = {"footprint": None}`` rewrite
87 # would silently drop pre-existing metadata.
88 v1_0_0 = {
89 "blends": {},
90 "isolated": {},
91 "model_type": MODEL_TYPE,
92 "version": "1.0.0",
93 "metadata": {"survey": "DES"},
94 }
95 result = _to_1_0_1(copy.deepcopy(v1_0_0))
96 self.assertEqual(result["version"], "1.0.1")
97 self.assertEqual(
98 result["metadata"], {"survey": "DES", "footprint": None}
99 )
101 def test_to_1_0_1_handles_none_metadata(self):
102 """``_to_1_0_1`` tolerates an explicit ``metadata=None`` entry
103 from older payloads.
105 Regression test for finding IO-3 of
106 ``audits/audit-2026-05-05.md``. The naive
107 ``data.setdefault("metadata", {}).setdefault("footprint", None)``
108 idiom returned the existing ``None`` and then raised
109 ``AttributeError: 'NoneType' object has no attribute 'setdefault'``
110 when the payload carried ``metadata=None`` explicitly — the
111 same value emitted by
112 ``scarlet_model_to_lsst_scarlet_model`` for converted v0
113 archives. The migration must replace ``None`` with
114 ``{"footprint": None}``.
115 """
116 v1_0_0 = {
117 "blends": {},
118 "isolated": {},
119 "model_type": MODEL_TYPE,
120 "version": "1.0.0",
121 "metadata": None,
122 }
123 result = _to_1_0_1(copy.deepcopy(v1_0_0))
124 self.assertEqual(result["version"], "1.0.1")
125 self.assertEqual(result["metadata"], {"footprint": None})
127 def test_to_1_0_2_promotes_real_hierarchical_spans(self):
128 """``_to_1_0_2`` converts a legacy ``hierarchical`` blend that
129 carries real ``spans``/``origin`` into an ``lsst_hierarchical``
130 blend, promoting the spans verbatim with ``legacy_spans=False``.
131 """
132 spans = np.zeros((5, 6), dtype=bool)
133 spans[1:4, 2:5] = True
134 child = scl.io.ScarletBlendData(origin=(10, 20), shape=(5, 6), sources={})
135 legacy = scl.io.HierarchicalBlendData(
136 children={1: child},
137 metadata={"spans": spans.astype(int), "origin": (10, 20)},
138 )
139 data = {
140 "blends": {7: legacy.as_dict()},
141 "isolated": {},
142 "model_type": MODEL_TYPE,
143 "version": "1.0.1",
144 "metadata": {},
145 }
146 result = _to_1_0_2(copy.deepcopy(data))
147 self.assertEqual(result["version"], "1.0.2")
148 blend = result["blends"][7]
149 self.assertEqual(blend["blend_type"], "lsst_hierarchical")
150 converted = scl.io.ScarletBlendBaseData.from_dict(blend)
151 self.assertIsInstance(converted, model_data_module.LsstHierarchicalBlendData)
152 np.testing.assert_array_equal(converted.span_array, spans)
153 self.assertEqual(tuple(converted.origin), (10, 20))
154 self.assertFalse(converted.legacy_spans)
156 def test_to_1_0_2_synthesizes_missing_spans(self):
157 """When a legacy ``hierarchical`` blend has no spans, ``_to_1_0_2``
158 synthesizes a filled rectangle from the children bbox and marks it
159 ``legacy_spans=True``.
160 """
161 child = scl.io.ScarletBlendData(origin=(10, 20), shape=(5, 6), sources={})
162 legacy = scl.io.HierarchicalBlendData(children={1: child})
163 data = {
164 "blends": {7: legacy.as_dict()},
165 "isolated": {},
166 "model_type": MODEL_TYPE,
167 "version": "1.0.1",
168 "metadata": {},
169 }
170 result = _to_1_0_2(copy.deepcopy(data))
171 converted = scl.io.ScarletBlendBaseData.from_dict(result["blends"][7])
172 self.assertTrue(converted.legacy_spans)
173 self.assertEqual(converted.span_array.shape, (5, 6))
174 self.assertTrue(converted.span_array.all())
175 self.assertEqual(tuple(converted.origin), (10, 20))
177 def test_to_1_0_2_ignores_flat_blends(self):
178 """``_to_1_0_2`` leaves non-hierarchical (flat) top-level blends
179 untouched — they have no meas-specific spans to promote.
180 """
181 flat = scl.io.ScarletBlendData(origin=(0, 0), shape=(3, 3), sources={})
182 data = {
183 "blends": {7: flat.as_dict()},
184 "isolated": {},
185 "model_type": MODEL_TYPE,
186 "version": "1.0.1",
187 "metadata": {},
188 }
189 result = _to_1_0_2(copy.deepcopy(data))
190 self.assertEqual(result["blends"][7]["blend_type"], "blend")
192 def test_schema_version_constants_match(self):
193 """``CURRENT_SCHEMA`` matches what's recorded as current for
194 ``MODEL_TYPE`` in the migration registry.
195 """
196 self.assertEqual(
197 CURRENT_SCHEMA,
198 scl.io.migration.MigrationRegistry.current[MODEL_TYPE],
199 )
201 def test_metadata_retains_deprecated_keys(self):
202 """During the deprecation period the promoted typed attributes
203 ``bands``/``model_psf``/``psf`` remain readable through
204 ``metadata`` for backwards compatibility. Throwaway once the keys
205 are removed after v31. (Membership tests do not warn.)
206 """
207 model = model_data_module.LsstScarletModelData(
208 bands=("g", "r"),
209 model_psf=np.ones((5, 5), dtype=np.float32),
210 psf=np.ones((2, 3, 3), dtype=np.float32),
211 )
212 for key in ("bands", "model_psf", "psf"):
213 self.assertIn(key, model.metadata)
216def setup_module(module):
217 lsst.utils.tests.init()
220class MemoryTester(lsst.utils.tests.MemoryTestCase):
221 pass
224if __name__ == "__main__": 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true
225 lsst.utils.tests.init()
226 unittest.main()