RTC behind on Arduino Nicla Vision

Hello,

I discovered that my new Nicla Vision does not keep time with the RTC as it should. As I want to log things with this board an accurate time keeping is required, but after some testing I found that the RTC clock runs a lot slower than the ntp time I am synching it to.

Firmware version: 5.0.0 and version 4.5.9

I tested the timing with deepsleep as well as with the board awake for a period of a few hours and found the following:
Deep-Sleep Test

From start to ntp resynch after 120 wakes:

  • NTP elapsed: 4,331 s (72 min 11 s)
  • RTC elapsed: 4,171 s (69 min 31 s)
  • RTC loss: 160 s

That is about:

-36{,}900\ \text{ppm}

or roughly 3.69% slow.

Awake Test

Two awake runs gave nearly identical results:

Run NTP elapsed RTC elapsed RTC loss
12:27:27 to 14:27:34 7,206 s 7,045 s 161 s
14:27:39 to 16:27:46 7,207 s 7,045 s 162 s

That is approximately:

-22{,}300\ \text{ppm}

or 2.23% slow.

Combined Conclusion

  • The RTC slow even while the Nicla awake.
  • Deep sleep makes the observed error worse: about 53 min/day versus 32 min/day awake.
  • RTC.info() is consistently 0x180300: documented lse_failed and newly_initialized flags are both clear, so this is not the documented LSE-failed fallback path.
  • rtc.calibration() is 0; normal RTC calibration cannot correct a 2-4% frequency error.

Has someone run into the same problem or is my Nicla Vision faulty?

My code:

# Writes append-only CSV records to rtc_drift.csv.
#specify wifi and ntp server detail below

import machine
import os
import time

import ntptime
import network

SLEEP_MS = 30_000
LOG_FILE = "rtc_drift.csv"
NTP_SYNC_EVERY_WAKES = 120  # 120 * 30 s = 1 hour
STATE_FILE = "rtc_drift_state.txt"
AWAKE_TEST_SECONDS = 2 * 60 * 60  # 2 hours
AWAKE_LOG_INTERVAL_SECONDS = 5 * 60  # 5 seconds

#wifi_country = 
#wifi_ssid = 
#wifi_password = 
#ntp_server = 

rtc = machine.RTC()


def datetime_text(dt):
    return "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ" % (
        dt[0], dt[1], dt[2], dt[4], dt[5], dt[6], dt[7] * 1000 // 256
    )


def datetime_seconds(dt):
    return time.mktime((
        dt[0], dt[1], dt[2], dt[4], dt[5], dt[6], 0, 0
    ))


def rtc_info_text():
    info = rtc.info()
    startup_ms = info & 0xFFFF
    lse_failed = bool(info & 0x10000)
    newly_initialized = bool(info & 0x20000)

    return info, startup_ms, lse_failed, newly_initialized


def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    network.country(wifi_country)
    wlan.connect(wifi_ssid, wifi_password)

    for _ in range(30):
        if wlan.status() == 3:
            return wlan
        if wlan.status() < 0:
            raise RuntimeError("Wi-Fi failed: %d" % wlan.status())
        time.sleep(1)

    raise RuntimeError("Wi-Fi connection timed out")


def ntp_datetime():
    ntptime.host = ntp_server
    timestamp = time.gmtime(ntptime.time())
    return (
        timestamp[0], timestamp[1], timestamp[2], timestamp[6],
        timestamp[3], timestamp[4], timestamp[5], timestamp[7]
    )


def append_record(event, rtc_dt, ntp_dt, info):
    info_value, startup_ms, lse_failed, newly_initialized = info

    rtc_epoch = datetime_seconds(rtc_dt)
    ntp_epoch = datetime_seconds(ntp_dt) if ntp_dt else 0
    offset_seconds = rtc_epoch - ntp_epoch if ntp_dt else ""

    new_file = not file_exists(LOG_FILE)
    with open(LOG_FILE, "a") as log_file:
        if new_file:
            log_file.write(
                "event,rtc_time,ntp_time,rtc_minus_ntp_seconds,"
                "rtc_info,startup_ms,lse_failed,newly_initialized,"
                "calibration\n"
            )

        log_file.write(
            "%s,%s,%s,%s,%d,%d,%d,%d,%d\n"
            % (
                event,
                datetime_text(rtc_dt),
                datetime_text(ntp_dt) if ntp_dt else "",
                offset_seconds,
                info_value,
                startup_ms,
                lse_failed,
                newly_initialized,
                rtc.calibration(),
            )
        )

    print(
        "RTC_TEST event=%s rtc=%s ntp=%s offset_s=%s "
        "info=0x%08X startup_ms=%d lse_failed=%s newly_initialized=%s "
        "calibration=%d"
        % (
            event,
            datetime_text(rtc_dt),
            datetime_text(ntp_dt) if ntp_dt else "",
            offset_seconds,
            info_value,
            startup_ms,
            lse_failed,
            newly_initialized,
            rtc.calibration(),
        )
    )


def file_exists(filename):
    try:
        os.stat(filename)
        return True
    except OSError:
        return False


def read_wake_count():
    try:
        with open(STATE_FILE, "r") as state_file:
            return int(state_file.read().strip())
    except (OSError, ValueError):
        return 0


def write_wake_count(wake_count):
    with open(STATE_FILE, "w") as state_file:
        state_file.write(str(wake_count))


