SP3Parser.java

/* Copyright 2002-2012 Space Applications Services
 * Licensed to CS GROUP (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.files.sp3;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Scanner;
import java.util.function.Function;
import java.util.regex.Pattern;

import org.hipparchus.exception.LocalizedCoreFormats;
import org.hipparchus.geometry.euclidean.threed.Vector3D;
import org.hipparchus.util.FastMath;
import org.orekit.annotation.DefaultDataContext;
import org.orekit.data.DataContext;
import org.orekit.data.DataSource;
import org.orekit.errors.OrekitException;
import org.orekit.errors.OrekitIllegalArgumentException;
import org.orekit.errors.OrekitMessages;
import org.orekit.files.general.EphemerisFileParser;
import org.orekit.frames.Frame;
import org.orekit.gnss.IGSUtils;
import org.orekit.gnss.TimeSystem;
import org.orekit.time.AbsoluteDate;
import org.orekit.time.DateComponents;
import org.orekit.time.DateTimeComponents;
import org.orekit.time.TimeComponents;
import org.orekit.time.TimeScale;
import org.orekit.time.TimeScales;
import org.orekit.utils.CartesianDerivativesFilter;
import org.orekit.utils.Constants;

/** A parser for the SP3 orbit file format. It supports all formats from sp3-a
 * to sp3-d.
 * <p>
 * <b>Note:</b> this parser is thread-safe, so calling {@link #parse} from
 * different threads is allowed.
 * </p>
 * @see <a href="https://files.igs.org/pub/data/format/sp3_docu.txt">SP3-a file format</a>
 * @see <a href="https://files.igs.org/pub/data/format/sp3c.txt">SP3-c file format</a>
 * @see <a href="https://files.igs.org/pub/data/format/sp3d.pdf">SP3-d file format</a>
 * @author Thomas Neidhart
 * @author Luc Maisonobe
 */
public class SP3Parser implements EphemerisFileParser<SP3> {

    /** Default number of samples to use when interpolating SP3 coordinates. */
    public static final int DEFAULT_INTERPOLATION_SAMPLES = 7;

    /** Spaces delimiters. */
    private static final String SPACES = "\\s+";

    /** Standard gravitational parameter in m³/s². */
    private final double mu;

    /** Number of data points to use in interpolation. */
    private final int interpolationSamples;

    /** Mapping from frame identifier in the file to a {@link Frame}. */
    private final Function<? super String, ? extends Frame> frameBuilder;

    /** Set of time scales. */
    private final TimeScales timeScales;

    /**
     * Create an SP3 parser using default values.
     *
     * <p>This constructor uses the {@link DataContext#getDefault() default data context}.
     * It also uses a {@link #DEFAULT_INTERPOLATION_SAMPLES default number of samples} to
     * interpolate coordinates.
     *
     * @see #SP3Parser(double, int, Function)
     * @see IGSUtils#guessFrame(String)
     */
    @DefaultDataContext
    public SP3Parser() {
        this(Constants.EIGEN5C_EARTH_MU, DEFAULT_INTERPOLATION_SAMPLES, IGSUtils::guessFrame);
    }

    /**
     * Create an SP3 parser and specify the extra information needed to create a {@link
     * org.orekit.propagation.Propagator Propagator} from the ephemeris data.
     *
     * <p>This constructor uses the {@link DataContext#getDefault() default data context}.
     *
     * @param mu                   is the standard gravitational parameter to use for
     *                             creating {@link org.orekit.orbits.Orbit Orbits} from
     *                             the ephemeris data. See {@link Constants}.
     * @param interpolationSamples is the number of samples to use when interpolating.
     * @param frameBuilder         is a function that can construct a frame from an SP3
     *                             coordinate system string. The coordinate system can be
     *                             any 5 character string e.g. ITR92, IGb08.
     * @see #SP3Parser(double, int, Function, TimeScales)
     * @see IGSUtils#guessFrame(String)
     */
    @DefaultDataContext
    public SP3Parser(final double mu,
                     final int interpolationSamples,
                     final Function<? super String, ? extends Frame> frameBuilder) {
        this(mu, interpolationSamples, frameBuilder,
                DataContext.getDefault().getTimeScales());
    }

