diff --git a/README.rst b/README.rst index 2be3f24..32d8ee0 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/edtf/__init__.py b/edtf/__init__.py index b64fd68..a86232f 100644 --- a/edtf/__init__.py +++ b/edtf/__init__.py @@ -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 diff --git a/edtf/convert.py b/edtf/convert.py index 68d8507..c1bfd3a 100644 --- a/edtf/convert.py +++ b/edtf/convert.py @@ -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 @@ -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) diff --git a/edtf/fields.py b/edtf/fields.py index 916c259..83d10a7 100644 --- a/edtf/fields.py +++ b/edtf/fields.py @@ -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', @@ -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: diff --git a/edtf/tests.py b/edtf/tests.py index 4b9e3bf..0e49e67 100644 --- a/edtf/tests.py +++ b/edtf/tests.py @@ -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) + )