/
fitstime.py
655 lines (556 loc) · 23.8 KB
/
fitstime.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
# Licensed under a 3-clause BSD style license - see LICENSE.rst
import re
import warnings
from collections import OrderedDict, defaultdict
import numpy as np
from astropy import units as u
from astropy.coordinates import EarthLocation
from astropy.table import Column, MaskedColumn
from astropy.table.column import col_copy
from astropy.time import Time, TimeDelta
from astropy.time.core import BARYCENTRIC_SCALES
from astropy.time.formats import FITS_DEPRECATED_SCALES
from astropy.utils.exceptions import AstropyUserWarning
from . import Card, Header
# The following is based on the FITS WCS Paper IV, "Representations of time
# coordinates in FITS".
# https://ui.adsabs.harvard.edu/abs/2015A%26A...574A..36R
# FITS WCS standard specified "4-3" form for non-linear coordinate types
TCTYP_RE_TYPE = re.compile(r"(?P<type>[A-Z]+)[-]+")
TCTYP_RE_ALGO = re.compile(r"(?P<algo>[A-Z]+)\s*")
# FITS Time standard specified time units
FITS_TIME_UNIT = ["s", "d", "a", "cy", "min", "h", "yr", "ta", "Ba"]
# Global time reference coordinate keywords
OBSGEO_XYZ = ("OBSGEO-X", "OBSGEO-Y", "OBSGEO-Z")
OBSGEO_LBH = ("OBSGEO-L", "OBSGEO-B", "OBSGEO-H")
TIME_KEYWORDS = (
(
"DATE",
"DATE-AVG",
"DATE-BEG",
"DATE-END",
"DATE-OBS",
"DATEREF",
"JDREF",
"MJD-AVG",
"MJD-BEG",
"MJD-END",
"MJD-OBS",
"MJDREF",
"TIMEOFFS",
"TIMESYS",
"TIMEUNIT",
"TREFDIR",
"TREFPOS",
)
+ OBSGEO_LBH
+ OBSGEO_XYZ
)
# Column-specific time override keywords
COLUMN_TIME_KEYWORDS = ("TCTYP", "TCUNI", "TRPOS")
# Column-specific keywords regex
COLUMN_TIME_KEYWORD_REGEXP = f"({'|'.join(COLUMN_TIME_KEYWORDS)})[0-9]+"
def is_time_column_keyword(keyword):
"""
Check if the FITS header keyword is a time column-specific keyword.
Parameters
----------
keyword : str
FITS keyword.
"""
return re.match(COLUMN_TIME_KEYWORD_REGEXP, keyword) is not None
# Set astropy time global information
GLOBAL_TIME_INFO = {
"TIMESYS": ("UTC", "Default time scale"),
"JDREF": (0.0, "Time columns are jd = jd1 + jd2"),
"TREFPOS": ("TOPOCENTER", "Time reference position"),
}
def _verify_global_info(global_info):
"""
Given the global time reference frame information, verify that
each global time coordinate attribute will be given a valid value.
Parameters
----------
global_info : dict
Global time reference frame information.
"""
# Translate FITS deprecated scale into astropy scale, or else just convert
# to lower case for further checks.
global_info["scale"] = FITS_DEPRECATED_SCALES.get(
global_info["TIMESYS"], global_info["TIMESYS"].lower()
)
# Verify global time scale
if global_info["scale"] not in Time.SCALES:
# 'GPS' and 'LOCAL' are FITS recognized time scale values
# but are not supported by astropy.
if global_info["scale"] == "gps":
warnings.warn(
"Global time scale (TIMESYS) has a FITS recognized time scale "
'value "GPS". In Astropy, "GPS" is a time from epoch format '
"which runs synchronously with TAI; GPS is approximately 19 s "
"ahead of TAI. Hence, this format will be used.",
AstropyUserWarning,
)
# Assume that the values are in GPS format
global_info["scale"] = "tai"
global_info["format"] = "gps"
if global_info["scale"] == "local":
warnings.warn(
"Global time scale (TIMESYS) has a FITS recognized time scale "
'value "LOCAL". However, the standard states that "LOCAL" should be '
"tied to one of the existing scales because it is intrinsically "
"unreliable and/or ill-defined. Astropy will thus use the default "
'global time scale "UTC" instead of "LOCAL".',
AstropyUserWarning,
)
# Default scale 'UTC'
global_info["scale"] = "utc"
global_info["format"] = None
else:
raise AssertionError(
"Global time scale (TIMESYS) should have a FITS recognized "
"time scale value (got {!r}). The FITS standard states that "
"the use of local time scales should be restricted to alternate "
"coordinates.".format(global_info["TIMESYS"])
)
else:
# Scale is already set
global_info["format"] = None
# Check if geocentric global location is specified
obs_geo = [global_info[attr] for attr in OBSGEO_XYZ if attr in global_info]
# Location full specification is (X, Y, Z)
if len(obs_geo) == 3:
global_info["location"] = EarthLocation.from_geocentric(*obs_geo, unit=u.m)
else:
# Check if geodetic global location is specified (since geocentric failed)
# First warn the user if geocentric location is partially specified
if obs_geo:
warnings.warn(
"The geocentric observatory location {} is not completely "
"specified (X, Y, Z) and will be ignored.".format(obs_geo),
AstropyUserWarning,
)
# Check geodetic location
obs_geo = [global_info[attr] for attr in OBSGEO_LBH if attr in global_info]
if len(obs_geo) == 3:
global_info["location"] = EarthLocation.from_geodetic(*obs_geo)
else:
# Since both geocentric and geodetic locations are not specified,
# location will be None.
# Warn the user if geodetic location is partially specified
if obs_geo:
warnings.warn(
"The geodetic observatory location {} is not completely "
"specified (lon, lat, alt) and will be ignored.".format(obs_geo),
AstropyUserWarning,
)
global_info["location"] = None
# Get global time reference
# Keywords are listed in order of precedence, as stated by the standard
for key, format_ in (("MJDREF", "mjd"), ("JDREF", "jd"), ("DATEREF", "fits")):
if key in global_info:
global_info["ref_time"] = {"val": global_info[key], "format": format_}
break
else:
# If none of the three keywords is present, MJDREF = 0.0 must be assumed
global_info["ref_time"] = {"val": 0, "format": "mjd"}
def _verify_column_info(column_info, global_info):
"""
Given the column-specific time reference frame information, verify that
each column-specific time coordinate attribute has a valid value.
Return True if the coordinate column is time, or else return False.
Parameters
----------
global_info : dict
Global time reference frame information.
column_info : dict
Column-specific time reference frame override information.
"""
scale = column_info.get("TCTYP", None)
unit = column_info.get("TCUNI", None)
location = column_info.get("TRPOS", None)
if scale is not None:
# Non-linear coordinate types have "4-3" form and are not time coordinates
if TCTYP_RE_TYPE.match(scale[:5]) and TCTYP_RE_ALGO.match(scale[5:]):
return False
elif scale.lower() in Time.SCALES:
column_info["scale"] = scale.lower()
column_info["format"] = None
elif scale in FITS_DEPRECATED_SCALES.keys():
column_info["scale"] = FITS_DEPRECATED_SCALES[scale]
column_info["format"] = None
# TCTYPn (scale) = 'TIME' indicates that the column scale is
# controlled by the global scale.
elif scale == "TIME":
column_info["scale"] = global_info["scale"]
column_info["format"] = global_info["format"]
elif scale == "GPS":
warnings.warn(
'Table column "{}" has a FITS recognized time scale value "GPS". '
'In Astropy, "GPS" is a time from epoch format which runs '
"synchronously with TAI; GPS runs ahead of TAI approximately "
"by 19 s. Hence, this format will be used.".format(column_info),
AstropyUserWarning,
)
column_info["scale"] = "tai"
column_info["format"] = "gps"
elif scale == "LOCAL":
warnings.warn(
'Table column "{}" has a FITS recognized time scale value "LOCAL". '
'However, the standard states that "LOCAL" should be tied to one '
"of the existing scales because it is intrinsically unreliable "
"and/or ill-defined. Astropy will thus use the global time scale "
"(TIMESYS) as the default.".format(column_info),
AstropyUserWarning,
)
column_info["scale"] = global_info["scale"]
column_info["format"] = global_info["format"]
else:
# Coordinate type is either an unrecognized local time scale
# or a linear coordinate type
return False
# If TCUNIn is a time unit or TRPOSn is specified, the column is a time
# coordinate. This has to be tested since TCTYP (scale) is not specified.
elif (unit is not None and unit in FITS_TIME_UNIT) or location is not None:
column_info["scale"] = global_info["scale"]
column_info["format"] = global_info["format"]
# None of the conditions for time coordinate columns is satisfied
else:
return False
# Check if column-specific reference position TRPOSn is specified
if location is not None:
# Observatory position (location) needs to be specified only
# for 'TOPOCENTER'.
if location == "TOPOCENTER":
column_info["location"] = global_info["location"]
if column_info["location"] is None:
warnings.warn(
'Time column reference position "TRPOSn" value is "TOPOCENTER". '
"However, the observatory position is not properly specified. "
"The FITS standard does not support this and hence reference "
"position will be ignored.",
AstropyUserWarning,
)
else:
column_info["location"] = None
# Warn user about ignoring global reference position when TRPOSn is
# not specified
elif global_info["TREFPOS"] == "TOPOCENTER":
if global_info["location"] is not None:
warnings.warn(
'Time column reference position "TRPOSn" is not specified. The '
'default value for it is "TOPOCENTER", and the observatory position '
"has been specified. However, for supporting column-specific location, "
"reference position will be ignored for this column.",
AstropyUserWarning,
)
column_info["location"] = None
else:
column_info["location"] = None
# Get reference time
column_info["ref_time"] = global_info["ref_time"]
return True
def _get_info_if_time_column(col, global_info):
"""
Check if a column without corresponding time column keywords in the
FITS header represents time or not. If yes, return the time column
information needed for its conversion to Time.
This is only applicable to the special-case where a column has the
name 'TIME' and a time unit.
"""
# Column with TTYPEn = 'TIME' and lacking any TC*n or time
# specific keywords will be controlled by the global keywords.
if col.info.name.upper() == "TIME" and col.info.unit in FITS_TIME_UNIT:
column_info = {
"scale": global_info["scale"],
"format": global_info["format"],
"ref_time": global_info["ref_time"],
"location": None,
}
if global_info["TREFPOS"] == "TOPOCENTER":
column_info["location"] = global_info["location"]
if column_info["location"] is None:
warnings.warn(
'Time column "{}" reference position will be ignored '
"due to unspecified observatory position.".format(col.info.name),
AstropyUserWarning,
)
return column_info
return None
def _convert_global_time(table, global_info):
"""
Convert the table metadata for time informational keywords
to astropy Time.
Parameters
----------
table : `~astropy.table.Table`
The table whose time metadata is to be converted.
global_info : dict
Global time reference frame information.
"""
# Read in Global Informational keywords as Time
for key, value in global_info.items():
# FITS uses a subset of ISO-8601 for DATE-xxx
if key not in table.meta:
try:
table.meta[key] = _convert_time_key(global_info, key)
except ValueError:
pass
def _convert_time_key(global_info, key):
"""
Convert a time metadata key to a Time object.
Parameters
----------
global_info : dict
Global time reference frame information.
key : str
Time key.
Returns
-------
astropy.time.Time
Raises
------
ValueError
If key is not a valid global time keyword.
"""
value = global_info[key]
if key.startswith("DATE"):
scale = "utc" if key == "DATE" else global_info["scale"]
precision = len(value.split(".")[-1]) if "." in value else 0
return Time(value, format="fits", scale=scale, precision=precision)
# MJD-xxx in MJD according to TIMESYS
elif key.startswith("MJD-"):
return Time(value, format="mjd", scale=global_info["scale"])
else:
raise ValueError("Key is not a valid global time keyword")
def _convert_time_column(col, column_info):
"""
Convert time columns to astropy Time columns.
Parameters
----------
col : `~astropy.table.Column`
The time coordinate column to be converted to Time.
column_info : dict
Column-specific time reference frame override information.
"""
# The code might fail while attempting to read FITS files not written by astropy.
try:
# ISO-8601 is the only string representation of time in FITS
if col.info.dtype.kind in ["S", "U"]:
# [+/-C]CCYY-MM-DD[Thh:mm:ss[.s...]] where the number of characters
# from index 20 to the end of string represents the precision
precision = max(int(col.info.dtype.str[2:]) - 20, 0)
return Time(
col,
format="fits",
scale=column_info["scale"],
precision=precision,
location=column_info["location"],
)
if column_info["format"] == "gps":
return Time(col, format="gps", location=column_info["location"])
# If reference value is 0 for JD or MJD, the column values can be
# directly converted to Time, as they are absolute (relative
# to a globally accepted zero point).
if column_info["ref_time"]["val"] == 0 and column_info["ref_time"][
"format"
] in ["jd", "mjd"]:
# (jd1, jd2) where jd = jd1 + jd2
if col.shape[-1] == 2 and col.ndim > 1:
return Time(
col[..., 0],
col[..., 1],
scale=column_info["scale"],
format=column_info["ref_time"]["format"],
location=column_info["location"],
)
else:
return Time(
col,
scale=column_info["scale"],
format=column_info["ref_time"]["format"],
location=column_info["location"],
)
# Reference time
ref_time = Time(
column_info["ref_time"]["val"],
scale=column_info["scale"],
format=column_info["ref_time"]["format"],
location=column_info["location"],
)
# Elapsed time since reference time
if col.shape[-1] == 2 and col.ndim > 1:
delta_time = TimeDelta(col[..., 0], col[..., 1])
else:
delta_time = TimeDelta(col)
return ref_time + delta_time
except Exception as err:
warnings.warn(
'The exception "{}" was encountered while trying to convert the time '
'column "{}" to Astropy Time.'.format(err, col.info.name),
AstropyUserWarning,
)
return col
def fits_to_time(hdr, table):
"""
Read FITS binary table time columns as `~astropy.time.Time`.
This method reads the metadata associated with time coordinates, as
stored in a FITS binary table header, converts time columns into
`~astropy.time.Time` columns and reads global reference times as
`~astropy.time.Time` instances.
Parameters
----------
hdr : `~astropy.io.fits.header.Header`
FITS Header
table : `~astropy.table.Table`
The table whose time columns are to be read as Time
Returns
-------
hdr : `~astropy.io.fits.header.Header`
Modified FITS Header (time metadata removed)
"""
# Set defaults for global time scale, reference, etc.
global_info = {"TIMESYS": "UTC", "TREFPOS": "TOPOCENTER"}
# Set default dictionary for time columns
time_columns = defaultdict(OrderedDict)
# Make a "copy" (not just a view) of the input header, since it
# may get modified. the data is still a "view" (for now)
hcopy = hdr.copy(strip=True)
# Scan the header for global and column-specific time keywords
for key, value, comment in hdr.cards:
if key in TIME_KEYWORDS:
global_info[key] = value
hcopy.remove(key)
elif is_time_column_keyword(key):
base, idx = re.match(r"([A-Z]+)([0-9]+)", key).groups()
time_columns[int(idx)][base] = value
hcopy.remove(key)
elif value in OBSGEO_XYZ and re.match("TTYPE[0-9]+", key):
global_info[value] = table[value]
# Verify and get the global time reference frame information
_verify_global_info(global_info)
_convert_global_time(table, global_info)
# Columns with column-specific time (coordinate) keywords
if time_columns:
for idx, column_info in time_columns.items():
# Check if the column is time coordinate (not spatial)
if _verify_column_info(column_info, global_info):
colname = table.colnames[idx - 1]
# Convert to Time
table[colname] = _convert_time_column(table[colname], column_info)
# Check for special-cases of time coordinate columns
for idx, colname in enumerate(table.colnames):
if (idx + 1) not in time_columns:
column_info = _get_info_if_time_column(table[colname], global_info)
if column_info:
table[colname] = _convert_time_column(table[colname], column_info)
return hcopy
def time_to_fits(table):
"""
Replace Time columns in a Table with non-mixin columns containing
each element as a vector of two doubles (jd1, jd2) and return a FITS
header with appropriate time coordinate keywords.
jd = jd1 + jd2 represents time in the Julian Date format with
high-precision.
Parameters
----------
table : `~astropy.table.Table`
The table whose Time columns are to be replaced.
Returns
-------
table : `~astropy.table.Table`
The table with replaced Time columns
hdr : `~astropy.io.fits.header.Header`
Header containing global time reference frame FITS keywords
"""
# Make a light copy of table (to the extent possible) and clear any indices along
# the way. Indices are not serialized and cause problems later, but they are not
# needed here so just drop. For Column subclasses take advantage of copy() method,
# but for others it is required to actually copy the data if there are attached
# indices. See #8077 and #9009 for further discussion.
new_cols = []
for col in table.itercols():
if isinstance(col, Column):
new_col = col.copy(copy_data=False) # Also drops any indices
else:
new_col = col_copy(col, copy_indices=False) if col.info.indices else col
new_cols.append(new_col)
newtable = table.__class__(new_cols, copy=False)
newtable.meta = table.meta
# Global time coordinate frame keywords
hdr = Header(
[
Card(keyword=key, value=val[0], comment=val[1])
for key, val in GLOBAL_TIME_INFO.items()
]
)
# Store coordinate column-specific metadata
newtable.meta["__coordinate_columns__"] = defaultdict(OrderedDict)
coord_meta = newtable.meta["__coordinate_columns__"]
time_cols = table.columns.isinstance(Time)
# Geocentric location
location = None
for col in time_cols:
# By default, Time objects are written in full precision, i.e. we store both
# jd1 and jd2 (serialize_method['fits'] = 'jd1_jd2'). Formatted values for
# Time can be stored if the user explicitly chooses to do so.
col_cls = MaskedColumn if col.masked else Column
if col.info.serialize_method["fits"] == "formatted_value":
newtable.replace_column(col.info.name, col_cls(col.value))
continue
# The following is necessary to deal with multi-dimensional ``Time`` objects
# (i.e. where Time.shape is non-trivial).
jd12 = np.stack([col.jd1, col.jd2], axis=-1)
# Roll the 0th (innermost) axis backwards, until it lies in the last position
# (jd12.ndim)
newtable.replace_column(col.info.name, col_cls(jd12, unit="d"))
# Time column-specific override keywords
coord_meta[col.info.name]["coord_type"] = col.scale.upper()
coord_meta[col.info.name]["coord_unit"] = "d"
# Time column reference position
if col.location is None:
coord_meta[col.info.name]["time_ref_pos"] = None
if location is not None:
warnings.warn(
'Time Column "{}" has no specified location, but global Time '
"Position is present, which will be the default for this column "
"in FITS specification.".format(col.info.name),
AstropyUserWarning,
)
else:
coord_meta[col.info.name]["time_ref_pos"] = "TOPOCENTER"
# Compatibility of Time Scales and Reference Positions
if col.scale in BARYCENTRIC_SCALES:
warnings.warn(
'Earth Location "TOPOCENTER" for Time Column "{}" is incompatible '
'with scale "{}".'.format(col.info.name, col.scale.upper()),
AstropyUserWarning,
)
if location is None:
# Set global geocentric location
location = col.location
if location.size > 1:
for dim in ("x", "y", "z"):
newtable.add_column(
Column(getattr(location, dim).to_value(u.m)),
name=f"OBSGEO-{dim.upper()}",
)
else:
hdr.extend(
[
Card(
keyword=f"OBSGEO-{dim.upper()}",
value=getattr(location, dim).to_value(u.m),
)
for dim in ("x", "y", "z")
]
)
elif np.any(location != col.location):
raise ValueError(
"Multiple Time Columns with different geocentric "
"observatory locations ({}, {}) encountered."
"This is not supported by the FITS standard.".format(
location, col.location
)
)
return newtable, hdr