2

I have a database that stores Dates and DateTimes (as INTEGERs and DOUBLEs, respectively) as Modified Julian Day Numbers (MJD). Modified Julian Day Numbers are a consecutive count of days from midnight UTC, 17 November 1858. By definition they are always reckoned in UTC, have an offset of +0:00 from GMT, and do not adjust for daylight savings. These properties simplify certain operations with DateTimes such as precedence and date arithmetic.

The downside is that MJDs must be relocalized from UTC and delocalized back to UTC before and after use, particularly for applications for which day boundaries are critically important (Medicare, for example, recognizes a billable date boundary as midnight in -local- time).

Consider the following static factory method whose purpose is to delocalize into an MJD (in UTC) a "regional day number" (basically, an MJD that has had the appropriate offset added to it so that it represents a local DateTime):

public static MJD ofDayNumberInZone(double regDN, ZoneId zone) {
    :
    :            
}

It seems intuitively obvious that if you have a local date and time, and you know the local time zone, that you should have all the information you need in order to offset regDN back to UTC (as required by an MJD).

In fact, this function is fairly simple to write using the previous Java Calendar API. The regDN is easily converted to a Date which is used to set a GregorianCalendar instance. Knowing the "local time zone" the calendar reports ZONE_OFFSET and DST_OFFSET values that can then be used to adjust the day number into an MJD.

This is my attempt to write a similar algorithm in the Java 8 DateTime API:

public static MJD ofDayNumberInZone(double zonedMJD, ZoneId zone) {
        double epochSec = ((zonedMJD - MJD.POSIX_EPOCH_AS_MJD) * 86400.0);
        LocalDateTime dt = LocalDateTime
            .ofEpochSecond(
                    (long) epochSec, 
                    (int) (epochSec - Math.floor(epochSec) * 1000000000.0),
--->                zone.getRules().getOffset( <Instant> )
            );   
}

