1   /* Copyright 2002-2026 CS GROUP
2    * Licensed to CS GROUP (CS) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * CS licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *   http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.orekit.gnss;
18  
19  import java.io.BufferedReader;
20  import java.io.IOException;
21  import java.io.InputStream;
22  import java.io.InputStreamReader;
23  import java.nio.charset.StandardCharsets;
24  import java.text.ParseException;
25  import java.util.ArrayList;
26  import java.util.List;
27  import java.util.Locale;
28  import java.util.regex.Pattern;
29  
30  import org.hipparchus.util.Pair;
31  import org.orekit.annotation.DefaultDataContext;
32  import org.orekit.data.AbstractSelfFeedingLoader;
33  import org.orekit.data.DataContext;
34  import org.orekit.data.DataLoader;
35  import org.orekit.data.DataProvidersManager;
36  import org.orekit.errors.OrekitException;
37  import org.orekit.errors.OrekitMessages;
38  import org.orekit.frames.Frame;
39  import org.orekit.propagation.analytical.gnss.data.GNSSOrbitalElementsFactory;
40  import org.orekit.propagation.analytical.gnss.data.GPSAlmanac;
41  import org.orekit.propagation.analytical.gnss.data.GPSAlmanacFactory;
42  import org.orekit.time.GNSSDate;
43  import org.orekit.time.TimeScales;
44  import org.orekit.utils.IERSConventions;
45  import org.orekit.utils.ParameterDriversList;
46  
47  /**
48   * This class reads Yuma almanac files and provides {@link GPSAlmanac GPS almanacs}.
49   *
50   * <p>The definition of a Yuma almanac comes from the
51   * <a href="http://www.navcen.uscg.gov/?pageName=gpsYuma">U.S. COAST GUARD NAVIGATION CENTER</a>.</p>
52   *
53   * <p>The format of the files holding Yuma almanacs is not precisely specified,
54   * so the parsing rules have been deduced from the downloadable files at
55   * <a href="http://www.navcen.uscg.gov/?pageName=gpsAlmanacs">NAVCEN</a>
56   * and at <a href="https://celestrak.com/GPS/almanac/Yuma/">CelesTrak</a>.</p>
57   *
58   * @author Pascal Parraud
59   * @since 8.0
60   *
61   */
62  public class YUMAParser extends AbstractSelfFeedingLoader implements DataLoader {
63  
64      // Constants
65      /** The source of the almanacs. */
66      private static final String SOURCE = "YUMA";
67  
68      /** the useful keys in the YUMA file. */
69      private static final String[] KEY = {
70          "id", // ID
71          "health", // Health
72          "eccentricity", // Eccentricity
73          "time", // Time of Applicability(s)
74          "orbital", // Orbital Inclination(rad)
75          "rate", // Rate of Right Ascen(r/s)
76          "sqrt", // SQRT(A)  (m 1/2)
77          "right", // Right Ascen at Week(rad)
78          "argument", // Argument of Periapsis(rad)
79          "mean", // Mean Anom(rad)
80          "af0", // Af0(s)
81          "af1", // Af1(s/s)
82          "week" // week
83      };
84  
85      /** Default supported files name pattern. */
86      private static final String DEFAULT_SUPPORTED_NAMES = ".*\\.alm$";
87  
88      /** Pattern for delimiting regular expressions. */
89      private static final Pattern SEPARATOR = Pattern.compile(":");
90  
91      // Fields
92      /** the list of all the almanacs read from the file. */
93      private final List<GPSAlmanac> almanacs;
94  
95      /** the list of all the PRN numbers of all the almanacs read from the file. */
96      private final List<Integer> prnList;
97  
98      /** Set of time scales to use. */
99      private final TimeScales timeScales;
100 
101     /** Reference inertial frame.
102      * @since 14.0
103      */
104     private final Frame inertial;
105 
106     /** Body fixed frame.
107      * @since 14.0
108      */
109     private final Frame bodyFixed;
110 
111     /** Simple constructor.
112     *
113     * <p>This constructor does not load any data by itself. Data must be loaded
114     * later on by calling one of the {@link #loadData() loadData()} method or
115     * the {@link #loadData(InputStream, String) loadData(inputStream, fileName)}
116     * method.</p>
117      *
118      * <p>The supported files names are used when getting data from the
119      * {@link #loadData() loadData()} method that relies on the
120      * {@link DataContext#getDefault() default data context}. The frames
121      * are set to EME2000 and ITRF with IERS 2010 conventions. They are useless when
122      * getting data from the {@link #loadData(InputStream, String) loadData(input, name)}
123      * method.</p>
124      *
125      * @param supportedNames regular expression for supported files names
126      * (if null, a default pattern matching files with a ".alm" extension will be used)
127      * @see #loadData()
128      * @see #YUMAParser(String, DataProvidersManager, TimeScales, Frame, Frame)
129     */
130     @DefaultDataContext
131     public YUMAParser(final String supportedNames) {
132         this(supportedNames,
133                 DataContext.getDefault().getDataProvidersManager(),
134                 DataContext.getDefault().getTimeScales(),
135                 DataContext.getDefault().getFrames().getEME2000(),
136                 DataContext.getDefault().getFrames().getITRF(IERSConventions.IERS_2010, false));
137     }
138 
139     /**
140      * Create a YUMA loader/parser with the given source for YUMA auxiliary data files.
141      *
142      * <p>This constructor does not load any data by itself. Data must be loaded
143      * later on by calling one of the {@link #loadData() loadData()} method or
144      * the {@link #loadData(InputStream, String) loadData(inputStream, fileName)}
145      * method.</p>
146      *
147      * <p>The supported files names are used when getting data from the
148      * {@link #loadData() loadData()} method that relies on the
149      * {@code dataProvidersManager}. They are useless when
150      * getting data from the {@link #loadData(InputStream, String) loadData(input, name)}
151      * method.</p>
152      *
153      * @param supportedNames regular expression for supported files names
154      * (if null, a default pattern matching files with a ".alm" extension will be used)
155      * @param dataProvidersManager provides access to auxiliary data.
156      * @param timeScales to use when parsing the GPS dates.
157      * @param inertial   reference inertial frame
158      * @param bodyFixed  body fixed frame
159      * @since 14.0
160      * @see #loadData()
161      */
162     public YUMAParser(final String supportedNames,
163                       final DataProvidersManager dataProvidersManager,
164                       final TimeScales timeScales, final Frame inertial, final Frame bodyFixed) {
165         super((supportedNames == null) ? DEFAULT_SUPPORTED_NAMES : supportedNames,
166                 dataProvidersManager);
167         this.almanacs   = new ArrayList<>();
168         this.prnList    = new ArrayList<>();
169         this.timeScales = timeScales;
170         this.inertial   = inertial;
171         this.bodyFixed  = bodyFixed;
172     }
173 
174     /**
175      * Loads almanacs.
176      *
177      * <p>The almanacs already loaded in the instance will be discarded
178      * and replaced by the newly loaded data.</p>
179      * <p>This feature is useful when the file selection is already set up by
180      * the {@link DataProvidersManager data providers manager} configuration.</p>
181      *
182      */
183     public void loadData() {
184         // load the data from the configured data providers
185         feed(this);
186         if (almanacs.isEmpty()) {
187             throw new OrekitException(OrekitMessages.NO_YUMA_ALMANAC_AVAILABLE);
188         }
189     }
190 
191     @Override
192     public void loadData(final InputStream input, final String name)
193         throws IOException, ParseException, OrekitException {
194 
195         // Clears the lists
196         almanacs.clear();
197         prnList.clear();
198 
199         // Creates the reader
200         try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
201             // Gathers data to create one GPSAlmanac from 13 consecutive lines
202             final List<Pair<String, String>> entries =
203                     new ArrayList<>(KEY.length);
204 
205             // Reads the data one line at a time
206             for (String line = reader.readLine(); line != null; line = reader.readLine()) {
207                 // Try to split the line into 2 tokens as key:value
208                 final String[] token = SEPARATOR.split(line.trim());
209                 // If the line is made of 2 tokens
210                 if (token.length == 2) {
211                     // Adds these tokens as an entry to the entries
212                     entries.add(new Pair<>(token[0].trim(), token[1].trim()));
213                 }
214                 // If the number of entries equals the expected number
215                 if (entries.size() == KEY.length) {
216                     // Gets a GPSAlmanac from the entries
217                     final GPSAlmanac almanac = getAlmanac(entries, name);
218                     // Adds the GPSAlmanac to the list
219                     almanacs.add(almanac);
220                     // Adds the PRN number of the GPSAlmanac to the list
221                     prnList.add(almanac.getPrn());
222                     // Clears the entries
223                     entries.clear();
224                 }
225             }
226         } catch (IOException ioe) {
227             throw new OrekitException(ioe, OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
228                                       name);
229         }
230     }
231 
232     @Override
233     public boolean stillAcceptsData() {
234         return almanacs.isEmpty();
235     }
236 
237     @Override
238     public String getSupportedNames() {
239         return super.getSupportedNames();
240     }
241 
242     /**
243      * Gets all the {@link GPSAlmanac GPS almanacs} read from the file.
244      *
245      * @return the list of {@link GPSAlmanac} from the file
246      */
247     public List<GPSAlmanac> getAlmanacs() {
248         return almanacs;
249     }
250 
251     /**
252      * Gets the PRN numbers of all the {@link GPSAlmanac GPS almanacs} read from the file.
253      *
254      * @return the PRN numbers of all the {@link GPSAlmanac GPS almanacs} read from the file
255      */
256     public List<Integer> getPRNNumbers() {
257         return prnList;
258     }
259 
260     /**
261      * Builds a {@link GPSAlmanac GPS almanac} from data read in the file.
262      *
263      * @param entries the data read from the file
264      * @param name name of the file
265      * @return a {@link GPSAlmanac GPS almanac}
266      */
267     private GPSAlmanac getAlmanac(final List<Pair<String, String>> entries, final String name) {
268         try {
269             // Initializes almanac and set the source
270             final GPSAlmanacFactory factory =
271                 new GPSAlmanacFactory(timeScales, SatelliteSystem.GPS, inertial, bodyFixed);
272             factory.setSource(SOURCE);
273 
274             // Initializes checks
275             final boolean[] checks = new boolean[KEY.length];
276             // Loop over entries
277             final ParameterDriversList orb = factory.getOrbitalParametersDrivers();
278             for (Pair<String, String> entry: entries) {
279                 final String lowerCaseKey = entry.getKey().toLowerCase(Locale.US);
280                 if (lowerCaseKey.startsWith(KEY[0])) {
281                     // Gets the PRN of the SVN
282                     factory.setPrn(Integer.parseInt(entry.getValue()));
283                     checks[0] = true;
284                 } else if (lowerCaseKey.startsWith(KEY[1])) {
285                     // Gets the Health status
286                     factory.setHealth(Integer.parseInt(entry.getValue()));
287                     checks[1] = true;
288                 } else if (lowerCaseKey.startsWith(KEY[2])) {
289                     // Gets the eccentricity
290                     orb.findByName(GNSSOrbitalElementsFactory.ECCENTRICITY).
291                         setValue(Double.parseDouble(entry.getValue()));
292                     checks[2] = true;
293                 } else if (lowerCaseKey.startsWith(KEY[3])) {
294                     // Gets the Time of Applicability
295                     factory.getTimeDriver().setValue(Double.parseDouble(entry.getValue()));
296                     checks[3] = true;
297                 } else if (lowerCaseKey.startsWith(KEY[4])) {
298                     // Gets the Inclination
299                     orb.findByName(GNSSOrbitalElementsFactory.INCLINATION).
300                         setValue(Double.parseDouble(entry.getValue()));
301                     checks[4] = true;
302                 } else if (lowerCaseKey.startsWith(KEY[5])) {
303                     // Gets the Rate of Right Ascension
304                     factory.getOmegaDotDriver().setValue(Double.parseDouble(entry.getValue()));
305                     checks[5] = true;
306                 } else if (lowerCaseKey.startsWith(KEY[6])) {
307                     // Gets the square root of the semi-major axis
308                     final double sqrtA = Double.parseDouble(entry.getValue());
309                     orb.findByName(GNSSOrbitalElementsFactory.SEMI_MAJOR_AXIS).
310                         setValue(sqrtA * sqrtA);
311                     checks[6] = true;
312                 } else if (lowerCaseKey.startsWith(KEY[7])) {
313                     // Gets the Right Ascension of Ascending Node
314                     orb.findByName(GNSSOrbitalElementsFactory.NODE_LONGITUDE).
315                         setValue(Double.parseDouble(entry.getValue()));
316                     checks[7] = true;
317                 } else if (lowerCaseKey.startsWith(KEY[8])) {
318                     // Gets the Argument of Periapsis
319                     orb.findByName(GNSSOrbitalElementsFactory.ARGUMENT_OF_PERIAPSIS).
320                         setValue(Double.parseDouble(entry.getValue()));
321                     checks[8] = true;
322                 } else if (lowerCaseKey.startsWith(KEY[9])) {
323                     // Gets the Mean Anomaly
324                     orb.findByName(GNSSOrbitalElementsFactory.MEAN_ANOMALY).
325                         setValue(Double.parseDouble(entry.getValue()));
326                     checks[9] = true;
327                 } else if (lowerCaseKey.startsWith(KEY[10])) {
328                     // Gets the SV clock bias
329                     factory.getAf0Driver().setValue(Double.parseDouble(entry.getValue()));
330                     checks[10] = true;
331                 } else if (lowerCaseKey.startsWith(KEY[11])) {
332                     // Gets the SV clock Drift
333                     factory.getAf1Driver().setValue(Double.parseDouble(entry.getValue()));
334                     checks[11] = true;
335                 } else if (lowerCaseKey.startsWith(KEY[12])) {
336                     // Gets the week number
337                     factory.setTimeOfEphemeris(new GNSSDate(Integer.parseInt(entry.getValue()),
338                                                             factory.getTimeDriver().getValue(),
339                                                             factory.getSystem()));
340                     checks[12] = true;
341                 } else {
342                     // Unknown entry: the file is not a YUMA file
343                     throw new OrekitException(OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
344                                               name);
345                 }
346             }
347 
348             // If all expected fields have been read
349             if (readOK(checks)) {
350 
351                 // Add default values to missing keys
352                 factory.setSvn(-1);
353                 factory.setUra(-1);
354                 factory.setSatConfiguration(-1);
355 
356                 return factory.createFromDrivers();
357             } else {
358                 // The file is not a YUMA file
359                 throw new OrekitException(OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
360                                           name);
361             }
362         } catch (NumberFormatException nfe) {
363             throw new OrekitException(nfe, OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
364                                       name);
365         }
366     }
367 
368     /** Checks if all expected fields have been read.
369      * @param checks flags for read fields
370      * @return true if all expected fields have been read, false if not
371      */
372     private boolean readOK(final boolean[] checks) {
373         for (boolean check: checks) {
374             if (!check) {
375                 return false;
376             }
377         }
378         return true;
379     }
380 }