# MIT License
#
# Copyright (c) 2017 Satellogic SA
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import datetime as dt
import logging
import warnings
from collections import namedtuple
from math import pi, acos, degrees, radians

import numpy as np
try:
    from scipy.optimize import brentq, minimize_scalar
except ImportError:
    warnings.warn('scipy module was not found, some features may not work properly.',
                  ImportWarning)

from orbit_predictor.constants import MU_E
from orbit_predictor.exceptions import NotReachable, PropagationError
from orbit_predictor import coordinate_systems
from orbit_predictor.keplerian import rv2coe
from orbit_predictor.utils import (
    angle_between,
    cross_product,
    dot_product,
    reify,
    vector_diff,
    vector_norm,
    gstime_from_datetime,
    get_shadow,
    get_sun,
    eclipse_duration,
    get_satellite_minus_penumbra_verticals,
)

logger = logging.getLogger(__name__)


ONE_SECOND = dt.timedelta(seconds=1)


def round_datetime(dt_):
    return dt_


class Position(namedtuple(
        "Position", ['when_utc', 'position_ecef', 'velocity_ecef', 'error_estimate'])):

    @reify
    def position_llh(self):
        """Latitude (deg), longitude (deg), altitude (km)."""
        return coordinate_systems.ecef_to_llh(self.position_ecef)

    @reify
    def osculating_elements(self):
        """Osculating Keplerian orbital elements.

        Semimajor axis (km), eccentricity, inclination (deg),
        right ascension of the ascending node or RAAN (deg),
        argument of perigee (deg), true anomaly (deg).

        """
        gmst = gstime_from_datetime(self.when_utc)
        position_eci = coordinate_systems.ecef_to_eci(self.position_ecef, gmst)
        velocity_eci = coordinate_systems.ecef_to_eci(self.velocity_ecef, gmst)

        # Convert position to Keplerian osculating elements
        p, ecc, inc, raan, argp, ta = rv2coe(
            MU_E, np.array(position_eci), np.array(velocity_eci)
        )
        # Transform to more familiar semimajor axis
        sma = p / (1 - ecc ** 2)

        return sma, ecc, degrees(inc), degrees(raan), degrees(argp), degrees(ta)


class PredictedPass:
    def __init__(self, location, sate_id,
                 max_elevation_deg,
                 aos, los, duration_s,
                 max_elevation_position=None,
                 max_elevation_date=None):
        self.location = location
        self.sate_id = sate_id
        self.max_elevation_position = max_elevation_position
        self.max_elevation_date = max_elevation_date
        self.max_elevation_deg = max_elevation_deg
        self.aos = aos
        self.los = los
        self.duration_s = duration_s

    @property
    def midpoint(self):
        """Returns a datetime of the midpoint of the pass"""
        return self.aos + (self.los - self.aos) / 2

    def __repr__(self):
        return "<PredictedPass {} over {} on {}>".format(self.sate_id, self.location, self.aos)

    def __eq__(self, other):
        return all([issubclass(other.__class__, PredictedPass),
                    self.location == other.location,
                    self.sate_id == other.sate_id,
                    self.max_elevation_position == other.max_elevation_position,
                    self.max_elevation_date == other.max_elevation_date,
                    self.max_elevation_deg == other.max_elevation_deg,
                    self.aos == other.aos,
                    self.los == other.los,
                    self.duration_s == other.duration_s])

    def get_off_nadir_angle(self):
        warnings.warn("This method is deprecated!", DeprecationWarning)
        return self.off_nadir_deg

    @reify
    def off_nadir_deg(self):
        """Computes off-nadir angle calculation

        Given satellite position ``sate_pos``, velocity ``sate_vel``, and
        location ``target`` in a common frame, off-nadir angle ``off_nadir_angle``
        is given by:
            t2b = sate_pos - target
            cos(off_nadir_angle) =     (sate_pos  · t2b)     # Vectorial dot product
                                    _______________________
                                    || sate_pos || || t2b||

        Sign for the rotation is calculated this way

        cross = target ⨯ sate_pos
        sign =   cross · sate_vel
               ____________________
               | cross · sate_vel |
        """
        sate_pos = self.max_elevation_position.position_ecef
        sate_vel = self.max_elevation_position.velocity_ecef
        target = self.location.position_ecef
        t2b = vector_diff(sate_pos, target)
        angle = acos(
            dot_product(sate_pos, t2b) / (vector_norm(sate_pos) * vector_norm(t2b))
        )

        cross = cross_product(target, sate_pos)
        dot = dot_product(cross, sate_vel)
        try:
            sign = dot / abs(dot)
        except ZeroDivisionError:
            sign = 1

        return degrees(angle) * sign


