Skip to content

Commit 9a5e356

Browse files
authored
Merge pull request #7122 from vermashresth/issue6487
Added LOCAL time scale
2 parents a2e3c72 + cd84139 commit 9a5e356

8 files changed

Lines changed: 250 additions & 47 deletions

File tree

‎CHANGES.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ astropy.time
8484

8585
- Added support for missing values (masking) to the ``Time`` class. [#6028]
8686

87+
- Added supper for a 'local' time scale (for free-running clocks, etc.),
88+
and round-tripping to the corresponding FITS time scale. [#7122]
89+
8790
astropy.units
8891
^^^^^^^^^^^^^
8992

@@ -168,6 +171,8 @@ astropy.tests
168171

169172
astropy.time
170173
^^^^^^^^^^^^
174+
- Added the ability to use ``local`` as time scale in ``Time`` and
175+
``TimeDelta``. [#6487]
171176

172177
astropy.units
173178
^^^^^^^^^^^^^

‎astropy/io/fits/tests/test_fitstime.py‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -338,15 +338,12 @@ def test_io_time_read_fits_scale(self, table_types):
338338
bhdu = fits.BinTableHDU.from_columns([c])
339339
bhdu.writeto(self.temp('time.fits'), overwrite=True)
340340

341-
with catch_warnings() as w:
342-
tm = table_types.read(self.temp('time.fits'), astropy_native=True)
343-
assert len(w) == 1
344-
assert 'FITS recognized time scale value "LOCAL"' in str(w[0].message)
341+
tm = table_types.read(self.temp('time.fits'), astropy_native=True)
345342

346343
assert isinstance(tm['local_time'], Time)
347344
assert tm['local_time'].format == 'mjd'
348-
# Default scale is UTC
349-
assert tm['local_time'].scale == 'utc'
345+
346+
assert tm['local_time'].scale == 'local'
350347
assert (tm['local_time'].value == local_time).all()
351348

352349
@pytest.mark.parametrize('table_types', (Table, QTable))

‎astropy/table/tests/test_mixin.py‎

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -127,20 +127,23 @@ def test_votable_quantity_write(tmpdir):
127127

128128

129129
@pytest.mark.parametrize('table_types', (Table, QTable))
130-
def test_io_time_write_fits(tmpdir, table_types):
130+
def test_io_time_write_fits_standard(tmpdir, table_types):
131131
"""
132132
Test that table with Time mixin columns can be written by io.fits.
133133
Validation of the output is done. Test that io.fits writes a table
134134
containing Time mixin columns that can be partially round-tripped
135135
(metadata scale, location).
136+
137+
Note that we postpone checking the "local" scale, since that cannot
138+
be done with format 'cxcsec', as it requires an epoch.
136139
"""
137-
t = table_types([[1,2], ['string', 'column']])
138-
for scale in time.TIME_SCALES:
139-
t['a'+scale] = time.Time([[1,2],[3,4]], format='cxcsec', scale=scale,
140-
location=EarthLocation(-2446354,
141-
4237210, 4077985, unit='m'))
140+
t = table_types([[1, 2], ['string', 'column']])
141+
for scale in time.STANDARD_TIME_SCALES:
142+
t['a'+scale] = time.Time([[1, 2], [3, 4]], format='cxcsec',
143+
scale=scale, location=EarthLocation(
144+
-2446354, 4237210, 4077985, unit='m'))
142145
t['b'+scale] = time.Time(['1999-01-01T00:00:00.123456789',
143-
'2010-01-01T00:00:00'], format='isot', scale=scale)
146+
'2010-01-01T00:00:00'], scale=scale)
144147
t['c'] = [3., 4.]
145148

146149
filename = str(tmpdir.join('table-tmp'))
@@ -149,7 +152,7 @@ def test_io_time_write_fits(tmpdir, table_types):
149152
t.write(filename, format='fits', overwrite=True)
150153
tm = table_types.read(filename, format='fits', astropy_native=True)
151154

152-
for scale in time.TIME_SCALES:
155+
for scale in time.STANDARD_TIME_SCALES:
153156
for ab in ('a', 'b'):
154157
name = ab + scale
155158

@@ -176,22 +179,85 @@ def test_io_time_write_fits(tmpdir, table_types):
176179
assert (tm[name] == t[name]).all()
177180

178181
# Test for conversion of time data to its value, as defined by its format
179-
for scale in time.TIME_SCALES:
182+
for scale in time.STANDARD_TIME_SCALES:
180183
for ab in ('a', 'b'):
181184
name = ab + scale
182185
t[name].info.serialize_method['fits'] = 'formatted_value'
183186

184187
t.write(filename, format='fits', overwrite=True)
185188
tm = table_types.read(filename, format='fits')
186189

187-
for scale in time.TIME_SCALES:
190+
for scale in time.STANDARD_TIME_SCALES:
188191
for ab in ('a', 'b'):
189192
name = ab + scale
190193

191194
assert not isinstance(tm[name], time.Time)
192195
assert (tm[name] == t[name].value).all()
193196

194197

198+
@pytest.mark.parametrize('table_types', (Table, QTable))
199+
def test_io_time_write_fits_local(tmpdir, table_types):
200+
"""
201+
Test that table with a Time mixin with scale local can also be written
202+
by io.fits. Like ``test_io_time_write_fits_standard`` above, but avoiding
203+
``cxcsec`` format, which requires an epoch and thus cannot be used for a
204+
local time scale.
205+
"""
206+
t = table_types([[1, 2], ['string', 'column']])
207+
t['a_local'] = time.Time([[50001, 50002], [50003, 50004]],
208+
format='mjd', scale='local',
209+
location=EarthLocation(-2446354, 4237210, 4077985,
210+
unit='m'))
211+
t['b_local'] = time.Time(['1999-01-01T00:00:00.123456789',
212+
'2010-01-01T00:00:00'], scale='local')
213+
t['c'] = [3., 4.]
214+
215+
filename = str(tmpdir.join('table-tmp'))
216+
217+
# Show that FITS format succeeds
218+
t.write(filename, format='fits', overwrite=True)
219+
tm = table_types.read(filename, format='fits', astropy_native=True)
220+
221+
for ab in ('a', 'b'):
222+
name = ab + '_local'
223+
224+
# Assert that the time columns are read as Time
225+
assert isinstance(tm[name], time.Time)
226+
227+
# Assert that the scales round-trip
228+
assert tm[name].scale == t[name].scale
229+
230+
# Assert that the format is jd
231+
assert tm[name].format == 'jd'
232+
233+
# Assert that the location round-trips
234+
assert tm[name].location == t[name].location
235+
236+
# Finally assert that the column data round-trips
237+
assert (tm[name] == t[name]).all()
238+
239+
for name in ('col0', 'col1', 'c'):
240+
# Assert that the non-time columns are read as Column
241+
assert isinstance(tm[name], Column)
242+
243+
# Assert that the non-time columns' data round-trips
244+
assert (tm[name] == t[name]).all()
245+
246+
# Test for conversion of time data to its value, as defined by its format.
247+
for ab in ('a', 'b'):
248+
name = ab + '_local'
249+
t[name].info.serialize_method['fits'] = 'formatted_value'
250+
251+
t.write(filename, format='fits', overwrite=True)
252+
tm = table_types.read(filename, format='fits')
253+
254+
for ab in ('a', 'b'):
255+
name = ab + '_local'
256+
257+
assert not isinstance(tm[name], time.Time)
258+
assert (tm[name] == t[name].value).all()
259+
260+
195261
def test_votable_mixin_write_fail(mixin_cols):
196262
"""
197263
Test that table with mixin columns (excluding Quantity) cannot be written by

‎astropy/time/core.py‎

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@
2929
from .formats import TimeFromEpoch # pylint: disable=W0611
3030

3131

32-
__all__ = ['Time', 'TimeDelta', 'TIME_SCALES', 'TIME_DELTA_SCALES',
32+
__all__ = ['Time', 'TimeDelta', 'TIME_SCALES', 'STANDARD_TIME_SCALES', 'TIME_DELTA_SCALES',
3333
'ScaleValueError', 'OperandTypeError', 'TimeInfo']
3434

3535

36-
TIME_SCALES = ('tai', 'tcb', 'tcg', 'tdb', 'tt', 'ut1', 'utc')
36+
STANDARD_TIME_SCALES = ('tai', 'tcb', 'tcg', 'tdb', 'tt', 'ut1', 'utc')
37+
LOCAL_SCALES = ('local',)
38+
TIME_TYPES = dict((scale, scales) for scales in (STANDARD_TIME_SCALES, LOCAL_SCALES) for scale in scales)
39+
TIME_SCALES = STANDARD_TIME_SCALES + LOCAL_SCALES
3740
MULTI_HOPS = {('tai', 'tcb'): ('tt', 'tdb'),
3841
('tai', 'tcg'): ('tt',),
3942
('tai', 'ut1'): ('utc',),
@@ -55,8 +58,8 @@
5558
ROTATIONAL_SCALES = ('ut1',)
5659
TIME_DELTA_TYPES = dict((scale, scales)
5760
for scales in (GEOCENTRIC_SCALES, BARYCENTRIC_SCALES,
58-
ROTATIONAL_SCALES) for scale in scales)
59-
TIME_DELTA_SCALES = TIME_DELTA_TYPES.keys()
61+
ROTATIONAL_SCALES, LOCAL_SCALES) for scale in scales)
62+
TIME_DELTA_SCALES = GEOCENTRIC_SCALES + BARYCENTRIC_SCALES + ROTATIONAL_SCALES + LOCAL_SCALES
6063
# For time scale changes, we need L_G and L_B, which are stored in erfam.h as
6164
# /* L_G = 1 - d(TT)/d(TCG) */
6265
# define ERFA_ELG (6.969290134e-10)
@@ -272,12 +275,13 @@ def __init__(self, val, val2=None, format=None, scale=None,
272275
self._time.in_subfmt = in_subfmt
273276
if out_subfmt is not None:
274277
self._time.out_subfmt = out_subfmt
275-
278+
self.SCALES = TIME_TYPES[self.scale]
276279
if scale is not None:
277280
self._set_scale(scale)
278281
else:
279282
self._init_from_vals(val, val2, format, scale, copy,
280283
precision, in_subfmt, out_subfmt)
284+
self.SCALES = TIME_TYPES[self.scale]
281285

282286
if self.location is not None and (self.location.size > 1 and
283287
self.location.shape != self.shape):
@@ -1037,7 +1041,7 @@ def _apply(self, method, *args, format=None, **kwargs):
10371041
tm.in_subfmt, tm.out_subfmt,
10381042
from_jd=True)
10391043
tm._format = new_format
1040-
1044+
tm.SCALES = self.SCALES
10411045
return tm
10421046

10431047
def __copy__(self):
@@ -1476,14 +1480,25 @@ def __sub__(self, other):
14761480
if other.scale not in (out.scale, None):
14771481
other = getattr(other, out.scale)
14781482
else:
1479-
out._set_scale(other.scale if other.scale is not None
1480-
else 'tai')
1483+
if other.scale is None:
1484+
out._set_scale('tai')
1485+
else:
1486+
if self.scale not in TIME_TYPES[other.scale]:
1487+
raise TypeError("Cannot subtract Time and TimeDelta instances "
1488+
"with scales '{0}' and '{1}'"
1489+
.format(self.scale, other.scale))
1490+
out._set_scale(other.scale)
14811491
# remove attributes that are invalidated by changing time
14821492
for attr in ('_delta_ut1_utc', '_delta_tdb_tt'):
14831493
if hasattr(out, attr):
14841494
delattr(out, attr)
14851495

14861496
else: # T - T
1497+
# the scales should be compatible (e.g., cannot convert TDB to LOCAL)
1498+
if other.scale not in self.SCALES:
1499+
raise TypeError("Cannot subtract Time instances "
1500+
"with scales '{0}' and '{1}'"
1501+
.format(self.scale, other.scale))
14871502
self_time = (self._time if self.scale in TIME_DELTA_SCALES
14881503
else self.tai._time)
14891504
# set up TimeDelta, subtraction to be done shortly
@@ -1526,8 +1541,14 @@ def __add__(self, other):
15261541
if other.scale not in (out.scale, None):
15271542
other = getattr(other, out.scale)
15281543
else:
1529-
out._set_scale(other.scale if other.scale is not None else 'tai')
1530-
1544+
if other.scale is None:
1545+
out._set_scale('tai')
1546+
else:
1547+
if self.scale not in TIME_TYPES[other.scale]:
1548+
raise TypeError("Cannot add Time and TimeDelta instances "
1549+
"with scales '{0}' and '{1}'"
1550+
.format(self.scale, other.scale))
1551+
out._set_scale(other.scale)
15311552
# remove attributes that are invalidated by changing time
15321553
for attr in ('_delta_ut1_utc', '_delta_tdb_tt'):
15331554
if hasattr(out, attr):

‎astropy/time/formats.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,14 @@ def to_value(self, parent=None):
420420
if self.scale != self.epoch_scale:
421421
if parent is None:
422422
raise ValueError('cannot compute value without parent Time object')
423-
tm = getattr(parent, self.epoch_scale)
423+
try:
424+
tm = getattr(parent, self.epoch_scale)
425+
except Exception as err:
426+
raise ScaleValueError("Cannot convert from '{0}' epoch scale '{1}'"
427+
"to specified scale '{2}', got error:\n{3}"
428+
.format(self.name, self.epoch_scale,
429+
self.scale, err))
430+
424431
jd1, jd2 = tm._time.jd1, tm._time.jd2
425432
else:
426433
jd1, jd2 = self.jd1, self.jd2

‎astropy/time/tests/test_basic.py‎

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
from copy import deepcopy
88

99
import numpy as np
10+
from numpy.testing import assert_allclose
1011

1112
from ...tests.helper import catch_warnings, pytest
1213
from ...utils import isiterable
13-
from .. import Time, ScaleValueError, TIME_SCALES, TimeString, TimezoneInfo
14+
from .. import Time, ScaleValueError, STANDARD_TIME_SCALES, TimeString, TimezoneInfo
1415
from ...coordinates import EarthLocation
1516
from ... import units as u
1617
from ... import _erfa as erfa
@@ -311,19 +312,25 @@ def test_location_array(self):
311312
assert t5.location.shape == t5.shape
312313
assert t5.tdb.shape == t5.shape
313314

314-
def test_all_transforms(self):
315-
"""Test that all transforms work. Does not test correctness,
316-
except reversibility [#2074]"""
315+
def test_all_scale_transforms(self):
316+
"""Test that standard scale transforms work. Does not test correctness,
317+
except reversibility [#2074]. Also tests that standard scales can't be
318+
converted to local scales"""
317319
lat = 19.48125
318320
lon = -155.933222
319-
for scale1 in TIME_SCALES:
321+
for scale1 in STANDARD_TIME_SCALES:
320322
t1 = Time('2006-01-15 21:24:37.5', format='iso', scale=scale1,
321323
location=(lon, lat))
322-
for scale2 in TIME_SCALES:
324+
for scale2 in STANDARD_TIME_SCALES:
323325
t2 = getattr(t1, scale2)
324326
t21 = getattr(t2, scale1)
325327
assert allclose_jd(t21.jd, t1.jd)
326328

329+
# test for conversion to local scale
330+
scale3 = 'local'
331+
with pytest.raises(ScaleValueError):
332+
t2 = getattr(t1, scale3)
333+
327334
def test_creating_all_formats(self):
328335
"""Create a time object using each defined format"""
329336
Time(2000.5, format='decimalyear')
@@ -349,6 +356,35 @@ def test_creating_all_formats(self):
349356
Time(dt, format='datetime', scale='tai')
350357
Time([dt, dt], format='datetime', scale='tai')
351358

359+
def test_local_format_transforms(self):
360+
"""
361+
Test trasformation of local time to different formats
362+
Transformation to formats with reference time should give
363+
ScalevalueError
364+
"""
365+
t = Time('2006-01-15 21:24:37.5', scale='local')
366+
assert_allclose(t.jd, 2453751.3921006946, atol=0.001/3600./24., rtol=0.)
367+
assert_allclose(t.mjd, 53750.892100694444, atol=0.001/3600./24., rtol=0.)
368+
assert_allclose(t.decimalyear, 2006.0408002758752, atol=0.001/3600./24./365., rtol=0.)
369+
assert t.datetime == datetime.datetime(2006, 1, 15, 21, 24, 37, 500000)
370+
assert t.isot == '2006-01-15T21:24:37.500'
371+
assert t.yday == '2006:015:21:24:37.500'
372+
assert t.fits == '2006-01-15T21:24:37.500(LOCAL)'
373+
assert_allclose(t.byear, 2006.04217888831, atol=0.001/3600./24./365., rtol=0.)
374+
assert_allclose(t.jyear, 2006.0407723496082, atol=0.001/3600./24./365., rtol=0.)
375+
assert t.byear_str == 'B2006.042'
376+
assert t.jyear_str == 'J2006.041'
377+
378+
# epochTimeFormats
379+
with pytest.raises(ScaleValueError):
380+
t2 = t.gps
381+
with pytest.raises(ScaleValueError):
382+
t2 = t.unix
383+
with pytest.raises(ScaleValueError):
384+
t2 = t.cxcsec
385+
with pytest.raises(ScaleValueError):
386+
t2 = t.plot_date
387+
352388
def test_datetime(self):
353389
"""
354390
Test datetime format, including guessing the format from the input type
@@ -491,6 +527,11 @@ def test_init_from_time_objects(self):
491527
t5 = Time([t4[:2], t4[4:5]])
492528
assert t5.shape == (3, 5)
493529

530+
# throw error when deriving local scale time
531+
# from non local time scale
532+
with pytest.raises(ValueError):
533+
t6 = Time(t1, scale='local')
534+
494535

495536
class TestVal2():
496537
"""Tests related to val2"""
@@ -629,6 +670,9 @@ def test_fits_scale(self):
629670
# Test with scale and FITS string scale
630671
t = Time('2045-11-08T00:00:00.000(UTC)', scale='utc')
631672
assert t.scale == 'utc'
673+
# Test with local time scale and FITS string scale
674+
t = Time('2045-11-08T00:00:00.000(LOCAL)')
675+
assert t.scale == 'local'
632676
# Check that inconsistent scales lead to errors.
633677
with pytest.raises(ValueError):
634678
Time('2000-01-02(TAI)', scale='utc')

0 commit comments

Comments
 (0)