View Javadoc
1   package nom.tam.fits;
2   
3   import java.text.DecimalFormat;
4   
5   /*
6    * #%L
7    * nom.tam FITS library
8    * %%
9    * Copyright (C) 1996 - 2024 nom-tam-fits
10   * %%
11   * This is free and unencumbered software released into the public domain.
12   *
13   * Anyone is free to copy, modify, publish, use, compile, sell, or
14   * distribute this software, either in source code form or as a compiled
15   * binary, for any purpose, commercial or non-commercial, and by any
16   * means.
17   *
18   * In jurisdictions that recognize copyright laws, the author or authors
19   * of this software dedicate any and all copyright interest in the
20   * software to the public domain. We make this dedication for the benefit
21   * of the public at large and to the detriment of our heirs and
22   * successors. We intend this dedication to be an overt act of
23   * relinquishment in perpetuity of all present and future rights to this
24   * software under copyright law.
25   *
26   * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
27   * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
28   * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
29   * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
30   * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
31   * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
32   * OTHER DEALINGS IN THE SOFTWARE.
33   * #L%
34   */
35  
36  import java.util.Calendar;
37  import java.util.Date;
38  import java.util.GregorianCalendar;
39  import java.util.TimeZone;
40  import java.util.regex.Matcher;
41  import java.util.regex.Pattern;
42  
43  /**
44   * ISO timestamp support for FITS headers. Such timestamps are used with <code>DATE</code> style header keywords, such
45   * as <code>DATE-OBS</code> or <code>DATE-END</code>.
46   */
47  public class FitsDate implements Comparable<FitsDate> {
48  
49      private int year = -1;
50  
51      private int month = -1;
52  
53      private int mday = -1;
54  
55      private int hour = -1;
56  
57      private int minute = -1;
58  
59      private int second = -1;
60  
61      private int millisecond = -1;
62  
63      /**
64       * Convert a FITS date string to a Java <CODE>Date</CODE> object.
65       *
66       * @param  dStr          the FITS date
67       *
68       * @throws FitsException if <CODE>dStr</CODE> does not contain a valid FITS date.
69       */
70      public FitsDate(String dStr) throws FitsException {
71          // if the date string is null, we are done
72          if (dStr == null || dStr.isEmpty()) {
73              return;
74          }
75  
76          Matcher match = NEW_REGEX.matcher(dStr);
77          if (match.matches()) {
78              // The regex match ensures we can never get a NumberFormatException here...
79              year = Integer.parseInt(match.group(NEW_FORMAT_YEAR_GROUP));
80              month = getInt(match.group(NEW_FORMAT_MONTH_GROUP));
81              mday = getInt(match.group(NEW_FORMAT_DAY_OF_MONTH_GROUP));
82              hour = getInt(match.group(NEW_FORMAT_HOUR_GROUP));
83              minute = getInt(match.group(NEW_FORMAT_MINUTE_GROUP));
84              second = getInt(match.group(NEW_FORMAT_SECOND_GROUP));
85              millisecond = getMilliseconds(match.group(NEW_FORMAT_MILLISECOND_GROUP));
86              if (millisecond >= 1000) {
87                  second++;
88                  millisecond -= 1000;
89              }
90          } else {
91              // The regex match ensures we can never get a NumberFormatException here...
92              match = OLD_REGEX.matcher(dStr);
93              if (!match.matches()) {
94                  if (dStr.trim().isEmpty()) {
95                      return;
96                  }
97                  throw new FitsException("Bad FITS date string \"" + dStr + '"');
98              }
99              year = 1900 + getInt(match.group(OLD_FORMAT_YEAR_GROUP));
100             month = getInt(match.group(OLD_FORMAT_MONTH_GROUP));
101             mday = getInt(match.group(OLD_FORMAT_DAY_OF_MONTH_GROUP));
102         }
103     }
104 
105     private static int getInt(String value) throws NumberFormatException {
106         return (value != null) ? Integer.parseInt(value) : -1;
107     }
108 
109     private static int getMilliseconds(String value) throws NumberFormatException {
110         return (value == null || value.length() <= 1) ? -1 : (int) Math.round(1000.0 * Double.parseDouble(value));
111     }
112 
113     /**
114      * Get a Java Date object corresponding to this FITS date.
115      *
116      * @return The Java Date object.
117      */
118     public Date toDate() {
119         if (month == -1) {
120             return null;
121         }
122 
123         Calendar cal = new GregorianCalendar(UTC);
124 
125         if (year > 0) {
126             cal.set(Calendar.ERA, GregorianCalendar.AD);
127             cal.set(Calendar.YEAR, year);
128         } else {
129             cal.set(Calendar.ERA, GregorianCalendar.BC);
130             cal.set(Calendar.YEAR, 1 - year);
131         }
132         cal.set(Calendar.MONTH, month - 1);
133         cal.set(Calendar.DAY_OF_MONTH, mday);
134 
135         if (hour == -1) {
136             cal.set(Calendar.HOUR_OF_DAY, 0);
137             cal.set(Calendar.MINUTE, 0);
138             cal.set(Calendar.SECOND, 0);
139             cal.set(Calendar.MILLISECOND, 0);
140         } else {
141             cal.set(Calendar.HOUR_OF_DAY, hour);
142             cal.set(Calendar.MINUTE, minute);
143             cal.set(Calendar.SECOND, second);
144             if (millisecond == -1) {
145                 cal.set(Calendar.MILLISECOND, 0);
146             } else {
147                 cal.set(Calendar.MILLISECOND, millisecond);
148             }
149         }
150         return cal.getTime();
151     }
152 
153     @Override
154     public String toString() {
155         if (month == -1) {
156             return "";
157         }
158 
159         // Delegate to the centralized Date -> FITS formatter, but keep the original ".000"
160         // omission for values parsed without a fractional-seconds component.
161         String formatted = getFitsDateString(toDate(), hour != -1);
162         if (hour != -1 && millisecond == -1) {
163             return formatted.substring(0, formatted.lastIndexOf('.'));
164         }
165         return formatted;
166     }
167 
168     @Override
169     public boolean equals(Object o) {
170         if (o == this) {
171             return true;
172         }
173         if (!(o instanceof FitsDate)) {
174             return false;
175         }
176 
177         return compareTo((FitsDate) o) == 0;
178     }
179 
180     @Override
181     public int hashCode() {
182         return Integer.hashCode(year) ^ Integer.hashCode(month) ^ Integer.hashCode(mday) ^ Integer.hashCode(hour)
183                 ^ Integer.hashCode(minute) ^ Integer.hashCode(second) ^ Integer.hashCode(millisecond);
184     }
185 
186     @Override
187     public int compareTo(FitsDate fitsDate) {
188         int result = Integer.compare(year, fitsDate.year);
189         if (result != 0) {
190             return result;
191         }
192 
193         result = Integer.compare(month, fitsDate.month);
194         if (result != 0) {
195             return result;
196         }
197 
198         result = Integer.compare(mday, fitsDate.mday);
199         if (result != 0) {
200             return result;
201         }
202 
203         result = Integer.compare(hour, fitsDate.hour);
204         if (result != 0) {
205             return result;
206         }
207 
208         result = Integer.compare(minute, fitsDate.minute);
209         if (result != 0) {
210             return result;
211         }
212 
213         result = Integer.compare(second, fitsDate.second);
214         if (result != 0) {
215             return result;
216         }
217 
218         return Integer.compare(millisecond, fitsDate.millisecond);
219     }
220 
221     /**
222      * Returns the FITS date string for the current date and time.
223      * 
224      * @return the current date in FITS date format
225      * 
226      * @see    #getFitsDateString(Date)
227      */
228     public static String getFitsDateString() {
229         return getFitsDateString(new Date(), true);
230     }
231 
232     /**
233      * Returns the FITS date string for a specific date and time
234      * 
235      * @return       a created FITS format date string Java Date object.
236      *
237      * @param  epoch The epoch to be converted to FITS format.
238      * 
239      * @see          #getFitsDateString(Date, boolean)
240      * @see          #getFitsDateString()
241      */
242     public static String getFitsDateString(Date epoch) {
243         return getFitsDateString(epoch, true);
244     }
245 
246     /**
247      * Returns the FITS date string, with or without the time component, for a specific date and time.
248      * <p>
249      * Years are formatted per FITS Section 9.1.1 ("[{+,-}C]CCYY"): <code>0</code>-<code>9999</code> as an unsigned
250      * 4-digit value, <code>10000</code>-<code>99999</code> as <code>+</code> followed by 5 digits, and
251      * <code>-1</code>-<code>-99999</code> as <code>-</code> followed by 5 digits.
252      * </p>
253      * 
254      * @return               a created FITS format date string. Note that the date is not rounded.
255      *
256      * @param  epoch         The epoch to be converted to FITS format.
257      * @param  timeOfDay     Whether the time of day information shouldd be included
258      * 
259      * @throws FitsException if the year of <code>epoch</code> is outside of the range <code>-99999</code> to
260      *                           <code>99999</code> that a FITS date can represent.
261      *
262      * @see                  #getFitsDateString(Date)
263      * @see                  #getFitsDateString()
264      */
265     public static String getFitsDateString(Date epoch, boolean timeOfDay) {
266         Calendar cal = new GregorianCalendar(UTC);
267         cal.setTime(epoch);
268 
269         int fitsYear = toFitsYear(cal.get(Calendar.ERA), cal.get(Calendar.YEAR));
270 
271         StringBuilder fitsDate = new StringBuilder(FITS_DATE_STRING_SIZE);
272         appendYear(fitsDate, fitsYear);
273         fitsDate.append('-');
274         appendTwoDigitValue(fitsDate, cal.get(Calendar.MONTH) + 1);
275         fitsDate.append('-');
276         appendTwoDigitValue(fitsDate, cal.get(Calendar.DAY_OF_MONTH));
277 
278         if (timeOfDay) {
279             fitsDate.append('T');
280             appendTwoDigitValue(fitsDate, cal.get(Calendar.HOUR_OF_DAY));
281             fitsDate.append(':');
282             appendTwoDigitValue(fitsDate, cal.get(Calendar.MINUTE));
283             fitsDate.append(':');
284             appendTwoDigitValue(fitsDate, cal.get(Calendar.SECOND));
285             fitsDate.append('.');
286             appendThreeDigitValue(fitsDate, cal.get(Calendar.MILLISECOND));
287         }
288         return fitsDate.toString();
289     }
290 
291     /**
292      * Converts a {@link Calendar} era/year pair into the signed FITS year (BC 1 is FITS 0, BC 2 is FITS -1, etc.).
293      */
294     private static int toFitsYear(int era, int calendarYear) {
295         if (era == GregorianCalendar.BC) {
296             return 1 - calendarYear;
297         }
298         return calendarYear;
299     }
300 
301     /**
302      * Appends the FITS Section 9.1.1 representation of a signed astronomical year to the buffer.
303      *
304      * @throws FitsException if the year is outside of the range that FITS can represent.
305      */
306     private static void appendYear(StringBuilder buf, int year) {
307         if (year < -MAX_FITS_YEAR || year > MAX_FITS_YEAR) {
308             throw new FitsException("Year " + year + " is outside of the range [-" + MAX_FITS_YEAR + ":" + MAX_FITS_YEAR
309                     + "] that a FITS date can represent");
310         }
311         if (year < 0 || year > 9999) {
312             buf.append(YEAR_FORMAT_5_DIGITS.format(year));
313         } else {
314             buf.append(YEAR_FORMAT_4_DIGITS.format(year));
315         }
316     }
317 
318     private static void appendThreeDigitValue(StringBuilder buf, int value) {
319         if (value <= 99) {
320             buf.append('0');
321         }
322         appendTwoDigitValue(buf, value);
323     }
324 
325     private static void appendTwoDigitValue(StringBuilder buf, int value) {
326         if (value <= 9) {
327             buf.append('0');
328         }
329         buf.append(value);
330     }
331 
332     /** FITS dates are always UTC dates */
333     private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
334 
335     private static final Pattern NEW_REGEX = Pattern
336             .compile("\\s*((\\d{4}|[+-]\\d{5})-(\\d\\d)-(\\d\\d))(T(\\d\\d):(\\d\\d):(\\d\\d)(\\.(\\d+)?)?)?\\s*");
337 
338     private static final int NEW_FORMAT_YEAR_GROUP = 2;
339 
340     private static final int NEW_FORMAT_MONTH_GROUP = 3;
341 
342     private static final int NEW_FORMAT_DAY_OF_MONTH_GROUP = 4;
343 
344     private static final int NEW_FORMAT_HOUR_GROUP = 6;
345 
346     private static final int NEW_FORMAT_MINUTE_GROUP = 7;
347 
348     private static final int NEW_FORMAT_SECOND_GROUP = 8;
349 
350     private static final int NEW_FORMAT_MILLISECOND_GROUP = 9;
351 
352     private static final Pattern OLD_REGEX = Pattern.compile("\\s*(\\d\\d)/(\\d\\d)/(\\d\\d)\\s*");
353 
354     private static final int OLD_FORMAT_DAY_OF_MONTH_GROUP = 1;
355 
356     private static final int OLD_FORMAT_MONTH_GROUP = 2;
357 
358     private static final int OLD_FORMAT_YEAR_GROUP = 3;
359 
360     /**
361      * The largest (and, negated, the smallest) year value permitted by FITS Section 9.1.1 ("[{+,-}C]CCYY" extended to 5
362      * digits).
363      */
364     private static final int MAX_FITS_YEAR = 99999;
365 
366     private static final int FITS_DATE_STRING_SIZE = 25;
367 
368     private static final DecimalFormat YEAR_FORMAT_4_DIGITS = new DecimalFormat("0000");
369 
370     private static final DecimalFormat YEAR_FORMAT_5_DIGITS = new DecimalFormat("+00000;-00000");
371 }