GeoMagneticField.java

/* Copyright 2011-2012 Space Applications Services
 * Licensed to CS Communication & Systèmes (CS) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * CS licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.orekit.models.earth;

import org.apache.commons.math3.geometry.euclidean.threed.Vector3D;
import org.apache.commons.math3.util.FastMath;
import org.orekit.bodies.GeodeticPoint;
import org.orekit.errors.OrekitException;
import org.orekit.errors.OrekitMessages;
import org.orekit.utils.Constants;

/** Used to calculate the geomagnetic field at a given geodetic point on earth.
 * The calculation is estimated using spherical harmonic expansion of the
 * geomagnetic potential with coefficients provided by an actual geomagnetic
 * field model (e.g. IGRF, WMM).
 * <p>
 * Based on original software written by Manoj Nair from the National
 * Geophysical Data Center, NOAA, as part of the WMM 2010 software release
 * (WMM_SubLibrary.c)
 * </p>
 * @see <a href="http://www.ngdc.noaa.gov/geomag/WMM/DoDWMM.shtml">World Magnetic Model Overview</a>
 * @see <a href="http://www.ngdc.noaa.gov/geomag/WMM/soft.shtml">WMM Software Downloads</a>
 * @author Thomas Neidhart
 */
public class GeoMagneticField {

    /** Semi major-axis of WGS-84 ellipsoid in km. */
    private static double a = Constants.WGS84_EARTH_EQUATORIAL_RADIUS / 1000d;

    /** The first eccentricity squared. */
    private static double epssq = 0.0066943799901413169961;

    /** Mean radius of IAU-66 ellipsoid, in km. */
    private static double ellipsoidRadius = 6371.2;

    /** The model name. */
    private String modelName;

    /** Base time of magnetic field model epoch (yrs). */
    private double epoch;

    /** C - Gauss coefficients of main geomagnetic model (nT). */
    private double[] g;

    /** C - Gauss coefficients of main geomagnetic model (nT). */
    private double[] h;

    /** CD - Gauss coefficients of secular geomagnetic model (nT/yr). */
    private double[] dg;

    /** CD - Gauss coefficients of secular geomagnetic model (nT/yr). */
    private double[] dh;

    /** maximum degree of spherical harmonic model. */
    private int maxN;

    /** maximum degree of spherical harmonic secular variations. */
    private int maxNSec;

    /** the validity start of this magnetic field model. */
    private double validityStart;
    /** the validity end of this magnetic field model. */
    private double validityEnd;

    /** Pre-calculated ratio between gauss-normalized and schmidt quasi-normalized
     * associated Legendre functions.
     */
    private double[] schmidtQuasiNorm;

    /** Create a new geomagnetic field model with the given parameters. Internal
     * structures are initialized according to the specified degrees of the main
     * and secular variations.
     * @param modelName the model name
     * @param epoch the epoch of the model
     * @param maxN the maximum degree of the main model
     * @param maxNSec the maximum degree of the secular variations
     * @param validityStart validity start of this model
     * @param validityEnd validity end of this model
     */
    protected GeoMagneticField(final String modelName, final double epoch,
                               final int maxN, final int maxNSec,
                               final double validityStart, final double validityEnd) {

        this.modelName = modelName;
        this.epoch = epoch;
        this.maxN = maxN;
        this.maxNSec = maxNSec;

        this.validityStart = validityStart;
        this.validityEnd = validityEnd;

        // initialize main and secular field coefficient arrays
        final int maxMainFieldTerms = (maxN + 1) * (maxN + 2) / 2;
        g = new double[maxMainFieldTerms];
        h = new double[maxMainFieldTerms];

        final int maxSecularFieldTerms = (maxNSec + 1) * (maxNSec + 2) / 2;
        dg = new double[maxSecularFieldTerms];
        dh = new double[maxSecularFieldTerms];

        // pre-calculate the ratio between gauss-normalized and schmidt quasi-normalized
        // associated Legendre functions as they depend only on the degree of the model.

        schmidtQuasiNorm = new double[maxMainFieldTerms + 1];
        schmidtQuasiNorm[0] = 1.0;

        int index;
        int index1;
        for (int n = 1; n <= maxN; n++) {
            index = n * (n + 1) / 2;
            index1 = (n - 1) * n / 2;

            // for m = 0
            schmidtQuasiNorm[index] =
                schmidtQuasiNorm[index1] * (double) (2 * n - 1) / (double) n;

            for (int m = 1; m <= n; m++) {
                index = n * (n + 1) / 2 + m;
                index1 = n * (n + 1) / 2 + m - 1;
                schmidtQuasiNorm[index] =
                    schmidtQuasiNorm[index1] *
                    FastMath.sqrt((double) ((n - m + 1) * (m == 1 ? 2 : 1)) / (double) (n + m));
            }
        }
    }

