🇮🇹 Italiano
🇮🇹 Italiano
Appearance
🇮🇹 Italiano
🇮🇹 Italiano
Appearance
Deliverable D2.1.1: Documento Progettazione Tecnica Data-Lake
L'architettura del Data Lake MAPS adotta il pattern Medallion (anche denominato Multi-Hop Architecture), best practice nell'ecosistema Modern Data Stack, che organizza i dati in tre layer di progressivo raffinamento con crescente livello di qualità, pulizia e business-readiness:
FONTI ESTERNE → BRONZE → SILVER → GOLD → APPLICAZIONIPer il progetto MAPS, l'architettura Medallion è particolarmente indicata per cinque motivazioni principali.
L'eterogeneità delle fonti dati costituisce il primo fattore determinante. Il progetto gestisce oltre 180 dataset provenienti da fonti pubbliche diverse (ISTAT, Ministeri, enti territoriali), distribuiti in formati multipli (CSV, Excel, PDF, JSON, HTML) con qualità variabile che spazia da dataset strutturati a documenti semi-strutturati. La stratificazione in layer progressivi permette di standardizzare gradualmente questa eterogeneità senza perdere le informazioni originali.
I requisiti di audit e compliance rappresentano il secondo elemento critico. Sebbene i dati siano pubblici, la ricerca scientifica richiede tracciabilità completa della provenienza e delle trasformazioni applicate. L'articolo 30 del GDPR impone inoltre la documentazione delle attività di trattamento dati. Il livello Bronze immutabile fornisce la "source of truth" originale necessaria per soddisfare questi requisiti.
La complessità delle trasformazioni richiede un'architettura articolata. Le pipeline operano in modalità multi-step, passando dall'acquisizione da fonti eterogenee (CSV, Excel, PDF, JSON, HTML) alla validazione, quindi alla normalizzazione in schema EAV e infine alle aggregazioni territoriali. La gestione di serie storiche 2010-2025 con fusioni e scissioni comunali aggiunge ulteriore complessità. La separazione logica tra acquisizione grezza (Bronze), pulizia (Silver) e business logic (Gold) semplifica lo sviluppo e il debugging delle trasformazioni.
Il riutilizzo dei dati giustifica l'introduzione di un layer intermedio validato. Gli stessi dataset vengono impiegati per analisi multiple (demografia, servizi, mobilità), rendendo inefficiente il re-processing dalla fonte esterna ad ogni utilizzo. Il livello Silver funge da cache enterprise validata, accelerando le analisi downstream.
Performance e scalabilità costituiscono l'ultimo fattore abilitante. Le query analitiche operano su oltre 8.000 comuni con decine di attributi ciascuno, mentre le operazioni spaziali PostGIS risultano computazionalmente intensive. Il livello Gold denormalizzato ottimizza le prestazioni delle interrogazioni frequenti, riducendo i tempi di risposta da secondi a millisecondi.
graph TB
A[Fonti Esterne
ISTAT, GTFS, Ministeri, OpenData] --> B[Prefect Orchestration
Worker Pools: istat, pdf, analytics]
B --> C[Bronze Layer
File System - Raw Files]
C --> D[Silver Layer
PostgreSQL - EAV Schema]
D --> E[Gold Layer
PostgreSQL + PostGIS]
B -.metadata.-> F[OpenMetadata
Internal Governance]
E --> F
E --> G[DuckDB
Analytics]
E --> H[API v2
Web Apps]
E --> I[CKAN
Open Data Catalog]
I --> J[dati.gov.it
National Portal]
style C fill:#cd7f32
style D fill:#c0c0c0
style E fill:#ffd700
I dati transitano dalle fonti esterne attraverso tre layer progressivi, orchestrati da Prefect. Il Bronze layer conserva i file originali; il Silver layer li normalizza in schema EAV su PostgreSQL; il Gold layer produce le tabelle analitiche con estensioni spaziali PostGIS. I dati pubblicati raggiungono il portale nazionale tramite CKAN, mentre OpenMetadata traccia la governance lungo l'intero flusso.
Ruolo: Archivio immutabile dei dati originali esattamente come scaricati dalla fonte.
Principio chiave: "Non modificare mai, conservare sempre"
| Aspetto | Specifica MAPS |
|---|---|
| Storage | File system locale /data/bronze/ (server op-linkurious) |
| Formato | File originali senza trasformazioni (CSV, XLSX, PDF, JSON, HTML, Parquet) |
| Convenzione di nomenclatura | /data/bronze/{fonte}/{anno}/{dataset}_{timestamp}.{ext} |
| Politica di conservazione | Indefinita (storage cost è basso: ~50GB totali per 180 dataset) |
| Backup | Snapshot giornalieri via backup.sh script |
| Modalità di accesso | Write-once, read-rarely (solo per reprocessing o audit) |
/data/bronze/
├── istat/
│ ├── 2024/
│ │ ├── popolazione_comuni_20240218.csv
│ │ ├── pendolarismo_matrix_20240218.csv
│ │ ├── confini_amministrativi_20240218.geojson
│ │ └── _metadata/
│ │ ├── popolazione_comuni_20240218.json # Metadata file
│ │ └── checksums.sha256
│ └── 2023/
│ └── popolazione_comuni_20230315.csv
├── minlavoro/
│ └── 2024/
│ ├── tabacchi_adm_report_20240218.pdf
│ └── _metadata/
│ └── tabacchi_adm_report_20240218.json
├── minsalute/
│ └── 2023/
│ ├── strutture_sanitarie_asl_20231120.xlsx
│ └── _metadata/
│ └── strutture_sanitarie_asl_20231120.json
└── minambiente/
└── 2024/
├── ato_gas_page_20240115.html # ← HTML file
└── _metadata/
└── ato_gas_page_20240115.jsonIl tracciamento avviene su due livelli complementari: un file JSON locale affiancato a ciascun file acquisito, e una tabella centralizzata su PostgreSQL che registra lo stato di tutte le acquisizioni.
Ogni file Bronze ha un corrispondente file JSON con i metadati di acquisizione:
Esempio: /data/bronze/istat/2024/_metadata/popolazione_comuni_20240218.json
{
"file_path": "/data/bronze/istat/2024/popolazione_comuni_20240218.csv",
"fonte": "istat",
"dataset": "popolazione",
"anno_riferimento": 2024,
"download_timestamp": "2024-02-18T03:00:15Z",
"download_url": "https://www.istat.it/storage/cartografia/popolazione_comuni_2024.csv",
"file_size_bytes": 3355482,
"file_hash_sha256": "a3f5b8c9d2e1f4a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0",
"mime_type": "text/csv",
"encoding": "UTF-8",
"rows_detected": 7901,
"columns_detected": 12,
"prefect_flow_run_id": "abc123-456-def-789",
"ingestion_status": "completed"
}Il file viene scritto dal flow Prefect al termine di ogni acquisizione e risiede nella sottodirectory _metadata/ accanto al file dati corrispondente. Registra le informazioni operative essenziali: provenienza, hash di integrità, stato e identificativo del flow che ha eseguito il download. Svolge tre funzioni nell'architettura: consente il reprocessing mirato (in caso di errore nel layer Silver, la pipeline individua il file Bronze da rielaborare senza riscaricare dalla fonte); garantisce l'audit trail tramite hash SHA256; e fornisce i metadati tecnici che il flow pubblica in OpenMetadata tramite API, alimentando il catalogo di governance centralizzato.
Tracciamento su Database (PostgreSQL):
Mentre il JSON è locale al file system, la tabella bronze.ingestion_log centralizza lo stato di tutte le acquisizioni, consentendo query aggregate su fonti, dataset e stati. Funge da registro consultabile da Prefect per gestire le riesecuzioni e garantire la deduplicazione tramite il vincolo di unicità sull'hash del file.
-- Schema: bronze
CREATE TABLE bronze.ingestion_log (
id BIGSERIAL PRIMARY KEY,
fonte VARCHAR(50) NOT NULL,
dataset VARCHAR(100) NOT NULL,
anno_rif INTEGER NOT NULL,
file_path TEXT NOT NULL,
file_size_bytes BIGINT,
file_hash_sha256 CHAR(64),
download_url TEXT,
download_timestamp TIMESTAMP NOT NULL,
prefect_flow_run_id UUID,
status VARCHAR(20) NOT NULL, -- 'completed', 'failed', 'in_progress'
error_message TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (fonte, dataset, anno_rif, file_hash_sha256)
);
-- Index per query frequenti
CREATE INDEX idx_ingestion_log_fonte_dataset ON bronze.ingestion_log(fonte, dataset);
CREATE INDEX idx_ingestion_log_status ON bronze.ingestion_log(status);
CREATE INDEX idx_ingestion_log_timestamp ON bronze.ingestion_log(download_timestamp DESC);Il Bronze layer offre tre garanzie fondamentali:
(fonte, dataset, anno_rif, file_hash).Alcune fonti pubbliche italiane pubblicano dati esclusivamente come tabelle HTML embedded in pagine web, senza mettere a disposizione file CSV o Excel scaricabili. È il caso di diversi registri regionali di strutture sanitarie accreditate, di elenchi di enti pubblici e di sezioni di portali ministeriali in cui i dati sono presentati per la consultazione online ma non esportabili direttamente. Questo tipo di fonte richiede un approccio di acquisizione dedicato, basato su HTTP GET della pagina e parsing con BeautifulSoup, prima di poter procedere alla normalizzazione dei dati.
Il flusso Bronze-HTML segue la stessa logica degli altri formati: la pagina HTML viene salvata integralmente nel Bronze layer prima di qualsiasi trasformazione.
sequenceDiagram
participant S as Source Website
participant P as Prefect Flow
participant B as Bronze Layer
participant SI as Silver Layer
S->>P: HTTP GET HTML page
P->>B: Save raw HTML file
P->>B: Log metadata JSON
Note over B: HTML preserved unchanged
P->>P: Parse HTML with BeautifulSoup
P->>P: Clean & Validate DataFrame
P->>SI: Load to EAV schema
Il vantaggio del pattern Bronze rispetto all'acquisizione diretta senza archiviazione intermedia è la resilienza: se il parser fallisce perché la struttura HTML della pagina è cambiata, è possibile correggere il codice e rielaborare il file già salvato, senza tornare alla fonte. Se il server della fonte è temporaneamente irraggiungibile o la pagina viene rimossa, il Bronze layer conserva la copia originale e permette la riprocessazione. La storia delle versioni HTML acquisite nel tempo consente inoltre di rilevare modifiche retroattive ai dati pubblicati.
Ruolo: Fonte di riferimento unica per tutti i processi analitici del progetto. I dati sono puliti, validati e normalizzati a partire dal Bronze layer.
Principio chiave: "Verifica prima di fidarti, poi archivia"
| Aspetto | Specifica MAPS |
|---|---|
| Storage | PostgreSQL schema silver |
| Formato | Tabelle relazionali normalizzate (schema EAV) |
| Convenzione di nomenclatura | Schema silver, tabelle principali: territory_types, territories, territory_identifiers, territory_names, territory_containments, territory_attributes |
| Politica di conservazione | Versionamento temporale (valid_from, valid_to) per serie storiche |
| Backup | Snapshot giornalieri PostgreSQL + WAL archiving |
| Modalità di accesso | Read-heavy (query analitiche), write-moderate (batch ingestion) |
Il progetto MAPS acquisisce oltre 180 dataset con strutture eterogenee: popolazione, strutture sanitarie, infrastrutture di trasporto, dati ambientali. Adottare un modello relazionale tradizionale richiederebbe una tabella separata per ciascun dataset, rendendo difficile la gestione delle evoluzioni nel tempo e l'aggiunta di nuove fonti. Il modello EAV (Entity-Attribute-Value) risolve questo problema rappresentando ogni osservazione come una riga (territory_id, attribute, value): lo schema rimane stabile al variare dei dataset acquisiti, e nuovi attributi si aggiungono senza modifiche alla struttura del database.
Un ulteriore requisito è emerso nel corso del progetto: i dati non sono sempre disponibili a livello comunale. Alcuni dataset sono pubblicati a livello provinciale o regionale; altri si riferiscono ad aggregazioni funzionali come i Sistemi Locali del Lavoro (SLL) o i nuovi Sistemi Locali Omogenei (SLO) che questo progetto si propone di definire. Lo schema Silver supporta quindi entità territoriali di qualsiasi tipo, non solo i comuni.
Il modello si articola in sei tabelle principali: territory_types per il registro dei tipi di entità; territories per il registro principale di tutte le entità territoriali, ciascuna con un id surrogato stabile che non cambia mai anche quando i codici ISTAT vengono riassegnati; territory_identifiers per i codici ISTAT temporali e altri identificatori esterni; territory_names per i nomi ufficiali e alternativi con supporto multilingue; territory_containments per il contenimento gerarchico temporale (comune → provincia, con date precise per i cambi di provincia); e territory_attributes per tutti i valori osservati con tracciabilità completa. Gli eventi di variazione amministrativa (fusioni, incorporazioni, scissioni) sono tracciati tramite territory_relationships, che memorizza i legami predecessore/successore derivati dai dati ISTAT delle variazioni amministrative.
erDiagram
TERRITORY_TYPES ||--o{ TERRITORIES : "typed as"
TERRITORIES ||--o{ TERRITORY_IDENTIFIERS : "has identifiers"
TERRITORIES ||--o{ TERRITORY_NAMES : "has names"
TERRITORIES ||--o{ TERRITORY_CONTAINMENTS : "member"
TERRITORIES ||--o{ TERRITORY_CONTAINMENTS : "container"
TERRITORIES ||--o{ TERRITORY_ATTRIBUTES : "has attributes"
TERRITORY_TYPES {
varchar code PK
varchar label
varchar authority
text coding_scheme
integer hierarchy_level
}
TERRITORIES {
serial id PK
varchar type_code FK
varchar label
date valid_from
date valid_to
text end_reason
}
TERRITORY_IDENTIFIERS {
serial id PK
integer territory_id FK
varchar scheme
varchar identifier
date valid_from
date valid_to
}
TERRITORY_NAMES {
serial id PK
integer territory_id FK
varchar name
varchar language
varchar name_type
date valid_from
date valid_to
}
TERRITORY_CONTAINMENTS {
serial id PK
integer member_id FK
integer container_id FK
date valid_from
date valid_to
}
TERRITORY_ATTRIBUTES {
serial id PK
integer territory_id FK
varchar type_code
varchar attribute
text value
varchar data_type
varchar source
date valid_from
date valid_to
}
La tabella territory_attributes è il cuore dello schema Silver. Ogni riga rappresenta un singolo valore osservato per un'entità territoriale in un periodo di validità, con la tracciabilità completa della fonte. Le colonne valid_from e valid_to implementano il versionamento temporale secondo il pattern SCD Type 2. In modo cruciale, territory_attributes fa riferimento all'id surrogato stabile del territorio invece che direttamente al codice ISTAT: quando un comune cambia provincia e riceve un nuovo codice ISTAT, lo storico degli attributi viene preservato senza alcuna migrazione dei dati.
-- Registro dei tipi di entità territoriale
CREATE TABLE silver.territory_types (
code VARCHAR(30) PRIMARY KEY,
label VARCHAR(100) NOT NULL,
authority VARCHAR(100), -- "ISTAT", "SNAI", "MAPS", ...
coding_scheme TEXT,
hierarchy_level INTEGER -- 1=nazione ... 5=comune; NULL=aggregazione funzionale
);
-- Registro principale — id surrogato stabile, non cambia mai
CREATE TABLE silver.territories (
id SERIAL PRIMARY KEY,
type_code VARCHAR(30) NOT NULL REFERENCES silver.territory_types(code),
label VARCHAR(255),
valid_from DATE, -- da eventi variazioni CS; NULL = inizio sconosciuto
valid_to DATE, -- da eventi variazioni ES; NULL = ancora attivo
end_reason TEXT
);
-- Identificatori esterni temporali (codici ISTAT, catastali, ...)
CREATE TABLE silver.territory_identifiers (
id SERIAL PRIMARY KEY,
territory_id INTEGER NOT NULL REFERENCES silver.territories(id),
scheme VARCHAR(50) NOT NULL, -- 'istat', 'catasto', 'fiscale', 'uts', ...
identifier VARCHAR(50) NOT NULL,
valid_from DATE,
valid_to DATE,
fonte VARCHAR(100)
);
-- Nomi temporali (ufficiali + bilingui + alternativi)
CREATE TABLE silver.territory_names (
id SERIAL PRIMARY KEY,
territory_id INTEGER NOT NULL REFERENCES silver.territories(id),
name TEXT NOT NULL,
language VARCHAR(10), -- 'it', 'de', 'fr', 'sl', ...
name_type VARCHAR(50), -- 'official', 'local', 'alias', ...
valid_from DATE,
valid_to DATE
);
-- Contenimento gerarchico temporale (comune → provincia, con date per i cambi di provincia)
CREATE TABLE silver.territory_containments (
id SERIAL PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES silver.territories(id),
container_id INTEGER NOT NULL REFERENCES silver.territories(id),
valid_from DATE,
valid_to DATE
);
-- EAV generalizzata — referenzia territory_id stabile, non il codice ISTAT
CREATE TABLE silver.territory_attributes (
id SERIAL PRIMARY KEY,
territory_id INTEGER NOT NULL REFERENCES silver.territories(id),
type_code VARCHAR(30) NOT NULL, -- denormalizzato da territories per comodità
attribute VARCHAR(255) NOT NULL,
value TEXT,
data_type VARCHAR(50), -- integer, float, string, boolean, date
source VARCHAR(100),
valid_from DATE,
valid_to DATE, -- NULL = valore corrente
UNIQUE (territory_id, attribute, valid_from)
);
CREATE INDEX idx_ta_territory ON silver.territory_attributes(territory_id);
CREATE INDEX idx_ta_type ON silver.territory_attributes(type_code);
CREATE INDEX idx_ta_attribute ON silver.territory_attributes(attribute);
CREATE INDEX idx_ta_source ON silver.territory_attributes(source);Per rendere concreto il modello, si riporta un esempio reale: il comune di AGLIÈ (codice ISTAT 001001), con attributi provenienti da tre fonti distinte, affiancato da un record a livello provinciale e da un record relativo a un Sistema Locale del Lavoro. Tutti i tipi di entità coesistono nella stessa tabella grazie allo schema EAV generalizzato. La risoluzione territoriale passa sempre tramite l'id surrogato stabile; il codice ISTAT viene cercato in territory_identifiers.
-- Prima di tutto, risolvere territory_id per AGLIÈ (codice ISTAT 001001)
-- Questo join viene gestito da territory_resolver.py al momento della trasformazione
SELECT t.id FROM silver.territories t
JOIN silver.territory_identifiers ti ON ti.territory_id = t.id
WHERE t.type_code = 'comune' AND ti.scheme = 'istat' AND ti.identifier = '001001';
-- → id = 42 (esempio)
-- Comune AGLIÈ (territory_id=42) — attributi da tre fonti
INSERT INTO silver.territory_attributes
(territory_id, type_code, attribute, value, data_type, source, valid_from)
VALUES
(42, 'comune', 'popolazione', '2635', 'integer', 'ISTAT', '2024-01-01'),
(42, 'comune', 'superficie_kmq', '13.98', 'float', 'ISTAT', '2024-01-01'),
(42, 'comune', 'num_tabacchi', '2', 'integer', 'ADM', '2024-01-01'),
(42, 'comune', 'num_asl', '1', 'integer', 'MinSalute', '2023-01-01'),
(42, 'comune', 'ato_gas_id', 'ATO-PIE-01', 'string', 'MinAmbiente', '2024-01-01');
-- Provincia di Torino (territory_id=107) — dataset a livello provinciale
INSERT INTO silver.territory_attributes
(territory_id, type_code, attribute, value, data_type, source, valid_from)
VALUES
(107, 'provincia', 'popolazione', '2259523', 'integer', 'ISTAT', '2023-01-01'),
(107, 'provincia', 'pil_pro_capite', '32400', 'integer', 'ISTAT', '2022-01-01');
-- Sistema Locale del Lavoro (territory_id=8) — aggregazione funzionale
INSERT INTO silver.territory_attributes
(territory_id, type_code, attribute, value, data_type, source, valid_from)
VALUES
(8, 'sll', 'addetti_industria', '15200', 'integer', 'ISTAT', '2021-01-01');Ogni dataset acquisito nel Bronze layer viene trasformato in Silver attraverso una pipeline Prefect standard in quattro fasi: parsing del file sorgente nel formato originale (CSV, Excel, PDF, HTML), pulizia e normalizzazione dei valori, validazione con Great Expectations, e caricamento nello schema EAV. Questa struttura è uniforme per tutti i dataset, indipendentemente dalla fonte.
@task(name="bronze-to-silver-transform")
def transform_bronze_to_silver(
bronze_file_path: str,
entity_type: str, # 'comune', 'provincia', 'regione', 'sll', ... — usato per risolvere territory_id
fonte: str,
dataset: str,
anno: int
):
"""
Standard transformation pipeline: Bronze → Silver
Steps:
1. Parse: Read Bronze file (CSV/PDF/Excel/HTML)
2. Clean: Normalize encoding, trim whitespace, fix typos
3. Validate: Check data quality, enforce business rules
4. Load: Risolvi territory_id e inserisci in silver.territory_attributes
"""
# Step 1: Parse
if bronze_file_path.endswith('.csv'):
df = pd.read_csv(bronze_file_path)
elif bronze_file_path.endswith('.pdf'):
df = extract_pdf_tables(bronze_file_path) # Docling
elif bronze_file_path.endswith('.xlsx'):
df = pd.read_excel(bronze_file_path)
elif bronze_file_path.endswith('.html'):
df = parse_html_to_dataframe(bronze_file_path) # BeautifulSoup
# Step 2: Clean
df = clean_dataframe(df)
# Step 3: Validate with Great Expectations
validation_results = validate_dataframe(df, fonte, dataset)
if not validation_results.passed:
raise ValueError(f"Validation failed: {validation_results.errors}")
# Step 4: Load to Silver (EAV generalizzata)
load_to_territory_attributes(df, entity_type, fonte, anno)Nel periodo 2010-2025 si sono verificate circa 100 fusioni o separazioni comunali. Quando due comuni si fondono in uno nuovo, i dati storici degli enti preesistenti rimangono validi per il periodo precedente alla fusione: non vengono cancellati né sovrascritti, ma delimitati temporalmente tramite le colonne valid_from e valid_to. Il nuovo comune ottiene i propri record a partire dalla data di istituzione. Le query possono quindi interrogare lo stato del territorio a qualsiasi data storica con un semplice filtro temporale.
L'esempio seguente mostra una fusione avvenuta nel 2019: i comuni 001234 e 001235 confluiscono nel nuovo comune 001236. Nel modello a identità surrogata, i due insediamenti originari mantengono le loro righe in territories (ora con valid_to impostato); il nuovo comune ottiene una nuova riga. La relazione è registrata in territory_relationships.
-- territory_id=10 → comune 001234 (valid_to 2019-01-01)
-- territory_id=11 → comune 001235 (valid_to 2019-01-01)
-- territory_id=12 → comune 001236 (valid_from 2019-01-01, valid_to NULL)
-- Attributi prima della fusione (valid_to chiude alla data della fusione)
INSERT INTO silver.territory_attributes
(territory_id, type_code, attribute, value, source, valid_from, valid_to)
VALUES
(10, 'comune', 'popolazione', '1500', 'ISTAT', '2018-01-01', '2019-01-01'),
(11, 'comune', 'popolazione', '800', 'ISTAT', '2018-01-01', '2019-01-01');
-- Attributi dopo la fusione
INSERT INTO silver.territory_attributes
(territory_id, type_code, attribute, value, source, valid_from, valid_to)
VALUES
(12, 'comune', 'popolazione', '2300', 'ISTAT', '2019-01-01', NULL);
-- Query: Popolazione dell'insediamento con codice ISTAT 001234 nel 2018
SELECT ta.value FROM silver.territory_attributes ta
JOIN silver.territory_identifiers ti ON ti.territory_id = ta.territory_id
WHERE ti.scheme = 'istat' AND ti.identifier = '001234'
AND ta.attribute = 'popolazione'
AND '2018-12-31' BETWEEN ta.valid_from AND COALESCE(ta.valid_to, '9999-12-31');
-- Risultato: 1500Ruolo: Dati ottimizzati per applicazioni specifiche — aggregati, denormalizzati, arricchiti con geometrie spaziali. È il layer che alimenta direttamente le dashboard, le API e gli algoritmi SLO.
Principio chiave: "Ottimizza per le query, non per lo spazio"
| Aspetto | Specifica MAPS |
|---|---|
| Storage | PostgreSQL schema gold con estensioni PostGIS |
| Formato | Tabelle denormalizzate (wide table) con geometrie spaziali |
| Convenzione di nomenclatura | Schema gold, tabelle tematiche per dominio (comuni, attrattori, serie storiche) |
| Politica di conservazione | Snapshot aggiornati periodicamente (giornaliero o settimanale); ricostruibili dal Silver layer |
| Backup | Snapshot giornalieri (ma ricostruibili dal Silver layer) |
| Modalità di accesso | Sola lettura ad alta frequenza (query aggregate, accesso puntuale via API, scansioni complete per gli algoritmi SLO) |
Un data mart è una vista tematica e ottimizzata dei dati, progettata per rispondere a un insieme specifico di domande analitiche. Nel Gold layer, ogni data mart è una tabella denormalizzata che aggrega in un'unica struttura informazioni provenienti da più fonti Silver, eliminando la necessità di join complessi al momento della query. Questo approccio sacrifica lo spazio di archiviazione — i dati vengono duplicati rispetto al Silver — a favore della velocità di interrogazione e della semplicità d'uso per chi sviluppa applicazioni o algoritmi.
Il Gold layer di MAPS definisce tre data mart principali, ciascuno orientato a un dominio di analisi distinto.
A. gold.comuni_aggregati
È la tabella centrale del Gold layer: una rappresentazione denormalizzata di ciascun comune italiano, con tutti gli attributi rilevanti raccolti in un'unica riga. Ogni record integra dati demografici da ISTAT, conteggi di servizi dai Ministeri, informazioni geografiche con geometria PostGIS, e gli attributi calcolati dall'algoritmo SLO (livello di attrattività, isocrona a 60 minuti). Questa struttura consente query analitiche complesse in tempi inferiori ai 10 ms, senza alcun join tra tabelle.
CREATE TABLE gold.comuni_aggregati (
-- Identificativi
codice_istat VARCHAR(6) PRIMARY KEY,
denominazione VARCHAR(255) NOT NULL,
denominazione_full VARCHAR(255), -- Con sigla provincia
-- Gerarchia amministrativa
codice_regione CHAR(2) NOT NULL,
denominazione_regione VARCHAR(100) NOT NULL,
codice_provincia CHAR(3),
denominazione_provincia VARCHAR(100),
sigla_provincia CHAR(2),
-- Attributi demografici (da ISTAT)
popolazione_2024 INTEGER,
popolazione_2023 INTEGER,
popolazione_2022 INTEGER,
crescita_popolazione_pct NUMERIC(5,2), -- Calculated: (2024-2023)/2023*100
-- Attributi geografici
superficie_kmq NUMERIC(10,2),
densita_abitanti_kmq NUMERIC(10,2), -- Calculated: pop/superficie
altitudine_m INTEGER,
zona_altimetrica VARCHAR(50), -- 'montagna', 'collina', 'pianura'
-- Geometria PostGIS
geometria GEOMETRY(MultiPolygon, 4326) NOT NULL,
centroide GEOMETRY(Point, 4326),
-- Servizi (da Ministeri)
num_strutture_sanitarie INTEGER DEFAULT 0,
num_asl INTEGER DEFAULT 0,
num_scuole_primarie INTEGER DEFAULT 0,
num_scuole_secondarie INTEGER DEFAULT 0,
num_tabacchi INTEGER DEFAULT 0,
num_uffici_postali INTEGER DEFAULT 0,
-- Infrastrutture (da OpenData)
ha_stazione_ferroviaria BOOLEAN DEFAULT FALSE,
ha_casello_autostradale BOOLEAN DEFAULT FALSE,
ha_aeroporto BOOLEAN DEFAULT FALSE,
-- Ambiti territoriali (da registri pubblici)
ato_gas_id VARCHAR(50),
ato_gas_denominazione VARCHAR(255),
ato_gas_gestore VARCHAR(255),
ato_acqua_id VARCHAR(50),
ato_rifiuti_id VARCHAR(50),
-- Attributi DLS (Calculated)
attractor_level VARCHAR(50), -- 'metropolitan', 'urban', 'semi-urban', 'rural'
cluster_dls_id INTEGER,
isochrone_60min GEOMETRY(MultiPolygon, 4326),
-- Metadata
last_updated TIMESTAMP NOT NULL DEFAULT NOW(),
data_completeness_pct NUMERIC(5,2), -- % attributi popolati
-- Constraints
CONSTRAINT valid_codice CHECK (codice_istat ~ '^\d{6}$')
);
-- Spatial indexes
CREATE INDEX idx_gold_comuni_geometria ON gold.comuni_aggregati USING GIST(geometria);
CREATE INDEX idx_gold_comuni_centroide ON gold.comuni_aggregati USING GIST(centroide);
CREATE INDEX idx_gold_comuni_isochrone ON gold.comuni_aggregati USING GIST(isochrone_60min);
-- Attribute indexes
CREATE INDEX idx_gold_comuni_regione ON gold.comuni_aggregati(codice_regione);
CREATE INDEX idx_gold_comuni_provincia ON gold.comuni_aggregati(codice_provincia);
CREATE INDEX idx_gold_comuni_attractor ON gold.comuni_aggregati(attractor_level);L'esempio di query seguente mostra come la struttura denormalizzata permetta di filtrare per ambito territoriale del gas e soglia di popolazione con una singola istruzione, senza join, e con un tempo di esecuzione stimato inferiore ai 10 ms.
-- Query: Comuni in ATO Gas "ATO-PIE-01" con popolazione > 5000
SELECT
denominazione,
popolazione_2024,
ato_gas_gestore,
num_tabacchi,
attractor_level
FROM gold.comuni_aggregati
WHERE ato_gas_id = 'ATO-PIE-01'
AND popolazione_2024 > 5000
ORDER BY popolazione_2024 DESC;
-- Execution: Index scan su ato_gas_id, no joins, < 10msB. gold.dls_attractors
Contiene i risultati dell'analisi degli attrattori territoriali prodotta dall'algoritmo SLO. Ogni riga rappresenta un comune classificato secondo il suo livello di attrattività (metropolitano, urbano, semi-urbano, rurale), con i punteggi parziali per categoria di servizio, la geometria dell'isocrona a 60 minuti e l'elenco dei comuni raggiungibili. Questa tabella è il principale output del Work Package 3 e alimenta direttamente le visualizzazioni cartografiche e il simulatore di politiche territoriali.
CREATE TABLE gold.dls_attractors (
codice_istat VARCHAR(6) PRIMARY KEY REFERENCES gold.comuni_aggregati(codice_istat),
denominazione VARCHAR(255) NOT NULL,
-- Attractor classification
attractor_level VARCHAR(50) NOT NULL, -- 'metropolitan', 'urban', 'semi-urban', 'rural'
attractor_score NUMERIC(5,2), -- 0-100 score
-- Service availability (weighted scores)
servizi_sanitari_score NUMERIC(5,2),
servizi_educativi_score NUMERIC(5,2),
servizi_commerciali_score NUMERIC(5,2),
servizi_trasporti_score NUMERIC(5,2),
-- Isochrone analysis (60 min travel time)
isochrone_60min GEOMETRY(MultiPolygon, 4326),
comuni_raggiungibili_60min INTEGER[], -- Array codici ISTAT
popolazione_raggiungibile_60min INTEGER,
-- Clustering
cluster_id INTEGER NOT NULL,
cluster_centroid GEOMETRY(Point, 4326),
-- Metadata
calculation_date TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT valid_attractor_level CHECK (attractor_level IN ('metropolitan', 'urban', 'semi-urban', 'rural'))
);C. Data mart aggiuntivi
I due data mart descritti sopra coprono i requisiti analitici identificati nella fase di progettazione. Il Gold layer è tuttavia progettato per accogliere ulteriori tabelle tematiche al crescere delle necessità del progetto. Nuovi data mart possono essere aggiunti senza modificare la struttura Silver, semplicemente definendo una nuova funzione di refresh che legge dal layer EAV e materializza i dati nella forma richiesta. Esempi possibili includono viste per l'analisi dei flussi di mobilità, tabelle di supporto per il simulatore di politiche territoriali (WP5), o aggregazioni tematiche per specifici indicatori di svantaggio territoriale.
I data mart Gold sono la principale interfaccia di accesso ai dati del progetto, ma con modalità distinte a seconda del tipo di utilizzo. Le API REST e gli algoritmi SLO interrogano le tabelle Gold direttamente via PostgreSQL, sfruttando gli indici spaziali e la bassa latenza delle query puntuali. Per l'analisi esplorativa — notebook Jupyter, script Python dei ricercatori, verifica di ipotesi — si utilizza invece DuckDB in modalità embedded.
DuckDB legge le tabelle Gold di PostgreSQL tramite federation diretta, senza necessità di copiare i dati, con una sintassi SQL identica a quella standard:
import duckdb
con = duckdb.connect()
con.execute("INSTALL postgres; LOAD postgres;")
con.execute("""
ATTACH 'dbname=maps_db host=localhost user=maps password=...'
AS maps (TYPE postgres, READ_ONLY);
""")
# Query sul Gold layer: comuni con alta attrattività in area montana
result = con.execute("""
SELECT denominazione, attractor_score, popolazione_2024, zona_altimetrica
FROM maps.gold.dls_attractors a
JOIN maps.gold.comuni_aggregati c USING (codice_istat)
WHERE a.attractor_level = 'urban'
AND c.zona_altimetrica = 'montagna'
ORDER BY a.attractor_score DESC
LIMIT 20
""").df()Quando i dati devono essere distribuiti a ricercatori esterni o archiviati per analisi offline, il Gold layer può essere esportato in formato Parquet — un formato colonnare compresso che DuckDB legge direttamente, senza database, con prestazioni equivalenti a quelle in-memory.
Il popolamento del Gold layer avviene attraverso una funzione SQL schedulata da Prefect con cadenza giornaliera. La funzione esegue un ciclo completo di ricreazione della tabella: prima svuota il contenuto esistente con TRUNCATE, poi lo ricostruisce con una query di pivot che trasforma le righe EAV del Silver layer in colonne della tabella denormalizzata Gold. Al termine, un comando ANALYZE aggiorna le statistiche del query optimizer.
-- Refresh Gold tables da Silver (scheduled daily)
CREATE OR REPLACE FUNCTION gold.refresh_comuni_aggregati()
RETURNS void AS $$
BEGIN
-- Truncate e rebuild (snapshot approach)
TRUNCATE gold.comuni_aggregati;
-- Pivot EAV → Wide table
INSERT INTO gold.comuni_aggregati
SELECT
c.codice_istat,
c.denominazione,
c.geometria,
ST_Centroid(c.geometria) AS centroide,
-- Pivot attributi da Silver
MAX(CASE WHEN e.attribute = 'popolazione_2024' THEN e.value::INTEGER END) AS popolazione_2024,
MAX(CASE WHEN e.attribute = 'popolazione_2023' THEN e.value::INTEGER END) AS popolazione_2023,
MAX(CASE WHEN e.attribute = 'superficie_kmq' THEN e.value::NUMERIC END) AS superficie_kmq,
-- Campi calcolati
(MAX(CASE WHEN e.attribute = 'popolazione_2024' THEN e.value::NUMERIC END) -
MAX(CASE WHEN e.attribute = 'popolazione_2023' THEN e.value::NUMERIC END)) /
NULLIF(MAX(CASE WHEN e.attribute = 'popolazione_2023' THEN e.value::NUMERIC END), 0) * 100
AS crescita_popolazione_pct,
-- Ambiti territoriali
MAX(CASE WHEN e.attribute = 'ato_gas_id' THEN e.value END) AS ato_gas_id,
MAX(CASE WHEN e.attribute = 'ato_gas_gestore' THEN e.value END) AS ato_gas_gestore,
NOW() AS last_updated
FROM
gold.comuni_anagrafica c
LEFT JOIN silver.territory_identifiers ti ON ti.scheme = 'istat' AND ti.identifier = c.codice_istat
LEFT JOIN silver.territory_attributes e ON e.territory_id = ti.territory_id
AND e.valid_to IS NULL -- Solo dati correnti
GROUP BY
c.codice_istat, c.denominazione, c.geometria;
-- Analyze per query optimizer
ANALYZE gold.comuni_aggregati;
END;
$$ LANGUAGE plpgsql;
-- Schedule refresh (chiamato da Prefect flow daily)
SELECT gold.refresh_comuni_aggregati();L'approccio con funzione SQL schedulata da Prefect è la scelta adottata per il progetto MAPS, in quanto mantiene la logica di trasformazione vicina al database e sfrutta le capacità spaziali di PostGIS. Esistono tuttavia approcci alternativi ugualmente validi.
Con dbt (data build tool) le trasformazioni Silver → Gold si esprimono come modelli SQL versionati, con dependency graph automatico, test integrati e documentazione generata; è la scelta preferibile quando il numero di modelli Gold cresce e la manutenibilità diventa prioritaria.
Con Prefect puro le trasformazioni avvengono in Python tramite task dedicati, eliminando la dipendenza da funzioni SQL nel database; è preferibile quando le trasformazioni richiedono logica complessa non esprimibile in SQL o quando si vuole un unico punto di controllo per l'intera pipeline.
Il progetto MAPS acquisisce oltre 180 dataset da fonti eterogenee — ISTAT, Ministeri, registri pubblici, dati OpenData — ciascuno con attributi propri, copertura temporale variabile e disponibilità non uniforme tra comuni. Questa eterogeneità pone un problema di progettazione del Silver layer: come strutturare uno schema che possa accogliere attributi nuovi senza richiedere modifiche alla base dati, che registri la provenienza di ogni valore e che gestisca le variazioni nel tempo?
L'approccio tradizionale, basato su una tabella "wide" con una colonna per ogni attributo, risulta inadeguato in questo contesto. La tabella avrebbe centinaia di colonne, la maggior parte delle quali vuote per qualsiasi comune specifico, e richiederebbe una migrazione dello schema ogni volta che si aggiunge un nuovo dataset. La tracciabilità temporale sarebbe assente o richiederebbe strutture parallele.
| codice_istat | popolazione | n_asili | has_ospedale | n_scuole | ato_gas_id | ... |
|---|---|---|---|---|---|---|
| 001001 | 45230 | 12 | true | ? | ? | ... |
| 001002 | 8420 | 2 | false | ? | ? | ... |
Il modello EAV (Entity-Attribute-Value) risolve questi problemi invertendo la struttura: invece di avere una colonna per attributo, ogni attributo diventa una riga. Lo schema rimane fisso — tre colonne fondamentali più i metadati — mentre il contenuto cresce verticalmente all'aggiunta di nuovi dataset, senza alcuna modifica strutturale.
| entity | attribute | value | valid_from | source |
|---|---|---|---|---|
| 001001 | popolazione | 45230 | 2021-01-01 | ISTAT |
| 001001 | n_asili_nido | 12 | 2021-01-01 | ISTAT |
| 001001 | has_ospedale | true | 2015-01-01 | MinSalute |
| 001002 | popolazione | 8420 | 2021-01-01 | ISTAT |
| 001002 | cod_pre_fusione | 001045 | 2017-01-01 | ISTAT |
La colonna source garantisce la tracciabilità della provenienza per ogni singolo valore; le colonne valid_from e valid_to consentono di gestire serie storiche e variazioni comunali senza strutture parallele. Lo storage è efficiente perché vengono registrati solo gli attributi effettivamente disponibili, non i valori mancanti.
Il compromesso principale è la complessità delle query: per leggere un insieme di attributi relativi a un comune, è necessario un'operazione di pivot (CASE WHEN) che trasforma le righe EAV in colonne. Questa complessità è però confinata al layer di trasformazione Silver → Gold, invisibile a chi utilizza i data mart. Le prestazioni di lettura, potenzialmente penalizzate dal maggior numero di righe, sono mitigate da indici appropriati su codice_istat e attributo. I valori sono archiviati come testo e vengono convertiti al tipo corretto durante il popolamento del Gold layer.
Lo schema EAV nel Silver layer rappresenta il compromesso ottimale per MAPS: massima flessibilità in acquisizione, governabilità nel tempo, e nessun accoppiamento strutturale tra l'arrivo di nuovi dati e lo schema del database.
La scelta dello stack tecnologico per MAPS è guidata da tre criteri: adeguatezza alla scala dei dati trattati, indipendenza da fornitori commerciali, e maturità degli strumenti. Il progetto lavora con circa 180 dataset a granularità comunale — un volume nell'ordine delle decine di gigabyte, non dei petabyte — che non richiede infrastrutture cloud di grandi dimensioni ma beneficia di strumenti specializzati per dati spaziali e analisi territoriale.
L'intero stack è composto da software open source, ciascuno scelto per un ruolo preciso e non sovrapponibile agli altri. PostgreSQL con l'estensione PostGIS è il nucleo del sistema: gestisce sia lo schema EAV del Silver layer sia le tabelle denormalizzate del Gold layer, con supporto nativo per operazioni geografiche come il calcolo delle isocrone. Prefect orchestra l'esecuzione delle pipeline, dalla raccolta dei dati Bronze fino al popolamento del Gold layer, con monitoraggio e gestione degli errori. OpenMetadata cataloga internamente tutti i dataset, traccandone la lineage e la qualità; CKAN espone verso l'esterno i dataset approvati per la pubblicazione open data. Great Expectations presidia la qualità dei dati nel passaggio Bronze → Silver, mentre DuckDB offre ai ricercatori un'interfaccia analitica leggera e portatile sul Gold layer.
| Componente | Tecnologia | Versione | Ruolo |
|---|---|---|---|
| Storage primario | PostgreSQL + PostGIS | 17 + 3.5 | Master data, operazioni spaziali |
| Orchestrazione | Prefect | 3.x | Scheduling, monitoring, lineage ETL |
| Analytics layer | DuckDB | 1.x | Query analytics (federation da PostgreSQL) |
| Governance interna | OpenMetadata | 1.x | Catalog interno, lineage, data quality, profiling |
| Catalogo open data | CKAN | 2.11 | Catalogo pubblico open data, DCAT-AP_IT |
| Data quality | Great Expectations | 1.x | Validation, anomaly detection |
| PDF extraction | Docling | Latest | Estrazione tabelle da PDF (97.9% accuracy) |
| Object storage | MinIO (o GCS) | Latest | Raw files, PDFs, archivi Excel |
graph TB
subgraph "Data Sources"
A1[ISTAT CSV]
A2[Min PDF]
A3[HTML Tables]
end
subgraph "Orchestration"
B[Prefect
Worker Pools]
end
subgraph "Storage Layers"
C[Bronze
File System]
D[Silver
PostgreSQL EAV]
E[Gold
PostgreSQL + PostGIS]
end
subgraph "Internal Governance & Analytics"
F[OpenMetadata
Internal Catalog]
G[Great Expectations
Quality]
H[DuckDB
Analytics]
end
subgraph "Public Access"
I[CKAN
Open Data Catalog]
J[dati.gov.it]
end
A1 --> B
A2 --> B
A3 --> B
B --> C
C --> D
D --> E
D --> G
B -.metadata.-> F
E --> F
E --> H
E --> I
I --> J
Le motivazioni dettagliate delle scelte tecnologiche — confronti quantitativi tra le alternative considerate per ciascun componente, analisi dei costi e valutazione dei vincoli — sono documentate nell'Appendice A.
I servizi della piattaforma MAPS sono distribuiti come container orchestrati su un cluster Kubernetes managed (DigitalOcean DOKS). Il database PostgreSQL + PostGIS è gestito come servizio managed dallo stesso provider, eliminando l'overhead operativo di backup, failover e patching. Lo storage persistente per il Bronze layer è fornito da volumi a blocchi. Il traffico HTTPS è terminato da un Load Balancer con certificati Let's Encrypt, distribuito verso i servizi esposti tramite Ingress Controller.
Il solution design completo — motivazioni della scelta infrastrutturale, dimensionamento, topologia dei servizi, sicurezza e stima dei costi — è documentato nel deliverable D2.1.2.
La governance dei dati è una componente strutturale dell'architettura MAPS, non un'aggiunta successiva. Un progetto che integra oltre 200 dataset da fonti pubbliche eterogenee, li trasforma attraverso tre layer progressivi e li pubblica come open data a fini di ricerca scientifica, ha bisogno di sapere in ogni momento da dove proviene ciascun dato, quali trasformazioni ha subito, e con quale livello di qualità è arrivato a destinazione. Senza questa tracciabilità, i risultati analitici del WP3 — la mappa dei Sistemi di Vita Quotidiana, la classificazione degli attrattori — non sarebbero verificabili né riproducibili.
La governance si articola in tre dimensioni complementari: il tracciamento della lineage (da dove viene il dato e come è stato trasformato), la misurazione della qualità (quanto il dato è completo, coerente e affidabile), e la catalogazione (quali dataset esistono, chi ne è responsabile, come sono strutturati).
La lineage descrive il percorso di ciascun dato dalla fonte esterna fino all'utilizzo finale. Nel contesto dell'architettura Medallion, questo percorso attraversa almeno tre passaggi: l'acquisizione nel Bronze layer, la normalizzazione nel Silver layer, e l'aggregazione nel Gold layer. A ciascun passaggio possono corrispondere trasformazioni significative — parsing di un PDF, riconciliazione dei codici ISTAT per fusioni comunali, calcolo di aggregazioni spaziali — e la lineage deve documentarle tutte.
OpenMetadata è lo strumento scelto per il tracciamento della lineage. Si integra nativamente con PostgreSQL, da cui legge automaticamente la struttura degli schemi (tabelle, colonne, tipi), e con Prefect, da cui riceve i metadati di esecuzione delle pipeline (flow run, task run, stato, durata, errori). Questa doppia integrazione consente di ricostruire la catena completa: quale flow ha prodotto quale tabella, partendo da quali dati sorgente, in quale esecuzione, con quale esito.
La lineage si articola su tre livelli di granularità. Il livello di dataset traccia le dipendenze tra tabelle: la tabella Gold comuni_aggregati dipende dalla tabella Silver territory_attributes, che a sua volta dipende dai file Bronze di ciascuna fonte. Il livello di colonna traccia la provenienza dei singoli attributi: il campo popolazione_residente nel Gold layer deriva dall'attributo omonimo nel Silver layer, originato dal file CSV ISTAT della popolazione. Il livello di pipeline traccia le esecuzioni: quale run di Prefect ha aggiornato una data tabella, quando, con quale durata e con quale esito.
graph TB
subgraph "Fonti Esterne"
S1[ISTAT CSV
Popolazione]
S2[MinAmbiente HTML
ATO Gas]
S3[MinSalute PDF
ASL]
end
subgraph "Bronze Layer"
B1[popolazione_2024.csv]
B2[ato_gas_page.html]
B3[elenco_asl.pdf]
end
subgraph "Silver Layer"
SV[territory_attributes
Schema EAV generalizzato]
end
subgraph "Gold Layer"
G1[comuni_aggregati]
G2[dls_attractors]
G3[isochrone_maps]
end
subgraph "Applicazioni"
A1[CKAN Open Data]
A2[API Web]
A3[DuckDB Analytics]
end
S1 -->|"Prefect: download"| B1
S2 -->|"Prefect: download"| B2
S3 -->|"Prefect: download"| B3
B1 -->|"Prefect: parse CSV"| SV
B2 -->|"Prefect: parse HTML"| SV
B3 -->|"Prefect: Docling extract"| SV
SV -->|"SQL: aggregate"| G1
SV -->|"PostGIS: spatial"| G2
SV -->|"PostGIS: isochrone"| G3
G1 --> A1
G1 --> A2
G1 --> A3
G2 --> A2
G3 --> A2
style B1 fill:#cd7f32,color:#000
style B2 fill:#cd7f32,color:#000
style B3 fill:#cd7f32,color:#000
style SV fill:#c0c0c0,color:#000
style G1 fill:#ffd700,color:#000
style G2 fill:#ffd700,color:#000
style G3 fill:#ffd700,color:#000
Il valore pratico della lineage emerge in diversi scenari operativi. Quando una fonte esterna pubblica un aggiornamento, la lineage indica immediatamente quali tabelle Silver e Gold devono essere ricalcolate, evitando riesecuzioni inutili o, peggio, dimenticanze. Quando un'analisi produce risultati inattesi, la lineage consente di risalire la catena e verificare se la causa è un dato sorgente anomalo, un errore di trasformazione, o un cambiamento nella struttura della fonte. Quando un dataset viene pubblicato su CKAN, la lineage fornisce la documentazione metodologica richiesta dagli standard DCAT-AP_IT: provenienza, trasformazioni applicate, data di ultimo aggiornamento.
La qualità dei dati è verificata in modo sistematico a ogni passaggio della pipeline, con regole di validazione definite tramite Great Expectations. Le validazioni non sono controlli generici applicati uniformemente a tutti i dataset: ciascuna fonte ha un proprio profilo di qualità atteso, con soglie e regole calibrate sulla natura specifica dei dati.
Nel passaggio Bronze → Silver, le validazioni presidiano l'integrità strutturale. Per i dataset a granularità comunale, il controllo fondamentale è la copertura: il numero di comuni presenti nel file deve essere coerente con l'universo di riferimento (7.896 comuni al 2024, con variazioni annuali dovute a fusioni e scissioni). Vengono inoltre verificati la validità dei codici ISTAT (formato, lunghezza, appartenenza all'anagrafe comunale di riferimento), l'assenza di duplicati, la coerenza dei tipi di dato (valori numerici nei campi numerici, date in formato valido) e la completezza (percentuale di valori nulli per ciascun attributo).
Nel passaggio Silver → Gold, le validazioni si concentrano sulla coerenza semantica. Le aggregazioni territoriali devono produrre totali coerenti con i dati di partenza (ad esempio, la somma della popolazione dei comuni di una provincia deve corrispondere al dato provinciale). Le operazioni spaziali PostGIS devono generare geometrie valide (nessun self-intersection, copertura territoriale completa). Le serie storiche devono mantenere continuità: un comune che esisteva nel 2023 deve avere un successore nel 2024, o essere documentato come soppresso.
I risultati delle validazioni sono registrati in OpenMetadata come metriche di qualità associate a ciascuna tabella. Le metriche principali sono cinque: completezza (percentuale di valori non nulli), unicità (assenza di record duplicati), validità (conformità dei valori al dominio atteso), coerenza (compatibilità tra attributi correlati), e tempestività (distanza tra la data di pubblicazione della fonte e la data di acquisizione). Queste metriche sono consultabili nel catalogo interno e consentono di valutare a colpo d'occhio lo stato di salute di ciascun dataset.
Quando una validazione fallisce — ad esempio, un file CSV contiene solo 5.000 comuni anziché i 7.896 attesi — la pipeline si interrompe e il dato non viene propagato al layer successivo. L'errore viene registrato nel log di ingestion, segnalato nell'interfaccia di Prefect e associato alla tabella corrispondente in OpenMetadata. Il dato Bronze rimane disponibile per l'analisi del problema, ma il Silver layer è protetto da ingestioni parziali o corrotte.
OpenMetadata funge da catalogo interno dell'intera piattaforma dati. Ogni tabella, in ciascuno dei tre layer, è registrata con i suoi metadati: struttura (colonne, tipi, vincoli), provenienza (fonte, pipeline che l'ha generata), qualità (metriche dell'ultima validazione), responsabilità (owner del dataset), e documentazione (descrizione testuale, note metodologiche).
Il catalogo si popola in parte automaticamente — OpenMetadata legge la struttura delle tabelle PostgreSQL e i metadati delle esecuzioni Prefect — e in parte manualmente, per le informazioni che richiedono conoscenza del dominio: la descrizione di un attributo, la metodologia di calcolo di un indicatore, le avvertenze sull'uso di un dataset. Questa combinazione di automazione e curatela consente di mantenere il catalogo aggiornato senza un onere di documentazione eccessivo.
Il catalogo risponde a tre esigenze operative:
Le pipeline di ingestion del progetto MAPS seguono tre principi architetturali che garantiscono affidabilità e manutenibilità nel tempo: idempotenza delle operazioni, caricamento incrementale dei dati, e gestione controllata dell'evoluzione degli schemi.
Ogni pipeline deve poter essere rieseguita in qualsiasi momento senza generare duplicati né corrompere i dati esistenti. Questo requisito è essenziale in un contesto dove le fonti dati sono eterogenee e i fallimenti parziali sono frequenti: un download interrotto, un file PDF malformato, un timeout di rete possono interrompere l'esecuzione a metà, e la pipeline deve poter ripartire dall'inizio senza effetti collaterali.
L'idempotenza è implementata a livello di database attraverso operazioni di upsert: quando un record viene inserito nel Silver layer, la chiave composta (codice ISTAT, attributo, fonte, dataset, anno di riferimento) determina se si tratta di un nuovo dato o di un aggiornamento. Se il record esiste già, il valore viene sovrascritto e il timestamp di aggiornamento viene registrato; se non esiste, viene creato. Il risultato è identico sia alla prima esecuzione sia a ogni esecuzione successiva: lo stato finale del database dipende esclusivamente dai dati sorgente, non dalla storia delle esecuzioni.
Nel Bronze layer l'idempotenza è garantita dalla struttura stessa del layer: i file scaricati sovrascrivono eventuali versioni precedenti nella stessa posizione del file system, identificata dalla combinazione fonte/dataset/anno. L'hash SHA-256 del file consente di verificare se il contenuto è effettivamente cambiato rispetto alla versione precedente, evitando trasformazioni inutili nel caso in cui la fonte non abbia pubblicato aggiornamenti.
La strategia di caricamento differisce tra Silver layer e Gold layer, riflettendo le diverse esigenze dei due strati.
Il Silver layer adotta un approccio incrementale con versionamento temporale. Quando un dataset viene aggiornato, la pipeline confronta i nuovi dati con quelli già presenti: i record nuovi vengono inseriti, quelli modificati vengono aggiornati con chiusura della versione precedente (campo valid_to) e apertura di una nuova versione (campo valid_from), quelli invariati non vengono toccati. Questa strategia minimizza il volume delle scritture e preserva la storia delle modifiche, rendendo possibile interrogare lo stato dei dati a una qualsiasi data passata.
Il Gold layer adotta invece un approccio a snapshot: le tabelle denormalizzate vengono ricostruite integralmente a partire dai dati Silver ogni volta che un aggiornamento lo richiede. La ricostruzione completa è preferibile all'aggiornamento incrementale per due ragioni. La prima è la natura aggregata dei dati Gold: una modifica a un singolo record Silver può propagarsi su più tabelle Gold e su più righe di ciascuna tabella, rendendo l'aggiornamento puntuale complesso e fragile. La seconda è il volume contenuto: con circa 8.000 comuni e poche centinaia di attributi per tabella, la ricostruzione completa richiede pochi minuti e non giustifica la complessità aggiuntiva di un meccanismo incrementale.
L'architettura deve gestire due tipi di evoluzione nel tempo: l'aggiunta di nuove fonti dati e la modifica della struttura dei dati esistenti.
L'aggiunta di nuove fonti è il caso più frequente e più semplice. Lo schema EAV del Silver layer è progettato per accogliere nuovi attributi senza modifiche strutturali: un nuovo dataset si traduce in nuove righe nella tabella territory_attributes con un nuovo valore nel campo attribute, senza bisogno di aggiungere colonne o alterare tabelle. Nel Gold layer, l'aggiunta di un nuovo attributo richiede una nuova colonna nella tabella denormalizzata corrispondente, ma questa operazione è non-breaking: la colonna viene aggiunta con un valore predefinito e popolata dalla pipeline, senza impatto sulle query esistenti.
La modifica della struttura dei dati esistenti è meno frequente ma più delicata. Può accadere che una fonte ISTAT modifichi il formato di un file, aggiunga o rimuova colonne, o cambi la codifica dei valori tra un rilascio e il successivo. In questi casi, la pipeline di trasformazione Bronze → Silver deve essere aggiornata per gestire il nuovo formato, mantenendo la compatibilità con le versioni precedenti ancora presenti nel Bronze layer. Il versionamento temporale del Silver layer garantisce che i dati già acquisiti con il vecchio formato restino validi e interrogabili, mentre i nuovi dati seguono il formato aggiornato.
OpenMetadata registra ogni modifica di schema come evento di lineage, consentendo di tracciare quando una tabella è stata alterata, da quale pipeline, e per quale motivo. Questo tracciamento è particolarmente utile nelle fasi di debug: quando un'analisi produce risultati diversi da quelli attesi, la storia delle modifiche di schema consente di verificare se la causa è un cambiamento nella struttura dei dati piuttosto che nei dati stessi.
Un'architettura a tre layer con oltre 200 dataset e pipeline di trasformazione concatenate richiede un sistema di monitoraggio che consenta di rispondere in ogni momento a tre domande: i dati stanno entrando correttamente?, le trasformazioni stanno funzionando?, e i dati prodotti sono utilizzabili?
Il monitoraggio si organizza lungo i tre layer dell'architettura Medallion, con indicatori specifici per ciascuno.
Per il Bronze layer, l'indicatore principale è il tasso di successo delle acquisizioni. Ogni esecuzione di una pipeline di download viene registrata nella tabella bronze.ingestion_log con l'esito (successo, fallimento, parziale), la dimensione del file scaricato e l'hash del contenuto. Il monitoraggio verifica che le acquisizioni pianificate vengano effettivamente eseguite e che i file scaricati abbiano dimensioni coerenti con le aspettative: un file CSV della popolazione ISTAT che pesa pochi kilobyte anziché i megabyte attesi segnala un problema alla fonte o nel download. La crescita dello storage Bronze è contenuta — nell'ordine di pochi gigabyte all'anno, data la frequenza di aggiornamento semestrale o annuale delle fonti — e viene monitorata per garantire che i volumi persistenti abbiano capacità sufficiente.
Per il Silver layer, gli indicatori chiave sono la latenza di ingestion e la qualità dei dati. La latenza misura il tempo che intercorre tra il completamento del download Bronze e la disponibilità del dato normalizzato nel Silver layer: per la maggior parte dei dataset (CSV, Excel) questo tempo è nell'ordine dei minuti; per i PDF complessi che richiedono estrazione con Docling può arrivare a qualche decina di minuti. Le metriche di qualità — completezza, unicità, validità — sono quelle prodotte dalle validazioni Great Expectations e registrate in OpenMetadata, come descritto nella sezione precedente. Un degrado di questi indicatori nel tempo segnala un cambiamento nella struttura o nella qualità della fonte, che richiede intervento sulla pipeline di trasformazione.
Per il Gold layer, il monitoraggio si concentra sulle prestazioni delle query e sulla coerenza delle aggregazioni. I tempi di risposta delle query analitiche sono tracciati tramite l'estensione PostgreSQL pg_stat_statements, con l'obiettivo di mantenere il 95esimo percentile sotto i 100 millisecondi per le interrogazioni più comuni. La ricostruzione delle tabelle Gold a partire dal Silver layer deve completarsi in tempi ragionevoli — nell'ordine dei minuti per il volume attuale del progetto — e il monitoraggio segnala eventuali rallentamenti che possono indicare una crescita dei dati oltre le soglie previste o query di aggregazione da ottimizzare.
Prefect fornisce nativamente il monitoraggio dell'esecuzione delle pipeline, con visibilità sullo stato di ciascun flow run (in esecuzione, completato, fallito), sui tempi di esecuzione di ciascun task, e sui log dettagliati in caso di errore. L'interfaccia web di Prefect consente di consultare la storia delle esecuzioni, identificare i task falliti e rieseguire le pipeline con un singolo intervento.
Il monitoraggio delle pipeline si integra con la lineage tracciata in OpenMetadata: quando un flow fallisce, è possibile risalire immediatamente a quali tabelle Silver e Gold non sono state aggiornate, e quindi quali dati a valle potrebbero essere obsoleti. Questa correlazione tra stato delle pipeline e stato dei dati è particolarmente utile quando più pipeline condividono tabelle di destinazione: il fallimento di una singola pipeline non compromette l'intero layer, ma il monitoraggio deve segnalare quali porzioni dei dati sono rimaste ferme all'ultimo aggiornamento riuscito.
Al livello infrastrutturale, il monitoraggio del cluster Kubernetes e del database PostgreSQL copre le metriche classiche di utilizzo risorse: CPU, memoria, I/O disco, spazio di archiviazione. DigitalOcean fornisce metriche di base per i nodi del cluster DOKS e per il database managed; per un monitoraggio più dettagliato, lo stack Prometheus + Grafana può essere distribuito nel cluster come servizio aggiuntivo.
Le soglie di attenzione sono calibrate sui profili di carico descritti nella sezione di dimensionamento: un utilizzo CPU sostenuto oltre l'80% sui nodi worker durante le finestre di esecuzione ETL è normale; lo stesso livello in assenza di pipeline attive indica un problema. L'occupazione disco del database viene monitorata con soglia al 70% della capacità allocata, lasciando margine per la crescita e per le operazioni di manutenzione PostgreSQL (vacuum, reindex) che richiedono spazio temporaneo.