def disconnect_wifi(wlan):
    if wlan is not None:
        wlan.disconnect()
        wlan.active(False)


# def main():
#     print("RTC test starting; press Stop within 5 seconds to abort.")
#     time.sleep(5)

#     wake_count = read_wake_count() + 1
#     write_wake_count(wake_count)

#     rtc_dt = rtc.datetime()
#     info = rtc_info_text()

#     # This line occurs on every deep-sleep reboot and is the evidence for
#     # LSE versus LSI fallback and unexpected RTC initialization.
#     append_record("wake_%d" % wake_count, rtc_dt, None, info)

#     if wake_count == 1 or wake_count % NTP_SYNC_EVERY_WAKES == 0:
#         wlan = None
#         try:
#             wlan = connect_wifi()

#             # Measure error BEFORE correcting the device calendar.
#             ntp_dt = ntp_datetime()
#             append_record(
#                 "ntp_check_before_set_%d" % wake_count,
#                 rtc.datetime(),
#                 ntp_dt,
#                 rtc_info_text(),
#             )

#             rtc.datetime(ntp_dt)

#             # This confirms that setting the calendar worked.
#             append_record(
#                 "ntp_set_%d" % wake_count,
#                 rtc.datetime(),
#                 ntp_dt,
#                 rtc_info_text(),
#             )
#         except Exception as error:
#             print("RTC_TEST_NTP_ERROR:", error)
#             append_record(
#                 "ntp_failed_%d" % wake_count,
#                 rtc.datetime(),
#                 None,
#                 rtc_info_text(),
#             )
#         finally:
#             disconnect_wifi(wlan)

#     rtc.wakeup(SLEEP_MS)
#     machine.deepsleep()

def main():
    wlan = None
    try:
        wlan = connect_wifi()

        # Establish RTC and NTP reference at the same time.
        ntp_dt = ntp_datetime()
        rtc.datetime(ntp_dt)
        append_record(
            "awake_ntp_set",
            rtc.datetime(),
            ntp_dt,
            rtc_info_text(),
        )

        disconnect_wifi(wlan)
        wlan = None

        print("RTC_TEST staying awake for %d seconds" % AWAKE_TEST_SECONDS)

        # Keep the interpreter active without calling lightsleep/deepsleep.
        started_ms = time.ticks_ms()
        next_log_ms = 0

        while time.ticks_diff(time.ticks_ms(), started_ms) < (
            AWAKE_TEST_SECONDS * 1000
        ):
            elapsed_ms = time.ticks_diff(time.ticks_ms(), started_ms)

            if elapsed_ms >= next_log_ms:
                append_record(
                    "awake_elapsed_%ds" % (elapsed_ms // 1000),
                    rtc.datetime(),
                    None,
                    rtc_info_text(),
                )
                next_log_ms += AWAKE_LOG_INTERVAL_SECONDS * 1000

            time.sleep(1)

        wlan = connect_wifi()

        # Measure the offset before modifying RTC time.
        ntp_dt = ntp_datetime()
        append_record(
            "awake_ntp_check_before_set",
            rtc.datetime(),
            ntp_dt,
            rtc_info_text(),
        )

    except Exception as error:
        print("RTC_TEST_ERROR:", error)
        append_record(
            "awake_test_failed",
            rtc.datetime(),
            None,
            rtc_info_text(),
        )
    finally:
        disconnect_wifi(wlan)

main()

Hi @rolu75,

Thanks for the excellent measurements — they made this easy to track down. Your board is not faulty. Here’s what’s going on:

Root cause: The RTC on the Nicla Vision runs from the STM32H747’s internal LSI RC oscillator (~32.0 kHz), but the RTC prescalers were set to divide by 32768. That’s the entire systematic error: 32000/32768 = −2.34%, which matches your awake measurement of −2.23% almost exactly. Your RTC.info() value of 0x180300 decodes to exactly this state (RTC on LSI, external oscillator never enabled). Deep sleep is worse because the LSI is an RC oscillator that drifts with temperature and voltage. And as you found, rtc.calibration() maxes out at ±488 ppm, so it can’t correct any of this.

Why not the accurate external oscillator? The Nicla does have a ±20 ppm 32.768 kHz oscillator (SiT1532) wired to the MCU, and I got the RTC running from it on the bench — it held ±0 ppm. Unfortunately it can’t be shipped: the Arduino MCUboot bootloader re-initializes the RTC back onto the LSI at every hard reset when it finds any other clock source selected, and that wipes the date/time. So every deep-sleep wake would lose the calendar entirely — worse than drift for your logging use case. That would need a fix in Arduino’s bootloader.

The fix: Keep the LSI, but divide by 32000 instead of 32768. Measured on a Nicla Vision here: −22,300 ppm → −684 ppm (about 34 min/day down to under 1 min/day). Time survives deep sleep and resets. The small remaining offset is part-to-part LSI tolerance, and rtc.calibration() can now trim most of it — with your hourly NTP re-sync you should be in great shape.

PR (fixes the Nicla, Portenta H7, Giga, and Opta): stm32/boards: Set the RTC prescalers for the LSI on Arduino boards. by kwagyeman · Pull Request #19650 · micropython/micropython · GitHub

Once it’s merged it’ll be picked up in a future OpenMV firmware release.

Thank you very much @kwagyeman for the quick and extensive reply.