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