Почему не вычитается два значения локальной даты DateTime для учета летнего времени?

Я играю с некоторым кодом на С#, чтобы попытаться понять, как вычесть объекты DateTime в С# в отношении летнего времени.

В Google и других источниках время перехода на летнее время "spring вперед" в Восточном стандартном часовом поясе в 2017 году было в 12:00 12 марта. Таким образом, первые несколько часов дня в эту дату были

   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

Итак, если бы я рассчитывал разницу во времени между 1:00 и 4:00 в этот часовой пояс в эту дату, я ожидал, что результат будет 2 часов.

Однако код, который я собрал, чтобы попытаться смоделировать эту проблему, возвращает часовой пояс 3.

код:

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);

На моем ПК это генерирует:

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

Весь этот вывод будет таким, как ожидалось, за исключением того, что конечное значение 3 часа, которое, как я уже отмечал выше, я ожидаю вместо этого на 2 часа.

Очевидно, что мой код неправильно моделирует ситуацию, о которой я имею в виду. Каков недостаток?

Ответ 1

Заметим:

// 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 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);

Результаты:

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

Обратите внимание, что DateTime несет DateTimeKind в свойстве Kind, который по умолчанию равен Unspecified. Он не принадлежит к определенному часовому поясу. DateTimeOffset не имеет вида, он имеет Offset, который сообщает вам, насколько далеко локальное время смещено от UTC. Ни один из них не дает вам часового пояса. Это то, что делает объект TimeZoneInfo. См. "Часовой пояс!= Смещение" в вики-указатель часового пояса.

Часть, которую, я думаю, вы, возможно, разочарованы, заключается в том, что по нескольким историческим причинам объект DateTime никогда не понимает часовые пояса при выполнении математики, даже если у вас может быть DateTimeKind.Local. Он мог бы быть реализован для наблюдения за переходами локального часового пояса, но это не было сделано таким образом.

Вы также можете быть заинтересованы в Noda Time, который дает совсем другой API для даты и времени в .NET, в гораздо более разумным и целенаправленным способом.

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

Мы можем видеть период в три часа, но это не значит, что истекшее время. Это просто означает, что два локальных значения находятся на расстоянии три часа в своем положении на часах. NodaTime понимает разницу между этими понятиями, в то время как встроенные типы .Net не поддерживают.

Некоторые последующие чтения для вас:

О, и еще одна вещь. У вашего кода это...

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

Поскольку созданный DateTime не имеет определенного типа, вы просите конвертировать из локального часового пояса вашего компьютера в восточное время. Если вы не в восточное время, ваша переменная oneAm может быть не равна 1 AM!

Ответ 2

Итак, это описано в документации MSDN.

В основном, при вычитании одной даты из другой вы должны использовать DateTimeOffset.Subtract(), а не арифметическое вычитание, как вы здесь.

TimeSpan difference = fourAm.Subtract(oneAm);

Устанавливает ожидаемую разницу во времени на 2 часа.

Ответ 3

Хорошо, поэтому я внес некоторые незначительные изменения в ваш код. Не уверен, что это то, чего вы пытаетесь достичь или нет, но это даст вам то, что вы хотите...

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();
    }