Coverage for python/lsst/sphgeom/_continue_class.py: 39%

78 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-30 01:24 -0700

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# 

28 

29"""Extend any of the C++ Python classes by adding additional methods.""" 

30 

31# Nothing to export. 

32__all__ = [] 

33 

34import math 

35import sys 

36import typing 

37 

38from ._sphgeom import ( 

39 Angle, 

40 Box, 

41 Circle, 

42 ConvexPolygon, 

43 LonLat, 

44 Region, 

45 UnitVector3d, 

46) 

47 

48# Copy and paste from lsst.utils.wrappers: 

49# * INTRINSIC_SPECIAL_ATTRIBUTES 

50# * isAttributeSafeToTransfer 

51# * continueClass 

52_INTRINSIC_SPECIAL_ATTRIBUTES = frozenset( 

53 ( 

54 "__qualname__", 

55 "__module__", 

56 "__metaclass__", 

57 "__dict__", 

58 "__weakref__", 

59 "__class__", 

60 "__subclasshook__", 

61 "__name__", 

62 "__doc__", 

63 ) 

64) 

65 

66 

67def _isAttributeSafeToTransfer(name: str, value: typing.Any) -> bool: 

68 if name.startswith("__") and ( 

69 value is getattr(object, name, None) or name in _INTRINSIC_SPECIAL_ATTRIBUTES 

70 ): 

71 return False 

72 return True 

73 

74 

75def _continueClass(cls): 

76 orig = getattr(sys.modules[cls.__module__], cls.__name__) 

77 for name in dir(cls): 

78 # Common descriptors like classmethod and staticmethod can only be 

79 # accessed without invoking their magic if we use __dict__; if we use 

80 # getattr on those we'll get e.g. a bound method instance on the dummy 

81 # class rather than a classmethod instance we can put on the target 

82 # class. 

83 attr = cls.__dict__.get(name, None) or getattr(cls, name) 

84 if _isAttributeSafeToTransfer(name, attr): 

85 setattr(orig, name, attr) 

86 return orig 

87 

88 

89def _inf_to_limit(value: float, min: float, max: float) -> float: 

90 """Map a value to a fixed range if infinite.""" 

91 if not math.isinf(value): 

92 return value 

93 if value > 0.0: 

94 return max 

95 return min 

96 

97 

98def _inf_to_lat(lat: float) -> float: 

99 """Map latitude +Inf to +90 and -Inf to -90 degrees.""" 

100 return _inf_to_limit(lat, -90.0, 90.0) 

101 

102 

103def _inf_to_lon(lat: float) -> float: 

104 """Map longitude +Inf to +360 and -Inf to 0 degrees.""" 

105 return _inf_to_limit(lat, 0.0, 360.0) 

106 

107 

108@_continueClass 

109class Region: 

110 """A minimal interface for 2-dimensional regions on the unit sphere.""" 

111 

112 @classmethod 

113 def from_ivoa_pos(cls, pos: str) -> Region: 

114 """Create a Region from an IVOA POS string. 

115 

116 Parameters 

117 ---------- 

118 pos : `str` 

119 A string using the IVOA SIAv2 POS syntax. 

120 

121 Returns 

122 ------- 

123 region : `Region` 

124 A region equivalent to the POS string. 

125 

126 Notes 

127 ----- 

128 See 

129 https://ivoa.net/documents/SIA/20151223/REC-SIA-2.0-20151223.html#toc12 

130 for a description of the POS parameter but in summary the options are: 

131 

132 * ``CIRCLE <longitude> <latitude> <radius>`` 

133 * ``RANGE <longitude1> <longitude2> <latitude1> <latitude2>`` 

134 * ``POLYGON <longitude1> <latitude1> ... (at least 3 pairs)`` 

135 

136 Units are degrees in all coordinates. 

137 """ 

138 shape, *coordinates = pos.split() 

139 coordinates = tuple(float(c) for c in coordinates) 

