@@ -92,60 +92,72 @@ class OrbitalError(Exception):
9292 pass
9393
9494
95+ def ecef_to_topocentric (rx , ry , rz , lat , lon , theta ):
96+ """Convert ECEF vector to topocentric-horizon coordinates."""
97+ sin_lat = np .sin (lat )
98+ cos_lat = np .cos (lat )
99+ sin_theta = np .sin (theta )
100+ cos_theta = np .cos (theta )
101+
102+ # Transform ECEF coordinates to topocentric-horizon coordinates
103+ top_s = sin_lat * cos_theta * rx + sin_lat * sin_theta * ry - cos_lat * rz
104+ top_e = - sin_theta * rx + cos_theta * ry
105+ top_z = cos_lat * cos_theta * rx + cos_lat * sin_theta * ry + sin_lat * rz
106+
107+ return top_s , top_e , top_z
108+
109+ def compute_azimuth_elevation (top_s , top_e , top_z , rg_ ):
110+ """Compute azimuth and elevation from topocentric coordinates."""
111+
112+ # Azimuth is undefined when elevation is 90 degrees, 180 (pi) will be returned.
113+ az_ = np .arctan2 (- top_e , top_s ) + np .pi
114+ az_ = np .mod (az_ , 2 * np .pi ) # Needed on some platforms
115+
116+ # Due to rounding top_z can be larger than rg_ (when el_ ~ 90).
117+ top_z_divided_by_rg_ = np .clip (top_z / rg_ , - 1 , 1 )
118+ el_ = np .arcsin (top_z_divided_by_rg_ )
119+
120+ return np .rad2deg (az_ ), np .rad2deg (el_ )
121+
95122def get_observer_look (sat_lon , sat_lat , sat_alt , utc_time , lon , lat , alt ):
96- """Calculate observers look angle to a satellite.
123+ """Calculate observer's look angle to a satellite.
97124
98125 http://celestrak.com/columns/v02n02/
99126
100- :utc_time: Observation time (datetime object)
101- :lon: Longitude of observer position on ground in degrees east
102- :lat: Latitude of observer position on ground in degrees north
103- :alt: Altitude above sea-level (geoid) of observer position on ground in km
104-
105- :return: (Azimuth, Elevation)
127+ :param sat_lon: Satellite longitude in degrees east
128+ :param sat_lat: Satellite latitude in degrees north
129+ :param sat_alt: Satellite altitude in km
130+ :param utc_time: Observation time (datetime object)
131+ :param lon: Observer longitude in degrees east
132+ :param lat: Observer latitude in degrees north
133+ :param alt: Observer altitude in km
134+ :return: (Azimuth, Elevation) in degrees
106135 """
107- (pos_x , pos_y , pos_z ), (vel_x , vel_y , vel_z ) = astronomy .observer_position (
108- utc_time , sat_lon , sat_lat , sat_alt )
109-
110- (opos_x , opos_y , opos_z ), (ovel_x , ovel_y , ovel_z ) = \
111- astronomy .observer_position (utc_time , lon , lat , alt )
136+ # Get satellite and observer ECEF positions
137+ (pos_x , pos_y , pos_z ), _ = astronomy .observer_position (utc_time , sat_lon , sat_lat , sat_alt )
138+ (opos_x , opos_y , opos_z ), _ = astronomy .observer_position (utc_time , lon , lat , alt )
112139
140+ # Convert observer coordinates to radians
113141 lon = np .deg2rad (lon )
114142 lat = np .deg2rad (lat )
115143
144+ # Compute local sidereal time
116145 theta = (astronomy .gmst (utc_time ) + lon ) % (2 * np .pi )
117146
147+ # Vector from observer to satellite
118148 rx = pos_x - opos_x
119149 ry = pos_y - opos_y
120150 rz = pos_z - opos_z
121-
122- sin_lat = np .sin (lat )
123- cos_lat = np .cos (lat )
124- sin_theta = np .sin (theta )
125- cos_theta = np .cos (theta )
126-
127- top_s = sin_lat * cos_theta * rx + \
128- sin_lat * sin_theta * ry - cos_lat * rz
129- top_e = - sin_theta * rx + cos_theta * ry
130- top_z = cos_lat * cos_theta * rx + \
131- cos_lat * sin_theta * ry + sin_lat * rz
132-
133- # Azimuth is undefined when elevation is 90 degrees, 180 (pi) will be returned.
134- az_ = np .arctan2 (- top_e , top_s ) + np .pi
135- az_ = np .mod (az_ , 2 * np .pi ) # Needed on some platforms
136-
137151 rg_ = np .sqrt (rx * rx + ry * ry + rz * rz )
138152
139- top_z_divided_by_rg_ = top_z / rg_
153+ # Convert to topocentric coordinates
154+ top_s , top_e , top_z = ecef_to_topocentric (rx , ry , rz , lat , lon , theta )
140155
141- # Due to rounding top_z can be larger than rg_ (when el_ ~ 90).
142- top_z_divided_by_rg_ = top_z_divided_by_rg_ .clip (max = 1 )
143- el_ = np .arcsin (top_z_divided_by_rg_ )
144-
145- return np .rad2deg (az_ ), np .rad2deg (el_ )
156+ # Compute azimuth and elevation
157+ return compute_azimuth_elevation (top_s , top_e , top_z , rg_ )
146158
147159
148- class Orbital ( object ) :
160+ class Orbital :
149161 """Class for orbital computations.
150162
151163 The *satellite* parameter is the name of the satellite to work on and is
@@ -336,7 +348,7 @@ def get_orbit_number(self, utc_time, tbus_style=False, as_float=False):
336348
337349 return orbit
338350
339- def get_next_passes (self , utc_time , length , lon , lat , alt , tol = 0.001 , horizon = 0 ) :
351+ def get_next_passes (self , utc_time : dt . datetime , length : float , lon : float , lat : float , alt : float , tol : float = 0.001 , horizon : float = 0 ) -> list [ tuple [ dt . datetime , dt . datetime , dt . datetime ]] :
340352 """Calculate passes for the next hours for a given start time and a given observer.
341353
342354 Original by Martin.
@@ -522,23 +534,61 @@ def _elevation_inv(self, utc_time, lon, lat, alt, horizon, minutes):
522534 """Compute the inverse of elevation."""
523535 return - self ._elevation (utc_time , lon , lat , alt , horizon , minutes )
524536
537+ def get_lonlatalt_vectorized (self , utc_times ):
538+ """Vectorized version of get_lonlatalt for multiple times."""
539+ if isinstance (utc_times , str ) or not hasattr (utc_times , '__iter__' ):
540+ raise TypeError ("Input must be a datetime or iterable of datetimes." )
541+ if utc_times is None or len (utc_times ) == 0 :
542+ return np .array ([]), np .array ([]), np .array ([])
543+ if not isinstance (utc_times , (list , np .ndarray )):
544+ utc_times = [utc_times ]
545+ results = [self .get_lonlatalt (t ) for t in utc_times ]
546+ lon , lat , alt = zip (* results )
547+ return np .array (lon ), np .array (lat ), np .array (alt )
548+
549+ def get_observer_look_vectorized (self , utc_times , lon , lat , alt ):
550+ """Vectorized observer look angles."""
551+ if isinstance (utc_times , str ) or not hasattr (utc_times , '__iter__' ):
552+ raise TypeError ("Input must be a datetime or iterable of datetimes." )
553+ if utc_times is None or len (utc_times ) == 0 :
554+ return np .array ([]), np .array ([])
555+ if not isinstance (utc_times , (list , np .ndarray )):
556+ utc_times = [utc_times ]
557+ results = [self .get_observer_look (t , lon , lat , alt ) for t in utc_times ]
558+ az , el = zip (* results )
559+ return np .array (az ), np .array (el )
560+
561+ def get_orbit_number_vectorized (self , utc_times , tbus_style = False ):
562+ """Vectorized orbit number calculation."""
563+ if isinstance (utc_times , str ) or not hasattr (utc_times , '__iter__' ):
564+ raise TypeError ("Input must be a datetime or iterable of datetimes." )
565+ if utc_times is None or len (utc_times ) == 0 :
566+ return []
567+ if not isinstance (utc_times , (list , np .ndarray )):
568+ utc_times = [utc_times ]
569+ return [self .get_orbit_number (t , tbus_style = tbus_style ) for t in utc_times ]
570+
525571
526572def _get_root (fun , start , end , tol = 0.01 ):
527- """Root finding scheme."""
528- x_0 = end
529- x_1 = start
530- fx_0 = fun (end )
531- fx_1 = fun (start )
573+ """Find a root of `fun` in [start, end] using Brent's method with tolerance `tol`."""
574+ x_0 = float (end )
575+ x_1 = float (start )
576+ fx_0 = fun (x_0 )
577+ fx_1 = fun (x_1 )
578+
579+ if fx_0 * fx_1 > 0 :
580+ raise ValueError ("Function values at interval endpoints must have opposite signs." )
581+
582+ # Swap for better convergence
532583 if abs (fx_0 ) < abs (fx_1 ):
533584 fx_0 , fx_1 = fx_1 , fx_0
534585 x_0 , x_1 = x_1 , x_0
535586
536- x_n = optimize .brentq (fun , x_0 , x_1 )
537- return x_n
587+ return optimize .brentq (fun , x_0 , x_1 , xtol = tol , rtol = tol )
538588
539589
540- def _get_max_parab (fun , start , end , tol = 0.01 ):
541- """Successive parabolic interpolation."""
590+ def _get_max_parab (fun , start , end , tol = 0.01 , max_iter = 50 ):
591+ """Find peak of `fun` in [start, end] using parabolic interpolation with fallback ."""
542592 a = float (start )
543593 c = float (end )
544594 b = (a + c ) / 2.0
@@ -547,25 +597,35 @@ def _get_max_parab(fun, start, end, tol=0.01):
547597 f_b = fun (b )
548598 f_c = fun (c )
549599
550- x = b
551- with np .errstate (invalid = "raise" ):
552- while True :
553- try :
554- x = x - 0.5 * (((b - a ) ** 2 * (f_b - f_c )
555- - (b - c ) ** 2 * (f_b - f_a )) /
556- ((b - a ) * (f_b - f_c ) - (b - c ) * (f_b - f_a )))
557- except FloatingPointError :
558- return b
559- if abs (b - x ) <= tol :
560- return x
561- f_x = fun (x )
562- # sometimes the estimation diverges... return best guess
563- if f_x > f_b :
564- logger .info ("Parabolic interpolation did not converge, returning best guess so far." )
565- return b
566-
567- a , b , c = (a + x ) / 2.0 , x , (x + c ) / 2.0
568- f_a , f_b , f_c = fun (a ), f_x , fun (c )
600+ # Handle flat or symmetric cases
601+ if f_a == f_b == f_c :
602+ return b
603+
604+ for _ in range (max_iter ):
605+ denom = ((b - a ) * (f_b - f_c ) - (b - c ) * (f_b - f_a ))
606+ if denom == 0 :
607+ return b # fallback
608+
609+ try :
610+ x = b - 0.5 * (((b - a )** 2 * (f_b - f_c ) - (b - c )** 2 * (f_b - f_a )) / denom )
611+ except ZeroDivisionError :
612+ return b
613+
614+ if abs (b - x ) <= tol :
615+ return x
616+
617+ f_x = fun (x )
618+
619+ # Divergence or invalid result
620+ if f_x > f_b or not (a <= x <= c ):
621+ return b
622+
623+ # Update bracket
624+ a , b , c = (a + x ) / 2.0 , x , (x + c ) / 2.0
625+ f_a , f_b , f_c = fun (a ), f_x , fun (c )
626+
627+ # Max iterations reached
628+ return b
569629
570630
571631class OrbitElements (object ):
@@ -1271,17 +1331,20 @@ def kep2xyz(kep):
12711331
12721332
12731333if __name__ == "__main__" :
1274- obs_lon , obs_lat = np .deg2rad ((12.4143 , 55.9065 ))
1275- obs_alt = 0.02
1334+ # Observer's location in degrees
1335+ obs_lon , obs_lat = 12.4143 , 55.9065
1336+ obs_alt = 0.02 # Altitude in km
12761337 o = Orbital (satellite = "METOP-B" )
12771338
12781339 t_start = dt .datetime .now ()
1279- t_stop = t_start + dt .timedelta (minutes = 20 )
1280- t = t_start
1281- while t < t_stop :
1282- t += dt .timedelta (seconds = 15 )
1283- lon , lat , alt = o .get_lonlatalt (t )
1284- lon , lat = np .rad2deg ((lon , lat ))
1285- az , el = o .get_observer_look (t , obs_lon , obs_lat , obs_alt )
1286- ob = o .get_orbit_number (t , tbus_style = True )
1287- print (az , el , ob )
1340+ t_end = t_start + dt .timedelta (minutes = 20 )
1341+ time_step = 15 # seconds
1342+ times = [t_start + dt .timedelta (seconds = i * time_step )
1343+ for i in range (int ((t_end - t_start ).total_seconds () // time_step ))]
1344+
1345+ lon , lat , alt = o .get_lonlatalt_vectorized (times )
1346+ az , el = o .get_observer_look_vectorized (times , obs_lon , obs_lat , obs_alt )
1347+ ob = o .get_orbit_number_vectorized (times , tbus_style = True )
1348+
1349+ for i , t in enumerate (times ):
1350+ print (f"Time: { t .strftime ('%H:%M:%S' )} | Az: { az [i ]:.2f} ° | El: { el [i ]:.2f} ° | Orbit: { ob [i ]} " )
0 commit comments