New Upstream Release - sgp4

Ready changes

Summary

Merged new upstream version: 2.21 (was: 2.15).

Resulting package

Built on 2022-05-21T02:15 (took 2m32s)

The resulting binary packages can be installed (if you have the apt repository enabled) by running one of:

apt install -t fresh-releases python3-sgp4

Lintian Result

Diff

diff --git a/PKG-INFO b/PKG-INFO
index d1daddb..718181d 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,7 +1,7 @@
 Metadata-Version: 1.1
 Name: sgp4
-Version: 2.15
-Summary: Track earth satellite TLE orbits using up-to-date 2020 version of SGP4
+Version: 2.21
+Summary: Track Earth satellites given TLE data, using up-to-date 2020 SGP4 routines.
 Home-page: https://github.com/brandon-rhodes/python-sgp4
 Author: Brandon Rhodes
 Author-email: brandon@rhodesmill.org
@@ -17,14 +17,17 @@ Description:
         satellites themselves deviate from the ideal orbits described in TLE
         files.
         
-        * If your platform supports it, this package compiles the verbatim
-          source code from the official C++ version of SGP4.  You can call the
-          routine directly, or through an array API that loops over arrays of
-          satellites and arrays of times with machine code instead of Python.
+        * If your platform supports it, this package compiles and uses the
+          verbatim source code from the official C++ version of SGP4.
         
         * Otherwise, a slower but reliable Python implementation of SGP4 is used
           instead.
         
+        * If, instead of asking for the position of a single satellite at a
+          single time, you supply this library with an array of satellites and
+          an array of times, then the arrays can be processed using machine code
+          instead of requiring you to run a slow Python loop over them.
+        
         Note that the SGP4 propagator returns raw *x,y,z* Cartesian coordinates
         in a “True Equator Mean Equinox” (TEME) reference frame that’s centered
         on the Earth but does not rotate with it — an “Earth centered inertial”
@@ -243,9 +246,9 @@ Description:
         so if you only have one date, be sure to provide NumPy arrays of length
         one.  Here is a sample computation for 2 satellites and 4 dates:
         
-        >>> s = '1 20580U 90037B   19342.88042116  .00000361  00000-0  11007-4 0  9996'
-        >>> t = '2 20580  28.4682 146.6676 0002639 185.9222 322.7238 15.09309432427086'
-        >>> satellite2 = Satrec.twoline2rv(s, t)
+        >>> u = '1 20580U 90037B   19342.88042116  .00000361  00000-0  11007-4 0  9996'
+        >>> w = '2 20580  28.4682 146.6676 0002639 185.9222 322.7238 15.09309432427086'
+        >>> satellite2 = Satrec.twoline2rv(u, w)
         
         >>> from sgp4.api import SatrecArray
         >>> a = SatrecArray([satellite, satellite2])
@@ -276,45 +279,6 @@ Description:
           [-3.85  6.28 -1.85]
           [-3.91  6.25 -1.83]]]
         
-        Attributes
-        ----------
-        
-        The attributes of a ``Satrec`` object carry the data loaded from the TLE
-        entry.
-        Most of this class's hundred-plus attributes are intermediate values
-        of interest only to the propagation algorithm itself.  Here are the
-        attributes set by ``sgp4.io.twoline2rv()`` in which users are likely
-        to be interested:
-        
-        ``satnum``
-            Unique satellite number given in the TLE file.
-        ``epochyr``
-            Full four-digit year of this element set's epoch moment.
-        ``epochdays``
-            Fractional days into the year of the epoch moment.
-        ``jdsatepoch``
-            Julian date of the epoch (computed from ``epochyr`` and ``epochdays``).
-        ``ndot``
-            First time derivative of the mean motion (ignored by SGP4).
-        ``nddot``
-            Second time derivative of the mean motion (ignored by SGP4).
-        ``bstar``
-            Ballistic drag coefficient B* in inverse earth radii.
-        ``inclo``
-            Inclination in radians.
-        ``nodeo``
-            Right ascension of ascending node in radians.
-        ``ecco``
-            Eccentricity.
-        ``argpo``
-            Argument of perigee in radians.
-        ``mo``
-            Mean anomaly in radians.
-        ``no_kozai``
-            Mean motion in radians per minute.
-        
-          Look at the class's documentation for details.
-        
         Export
         ------
         
@@ -328,8 +292,14 @@ Description:
         >>> line2
         '2 25544  51.6439 211.2001 0007417  17.6667  85.6398 15.50103472202482'
         
-        And another that produces the fields defined by the new OMM format (see
-        the “OMM” section above):
+        Happily, these are exactly the two TLE lines that we used to create this
+        satellite object:
+        
+        >>> (s == line1) and (t == line2)
+        True
+        
+        Another export routine is available that produces the fields defined by
+        the new OMM format (see the “OMM” section above):
         
         >>> from pprint import pprint
         >>> fields = exporter.export_omm(satellite, 'ISS (ZARYA)')
@@ -380,42 +350,200 @@ Description:
         Providing your own elements
         ---------------------------
         