class Predictor:

    @property
    def sate_id(self):
        raise NotImplementedError

    def propagate_eci(self, when_utc=None):
        raise NotImplementedError

    def get_position(self, when_utc=None):
        raise NotImplementedError("You have to implement it!")

    def get_shadow(self, when_utc=None):
        """Gives illumination at given time (2 for illuminated, 1 for penumbra, 0 for umbra)."""
        if when_utc is None:
            when_utc = dt.datetime.utcnow()

        return get_shadow(
            self.get_position(when_utc).position_ecef,
            when_utc
        )

    def get_normal_vector(self, when_utc=None):
        """Gets unitary normal vector (orthogonal to orbital plane) at given time."""
        if when_utc is None:
            when_utc = dt.datetime.utcnow()

        position, velocity = self.propagate_eci(when_utc)
        orbital_plane_normal = np.cross(position, velocity)
        return orbital_plane_normal / vector_norm(orbital_plane_normal)

    def get_beta(self, when_utc=None):
        """Gets angle between orbital plane and Sun direction (beta) at given time, in degrees."""
        if when_utc is None:
            when_utc = dt.datetime.utcnow()

        # Here we calculate the complementary angle of beta,
        # because we use the normal vector of the orbital plane
        beta_comp = angle_between(
            get_sun(when_utc),
            self.get_normal_vector(when_utc)
        )

        # We subtract from 90 degrees to return the real beta angle
        return 90 - beta_comp


