4

I'm playing with some C# code to try to gain an understanding of how subtracting DateTime objects in C# works with respect to Daylight Saving Time.

Per Google and other sources, the Daylight Saving Time "spring ahead" event in the Eastern Standard Time zone in 2017 was at 2:00am on March 12. So, the first few hours of the day on that date were:

   12:00am - 1:00am
    1:00am - 2:00am
   (There was no 2:00am - 3:00am hour due to the "spring ahead")
    3:00am - 4:00am

So, if I were to calculate the time differential between 1:00am and 4:00am in that time zone on that date, I'd expect the result to be 2 hours.

However, the code I put together to try to simulate this problem is returning a 3 hour TimeSpan.

Code:

TimeZoneInfo easternStandardTime = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");

DateTime oneAm = TimeZoneInfo.ConvertTime(new DateTime(2017, 03, 12, 01, 00, 00), easternStandardTime);
DateTime fourAm = TimeZoneInfo.ConvertTime(new DateTime(2017, 03, 12, 04, 00, 00), easternStandardTime);

TimeSpan difference = (fourAm - oneAm);

Console.WriteLine(oneAm);
Console.WriteLine(fourAm);
Console.WriteLine(TimeZoneInfo.Local.IsDaylightSavingTime(oneAm));
Console.WriteLine(TimeZoneInfo.Local.IsDaylightSavingTime(fourAm));
Console.WriteLine(difference);

On my PC, this generates:

2017-03-12 01:00:00.000 -5
2017-03-12 04:00:00.000 -4
False
True
03:00:00

All of that output is as expected -- except that final value of 3 hours, which as I noted above, I would expect to be 2 hours instead.

Obviously, my code isn't correctly simulating the situation that I have in mind. What is the flaw?

Jon Schneider
  • 25,758
  • 23
  • 142
  • 170
  • What happens if you run this the very next day (not across a time change)? – leigero Apr 26 '17 at 20:54
  • @leigero The result TimeSpan is still 3 hours. The two IsDaylightSavingTime calls both return True. The timezone offset for both dates shows as -4. – Jon Schneider Apr 26 '17 at 20:56
  • `DateTime` does not contain any timezone info. Use `DateTimeOffset`. – Sam Axe Apr 26 '17 at 21:17
  • @SamAxe I suspect you're probably right, but can you explain further (maybe in an answer on this question)? What's the deal with the TimeZoneInfo.ConvertTime method which takes a time zone and returns a DateTime, if DateTime does not contain any timezone info? – Jon Schneider Apr 26 '17 at 21:18
  • Bit of a judgment call, but they decided to implement the ~1 nanosecond version. Instead of the hundreds of nanoseconds it would take to figure out the UTC time. Speed is a feature too. DateTimeOffset always stores UTC time internally so doesn't have this problem. Live and learn, for Microsoft too. – Hans Passant Apr 26 '17 at 21:52
  • @HansPassant Are you saying it's the addition/subtraction operators and methods on DateTime that aren't converting to UTC? If you could expand your comment into an answer, that would help a lot. I'm still unclear on exactly why the DateTime class reports the correct time offsets (-5 and -4 respectively) for my two DateTime objects, yet subtracting them (evidently?) ignores those same offsets. – Jon Schneider Apr 26 '17 at 21:57
  • 1
    DateTime.ToString() is allowed to be slow, strings are for humans, so it does take its merry time to calculate the UTC offset. – Hans Passant Apr 26 '17 at 22:06

3 Answers3

9

Observe:

// These are just plain unspecified DateTimes
DateTime dtOneAm = new DateTime(2017, 03, 12, 01, 00, 00);
DateTime dtFourAm = new DateTime(2017, 03, 12, 04, 00, 00);

// The difference is not going to do anything other than 4-1=3
TimeSpan difference1 = dtFourAm - dtOneAm;

// ... but we have a time zone to consider!
TimeZoneInfo eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");

// Use that time zone to get DateTimeOffset values.
// The GetUtcOffset method has what we need.
DateTimeOffset dtoOneAmEastern = new DateTimeOffset(dtOneAm, eastern.GetUtcOffset(dtOneAm));
DateTimeOffset dtoFourAmEastern = new DateTimeOffset(dtFourAm, eastern.GetUtcOffset(dtFourAm));

