Skip to content

Commit ff630f9

Browse files
committed
vectorized
1 parent 960a124 commit ff630f9

File tree

3 files changed

+742
-221
lines changed

3 files changed

+742
-221
lines changed

pyorbital/orbital.py

Lines changed: 146 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
95121
def 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

526580
def _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

571639
class OrbitElements(object):
@@ -1271,17 +1339,20 @@ def kep2xyz(kep):
12711339

12721340

12731341
if __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

Comments
 (0)