YUMAParser.java

/* Copyright 2002-2020 CS Group
 * 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.gnss;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

import org.hipparchus.util.Pair;
import org.orekit.annotation.DefaultDataContext;
import org.orekit.data.AbstractSelfFeedingLoader;
import org.orekit.data.DataContext;
import org.orekit.data.DataLoader;
import org.orekit.data.DataProvidersManager;
import org.orekit.errors.OrekitException;
import org.orekit.errors.OrekitMessages;
import org.orekit.time.AbsoluteDate;
import org.orekit.time.GNSSDate;
import org.orekit.time.TimeScales;


/**
 * This class reads Yuma almanac files and provides {@link GPSAlmanac GPS almanacs}.
 *
 * <p>The definition of a Yuma almanac comes from the
 * <a href="http://www.navcen.uscg.gov/?pageName=gpsYuma">U.S. COAST GUARD NAVIGATION CENTER</a>.</p>
 *
 * <p>The format of the files holding Yuma almanacs is not precisely specified,
 * so the parsing rules have been deduced from the downloadable files at
 * <a href="http://www.navcen.uscg.gov/?pageName=gpsAlmanacs">NAVCEN</a>
 * and at <a href="https://celestrak.com/GPS/almanac/Yuma/">CelesTrak</a>.</p>
 *
 * @author Pascal Parraud
 * @since 8.0
 *
 */
public class YUMAParser extends AbstractSelfFeedingLoader implements DataLoader {

    // Constants
    /** The source of the almanacs. */
    private static final String SOURCE = "YUMA";

    /** the useful keys in the YUMA file. */
    private static final String[] KEY = {
        "id", // ID
        "health", // Health
        "eccentricity", // Eccentricity
        "time", // Time of Applicability(s)
        "orbital", // Orbital Inclination(rad)
        "rate", // Rate of Right Ascen(r/s)
        "sqrt", // SQRT(A)  (m 1/2)
        "right", // Right Ascen at Week(rad)
        "argument", // Argument of Perigee(rad)
        "mean", // Mean Anom(rad)
        "af0", // Af0(s)
        "af1", // Af1(s/s)
        "week" // week
    };

    /** Default supported files name pattern. */
    private static final String DEFAULT_SUPPORTED_NAMES = ".*\\.alm$";

    // Fields
    /** the list of all the almanacs read from the file. */
    private final List<GPSAlmanac> almanacs;

    /** the list of all the PRN numbers of all the almanacs read from the file. */
    private final List<Integer> prnList;

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

    /** Simple constructor.
    *
    * <p>This constructor does not load any data by itself. Data must be loaded
    * later on by calling one of the {@link #loadData() loadData()} method or
    * the {@link #loadData(InputStream, String) loadData(inputStream, fileName)}
    * method.</p>
     *
     * <p>The supported files names are used when getting data from the
     * {@link #loadData() loadData()} method that relies on the
     * {@link DataContext#getDefault() default data context}. They are useless when
     * getting data from the {@link #loadData(InputStream, String) loadData(input, name)}
     * method.</p>
     *
     * @param supportedNames regular expression for supported files names
     * (if null, a default pattern matching files with a ".alm" extension will be used)
     * @see #loadData()
     * @see #YUMAParser(String, DataProvidersManager, TimeScales)
    */
    @DefaultDataContext
    public YUMAParser(final String supportedNames) {
        this(supportedNames,
                DataContext.getDefault().getDataProvidersManager(),
                DataContext.getDefault().getTimeScales());
    }

