Coverage for tests/test_io_source_data.py: 94%

44 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-22 08:43 +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"""Round-trip tests for ``IsolatedSourceData`` serialization.""" 

23 

24import unittest 

25 

26import lsst.scarlet.lite as scl 

27import lsst.utils.tests 

28import numpy as np 

29from lsst.meas.extensions.scarlet.io.source_data import IsolatedSourceData 

30 

31 

32class TestIsolatedSourceData(lsst.utils.tests.TestCase): 

33 """Tests for ``IsolatedSourceData.as_dict`` ↔ ``from_dict`` in 

34 ``lsst.meas.extensions.scarlet.io.source_data``. 

35 

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

41 

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 ) 

56 

57 roundtripped = IsolatedSourceData.from_dict(original.as_dict()) 

58 

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) 

67 

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) 

82 

83 roundtripped = IsolatedSourceData.from_dict(encoded) 

84 self.assertIsNone(roundtripped.metadata) 

85 

86 def test_as_dict_peak_is_int(self): 

87 """Encoded ``peak`` values are ``int``, matching the dataclass 

88 contract (``tuple[int, int]``). 

89 

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) 

104 

105 def test_span_array_roundtrip(self): 

106 """A non-trivial ``span_array`` survives the round-trip 

107 bit-for-bit. 

108 

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) 

118 

119 original = IsolatedSourceData( 

120 span_array=span, 

121 origin=(-7, 3), 

122 peak=(0, 10), 

123 ) 

124 roundtripped = IsolatedSourceData.from_dict(original.as_dict()) 

125 

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) 

131 

132 

133def setup_module(module): 

134 lsst.utils.tests.init() 

135 

136 

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

138 pass 

139 

140 

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