    /**
     * Create an SP3 parser and specify the extra information needed to create a {@link
     * org.orekit.propagation.Propagator Propagator} from the ephemeris data.
     *
     * @param mu                   is the standard gravitational parameter to use for
     *                             creating {@link org.orekit.orbits.Orbit Orbits} from
     *                             the ephemeris data. See {@link Constants}.
     * @param interpolationSamples is the number of samples to use when interpolating.
     * @param frameBuilder         is a function that can construct a frame from an SP3
     *                             coordinate system string. The coordinate system can be
     * @param timeScales           the set of time scales used for parsing dates.
     * @since 10.1
     */
    public SP3Parser(final double mu,
                     final int interpolationSamples,
                     final Function<? super String, ? extends Frame> frameBuilder,
                     final TimeScales timeScales) {
        this.mu                   = mu;
        this.interpolationSamples = interpolationSamples;
        this.frameBuilder         = frameBuilder;
        this.timeScales           = timeScales;
    }

    @Override
    public SP3 parse(final DataSource source) {

        try (Reader reader = source.getOpener().openReaderOnce();
             BufferedReader br = (reader == null) ? null : new BufferedReader(reader)) {

            if (br == null) {
                throw new OrekitException(OrekitMessages.UNABLE_TO_FIND_FILE, source.getName());
            }

            // initialize internal data structures
            final ParseInfo pi = new ParseInfo(source.getName(), this);

            int lineNumber = 0;
            Iterable<LineParser> candidateParsers = Collections.singleton(LineParser.HEADER_VERSION);
            nextLine:
                for (String line = br.readLine(); line != null; line = br.readLine()) {
                    ++lineNumber;
                    for (final LineParser candidate : candidateParsers) {
                        if (candidate.canHandle(line)) {
                            try {
                                candidate.parse(line, pi);
                                if (pi.done) {
                                    break nextLine;
                                }
                                candidateParsers = candidate.allowedNext();
                                continue nextLine;
                            } catch (StringIndexOutOfBoundsException | NumberFormatException e) {
                                throw new OrekitException(e,
                                                          OrekitMessages.UNABLE_TO_PARSE_LINE_IN_FILE,
                                                          lineNumber, pi.fileName, line);
                            }
                        }
                    }

                    // no parsers found for this line
                    throw new OrekitException(OrekitMessages.UNABLE_TO_PARSE_LINE_IN_FILE,
                                              lineNumber, pi.fileName, line);

                }

            pi.file.validate(true, pi.fileName);
            return pi.file;

        } catch (IOException ioe) {
            throw new OrekitException(ioe, LocalizedCoreFormats.SIMPLE_MESSAGE, ioe.getLocalizedMessage());
        }

    }

    /** Transient data used for parsing a sp3 file. The data is kept in a
     * separate data structure to make the parser thread-safe.
     * <p><b>Note</b>: The class intentionally does not provide accessor
     * methods, as it is only used internally for parsing a SP3 file.</p>
     */
    private static class ParseInfo {

        /** File name.
         * @since 12.0
         */
        private final String fileName;

        /** Englobing parser. */
        private final SP3Parser parser;

        /** The corresponding SP3File object. */
        private SP3 file;

        /** The latest epoch as read from the SP3 file. */
        private AbsoluteDate latestEpoch;

        /** The latest position as read from the SP3 file. */
        private Vector3D latestPosition;

        /** The latest position accuracy as read from the SP3 file.
         * @since 12.0
         */
        private Vector3D latestPositionAccuracy;

        /** The latest clock value as read from the SP3 file. */
        private double latestClock;

