18

My local timezone is (UTC+10:00) Canberra, Melbourne, Sydney

Sat 31-Mar-2012 15:59 UTC = Sun 01-Apr-2012 02:59 +11:00
Sat 31-Mar-2012 16:00 UTC = Sun 01-Apr-2012 02:00 +10:00

Daylight savings finishes at 3 AM first Sunday in April and the clock wind back 1 hour.

Given the following code ....

DateTime dt1 = DateTime.Parse("31-Mar-2012 15:59", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal);

DateTime dt2 = DateTime.Parse("31-Mar-2012 15:59", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal).AddMinutes(1);
DateTime dt3 = DateTime.Parse("31-Mar-2012 16:00", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal);

Console.WriteLine("{0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1);
Console.WriteLine("{0:yyyy-MMM-dd HH:mm:ss.ffff K} ({1}) = {2:yyyy-MMM-dd HH:mm:ss.ffff K} ({3})", dt2, dt2.Kind, dt3, dt3.Kind);
Console.WriteLine("{0} : {1} : {2}", dt1.ToUniversalTime().Hour, dt2.ToUniversalTime().Hour, dt3.ToUniversalTime().Hour);

I get the following output

2012-Apr-01 02:59:00.0000 +11:00
2012-Apr-01 03:00:00.0000 +10:00 (Local) = 2012-Apr-01 02:00:00.0000 +10:00 (Local)
15 : 17 : 16

Adding 1 minute to the original datetime makes the local time 3AM but also set the offset to +10 hours. Adding 1 minute to the UTC date and parsing correctly sets the local time to 2 AM with a +10 UTC offset.

Repeating with

DateTime dt1 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc);

DateTime dt2 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc).AddMinutes(1);
DateTime dt3 = new DateTime(2012, 03, 31, 16, 0, 0, DateTimeKind.Utc);

or

DateTime dt1 = DateTime.Parse("31-Mar-2012 15:59", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);

DateTime dt2 = DateTime.Parse("31-Mar-2012 15:59", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal).AddMinutes(1);
DateTime dt3 = DateTime.Parse("31-Mar-2012 16:00", CultureInfo.CurrentCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal); 

gives

2012-Mar-31 15:59:00.0000 Z
2012-Mar-31 16:00:00.0000 Z (Utc) = 2012-Mar-31 16:00:00.0000 Z (Utc)
15 : 16 : 16

as expected

Repeating again with

DateTime dt1 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc).ToLocalTime();

DateTime dt2 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc).ToLocalTime().AddMinutes(1);
DateTime dt3 = new DateTime(2012, 03, 31, 16, 0, 0, DateTimeKind.Utc).ToLocalTime();

gives the original

2012-Apr-01 02:59:00.0000 +11:00
2012-Apr-01 03:00:00.0000 +10:00 (Local) = 2012-Apr-01 02:00:00.0000 +10:00 (Local)
15 : 17 : 16

Can anyone explain this ?

Indecently if I use the TimeZoneInfo to convert from UTC to AUS Eastern Standard Time I get the correct time, but I lose the offset information in the DateTime instance as the DateTime.Kind == DateTimeKind.Unspecified

== Additional scenario to highlight

This is just simple timespan adding, starting with an NON-ambiguous UTC date, 1 minute before Daylight savings finishes.

DateTime dt1 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc);  
DateTime dt2 = new DateTime(2012, 03, 31, 15, 59, 0, DateTimeKind.Utc).ToLocalTime();  

