Coverage for tests/test_io_source_data.py: 94%
44 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 09:32 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 09:32 +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"""Round-trip tests for ``IsolatedSourceData`` serialization."""
24import unittest
26import lsst.scarlet.lite as scl
27import lsst.utils.tests
28import numpy as np
29from lsst.meas.extensions.scarlet.io.source_data import IsolatedSourceData
32class TestIsolatedSourceData(lsst.utils.tests.TestCase):
33 """Tests for ``IsolatedSourceData.as_dict`` ↔ ``from_dict`` in
34 ``lsst.meas.extensions.scarlet.io.source_data``.
36 ``IsolatedSourceData`` is a dataclass that backs the on-disk JSON
37 representation of every isolated-source row in a deblend catalog.
38 Any serialization round-trip that drops or reorders a field would
39 silently corrupt those catalogs at the next read.
40 """
42 def test_isolated_source_data_roundtrip(self):
43 """All fields of ``IsolatedSourceData`` survive
44 ``as_dict`` → ``from_dict``.
45 """
46 # Non-trivial span_array (a circle) is sufficient to check
47 # shape and dtype round-trip; the dedicated span-array test
48 # below pins bit-exact recovery for a larger mask.
49 span = scl.utils.get_circle_mask(5, dtype=np.float32)
50 original = IsolatedSourceData(
51 span_array=span,
52 origin=(5, 10),
53 peak=(7, 12),
54 metadata={"id": 42, "note": "test"},
55 )
57 roundtripped = IsolatedSourceData.from_dict(original.as_dict())
59 np.testing.assert_array_equal(
60 roundtripped.span_array, original.span_array
61 )
62 self.assertEqual(roundtripped.origin, original.origin)
63 self.assertEqual(roundtripped.peak, original.peak)
64 self.assertEqual(roundtripped.metadata, original.metadata)
65 self.assertEqual(roundtripped.source_type, original.source_type)
66 self.assertEqual(roundtripped.version, original.version)
68 def test_isolated_source_data_roundtrip_no_metadata(self):
69 """``metadata=None`` round-trips as ``None`` (the field is
70 omitted from the dict entirely).
71 """
72 original = IsolatedSourceData(
73 span_array=np.ones((3, 3), dtype=np.float32),
74 origin=(0, 0),
75 peak=(1, 1),
76 )
77 encoded = original.as_dict()
78 # The dict shape should not carry a "metadata" key when the
79 # source has no metadata — otherwise downstream JSON would
80 # have a stray null that callers might mishandle.
81 self.assertNotIn("metadata", encoded)
83 roundtripped = IsolatedSourceData.from_dict(encoded)
84 self.assertIsNone(roundtripped.metadata)
86 def test_as_dict_peak_is_int(self):
87 """Encoded ``peak`` values are ``int``, matching the dataclass
88 contract (``tuple[int, int]``).
90 Regression test for finding IO-1 of the
91 ``audits/audit-2026-05-05.md`` audit: ``as_dict`` previously
92 wrote floats while ``from_dict`` read ints, so any sub-pixel
93 peak that reached this path would be silently truncated.
94 """
95 original = IsolatedSourceData(
96 span_array=np.ones((3, 3), dtype=np.float32),
97 origin=(0, 0),
98 peak=(2, 3),
99 )
100 encoded = original.as_dict()
101 for value in encoded["peak"]:
102 self.assertIsInstance(value, int)
103 self.assertNotIsInstance(value, bool)
105 def test_span_array_roundtrip(self):
106 """A non-trivial ``span_array`` survives the round-trip
107 bit-for-bit.
109 The mask is a diameter-15 circle (significantly larger than the
110 scalar-field test) and the assertion is exact element-wise
111 equality, so any reshape or stride bug would show up.
112 """
113 span = scl.utils.get_circle_mask(15, dtype=np.float32)
114 # Sanity check that the test fixture itself is "non-trivial":
115 # the circle covers most but not all of the bounding box.
116 self.assertGreater(span.sum(), 0)
117 self.assertLess(span.sum(), span.size)
119 original = IsolatedSourceData(
120 span_array=span,
121 origin=(-7, 3),
122 peak=(0, 10),
123 )
124 roundtripped = IsolatedSourceData.from_dict(original.as_dict())
126 np.testing.assert_array_equal(
127 roundtripped.span_array, original.span_array
128 )
129 self.assertEqual(roundtripped.span_array.shape, span.shape)
130 self.assertEqual(roundtripped.span_array.dtype, span.dtype)
133def setup_module(module):
134 lsst.utils.tests.init()
137class MemoryTester(lsst.utils.tests.MemoryTestCase):
138 pass
141if __name__ == "__main__": 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
142 lsst.utils.tests.init()
143 unittest.main()