The problem is indicated at the arrow. Constructing a LocalDateTime instance using the ofEpochSecond method seems to require that you know the offsets in advance, which seems counterintuitive (I have the local time and the time zone already, it's the offset I want).

I haven't been successful in finding a simple way to obtain the offsets from local time back to UTC using the Java 8 API. While I could continue to use the old Calendar API, the new DateTime libraries offer compelling advantages ... so I'd like to try and figure this out. What am I missing?


EDIT: Here is an example, using the old Java Calendar API, of how a count of days and fractional days in an arbitrary time zone is "deregionalized" into UTC. This method takes a double which is the "regionalized day number" and a time zone object. It uses a GregorianCalendar to convert the parameters into a UTC count of milliseconds from the Epoch:

    private static final Object             lockCal = new Object();
    private static final SimpleDateFormat       SDF = new SimpleDateFormat();
    private static final GregorianCalendar      CAL = new
            GregorianCalendar(TimeZone.getTimeZone(HECTOR_ZONE));
        :
        :

    public static MJD ofDayNumberInZone(double rdn, TimeZone tz) {
        Date dat = new Date((long) ((rdn - MJD.POSIX_EPOCH_AS_MJD) * 
                (86400.0 * 1000.0)));
        return MJD.ofDateInZone(dat, tz);
    }

    public static MJD ofDateInZone(Date dat, TimeZone tz) {
        long utcMillisFromEpoch;

        synchronized(lockCal) {
            CAL.setTimeZone(tz);
            CAL.setTime(dat);
            utcMillisFromEpoch = CAL.getTimeInMillis();
        }
        return MJD.ofEpochMillisInUTC(utcMillisFromEpoch);
    }

    public static MJD ofEpochMillisInUTC(long millis) 
        { return new MJD((millis / (86400.0 * 1000.0)) + POSIX_EPOCH_AS_MJD);          }
scottb
  • 9,908
  • 3
  • 40
  • 56
  • So is the purpose of the method to convert from a `Zoned MJD` to a `UTC MJD`? – fdsa Aug 05 '15 at 02:08
  • I don't use Java, but lets imagine you succeed. I'd expect you to be able to get the offset in force when the program runs, or maybe take into account the date and change the offset for daylight saving time for the current year. I wouldn't expect Java to be able to provide historical offsets back when the begin/end dates for DST were different, and of course, predicting the action of Congress in the future is impossible. So are you making the proper allowances for historical or future date/times? – Gerard Ashton Aug 05 '15 at 13:26
  • @GerardAshton: It's a separate matter not related to my question, however the Java DateTime API is fully IANA Time Zone Database aware and provides historically accurate offsets to zone-specific time offset calculations. – scottb Aug 05 '15 at 17:21
  • I notice the getOffset method requires an instant, which is created from seconds since the epoch and nanoseconds. But if you don't know the offset and don't know the Greenwich/UTC time, then you can't figure out the seconds since the epoch, so you can't create the instant. – Gerard Ashton Aug 06 '15 at 02:47
  • @GerardAshton: here's the paradox I am running into ... if -you- know the local time and the local time zone, then -you- have enough information to correctly arrive at UTC. The Java 8 DateTime API, however, does not appear to be able to construct any Instant unless it knows how to represent it internally in UTC (apparently, with prior knowledge of the offset to UTC as a precondition). I am stuck at how to take a local time and a time zone and make an Instant from it. This is simple in the old API, but I'm finding the new API hard to use. – scottb Aug 06 '15 at 03:17
  • @fdsa: Yes, that is a concise statement of the problem: taking a zoned MJD (if you can agree that there is such a thing; all MJD's are supposed to be in UTC) and arriving at UTC MJD from it. – scottb Aug 06 '15 at 03:20
  • I looked at the [Java 8 date documentation](http://docs.oracle.com/javase/8/docs/technotes/guides/datetime/index.html). It seems there are two kinds of non-UTC/Greenwich time. LocalDateTime is not connected by the Java.time package to any particular time zone, so it needs to be figured out from context or by source code unrelated to Java.time. ZonedDateTime is a local time for which Java.time keeps track of the zone; Java knows what the rules are. If you explore ZonedDateTime you may find what you want. There is a case where conversion is impossible; local-->UTC in the fall DST change. – Gerard Ashton Aug 06 '15 at 14:53

1 Answers1

2

Per your comments, your core issue seems to be about the ambiguity of converting a date-time without time zone (a LocalDateTime) into a zoned moment (a ZonedDateTime). You explain that anomalies such as Daylight Saving Time (DST) can result in invalid values.

ZonedDateTime zdt = myLocalDateTime.atZone( myZoneId );

This is true. There is no perfect solution when landing in the DST “Spring-forward” or ”Fall-back” cutovers. However, the java.time classes do resolve the ambiguity by adopting a certain policy. You may or may not agree with that policy. But if you do agree, then you can rely on java.time to determine a result.

To quote the documentation for ZonedDateTime.ofLocal:

In the case of an overlap, where clocks are set back, there are two valid offsets. If the preferred offset is one of the valid offsets then it is used. Otherwise the earlier valid offset is used, typically corresponding to "summer".

In the case of a gap, where clocks jump forward, there is no valid offset. Instead, the local date-time is adjusted to be later by the length of the gap. For a typical one hour daylight savings change, the local date-time will be moved one hour later into the offset typically corresponding to "summer".

    LocalDate modifiedJulianEpoch = LocalDate.of( 1858 , 11 , 17 );
    LocalDate today = LocalDate.now( ZoneOffset.UTC );
    long days = ChronoUnit.DAYS.between (  modifiedJulianEpoch , today );

today: 2017-03-19 days: 57831

I do not quite understand your issues. But it seems to me that the point of MJD (Modified Julian Days) is to have a way to track a “One True Time” to avoid all the confusion of time zones. In standard ISO 8601 calendar system, UTC plays than role of “One True Time”. So I suggest sticking to UTC.

When you need to consider a region’s wall-clock time, such as your Medicare example of the region’s end-of-day, determine the regional wall-clock time and then convert to UTC. The Instant class in java.time is always in UTC by definition.

ZoneId z = ZoneId.of( "America/Los_Angeles" );
LocalDate localDate = LocalDate.now( z );
ZonedDateTime firstMomentNextDay = localDate.plusDays( 1 ).atStartOfDay( z );
Instant medicareExpiration = firstMomentNextDay.toInstant(); // UTC
BigDecimal modJulDays = this.convertInstantToModifiedJulianDays( medicareExpiration ) ;

Use BigDecimal when working with fractional decimals where accuracy matters. Using double, Double, float, or Float means using Floating-Point technology that trades away accuracy for faster performance.

Here is a rough-cut at some code to do the conversion from BigDecimal (Modified Julian Days) to Instant. I suppose some clever person might find a leaner or meaner version of this code, but my code here seems to be working. Use at your own risk. I barely tested this code at all.

public Instant convertModifiedJulianDaysToInstant ( BigDecimal modJulDays ) {
    Instant epoch = OffsetDateTime.of ( 1858, 11, 17, 0, 0, 0, 0, ZoneOffset.UTC ).toInstant ( ); // TODO: Make into a constant to optimize.
    long days = modJulDays.toBigInteger ( ).longValue ( );
    BigDecimal fractionOfADay = modJulDays.subtract ( new BigDecimal ( days ) ); // Extract the fractional number, separate from the integer number.
    BigDecimal secondsFractional = new BigDecimal ( TimeUnit.DAYS.toSeconds ( 1 ) ).multiply ( fractionOfADay );
    long secondsWhole = secondsFractional.longValue ( );
    long nanos = secondsFractional.subtract ( new BigDecimal ( secondsWhole ) ).multiply ( new BigDecimal ( 1_000_000_000L ) ).longValue ( );
    Duration duration = Duration.ofDays ( days ).plusSeconds ( secondsWhole ).plusNanos ( nanos );
    Instant instant = epoch.plus ( duration );
    return instant;
}

And going the other direction.

public BigDecimal convertInstantToModifiedJulianDays ( Instant instant ) {
    Instant epoch = OffsetDateTime.of ( 1858, 11, 17, 0, 0, 0, 0, ZoneOffset.UTC ).toInstant ( ); // TODO: Make into a constant to optimize.
    Duration duration = Duration.between ( epoch, instant );
    long wholeDays = duration.toDays ( );
    Duration durationRemainder = duration.minusDays ( wholeDays );

    BigDecimal wholeDaysBd = new BigDecimal ( wholeDays );
    BigDecimal partialDayInNanosBd = new BigDecimal ( durationRemainder.toNanos ( ) ); // Convert entire duration to a total number of nanoseconds.
    BigDecimal nanosInADayBd = new BigDecimal ( TimeUnit.DAYS.toNanos ( 1 ) );  // How long is a standard day in nanoseconds?
    int scale = 9; // Maximum number of digits to the right of the decimal point.
    BigDecimal partialDayBd = partialDayInNanosBd.divide ( nanosInADayBd ); // Get a fraction by dividing a total number of nanos in a day by our partial day of nanos.
    BigDecimal result = wholeDaysBd.add ( partialDayBd );
    return result;
}

Calling those conversion methods.

    BigDecimal input = new BigDecimal ( "57831.5" );
    Instant instant = this.convertModifiedJulianDaysToInstant ( input );
    BigDecimal output = this.convertInstantToModifiedJulianDays ( instant );

Dump to console.

    System.out.println ( "input.toString(): " + input );
    System.out.println ( "instant.toString(): " + instant );
    System.out.println ( "output.toString(): " + output );

input.toString(): 57831.5

instant.toString(): 2017-03-19T12:00:00Z

output.toString(): 57831.5

See all that code running live at IdeOne.com.

Also, my Answer to a similar Question may be helpful.

Community
  • 1
  • 1
Basil Bourque
  • 303,325
  • 100
  • 852
  • 1,154