class CartesianPredictor(Predictor):

    def _propagate_ecef(self, when_utc=None):
        """Return position and velocity in the given date using ECEF coordinate system."""
        if when_utc is None:
            when_utc = dt.datetime.utcnow()

        position_eci, velocity_eci = self.propagate_eci(when_utc)
        gmst = gstime_from_datetime(when_utc)
        position_ecef = coordinate_systems.eci_to_ecef(position_eci, gmst)
        velocity_ecef = coordinate_systems.eci_to_ecef(velocity_eci, gmst)
        return position_ecef, velocity_ecef

    @reify
    def mean_motion(self):
        """Mean motion, in radians per minute"""
        raise NotImplementedError

    @reify
    def period(self):
        """Orbital period, in minutes"""
        return 2 * pi / self.mean_motion

    def get_position(self, when_utc=None):
        """Return a Position namedtuple in ECEF coordinate system"""
        if when_utc is None:
            when_utc = dt.datetime.utcnow()

        position_ecef, velocity_ecef = self._propagate_ecef(when_utc)

        return Position(when_utc=when_utc, position_ecef=position_ecef,
                        velocity_ecef=velocity_ecef, error_estimate=None)

    def get_only_position(self, when_utc=None):
        """Return a tuple in ECEF coordinate system"""
        return self.get_position(when_utc).position_ecef

    def get_eclipse_duration(self, when_utc=None, tolerance=1e-1):
        """Gets eclipse duration at given time, in minutes"""
        ecc = self.get_position(when_utc).osculating_elements[1]
        if ecc > tolerance:
            raise NotImplementedError("Non circular orbits are not supported")

        beta = self.get_beta(when_utc)
        return eclipse_duration(beta, self.period)

    def passes_over(self, location, when_utc, limit_date=None, max_elevation_gt=0, aos_at_dg=0):
        return LocationPredictor(location, self, when_utc, limit_date,
                                 max_elevation_gt, aos_at_dg)

    def get_next_pass(self, location, when_utc=None, max_elevation_gt=5,
                      aos_at_dg=0, limit_date=None):
        """Return a PredictedPass instance with the data of the next pass over the given location

        location_llh: point on Earth we want to see from the satellite.
        when_utc: datetime UTC after which the pass is calculated, default to now.
        max_elevation_gt: filter passes with max_elevation under it.
        aos_at_dg: This is if we want to start the pass at a specific elevation.

        The next pass with a LOS strictly after when_utc will be returned,
        possibly the current pass.
        """
        if when_utc is None:
            when_utc = dt.datetime.utcnow()

        for pass_ in self.passes_over(location, when_utc, limit_date,
                                      max_elevation_gt=max_elevation_gt,
                                      aos_at_dg=aos_at_dg):
            return pass_
        else:
            raise NotReachable('Propagation limit date exceeded')

    def eclipses_since(self, when_utc=None, limit_date=None):
        """
        An iterator that yields all eclipses start and end times between
        when_utc and limit_date.

        The next eclipse with a end strictly after when_utc will be returned,
        possibly the current eclipse.
        The last eclipse returned starts before limit_date, but it can end
        strictly after limit_date.
        No circular orbits are not supported, and will raise NotImplementedError.
        """
        def _get_illumination(t):
            my_start = start + dt.timedelta(seconds=t)
            result = get_satellite_minus_penumbra_verticals(
                self.get_only_position(my_start),
                my_start
            )
            return result

        if when_utc is None:
            when_utc = dt.datetime.utcnow()

        orbital_period_s = self.period * 60
        # A third of the orbit period is used as the base window of the search.
        # This window ensures the function get_satellite_minus_penumbra_verticals
        # will not have more than one local minimum (one in the illuminated phase and
        # the other in penumbra).
        base_search_window_s = orbital_period_s / 3
        start = when_utc

        while limit_date is None or start < limit_date:

            # a minimum negative value is aproximatelly the middle point of the eclipse
            minimum_illumination = minimize_scalar(
                _get_illumination,
                bounds=(0, base_search_window_s),
                method="bounded",
                options={"xatol": 1e-2},
            )
            eclipse_center_candidate_delta_s = minimum_illumination.x

            # If found a minimum that is not illuminated, there is an eclipse here
            if _get_illumination(eclipse_center_candidate_delta_s) < 0:
                # The small time interval to search zeros around the center
                # is estimated with the expected eclipse duration (which generally
                # is smaller than expected, and that is the reason of the 1.5 coeficient).
                # Also a minimum of 180 seconds was added because
                # in some cases the estimation is 0 even though there is an eclipse.
                eclipse_duration_estimation_s = self.get_eclipse_duration(start) * 60
                zero_search_window_s = max(180, 1.5 * eclipse_duration_estimation_s)

                # Search now both zeros to get the start and end of the eclipse
                eclipse_start_delta_s = brentq(
                    _get_illumination,
                    eclipse_center_candidate_delta_s - zero_search_window_s,
                    eclipse_center_candidate_delta_s,
                    xtol=1e-2,
                    full_output=False,
                )
                eclipse_end_delta_s = brentq(
                    _get_illumination,
                    eclipse_center_candidate_delta_s,
                    eclipse_center_candidate_delta_s + zero_search_window_s,
                    xtol=1e-2,
                    full_output=False,
                )
                eclipse_start = start + dt.timedelta(seconds=eclipse_start_delta_s)
                eclipse_end = start + dt.timedelta(seconds=eclipse_end_delta_s)
                yield eclipse_start, eclipse_end
                start = eclipse_end + dt.timedelta(seconds=base_search_window_s)
            else:
                start += dt.timedelta(seconds=base_search_window_s)


class GPSPredictor(Predictor):
    pass


