@@ -92,60 +92,71 @@ 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+ # Azimuth is undefined when elevation is 90 degrees, 180 (pi) will be returned.
112+ az_ = np .arctan2 (- top_e , top_s ) + np .pi
113+ az_ = np .mod (az_ , 2 * np .pi ) # Needed on some platforms
114+
115+ # Due to rounding top_z can be larger than rg_ (when el_ ~ 90).
116+ top_z_divided_by_rg_ = np .clip (top_z / rg_ , - 1 , 1 )
117+ el_ = np .arcsin (top_z_divided_by_rg_ )
118+
119+ return np .rad2deg (az_ ), np .rad2deg (el_ )
120+
95121def get_observer_look (sat_lon , sat_lat , sat_alt , utc_time , lon , lat , alt ):
96- """Calculate observers look angle to a satellite.
122+ """Calculate observer's look angle to a satellite.
97123
98124 http://celestrak.com/columns/v02n02/
99125
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)
126+ :param sat_lon: Satellite longitude in degrees east
127+ :param sat_lat: Satellite latitude in degrees north
128+ :param sat_alt: Satellite altitude in km
129+ :param utc_time: Observation time (datetime object)
130+ :param lon: Observer longitude in degrees east
131+ :param lat: Observer latitude in degrees north
132+ :param alt: Observer altitude in km
133+ :return: (Azimuth, Elevation) in degrees
106134 """
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 )
135+ # Get satellite and observer ECEF positions
136+ (pos_x , pos_y , pos_z ), _ = astronomy .observer_position (utc_time , sat_lon , sat_lat , sat_alt )
137+ (opos_x , opos_y , opos_z ), _ = astronomy .observer_position (utc_time , lon , lat , alt )
112138
139+ # Convert observer coordinates to radians
113140 lon = np .deg2rad (lon )
114141 lat = np .deg2rad (lat )
115142
143+ # Compute local sidereal time
116144 theta = (astronomy .gmst (utc_time ) + lon ) % (2 * np .pi )
117145
146+ # Vector from observer to satellite
118147 rx = pos_x - opos_x
119148 ry = pos_y - opos_y
120149 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-
137150 rg_ = np .sqrt (rx * rx + ry * ry + rz * rz )
138151
139- top_z_divided_by_rg_ = top_z / rg_
152+ # Convert to topocentric coordinates
153+ top_s , top_e , top_z = ecef_to_topocentric (rx , ry , rz , lat , lon , theta )
140154
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_ )
155+ # Compute azimuth and elevation
156+ return compute_azimuth_elevation (top_s , top_e , top_z , rg_ )
146157
147158
148- class Orbital ( object ) :
159+ class Orbital :
149160 """Class for orbital computations.
150161
151162 The *satellite* parameter is the name of the satellite to work on and is
@@ -336,7 +347,16 @@ def get_orbit_number(self, utc_time, tbus_style=False, as_float=False):
336347
337348 return orbit
338349
339- def get_next_passes (self , utc_time , length , lon , lat , alt , tol = 0.001 , horizon = 0 ):
350+ def get_next_passes (
351+ self ,
352+ utc_time : dt .datetime ,
353+ length : float ,
354+ lon : float ,
355+ lat : float ,
356+ alt : float ,
357+ tol : float = 0.001 ,
358+ horizon : float = 0
359+ ) -> list [tuple [dt .datetime , dt .datetime , dt .datetime ]]:
340360 """Calculate passes for the next hours for a given start time and a given observer.
341361
342362 Original by Martin.
@@ -522,23 +542,61 @@ def _elevation_inv(self, utc_time, lon, lat, alt, horizon, minutes):
522542 """Compute the inverse of elevation."""
523543 return - self ._elevation (utc_time , lon , lat , alt , horizon , minutes )
524544
545+ def get_lonlatalt_vectorized (self , utc_times ):
546+ """Vectorized version of get_lonlatalt for multiple times."""
547+ if isinstance (utc_times , str ) or not hasattr (utc_times , "__iter__" ):
548+ raise TypeError ("Input must be a datetime or iterable of datetimes." )
549+ if utc_times is None or len (utc_times ) == 0 :
550+ return np .array ([]), np .array ([]), np .array ([])
551+ if not isinstance (utc_times , (list , np .ndarray )):
552+ utc_times = [utc_times ]
553+ results = [self .get_lonlatalt (t ) for t in utc_times ]
554+ lon , lat , alt = zip (* results )
555+ return np .array (lon ), np .array (lat ), np .array (alt )
556+
557+ def get_observer_look_vectorized (self , utc_times , lon , lat , alt ):
558+ """Vectorized observer look angles."""
559+ if isinstance (utc_times , str ) or not hasattr (utc_times , "__iter__" ):
560+ raise TypeError ("Input must be a datetime or iterable of datetimes." )
561+ if utc_times is None or len (utc_times ) == 0 :
562+ return np .array ([]), np .array ([])
563+ if not isinstance (utc_times , (list , np .ndarray )):
564+ utc_times = [utc_times ]
565+ results = [self .get_observer_look (t , lon , lat , alt ) for t in utc_times ]
566+ az , el = zip (* results )
567+ return np .array (az ), np .array (el )
568+
569+ def get_orbit_number_vectorized (self , utc_times , tbus_style = False ):
570+ """Vectorized orbit number calculation."""
571+ if isinstance (utc_times , str ) or not hasattr (utc_times , "__iter__" ):
572+ raise TypeError ("Input must be a datetime or iterable of datetimes." )
573+ if utc_times is None or len (utc_times ) == 0 :
574+ return []
575+ if not isinstance (utc_times , (list , np .ndarray )):
576+ utc_times = [utc_times ]
577+ return [self .get_orbit_number (t , tbus_style = tbus_style ) for t in utc_times ]
578+
525579
526580def _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 )
581+ """Find a root of `fun` in [start, end] using Brent's method with tolerance `tol`."""
582+ x_0 = float (end )
583+ x_1 = float (start )
584+ fx_0 = fun (x_0 )
585+ fx_1 = fun (x_1 )
586+
587+ if fx_0 * fx_1 > 0 :
588+ raise ValueError ("Function values at interval endpoints must have opposite signs." )
589+
590+ # Swap for better convergence
532591 if abs (fx_0 ) < abs (fx_1 ):
533592 fx_0 , fx_1 = fx_1 , fx_0
534593 x_0 , x_1 = x_1 , x_0
535594
536- x_n = optimize .brentq (fun , x_0 , x_1 )
537- return x_n
595+ return optimize .brentq (fun , x_0 , x_1 , xtol = tol , rtol = tol )
538596
539597
540- def _get_max_parab (fun , start , end , tol = 0.01 ):
541- """Successive parabolic interpolation."""
598+ def _get_max_parab (fun , start , end , tol = 0.01 , max_iter = 50 ):
599+ """Find peak of `fun` in [start, end] using parabolic interpolation with fallback ."""
542600 a = float (start )
543601 c = float (end )
544602 b = (a + c ) / 2.0
@@ -547,25 +605,35 @@ def _get_max_parab(fun, start, end, tol=0.01):
547605 f_b = fun (b )
548606 f_c = fun (c )
549607
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 )
608+ # Handle flat or symmetric cases
609+ if f_a == f_b == f_c :
610+ return b
611+
612+ for _ in range (max_iter ):
613+ denom = ((b - a ) * (f_b - f_c ) - (b - c ) * (f_b - f_a ))
614+ if denom == 0 :
615+ return b # fallback
616+
617+ try :
618+ x = b - 0.5 * (((b - a )** 2 * (f_b - f_c ) - (b - c )** 2 * (f_b - f_a )) / denom )
619+ except ZeroDivisionError :
620+ return b
621+
622+ if abs (b - x ) <= tol :
623+ return x
624+
625+ f_x = fun (x )
626+
627+ # Divergence or invalid result
628+ if f_x > f_b or not (a <= x <= c ):
629+ return b
630+
631+ # Update bracket
632+ a , b , c = (a + x ) / 2.0 , x , (x + c ) / 2.0
633+ f_a , f_b , f_c = fun (a ), f_x , fun (c )
634+
635+ # Max iterations reached
636+ return b
569637
570638
571639class OrbitElements (object ):
@@ -1271,17 +1339,20 @@ def kep2xyz(kep):
12711339
12721340
12731341if __name__ == "__main__" :
1274- obs_lon , obs_lat = np .deg2rad ((12.4143 , 55.9065 ))
1275- obs_alt = 0.02
1342+ # Observer's location in degrees
1343+ obs_lon , obs_lat = 12.4143 , 55.9065
1344+ obs_alt = 0.02 # Altitude in km
12761345 o = Orbital (satellite = "METOP-B" )
12771346
12781347 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 )
1348+ t_end = t_start + dt .timedelta (minutes = 20 )
1349+ time_step = 15 # seconds
1350+ times = [t_start + dt .timedelta (seconds = i * time_step )
1351+ for i in range (int ((t_end - t_start ).total_seconds () // time_step ))]
1352+
1353+ lon , lat , alt = o .get_lonlatalt_vectorized (times )
1354+ az , el = o .get_observer_look_vectorized (times , obs_lon , obs_lat , obs_alt )
1355+ ob = o .get_orbit_number_vectorized (times , tbus_style = True )
1356+
1357+ for i , t in enumerate (times ):
1358+ print (f"Time: { t .strftime ('%H:%M:%S' )} | Az: { az [i ]:.2f} ° | El: { el [i ]:.2f} ° | Orbit: { ob [i ]} " )
0 commit comments