Console.WriteLine("Original in UTC     : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1);  
Console.WriteLine("Original in Local   : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1.ToLocalTime());  
Console.WriteLine("+ 1 Minute in Local : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1.AddMinutes(1).ToLocalTime());  
Console.WriteLine("+ 1 Minute in UTC   : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt1.AddMinutes(1));  
Console.WriteLine("=====================================================");
Console.WriteLine("Original in UTC     : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt2.ToUniversalTime());  
Console.WriteLine("Original in Local   : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt2);  
Console.WriteLine("+ 1 Minute in Local : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt2.AddMinutes(1));  
Console.WriteLine("+ 1 Minute in UTC   : {0:yyyy-MMM-dd HH:mm:ss.ffff K}", dt2.AddMinutes(1).ToUniversalTime());  

gives

Original in UTC : 2012-Mar-31 15:59:00.0000 Z
Original in Local : 2012-Apr-01 02:59:00.0000 +11:00
+ 1 Minute in Local : 2012-Apr-01 02:00:00.0000 +10:00
+ 1 Minute in UTC : 2012-Mar-31 16:00:00.0000 Z

=====================================================

Original in UTC : 2012-Mar-31 15:59:00.0000 Z
Original in Local : 2012-Apr-01 02:59:00.0000 +11:00
+ 1 Minute in Local : 2012-Apr-01 03:00:00.0000 +10:00
+ 1 Minute in UTC : 2012-Mar-31 17:00:00.0000 Z

Robert Slaney
  • 3,712
  • 1
  • 21
  • 25
  • DateTime does NOT "keep" the local offset. I merely shows the offset that would be in effect at that time. Since dt2 is always in local time, the view it has of the current hour IS "true". You should be using DateTimeOffset if you want to carry around the "as applied" offset http://msdn.microsoft.com/en-us/library/system.datetimeoffset.aspx – IDisposable Mar 02 '12 at 01:42
  • ... if that was true then I would have expected the third result from dt2 in the last scenario to be 03:00:00 +11:00, but it knows that DST has finished. It correctly switched to +10:00, but didn't take off the hour. DateTimeOffset shows the time as 03:00:00 +11:00, which is not valid for my local timezone. – Robert Slaney Mar 02 '12 at 02:22
  • No, it knows that YOU SAID this was 3:00 as of 4/1/2012, so the offset AT THAT moment is +10:00 – IDisposable Mar 02 '12 at 03:38
  • I never said it was 03:00 +10, I added 1 minute to 2:59 +11. It should have resulted in 02:00 +10 The DateTime.Kind property of that date was Local – Robert Slaney Mar 02 '12 at 04:09
  • 1
    @RobertSlaney: That's the problem - that it *was* doing local arithmetic. When you've got a DateTimeKind of Local, it doesn't take any DST into account; you're not adding "elapsed" time, you're just adding to the local time. – Jon Skeet Mar 02 '12 at 06:24
  • thanks Jon, what I read into that is that you can ONLY do datetime arithmetic if the DateTimeKind is UTC. Then you have to convert to local time. Correct ? That also implies that the "K" format string is independently evaluated from the actual as it went to +10 which is therefore DST aware but the time stayed at 03:00:00 which isn't DST aware – Robert Slaney Mar 04 '12 at 21:32

2 Answers2

29

I believe the problem is in terms of when the conversions are performed.

You're parsing assuming universal time, but then implicitly converting to a "local" kind - with a value of 2:59:59. When you ask that "local" value to add a minute, it's just adding a minute to the local value, with no consideration for time zone. When you then print the offset, the system is trying to work out the offset at the local time of 3am... which is +10.

So effectively you've got:

  • Parse step 1: treat string as universal (15:59 UTC)
  • Parse step 2: convert result to local (2:59 local)
  • Addition: in local time, no time zone values are applied (3:00 local)
  • Format step 1: offset is requested, so work out what that local time maps to (17:00 UTC)
  • Format step 2: compute offset as difference between local and universal (+10)

Yes, it's all a bit painful - DateTime is painful in general, which is the main reason I'm writing Noda Time, where there are separate types for "date/time in a zone" vs "local date/time" (or "local date" or "local time"), and it's obvious which you're using at any one point.

It's not clear to me what you're actually trying to achieve here - if you can be more specific, I can show you what you would do in Noda Time, although there may be some inherent ambiguities (conversions from local date/times to "zoned" date/times can have 0, 1 or 2 results).

EDIT: If the aim is merely to remember the time zone as well as the instant, in Noda Time you'd want ZonedDateTime, like this:

using System;
using NodaTime;

class Program
{
    static void Main(string[] args)
    {
        var zone = DateTimeZone.ForId("Australia/Melbourne");
        ZonedDateTime start = Instant.FromUtc(2012, 3, 31, 15, 59, 0)
                                     .InZone(zone);
        ZonedDateTime end = start + Duration.FromMinutes(1);

        Console.WriteLine("{0} ({1})", start.LocalDateTime, start.Offset);
        Console.WriteLine("{0} ({1})", end.LocalDateTime, end.Offset);
    }
}

See the notes on calendar arithmetic for some more information about this.

Jon Skeet
  • 1,421,763
  • 867
  • 9,128
  • 9,194
  • I saw the post on your blog and was in the middle of writing some unit tests around daylight savings to verify Noda Time DST handling but got stuck when I saw these results just from DateTime struct – Robert Slaney Mar 02 '12 at 00:50
  • Marking this answer as correct, as the various discussions have highlighted that DateTime arithmetic on non-UTC datetime is fundamentally broken in .NET. The only way to combat this is to wrap or replace DateTime – Robert Slaney Mar 04 '12 at 21:39
0

My way of dealing with this is to treat DateTime's a bit like Floats - they require special handling for when you're manipulating them vs when you're showing them to the user. I use a little library I wrote to wrap them:

https://github.com/b9chris/TimeZoneInfoLib.Net

And always treat them as UTC + TimeZoneInfo. That way you can do all the typical math you'd normally do, operating solely UTC to UTC, and only deal with local DateTimes at the last step of showing them to the user in some nice format. Another bonus of this structure is you can more accurately show a clean timezone to the user in a format they're used to, rather than scraping around in the TimeZoneInfo class each time.

Chris Moschini
  • 36,764
  • 19
  • 160
  • 190