class LocationPredictor:
    """Predicts passes over a given location
    Exposes an iterable interface
    """

    def __init__(self, location, predictor, start_date, limit_date=None,
                 max_elevation_gt=0, aos_at_dg=0, *, propagator=None):
        if propagator is not None:
            warnings.warn(
                "propagator parameter was renamed to predictor "
                "and will be removed in a future release",
                DeprecationWarning
            )
            predictor = propagator

        self.location = location
        self.predictor = predictor
        self.start_date = start_date
        self.limit_date = limit_date

        self.max_elevation_gt = radians(max([max_elevation_gt, aos_at_dg]))
        self.aos_at = radians(aos_at_dg)

    @property
    def propagator(self):
        warnings.warn(
            "propagator parameter was renamed to predictor "
            "and will be removed in a future release",
            DeprecationWarning
        )
        return self.predictor

    def __iter__(self):
        """Returns one pass each time"""
        current_date = self.start_date
        while True:
            if self.is_ascending(current_date):
                # we need a descending point
                ascending_date = current_date
                descending_date = self._find_nearest_descending(ascending_date)
                pass_ = self._refine_pass(ascending_date, descending_date)
                if pass_.valid:
                    if self.limit_date is not None and pass_.aos > self.limit_date:
                        break
                    yield self._build_predicted_pass(pass_)

                if self.limit_date is not None and current_date > self.limit_date:
                    break

                current_date = pass_.tca + self._orbit_step(0.6)

            else:
                current_date = self._find_nearest_ascending(current_date)

    def _build_predicted_pass(self, accuratepass):
        """Returns a classic predicted pass"""
        tca_position = self.predictor.get_position(accuratepass.tca)

        return PredictedPass(self.location, self.predictor.sate_id,
                             max_elevation_deg=accuratepass.max_elevation_deg,
                             aos=accuratepass.aos,
                             los=accuratepass.los,
                             duration_s=accuratepass.duration.total_seconds(),
                             max_elevation_position=tca_position,
                             max_elevation_date=accuratepass.tca,
                             )

    def _find_nearest_descending(self, ascending_date):
        for candidate in self._sample_points(ascending_date):
            if not self.is_ascending(candidate):
                return candidate
        else:
            logger.error('Could not find a descending pass over %s start date: %s - TLE: %s',
                         self.location, ascending_date, self.predictor.tle)
            raise PropagationError("Can not find an descending phase")

    def _find_nearest_ascending(self, descending_date):
        for candidate in self._sample_points(descending_date):
            if self.is_ascending(candidate):
                return candidate
        else:
            logger.error('Could not find an ascending pass over %s start date: %s - TLE: %s',
                         self.location, descending_date, self.predictor.tle)
            raise PropagationError('Can not find an ascending phase')

    def _sample_points(self, date):
        """Helper method to found ascending or descending phases of elevation"""
        start = date
        end = date + self._orbit_step(0.99)
        mid = self.midpoint(start, end)
        mid_right = self.midpoint(mid, end)
        mid_left = self.midpoint(start, mid)

        return [end, mid, mid_right, mid_left]

    def _refine_pass(self, ascending_date, descending_date):
        tca = self._find_tca(ascending_date, descending_date)
        elevation = self._elevation_at(tca)

        if elevation > self.max_elevation_gt:
            aos = self._find_aos(tca)
            los = self._find_los(tca)
        else:
            aos = los = None

        return AccuratePredictedPass(aos, tca, los, elevation)

    def _find_tca(self, ascending_date, descending_date):
        while not self._precision_reached(ascending_date, descending_date):
            midpoint = self.midpoint(ascending_date, descending_date)
            if self.is_ascending(midpoint):
                ascending_date = midpoint
            else:
                descending_date = midpoint

        return ascending_date

    def _precision_reached(self, start, end):
        # TODO: Allow the precision to change from the outside
        return end - start <= ONE_SECOND

    @staticmethod
    def midpoint(start, end):
        """Returns the midpoint between two dates"""
        return start + (end - start) / 2

    def _elevation_at(self, when_utc):
        position = self.predictor.get_only_position(when_utc)
        return self.location.elevation_for(position)

    def is_passing(self, when_utc):
        """Returns a boolean indicating if satellite is actually visible"""
        return bool(self._elevation_at(when_utc))

    def is_ascending(self, when_utc):
        """Check is elevation is ascending or descending on a given point"""
        elevation = self._elevation_at(when_utc)
        next_elevation = self._elevation_at(when_utc + ONE_SECOND)
        return elevation <= next_elevation

    def _orbit_step(self, size):
        """Returns a time step, that will make the satellite advance a given number of orbits"""
        step_in_radians = size * 2 * pi
        seconds = (step_in_radians / self.predictor.mean_motion) * 60
        return dt.timedelta(seconds=seconds)

    def _find_aos(self, tca):
        end = tca
        start = tca - self._orbit_step(0.34)  # On third of the orbit
        elevation = self._elevation_at(start)
        assert elevation < 0
        while not self._precision_reached(start, end):
            midpoint = self.midpoint(start, end)
            elevation = self._elevation_at(midpoint)
            if elevation < self.aos_at:
                start = midpoint
            else:
                end = midpoint
        return end

    def _find_los(self, tca):
        start = tca
        end = tca + self._orbit_step(0.34)
        while not self._precision_reached(start, end):
            midpoint = self.midpoint(start, end)
            elevation = self._elevation_at(midpoint)

            if elevation < self.aos_at:
                end = midpoint
            else:
                start = midpoint

        return start


