Skip to content

Commit e92fdf4

Browse files
committed
Add next pass time prediction
1 parent 7b9250d commit e92fdf4

File tree

3 files changed

+180
-2
lines changed

3 files changed

+180
-2
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ satellite_position.to_lat_long_alt(time) # => Predict::LatLongAlt(@latitude=0.89
5050
# Predict look angles from an observer (200m altitude)
5151
observer = Predict::LatLongAlt.from_degrees(52.9, -2.24, 200.0)
5252
observer.look_at(satellite_position, time) # => Predict::LookAngle(@azimuth=0.50118495648349193, @elevation=-0.55812612380797122, @range=7501.0178628601843)
53+
54+
# Predict next pass time
55+
start_time, end_time = satellite.next_pass(at: observer, after: time)
56+
start_time # => 2016-12-05 16:23:55
57+
end_time # => 2016-12-05 16:32:29
58+
59+
# And the one after that...
60+
start_time, end_time = satellite.next_pass(at: observer, after: end_time)
61+
start_time # => 2016-12-05 17:58:46
62+
end_time # => 2016-12-05 18:09:15
5363
```
5464

5565
## Development

spec/predict/satellite_spec.cr

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,4 +197,56 @@ describe Predict::Satellite do
197197
look_angle.elevation.should be_close(0.0574066, 0.5e-7)
198198
look_angle.range.should be_close(40812.259, 0.5e-3)
199199
end
200+
201+
describe "#next_pass" do
202+
it "finds the next satellite pass" do
203+
tle = Predict::TLE.parse_three_line <<-TLE
204+
AO-51 [+]
205+
1 28375U 04025K 09105.66391970 .00000003 00000-0 13761-4 0 3643
206+
2 28375 098.0551 118.9086 0084159 315.8041 043.6444 14.40638450251959
207+
TLE
208+
time = Time.new(2009, 1, 5, 0, 0, 0)
209+
210+
satellite = Predict::Satellite.new(tle)
211+
212+
pass_start, pass_end = satellite.next_pass(at: GROUND_STATION, after: time)
213+
pass_start.should be_close(Time.new(2009, 1, 5, 4, 28, 10), 5.seconds)
214+
pass_end.should be_close(Time.new(2009, 1, 5, 4, 32, 15), 5.seconds)
215+
216+
pass_start, pass_end = satellite.next_pass(at: GROUND_STATION, after: pass_end)
217+
pass_start.should be_close(Time.new(2009, 1, 5, 6, 4, 0), 5.seconds)
218+
pass_end.should be_close(Time.new(2009, 1, 5, 6, 18, 0), 5.seconds)
219+
220+
pass_start, pass_end = satellite.next_pass(at: GROUND_STATION, after: pass_end)
221+
pass_start.should be_close(Time.new(2009, 1, 5, 7, 42, 45), 5.seconds)
222+
pass_end.should be_close(Time.new(2009, 1, 5, 7, 57, 50), 5.seconds)
223+
224+
pass_start, pass_end = satellite.next_pass(at: GROUND_STATION, after: pass_end)
225+
pass_start.should be_close(Time.new(2009, 1, 5, 9, 22, 5), 5.seconds)
226+
pass_end.should be_close(Time.new(2009, 1, 5, 9, 34, 20), 5.seconds)
227+
228+
pass_start, pass_end = satellite.next_pass(at: GROUND_STATION, after: pass_end)
229+
pass_start.should be_close(Time.new(2009, 1, 5, 11, 2, 5), 5.seconds)
230+
pass_end.should be_close(Time.new(2009, 1, 5, 11, 7, 35), 5.seconds)
231+
end
232+
233+
it "finds the current pass with find_occuring_pass" do
234+
tle = Predict::TLE.parse_three_line <<-TLE
235+
AO-51 [+]
236+
1 28375U 04025K 09105.66391970 .00000003 00000-0 13761-4 0 3643
237+
2 28375 098.0551 118.9086 0084159 315.8041 043.6444 14.40638450251959
238+
TLE
239+
time = Time.new(2009, 1, 5, 4, 30, 0)
240+
241+
satellite = Predict::Satellite.new(tle)
242+
243+
pass_start, pass_end = satellite.next_pass(at: GROUND_STATION, after: time)
244+
pass_start.should be_close(Time.new(2009, 1, 5, 6, 4, 0), 5.seconds)
245+
pass_end.should be_close(Time.new(2009, 1, 5, 6, 18, 0), 5.seconds)
246+
247+
pass_start, pass_end = satellite.next_pass(at: GROUND_STATION, after: time, find_occuring_pass: true)
248+
pass_start.should be_close(Time.new(2009, 1, 5, 4, 28, 10), 5.seconds)
249+
pass_end.should be_close(Time.new(2009, 1, 5, 4, 32, 15), 5.seconds)
250+
end
251+
end
200252
end

src/predict/satellite.cr

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,45 @@
1+
private def converge_root(a, b, *, accuracy = 1e-6, max_iterations = 40)
2+
xa = yield a
3+
xb = yield b
4+
5+
raise "Root is not bracketed" unless (xa * xb) <= 0
6+
7+
if xa < 0
8+
# Function is increasing a -> b
9+
dx = b - a
10+
root = a
11+
else
12+
# Function is decreasing a -> b
13+
dx = a - b
14+
root = b
15+
end
16+
17+
# In this algorithm, the search section is described by the lower bound of
18+
# *root*, and the upper bound of *root + dx*. Halving dx is equivalent to
19+
# taking the left section, halving dx and setting root to be the midsection is
20+
# equivalent to taking the right section.
21+
22+
max_iterations.times do |i|
23+
dx *= 0.5 # halve dx
24+
xmid = root + dx # calculate x value of midsection
25+
fmid = yield xmid # find function value at midsection
26+
root = xmid if fmid <= 0 # midsection is below root, take right section
27+
return root if dx.abs < accuracy # the root is always within (root + dx), so if dx < accuracy we can return
28+
end
29+
30+
raise "Root finding failed: too many iterations"
31+
end
32+
133
module Predict
234
# Struct containing an initialised SGP4 model of a satellite.
3-
struct Satellite
35+
class Satellite
36+
getter tle : TLE
437
getter satrec = SGP4::Elset.new
538
getter constants : GravityConstants
639
@epoch : Time
740

841
# Initialises the `Satellite` with *tle*, using *grav_type*
9-
def initialize(tle : TLE, grav_type = GravityConstants::WGS72)
42+
def initialize(@tle : TLE, grav_type = GravityConstants::WGS72)
1043
@constants = GravityConstants[grav_type]
1144
@epoch = tle.epoch
1245
initialize_satrec(tle)
@@ -64,6 +97,89 @@ module Predict
6497
{position, velocity}
6598
end
6699

100+
private def look_angles(location, time)
101+
satellite_position, _ = predict(time)
102+
location.look_at(satellite_position, time)
103+
end
104+
105+
# Finds the next pass at *location* occuring after the given time. When
106+
# *find_occuring_pass* is true, a satellite pass currently in progress at
107+
# the supplied time may be returned. The returned value is a tuple of pass
108+
# start time followed by pass end time.
109+
def next_pass(*, at : LatLongAlt, after : Time, find_occuring_pass = false) : {Time, Time}
110+
time = after
111+
location = at
112+
113+
raise "Satellite will never be visible" unless pass_possible? location
114+
115+
orbit_time = (MINS_PER_DAY / tle.mean_motion).minutes
116+
117+
# Wind back 1/4 orbit if we want to find currently occurring passes
118+
time -= orbit_time / 4 if find_occuring_pass
119+
120+
look_angles = look_angles(location, time)
121+
122+
# Ensure satellite is not above the horizon
123+
if look_angles.elevation > 0
124+
# Move forward in 60 second intervals until the satellite goes below the horizon
125+
while look_angles.elevation > 0
126+
time += 60.seconds
127+
look_angles = look_angles(location, time)
128+
end
129+
130+
# Move forward 3/4 orbit
131+
time += orbit_time * 0.75
132+
look_angles = look_angles(location, time)
133+
end
134+
135+
# Find the time it comes over the horizon
136+
while look_angles.elevation < 0
137+
time += 60.seconds
138+
look_angles = look_angles(location, time)
139+
end
140+
141+
# Find pass start
142+
start_time = converge_root(time, time - 60.seconds, accuracy: 1.millisecond) do |time|
143+
look_angles(location, time).elevation
144+
end
145+
146+
# Find the time when it goes below
147+
while look_angles.elevation > 0
148+
time += 30.seconds
149+
look_angles = look_angles(location, time)
150+
end
151+
152+
# p look_angles.elevation
153+
# p look_angles(location, time - 30.seconds).elevation
154+
155+
# Find los time
156+
end_time = converge_root(time, time - 30.seconds, accuracy: 1.millisecond) do |time|
157+
look_angles(location, time).elevation
158+
end
159+
160+
{start_time, end_time}
161+
end
162+
163+
# Returns true if it's possible for the satellite to be visible from
164+
# *location*.
165+
def pass_possible?(location : LatLongAlt)
166+
return false if tle.mean_motion < 1e-8
167+
168+
incl = tle.inclination
169+
incl = 180 - incl if incl >= 90.0
170+
incl *= DEG2RAD
171+
172+
# Cube root of Kepler's third law constant for the earth and satellite
173+
# with negligible mass, expressed in (km^3/s^2).
174+
k = 331.25
175+
176+
semi_major_axis = k * (MINS_PER_DAY / tle.mean_motion)**TWO_THIRDS
177+
apogee = semi_major_axis * (1.0 + tle.eccentricity) # From center of earth
178+
ground_track_angle = Math.acos(@constants.radiusearthkm / apogee)
179+
180+
ground_track_angle + incl > location.latitude.abs
181+
end
182+
67183
private def check_error
68184
case @satrec.error
69185
when 1

0 commit comments

Comments
 (0)