140 n_floats = len(coordinates) 

141 if shape == "CIRCLE": 

142 if n_floats != 3: 

143 raise ValueError(f"CIRCLE requires 3 numbers but got {n_floats} in '{pos}'.") 

144 center = LonLat.fromDegrees(coordinates[0], coordinates[1]) 

145 radius = Angle.fromDegrees(coordinates[2]) 

146 return Circle(UnitVector3d(center), radius) 

147 

148 if shape == "RANGE": 

149 if n_floats != 4: 

150 raise ValueError(f"RANGE requires 4 numbers but got {n_floats} in '{pos}'.") 

151 # POS allows +Inf and -Inf in ranges. These are not allowed by 

152 # Box and so must be converted. 

153 return Box( 

154 LonLat.fromDegrees(_inf_to_lon(coordinates[0]), _inf_to_lat(coordinates[2])), 

155 LonLat.fromDegrees(_inf_to_lon(coordinates[1]), _inf_to_lat(coordinates[3])), 

156 ) 

157 

158 if shape == "POLYGON": 

159 if n_floats % 2 != 0: 

160 raise ValueError(f"POLYGON requires even number of floats but got {n_floats} in '{pos}'.") 

161 if n_floats < 6: 

162 raise ValueError( 

163 f"POLYGON specification requires at least 3 coordinates, got {n_floats // 2} in '{pos}'" 

164 ) 

165 # Coordinates are x1, y1, x2, y2, x3, y3... 

166 # Get pairs by skipping every other value. 

167 pairs = list(zip(coordinates[0::2], coordinates[1::2], strict=True)) 

168 vertices = [LonLat.fromDegrees(lon, lat) for lon, lat in pairs] 

169 return ConvexPolygon([UnitVector3d(c) for c in vertices]) 

170 

171 raise ValueError(f"Unrecognized shape in POS string '{pos}'") 

172 

173 def to_ivoa_pos(self) -> str: 

174 """Represent the region as an IVOA POS string. 

175 

176 Returns 

177 ------- 

178 pos : `str` 

179 The region in ``POS`` format. 

180 """ 

181 raise NotImplementedError("This region can not be converted to an IVOA POS string.") 

182 

183 

184@_continueClass 

185class Circle: # noqa: F811 

186 """A circular region on the unit sphere that contains its boundary.""" 

187 

188 def to_ivoa_pos(self) -> str: 

189 # Docstring inherited. 

190 center = LonLat(self.getCenter()) 

191 lon = center.getLon().asDegrees() 

192 lat = center.getLat().asDegrees() 

193 rad = self.getOpeningAngle().asDegrees() 

194 return f"CIRCLE {lon} {lat} {rad}" 

195 

196 

197@_continueClass 

198class Box: # noqa: F811 

199 """A rectangle in spherical coordinate space that contains its boundary.""" 

200 

201 def to_ivoa_pos(self) -> str: 

202 # Docstring inherited. 

203 lon_range = self.getLon() 

204 lat_range = self.getLat() 

205 

206 lon1 = lon_range.getA().asDegrees() 

207 lon2 = lon_range.getB().asDegrees() 

208 lat1 = lat_range.getA().asDegrees() 

209 lat2 = lat_range.getB().asDegrees() 

210 

211 # Do not attempt to map to +/- Inf -- there is no way to know if 

212 # that is any better than 0. -> 360. 

213 return f"RANGE {lon1} {lon2} {lat1} {lat2}" 

214 

215 

216@_continueClass 

217class ConvexPolygon: # noqa: F811 

218 """A rectangle in spherical coordinate space that contains its boundary.""" 

219 

220 def to_ivoa_pos(self) -> str: 

221 # Docstring inherited. 

222 coords = (LonLat(v) for v in self.getVertices()) 

223 coord_strings = [f"{c.getLon().asDegrees()} {c.getLat().asDegrees()}" for c in coords] 

224 

225 return f"POLYGON {' '.join(coord_strings)}"