Pipeline Emissioni Scope 3 Value Chain: Dal Dato Grezzo all'Audit Trail
Il 70-90% delle emissioni totali di un'azienda tech o software si nasconde fuori dai propri confini: nei server cloud acquistati come servizio, nei laptop dei dipendenti, nei voli di lavoro, nel codice che i clienti eseguono sui loro dispositivi. Queste sono le emissioni Scope 3, e per la maggior parte delle organizzazioni digitali rappresentano il problema di misurazione più complesso che esista in ambito ESG.
A differenza di Scope 1 (bruciamo noi il combustibile) e Scope 2 (compriamo noi l'elettricità), lo Scope 3 richiede di raccogliere dati da centinaia di fornitori, applicare fattori di emissione eterogenei, gestire gradi di incertezza elevatissimi e produrre un audit trail verificabile da revisori esterni. Con la CSRD/ESRS E1 che rende obbligatorio il reporting Scope 3 per le grandi aziende a partire dal 2025-2026, e con le PMI nel mirino entro il 2028, la questione non è più accademica: è ingegneristica.
In questo articolo costruiamo una pipeline completa per il calcolo delle emissioni Scope 3 value chain: dall'architettura ETL per la raccolta dati supplier, all'integrazione con piattaforme come CDP ed EcoVadis, dall'activity-based vs spend-based calculation, fino al DAG Airflow per l'automazione e all'audit trail immutabile per i verificatori. Ogni sezione include codice Python funzionante e best practice operative testate in contesti reali.
Cosa Imparerai
- Le 15 categorie GHG Protocol Scope 3 e quali sono rilevanti per le aziende software/SaaS
- Architettura ETL/ELT per la raccolta dati supplier: questionari, CDP, EcoVadis e API dirette
- Activity-based vs spend-based: formule, accuratezza e quando usare quale approccio
- Pipeline Python con Apache Airflow: DAG per calcolo Scope 3 automatizzato e scalabile
- Data quality scoring e propagazione dell'incertezza statistica nelle stime
- Audit trail immutabile con hash chain SHA-256 per tracciabilità verificatori esterni
- Visualizzazione Sankey diagram per value chain e heatmap categorie priorità
- Requisiti CSRD/ESRS E1: cosa devi disclosure e con quale granularità
- Caso studio completo: azienda SaaS con 50 supplier, calcolo Scope 3 end-to-end
- Integrazione con EcoVadis Carbon Data Network e API Climatiq per fattori di emissione
Serie Green Software - 10 Articoli
| # | Articolo | Argomento |
|---|---|---|
| 1 | Principi Green Software Foundation | Carbon efficiency, GSF, SCI |
| 2 | CodeCarbon: Misurare il Codice | Misurazione, dashboard, ottimizzazione |
| 3 | Climatiq API: Calcoli Carbonio | API REST, GHG Protocol, Scope 1-3 |
| 4 | Carbon Aware SDK | Time shifting, location shifting |
| 5 | Scope 1-2-3: Data Modeling ESG | Struttura dati, calcoli, aggregazione |
| 6 | GreenOps: Kubernetes Carbon-Aware | Scheduling, scaling, monitoring |
| 7 | Pipeline Emissioni Scope 3 Value Chain | Questo articolo |
| 8 | API ESG Reporting: CSRD | API, workflow, compliance |
| 9 | Pattern Architetturali Sostenibili | Storage, caching, batch |
| 10 | AI e Carbonio: ML Training | Training ML, ottimizzazione, Green AI |
Le 15 Categorie GHG Protocol Scope 3
Il GHG Protocol Corporate Value Chain (Scope 3) Standard è il framework internazionale di riferimento pubblicato nel 2011 e attualmente in revisione con aggiornamenti attesi nel 2026. Esso suddivide le emissioni indirette della value chain in 15 categorie distinte, organizzate in due macrogruppi: upstream (attività prima della produzione/erogazione del servizio) e downstream (attività dopo la vendita al cliente).
15 Categorie Scope 3: Upstream e Downstream
| Cat. | Nome | Flusso | Rilevanza SaaS/Tech |
|---|---|---|---|
| 1 | Purchased goods & services | Upstream | ALTA: server hardware, software licenze, servizi consulenza |
| 2 | Capital goods | Upstream | MEDIA: datacenter equipment, laptop, telefoni aziendali |
| 3 | Fuel & energy-related activities | Upstream | MEDIA: emissioni produzione energia acquistata (upstream Scope 2) |
| 4 | Upstream transportation & distribution | Upstream | BASSA: spedizioni hardware a uffici e datacenter |
| 5 | Waste generated in operations | Upstream | BASSA: RAEE, carta, rifiuti uffici |
| 6 | Business travel | Upstream | ALTA: voli, hotel, treni per team distribuiti |
| 7 | Employee commuting | Upstream | ALTA: spostamenti casa-ufficio, specie per team ibridi |
| 8 | Upstream leased assets | Upstream | MEDIA: uffici in affitto (se non contabilizzati in Scope 1/2) |
| 9 | Downstream transportation & distribution | Downstream | BASSA: distribuzione software su media fisici (rara) |
| 10 | Processing of sold products | Downstream | N/A: non applicabile per software puro |
| 11 | Use of sold products | Downstream | MOLTO ALTA: energia consumata dai clienti usando il SaaS |
| 12 | End-of-life treatment of sold products | Downstream | BASSA: dispositivi utente a fine vita |
| 13 | Downstream leased assets | Downstream | MEDIA: hardware in leasing a clienti |
| 14 | Franchises | Downstream | N/A: non applicabile |
| 15 | Investments | Downstream | ALTA: portfolio aziendale, partecipazioni in startup |
Per un'azienda SaaS o di sviluppo software, le categorie più rilevanti sono tipicamente: Cat. 1 (purchased goods & services, spesso la voce più grande), Cat. 6 (business travel), Cat. 7 (employee commuting) e Cat. 11 (use of sold products). La doppia materialità richiesta dalla CSRD impone di identificare quali categorie siano materiali sia dal punto di vista dell'impatto ambientale sia del rischio finanziario per l'azienda.
Errore Comune: Omettere Cat. 11 per SaaS
Molte aziende software escludono la Categoria 11 ("Use of sold products") assumendo che non si applichi. In realtà, ogni API call, ogni query, ogni watt consumato dai clienti per far girare il vostro software è emissione Scope 3 Cat. 11 di vostra responsabilità. Per un SaaS con milioni di utenti, questa può essere la categoria dominante. Il metodo di calcolo usa la software carbon intensity (SCI) moltiplicata per le unità funzionali erogate.
Architettura della Data Collection Pipeline
Raccogliere dati affidabili da tutta la value chain è il collo di bottiglia numero uno di qualsiasi progetto Scope 3. La pipeline deve gestire fonti eterogenee: questionari manuali, piattaforme ESG di terze parti, API dirette con fornitori, file CSV inviati via email, dati ERP interni. L'architettura seguente adotta un pattern ETL a tre strati (Bronze/Silver/Gold) ispirato al Lakehouse.
Architettura Pipeline Scope 3: Bronze / Silver / Gold
| Layer | Contenuto | Tecnologia | Scopo |
|---|---|---|---|
| Bronze (Raw) | Dati grezzi immutabili dai supplier | S3/GCS, Delta Lake | Audit trail, replay, source of truth |
| Silver (Standardized) | Dati normalizzati per unità e valuta | dbt, Spark, Pandas | Calcolo emissioni, join con emission factors |
| Gold (Reporting) | Emissioni aggregate per categoria GHG | PostgreSQL, BigQuery | Dashboard, CSRD report, verificatori |
Il layer Bronze è fondamentale: tutti i dati ricevuti vengono salvati as-is con timestamp di ingestion, hash SHA-256 del contenuto e metadati della fonte. Questo garantisce la possibilità di riprocessare l'intera pipeline se cambiano i fattori di emissione o la metodologia, senza perdere i dati originali.
# models/scope3_pipeline.py
# Struttura dati per la pipeline Scope 3
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Optional
import hashlib
import json
class DataSource(Enum):
SUPPLIER_QUESTIONNAIRE = "supplier_questionnaire"
CDP_API = "cdp_api"
ECOVADIS_API = "ecovadis_api"
ERP_EXPORT = "erp_export"
MANUAL_UPLOAD = "manual_upload"
CLIMATIQ_API = "climatiq_api"
class CalculationMethod(Enum):
ACTIVITY_BASED = "activity_based"
SPEND_BASED = "spend_based"
HYBRID = "hybrid"
SUPPLIER_SPECIFIC = "supplier_specific"
class DataQualityTier(Enum):
TIER_1 = "primary_data" # Dati primari dal supplier
TIER_2 = "secondary_sector" # Fattori settoriali
TIER_3 = "spend_estimated" # Stima basata su spesa
@dataclass
class RawSupplierData:
"""Layer Bronze: dato grezzo immutabile"""
supplier_id: str
source: DataSource
raw_payload: dict
received_at: datetime
content_hash: str = field(init=False)
def __post_init__(self):
payload_str = json.dumps(self.raw_payload, sort_keys=True)
self.content_hash = hashlib.sha256(
payload_str.encode()
).hexdigest()
@dataclass
class StandardizedActivity:
"""Layer Silver: attività normalizzata"""
activity_id: str
supplier_id: str
scope3_category: int # 1-15
activity_type: str # es. "freight_transport"
quantity: float
unit: str # es. "tonne.km"
reporting_period_start: datetime
reporting_period_end: datetime
source: DataSource
quality_tier: DataQualityTier
emission_factor_id: Optional[str] = None
uncertainty_pct: float = 0.0
raw_data_hash: str = "" # Ref al Bronze layer
@dataclass
class EmissionResult:
"""Layer Gold: emissione calcolata"""
result_id: str
activity_id: str
scope3_category: int
co2e_tonnes: float
calculation_method: CalculationMethod
emission_factor_source: str # es. "climatiq:IPCC_2021"
emission_factor_value: float
quality_tier: DataQualityTier
uncertainty_pct: float
calculated_at: datetime
pipeline_version: str
audit_hash: str = field(init=False)
def __post_init__(self):
audit_data = {
"result_id": self.result_id,
"activity_id": self.activity_id,
"co2e_tonnes": self.co2e_tonnes,
"emission_factor_source": self.emission_factor_source,
"calculated_at": self.calculated_at.isoformat(),
"pipeline_version": self.pipeline_version,
}
self.audit_hash = hashlib.sha256(
json.dumps(audit_data, sort_keys=True).encode()
).hexdigest()
Supplier Data Integration: CDP, EcoVadis e API Dirette
La raccolta dati dai fornitori avviene attraverso canali multipli con livelli di qualità e automazione molto diversi. Il Carbon Disclosure Project (CDP) raccoglie dati da oltre 24.000 aziende ed espone un'API per accedere ai report verificati. EcoVadis ha lanciato nel 2025 il Carbon Data Network con 48.000+ reporter GHG che condividono dati in modo standardizzato. Infine, molti grandi fornitori espongono API proprietarie per la condivisione diretta dei propri footprint.
# collectors/supplier_collector.py
# Integrazione con fonti dati supplier
import httpx
import asyncio
from typing import AsyncGenerator
from datetime import datetime
from models.scope3_pipeline import RawSupplierData, DataSource
class ClimatiqEmissionFactors:
"""Client per Climatiq API - emission factors database"""
BASE_URL = "https://beta3.api.climatiq.io"
def __init__(self, api_key: str):
self.api_key = api_key
self.headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
async def get_emission_factor(
self,
activity_id: str,
year: int = 2024,
region: str = "IT"
) -> dict:
"""Recupera fattore di emissione per attività specifica"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/estimate",
headers=self.headers,
json={
"emission_factor": {
"activity_id": activity_id,
"data_version": "^21",
"year": year,
"region": region
},
"parameters": {
"money": 1.0,
"money_unit": "eur"
}
}
)
response.raise_for_status()
return response.json()
async def batch_estimate(
self,
activities: list[dict]
) -> list[dict]:
"""Stima batch per multiple attività - ottimizza le API call"""
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/batch",
headers=self.headers,
json={"batch": activities},
timeout=30.0
)
response.raise_for_status()
return response.json().get("results", [])
class EcoVadisCollector:
"""Raccoglie dati Scope 3 dalla piattaforma EcoVadis"""
def __init__(self, api_key: str, base_url: str):
self.api_key = api_key
self.base_url = base_url
async def fetch_supplier_carbon_data(
self,
supplier_ecovadis_id: str,
reporting_year: int
) -> RawSupplierData:
"""Recupera dati carbonio per un supplier dalla Carbon Data Network"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/v1/suppliers/{supplier_ecovadis_id}/carbon",
headers={"X-API-Key": self.api_key},
params={"year": reporting_year},
timeout=15.0
)
if response.status_code == 404:
# Supplier non ha condiviso dati primari
return self._create_no_data_record(supplier_ecovadis_id)
response.raise_for_status()
payload = response.json()
return RawSupplierData(
supplier_id=supplier_ecovadis_id,
source=DataSource.ECOVADIS_API,
raw_payload=payload,
received_at=datetime.utcnow()
)
def _create_no_data_record(self, supplier_id: str) -> RawSupplierData:
return RawSupplierData(
supplier_id=supplier_id,
source=DataSource.ECOVADIS_API,
raw_payload={"status": "no_data", "supplier_id": supplier_id},
received_at=datetime.utcnow()
)
class CDPCollector:
"""Raccoglie dati da CDP (Carbon Disclosure Project)"""
CDP_API_URL = "https://api.cdp.net/v1"
def __init__(self, api_token: str):
self.api_token = api_token
async def search_supplier(
self,
company_name: str,
year: int = 2024
) -> RawSupplierData | None:
"""Cerca un supplier nel database CDP e recupera dati GHG"""
async with httpx.AsyncClient() as client:
# Ricerca azienda
search_resp = await client.get(
f"{self.CDP_API_URL}/companies/search",
headers={"Authorization": f"Bearer {self.api_token}"},
params={"q": company_name, "year": year}
)
if not search_resp.json().get("results"):
return None
company_id = search_resp.json()["results"][0]["id"]
# Recupera dati GHG disclosure
ghg_resp = await client.get(
f"{self.CDP_API_URL}/companies/{company_id}/ghg-emissions",
headers={"Authorization": f"Bearer {self.api_token}"},
params={"year": year}
)
if ghg_resp.status_code != 200:
return None
return RawSupplierData(
supplier_id=company_name,
source=DataSource.CDP_API,
raw_payload=ghg_resp.json(),
received_at=datetime.utcnow()
)
class BronzeLayerStorage:
"""Salvataggio immutabile nel layer Bronze"""
def __init__(self, storage_client, bucket: str):
self.storage = storage_client
self.bucket = bucket
async def store(self, raw_data: RawSupplierData) -> str:
"""Salva dato grezzo con path deterministico basato su hash"""
path = (
f"scope3/bronze/"
f"{raw_data.received_at.year}/"
f"{raw_data.received_at.month:02d}/"
f"{raw_data.supplier_id}/"
f"{raw_data.content_hash}.json"
)
await self.storage.upload_json(
bucket=self.bucket,
path=path,
data={
"supplier_id": raw_data.supplier_id,
"source": raw_data.source.value,
"received_at": raw_data.received_at.isoformat(),
"content_hash": raw_data.content_hash,
"payload": raw_data.raw_payload
}
)
return path
Activity-Based vs Spend-Based: Scegliere il Metodo Giusto
Il GHG Protocol definisce quattro metodi di calcolo per lo Scope 3, che nella pratica si riducono a due approcci fondamentali: activity-based e spend-based. La scelta dipende dalla disponibilità dei dati, dalla materialità della categoria e dalla maturità della relazione con il fornitore.
Confronto Metodologico: Activity-Based vs Spend-Based
| Dimensione | Activity-Based | Spend-Based |
|---|---|---|
| Formula | Quantità × Emission Factor (unità/kg CO2e) | Spesa (EUR) × EEIO Factor (kg CO2e/EUR) |
| Accuratezza | Alta (±5-15% con dati primari) | Bassa-Media (±50-100%) |
| Dati richiesti | Quantità fisiche (kg, km, kWh, t) | Solo fatture contabili (EUR, USD) |
| Fonte EF | Climatiq, IPCC, DEFRA, ecoinvent | USEEIO, EXIOBASE, WIOD |
| Quando usarla | Categorie materiali, fornitori grandi | Kick-off, piccoli fornitori, Cat. <1% |
| Effort raccolta | Alto: richiede collaborazione supplier | Basso: dati già in ERP/SAP |
| CSRD accettabilità | Preferito per categorie materiali | Accettato come proxy iniziale |
La strategia ottimale è un approccio ibrido progressivo: si parte con spend-based per avere una baseline rapida sull'intera value chain, poi si migra progressivamente verso activity-based per le categorie materiali identificate. GHG Protocol definisce tre livelli di qualità del dato (Tier 1, 2, 3) che corrispondono esattamente a questa progressione.
# calculators/emission_calculator.py
# Calcolo emissioni activity-based e spend-based
from dataclasses import dataclass
from typing import Optional
import math
# ============================================================
# EMISSION FACTORS DATABASE (simplified)
# In produzione: usa Climatiq API o database ecoinvent
# ============================================================
EMISSION_FACTORS: dict[str, dict] = {
# Cat. 1: Purchased goods & services
"cloud_compute_kwh": {
"value": 0.233, # kg CO2e/kWh (IT grid mix 2024)
"unit": "kWh",
"source": "IEA 2024",
"uncertainty_pct": 10.0
},
"hardware_laptop": {
"value": 350.0, # kg CO2e/unit (embodied carbon)
"unit": "unit",
"source": "Dell 2024 PCF",
"uncertainty_pct": 20.0
},
# Cat. 6: Business travel
"flight_economy_short": {
"value": 0.255, # kg CO2e/passenger.km
"unit": "passenger.km",
"source": "DEFRA 2024",
"uncertainty_pct": 15.0
},
"flight_economy_long": {
"value": 0.195,
"unit": "passenger.km",
"source": "DEFRA 2024",
"uncertainty_pct": 15.0
},
# Cat. 7: Employee commuting
"car_average": {
"value": 0.170, # kg CO2e/km
"unit": "km",
"source": "DEFRA 2024",
"uncertainty_pct": 12.0
},
"public_transport_it": {
"value": 0.048,
"unit": "passenger.km",
"source": "Ispra 2024",
"uncertainty_pct": 18.0
},
}
# EEIO Spend-based factors (EXIOBASE 3.8)
# kg CO2e per EUR di spesa per categoria merceologica
EEIO_FACTORS: dict[str, float] = {
"it_services": 0.312, # IT e telecomunicazioni
"professional_services": 0.198, # Consulenza, legale, etc.
"office_supplies": 0.445,
"cloud_hosting": 0.287,
"marketing": 0.231,
"utilities": 0.892,
"hr_services": 0.167,
"travel_accommodation": 0.521,
}
def calculate_activity_based(
activity_type: str,
quantity: float,
custom_ef: Optional[float] = None
) -> tuple[float, float]:
"""
Calcola emissioni con metodo activity-based.
Returns:
(co2e_kg, uncertainty_pct)
"""
if custom_ef is not None:
return quantity * custom_ef, 30.0 # alta incertezza EF custom
ef_data = EMISSION_FACTORS.get(activity_type)
if not ef_data:
raise ValueError(f"Emission factor non trovato: {activity_type}")
co2e_kg = quantity * ef_data["value"]
uncertainty = ef_data["uncertainty_pct"]
return co2e_kg, uncertainty
def calculate_spend_based(
spend_eur: float,
procurement_category: str,
inflation_correction: float = 1.0
) -> tuple[float, float]:
"""
Calcola emissioni con metodo spend-based (EEIO).
Args:
spend_eur: importo in EUR
procurement_category: categoria merceologica EEIO
inflation_correction: fattore per correggere inflazione vs anno base EEIO
Returns:
(co2e_kg, uncertainty_pct)
"""
eeio_factor = EEIO_FACTORS.get(procurement_category)
if not eeio_factor:
raise ValueError(f"EEIO factor non trovato: {procurement_category}")
# Corregge per inflazione (EEIO factors spesso in EUR 2015)
adjusted_spend = spend_eur / inflation_correction
co2e_kg = adjusted_spend * eeio_factor
# Lo spend-based ha incertezza intrinsecamente alta
uncertainty = 75.0
return co2e_kg, uncertainty
def propagate_uncertainty(
values: list[float],
uncertainties_pct: list[float]
) -> float:
"""
Propagazione incertezza quadratica (somma in quadratura).
Valida quando le incertezze sono indipendenti.
Returns:
uncertainty_pct sul totale
"""
weighted_variance_sum = sum(
(v * u/100) ** 2
for v, u in zip(values, uncertainties_pct)
)
total = sum(values)
if total == 0:
return 0.0
combined_std = math.sqrt(weighted_variance_sum)
return (combined_std / total) * 100
def calculate_category_total(
activities: list[dict]
) -> dict:
"""
Calcola totale categoria Scope 3 con propagazione incertezza.
activities: lista di {method, value_kg, uncertainty_pct}
"""
if not activities:
return {"total_co2e_kg": 0.0, "uncertainty_pct": 0.0}
values = [a["value_kg"] for a in activities]
uncertainties = [a["uncertainty_pct"] for a in activities]
total_co2e = sum(values)
combined_uncertainty = propagate_uncertainty(values, uncertainties)
# Qualità aggregata: peggiore del gruppo determina il tier
quality_tiers = [a.get("quality_tier", "TIER_3") for a in activities]
dominant_tier = min(quality_tiers) # TIER_1 < TIER_2 < TIER_3 lexicograficamente
return {
"total_co2e_kg": total_co2e,
"total_co2e_tonnes": total_co2e / 1000,
"uncertainty_pct": combined_uncertainty,
"uncertainty_kg": total_co2e * combined_uncertainty / 100,
"dominant_quality_tier": dominant_tier,
"activity_count": len(activities)
}
Pipeline Airflow: DAG per Calcolo Scope 3 Automatizzato
L'orchestrazione della pipeline Scope 3 richiede un DAG ben strutturato che gestisca il ciclo di vita completo: raccolta dati, standardizzazione, calcolo emissioni, quality check e pubblicazione nel layer Gold. Il DAG deve essere idempotente (eseguibile più volte senza effetti collaterali) e ripristinabile in caso di fallimento parziale.
# dags/scope3_pipeline_dag.py
# Apache Airflow DAG per pipeline emissioni Scope 3
from datetime import datetime, timedelta
from airflow import DAG
from airflow.decorators import task, task_group
from airflow.providers.postgres.hooks.postgres import PostgresHook
from airflow.models import Variable
import json
import logging
logger = logging.getLogger(__name__)
# ============================================================
# CONFIGURAZIONE DAG
# ============================================================
SCOPE3_DAG_CONFIG = {
"reporting_year": 2024,
"companies": [
{"id": "S001", "name": "AWS", "tier": "TIER_1", "source": "ecovadis"},
{"id": "S002", "name": "Microsoft Azure", "tier": "TIER_1", "source": "cdp"},
{"id": "S003", "name": "Supplier_XYZ", "tier": "TIER_2", "source": "questionnaire"},
# ... altri supplier
],
"categories_enabled": [1, 2, 3, 6, 7, 11, 15],
"quality_threshold_pct": 80.0,
"alert_email": "esg-team@company.com"
}
default_args = {
"owner": "esg-team",
"depends_on_past": False,
"email_on_failure": True,
"email": [SCOPE3_DAG_CONFIG["alert_email"]],
"retries": 3,
"retry_delay": timedelta(minutes=5),
}
with DAG(
dag_id="scope3_emissions_pipeline",
default_args=default_args,
description="Pipeline calcolo emissioni Scope 3 value chain",
schedule_interval="@quarterly", # Esecuzione trimestrale
start_date=datetime(2024, 1, 1),
catchup=False,
tags=["emissions", "scope3", "esg", "ghg-protocol"],
max_active_runs=1, # Serializza: mai due calcoli in parallelo
) as dag:
# ============================================================
# FASE 1: RACCOLTA DATI SUPPLIER (in parallelo per supplier)
# ============================================================
@task_group(group_id="data_collection")
def collect_supplier_data():
@task(task_id="fetch_ecovadis_suppliers")
def fetch_ecovadis() -> list[dict]:
"""Raccoglie dati da EcoVadis Carbon Data Network"""
from collectors.supplier_collector import EcoVadisCollector
import asyncio
api_key = Variable.get("ECOVADIS_API_KEY", deserialize_json=False)
collector = EcoVadisCollector(api_key, "https://api.ecovadis.com")
suppliers_ecovadis = [
s for s in SCOPE3_DAG_CONFIG["companies"]
if s["source"] == "ecovadis"
]
results = []
for supplier in suppliers_ecovadis:
raw = asyncio.run(
collector.fetch_supplier_carbon_data(
supplier["id"],
SCOPE3_DAG_CONFIG["reporting_year"]
)
)
results.append({
"supplier_id": raw.supplier_id,
"content_hash": raw.content_hash,
"status": "fetched",
"has_data": raw.raw_payload.get("status") != "no_data"
})
logger.info(f"EcoVadis - Supplier {supplier['id']}: fetched")
return results
@task(task_id="fetch_cdp_suppliers")
def fetch_cdp() -> list[dict]:
"""Raccoglie dati verificati da CDP"""
from collectors.supplier_collector import CDPCollector
import asyncio
api_token = Variable.get("CDP_API_TOKEN")
collector = CDPCollector(api_token)
suppliers_cdp = [
s for s in SCOPE3_DAG_CONFIG["companies"]
if s["source"] == "cdp"
]
results = []
for supplier in suppliers_cdp:
raw = asyncio.run(
collector.search_supplier(
supplier["name"],
SCOPE3_DAG_CONFIG["reporting_year"]
)
)
if raw:
results.append({
"supplier_id": supplier["id"],
"content_hash": raw.content_hash,
"status": "fetched",
"has_data": True
})
else:
results.append({
"supplier_id": supplier["id"],
"status": "not_found",
"has_data": False
})
return results
@task(task_id="process_manual_questionnaires")
def process_questionnaires() -> list[dict]:
"""Processa questionari manuali caricati in S3"""
# In produzione: legge da bucket S3 o SharePoint
# Qui restituiamo dati di esempio
return [{
"supplier_id": "S003",
"status": "processed",
"has_data": True,
"scope3_cat1_tco2e": 45.2,
"scope3_cat6_tco2e": 12.8
}]
ev = fetch_ecovadis()
cdp = fetch_cdp()
q = process_questionnaires()
return [ev, cdp, q]
# ============================================================
# FASE 2: STANDARDIZZAZIONE E CALCOLO EMISSIONI
# ============================================================
@task(task_id="standardize_activities")
def standardize_activities(collection_results: list) -> list[dict]:
"""Normalizza tutti i dati in unità fisiche standard"""
from normalizers.activity_normalizer import ActivityNormalizer
normalizer = ActivityNormalizer()
standardized = []
for batch in collection_results:
for result in batch:
if result.get("has_data"):
activities = normalizer.normalize(result)
standardized.extend(activities)
logger.info(f"Standardizzate {len(standardized)} attività")
return standardized
@task(task_id="calculate_emissions")
def calculate_emissions(activities: list[dict]) -> list[dict]:
"""Calcola emissioni CO2e per ogni attività standardizzata"""
from calculators.emission_calculator import (
calculate_activity_based,
calculate_spend_based
)
from models.scope3_pipeline import CalculationMethod
results = []
for activity in activities:
if activity["method"] == "activity_based":
co2e_kg, uncertainty = calculate_activity_based(
activity["activity_type"],
activity["quantity"]
)
method = CalculationMethod.ACTIVITY_BASED
else:
co2e_kg, uncertainty = calculate_spend_based(
activity["spend_eur"],
activity["procurement_category"]
)
method = CalculationMethod.SPEND_BASED
results.append({
**activity,
"co2e_kg": co2e_kg,
"co2e_tonnes": co2e_kg / 1000,
"uncertainty_pct": uncertainty,
"calculation_method": method.value,
"calculated_at": datetime.utcnow().isoformat()
})
return results
# ============================================================
# FASE 3: DATA QUALITY CHECK
# ============================================================
@task(task_id="data_quality_check")
def data_quality_check(results: list[dict]) -> dict:
"""Verifica qualità dati e genera score per categoria"""
from quality.data_quality_scorer import DataQualityScorer
scorer = DataQualityScorer()
quality_report = scorer.score_results(results)
if quality_report["overall_score"] < SCOPE3_DAG_CONFIG["quality_threshold_pct"]:
logger.warning(
f"Quality score sotto soglia: {quality_report['overall_score']}%"
)
return quality_report
# ============================================================
# FASE 4: AUDIT TRAIL E PUBBLICAZIONE GOLD LAYER
# ============================================================
@task(task_id="create_audit_trail")
def create_audit_trail(
results: list[dict],
quality_report: dict
) -> str:
"""Crea audit trail immutabile con hash chain"""
from audit.hash_chain import HashChain
chain = HashChain()
chain_id = chain.create_chain(
calculation_results=results,
quality_report=quality_report,
pipeline_version="2.1.0",
methodology="GHG_Protocol_Scope3_2011",
reporting_standard="CSRD_ESRS_E1"
)
logger.info(f"Audit trail creato: {chain_id}")
return chain_id
@task(task_id="publish_gold_layer")
def publish_gold_layer(
results: list[dict],
audit_chain_id: str
) -> None:
"""Pubblica dati aggregati nel Gold layer (PostgreSQL)"""
hook = PostgresHook(postgres_conn_id="emissions_db")
for result in results:
hook.run(
"""
INSERT INTO scope3_emissions_gold (
supplier_id, scope3_category, co2e_tonnes,
calculation_method, uncertainty_pct,
quality_tier, audit_chain_id,
reporting_year, published_at
) VALUES (
%(supplier_id)s, %(scope3_category)s, %(co2e_tonnes)s,
%(calculation_method)s, %(uncertainty_pct)s,
%(quality_tier)s, %(audit_chain_id)s,
%(reporting_year)s, NOW()
)
ON CONFLICT (supplier_id, scope3_category, reporting_year)
DO UPDATE SET
co2e_tonnes = EXCLUDED.co2e_tonnes,
updated_at = NOW()
""",
parameters={
**result,
"audit_chain_id": audit_chain_id,
"reporting_year": SCOPE3_DAG_CONFIG["reporting_year"]
}
)
logger.info(f"Pubblicati {len(results)} record nel Gold layer")
# ============================================================
# WIRING DEL DAG
# ============================================================
collection_results = collect_supplier_data()
standardized = standardize_activities(collection_results)
emission_results = calculate_emissions(standardized)
quality = data_quality_check(emission_results)
chain_id = create_audit_trail(emission_results, quality)
publish_gold_layer(emission_results, chain_id)
Data Quality Scoring e Propagazione dell'Incertezza
Il GHG Protocol Scope 3 Standard riconosce esplicitamente che le emissioni di cat. 1-15 non sono mai note con certezza assoluta. Il reporting di qualità deve includere una stima dell'incertezza quantitativa associata a ciascuna categoria. L'IPCC ha formalizzato il metodo di propagazione dell'incertezza nella sua Good Practice Guidance.
# quality/data_quality_scorer.py
# Scoring qualità dati Scope 3
from dataclasses import dataclass
from enum import Enum
from typing import Optional
import math
from datetime import datetime, timedelta
class QualityDimension(Enum):
COMPLETENESS = "completeness"
ACCURACY = "accuracy"
TIMELINESS = "timeliness"
VERIFICATION = "verification"
GRANULARITY = "granularity"
@dataclass
class QualityScore:
dimension: QualityDimension
score: float # 0-100
weight: float # peso nel calcolo aggregato
notes: str = ""
def score_supplier_data_quality(
supplier: dict,
reference_date: datetime = None
) -> dict[str, float]:
"""
Calcola score qualità multi-dimensionale per i dati di un supplier.
Basato su GHG Protocol Data Quality Guidance.
"""
if reference_date is None:
reference_date = datetime.utcnow()
scores = []
# 1. COMPLETENESS: quante delle categorie richieste sono presenti?
required_fields = [
"scope3_cat1_tco2e", "scope3_cat6_tco2e", "scope3_cat7_tco2e"
]
present = sum(1 for f in required_fields if supplier.get(f) is not None)
completeness_score = (present / len(required_fields)) * 100
scores.append(QualityScore(
dimension=QualityDimension.COMPLETENESS,
score=completeness_score,
weight=0.30
))
# 2. TIMELINESS: quanto sono recenti i dati?
data_year = supplier.get("reporting_year", 2020)
current_year = reference_date.year
age_years = current_year - data_year
if age_years <= 1:
timeliness_score = 100.0
elif age_years == 2:
timeliness_score = 75.0
elif age_years == 3:
timeliness_score = 50.0
else:
timeliness_score = 20.0
scores.append(QualityScore(
dimension=QualityDimension.TIMELINESS,
score=timeliness_score,
weight=0.20
))
# 3. VERIFICATION: i dati sono stati verificati da terze parti?
verification_level = supplier.get("verification", "none")
verification_score = {
"independent_assured": 100.0, # GHG verificato da auditor indipendente
"limited_assurance": 80.0, # Limited assurance
"internal_reviewed": 60.0, # Solo review interna
"supplier_declared": 40.0, # Auto-dichiarazione
"estimated": 20.0, # Stima spend-based
"none": 0.0
}.get(verification_level, 20.0)
scores.append(QualityScore(
dimension=QualityDimension.VERIFICATION,
score=verification_score,
weight=0.30
))
# 4. GRANULARITY: attività-specifico o aggregato?
data_type = supplier.get("data_type", "aggregated")
granularity_score = {
"site_specific": 100.0, # Dati per sito produttivo
"product_specific": 90.0, # PCF per prodotto/servizio
"supplier_specific": 70.0, # Dato totale supplier
"sector_average": 40.0, # Media settoriale
"aggregated": 20.0
}.get(data_type, 20.0)
scores.append(QualityScore(
dimension=QualityDimension.GRANULARITY,
score=granularity_score,
weight=0.20
))
# Calcolo score aggregato ponderato
overall = sum(s.score * s.weight for s in scores)
# Mappa score a Tier GHG Protocol
if overall >= 80:
tier = "TIER_1"
uncertainty_band_pct = 15.0
elif overall >= 50:
tier = "TIER_2"
uncertainty_band_pct = 40.0
else:
tier = "TIER_3"
uncertainty_band_pct = 75.0
return {
"overall_score": round(overall, 1),
"tier": tier,
"uncertainty_band_pct": uncertainty_band_pct,
"dimension_scores": {
s.dimension.value: round(s.score, 1)
for s in scores
}
}
def monte_carlo_uncertainty(
base_estimate_tco2e: float,
uncertainty_pct: float,
n_simulations: int = 10_000
) -> dict:
"""
Stima intervallo di confidenza con simulazione Monte Carlo.
Per reporting CSRD si raccomanda almeno 1.000 simulazioni.
"""
import random
# Distribuzione log-normale (emissioni non possono essere negative)
sigma = math.log(1 + (uncertainty_pct / 100) ** 2) ** 0.5
mu = math.log(base_estimate_tco2e) - sigma ** 2 / 2
simulated = [
math.exp(random.gauss(mu, sigma))
for _ in range(n_simulations)
]
simulated_sorted = sorted(simulated)
p05 = simulated_sorted[int(n_simulations * 0.05)]
p50 = simulated_sorted[int(n_simulations * 0.50)]
p95 = simulated_sorted[int(n_simulations * 0.95)]
return {
"base_estimate_tco2e": base_estimate_tco2e,
"p05_tco2e": round(p05, 2),
"p50_tco2e": round(p50, 2),
"p95_tco2e": round(p95, 2),
"confidence_interval_90pct": {
"lower": round(p05, 2),
"upper": round(p95, 2)
},
"coefficient_of_variation": round(
(p95 - p05) / (2 * p50) * 100, 1
)
}
Audit Trail Immutabile con Hash Chain
La tracciabilità end-to-end è uno dei requisiti più critici per il reporting Scope 3 verificabile. I revisori esterni devono poter risalire da ogni numero nel report finale fino alla fonte primaria del dato, passando per tutti i passaggi di trasformazione. Una hash chain ispirata alla tecnologia blockchain (ma senza la complessità distribuita) garantisce l'immutabilità dell'audit trail.
# audit/hash_chain.py
# Audit trail immutabile per emissioni Scope 3
import hashlib
import json
import uuid
from datetime import datetime
from typing import Optional
import logging
logger = logging.getLogger(__name__)
class AuditRecord:
"""Singolo record nell'audit chain"""
def __init__(
self,
record_type: str,
payload: dict,
previous_hash: str,
chain_id: str,
sequence: int
):
self.record_id = str(uuid.uuid4())
self.record_type = record_type
self.payload = payload
self.previous_hash = previous_hash
self.chain_id = chain_id
self.sequence = sequence
self.created_at = datetime.utcnow().isoformat()
self.record_hash = self._compute_hash()
def _compute_hash(self) -> str:
"""SHA-256 hash di tutti i campi del record (eccetto il hash stesso)"""
data = {
"record_id": self.record_id,
"record_type": self.record_type,
"chain_id": self.chain_id,
"sequence": self.sequence,
"previous_hash": self.previous_hash,
"created_at": self.created_at,
"payload_hash": hashlib.sha256(
json.dumps(self.payload, sort_keys=True, default=str).encode()
).hexdigest()
}
return hashlib.sha256(
json.dumps(data, sort_keys=True).encode()
).hexdigest()
def to_dict(self) -> dict:
return {
"record_id": self.record_id,
"record_type": self.record_type,
"chain_id": self.chain_id,
"sequence": self.sequence,
"previous_hash": self.previous_hash,
"record_hash": self.record_hash,
"created_at": self.created_at,
"payload": self.payload
}
class HashChain:
"""
Hash chain per audit trail immutabile emissioni Scope 3.
Ogni record contiene l'hash del record precedente,
rendendo impossibile modificare un record senza invalidare
tutti i record successivi.
"""
GENESIS_HASH = "0" * 64 # Hash del primo record della chain
def __init__(self, db_client=None):
self.db = db_client
self.records: list[AuditRecord] = []
def create_chain(
self,
calculation_results: list[dict],
quality_report: dict,
pipeline_version: str,
methodology: str,
reporting_standard: str
) -> str:
"""Crea una nuova chain per un calcolo Scope 3 completo"""
chain_id = str(uuid.uuid4())
previous_hash = self.GENESIS_HASH
# Record 1: Metadati della pipeline
pipeline_record = AuditRecord(
record_type="PIPELINE_METADATA",
payload={
"version": pipeline_version,
"methodology": methodology,
"reporting_standard": reporting_standard,
"calculation_timestamp": datetime.utcnow().isoformat(),
"total_activities": len(calculation_results)
},
previous_hash=previous_hash,
chain_id=chain_id,
sequence=0
)
self.records.append(pipeline_record)
previous_hash = pipeline_record.record_hash
# Record 2: Quality report
quality_record = AuditRecord(
record_type="QUALITY_ASSESSMENT",
payload=quality_report,
previous_hash=previous_hash,
chain_id=chain_id,
sequence=1
)
self.records.append(quality_record)
previous_hash = quality_record.record_hash
# Record 3..N: Singoli risultati di emissione
for i, result in enumerate(calculation_results):
emission_record = AuditRecord(
record_type="EMISSION_CALCULATION",
payload={
"supplier_id": result.get("supplier_id"),
"scope3_category": result.get("scope3_category"),
"co2e_tonnes": result.get("co2e_tonnes"),
"calculation_method": result.get("calculation_method"),
"emission_factor_source": result.get("emission_factor_source"),
"uncertainty_pct": result.get("uncertainty_pct"),
"quality_tier": result.get("quality_tier")
},
previous_hash=previous_hash,
chain_id=chain_id,
sequence=2 + i
)
self.records.append(emission_record)
previous_hash = emission_record.record_hash
# Persist su DB (o storage immutabile)
if self.db:
self._persist_chain(chain_id)
logger.info(
f"Chain {chain_id} creata con {len(self.records)} record. "
f"Final hash: {previous_hash[:16]}..."
)
return chain_id
def verify_chain_integrity(self, chain_id: str) -> bool:
"""
Verifica che nessun record sia stato alterato.
Percorre la chain ricomputando ogni hash.
"""
records = self._load_chain(chain_id)
if not records:
return False
expected_previous = self.GENESIS_HASH
for record_dict in records:
# Ricomputa hash
record = AuditRecord(
record_type=record_dict["record_type"],
payload=record_dict["payload"],
previous_hash=record_dict["previous_hash"],
chain_id=record_dict["chain_id"],
sequence=record_dict["sequence"]
)
if record_dict["previous_hash"] != expected_previous:
logger.error(
f"Chain corrotta al record {record_dict['sequence']}: "
f"previous_hash non corrisponde"
)
return False
expected_previous = record.record_hash
return True
def _persist_chain(self, chain_id: str) -> None:
"""Salva tutti i record della chain nel DB"""
for record in self.records:
self.db.insert("scope3_audit_chain", record.to_dict())
def _load_chain(self, chain_id: str) -> list[dict]:
"""Carica i record della chain dal DB in ordine di sequenza"""
if not self.db:
return [r.to_dict() for r in self.records]
return self.db.query(
"SELECT * FROM scope3_audit_chain WHERE chain_id = %s ORDER BY sequence",
[chain_id]
)
Visualizzazione: Sankey Diagram e Heatmap Categorie
Una pipeline Scope 3 ben costruita deve anche produrre visualizzazioni che rendano i dati comprensibili a stakeholder tecnici e non tecnici. Il Sankey diagram è lo strumento ideale per mostrare i flussi di emissioni lungo la value chain, mentre una heatmap permette di identificare rapidamente le categorie più materiali e quelle con qualità del dato più bassa.
# visualizations/scope3_charts.py
# Generazione Sankey diagram e heatmap Scope 3
import plotly.graph_objects as go
import plotly.express as px
import pandas as pd
from typing import Optional
def create_scope3_sankey(
emission_data: list[dict],
title: str = "Scope 3 Value Chain Emissions"
) -> go.Figure:
"""
Crea Sankey diagram per visualizzare flussi emissioni Scope 3.
Struttura: Supplier -> Categoria S3 -> Totale Scope 3
"""
# Raccoglie nodi unici
suppliers = list(set(d["supplier_id"] for d in emission_data))
categories = list(set(f"Cat. {d['scope3_category']}" for d in emission_data))
all_nodes = suppliers + categories + ["Scope 3 Total"]
node_index = {node: i for i, node in enumerate(all_nodes)}
# Costruisce link source->target->value
source_indices = []
target_indices = []
values = []
link_labels = []
for record in emission_data:
supplier = record["supplier_id"]
category = f"Cat. {record['scope3_category']}"
tco2e = record["co2e_tonnes"]
# Supplier -> Categoria
source_indices.append(node_index[supplier])
target_indices.append(node_index[category])
values.append(tco2e)
link_labels.append(f"{tco2e:.1f} tCO2e")
# Categoria -> Total
for cat in categories:
cat_total = sum(
d["co2e_tonnes"]
for d in emission_data
if f"Cat. {d['scope3_category']}" == cat
)
source_indices.append(node_index[cat])
target_indices.append(node_index["Scope 3 Total"])
values.append(cat_total)
link_labels.append(f"{cat_total:.1f} tCO2e")
# Colori nodi
node_colors = (
["#2196F3"] * len(suppliers) + # Blu per supplier
["#FF9800"] * len(categories) + # Arancione per categorie
["#4CAF50"] # Verde per totale
)
fig = go.Figure(go.Sankey(
arrangement="snap",
node=dict(
pad=20,
thickness=20,
line=dict(color="white", width=0.5),
label=all_nodes,
color=node_colors,
hovertemplate="{label}
tCO2e: {value:.1f}<extra></extra>"
),
link=dict(
source=source_indices,
target=target_indices,
value=values,
label=link_labels,
color="rgba(100,100,100,0.3)"
)
))
fig.update_layout(
title_text=title,
font_size=12,
height=600,
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)"
)
return fig
def create_category_heatmap(
emission_data: list[dict]
) -> go.Figure:
"""
Heatmap: asse X = categoria Scope 3, asse Y = qualità dato.
Colore = tCO2e. Aiuta a prioritizzare effort raccolta dati.
"""
df = pd.DataFrame(emission_data)
# Aggrega per categoria e tier qualità
pivot = df.pivot_table(
values="co2e_tonnes",
index="quality_tier",
columns="scope3_category",
aggfunc="sum",
fill_value=0
)
# Ordina tier (TIER_1 migliore in alto)
tier_order = ["TIER_1", "TIER_2", "TIER_3"]
pivot = pivot.reindex(
[t for t in tier_order if t in pivot.index]
)
fig = go.Figure(go.Heatmap(
z=pivot.values,
x=[f"Cat. {c}" for c in pivot.columns],
y=list(pivot.index),
colorscale="RdYlGn_r", # Rosso = alta emissione (critico)
text=pivot.values.round(1),
texttemplate="%{text} t",
textfont={"size": 11},
hovertemplate="Categoria: %{x}
Tier: %{y}
%{z:.1f} tCO2e<extra></extra>",
colorbar=dict(title="tCO2e")
))
fig.update_layout(
title="Heatmap Scope 3: Emissioni per Categoria e Qualità Dato",
xaxis_title="Categoria GHG Protocol",
yaxis_title="Tier Qualità Dato",
height=350,
margin=dict(l=80, r=20, t=60, b=60)
)
return fig
def generate_scope3_dashboard_html(
emission_data: list[dict],
output_path: str
) -> None:
"""Genera report HTML standalone con tutti i grafici"""
sankey = create_scope3_sankey(emission_data)
heatmap = create_category_heatmap(emission_data)
total_tco2e = sum(d["co2e_tonnes"] for d in emission_data)
by_category = {}
for d in emission_data:
cat = d["scope3_category"]
by_category[cat] = by_category.get(cat, 0) + d["co2e_tonnes"]
top_category = max(by_category, key=by_category.get)
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>Scope 3 Emissions Report</title>
<meta charset="utf-8">
</head>
<body>
<h1>Scope 3 Value Chain Emissions Report</h1>
<p>Totale: <strong>{total_tco2e:.1f} tCO2e</strong></p>
<p>Categoria più materiale: Cat. {top_category}
({by_category[top_category]:.1f} tCO2e)</p>
{sankey.to_html(full_html=False)}
{heatmap.to_html(full_html=False)}
</body>
</html>
"""
with open(output_path, "w") as f:
f.write(html_content)
Requisiti CSRD/ESRS E1 per il Reporting Scope 3
La Corporate Sustainability Reporting Directive (CSRD) e il relativo standard ESRS E1 (Climate Change) trasformano il reporting Scope 3 da volontario a obbligatorio per migliaia di aziende europee. Il calendario di implementazione è scaglionato e già in corso.
Timeline CSRD Scope 3 Obbligatorio
| FY | Report Entro | Soggetti | Note |
|---|---|---|---|
| 2024 | Inizio 2025 | Grandi PIE già soggette NFRD (>500 dip.) | Prima ondata: ~12.000 aziende EU |
| 2025 | Inizio 2026 | Tutte le grandi aziende (>250 dip. o >40M EUR) | ~50.000 aziende EU |
| 2026 | Inizio 2027 | PMI quotate | Standard ESRS semplificato |
| 2028 | Inizio 2029 | Non-EU companies con filiali EU | Impatto globale significativo |
ESRS E1 richiede specificamente per le emissioni Scope 3:
- Disclosure di tutte le categorie materiali: la materialità va determinata tramite analisi di doppia materialità (impatto + rischio finanziario). Per la maggior parte delle aziende tech, almeno 4-6 categorie risultano materiali.
- Breakdown per categoria: i valori non possono essere riportati come unico totale aggregato; ogni categoria materiale deve avere il proprio dato in tCO2e.
- Metodologia esplicita: per ogni categoria bisogna dichiarare il metodo di calcolo (activity-based, spend-based, supplier-specific), la fonte dei fattori di emissione e il tier qualità del dato.
- No netting con carbon credits: le emissioni lorde devono essere riportate separatamente da qualsiasi compensazione o carbon offset acquistato.
- Assurance obbligatoria: limited assurance inizialmente, con obiettivo di passaggio a reasonable assurance in futuro. L'audit trail descritto in questo articolo è esattamente ciò che i revisori richiederanno.
- Target e piano di transizione: le aziende devono dichiarare obiettivi di riduzione allineati 1.5°C (preferibilmente validati da SBTi) con milestone intermedie.
Attenzione: Scope 3 e Doppia Materialità
ESRS E1 non richiede di riportare tutte e 15 le categorie Scope 3, ma solo quelle identificate come materiali nell'analisi di doppia materialità. Tuttavia, il processo di determinazione della materialità deve essere documentato e auditabile. Escludere una categoria "per mancanza di dati" non è una giustificazione accettabile: bisogna dimostrare che quella categoria non è materiale per il business specifico.
Caso Studio: Azienda SaaS con 50 Supplier
Traduciamo tutto in un esempio concreto: una mid-size SaaS italiana con 150 dipendenti, revenue 15M EUR, infrastruttura su AWS e 50 fornitori attivi. Il management ha deciso di avviare il calcolo Scope 3 in vista della CSRD e ha una finestra di 3 mesi per consegnare dati verificabili al proprio auditor.
Profilo Azienda: SaaS Italia S.r.l.
| Parametro | Valore |
|---|---|
| Dipendenti | 150 (70% smart working) |
| Sedi | Milano HQ + ufficio Roma |
| Infrastruttura | AWS eu-west-1 (primary), GCP europe-west1 (backup) |
| Supplier attivi | 50 (8 grandi, 42 piccoli/medi) |
| Spesa procurement | ~4.2M EUR/anno |
| Voli annui | ~380 voli (conferenze + clienti) |
Fase 1 – Analisi di Materialità (Settimana 1-2): Il team ESG ha condotto un'analisi rapida per identificare le categorie Scope 3 materiali. Usando i dati di spesa dall'ERP (SAP) come proxy iniziale con fattori EEIO, hanno ottenuto questa stima di "screening":
Screening Materialità Scope 3 - SaaS Italia S.r.l.
| Cat. | Descrizione | Stima Spend-based (tCO2e) | % del Totale | Decisione |
|---|---|---|---|---|
| 1 | Purchased goods & services (cloud, SW) | 342 | 54% | MATERIALE → Activity-based |
| 6 | Business travel | 98 | 15% | MATERIALE → Activity-based |
| 7 | Employee commuting | 87 | 14% | MATERIALE → Survey dipendenti |
| 11 | Use of sold products | 76 | 12% | MATERIALE → SCI measurement |
| 2 | Capital goods (laptop, hardware) | 28 | 4% | MATERIALE → Vendor PCF |
| Altre | Cat. 3, 5, 8, 15 | 6 | 1% | Non materiale → Spend-based |
Fase 2 – Raccolta Dati (Settimana 2-8):
- Cat. 1 (Cloud): AWS Customer Carbon Footprint Tool e GCP Carbon Footprint forniscono dati di emissione mensili per account. Dati estratti via API e caricati nel Bronze layer. Qualità: TIER 1 (supplier-specific, verificato AWS).
- Cat. 1 (Software e servizi): 8 fornitori grandi (>50K EUR/anno) contattati con questionario strutturato. 5 hanno risposto con dati primari (tra cui Microsoft ERP, Slack, Salesforce). 3 non hanno dati → spend-based con EEIO.
- Cat. 6 (Business travel): dati estratti dall'agenzia viaggi (Carlson Wagonlit) via API: 380 voli con routing e classe. Calcolo activity-based con DEFRA 2024.
- Cat. 7 (Commuting): survey anonimo a tutti 150 dipendenti (82% tasso risposta). Mezzo di trasporto, distanza media, giorni settimana in ufficio.
- Cat. 11 (Use of sold products): SCI (Software Carbon Intensity) calcolata con CodeCarbon su infrastruttura prod. Moltiplicata per numero sessioni attive/mese.
# case_study/saas_italia_scope3.py
# Calcolo completo Scope 3 per SaaS Italia S.r.l.
from calculators.emission_calculator import (
calculate_activity_based,
calculate_spend_based,
calculate_category_total
)
def calculate_cat1_cloud() -> dict:
"""Cat. 1: Emissioni cloud AWS + GCP (dati primari vendor)"""
# Dati estratti dall'AWS Customer Carbon Footprint API
aws_kwh_year = 187_500 # kWh totali 2024
gcp_kwh_year = 12_300
aws_ef = 0.233 # kg CO2e/kWh IT grid (AWS eu-west-1)
gcp_ef = 0.198 # kg CO2e/kWh GCP europe-west1
aws_co2, aws_unc = calculate_activity_based("cloud_compute_kwh", aws_kwh_year, aws_ef)
gcp_co2, gcp_unc = calculate_activity_based("cloud_compute_kwh", gcp_kwh_year, gcp_ef)
activities = [
{"value_kg": aws_co2, "uncertainty_pct": 8.0, "quality_tier": "TIER_1"},
{"value_kg": gcp_co2, "uncertainty_pct": 10.0, "quality_tier": "TIER_1"},
]
result = calculate_category_total(activities)
result["category"] = 1
result["sub_category"] = "cloud_infrastructure"
return result
def calculate_cat6_business_travel() -> dict:
"""Cat. 6: Business travel (dati agenzia viaggi)"""
# 380 voli totali anno 2024
# 60% corto raggio (<1500km), 40% lungo raggio
short_haul_pkm = 380 * 0.6 * 850 # 850km avg corto raggio
long_haul_pkm = 380 * 0.4 * 3200 # 3200km avg lungo raggio
short_co2, short_unc = calculate_activity_based(
"flight_economy_short", short_haul_pkm
)
long_co2, long_unc = calculate_activity_based(
"flight_economy_long", long_haul_pkm
)
# Radiative forcing factor x1.9 per quota alta
rf_factor = 1.9
short_co2 *= rf_factor
long_co2 *= rf_factor
activities = [
{"value_kg": short_co2, "uncertainty_pct": 20.0, "quality_tier": "TIER_2"},
{"value_kg": long_co2, "uncertainty_pct": 20.0, "quality_tier": "TIER_2"},
]
result = calculate_category_total(activities)
result["category"] = 6
return result
def calculate_cat7_commuting() -> dict:
"""Cat. 7: Employee commuting (survey 123/150 dipendenti)"""
# Risultati survey (valori medi per dipendente/anno)
commuters = {
"car_solo": {"count": 38, "km_day": 28, "days_year": 120},
"car_shared": {"count": 12, "km_day": 22, "days_year": 110},
"public_transport": {"count": 52, "km_day": 35, "days_year": 140},
"cycling_walking": {"count": 21, "km_day": 4, "days_year": 150},
}
activities = []
# Auto privata
car_pkm = (
commuters["car_solo"]["count"] *
commuters["car_solo"]["km_day"] *
commuters["car_solo"]["days_year"]
)
co2_car, unc = calculate_activity_based("car_average", car_pkm)
activities.append({"value_kg": co2_car, "uncertainty_pct": 15.0, "quality_tier": "TIER_2"})
# Trasporto pubblico
pt_pkm = (
commuters["public_transport"]["count"] *
commuters["public_transport"]["km_day"] *
commuters["public_transport"]["days_year"]
)
co2_pt, unc = calculate_activity_based("public_transport_it", pt_pkm)
activities.append({"value_kg": co2_pt, "uncertainty_pct": 20.0, "quality_tier": "TIER_2"})
# Ciclismo/piedi: zero emissioni dirette
activities.append({"value_kg": 0.0, "uncertainty_pct": 0.0, "quality_tier": "TIER_1"})
result = calculate_category_total(activities)
result["category"] = 7
result["survey_response_rate"] = 82.0
return result
def calculate_cat11_use_of_products() -> dict:
"""Cat. 11: Energia consumata dai clienti usando il SaaS"""
# SCI = 0.045 gCO2e per ogni API call (misurato con CodeCarbon)
sci_gco2e_per_call = 0.045
avg_calls_per_month = 48_500_000 # 48.5M calls/mese (dati produzione)
months = 12
total_calls = avg_calls_per_month * months
co2e_grams = total_calls * sci_gco2e_per_call
co2e_kg = co2e_grams / 1000
activities = [
{"value_kg": co2e_kg, "uncertainty_pct": 25.0, "quality_tier": "TIER_2"}
]
result = calculate_category_total(activities)
result["category"] = 11
result["metric"] = "API calls"
result["total_calls"] = total_calls
return result
def run_full_scope3_calculation() -> dict:
"""Esegue il calcolo completo Scope 3 per SaaS Italia S.r.l."""
results = {
"cat_1_cloud": calculate_cat1_cloud(),
"cat_6_travel": calculate_cat6_business_travel(),
"cat_7_commuting": calculate_cat7_commuting(),
"cat_11_use": calculate_cat11_use_of_products(),
}
# Categoria residuale (spend-based per tutto il resto)
residual_spend_eur = 210_000 # ~5% della spesa totale
residual_co2_kg, res_unc = calculate_spend_based(
residual_spend_eur, "it_services"
)
results["cat_residual"] = {
"total_co2e_tonnes": residual_co2_kg / 1000,
"uncertainty_pct": res_unc,
"category": "other",
"dominant_quality_tier": "TIER_3"
}
# Totale Scope 3
total_tco2e = sum(
v["total_co2e_tonnes"] for v in results.values()
)
from calculators.emission_calculator import propagate_uncertainty
all_values = [v["total_co2e_tonnes"] for v in results.values()]
all_uncertainties = [v["uncertainty_pct"] for v in results.values()]
overall_uncertainty = propagate_uncertainty(all_values, all_uncertainties)
return {
"company": "SaaS Italia S.r.l.",
"reporting_year": 2024,
"methodology": "GHG Protocol Corporate Value Chain Standard",
"scope3_total_tco2e": round(total_tco2e, 1),
"overall_uncertainty_pct": round(overall_uncertainty, 1),
"categories": results,
"notes": "Cat. 11 include radiative forcing factor per aviation"
}
if __name__ == "__main__":
import json
report = run_full_scope3_calculation()
print("=" * 50)
print(f"SCOPE 3 TOTALE: {report['scope3_total_tco2e']} tCO2e")
print(f"Incertezza: +/- {report['overall_uncertainty_pct']}%")
print("=" * 50)
for name, cat in report["categories"].items():
tco2e = cat.get("total_co2e_tonnes", 0)
unc = cat.get("uncertainty_pct", 0)
pct = tco2e / report["scope3_total_tco2e"] * 100
print(f" {name:25s} {tco2e:6.1f} tCO2e ({pct:.0f}%) ±{unc:.0f}%")
Il risultato del calcolo per SaaS Italia S.r.l. produce:
Risultati Finali Scope 3 - SaaS Italia S.r.l. (FY 2024)
| Categoria | tCO2e | % Totale | Incertezza | Tier |
|---|---|---|---|---|
| Cat. 1 – Cloud & Servizi | 43.7 | 36% | ±9% | TIER 1 |
| Cat. 6 – Business Travel | 33.5 | 28% | ±20% | TIER 2 |
| Cat. 7 – Commuting | 23.8 | 20% | ±17% | TIER 2 |
| Cat. 11 – Use of Products | 26.2 | 22% | ±25% | TIER 2 |
| Cat. Residuali | 6.5 | 5% | ±75% | TIER 3 |
| TOTALE SCOPE 3 | 133.7 | 100% | ±13% | TIER 2 |
Aggiungendo Scope 1 (~8 tCO2e dalla caldaia del HQ) e Scope 2 (~12 tCO2e dall'elettricità degli uffici), si ottiene un footprint totale di ~154 tCO2e per il 2024, di cui il 87% è Scope 3. Esattamente il pattern tipico delle aziende software.
Best Practice e Anti-Pattern nella Pipeline Scope 3
Checklist Implementazione Pipeline Scope 3
| Area | Best Practice | Anti-Pattern da Evitare |
|---|---|---|
| Dati | Bronze layer immutabile per ogni dato ricevuto | Sovrascrivere i dati grezzi con versioni corrette |
| Calcolo | Versionare i fattori di emissione usati | Usare EF senza indicare anno e fonte |
| Incertezza | Propagare sempre l'incertezza su ogni categoria | Riportare solo il valore puntuale senza range |
| Qualità | Score qualità esplicito e documentato | Mescolare TIER 1 e TIER 3 senza distinzione |
| Audit | Hash chain per ogni calcolo, verificabile off-chain | Report Excel non versionato e non tracciabile |
| Supplier | Prioritizzare top 20 supplier per spesa/emissioni | Trattare tutti 50 supplier allo stesso modo |
| Aggiornamento | Piano annuale di miglioramento qualità dato | Accettare spend-based come soluzione permanente |
| Scope | Documentare esplicitamente le esclusioni motivate | Escludere categorie senza motivazione formale |
Piano di Miglioramento Progressivo (Data Maturity Roadmap)
Il GHG Protocol incoraggia esplicitamente un approccio progressivo: è meglio avere dati spend-based di bassa qualità oggi che non avere nulla. L'obiettivo è migliorare ogni anno il tier qualità delle categorie materiali:
- Anno 1 (baseline): 100% spend-based, errore ±75%, TIER 3
- Anno 2: top 10 supplier con activity-based, ±40%, TIER 2
- Anno 3: top 20 supplier con dati primari verificati, ±20%, TIER 2
- Anno 4+: integrazione EcoVadis/CDP per tutti i supplier, ±10%, TIER 1
Questo miglioramento progressivo è documentabile nel report CSRD come "evoluzione della metodologia" e viene valutato positivamente dai revisori.
Schema Database per il Gold Layer
Il Gold layer richiede uno schema progettato per supportare query di aggregazione rapide per il reporting CSRD, mantenendo la tracciabilità verso l'audit chain.
-- schema/scope3_gold.sql
-- Schema PostgreSQL per il Gold Layer Scope 3
-- Tabella principale: emissioni aggregate per categoria
CREATE TABLE scope3_emissions_gold (
id BIGSERIAL PRIMARY KEY,
company_id VARCHAR(50) NOT NULL,
reporting_year INTEGER NOT NULL,
scope3_category INTEGER NOT NULL CHECK (scope3_category BETWEEN 1 AND 15),
supplier_id VARCHAR(100),
-- Valori emissioni
co2e_tonnes DECIMAL(12, 3) NOT NULL,
co2_tonnes DECIMAL(12, 3),
ch4_tonnes_co2e DECIMAL(12, 3),
n2o_tonnes_co2e DECIMAL(12, 3),
-- Metodologia e qualità
calculation_method VARCHAR(30) NOT NULL, -- activity_based, spend_based, etc.
emission_factor_source VARCHAR(100) NOT NULL,
emission_factor_value DECIMAL(10, 6),
quality_tier VARCHAR(10) NOT NULL, -- TIER_1, TIER_2, TIER_3
uncertainty_pct DECIMAL(5, 1) NOT NULL,
uncertainty_tonnes DECIMAL(12, 3) GENERATED ALWAYS AS
(co2e_tonnes * uncertainty_pct / 100) STORED,
-- Tracciabilità
audit_chain_id UUID NOT NULL REFERENCES scope3_audit_chain(chain_id),
pipeline_version VARCHAR(20) NOT NULL,
reporting_standard VARCHAR(50) DEFAULT 'GHG_Protocol_Scope3_2011',
-- Timestamps
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Unicità per reporting period
CONSTRAINT uq_emission_period
UNIQUE (company_id, reporting_year, scope3_category, supplier_id)
);
-- Indici per performance query CSRD report
CREATE INDEX idx_scope3_company_year
ON scope3_emissions_gold (company_id, reporting_year);
CREATE INDEX idx_scope3_category
ON scope3_emissions_gold (scope3_category);
CREATE INDEX idx_scope3_quality
ON scope3_emissions_gold (quality_tier, uncertainty_pct);
-- View per report aggregato CSRD
CREATE VIEW v_scope3_csrd_report AS
SELECT
company_id,
reporting_year,
scope3_category,
SUM(co2e_tonnes) AS total_co2e_tonnes,
-- Propagazione incertezza quadratica
SQRT(SUM(POWER(co2e_tonnes * uncertainty_pct / 100, 2))) /
NULLIF(SUM(co2e_tonnes), 0) * 100 AS combined_uncertainty_pct,
-- Qualità aggregata (tier peggiore nella categoria)
MIN(quality_tier) AS data_quality_tier,
-- Metodo più usato
MODE() WITHIN GROUP (ORDER BY calculation_method) AS primary_method,
COUNT(DISTINCT supplier_id) AS supplier_count,
MAX(updated_at) AS last_updated
FROM scope3_emissions_gold
GROUP BY company_id, reporting_year, scope3_category
ORDER BY company_id, reporting_year, scope3_category;
-- Tabella audit chain
CREATE TABLE scope3_audit_chain (
chain_id UUID PRIMARY KEY,
record_id UUID NOT NULL DEFAULT gen_random_uuid(),
sequence INTEGER NOT NULL,
record_type VARCHAR(50) NOT NULL,
previous_hash CHAR(64) NOT NULL,
record_hash CHAR(64) NOT NULL UNIQUE,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_chain_sequence UNIQUE (chain_id, sequence)
);
-- Indice per verifica integrità chain
CREATE INDEX idx_audit_chain_id_seq
ON scope3_audit_chain (chain_id, sequence);
Conclusioni e Prossimi Passi
Costruire una pipeline robusta per le emissioni Scope 3 non è un esercizio accademico: è un'infrastruttura dati critica che diventerà obbligatoria per migliaia di aziende europee entro il 2028. I principi chiave che abbiamo visto in questo articolo sono applicabili indipendentemente dalla dimensione aziendale:
- Immutabilità dei dati grezzi: il Bronze layer con hash SHA-256 garantisce che ogni revisore possa sempre risalire alla fonte originale del dato, anche anni dopo.
- Progressività metodologica: iniziare con spend-based e migrare verso activity-based per le categorie materiali è l'approccio raccomandato dal GHG Protocol stesso, non una scorciatoia.
- Quantificazione dell'incertezza: riportare le emissioni senza un intervallo di confidenza è informazione incompleta. La propagazione quadratica dell'incertezza è semplice da implementare e fondamentale per la credibilità del report.
- Audit trail verificabile: la hash chain permette a un verificatore esterno di confermare matematicamente che nessun dato è stato alterato post-calcolo.
- Integrazione ecosistema: piattaforme come EcoVadis Carbon Data Network e CDP riducono enormemente il burden di raccolta dati, specialmente per grandi supply chain.
Il caso studio di SaaS Italia S.r.l. mostra che anche un'azienda di medie dimensioni può produrre un Scope 3 report CSRD-compliant in 3 mesi con un team di 2-3 persone, combinando dati primari per le categorie materiali e spend-based per il residuale. La chiave è la priorizazione: non cercare la perfezione ovunque, ma concentrare l'effort dove le emissioni sono più alte.
Risorse Utili
- GHG Protocol Scope 3 Standard: ghgprotocol.org/corporate-value-chain-scope-3-standard
- Climatiq API (emission factors database): climatiq.io
- EcoVadis Carbon Data Network: ecovadis.com/solutions/carbon
- ESRS E1 Climate Change (testo ufficiale EU): EFRAG ESRS E1
- EXIOBASE 3.8 (EEIO spend-based factors): exiobase.eu
Prossimo Articolo della Serie
Nel prossimo articolo API ESG Reporting: Integrazione con Workflow CSRD costruiremo un layer API REST sopra i dati Scope 3 calcolati in questo articolo, implementando endpoint conformi ai formati richiesti dalla direttiva europea e integrando il workflow di approvazione del report con firma digitale del revisore.
Vedremo anche come esporre i dati in formato XBRL/iXBRL per la submission all'ESEF (European Single Electronic Format), il formato obbligatorio per i report CSRD quotati in borsa europea.

Commenti
Caricamento commenti...
Accedi per commentare