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

1# SPDX-License-Identifier: Apache-2.0 

2from __future__ import annotations 

3 

4from dataclasses import dataclass 

5from enum import StrEnum, auto 

6from typing import TYPE_CHECKING 

7 

8from pydantic import BaseModel 

9 

10from sensorkit.common.keyword import declare_keyword 

11 

12if TYPE_CHECKING: 

13 from sensorkit.astro.coords import Equatorial, Geodetic, Horizontal 

14 

15 

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() 

24 

25 def to_astropy(self): 

26 """Return the corresponding astropy coordinate frame class.""" 

27 import astropy.coordinates as ac 

28 

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) 

40 

41 

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 

48 

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] 

55 

56 @property 

57 def norad_id(self): 

58 raw = self.line1[2:7].strip() 

59 

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 ) 

65 

66 

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 

73 

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 ) 

82 

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 ) 

91 

92 

93@declare_keyword 

94class AltAzPointing(BaseModel): 

95 """Current telescope pointing in horizontal (altitude/azimuth) coordinates.""" 

96 altitude_degrees: float 

97 azimuth_degrees: float 

98 

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) 

103 

104 

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 

111 

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) 

116 

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) 

121 

122 @property 

123 def ra_hms(self): 

124 """Right ascension formatted as hours, minutes, seconds.""" 

125 return self.to_coords().ra_hms 

126 

127 @property 

128 def dec_dms(self): 

129 """Declination formatted as degrees, minutes, seconds.""" 

130 return self.to_coords().dec_dms