-        If instead of parsing a TLE you want to provide your own orbital
-        elements, you can call the ``sgp4init()`` method of any existing
-        satellite object to reset it to those new elements.
-        
-        >>> sat = Satrec()
-        >>> sat.sgp4init(
-        ...     WGS72,           # gravity model
-        ...     'i',             # 'a' = old AFSPC mode, 'i' = improved mode
-        ...     5,               # satnum: Satellite number
-        ...     18441.785,       # epoch: days since 1949 December 31 00:00 UT
-        ...     2.8098e-05,      # bstar: drag coefficient (/earth radii)
-        ...     6.969196665e-13, # ndot: ballistic coefficient (revs/day)
-        ...     0.0,             # nddot: second derivative of mean motion (revs/day^3)
-        ...     0.1859667,       # ecco: eccentricity
-        ...     5.7904160274885, # argpo: argument of perigee (radians)
-        ...     0.5980929187319, # inclo: inclination (radians)
-        ...     0.3373093125574, # mo: mean anomaly (radians)
-        ...     0.0472294454407, # no_kozai: mean motion (radians/minute)
-        ...     6.0863854713832, # nodeo: right ascension of ascending node (radians)
+        If instead of parsing a TLE you want to specify orbital elements
+        directly, you can pass them as floating point numbers to a satellite
+        object’s ``sgp4init()`` method.  For example, here’s how to build the
+        same International Space Station orbit that we loaded from a TLE in the
+        first code example above:
+        
+        >>> satellite2 = Satrec()
+        >>> satellite2.sgp4init(
+        ...     WGS72,                # gravity model
+        ...     'i',                  # 'a' = old AFSPC mode, 'i' = improved mode
+        ...     25544,                # satnum: Satellite number
+        ...     25545.69339541,       # epoch: days since 1949 December 31 00:00 UT
+        ...     3.8792e-05,           # bstar: drag coefficient (1/earth radii)
+        ...     0.0,                  # ndot: ballistic coefficient (revs/day)
+        ...     0.0,                  # nddot: mean motion 2nd derivative (revs/day^3)
+        ...     0.0007417,            # ecco: eccentricity
+        ...     0.3083420829620822,   # argpo: argument of perigee (radians)
+        ...     0.9013560935706996,   # inclo: inclination (radians)
+        ...     1.4946964807494398,   # mo: mean anomaly (radians)
+        ...     0.06763602333248933,  # no_kozai: mean motion (radians/minute)
+        ...     3.686137125541276,    # nodeo: R.A. of ascending node (radians)
         ... )
         
-        To compute the “epoch” value, simply take a normal Julian date and
-        subtract ``2433281.5`` days.
+        These numbers don’t look the same as the numbers in the TLE, because the
+        underlying ``sgp4init()`` routine uses different units: radians rather
+        than degrees.  But this is the same orbit and will produce the same
+        positions.
+        
+        Note that ``ndot`` and ``nddot`` are ignored by the SGP4 propagator, so
+        you can leave them ``0.0`` without any effect on the resulting satellite
+        positions.  But they do at least get saved to the satellite object, and
+        written out if you write the parameters to a TLE or OMM file (see the
+        “Export” section, above).
+        
+        To compute the “epoch” argument, take the epoch’s Julian date and
+        subtract 2433281.5 days.
         
-        In addition to setting the attributes natively set by the underlying
-        ``sgp4init()`` routine, this library also goes ahead and sets the date
-        fields ``epochyr``, ``epochdays``, ``jdsatepoch``, and ``jdsatepochF``.
+        While the underlying ``sgp4init()`` routine leaves the attributes
+        ``epochyr``, ``epochdays``, ``jdsatepoch``, and ``jdsatepochF`` unset,
+        this library goes ahead and sets them anyway for you, using the epoch
+        you provided.
         
-        The character provided as the second argument can be ``'a'`` to run the
-        computations so that they are compatible with the old Air Force Space
-        Command edition of the library, or ``'i'`` to run the new and improved
-        version of the SGP4 algorithm.
+        See the next section for the complete list of attributes that are
+        available from the satellite record once it has been initialized.
+        
+        Attributes
+        ----------
         
-        You can also directly access a satellite’s orbital parameters by asking
-        for the attributes ``sat.epoch``, ``sat.bstar``, and so forth, using the
-        names given in the comments above.
+        There are several dozen ``Satrec`` attributes that expose data from the
+        underlying C++ SGP4 record.  They fall into the following categories.
+        
+        *Identification*
+        
+        These are copied directly from the TLE record but aren’t used by the
+        propagation math.
+        
+        | ``satnum`` — Unique number assigned to the satellite.
+        | ``classification`` — ``'U'``, ``'C'``, or ``'S'``
+          indicating the element set is Unclassified, Classified, or Secret.
+        | ``ephtype`` — Integer “ephemeris type”, used internally by space
+          agencies to mark element sets that are not ready for publication;
+          this field should always be ``0`` in published TLEs.
+        | ``elnum`` — Element set number.
+        | ``revnum`` — Satellite’s revolution number at the moment of the epoch,
+          presumably counting from 1 following launch.
+        
+        *Orbital Elements*
+        
+        These are the orbital parameters, copied verbatim from the text of the
+        TLE record.  They describe the orbit at the moment of the TLE’s epoch
+        and so remain constant even as the satellite record is used over and
+        over again to propagate positions for different times.
+        
+        | ``epochyr`` — Epoch date: the last two digits of the year.
+        | ``epochdays`` — Epoch date: the number of days into the year,
+          including a decimal fraction for the UTC time of day.
+        | ``ndot`` — First time derivative of the mean motion
+          (loaded from the TLE, but otherwise ignored).
+        | ``nddot`` — Second time derivative of the mean motion
+          (loaded from the TLE, but otherwise ignored).
+        | ``bstar`` — Ballistic drag coefficient B* (1/earth radii).
+        | ``inclo`` — Inclination (radians).
+        | ``nodeo`` — Right ascension of ascending node (radians).
+        | ``ecco`` — Eccentricity.
+        | ``argpo`` — Argument of perigee (radians).
+        | ``mo`` — Mean anomaly (radians).
+        | ``no_kozai`` — Mean motion (radians/minute).
+        | ``no`` — Alias for ``no_kozai``, for compatibility with old code.
+        
+        You can also access the epoch as a Julian date:
+        
+        | ``jdsatepoch`` — Whole part of the epoch’s Julian date.
+        | ``jdsatepochF`` — Fractional part of the epoch’s Julian date.
+        
+        *Computed Orbit Properties*
+        
+        These are computed when the satellite is first loaded,
+        as a convenience for callers who might be interested in them.
+        They aren’t used by the SGP4 propagator itself.
+        
+        | ``a`` — Semi-major axis (earth radii).
+        | ``altp`` — Altitude of the satellite at perigee
+          (earth radii, assuming a spherical Earth).
+        | ``alta`` — Altitude of the satellite at apogee
+          (earth radii, assuming a spherical Earth).
+        | ``argpdot`` — Rate at which the argument of perigee is changing
+          (radians/minute).
+        | ``gsto`` — Greenwich Sidereal Time at the satellite’s epoch (radians).
+        | ``mdot`` — Rate at which the mean anomaly is changing (radians/minute)
+        | ``nodedot`` — Rate at which the right ascension of the ascending node
+          is changing (radians/minute).
+        
+        *Propagator Mode*
+        
+        | ``operationmode`` — A single character that directs SGP4
+          to either operate in its modern ``'i'`` improved mode
+          or in its legacy ``'a'`` AFSPC mode.
+        | ``method`` — A single character, chosen automatically
+          when the orbital elements were loaded, that indicates whether SGP4
+          has chosen to use its built-in ``'n'`` Near Earth
+          or ``'d'`` Deep Space mode for this satellite.
+        
+        *Result of Most Recent Propagation*
+        
+        | ``t`` —
+          The time you gave when you most recently asked SGP4
+          to compute this satellite’s position,
+          measured in minutes before (negative) or after (positive)
+          the satellite’s epoch.
+        | ``error`` —
+          Error code produced by the most recent SGP4 propagation
+          you performed with this element set.
+        
+        The possible ``error`` codes are:
+        
+        0. No error.
+        1. Mean eccentricity is outside the range 0 ≤ e < 1.
+        2. Mean motion has fallen below zero.
+        3. Perturbed eccentricity is outside the range 0 ≤ e ≤ 1.
+        4. Length of the orbit’s semi-latus rectum has fallen below zero.
+        5. (No longer used.)
+        6. Orbit has decayed: the computed position is underground.
+           (The position is still returned, in case the vector is helpful
+           to software that might be searching for the moment of re-entry.)
+        
+        *Mean Elements From Most Recent Propagation*
+        
+        Partway through each propagation, the SGP4 routine saves a set of
+        “singly averaged mean elements” that describe the orbit’s shape at the
+        moment for which a position is being computed.  They are averaged with
+        respect to the mean anomaly and include the effects of secular gravity,
+        atmospheric drag, and — in Deep Space mode — of those pertubations from
+        the Sun and Moon that SGP4 averages over an entire revolution of each of
+        those bodies.  They omit both the shorter-term and longer-term periodic
+        pertubations from the Sun and Moon that SGP4 applies right before
+        computing each position.
+        
+        | ``am`` — Average semi-major axis (earth radii).
+        | ``em`` — Average eccentricity.
+        | ``im`` — Average inclination (radians).
+        | ``Om`` — Average right ascension of ascending node (radians).
+        | ``om`` — Average argument of perigee (radians).
+        | ``mm`` — Average mean anomaly (radians).
+        | ``nm`` — Average mean motion (radians/minute).
+        
+        *Gravity Model Parameters*
+        
+        When the satellite record is initialized, your choice of gravity model
+        results in a slate of eight constants being copied in:
+        
+        | ``tumin`` — Minutes in one “time unit”.
+        | ``xke`` — The reciprocal of ``tumin``.
+        | ``mu`` — Earth’s gravitational parameter (km³/s²).
+        | ``radiusearthkm`` — Radius of the earth (km).
+        | ``j2``, ``j3``, ``j4`` — Un-normalized zonal harmonic values J₂, J₃, and J₄.
+        | ``j3oj2`` — The ratio J₃/J₂.
+        
+        Printing satellite attributes
+        -----------------------------
+        
+        If you want to print out a satellite, this library provides a convenient
+        “attribute dump” routine that takes a satellite and generates lines that
+        list its attributes::
+        
+            from sys import stdout
+            from sgp4.conveniences import dump_satrec
+        
+            stdout.writelines(dump_satrec(satellite))
+        
+        If you want to compare two satellites, then simply pass a second
+        argument; the second satellite’s attributes will be printed in a second
+        column next to those of the first. ::
+        
+            stdout.writelines(dump_satrec(satellite, satellite2))
         
         Validation against the official algorithm
         -----------------------------------------
@@ -463,6 +591,53 @@ Description:
         Changelog
         ---------
         
+        2022-04-06 — 2.21
+        
+        * Added ``dump_satrec()`` to the ``sgp4.conveniences`` module.
+        
+        * Fixed the ``Satrec`` attribute ``.error``, which was previously
+          building a nonsense integer from the wrong data in memory.
+        
+        * Removed ``.whichconst`` from Python ``Satrec``, to help users avoid
+          writing code that will break when the C++ extension is available.
+        
+        2021-07-01 — 2.20
+        
+        * Taught ``sgp4init()`` to round both ``epochdays`` and ``jdsatepochF``
+          to the same 8 decimal places used for the date fraction in a TLE, if
+          the user-supplied ``epoch`` itself has 8 or fewer digits behind the
+          decimal point.  This should make it easier to build satellites that
+          round-trip to TLE format with perfect accuracy.
+        
+        * Fixed how ``export_tle()`` formats the BSTAR field when its value, if
+          written in scientific notation, has a positive exponent.
+        
+        * Fixed the ``epochyr`` assigned by ``sgp4init()`` so years before 2000
+          have two digits instead of three (for example, so that 1980 produces
+          an ``epochyr`` of 80 instead of 980).
+        
+        2021-04-22 — 2.19
+        
+        * Extended the documentation on the Python Package Index and in the
+          module docstring so it lists every ``Satrec`` attribute that this
+          library exposes; even the more obscure ones might be useful to folks
+          working to analyze satellite orbits.
+        
+        2021-03-08 — 2.18
+        
+        * If a TLE satellite number lacks the required 5 digits,
+          ``twoline2rv()`` now gives the underlying C++ library a little help so
+          it can still parse the classification and international designator
+          correctly.
+        
+        * The ``Satrec`` attributes ``jdsatepoch``, ``jdsatepochF``,
+          ``epochyr``, and ``epochdays`` are now writeable, so users can adjust
+          their values manually — which should make up for the fact that the
+          ``sgp4init()`` method can’t set them with full floating point
+          precision.
+        
+        | 2021-02-17 — 2.17 — Fixed where in the output array the ``sgp4_array()`` method writes NaN values when an SGP4 propagation fails.
+        | 2021-02-12 — 2.16 — Fixed ``days2mdhms()`` rounding to always match TLE epoch.
         | 2021-01-08 — 2.15 — Fixed parsing of the ``satnum`` TLE field in the Python fallback code, when the field has a leading space; added OMM export routine.
         | 2020-12-16 — 2.14 — New data formats: added OMM message support for both XML and CSV, and added support for the new Alpha-5 extension to TLE files.
         | 2020-10-14 — 2.13 — Enhanced ``sgp4init()`` with custom code that also sets the ``epochdays`` and ``epochyr`` satellite attributes.
diff --git a/debian/changelog b/debian/changelog
index c358e89..8ab5337 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+sgp4 (2.21-1) UNRELEASED; urgency=low
+
+  * New upstream release.
+
+ -- Debian Janitor <janitor@jelmer.uk>  Sat, 21 May 2022 02:12:48 -0000
+
 sgp4 (2.15-0.1) unstable; urgency=medium
 
   * Non-maintainer upload.
diff --git a/extension/wrapper.cpp b/extension/wrapper.cpp
index be7327d..191be7b 100644
--- a/extension/wrapper.cpp
+++ b/extension/wrapper.cpp
@@ -74,12 +74,13 @@ _vectorized_sgp4(PyObject *args, elsetrec *raw_satrec_array, int imax)
             for (Py_ssize_t j=0; j < jmax; j++) {
                 double t = (jd[j] - satrec.jdsatepoch) * 1440.0
                          + (fr[j] - satrec.jdsatepochF) * 1440.0;
-                Py_ssize_t k = i * jmax + j;
-                SGP4Funcs::sgp4(satrec, t, r + k*3, v + k*3);
+                Py_ssize_t k1 = i * jmax + j;
+                Py_ssize_t k3 = 3 * k1;
+                SGP4Funcs::sgp4(satrec, t, r + k3, v + k3);
+                e[k1] = (uint8_t) satrec.error;
                 if (satrec.error && satrec.error < 6) {
-                    r[k] = r[k+1] = r[k+2] = v[k] = v[k+1] = v[k+2] = NAN;
+                    r[k3] = r[k3+1] = r[k3+2] = v[k3] = v[k3+1] = v[k3+2] = NAN;
                 }
-                e[k] = (uint8_t) satrec.error;
             }
         }
     }
