skyfielders / python-skyfield

Elegant astronomy for Python
MIT License
1.38k stars 208 forks source link

Computing range_rate over multiple satellites is slow #878

Open Sec42 opened 11 months ago

Sec42 commented 11 months ago

Hi,

I have a list of reception results (timestamp, frequency) that I am trying to match a specific satellite within a TLE file by matching the doppler shift of the signal to the speed of the satellite.

I was able to produce a working example using frame_latlon_and_rates, however the result is quite slow (in the order of 30 matches / second on my test server).

My input data contains about 14M measurements from a 24h period, so I am at least one order of magnitude away from it being usable.

Am I making some obvious mistake here? Most of the satellites are below the horizon, but as elevation is also a result of frame_latlon_and_rates checking it doesn't really give an improvement.

I am hoping you can help here.

#!/usr/bin/env python3

import datetime
import fileinput 

from skyfield.api import load, utc, Topos
import numpy as np

speed_of_light = 299792458

f0 = 1626270833 # Transmit frequency

observer={'lat': 48.153543, 'lon': 11.560702, 'alt': 516.0}
opos=Topos(latitude_degrees=observer['lat'], longitude_degrees=observer['lon'], elevation_m=observer['alt'])

filename="iridium-NEXT.txt"
satlist = load.tle_file(filename)
ts=load.timescale(builtin=True)

# For each measurement find best satellite
for line in fileinput.input():
    (m_time, m_freq) = line.split(None, 2)
    m_time=float(m_time)
    m_freq=int(m_freq)
    m_doppler=m_freq-f0

    time_t = datetime.datetime.utcfromtimestamp(m_time)
    time_t = time_t.replace(tzinfo=utc)
    t = ts.utc(time_t) 
    my=opos.at(t)

    print("Doppler",m_doppler,"at",t.utc_strftime(),":")

    bestsat=None
    bestfreq=9e9
    for sat in satlist:
        pos=sat.at(t)

        (el, az, dist ,_ ,_ ,dist_r) = (pos-my).frame_latlon_and_rates(opos)
        if el.degrees<0: continue # Below horizon

        shift = (speed_of_light-dist_r.m_per_s)/speed_of_light*f0-f0
        if abs(m_doppler-shift)<bestfreq:
            bestfreq=abs(m_doppler-shift)
            bestsat=sat
        print("%s [el=%5.1f° dist=%4.1fms ds~%6dHz]"%(sat.name, el.degrees, dist.m / speed_of_light * 1000, shift))

    print("Best delta=%dHz, Sat: %s"%(bestfreq,bestsat.name))
    print("")

The snippet requires an TLE file (e.g. iridium-NEXT.txt) and the input file looks like this:

1689372014.5190182 1626266084
1689372014.610932 1626301454
1689372015.4190197 1626265880
1689372015.6009111 1626301431
1689372015.689021 1626265883
1689372016.590894 1626301363
1689372016.7690234 1626265720
1689372017.0390263 1626265666
1689372018.2990286 1626265474
1689372018.481805 1626240163
1689372018.6608543 1626301311
1689372018.839031 1626265388
1689372018.9308512 1626301301
1689372019.7390337 1626265242
1689372019.9208314 1626301264
1689372020.009035 1626265200
1689372020.9108121 1626301249
1689372021.0890374 1626265028
1689372021.1818542 1626240068
1689372021.3590398 1626265011
1689372022.619045 1626264785
brandon-rhodes commented 11 months ago

It turns out that this is a very big and interesting question for Skyfield, because its initial design was around having lots of times — a Time vector of every day in a whole decade, for example — and trying to make those operations NumPy-efficient. Your problem is a challenge because it's orthogonal to that: given a single time, what are n objects doing?

Instead of trying to write up a huge answer that tackles everything at once, I'm going to try making a little daily comment that moves one step along in our understanding of how we're going to do this efficiently in Skyfield! Otherwise it might be weeks before you hear anything from me. 🙂 But one little step per day should be doable for my schedule, and let you see the progress we can make towards making your problem faster.

So, today, a first observation is that Skyfield's frame methods are a little unfortunate in your case, because .frame_latlon_and_rates() calls .frame_xyz_and_velocity() (with the plan of converting its x,y,z coordinates to angles) which then thinks, okay, where was the frame pointing at the time .t that this vector was measured, so it calls the frame's .rotation_at() method to find out.

So your opos object is getting hammered with n requests, where n is the number of satellites, for the answer to the question “how exactly were you oriented at time t?” And each time it performs exactly the same expensive computation and returns the same answer. Which is wasteful: because Skyfield doesn't see the big picture that you're going to ask this over and over for n satellites, it duplicates the same work over and over.