// Subtracting these will take the offset into account!
// It essentially does this: [4-(-4)]-[1-(-5)] = 8-6 = 2
TimeSpan difference2 = dtoFourAmEastern - dtoOneAmEastern;

// Let's see the results
Console.WriteLine("dtOneAm: {0:o} (Kind: {1})", dtOneAm, dtOneAm.Kind);
Console.WriteLine("dtFourAm: {0:o} (Kind: {1})", dtFourAm, dtOneAm.Kind);
Console.WriteLine("difference1: {0}", difference1);

Console.WriteLine("dtoOneAmEastern: {0:o})", dtoOneAmEastern);
Console.WriteLine("dtoFourAmEastern: {0:o})", dtoFourAmEastern);
Console.WriteLine("difference2: {0}", difference2);

Results:

dtOneAm: 2017-03-12T01:00:00.0000000 (Kind: Unspecified)
dtFourAm: 2017-03-12T04:00:00.0000000 (Kind: Unspecified)
difference1: 03:00:00

dtoOneAmEastern: 2017-03-12T01:00:00.0000000-05:00)
dtoFourAmEastern: 2017-03-12T04:00:00.0000000-04:00)
difference2: 02:00:00

Note that DateTime carries a DateTimeKind in its Kind property, which is Unspecified by default. It doesn't belong to any particular time zone. DateTimeOffset doesn't have a kind, it has an Offset, which tells you how far that local time is offset from UTC. Neither of these give you the time zone. That is what TimeZoneInfo object is doing. See "time zone != offset" in the timezone tag wiki.

The part I think you are perhaps frustrated with, is that for several historical reasons, the DateTime object does not ever understand time zones when doing math, even when you might have DateTimeKind.Local. It could have been implemented to observe the transitions of the local time zone, but it was not done that way.

You also might be interested in Noda Time, which gives a very different API for date and time in .NET, in a much more sensible and purposeful way.

using NodaTime;

...

// Start with just the local values.
// They are local to *somewhere*, who knows where?  We didn't say.
LocalDateTime ldtOneAm = new LocalDateTime(2017, 3, 12, 1, 0, 0);
LocalDateTime ldtFourAm = new LocalDateTime(2017, 3, 12, 4, 0, 0);

// The following won't compile, because LocalDateTime does not reference
// a linear time scale!
// Duration difference = ldtFourAm - ldtOneAm;

// We can get the 3 hour period, but what does that really tell us?
Period period = Period.Between(ldtOneAm, ldtFourAm, PeriodUnits.Hours);

// But now lets introduce a time zone
DateTimeZone eastern = DateTimeZoneProviders.Tzdb["America/New_York"];

// And apply the zone to our local values.
// We'll choose to be lenient about DST gaps & overlaps.
ZonedDateTime zdtOneAmEastern = ldtOneAm.InZoneLeniently(eastern);
ZonedDateTime zdtFourAmEastern = ldtFourAm.InZoneLeniently(eastern);

// Now we can get the difference as an exact elapsed amount of time
Duration difference = zdtFourAmEastern - zdtOneAmEastern;


// Dump the output
Console.WriteLine("ldtOneAm: {0}", ldtOneAm);
Console.WriteLine("ldtFourAm: {0}", ldtFourAm);
Console.WriteLine("period: {0}", period);

Console.WriteLine("zdtOneAmEastern: {0}", zdtOneAmEastern);
Console.WriteLine("zdtFourAmEastern: {0}", zdtFourAmEastern);
Console.WriteLine("difference: {0}", difference);
ldtOneAm: 3/12/2017 1:00:00 AM
ldtFourAm: 3/12/2017 4:00:00 AM
period: PT3H

zdtOneAmEastern: 2017-03-12T01:00:00 America/New_York (-05)
zdtFourAmEastern: 2017-03-12T04:00:00 America/New_York (-04)
difference: 0:02:00:00

We can see a period of three hours, but it doesn't really mean the same as the elapsed time. It just means the two local values are three hours apart in their position on a clock. NodaTime understands the difference between these concepts, while .Net's built-in types do not.

Some follow-up reading for you:

