Blog Programmazione

Il 25 ottobre le due e mezza arriva due volte

Quando finisce l’ora legale, un registro che scrive l’ora locale contiene marcatori che valgono due istanti diversi. Le durate calcolate su quei marcatori sbagliano di un’ora.

Domenica 25 ottobre, alle tre del mattino, gli orologi italiani tornano alle due: finisce l’ora legale. Per chi tiene un registro di eventi — un log, il file in cui un programma annota quello che fa e quando — l’ora fra le 02:00 e le 02:59 quella notte arriva due volte, e un marcatore come 2026-10-25 02:30:00 non dice a quale delle due. È un’ora di errore su ogni durata che attraversi quel confine.

Qualche parola prima di proseguire. Un fuso orario non è un numero fisso: è una regola che dice di quanto l’ora locale si discosta dal tempo universale coordinato (UTC, il riferimento mondiale) in ciascun momento dell’anno. In Italia quello scostamento vale un’ora in inverno (CET, +01:00) e due in estate (CEST, +02:00). Un istante è identificato da UTC; un’ora locale soltanto se le si attacca lo scostamento giusto.

Un’ora locale non identifica un istante

La documentazione di Python lo dice in due righe. Un oggetto aware, cioè consapevole del proprio fuso, «represents a specific moment in time that is not open to interpretation»; un oggetto naive, privo di fuso, «does not contain enough information to unambiguously locate itself relative to other date/time objects». Quasi tutti i log che si incontrano in azienda sono fatti di marcatori naive.

Ecco sei righe di un registro di accessi, nell’ordine in cui sono accadute.

2026-10-25 01:58:04 | utente=mrossi azione=accesso
2026-10-25 02:12:31 | utente=mrossi azione=apertura pratica 1180
2026-10-25 02:41:57 | utente=mrossi azione=salvataggio
2026-10-25 02:03:11 | utente=mrossi azione=salvataggio
2026-10-25 02:58:40 | utente=mrossi azione=esportazione elenco clienti
2026-10-25 03:07:22 | utente=mrossi azione=uscita

Letto così il file sembra sbagliato: la riga 4 segna le 02:03 e viene dopo una riga che segna le 02:41. Non è sbagliato: la riga 4 è stata scritta dopo che l’orologio è tornato indietro. Ordinando le sei righe come stringhe di testo, come fa qualunque foglio di calcolo, si ottiene l’ordine 1, 4, 2, 3, 5, 6, che è falso. E la durata della sessione, calcolata sottraendo il primo marcatore dall’ultimo senza tenere conto del fuso, dà 1:09:18 invece di 2:09:18: un’ora esatta in meno. Fra la riga 2 e la riga 4 la sottrazione ingenua dà perfino un valore negativo, dove in realtà sono passati cinquanta minuti.

Il database dei fusi e la regola europea

Le regole di tutti i fusi del mondo, con la loro storia, stanno in un archivio curato dalla IANA e installato su quasi tutti i sistemi operativi. Python lo usa dalla versione 3.9 con il modulo zoneinfo, che della provenienza dei dati dice: «By default, zoneinfo uses the system’s time zone data if available; if no system time zone data is available, the library will fall back to using the first-party tzdata package available on PyPI». Nel contenitore Linux in cui è stato scritto questo articolo l’archivio di sistema contiene 498 fusi.

Interrogandolo minuto per minuto su tutto il 2026 — una scansione eseguita davvero, non una deduzione — il fuso Europe/Rome cambia scostamento due volte: il 29 marzo, quando le 02:00 CET diventano le 03:00 CEST, e il 25 ottobre, quando le 03:00 CEST tornano a essere le 02:00 CET. Entrambe le transizioni cadono all’01:00 UTC. La fonte giuridica della regola è la direttiva 2000/84/CE del 19 gennaio 2001, «on summer-time arrangements»: titolo e data sono verificati su EUR-Lex, il testo degli articoli in questa stesura non è stato leggibile, quindi le date delle transizioni sono quelle misurate sull’archivio dei fusi.

Il punto pratico è un altro: quella regola non va riscritta a mano nel programma, perché è cambiata in passato ed è stata diversa da Paese a Paese. Un programma che legge l’archivio dei fusi funzionerà ancora il giorno in cui cambierà; uno che contiene «ultima domenica di ottobre» no.

Le due prove che la libreria standard permette

Per distinguere i tre casi — ora normale, ora ripetuta, ora che non esiste — bastano due righe e un attributo di datetime che molti non sanno di avere: fold. Sta «in [0, 1]», è «used to disambiguate wall times during a repeated interval», e «the values 0 and 1 represent, respectively, the earlier and later of the two moments with the same wall time representation».