    /** Returns the epoch for this magnetic field model.
     * @return the epoch
     */
    public double getEpoch() {
        return epoch;
    }

    /** Returns the model name.
     * @return the model name
     */
    public String getModelName() {
        return modelName;
    }

    /** Returns the start of the validity period for this model.
     * @return the validity start as decimal year
     */
    public double validFrom() {
        return validityStart;
    }

    /** Returns the end of the validity period for this model.
     * @return the validity end as decimal year
     */
    public double validTo() {
        return validityEnd;
    }

    /** Indicates whether this model supports time transformation or not.
     * @return <code>true</code> if this model can be transformed within its
     *         validity period, <code>false</code> otherwise
     */
    public boolean supportsTimeTransform() {
        return maxNSec > 0;
    }

    /** Set the given main field coefficients.
     * @param n the n index
     * @param m the m index
     * @param gnm the g coefficient at position n,m
     * @param hnm the h coefficient at position n,m
     */
    protected void setMainFieldCoefficients(final int n, final int m,
                                         final double gnm, final double hnm) {
        final int index = n * (n + 1) / 2 + m;
        g[index] = gnm;
        h[index] = hnm;
    }

    /** Set the given secular variation coefficients.
     * @param n the n index
     * @param m the m index
     * @param dgnm the dg coefficient at position n,m
     * @param dhnm the dh coefficient at position n,m
     */
    protected void setSecularVariationCoefficients(final int n, final int m,
                                                final double dgnm, final double dhnm) {
        final int index = n * (n + 1) / 2 + m;
        dg[index] = dgnm;
        dh[index] = dhnm;
    }

    /** Calculate the magnetic field at the specified geodetic point identified
     * by latitude, longitude and altitude.
     * @param latitude the latitude in decimal degrees
     * @param longitude the longitude in decimal degrees
     * @param height the altitude in kilometers above mean sea level
     * @return the {@link GeoMagneticElements} at the given geodetic point
     */
    public GeoMagneticElements calculateField(final double latitude,
                                              final double longitude,
                                              final double height) {

        final GeodeticPoint gp = new GeodeticPoint(FastMath.toRadians(latitude),
                                                   FastMath.toRadians(longitude),
                                                   height * 1000d);

        final SphericalCoordinates sph = transformToSpherical(gp);
        final SphericalHarmonicVars vars = new SphericalHarmonicVars(sph);
        final LegendreFunction legendre = new LegendreFunction(FastMath.sin(sph.phi));

        // sum up the magnetic field vector components
        final Vector3D magFieldSph = summation(sph, vars, legendre);
        // rotate the field to geodetic coordinates
        final Vector3D magFieldGeo = rotateMagneticVector(sph, gp, magFieldSph);
        // return the magnetic elements
        return new GeoMagneticElements(magFieldGeo);
    }

