[Git][debian-gis-team/pyorbital][upstream] New upstream version 1.11.0

Antonio Valentino (@antonio.valentino) gitlab at salsa.debian.org
Sat Nov 22 09:39:18 GMT 2025



Antonio Valentino pushed to branch upstream at Debian GIS Project / pyorbital


Commits:
b61b859b by Antonio Valentino at 2025-11-22T09:14:12+00:00
New upstream version 1.11.0
- - - - -


12 changed files:

- .github/workflows/ci.yaml
- .github/workflows/deploy-sdist.yaml
- .gitignore
- .pre-commit-config.yaml
- CHANGELOG.md
- + pyorbital/config.py
- pyorbital/etc/platforms.txt
- pyorbital/orbital.py
- + pyorbital/tests/test_orbit_elements.py
- pyorbital/tests/test_tlefile.py
- pyorbital/tlefile.py
- pyproject.toml


Changes:

=====================================
.github/workflows/ci.yaml
=====================================
@@ -28,7 +28,7 @@ jobs:
 
     steps:
       - name: Checkout source
-        uses: actions/checkout at v4
+        uses: actions/checkout at v5
 
       - name: Setup Conda Environment
         uses: conda-incubator/setup-miniconda at v3
@@ -66,6 +66,7 @@ jobs:
           python -m pip install \
           --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple/ \
           --trusted-host pypi.anaconda.org \
+          --only-binary ":all:" \
           --no-deps --pre --upgrade \
           matplotlib \
           numpy \


=====================================
.github/workflows/deploy-sdist.yaml
=====================================
@@ -11,7 +11,7 @@ jobs:
 
     steps:
       - name: Checkout source
-        uses: actions/checkout at v4
+        uses: actions/checkout at v5
 
       - name: Create sdist
         shell: bash -l {0}
@@ -21,7 +21,7 @@ jobs:
 
       - name: Publish package to PyPI
         if: github.event.action == 'published'
-        uses: pypa/gh-action-pypi-publish at v1.12.4
+        uses: pypa/gh-action-pypi-publish at v1.13.0
         with:
           user: __token__
           password: ${{ secrets.pypi_password }}


=====================================
.gitignore
=====================================
@@ -40,4 +40,8 @@ nosetests.xml
 # rope
 .ropeproject
 
+# Virtual environment
+venv/
+.venv/
+
 pyorbital/version.py


=====================================
.pre-commit-config.yaml
=====================================
@@ -3,11 +3,11 @@ fail_fast: false
 repos:
   - repo: https://github.com/astral-sh/ruff-pre-commit
     # Ruff version.
-    rev: 'v0.11.12'
+    rev: 'v0.14.3'
     hooks:
       - id: ruff
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v5.0.0
+    rev: v6.0.0
     hooks:
       - id: trailing-whitespace
         exclude: pyorbital/tests/SGP4-VER.TLE
@@ -15,12 +15,12 @@ repos:
       - id: check-yaml
         args: [--unsafe]
   - repo: https://github.com/PyCQA/bandit
-    rev: '1.8.3' # Update me!
+    rev: '1.8.6' # Update me!
     hooks:
       - id: bandit
         args: [--ini, .bandit]
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: 'v1.16.0'  # Use the sha / tag you want to point at
+    rev: 'v1.18.2'  # Use the sha / tag you want to point at
     hooks:
       - id: mypy
         additional_dependencies:
@@ -30,7 +30,7 @@ repos:
           - types-requests
         args: ["--python-version", "3.10", "--ignore-missing-imports"]
   - repo: https://github.com/pycqa/isort
-    rev: 6.0.1
+    rev: 7.0.0
     hooks:
       - id: isort
         language_version: python3


