Skip to content

shared/timeutils: Standardize supported date range on all platforms. #17366

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

yoctopuce
Copy link
Contributor

@yoctopuce yoctopuce commented May 27, 2025

This is pull request is to ensure that time functions (mktime, localtime and gmtime) work properly on a reasonable date range, on all platforms, regardless of the epoch.

It does fix issue #17340

Summary

The most important point is to have a test case that checks conversion from timestamp to tuple and back for every day in the supported time range. This was currently missing. The new test code starts from 2001, first iterates backward on tuple to find the earliest supported date and then goes forward by adding 86400 to the timestamp, to verify that the date computations are correct, including leap years, and do not raise an overflow.

The second part of the pull request is to ensure that this test does actually pass on all platforms.

The most severely affected platforms were 32 bit platforms using a 1970 epoch, as

  1. there was an assumption that timestamps can be stored in a mp_int_t
  2. conversion from the 1970 epoch to the 2000 epoch used internally by timeutils breaks computations for dates before 2000

On some of these platforms, the resulting supported date range was only 2001 to 2037. As 2038 is now only 13 years away, this clearly has to be fixed quickly.

Small code footprint beeing a key value for MicroPython, the suggested minimal supported time range for all platforms is 1970 to 2099. This range can be covered using 32 bit timestamps on ressource-limited platforms, fixes the 1970 epoch problems and requires less code for handling of leap years.

Support for years 2100 and beyond is only enabled for platforms using 64 bit pointers, which are typically less ressource-constrained.

Testing

The PR includes new test cases that verify the supported time range using architecture-independent Python code.

Trade-offs and Alternatives

The new timeutils code has a smaller footprint than the original for 32 bit machines, as it does not have to handle signed inputs and does not have to handle century-based leap years.

We could have opted to use signed timestamps centered on 2000, as was probably the original intent of the timeutils code. We felt however that it was more useful to fully support the 21st century rather going supporting dates back to 1940's but breaking in 2068. Handling timestamps as unsigned also simplifies the code by removing some checks for negative values.

We could have opted to upgrade all platforms to use 64-bit signed integers for time functions, which would have offered the broadest possible range for abstract date operations, spanning from the year 1600 to the year 3000 and beyond. However, this would have had a code size impact on 32-bit platforms, with little added value. We guessed that, if a specific application on a 32-bit platform required handling exotic dates, a Python-based implementation would remain a good option.

Copy link

github-actions bot commented May 27, 2025

Code size report:

   bare-arm:    +0 +0.000% 
minimal x86:    +0 +0.000% 
   unix x64:  -144 -0.017% standard
      stm32:   -36 -0.009% PYBV10
     mimxrt:  -280 -0.075% TEENSY40
        rp2:  -120 -0.013% RPI_PICO_W
       samd:   -68 -0.025% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:    +0 +0.000% VIRT_RV32

Copy link

codecov bot commented May 27, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 98.56%. Comparing base (b725c26) to head (cacc21e).
Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #17366   +/-   ##
=======================================
  Coverage   98.56%   98.56%           
=======================================
  Files         169      169           
  Lines       21948    21968   +20     
=======================================
+ Hits        21633    21653   +20     
  Misses        315      315           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@yoctopuce
Copy link
Contributor Author

(Force-pushed today after rebase to head revision)

@Josverl
Copy link
Contributor

Josverl commented May 31, 2025

Good to have the tests, and the improvements and consistency across ports.

Does that now also make the tuple order and sizes across all ports the same?

@yoctopuce
Copy link
Contributor Author

I wasn't even aware that some ports did not follow the documentation for the tuple content. Given how the test code is written, my guess is that if the order is not the expected one for year, month and day the test will certainly fail. The code does not check the content of time, this could be added, at least to ensure some level of consistency (tuple order).

As for the extra numbers and tuple size 8/9, this was not in the scope of this PR. I was considering submitting a separate PR to provide an optional support for a simple TZ strings with DST support, so that devices can provide proper localtime support for logs for instance. But I first have to implement it :-)

