Coverage for tests / test_CompoundRegion.py: 17%

188 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-26 08:41 +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/>. 

27 

28import pickle 

29import unittest 

30from base64 import b64encode 

31 

32try: 

33 import yaml 

34except ImportError: 

35 yaml = None 

36 

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) 

54 

55 

56class CompoundRegionTestMixin: 

57 """Tests for both UnionRegion and IntersectionRegion. 

58 

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

63 

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) 

78 

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) 

85 

86 def assertCompoundRegionsEqual(self, a, b): 

87 """Assert that two compound regions are equal. 

88 

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) 

97 

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) 

102 

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) 

121 

122 def testOperands(self): 

123 """Test the cloneOperands accessor.""" 

124 self.assertOperandsEqual(self.instance, self.operands) 

125 

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) 

134 

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) 

141 

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) 

146 

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 ) 

167 

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) 

172 

173 

174class UnionRegionTestCase(CompoundRegionTestMixin, unittest.TestCase): 

175 """Test UnionRegion.""" 

176 

177 def setUp(self): 

178 CompoundRegionTestMixin.setUp(self) 

179 self.instance = UnionRegion(*self.operands) 

180 

181 def testEmpty(self): 

182 """Test zero-operand union which is quivalent to empty region.""" 

183 region = UnionRegion() 

184 

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

189 

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) 

198 

199 self.assertEqual(Region.getRegions(region), []) 

200 

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

207 

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) 

216 

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

223 

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) 

231 

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

245 

246 

247class IntersectionRegionTestCase(CompoundRegionTestMixin, unittest.TestCase): 

248 """Test intersection region.""" 

249 

250 def setUp(self): 

251 CompoundRegionTestMixin.setUp(self) 

252 self.instance = IntersectionRegion(*self.operands) 

253 

254 def testEmpty(self): 

255 """Test zero-operand intersection (equivalent to full sphere).""" 

256 region = IntersectionRegion() 

257 

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

262 

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) 

272 

273 self.assertEqual(Region.getRegions(region), []) 

274 

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

281 

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) 

290 

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

308 

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

313 

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

320 

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

325 

326 def testDecodeOverlapsBase64(self): 

327 """Test Region.decodeOverlapsBase64. 

328 

329 This test is in this test case because it can make good use of the 

330 concrete regions defined in setUp. 

331 """ 

332 

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) 

341 

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) 

352 

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

356 

357 

358if __name__ == "__main__": 

359 unittest.main()