Skip to content

Commit

Permalink
#26 Add conversion functions to/from Julian Dates
Browse files Browse the repository at this point in the history
Add `struct_time_to_jd` and `jd_to_struct_time`
functions to `edtf.convert` module to handle
conversion to and from Julian Date numerical
float values.

These functions greatly simplify Julian Date (JD)
conversions, especially for converting from JD
values where fiddly handling may be required to
convert negative month/day/hour/minute/second
values returned by the `jdutil` module.
  • Loading branch information
jmurty committed Apr 19, 2018
1 parent 5acaff4 commit cfde723
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 8 deletions.
7 changes: 7 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,13 @@ the ``date_edtf`` value, and the underlying EDTF object will set the
``_earliest`` and ``_latest`` fields on the model to a float value representing
the Julian Date.


**WARNING**: The conversion to and from Julian Date numerical values can be
inaccurate, especially for ancient dates back to thousands of years BC. Ideally
Julian Date values should be used for range and ordering operations only where
complete accuracy is not required. They should **not** be used for definitive
storage or for display after roundtrip conversions.

::

from django.db import models
Expand Down
3 changes: 2 additions & 1 deletion edtf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
from edtf.natlang import text_to_edtf
from edtf.parser.parser_classes import *
from edtf.convert import dt_to_struct_time, struct_time_to_date, \
struct_time_to_datetime, trim_struct_time
struct_time_to_datetime, trim_struct_time, struct_time_to_jd, \
jd_to_struct_time
84 changes: 84 additions & 0 deletions edtf/convert.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from time import struct_time
from datetime import date, datetime

from edtf import jdutil


TIME_EMPTY_TIME = [0, 0, 0] # tm_hour, tm_min, tm_sec
TIME_EMPTY_EXTRAS = [0, 0, -1] # tm_wday, tm_yday, tm_isdst
Expand Down Expand Up @@ -59,3 +61,85 @@ def trim_struct_time(st, strip_time=False):
return struct_time(list(st[:3]) + TIME_EMPTY_TIME + TIME_EMPTY_EXTRAS)
else:
return struct_time(list(st[:6]) + TIME_EMPTY_EXTRAS)


def struct_time_to_jd(st):
"""
Return a float number representing the Julian Date for the given
`struct_time`.
NOTE: extra fields `tm_wday`, `tm_yday`, and `tm_isdst` are ignored.
"""
year, month, day = st[:3]
hours, minutes, seconds = st[3:6]

# Convert time of day to fraction of day
day += jdutil.hmsm_to_days(hours, minutes, seconds)

return jdutil.date_to_jd(year, month, day)


def jd_to_struct_time(jd):
"""
Return a `struct_time` converted from a Julian Date float number.
WARNING: Conversion to then from Julian Date value to `struct_time` can be
inaccurate and lose or gain time, especially for BC (negative) years.
NOTE: extra fields `tm_wday`, `tm_yday`, and `tm_isdst` are set to default
values, not real ones.
"""
year, month, day = jdutil.jd_to_date(jd)

# Convert time of day from fraction of day
day_fraction = day - int(day)
hour, minute, second, ms = jdutil.days_to_hmsm(day_fraction)
day = int(day)

# This conversion can return negative values for items we do not want to be
# negative: month, day, hour, minute, second.
year, month, day, hour, minute, second = _roll_negative_time_fields(
year, month, day, hour, minute, second)

return struct_time(
[year, month, day, hour, minute, second] + TIME_EMPTY_EXTRAS
)


def _roll_negative_time_fields(year, month, day, hour, minute, second):
"""
Fix date/time fields which have nonsense negative values for any field
except for year by rolling the overall date/time value backwards, treating
negative values as relative offsets of the next higher unit.
For example minute=5, second=-63 becomes minute=3, second=57 (5 minutes
less 63 seconds)
This is very unsophisticated handling of negative values which we would
ideally do with `dateutil.relativedelta` but cannot because that class does
not support arbitrary dates, especially not negative years which is the
only case where these nonsense values are likely to occur anyway.
NOTE: To greatly simplify the logic we assume all months are 30 days long.
"""
if second < 0:
minute += int(second / 60.0) # Adjust by whole minute in secs
minute -= 1 # Subtract 1 for negative second
second %= 60 # Convert negative second to positive remainder
if minute < 0:
hour += int(minute / 60.0) # Adjust by whole hour in minutes
hour -= 1 # Subtract 1 for negative minutes
minute %= 60 # Convert negative minute to positive remainder
if hour < 0:
day += int(hour / 24.0) # Adjust by whole day in hours
day -= 1 # Subtract 1 for negative minutes
hour %= 24 # Convert negative hour to positive remainder
if day < 0:
month += int(day / 30.0) # Adjust by whole month in days (assume 30)
month -= 1 # Subtract 1 for negative minutes
day %= 30 # Convert negative day to positive remainder
if month < 0:
year += int(month / 12.0) # Adjust by whole year in months
year -= 1 # Subtract 1 for negative minutes
month %= 12 # Convert negative month to positive remainder
return (year, month, day, hour, minute, second)
9 changes: 2 additions & 7 deletions edtf/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@

