Coverage for tests / test_CompoundRegion.py: 17%
188 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:29 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-14 23:29 +0000
1# This file is part of sphgeom.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://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 software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
28import pickle
29import unittest
30from base64 import b64encode
32try:
33 import yaml
34except ImportError:
35 yaml = None
37from lsst.sphgeom import (
38 CONTAINS,
39 DISJOINT,
40 INTERSECTS,
41 WITHIN,
42 Angle,
43 AngleInterval,
44 Box,
45 Circle,
46 CompoundRegion,
47 IntersectionRegion,
48 LonLat,
49 NormalizedAngleInterval,
50 Region,
51 UnionRegion,
52 UnitVector3d,
53)
56class CompoundRegionTestMixin:
57 """Tests for both UnionRegion and IntersectionRegion.
59 Concrete TestCase subclasses are responsible for adding an `instance`
60 attribute with a non-trivial instance of the `CompoundRegion` subclass
61 being tested.
62 """
64 def setUp(self):
65 self.point_in_circle = LonLat.fromDegrees(44.0, 45.0)
66 self.point_in_box = LonLat.fromDegrees(46.0, 45.0)
67 self.point_in_both = LonLat.fromDegrees(45.0, 45.0)
68 self.point_in_neither = LonLat.fromDegrees(45.0, 48.0)
69 self.circle = Circle(UnitVector3d(self.point_in_circle), Angle.fromDegrees(1.0))
70 self.box = Box.fromDegrees(
71 self.point_in_box.getLon().asDegrees() - 1.5,
72 self.point_in_box.getLat().asDegrees() - 1.5,
73 self.point_in_box.getLon().asDegrees() + 1.5,
74 self.point_in_box.getLat().asDegrees() + 1.5,
75 )
76 self.faraway = Circle(UnitVector3d(self.point_in_neither), Angle.fromDegrees(0.1))
77 self.operands = (self.circle, self.box)
79 def assertOperandsEqual(self, region, operands):
80 """Assert that a compound regions operands are equal to the given
81 tuple of operands.
82 """
83 regions = tuple(region.cloneOperand(i) for i in range(region.nOperands()))
84 self.assertCountEqual(regions, operands)
86 def assertCompoundRegionsEqual(self, a, b):
87 """Assert that two compound regions are equal.
89 CompoundRegion does not implement equality comparison because
90 regions in general do not, and hence it cannot delegate that operation
91 to its operands. But the concrete operands (circle and box) we use in
92 these tests do implement equality comparison.
93 """
94 operands = tuple(b.cloneOperand(i) for i in range(b.nOperands()))
95 self.assertEqual(type(a), type(b))
96 self.assertOperandsEqual(a, operands)
98 def assertRelations(self, r1, r2, relation, overlaps):
99 """Assert relation between two regions."""
100 self.assertEqual(r1.relate(r2), relation)
101 self.assertEqual(r1.overlaps(r2), overlaps)
103 def testSetUp(self):
104 """Test that the points and operand regions being tested have the
105 relationships expected.
106 """
107 self.assertTrue(self.circle.contains(UnitVector3d(self.point_in_circle)))
108 self.assertTrue(self.circle.contains(UnitVector3d(self.point_in_both)))
109 self.assertFalse(self.circle.contains(UnitVector3d(self.point_in_box)))
110 self.assertFalse(self.circle.contains(UnitVector3d(self.point_in_neither)))
111 self.assertTrue(self.box.contains(UnitVector3d(self.point_in_box)))
112 self.assertTrue(self.box.contains(UnitVector3d(self.point_in_both)))
113 self.assertFalse(self.box.contains(UnitVector3d(self.point_in_circle)))
114 self.assertFalse(self.box.contains(UnitVector3d(self.point_in_neither)))
115 self.assertRelations(self.circle, self.circle, CONTAINS | WITHIN, True)
116 self.assertRelations(self.circle, self.box, INTERSECTS, True)
117 self.assertRelations(self.circle, self.faraway, DISJOINT, False)
118 self.assertRelations(self.box, self.circle, INTERSECTS, True)
119 self.assertRelations(self.box, self.box, CONTAINS | WITHIN, True)
120 self.assertRelations(self.box, self.faraway, DISJOINT, False)
122 def testOperands(self):
123 """Test the cloneOperands accessor."""
124 self.assertOperandsEqual(self.instance, self.operands)
126 def testIterator(self):
127 """Test Python iteration."""
128 self.assertEqual(len(self.instance), len(self.operands))
129 it = iter(self.instance)
130 self.assertEqual(next(it), self.operands[0])
131 self.assertEqual(next(it), self.operands[1])
132 with self.assertRaises(StopIteration):
133 next(it)
135 def testCodec(self):
136 """Test that encode and decode round-trip."""
137 s = self.instance.encode()
138 self.assertCompoundRegionsEqual(type(self.instance).decode(s), self.instance)
139 self.assertCompoundRegionsEqual(CompoundRegion.decode(s), self.instance)
140 self.assertCompoundRegionsEqual(Region.decode(s), self.instance)
142 def testPickle(self):
143 """Test pickling round-trips."""
144 s = pickle.dumps(self.instance, pickle.HIGHEST_PROTOCOL)
145 self.assertCompoundRegionsEqual(pickle.loads(s), self.instance)
147 def testString(self):
148 """Test that repr returns a string that can be eval'd to yield an
149 equivalent instance.
150 """
151 self.assertCompoundRegionsEqual(
152 self.instance,
153 eval(
154 repr(self.instance),
155 {
156 "UnionRegion": UnionRegion,
157 "IntersectionRegion": IntersectionRegion,
158 "Box": Box,
159 "Circle": Circle,
160 "UnitVector3d": UnitVector3d,
161 "Angle": Angle,
162 "AngleInterval": AngleInterval,
163 "NormalizedAngleInterval": NormalizedAngleInterval,
164 },
165 ),
166 )
168 @unittest.skipIf(not yaml, "YAML module can not be imported")
169 def testYaml(self):
170 """Test that YAML dump and load round-trip."""
171 self.assertCompoundRegionsEqual(yaml.safe_load(yaml.dump(self.instance)), self.instance)
174class UnionRegionTestCase(CompoundRegionTestMixin, unittest.TestCase):
175 """Test UnionRegion."""
177 def setUp(self):
178 CompoundRegionTestMixin.setUp(self)
179 self.instance = UnionRegion(*self.operands)
181 def testEmpty(self):
182 """Test zero-operand union which is quivalent to empty region."""
183 region = UnionRegion()
185 self.assertFalse(region.contains(UnitVector3d(self.point_in_both)))
186 self.assertFalse(region.contains(UnitVector3d(self.point_in_circle)))
187 self.assertFalse(region.contains(UnitVector3d(self.point_in_box)))
188 self.assertFalse(region.contains(UnitVector3d(self.point_in_neither)))
190 self.assertRelations(region, self.box, DISJOINT, False)
191 self.assertRelations(region, self.circle, DISJOINT, False)
192 self.assertRelations(region, self.faraway, DISJOINT, False)
193 self.assertRelations(region, self.instance, DISJOINT, False)
194 self.assertRelations(self.box, region, DISJOINT, False)
195 self.assertRelations(self.circle, region, DISJOINT, False)
196 self.assertRelations(self.faraway, region, DISJOINT, False)
197 self.assertRelations(self.instance, region, DISJOINT, False)
199 self.assertEqual(Region.getRegions(region), [])
201 def testContains(self):
202 """Test point-in-region checks."""
203 self.assertTrue(self.instance.contains(UnitVector3d(self.point_in_both)))
204 self.assertTrue(self.instance.contains(UnitVector3d(self.point_in_circle)))
205 self.assertTrue(self.instance.contains(UnitVector3d(self.point_in_box)))
206 self.assertFalse(self.instance.contains(UnitVector3d(self.point_in_neither)))
208 def testRelate(self):
209 """Test region-region relationship checks."""
210 self.assertRelations(self.instance, self.circle, CONTAINS, True)
211 self.assertRelations(self.instance, self.box, CONTAINS, True)
212 self.assertRelations(self.instance, self.faraway, DISJOINT, False)
213 self.assertRelations(self.circle, self.instance, WITHIN, True)
214 self.assertRelations(self.box, self.instance, WITHIN, True)
215 self.assertRelations(self.faraway, self.instance, DISJOINT, False)
217 def testBounding(self):
218 """Test for getBounding*() methods."""
219 region = UnionRegion()
220 self.assertTrue(region.getBoundingBox().empty())
221 self.assertTrue(region.getBoundingBox3d().empty())
222 self.assertTrue(region.getBoundingCircle().empty())
224 for operand in self.operands:
225 self.assertEqual(self.instance.getBoundingBox().relate(operand), CONTAINS)
226 for operand in self.operands:
227 self.assertTrue(self.instance.getBoundingBox3d().contains(operand.getBoundingBox3d()))
228 # This test fails for first operand (Circle), I guess due to precision.
229 for operand in self.operands[-1:]:
230 self.assertTrue(self.instance.getBoundingCircle().relate(operand), CONTAINS)
232 def testDecodeBase64(self):
233 """Test Region.decodeBase64, which includes special handling for
234 union regions.
235 """
236 # Test with the full UnionRegion encoded, then base64-encoded.
237 s1 = b64encode(self.instance.encode()).decode("ascii")
238 self.assertCompoundRegionsEqual(Region.decodeBase64(s1), self.instance)
239 # Test alternate form with union members concatenated with ':' after
240 # base64-encoding.
241 s2 = ":".join(b64encode(region.encode()).decode("ascii") for region in self.instance)
242 self.assertCompoundRegionsEqual(Region.decodeBase64(s2), self.instance)
243 # Test that an empty string decodes as a UnionRegion with no members.
244 self.assertCompoundRegionsEqual(Region.decodeBase64(""), UnionRegion())
247class IntersectionRegionTestCase(CompoundRegionTestMixin, unittest.TestCase):
248 """Test intersection region."""
250 def setUp(self):
251 CompoundRegionTestMixin.setUp(self)
252 self.instance = IntersectionRegion(*self.operands)
254 def testEmpty(self):
255 """Test zero-operand intersection (equivalent to full sphere)."""
256 region = IntersectionRegion()
258 self.assertTrue(region.contains(UnitVector3d(self.point_in_both)))
259 self.assertTrue(region.contains(UnitVector3d(self.point_in_circle)))
260 self.assertTrue(region.contains(UnitVector3d(self.point_in_box)))
261 self.assertTrue(region.contains(UnitVector3d(self.point_in_neither)))
263 self.assertRelations(region, self.box, CONTAINS, True)
264 self.assertRelations(region, self.circle, CONTAINS, True)
265 self.assertRelations(region, self.faraway, CONTAINS, True)
266 self.assertRelations(region, self.instance, CONTAINS, True)
267 self.assertRelations(self.box, region, WITHIN, True)
268 self.assertRelations(self.circle, region, WITHIN, True)
269 self.assertRelations(self.faraway, region, WITHIN, True)
270 # Overlaps between intersections are very conservative.
271 self.assertRelations(self.instance, region, WITHIN, None)
273 self.assertEqual(Region.getRegions(region), [])
275 def testContains(self):
276 """Test point-in-region checks."""
277 self.assertTrue(self.instance.contains(UnitVector3d(self.point_in_both)))
278 self.assertFalse(self.instance.contains(UnitVector3d(self.point_in_circle)))
279 self.assertFalse(self.instance.contains(UnitVector3d(self.point_in_box)))
280 self.assertFalse(self.instance.contains(UnitVector3d(self.point_in_neither)))
282 def testRelate(self):
283 """Test region-region relationship checks."""
284 self.assertRelations(self.instance, self.box, WITHIN, None)
285 self.assertRelations(self.instance, self.circle, WITHIN, None)
286 self.assertRelations(self.instance, self.faraway, DISJOINT, False)
287 self.assertRelations(self.circle, self.instance, CONTAINS, None)
288 self.assertRelations(self.box, self.instance, CONTAINS, None)
289 self.assertRelations(self.faraway, self.instance, DISJOINT, False)
291 def testGetRegion(self):
292 c1 = Circle(UnitVector3d(0.0, 0.0, 1.0), 1.0)
293 c2 = Circle(UnitVector3d(1.0, 0.0, 1.0), 2.0)
294 b1 = Box.fromDegrees(90, 0, 180, 45)
295 b2 = Box.fromDegrees(135, 15, 135, 30)
296 u1 = UnionRegion(c1, b1)
297 u2 = UnionRegion(c2, b2)
298 i1 = IntersectionRegion(c1, b1)
299 i2 = IntersectionRegion(c2, b2)
300 ur = UnionRegion(u1, u2)
301 ir = IntersectionRegion(i1, i2)
302 self.assertEqual(Region.getRegions(c1), [c1])
303 self.assertEqual(Region.getRegions(i1), [c1, b1])
304 self.assertEqual(Region.getRegions(u1), [c1, b1])
305 # Compounds of compounds will be flattened, order preserved.
306 self.assertEqual(Region.getRegions(ir), [c1, b1, c2, b2])
307 self.assertEqual(Region.getRegions(ur), [c1, b1, c2, b2])
309 # TODO: This test fails because CompoundRegion does not define
310 # equality operator, and it is non-trivial to add one.
311 # ur2 = UnionRegion(u1, i1, u2)
312 # self.assertEqual(Region.getRegions(ur2), [c1, b1, i1, c2, b2])
314 def testBounding(self):
315 """Test for getBounding*() methods."""
316 region = UnionRegion()
317 self.assertTrue(region.getBoundingBox().full())
318 self.assertTrue(region.getBoundingBox3d().full())
319 self.assertTrue(region.getBoundingCircle().full())
321 # Only Box3d test works reliably, other two fails due to boundary
322 # overlaps and precision.
323 for operand in self.operands:
324 self.assertTrue(operand.getBoundingBox3d().contains(self.instance.getBoundingBox3d()))
326 def testDecodeOverlapsBase64(self):
327 """Test Region.decodeOverlapsBase64.
329 This test is in this test case because it can make good use of the
330 concrete regions defined in setUp.
331 """
333 def run_overlaps(pairs):
334 or_terms = []
335 for a, b in pairs:
336 a_str = b64encode(a.encode()).decode("ascii")
337 b_str = b64encode(b.encode()).decode("ascii")
338 or_terms.append(f"{a_str}&{b_str}")
339 overlap_str = "|".join(or_terms)
340 return Region.decodeOverlapsBase64(overlap_str)
342 self.assertEqual(run_overlaps([]), False)
343 self.assertEqual(run_overlaps([(self.box, self.circle)]), True)
344 self.assertEqual(run_overlaps([(self.box, self.faraway)]), False)
345 self.assertEqual(run_overlaps([(self.circle, self.faraway)]), False)
346 self.assertEqual(run_overlaps([(self.instance, self.box)]), None)
347 self.assertEqual(run_overlaps([(self.box, self.circle), (self.box, self.faraway)]), True)
348 self.assertEqual(run_overlaps([(self.faraway, self.circle), (self.box, self.faraway)]), False)
349 self.assertEqual(run_overlaps([(self.instance, self.box), (self.circle, self.faraway)]), None)
350 self.assertEqual(run_overlaps([(self.instance, self.box), (self.circle, self.box)]), True)
351 self.assertEqual(run_overlaps([(self.circle, self.box), (self.instance, self.box)]), True)
353 with self.assertRaises(RuntimeError):
354 # Decoding a single region is an error; that's not an expression.
355 Region.decodeOverlapsBase64(b64encode(self.box.encode()).decode("ascii"))
358if __name__ == "__main__":
359 unittest.main()