    /** Time transform the model coefficients from the base year of the model
     * using secular variation coefficients.
     * @param year the year to which the model shall be transformed
     * @return a time-transformed magnetic field model
     * @throws OrekitException if the specified year is outside the validity period of the
     *                         model or the model does not support time transformations
     *                         (i.e. no secular variations available)
     */
    public GeoMagneticField transformModel(final double year) throws OrekitException {

        if (!supportsTimeTransform()) {
            throw new OrekitException(OrekitMessages.UNSUPPORTED_TIME_TRANSFORM, modelName, String.valueOf(epoch));
        }

        // the model can only be transformed within its validity period
        if (year < validityStart || year > validityEnd) {
            throw new OrekitException(OrekitMessages.OUT_OF_RANGE_TIME_TRANSFORM,
                                      modelName, String.valueOf(epoch), year, validityStart, validityEnd);
        }

        final double dt = year - epoch;
        final int maxSecIndex = maxNSec * (maxNSec + 1) / 2 + maxNSec;

        final GeoMagneticField transformed = new GeoMagneticField(modelName, year, maxN, maxNSec,
                                                                  validityStart, validityEnd);

        for (int n = 1; n <= maxN; n++) {
            for (int m = 0; m <= n; m++) {
                final int index = n * (n + 1) / 2 + m;
                if (index <= maxSecIndex) {
                    transformed.h[index] = h[index] + dt * dh[index];
                    transformed.g[index] = g[index] + dt * dg[index];
                    // we need a copy of the secular var coef to calculate secular change
                    transformed.dh[index] = dh[index];
                    transformed.dg[index] = dg[index];
                } else {
                    // just copy the parts that do not have corresponding secular variation coefficients
                    transformed.h[index] = h[index];
                    transformed.g[index] = g[index];
                }
            }
        }

        return transformed;
    }

    /** Time transform the model coefficients from the base year of the model
     * using a linear interpolation with a second model. The second model is
     * required to have an adjacent validity period.
     * @param otherModel the other magnetic field model
     * @param year the year to which the model shall be transformed
     * @return a time-transformed magnetic field model
     * @throws OrekitException if the specified year is outside the validity period of the
     *                         model or the model does not support time transformations
     *                         (i.e. no secular variations available)
     */
    public GeoMagneticField transformModel(final GeoMagneticField otherModel, final double year)
        throws OrekitException {

        // the model can only be transformed within its validity period
        if (year < validityStart || year > validityEnd) {
            throw new OrekitException(OrekitMessages.OUT_OF_RANGE_TIME_TRANSFORM,
                                      modelName, String.valueOf(epoch), year, validityStart, validityEnd);
        }

        final double factor = (year - epoch) / (otherModel.epoch - epoch);
        final int maxNCommon = Math.min(maxN, otherModel.maxN);
        final int maxNCommonIndex = maxNCommon * (maxNCommon + 1) / 2 + maxNCommon;

        final int newMaxN = Math.max(maxN, otherModel.maxN);

        final GeoMagneticField transformed = new GeoMagneticField(modelName, year, newMaxN, 0,
                                                                  validityStart, validityEnd);

        for (int n = 1; n <= newMaxN; n++) {
            for (int m = 0; m <= n; m++) {
                final int index = n * (n + 1) / 2 + m;
                if (index <= maxNCommonIndex) {
                    transformed.h[index] = h[index] + factor * (otherModel.h[index] - h[index]);
                    transformed.g[index] = g[index] + factor * (otherModel.g[index] - g[index]);
                } else {
                    if (maxN < otherModel.maxN) {
                        transformed.h[index] = factor * otherModel.h[index];
                        transformed.g[index] = factor * otherModel.g[index];
                    } else {
                        transformed.h[index] = h[index] + factor * -h[index];
                        transformed.g[index] = g[index] + factor * -g[index];
                    }
                }
            }
        }

        return transformed;
    }

    /** Utility function to get a decimal year for a given day.
     * @param day the day (1-31)
     * @param month the month (1-12)
     * @param year the year
     * @return the decimal year represented by the given day
     */
    public static double getDecimalYear(final int day, final int month, final int year) {
        final int[] days = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
        final int leapYear = (((year % 4) == 0) && (((year % 100) != 0) || ((year % 400) == 0))) ? 1 : 0;

        final double dayInYear = days[month - 1] + (day - 1) + (month > 2 ? leapYear : 0);
        return (double) year + (dayInYear / (365.0d + leapYear));
    }