from edtf import parse_edtf, EDTFObject
from edtf.natlang import text_to_edtf
from edtf.convert import struct_time_to_date
from edtf.jdutil import date_to_jd, hmsm_to_days
from edtf.convert import struct_time_to_date, struct_time_to_jd

DATE_ATTRS = (
'lower_strict',
Expand Down Expand Up @@ -125,11 +124,7 @@ def pre_save(self, instance, add):
continue
value = getattr(edtf, attr)() # struct_time
if isinstance(target_field, models.FloatField):
year, month, day = value[:3]
# Convert time of day to fraction of day
hours, minutes, seconds = value[3:6]
day += hmsm_to_days(hours, minutes, seconds)
value = date_to_jd(year, month, day)
value = struct_time_to_jd(value)
elif isinstance(target_field, models.DateField):
value = struct_time_to_date(value)
else:
Expand Down
78 changes: 78 additions & 0 deletions edtf/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,81 @@ def test_trim_struct_time(self):
self.assertEqual(trimmed_st[6:], (0, 0, -1))
# Confirm 'extra' fields in untrimmed `struct_time` has real values
self.assertNotEqual(st[6:], (0, 0, -1))

def test_struct_time_to_jd(self):
# Check conversion of AD date & time to Julian Date number
st_ad = struct_time(
[2018, 4, 19] + [10, 13, 54] + convert.TIME_EMPTY_EXTRAS)
jd_ad = 2458227.9263194446
self.assertEqual(jd_ad, convert.struct_time_to_jd(st_ad))
# Check conversion of BC date & time to Julian Date number
st_bc = struct_time(
[-2018, 4, 19] + [10, 13, 54] + convert.TIME_EMPTY_EXTRAS)
jd_bc = 984091.9263194444
self.assertEqual(jd_bc, convert.struct_time_to_jd(st_bc))

def test_jd_to_struct_time(self):
# Check conversion of Julian Date number to AD date & time
jd_ad = 2458227.9263194446 # As in `test_struct_time_to_jd`
st_ad = struct_time(
[2018, 4, 19] + [10, 13, 54] + convert.TIME_EMPTY_EXTRAS)
self.assertEqual(st_ad, convert.jd_to_struct_time(jd_ad))
# Check conversion of Julian Date number to BC date & time
# WARNING: Converted time is off by 1 second, 53 not 54
jd_bc = 984091.9263194444 # As in `test_struct_time_to_jd`
st_bc = struct_time(
[-2018, 4, 19] + [10, 13, 54 - 1] + convert.TIME_EMPTY_EXTRAS)
self.assertEqual(st_bc, convert.jd_to_struct_time(jd_bc))

def test_jd_round_trip_for_extreme_future(self):
original_st = struct_time(
[999999, 8, 4] + [21, 15, 3] + convert.TIME_EMPTY_EXTRAS)
jd = convert.struct_time_to_jd(original_st)
converted_st = convert.jd_to_struct_time(jd)
# Confirm that year, month, day, hour, minute are correct (not second)
self.assertEqual(original_st[:5], converted_st[:5])
# WARNING: Seconds are off by 1, should be 3 but is 2
self.assertEqual(3 - 1, converted_st[5])

def test_jd_round_trip_for_extreme_past(self):
original_st = struct_time(
[-999999, 8, 4] + [21, 15, 3] + convert.TIME_EMPTY_EXTRAS)
converted_st = convert.jd_to_struct_time(
convert.struct_time_to_jd(original_st))
# WARNING: We have lost a year of accuracy
self.assertEqual(
(-999999 + 1, # Year off by 1
8, 4, 21, 15, 3, 0, 0, -1),
tuple(converted_st))

def test_jd_round_trip_for_zero_year_aka_1_bc(self):
original_st = struct_time(
[0, 9, 5] + [4, 58, 59] + convert.TIME_EMPTY_EXTRAS)
converted_st = convert.jd_to_struct_time(
convert.struct_time_to_jd(original_st))
self.assertEqual(
(0, 9, 5, 4, 58, 59, 0, 0, -1),
tuple(converted_st))

def test_jd_round_trip_for_2_bc(self):
original_st = struct_time(
[-1, 12, 5] + [4, 58, 59] + convert.TIME_EMPTY_EXTRAS)
converted_st = convert.jd_to_struct_time(
convert.struct_time_to_jd(original_st))
self.assertEqual(
(-1, 12, 5, 4, 58, 59, 0, 0, -1),
tuple(converted_st))

def test_roll_negative_time_fields(self):
# Confirm time value is adjusted as expected
year = -100
month = -17 # More than 1 year
day = -34 # More than 1 month
hour = -25 # More than 1 day
minute = -74 # More than 1 hour
second = -253 # More than 1 minute
self.assertEqual(
(-102, 5, 24, 21, 41, 47),
convert._roll_negative_time_fields(
year, month, day, hour, minute, second)
)

0 comments on commit cfde723

Please sign in to comment.