Time in CircuitPython

Second hand stepping on a Rolex Submariner wrist watch at eight times a second, shown at 0.05x speed, courtesy of Horology House.

The time on a computer passes in small steps. If these values are stored or processed using a fixed size representation then care is needed to prevent issues. For example:

CircuitPython time functions

CircuitPython provides a subset of functions from the time library. The monotonic in their name refers to a guarantee that time will not go backwards. For comparison, gettimeofday on Linux does not provide this guarantee and naive use for timing durations can result in negative values!

time.monotonic()

Python and CircuitPython's time.monotonic() returns time in seconds as a float variable. This return value increases over time reducing the resolution available to represent the fractional part of the value. The effect is far more significant for the 30bit storage representation used by CircuitPython (based on single precision floating point) in combination with the epoch time of 0.0 at power-up. This lowers the precision and granularity of the millisecond portion as the program continues to run and can be very significant if the program runs for days or weeks.

time.monotonic_ns()

Python (3.5+) and CircuitPython's time.monotonic_ns() (available on all boards bar Gemma M0, Trinket M0 and Feather M0 non-Express) is the equivalent of time.monotonic() with an  int return value in nanoseconds (one billionth of a second). This value is not subject to reduced precision over time as long as the value is not accidentally converted to a float. Examples of correct and incorrect usage are shown below.

Download: file
import time