Oh, and one other thing. Your code has this...

DateTime oneAm = TimeZoneInfo.ConvertTime(new DateTime(2017, 03, 12, 01, 00, 00), easternStandardTime);

Since the DateTime you create has unspecified kind, you are asking to convert from your computer's local time zone to Eastern time. If you happen to be not in Eastern time, your oneAm variable might not be 1 AM at all!

Community
  • 1
  • 1
Matt Johnson-Pint
  • 230,703
  • 74
  • 448
  • 575
-1

Ok, so I made some minor changes to your code. Not sure if this is what you are trying to achieve or not but this will give you what you want...

static void Main() {
        TimeZoneInfo easternStandardTime = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
        TimeZone timeZone = TimeZone.CurrentTimeZone;

        DateTime oneAm = TimeZoneInfo.ConvertTime(new DateTime(2017, 03, 12, 01, 00, 00), easternStandardTime);
        DateTime fourAm = TimeZoneInfo.ConvertTime(new DateTime(2017, 03, 12, 04, 00, 00), easternStandardTime);

        DaylightTime time = timeZone.GetDaylightChanges(fourAm.Year);

        TimeSpan difference = ((fourAm - time.Delta) - oneAm);

        Console.WriteLine(oneAm);
        Console.WriteLine(fourAm);
        Console.WriteLine(TimeZoneInfo.Local.IsDaylightSavingTime(oneAm));
        Console.WriteLine(TimeZoneInfo.Local.IsDaylightSavingTime(fourAm));
        Console.WriteLine(difference);
        Console.ReadLine();
    }
ReRoute
  • 371
  • 1
  • 13
  • Thanks. Can you explain why this is necessary, though? I'm still confused by why just subtracting 2017-03-12 01:00 -5 (i.e. 6:00 UTC) from 2017-03-12 04:00 -4 (i.e. 8:00 UTC) yields 3. – Jon Schneider Apr 26 '17 at 21:44
  • As I understand it, the language can understand that it is in fact when time shifts but when you do the time-span it is not accounted for and is looked at as if you are taking a span between 12/03/2017 01.00.00 and 12/03/2017 04.00.00. Aka 4 -1. I found this article helpful in explaining how Daylight Savings Time is understood. It is towards the bottom : https://msdn.microsoft.com/en-us/library/ms973825.aspx – ReRoute Apr 26 '17 at 21:50
  • This is still problematic, as the local time zone is being introduced in the conversion functions. You're converting *from* local time *to* eastern time. This is not the same as applying the eastern time zone offset. Also, the `DaylightTime.Delta` object here is just going to be 1 hour. It does not do what you think it does. Really, the `GetDaylightChanges` method is rarely useful. – Matt Johnson-Pint Apr 26 '17 at 22:05
-2

So this is addressed in the MSDN documentation.

Basicaly, when subtracting one date from another you should be using DateTimeOffset.Subtract() and not arithmetic subtraction as you have here.

TimeSpan difference = fourAm.Subtract(oneAm);

Yields the expected 2 hour time difference.

leigero
  • 3,233
  • 12
  • 42
  • 63
  • I actually did try using the .Subtract method instead of the minus operator when I was playing around with this prior to posting. However, I was using DateTime objects, not DateTimeOffset, and the code still returned a 3 hour TimeSpan. I'm starting to suspect that trying to do math with DateTime objects as in my example isn't the right approach, although I don't really understand WHY that is yet. – Jon Schneider Apr 26 '17 at 21:07
  • Still yields the same 3 hours as up there. – ReRoute Apr 26 '17 at 21:11
  • Can you explain why using DateTime doesn't work? I'm still confused by why subtracting 2017-03-12 01:00 -5 (i.e. 6:00 UTC) from 2017-03-12 04:00 -4 (i.e. 8:00 UTC) yields 3. – Jon Schneider Apr 26 '17 at 21:45
  • 2
    Firstly, `fourAm.Subtract(oneAm)` calls `DateTime.Subtract`, not `DateTimeOffset.Subtract`. Secondly, the documentation you link to says the opposite: it specifically says that in languages that support operator overloading, you can just use `-` instead of calling `Subtract`. –  Apr 26 '17 at 22:16