Clima localității mele¶
Administrația Națională de Meteorologie publică pe data.gov.ro un set de date meteo zilnice gridate: temperatura maximă, temperatura minimă și precipitațiile, pentru fiecare zi din 1961 până ieri, pe o grilă de ~1 km care acoperă toată România. EOCube.Ro îl re-publică într-un singur cub de date (format Zarr, ~185 GB, actualizat zilnic) din care se poate citi doar bucata de interes.
În acest caiet alegem o localitate, decupăm cei 65 de ani de date pentru ea și răspundem la câteva întrebări simple: cât s-a încălzit, câte zile tropicale și de îngheț are un an, cum arată anul curent față de „normal”, cum au evoluat ploile.
Nu este nevoie de cont: cubul meteo este public și se citește anonim. Caietul
rulează neschimbat pe notebooks.eocube.ro sau pe
orice calculator cu Python (pip install xarray zarr s3fs rioxarray matplotlib eocube).
Modificați aici¶
Singurele lucruri de schimbat pentru a reface analiza pentru altă localitate: numele unității administrative (oraș sau comună, așa cum apare în nomenclatorul SIRUTA) și perioada de referință față de care comparăm (implicit normala climatică 1991–2020).
LOCALITATE = "Timișoara"
REFERINTA = ("1991", "2020") # perioada „normală” cu care comparăm
Bibliotecile folosite¶
xarray— tablouri multidimensionale cu etichete (timp, latitudine, longitudine); cu el deschidem cubul.s3fs— acces la stocarea de obiecte a platformei, aici anonim.rioxarray— decupare geografică după un poligon.pandasșimatplotlib— serii de timp și grafice.eocube— biblioteca platformei; de aici luăm conturul localității (serviciul geo-spatial.org).
import logging
import warnings
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import rioxarray # noqa: F401 (activează accesorul .rio)
import s3fs
import xarray as xr
from eocube.services.geospatialorg.uat import get_administrative_unit_by_name
warnings.filterwarnings("ignore")
logging.getLogger("owslib").setLevel(logging.ERROR) # owslib comentează cererea, răspunsul e corect
plt.rcParams.update({"figure.dpi": 110, "axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.25})
ACCENT = "#0a7470" # culoarea de accent EOCube
LUNI = ["Ian", "Feb", "Mar", "Apr", "Mai", "Iun", "Iul", "Aug", "Sep", "Oct", "Nov", "Dec"]
Deschidem cubul meteo¶
Cubul este un director Zarr în bucket-ul public meteo. open_zarr citește doar
metadatele (sub o secundă) — datele propriu-zise se aduc mai târziu, numai pentru
zona și perioada cerute.
fs = s3fs.S3FileSystem(anon=True, endpoint_url="https://storage.svc.uvt-01.eocube.ro")
cub = xr.open_zarr(fs.get_mapper("meteo/cube/open-v1"))
cub
<xarray.Dataset> Size: 185GB
Dimensions: (time: 32872, y: 483, x: 972)
Coordinates:
* time (time) datetime64[ns] 263kB 1961-01-01 ... 2050-...
* y (y) float64 4kB 48.35 48.34 48.34 ... 43.55 43.54
* x (x) float64 8kB 20.13 20.14 20.15 ... 29.84 29.84
Data variables:
last_max_temp_time datetime64[ns] 8B ...
last_min_temp_time datetime64[ns] 8B ...
last_precipitation_time datetime64[ns] 8B ...
max_temp (time, y, x) float32 62GB dask.array<chunksize=(90, 161, 324), meta=np.ndarray>
min_temp (time, y, x) float32 62GB dask.array<chunksize=(90, 161, 324), meta=np.ndarray>
precipitation (time, y, x) float32 62GB dask.array<chunksize=(90, 161, 324), meta=np.ndarray>
spatial_ref int64 8B ...
Attributes:
institution: National Meteorological Administration, Department of Clima...
processor: West University of Timisoara
source: copyright: MeteoRomaniaAxa timpului este pre-alocată până în 2050, dar datele reale se opresc la ultima zi publicată de ANM. O citim din cub și decupăm tot ce urmează după ea.
ultima_zi = pd.Timestamp(cub.last_precipitation_time.values)
print("Date disponibile: 1961-01-01 →", ultima_zi.date())
Date disponibile: 1961-01-01 → 2026-09-28
Conturul localității¶
Poligonul unității administrative vine de la geo-spatial.org, prin funcția din
eocube. Rezultatul este un GeoDataFrame; păstrăm prima potrivire.
uat = get_administrative_unit_by_name(LOCALITATE).iloc[0]
print(uat["name"], "—", uat["natLevName"], "—", "județul", uat["county"])
uat.geometry
Decupăm și încărcăm datele¶
Trei pași: (1) spunem lui rioxarray ce sistem de coordonate are grila, (2) tăiem
cubul după poligon și după interval, (3) aducem rezultatul în memorie cu .load().
Pentru un oraș, decupajul are câteva sute de pixeli × 24 000 de zile — sub 50 MB, se
descarcă în circa un minut. Regula de aur: mai întâi decupăm, apoi calculăm; pe
un tablou mic în memorie orice statistică durează o fracțiune de secundă.
local = (
cub[["max_temp", "min_temp", "precipitation"]]
.rio.write_crs("EPSG:4326")
.rio.clip([uat.geometry])
.sel(time=slice("1961", ultima_zi))
.load()
)
local
<xarray.Dataset> Size: 82MB
Dimensions: (time: 24012, x: 19, y: 15)
Coordinates:
* time (time) datetime64[ns] 192kB 1961-01-01 ... 2026-09-28
* x (x) float64 152B 21.11 21.12 21.13 ... 21.27 21.29 21.29
* y (y) float64 120B 45.83 45.81 45.8 45.8 ... 45.7 45.7 45.69
spatial_ref int64 8B 0
Data variables:
max_temp (time, y, x) float32 27MB nan nan 4.0 4.0 ... nan nan nan nan
min_temp (time, y, x) float32 27MB nan nan 2.4 2.4 ... nan nan nan nan
precipitation (time, y, x) float32 27MB nan nan 0.0 0.0 ... nan nan nan nan
Attributes:
institution: National Meteorological Administration, Department of Clima...
processor: West University of Timisoara
source: copyright: MeteoRomaniaMediem cei câțiva pixeli ai localității într-o singură serie zilnică și trecem în
pandas, unde restul caietului este doar aritmetică pe un tabel.
zilnic = local.mean(dim=["x", "y"]).to_dataframe()[["max_temp", "min_temp", "precipitation"]]
zilnic["temp_medie"] = (zilnic["max_temp"] + zilnic["min_temp"]) / 2
zilnic.tail()
| max_temp | min_temp | precipitation | temp_medie | |
|---|---|---|---|---|
| time | ||||
| 2026-09-24 | 21.318302 | 4.403268 | 0.018301 | 12.860785 |
| 2026-09-25 | 19.574509 | 12.858171 | 0.000000 | 16.216339 |
| 2026-09-26 | 23.141830 | 9.803267 | 0.000000 | 16.472549 |
| 2026-09-27 | 25.462091 | 8.543137 | 0.000000 | 17.002613 |
| 2026-09-28 | 26.385620 | 8.711765 | 0.000000 | 17.548693 |
Dungile încălzirii¶
Reprezentarea introdusă de climatologul Ed Hawkins: câte o dungă pe an, colorată după abaterea temperaturii medii anuale față de perioada de referință. Folosim doar anii compleți.
anual = zilnic["temp_medie"].resample("YS").mean()
anual = anual[anual.index.year < ultima_zi.year] # doar ani compleți
abatere = anual - anual[REFERINTA[0]:REFERINTA[1]].mean()
norm = plt.Normalize(-2.5, 2.5)
fig, ax = plt.subplots(figsize=(12, 2.6))
ax.bar(anual.index.year, 1, width=1, color=plt.cm.RdBu_r(norm(abatere)))
ax.set(yticks=[], xlim=(anual.index.year[0] - 0.5, anual.index.year[-1] + 0.5),
title=f"{LOCALITATE}: temperatura medie anuală față de {REFERINTA[0]}–{REFERINTA[1]}")
ax.grid(False)
fig.colorbar(plt.cm.ScalarMappable(norm=norm, cmap="RdBu_r"), ax=ax, pad=0.01, label="°C")
plt.show()
top5 = ", ".join(f"{an.year} ({v:+.1f} °C)" for an, v in abatere.nlargest(5).items())
print("Cei mai calzi 5 ani:", top5)
Anul curent față de „normal”¶
Pentru fiecare zi a anului calculăm, din perioada de referință, mediana temperaturii maxime și intervalul în care cad 80 % din ani (percentilele 10–90). Peste această bandă desenăm anul în curs.
ref = zilnic.loc[REFERINTA[0]:REFERINTA[1], "max_temp"]
clima = ref.groupby(ref.index.dayofyear).quantile([0.1, 0.5, 0.9]).unstack()
clima = clima.rolling(15, center=True, min_periods=1).mean() # netezire de 15 zile
acest_an = zilnic.loc[str(ultima_zi.year), "max_temp"]
fig, ax = plt.subplots(figsize=(12, 4.5))
ax.fill_between(clima.index, clima[0.1], clima[0.9], color="0.85",
label="80 % din anii de referință")
ax.plot(clima.index, clima[0.5], color="0.4", lw=1.5,
label=f"mediana {REFERINTA[0]}–{REFERINTA[1]}")
ax.plot(acest_an.index.dayofyear, acest_an, color=ACCENT, lw=1.2, label=str(ultima_zi.year))
inceput_luni = pd.date_range("2001-01-01", periods=12, freq="MS").dayofyear
ax.set(xticks=inceput_luni, xticklabels=LUNI, ylabel="temperatura maximă zilnică (°C)",
title=f"{LOCALITATE}: anul {ultima_zi.year} față de normala climatică")
ax.legend(loc="upper left", frameon=False)
plt.show()
ani = pd.DataFrame({
"zile tropicale (Tmax ≥ 30 °C)": (zilnic["max_temp"] >= 30).resample("YS").sum(),
"zile cu îngheț (Tmin < 0 °C)": (zilnic["min_temp"] < 0).resample("YS").sum(),
})
ani = ani[ani.index.year < ultima_zi.year]
fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharex=True)
for ax, col, culoare in zip(axes, ani.columns, ["#c0392b", "#2c6fbb"]):
ax.bar(ani.index.year, ani[col], color=culoare, alpha=0.45, width=0.8)
ax.plot(ani.index.year, ani[col].rolling(10, center=True).mean(), color=culoare, lw=2.2)
ax.set(title=col, ylabel="zile / an")
plt.tight_layout(); plt.show()
print("Media anuală 1961–1990:", ani["1961":"1990"].mean().round(0).to_dict())
print(f"Media anuală {REFERINTA[0]}–{REFERINTA[1]}:",
ani[REFERINTA[0]:REFERINTA[1]].mean().round(0).to_dict())
Sezonul fără îngheț¶
Pentru agricultură contează cât durează intervalul dintre ultimul îngheț de primăvară și primul îngheț de toamnă. Îl calculăm din datele zilnice: ultima zi cu minima sub 0 °C din prima jumătate a anului și prima astfel de zi din a doua jumătate.
zile_inghet = pd.Series(zilnic.index[zilnic["min_temp"] < 0])
primavara = zile_inghet[zile_inghet.dt.month <= 6]
toamna = zile_inghet[zile_inghet.dt.month >= 7]
ultim_inghet = primavara.groupby(primavara.dt.year).max().dt.dayofyear
prim_inghet = toamna.groupby(toamna.dt.year).min().dt.dayofyear
sezon = (prim_inghet - ultim_inghet).loc[: ultima_zi.year - 1]
fig, ax = plt.subplots(figsize=(12, 3.5))
ax.plot(sezon.index, sezon, color=ACCENT, lw=1, alpha=0.6)
ax.plot(sezon.index, sezon.rolling(10, center=True).mean(), color=ACCENT, lw=2.5)
ax.set(ylabel="zile",
title=f"{LOCALITATE}: lungimea sezonului fără îngheț (linia groasă = media pe 10 ani)")
plt.show()
Precipitațiile¶
Trei întrebări despre ploi, fiecare într-un panou: cât plouă pe an (față de normala de referință), cea mai ploioasă zi din fiecare an și cea mai lungă perioadă secetoasă (zile consecutive cu sub 1 mm).
uscat = zilnic["precipitation"] < 1
episod = (~uscat).cumsum() # fiecare zi ploioasă începe un episod nou
zile_secetoase = uscat.groupby(episod).cumsum() # câte zile uscate la rând, până în ziua curentă
ploi = pd.DataFrame({
"total anual (mm)": zilnic["precipitation"].resample("YS").sum(),
"maximul zilnic (mm)": zilnic["precipitation"].resample("YS").max(),
"cea mai lungă perioadă secetoasă (zile)": zile_secetoase.resample("YS").max(),
})
ploi = ploi[ploi.index.year < ultima_zi.year]
normala = ploi.loc[REFERINTA[0]:REFERINTA[1], "total anual (mm)"].mean()
fig, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True)
culori = np.where(ploi["total anual (mm)"] >= normala, "#2c6fbb", "#c98a1b")
axes[0].bar(ploi.index.year, ploi["total anual (mm)"], color=culori, width=0.8)
axes[0].axhline(normala, color="0.3", lw=1, ls="--")
axes[0].set(title=f"total anual — albastru: peste normala de {normala:.0f} mm, ocru: sub",
ylabel="mm")
axes[1].bar(ploi.index.year, ploi["maximul zilnic (mm)"], color="#2c6fbb", width=0.8)
axes[1].set(title="cea mai ploioasă zi a anului", ylabel="mm")
axes[2].bar(ploi.index.year, ploi["cea mai lungă perioadă secetoasă (zile)"], color="#c98a1b",
width=0.8)
axes[2].set(title="cea mai lungă perioadă secetoasă (zile consecutive cu < 1 mm)",
ylabel="zile")
plt.tight_layout(); plt.show()
Calendarul anomaliilor lunare¶
O vedere de ansamblu a tuturor celor 65 de ani: fiecare celulă este o lună, colorată după cât a fost mai caldă sau mai rece decât aceeași lună din perioada de referință.
lunar = zilnic["temp_medie"].resample("MS").mean()
lunar = lunar[lunar.index < ultima_zi.to_period("M").start_time] # doar luni complete
referinta = lunar[REFERINTA[0]:REFERINTA[1]]
normala_lunara = referinta.groupby(referinta.index.month).mean()
abateri = lunar - normala_lunara.reindex(lunar.index.month).values
tabel = (abateri.to_frame("v").assign(an=abateri.index.year, luna=abateri.index.month)
.pivot(index="an", columns="luna", values="v"))
fig, ax = plt.subplots(figsize=(7, 13))
im = ax.pcolormesh(tabel.columns, tabel.index, tabel.values, cmap="RdBu_r", vmin=-4, vmax=4)
ax.set(xticks=range(1, 13), xticklabels=LUNI,
title=f"{LOCALITATE}: abaterea lunară față de {REFERINTA[0]}–{REFERINTA[1]}")
ax.invert_yaxis(); ax.grid(False)
fig.colorbar(im, ax=ax, label="°C", shrink=0.5)
plt.show()
Tabelul anual, pentru Excel sau QGIS¶
Toți indicatorii de mai sus, un rând pe an, într-un fișier CSV pe care îl puteți deschide oriunde.
rezumat = pd.concat([anual.rename("temperatura medie (°C)").round(2), ani, ploi.round(1)], axis=1)
rezumat.index = rezumat.index.year
rezumat.index.name = "an"
rezumat.to_csv(f"clima_{LOCALITATE}.csv")
rezumat.tail(10)
| temperatura medie (°C) | zile tropicale (Tmax ≥ 30 °C) | zile cu îngheț (Tmin < 0 °C) | total anual (mm) | maximul zilnic (mm) | cea mai lungă perioadă secetoasă (zile) | |
|---|---|---|---|---|---|---|
| an | ||||||
| 2016 | 12.38 | 37 | 71 | 808.299988 | 60.900002 | 18 |
| 2017 | 12.55 | 57 | 73 | 500.100006 | 28.900000 | 20 |
| 2018 | 13.46 | 56 | 67 | 554.900024 | 36.200001 | 32 |
| 2019 | 13.70 | 57 | 65 | 475.600006 | 24.700001 | 25 |
| 2020 | 13.02 | 50 | 61 | 540.400024 | 51.900002 | 31 |
| 2021 | 12.63 | 61 | 72 | 542.000000 | 40.700001 | 20 |
| 2022 | 13.31 | 65 | 82 | 475.600006 | 25.200001 | 31 |
| 2023 | 13.79 | 51 | 50 | 685.599976 | 34.000000 | 19 |
| 2024 | 14.44 | 78 | 64 | 435.899994 | 25.299999 | 30 |
| 2025 | 13.46 | 72 | 63 | 321.799988 | 25.700001 | 34 |
Ce urmează¶
- Clima României pe hartă — aceleași date, pentru toată țara deodată, cu un cluster Dask pornit din platformă.
- Schimbați
LOCALITATEcu orice oraș sau comună și rulați din nou: tot caietul se reface în circa un minut. - Sursa datelor: Administrația Națională de Meteorologie (© MeteoRomania), set publicat pe data.gov.ro și re-gridat de Universitatea de Vest din Timișoara. Citați ANM în orice lucrare bazată pe aceste date.