Coverage for core / src / sensorkit / astro / common.py: 93%
74 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
1# SPDX-License-Identifier: Apache-2.0
2from __future__ import annotations
4from dataclasses import dataclass
5from enum import StrEnum, auto
6from typing import TYPE_CHECKING
8from pydantic import BaseModel
10from sensorkit.common.keyword import declare_keyword
12if TYPE_CHECKING:
13 from sensorkit.astro.coords import Equatorial, Geodetic, Horizontal
16class ReferenceFrame(StrEnum):
17 """Supported celestial/terrestrial reference frames."""
18 ALTAZ = auto()
19 CIRF = auto()
20 GCRF = auto()
21 ICRF = auto()
22 ITRF = auto()
23 TEME = auto()
25 def to_astropy(self):
26 """Return the corresponding astropy coordinate frame class."""
27 import astropy.coordinates as ac
29 match self:
30 case ReferenceFrame.CIRF:
31 return ac.CIRS
32 case ReferenceFrame.GCRF:
33 return ac.GCRS
34 case ReferenceFrame.ICRF:
35 return ac.ICRS
36 case ReferenceFrame.ITRF:
37 return ac.ITRS
38 case _:
39 return ac.frame_transform_graph.lookup_name(self.value)
42@dataclass(frozen=True, slots=True)
43class TLE:
44 """A Two-Line Element set, optionally with a name line (line0)."""
45 line0: str | None
46 line1: str
47 line2: str
49 def to_list(self):
50 """Return the TLE as a list of strings, omitting line0 if it is None."""
51 if self.line0 is not None:
52 return [self.line0, self.line1, self.line2]
53 else:
54 return [self.line1, self.line2]
56 @property
57 def norad_id(self):
58 raw = self.line1[2:7].strip()
60 return (
61 str((ord(raw[0].upper()) - ord("A") + 10) * 10000 + int(raw[1:]))
62 if raw and raw[0].isalpha()
63 else str(int(raw))
64 )
67@declare_keyword
68class SitePosition(BaseModel):
69 """Geographic location of the observing site in degrees and km."""
70 latitude_degrees: float
71 longitude_degrees: float
72 altitude_km: float
74 @classmethod
75 def from_coords(cls, coords: Geodetic):
76 """Create a SitePosition from a Geodetic coordinate object."""
77 return cls(
78 latitude_degrees=coords.lat,
79 longitude_degrees=coords.lon,
80 altitude_km=coords.elev / 1000,
81 )
83 def to_coords(self):
84 """Convert to a Geodetic coordinate object."""
85 from sensorkit.astro.coords import Geodetic
86 return Geodetic(
87 lon=self.longitude_degrees,
88 lat=self.latitude_degrees,
89 elev=self.altitude_km * 1000,
90 )
93@declare_keyword
94class AltAzPointing(BaseModel):
95 """Current telescope pointing in horizontal (altitude/azimuth) coordinates."""
96 altitude_degrees: float
97 azimuth_degrees: float
99 @classmethod
100 def from_coords(cls, coords: Horizontal):
101 """Create an AltAzPointing from a Horizontal coordinate object."""
102 return cls(altitude_degrees=coords.alt, azimuth_degrees=coords.az)
105@declare_keyword
106class RADecPointing(BaseModel):
107 """Current telescope pointing in equatorial (RA/Dec) coordinates."""
108 right_ascension_hours: float
109 declination_degrees: float
110 reference_frame: ReferenceFrame = ReferenceFrame.ICRF
112 @classmethod
113 def from_coords(cls, coords: Equatorial):
114 """Create an RADecPointing from an Equatorial coordinate object."""
115 return cls(right_ascension_hours=coords.ra, declination_degrees=coords.dec)
117 def to_coords(self):
118 """Convert to an Equatorial coordinate object."""
119 from sensorkit.astro.coords import Equatorial
120 return Equatorial(ra=self.right_ascension_hours * 15, dec=self.declination_degrees)
122 @property
123 def ra_hms(self):
124 """Right ascension formatted as hours, minutes, seconds."""
125 return self.to_coords().ra_hms
127 @property
128 def dec_dms(self):
129 """Declination formatted as degrees, minutes, seconds."""
130 return self.to_coords().dec_dms