        /** The latest clock value as read from the SP3 file.
         * @since 12.0
         */
        private double latestClockAccuracy;

        /** The latest clock event flag as read from the SP3 file.
         * @since 12.0
         */
        private boolean latestClockEvent;

        /** The latest clock prediction flag as read from the SP3 file.
         * @since 12.0
         */
        private boolean latestClockPrediction;

        /** The latest orbit maneuver event flag as read from the SP3 file.
         * @since 12.0
         */
        private boolean latestOrbitManeuverEvent;

        /** The latest orbit prediction flag as read from the SP3 file.
         * @since 12.0
         */
        private boolean latestOrbitPrediction;

        /** Indicates if the SP3 file has velocity entries. */
        private boolean hasVelocityEntries;

        /** The timescale used in the SP3 file. */
        private TimeScale timeScale;

        /** Date and time of the file. */
        private DateTimeComponents epoch;

        /** The number of satellites as contained in the SP3 file. */
        private int maxSatellites;

        /** The number of satellites accuracies already seen. */
        private int nbAccuracies;

        /** End Of File reached indicator. */
        private boolean done;

        /** Create a new {@link ParseInfo} object.
         * @param fileName file name
         * @param parser englobing parser
         */
        protected ParseInfo(final String fileName,
                            final SP3Parser parser) {
            this.fileName      = fileName;
            this.parser        = parser;
            latestEpoch        = null;
            latestPosition     = null;
            latestClock        = 0.0;
            hasVelocityEntries = false;
            epoch              = DateTimeComponents.JULIAN_EPOCH;
            timeScale          = parser.timeScales.getGPS();
            maxSatellites      = 0;
            nbAccuracies       = 0;
            done               = false;
        }
    }

    /** Parsers for specific lines. */
    private enum LineParser {