@@ -119,6 +120,11 @@ Satrec_twoline2rv(PyTypeObject *cls, PyObject *args)
     line1[68] = '\0';
     line2[68] = '\0';
 
+    /* Allocate the new object. */
+    SatrecObject *self = (SatrecObject*) cls->tp_alloc(cls, 0);
+    if (!self)
+        return NULL;
+
     /* Correct for locales that use a comma as the decimal point, since
        users report that the scanf() function on macOS is sensitive to
        locale when parsing floats.  This operation is not thread-safe,
@@ -131,10 +137,18 @@ Satrec_twoline2rv(PyTypeObject *cls, PyObject *args)
     if (switch_locale)
         old_locale = setlocale(LC_NUMERIC, "C");
 
-    SatrecObject *self = (SatrecObject*) cls->tp_alloc(cls, 0);
-    if (!self)
-        return NULL;
+    /* Leading spaces in a catalog number make scanf() in the official
+       code consume the Classification letter as part of the catalog
+       number.  (The first character of the International Designator
+       then gets consumed as the Classification instead.)  But no
+       parsing error is reported, which is bad for users, so let's avoid
+       the situation by adding leading zeros ourselves. */
+    for (int i=2; i<7; i++) {
+        if (line1[i] == ' ') line1[i] = '0';
+        if (line2[i] == ' ') line2[i] = '0';
+    }
 
+    /* Call the official routine. */
     SGP4Funcs::twoline2rv(line1, line2, ' ', ' ', 'i', whichconst,
                           dummy, dummy, dummy, self->satrec);
 
@@ -192,14 +206,27 @@ Satrec_sgp4init(PyObject *self, PyObject *args)
                         nodeo, satrec);
 
     /* Populate date fields that SGP4Funcs::twoline2rv would set. */
+    double whole;
+    double fraction = modf(epoch, &whole);
+    double whole_jd = whole + 2433281.5;
+
+    /* Go out on a limb: if `epoch` has no decimal digits past the 8
+       decimal places stored in a TLE, then assume the user is trying
+       to specify an exact decimal fraction. */
+    double epoch8 = epoch * 1e8;
+    if (round(epoch8) == epoch8) {
+        fraction = round(fraction * 1e8) / 1e8;
+    }
+
+    satrec.jdsatepoch = whole_jd;
+    satrec.jdsatepochF = fraction;
+
     int y, m, d, H, M;
     double S, jan0jd, jan0fr /* always comes out 0.0 */;
-    SGP4Funcs::invjday_SGP4(2433281.5, epoch, y, m, d, H, M, S);
+    SGP4Funcs::invjday_SGP4(2433281.5, whole, y, m, d, H, M, S);
     SGP4Funcs::jday_SGP4(y, 1, 0, 0, 0, 0.0, jan0jd, jan0fr);
-    satrec.epochyr = y % 1000;
-    satrec.epochdays = 2433281.5 - jan0jd + epoch;
-    satrec.jdsatepochF = modf(epoch, &satrec.jdsatepoch);
-    satrec.jdsatepoch += 2433281.5;
+    satrec.epochyr = y % 100;
+    satrec.epochdays = whole_jd - jan0jd + fraction;
 
     /* Return true as sgp4init does, satrec.error contains any error codes */
 
