Skip to content

RFC: routines for handling wrapped tick counts #3410

@jepler

Description

@jepler

I plan to raise this issue In the Weeds on Monday and if there is a consensus about approach I can be the implementor

Problem statement

Libraries and users need a way to manage time durations, and existing solutions are not entirely satisfactory. Thanks to @dglaude for raising this issue on Discord and github.

For consistency and portability reasons, we have only included compatible time-related functions in the standard time module. When it comes to dealing with durations and times not related to a particular epoch, this means we have

  • time.monotonic(), available on all builds, which returns a floating point number of seconds that increases until the microcontroller is reset. monotonic() loses precision so that after around 18 hours, intervals of 1/64 second can no longer be distinguished; within several days, intervals of 1/8 second can no longer be distinguished
  • time.monotonic_ns(), which returns an integer number of nanoseconds that increases until the microcontroller is reset. monotonic_ns() is only available on builds with long integers, because without it the count overflows in less than 2 seconds.

Multiple Adafruit bundle libraries have implemented workarounds of the general form:

if (monotonic_ns exists and is usable):
    def custom_timekeeping_function():
        implement in terms of monotonic_ns
else:
    def custom_timekeeping_function():
        implement in terms of monotonic

however, due to the problem of time.monotonic granularity, this leads to programs which have to be restarted (reset button, not soft restart) everyday or they misbehave.

Another (resolvable) problem is that due to the evolution of the core, the test (monotonic_ns exists and is usable) is different between 5.3 and 6.0, which means that at the time I write this, the adafruit_debouncer library does not work on the Trinket M0. The other workaround I know of, in adafruit_led_animation, is what I would consider the canonical workaround.

The ble_midi library uses monotonic_ns with no workaround, but we probably do not anticipate any devices that support ble but are too resource-constrained to have long ints.

I did not survey libraries that use time.monotonic only; there are a large number of them and there are probably many "misbehaves after hours/days" class bugs there, such as

adafruit_esp32spi.py:        while (time.monotonic() - times) < 0.1:

In all, 89 files in the Adafruit bundle and 14 files in the community bundle seem to use time.monotonic().

Requirements

I think we can only address this by adding new code to CircuitPython. This is a dicey proposition but right this second we do have space in our most constrained builds due to improvements in message compression, so we should be able to make it work for 6.0.

  • The new APIs are not placed in a desktop Python module like time
    • microcontroller and rtc are two plausible places
    • microcontroller already has sleep_us
  • work with the data type of (short) integers only
  • is feasible to implement in terms of time.monotonic_ns, so it can be implemented in blinka and for backwards compatibility on long int builds
  • is feasible to implement in terms of time.monotonic, without worsening the granularity problem

There is a solution, though it means giving up these two properties:

  • The clock increases forever, never wrapping around
  • the units of the clock are one of the python-"comptaible" units, seconds
    or nanoseconds

Proposal

Pull some select routines from micropython.utime into microcontroller, possibly choosing more expressive names:

microcontroller.ticks_ms()
microcontroller.ticks_add()
microcontroller.ticks_diff()

ticks_ms counts up, but each time it would count past a maximum value, it wraps back to zero. If you have two values from ticks_ms(), you can add them (or subtract them, by negating one):

start = ticks_ms()
wait_for_event()
end = ticks_ms
duration_ms = ticks_add(end, -start)

You can use ticks_diff to find out if you're before or after a deadline:

start = ticks_ms()
deadline = ticks_add(start, 100)
while ticks_diff(deadline, ticks_ms()) > 0:
    do_something_quick()

In practical terms, ticks_ms() will wrap every 2**29 milliseconds, which is approximately 6 days. Determining "before or after" works when the times are within half of the wrapping period, or about 3 days. When it comes to a program task like "count down to Halloween", it is not the appropriate API, and date-based functions are more appropriate. When it comes to a program task like "make the LED animation advance as close to every 1/20s as possible, forever", it is adequate indefinitely.

elaborating on some choices I made:

  • the TICKS_PERIOD of 2**29 is chosen to simplify the backwards-compatibility implementation of ticks_add and ticks_diff on platforms without long ints; we can add or subtract any two numbers where the absolute values are less than TICKS_PERIOD without overflow.

  • TICKS_PERIOD and other constants in the adafruit_ticks library should not be accessed and are considered implementation defined. The minimum guarantee would be that ticks wouldn't wrap around for at least 2 days, allowing differences of at least 1 day to be represented. (I can't think of a reason we'd want to do smaller than 2**29 either)

  • ms is the best choice for granularity: seconds are too long, microseconds are too short, and other granularities like 1/1024s, .001s, etc., feel too arbitrary

Backwards compatible helper library: adafruit_ticks

This backwards compatible implementation is the one that shold be used (rather than directly using microcontroller.ticks_ms etc), because it will function on a range of CircuitPython versions from 5.x to 6.0 as well as on Adafruit Blinka. It makes a best effort implementation, but cannot solve the granularity problem on builds without long ints.

_MS_PER_NS = const(1000000)
_MS_PER_S = const(1000)
_TICKS_MAX = const(536870911)
_TICKS_PERIOD = const(536870912)
_TICKS_HALFPERIOD = const(268435456)

try:
    from microcontroller import ticks_ms, ticks_add, ticks_diff
    ticks_ms()
    ticks_add(0, -1)
    ticks_diff(1, 100)
except (ImportError, NotImplementedError):
    import time

    try:
        def ticks_ms():
            return (time.monotonic_ns() // MS_PER_NS) & _TICKS_MAX
        ticks_ms()
    except (NameError, NotImplementedError):
        def ticks_ms():
            return round(time.monotonic() * MS_PER_S % _TICKS_PERIOD)

    def ticks_add(ticks, delta):
        return (a + b) % _TICKS_PERIOD

    def ticks_diff(end, start):
        diff = (end - start) & _TICKS_MAX
        diff = ((diff + _TICKS_HALFPERIOD) & _TICKS_MAX) - _TICKS_HALFPERIOD
        return diff

this library could be rewritten so that the availablity of ticks_ms is considered separately from ticks_add and ticks_diff, reducing the in-core implementation footprint from 3 functions to 1

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions