Coverage for python/lsst/sphgeom/_continue_class.py: 100%
78 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 09:19 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 09:19 +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#
29"""Extend any of the C++ Python classes by adding additional methods."""
31# Nothing to export.
32__all__ = []
34import math
35import sys
36import typing
38from ._sphgeom import (
39 Angle,
40 AngleInterval,
41 Box,
42 Circle,
43 ConvexPolygon,
44 LonLat,
45 NormalizedAngleInterval,
46 Region,
47 UnitVector3d,
48)
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)
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
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
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
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)
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)
110@_continueClass
111class Region: # type: ignore[no-redef]
112 """A minimal interface for 2-dimensional regions on the unit sphere."""
114 @classmethod
115 def from_ivoa_pos(cls, pos: str) -> Region:
116 """Create a Region from an IVOA POS string.
118 Parameters
119 ----------
120 pos : `str`
121 A string using the IVOA SIAv2 POS syntax.
123 Returns
124 -------
125 region : `Region`
126 A region equivalent to the POS string.
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:
134 * ``CIRCLE <longitude> <latitude> <radius>``
135 * ``RANGE <longitude1> <longitude2> <latitude1> <latitude2>``
136 * ``POLYGON <longitude1> <latitude1> ... (at least 3 pairs)``
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)
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 )
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])
173 raise ValueError(f"Unrecognized shape in POS string '{pos}'")
175 def to_ivoa_pos(self) -> str:
176 """Represent the region as an IVOA POS string.
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.")
186@_continueClass
187class Circle: # type: ignore[no-redef] # noqa: F811
188 """A circular region on the unit sphere that contains its boundary."""
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}"
199@_continueClass
200class Box: # type: ignore[no-redef] # noqa: F811
201 """A rectangle in spherical coordinate space that contains its boundary."""
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]
208 lon1 = lon_range.getA().asDegrees()
209 lon2 = lon_range.getB().asDegrees()
210 lat1 = lat_range.getA().asDegrees()
211 lat2 = lat_range.getB().asDegrees()
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}"
218@_continueClass
219class ConvexPolygon: # type: ignore[no-redef] # noqa: F811
220 """A rectangle in spherical coordinate space that contains its boundary."""
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]
227 return f"POLYGON {' '.join(coord_strings)}"