From f08612bcf5e490f9c5355e61c92216e1ad00fd58 Mon Sep 17 00:00:00 2001 From: Kai Bao Date: Fri, 4 Sep 2026 08:06:45 +0200 Subject: [PATCH] Write the time conversions with the C++20 calendar types TimeService converted between std::time_t and civil time in two ways that both go wrong outside a narrow window, and neither is specific to any platform: - portable_timegm() computed 24L * days, so the expression was 32-bit wherever long is 32 bits: every date outside roughly 1902-2038 overflowed, and a schedule reaching year 3000 came back as 1911. - The other direction was *std::gmtime(&t), dereferenced without a check. std::gmtime() returns nullptr for time points its C runtime refuses - MSVC's for everything before 1970 and after year 3000, both of which schedules legitimately reach - so InteHEAD and DoubHEAD crashed writing the first report step of such a schedule. It also hands back a pointer to a static buffer, so concurrent conversions overwrite each other's result. Both directions are now written with the C++20 calendar types, which are the same algorithms standardised. Only the calendar types - sys_days, year_month_day, weekday - are used, never the time zone part of the library: nothing here needs tzdata, and nothing needs the libstdc++ 14 that shipped it. The calendar types are in every standard library OPM builds with (libstdc++ 11, libc++, MSVC 2019 16.10 and later). - portable_timegm() builds std::chrono::sys_days from year/month/1 and adds the day of the month, so a day beyond the month still counts on from its first (33 January is 2 February): the wrap-around mkdatetime() relies on to reject such input. The month is normalised into the year as before. - TimeService::portable_gmtime() replaces std::gmtime(), and sits beside portable_timegm() as its inverse: it takes the day count apart with year_month_day and weekday, fills every field std::gmtime() fills (DoubHEAD's day-of-year calculation reads tm_yday), and returns the std::tm by value. Its floor division splits time_t into day and second taking quotient and remainder first, since the shorter (t - 86399) / 86400 overflows at the smallest time_t. TimeStampUTC(time_t), InteHEAD, DoubHEAD and OutputStream go through it. - The accepted range is std::chrono::year's, -32767-01-01 to 32767-12-31, in both directions; outside it either function throws std::out_of_range, the bounds taken from itself and shared by both. portable_timegm() checks the finished instant, not only the year, since the day of the month or the time of day can carry a std::tm past the end (32767-12-32, or 24:00 on the last day) - what it returns, portable_gmtime() always takes back. No schedule is anywhere near either end, and a refusal is the defined behaviour in place of a crash or a silently wrong date. On Linux the old code accepted such a date and produced a wrong one; a deck that holds one now fails with the DATES record's location, since ScheduleDeck wraps the throw in an OpmInputError. - Every second count is a long long, in the bounds and in the arithmetic on the std::tm fields alike, so an absurd field ends in the refusal above rather than in an overflow on the way there. Not std::time_t: the bounds are near 1e12, and a constant expression that overflows is ill-formed rather than merely wrong, so a 32-bit time_t would not compile. Leaving the arithmetic at the width of long is what the old conversion got wrong. - That fixes the arithmetic, but a std::time_t is still what both conversions take and return, and a narrow one would truncate every date the bounds admit. A static_assert requires the 64 bits instead, since a schedule runs past 2038 as a matter of course. This is deliberate: it refuses to build on a platform with a 32-bit time_t, which none of OPM's targets is, rather than let dates go wrong silently there. - The hand-written days_from_civil() is gone; nothing else used it. tests/test_TimeService.cpp covers both directions: dates on both sides of the epoch and past year 3000, tm_yday and tm_wday against known dates, year 1, a round trip through TimeStampUTC, both ends of the calendar with the first instant past each refused, a std::tm carried past either end by its year, its day of the month or its time of day refused in the other direction, absurd tm fields refused, and the ends of the 64-bit range. It also covers mkdate() still rejecting 30 February, 33 January and month 13, which nothing else in the suite did: that rejection works only because portable_timegm() lets the day of the month carry instead of normalising it away or refusing it, so a later rewrite of the conversion could drop it silently. --- CMakeLists_files.cmake | 1 + opm/common/utility/TimeService.cpp | 193 ++++++++++++++++----- opm/common/utility/TimeService.hpp | 26 +++ opm/io/eclipse/OutputStream.cpp | 3 +- opm/output/eclipse/DoubHEAD.cpp | 6 +- opm/output/eclipse/InteHEAD.cpp | 2 +- tests/test_TimeService.cpp | 267 +++++++++++++++++++++++++++++ 7 files changed, 450 insertions(+), 48 deletions(-) create mode 100644 tests/test_TimeService.cpp diff --git a/CMakeLists_files.cmake b/CMakeLists_files.cmake index 5b684b52fb9..9e319b7f313 100644 --- a/CMakeLists_files.cmake +++ b/CMakeLists_files.cmake @@ -585,6 +585,7 @@ list(APPEND TEST_SOURCE_FILES tests/test_Summary_Group.cpp tests/test_Summary_GSatProd.cpp tests/test_Tables.cpp + tests/test_TimeService.cpp tests/test_uniformtablelinear.cpp tests/test_Uns2CPG.cpp tests/test_Visitor.cpp diff --git a/opm/common/utility/TimeService.cpp b/opm/common/utility/TimeService.cpp index a54ba38afb0..cb6932e9482 100644 --- a/opm/common/utility/TimeService.cpp +++ b/opm/common/utility/TimeService.cpp @@ -25,7 +25,6 @@ #include #include -#include #include #include #include @@ -68,36 +67,36 @@ namespace { - // The days_from_civil() function is from Howard Hinnant, http://howardhinnant.github.io/date_algorithms.html - // The website states: "Consider these donated to the public domain." - - // Returns number of days since civil 1970-01-01. Negative values indicate - // days prior to 1970-01-01. - // Preconditions: y-m-d represents a date in the civil (Gregorian) calendar - // m is in [1, 12] - // d is in [1, last_day_of_month(y, m)] - // y is "approximately" in - // [numeric_limits::min()/366, numeric_limits::max()/366] - // Exact range of validity is: - // [civil_from_days(numeric_limits::min()), - // civil_from_days(numeric_limits::max()-719468)] - template - constexpr - Int - days_from_civil(Int y, unsigned m, unsigned d) noexcept - { - static_assert(std::numeric_limits::digits >= 18, - "This algorithm has not been ported to a 16 bit unsigned integer"); - static_assert(std::numeric_limits::digits >= 20, - "This algorithm has not been ported to a 16 bit signed integer"); - y -= m <= 2; - const Int era = (y >= 0 ? y : y-399) / 400; - const unsigned yoe = static_cast(y - era * 400); // [0, 399] - const unsigned doy = (153*(m > 2 ? m-3 : m+9) + 2)/5 + d-1; // [0, 365] - const unsigned doe = yoe * 365 + yoe/4 - yoe/100 + doy; // [0, 146096] - return era * 146097 + static_cast(doe) - 719468; + // The days std::chrono::year spans, -32767-01-01 to 32767-12-31, taken + // from the types themselves rather than restated, and the seconds those + // days hold. Both conversions accept exactly this and refuse the rest. + // + // long long, not std::time_t: the second counts are near 1e12, and a + // 32-bit time_t would not merely hold the wrong value - a constant + // expression that overflows is ill-formed, so the file would not compile. + // Leaving the arithmetic at the width of long is what the old conversion + // got wrong; a fixed 64 bits here is what keeps that from recurring. + // Comparing a time_t against these widens it, which loses nothing. + namespace calendar_bounds { + constexpr long long first_day = + std::chrono::sys_days{std::chrono::year::min() / std::chrono::January / 1} + .time_since_epoch().count(); + constexpr long long last_day = + std::chrono::sys_days{std::chrono::year::max() / std::chrono::December / 31} + .time_since_epoch().count(); + constexpr long long first_second = first_day * 86400; + constexpr long long last_second = last_day * 86400 + 86399; } + // The arithmetic above is a fixed 64 bits, but a std::time_t is what + // both conversions take and return, and a narrow one would silently + // truncate every date the calendar bounds admit. A schedule runs past + // 2038 as a matter of course, so require the width rather than lose the + // dates quietly. Nothing OPM Flow builds on has a narrower time_t. + static_assert(sizeof(std::time_t) >= 8, + "OPM Flow schedules run past 2038; std::time_t must be 64-bit"); + + } // anonymous namespace @@ -175,23 +174,132 @@ std::time_t mkdate(int in_year, int in_month, int in_day) { return mkdatetime(in_year , in_month , in_day, 0,0,0); } -// The portable_timegm() function is based on -// https://stackoverflow.com/questions/16647819/timegm-cross-platform -// answer by Sergey D. +// timegm() is POSIX, not C++, and the Windows spelling _mkgmtime() stops at +// year 3000. This is the conversion written with the C++20 calendar +// types instead, valid wherever they are: a std::tm's year, month, day and +// time of day to seconds since the epoch, in UTC. +// +// Only the month is normalised into the year, as the earlier version of this +// function did. A day outside the month counts on from its first (33 January +// is 2 February) and the time of day is added as it stands, which is the +// wrap-around mkdatetime() relies on to reject such input. std::time_t portable_timegm(const std::tm* t) { - int year = t->tm_year + 1900; - int month = t->tm_mon; // 0-11 + namespace ch = std::chrono; + + // Everything in long long before any arithmetic: a std::tm's fields are + // ints, and a caller's absurd value must end in the refusal below, not in + // an overflow on the way there. + long long yr = static_cast(t->tm_year) + 1900; + long long month = t->tm_mon; // 0-11 if (month > 11) { - year += month / 12; + yr += month / 12; month %= 12; } else if (month < 0) { - int years_diff = (11 - month) / 12; - year -= years_diff; + const long long years_diff = (11 - month) / 12; + yr -= years_diff; month += 12 * years_diff; } - int days_from_1970 = days_from_civil(year, month + 1, t->tm_mday); - return 60 * (60 * (24L * days_from_1970 + t->tm_hour) + t->tm_min) + t->tm_sec; + + // std::chrono::year runs from -32767 to 32767. No schedule is anywhere + // near either end; refuse the rest rather than hand back a wrong instant. + if (yr < static_cast(ch::year::min()) || yr > static_cast(ch::year::max())) { + throw std::out_of_range { + "Calendar year " + std::to_string(yr) + + " is outside the range std::chrono::year can represent" + }; + } + + const ch::sys_days first_of_month = + ch::year{static_cast(yr)} / ch::month{static_cast(month + 1)} / 1; + // long long, not int: 86400 * days overflows a 32-bit type for any date + // outside roughly 1902-2038, and widening the day count promotes the + // whole expression. Not std::time_t either, for the reason the bounds + // above are not. + const long long days_from_1970 = + static_cast(first_of_month.time_since_epoch().count()) + + (static_cast(t->tm_mday) - 1); + const long long result = + 60 * (60 * (24 * days_from_1970 + t->tm_hour) + t->tm_min) + t->tm_sec; + + // The day of the month and the time of day may have carried the instant + // past the calendar's end even though the year was inside it - + // 32767-12-32, or 24:00 on the last day - and portable_gmtime() would + // refuse what came back. Refuse it here instead, against the same bounds. + if (result < calendar_bounds::first_second || result > calendar_bounds::last_second) { + throw std::out_of_range { + "Date " + std::to_string(yr) + "-" + std::to_string(month + 1) + "-" + + std::to_string(t->tm_mday) + " with the time of day added lies outside " + + "the range std::chrono::year can represent" + }; + } + return static_cast(result); +} + +/* + Break a time_t into UTC civil time without std::gmtime(), the inverse of + portable_timegm() above. + + std::gmtime() is unsuitable here on two counts. It returns nullptr for + time points it cannot represent -- some C runtimes refuse dates beyond + year 3000, which simulation schedules legitimately reach -- and + dereferencing that is a crash. It also returns a pointer to a static + buffer, so two threads converting timestamps concurrently overwrite each + other's result. + + The C++20 calendar types have neither problem and are the exact + inverse of what portable_timegm() does in the other direction, so use + them unconditionally: every platform then exercises the same code. + + Every field std::gmtime() sets is set here too, so this is a drop-in + replacement: DoubHEAD's day-of-year calculation reads tm_yday. + tm_isdst is 0, as it is for UTC. +*/ +std::tm portable_gmtime(const std::time_t t) +{ + namespace ch = std::chrono; + + // Floor division, without the overflow that t - 86399 has at the + // bottom of the range: take the quotient and remainder first, then + // carry a negative remainder into the day. Neither operation can + // overflow for any representable time_t. + auto days = t / 86400; + auto secs = t % 86400; + if (secs < 0) { + secs += 86400; + --days; + } + + // A 64-bit time_t exceeds the days std::chrono::year spans by a wide + // margin in either direction; no schedule holds such an instant, and + // a day count outside them does not fit the calendar arithmetic, so + // refuse it - as std::gmtime() refuses, with nullptr, what its + // runtime cannot represent - rather than hand back a wrong date. + if ((days < calendar_bounds::first_day) || (days > calendar_bounds::last_day)) { + throw std::out_of_range { + "Time point " + std::to_string(static_cast(t)) + + " is outside the range of years std::chrono::year can represent" + }; + } + + const ch::sys_days day{ch::days{static_cast(days)}}; + const ch::year_month_day ymd{day}; + + std::tm tm{}; + tm.tm_year = static_cast(ymd.year()) - 1900; + tm.tm_mon = static_cast(static_cast(ymd.month())) - 1; + tm.tm_mday = static_cast(static_cast(ymd.day())); + tm.tm_hour = static_cast(secs / 3600); secs %= 3600; + tm.tm_min = static_cast(secs / 60); + tm.tm_sec = static_cast(secs % 60); + + // Day of year, counted from 0 as std::tm wants it, and the weekday + // with Sunday as 0, as std::tm and 's c_encoding() agree. + tm.tm_yday = static_cast((day - ch::sys_days{ymd.year() / ch::January / 1}).count()); + tm.tm_wday = static_cast(ch::weekday{day}.c_encoding()); + + tm.tm_isdst = 0; // UTC never has one + return tm; } std::time_t timeFromEclipse(const DeckRecord &dateRecord) { @@ -239,13 +347,11 @@ namespace { return timePoint; } - } Opm::TimeStampUTC::TimeStampUTC(const std::time_t tp) { - auto t = tp; - const auto tm = *std::gmtime(&t); + const auto tm = TimeService::portable_gmtime(tp); this->ymd_ = YMD { tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday }; @@ -263,8 +369,7 @@ Opm::TimeStampUTC::TimeStampUTC(const Opm::TimeStampUTC::YMD& ymd, Opm::TimeStampUTC& Opm::TimeStampUTC::operator=(const std::time_t tp) { - auto t = tp; - const auto tm = *std::gmtime(&t); + const auto tm = TimeService::portable_gmtime(tp); this->ymd_ = YMD { tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday }; diff --git a/opm/common/utility/TimeService.hpp b/opm/common/utility/TimeService.hpp index bb829af4ca2..bf2207066ea 100644 --- a/opm/common/utility/TimeService.hpp +++ b/opm/common/utility/TimeService.hpp @@ -46,7 +46,33 @@ namespace Opm { std::time_t mkdatetime(int in_year, int in_month, int in_day, int hour, int minute, int second); std::time_t mkdate(int in_year, int in_month, int in_day); + /// Seconds since the epoch for a std::tm read as UTC civil time: POSIX + /// timegm(), written with the C++20 calendar types so it is the + /// same everywhere (Windows' _mkgmtime() stops at year 3000). + /// + /// The month is normalised into the year; a day beyond the month and the + /// time of day are added as they stand, so 33 January is 2 February. + /// + /// Throws std::out_of_range for an instant outside what std::chrono::year + /// can represent, -32767-01-01T00:00:00Z to 32767-12-31T23:59:59Z. That + /// is a year past either end, and also an in-range year whose day of the + /// month or time of day carries the instant past it -- 32767-12-32, or + /// 24:00 on the last day. What this returns, portable_gmtime() always + /// takes back. std::time_t portable_timegm(const std::tm* t); + + /// Break a time_t into UTC civil time. + /// + /// The inverse of portable_timegm(), and a replacement for std::gmtime(): + /// that returns nullptr for time points its C runtime cannot represent -- + /// MSVC's refuses everything before 1970 and after year 3000, both of + /// which simulation schedules legitimately reach -- and hands back a + /// pointer to a static buffer that concurrent callers overwrite. + /// + /// Throws std::out_of_range for an instant whose calendar year lies + /// outside std::chrono::year, -32767 to 32767; within it, every field + /// std::gmtime() fills is filled, tm_yday and tm_wday included. + std::tm portable_gmtime(std::time_t t); std::time_t timeFromEclipse(const DeckRecord &dateRecord); } diff --git a/opm/io/eclipse/OutputStream.cpp b/opm/io/eclipse/OutputStream.cpp index e116e660930..693a1403b2f 100644 --- a/opm/io/eclipse/OutputStream.cpp +++ b/opm/io/eclipse/OutputStream.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include @@ -667,7 +668,7 @@ namespace { std::tm startTimeToGmtime(const SummarySpecification::StartTime start) { const auto timepoint = std::chrono::system_clock::to_time_t(start); - return *std::gmtime(&timepoint); + return TimeService::portable_gmtime(timepoint); } std::vector diff --git a/opm/output/eclipse/DoubHEAD.cpp b/opm/output/eclipse/DoubHEAD.cpp index 2af0705ecc4..1953c1a9426 100644 --- a/opm/output/eclipse/DoubHEAD.cpp +++ b/opm/output/eclipse/DoubHEAD.cpp @@ -22,6 +22,8 @@ #include // Opm::RestartIO::makeUTCTime() #include +#include + #include #include #include @@ -350,7 +352,7 @@ namespace { double toDateNum(const std::chrono::time_point tp) { const auto t0 = std::chrono::system_clock::to_time_t(tp); - const auto tm0 = *std::gmtime(&t0); + const auto tm0 = Opm::TimeService::portable_gmtime(t0); // Set clock to 01:00:00+0000 on 2001-- to get // "accurate" day-of-year calculation (no leap year, no DST offset, @@ -368,7 +370,7 @@ namespace { const auto t1 = Opm::TimeService::makeUTCTime(tm1); if (t1 != static_cast(-1)) { - tm1 = *std::gmtime(&t1); // Get new tm_yday. + tm1 = Opm::TimeService::portable_gmtime(t1); // Get new tm_yday. return toDateNum(tm0.tm_year, tm1.tm_yday); } diff --git a/opm/output/eclipse/InteHEAD.cpp b/opm/output/eclipse/InteHEAD.cpp index 3b66514c02f..0206149e62e 100644 --- a/opm/output/eclipse/InteHEAD.cpp +++ b/opm/output/eclipse/InteHEAD.cpp @@ -887,7 +887,7 @@ Opm::RestartIO::getSimulationTimePoint(const std::time_t start, const double elapsed) { const auto now = TimeService::advance(start, elapsed); - const auto tp = *std::gmtime(&now); + const auto tp = TimeService::portable_gmtime(now); auto sec = 0.0; // Not really used here. auto usec = std::floor(1.0e6 * std::modf(elapsed, &sec)); diff --git a/tests/test_TimeService.cpp b/tests/test_TimeService.cpp new file mode 100644 index 00000000000..2ba776085b6 --- /dev/null +++ b/tests/test_TimeService.cpp @@ -0,0 +1,267 @@ +/* + Copyright 2026 Equinor ASA. + + This file is part of the Open Porous Media project (OPM). + + OPM is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + OPM is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with OPM. If not, see . +*/ + +#include "config.h" + +#define BOOST_TEST_MODULE Test TimeService +#include + +#include + +#include +#include +#include +#include + +// TimeStampUTC(std::time_t) breaks a time_t into civil time itself rather than +// calling std::gmtime, so these check the range std::gmtime would not have +// covered: dates a C runtime may refuse, and instants before the epoch. + +BOOST_AUTO_TEST_CASE(FromTimeT_Epoch) +{ + const auto ts = Opm::TimeStampUTC{ std::time_t{0} }; + + BOOST_CHECK_EQUAL(ts.year(), 1970); + BOOST_CHECK_EQUAL(ts.month(), 1); + BOOST_CHECK_EQUAL(ts.day(), 1); + BOOST_CHECK_EQUAL(ts.hour(), 0); + BOOST_CHECK_EQUAL(ts.minutes(), 0); + BOOST_CHECK_EQUAL(ts.seconds(), 0); +} + +BOOST_AUTO_TEST_CASE(FromTimeT_BeforeEpoch) +{ + // One second before the epoch. Needs floor division of a negative time_t: + // truncation towards zero would land on 1970-01-01. + const auto ts = Opm::TimeStampUTC{ std::time_t{-1} }; + + BOOST_CHECK_EQUAL(ts.year(), 1969); + BOOST_CHECK_EQUAL(ts.month(), 12); + BOOST_CHECK_EQUAL(ts.day(), 31); + BOOST_CHECK_EQUAL(ts.hour(), 23); + BOOST_CHECK_EQUAL(ts.minutes(), 59); + BOOST_CHECK_EQUAL(ts.seconds(), 59); +} + +BOOST_AUTO_TEST_CASE(FromTimeT_LeapDay) +{ + // 2000-02-29, the century leap year the 400-year rule keeps. + const auto ts = Opm::TimeStampUTC{ std::time_t{951'782'400} }; + + BOOST_CHECK_EQUAL(ts.year(), 2000); + BOOST_CHECK_EQUAL(ts.month(), 2); + BOOST_CHECK_EQUAL(ts.day(), 29); +} + +BOOST_AUTO_TEST_CASE(FromTimeT_BeyondYear3000) +{ + // 3001-01-01T00:00:00Z: the first instant past MSVC's _gmtime64 range, + // which ends with year 3000. That refusal is why the conversion no longer + // goes through std::gmtime. Schedules do reach here. + const auto ts = Opm::TimeStampUTC{ std::time_t{32'535'216'000} }; + + BOOST_CHECK_EQUAL(ts.year(), 3001); + BOOST_CHECK_EQUAL(ts.month(), 1); + BOOST_CHECK_EQUAL(ts.day(), 1); + BOOST_CHECK_EQUAL(ts.hour(), 0); +} + +BOOST_AUTO_TEST_CASE(RoundTripThroughTimeT) +{ + // asTimeT() and TimeStampUTC(time_t) go through portable_timegm() and + // portable_gmtime() respectively; they must be exact inverses. + for (const auto& ymd : { Opm::TimeStampUTC::YMD{1901, 1, 1}, + Opm::TimeStampUTC::YMD{1969, 12, 31}, + Opm::TimeStampUTC::YMD{1970, 1, 1}, + Opm::TimeStampUTC::YMD{2000, 2, 29}, + Opm::TimeStampUTC::YMD{2026, 8, 5}, + Opm::TimeStampUTC::YMD{2100, 3, 1}, + Opm::TimeStampUTC::YMD{3000, 1, 1} }) + { + const auto stamp = Opm::TimeStampUTC{ ymd }.hour(13).minutes(37).seconds(7); + const auto back = Opm::TimeStampUTC{ Opm::asTimeT(stamp) }; + + BOOST_CHECK_EQUAL(back.year(), ymd.year); + BOOST_CHECK_EQUAL(back.month(), ymd.month); + BOOST_CHECK_EQUAL(back.day(), ymd.day); + BOOST_CHECK_EQUAL(back.hour(), 13); + BOOST_CHECK_EQUAL(back.minutes(), 37); + BOOST_CHECK_EQUAL(back.seconds(), 7); + } +} + +// mkdatetime() rejects an impossible date by converting it and converting it +// back, and that check only works because portable_timegm() lets a day beyond +// the month carry into the next one instead of normalising it away or +// refusing it outright. Nothing else in the suite covers that, so a later +// rewrite of portable_timegm() -- to year_month_day_last, say, or to an ok() +// check on the date -- would quietly make OPM accept 30 FEB in a DATES record +// with every other test still passing. + +BOOST_AUTO_TEST_CASE(MkDate_RejectsImpossibleDates) +{ + // 30 February 1983 counts on to 2 March, 33 January 2026 to 2 February, + // and month 13 of 2026 to January 2027: in each case mkdate() is handed + // back a date it did not ask for, and says so. + BOOST_CHECK_THROW(Opm::TimeService::mkdate(1983, 2, 30), std::invalid_argument); + BOOST_CHECK_THROW(Opm::TimeService::mkdate(2026, 1, 33), std::invalid_argument); + BOOST_CHECK_THROW(Opm::TimeService::mkdate(2026, 13, 1), std::invalid_argument); + + // 29 February is not wrap-around in a leap year, and is in every other. + BOOST_CHECK_NO_THROW(Opm::TimeService::mkdate(2000, 2, 29)); + BOOST_CHECK_THROW(Opm::TimeService::mkdate(1900, 2, 29), std::invalid_argument); + BOOST_CHECK_THROW(Opm::TimeService::mkdate(2026, 2, 29), std::invalid_argument); +} + +// portable_gmtime() is the std::gmtime() replacement on the restart output +// path, and that path reads more than the date: DoubHEAD takes tm_yday, and +// a drop-in replacement has to agree on tm_wday too. Dates with a known +// weekday and day of year, on both sides of the epoch and beyond year 3000. + +BOOST_AUTO_TEST_CASE(PortableGmtime_DayOfYearAndWeekday) +{ + struct Case { std::time_t t; int year; int mon; int mday; int yday; int wday; }; + for (const auto& c : { Case{ 0, 1970, 1, 1, 0, 4 }, // Thursday + Case{ -1, 1969, 12, 31, 364, 3 }, // Wednesday + Case{ 951'782'400, 2000, 2, 29, 59, 2 }, // Tuesday + // Sunday, and a leap year's last day + Case{ 978'220'800, 2000, 12, 31, 365, 0 }, + // Thursday, past _gmtime64's range + Case{ 32'535'216'000, 3001, 1, 1, 0, 4 } }) + { + const auto tm = Opm::TimeService::portable_gmtime(c.t); + + BOOST_CHECK_EQUAL(tm.tm_year + 1900, c.year); + BOOST_CHECK_EQUAL(tm.tm_mon + 1, c.mon); + BOOST_CHECK_EQUAL(tm.tm_mday, c.mday); + BOOST_CHECK_EQUAL(tm.tm_yday, c.yday); + BOOST_CHECK_EQUAL(tm.tm_wday, c.wday); + BOOST_CHECK_EQUAL(tm.tm_isdst, 0); + } +} + +BOOST_AUTO_TEST_CASE(PortableGmtime_FarFromEpoch) +{ + // 0001-01-01T00:00:00Z, a Monday: 62 billion seconds before the epoch, + // so the floor division of a negative time_t is exercised well away + // from anything a C runtime handles, with a checkable answer. + const auto tm = Opm::TimeService::portable_gmtime(std::time_t{-62'135'596'800}); + + BOOST_CHECK_EQUAL(tm.tm_year + 1900, 1); + BOOST_CHECK_EQUAL(tm.tm_mon + 1, 1); + BOOST_CHECK_EQUAL(tm.tm_mday, 1); + BOOST_CHECK_EQUAL(tm.tm_hour, 0); + BOOST_CHECK_EQUAL(tm.tm_yday, 0); + BOOST_CHECK_EQUAL(tm.tm_wday, 1); +} + +BOOST_AUTO_TEST_CASE(PortableGmtime_EndsOfTheCalendar) +{ + // The last and the first instant std::chrono::year can represent: + // 32767-12-31T23:59:59Z and -32767-01-01T00:00:00Z. Both convert, with + // every field, and what one direction hands back the other takes back. + { + const auto t = std::time_t{971'890'963'199}; + auto tm = Opm::TimeService::portable_gmtime(t); + + BOOST_CHECK_EQUAL(tm.tm_year + 1900, 32767); + BOOST_CHECK_EQUAL(tm.tm_mon + 1, 12); + BOOST_CHECK_EQUAL(tm.tm_mday, 31); + BOOST_CHECK_EQUAL(tm.tm_hour, 23); + BOOST_CHECK_EQUAL(tm.tm_min, 59); + BOOST_CHECK_EQUAL(tm.tm_sec, 59); + BOOST_CHECK_EQUAL(tm.tm_yday, 364); + BOOST_CHECK_EQUAL(Opm::TimeService::portable_timegm(&tm), t); + } + { + // Taken from itself, and checked against the arithmetic: + // 12'687'428 days before the epoch. + namespace ch = std::chrono; + const auto first_day = ch::sys_days{ch::year::min() / ch::January / 1} + .time_since_epoch().count(); + const auto t = std::time_t{first_day} * 86400; + BOOST_CHECK_EQUAL(t, std::time_t{-1'096'193'779'200}); + auto tm = Opm::TimeService::portable_gmtime(t); + + BOOST_CHECK_EQUAL(tm.tm_year + 1900, -32767); + BOOST_CHECK_EQUAL(tm.tm_mon + 1, 1); + BOOST_CHECK_EQUAL(tm.tm_mday, 1); + BOOST_CHECK_EQUAL(tm.tm_hour, 0); + BOOST_CHECK_EQUAL(tm.tm_yday, 0); + BOOST_CHECK_EQUAL(Opm::TimeService::portable_timegm(&tm), t); + } +} + +BOOST_AUTO_TEST_CASE(PortableGmtime_YearOutsideTm) +{ + // The ends of the 64-bit range. The floor division there used to + // overflow (t - 86399 at the minimum); now it does not, and what is + // refused is only what comes after it: a year outside std::chrono::year. + // That refusal is the defined behaviour, in place of a silently wrong + // date, and it starts one second past either end of the calendar. + BOOST_CHECK_THROW(Opm::TimeService::portable_gmtime(std::numeric_limits::min()), + std::out_of_range); + BOOST_CHECK_THROW(Opm::TimeService::portable_gmtime(std::numeric_limits::max()), + std::out_of_range); + BOOST_CHECK_THROW(Opm::TimeService::portable_gmtime(std::time_t{971'890'963'199} + 1), + std::out_of_range); + BOOST_CHECK_THROW(Opm::TimeService::portable_gmtime(std::time_t{-1'096'193'779'200} - 1), + std::out_of_range); + + // And the other direction refuses a std::tm whose year is past the end, + // after month normalisation: December of 32767 plus one month is 32768. + std::tm past{}; + past.tm_year = 32767 - 1900; + past.tm_mon = 12; + past.tm_mday = 1; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&past), std::out_of_range); + + // Or whose year is inside it but whose day of the month or time of day + // carries the instant past the end: 32767-12-32, 24:00 on 32767-12-31, + // and an hour before -32767-01-01. The last valid instants beside them + // convert. + std::tm edge{}; + edge.tm_year = 32767 - 1900; + edge.tm_mon = 11; + edge.tm_mday = 31; + edge.tm_hour = 23; edge.tm_min = 59; edge.tm_sec = 59; + BOOST_CHECK_EQUAL(Opm::TimeService::portable_timegm(&edge), std::time_t{971'890'963'199}); + edge.tm_mday = 32; edge.tm_hour = 0; edge.tm_min = 0; edge.tm_sec = 0; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); + edge.tm_mday = 31; edge.tm_hour = 24; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); + + edge = std::tm{}; + edge.tm_year = -32767 - 1900; + edge.tm_mon = 0; + edge.tm_mday = 1; + BOOST_CHECK_EQUAL(Opm::TimeService::portable_timegm(&edge), std::time_t{-1'096'193'779'200}); + edge.tm_hour = -1; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); + + // An absurd field is refused too, rather than overflowing on the way. + edge = std::tm{}; + edge.tm_year = 2026 - 1900; + edge.tm_mon = std::numeric_limits::min(); + edge.tm_mday = 1; + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); + edge.tm_mon = 0; + edge.tm_mday = std::numeric_limits::max(); + BOOST_CHECK_THROW(Opm::TimeService::portable_timegm(&edge), std::out_of_range); +}