    /** Transform geodetic coordinates to spherical coordinates.
     * @param gp the geodetic point
     * @return the spherical coordinates wrt to the reference ellipsoid of the model
     */
    private SphericalCoordinates transformToSpherical(final GeodeticPoint gp) {

        // Convert geodetic coordinates (defined by the WGS-84 reference ellipsoid)
        // to Earth Centered Earth Fixed Cartesian coordinates, and then to spherical coordinates.

        final double lat = gp.getLatitude();
        final double heightAboveEllipsoid = gp.getAltitude() / 1000d;
        final double sinLat = FastMath.sin(lat);

        // compute the local radius of curvature on the reference ellipsoid
        final double rc = a / FastMath.sqrt(1.0d - epssq * sinLat * sinLat);

        // compute ECEF Cartesian coordinates of specified point (for longitude=0)
        final double xp = (rc + heightAboveEllipsoid) * FastMath.cos(lat);
        final double zp = (rc * (1.0d - epssq) + heightAboveEllipsoid) * sinLat;

        // compute spherical radius and angle lambda and phi of specified point
        final double r = FastMath.hypot(xp, zp);
        return new SphericalCoordinates(r, gp.getLongitude(), FastMath.asin(zp / r));
    }

    /** Rotate the magnetic vectors to geodetic coordinates.
     * @param sph the spherical coordinates
     * @param gp the geodetic point
     * @param field the magnetic field in spherical coordinates
     * @return the magnetic field in geodetic coordinates
     */
    private Vector3D rotateMagneticVector(final SphericalCoordinates sph,
                                          final GeodeticPoint gp,
                                          final Vector3D field) {

        // difference between the spherical and geodetic latitudes
        final double psi = sph.phi - gp.getLatitude();

        // rotate spherical field components to the geodetic system
        final double Bz = field.getX() * FastMath.sin(psi) + field.getZ() * FastMath.cos(psi);
        final double Bx = field.getX() * FastMath.cos(psi) - field.getZ() * FastMath.sin(psi);
        final double By = field.getY();

        return new Vector3D(Bx, By, Bz);
    }

    /** Computes Geomagnetic Field Elements X, Y and Z in spherical coordinate
     * system using spherical harmonic summation.
     * The vector Magnetic field is given by -grad V, where V is geomagnetic
     * scalar potential. The gradient in spherical coordinates is given by:
     * <pre>
     *          dV ^   1 dV ^       1    dV ^
     * grad V = -- r + - -- t + -------- -- p
     *          dr     r dt     r sin(t) dp
     * </pre>
     * @param sph the spherical coordinates
     * @param vars the spherical harmonic variables
     * @param legendre the legendre function
     * @return the magnetic field vector in spherical coordinates
     */
    private Vector3D summation(final SphericalCoordinates sph, final SphericalHarmonicVars vars,
                               final LegendreFunction legendre) {

        int index;
        double Bx = 0.0;
        double By = 0.0;
        double Bz = 0.0;

        for (int n = 1; n <= maxN; n++) {
            for (int m = 0; m <= n; m++) {
                index = n * (n + 1) / 2 + m;

                /**
                 * <pre>
                 *       nMax               (n+2)   n    m            m           m
                 * Bz = -SUM (n + 1) * (a/r)     * SUM [g cos(m p) + h sin(m p)] P (sin(phi))
                 *       n=1                       m=0   n            n           n
                 * </pre>
                 * Equation 12 in the WMM Technical report. Derivative with respect to radius.
                 */
                Bz -= vars.relativeRadiusPower[n] *
                      (g[index] * vars.cmLambda[m] + h[index] * vars.smLambda[m]) * (1d + n) * legendre.mP[index];

                /**
                 * <pre>
                 *      nMax     (n+2)   n    m            m            m
                 * By = SUM (a/r)     * SUM [g cos(m p) + h sin(m p)] dP (sin(phi))
                 *      n=1             m=0   n            n            n
                 * </pre>
                 * Equation 11 in the WMM Technical report. Derivative with respect to longitude, divided by radius.
                 */
                By += vars.relativeRadiusPower[n] *
                      (g[index] * vars.smLambda[m] - h[index] * vars.cmLambda[m]) * (double) m * legendre.mP[index];
                /**
                 * <pre>
                 *        nMax     (n+2)   n    m            m            m
                 * Bx = - SUM (a/r)     * SUM [g cos(m p) + h sin(m p)] dP (sin(phi))
                 *        n=1             m=0   n            n            n
                 * </pre>
                 * Equation 10 in the WMM Technical report. Derivative with respect to latitude, divided by radius.
                 */
                Bx -= vars.relativeRadiusPower[n] *
                      (g[index] * vars.cmLambda[m] + h[index] * vars.smLambda[m]) * legendre.mPDeriv[index];
            }
        }

        final double cosPhi = FastMath.cos(sph.phi);
        if (FastMath.abs(cosPhi) > 1.0e-10) {
            By = By / cosPhi;
        } else {
            // special calculation for component - By - at geographic poles.
            // To avoid using this function, make sure that the latitude is not
            // exactly +/-90.
            By = summationSpecial(sph, vars);
        }

        return new Vector3D(Bx, By, Bz);
    }

