Skip to content

Commit fda504b

Browse files
committed
vectorized
1 parent 960a124 commit fda504b

File tree

3 files changed

+783
-227
lines changed

3 files changed

+783
-227
lines changed

pyorbital/orbital.py

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

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

571631
class OrbitElements(object):
@@ -1271,17 +1331,20 @@ def kep2xyz(kep):
12711331

12721332

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

Comments
 (0)