@@ -268,16 +295,16 @@ static PyMemberDef Satrec_members[] = {
 
     {"operationmode", T_CHAR, O(operationmode), READONLY,
      PyDoc_STR("Operation mode: 'a' legacy AFSPC, or 'i' improved.")},
-    {"jdsatepoch", T_DOUBLE, O(jdsatepoch), READONLY,
+    {"jdsatepoch", T_DOUBLE, O(jdsatepoch), 0,
      PyDoc_STR("Julian date of epoch, day number (see jdsatepochF).")},
-    {"jdsatepochF", T_DOUBLE, O(jdsatepochF), READONLY,
+    {"jdsatepochF", T_DOUBLE, O(jdsatepochF), 0,
      PyDoc_STR("Julian date of epoch, fraction of day (see jdsatepoch).")},
     {"classification", T_CHAR, O(classification), 0,
      "Usually U=Unclassified, C=Classified, or S=Secret."},
     /* intldesg: inline character array; see Satrec_getset. */
-    {"epochyr", T_INT, O(epochyr), READONLY,
+    {"epochyr", T_INT, O(epochyr), 0,
      PyDoc_STR("Year of this element set's epoch (see epochdays). Not set by sgp4init().")},
-    {"epochdays", T_DOUBLE, O(epochdays), READONLY,
+    {"epochdays", T_DOUBLE, O(epochdays), 0,
      PyDoc_STR("Day of the year of this element set's epoch (see epochyr). Not set by sgp4init().")},
     {"ndot", T_DOUBLE, O(ndot), READONLY,
      PyDoc_STR("Ballistic Coefficient in revs/day.")},
@@ -314,7 +341,7 @@ static PyMemberDef Satrec_members[] = {
 
     {"method", T_CHAR, O(method), READONLY,
      PyDoc_STR("Method, either 'n' near earth or 'd' deep space.")},
-    {"error", T_INT, O(method), READONLY,
+    {"error", T_INT, O(error), READONLY,
      PyDoc_STR("Error code (1-6) documented in sgp4()")},
     {"a", T_DOUBLE, O(a), READONLY,
      PyDoc_STR("semi-major axis")},
diff --git a/setup.py b/setup.py
index 3a573e3..9e9f82a 100644
--- a/setup.py
+++ b/setup.py
@@ -1,13 +1,9 @@
 import os
 import sys
 from distutils.core import setup, Extension
-from textwrap import dedent
-
-import sgp4, sgp4.model
 
+import sgp4
 description, long_description = sgp4.__doc__.split('\n', 1)
-satdoc = dedent(sgp4.model.Satellite.__doc__.split('\n', 1)[1])
-long_description = long_description.replace('entry.', 'entry.' + satdoc)
 
 # Force compilation on Travis CI + Python 3 to make sure it keeps working.
 optional = True
@@ -31,10 +27,19 @@ if sys.version_info[0] == 3:
         # multiple processors when available?
         # extra_compile_args=['-fopenmp'],
         # extra_link_args=['-fopenmp'],
+        extra_compile_args=['-ffloat-store'],
     ))
 
+# Read the package's "__version__" without importing it.
+path = 'sgp4/__init__.py'
+with open(path, 'rb') as f:
+    text = f.read().decode('utf-8')
+text = text.replace('-*- coding: utf-8 -*-', '')  # for Python 2.7
+namespace = {}
+eval(compile(text, path, 'exec'), namespace)
+
 setup(name = 'sgp4',
-      version = '2.15',
+      version = namespace['__version__'],
       description = description,
       long_description = long_description,
       license = 'MIT',
diff --git a/sgp4/__init__.py b/sgp4/__init__.py
index 33a71fc..95207de 100644
--- a/sgp4/__init__.py
+++ b/sgp4/__init__.py
@@ -1,5 +1,5 @@
 # -*- coding: utf-8 -*-
-"""Track earth satellite TLE orbits using up-to-date 2020 version of SGP4
+"""Track Earth satellites given TLE data, using up-to-date 2020 SGP4 routines.
 
 This Python package computes the position and velocity of an
 earth-orbiting satellite, given the satellite's TLE orbital elements
@@ -11,14 +11,17 @@ the algorithm.  This error is far less than the 1–3 km/day by which
 satellites themselves deviate from the ideal orbits described in TLE
 files.
 
-* If your platform supports it, this package compiles the verbatim
-  source code from the official C++ version of SGP4.  You can call the
-  routine directly, or through an array API that loops over arrays of
-  satellites and arrays of times with machine code instead of Python.
+* If your platform supports it, this package compiles and uses the
+  verbatim source code from the official C++ version of SGP4.
 
 * Otherwise, a slower but reliable Python implementation of SGP4 is used
   instead.
 
+* If, instead of asking for the position of a single satellite at a
+  single time, you supply this library with an array of satellites and
+  an array of times, then the arrays can be processed using machine code
+  instead of requiring you to run a slow Python loop over them.
+
 Note that the SGP4 propagator returns raw *x,y,z* Cartesian coordinates
 in a “True Equator Mean Equinox” (TEME) reference frame that’s centered
 on the Earth but does not rotate with it — an “Earth centered inertial”
@@ -237,9 +240,9 @@ dates, build a ``SatrecArray`` from several individual satellites.  Its
 so if you only have one date, be sure to provide NumPy arrays of length
 one.  Here is a sample computation for 2 satellites and 4 dates:
 
->>> s = '1 20580U 90037B   19342.88042116  .00000361  00000-0  11007-4 0  9996'
->>> t = '2 20580  28.4682 146.6676 0002639 185.9222 322.7238 15.09309432427086'
->>> satellite2 = Satrec.twoline2rv(s, t)
+>>> u = '1 20580U 90037B   19342.88042116  .00000361  00000-0  11007-4 0  9996'
+>>> w = '2 20580  28.4682 146.6676 0002639 185.9222 322.7238 15.09309432427086'
+>>> satellite2 = Satrec.twoline2rv(u, w)
 
 >>> from sgp4.api import SatrecArray
 >>> a = SatrecArray([satellite, satellite2])
@@ -270,12 +273,6 @@ one.  Here is a sample computation for 2 satellites and 4 dates:
   [-3.85  6.28 -1.85]
   [-3.91  6.25 -1.83]]]
 
-Attributes
-----------
-
-The attributes of a ``Satrec`` object carry the data loaded from the TLE
-entry.  Look at the class's documentation for details.
-
 Export
 ------
 
@@ -289,8 +286,14 @@ file, there’s an export routine that will turn it back into a TLE:
 >>> line2
 '2 25544  51.6439 211.2001 0007417  17.6667  85.6398 15.50103472202482'
 
-And another that produces the fields defined by the new OMM format (see
-the “OMM” section above):
+Happily, these are exactly the two TLE lines that we used to create this
+satellite object:
+
+>>> (s == line1) and (t == line2)
+True
+
+Another export routine is available that produces the fields defined by
+the new OMM format (see the “OMM” section above):
 
 >>> from pprint import pprint
 >>> fields = exporter.export_omm(satellite, 'ISS (ZARYA)')
@@ -341,42 +344,200 @@ constants as were used to generate the TLE.
 Providing your own elements
 ---------------------------
 
-If instead of parsing a TLE you want to provide your own orbital
-elements, you can call the ``sgp4init()`` method of any existing
-satellite object to reset it to those new elements.
-
->>> sat = Satrec()
->>> sat.sgp4init(
-...     WGS72,           # gravity model
-...     'i',             # 'a' = old AFSPC mode, 'i' = improved mode
-...     5,               # satnum: Satellite number
-...     18441.785,       # epoch: days since 1949 December 31 00:00 UT
-...     2.8098e-05,      # bstar: drag coefficient (/earth radii)
-...     6.969196665e-13, # ndot: ballistic coefficient (revs/day)
-...     0.0,             # nddot: second derivative of mean motion (revs/day^3)
-...     0.1859667,       # ecco: eccentricity
-...     5.7904160274885, # argpo: argument of perigee (radians)
-...     0.5980929187319, # inclo: inclination (radians)
-...     0.3373093125574, # mo: mean anomaly (radians)
-...     0.0472294454407, # no_kozai: mean motion (radians/minute)
-...     6.0863854713832, # nodeo: right ascension of ascending node (radians)
+If instead of parsing a TLE you want to specify orbital elements
+directly, you can pass them as floating point numbers to a satellite
+object’s ``sgp4init()`` method.  For example, here’s how to build the
+same International Space Station orbit that we loaded from a TLE in the
+first code example above:
+
+>>> satellite2 = Satrec()
+>>> satellite2.sgp4init(
+...     WGS72,                # gravity model
+...     'i',                  # 'a' = old AFSPC mode, 'i' = improved mode
+...     25544,                # satnum: Satellite number
+...     25545.69339541,       # epoch: days since 1949 December 31 00:00 UT
+...     3.8792e-05,           # bstar: drag coefficient (1/earth radii)
+...     0.0,                  # ndot: ballistic coefficient (revs/day)
+...     0.0,                  # nddot: mean motion 2nd derivative (revs/day^3)
+...     0.0007417,            # ecco: eccentricity
+...     0.3083420829620822,   # argpo: argument of perigee (radians)
+...     0.9013560935706996,   # inclo: inclination (radians)
+...     1.4946964807494398,   # mo: mean anomaly (radians)
+...     0.06763602333248933,  # no_kozai: mean motion (radians/minute)
+...     3.686137125541276,    # nodeo: R.A. of ascending node (radians)
 ... )
 
-To compute the “epoch” value, simply take a normal Julian date and
-subtract ``2433281.5`` days.
+These numbers don’t look the same as the numbers in the TLE, because the
+underlying ``sgp4init()`` routine uses different units: radians rather
+than degrees.  But this is the same orbit and will produce the same
+positions.
+
+Note that ``ndot`` and ``nddot`` are ignored by the SGP4 propagator, so
+you can leave them ``0.0`` without any effect on the resulting satellite
+positions.  But they do at least get saved to the satellite object, and
+written out if you write the parameters to a TLE or OMM file (see the
+“Export” section, above).
+
+To compute the “epoch” argument, take the epoch’s Julian date and
+subtract 2433281.5 days.
 
-In addition to setting the attributes natively set by the underlying
-``sgp4init()`` routine, this library also goes ahead and sets the date
-fields ``epochyr``, ``epochdays``, ``jdsatepoch``, and ``jdsatepochF``.
+While the underlying ``sgp4init()`` routine leaves the attributes
+``epochyr``, ``epochdays``, ``jdsatepoch``, and ``jdsatepochF`` unset,
+this library goes ahead and sets them anyway for you, using the epoch
+you provided.
 
-The character provided as the second argument can be ``'a'`` to run the
-computations so that they are compatible with the old Air Force Space
-Command edition of the library, or ``'i'`` to run the new and improved
-version of the SGP4 algorithm.
+See the next section for the complete list of attributes that are
+available from the satellite record once it has been initialized.
+
+Attributes
+----------
 
-You can also directly access a satellite’s orbital parameters by asking
-for the attributes ``sat.epoch``, ``sat.bstar``, and so forth, using the
-names given in the comments above.
+There are several dozen ``Satrec`` attributes that expose data from the
+underlying C++ SGP4 record.  They fall into the following categories.
+
+*Identification*
+
+These are copied directly from the TLE record but aren’t used by the
+propagation math.
+
+| ``satnum`` — Unique number assigned to the satellite.
+| ``classification`` — ``'U'``, ``'C'``, or ``'S'``
+  indicating the element set is Unclassified, Classified, or Secret.
+| ``ephtype`` — Integer “ephemeris type”, used internally by space
+  agencies to mark element sets that are not ready for publication;
+  this field should always be ``0`` in published TLEs.
+| ``elnum`` — Element set number.
+| ``revnum`` — Satellite’s revolution number at the moment of the epoch,
+  presumably counting from 1 following launch.
+
+*Orbital Elements*
+
+These are the orbital parameters, copied verbatim from the text of the
+TLE record.  They describe the orbit at the moment of the TLE’s epoch
+and so remain constant even as the satellite record is used over and
+over again to propagate positions for different times.
+
+| ``epochyr`` — Epoch date: the last two digits of the year.
+| ``epochdays`` — Epoch date: the number of days into the year,
+  including a decimal fraction for the UTC time of day.
+| ``ndot`` — First time derivative of the mean motion
+  (loaded from the TLE, but otherwise ignored).
+| ``nddot`` — Second time derivative of the mean motion
+  (loaded from the TLE, but otherwise ignored).
+| ``bstar`` — Ballistic drag coefficient B* (1/earth radii).
+| ``inclo`` — Inclination (radians).
+| ``nodeo`` — Right ascension of ascending node (radians).
+| ``ecco`` — Eccentricity.
+| ``argpo`` — Argument of perigee (radians).
+| ``mo`` — Mean anomaly (radians).
+| ``no_kozai`` — Mean motion (radians/minute).
+| ``no`` — Alias for ``no_kozai``, for compatibility with old code.
+
+You can also access the epoch as a Julian date:
+
+| ``jdsatepoch`` — Whole part of the epoch’s Julian date.
+| ``jdsatepochF`` — Fractional part of the epoch’s Julian date.
+
+*Computed Orbit Properties*
+
+These are computed when the satellite is first loaded,
+as a convenience for callers who might be interested in them.
+They aren’t used by the SGP4 propagator itself.
+
+| ``a`` — Semi-major axis (earth radii).
+| ``altp`` — Altitude of the satellite at perigee
+  (earth radii, assuming a spherical Earth).
+| ``alta`` — Altitude of the satellite at apogee
+  (earth radii, assuming a spherical Earth).
+| ``argpdot`` — Rate at which the argument of perigee is changing
+  (radians/minute).
+| ``gsto`` — Greenwich Sidereal Time at the satellite’s epoch (radians).
+| ``mdot`` — Rate at which the mean anomaly is changing (radians/minute)
+| ``nodedot`` — Rate at which the right ascension of the ascending node
+  is changing (radians/minute).
+
+*Propagator Mode*
+
+| ``operationmode`` — A single character that directs SGP4
+  to either operate in its modern ``'i'`` improved mode
+  or in its legacy ``'a'`` AFSPC mode.
+| ``method`` — A single character, chosen automatically
+  when the orbital elements were loaded, that indicates whether SGP4
+  has chosen to use its built-in ``'n'`` Near Earth
+  or ``'d'`` Deep Space mode for this satellite.
+
+*Result of Most Recent Propagation*
+
+| ``t`` —
+  The time you gave when you most recently asked SGP4
+  to compute this satellite’s position,
+  measured in minutes before (negative) or after (positive)
+  the satellite’s epoch.
+| ``error`` —
+  Error code produced by the most recent SGP4 propagation
+  you performed with this element set.
+
+The possible ``error`` codes are:
+
+0. No error.
+1. Mean eccentricity is outside the range 0 ≤ e < 1.
+2. Mean motion has fallen below zero.
+3. Perturbed eccentricity is outside the range 0 ≤ e ≤ 1.
+4. Length of the orbit’s semi-latus rectum has fallen below zero.
+5. (No longer used.)
+6. Orbit has decayed: the computed position is underground.
+   (The position is still returned, in case the vector is helpful
+   to software that might be searching for the moment of re-entry.)
+
+*Mean Elements From Most Recent Propagation*
+
+Partway through each propagation, the SGP4 routine saves a set of
+“singly averaged mean elements” that describe the orbit’s shape at the
+moment for which a position is being computed.  They are averaged with
+respect to the mean anomaly and include the effects of secular gravity,
+atmospheric drag, and — in Deep Space mode — of those pertubations from
+the Sun and Moon that SGP4 averages over an entire revolution of each of
+those bodies.  They omit both the shorter-term and longer-term periodic
+pertubations from the Sun and Moon that SGP4 applies right before
+computing each position.
+
+| ``am`` — Average semi-major axis (earth radii).
+| ``em`` — Average eccentricity.
+| ``im`` — Average inclination (radians).
+| ``Om`` — Average right ascension of ascending node (radians).
+| ``om`` — Average argument of perigee (radians).
+| ``mm`` — Average mean anomaly (radians).
+| ``nm`` — Average mean motion (radians/minute).
+
+*Gravity Model Parameters*
+
+When the satellite record is initialized, your choice of gravity model
+results in a slate of eight constants being copied in:
+
+| ``tumin`` — Minutes in one “time unit”.
+| ``xke`` — The reciprocal of ``tumin``.
+| ``mu`` — Earth’s gravitational parameter (km³/s²).
+| ``radiusearthkm`` — Radius of the earth (km).
+| ``j2``, ``j3``, ``j4`` — Un-normalized zonal harmonic values J₂, J₃, and J₄.
+| ``j3oj2`` — The ratio J₃/J₂.
+
+Printing satellite attributes
+-----------------------------
+
+If you want to print out a satellite, this library provides a convenient
+“attribute dump” routine that takes a satellite and generates lines that
+list its attributes::
+
+    from sys import stdout
+    from sgp4.conveniences import dump_satrec
+
+    stdout.writelines(dump_satrec(satellite))
+
+If you want to compare two satellites, then simply pass a second
+argument; the second satellite’s attributes will be printed in a second
+column next to those of the first. ::
+
+    stdout.writelines(dump_satrec(satellite, satellite2))
 
 Validation against the official algorithm
 -----------------------------------------
@@ -424,6 +585,53 @@ https://pypi.org/project/sgp4/1.4/
 Changelog
 ---------
 
+2022-04-06 — 2.21
+
+* Added ``dump_satrec()`` to the ``sgp4.conveniences`` module.
+
+* Fixed the ``Satrec`` attribute ``.error``, which was previously
+  building a nonsense integer from the wrong data in memory.
+
+* Removed ``.whichconst`` from Python ``Satrec``, to help users avoid
+  writing code that will break when the C++ extension is available.
+
+2021-07-01 — 2.20
+
+* Taught ``sgp4init()`` to round both ``epochdays`` and ``jdsatepochF``
+  to the same 8 decimal places used for the date fraction in a TLE, if
+  the user-supplied ``epoch`` itself has 8 or fewer digits behind the
+  decimal point.  This should make it easier to build satellites that
+  round-trip to TLE format with perfect accuracy.
+
+* Fixed how ``export_tle()`` formats the BSTAR field when its value, if
+  written in scientific notation, has a positive exponent.
+
+* Fixed the ``epochyr`` assigned by ``sgp4init()`` so years before 2000
+  have two digits instead of three (for example, so that 1980 produces
+  an ``epochyr`` of 80 instead of 980).
+
+2021-04-22 — 2.19
+
+* Extended the documentation on the Python Package Index and in the
+  module docstring so it lists every ``Satrec`` attribute that this
+  library exposes; even the more obscure ones might be useful to folks
+  working to analyze satellite orbits.
+
+2021-03-08 — 2.18
+
+* If a TLE satellite number lacks the required 5 digits,
+  ``twoline2rv()`` now gives the underlying C++ library a little help so
+  it can still parse the classification and international designator
+  correctly.
+
+* The ``Satrec`` attributes ``jdsatepoch``, ``jdsatepochF``,
+  ``epochyr``, and ``epochdays`` are now writeable, so users can adjust
+  their values manually — which should make up for the fact that the
+  ``sgp4init()`` method can’t set them with full floating point
+  precision.
+
+| 2021-02-17 — 2.17 — Fixed where in the output array the ``sgp4_array()`` method writes NaN values when an SGP4 propagation fails.
+| 2021-02-12 — 2.16 — Fixed ``days2mdhms()`` rounding to always match TLE epoch.
 | 2021-01-08 — 2.15 — Fixed parsing of the ``satnum`` TLE field in the Python fallback code, when the field has a leading space; added OMM export routine.
 | 2020-12-16 — 2.14 — New data formats: added OMM message support for both XML and CSV, and added support for the new Alpha-5 extension to TLE files.
 | 2020-10-14 — 2.13 — Enhanced ``sgp4init()`` with custom code that also sets the ``epochdays`` and ``epochyr`` satellite attributes.
@@ -447,3 +655,4 @@ Changelog
 | 2012-08-27 — 1.0 — Initial release
 
 """
+__version__ = '2.21'
diff --git a/sgp4/api.py b/sgp4/api.py
index af76ec5..5e83a79 100644
--- a/sgp4/api.py
+++ b/sgp4/api.py
@@ -1,7 +1,9 @@
 """Public API that tries to import C++ module, but falls back to Python."""
 
-__all__ = ('Satrec', 'SatrecArray', 'WGS72OLD', 'WGS72', 'WGS84',
-           'jday', 'days2mdhms')
+__all__ = (
+    'SGP4_ERRORS', 'Satrec', 'SatrecArray', 'WGS72OLD', 'WGS72', 'WGS84',
+    'accelerated', 'jday', 'days2mdhms',
+)
 
 from .functions import jday, days2mdhms
 
diff --git a/sgp4/conveniences.py b/sgp4/conveniences.py
index 69c9d9b..84e1e0e 100644
--- a/sgp4/conveniences.py
+++ b/sgp4/conveniences.py
@@ -6,6 +6,7 @@ itself, native Python datetime handling could be convenient.
 
 """
 import datetime as dt
+import sgp4
 from .functions import days2mdhms, jday
 
 class _UTC(dt.tzinfo):
@@ -71,3 +72,38 @@ def sat_epoch_datetime(sat):
     second = int(second)
     micro = int(fraction * 1e6)
     return dt.datetime(year, month, day, hour, minute, second, micro, UTC)
+
+_ATTRIBUTES = None
+
+def dump_satrec(sat, sat2=None):
+    """Yield lines that list the attributes of one or two satellites."""
+
+    global _ATTRIBUTES
+    if _ATTRIBUTES is None:
+        _ATTRIBUTES = []
+        for line in sgp4.__doc__.splitlines():
+            if line.endswith('*'):
+                title = line.strip('*')
+                _ATTRIBUTES.append(title)
+            elif line.startswith('| ``'):
+                pieces = line.split('``')
+                _ATTRIBUTES.append(pieces[1])
+                i = 2
+                while pieces[i] == ', ':
+                    _ATTRIBUTES.append(pieces[i+1])
+                    i += 2
+
+    for item in _ATTRIBUTES:
+        if item[0].isupper():
+            title = item
+            yield '\n'
+            yield '# -------- {0} --------\n'.format(title)
+        else:
+            name = item
+            value = getattr(sat, item, '(not set)')
+            line = '{0} = {1!r}\n'.format(item, value)
+            if sat2 is not None:
+                value2 = getattr(sat2, name, '(not set)')
+                verdict = '==' if (value == value2) else '!='
+                line = '{0:39} {1} {2!r}\n'.format(line[:-1], verdict, value2)
+            yield line
diff --git a/sgp4/exporter.py b/sgp4/exporter.py
index 00ebed1..dd9b84e 100644
--- a/sgp4/exporter.py
+++ b/sgp4/exporter.py
@@ -42,14 +42,11 @@ def export_tle(satrec):
     # Add First Time Derivative of the Mean Motion (don't use "+")
     append("{0: 8.8f}".format(satrec.ndot * (_xpdotp * 1440.0)).replace("0", "", 1) + " ")
 
-    # Add Second Time Derivative of Mean Motion (don't use "+")
-    # Multiplication with 10 is a hack to get the exponent right
-    append("{0: 4.4e}".format((satrec.nddot * (_xpdotp * 1440.0 * 1440)) * 10).replace(".", "")
-                        .replace("e+00", "-0").replace("e-0", "-") + " ")
+    # Add Second Time Derivative of Mean Motion
+    append(_abbreviate_rate(satrec.nddot * _xpdotp * 20736000.0, '-0'))
 
     # Add BSTAR
-    # Multiplication with 10 is a hack to get the exponent right
-    append("{0: 4.4e}".format(satrec.bstar * 10).replace(".", "").replace("e+00", "+0").replace("e-0", "-") + " ")
+    append(_abbreviate_rate(satrec.bstar * 10.0, '+0'))
 
     # Add Ephemeris Type and Element Number
     ephtype = getattr(satrec, 'ephtype', 0)
@@ -105,6 +102,15 @@ def export_tle(satrec):
 
     return line1, line2
 
+def _abbreviate_rate(value, zero_exponent_string):
+    return (
+        '{0: 4.4e} '.format(value)
+        .replace('.', '')
+        .replace('e+00', zero_exponent_string)
+        .replace('e-0', '-')
+        .replace('e+0', '+')
+    )
+
 def export_omm(satrec, object_name):
     launch_year = int(satrec.intldesg[:2])
     launch_year += 1900 + (launch_year < 57) * 100
diff --git a/sgp4/functions.py b/sgp4/functions.py
index 9adb47b..619e802 100644
--- a/sgp4/functions.py
+++ b/sgp4/functions.py
@@ -5,8 +5,6 @@ modules to offer simple date handling, so this small module holds the
 routines instead.
 
 """
-from math import trunc
-
 def jday(year, mon, day, hr, minute, sec):
     """Return two floats that, when added, produce the specified Julian date.
 
@@ -43,9 +41,9 @@ def jday(year, mon, day, hr, minute, sec):
 def days2mdhms(year, days, round_to_microsecond=6):
     """Convert a float point number of days into the year into date and time.
 
-    Given the integer year plus the "day of the year" (where 1.0 means
+    Given the integer year plus the "day of the year" where 1.0 means
     the beginning of January 1, 2.0 means the beginning of January 2,
-    and so forth), returns the Gregorian calendar month, day, hour,
+    and so forth, return the Gregorian calendar month, day, hour,
     minute, and floating point seconds.
 
     >>> days2mdhms(2000, 1.0)   # January 1
@@ -55,28 +53,28 @@ def days2mdhms(year, days, round_to_microsecond=6):
     >>> days2mdhms(2000, 366.0)  # December 31, since 2000 was a leap year
     (12, 31, 0, 0, 0.0)
 
+    The floating point seconds are rounded to an even number of
+    microseconds if ``round_to_microsecond`` is true.
+
     """
-    whole, fraction = divmod(days, 1.0)
+    second = days * 86400.0
+    if round_to_microsecond:
+        second = round(second, round_to_microsecond)
+
+    minute, second = divmod(second, 60.0)
+    if round_to_microsecond:
+        second = round(second, round_to_microsecond)
+
+    minute = int(minute)
+    hour, minute = divmod(minute, 60)
+    day_of_year, hour = divmod(hour, 24)
 
     is_leap = year % 400 == 0 or (year % 4 == 0 and year % 100 != 0)
-    month, day = _day_of_year_to_month_day(int(whole), is_leap)
+    month, day = _day_of_year_to_month_day(day_of_year, is_leap)
     if month == 13:  # behave like the original in case of overflow
         month = 12
         day += 31
 
-    # The 8 digits of floating point day specified in the TLE have a
-    # resolution of exactly 1e-8 * 24 * 3600 * 1e6 = 864 microseconds,
-    # so round off any floating-point noise beyond the microsecond.
-    if round_to_microsecond:
-        fraction += 0.5 / 86400e6
-
-    second = fraction * 86400.0
-    minute, second = divmod(second, 60.0)
-    hour, minute = divmod(minute, 60.0)
-
-    if round_to_microsecond:
-        second = trunc(second * 1e6) / 1e6
-
     return month, day, int(hour), int(minute), second
 
 def _day_of_year_to_month_day(day_of_year, is_leap):
diff --git a/sgp4/io.py b/sgp4/io.py
index 57ac594..46e5ece 100644
--- a/sgp4/io.py
+++ b/sgp4/io.py
@@ -126,7 +126,6 @@ def twoline2rv(longstr1, longstr2, whichconst, opsmode='i', satrec=None):
         satrec = Satellite()
 
     satrec.error = 0;
-    satrec.whichconst = whichconst  # Python extension: remembers its consts
 
     line = longstr1.rstrip()
 
diff --git a/sgp4/model.py b/sgp4/model.py
index 3cb0ba9..ad256ec 100644
--- a/sgp4/model.py
+++ b/sgp4/model.py
@@ -33,7 +33,7 @@ class Satrec(object):
         'pho', 'pinco', 'plo', 'radiusearthkm', 'revnum', 'satnum', 'se2',
         'se3', 'sgh2', 'sgh3', 'sgh4', 'sh2', 'sh3', 'si2', 'si3', 'sinmao',
         'sl2', 'sl3', 'sl4', 't', 't2cof', 't3cof', 't4cof', 't5cof', 'tumin',
-        'whichconst', 'x1mth2', 'x7thm1', 'xfact', 'xgh2', 'xgh3', 'xgh4',
+        'x1mth2', 'x7thm1', 'xfact', 'xgh2', 'xgh3', 'xgh4',
         'xh2', 'xh3', 'xi2', 'xi3', 'xke', 'xl2', 'xl3', 'xl4', 'xlamo',
         'xlcof', 'xli', 'xmcof', 'xni', 'zmol', 'zmos',
         'jdsatepochF'
@@ -73,14 +73,22 @@ class Satrec(object):
     def sgp4init(self, whichconst, opsmode, satnum, epoch, bstar,
                  ndot, nddot, ecco, argpo, inclo, mo, no_kozai, nodeo):
         whichconst = gravity_constants[whichconst]
+        whole, fraction = divmod(epoch, 1.0)
+        whole_jd = whole + 2433281.5
 
-        y, m, d, H, M, S = invjday(epoch + 2433281.5)
-        jan0epoch = jday(y, 1, 0, 0, 0, 0.0) - 2433281.5
+        # Go out on a limb: if `epoch` has no decimal digits past the 8
+        # decimal places stored in a TLE, then assume the user is trying
+        # to specify an exact decimal fraction.
+        if round(epoch, 8) == epoch:
+            fraction = round(fraction, 8)
 
-        self.epochyr = y % 1000
-        self.epochdays = epoch - jan0epoch
-        self.jdsatepoch, self.jdsatepochF = divmod(epoch, 1.0)
-        self.jdsatepoch += 2433281.5
+        self.jdsatepoch = whole_jd
+        self.jdsatepochF = fraction
+
+        y, m, d, H, M, S = invjday(whole_jd)
+        jan0 = jday(y, 1, 0, 0, 0, 0.0)
+        self.epochyr = y % 100
+        self.epochdays = whole_jd - jan0 + fraction
 
         sgp4init(whichconst, opsmode, satnum, epoch, bstar, ndot, nddot,
                  ecco, argpo, inclo, mo, no_kozai, nodeo, self)
@@ -172,41 +180,7 @@ class SatrecArray(object):
         return e, r, v
 
 class Satellite(object):
-    """The old Satellite object for compatibility with sgp4 1.x.
-
-    Most of this class's hundred-plus attributes are intermediate values
-    of interest only to the propagation algorithm itself.  Here are the
-    attributes set by ``sgp4.io.twoline2rv()`` in which users are likely
-    to be interested:
-
-    ``satnum``
-        Unique satellite number given in the TLE file.
-    ``epochyr``
-        Full four-digit year of this element set's epoch moment.
-    ``epochdays``
-        Fractional days into the year of the epoch moment.
-    ``jdsatepoch``
-        Julian date of the epoch (computed from ``epochyr`` and ``epochdays``).
-    ``ndot``
-        First time derivative of the mean motion (ignored by SGP4).
-    ``nddot``
-        Second time derivative of the mean motion (ignored by SGP4).
-    ``bstar``
-        Ballistic drag coefficient B* in inverse earth radii.
-    ``inclo``
-        Inclination in radians.
-    ``nodeo``
-        Right ascension of ascending node in radians.
-    ``ecco``
-        Eccentricity.
-    ``argpo``
-        Argument of perigee in radians.
-    ``mo``
-        Mean anomaly in radians.
-    ``no_kozai``
-        Mean motion in radians per minute.
-
-    """
+    """The old Satellite object, for compatibility with sgp4 1.x."""
     jdsatepochF = 0.0  # for compatibility with new Satrec; makes tests simpler
 
     # TODO: only offer this on legacy class we no longer document
diff --git a/sgp4/tests.py b/sgp4/tests.py
index fba2b6a..c08263a 100644
--- a/sgp4/tests.py
+++ b/sgp4/tests.py
@@ -19,6 +19,8 @@ try:
 except ImportError:
     from StringIO import StringIO
 
+import numpy as np
+
 from sgp4.api import WGS72OLD, WGS72, WGS84, Satrec, jday, accelerated
 from sgp4.earth_gravity import wgs72
 from sgp4.ext import invjday, newtonnu, rv2coe
@@ -47,7 +49,9 @@ VANGUARD_ATTRS = {
     'operationmode': 'i',
     # Time
     'epochyr': 0,
+    'epochdays': 179.78495062,
     'jdsatepoch': 2451722.5,
+    'jdsatepochF': 0.78495062,
     # Orbit
     'bstar': 2.8098e-05,
     'ndot': 6.96919666594958e-13,
@@ -59,7 +63,7 @@ VANGUARD_ATTRS = {
     'no_kozai': 0.04722944544077857,
     'nodeo': 6.08638547138321,
 }
-VANGUARD_EPOCH = 18441.7849506199999894
+VANGUARD_EPOCH = 18441.78495062
 
 # Handle deprecated assertRaisesRegexp, but allow its use Python 2.6 and 2.7
 if sys.version_info[:2] == (2, 7) or sys.version_info[:2] == (2, 6):
@@ -78,7 +82,6 @@ def test_legacy_built_with_twoline2rv():
     verify_vanguard_1(sat, legacy=True)
 
 def test_satrec_initialized_with_sgp4init():
-    # epochyr and epochdays are not set by sgp4init
     sat = Satrec()
     sat.sgp4init(
         WGS72,
@@ -108,6 +111,21 @@ def test_legacy_initialized_with_sgp4init():
     )
     verify_vanguard_1(sat, legacy=True)
 
+# ------------------------------------------------------------------------
+#                            Test array API
+
+def test_whether_array_logic_writes_nan_values_to_correct_row():
+    # https://github.com/brandon-rhodes/python-sgp4/issues/87
+    l1 = "1 44160U 19006AX  20162.79712247 +.00816806 +19088-3 +34711-2 0  9997"
+    l2 = "2 44160 095.2472 272.0808 0216413 032.6694 328.7739 15.58006382062511"
+    sat = Satrec.twoline2rv(l1, l2)
+    jd0 = np.array([2459054.5, 2459055.5])
+    jd1 = np.array([0.79712247, 0.79712247])
+    e, r, v = sat.sgp4_array(jd0, jd1)
+    assert list(e) == [6, 1]
+    assert np.isnan(r).tolist() == [[False, False, False], [True, True, True]]
+    assert np.isnan(v).tolist() == [[False, False, False], [True, True, True]]
+
 # ------------------------------------------------------------------------
 #                 Other Officially Supported Routines
 #
@@ -224,6 +242,16 @@ def test_export_tle_raises_error_for_out_of_range_angles():
         )
         assertRaises(ValueError, export_tle, sat)
 
+def test_tle_import_export_round_trips():
+    for line1, line2 in [(
+        '1 44542U 19061A   21180.78220369 -.00000015  00000-0 -66561+1 0  9997',
+        '2 44542  54.7025 244.1098 0007981 318.8601 283.5781  1.86231125 12011',
+    )]:
+        sat = Satrec.twoline2rv(line1, line2)
+        outline1, outline2 = export_tle(sat)
+        assertEqual(line1, outline1)
+        assertEqual(line2, outline2)
+
 def test_all_three_gravity_models_with_twoline2rv():
     # The numbers below are those produced by Vallado's C++ code.
     # (Why does the Python version not produce the same values to
@@ -257,13 +285,9 @@ GRAVITY_DIGITS = (
     # Why don't Python and C agree more closely?
     4 if not accelerated
 
-    # Insist on very high precision on my Linux laptop, to signal me if
-    # some future adjustment subtlely changes the library's results.
-    else 12 if platform.system() == 'Linux' and platform.machine() == 'x86_64'
-
-    # Otherwise, reduce our expectations.  Note that at least 6 digits
-    # past the decimal point are necessary to let the test distinguish
-    # between WSG72OLD and WGS72.  See:
+    # Otherwise, try 10 digits.  Note that at least 6 digits past the
+    # decimal point are necessary to let the test distinguish between
+    # WSG72OLD and WGS72.  See:
     # https://github.com/conda-forge/sgp4-feedstock/pull/19
     # https://github.com/brandon-rhodes/python-sgp4/issues/69
     else 10
@@ -293,10 +317,13 @@ def assert_wgs84(sat):
 
 def test_satnum_leading_spaces():
     # https://github.com/brandon-rhodes/python-sgp4/issues/81
+    # https://github.com/brandon-rhodes/python-sgp4/issues/90
     l1 = '1  4859U 21001A   21007.63955392  .00000000  00000+0  00000+0 0  9990'
     l2 = '2  4859 000.0000 000.0000 0000000 000.0000 000.0000 01.00000000    09'
     sat = Satrec.twoline2rv(l1, l2)
     assertEqual(sat.satnum, 4859)
+    assertEqual(sat.classification, 'U')
+    assertEqual(sat.intldesg, '21001A')
 
 def test_satnum_alpha5_encoding():
     def make_sat(satnum_string):
@@ -333,6 +360,17 @@ def test_intldesg_with_7_characters():
     )
     assertEqual(sat.intldesg, '13066AE')
 
+def test_1990s_satrec_initialized_with_sgp4init():
+    sat = Satrec()
+    sat.sgp4init(
+        WGS72,
+        'i',
+        VANGUARD_ATTRS['satnum'],
+        VANGUARD_EPOCH - 365.0,  # change year 2000 to 1999
+        *sgp4init_args(VANGUARD_ATTRS)
+    )
+    assertEqual(sat.epochyr, 99)
+
 def test_setters():
     sat = Satrec()
 
@@ -459,7 +497,9 @@ def verify_vanguard_1(sat, legacy=False):
     if legacy:
         attrs = attrs.copy()
         del attrs['epochyr']
+        del attrs['epochdays']
         del attrs['jdsatepoch']
+        del attrs['jdsatepochF']
 
     for name, value in attrs.items():
         try:
@@ -469,10 +509,6 @@ def verify_vanguard_1(sat, legacy=False):
             e.args = ('for attribute %s, %s' % (name, message),)
             raise e
 
-    if not legacy:
-        assertAlmostEqual(sat.epochdays, 179.78495062, delta=3e-14)
-        assertAlmostEqual(sat.jdsatepochF, 0.78495062, delta=1e-13)
-
 def sgp4init_args(d):
     """Given a dict of orbital parameters, return them in sgp4init order."""
     return (d['bstar'], d['ndot'], d['nddot'], d['ecco'], d['argpo'],
@@ -493,6 +529,7 @@ def test_satrec_against_tcppver_using_julian_dates():
         jd = satrec.jdsatepoch + whole
         fr = satrec.jdsatepochF + fraction
         e, r, v = satrec.sgp4(jd, fr)
+        assert e == satrec.error
         return e, r, v
 
     run_satellite_against_tcppver(Satrec.twoline2rv, invoke, [1,1,6,6,4,3,6])
@@ -501,6 +538,7 @@ def test_satrec_against_tcppver_using_tsince():
 
     def invoke(satrec, tsince):
         e, r, v = satrec.sgp4_tsince(tsince)
+        assert e == satrec.error
         return e, r, v
 
     run_satellite_against_tcppver(Satrec.twoline2rv, invoke, [1,1,6,6,4,3,6])
@@ -536,6 +574,7 @@ def run_satellite_against_tcppver(twoline2rv, invoke, expected_errors):
     # output in tcppver.out.
 
     data = get_data(__name__, 'tcppver.out')
+    data = data.replace(b'\r', b'')
     tcppver_lines = data.decode('ascii').splitlines(True)
 
     error_list = []
@@ -722,23 +761,15 @@ def test_omm_csv_matches_old_tle():
     assert_satellites_match(sat1, sat2)
 
 def assert_satellites_match(sat1, sat2):
-    julian_fractions = {'epochdays', 'jdsatepochF'}
-    todo = {'whichconst'}
-
     for attr in dir(sat1):
         if attr.startswith('_'):
             continue
-        if attr in todo:
-            continue
         value1 = getattr(sat1, attr, None)
         if value1 is None:
             continue
         if callable(value1):
             continue
         value2 = getattr(sat2, attr)
-        if attr in julian_fractions:
-            value1 = round(value1, 10)
-            value2 = round(value2, 10)
         assertEqual(value1, value2, '%s %r != %r' % (attr, value1, value2))
 
 # Live example of OMM:

Debdiff

[The following lists of changes regard files as different if they have different names, permissions or owners.]

Files in second set of .debs but not in first

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/sgp4-2.21.egg-info

Files in first set of .debs but not in second

-rw-r--r--  root/root   /usr/lib/python3/dist-packages/sgp4-2.15.egg-info/PKG-INFO
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/sgp4-2.15.egg-info/dependency_links.txt
-rw-r--r--  root/root   /usr/lib/python3/dist-packages/sgp4-2.15.egg-info/top_level.txt

No differences were encountered in the control files

More details

Full run details