class AccuratePredictedPass:

    def __init__(self, aos, tca, los, max_elevation):
        self.aos = round_datetime(aos) if aos is not None else None
        self.tca = round_datetime(tca)
        self.los = round_datetime(los) if los is not None else None
        self.max_elevation = max_elevation

    @property
    def valid(self):
        return self.max_elevation > 0 and self.aos is not None and self.los is not None

    @reify
    def max_elevation_deg(self):
        return degrees(self.max_elevation)

    @reify
    def duration(self):
        return self.los - self.aos

[(54, 'logging.getLogger', 'logging.getLogger', False, 'import logging\n'), (57, 'datetime.timedelta', 'dt.timedelta', True, 'import datetime as dt\n'), (64, 'collections.namedtuple', 'namedtuple', False, 'from collections import namedtuple\n'), (33, 'warnings.warn', 'warnings.warn', False, 'import warnings\n'), (70, 'orbit_predictor.coordinate_systems.ecef_to_llh', 'coordinate_systems.ecef_to_llh', False, 'from orbit_predictor import coordinate_systems\n'), (81, 'orbit_predictor.utils.gstime_from_datetime', 'gstime_from_datetime', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (82, 'orbit_predictor.coordinate_systems.ecef_to_eci', 'coordinate_systems.ecef_to_eci', False, 'from orbit_predictor import coordinate_systems\n'), (83, 'orbit_predictor.coordinate_systems.ecef_to_eci', 'coordinate_systems.ecef_to_eci', False, 'from orbit_predictor import coordinate_systems\n'), (130, 'warnings.warn', 'warnings.warn', False, 'import warnings\n'), (155, 'orbit_predictor.utils.vector_diff', 'vector_diff', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (160, 'orbit_predictor.utils.cross_product', 'cross_product', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (161, 'orbit_predictor.utils.dot_product', 'dot_product', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (198, 'numpy.cross', 'np.cross', True, 'import numpy as np\n'), (225, 'orbit_predictor.utils.gstime_from_datetime', 'gstime_from_datetime', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (226, 'orbit_predictor.coordinate_systems.eci_to_ecef', 'coordinate_systems.eci_to_ecef', False, 'from orbit_predictor import coordinate_systems\n'), (227, 'orbit_predictor.coordinate_systems.eci_to_ecef', 'coordinate_systems.eci_to_ecef', False, 'from orbit_predictor import coordinate_systems\n'), (261, 'orbit_predictor.utils.eclipse_duration', 'eclipse_duration', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (388, 'math.radians', 'radians', False, 'from math import pi, acos, degrees, radians\n'), (392, 'warnings.warn', 'warnings.warn', False, 'import warnings\n'), (511, 'datetime.timedelta', 'dt.timedelta', True, 'import datetime as dt\n'), (556, 'math.degrees', 'degrees', False, 'from math import pi, acos, degrees, radians\n'), (87, 'numpy.array', 'np.array', True, 'import numpy as np\n'), (87, 'numpy.array', 'np.array', True, 'import numpy as np\n'), (92, 'math.degrees', 'degrees', False, 'from math import pi, acos, degrees, radians\n'), (92, 'math.degrees', 'degrees', False, 'from math import pi, acos, degrees, radians\n'), (92, 'math.degrees', 'degrees', False, 'from math import pi, acos, degrees, radians\n'), (92, 'math.degrees', 'degrees', False, 'from math import pi, acos, degrees, radians\n'), (167, 'math.degrees', 'degrees', False, 'from math import pi, acos, degrees, radians\n'), (185, 'datetime.datetime.utcnow', 'dt.datetime.utcnow', True, 'import datetime as dt\n'), (195, 'datetime.datetime.utcnow', 'dt.datetime.utcnow', True, 'import datetime as dt\n'), (199, 'orbit_predictor.utils.vector_norm', 'vector_norm', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (204, 'datetime.datetime.utcnow', 'dt.datetime.utcnow', True, 'import datetime as dt\n'), (209, 'orbit_predictor.utils.get_sun', 'get_sun', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (222, 'datetime.datetime.utcnow', 'dt.datetime.utcnow', True, 'import datetime as dt\n'), (243, 'datetime.datetime.utcnow', 'dt.datetime.utcnow', True, 'import datetime as dt\n'), (280, 'datetime.datetime.utcnow', 'dt.datetime.utcnow', True, 'import datetime as dt\n'), (287, 'orbit_predictor.exceptions.NotReachable', 'NotReachable', False, 'from orbit_predictor.exceptions import NotReachable, PropagationError\n'), (309, 'datetime.datetime.utcnow', 'dt.datetime.utcnow', True, 'import datetime as dt\n'), (322, 'scipy.optimize.minimize_scalar', 'minimize_scalar', False, 'from scipy.optimize import brentq, minimize_scalar\n'), (375, 'warnings.warn', 'warnings.warn', False, 'import warnings\n'), (441, 'orbit_predictor.exceptions.PropagationError', 'PropagationError', False, 'from orbit_predictor.exceptions import NotReachable, PropagationError\n'), (450, 'orbit_predictor.exceptions.PropagationError', 'PropagationError', False, 'from orbit_predictor.exceptions import NotReachable, PropagationError\n'), (157, 'orbit_predictor.utils.dot_product', 'dot_product', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (301, 'datetime.timedelta', 'dt.timedelta', True, 'import datetime as dt\n'), (341, 'scipy.optimize.brentq', 'brentq', False, 'from scipy.optimize import brentq, minimize_scalar\n'), (348, 'scipy.optimize.brentq', 'brentq', False, 'from scipy.optimize import brentq, minimize_scalar\n'), (360, 'datetime.timedelta', 'dt.timedelta', True, 'import datetime as dt\n'), (157, 'orbit_predictor.utils.vector_norm', 'vector_norm', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (157, 'orbit_predictor.utils.vector_norm', 'vector_norm', False, 'from orbit_predictor.utils import angle_between, cross_product, dot_product, reify, vector_diff, vector_norm, gstime_from_datetime, get_shadow, get_sun, eclipse_duration, get_satellite_minus_penumbra_verticals\n'), (355, 'datetime.timedelta', 'dt.timedelta', True, 'import datetime as dt\n'), (356, 'datetime.timedelta', 'dt.timedelta', True, 'import datetime as dt\n'), (358, 'datetime.timedelta', 'dt.timedelta', True, 'import datetime as dt\n')]