        /** Parser for version, epoch, data used and agency information. */
        HEADER_VERSION("^#[a-z].*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                try (Scanner s1      = new Scanner(line);
                     Scanner s2      = s1.useDelimiter(SPACES);
                     Scanner scanner = s2.useLocale(Locale.US)) {
                    scanner.skip("#");
                    final String v = scanner.next();

                    final SP3Header header = new SP3Header();
                    header.setVersion(v.substring(0, 1).toLowerCase().charAt(0));

                    pi.hasVelocityEntries = "V".equals(v.substring(1, 2));
                    header.setFilter(pi.hasVelocityEntries ?
                                     CartesianDerivativesFilter.USE_PV :
                                     CartesianDerivativesFilter.USE_P);

                    final int    year   = Integer.parseInt(v.substring(2));
                    final int    month  = scanner.nextInt();
                    final int    day    = scanner.nextInt();
                    final int    hour   = scanner.nextInt();
                    final int    minute = scanner.nextInt();
                    final double second = scanner.nextDouble();

                    pi.epoch = new DateTimeComponents(year, month, day,
                                                      hour, minute, second);

                    final int numEpochs = scanner.nextInt();
                    header.setNumberOfEpochs(numEpochs);

                    // data used indicator
                    final String fullSpec = scanner.next();
                    final List<DataUsed> dataUsed = new ArrayList<>();
                    for (final String specifier : fullSpec.split("\\+")) {
                        dataUsed.add(DataUsed.parse(specifier, pi.fileName, header.getVersion()));
                    }
                    header.setDataUsed(dataUsed);

                    header.setCoordinateSystem(scanner.next());
                    header.setOrbitTypeKey(scanner.next());
                    header.setAgency(scanner.hasNext() ? scanner.next() : "");
                    pi.file = new SP3(header, pi.parser.mu, pi.parser.interpolationSamples,
                                      pi.parser.frameBuilder.apply(header.getCoordinateSystem()));
                }
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Collections.singleton(HEADER_DATE_TIME_REFERENCE);
            }

        },

        /** Parser for additional date/time references in gps/julian day notation. */
        HEADER_DATE_TIME_REFERENCE("^##.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                try (Scanner s1      = new Scanner(line);
                     Scanner s2      = s1.useDelimiter(SPACES);
                     Scanner scanner = s2.useLocale(Locale.US)) {
                    scanner.skip("##");

                    // gps week
                    pi.file.getHeader().setGpsWeek(scanner.nextInt());
                    // seconds of week
                    pi.file.getHeader().setSecondsOfWeek(scanner.nextDouble());
                    // epoch interval
                    pi.file.getHeader().setEpochInterval(scanner.nextDouble());
                    // modified julian day
                    pi.file.getHeader().setModifiedJulianDay(scanner.nextInt());
                    // day fraction
                    pi.file.getHeader().setDayFraction(scanner.nextDouble());
                }
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Collections.singleton(HEADER_SAT_IDS);
            }

        },

        /** Parser for satellites identifiers. */
        HEADER_SAT_IDS("^\\+ .*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {

                if (pi.maxSatellites == 0) {
                    // this is the first ids line, it also contains the number of satellites
                    pi.maxSatellites = Integer.parseInt(line.substring(3, 6).trim());
                }

                final int lineLength = line.length();
                int count = pi.file.getSatelliteCount();
                int startIdx = 9;
                while (count++ < pi.maxSatellites && (startIdx + 3) <= lineLength) {
                    final String satId = line.substring(startIdx, startIdx + 3).trim();
                    if (!satId.isEmpty()) {
                        pi.file.addSatellite(satId);
                    }
                    startIdx += 3;
                }
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(HEADER_SAT_IDS, HEADER_ACCURACY);
            }

        },

        /** Parser for general accuracy information for each satellite. */
        HEADER_ACCURACY("^\\+\\+.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                final int lineLength = line.length();
                int startIdx = 9;
                while (pi.nbAccuracies < pi.maxSatellites && (startIdx + 3) <= lineLength) {
                    final String sub = line.substring(startIdx, startIdx + 3).trim();
                    if (!sub.isEmpty()) {
                        final int exponent = Integer.parseInt(sub);
                        // the accuracy is calculated as 2**exp (in mm)
                        pi.file.getHeader().setAccuracy(pi.nbAccuracies++,
                                                        SP3Utils.siAccuracy(SP3Utils.POSITION_ACCURACY_UNIT,
                                                                            SP3Utils.POS_VEL_BASE_ACCURACY,
                                                                            exponent));
                    }
                    startIdx += 3;
                }
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(HEADER_ACCURACY, HEADER_TIME_SYSTEM);
            }

        },

        /** Parser for time system. */
        HEADER_TIME_SYSTEM("^%c.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {

                if (pi.file.getHeader().getType() == null) {
                    // this the first custom fields line, the only one really used
                    pi.file.getHeader().setType(SP3FileType.parse(line.substring(3, 5).trim()));

                    // now identify the time system in use
                    final String tsStr = line.substring(9, 12).trim();
                    final TimeSystem ts;
                    if (tsStr.equalsIgnoreCase("ccc")) {
                        ts = TimeSystem.GPS;
                    } else {
                        ts = TimeSystem.parseTimeSystem(tsStr);
                    }
                    pi.file.getHeader().setTimeSystem(ts);
                    pi.timeScale = ts.getTimeScale(pi.parser.timeScales);

                    // now we know the time scale used, we can set the file epoch
                    pi.file.getHeader().setEpoch(new AbsoluteDate(pi.epoch, pi.timeScale));
                }

            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(HEADER_TIME_SYSTEM, HEADER_STANDARD_DEVIATIONS);
            }

        },

        /** Parser for standard deviations of position/velocity/clock components. */
        HEADER_STANDARD_DEVIATIONS("^%f.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                final double posVelBase = Double.parseDouble(line.substring(3, 13).trim());
                if (posVelBase != 0.0) {
                    // (mm or 10⁻⁴ mm/s)
                    pi.file.getHeader().setPosVelBase(posVelBase);
                }

                final double clockBase = Double.parseDouble(line.substring(14, 26).trim());
                if (clockBase != 0.0) {
                    // (ps or 10⁻⁴ ps/s)
                    pi.file.getHeader().setClockBase(clockBase);
                }
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(HEADER_STANDARD_DEVIATIONS, HEADER_CUSTOM_PARAMETERS);
            }

        },

        /** Parser for custom parameters. */
        HEADER_CUSTOM_PARAMETERS("^%i.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                // ignore additional custom parameters
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(HEADER_CUSTOM_PARAMETERS, HEADER_COMMENTS);
            }

        },

        /** Parser for comments. */
        HEADER_COMMENTS("^[%]?/\\*.*|") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                pi.file.getHeader().addComment(line.substring(line.indexOf('*') + 1).trim());
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(HEADER_COMMENTS, DATA_EPOCH);
            }

        },

        /** Parser for epoch. */
        DATA_EPOCH("^\\* .*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                final int    year;
                final int    month;
                final int    day;
                final int    hour;
                final int    minute;
                final double second;
                try (Scanner s1      = new Scanner(line);
                     Scanner s2      = s1.useDelimiter(SPACES);
                     Scanner scanner = s2.useLocale(Locale.US)) {
                    scanner.skip("\\*");
                    year   = scanner.nextInt();
                    month  = scanner.nextInt();
                    day    = scanner.nextInt();
                    hour   = scanner.nextInt();
                    minute = scanner.nextInt();
                    second = scanner.nextDouble();
                }

                // some SP3 files have weird epochs as in the following three examples, where
                // the middle dates are wrong
                //
                // *  2016  7  6 16 58  0.00000000
                // PL51  11872.234459   3316.551981    101.400098 999999.999999
                // VL51   8054.606014 -27076.640110 -53372.762255 999999.999999
                // *  2016  7  6 16 60  0.00000000
                // PL51  11948.228978   2986.113872   -538.901114 999999.999999
                // VL51   4605.419303 -27972.588048 -53316.820671 999999.999999
                // *  2016  7  6 17  2  0.00000000
                // PL51  11982.652569   2645.786926  -1177.549463 999999.999999
                // VL51   1128.248622 -28724.293303 -53097.358387 999999.999999
                //
                // *  2016  7  6 23 58  0.00000000
                // PL51   3215.382310  -7958.586164   8812.395707
                // VL51 -18058.659942 -45834.335707 -34496.540437
                // *  2016  7  7 24  0  0.00000000
                // PL51   2989.229334  -8494.421415   8385.068555
                // VL51 -19617.027447 -43444.824985 -36706.159070
                // *  2016  7  7  0  2  0.00000000
                // PL51   2744.983592  -9000.639164   7931.904779
                // VL51 -21072.925764 -40899.633288 -38801.567078
                //
                // * 2021 12 31  0  0  0.00000000
                // PL51   6578.459330   5572.231927  -8703.502054
                // VL51  -5356.007694 -48869.881161 -35036.676469
                // * 2022  1  0  0  2  0.00000000
                // PL51   6499.035610   4978.263048  -9110.135595
                // VL51  -7881.633197 -50092.564035 -32717.740919
                // * 2022  1  0  0  4  0.00000000
                // PL51   6389.313975   4370.794537  -9488.314264
                // VL51 -10403.797055 -51119.231402 -30295.421935
                // In the first case, the date should really be 2016  7  6 17  0  0.00000000,
                // i.e as the minutes field overflows, the hours field should be incremented
                // In the second case, the date should really be 2016  7  7  0  0  0.00000000,
                // i.e. as the hours field overflows, the day field should be kept as is
                // we cannot be sure how carry was managed when these bogus files were written
                // so we try different options, incrementing or not previous field, and selecting
                // the closest one to expected date
                // In the third case, there are two different errors: the date is globally
                // shifted to the left by one character, and the day is 0 instead of 1
                DateComponents dc = day == 0 ?
                                    new DateComponents(new DateComponents(year, month, 1), -1) :
                                    new DateComponents(year, month, day);
                final List<AbsoluteDate> candidates = new ArrayList<>();
                int h = hour;
                int m = minute;
                double s = second;
                if (s >= 60.0) {
                    s -= 60;
                    addCandidate(candidates, dc, h, m, s, pi.timeScale);
                    m++;
                }
                if (m > 59) {
                    m = 0;
                    addCandidate(candidates, dc, h, m, s, pi.timeScale);
                    h++;
                }
                if (h > 23) {
                    h = 0;
                    addCandidate(candidates, dc, h, m, s, pi.timeScale);
                    dc = new DateComponents(dc, 1);
                }
                addCandidate(candidates, dc, h, m, s, pi.timeScale);
                final AbsoluteDate expected = pi.latestEpoch == null ?
                                              pi.file.getHeader().getEpoch() :
                                              pi.latestEpoch.shiftedBy(pi.file.getHeader().getEpochInterval());
                pi.latestEpoch = null;
                for (final AbsoluteDate candidate : candidates) {
                    if (FastMath.abs(candidate.durationFrom(expected)) < 0.01 * pi.file.getHeader().getEpochInterval()) {
                        pi.latestEpoch = candidate;
                    }
                }
                if (pi.latestEpoch == null) {
                    // no date recognized, just parse again the initial fields
                    // in order to generate again an exception
                    pi.latestEpoch = new AbsoluteDate(year, month, day, hour, minute, second, pi.timeScale);
                }

            }

            /** Add an epoch candidate to a list.
             * @param candidates list of candidates
             * @param dc date components
             * @param hour hour number from 0 to 23
             * @param minute minute number from 0 to 59
             * @param second second number from 0.0 to 60.0 (excluded)
             * @param timeScale time scale
             * @since 11.1.1
             */
            private void addCandidate(final List<AbsoluteDate> candidates, final DateComponents dc,
                                      final int hour, final int minute, final double second,
                                      final TimeScale timeScale) {
                try {
                    candidates.add(new AbsoluteDate(dc, new TimeComponents(hour, minute, second), timeScale));
                } catch (OrekitIllegalArgumentException oiae) {
                    // ignored
                }
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Collections.singleton(DATA_POSITION);
            }

        },

        /** Parser for position. */
        DATA_POSITION("^P.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                final String satelliteId = line.substring(1, 4).trim();

                if (!pi.file.containsSatellite(satelliteId)) {
                    pi.latestPosition = Vector3D.ZERO;
                } else {

                    final SP3Header header = pi.file.getHeader();

                    // the position values are in km and have to be converted to m
                    pi.latestPosition = new Vector3D(SP3Utils.POSITION_UNIT.toSI(Double.parseDouble(line.substring(4, 18).trim())),
                                                     SP3Utils.POSITION_UNIT.toSI(Double.parseDouble(line.substring(18, 32).trim())),
                                                     SP3Utils.POSITION_UNIT.toSI(Double.parseDouble(line.substring(32, 46).trim())));

                    // clock (microsec)
                    final double clockField = line.trim().length() <= 46 ?
                                              SP3Utils.DEFAULT_CLOCK_VALUE :
                                              Double.parseDouble(line.substring(46, 60).trim());
                    pi.latestClock = FastMath.abs(clockField - SP3Utils.DEFAULT_CLOCK_VALUE) < 1.0e-6 ?
                                     Double.NaN : SP3Utils.CLOCK_UNIT.toSI(clockField);

                    if (pi.latestPosition.getNorm() > 0) {

                        if (line.length() < 69 ||
                            line.substring(61, 63).trim().isEmpty() ||
                            line.substring(64, 66).trim().isEmpty() ||
                            line.substring(67, 69).trim().isEmpty()) {
                            pi.latestPositionAccuracy = null;
                        } else {
                            pi.latestPositionAccuracy = new Vector3D(SP3Utils.siAccuracy(SP3Utils.POSITION_ACCURACY_UNIT,
                                                                                         header.getPosVelBase(),
                                                                                         Integer.parseInt(line.substring(61, 63).trim())),
                                                                     SP3Utils.siAccuracy(SP3Utils.POSITION_ACCURACY_UNIT,
                                                                                         header.getPosVelBase(),
                                                                                         Integer.parseInt(line.substring(64, 66).trim())),
                                                                     SP3Utils.siAccuracy(SP3Utils.POSITION_ACCURACY_UNIT,
                                                                                         header.getPosVelBase(),
                                                                                         Integer.parseInt(line.substring(67, 69).trim())));
                        }

                        if (line.length() < 73 || line.substring(70, 73).trim().isEmpty()) {
                            pi.latestClockAccuracy    = Double.NaN;
                        } else {
                            pi.latestClockAccuracy    = SP3Utils.siAccuracy(SP3Utils.CLOCK_ACCURACY_UNIT,
                                                                            header.getClockBase(),
                                                                            Integer.parseInt(line.substring(70, 73).trim()));
                        }

                        pi.latestClockEvent         = line.length() >= 75 && line.charAt(74) == 'E';
                        pi.latestClockPrediction    = line.length() >= 76 && line.charAt(75) == 'P';
                        pi.latestOrbitManeuverEvent = line.length() >= 79 && line.charAt(78) == 'M';
                        pi.latestOrbitPrediction    = line.length() >= 80 && line.charAt(79) == 'P';

                        if (!pi.hasVelocityEntries) {
                            final SP3Coordinate coord =
                                            new SP3Coordinate(pi.latestEpoch,
                                                              pi.latestPosition,           pi.latestPositionAccuracy,
                                                              Vector3D.ZERO,               null,
                                                              pi.latestClock,              pi.latestClockAccuracy,
                                                              0.0,                         Double.NaN,
                                                              pi.latestClockEvent,         pi.latestClockPrediction,
                                                              pi.latestOrbitManeuverEvent, pi.latestOrbitPrediction);
                            pi.file.getEphemeris(satelliteId).addCoordinate(coord, header.getEpochInterval());
                        }
                    }
                }
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(DATA_EPOCH, DATA_POSITION, DATA_POSITION_CORRELATION, DATA_VELOCITY, EOF);
            }

        },

        /** Parser for position correlation. */
        DATA_POSITION_CORRELATION("^EP.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                // ignored for now
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(DATA_EPOCH, DATA_POSITION, DATA_VELOCITY, EOF);
            }

        },

        /** Parser for velocity. */
        DATA_VELOCITY("^V.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                final String satelliteId = line.substring(1, 4).trim();

                if (pi.file.containsSatellite(satelliteId) && pi.latestPosition.getNorm() > 0) {

                    final SP3Header header = pi.file.getHeader();

                    // the velocity values are in dm/s and have to be converted to m/s
                    final Vector3D velocity = new Vector3D(SP3Utils.VELOCITY_UNIT.toSI(Double.parseDouble(line.substring(4, 18).trim())),
                                                           SP3Utils.VELOCITY_UNIT.toSI(Double.parseDouble(line.substring(18, 32).trim())),
                                                           SP3Utils.VELOCITY_UNIT.toSI(Double.parseDouble(line.substring(32, 46).trim())));

                    // clock rate in file is 1e-4 us / s
                    final double clockRateField = line.trim().length() <= 46 ?
                                                  SP3Utils.DEFAULT_CLOCK_RATE_VALUE :
                                                  Double.parseDouble(line.substring(46, 60).trim());
                    final double clockRateChange = FastMath.abs(clockRateField - SP3Utils.DEFAULT_CLOCK_RATE_VALUE) < 1.0e-6 ?
                                                   Double.NaN : SP3Utils.CLOCK_RATE_UNIT.toSI(clockRateField);

                    final Vector3D velocityAccuracy;
                    if (line.length() < 69 ||
                        line.substring(61, 63).trim().isEmpty() ||
                        line.substring(64, 66).trim().isEmpty() ||
                        line.substring(67, 69).trim().isEmpty()) {
                        velocityAccuracy  = null;
                    } else {
                        velocityAccuracy = new Vector3D(SP3Utils.siAccuracy(SP3Utils.VELOCITY_ACCURACY_UNIT,
                                                                            header.getPosVelBase(),
                                                                            Integer.parseInt(line.substring(61, 63).trim())),
                                                        SP3Utils.siAccuracy(SP3Utils.VELOCITY_ACCURACY_UNIT,
                                                                            header.getPosVelBase(),
                                                                            Integer.parseInt(line.substring(64, 66).trim())),
                                                        SP3Utils.siAccuracy(SP3Utils.VELOCITY_ACCURACY_UNIT,
                                                                            header.getPosVelBase(),
                                                                            Integer.parseInt(line.substring(67, 69).trim())));
                    }

                    final double clockRateAccuracy;
                    if (line.length() < 73 || line.substring(70, 73).trim().isEmpty()) {
                        clockRateAccuracy = Double.NaN;
                    } else {
                        clockRateAccuracy = SP3Utils.siAccuracy(SP3Utils.CLOCK_RATE_ACCURACY_UNIT,
                                                                header.getClockBase(),
                                                                Integer.parseInt(line.substring(70, 73).trim()));
                    }

                    final SP3Coordinate coord =
                            new SP3Coordinate(pi.latestEpoch,
                                              pi.latestPosition,           pi.latestPositionAccuracy,
                                              velocity,                    velocityAccuracy,
                                              pi.latestClock,              pi.latestClockAccuracy,
                                              clockRateChange,             clockRateAccuracy,
                                              pi.latestClockEvent,         pi.latestClockPrediction,
                                              pi.latestOrbitManeuverEvent, pi.latestOrbitPrediction);
                    pi.file.getEphemeris(satelliteId).addCoordinate(coord, header.getEpochInterval());
                }
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(DATA_EPOCH, DATA_POSITION, DATA_VELOCITY_CORRELATION, EOF);
            }

        },

        /** Parser for velocity correlation. */
        DATA_VELOCITY_CORRELATION("^EV.*") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                // ignored for now
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Arrays.asList(DATA_EPOCH, DATA_POSITION, EOF);
            }

        },

        /** Parser for End Of File marker. */
        EOF("^[eE][oO][fF]\\s*$") {

            /** {@inheritDoc} */
            @Override
            public void parse(final String line, final ParseInfo pi) {
                pi.done = true;
            }

            /** {@inheritDoc} */
            @Override
            public Iterable<LineParser> allowedNext() {
                return Collections.singleton(EOF);
            }

        };

        /** Pattern for identifying line. */
        private final Pattern pattern;

        /** Simple constructor.
         * @param lineRegexp regular expression for identifying line
         */
        LineParser(final String lineRegexp) {
            pattern = Pattern.compile(lineRegexp);
        }

        /** Parse a line.
         * @param line line to parse
         * @param pi holder for transient data
         */
        public abstract void parse(String line, ParseInfo pi);

        /** Get the allowed parsers for next line.
         * @return allowed parsers for next line
         */
        public abstract Iterable<LineParser> allowedNext();

        /** Check if parser can handle line.
         * @param line line to parse
         * @return true if parser can handle the specified line
         */
        public boolean canHandle(final String line) {
            return pattern.matcher(line).matches();
        }

    }

}