2026-04-07 18:21:28 +01:00
///////////////////////////////////////////////////////////////////////////////////
2026-04-03 20:33:00 +01:00
// Copyright (C) 2026 Jon Beniston, M7RCE <jon@beniston.com> //
// //
// 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 as 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 V3 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/>. //
///////////////////////////////////////////////////////////////////////////////////
#include "spice.h"
#include "util/units.h"
#include "util/profiler.h"
#include <cstring>
#include <QFileInfo>
#include <SpiceUsr.h>
static QMutex spiceMutex ; // SPICE isn't thread-safe, so we use a global mutex that should be locked via spiceLock() before calling any function in this file.
static QStringList spiceEphemeridesLoaded ;
void spiceInit ()
{
QMutexLocker locker ( & spiceMutex );
// Don't abort on error from SPICE
erract_c ( "SET" , 0 , ( char * ) "RETURN" );
}
bool spiceLock ( QStringList ephemerides )
{
spiceMutex . lock ();
QStringList files = ephemerides ;
bool changed = false ;
// Unload no longer used ephemerides
QMutableListIterator < QString > itr ( spiceEphemeridesLoaded );
while ( itr . hasNext ())
{
QString file = itr . next ();
if ( ! files . contains ( file ))
{
unload_c ( file . toStdString (). c_str ());
itr . remove ();
changed = true ;
}
}
// Load new ephemerides - file may not have been downloaded yet
for ( const auto & file : files )
{
if ( ! spiceEphemeridesLoaded . contains ( file ) && QFileInfo :: exists ( file ))
{
furnsh_c ( file . toStdString (). c_str ());
if ( ! failed_c ())
{
spiceEphemeridesLoaded . append ( file );
changed = true ;
}
else
{
qWarning () << "StarTracker: Failed to load SPICE ephemeris file: " << file ;
reset_c ();
}
}
}
return changed ;
}
void spiceUnlock ()
{
spiceMutex . unlock ();
}
// Normalize angle to [0,360)
static SpiceDouble normalize360 ( SpiceDouble deg )
{
SpiceDouble d = fmod ( deg , 360.0 );
if ( d < 0.0 ) {
d += 360.0 ;
}
return d ;
}
// Compute topocentric observer position in J2000 at ET
static bool computeTopocenterJ2000 ( SpiceDouble latDeg , SpiceDouble lonDeg , SpiceDouble altKm , SpiceDouble et , SpiceDouble obsJ2000 [ 3 ])
{
SpiceInt n ;
SpiceDouble radii [ 3 ];
// Earth body radii for geodetic -> rectangular
bodvrd_c ( "EARTH" , "RADII" , 3 , & n , radii );
if ( n < 3 )
{
reset_c ();
return false ;
}
SpiceDouble re = radii [ 0 ];
SpiceDouble rp = radii [ 2 ];
SpiceDouble flatten = ( re - rp ) / re ;
// Geodetic to IAU_EARTH body-fixed rectangular coords
SpiceDouble lonRad = Units :: degreesToRadians ( lonDeg );
SpiceDouble latRad = Units :: degreesToRadians ( latDeg );
SpiceDouble topoIAE [ 3 ];
georec_c ( lonRad , latRad , altKm , re , flatten , topoIAE );
// Transform IAU_EARTH -> J2000 at ET
SpiceDouble xformIAEToJ2000 [ 3 ][ 3 ];
pxform_c ( "IAU_EARTH" , "J2000" , et , xformIAEToJ2000 );
SpiceDouble topoJ2000 [ 3 ];
mxv_c ( xformIAEToJ2000 , topoIAE , topoJ2000 );
/// Earth center in J2000 w.r.t. SSB
SpiceDouble earthPosJ2000 [ 3 ], ltDummy ;
spkpos_c ( "EARTH" , et , "J2000" , "NONE" , "SOLAR SYSTEM BARYCENTER" , earthPosJ2000 , & ltDummy );
if ( failed_c ())
{
reset_c ();
return false ;
}
// observer = earth_center + topo offset
vadd_c ( earthPosJ2000 , topoJ2000 , obsJ2000 );
return true ;
}
// Convert QT date time to SPICE time (TBD seconds past J2000 epoch)
bool dateTimeToET ( const QDateTime & dateTime , double & et )
{
const QByteArray utcBA = dateTime . toUTC (). toString ( Qt :: ISODateWithMs ). toLatin1 ();
const char * utc = utcBA . data ();
str2et_c ( utc , & et );
if ( failed_c ())
{
reset_c ();
return false ;
}
return true ;
}
// Get list of targets (bodies) from a SPICE SPK file. Non SPK files will be ignored.
QStringList getSPICETargets ( const QString & file )
{
QStringList targets ;
const QByteArray fileBA = file . toLatin1 ();
const char * fileStr = fileBA . constData ();
// Check file is SPK
SpiceChar arch [ 32 ];
SpiceChar kernelType [ 32 ];
getfat_c ( fileStr , sizeof ( arch ), sizeof ( kernelType ), arch , kernelType );
if ( strcmp ( arch , "DAF" ) || strcmp ( kernelType , "SPK" )) {
return targets ;
}
SPICEINT_CELL ( ids , 1000 );
spkobj_c ( fileStr , & ids );
if ( ! failed_c ())
{
for ( int i = 0 ; i < card_c ( & ids ); i ++ )
{
SpiceBoolean found ;
SpiceInt id ;
char bodyName [ 33 ];
id = SPICE_CELL_ELEM_I ( & ids , i );
bodc2n_c ( id , sizeof ( bodyName ), bodyName , & found );
if ( ! failed_c ())
{
QString nameStr = QString :: fromLatin1 ( bodyName );
if ( ! targets . contains ( nameStr )) {
targets . append ( nameStr );
}
}
else
{
reset_c ();
}
}
}
else
{
reset_c ();
}
return targets ;
}
// Calculate azimuth and elevation of a target body from a position on Earth using SPICE
bool getAzElFromSPICE ( const QString & target , double et , double latitude , double longitude , double altitudeKm , double & azimuth , double & elevation )
{
QByteArray targetBA = target . toLatin1 ();
const char * targetStr = targetBA . constData ();
// Get body radii for the observer's central body
SpiceInt n ;
SpiceDouble radii [ 3 ];
bodvrd_c ( "EARTH" , "RADII" , 3 , & n , radii );
// Convert geodetic coordinates to body-fixed rectangular coordinates
SpiceDouble bodfix [ 3 ];
SpiceDouble longitudeRad = Units :: degreesToRadians ( longitude );
SpiceDouble latitudeRad = Units :: degreesToRadians ( latitude );
georec_c ( longitudeRad , latitudeRad , altitudeKm , radii [ 0 ], ( radii [ 0 ] - radii [ 2 ]) / radii [ 0 ], bodfix );
// Calculate Alz/Az
SpiceDouble lt ;
SpiceDouble azlsta [ 6 ];
azlcpo_c ( "ELLIPSOID" , targetStr , et , "LT+S" , SPICEFALSE , SPICETRUE , bodfix , "EARTH" , "IAU_EARTH" , azlsta , & lt );
if ( ! failed_c ())
{
azimuth = Units :: radiansToDegrees ( azlsta [ 1 ]);
elevation = Units :: radiansToDegrees ( azlsta [ 2 ]);
}
else
{
reset_c ();
return false ;
}
return true ;
}
// Calculate RA and Dec of a target body from Earth using SPICE
bool getRADecFromSPICE ( const QString & target , double et , double & ra , double & dec )
{
QByteArray targetBA = target . toLatin1 ();
const char * targetStr = targetBA . constData ();
// Get state (position & velocity) of a target body, relative to observing body (Earth), corrected for light time and stellar aberation
SpiceDouble state [ 6 ];
SpiceDouble lightTime ;
spkezr_c ( targetStr , et , "J2000" , "LT+S" , "EARTH" , state , & lightTime );
if ( ! failed_c ())
{
SpiceDouble radec [ 3 ];
// Convert geocentric rectangular coordinates to RA/DEC (spherical coordinates)
recrad_c ( state , & radec [ 0 ], & radec [ 1 ], & radec [ 2 ]);
ra = Units :: radiansToDegrees ( radec [ 1 ]);
dec = Units :: radiansToDegrees ( radec [ 2 ]);
// Normalize RA to 0-360 range
if ( ra < 0.0 ) {
ra += 360.0 ;
}
// Convert from degrees to hours
ra = ra / ( 360.0 / 24.0 );
}
else
{
reset_c ();
return false ;
}
return true ;
}
// Calculate Jupiter's CML (Central Meridian Longitude) as seen from a location on Earth and the phase of the given Jovian moon (IO or GANYMEDE)
2026-04-07 18:21:28 +01:00
bool spiceJupiterMoonPhase ( const QString & moon , double et , double latitude , double longitude , double altitudeMetres , double & cml , double & phase )
2026-04-03 20:33:00 +01:00
{
const QByteArray moonBA = moon . toUpper (). toLatin1 ();
const char * moonName = moonBA . data ();
SpiceDouble te = et ; // Emission epoch
SpiceDouble moonPosSSB [ 3 ]; // Io position w.r.t SSB at te
SpiceDouble obsPosSSB [ 3 ]; // Pbserver position w.r.t SSB at reception (et)
SpiceDouble lt = 0.0 ; // Light time
// Compute topocenter obsPosSSB at reception ET
if ( ! computeTopocenterJ2000 ( latitude , longitude , altitudeMetres / 1000.0 , et , obsPosSSB ))
{
reset_c ();
return false ;
}
// Iterate to find Io emission epoch te
SpiceDouble moonPosGuess [ 3 ], ltDummy ;
spkpos_c ( moonName , et , "J2000" , "NONE" , "SOLAR SYSTEM BARYCENTER" , moonPosGuess , & ltDummy );
if ( failed_c ())
{
reset_c ();
return false ;
}
const SpiceDouble c = clight_c ();
SpiceDouble vec [ 3 ];
vsub_c ( obsPosSSB , moonPosGuess , vec );
lt = vnorm_c ( vec ) / c ;
const int MAX_ITERS = 50 ;
const SpiceDouble TOL = 1e-9 ;
int iter ;
for ( iter = 0 ; iter < MAX_ITERS ; ++ iter )
{
te = et - lt ;
spkpos_c ( moonName , te , "J2000" , "NONE" , "SOLAR SYSTEM BARYCENTER" , moonPosSSB , & ltDummy );
if ( failed_c ())
{
reset_c ();
return false ;
}
vsub_c ( obsPosSSB , moonPosSSB , vec );
SpiceDouble new_lt = vnorm_c ( vec ) / c ;
if ( fabs ( new_lt - lt ) < TOL )
{
lt = new_lt ;
break ;
}
lt = new_lt ;
}
if ( iter >= MAX_ITERS ) {
qDebug () << "calculateIoPhase: light-time iteration did not converge" ;
}
// Moon relative to Jupiter (J2000) at te
SpiceDouble moonPosJupiterJ2000 [ 3 ];
spkpos_c ( moonName , te , "J2000" , "NONE" , "JUPITER" , moonPosJupiterJ2000 , & ltDummy );
if ( failed_c ())
{
reset_c ();
return false ;
}
// Jupiter position (SSB) at te to compute observer-relative vector
SpiceDouble jupPosSSBTe [ 3 ];
spkpos_c ( "JUPITER" , te , "J2000" , "NONE" , "SOLAR SYSTEM BARYCENTER" , jupPosSSBTe , & ltDummy );
if ( failed_c ())
{
reset_c ();
return false ;
}
// Observer vector relative to Jupiter in J2000: obs_ssb (at et) - jup_pos_ssb_te
SpiceDouble obsPosJupiterJ2000 [ 3 ];
vsub_c ( obsPosSSB , jupPosSSBTe , obsPosJupiterJ2000 );
// Transform both vectors into IAU_JUPITER body-fixed frame evaluated at te
SpiceDouble xform [ 3 ][ 3 ];
pxform_c ( "J2000" , "IAU_JUPITER" , te , xform );
2026-04-04 13:18:44 +01:00
SpiceDouble moonBodyFixed [ 3 ], obsBodyFixed [ 3 ];
mxv_c ( xform , moonPosJupiterJ2000 , moonBodyFixed );
mxv_c ( xform , obsPosJupiterJ2000 , obsBodyFixed );
2026-04-03 20:33:00 +01:00
// Jupiter radii
SpiceInt nrad ;
SpiceDouble radiiJupiter [ 3 ];
bodvrd_c ( "JUPITER" , "RADII" , 3 , & nrad , radiiJupiter );
if ( nrad < 3 )
{
reset_c ();
return false ;
}
SpiceDouble re_j = radiiJupiter [ 0 ];
SpiceDouble rp_j = radiiJupiter [ 2 ];
SpiceDouble f_j = ( re_j - rp_j ) / re_j ;
// Moon planetographic longitude using the same convention as CML
SpiceDouble moonLonRad , moonLatRad , moonAltKm ;
2026-04-04 13:18:44 +01:00
recpgr_c ( "JUPITER" , moonBodyFixed , re_j , f_j , & moonLonRad , & moonLatRad , & moonAltKm );
2026-04-03 20:33:00 +01:00
SpiceDouble moonLonDeg = normalize360 ( Units :: radiansToDegrees ( moonLonRad ));
// CML (sub-observer planetographic longitude)
SpiceDouble subObsLonRad , subObsLatRad , subObsAltKm ;
2026-04-04 13:18:44 +01:00
recpgr_c ( "JUPITER" , obsBodyFixed , re_j , f_j , & subObsLonRad , & subObsLatRad , & subObsAltKm );
2026-04-03 20:33:00 +01:00
cml = normalize360 ( Units :: radiansToDegrees ( subObsLonRad ));
// Moon phase (0 deg on far side)
phase = normalize360 ( cml - moonLonDeg + 180 );
return true ;
}
2026-04-07 18:21:28 +01:00
// Get position of named body relative to Sun, in ECLIPJ2000 reference frame. No abberation correction.
bool spicePosition ( const QString & name , double et , QVector3D & positionKm )
{
SpiceDouble pos [ 3 ];
SpiceDouble lightTime ;
const QByteArray nameBA = name . toLatin1 ();
const char * nameStr = nameBA . constData ();
// Get position of named body
spkpos_c ( nameStr , et , "ECLIPJ2000" , "NONE" , "SUN" , pos , & lightTime );
if ( ! failed_c ())
{
positionKm . setX ( pos [ 0 ]);
positionKm . setY ( pos [ 1 ]);
positionKm . setZ ( pos [ 2 ]);
return true ;
}
else
{
qDebug () << "spicePosition: Failed to get position for" << name ;
reset_c ();
return false ;
}
}
// Convert orbital elements to perifocal (PQW) coordinate system
static void orbitalElementsToPerifocal ( SpiceDouble omega , SpiceDouble inc , SpiceDouble argp ,
SpiceDouble p [ 3 ], SpiceDouble q [ 3 ])
{
SpiceDouble cO = cos ( omega );
SpiceDouble sO = sin ( omega );
SpiceDouble ci = cos ( inc );
SpiceDouble si = sin ( inc );
SpiceDouble cw = cos ( argp );
SpiceDouble sw = sin ( argp );
// Phat (toward periapsis)
p [ 0 ] = cO * cw - sO * sw * ci ;
p [ 1 ] = sO * cw + cO * sw * ci ;
p [ 2 ] = sw * si ;
// Qhat (90 deg ahead in orbit plane)
q [ 0 ] = - cO * sw - sO * cw * ci ;
q [ 1 ] = - sO * sw + cO * cw * ci ;
q [ 2 ] = cw * si ;
}
// Get orbital path around the Sun, in XY plane of ECLIPJ2000 reference frame.
bool spiceOrbit ( const QString & name , double et , QList < QPointF > & orbit )
{
const QByteArray nameBA = name . toUpper (). toLatin1 ();
const char * target = nameBA . data ();
// Sun relative state, expressed in ECLIPJ2000
SpiceDouble state [ 6 ], lt ;
spkezr_c ( target , et , "ECLIPJ2000" , "NONE" , "SUN" , state , & lt );
// Sun GM (gravitational parameter = G * M)
SpiceInt dim ;
SpiceDouble mu ;
bodvrd_c ( "SUN" , "GM" , 1 , & dim , & mu );
// Osculating conic elements
SpiceDouble elts [ 8 ];
oscelt_c ( state , et , mu , elts );
SpiceDouble rp = elts [ 0 ]; // perifocal distance (km)
SpiceDouble e = elts [ 1 ]; // eccentricity
SpiceDouble inc = elts [ 2 ]; // inclination (rad)
SpiceDouble omega = elts [ 3 ]; // longitude of ascending node (rad)
SpiceDouble argp = elts [ 4 ]; // argument of periapsis (rad)
if ( e >= 1.0 )
{
qDebug () << "spiceOrbit: Not an ellipse" << name ;
return false ;
}
SpiceDouble a = rp / ( 1.0 - e ); // semi-major axis in orbital plane
SpiceDouble b = a * sqrt ( 1.0 - e * e ); // semi-minor axis in orbital plane
SpiceDouble pHat [ 3 ], qHat [ 3 ];
orbitalElementsToPerifocal ( omega , inc , argp , pHat , qHat );
const int N = 1000 ; // Could vary this with zoom level. 1k good enough when zoomed to orbit of the Moon around Earth
for ( int k = 0 ; k <= N ; k ++ )
{
const double E = 2.0 * M_PI * ( double ) k / ( double ) N ;
// Ellipse in PQW with Sun at focus
double xp = a * ( cos ( E ) - e );
double yp = b * sin ( E );
// Rotate into ECLIPJ2000
double x = xp * pHat [ 0 ] + yp * qHat [ 0 ];
double y = xp * pHat [ 1 ] + yp * qHat [ 1 ];
QPointF point = { x , y };
orbit . append ( point );
}
return true ;
}