Coverage for python/lsst/sphgeom/_continue_class.py: 39%
78 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-30 08:25 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-30 08:25 +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 Box,
41 Circle,
42 ConvexPolygon,
43 LonLat,
44 Region,
45 UnitVector3d,
46)
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)
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
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
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
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)
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)
108@_continueClass
109class Region:
110 """A minimal interface for 2-dimensional regions on the unit sphere."""
112 @classmethod
113 def from_ivoa_pos(cls, pos: str) -> Region:
114 """Create a Region from an IVOA POS string.
116 Parameters
117 ----------
118 pos : `str`
119 A string using the IVOA SIAv2 POS syntax.
121 Returns
122 -------
123 region : `Region`
124 A region equivalent to the POS string.
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:
132 * ``CIRCLE <longitude> <latitude> <radius>``
133 * ``RANGE <longitude1> <longitude2> <latitude1> <latitude2>``
134 * ``POLYGON <longitude1> <latitude1> ... (at least 3 pairs)``
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)
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 )
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])
171 raise ValueError(f"Unrecognized shape in POS string '{pos}'")
173 def to_ivoa_pos(self) -> str:
174 """Represent the region as an IVOA POS string.
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.")
184@_continueClass
185class Circle: # noqa: F811
186 """A circular region on the unit sphere that contains its boundary."""
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}"
197@_continueClass
198class Box: # noqa: F811
199 """A rectangle in spherical coordinate space that contains its boundary."""
201 def to_ivoa_pos(self) -> str:
202 # Docstring inherited.
203 lon_range = self.getLon()
204 lat_range = self.getLat()
206 lon1 = lon_range.getA().asDegrees()
207 lon2 = lon_range.getB().asDegrees()
208 lat1 = lat_range.getA().asDegrees()
209 lat2 = lat_range.getB().asDegrees()
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}"
216@_continueClass
217class ConvexPolygon: # noqa: F811
218 """A rectangle in spherical coordinate space that contains its boundary."""
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]
225 return f"POLYGON {' '.join(coord_strings)}"