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

78 statements  

« 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/>. 

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 Angle, Box, Circle, ConvexPolygon, LonLat, Region, UnitVector3d 

39 

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

41# * INTRINSIC_SPECIAL_ATTRIBUTES 

42# * isAttributeSafeToTransfer 

43# * continueClass 

44_INTRINSIC_SPECIAL_ATTRIBUTES = frozenset( 

45 ( 

46 "__qualname__", 

47 "__module__", 

48 "__metaclass__", 

49 "__dict__", 

50 "__weakref__", 

51 "__class__", 

52 "__subclasshook__", 

53 "__name__", 

54 "__doc__", 

55 ) 

56) 

57 

58 

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

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

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

62 ): 

63 return False 

64 return True 

65 

66 

67def _continueClass(cls): 

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

69 for name in dir(cls): 

70 # Common descriptors like classmethod and staticmethod can only be 

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

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

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

74 # class. 

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

76 if _isAttributeSafeToTransfer(name, attr): 

77 setattr(orig, name, attr) 

78 return orig 

79 

80 

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

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

83 if not math.isinf(value): 

84 return value 

85 if value > 0.0: 

86 return max 

87 return min 

88 

89 

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

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

92 return _inf_to_limit(lat, -90.0, 90.0) 

93 

94 

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

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

97 return _inf_to_limit(lat, 0.0, 360.0) 

98 

99 

100@_continueClass 

101class Region: 

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

103 

104 @classmethod 

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

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

107 

108 Parameters 

109 ---------- 

110 pos : `str` 

111 A string using the IVOA SIAv2 POS syntax. 

112 

113 Returns 

114 ------- 

115 region : `Region` 

116 A region equivalent to the POS string. 

117 

118 Notes 

119 ----- 

120 See 

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

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

123 

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

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

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

127 

128 Units are degrees in all coordinates. 

129 """ 

130 shape, *coordinates = pos.split() 

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

132 n_floats = len(coordinates) 

133 if shape == "CIRCLE": 

134 if n_floats != 3: 

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

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

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

138 return Circle(UnitVector3d(center), radius) 

139 

140 if shape == "RANGE": 

141 if n_floats != 4: 

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

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

144 # Box and so must be converted. 

145 return Box( 

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

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

148 ) 

149 

150 if shape == "POLYGON": 

151 if n_floats % 2 != 0: 

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

153 if n_floats < 6: 

154 raise ValueError( 

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

156 ) 

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

158 # Get pairs by skipping every other value. 

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

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

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

162 

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

164 

165 def to_ivoa_pos(self) -> str: 

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

167 

168 Returns 

169 ------- 

170 pos : `str` 

171 The region in ``POS`` format. 

172 """ 

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

174 

175 

176@_continueClass 

177class Circle: # noqa: F811 

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

179 

180 def to_ivoa_pos(self) -> str: 

181 # Docstring inherited. 

182 center = LonLat(self.getCenter()) 

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

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

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

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

187 

188 

189@_continueClass 

190class Box: # noqa: F811 

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

192 

193 def to_ivoa_pos(self) -> str: 

194 # Docstring inherited. 

195 lon_range = self.getLon() 

196 lat_range = self.getLat() 

197 

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

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

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

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

202 

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

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

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

206 

207 

208@_continueClass 

209class ConvexPolygon: # noqa: F811 

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

211 

212 def to_ivoa_pos(self) -> str: 

213 # Docstring inherited. 

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

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

216 

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