Skyfield's design actually tries to side-step this problem in one particular case: when .altaz() is called, Skyfield accesses a cached private property named _altaz_rotation on the observer's geographic location, instead of asking it for its orientation directly. This means that it's computed only on the first try, instead of every time.

But that doesn't work for (a) the general case where the frame doesn't happen to be alt-az, or (b) even for the specific case you yourself have fallen into where the frame is in fact an alt-az horizon-based frame but you don't just want the angles altitude and azimuth and distance, but where you want their rates of change as well, and so have to call the general-purpose .frame_latlon_and_rates() method instead of the specific special .altaz() method.

Maybe Skyfield needs to switch to a more general caching mechanism that works for any frame? We'll have to see, there's lots of investigation to do here! For today, I'll just suggest you try adding a cache yourself to the opos, and see if you get a speedup. Try this:

from functools import lru_cache
opos.rotation_at = lru_cache(maxsize=None)(opos.rotation_at)
opos._dRdt_times_RT_at = lru_cache(maxsize=None)(opos._dRdt_times_RT_at)

That teaches the opos object to cache repeated calls to its frame-rotation methods if the same time gets asked about multiple times. Add it at the top of your code, and let me know if you see a difference — that would validate for us our initial idea here, that a big expense is that the frame is being asked for its orientation repeatedly!

Sec42 commented 11 months ago

Hi,

Thank you for the explanation and for taking the time to helping find a solution for this issue.

I tested your suggestion of adding caching the two methods of opos. On the sample test file with 750 lines it yielded something between 15% and 20% improvement:

OLD:

./freq_sat_match.py < example.tf > /tmp/exO  112,18s user 120,00s system 1155% cpu 20,092 total
./freq_sat_match.py < example.tf > /tmp/exO  113,52s user 122,10s system 1157% cpu 20,355 total

NEW:

./freq_sat_match.py < example.tf > /tmp/exN  94,54s user 100,23s system 1176% cpu 16,559 total
./freq_sat_match.py < example.tf > /tmp/exN  95,30s user 100,40s system 1137% cpu 17,206 total

I'm wondering if there is a cheaper way to check for visibility (elevation >0) to maybe save a bunch of work, but I'm not sure if after the addition of caching it would make much difference.

Sec42 commented 11 months ago

Not sure if this is informative for you, but I ran the modified version with a larger (~10k lines) input file through cProf, and these are the top lines:

         143529615 function calls (141654588 primitive calls) in 402.656 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
  5392065   38.646    0.000   38.646    0.000 {built-in method numpy.core._multiarray_umath.c_einsum}
8237809/7318714   31.144    0.000  111.714    0.000 {built-in method numpy.core._multiarray_umath.implement_array
_function}
   885760   27.042    0.000   68.831    0.000 functions.py:88(_to_spherical_and_rates)
   885760   19.822    0.000  161.199    0.000 sgp4lib.py:186(_at)
  2657300   17.599    0.000   17.599    0.000 {method 'reduce' of 'numpy.ufunc' objects}
    66433   13.825    0.000   13.825    0.000 {method 'dot' of 'numpy.ndarray' objects}
  1771520   13.817    0.000   31.018    0.000 functions.py:45(length_of)
   885760   12.829    0.000   69.820    0.000 positionlib.py:440(frame_xyz_and_velocity)
        1   12.102   12.102  402.657  402.657 freq_sat_match.py:1(<module>)
  3631896   11.695    0.000   11.695    0.000 {built-in method numpy.array}
   885760   11.105    0.000  161.858    0.000 positionlib.py:474(frame_latlon_and_rates)
 26971397   10.923    0.000   10.923    0.000 einsumfunc.py:1001(_einsum_dispatcher)
   885760   10.280    0.000   47.895    0.000 sgp4lib.py:151(_position_and_velocity_TEME_km)
  5392065    9.584    0.000   88.933    0.000 <__array_function__ internals>:177(einsum)
   907905    8.819    0.000   14.494    0.000 functions.py:141(rot_z)
   885760    7.970    0.000    7.970    0.000 sgp4lib.py:301(theta_GMST1982)
   885760    7.459    0.000   17.224    0.000 positionlib.py:169(__sub__)
  1782592    7.206    0.000   17.254    0.000 positionlib.py:95(__init__)
  5392065    6.994    0.000   45.640    0.000 einsumfunc.py:1009(einsum)