Da qui la prima prova: se per la stessa ora locale lo scostamento calcolato con fold=0 è diverso da quello calcolato con fold=1, l’ora è ambigua. Alle 02:00 del 25 ottobre i due valori sono 2:00:00 e 1:00:00, e i due nomi di fuso CEST e CET. La seconda prova riguarda il buco di marzo e si fa con un giro di conversione: si porta l’ora locale a UTC e la si riporta nel fuso di partenza; se non si torna al punto di partenza, quell’ora locale non è mai esistita. Le 02:30 del 29 marzo diventano così 03:30 CEST, e un registro che le contiene sta dicendo qualcosa di sbagliato: un orologio fermo, un fuso di configurazione diverso da quello che credete, un marcatore ricostruito a posteriori.

Sciogliere l’ambiguità con l’ordine del file

Riconoscere l’ambiguità non la risolve: le 02:30 restano due istanti. C’è però un’informazione che il file porta con sé e la singola riga no, l’ordine in cui le righe sono state scritte. Un registro ad accodamento — quello in cui ogni riga nuova finisce in fondo — è cronologico per costruzione. La regola allora è questa: si tiene da parte l’istante UTC dell’ultima riga accettata e, per un’ora ambigua, si sceglie il più piccolo dei due istanti possibili che non sia anteriore a quello. Nel registro di prima le righe 2 e 3 cadono nella prima passata, le righe 4 e 5 nella seconda, e il file si ordina.

La regola è mia e non viene da nessuna documentazione, e ha un prezzo dichiarato: se la prima riga utile del file cade dentro l’ora ripetuta non c’è un precedente su cui appoggiarsi, e lo script non tira a indovinare — segnala AMBIGUO e lascia la riga non convertita. Lo stesso vale per le righe ambigue che seguono una riga non risolta, perché il riferimento non avanza. Meglio una colonna vuota che un istante inventato.

Lo script

Cento righe, sola libreria standard, eseguito su Python 3.13.16. Prende il registro, il nome del fuso con cui interpretarlo e la copia normalizzata da scrivere; il file di partenza non lo tocca. Esce con 0 se ha convertito tutto, con 1 se qualcosa è rimasto ambiguo, inesistente o illeggibile, con 2 se non è nemmeno partito.

#!/usr/bin/env python3
"""Converte in istanti UTC i marcatori temporali locali di un registro.

Uso: ora-legale.py REGISTRO FUSO USCITA.csv
Esempio: ora-legale.py accessi.log Europe/Rome accessi-utc.csv
"""
import csv
import sys
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

FORMATO = "%Y-%m-%d %H:%M:%S"


def esiste(locale):
    """Falso se l'ora locale cade nel salto di inizio ora legale."""
    return locale.astimezone(timezone.utc).astimezone(locale.tzinfo) == locale


def ambiguo(locale):
    """Vero se l'ora locale ricorre due volte (fine dell'ora legale)."""
    return locale.replace(fold=0).utcoffset() != locale.replace(fold=1).utcoffset()


def istante(locale, fold):
    return locale.replace(fold=fold).astimezone(timezone.utc)


def interpreta(testo, fuso, ultimo):
    """Restituisce (esito, istante_utc, locale_risolto).

    'ultimo' e' l'istante UTC dell'ultima riga accettata: serve a sciogliere
    l'ambiguita' assumendo che il registro sia scritto in ordine cronologico.
    """
    try:
        locale = datetime.strptime(testo, FORMATO).replace(tzinfo=fuso)
    except ValueError:
        return "formato", None, None
    if not esiste(locale):
        return "inesistente", None, locale
    if not ambiguo(locale):
        return "determinato", istante(locale, 0), locale
    prima, dopo = istante(locale, 0), istante(locale, 1)
    if ultimo is None:
        return "ambiguo", None, locale
    if prima >= ultimo:
        return "ambiguo-primo", prima, locale.replace(fold=0)
    if dopo >= ultimo:
        return "ambiguo-secondo", dopo, locale.replace(fold=1)
    return "ambiguo", None, locale


def main(argomenti):
    if len(argomenti) != 3:
        print(__doc__, file=sys.stderr)
        return 2
    percorso, nome_fuso, uscita = argomenti
    try:
        fuso = ZoneInfo(nome_fuso)
    except ZoneInfoNotFoundError:
        print(f"fuso orario sconosciuto: {nome_fuso}", file=sys.stderr)
        return 2
    try:
        with open(percorso, encoding="utf-8") as file_registro:
            righe = file_registro.read().splitlines()
    except OSError as errore:
        print(f"registro illeggibile: {errore}", file=sys.stderr)
        return 2

    conteggio, ultimo, normalizzate = {}, None, []
    for numero, riga in enumerate(righe, 1):
        if not riga.strip():
            continue
        esito, utc, locale = interpreta(riga[:19], fuso, ultimo)
        conteggio[esito] = conteggio.get(esito, 0) + 1
        resto = riga[19:].lstrip(" |")
        if utc is None:
            print(f"riga {numero}: {riga[:19]!r} -> {esito.upper()}  {resto}")
            normalizzate.append((numero, riga[:19], "", "", esito, resto))
            continue
        ultimo = utc
        print(f"riga {numero}: {locale.isoformat()}  UTC {utc:%Y-%m-%d %H:%M:%S}  "
              f"[{esito}]  {resto}")
        normalizzate.append((numero, riga[:19], locale.isoformat(),
                             utc.strftime("%Y-%m-%dT%H:%M:%SZ"), esito, resto))

    with open(uscita, "w", encoding="utf-8", newline="") as file_uscita:
        scrittore = csv.writer(file_uscita)
        scrittore.writerow(["riga", "locale_originale", "locale_con_scostamento",
                            "utc", "esito", "testo"])
        scrittore.writerows(normalizzate)

    print("\nRiepilogo:", ", ".join(f"{k}={v}" for k, v in sorted(conteggio.items())))
    irrisolte = sum(conteggio.get(k, 0) for k in ("ambiguo", "inesistente", "formato"))
    print(f"righe non convertite: {irrisolte}; copia normalizzata in {uscita}")
    return 1 if irrisolte else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Sul registro di sei righe visto sopra stampa questo.

