c++c++-chronofloor

Use std::chrono to extract number of seconds passed since midnight in local time


I want to calculate the number of seconds since daystart. The problem is that std::chrono::floor gives me the daystart in UTC as opposed to that of my local timezone. Compare this:

Demo:

#include <time.h>
#include <cstdio>
#include <iostream>
#include <cstdint>
#include <chrono>
#include <ctime>

int main() {
    setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0", 1);
    tzset();

    const auto tp_daystart = std::chrono::floor<std::chrono::days>(std::chrono::system_clock::now());
    const auto tp_now = std::chrono::system_clock::now();
    const auto daysec = std::chrono::duration_cast<std::chrono::seconds>(tp_now - tp_daystart);
    std::time_t ttp = std::chrono::system_clock::to_time_t(tp_now);
    std::time_t ttp_s = std::chrono::system_clock::to_time_t(tp_daystart);
    std::cout << "time start: " << std::ctime(&ttp_s) << " / time now: " << std::ctime(&ttp) << "seconds since start of day = " << daysec.count() << "\n";
}

This yields:

time start: Mon May  1 02:00:00 2023
 / time now: Mon May  1 16:26:04 2023
seconds since start of day = 51964

The seconds from start of day is of course wrong, as it calculates from 2am to 4pm when it should actually start at 00:00:00. , as I do want to calculate the seconds since the start of this day in this timezone! How do I accomplish that?


Solution

  • This question actually has a few tricky details where decisions must be made on exactly what you want to happen when weird things like daylight savings complicates things. But whatever you want to happen, it can be done in chrono.

    I'm going to assume that you want physical seconds since the start of the day. That means that if there was a UTC offset change between midnight and now (e.g. an hour got subtracted) that this subtraction will be taken into account.

    This implies that we need to:

    1. Get the UTC time now.
    2. Get the UTC time of the local midnight.
    3. Subtract the two UTC times.

    As you're interested in seconds precision, we'll truncate to that from the start, and then not worry about it further:

    #include <chrono>
    #include <format>
    #include <iostream>
    
    int
    main()
    {
        using namespace std;
        using namespace chrono;
    
        auto utc_now = floor<seconds>(system_clock::now());
    

    For readability purposes, I'm using local using directives to cut down on the verbosity.

    Next get the local time that corresponds to utc_now:

        auto local = zoned_time{current_zone(), utc_now};
    

    current_zone() looks up the currently set time zone. And zoned_time is simply a handy "pair" which holds a time_zone const* and a sys_time together. One can use this structure to extract the local time. Or you can just format the local time out of it:

        cout << "time now  : " << format("{:%a %b %e %T %Y}", local) << '\n';
    

    For me this just output my local time and date:

    time now  : Mon May  1 10:56:07 2023
    

    Now to get the local midnight, the truncation to days must happen in local time, not system time:

        local = floor<days>(local.get_local_time());
    

    This extracts the local time, truncates it to days and then assigns that local time back into the zoned_time. This will change the underlying UTC time point (sys_time) but not the time zone.

    Now you can format local again:

        cout << "time start: " << format("{:%a %b %e %T %Y}", local) << '\n';
    

    Example output:

    time start: Mon May  1 00:00:00 2023
    

    Finally, extract the UTC time from the local midnight (stored in local) and subtract it from the starting UTC time:

        auto delta = utc_now - local.get_sys_time();
        cout << "seconds since start of day = " << delta << '\n';
    

    Example output:

    seconds since start of day = 39367s
    

    There is one more complication: What if there existed a UTC offset change that caused local time to completely skip over the local midnight? Or what if there were two local midnights?

    How do you want to handle that?

    There are several possibilities:

    1. You could ignore this possibility because you are sure your local time zone never does this. In that case the code above is fine as is. If it does happen, an exception will be thrown on the line of code that assigns the local midnight back into local.

    2. If there are two midnights, you want to choose the first one, and if there are zero midnights you want to start counting at whatever the first local time is that is after midnight. For example if local time skips from 23:30 to 00:30, one starts counting at 00:30 local time.

    In this case change:

        local = floor<days>(local.get_local_time());
    

    to:

        local = zoned_time{local.get_time_zone(),
                           floor<days>(local.get_local_time()),
                           choose::earliest};
    

    This:

    Now an exception will never be thrown, and you will always count seconds from the very first instant of today, even if there are also bits of yesterday counted too.

    If you want the second midnight, so that there are never bits of yesterday counted, then change choose::earliest to choose::latest.


    If you don't want to actually count physical seconds, but rather "calendrical seconds", then do the arithmetic in local time, instead of UTC. This will paradoxically not involve the complexity of changing UTC offsets during the day, and so be simpler. In this computation, 4am will always be 4 hours after midnight, even if there was a UTC offset change at 2am that set the local time back to 1am.

    auto utc_now = floor<seconds>(system_clock::now());
    auto local_now = zoned_time{current_zone(), utc_now}.get_local_time();
    auto local_midnight = floor<days>(local_now);
    auto delta = local_now - local_midnight;
    cout << "time now  : " << format("{:%a %b %e %T %Y}", local_now) << '\n';
    cout << "time start: " << format("{:%a %b %e %T %Y}", local_midnight) << '\n';
    cout << "seconds since start of day = " << delta << '\n';
    

    In this version, local time is never mapped back to UTC, so there is no opportunity to discover that said mapping is not unique and subsequently throw an exception.

    And to be clear, all three different versions of this code almost always give the same result. It is only when a UTC offset change is made between "now" and the previous local midnight, that these different versions give different results.