    /** Special calculation for the component By at geographic poles.
     * @param sph the spherical coordinates
     * @param vars the spherical harmonic variables
     * @return the By component of the magnetic field
     */
    private double summationSpecial(final SphericalCoordinates sph, final SphericalHarmonicVars vars) {

        double k;
        final double sinPhi = FastMath.sin(sph.phi);
        final double[] mPcupS = new double[maxN + 1];
        mPcupS[0] = 1;
        double By = 0.0;

        for (int n = 1; n <= maxN; n++) {
            final int index = n * (n + 1) / 2 + 1;
            if (n == 1) {
                mPcupS[n] = mPcupS[n - 1];
            } else {
                k = (double) (((n - 1) * (n - 1)) - 1) / (double) ((2 * n - 1) * (2 * n - 3));
                mPcupS[n] = sinPhi * mPcupS[n - 1] - k * mPcupS[n - 2];
            }

            /**
             * <pre>
             *      nMax     (n+2)   n    m            m            m
             * By = SUM (a/r)     * SUM [g cos(m p) + h sin(m p)] dP (sin(phi))
             *      n=1             m=0   n            n            n
             * </pre>
             * Equation 11 in the WMM Technical report. Derivative with respect to longitude, divided by radius.
             */
            By += vars.relativeRadiusPower[n] *
                  (g[index] * vars.smLambda[1] - h[index] * vars.cmLambda[1]) * mPcupS[n] * schmidtQuasiNorm[index];
        }

        return By;
    }

    /** Utility class to hold spherical coordinates. */
    private static class SphericalCoordinates {

        /** the radius. */
        private double r;

        /** the azimuth angle. */
        private double lambda;

        /** the polar angle. */
        private double phi;

        /** Create a new spherical coordinate object.
         * @param r the radius
         * @param lambda the lambda angle
         * @param phi the phi angle
         */
        private SphericalCoordinates(final double r, final double lambda, final double phi) {
            this.r = r;
            this.lambda = lambda;
            this.phi = phi;
        }
    }

    /** Utility class to compute certain variables for magnetic field summation. */
    private class SphericalHarmonicVars {

        /** (Radius of Earth / Spherical radius r)^(n+2). */
        private double[] relativeRadiusPower;

        /** cos(m*lambda). */
        private double[] cmLambda;

        /** sin(m*lambda). */
        private double[] smLambda;

