Coverage for tests/test_wholeTractMaskFraction.py: 97%
139 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-18 18:34 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-18 18:34 +0000
1# This file is part of analysis_tools.
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/>.
21from __future__ import annotations
23from unittest import TestCase, main
25import numpy as np
27import lsst.utils.tests
28from lsst.analysis.tools.actions.scalar import (
29 CountAction,
30 MaxAction,
31 MeanAction,
32 MinAction,
33 WeightedMeanAction,
34)
35from lsst.analysis.tools.atools import WholeTractMaskFractionTool
36from lsst.analysis.tools.tasks import WholeTractMaskFractionAnalysisTask
37from lsst.pex.exceptions import InvalidParameterError
38from lsst.pipe.base import InMemoryDatasetHandle
40_PLANE_BITS = {"NO_DATA": 1, "SAT": 2, "CR": 4}
43class _MockMask:
44 def __init__(self, array, plane_bits=_PLANE_BITS):
45 self._array = array
46 self._plane_bits = plane_bits
48 @property
49 def array(self):
50 return self._array
52 def getPlaneBitMask(self, plane):
53 if plane not in self._plane_bits:
54 raise InvalidParameterError(f"Unknown mask plane: {plane!r}")
55 return self._plane_bits[plane]
58class _MockExposure:
59 def __init__(self, array, plane_bits=_PLANE_BITS):
60 self._mask = _MockMask(array, plane_bits)
62 def getMask(self):
63 return self._mask
66def _make_tool(mask_planes):
67 """Create and finalize a WholeTractMaskFractionTool with standard
68 aggregations."""
69 tool = WholeTractMaskFractionTool()
71 all_planes = set(mask_planes) | {"NO_DATA"}
72 aggregations = {"min": MinAction, "max": MaxAction, "mean": MeanAction, "ct": CountAction}
74 for plane in all_planes:
75 for agg_name, agg_cls in aggregations.items():
76 action = agg_cls()
77 action.vectorKey = f"{plane}_fraction"
78 setattr(tool.process.calculateActions, f"{agg_name}_{plane}_fraction", action)
80 for plane in mask_planes:
81 if plane != "NO_DATA": 81 ↛ 80line 81 didn't jump to line 80 because the condition on line 81 was always true
82 action = WeightedMeanAction()
83 action.vectorKey = f"{plane}_valid_data_fraction"
84 action.weightsKey = "valid_data_pixel_count"
85 setattr(tool.process.calculateActions, f"mean_{plane}_valid_data_fraction", action)
87 tool.finalize()
88 return tool
91def _make_task(mask_planes):
92 """Create a WholeTractMaskFractionAnalysisTask with one configured tool."""
93 config = WholeTractMaskFractionAnalysisTask.ConfigClass()
94 config.connections.outputName = "wholeTractMaskFraction"
95 config.maskPlanes = mask_planes
96 config.atools.maskFractionMetrics = WholeTractMaskFractionTool
97 return WholeTractMaskFractionAnalysisTask(config=config)
100class TestWholeTractMaskFractionToolFinalize(TestCase):
102 def testUnitsAreEmptyString(self):
103 tool = _make_tool(["SAT", "CR"])
104 for unit in tool.produce.metric.units.values():
105 self.assertEqual(unit, "")
108class TestWholeTractMaskFractionTaskCollectAllPlanes(TestCase):
110 def testConfiguredPlanesIncluded(self):
111 task = _make_task(["SAT", "CR"])
112 self.assertIn("SAT", task.config.maskPlanes)
113 self.assertIn("CR", task.config.maskPlanes)
116class TestWholeTractMaskFractionTaskComputeKeyedData(TestCase):
118 def setUp(self):
119 self.task = _make_task(["SAT", "CR"])
121 def _compute(self, handles, planes=None):
122 if planes is None:
123 planes = {"SAT", "CR", "NO_DATA"}
124 return self.task._computeMaskFractions(handles, planes)
126 def testFractionAllFlagged(self):
127 """All pixels flagged → fraction == 1.0."""
128 arr = np.full((4, 4), _PLANE_BITS["SAT"], dtype=np.int32)
129 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))])
130 self.assertAlmostEqual(result["SAT_fraction"][0], 1.0)
132 def testFractionNoneFlagged(self):
133 """No pixels flagged → fraction == 0.0."""
134 arr = np.zeros((4, 4), dtype=np.int32)
135 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))])
136 self.assertAlmostEqual(result["SAT_fraction"][0], 0.0)
138 def testFractionHalfFlagged(self):
139 arr = np.zeros((4, 4), dtype=np.int32)
140 arr[:2, :] = _PLANE_BITS["SAT"] # half the pixels
141 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))])
142 self.assertAlmostEqual(result["SAT_fraction"][0], 0.5)
144 def testNoDataAlwaysIncluded(self):
145 arr = np.zeros((4, 4), dtype=np.int32)
146 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))])
147 self.assertIn("NO_DATA_fraction", result)
149 def testNoDataHasNoFractionValid(self):
150 arr = np.zeros((4, 4), dtype=np.int32)
151 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))])
152 self.assertNotIn("NO_DATA_valid_data_fraction", result)
154 def testFractionValidExcludesNoDataPixels(self):
155 # 8 pixels total: 4 NO_DATA, 4 valid; of the 4 valid, 2 are SAT
156 arr = np.zeros((2, 4), dtype=np.int32)
157 arr[0, :] = _PLANE_BITS["NO_DATA"] # row 0: NO_DATA
158 arr[1, :2] = _PLANE_BITS["SAT"] # row 1: 2 SAT (valid)
159 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))])
160 self.assertAlmostEqual(result["SAT_valid_data_fraction"][0], 0.5)
162 def testValidPixelCountCorrect(self):
163 arr = np.zeros((2, 4), dtype=np.int32)
164 arr[0, :] = _PLANE_BITS["NO_DATA"] # 4 NO_DATA pixels
165 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))])
166 self.assertEqual(result["valid_data_pixel_count"][0], 4)
168 def testUnknownPlaneSkipped(self):
169 arr = np.zeros((4, 4), dtype=np.int32)
170 planes = {"SAT", "CR", "NO_DATA", "UNKNOWN_PLANE"}
171 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))], planes=planes)
172 self.assertNotIn("UNKNOWN_PLANE_fraction", result)
174 def testMultiplePatchesAccumulate(self):
175 arr_a = np.zeros((4, 4), dtype=np.int32)
176 arr_b = np.full((4, 4), _PLANE_BITS["SAT"], dtype=np.int32)
177 result = self._compute(
178 [InMemoryDatasetHandle(_MockExposure(arr_a)), InMemoryDatasetHandle(_MockExposure(arr_b))]
179 )
180 self.assertEqual(len(result["SAT_fraction"]), 2)
181 self.assertAlmostEqual(result["SAT_fraction"][0], 0.0)
182 self.assertAlmostEqual(result["SAT_fraction"][1], 1.0)
184 def testValidPixelCountAcrossMultiplePatches(self):
185 arr_a = np.zeros((2, 4), dtype=np.int32)
186 arr_a[0, :] = _PLANE_BITS["NO_DATA"] # 4 valid pixels
187 arr_b = np.zeros((2, 4), dtype=np.int32) # 8 valid pixels
188 result = self._compute(
189 [InMemoryDatasetHandle(_MockExposure(arr_a)), InMemoryDatasetHandle(_MockExposure(arr_b))]
190 )
191 np.testing.assert_array_equal(result["valid_data_pixel_count"], [4, 8])
193 def testAllNoDataPatchYieldsNaNFractionValid(self):
194 """A patch where all pixels are NO_DATA should yield NaN for
195 _fraction_valid, not be omitted, so the vector length matches
196 _fraction."""
197 arr_all_no_data = np.full((4, 4), _PLANE_BITS["NO_DATA"], dtype=np.int32)
198 arr_normal = np.zeros((4, 4), dtype=np.int32)
199 arr_normal[:2, :] = _PLANE_BITS["SAT"]
200 result = self._compute(
201 [
202 InMemoryDatasetHandle(_MockExposure(arr_all_no_data)),
203 InMemoryDatasetHandle(_MockExposure(arr_normal)),
204 ]
205 )
206 # Both patches contribute to both vectors
207 self.assertEqual(len(result["SAT_fraction"]), 2)
208 self.assertEqual(len(result["SAT_valid_data_fraction"]), 2)
209 # All-NO_DATA patch → NaN; normal patch → 0.5
210 self.assertTrue(np.isnan(result["SAT_valid_data_fraction"][0]))
211 self.assertAlmostEqual(result["SAT_valid_data_fraction"][1], 0.5)
213 def testTwoPlanesComputedIndependently(self):
214 """SAT and CR fractions should be computed independently."""
215 arr = np.zeros((4, 4), dtype=np.int32)
216 arr[:1, :] = _PLANE_BITS["SAT"] # 4/16 = 0.25 SAT
217 arr[1:2, :] = _PLANE_BITS["CR"] # 4/16 = 0.25 CR
218 result = self._compute([InMemoryDatasetHandle(_MockExposure(arr))])
219 self.assertAlmostEqual(result["SAT_fraction"][0], 0.25)
220 self.assertAlmostEqual(result["CR_fraction"][0], 0.25)
223class MyMemoryTestCase(lsst.utils.tests.MemoryTestCase):
224 pass
227def setup_module(module):
228 lsst.utils.tests.init()
231if __name__ == "__main__": 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true
232 lsst.utils.tests.init()
233 main()