=====================================
CHANGELOG.md
=====================================
@@ -1,3 +1,38 @@
+###############################################################################
+## Version 1.11.0 (2025/11/18)
+
+### Issues Closed
+
+* [Issue 203](https://github.com/pytroll/pyorbital/issues/203) - Coordinate system and units for `get_position` are unspecified
+* [Issue 145](https://github.com/pytroll/pyorbital/issues/145) - Geostationary satellite scan geometry example
+* [Issue 111](https://github.com/pytroll/pyorbital/issues/111) - Documentation refers to PPP_CONFIG_DIR
+* [Issue 74](https://github.com/pytroll/pyorbital/issues/74) - ValueError in case of empty or missing TLE files ([PR 205](https://github.com/pytroll/pyorbital/pull/205) by [@pnuu](https://github.com/pnuu))
+* [Issue 61](https://github.com/pytroll/pyorbital/issues/61) - Test data never makes it in sdist/bdist packages
+* [Issue 43](https://github.com/pytroll/pyorbital/issues/43) - Satellite elevation/azimuth wrong
+
+In this release 6 issues were closed.
+
+### Pull Requests Merged
+
+#### Bugs fixed
+
+* [PR 214](https://github.com/pytroll/pyorbital/pull/214) - Fix TLE writing by adding a newline after the last TLE entry
+* [PR 213](https://github.com/pytroll/pyorbital/pull/213) - Fix timeout preventing storage of TLEs
+
+#### Features added
+
+* [PR 213](https://github.com/pytroll/pyorbital/pull/213) - Fix timeout preventing storage of TLEs
+* [PR 211](https://github.com/pytroll/pyorbital/pull/211) - Rename 'excentricity' properties to 'eccentricity'
+* [PR 210](https://github.com/pytroll/pyorbital/pull/210) - Start deprecating automatic tle downloads
+* [PR 205](https://github.com/pytroll/pyorbital/pull/205) - Add fallback when `TLES` environment variable not listing any files ([74](https://github.com/pytroll/pyorbital/issues/74), [74](https://github.com/pytroll/pyorbital/issues/74))
+* [PR 204](https://github.com/pytroll/pyorbital/pull/204) - Extend `OrbitElements` Class with New Methods and Add Pytest Coverage
+* [PR 202](https://github.com/pytroll/pyorbital/pull/202) - Move import of tlefile inline to avoid import-time side effects
+
+In this release 8 pull requests were closed.
+
+###############################################################################
+
+
 ## Version 1.10.2 (2025/06/09)
 
 


=====================================
pyorbital/config.py
=====================================
@@ -0,0 +1,4 @@
+"""Set up the config object."""
+from donfig import Config
+
+config = Config("pyorbital")


=====================================
pyorbital/etc/platforms.txt
=====================================
@@ -48,6 +48,31 @@ INSAT-3C 27298
 INSAT-3D 39216
 JASON-2 33105
 Kalpana-1 27525
+KINEIS-5A 62084
+KINEIS-4E 63300
+KINEIS-3B 61224
+KINEIS-2D 62930
+KINEIS-5E 62083
+KINEIS-3A 61223
+KINEIS-2C 62929
+KINEIS-1A 60084
+KINEIS-5D 62082
+KINEIS-3E 61222
+KINEIS-4B 63304
+KINEIS-1E 60083
+KINEIS-5C 62081
+KINEIS-3D 61221
+KINEIS-4A 63303
+KINEIS-1D 60082
+KINEIS-2B 62934
+KINEIS-3C 61220
+KINEIS-4D 63302
+KINEIS-1C 60081
+KINEIS-2A 62932
+KINEIS-5B 62085
+KINEIS-4C 63301
+KINEIS-1B 60079
+KINEIS-2E 62931
 Landsat-7 25682
 Landsat-8 39084
 Meteosat-7 24932


=====================================
pyorbital/orbital.py
=====================================
@@ -32,7 +32,7 @@ from functools import partial
 import numpy as np
 from scipy import optimize
 
-from pyorbital import astronomy, dt2np, tlefile
+from pyorbital import astronomy, dt2np
 
 try:
     import dask.array as da
@@ -155,6 +155,8 @@ class Orbital(object):
 
     def __init__(self, satellite, tle_file=None, line1=None, line2=None):
         """Initialize the class."""
+        from pyorbital import tlefile
+
         satellite = satellite.upper()
         self.satellite_name = satellite
         self.tle = tlefile.read(satellite, tle_file=tle_file,
@@ -568,46 +570,135 @@ def _get_max_parab(fun, start, end, tol=0.01):
             f_a, f_b, f_c = fun(a), f_x, fun(c)
 
 
-class OrbitElements(object):
+class OrbitElements:
     """Class holding the orbital elements."""
 
     def __init__(self, tle):
         """Initialize the class."""
         self.epoch = tle.epoch
-        self.excentricity = tle.excentricity
+        self.eccentricity = tle.eccentricity
         self.inclination = np.deg2rad(tle.inclination)
         self.right_ascension = np.deg2rad(tle.right_ascension)
         self.arg_perigee = np.deg2rad(tle.arg_perigee)
         self.mean_anomaly = np.deg2rad(tle.mean_anomaly)
 
-        self.mean_motion = tle.mean_motion * (np.pi * 2 / XMNPDA)
-        self.mean_motion_derivative = tle.mean_motion_derivative * \
-            np.pi * 2 / XMNPDA ** 2
-        self.mean_motion_sec_derivative = tle.mean_motion_sec_derivative * \
-            np.pi * 2 / XMNPDA ** 3
+        self.mean_motion = tle.mean_motion * (2 * np.pi / XMNPDA)
+        self.mean_motion_derivative = tle.mean_motion_derivative * (2 * np.pi / XMNPDA**2)
+        self.mean_motion_sec_derivative = tle.mean_motion_sec_derivative * (2 * np.pi / XMNPDA**3)
         self.bstar = tle.bstar * AE
 
-        self.original_mean_motion, self.semi_major_axis = \
-            self._calculate_mean_motion_and_semi_major_axis()
-        self._calculate_mean_motion_and_semi_major_axis()
+        self.original_mean_motion, self.semi_major_axis = self._calculate_mean_motion_and_semi_major_axis()
+
+        self.period = 2 * np.pi / self.original_mean_motion
+        self.right_ascension_lon = self.right_ascension - astronomy.gmst(self.epoch)
+        self.right_ascension_lon = np.fmod(self.right_ascension_lon + np.pi, 2 * np.pi) - np.pi
+
+    @property
+    def excentricity(self):
+        """Get 'eccentricity' using legacy 'excentricity' name."""
+        warnings.warn("The 'eccentricity' property is deprecated in favor of 'eccentricity'", stacklevel=2)
+        return self.eccentricity
+
+    @property
+    def apogee(self):
+        """Compute apogee altitude in kilometers."""
+        return ((self.semi_major_axis * (1 + self.eccentricity)) / AE - AE) * XKMPER
+
+    @property
+    def perigee(self):
+        """Compute perigee altitude in kilometers."""
+        return ((self.semi_major_axis * (1 - self.eccentricity)) / AE - AE) * XKMPER
+
+    @property
+    def is_circular(self):
+        """Check if orbit is nearly circular."""
+        return self.eccentricity < 1e-3
+
+    @property
+    def is_retrograde(self):
+        """Check if orbit is retrograde (inclination > 90°)."""
+        return self.inclination > np.pi / 2
+
+    def _get_true_anomaly(self):
+        """Computes the True Anomaly (nu) from Mean Anomaly (M) and Eccentricity (e)."""
+        M = self.mean_anomaly
+        e = self.eccentricity
+        E = M # Initial guess for Eccentric Anomaly (E)
+
+        # Iteratively solve Kepler's Equation (M = E - e*sin(E))
+        for _ in range(10): # Max 10 iterations
+            f = E - e * np.sin(E) - M
+            f_prime = 1 - e * np.cos(E)
+            E_new = E - f / f_prime
+            if np.abs(E_new - E) < 1e-8: # Tolerance
+                E = E_new
+                break
+            E = E_new
+
+        # Convert Eccentric Anomaly (E) to True Anomaly (nu)
+        nu = 2 * np.arctan2(
+            np.sqrt(1 + e) * np.sin(E / 2),
+            np.sqrt(1 - e) * np.cos(E / 2)
+        )
+        return nu
+
+    def position_vector_in_orbital_plane(self):
+        """Compute position vector in the orbital plane at epoch.
+
+        The x-axis points toward the perigee.
+        """
+        true_anomaly = self._get_true_anomaly()
+
+        # Calculate radius (r) and coordinates using the True Anomaly
+        r = self.semi_major_axis * (1 - self.eccentricity**2) / \
+            (1 + self.eccentricity * np.cos(true_anomaly))
+
+        x = r * np.cos(true_anomaly)
+        y = r * np.sin(true_anomaly)
+
+        return np.array([x, y])
+
+    def _get_velocity_at_apsis(self, e_1, e_2):
+        """Helper method to compute orbital velocity at an apsis."""
+        mu = XKE**2 * AE**3
 
-        self.period = np.pi * 2 / self.original_mean_motion
-        self.perigee = (self.semi_major_axis * (1 - self.excentricity) / AE - AE) * XKMPER
-        self.right_ascension_lon = (self.right_ascension
-                                    - astronomy.gmst(self.epoch))
+        # r_apsis is the distance at the apsis (perigee or apogee)
+        r_apsis = self.semi_major_axis * e_1
 
-        if self.right_ascension_lon > np.pi:
-            self.right_ascension_lon -= 2 * np.pi
+        # The velocity formula: V = sqrt( mu * (2/r - 1/a) ).
+        # For apse lines (r = a * (1-e^2)/(1+e*cos(nu)), it simplifies to:
+        # V = sqrt( mu * (1 ± e) / r )
+        v_er_per_min = np.sqrt(mu * (e_2 / r_apsis))
+
+        # Conversion factor: (AE * XKMPER) converts ER -> km; / 60 converts min -> s
+        conversion_factor = (AE * XKMPER) / 60.0
+
+        return v_er_per_min * conversion_factor
+
+    def velocity_at_perigee(self):
+        """Compute orbital velocity at perigee in km/s."""
+        return self._get_velocity_at_apsis(1 - self.eccentricity, 1 + self.eccentricity)
+
+    def velocity_at_apogee(self):
+        """Compute orbital velocity at apogee in km/s."""
+        return self._get_velocity_at_apsis(1 + self.eccentricity, 1 - self.eccentricity)
 
     def _calculate_mean_motion_and_semi_major_axis(self):
+        """Apply SGP4 perturbation corrections to mean motion and semi-major axis.
+
+        Based on inclination and eccentricity.
+        """
         a_1 = (XKE / self.mean_motion) ** (2.0 / 3)
-        delta_1 = ((3 / 2.0) * (CK2 / a_1**2) * ((3 * np.cos(self.inclination)**2 - 1) /
-                                                 (1 - self.excentricity**2)**(2.0 / 3)))
+        delta_1 = (3 / 2.0) * (CK2 / a_1**2) * ((3 * np.cos(self.inclination)**2 - 1) /
+                                               (1 - self.eccentricity**2)**(3 / 2))
         a_0 = a_1 * (1 - delta_1 / 3 - delta_1**2 - (134.0 / 81) * delta_1**3)
-        delta_0 = ((3 / 2.0) * (CK2 / a_0**2) * ((3 * np.cos(self.inclination)**2 - 1) /
-                                                 (1 - self.excentricity**2)**(2.0 / 3)))
+        delta_0 = (3 / 2.0) * (CK2 / a_0**2) * ((3 * np.cos(self.inclination)**2 - 1) /
+                                               (1 - self.eccentricity**2)**(3 / 2))
+
+        corrected_mean_motion = self.mean_motion / (1 + delta_0)
+        corrected_semi_major_axis = a_0 / (1 - delta_0)
 
-        return (self.mean_motion / (1 + delta_0), a_0 / (1 - delta_0))
+        return corrected_mean_motion, corrected_semi_major_axis
 
 
 class _SGDP4Base:
@@ -619,7 +710,7 @@ class _SGDP4Base:
 
         _check_orbital_elements(orbit_elements)
 
-        self.eo = orbit_elements.excentricity
+        self.eo = orbit_elements.eccentricity
         self.xincl = orbit_elements.inclination
         self.xno = orbit_elements.original_mean_motion
         self.bstar = orbit_elements.bstar
@@ -1205,8 +1296,8 @@ class _Keplerians:
 
 
 def _check_orbital_elements(orbit_elements):
-    if not (0 < orbit_elements.excentricity < ECC_LIMIT_HIGH):
-        raise OrbitalError("Eccentricity out of range: %e" % orbit_elements.excentricity)
+    if not (0 < orbit_elements.eccentricity < ECC_LIMIT_HIGH):
+        raise OrbitalError("Eccentricity out of range: %e" % orbit_elements.eccentricity)
     if not ((0.0035 * 2 * np.pi / XMNPDA) < orbit_elements.original_mean_motion < (18 * 2 * np.pi / XMNPDA)):
         raise OrbitalError("Mean motion out of range: %e" % orbit_elements.original_mean_motion)
     if not (0 < orbit_elements.inclination < np.pi):


=====================================
pyorbital/tests/test_orbit_elements.py
=====================================
@@ -0,0 +1,296 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2012-2024 Pytroll Community
+
+# Author(s):
+
+#   Martin Raspaud <martin.raspaud at smhi.se>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Test the orbit orbit elements."""
+
+import datetime
+
+import numpy as np
+import pytest
+
+from pyorbital.orbital import (
+    AE,
+    ECC_EPS,
+    ECC_LIMIT_HIGH,
+    XKMPER,
+    XMNPDA,
+    OrbitElements,
+)
+
+
+class MockTLE:
+    """Mock TLE object for testing OrbitElements."""
+
+    def __init__(self):
+        """Initialize mock TLE values for testing."""
+        self.epoch = datetime.datetime(2025, 9, 30, 12, 0, 0)
+        self.eccentricity = 0.001
+        self.inclination = 98.7
+        self.right_ascension = 120.0
+        self.arg_perigee = 87.0
+        self.mean_anomaly = 0.0
+        self.mean_motion = 14.2
+        self.mean_motion_derivative = 0.0
+        self.mean_motion_sec_derivative = 0.0
+        self.bstar = 0.0001
+
+
+def test_orbit_elements_computation():
+    """Test basic orbital element computations (SMA, Period, Angle Range)."""
+    tle = MockTLE()
+    orbit = OrbitElements(tle)
+
+    assert isinstance(orbit.semi_major_axis, float)
+    assert orbit.semi_major_axis > 0
+    assert orbit.period > 0
+    assert -np.pi <= orbit.right_ascension_lon <= np.pi
+
+
+def test_zero_eccentricity():
+    """Test perigee calculation with zero eccentricity."""
+    tle = MockTLE()
+    tle.eccentricity = 0.0
+    orbit = OrbitElements(tle)
+    # Perigee == Apogee altitude when e=0
+    assert orbit.perigee == pytest.approx(
+        (orbit.semi_major_axis / AE - AE) * XKMPER, abs=1e-3
+    )
+
+
+def test_retrograde_orbit_inclination():
+    """Test basic inclination check for retrograde orbit."""
+    tle = MockTLE()
+    tle.inclination = 120.0
+    orbit = OrbitElements(tle)
+    assert orbit.inclination > np.pi / 2
+
+
+def test_ra_lon_wrapping():
+    """Test longitude wrapping of right ascension with input > 360 deg."""
+    tle = MockTLE()
+    tle.right_ascension = 400.0  # degrees, > 360
+    orbit = OrbitElements(tle)
+    assert -np.pi <= orbit.right_ascension_lon <= np.pi
+
+
+def test_mean_motion_conversion():
+    """Test conversion of mean motion to radians per minute."""
+    tle = MockTLE()
+    orbit = OrbitElements(tle)
+    expected = tle.mean_motion * (2 * np.pi / XMNPDA)
+    assert orbit.mean_motion == pytest.approx(expected, rel=1e-6)
+
+
+ at pytest.mark.parametrize("incl_deg", [0, 45, 90, 135])
+def test_semi_major_axis_vs_inclination(incl_deg):
+    """Test semi-major axis computation across inclinations."""
+    tle = MockTLE()
+    tle.inclination = incl_deg
+    orbit = OrbitElements(tle)
+    assert orbit.semi_major_axis > 0
+
+
+ at pytest.mark.parametrize("bstar", [0.0, 1e-5, 0.0001, 0.01])
+def test_bstar_scaling(bstar):
+    """Test scaling of bstar drag term."""
+    tle = MockTLE()
+    tle.bstar = bstar
+    orbit = OrbitElements(tle)
+    assert orbit.bstar == pytest.approx(bstar * AE)
+
+
+def test_mean_motion_derivatives():
+    """Test mean motion derivatives are correctly computed."""
+    tle = MockTLE()
+    orbit = OrbitElements(tle)
+    tle.mean_motion_derivative = 1e-5
+    tle.mean_motion_sec_derivative = 1e-7
+    orbit = OrbitElements(tle)
+    assert orbit.mean_motion_derivative > 0
+    assert orbit.mean_motion_sec_derivative > 0
+
+
+def test_apogee_computation():
+    """Test apogee altitude calculation."""
+    tle = MockTLE()
+    orbit = OrbitElements(tle)
+    expected_apogee = (
+        (orbit.semi_major_axis * (1 + orbit.eccentricity)) / AE - AE
+    ) * XKMPER
+    assert orbit.apogee == pytest.approx(expected_apogee, abs=1e-3)
+
+
+def test_semi_major_axis_accuracy():
+    """Test semi-major axis against analytical reference value (in km)."""
+    tle = MockTLE()
+    orbit = OrbitElements(tle)
+    expected_sma_km = 7200.645659667062
+    computed_sma_km = orbit.semi_major_axis * XKMPER / AE
+    assert computed_sma_km == pytest.approx(expected_sma_km, abs=0.1)
+
+
+def test_orbital_period_accuracy():
+    """Test orbital period against corrected mean motion (in seconds)."""
+    tle = MockTLE()
+    orbit = OrbitElements(tle)
+    expected_period_sec = 6080.901176943267
+    computed_period_sec = orbit.period * 60
+    assert computed_period_sec == pytest.approx(expected_period_sec, abs=1.0)
+
+
+def test_velocity_at_perigee_accuracy():
+    """Test orbital velocity at perigee against analytical reference."""
+    tle = MockTLE()
+    orbit = OrbitElements(tle)
+    expected_velocity_kms = 7.44762253625217
+    computed_velocity_kms = orbit.velocity_at_perigee()
+    assert computed_velocity_kms == pytest.approx(expected_velocity_kms, abs=0.005)
+
+
+def test_velocity_at_apogee_accuracy():
+    """Test orbital velocity at apogee against analytical reference."""
+    tle = MockTLE()
+    orbit = OrbitElements(tle)
+    expected_velocity_kms = 7.432742171544375
+    computed_velocity_kms = orbit.velocity_at_apogee()
+    assert computed_velocity_kms == pytest.approx(expected_velocity_kms, abs=0.005)
+
+
+def test_position_vector_in_orbital_plane_perigee_accuracy():
+    """Test position vector at perigee (Mean Anomaly = 0°)."""
+    tle = MockTLE()
+    tle.eccentricity = 0.001
+    tle.mean_anomaly = 0.0
+    orbit = OrbitElements(tle)
+    pos = orbit.position_vector_in_orbital_plane()
+    expected_x = 1.1278289051591721  # Earth radii
+    expected_y = 0.0
+    assert pos[0] == pytest.approx(expected_x, rel=1e-6)
+    assert pos[1] == pytest.approx(expected_y, abs=1e-8)
+
+
+ at pytest.mark.parametrize(
+    ("mean_anomaly_deg", "expected_radius"),
+    [
+        (0, 7193.445014007397),
+        (90, 7200.6528603079205),
+        (180, 7207.84630532673),
+        (270, 7200.6528603079205),
+    ],
+)
+def test_position_vector_in_orbital_plane_varied(mean_anomaly_deg, expected_radius):
+    """Verify position vector magnitude matches expected radius at given mean anomaly."""
+    tle = MockTLE()
+    tle.mean_anomaly = mean_anomaly_deg
+    orbit = OrbitElements(tle)
+    pos = orbit.position_vector_in_orbital_plane()
+    actual_radius = np.linalg.norm(pos) * XKMPER
+    assert np.isclose(actual_radius, expected_radius, rtol=1e-6)
+
+
+ at pytest.mark.parametrize(
+    ("eccentricity", "expected"),
+    [
+        (0.0005, True),
+        (0.01, False),
+    ],
+)
+def test_is_circular_property(eccentricity, expected):
+    """Test circular orbit detection based on eccentricity."""
+    tle = MockTLE()
+    tle.eccentricity = eccentricity
+    orbit = OrbitElements(tle)
+    assert orbit.is_circular == expected
+
+
+ at pytest.mark.parametrize(
+    ("inclination_deg", "expected"),
+    [
+        (100.0, True),
+        (80.0, False),
+    ],
+)
+def test_is_retrograde_property(inclination_deg, expected):
+    """Test retrograde orbit detection based on inclination."""
+    tle = MockTLE()
+    tle.inclination = inclination_deg
+    orbit = OrbitElements(tle)
+    assert orbit.is_retrograde == expected
+
+
+ at pytest.mark.parametrize("eccentricity", [0.001, 0.01, 0.1, 0.5])
+def test_velocity_perigee_greater_than_apogee(eccentricity):
+    """Ensure velocity at perigee is greater than at apogee for elliptical orbits."""
+    tle = MockTLE()
+    tle.eccentricity = eccentricity
+    orbit = OrbitElements(tle)
+    v_perigee = orbit.velocity_at_perigee()
+    v_apogee = orbit.velocity_at_apogee()
+    assert v_perigee > v_apogee
+
+
+ at pytest.mark.parametrize("eccentricity", [0.0, ECC_EPS / 10, ECC_EPS])
+def test_velocity_equal_for_circular_orbits(eccentricity):
+    """Ensure velocity at perigee equals velocity at apogee for nearly circular orbits."""
+    tle = MockTLE()
+    tle.eccentricity = eccentricity
+    orbit = OrbitElements(tle)
+    v_perigee = orbit.velocity_at_perigee()
+    v_apogee = orbit.velocity_at_apogee()
+    assert v_perigee == pytest.approx(v_apogee, rel=1e-5)
+
+
+def test_high_eccentricity_limit():
+    """Test behavior near the upper eccentricity limit."""
+    tle = MockTLE()
+    tle.eccentricity = ECC_LIMIT_HIGH
+    orbit = OrbitElements(tle)
+    assert orbit.semi_major_axis > 0
+    assert orbit.velocity_at_perigee() > orbit.velocity_at_apogee()
+
+
+def test_sgp4_unnormalization_value():
+    """Validate SGP4 un-normalization of mean motion and semi-major axis.
+
+    This test uses hardcoded reference values from validated SGP4 output.
+    """
+
+    class RefTLE(MockTLE):
+        """Reference TLE with fixed orbital parameters for precision testing."""
+
+        def __init__(self):
+            super().__init__()
+            """Initialize reference TLE values for SGP4 unnormalization test."""
+            self.inclination = 98.7408  # degrees
+            self.eccentricity = 0.001140
+            self.mean_motion = 14.28634842  # rev/day
+            self.epoch = datetime.datetime(2200, 1, 1, 0, 0, 0)
+
+    tle = RefTLE()
+    orbit = OrbitElements(tle)
+
+    # Hardcoded expected values from validated SGP4 propagation
+    expected_mean_motion = 0.06237319306246916  # rad/min
+    expected_semi_major_axis = 1.1244009310620886  # Earth radii
+
+    assert orbit.original_mean_motion == pytest.approx(expected_mean_motion, rel=1e-8)
+    assert orbit.semi_major_axis == pytest.approx(expected_semi_major_axis, rel=1e-8)


=====================================
pyorbital/tests/test_tlefile.py
=====================================
@@ -32,6 +32,8 @@ from unittest import mock
 
 import pytest
 
+from pyorbital.config import config
+
 LINE0 = "ISS (ZARYA)"
 LINE1 = "1 25544U 98067A   08264.51782528 -.00002182  00000-0 -11606-4 0  2927"
 LINE2 = "2 25544  51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"
@@ -120,9 +122,9 @@ def test_read_tlefile_standard_platform_name(monkeypatch, fake_platforms_txt_fil
     from pyorbital import tlefile
 
     path_to_platforms_txt_file = fake_platforms_txt_file.parent
-    monkeypatch.setenv("PYORBITAL_CONFIG_PATH", str(path_to_platforms_txt_file))
 
-    tle_n21 = tlefile.read("NOAA-21", str(fake_tlefile))
+    with config.set(config_path=str(path_to_platforms_txt_file)):
+        tle_n21 = tlefile.read("NOAA-21", str(fake_tlefile))
     assert tle_n21.line1 == "1 54234U 22150A   23045.56664999  .00000332  00000+0  17829-3 0  9993"
     assert tle_n21.line2 == "2 54234  98.7059 345.5113 0001226  81.6523 278.4792 14.19543871 13653"
 
@@ -135,9 +137,9 @@ def test_read_tlefile_non_standard_platform_name(monkeypatch, fake_platforms_txt
     from pyorbital import tlefile
 
     path_to_platforms_txt_file = fake_platforms_txt_file.parent
-    monkeypatch.setenv("PYORBITAL_CONFIG_PATH", str(path_to_platforms_txt_file))
 
-    tle_n20 = tlefile.read("NOAA 20", str(fake_tlefile))
+    with config.set(config_path=str(path_to_platforms_txt_file)):
+        tle_n20 = tlefile.read("NOAA 20", str(fake_tlefile))
 
     assert tle_n20.line1 == "1 43013U 17073A   23045.54907786  .00000253  00000+0  14081-3 0  9995"
     assert tle_n20.line2 == "2 43013  98.7419 345.5839 0001610  80.3742 279.7616 14.19558274271576"
@@ -168,11 +170,11 @@ def test_read_tlefile_non_standard_platform_name_matching_start_of_name_in_tlefi
     from pyorbital import tlefile
 
     path_to_platforms_txt_file = fake_platforms_txt_file.parent
-    monkeypatch.setenv("PYORBITAL_CONFIG_PATH", str(path_to_platforms_txt_file))
 
-    with pytest.raises(KeyError) as exc_info:
-        with caplog.at_level(logging.DEBUG):
-            _ = tlefile.read(sat_name, str(fake_tlefile))
+    with config.set(config_path=str(path_to_platforms_txt_file)):
+        with pytest.raises(KeyError) as exc_info:
+            with caplog.at_level(logging.DEBUG):
+                _ = tlefile.read(sat_name, str(fake_tlefile))
 
     assert f"Found a possible match: {expected}?" in caplog.text
     assert str(exc_info.value) == f'"Found no TLE entry for \'{sat_name}\'"'
@@ -308,11 +310,10 @@ def test_get_config_path_ppp_config_set_and_pyorbital(caplog, monkeypatch):
     from pyorbital.tlefile import _get_config_path
 
     pyorbital_config_dir = "/path/to/pyorbital/config/dir"
-    monkeypatch.setenv("PYORBITAL_CONFIG_PATH", pyorbital_config_dir)
     monkeypatch.setenv("PPP_CONFIG_DIR", "/path/to/old/mpop/config/dir")
-
-    with caplog.at_level(logging.WARNING):
-        res = _get_config_path()
+    with config.set(config_path=pyorbital_config_dir):
+        with caplog.at_level(logging.WARNING):
+            res = _get_config_path()
 
     assert res == pyorbital_config_dir
     assert caplog.text == ""
@@ -327,10 +328,10 @@ def test_get_config_path_pyorbital_ppp_missing(caplog, monkeypatch):
     from pyorbital.tlefile import _get_config_path
 
     pyorbital_config_dir = "/path/to/pyorbital/config/dir"
-    monkeypatch.setenv("PYORBITAL_CONFIG_PATH", pyorbital_config_dir)
 
-    with caplog.at_level(logging.DEBUG):
-        res = _get_config_path()
+    with config.set(config_path=pyorbital_config_dir):
+        with caplog.at_level(logging.DEBUG):
+            res = _get_config_path()
 
     assert res == pyorbital_config_dir
     log_output = ("Path to the Pyorbital configuration (where e.g. " +
@@ -384,6 +385,25 @@ def test_get_uris_and_open_func_using_tles_env(caplog, fake_local_tles_dir, monk
     assert log_message in caplog.text
 
 
+def test_get_uris_and_open_func_using_empty_tles_env(caplog, fake_local_tles_dir, monkeypatch):
+    """Test getting the uris and associated open-function for reading tles.
+
+    Test providing no tle file but using TLES env that returns no files.
+    """
+    from pyorbital.tlefile import _get_uris_and_open_func
+
+    monkeypatch.setenv("TLES", "/no/files/here/tle*txt")
+
+    with caplog.at_level(logging.DEBUG):
+        with pytest.warns():
+            uris, _ = _get_uris_and_open_func()
+
+    warning_text = "TLES environment variable points to no TLE files"
+    debug_text = "Fetch TLE from the internet."
+    assert warning_text in caplog.text
+    assert debug_text in caplog.text
+
+
 class TLETest(unittest.TestCase):
     """Test TLE reading.
 
@@ -417,7 +437,7 @@ class TLETest(unittest.TestCase):
         # line 2
         assert tle.inclination == 51.6416
         assert tle.right_ascension == 247.4627
-        assert tle.excentricity == 0.0006703
+        assert tle.eccentricity == 0.0006703
         assert tle.arg_perigee == 130.536
         assert tle.mean_anomaly == 325.0288
         assert tle.mean_motion == 15.72125391
@@ -513,11 +533,7 @@ class TestDownloader(unittest.TestCase):
 
     @mock.patch("pyorbital.tlefile.requests")
     def test_fetch_plain_tle_not_configured(self, requests):
-        """Test downloading and a TLE file from internet."""
-        requests.get = mock.MagicMock()
-        requests.get.return_value = _get_req_response(200)
-
-        # Not configured
+        """Test that plain TLE downloading is not called when not configured."""
         self.dl.config["downloaders"] = {}
         res = self.dl.fetch_plain_tle()
         assert res == {}
@@ -525,7 +541,7 @@ class TestDownloader(unittest.TestCase):
 
     @mock.patch("pyorbital.tlefile.requests")
     def test_fetch_plain_tle_two_sources(self, requests):
-        """Test downloading and a TLE file from internet."""
+        """Test downloading a TLE file from internet."""
         requests.get = mock.MagicMock()
         requests.get.return_value = _get_req_response(200)
 
@@ -540,7 +556,24 @@ class TestDownloader(unittest.TestCase):
         assert "source_2" in res
         assert len(res["source_2"]) == 1
         assert mock.call("mocked_url_1", timeout=15) in requests.get.mock_calls
-        assert len(requests.get.mock_calls) == 4
+        assert len([c for c in requests.get.mock_calls if c.args]) == 4
+
+    @mock.patch("logging.error")
+    @mock.patch("pyorbital.tlefile.requests.get")
+    def test_fetch_plain_tle_timeout(self, requests_get, logging_error):
+        """Test that timeout is logged."""
+        from requests.exceptions import Timeout
+
+        requests_get.side_effect = Timeout
+
+        self.dl.config["downloaders"] = FETCH_PLAIN_TLE_CONFIG
+
+        res = self.dl.fetch_plain_tle()
+        for url in ["mocked_url_1", "mocked_url_2", "mocked_url_3", "mocked_url_4"]:
+            expected = mock.call(f"Failed to make request to {url} within 15 seconds!")
+            assert expected in logging_error.mock_calls
+        assert not res["source_1"]
+        assert not res["source_2"]
 
     @mock.patch("pyorbital.tlefile.requests")
     def test_fetch_plain_tle_server_is_a_teapot(self, requests):
@@ -560,7 +593,7 @@ class TestDownloader(unittest.TestCase):
         assert len(res["source_2"]) == 0
 
         assert mock.call("mocked_url_1", timeout=15) in requests.get.mock_calls
-        assert len(requests.get.mock_calls) == 4
+        assert len([c for c in requests.get.mock_calls if c.args]) == 4
 
     @mock.patch("pyorbital.tlefile.requests")
     def test_fetch_spacetrack_login_fails(self, requests):
@@ -721,6 +754,7 @@ class TestSQLiteTLE(unittest.TestCase):
         """Clean temporary files."""
         with suppress(PermissionError, NotADirectoryError):
             self.temp_dir.cleanup()
+        self.db.close()
 
     def test_init(self):
         """Test that the init did what it should have."""
@@ -821,7 +855,9 @@ class TestSQLiteTLE(unittest.TestCase):
         assert "%" not in files[0]
         # The satellite name should be in the file
         with open(files[0], "r") as fid:
-            data = fid.read().split("\n")
+            data = fid.read()
+        assert data.endswith("\n")
+        data = data.strip("\n").split("\n")
         assert len(data) == 3
         assert "ISS" in data[0]
         assert data[1] == LINE1
@@ -845,7 +881,7 @@ class TestSQLiteTLE(unittest.TestCase):
         files = sorted(glob.glob(os.path.join(tle_dir, "tle_*txt")))
         assert len(files) == 2
         with open(files[1], "r") as fid:
-            data = fid.read().split("\n")
+            data = fid.read().strip("\n").split("\n")
         assert len(data) == 2
         assert data[0] == LINE1
         assert data[1] == LINE2
@@ -856,6 +892,6 @@ def test_tle_instance_printing():
 
     tle = Tle("ISS", line1=LINE1, line2=LINE2)
 
-    expected = "{'arg_perigee': 130.536,\n 'bstar': -1.1606e-05,\n 'classification': 'U',\n 'element_number': 292,\n 'ephemeris_type': 0,\n 'epoch': np.datetime64('2008-09-20T12:25:40.104192'),\n 'epoch_day': 264.51782528,\n 'epoch_year': '08',\n 'excentricity': 0.0006703,\n 'id_launch_number': '067',\n 'id_launch_piece': 'A  ',\n 'id_launch_year': '98',\n 'inclination': 51.6416,\n 'mean_anomaly': 325.0288,\n 'mean_motion': 15.72125391,\n 'mean_motion_derivative': -2.182e-05,\n 'mean_motion_sec_derivative': 0.0,\n 'orbit': 56353,\n 'right_ascension': 247.4627,\n 'satnumber': '25544'}"  # noqa
+    expected = "{'arg_perigee': 130.536,\n 'bstar': -1.1606e-05,\n 'classification': 'U',\n 'eccentricity': 0.0006703,\n 'element_number': 292,\n 'ephemeris_type': 0,\n 'epoch': np.datetime64('2008-09-20T12:25:40.104192'),\n 'epoch_day': 264.51782528,\n 'epoch_year': '08',\n 'id_launch_number': '067',\n 'id_launch_piece': 'A  ',\n 'id_launch_year': '98',\n 'inclination': 51.6416,\n 'mean_anomaly': 325.0288,\n 'mean_motion': 15.72125391,\n 'mean_motion_derivative': -2.182e-05,\n 'mean_motion_sec_derivative': 0.0,\n 'orbit': 56353,\n 'right_ascension': 247.4627,\n 'satnumber': '25544'}"  # noqa
 
     assert str(tle) == expected


=====================================
pyorbital/tlefile.py
=====================================
@@ -24,13 +24,17 @@ import io
 import logging
 import os
 import sqlite3
+import warnings
 from itertools import zip_longest
 from urllib.request import urlopen
+from warnings import warn
 
 import defusedxml.ElementTree as ET
 import numpy as np
 import requests
 
+from pyorbital.config import config
+
 TLE_GROUPS = ("active",
               "weather",
               "resource",
@@ -48,20 +52,17 @@ TLE_URLS = [f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=t
 LOGGER = logging.getLogger(__name__)
 PKG_CONFIG_DIR = os.path.join(os.path.realpath(os.path.dirname(__file__)), "etc")
 
-class TleDownloadTimeoutError(Exception):
-    """TLE download timeout exception."""
-
 
 def _get_config_path():
     """Get the config path for Pyorbital."""
-    if "PPP_CONFIG_DIR" in os.environ and "PYORBITAL_CONFIG_PATH" not in os.environ:
+    if "PPP_CONFIG_DIR" in os.environ and "config_path" not in config:
         LOGGER.warning(
             "The use of PPP_CONFIG_DIR is no longer supported!" +
             " Please use PYORBITAL_CONFIG_PATH if you need a custom config path for pyorbital!")
         LOGGER.debug("Using the package default for configuration: %s", PKG_CONFIG_DIR)
         return PKG_CONFIG_DIR
     else:
-        pyorbital_config_path = os.getenv("PYORBITAL_CONFIG_PATH", PKG_CONFIG_DIR)
+        pyorbital_config_path = config.get("config_path", PKG_CONFIG_DIR)
 
     LOGGER.debug("Path to the Pyorbital configuration (where e.g. platforms.txt is found): %s",
                  str(pyorbital_config_path))
@@ -161,7 +162,7 @@ class ChecksumError(Exception):
     """ChecksumError."""
 
 
-class Tle(object):
+class Tle:
     """Class holding TLE objects."""
 
     def __init__(self, platform, tle_file=None, line1=None, line2=None):
@@ -186,7 +187,7 @@ class Tle(object):
         self.element_number = None
         self.inclination = None
         self.right_ascension = None
-        self.excentricity = None
+        self.eccentricity = None
         self.arg_perigee = None
         self.mean_anomaly = None
         self.mean_motion = None
@@ -196,6 +197,12 @@ class Tle(object):
         self._checksum()
         self._parse_tle()
 
+    @property
+    def excentricity(self):
+        """Get 'eccentricity' using legacy 'excentricity' name."""
+        warnings.warn("The 'eccentricity' property is deprecated in favor of 'eccentricity'", stacklevel=2)
+        return self.eccentricity
+
     @property
     def line1(self):
         """Return first TLE line."""
@@ -271,12 +278,40 @@ class Tle(object):
 
         self.inclination = float(self._line2[8:16])
         self.right_ascension = float(self._line2[17:25])
-        self.excentricity = int(self._line2[26:33]) * 10 ** -7
+        self.eccentricity = int(self._line2[26:33]) * 10 ** -7
         self.arg_perigee = float(self._line2[34:42])
         self.mean_anomaly = float(self._line2[43:51])
         self.mean_motion = float(self._line2[52:63])
         self.orbit = int(self._line2[63:68])
 
+    def to_dict(self):
+        """Return the raw, parsed TLE elements as a dictionary."""
+        return {
+            "platform": self.platform,
+            "satnumber": self.satnumber,
+            "classification": self.classification,
+            "id_launch_year": self.id_launch_year,
+            "id_launch_number": self.id_launch_number,
+            "id_launch_piece": self.id_launch_piece,
+            "epoch_year": self.epoch_year,
+            "epoch_day": self.epoch_day,
+            "epoch": self.epoch,
+            "mean_motion_derivative": self.mean_motion_derivative,
+            "mean_motion_sec_derivative": self.mean_motion_sec_derivative,
+            "bstar": self.bstar,
+            "ephemeris_type": self.ephemeris_type,
+            "element_number": self.element_number,
+            "inclination": self.inclination,
+            "right_ascension": self.right_ascension,
+            "eccentricity": self.eccentricity,
+            "arg_perigee": self.arg_perigee,
+            "mean_anomaly": self.mean_anomaly,
+            "mean_motion": self.mean_motion,
+            "orbit": self.orbit,
+            "line1": self._line1,
+            "line2": self._line2,
+        }
+
     def __str__(self):
         """Format the class data for printing."""
         import pprint
@@ -294,36 +329,62 @@ def _get_local_tle_path_from_env():
 
 def _get_uris_and_open_func(tle_file=None):
     """Get the uri's and the adequate file open call for the TLE files."""
-    def _open(filename):
-        return io.open(filename, "rb")
-
     local_tle_path = _get_local_tle_path_from_env()
 
     if tle_file:
-        if isinstance(tle_file, io.StringIO):
-            uris = (tle_file,)
-            open_func = _dummy_open_stringio
-        elif "ADMIN_MESSAGE" in tle_file:
-            uris = (io.StringIO(read_tle_from_mmam_xml_file(tle_file)),)
-            open_func = _dummy_open_stringio
-        else:
-            uris = (tle_file,)
-            open_func = _open
+        uris, open_func = _get_tle_file_uris_and_open_method(tle_file)
     elif local_tle_path:
-        # TODO: get the TLE file closest in time to the actual satellite
-        # overpass, NOT the latest!
-        list_of_tle_files = glob.glob(local_tle_path)
+        uris, open_func = _get_local_uris_and_open_method(local_tle_path)
+    else:
+        uris, open_func = _get_internet_uris_and_open_method()
+    return uris, open_func
+
+
+def _get_tle_file_uris_and_open_method(tle_file):
+    if isinstance(tle_file, io.StringIO):
+        uris = (tle_file,)
+        open_func = _dummy_open_stringio
+    elif "ADMIN_MESSAGE" in tle_file:
+        uris = (io.StringIO(read_tle_from_mmam_xml_file(tle_file)),)
+        open_func = _dummy_open_stringio
+    else:
+        uris = (tle_file,)
+        open_func = _open
+    return uris, open_func
+
+
+def _open(filename):
+    return io.open(filename, "rb")
+
+
+def _get_local_uris_and_open_method(local_tle_path):
+    # TODO: get the TLE file closest in time to the actual satellite
+    # overpass, NOT the latest!
+    list_of_tle_files = glob.glob(local_tle_path)
+    if list_of_tle_files:
         uris = (max(list_of_tle_files, key=os.path.getctime), )
         LOGGER.debug("Reading TLE from %s", uris[0])
         open_func = _open
     else:
-        LOGGER.debug("Fetch TLE from the internet.")
-        uris = TLE_URLS
-        open_func = urlopen
+        if config.get("fetch_from_celestrak", None) is not True:
+            warn("In the future, implicit downloads of TLEs from Celestrak will be disabled by default. "
+                 "You can enable it (and remove this warning) by setting PYORBITAL_FETCH_FROM_CELESTRAK to True.",
+                 DeprecationWarning)
+        LOGGER.warning("TLES environment variable points to no TLE files")
+        throttle_warning = "TLEs will be downloaded from Celestrak, which can throttle the connection."
+        LOGGER.warning(throttle_warning)
+        warnings.warn(throttle_warning)
+
+        uris, open_func = _get_internet_uris_and_open_method()
 
     return uris, open_func
 
 
+def _get_internet_uris_and_open_method():
+    LOGGER.debug("Fetch TLE from the internet.")
+    return TLE_URLS, urlopen
+
+
 def _get_first_tle(uris, open_func, platform=""):
     return _get_tles_from_uris(uris, open_func, platform=platform, only_first=True)
 
@@ -421,8 +482,9 @@ class Downloader(object):
                     try:
                         req = requests.get(uri, timeout=15)  # 15 seconds
                     except requests.exceptions.Timeout:
-                        raise TleDownloadTimeoutError(f"Failed to make request to {str(uri)} within 15 seconds!")
-                    if req.status_code == 200:
+                        logging.error(f"Failed to make request to {str(uri)} within 15 seconds!")
+                        req = None
+                    if req and req.status_code == 200:
                         tles[source] += _parse_tles_for_downloader((req.text,), io.StringIO)
                     else:
                         failures.append(uri)
@@ -430,8 +492,7 @@ class Downloader(object):
                     logging.error(
                         "Could not fetch TLEs from %s, %d failure(s): [%s]",
                         source, len(failures), ", ".join(failures))
-                logging.info("Downloaded %d TLEs from %s",
-                             len(tles[source]), source)
+                logging.info("Downloaded %d TLEs from %s", len(tles[source]), source)
         return tles
 
     def fetch_spacetrack(self):
@@ -619,6 +680,8 @@ class SQLiteTLE(object):
 
         with open(fname, "w") as fid:
             fid.write("\n".join(data))
+            # Add a line-change after the last entry
+            fid.write("\n")
 
         logging.info("Wrote %d TLEs to %s", len(data), fname)
 


=====================================
pyproject.toml
=====================================
@@ -10,6 +10,7 @@ dependencies = ["numpy>=1.19.0",
                 "requests",
                 "defusedxml",
                 "pyproj>=3.7.1",
+                "donfig",
                 ]
 readme = "README.md"
 requires-python = ">=3.10"



View it on GitLab: https://salsa.debian.org/debian-gis-team/pyorbital/-/commit/b61b859b737a11bddcc30db6bb4b7767b71a1a88

-- 
View it on GitLab: https://salsa.debian.org/debian-gis-team/pyorbital/-/commit/b61b859b737a11bddcc30db6bb4b7767b71a1a88
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20251122/d6a0770e/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list