    /**
     * Create a YUMA loader/parser with the given source for YUMA auxiliary data files.
     *
     * <p>This constructor does not load any data by itself. Data must be loaded
     * later on by calling one of the {@link #loadData() loadData()} method or
     * the {@link #loadData(InputStream, String) loadData(inputStream, fileName)}
     * method.</p>
     *
     * <p>The supported files names are used when getting data from the
     * {@link #loadData() loadData()} method that relies on the
     * {@code dataProvidersManager}. They are useless when
     * getting data from the {@link #loadData(InputStream, String) loadData(input, name)}
     * method.</p>
     *
     * @param supportedNames regular expression for supported files names
     * (if null, a default pattern matching files with a ".alm" extension will be used)
     * @param dataProvidersManager provides access to auxiliary data.
     * @param timeScales to use when parsing the GPS dates.
     * @see #loadData()
     * @since 10.1
     */
    public YUMAParser(final String supportedNames,
                      final DataProvidersManager dataProvidersManager,
                      final TimeScales timeScales) {
        super((supportedNames == null) ? DEFAULT_SUPPORTED_NAMES : supportedNames,
                dataProvidersManager);
        this.almanacs = new ArrayList<>();
        this.prnList = new ArrayList<>();
        this.timeScales = timeScales;
    }

    /**
     * Loads almanacs.
     *
     * <p>The almanacs already loaded in the instance will be discarded
     * and replaced by the newly loaded data.</p>
     * <p>This feature is useful when the file selection is already set up by
     * the {@link DataProvidersManager data providers manager} configuration.</p>
     *
     */
    public void loadData() {
        // load the data from the configured data providers
        feed(this);
        if (almanacs.isEmpty()) {
            throw new OrekitException(OrekitMessages.NO_YUMA_ALMANAC_AVAILABLE);
        }
    }