# Good - int value preserved
start = time.monotonic_ns()
time.sleep(0.005)
ms_duration = (((time.monotonic_ns() - start) + 500000)
               // 1000000)
print(ms_duration)


# Good - int subtraction then the small value
# duration value is ok to convert to a float
start = time.monotonic_ns()
time.sleep(0.005)
ms_duration = round((time.monotonic_ns() - start) / 1e6, 1)
print(ms_duration)


# BAD - accidental/premature conversion to
# float reducing accuracy of calculation
start_ms = time.monotonic_ns() / 1e6  # BAD
time.sleep(0.005)
ms_duration = round(time.monotonic_ns() / 1e6  # BAD
                    - start_ms, 1)
print(ms_duration)

The first example will print 5. The second example prints values like 4.6 or 4.7. The third example will print 0.0 or infrequently 32.0 for a board that's been running for one day!

Care is needed when using the exponential notation like 1e6 for 1000000. In Python, 1e6 is always a float. The example below from REPL shows how unintended type promotion to float spoils the accuracy of the addition.

Download: file
>>> t1 = time.monotonic_ns()
>>> t1
167333909366000
>>> t1 + 1e9
1.67335e+14
>>> int(t1 + 1e9)
167334878642176

>>> t1 + 1000000000
167334909366000

Using the correct division operator for floor division (//) and ensuring it's used with int types is also critical to avoid trouble from floats.

Download: file
>>> smaller = 1034567890
>>> larger = smaller + 10

>>> if larger > smaller: print("all ok - integer calculation/comparison")
...
all ok - integer calculation/comparison
>>> if larger // 10 > smaller // 10: print("all ok - integer calculation/comparison")
...
all ok - integer calculation/comparison

>>> if larger / 10 > smaller // 10: print("not ok - float division")
...
>>> if larger // 1e1 > smaller // 10: print("not ok - integer division used with float")
...

Making Large Numbers Readable

The 1e9 representation is useful for one billion because
the number 9 is easier to read and validate compared to visually counting the number of zeroes in 1000000000. The previous section mentions the type of 1e9 is a float.

The language Ada introduced a representation of numeric literals (numbers) with optional underscores to allow a number to visually broken up. From Honeywell Bull's successful "GREEN" proposal for Ada, "Rationale for the Design of the GREEN Programming Language (1979)":

The underscore is permitted within a number to break up long sequences of digits, a requirement that has long been recognized by printers.

Java introduced this with V7 in 2011, Python introduced this in 3.6 in 2016. CircuitPython implements this, an example on REPL can be seen below showing how large int values can be more clearly represented.

Download: file
Adafruit CircuitPython 5.0.0 on 2020-03-02; Adafruit CLUE nRF52840 Express with nRF52840
>>>
>>> an_int = 1_000_000_000
>>> print(an_int,
...       an_int // 1000_000, an_int // 1_00_00_00,
...       int("-1_234_567"))
1000000000 1000 1000 -1234567

Performance Variability

In general, interpreted languages tend to offer less predictable performance than compiled languages so there may be some variation in this timing. CircuitPython performance will be occasionally affected by:

  • garbage collection (a few ms),
  • various other background tasks like USB activity including CIRCUITPY file system operations,
  • automatic displayio screen refreshes (can be 100+ ms).
For benchmarking on boards with a screen, it is important to disable displayio automatic screen updates for serial console output.

Demonstration of time.monotonic() Granularity

This is a short program which demonstrates the degradation in sub-second time.monotonic() resolution. The output will differ on boards which have been powered up for a long time showing how time becomes more granular.

Download: file
import board, time

# Disable updates to LCD screen which 
# will occasionally introduce very large
# delays between statement execution
board.DISPLAY.auto_refresh = False


print(*[("{:" + str(hdr[0])
         + "s}").format(hdr[1])[:hdr[0]]
        for hdr in ((3, "num"),
                    (15, "monotonic_ns()"),
                    (12, "monotonic()"),
                    (8, "fraction"),
                    (9, "offset"))
       ],
       sep=" | ")

count = 1
previous = time.monotonic()

# Print the time when it changes according
# to time.monotonic() to inspect the
# sub-second precision
while True:
    now = time.monotonic()
    now_ns = time.monotonic_ns()
    if now != previous:
        now_ns_str = str(now_ns)
        now_frac = now - int(now)
        print("{:3d}".format(count),
              now_ns_str[:-9] + "." + now_ns_str[-9:],
              "{:.6f}".format(now),
              "{:.6f}".format(now_frac),
              "{:.6f}".format(int(now_ns_str[-9:-3])
                              / 1e6 - now_frac),
              sep=" | ")
        previous = now
        count = 1
    else:
        count += 1

Ten Minutes

A CLUE board which is approaching 10 minutes of uptime prints the time on every iteration of the loop and has sub-millisecond precision from time.monotonic().

Download: file
num | monotonic_ns()  | monotonic()  | fraction | offset
  1 | 563.305638000 | 563.304901 | 0.304932 | 0.000706
  1 | 563.310520000 | 563.309813 | 0.309814 | 0.000705
  1 | 563.315276000 | 563.314915 | 0.314941 | 0.000335
  1 | 563.319893000 | 563.318825 | 0.318848 | 0.001045
  1 | 563.324549000 | 563.323975 | 0.323975 | 0.000574
  1 | 563.329326000 | 563.328838 | 0.328857 | 0.000468
  1 | 563.333935000 | 563.332987 | 0.333008 | 0.000927
  1 | 563.338627000 | 563.337898 | 0.337891 | 0.000736
  1 | 563.343376000 | 563.343000 | 0.343018 | 0.000358
  1 | 563.348121000 | 563.347864 | 0.347900 | 0.000221

Two Days

After almost two days the sub-second precision has dramatically dropped - the granularity is now 1/16th of a second (62.5ms).

Download: file
num | monotonic_ns()  | monotonic()  | fraction | offset
  1 | 149621.908317000 | 149621.868134 | 0.875000 | 0.033317
179 | 149621.929066000 | 149621.939659 | 0.937500 | -0.008434

584 | 149621.993094000 | 149622.001648 | 0.000000 | 0.993094
663 | 149622.057075000 | 149622.058868 | 0.062500 | -0.005425
663 | 149622.121106000 | 149622.130394 | 0.125000 | -0.003894
662 | 149622.185034000 | 149622.192383 | 0.187500 | -0.002466
663 | 149622.249074000 | 149622.249603 | 0.250000 | -0.000926
663 | 149622.313108000 | 149622.306824 | 0.312500 | 0.000608
472 | 149622.360060000 | 149622.383118 | 0.375000 | -0.014940
581 | 149622.424027000 | 149622.430801 | 0.437500 | -0.013473
663 | 149622.488075000 | 149622.497559 | 0.500000 | -0.011925
663 | 149622.552114000 | 149622.559547 | 0.562500 | -0.010386
662 | 149622.616032000 | 149622.621536 | 0.625000 | -0.008968
663 | 149622.680073000 | 149622.688293 | 0.687500 | -0.007427
663 | 149622.744107000 | 149622.750282 | 0.750000 | -0.005893
660 | 149622.808095000 | 149622.812271 | 0.812500 | -0.004405
582 | 149622.872057000 | 149622.869492 | 0.875000 | -0.002943
663 | 149622.936033000 | 149622.941017 | 0.937500 | -0.001467

663 | 149623.000109000 | 149623.003006 | 0.000000 | 0.000109
495 | 149623.049088000 | 149623.060226 | 0.062500 | -0.013412

The loop is typically running over 600 times before time.monotonic() changes. This is because only 4 bits are left for the fractional component of the time.

This guide was first published on Apr 01, 2020. It was last updated on Apr 01, 2020.
This page (Time in CircuitPython) was last updated on Jul 08, 2020.