        /** Calculates the spherical harmonic variables for a given spherical coordinate.
         * @param sph the spherical coordinate
         */
        private SphericalHarmonicVars(final SphericalCoordinates sph) {

            relativeRadiusPower = new double[maxN + 1];

            // Compute a table of (EARTH_REFERENCE_RADIUS_KM / radius)^n for i in
            // 0 .. maxN (this is much faster than calling FastMath.pow maxN+1 times).

            final double p = ellipsoidRadius / sph.r;
            relativeRadiusPower[0] = p * p;
            for (int n = 1; n <= maxN; n++) {
                relativeRadiusPower[n] = relativeRadiusPower[n - 1] * (ellipsoidRadius / sph.r);
            }

            // Compute tables of sin(lon * m) and cos(lon * m) for m = 0 .. maxN
            // this is much faster than calling FastMath.sin and FastMath.cos maxN+1 times.

            cmLambda = new double[maxN + 1];
            smLambda = new double[maxN + 1];

            cmLambda[0] = 1.0d;
            smLambda[0] = 0.0d;

            final double cosLambda = FastMath.cos(sph.lambda);
            final double sinLambda = FastMath.sin(sph.lambda);
            cmLambda[1] = cosLambda;
            smLambda[1] = sinLambda;

            for (int m = 2; m <= maxN; m++) {
                cmLambda[m] = cmLambda[m - 1] * cosLambda - smLambda[m - 1] * sinLambda;
                smLambda[m] = cmLambda[m - 1] * sinLambda + smLambda[m - 1] * cosLambda;
            }
        }
    }

    /** Utility class to compute a table of Schmidt-semi normalized associated Legendre functions. */
    private class LegendreFunction {

        /** the vector of all associated Legendre polynomials. */
        private double[] mP;

        /** the vector of derivatives of the Legendre polynomials wrt latitude. */
        private double[] mPDeriv;

        /** Calculate the Schmidt-semi normalized Legendre function.
         * <p>
         * <b>Note:</b> In geomagnetism, the derivatives of ALF are usually
         * found with respect to the colatitudes. Here the derivatives are found
         * with respect to the latitude. The difference is a sign reversal for
         * the derivative of the Associated Legendre Functions.
         * </p>
         * @param x sinus of the spherical latitude (or cosinus of the spherical colatitude)
         */
        private LegendreFunction(final double x) {

            final int numTerms = (maxN + 1) * (maxN + 2) / 2;

            mP = new double[numTerms + 1];
            mPDeriv = new double[numTerms + 1];

            mP[0] = 1.0;
            mPDeriv[0] = 0.0;

            // sin (geocentric latitude) - sin_phi
            final double z = FastMath.sqrt((1.0d - x) * (1.0d + x));

            int index;
            int index1;
            int index2;

            // First, compute the Gauss-normalized associated Legendre functions
            for (int n = 1; n <= maxN; n++) {
                for (int m = 0; m <= n; m++) {
                    index = n * (n + 1) / 2 + m;
                    if (n == m) {
                        index1 = (n - 1) * n / 2 + m - 1;
                        mP[index] = z * mP[index1];
                        mPDeriv[index] = z * mPDeriv[index1] + x * mP[index1];
                    } else if (n == 1 && m == 0) {
                        index1 = (n - 1) * n / 2 + m;
                        mP[index] = x * mP[index1];
                        mPDeriv[index] = x * mPDeriv[index1] - z * mP[index1];
                    } else if (n > 1 && n != m) {
                        index1 = (n - 2) * (n - 1) / 2 + m;
                        index2 = (n - 1) * n / 2 + m;
                        if (m > n - 2) {
                            mP[index] = x * mP[index2];
                            mPDeriv[index] = x * mPDeriv[index2] - z * mP[index2];
                        } else {
                            final double k = (double) ((n - 1) * (n - 1) - (m * m)) /
                                             (double) ((2 * n - 1) * (2 * n - 3));

                            mP[index] = x * mP[index2] - k * mP[index1];
                            mPDeriv[index] = x * mPDeriv[index2] - z * mP[index2] - k * mPDeriv[index1];
                        }
                    }

                }
            }

            // Converts the Gauss-normalized associated Legendre functions to the Schmidt quasi-normalized
            // version using pre-computed relation stored in the variable schmidtQuasiNorm

            for (int n = 1; n <= maxN; n++) {
                for (int m = 0; m <= n; m++) {
                    index = n * (n + 1) / 2 + m;

                    mP[index] = mP[index] * schmidtQuasiNorm[index];
                    // The sign is changed since the new WMM routines use derivative with
                    // respect to latitude instead of co-latitude
                    mPDeriv[index] = -mPDeriv[index] * schmidtQuasiNorm[index];
                }
            }
        }
    }
}