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

78 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-09 01:59 -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 AngleInterval, 

41 Box, 

42 Circle, 

43 ConvexPolygon, 

44 LonLat, 

45 NormalizedAngleInterval, 

46 Region, 

47 UnitVector3d, 

48) 

49 

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

51# * INTRINSIC_SPECIAL_ATTRIBUTES 

52# * isAttributeSafeToTransfer 

53# * continueClass 

54_INTRINSIC_SPECIAL_ATTRIBUTES = frozenset( 

55 ( 

56 "__qualname__", 

57 "__module__", 

58 "__metaclass__", 

59 "__dict__", 

60 "__weakref__", 

61 "__class__", 

62 "__subclasshook__", 

63 "__name__", 

64 "__doc__", 

65 ) 

66) 

67 

68 

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

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

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

72 ): 

73 return False 

74 return True 

75 

76 

77def _continueClass(cls: type) -> type: 

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

79 for name in dir(cls): 

80 # Common descriptors like classmethod and staticmethod can only be 

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

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

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

84 # class. 

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

86 if _isAttributeSafeToTransfer(name, attr): 

87 setattr(orig, name, attr) 

88 return orig 

89 

90 

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

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

93 if not math.isinf(value): 

94 return value 

95 if value > 0.0: 

96 return max 

97 return min 

98 

99 

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

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

102 return _inf_to_limit(lat, -90.0, 90.0) 

103 

104 

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

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

107 return _inf_to_limit(lat, 0.0, 360.0) 

108 

109 

110@_continueClass 

111class Region: # type: ignore[no-redef] 

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

113 

114 @classmethod 

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

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

117 

118 Parameters 

119 ---------- 

120 pos : `str` 

121 A string using the IVOA SIAv2 POS syntax. 

122 

123 Returns 

124 ------- 

125 region : `Region` 

126 A region equivalent to the POS string. 

127 

128 Notes 

129 ----- 

130 See 

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

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

133 

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

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

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

137 

138 Units are degrees in all coordinates. 

139 """ 

140 shape, *_coordinates = pos.split() 

141 coordinates = tuple(float(c) for c in _coordinates) 

142 n_floats = len(coordinates) 

143 if shape == "CIRCLE": 

144 if n_floats != 3: 

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

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

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

148 return Circle(UnitVector3d(center), radius) 

149 

150 if shape == "RANGE": 

151 if n_floats != 4: 

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

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

154 # Box and so must be converted. 

155 return Box( 

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

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

158 ) 

159 

160 if shape == "POLYGON": 

161 if n_floats % 2 != 0: 

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

163 if n_floats < 6: 

164 raise ValueError( 

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

166 ) 

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

168 # Get pairs by skipping every other value. 

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

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

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

172 

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

174 

175 def to_ivoa_pos(self) -> str: 

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

177 

178 Returns 

179 ------- 

180 pos : `str` 

181 The region in ``POS`` format. 

182 """ 

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

184 

185 

186@_continueClass 

187class Circle: # type: ignore[no-redef] # noqa: F811 

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

189 

190 def to_ivoa_pos(self) -> str: 

191 # Docstring inherited. 

192 center = LonLat(self.getCenter()) # type: ignore[attr-defined] 

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

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

195 rad = self.getOpeningAngle().asDegrees() # type: ignore[attr-defined] 

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

197 

198 

199@_continueClass 

200class Box: # type: ignore[no-redef] # noqa: F811 

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

202 

203 def to_ivoa_pos(self) -> str: 

204 # Docstring inherited. 

205 lon_range: NormalizedAngleInterval = self.getLon() # type: ignore[attr-defined] 

206 lat_range: AngleInterval = self.getLat() # type: ignore[attr-defined] 

207 

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

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

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

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

212 

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

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

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

216 

217 

218@_continueClass 

219class ConvexPolygon: # type: ignore[no-redef] # noqa: F811 

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

221 

222 def to_ivoa_pos(self) -> str: 

223 # Docstring inherited. 

224 coords = (LonLat(v) for v in self.getVertices()) # type: ignore[attr-defined] 

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

226 

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