riga 1: 2026-10-25T01:58:04+02:00  UTC 2026-10-24 23:58:04  [determinato]  utente=mrossi azione=accesso
riga 2: 2026-10-25T02:12:31+02:00  UTC 2026-10-25 00:12:31  [ambiguo-primo]  utente=mrossi azione=apertura pratica 1180
riga 3: 2026-10-25T02:41:57+02:00  UTC 2026-10-25 00:41:57  [ambiguo-primo]  utente=mrossi azione=salvataggio
riga 4: 2026-10-25T02:03:11+01:00  UTC 2026-10-25 01:03:11  [ambiguo-secondo]  utente=mrossi azione=salvataggio
riga 5: 2026-10-25T02:58:40+01:00  UTC 2026-10-25 01:58:40  [ambiguo-secondo]  utente=mrossi azione=esportazione elenco clienti
riga 6: 2026-10-25T03:07:22+01:00  UTC 2026-10-25 02:07:22  [determinato]  utente=mrossi azione=uscita

Riepilogo: ambiguo-primo=2, ambiguo-secondo=2, determinato=2
righe non convertite: 0; copia normalizzata in accessi-utc.csv

Quello che lo script non fa

Vale la pena essere espliciti sui limiti, perché sono il confine fra uno strumento e un’illusione. Lo script assume che il file sia in ordine cronologico: su un’esportazione riordinata per un’altra colonna la regola dell’ultimo istante non ha senso e il risultato va buttato. Assume poi che chi ha scritto il registro stesse usando il fuso che gli passate: se la macchina era configurata diversamente, tutte le conversioni risulteranno coerenti e tutte sbagliate, e l’unico modo di accorgersene è confrontare un evento con una sorgente esterna di cui conoscete l’ora vera. Non dice niente, infine, sulla precisione dell’orologio che ha scritto il registro: una macchina senza sincronizzazione NTP può essere fuori di minuti, e nessuna conversione di fuso lo ripara.

Lo script è stato provato anche su UTC, dove nessuna riga risulta ambigua, e su America/Los_Angeles intorno al 1º novembre 2026: il meccanismo non ha niente di italiano, legge l’archivio dei fusi.

Se il registro serve come prova

Un marcatore temporale che finisce in un rapporto o in una perizia ha due requisiti che vengono prima della conversione: l’originale non si modifica, e il metodo si dichiara. La copia normalizzata va conservata accanto all’originale, non al suo posto, insieme al nome del fuso assunto e all’impronta del file di partenza. Le ore ambigue restano ambigue: si documenta il criterio con cui sono state assegnate, non una certezza che i dati non contengono. Sulle forme dell’uso probatorio decide chi segue il procedimento.

Cosa puoi fare

Da qui al 25 ottobre c’è tempo per tre cose. La prima è fare l’elenco dei sistemi che scrivono l’ora locale senza scostamento: il registro del gestionale, quello del centralino, le esportazioni dei controlli accessi, le videocamere, i file di backup. Si riconoscono a occhio, perché il marcatore finisce con i secondi e non con una Z o con un +02:00.

La seconda è chiedere a chi fornisce quei sistemi se il formato si può cambiare: UTC con la Z finale, oppure ora locale con lo scostamento esplicito nella forma ISO 8601, sono entrambi privi di ambiguità. Dove non si può cambiare, conviene almeno annotare per iscritto su quale fuso è configurata la macchina, perché quella è l’informazione che manca nel file.

La terza è una prova a costo zero: prendere il registro della notte fra il 25 e il 26 ottobre 2025, l’ultimo cambio d’ora già passato, e passarlo allo script. Il numero di righe che tornano ambigue è una misura diretta di quanto il problema vi riguarda; se sono zero perché quella notte nessuno lavorava, controllate i processi automatici, che di notte lavorano sempre. E per qualunque calcolo di durata o di scadenza, fate la sottrazione sugli istanti UTC: è l’unico piano su cui un’ora dura sempre sessanta minuti.