    @Override
    public void loadData(final InputStream input, final String name)
        throws IOException, ParseException, OrekitException {

        // Clears the lists
        almanacs.clear();
        prnList.clear();

        // Creates the reader
        final BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));

        try {
            // Gathers data to create one GPSAlmanac from 13 consecutive lines
            final List<Pair<String, String>> entries =
                    new ArrayList<>(KEY.length);

            // Reads the data one line at a time
            for (String line = reader.readLine(); line != null; line = reader.readLine()) {
                // Try to split the line into 2 tokens as key:value
                final String[] token = line.trim().split(":");
                // If the line is made of 2 tokens
                if (token.length == 2) {
                    // Adds these tokens as an entry to the entries
                    entries.add(new Pair<>(token[0].trim(), token[1].trim()));
                }
                // If the number of entries equals the expected number
                if (entries.size() == KEY.length) {
                    // Gets a GPSAlmanac from the entries
                    final GPSAlmanac almanac = getAlmanac(entries, name);
                    // Adds the GPSAlmanac to the list
                    almanacs.add(almanac);
                    // Adds the PRN number of the GPSAlmanac to the list
                    prnList.add(almanac.getPRN());
                    // Clears the entries
                    entries.clear();
                }
            }
        } catch (IOException ioe) {
            throw new OrekitException(ioe, OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
                                      name);
        }
    }

    @Override
    public boolean stillAcceptsData() {
        return almanacs.isEmpty();
    }

    @Override
    public String getSupportedNames() {
        return super.getSupportedNames();
    }

    /**
     * Gets all the {@link GPSAlmanac GPS almanacs} read from the file.
     *
     * @return the list of {@link GPSAlmanac} from the file
     */
    public List<GPSAlmanac> getAlmanacs() {
        return almanacs;
    }

    /**
     * Gets the PRN numbers of all the {@link GPSAlmanac GPS almanacs} read from the file.
     *
     * @return the PRN numbers of all the {@link GPSAlmanac GPS almanacs} read from the file
     */
    public List<Integer> getPRNNumbers() {
        return prnList;
    }

    /**
     * Builds a {@link GPSAlmanac GPS almanac} from data read in the file.
     *
     * @param entries the data read from the file
     * @param name name of the file
     * @return a {@link GPSAlmanac GPS almanac}
     */
    private GPSAlmanac getAlmanac(final List<Pair<String, String>> entries, final String name) {
        try {
            // Initializes fields
            int prn = 0;
            int health = 0;
            int week = 0;
            double ecc = 0;
            double toa = 0;
            double inc = 0;
            double dom = 0;
            double sqa = 0;
            double om0 = 0;
            double aop = 0;
            double anom = 0;
            double af0 = 0;
            double af1 = 0;
            // Initializes checks
            final boolean[] checks = new boolean[KEY.length];
            // Loop over entries
            for (Pair<String, String> entry: entries) {
                final String lowerCaseKey = entry.getKey().toLowerCase(Locale.US);
                if (lowerCaseKey.startsWith(KEY[0])) {
                    // Gets the PRN of the SVN
                    prn = Integer.parseInt(entry.getValue());
                    checks[0] = true;
                } else if (lowerCaseKey.startsWith(KEY[1])) {
                    // Gets the Health status
                    health = Integer.parseInt(entry.getValue());
                    checks[1] = true;
                } else if (lowerCaseKey.startsWith(KEY[2])) {
                    // Gets the eccentricity
                    ecc = Double.parseDouble(entry.getValue());
                    checks[2] = true;
                } else if (lowerCaseKey.startsWith(KEY[3])) {
                    // Gets the Time of Applicability
                    toa = Double.parseDouble(entry.getValue());
                    checks[3] = true;
                } else if (lowerCaseKey.startsWith(KEY[4])) {
                    // Gets the Inclination
                    inc = Double.parseDouble(entry.getValue());
                    checks[4] = true;
                } else if (lowerCaseKey.startsWith(KEY[5])) {
                    // Gets the Rate of Right Ascension
                    dom = Double.parseDouble(entry.getValue());
                    checks[5] = true;
                } else if (lowerCaseKey.startsWith(KEY[6])) {
                    // Gets the square root of the semi-major axis
                    sqa = Double.parseDouble(entry.getValue());
                    checks[6] = true;
                } else if (lowerCaseKey.startsWith(KEY[7])) {
                    // Gets the Right Ascension of Ascending Node
                    om0 = Double.parseDouble(entry.getValue());
                    checks[7] = true;
                } else if (lowerCaseKey.startsWith(KEY[8])) {
                    // Gets the Argument of Perigee
                    aop = Double.parseDouble(entry.getValue());
                    checks[8] = true;
                } else if (lowerCaseKey.startsWith(KEY[9])) {
                    // Gets the Mean Anomalie
                    anom = Double.parseDouble(entry.getValue());
                    checks[9] = true;
                } else if (lowerCaseKey.startsWith(KEY[10])) {
                    // Gets the SV clock bias
                    af0 = Double.parseDouble(entry.getValue());
                    checks[10] = true;
                } else if (lowerCaseKey.startsWith(KEY[11])) {
                    // Gets the SV clock Drift
                    af1 = Double.parseDouble(entry.getValue());
                    checks[11] = true;
                } else if (lowerCaseKey.startsWith(KEY[12])) {
                    // Gets the week number
                    week = Integer.parseInt(entry.getValue());
                    checks[12] = true;
                } else {
                    // Unknown entry: the file is not a YUMA file
                    throw new OrekitException(OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
                                              name);
                }
            }

            // If all expected fields have been read
            if (readOK(checks)) {
                // Returns a GPSAlmanac built from the entries
                final AbsoluteDate date =
                        new GNSSDate(week, toa * 1000, SatelliteSystem.GPS, timeScales)
                                .getDate();
                return new GPSAlmanac(SOURCE, prn, -1, week, toa, sqa, ecc, inc, om0, dom,
                                      aop, anom, af0, af1, health, -1, -1, date);
            } else {
                // The file is not a YUMA file
                throw new OrekitException(OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
                                          name);
            }
        } catch (NumberFormatException nfe) {
            throw new OrekitException(nfe, OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
                                      name);
        }
    }

    /** Checks if all expected fields have been read.
     * @param checks flags for read fields
     * @return true if all expected fields have been read, false if not
     */
    private boolean readOK(final boolean[] checks) {
        for (boolean check: checks) {
            if (!check) {
                return false;
            }
        }
        return true;
    }
}