---
title: "Temperaturas italianas, 2006–2023: una historia de datos climáticos"
description: "Desde un amplio libro de ISTAT hasta datos ordenados, estimaciones de tendencias y gráficos de matplotlib estilizados para combinar con el blog de las capitales de provincia italianas."
author: "Federico Viscioletti"
date: "2026-07-02"
lang: es
categories: [data science, python, climate, data visualization]
image: "images/italian-temperatures-analysis.png"
jupyter: python3
translations:
en: /posts/2026/07/02/italian-temperatures-analysis/
fr: /posts/2026/07/02/temperatures-italiennes-2006-2023-une-histoire-de-donnees-climatiques/
it: /posts/2026/07/02/temperature-italiane-2006-2023-una-storia-di-dati-climatici/
format:
html:
toc: true
code-fold: false
code-tools: true
code-overflow: wrap
embed-resources: false
fig-responsive: true
execute:
warning: false
message: false
echo: true
cache: false
fig-width: 7
fig-height: 4.2
fig-align: center
out-width: 100%
---
<style>
img.cover {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
object-position: center 25%;
}
img.post-photo-short {
width: 100%;
height: 420px;
object-fit: cover;
object-position: center 50%;
}
</style>
<script src="https://cdn.plot.ly/plotly-3.6.0.min.js"></script>
<img src="images/italian-temperatures-analysis.png" title="lo stivale, visto desde el espacio" class="cover"/>
# Introducción
Italia concentra climas alpino, continental y mediterráneo en una península estrecha. Eso la convierte en un buen lugar para estudiar cómo el calentamiento y los cambios en las precipitaciones se manifiestan en entornos locales muy diferentes.
En este artículo uso el libro de datos meteoclimáticos actualizado de ISTAT para las **capitales de provincia** para responder a unas cuantas preguntas sencillas:
- ¿Con qué rapidez se están calentando las capitales de provincia italianas?
- ¿Qué lugares son los más cálidos de media?
- ¿Avanza la precipitación en la misma dirección?
- ¿Cómo fue 2023 en relación con las líneas base recientes e históricas?
El libro de origen es rico, pero está estructurado de forma incómoda. Como suele ocurrir con las estadísticas oficiales, lo difícil no es dibujar los gráficos, sino convertir un diseño de Excel pensado para lectura humana en datos ordenados.
## Una nota para futuros científicos de datos
Una de las habilidades más potentes que puede desarrollar un científico de datos es la capacidad de convertir datos en bruto en información clara y accionable. Los organismos estadísticos nacionales como el **ISTAT** en Italia, **Eurostat** o la **US Census Bureau** ofrecen una gran cantidad de conjuntos de datos gratuitos y de alta calidad para experimentar y obtener conclusiones valiosas.
Como verás en este análisis, la barrera técnica es en realidad bastante baja. Una vez que aprendes a manejar libros de trabajo estructurados y a realizar un Análisis Exploratorio de Datos (EDA) básico, puedes descubrir historias sobre los campos que te apasionan: desde el cambio climático hasta la economía o las tendencias sociales. Espero que este cuaderno te sirva de hoja de ruta en tu propio proceso de descubrimiento.
*Este artículo reconstruye el análisis original en formato cuaderno usando el libro de ISTAT actualizado para las capitales de provincia, que cubre la temperatura y la precipitación anuales de 2006 a 2023, además de tablas de anomalías para 2023.*
Si te gusta este trabajo, por favor dale una estrella al [repositorio de Github relacionado siguiendo este enlace](https://www.github.com/feddernico/temperature-italia)
# Cargar el libro de trabajo
Las hojas clave para el análisis principal son:
- `Tavola_1`: temperatura media anual por capital de provincia, 2006–2023
- `Tavola_2`: precipitación anual por capital de provincia, 2006–2023
- `Tavola_3`: anomalías de temperatura de 2023 frente a 2006–2015 y a las normales climatológicas
```{python}
import matplotlib
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio
from pathlib import Path
pio.renderers.default = "notebook_connected"
pio.renderers["notebook_connected"].include_plotlyjs = False
FILE_PATH = (
"Tavole-_Dati-Meteoclimatici_Capoluoghi-Provincia_Anno-2023-e-serie-2006-2023.xlsx"
)
# --- Blog design system palette (see design-system.html) -----------------
BLUE = "#2780e3"
CHARCOAL = "#343a40"
INK = "#1e1e1e"
TEXT = "#343a40"
MUTED = "rgba(52, 58, 64, 0.72)"
MUTED_HEX = "#6c757d"
LINE = "#dee2e6"
SOFT = "#f8f9fa"
SOFT_BLUE = "#d4e6f9"
CODE = "#7d12ba"
matplotlib.rcParams.update(
{
"font.family": "sans-serif",
"font.size": 12,
"text.color": TEXT,
"axes.labelcolor": TEXT,
"axes.edgecolor": LINE,
"axes.titlecolor": TEXT,
"axes.titleweight": "normal",
"axes.titlesize": 16,
"axes.spines.top": False,
"axes.spines.right": False,
"axes.grid": True,
"grid.color": LINE,
"grid.linewidth": 0.8,
"grid.linestyle": "-",
"axes.facecolor": "#ffffff",
"figure.facecolor": "#ffffff",
"xtick.color": MUTED_HEX,
"ytick.color": MUTED_HEX,
"axes.linewidth": 1.0,
"legend.frameon": False,
"legend.fontsize": 11,
"figure.dpi": 130,
}
)
xls = pd.ExcelFile(FILE_PATH)
xls.sheet_names
```
# De formato ancho a ordenado
Las tablas anuales se almacenan en formato **ancho** (wide): una fila por ciudad y una columna por año. Ese formato es cómodo para inspección visual, pero incómodo para el análisis. Un pequeño parser nos permite fundir (melt) cada hoja a formato largo gestionando el centinela de valores ausentes del archivo (`....`).
```{python}
def parse_table(sheet_name, value_name):
df = pd.read_excel(FILE_PATH, sheet_name=sheet_name, header=None)
years = [int(x) for x in df.iloc[3, 1:19].tolist()]
data = df.iloc[4:, :19].copy()
data.columns = ["Province"] + years
data = data[data["Province"].notna()]
data = data[
~data["Province"]
.astype(str)
.str.contains(
r"Fonte|Totale|Nota|Nelle tavole|^\s*$",
regex=True,
na=False,
)
]
long = data.melt(id_vars="Province", var_name="Year", value_name=value_name)
long["Province"] = long["Province"].astype(str).str.strip()
long["Year"] = pd.to_numeric(long["Year"], errors="coerce")
long[value_name] = pd.to_numeric(
long[value_name].replace("....", np.nan),
errors="coerce",
)
return long
temperature_data = parse_table("Tavola_1", "Temperature")
precipitation_data = parse_table("Tavola_2", "Precipitation")
italy_weather_data = temperature_data.merge(
precipitation_data,
on=["Province", "Year"],
how="inner",
)
italy_weather_data.head()
```
```{python}
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)
```
Una comprobación rápida confirma que el conjunto de datos limpio abarca todo el rango 2006–2023.
```{python}
italy_weather_data.agg(
provinces=("Province", "nunique"),
start_year=("Year", "min"),
end_year=("Year", "max"),
)
```
# Tendencias nacionales
Una primera visión natural es la media anual de todas las capitales de provincia. Esto comprime la variación local en un único resumen nacional y hace mucho más fácil ver la dirección del cambio.
```{python}
def linear_slope(df, y_col):
clean = df.dropna(subset=[y_col]).copy()
x = clean["Year"].to_numpy(dtype=float)
y = clean[y_col].to_numpy(dtype=float)
if len(x) < 2:
return np.nan
return float(np.polyfit(x, y, 1)[0])
italy_temperature_trend = (
italy_weather_data.groupby("Year", as_index=False)["Temperature"].mean().dropna()
)
temp_coef = np.polyfit(
italy_temperature_trend["Year"].to_numpy(dtype=float),
italy_temperature_trend["Temperature"].to_numpy(dtype=float),
1,
)
italy_temperature_trend["Trend"] = (
temp_coef[0] * italy_temperature_trend["Year"] + temp_coef[1]
)
national_temp_slope = linear_slope(italy_temperature_trend, "Temperature")
national_temp_change = national_temp_slope * (2023 - 2006)
national_temp_slope, national_temp_change
```
En el conjunto de las capitales de provincia, la tasa de calentamiento ajustada es de aproximadamente **0,055 °C por año**, es decir, aproximadamente **0,9–1,0 °C en todo el periodo**. Eso no significa que cada ciudad se caliente exactamente al mismo ritmo, pero establece el telón de fondo nacional para el resto del análisis.
```{python}
#| label: fig-italy-temperature-trend
#| fig-cap: "Temperatura media anual en las capitales de provincia italianas, 2006–2023."
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
def parse_table(sheet_name, value_name):
df = pd.read_excel(FILE_PATH, sheet_name=sheet_name, header=None)
years = [int(x) for x in df.iloc[3, 1:19].tolist()]
data = df.iloc[4:, :19].copy()
data.columns = ["Province"] + years
data = data[data["Province"].notna()]
data["Province"] = data["Province"].astype(str).str.strip()
data = data[~data["Province"].str.contains(r"Fonte|Totale|Nota|Nelle tavole|^$", regex=True, na=False)]
long = data.melt(id_vars="Province", var_name="Year", value_name=value_name)
long["Year"] = pd.to_numeric(long["Year"], errors="coerce")
long[value_name] = pd.to_numeric(long[value_name].replace("....", np.nan), errors="coerce")
return long
italy_mean = italy_weather_data.groupby("Year", as_index=False)["Temperature"].mean().dropna()
coef = np.polyfit(italy_mean["Year"], italy_mean["Temperature"], 1)
italy_mean["Trend"] = coef[0] * italy_mean["Year"] + coef[1]
BG = "#f7f6f2"; PANEL = "#f9f8f5"; TEXT = "#28251d"; MUTED = "#7a7974"; GRID = "#dcd9d5"; ACCENT = "#01696f"
plt.rcParams.update({
"figure.facecolor": BG, "axes.facecolor": PANEL, "axes.edgecolor": GRID, "axes.labelcolor": TEXT,
"axes.titlecolor": TEXT, "xtick.color": MUTED, "ytick.color": MUTED, "text.color": TEXT,
"font.size": 11, "axes.titlesize": 18, "axes.titleweight": "bold", "legend.frameon": False
})
fig, ax = plt.subplots(figsize=(10.5, 5.8))
ax.plot(italy_mean["Year"], italy_mean["Temperature"], color=ACCENT, linewidth=2.8, marker="o", markersize=5.5, label="Annual mean", zorder=3)
ax.plot(italy_mean["Year"], italy_mean["Trend"], color=TEXT, linewidth=1.8, linestyle=(0, (4, 4)), alpha=0.7, label="Linear trend", zorder=2)
# Set title with padding and place subtitle below it
ax.set_title("Average annual temperature across Italian provincial capitals", loc="left", pad=38)
ax.text(0, 1.02, "Mean annual temperature by provincial capital, averaged nationally, 2006–2023", transform=ax.transAxes, fontsize=10.5, color=MUTED)
ax.set_xlabel("Year"); ax.set_ylabel("Temperature (°C)")
ax.xaxis.set_major_locator(MaxNLocator(integer=True))
ax.set_xticks(italy_mean["Year"][::2])
ax.grid(axis="y", color=GRID, linewidth=0.8, alpha=0.85)
ax.grid(axis="x", visible=False)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.legend(loc="upper left", ncol=2)
plt.tight_layout()
plt.show()
```
La precipitación es mucho más ruidosa. La media anual todavía puede resumirse con una línea sencilla, pero las oscilaciones interanuales son mucho mayores que para la temperatura, por lo que la tendencia ajustada debe leerse con más cautela. La banda sombreada del gráfico siguiente es una **dispersión descriptiva** — una desviación estándar de las medias nacionales anuales — añadida solo como guía visual de lo erráticamente que salta la precipitación de un año a otro. *No* es un intervalo de confianza ni ningún tipo de estimación de incertidumbre alrededor de la tendencia.
```{python}
italy_precipitation_trend = (
italy_weather_data.groupby("Year", as_index=False)["Precipitation"]
.mean()
.dropna()
)
prec_coef = np.polyfit(
italy_precipitation_trend["Year"].to_numpy(dtype=float),
italy_precipitation_trend["Precipitation"].to_numpy(dtype=float),
1,
)
italy_precipitation_trend["Trend"] = (
prec_coef[0] * italy_precipitation_trend["Year"] + prec_coef[1]
)
national_prec_slope = linear_slope(
italy_precipitation_trend,
"Precipitation"
)
print(f"The overall trend is {round(national_prec_slope, 2)}mm")
```
```{python}
#| label: fig-italy-precipitation-trend
#| fig-cap: "Precipitación media anual en las capitales de provincia italianas, 2006–2023."
fig, ax = plt.subplots(figsize=(10.5, 5.8))
# Descriptive spread: ±1 standard deviation of the yearly national means.
# This is a visual guide to year-to-year variability, NOT a confidence
# interval or an estimate of uncertainty around the trend line.
prec_std = italy_precipitation_trend["Precipitation"].std()
italy_precipitation_trend["Lower"] = italy_precipitation_trend["Precipitation"] - prec_std
italy_precipitation_trend["Upper"] = italy_precipitation_trend["Precipitation"] + prec_std
# Shaded band
ax.fill_between(italy_precipitation_trend["Year"], italy_precipitation_trend["Lower"], italy_precipitation_trend["Upper"], facecolor=ACCENT, alpha=0.12, linewidth=0, zorder=1, label="±1σ (year-to-year spread)")
# Lines
ax.plot(italy_precipitation_trend["Year"], italy_precipitation_trend["Precipitation"], color=ACCENT, linewidth=2.8, marker="o", markersize=5.5, label="Annual mean", zorder=3)
ax.plot(italy_precipitation_trend["Year"], italy_precipitation_trend["Trend"], color=TEXT, linewidth=1.8, linestyle=(0, (4, 4)), alpha=0.7, label="Linear trend", zorder=2)
# Titles and Subtitles
ax.set_title("Italy precipitation trend (2006–2023)", loc="left", pad=38)
ax.text(0, 1.02, "Mean annual precipitation across provincial capitals from ISTAT Tavola_2", transform=ax.transAxes, fontsize=10.5, color=MUTED)
ax.set_xlabel("Year"); ax.set_ylabel("Rain (mm)")
ax.xaxis.set_major_locator(MaxNLocator(integer=True))
ax.set_xticks(italy_precipitation_trend["Year"][::2])
ax.grid(axis="y", color=GRID, linewidth=0.8, alpha=0.85)
ax.grid(axis="x", visible=False)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.legend(loc="lower right")
plt.tight_layout()
plt.show()
```
# Los lugares más cálidos
Promediar la temperatura en todo el periodo 2006–2023 da una clasificación sencilla de "ciudades más cálidas". Como es de esperar, las líderes se concentran en Sicilia y el extremo sur.
```{python}
#| label: tbl-hottest-cities
#| tbl-cap: "Cinco capitales de provincia más cálidas por temperatura media, 2006–2023."
top_hottest = (
italy_weather_data.groupby("Province", as_index=False)["Temperature"]
.mean()
.sort_values("Temperature", ascending=False)
.head(5)
)
top_hottest
```
Ser cálido no es lo mismo que calentarse rápido. Para estimar las tasas de calentamiento, ajusto una pendiente lineal para cada ciudad después de conservar solo los lugares con al menos 15 observaciones anuales válidas, lo que reduce la influencia de historiales de reporte incompletos.
```{python}
def slope_per_city(group, value_col):
clean = group.dropna(subset=[value_col])
x = clean["Year"].to_numpy(dtype=float)
y = clean[value_col].to_numpy(dtype=float)
if len(x) < 2:
return np.nan
return round(float(np.polyfit(x, y, 1)[0]), 3)
warming_base = italy_weather_data.groupby("Province").filter(
lambda g: g["Temperature"].notna().sum() >= 15
)
warming_rates = (
warming_base.groupby("Province")
.apply(lambda g: slope_per_city(g, "Temperature"))
.rename("Trend (°C/year)")
.reset_index()
.sort_values("Trend (°C/year)", ascending=False)
)
warming_rates.head(10)
```
```{python}
#| label: fig-fastest-warming
#| fig-cap: "Capitales de provincia con calentamiento más rápido, filtrado a ciudades con al menos 15 observaciones anuales válidas."
top_warming = warming_rates.head(10)
fig, ax = plt.subplots(figsize=(10.5, 6.5))
bars = ax.barh(top_warming["Province"][::-1], top_warming["Trend (°C/year)"][::-1], color=ACCENT, alpha=0.8, edgecolor="none", height=0.65)
# Subtitle below title
ax.set_title("Fastest Warming Italian Provincial Capitals", loc="left", pad=38)
ax.text(0, 1.02, "Linear slope from annual mean temperature, cities with at least 15 valid years", transform=ax.transAxes, fontsize=10.5, color=MUTED_HEX)
ax.set_xlabel("Warming Trend (°C per year)", color=MUTED_HEX, fontsize=11, labelpad=10)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.grid(axis="x", linestyle="-", color=GRID, alpha=0.8)
ax.grid(axis="y", visible=False)
for bar in bars:
width = bar.get_width()
ax.text(width + 0.003, bar.get_y() + bar.get_height()/2, f"+{width:.3f}", va="center", color=TEXT, fontsize=10, fontweight="bold")
plt.tight_layout()
plt.show()
```
# Anomalías de 2023
Las series largas son útiles, pero el libro también incluye una tabla de anomalías específica para 2023. `Tavola_3` compara 2023 con la media de 2006–2015 y, cuando está disponible, con las normales climatológicas de 1981–2010 y 1971–2000.
```{python}
anomaly_sheet = pd.read_excel(FILE_PATH, sheet_name="Tavola_3", header=None)
anomaly_data = anomaly_sheet.iloc[4:114, :7].copy()
anomaly_data.columns = [
"Province",
"Diff_2023_vs_2006_2015",
"Mean_2006_2015",
"Anom_1981_2010",
"Clim_1981_2010",
"Anom_1971_2000",
"Clim_1971_2000",
]
anomaly_data = anomaly_data[anomaly_data["Province"].notna()]
anomaly_data = anomaly_data[
~anomaly_data["Province"].astype(str).str.contains(
r"Fonte|^\s*$",
regex=True,
na=False,
)
]
anomaly_data["Province"] = anomaly_data["Province"].astype(str).str.strip()
for col in anomaly_data.columns[1:]:
anomaly_data[col] = pd.to_numeric(
anomaly_data[col].replace("....", np.nan),
errors="coerce",
)
anomaly_data.head()
```
### Entender la línea base 1981–2010
En climatología, una **anomalía** es la diferencia entre la temperatura observada y una media a largo plazo llamada *normal climatológica*. La Organización Meteorológica Mundial (OMM) usa periodos de 30 años para establecer estas normales.
- **Anomalía de +0,0 °C**: el año fue exactamente tan cálido como la media de 1981–2010.
- **Anomalía positiva (p. ej., +2,66 °C)**: el año fue más cálido que la línea base histórica.
Cuando vemos valores que superan los +2,0 °C en la tabla siguiente, indica que esas ciudades experimentaron un año significativamente más cálido que las condiciones que caracterizaban el final del siglo XX.
Una advertencia sobre la cobertura: `Tavola_3` no proporciona anomalías de normales climatológicas para todas las capitales de provincia. La hoja señala que esas líneas base de referencia se calculan solo para **capitales de región** con series suficientemente largas, y algunas de las diferencias de 2023 frente a 2006–2015 también faltan donde la serie reciente está incompleta. El ranking siguiente es, por tanto, **entre lugares con comparaciones climatológicas disponibles, no todas las capitales de provincia** — muestra qué ciudades destacan dentro de ese subconjunto comparable, no una lista exhaustiva de cada lugar cálido de Italia. Dentro de ese subconjunto, 2023 destaca con especial fuerza en ciudades como **Perugia**, **Bologna**, **Milano** y **Torino**.
```{python}
#| label: tbl-top-anomalies
#| tbl-cap: "Mayores anomalías de temperatura de 2023 respecto a la climatología 1981–2010."
top_anomalies = (
anomaly_data.dropna(subset=["Anom_1981_2010"])
.sort_values("Anom_1981_2010", ascending=False)
[["Province", "Anom_1981_2010"]]
.head(10)
)
top_anomalies
```
```{python}
#| label: fig-2023-anomalies
#| fig-cap: "Mayores anomalías de temperatura de 2023 respecto a la climatología 1981–2010."
top_anomalies_plot = anomaly_data.dropna(subset=["Anom_1981_2010"]).sort_values("Anom_1981_2010", ascending=False).head(10)
fig, ax = plt.subplots(figsize=(10.5, 6.5))
bars = ax.barh(top_anomalies_plot["Province"][::-1], top_anomalies_plot["Anom_1981_2010"][::-1], color=ACCENT, alpha=0.8, edgecolor="none", height=0.65)
# Subtitle below title
ax.set_title("Largest 2023 temperature anomalies", loc="left", pad=38)
ax.text(0, 1.02, "Relative to 1981–2010 climatology from ISTAT Tavola_3", transform=ax.transAxes, fontsize=10.5, color=MUTED_HEX)
ax.set_xlabel("Anomaly (°C)", color=MUTED_HEX, fontsize=11, labelpad=10)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.grid(axis="x", linestyle="-", color=GRID, alpha=0.8)
ax.grid(axis="y", visible=False)
for bar in bars:
width = bar.get_width()
ax.text(width + 0.05, bar.get_y() + bar.get_height()/2, f"+{width:.2f}", va="center", color=TEXT, fontsize=10, fontweight="bold")
plt.tight_layout()
plt.show()
```
### Correlación: calor frente a precipitación
Un seguimiento natural es si los años más cálidos también tienden a ser más húmedos o más secos. Representar la temperatura media nacional anual frente a la precipitación total ofrece una primera vista de cómo se mueven juntos los dos agregados.
```{python}
#| label: fig-2023-heat-vs-rainfall
#| fig-cap: "Correlación: temperatura anual frente a precipitación."
import seaborn as sns
# Prepare data for correlation
corr_df = italy_temperature_trend[['Year', 'Temperature']].merge(
italy_precipitation_trend[['Year', 'Precipitation']],
on='Year'
)
fig, ax = plt.subplots(figsize=(8, 6))
sns.regplot(data=corr_df, x='Temperature', y='Precipitation',
scatter_kws={'color': ACCENT, 's': 60},
line_kws={'color': TEXT, 'linestyle': '--', 'linewidth': 1.5}, ax=ax)
ax.set_title("Correlation: Annual Temp vs. Precipitation", loc='left', pad=25)
ax.set_xlabel("Mean Temperature (°C)")
ax.set_ylabel("Total Precipitation (mm)")
# Calculate correlation coefficient
r_val = corr_df['Temperature'].corr(corr_df['Precipitation'])
ax.text(0.05, 0.05, f"Pearson r = {r_val:.2f}", transform=ax.transAxes,
fontsize=12, fontweight='bold', color=TEXT)
plt.tight_layout()
plt.show()
```
El diagrama de dispersión muestra una asociación negativa de débil a moderada en estos agregados anuales: a lo largo de los 18 años, los años más cálidos han tendido a coincidir con una precipitación nacional algo menor. Con un Pearson r de aproximadamente −0,34, la relación es real pero lejos de ser ajustada — muchos años cálidos no fueron notablemente secos. Esto solo describe cómo covarían las dos medias nacionales de un año a otro; no dice nada sobre el mecanismo físico que impulsa cada serie, y un único año puede mover la relación de forma notable — el fuerte descenso de 2022, por ejemplo, refleja la severa sequía que sufrió Italia ese año y tira visiblemente de la línea de regresión hacia abajo.
# Cartografiar el cambio
Un gráfico de series temporales muestra *cuándo* suben las temperaturas. Un mapa muestra *dónde* se sitúa el calor. Para geocodificar las capitales de provincia una sola vez, guardo las coordenadas en caché localmente y las reutilizo en renderizados posteriores.
```{python}
#| label: fig-temperature-map
#| fig-cap: "Temperatura media anual por capital de provincia para años seleccionados, 2006–2023."
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.io as pio
# Re-defining the parser to ensure data is available
def parse_table(sheet_name, value_name):
df = pd.read_excel(FILE_PATH, sheet_name=sheet_name, header=None)
years = [int(x) for x in df.iloc[3, 1:19].tolist()]
data = df.iloc[4:, :19].copy()
data.columns = ["Province"] + years
data = data[data["Province"].notna()]
data["Province"] = data["Province"].astype(str).str.strip()
data = data[~data["Province"].str.contains(r"Fonte|Totale|Nota|Nelle tavole|^$", regex=True, na=False)]
long = data.melt(id_vars="Province", var_name="Year", value_name=value_name)
long["Year"] = pd.to_numeric(long["Year"], errors="coerce")
long[value_name] = pd.to_numeric(long[value_name].replace("....", np.nan), errors="coerce")
return long
# Load data
temp_df = parse_table("Tavola_1", "Temperature")
precip_df = parse_table("Tavola_2", "Precipitation")
italy_weather_data = temp_df.merge(precip_df, on=["Province", "Year"], how="inner")
# A dictionary of coordinates for Italian provincial capitals
city_coords = {
'Torino': [45.0703, 7.6869], 'Vercelli': [45.3238, 8.4232], 'Novara': [45.4468, 8.6212],
'Cuneo': [44.3833, 7.5500], 'Asti': [44.9005, 8.2069], 'Alessandria': [44.9129, 8.6154],
'Biella': [45.5630, 8.0579], 'Verbano-Cusio-Ossola': [45.9220, 8.5516], 'Aosta': [45.7371, 7.3201],
'Milano': [45.4642, 9.1900], 'Lodi': [45.3139, 9.5032], 'Monza e della Brianza': [45.5845, 9.2744],
'Bergamo': [45.6983, 9.6773], 'Brescia': [45.5398, 10.2181], 'Pavia': [45.1850, 9.1546],
'Como': [45.8081, 9.0852], 'Varese': [45.8167, 8.8333], 'Sondrio': [46.1690, 9.8731],
'Lecco': [45.8559, 9.3977], 'Mantova': [45.1564, 10.7911], 'Cremona': [45.1333, 10.0333],
'Bolzano': [46.4983, 11.3548], 'Trento': [46.0667, 11.1167], 'Verona': [45.4384, 10.9916],
'Vicenza': [45.5479, 11.5446], 'Belluno': [46.1408, 12.2161], 'Treviso': [45.6667, 12.2450],
'Venezia': [45.4408, 12.3155], 'Padova': [45.4064, 11.8768], 'Rovigo': [45.0711, 11.7905],
'Pordenone': [45.9569, 12.6605], 'Udine': [46.0625, 13.2346], 'Gorizia': [45.9409, 13.6222],
'Trieste': [45.6495, 13.7768], 'Piacenza': [45.0526, 9.6930], 'Parma': [44.8015, 10.3279],
'Reggio nell\'Emilia': [44.6982, 10.6312], 'Modena': [44.6471, 10.9252], 'Bologna': [44.4949, 11.3426],
'Ferrara': [44.8381, 11.6198], 'Ravenna': [44.4183, 12.2035], 'Forlì-Cesena': [44.2227, 12.0409],
'Rimini': [44.0594, 12.5684], 'Massa Carrara': [44.0375, 10.1417], 'Lucca': [43.8429, 10.5027],
'Pistoia': [43.9333, 10.9167], 'Firenze': [43.7696, 11.2558], 'Livorno': [43.5485, 10.3106],
'Pisa': [43.7085, 10.4036], 'Arezzo': [43.4631, 11.8781], 'Siena': [43.3186, 11.3306],
'Grosseto': [42.7667, 11.1167], 'Prato': [43.8777, 11.1022], 'Perugia': [43.1107, 12.3908],
'Terni': [42.5639, 12.6427], 'Ancona': [43.6158, 13.5189], 'Pesaro e Urbino': [43.9100, 12.9133],
'Macerata': [43.3003, 13.4531], 'Ascoli Piceno': [42.8536, 13.5768], 'Fermo': [43.1610, 13.7183],
'Viterbo': [42.4191, 12.1051], 'Rieti': [42.4000, 12.8667], 'Roma': [41.9028, 12.4964],
'Latina': [41.4676, 12.9036], 'Frosinone': [41.6394, 13.3411], 'L\'Aquila': [42.3489, 13.3980],
'Teramo': [42.6589, 13.7044], 'Pescara': [42.4618, 14.2142], 'Chieti': [42.3511, 14.1675],
'Campobasso': [41.5603, 14.6584], 'Isernia': [41.5947, 14.2342], 'Caserta': [41.0736, 14.3347],
'Benevento': [41.1297, 14.7821], 'Napoli': [40.8518, 14.2681], 'Avellino': [40.9144, 14.7936],
'Salerno': [40.6779, 14.7658], 'Foggia': [41.4622, 15.5446], 'Bari': [41.1171, 16.8719],
'Taranto': [40.4677, 17.2433], 'Brindisi': [40.6321, 17.9361], 'Lecce': [40.3515, 18.1750],
'Barletta': [41.3197, 16.2768], 'Potenza': [40.6404, 15.8051], 'Matera': [40.6664, 16.6043],
'Cosenza': [39.2983, 16.2537], 'Catanzaro': [38.9098, 16.5877], 'Reggio di Calabria': [38.1105, 15.6434],
'Crotone': [39.0808, 17.1273], 'Vibo Valentia': [38.6753, 16.1011], 'Messina': [38.1938, 15.5540],
'Palermo': [38.1157, 13.3615], 'Trapani': [38.0175, 12.5150], 'Agrigento': [37.3111, 13.5765],
'Caltanissetta': [37.4903, 14.0622], 'Enna': [37.5671, 14.2750], 'Catania': [37.5079, 15.0830],
'Ragusa': [36.9269, 14.7231], 'Siracusa': [37.0755, 15.2866], 'Sassari': [40.7259, 8.5615],
'Nuoro': [40.3231, 9.3303], 'Cagliari': [39.2238, 9.1217], 'Oristano': [39.9056, 8.5911],
'Carbonia': [39.1671, 8.5222],
'La Spezia': [44.2384, 9.6912],
'Andria': [41.2276, 16.2955],
'Trani': [41.2782, 16.4186],
}
# The workbook uses short province-capital names (e.g. "Verbania", "Monza",
# "Pesaro Urbino", "Forlì") that do not match the longer administrative keys
# in city_coords above. Normalize before lookup so no point disappears
# silently when the dictionary key uses a different spelling.
COORD_NAME_ALIASES = {
'Verbania': 'Verbano-Cusio-Ossola',
'Monza': 'Monza e della Brianza',
'Pesaro Urbino': 'Pesaro e Urbino',
'Forlì': 'Forlì-Cesena',
}
def lookup_coords(province):
key = COORD_NAME_ALIASES.get(province, province)
return city_coords.get(key, [None, None])
# Map coordinates and warn about anything still unmapped so a miss is loud,
# not silent.
italy_weather_data['Lat'] = italy_weather_data['Province'].map(lambda x: lookup_coords(x)[0])
italy_weather_data['Lon'] = italy_weather_data['Province'].map(lambda x: lookup_coords(x)[1])
unmapped = italy_weather_data.loc[italy_weather_data['Lat'].isna(), 'Province'].unique()
if len(unmapped) > 0:
print(f"Unmapped provinces (no coordinates): {list(unmapped)}")
map_df = italy_weather_data.dropna(subset=['Lat', 'Lon', 'Temperature']).copy()
# Prepare Mapbox map
fig = px.scatter_mapbox(
map_df,
lat="Lat", lon="Lon", color="Temperature",
hover_name="Province", animation_frame="Year",
title="Evolution of Annual Temperatures in Italy (2006-2023)",
color_continuous_scale=px.colors.sequential.YlOrRd,
range_color=[map_df['Temperature'].min(), map_df['Temperature'].max()],
mapbox_style="open-street-map",
zoom=4.5, center=dict(lat=42.0, lon=12.5)
)
fig.update_layout(height=700, margin=dict(r=10, t=80, b=40, l=10), paper_bgcolor=globals().get('BG', '#f7f6f2'))
fig
```
Una vista complementaria promedia cada capital en toda la ventana 2006–2023, de modo que el persistente gradiente norte–sur — Sicilia y el extremo sur brillando como los más cálidos, los Alpes como los más frescos — se lee de un vistazo.
```{python}
#| label: fig-mean-temperature-map
#| fig-cap: "Temperatura media anual por capital de provincia, promediada en 2006–2023."
# Calculate mean per city for the static map
mean_per_city = (
map_df.groupby("Province", as_index=False)
.agg(
Lat=("Lat", "first"),
Lon=("Lon", "first"),
Temperature=("Temperature", "mean"),
)
)
# Create a static Mapbox plot to match the interactive look and feel
fig_static = px.scatter_mapbox(
mean_per_city,
lat="Lat",
lon="Lon",
color="Temperature",
hover_name="Province",
size_max=15,
color_continuous_scale=px.colors.sequential.YlOrRd,
range_color=[map_df['Temperature'].min(), map_df['Temperature'].max()],
mapbox_style="open-street-map",
zoom=4.5,
center=dict(lat=42.0, lon=12.5),
title="<b>Mean temperature by provincial capital (2006–2023)</b><br><span style='font-size:12px; color:#7a7974'>Average temperature across the full series from ISTAT Tavola_1</span>"
)
fig_static.update_layout(
height=700,
margin=dict(r=10, t=80, b=40, l=10),
paper_bgcolor=BG,
font=dict(color=TEXT)
)
fig_static
```
# Conclusiones
El libro actualizado hace fácil ver el patrón nacional: las capitales de provincia italianas muestran una clara tendencia ascendente de temperatura en 2006–2023, mientras que la precipitación es mucho más ruidosa y menos uniforme.
Algunas conclusiones amplias destacan:
- La media entre las capitales de provincia apunta a un calentamiento constante a lo largo del periodo.
- Las ciudades más cálidas se concentran en Sicilia y el extremo sur.
- El ranking de "calentamiento más rápido" depende fuertemente de la completitud de los datos, así que filtrar las series dispersas importa.
- La tabla de anomalías de 2023 añade un contexto histórico importante, mostrando que varias ciudades no solo fueron cálidas en términos absolutos, sino inusualmente cálidas respecto a las líneas base climatológicas.
La próxima extensión natural sería incorporar las hojas de capitales de región (`Tavola_9` y `Tavola_10`) y las tablas de extremos (`Tavola_7`, `Tavola_8`, `Tavola_11`–`Tavola_14`) para un segundo artículo centrado en extremos de calor, noches cálidas e indicadores de lluvia intensa.
::: {.companion-resources aria-label="Recursos complementarios para el análisis de temperaturas italianas"}
## Reproduce el análisis
¿Prefieres ejecutarlo tú mismo? El cuaderno, el conjunto de datos ordenado y el repositorio están disponibles para que puedas revisar la limpieza, reutilizar los datos o ampliar los gráficos.
::: {.companion-resources__grid}
::: {.companion-resource}
### Cuaderno
Ejecuta el análisis completo localmente y reproduce las figuras del artículo.
[Descargar `.ipynb`](/downloads/italian_temperatures_2006_2023.ipynb){.btn .btn-primary data-cta-id="italy-temperatures-notebook" download="italian_temperatures_2006_2023.ipynb"}
:::
::: {.companion-resource}
### Dataset ordenado
Usa la tabla limpia provincia-año con columnas de temperatura y precipitación.
[Descargar `.csv`](/downloads/italian_temperatures_2006_2023_clean.csv){.btn .btn-outline-primary data-cta-id="italy-temperatures-csv" download="italian_temperatures_2006_2023_clean.csv"}
:::
::: {.companion-resource}
### Repositorio
Dale una estrella o haz un fork del proyecto complementario en GitHub.
[Ver en GitHub](https://github.com/feddernico/temperature-italia){.btn .btn-outline-primary data-cta-id="italy-temperatures-star"}
:::
:::
:::