@dpgeorge dpgeorge added the shared Relates to shared/ directory in source label Jun 11, 2025
Copy link
Member

@dpgeorge dpgeorge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR. This is something that's been on my to-do list for a while, so it's great to see it finally get fixed properly.

What's required to switch bare-metal ports to use 64-bit range for timestamps?

return (mp_uint_t)(ns / 1000000000ULL);
}

static inline uint64_t timeutils_nanoseconds_since_epoch_to_nanoseconds_since_1970(uint64_t ns) {
static inline int64_t timeutils_nanoseconds_since_epoch_to_nanoseconds_since_1970(int64_t ns) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the arg and return type stay as unsigned here? (I guess there's a good reason you changed it to signed.)

@yoctopuce
Copy link
Contributor Author

Thanks for this PR. This is something that's been on my to-do list for a while, so it's great to see it finally get fixed properly.

What's required to switch bare-metal ports to use 64-bit range for timestamps?

Any bare-metal port with an epoch at 2000 would actually need a 64 signed timestamp to properly encode time on the whole range.

Internally, timeutils can still use the unsigned 32 bit representation to reduce code footprint, but the port should be using 64 bit signed timestamps when interfacing it. I will work on checking this on all platforms using timeutils. However I don't have the machines available at hand to test my changes... What kind of integration test is run on baremetal platforms during CI tests ? I am looking at tests for vfs_lfs, fatfs, modtime and machine_rtc

@yoctopuce yoctopuce force-pushed the fix_timeutils branch 5 times, most recently from 9766d35 to df0f466 Compare June 17, 2025 18:15
@yoctopuce
Copy link
Contributor Author

I have pushed the new version, which should bring proper datetime support for most platforms.
There are a several platforms that still use an uint64_t when reading their RTC clock, but as the code setting the clock cannot use dates before 2000, this should not be an issue. When creating an mp_obj_t to hold a timestamp , they now use a proper function that will not fail when the timestamp is past a signed int.

@robert-hh
Copy link
Contributor

robert-hh commented Jun 17, 2025

With which port did you test the PR?

@yoctopuce
Copy link
Contributor Author

With which port did you test the PR?

I tested on ports/unix (using int64_t timestamps) and on our bare-metal port (an ARM platform using 1970 epoch, using 32 bit timestamps), but I don't have MicroPython classic bare metals available here. This is the reason I was asking if there was any possibility of running CI test remotely on bare metal hardware.

@yoctopuce
Copy link
Contributor Author

yoctopuce commented Jun 17, 2025

Anyone could suggest an idea to explein why the qemu_mips would be failing ?
The failing code is a new bit of the extra coverage written in C, which works properly in the standard ports/unix:

        // convert a large timestamp value (stored in a mpz) to ll
        mp_printf(&mp_plat_print, "%lx\n", mp_obj_get_ll(obj_timestamp));

        // convert a small timestamp value (as a smallint) to a ll;
        obj_timestamp = timeutils_obj_from_timestamp(0xc0ffee);
        mp_printf(&mp_plat_print, "%lx\n", mp_obj_get_ll(obj_timestamp));

On qemu_mips, this is returning 0 instead of the proper values. The problem is likely the implementation of mp_obj_get_ll(), which uses mp_obj_int_to_bytes_impl() to retrieve the 64 bit value from the mpz number:

int64_t res;
mp_obj_int_to_bytes_impl((mp_obj_t)arg, MP_ENDIANNESS_BIG, sizeof(res), (byte *)&res);
return res;

It is very similar to ffifunc_print() implementation, so I don't see why it would not work...

edit 1 : ffi is apparently skipped on mips. But I believe I found the problem, pushing a new version...
edit 2 : that was it. My mistake was using mp_printf "%lx" with an int64_t, which does not seems to work on qemu_mips build.

@robert-hh
Copy link
Contributor

I compiled this PR for a few boards, installed them on the board and ran the test script time_mktime.py with these boards. Result:

The test passes for STM32, ESP32 and MIMXRT
The test fails for CC3200, ESP8266, SAMD, Renesas-Ra, RP2 and NRF

The fail does not necessary tell that the change is wrong. Chances are better that it is a problem of the test script. Can you provide a few test cases and the expected result, which I can feed manually to the boards at REPL?

@yoctopuce
Copy link
Contributor Author

I compiled this PR for a few boards, installed them on the board and ran the test script time_mktime.py with these boards. Result:

The test passes for STM32, ESP32 and MIMXRT The test fails for CC3200, ESP8266, SAMD, Renesas-Ra, RP2 and NRF

The fail does not necessary tell that the change is wrong. Chances are better that it is a problem of the test script. Can you provide a few test cases and the expected result, which I can feed manually to the boards at REPL?

Thank you very much, that helps a lot.

If you could try to run the script in tests/extmod/time_mktime.py, it should either report an error if I did make something really wrong, or display the properly supported date range for the platform. Knowing where it fails would problably give a fair amount of information on the issue.

If this test pass completely, maybe the problem is with another function.
You could for instance try

import time
print(time.time())

to check if I broke something there.

@robert-hh
Copy link
Contributor

As suspected the problem is with running the test e.g. on RP2 with:
./run-tests.py -t a0 extmod/time_mktime.py

When I run the test with mprmote run extmod/time_mktime.py almost all ports return the line:
tested from (1970, 1, 1) to (2100, 1, 1).

Exceptions:
NRF: AttributeError: 'module' object has no attribute 'mktime'
CC3200: is a little bit unreliable when executing code with mpremote run. Sometimes it fails.

Runtime of the test between 1.7s (Teensy 4.1) and 26.9s (Renesas-RA EK6M2).

@yoctopuce
Copy link
Contributor Author

As suspected the problem is with running the test e.g. on RP2 with: ./run-tests.py -t a0 extmod/time_mktime.py

When I run the test with mprmote run extmod/time_mktime.py almost all ports return the line: tested from (1970, 1, 1) to (2100, 1, 1).

Exceptions: NRF: AttributeError: 'module' object has no attribute 'mktime' CC3200: is a little bit unreliable when executing code with mpremote run. Sometimes it fails.

Runtime of the test between 1.7s (Teensy 4.1) and 26.9s (Renesas-RA EK6M2).

Excellent, thank you very much for running these all these tests. So it looks like the objective of having an uniform supported date range is met.

The NRF error makes perfect sense: NRF port has very limited support for module time, it does not include mktime. I have just added a check at the beginning of my test to make sure to properly reports a SKIP rather than a failure when time.mktime() is unavailable.

Thank you again for you help testing this PR.

@robert-hh
Copy link
Contributor

robert-hh commented Jun 18, 2025

It is still strange that the test looks fine when executed directly via mpremote run, and fails for some boards when executed by run-test. I reran some tests. It seems to pass for boards where the runtime of the test is below 10s, and fail for others. But the default timeout in run-tests.py is 30 seconds.
Other observations:

  • this morning the RP2 passed the test through run-tests.py. It's net execution time is <10s.
  • looking at the output of the ESP8266 and Renesas RA when running the test with run-tests.py the behavior is strange. First I see a long series of reboots, at the end of that series a "START TEST" message and then the proper test result. Before that that run-tests-py had already reported after ~11 seconds that the test failed. So it seems that run-tests.py has somewhere a ~10 second timeout.

Edit: If I modify time_mktime.py such that it's runtime on SAMD is below 10s, then it passes with run-tests.py.
Edit 2: The test is properly skipped for the NRF port.

@yoctopuce
Copy link
Contributor Author

I did not anticipate that the test would take that long to run on bare metals. It might be a problem if it slows down the whole testing process.

I have just pushed a new version that skips faster over first days of the month until the 27th. This should run significantly faster. Can you give it a try ?

@robert-hh
Copy link
Contributor

All boards except cc3200 pass the test now when performed using run-tests.py. The script time_mktime.py itself runs fine at the CC3200 board. So it's a test run issue. The problem seems to be in the code transport mechanism with pyboard.py. Even running the code with mpremote run is not reliable.
The CC3200 is already a little bit outdated. A fix of the test runner may not be needed.

@yoctopuce
Copy link
Contributor Author

All boards except cc3200 pass the test now when performed using run-tests.py. The script time_mktime.py itself runs fine at the CC3200 board. So it's a test run issue. The problem seems to be in the code transport mechanism with pyboard.py. Even running the code with mpremote run is not reliable. The CC3200 is already a little bit outdated. A fix of the test runner may not be needed.

Excellent, thank you very much for your assistance !

While you have all the platforms available at hand, would you ave a chance to try to the other PR that I pushed yesterday, #17444 ? Same scenario as here: there is a new test file tests/float/float_format_accuracy.py that performs a 10'000 float conversions and tests the accuracy, but I now suspect my test code might be too slow to run on some platforms, as it was the case here.

@robert-hh
Copy link
Contributor

I deleted my comments. You have to delete yours.

This is code makes sure that time functions work properly on a
reasonable date range, on all platforms, regardless of the epoch.
The suggested minimum range is 1970 to 2099.

In order to reduce code footprint, code to support far away dates
is only enabled when it makes sense.

New types are defined to identify timestamps. Helper functions
are provided to properly convert to/from mp_obj_t without
risking overflows.

Signed-off-by: Yoctopuce dev <[email protected]>
@yoctopuce
Copy link
Contributor Author

Excellent, thank you robert-hh for your help in testing this PR.

  • We now have all automated CI checks passed
  • Running run-test on STM32, ESP32, MIMXRT, ESP8266, SAMD, Renesas-Ra, RP2 and NRF is successful
  • Running time_mktime.py manually on CC3200 is also successful
    I guess we are ready for the next code review

@@ -314,6 +314,36 @@ mp_int_t mp_obj_get_int(mp_const_obj_t arg) {
return val;
}

#if MICROPY_LONGINT_IMPL != MICROPY_LONGINT_IMPL_NONE
mp_uint_t mp_obj_get_uint(mp_const_obj_t arg) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please can you put this change (adding these two methods) into a separate commit? It's really quite independent to the change to timeutils.

Please also put the new coverage test for these functions into the same commit.

Also, I don't think this will build correctly when longlong is enabled, because mp_obj_int_get_uint_checked() is not implemented for longlong...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, will do. Should that be a separate commit be in a separate PR as well, or a separate commit in the same PR ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A separate commit in this PR is fine.

@@ -27,6 +27,46 @@
#ifndef MICROPY_INCLUDED_LIB_TIMEUTILS_TIMEUTILS_H
#define MICROPY_INCLUDED_LIB_TIMEUTILS_TIMEUTILS_H

#if MICROPY_PY_BUILTINS_FLOAT && MICROPY_FLOAT_IMPL == MICROPY_FLOAT_IMPL_DOUBLE
#include <math.h> // required for trunc()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this if-guard and include should come after the include of py/obj.h below, so the MICROPY_xxx configurations are available.

//
// By default this is enabled for machines using 64 bit pointers only,
// but it can be enabled by specific ports
#ifndef MP_SUPPORT_Y2100_AND_BEYOND
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a configuration setting, so should start with MICROPY_.

I think it should be called MICROPY_EPOCH_SUPPORT_Y2100_AND_BEYOND and put in py/mpconfig.h, along with MICROPY_EPOCH_IS_1970 and MICROPY_EPOCH_IS_2000.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I wasn't sure how much visibility you wanted to give to that option, but I am happy to put in mpconfig.h

#if MP_SUPPORT_Y2100_AND_BEYOND || MICROPY_EPOCH_IS_2000
typedef int64_t platform_timestamp_t;
#else
typedef mp_uint_t platform_timestamp_t;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type is used in extmod code so (due to code layering) I think it's better to define it in the core in py/mpconfig.h.

And also call it mp_platform_timestamp_t or even just mp_timestamp_t (that matches eg mp_off_t).

#if MP_SUPPORT_Y2100_AND_BEYOND
typedef int64_t timeutils_timestamp_t;
#else
typedef mp_uint_t timeutils_timestamp_t;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type is only used in timeutils so can stay named as is and defined here 👍

return mp_obj_int_get_uint_checked(arg);
}

int64_t mp_obj_get_ll(mp_const_obj_t arg) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would call this mp_obj_get_int64() because that's what it is.

Or, this function should return long long type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used int64_t because this is what other functions in timeutils were already using, and because I find it clear. However, from another perspective, there is an existing mpz function which is using the type long long to do the reverse operation (mp_obj_new_int_from_ll()), so it might make more sense to use long long as well.

Which option do you prefer ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest changing he type to long long and keeping the function name.

@dpgeorge
Copy link
Member

Running the tests on PYBD_SF6 everything passes except tests/ports/stm32/modtime.py which has the following diff for its output:

 time.localtime( -1 ) returned (1999, 12, 31, 23, 59, 59, 4, 365) (pass)
 time.localtime( 447549467 ) returned (2014, 3, 7, 23, 17, 47, 4, 66) (pass)
 time.localtime( -940984933 ) returned (1970, 3, 7, 23, 17, 47, 5, 66) (pass)
-time.localtime( -1072915199 ) returned (1966, 1, 1, 0, 0, 1, 5, 1) (pass)
-time.localtime( -1072915200 ) returned (1966, 1, 1, 0, 0, 0, 5, 1) (pass)
-time.localtime( -1072915201 ) returned (1965, 12, 31, 23, 59, 59, 4, 365) (pass)
+time.localtime( -1072915199 ) returned (2102, 2, 6, 6, 28, 17, 1, 37) expecting (1966, 1, 1, 0, 0, 1, 5, 1)
+time.localtime( -1072915200 ) returned (2102, 2, 6, 6, 28, 16, 1, 37) expecting (1966, 1, 1, 0, 0, 0, 5, 1)
+time.localtime( -1072915201 ) returned (2102, 2, 6, 6, 28, 15, 1, 37) expecting (1965, 12, 31, 23, 59, 59, 4, 365)

I'm not sure what to do here? The new behaviour is probably more correct, but it's a breaking change.

@yoctopuce
Copy link
Contributor Author

Running the tests on PYBD_SF6 everything passes except tests/ports/stm32/modtime.py which has the following diff for its output:

 time.localtime( -1 ) returned (1999, 12, 31, 23, 59, 59, 4, 365) (pass)
 time.localtime( 447549467 ) returned (2014, 3, 7, 23, 17, 47, 4, 66) (pass)
 time.localtime( -940984933 ) returned (1970, 3, 7, 23, 17, 47, 5, 66) (pass)
-time.localtime( -1072915199 ) returned (1966, 1, 1, 0, 0, 1, 5, 1) (pass)
-time.localtime( -1072915200 ) returned (1966, 1, 1, 0, 0, 0, 5, 1) (pass)
-time.localtime( -1072915201 ) returned (1965, 12, 31, 23, 59, 59, 4, 365) (pass)
+time.localtime( -1072915199 ) returned (2102, 2, 6, 6, 28, 17, 1, 37) expecting (1966, 1, 1, 0, 0, 1, 5, 1)
+time.localtime( -1072915200 ) returned (2102, 2, 6, 6, 28, 16, 1, 37) expecting (1966, 1, 1, 0, 0, 0, 5, 1)
+time.localtime( -1072915201 ) returned (2102, 2, 6, 6, 28, 15, 1, 37) expecting (1965, 12, 31, 23, 59, 59, 4, 365)

I'm not sure what to do here? The new behaviour is probably more correct, but it's a breaking change.

The proper solution is probably to add an extra setting MP_SUPPORT_Y1969_AND_BEFORE, that provides a similar extension of the supported timerange to older date. Given that the bit size of timestamps is already parametric, it should not be a big problem to add that.

@dpgeorge
Copy link
Member

The proper solution is probably to add an extra setting MP_SUPPORT_Y1969_AND_BEFORE, that provides a similar extension of the supported timerange to older date.

OK, that sounds reasonable. If you can keep that test passing, that would make things simpler.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
shared Relates to shared/ directory in source
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants