1   /* Copyright 2002-2025 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.propagation.analytical.gnss.data.GPSAlmanac;
39  import org.orekit.time.TimeScales;
40  
41  
42  /**
43   * This class reads Yuma almanac files and provides {@link GPSAlmanac GPS almanacs}.
44   *
45   * <p>The definition of a Yuma almanac comes from the
46   * <a href="http://www.navcen.uscg.gov/?pageName=gpsYuma">U.S. COAST GUARD NAVIGATION CENTER</a>.</p>
47   *
48   * <p>The format of the files holding Yuma almanacs is not precisely specified,
49   * so the parsing rules have been deduced from the downloadable files at
50   * <a href="http://www.navcen.uscg.gov/?pageName=gpsAlmanacs">NAVCEN</a>
51   * and at <a href="https://celestrak.com/GPS/almanac/Yuma/">CelesTrak</a>.</p>
52   *
53   * @author Pascal Parraud
54   * @since 8.0
55   *
56   */
57  public class YUMAParser extends AbstractSelfFeedingLoader implements DataLoader {
58  
59      // Constants
60      /** The source of the almanacs. */
61      private static final String SOURCE = "YUMA";
62  
63      /** the useful keys in the YUMA file. */
64      private static final String[] KEY = {
65          "id", // ID
66          "health", // Health
67          "eccentricity", // Eccentricity
68          "time", // Time of Applicability(s)
69          "orbital", // Orbital Inclination(rad)
70          "rate", // Rate of Right Ascen(r/s)
71          "sqrt", // SQRT(A)  (m 1/2)
72          "right", // Right Ascen at Week(rad)
73          "argument", // Argument of Perigee(rad)
74          "mean", // Mean Anom(rad)
75          "af0", // Af0(s)
76          "af1", // Af1(s/s)
77          "week" // week
78      };
79  
80      /** Default supported files name pattern. */
81      private static final String DEFAULT_SUPPORTED_NAMES = ".*\\.alm$";
82  
83      /** Pattern for delimiting regular expressions. */
84      private static final Pattern SEPARATOR = Pattern.compile(":");
85  
86      // Fields
87      /** the list of all the almanacs read from the file. */
88      private final List<GPSAlmanac> almanacs;
89  
90      /** the list of all the PRN numbers of all the almanacs read from the file. */
91      private final List<Integer> prnList;
92  
93      /** Set of time scales to use. */
94      private final TimeScales timeScales;
95  
96      /** Simple constructor.
97      *
98      * <p>This constructor does not load any data by itself. Data must be loaded
99      * later on by calling one of the {@link #loadData() loadData()} method or
100     * the {@link #loadData(InputStream, String) loadData(inputStream, fileName)}
101     * method.</p>
102      *
103      * <p>The supported files names are used when getting data from the
104      * {@link #loadData() loadData()} method that relies on the
105      * {@link DataContext#getDefault() default data context}. They are useless when
106      * getting data from the {@link #loadData(InputStream, String) loadData(input, name)}
107      * method.</p>
108      *
109      * @param supportedNames regular expression for supported files names
110      * (if null, a default pattern matching files with a ".alm" extension will be used)
111      * @see #loadData()
112      * @see #YUMAParser(String, DataProvidersManager, TimeScales)
113     */
114     @DefaultDataContext
115     public YUMAParser(final String supportedNames) {
116         this(supportedNames,
117                 DataContext.getDefault().getDataProvidersManager(),
118                 DataContext.getDefault().getTimeScales());
119     }
120 
121     /**
122      * Create a YUMA loader/parser with the given source for YUMA auxiliary data files.
123      *
124      * <p>This constructor does not load any data by itself. Data must be loaded
125      * later on by calling one of the {@link #loadData() loadData()} method or
126      * the {@link #loadData(InputStream, String) loadData(inputStream, fileName)}
127      * method.</p>
128      *
129      * <p>The supported files names are used when getting data from the
130      * {@link #loadData() loadData()} method that relies on the
131      * {@code dataProvidersManager}. They are useless when
132      * getting data from the {@link #loadData(InputStream, String) loadData(input, name)}
133      * method.</p>
134      *
135      * @param supportedNames regular expression for supported files names
136      * (if null, a default pattern matching files with a ".alm" extension will be used)
137      * @param dataProvidersManager provides access to auxiliary data.
138      * @param timeScales to use when parsing the GPS dates.
139      * @see #loadData()
140      * @since 10.1
141      */
142     public YUMAParser(final String supportedNames,
143                       final DataProvidersManager dataProvidersManager,
144                       final TimeScales timeScales) {
145         super((supportedNames == null) ? DEFAULT_SUPPORTED_NAMES : supportedNames,
146                 dataProvidersManager);
147         this.almanacs = new ArrayList<>();
148         this.prnList = new ArrayList<>();
149         this.timeScales = timeScales;
150     }
151 
152     /**
153      * Loads almanacs.
154      *
155      * <p>The almanacs already loaded in the instance will be discarded
156      * and replaced by the newly loaded data.</p>
157      * <p>This feature is useful when the file selection is already set up by
158      * the {@link DataProvidersManager data providers manager} configuration.</p>
159      *
160      */
161     public void loadData() {
162         // load the data from the configured data providers
163         feed(this);
164         if (almanacs.isEmpty()) {
165             throw new OrekitException(OrekitMessages.NO_YUMA_ALMANAC_AVAILABLE);
166         }
167     }
168 
169     @Override
170     public void loadData(final InputStream input, final String name)
171         throws IOException, ParseException, OrekitException {
172 
173         // Clears the lists
174         almanacs.clear();
175         prnList.clear();
176 
177         // Creates the reader
178         try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
179             // Gathers data to create one GPSAlmanac from 13 consecutive lines
180             final List<Pair<String, String>> entries =
181                     new ArrayList<>(KEY.length);
182 
183             // Reads the data one line at a time
184             for (String line = reader.readLine(); line != null; line = reader.readLine()) {
185                 // Try to split the line into 2 tokens as key:value
186                 final String[] token = SEPARATOR.split(line.trim());
187                 // If the line is made of 2 tokens
188                 if (token.length == 2) {
189                     // Adds these tokens as an entry to the entries
190                     entries.add(new Pair<>(token[0].trim(), token[1].trim()));
191                 }
192                 // If the number of entries equals the expected number
193                 if (entries.size() == KEY.length) {
194                     // Gets a GPSAlmanac from the entries
195                     final GPSAlmanac almanac = getAlmanac(entries, name);
196                     // Adds the GPSAlmanac to the list
197                     almanacs.add(almanac);
198                     // Adds the PRN number of the GPSAlmanac to the list
199                     prnList.add(almanac.getPRN());
200                     // Clears the entries
201                     entries.clear();
202                 }
203             }
204         } catch (IOException ioe) {
205             throw new OrekitException(ioe, OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
206                                       name);
207         }
208     }
209 
210     @Override
211     public boolean stillAcceptsData() {
212         return almanacs.isEmpty();
213     }
214 
215     @Override
216     public String getSupportedNames() {
217         return super.getSupportedNames();
218     }
219 
220     /**
221      * Gets all the {@link GPSAlmanac GPS almanacs} read from the file.
222      *
223      * @return the list of {@link GPSAlmanac} from the file
224      */
225     public List<GPSAlmanac> getAlmanacs() {
226         return almanacs;
227     }
228 
229     /**
230      * Gets the PRN numbers of all the {@link GPSAlmanac GPS almanacs} read from the file.
231      *
232      * @return the PRN numbers of all the {@link GPSAlmanac GPS almanacs} read from the file
233      */
234     public List<Integer> getPRNNumbers() {
235         return prnList;
236     }
237 
238     /**
239      * Builds a {@link GPSAlmanac GPS almanac} from data read in the file.
240      *
241      * @param entries the data read from the file
242      * @param name name of the file
243      * @return a {@link GPSAlmanac GPS almanac}
244      */
245     private GPSAlmanac getAlmanac(final List<Pair<String, String>> entries, final String name) {
246         try {
247             // Initializes almanac and set the source
248             final GPSAlmanac almanac = new GPSAlmanac(timeScales, SatelliteSystem.GPS);
249             almanac.setSource(SOURCE);
250 
251             // Initializes checks
252             final boolean[] checks = new boolean[KEY.length];
253             // Loop over entries
254             for (Pair<String, String> entry: entries) {
255                 final String lowerCaseKey = entry.getKey().toLowerCase(Locale.US);
256                 if (lowerCaseKey.startsWith(KEY[0])) {
257                     // Gets the PRN of the SVN
258                     almanac.setPRN(Integer.parseInt(entry.getValue()));
259                     checks[0] = true;
260                 } else if (lowerCaseKey.startsWith(KEY[1])) {
261                     // Gets the Health status
262                     almanac.setHealth(Integer.parseInt(entry.getValue()));
263                     checks[1] = true;
264                 } else if (lowerCaseKey.startsWith(KEY[2])) {
265                     // Gets the eccentricity
266                     almanac.setE(Double.parseDouble(entry.getValue()));
267                     checks[2] = true;
268                 } else if (lowerCaseKey.startsWith(KEY[3])) {
269                     // Gets the Time of Applicability
270                     almanac.setTime(Double.parseDouble(entry.getValue()));
271                     checks[3] = true;
272                 } else if (lowerCaseKey.startsWith(KEY[4])) {
273                     // Gets the Inclination
274                     almanac.setI0(Double.parseDouble(entry.getValue()));
275                     checks[4] = true;
276                 } else if (lowerCaseKey.startsWith(KEY[5])) {
277                     // Gets the Rate of Right Ascension
278                     almanac.setOmegaDot(Double.parseDouble(entry.getValue()));
279                     checks[5] = true;
280                 } else if (lowerCaseKey.startsWith(KEY[6])) {
281                     // Gets the square root of the semi-major axis
282                     almanac.setSqrtA(Double.parseDouble(entry.getValue()));
283                     checks[6] = true;
284                 } else if (lowerCaseKey.startsWith(KEY[7])) {
285                     // Gets the Right Ascension of Ascending Node
286                     almanac.setOmega0(Double.parseDouble(entry.getValue()));
287                     checks[7] = true;
288                 } else if (lowerCaseKey.startsWith(KEY[8])) {
289                     // Gets the Argument of Perigee
290                     almanac.setPa(Double.parseDouble(entry.getValue()));
291                     checks[8] = true;
292                 } else if (lowerCaseKey.startsWith(KEY[9])) {
293                     // Gets the Mean Anomalie
294                     almanac.setM0(Double.parseDouble(entry.getValue()));
295                     checks[9] = true;
296                 } else if (lowerCaseKey.startsWith(KEY[10])) {
297                     // Gets the SV clock bias
298                     almanac.setAf0(Double.parseDouble(entry.getValue()));
299                     checks[10] = true;
300                 } else if (lowerCaseKey.startsWith(KEY[11])) {
301                     // Gets the SV clock Drift
302                     almanac.setAf1(Double.parseDouble(entry.getValue()));
303                     checks[11] = true;
304                 } else if (lowerCaseKey.startsWith(KEY[12])) {
305                     // Gets the week number
306                     almanac.setWeek(Integer.parseInt(entry.getValue()));
307                     checks[12] = true;
308                 } else {
309                     // Unknown entry: the file is not a YUMA file
310                     throw new OrekitException(OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
311                                               name);
312                 }
313             }
314 
315             // If all expected fields have been read
316             if (readOK(checks)) {
317 
318                 // Add default values to missing keys
319                 almanac.setSVN(-1);
320                 almanac.setURA(-1);
321                 almanac.setSatConfiguration(-1);
322 
323                 return almanac;
324             } else {
325                 // The file is not a YUMA file
326                 throw new OrekitException(OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
327                                           name);
328             }
329         } catch (NumberFormatException nfe) {
330             throw new OrekitException(nfe, OrekitMessages.NOT_A_SUPPORTED_YUMA_ALMANAC_FILE,
331                                       name);
332         }
333     }
334 
335     /** Checks if all expected fields have been read.
336      * @param checks flags for read fields
337      * @return true if all expected fields have been read, false if not
338      */
339     private boolean readOK(final boolean[] checks) {
340         for (boolean check: checks) {
341             if (!check) {
342                 return false;
343             }
344         }
345         return true;
346     }
347 }