xarray — wielowymiarowe tablice z etykietami

Materiał przygotowany z pomocą AI

Wymagania wstępne

Wpis zakłada znajomość podstaw Pythona i NumPy w stopniu podstawowym — patrz Python - podstawy. Dla części przykładów (rastrowe I/O przez rioxarray) przyda się też tło z Praca z rastrami w Pythonie: czym jest CRS, transformacja afiniczna, NoData. Nie jest to wymóg twardy — sekcje, które tego dotyczą, są wyraźnie oznaczone.

Środowisko do uruchomienia

Bloki kodu w tym artykule zostały zweryfikowane w Pythonie 3.12.12 z xarray 2026.4.0, numpy 2.4.4, pandas 3.0.2, matplotlib 3.10.9, rioxarray 0.22.0, rasterio 1.5.0. Pełna instrukcja odtworzenia środowiska: Środowisko Python dla przykładów.

NumPy daje nam tablice n-wymiarowe — ndarray — ale tablica numpy nie wie co znaczą jej osie. Wymiar pierwszy to czas? Szerokość geograficzna? Numer pasma? Trzeba pamiętać konwencję i nie pomylić ich w kodzie. Pandas dokłada etykiety dla 1D (Series) i 2D (DataFrame), ale powyżej dwóch wymiarów się kończy. A dane meteorologiczne, klimatyczne i rastrowe naturalnie mieszkają w trzech, czterech lub pięciu wymiarach: czas × szerokość × długość × wysokość × pasmo.

xarray to biblioteka, która zachowuje moc obliczeniową NumPy, dokłada etykiety wymiarów i współrzędne na każdej osi i robi to w sposób skalowalny do wielu wymiarów. To naturalne narzędzie dla pól meteorologicznych, danych klimatycznych, wyników modeli oceanograficznych — ale też dla zwykłej, czasowej serii pomiarów z wielu stacji.

Wpis omawia: czym xarray jest i czym nie jest, dwie centralne struktury danych (DataArray, Dataset), wczytywanie i zapis (NetCDF, GeoTIFF), indeksowanie po etykietach, arytmetykę z broadcastingiem, agregacje, operacje na osi czasu, leniwe obliczenia z dask — a na końcu pełne studium przypadku syntetycznego pola opadów nad Polską z agregacją miesięczną, oknem kroczącym i wizualizacją.

Czym jest xarray

W jednym zdaniu: xarray rozszerza NumPy o nazwy wymiarów i etykiety na każdej osi. Tablica numpy (365, 20, 20) to ślepe trzy osie. Ta sama tablica w xarray to (time, lat, lon) — z datą na osi time, szerokością geograficzną na lat, długością na lon — i samodokumentującą się reprezentacją w REPL-u.

Motywacja

Wyobraź sobie pole dziennych opadów dla roku 2025 nad obszarem 20 × 20 punktów siatki. Surowo w NumPy:

import numpy as np
 
precip = np.random.default_rng(42).gamma(1.0, 3.0, size=(365, 20, 20))
print(precip.shape)
# (365, 20, 20)
 
# Suma opadu w lipcu, wszystkie punkty:
suma_lipiec = precip[181:212].sum(axis=0)  # ← osi 0 to czas, trzeba pamietac
print(suma_lipiec.shape)
# (20, 20)

Problemy:

  • 181:212 to lipiec? Trzeba przeliczyć dni od początku roku. Albo zapomnieć i pomylić sierpień z lipcem.
  • axis=0 — co to było? Czas, lat czy lon? Bez komentarza nie wiadomo.
  • Punkt (10, 10) ma jaką szerokość/długość? Bez osobnej tablicy lats i lons w zmiennych obok — nie wiadomo.

W xarray te same dane:

import xarray as xr
import pandas as pd
import numpy as np
 
rng = np.random.default_rng(42)
times = pd.date_range("2025-01-01", periods=365, freq="D")
lats = np.linspace(49.0, 55.0, 20)
lons = np.linspace(14.0, 24.5, 20)
 
precip = xr.DataArray(
    rng.gamma(1.0, 3.0, size=(365, 20, 20)),
    dims=("time", "lat", "lon"),
    coords={"time": times, "lat": lats, "lon": lons},
    name="precipitation",
    attrs={"units": "mm/day"},
)
 
# Suma opadu w lipcu — po etykiecie:
suma_lipiec = precip.sel(time=slice("2025-07-01", "2025-07-31")).sum(dim="time")
print(suma_lipiec.shape)
# (20, 20)

Kod jest dłuższy na starcie, ale:

  • time=slice("2025-07-01", "2025-07-31") — czytelne na pierwszy rzut oka, niezależnie od kalendarza.
  • dim="time" — nie ma pomyłki, którą oś agregujemy.
  • Każdy punkt zna swoje współrzędne geograficzne (lat, lon).
  • attrs={"units": "mm/day"} — dane się same dokumentują.

Dla danych jednorazowych, jednowymiarowych, ten narzut się nie opłaca. Dla danych o większej liczbie wymiarów lub takich, które żyją w potokach przetwarzania przez dłuższy czas — bardzo szybko zwraca się sam.

xarray vs NumPy vs pandas

CechaNumPy ndarraypandas DataFramexarray DataArray/Dataset
Wymiarówdowolnie wiele2 (indeks + kolumny)dowolnie wiele
Etykietowane osienietak (2)tak (wszystkie)
Heterogeniczne kolumnynietaknie (w obrębie DataArray) — Dataset ma wiele zmiennych
I/O CSVprzez pandasnatywneprzez pandas / konwersję
I/O NetCDFnienienatywne
I/O GeoTIFFprzez rasterionieprzez rioxarray
Etykiety czasunietak (DatetimeIndex)tak
Broadcasting po nazwachpo pozycjiczęściowopo nazwie wymiaru
Skala (lazy/równolegle)ograniczonaograniczonanatywne wsparcie dask

Reguła kciuka:

  • 2D, heterogeniczne typy w kolumnach (np. tabela: nazwa stacji jako łańcuch znaków, przepływ jako float, data jako datetime) → pandas.
  • Dane N-wymiarowe o jednorodnym typie (raster, pole klimatyczne, kostka czasowa) → xarray.
  • Surowe obliczenia bez metadanych → numpy (xarray pod spodem i tak używa numpy).

Dla kogo i kiedy

Typowi użytkownicy xarray:

  • klimatolodzy, meteorologowie, oceanografowie — naturalna domena (pliki NetCDF są tu standardem);
  • analitycy danych satelitarnych (kostki czasowe Sentinel-2/Landsat);
  • hydrolodzy pracujący z polami opadu, ewapotranspiracji, temperatury;
  • każdy, kto przetwarza wynik modelu numerycznego o wymiarach (czas, x, y, [z]).

Jeśli pracujesz wyłącznie z pojedynczymi rastrami 2D bez osi czasu — rasterio + numpy w zupełności wystarczą, a xarray to dodatkowy poziom abstrakcji bez wielkiego zwrotu. Jeśli dochodzi czas — xarray zwraca się natychmiast.

DataArray

DataArray to centralny obiekt biblioteki. Trzymaj w głowie obraz:

DataArray
  ├── data      ← NumPy ndarray (lub dask array)
  ├── dims      ← krotka nazw wymiarów: ("time", "lat", "lon")
  ├── coords    ← słownik etykiet na osiach: time → ..., lat → ..., lon → ...
  ├── name      ← nazwa zmiennej (opcjonalna)
  └── attrs     ← słownik metadanych (units, long_name, ...)

Najprostsza konstrukcja

import xarray as xr
import numpy as np
 
# DataArray z czysto NumPy-owej tablicy 1D — bez wspolrzednych
a = xr.DataArray([1, 2, 3])
print(a)

Output:

<xarray.DataArray (dim_0: 3)> Size: 24B
array([1, 2, 3])
Dimensions without coordinates: dim_0

xarray automatycznie nazwał wymiar dim_0 (bo nie podaliśmy nazwy) i nie ma żadnych współrzędnych. To minimum.

Z nazwami wymiarów i współrzędnymi

import xarray as xr
 
a = xr.DataArray(
    [1, 2, 3],
    dims="x",
    coords={"x": [10, 20, 30]},
    name="pomiar",
    attrs={"units": "m"},
)
print(a)

Output:

<xarray.DataArray 'pomiar' (x: 3)> Size: 24B
array([1, 2, 3])
Coordinates:
  * x        (x) int64 24B 10 20 30
Attributes:
    units:    m

Atrybuty obiektu:

print(a.dims)     # ('x',)
print(a.shape)    # (3,)
print(a.size)     # 3
print(a.values)   # [1 2 3]   — surowy ndarray
print(a.name)     # pomiar
print(a.attrs)    # {'units': 'm'}
print(list(a.coords))   # ['x']
print(list(a.indexes))  # ['x']

Wszystko, co jest w coords, można czytać po imieniu jak atrybut:

print(a.x.values)
# [10 20 30]

2D i więcej

import xarray as xr
import numpy as np
import pandas as pd
 
rng = np.random.default_rng(0)
times = pd.date_range("2025-01-01", periods=4, freq="D")
sites = ["A", "B", "C"]
 
da = xr.DataArray(
    rng.normal(0.0, 1.0, size=(4, 3)),
    dims=("time", "site"),
    coords={"time": times, "site": sites},
    name="temperatura",
    attrs={"units": "degC", "long_name": "Temperatura powietrza"},
)
print(da)

Output (skrót):

<xarray.DataArray 'temperatura' (time: 4, site: 3)> Size: 96B
array([[ 0.12573022, -0.13210486,  0.64042265],
       [ 0.10490012, -0.53566937,  0.36159505],
       [ 1.30400005,  0.94708096, -0.70373524],
       [-1.26542147, -0.62327446,  0.04132598]])
Coordinates:
  * time     (time) datetime64[us] 32B 2025-01-01 2025-01-02 ... 2025-01-04
  * site     (site) <U1 12B 'A' 'B' 'C'
Attributes:
    units:      degC
    long_name:  Temperatura powietrza

W reprezentacji obiektu widać wszystko: kształt, dtype, wymiary, współrzędne i jednostki. Nie trzeba print(arr.shape); print(arr.dtype); print(times); print(sites) jak w NumPy — jeden print(da) pokazuje pełen kontekst.

dims, coords i name — formalnie

  • dims — krotka nazw wymiarów; uporządkowana (kolejność ma znaczenie dla .values i operacji typu transpose).
  • coords — obiekt słownikopodobny zawierający etykiety na osiach. Każda współrzędna ma własny wymiar lub jest „skalarna” (jedna wartość bez wymiaru).
  • name — opcjonalna nazwa zmiennej; jeśli jest ustawiona, jest wyświetlana w reprezentacji obiektu i propaguje się przy konwersjach (np. do pandas.Series.name).
  • attrs — słownik z metadanymi; przechowuje konwencję CF (units, long_name, standard_name, …) lub własne pola.

Coords ze swoim wymiarem vs bez wymiaru

Współrzędna może mieć ten sam wymiar co dane (np. time na osi time) — wtedy jest indeksowalna (sel(time=...)). Może też być skalarna lub mieć osobny wymiar. Skalarnych coords używa się np. do oznaczenia, że cała tablica reprezentuje pomiar z jednej stacji: coords={"station_id": "WW01"}.

Praktyka — odczytywanie metadanych

W przykładach z plikami NetCDF (klimatologia) typowe atrybuty to:

import xarray as xr
import numpy as np
 
da = xr.DataArray(
    np.zeros((3,)),
    dims="time",
    coords={"time": [0, 1, 2]},
    name="precipitation",
    attrs={
        "units": "kg m-2 s-1",
        "long_name": "Precipitation flux",
        "standard_name": "precipitation_flux",
        "_FillValue": -9999.0,
    },
)
 
print(da.attrs["units"])         # kg m-2 s-1
print(da.attrs.get("units"))     # to samo, bezpieczniej (None jak brak)
print(da.attrs.get("missing"))   # None — brak klucza

Atrybut units to konwencja CF (Climate and Forecast metadata conventions) — patrz oficjalna dokumentacja CF. xarray nie wymusza tej konwencji (każdy atrybut to po prostu wpis w słowniku), ale narzędzia w ekosystemie zakładają, że jednostki znajdują się w units.

Dataset

Dataset to kolekcja DataArray-jów dzielących te same wymiary i współrzędne. Analogicznie do pandas: DataArray ↔ Series, Dataset ↔ DataFrame.

Po co? W praktyce dane fizyczne często idą w komplecie: temperatura + opad + ciśnienie + wilgotność, wszystkie po tej samej siatce (time, lat, lon). Zamiast czterech osobnych obiektów DataArray trzymanych w słowniku w głowie, wszystko jest w jednym obiekcie Dataset.

Konstrukcja

import xarray as xr
import numpy as np
import pandas as pd
 
rng = np.random.default_rng(42)
times = pd.date_range("2025-01-01", periods=5, freq="D")
sites = ["KRK", "POZ", "WAW"]
 
ds = xr.Dataset(
    data_vars={
        "temperatura": (("time", "site"), rng.normal(10, 5, size=(5, 3))),
        "opad":        (("time", "site"), rng.gamma(1.0, 2.0, size=(5, 3))),
    },
    coords={
        "time": times,
        "site": sites,
    },
    attrs={"source": "syntetyczny przykład xarray"},
)
print(ds)

Output:

<xarray.Dataset> Size: 316B
Dimensions:      (time: 5, site: 3)
Coordinates:
  * time         (time) datetime64[us] 40B 2025-01-01 2025-01-02 ... 2025-01-05
  * site         (site) <U3 36B 'KRK' 'POZ' 'WAW'
Data variables:
    temperatura  (time, site) float64 120B 11.52 4.8 13.75 ... 10.33 15.64 12.34
    opad         (time, site) float64 120B 0.3075 0.1832 0.6304 ... 0.3593 1.371
Attributes:
    source:   syntetyczny przykład xarray

Dostęp do pojedynczej zmiennej:

temp = ds["temperatura"]
print(type(temp).__name__)
# DataArray
print(temp.dims)
# ('time', 'site')

Albo dostęp przez atrybut (działa, jeśli nazwa zmiennej jest poprawnym identyfikatorem Pythona i nie koliduje z atrybutami Dataset):

print(ds.temperatura.mean(dim="time").values)
# np.: [10.59  8.70 10.68] — srednie po dniach dla 3 stacji

data_vars vs coords

W obiekcie Dataset rozróżniamy:

  • data_vars (zmienne danych) — to są „pomiary”: temperatura, opad, ciśnienie. Każda jest osobnym obiektem DataArray.
  • coords — etykiety na osiach: time, site, ewentualnie lat, lon, z. Współrzędne nie są „pomiarami”, są opisem siatki.

Granica nie jest twarda — można promować zmienną do coords i odwrotnie, ale konwencjonalnie: coords to to, względem czego indeksujemy.

# Promocja zmiennej do coords
ds2 = ds.set_coords([])   # bez zmian — pokazanie API

DataArray vs Dataset — kiedy co

SytuacjaWybór
Jedna wielowymiarowa tablica wartości z metadanymiDataArray
Kilka tablic dzielących wymiary (temp, opad, cisnienie po siatce)Dataset
Pojedyncza zmienna w pipeline-ie (jednorazowe obliczenie)DataArray
Plik NetCDF z wieloma zmiennymi do trzymania razemDataset
Wyjście z modelu z kilkoma polami diagnostycznymiDataset

Konwersja w obie strony jest trywialna:

import xarray as xr
import numpy as np
import pandas as pd
 
temp = xr.DataArray(
    np.zeros((4, 3)),
    dims=("time", "site"),
    coords={"time": pd.date_range("2025-01-01", periods=4),
            "site": ["A", "B", "C"]},
    name="temperatura",
)
 
# DataArray -> Dataset
ds3 = temp.to_dataset()
print(list(ds3.data_vars))
# ['temperatura']
 
# Dataset z jedna zmienna -> DataArray
da_back = ds3["temperatura"]
print(da_back.dims)
# ('time', 'site')

Operacje na całym Dataset

Wiele metod działa na obiekcie Dataset „pakietowo” — obliczają się dla każdej zmiennej osobno:

import xarray as xr
import numpy as np
import pandas as pd
 
rng = np.random.default_rng(42)
times = pd.date_range("2025-01-01", periods=5)
ds = xr.Dataset(
    data_vars={
        "temperatura": (("time",), rng.normal(10, 5, 5)),
        "opad":        (("time",), rng.gamma(1.0, 2.0, 5)),
    },
    coords={"time": times},
)
print(ds.mean(dim="time"))

Output:

<xarray.Dataset> Size: 16B
Dimensions:      ()
Data variables:
    temperatura  float64 8B 9.005
    opad         float64 8B 2.845

Dostaliśmy obiekt Dataset z dwoma skalarami — średnia z każdej zmiennej po osi time.

Dodawanie i usuwanie zmiennych

import xarray as xr
import numpy as np
import pandas as pd
 
ds = xr.Dataset(
    data_vars={
        "t": (("time",), [1.0, 2.0, 3.0]),
        "p": (("time",), [10.0, 20.0, 30.0]),
    },
    coords={"time": pd.date_range("2025-01-01", periods=3)},
)
 
# Dodanie zmiennej pochodnej:
ds["ratio"] = ds["t"] / ds["p"]
print(list(ds.data_vars))
# ['t', 'p', 'ratio']
 
# Albo niemutujace assign (zwraca nowy Dataset):
ds_b = ds.assign(suma=ds["t"] + ds["p"])
print(list(ds_b.data_vars))
# ['t', 'p', 'ratio', 'suma']
 
# Usuniecie zmiennej:
ds_c = ds.drop_vars("ratio")
print(list(ds_c.data_vars))
# ['t', 'p']
 
# Wybor podzbioru zmiennych:
ds_sub = ds[["t", "ratio"]]
print(list(ds_sub.data_vars))
# ['t', 'ratio']

I/O — wejście i wyjście

xarray natywnie obsługuje kilka formatów wejścia/wyjścia. Bez dodatkowych pakietów (czyli „surowy” xarray) jest ograniczony — produkcyjnie używa się go z netCDF4, h5netcdf, zarr, rioxarray. W tym artykule pokażemy NetCDF (klasyczny standard) i GeoTIFF (przez rioxarray).

Wymagane backendy

Sam pakiet xarray nie umie zapisać NetCDF, Zarr czy GeoTIFF — potrzebuje silnika. W środowisku requirements.txt z Środowisko Python dla przykładów zainstalowane są rasterio i rioxarray (GeoTIFF). NetCDF wymaga dodatkowo netCDF4, h5netcdf lub scipy (każdy z nich potrafi obsłużyć NetCDF, z drobnymi różnicami). Sprawdzić, co jest dostępne:

import xarray as xr
print(xr.backends.list_engines())

Formaty

FormatRozszerzenieCharakterystykaPakiet
NetCDF4.ncbinarny, hierarchiczny, samoopisujący; standard klimat/meteonetCDF4 lub h5netcdf
NetCDF3.ncstarsza wersja, ograniczenia, ale bardzo szeroka kompatybilnośćscipy.io.netcdf
Zarr.zarr/format chunkowany, dobry do chmury, kompatybilny z daskzarr
GeoTIFF.tifjednowymiarowy raster (+pasma), georeferencjarioxarray + rasterio
HDF5.h5hierarchiczny, używany w danych satelitarnychh5py lub netCDF4

NetCDF — zapis i odczyt

NetCDF (Network Common Data Format) to binarny, samoopisujący format hierarchiczny rozwijany przez UCAR/Unidata. Zapisuje tablice n-wymiarowe wraz z opisem osi, wymiarów, jednostek i innych metadanych. To format pierwszego wyboru dla danych klimatycznych, meteorologicznych i oceanograficznych. Pliki NetCDF respektują konwencje CF (Climate and Forecast), które standaryzują nazwy atrybutów (units, standard_name, axis, …) — dzięki temu plik z jednego ośrodka jest czytelny dla narzędzi z drugiego.

Zapis (zakładając, że w środowisku jest jeden z backendów netCDF4/h5netcdf/scipy):

import xarray as xr
import numpy as np
import pandas as pd
 
rng = np.random.default_rng(42)
times = pd.date_range("2025-01-01", periods=10)
ds = xr.Dataset(
    data_vars={
        "temp": (("time",), rng.normal(10, 5, 10)),
        "rain": (("time",), rng.gamma(1.0, 2.0, 10)),
    },
    coords={"time": times},
    attrs={"title": "Przykładowe dane", "Conventions": "CF-1.8"},
)
 
# Zapis — wymaga zainstalowanego backendu NetCDF
ds.to_netcdf("/tmp/dane.nc")
 
# Odczyt
ds_read = xr.open_dataset("/tmp/dane.nc")
print(ds_read)
ds_read.close()

Środowisko bazy nie ma backendu NetCDF

W aktualnym requirements.txt (sesja 2026-05-12) brakuje netCDF4/h5netcdf/scipy. Wywołanie ds.to_netcdf(...) rzuci ValueError: cannot write NetCDF files because none of the suitable backend libraries (netCDF4, h5netcdf, scipy) are installed. Aby uruchomić powyższy blok lokalnie, doinstaluj jeden z backendów:

pip install netCDF4
# albo: pip install h5netcdf

Otwarcie pliku — open_dataset vs load_dataset

# open_dataset — leniwy (lazy); plik trzymany jest otwarty, dane czytane sa na zadanie
ds = xr.open_dataset("/tmp/dane.nc")
 
# load_dataset — czyta WSZYSTKO do pamieci i zamyka plik
ds_full = xr.load_dataset("/tmp/dane.nc")

Reguła:

  • open_dataset — dla plików większych niż pamięć, gdy chcemy pracować na podzbiorze.
  • load_dataset — dla małych plików, gdy potem manipulujemy strukturą katalogów.
  • Po open_dataset(...) zamykaj przez .close() albo użyj menedżera kontekstu with xr.open_dataset(...) as ds:.

GeoTIFF przez rioxarray

GeoTIFF nie jest „rodzimym” formatem xarray (w pliku jest tylko 2D raster z opcjonalnymi pasmami), ale rioxarray dorzuca odpowiednie nakładki. Sposób — patrz też Praca z rastrami w Pythonie.

import xarray as xr
import rioxarray  # noqa: F401  — rejestruje akcesor .rio
import numpy as np
 
# Stworzmy syntetyczny "DEM"
lats = np.linspace(49.0, 55.0, 10)
lons = np.linspace(14.0, 24.5, 10)
data = np.random.default_rng(42).normal(200.0, 50.0, size=(10, 10))
 
da = xr.DataArray(
    data,
    dims=("y", "x"),
    coords={"y": lats, "x": lons},
    name="dem",
)
da.rio.write_crs("EPSG:4326", inplace=True)
da.rio.to_raster("/tmp/dem_xr.tif")
 
# Odczyt
da_in = rioxarray.open_rasterio("/tmp/dem_xr.tif", masked=True).squeeze()
print(da_in.shape)
# (10, 10)
print(da_in.rio.crs)
# EPSG:4326

rioxarray wymaga osi nazwanych x i y (konwencja zgodna z GeoTIFF). Akcesor .rio udostępnia metody specyficzne dla rastra: .write_crs(), .to_raster(), .bounds(), .clip(), .reproject().

Konwersja do/z pandas

import xarray as xr
import pandas as pd
import numpy as np
 
times = pd.date_range("2025-01-01", periods=5)
da = xr.DataArray(
    np.arange(15).reshape(5, 3),
    dims=("time", "site"),
    coords={"time": times, "site": ["A", "B", "C"]},
    name="val",
)
 
# to_pandas — dla 1D zwroci Series, dla 2D DataFrame
df = da.to_pandas()
print(type(df).__name__)
# DataFrame
print(df.head(3))

Output:

site        A   B   C
time
2025-01-01  0   1   2
2025-01-02  3   4   5
2025-01-03  6   7   8

to_dataframe daje „długą” reprezentację (long form):

print(da.to_dataframe().head(6))

Output:

                 val
time       site
2025-01-01 A       0
           B       1
           C       2
2025-01-02 A       3
           B       4
           C       5

W drugą stronę — z pandas:

import pandas as pd
import xarray as xr
 
df = pd.DataFrame({
    "value": [1.0, 2.0, 3.0, 4.0],
    "time": pd.date_range("2025-01-01", periods=4),
    "site": ["A", "A", "B", "B"],
}).set_index(["time", "site"])
 
ds = xr.Dataset.from_dataframe(df)
print(ds)

Output:

<xarray.Dataset> Size: 112B
Dimensions:  (time: 4, site: 2)
Coordinates:
  * time     (time) datetime64[us] 32B 2025-01-01 2025-01-02 ...
  * site     (site) object 16B 'A' 'B'
Data variables:
    value    (time, site) float64 64B 1.0 nan 2.0 nan nan 3.0 nan 4.0

Uwaga: brakujące kombinacje (np. stacja B w dniu 2025-01-01) zostają wypełnione wartością NaN. To naturalne, bo xarray zakłada „pełną siatkę” wymiarów.

Indeksowanie i selekcja

Indeksowanie to miejsce, w którym xarray najmocniej odstaje od NumPy — i najmocniej zarabia na sobie. Dwie podstawowe metody:

  • .isel(...) — integer-based, jak arr[i, j] w NumPy; indeks pozycyjny 0..N.
  • .sel(...) — label-based, po etykietach współrzędnych: sel(time="2025-07-01").

Przykładowe dane

import xarray as xr
import numpy as np
import pandas as pd
 
times = pd.date_range("2025-01-01", periods=10, freq="D")
da = xr.DataArray(
    np.arange(30).reshape(10, 3),
    dims=("time", "site"),
    coords={"time": times, "site": ["A", "B", "C"]},
    name="val",
)

isel — po pozycji

# Pojedynczy element — przeciecie indeksow:
print(da.isel(time=0, site=0).values)
# 0
 
# Wycinek wzdluz osi czasu — pierwsze 3 dni:
print(da.isel(time=slice(0, 3)).shape)
# (3, 3)
 
# Ostatni element:
print(da.isel(time=-1).values)
# [27 28 29]

isel jest blisko czystego NumPy — jeśli znasz arr[0:3], arr[-1], to też wiesz, jak działa isel(x=slice(0,3)) i isel(x=-1).

sel — po etykiecie

# Konkretna data:
print(da.sel(time="2025-01-03").values)
# [6 7 8]
 
# Konkretna stacja:
print(da.sel(site="B").values)
# [ 1  4  7 10 13 16 19 22 25 28]
 
# Wycinek dat:
print(da.sel(time=slice("2025-01-02", "2025-01-05")).shape)
# (4, 3)

slice w sel jest obustronnie domknięty — inaczej niż w czystym Pythonie. To wynika z faktu, że indeks jest etykietowy, a nie pozycyjny: użytkownik mówi „od 2-go do 5-go”, co intuicyjnie obejmuje oba krańce.

Tolerancja — method="nearest" i tolerance

# Najbliższa data — przy zapytaniu o "2025-01-03 13:00" wraca dzien 04
print(da.sel(time="2025-01-03 13:00", method="nearest").values)
# [ 9 10 11]
 
# Z ograniczeniem maksymalnej odleglosci (24 h):
print(da.sel(time="2025-01-03 13:00", method="nearest",
             tolerance=pd.Timedelta("24h")).values)
# [ 9 10 11]

Opcji method="nearest" używa się głównie do dopasowania niedokładnych dat lub współrzędnych geograficznych (np. „najbliższa stacja do punktu 51.5°N, 17.2°E”).

.loc[] — jeszcze inny sposób

Notacja loc[] (znana z pandas) działa też w xarray, używając etykiet:

# Pojedynczy element po etykietach:
print(da.loc["2025-01-03", "B"].values)
# 7
 
# Wycinek:
print(da.loc["2025-01-02":"2025-01-04", ["A", "C"]].values)

Output:

[[ 3  5]
 [ 6  8]
 [ 9 11]]

loc[] jest zwięzła, ale w mojej praktyce mniej czytelna niż sel(time=..., site=...). To kwestia gustu.

Indeksowanie warunkowe (boolean indexing)

# Wszystkie dni od 5. do konca:
mask = da.time.dt.day >= 5
print(da.sel(time=mask).shape)
# (6, 3)
 
# Wartosci wieksze niz 15:
print(da.where(da > 15).values[:3])

Output:

[[nan nan nan]
 [nan nan nan]
 [nan nan nan]]

.where(cond) zwraca taki sam kształt z NaN tam, gdzie warunek nie jest spełniony. Drugi argument pozwala podać wartość zastępczą:

print(da.where(da > 15, 0).values[:3])

Output:

[[0 0 0]
 [0 0 0]
 [0 0 0]]

Indeksowanie przez listy etykiet (fancy indexing)

# Dwie wybrane stacje:
print(da.sel(site=["A", "C"]).shape)
# (10, 2)
 
# Trzy wybrane dni:
print(da.sel(time=["2025-01-01", "2025-01-05", "2025-01-10"]).shape)
# (3, 3)

Reindex — zmiana siatki bez interpolacji

import xarray as xr
import numpy as np
 
a = xr.DataArray([1.0, 2.0, 4.0, 8.0, 16.0],
                 dims="x", coords={"x": [0, 10, 20, 30, 40]})
 
# Reindex do nowej siatki — w nowych pozycjach wstawia NaN:
b = a.reindex(x=[0, 5, 10, 15, 20])
print(b.values)
# [ 1. nan  2. nan  4.]
 
# Z fill_value:
c = a.reindex(x=[0, 5, 10, 15, 20], fill_value=-1.0)
print(c.values)
# [ 1. -1.  2. -1.  4.]
 
# Z method="nearest" (najblizsza wartosc z oryginalu):
d = a.reindex(x=[0, 5, 10, 15, 20], method="nearest")
print(d.values)
# [1. 2. 2. 4. 4.]

reindex jest „przepisywaniem na nową siatkę” bez interpolacji. Dla prawdziwej interpolacji liniowej lub typu spline służy .interp() — ale ta wymaga scipy.

Operacje na danych

Arytmetyka i broadcasting

NumPy stosuje broadcasting po kształcie (ostatnie wymiary muszą się zgadzać). xarray stosuje go po nazwie wymiaru — co jest bezpieczniejsze:

import xarray as xr
import numpy as np
 
a = xr.DataArray(np.array([[1, 2, 3], [4, 5, 6]]),
                 dims=("x", "y"),
                 coords={"x": [10, 20], "y": [100, 200, 300]})
 
# Wektor po osi y:
b = xr.DataArray([10, 20, 30], dims="y", coords={"y": [100, 200, 300]})
 
print((a + b).values)

Output:

[[11 22 33]
 [14 25 36]]

xarray rozpoznał, że b ma wymiar y i rozszerzył go na wymiar x z a. Nie trzeba b.reshape(1, 3) ani b[None, :].

Automatyczne wyrównanie po coords

Gdy dwie tablice mają wspólny wymiar, ale różne współrzędne, xarray automatycznie znajduje przecięcie:

c = xr.DataArray([1, 2, 3], dims="x", coords={"x": [10, 20, 30]})
d = xr.DataArray([100, 200, 300], dims="x", coords={"x": [20, 30, 40]})
 
print((c + d).values)         # tylko wspolne wartosci x: 20 i 30
# [102 203]
print((c + d).x.values)
# [20 30]

To zachowanie chroni przed dodaniem do siebie danych z dwóch różnych dat lub punktów siatki. W NumPy np.array([1,2,3]) + np.array([100,200,300]) „udałoby się” bez ostrzeżenia, mimo że dane mogą reprezentować różne lokalizacje.

Agregacje

import xarray as xr
import numpy as np
 
a = xr.DataArray(np.array([[1, 2, 3], [4, 5, 6]]),
                 dims=("x", "y"),
                 coords={"x": [10, 20], "y": [100, 200, 300]})
 
print(float(a.mean()))            # globalna srednia
# 3.5
print(a.mean(dim="x").values)     # srednia po x — wynik wzdluz y
# [2.5 3.5 4.5]
print(a.mean(dim="y").values)     # srednia po y — wynik wzdluz x
# [2. 5.]
print(int(a.sum()))                # 21
print(float(a.std()))              # 1.707825127659933
print(int(a.min()), int(a.max()))  # 1 6
print(float(a.quantile(0.5)))      # 3.5

Dostępne agregacje: mean, sum, std, var, min, max, median, quantile, prod, count, argmin, argmax. Każda przyjmuje dim= (lub krotkę dim).

Liczenie tylko wartości (count) i NaN

import xarray as xr
import numpy as np
 
arr = xr.DataArray([1.0, np.nan, 3.0, 4.0], dims="x")
print(int(arr.count()))   # 3   — liczba wartosci nie-NaN
print(float(arr.mean()))  # 2.666...  — domyslnie skipna=True
print(float(arr.mean(skipna=False)))   # nan

Większość agregacji domyślnie pomija NaN (skipna=True). Dla typów bool lub int przyjmowane jest skipna=False — wtedy nie ma czego pomijać.

where / fillna / dropna / isnull

import xarray as xr
import numpy as np
 
arr = xr.DataArray([1.0, np.nan, 3.0, np.nan, 5.0],
                   dims="x", coords={"x": [10, 20, 30, 40, 50]})
 
print(arr.isnull().values)
# [False  True False  True False]
print(arr.notnull().values)
# [ True False  True False  True]
print(arr.dropna(dim="x").values)
# [1. 3. 5.]
print(arr.fillna(-1).values)
# [ 1. -1.  3. -1.  5.]
print(arr.where(arr > 2, 0).values)   # zachowuje >2, reszte zeruje
# [0. 0. 3. 0. 5.]

Operacje matematyczne

Uniwersalne funkcje (ufunc) z NumPy działają bezpośrednio:

import xarray as xr
import numpy as np
 
arr = xr.DataArray([1.0, 4.0, 9.0, 16.0], dims="x")
print(np.sqrt(arr).values)
# [1. 2. 3. 4.]
print(np.log(arr).values[:2])
# [0.         1.38629436]

Wynik zachowuje typ DataArray z osiami — niezależnie od tego, że woła go funkcja z NumPy.

Atrybuty po operacjach — uważnie

Zachowanie attrs przy operacjach to częste źródło niespójności. W xarray 2026.4.0 domyślne zachowanie jest takie: pojedyncza operacja (typu * 2, .mean(), .isel(...)) zachowuje attrs; operacja na dwóch obiektach z różnymi atrybutami daje pusty attrs:

import xarray as xr
import numpy as np
 
a = xr.DataArray([1.0, 2.0], dims="x", attrs={"units": "m"})
print((a * 2).attrs)            # {'units': 'm'}
print(a.isel(x=0).attrs)        # {'units': 'm'}
print(a.mean().attrs)           # {'units': 'm'}
 
a1 = xr.DataArray([1.0, 2.0], dims="x", attrs={"units": "m", "src": "X"})
a2 = xr.DataArray([3.0, 4.0], dims="x", attrs={"units": "s", "src": "Y"})
print((a1 + a2).attrs)          # {} — konflikty wyzeruja

Globalnie można to zmienić przez xr.set_options(keep_attrs=True/False):

with xr.set_options(keep_attrs=False):
    print((a * 2).attrs)
    # {}

Zachowanie zależne od wersji

Historycznie xarray miał domyślnie keep_attrs=False — atrybuty były kasowane po arytmetyce. W nowszych wersjach zachowanie się zmieniło. Jeśli kod migruje ze starszej wersji, sprawdź ten szczegół eksperymentalnie albo wymuś jawnie przez xr.set_options.

diff i cumsum

import xarray as xr
 
arr = xr.DataArray([1, 3, 7, 15, 31],
                   dims="x", coords={"x": [0, 1, 2, 3, 4]})
print(arr.diff("x").values)
# [ 2  4  8 16]   — roznice miedzy sasiednimi (n-1 elementow)
print(arr.cumsum().values)
# [ 1  4 11 26 57]

Operacje na łańcuchach znaków

import xarray as xr
 
sites = xr.DataArray(["stn_A", "stn_B", "stn_C"], dims="s")
print(sites.str.upper().values)
# ['STN_A' 'STN_B' 'STN_C']
print(sites.str.startswith("stn").values)
# [ True  True  True]
print(sites.str.len().values)
# [5 5 5]

Akcesor .str to przeniesienie odpowiednika z pandas — przydatny do filtrowania po nazwach stacji, identyfikatorach itp.

Wymiar czasowy

To miejsce, w którym xarray (razem z pandas pod spodem) świeci najjaśniej. Dane w hydrologii i meteorologii to przede wszystkim szeregi czasowe — dobowe, godzinne, 5-minutowe — a operacje typu „przelicz dzienne sumy na miesięczne” są stałym elementem przepływu pracy.

DatetimeIndex w coords

Wystarczy, że współrzędna na osi time jest typu datetime64 (najprościej: pandas.date_range), żeby xarray rozpoznał oś jako czasową i udostępnił akcesor .dt:

import xarray as xr
import pandas as pd
import numpy as np
 
times = pd.date_range("2025-01-01", periods=10, freq="D")
da = xr.DataArray(np.arange(10), dims="time", coords={"time": times})
 
print(da.time.dt.year.values[:3])
# [2025 2025 2025]
print(da.time.dt.month.values[:3])
# [1 1 1]
print(da.time.dt.day.values[:3])
# [1 2 3]
print(da.time.dt.dayofyear.values[:3])
# [1 2 3]
print(da.time.dt.season.values[:5])
# ['DJF' 'DJF' 'DJF' 'DJF' 'DJF']

Akcesor .dt udostępnia standardowy zestaw z pandas: year, month, day, hour, dayofyear, weekday, quarter, season (DJF/MAM/JJA/SON), is_month_start, …

resample — z jednego kroku do drugiego

import xarray as xr
import pandas as pd
import numpy as np
 
rng = np.random.default_rng(42)
times = pd.date_range("2025-01-01", periods=100, freq="D")
da = xr.DataArray(rng.gamma(1.0, 1.0, 100),
                  dims="time", coords={"time": times},
                  name="precip")
 
# Z dziennych do miesiecznych — suma:
miesieczne_sumy = da.resample(time="MS").sum()
print(miesieczne_sumy.shape)
# (4,)
print(miesieczne_sumy.values)
# [28.38107635 20.87407208 33.05586341  7.59437749]
 
# Z dziennych do tygodniowych — srednia:
tygodniowe_srednie = da.resample(time="W").mean()
print(tygodniowe_srednie.shape)
# (15,)

Częste „kody częstotliwości” (pandas/pandas 3.0):

KodZnaczenie
Ddziennie
hgodzinnie (uwaga: małe h w pandas 3.0)
Wtygodniowo (z domyślnym końcem niedziela)
MSpoczątek miesiąca (Month Start)
MEkoniec miesiąca (Month End)
YSpoczątek roku (Year Start)
YEkoniec roku (Year End)
QSpoczątek kwartału

Zmiana kodów w pandas 3.x

Starsze pandasy używały H (godziny), M (Month End), Y (Year End). W pandas 3.0 (które idzie w requirements.txt) część z nich jest „przepisana”: małe h dla godzin, ME/YE z literą E na końcu miesiąca/roku. Stare kody w xarray rzucają FutureWarning albo błąd. Jeśli kopiujesz przykład z internetu sprzed roku — sprawdź ten szczegół.

Dostępne agregacje po resample: mean, sum, max, min, std, count, median, first, last. Można też wywołać własną funkcję przez .reduce(np.func, dim="time") lub .map(func).

groupby — agregacja po atrybucie

groupby agreguje wzdłuż dowolnej współrzędnej lub jej pochodnej:

import xarray as xr
import pandas as pd
import numpy as np
 
rng = np.random.default_rng(42)
times = pd.date_range("2025-01-01", periods=365, freq="D")
da = xr.DataArray(rng.normal(10, 5, 365),
                  dims="time", coords={"time": times})
 
# Średnia po sezonach (DJF/MAM/JJA/SON):
season_means = da.groupby("time.season").mean()
print(season_means.values)
# np.: [DJF=..., MAM=..., JJA=..., SON=...]
print(season_means.coords["season"].values)
# ['DJF' 'JJA' 'MAM' 'SON']
 
# Średnia po miesiącu kalendarzowym:
month_means = da.groupby("time.month").mean()
print(month_means.shape)
# (12,)

"time.season", "time.month", "time.year", "time.dayofyear" to skróty do pól akcesora .dt — xarray rozpoznaje je w groupby.

groupby_bins — kategorie po wartościach

Gdy chcemy zgrupować po przedziałach wartości (np. „lat 49-51, 51-53, 53-55”):

import xarray as xr
import numpy as np
 
lats = np.linspace(49.0, 55.0, 20)
data = np.arange(20, dtype=float)
da = xr.DataArray(data, dims="lat", coords={"lat": lats})
 
# Średnia w trzech pasach szerokości:
binned = da.groupby_bins("lat", bins=[49, 51, 53, 55]).mean()
print(binned.values)
# [ 3.5  9.5 16. ]
print(binned.coords["lat_bins"].values)
# [Interval(49, 51, closed='right') Interval(51, 53, closed='right')
#  Interval(53, 55, closed='right')]

Wynik ma nową współrzędną lat_bins z obiektami pandas.Interval jako etykietami — czytelne, ale do zapisu do NetCDF trzeba je konwertować (np. na środki przedziałów).

rolling — okno przesuwne

import xarray as xr
import numpy as np
 
arr = xr.DataArray(np.arange(10.0, dtype=float), dims="x")
 
# Okno 3-elementowe, srednia:
roll = arr.rolling(x=3).mean()
print(roll.values)
# [nan nan  1.  2.  3.  4.  5.  6.  7.  8.]
# pierwsze dwie pozycje to NaN — brak pelnego okna
 
# Z center=True — okno wycentrowane:
roll_c = arr.rolling(x=3, center=True).mean()
print(roll_c.values)
# [nan  1.  2.  3.  4.  5.  6.  7.  8. nan]
 
# Z min_periods=1 — wynik nawet dla niepelnego okna:
roll_m = arr.rolling(x=3, min_periods=1).mean()
print(roll_m.values)
# [0.  0.5 1.  2.  3.  4.  5.  6.  7.  8. ]

rolling udostępnia operacje: mean, sum, min, max, std, var, count, median. Można też użyć .reduce(np.func) dla własnej funkcji.

Typowe zastosowanie hydrologiczne: 7-dniowa średnia ruchoma stanu wody (do wygładzania szumu) albo 30-dniowa suma opadu (do oceny zwilżenia zlewni).

Sezonowe agregacje — przykład pełny

import xarray as xr
import pandas as pd
import numpy as np
 
# Synteza: dzienne opady, rok 2025
rng = np.random.default_rng(42)
times = pd.date_range("2025-01-01", periods=365, freq="D")
doy = np.arange(365)
# wzor: maksimum w lipcu, minimum w styczniu
seasonal = 0.5 + 4.5 * (1.0 + np.sin(2 * np.pi * (doy - 90) / 365)) / 2.0
noise = rng.gamma(1.0, 1.0, 365)
precip = xr.DataArray(seasonal * noise,
                      dims="time", coords={"time": times},
                      name="precip", attrs={"units": "mm/day"})
 
# Suma miesieczna:
m = precip.resample(time="MS").sum()
print("Sumy miesięczne (mm):")
print(m.values.round(1))
 
# Wskazujemy najmokrejszy miesiąc:
idx = int(m.argmax())
print(f"Najmokrejszy miesiac: {m.time.values[idx]} z {float(m.isel(time=idx)):.1f} mm")

Output (wartości deterministyczne dla seed=42):

Sumy miesięczne (mm):
[ 16.4  24.9  67.4  95.7 125.7 150.8 157.5 145.7  83.2  57.1  28.4  24.7]
Najmokrejszy miesiac: 2025-07-01T00:00:00.000000 z 157.5 mm

Praca z dużymi danymi — dask

xarray od początku był projektowany pod leniwą ewaluację (lazy evaluation) dla danych nie mieszczących się w pamięci. Zaplecze, którego używa się do tego, to dask — biblioteka do równoległych i leniwych obliczeń na tablicach.

Idea chunków

Tablica (time=10000, lat=720, lon=1440) w float32 ma rozmiar 41 GB. Nie zmieści się w RAM przeciętnego komputera. dask dzieli ją na chunki (np. (time=100, lat=720, lon=1440) — po 414 MB) i operuje na nich kolejno, trzymając jednocześnie w pamięci tylko kilka chunków.

xarray integruje to przezroczyście — po wywołaniu .chunk(...) operacje stają się leniwe:

# Lazy chunking — wymaga zainstalowanego pakietu dask
import xarray as xr
import numpy as np
 
ds = xr.Dataset(
    {"v": (("time", "x"), np.random.rand(1000, 100))},
    coords={"time": np.arange(1000), "x": np.arange(100)},
)
 
# bez dask:
try:
    chunked = ds.chunk({"time": 100})
except ImportError as e:
    print("brak dask:", e)
# brak dask: chunk manager 'dask' is not available...

Środowisko bazy nie ma dask

W requirements.txt brakuje dask. Powyższy chunk rzuci ImportError. Aby uruchomić:

pip install dask

Po zainstalowaniu — typowy przebieg pracy

# Wymaga: pip install dask
import xarray as xr
 
# Otwarcie pliku z chunkowaniem — dane czytane sa leniwie
ds = xr.open_dataset("dane.nc", chunks={"time": 100})
print(ds["temperatura"].data)
# <dask.array.Array> — nie ndarray, tylko obietnica obliczen
 
# Operacje sa lazy:
srednia_miesieczna = ds["temperatura"].resample(time="MS").mean()
# tutaj NIC sie jeszcze nie policzylo
 
# Wymusza obliczenie:
wynik = srednia_miesieczna.compute()
# teraz: ndarray z policzonymi wartosciami

Kiedy używać a kiedy nie

dask opłaca się, gdy:

  • dane nie mieszczą się w RAM (rzędu dziesiątek GB i więcej);
  • operacje są „kłopotliwie równoległe” (embarrassingly parallel) — niezależnie po chunkach (np. resample, większość agregacji);
  • mamy więcej niż jeden rdzeń CPU lub klaster.

dask nie opłaca się, gdy:

  • dane są małe (poniżej GB) — narzut na zarządzanie chunkami przeważa zysk z równoległości;
  • algorytm jest globalny (np. odwrócenie macierzy, FFT na całej tablicy) — wymaga zebrania wszystkiego w jeden chunk;
  • pracujemy w interaktywnym notatniku, gdzie kolejność operacji jest nieprzewidywalna.

Reguła kciuka: zacznij bez dask. Jeśli pamięć siada lub obliczenia są wolne — wtedy chunk(...) na osi czasu (najczęściej) i dask.distributed.Client() do zrównoleglenia.

Praktyka — syntetyczny grid meteorologiczny

Tu zbudujemy pełny przykład: pole dziennych opadów nad Polską dla roku 2025 (365 dni × 20 punktów szerokości × 20 punktów długości). Wszystkie kroki: synteza, zapis, odczyt, agregacja i wykres.

Krok 1 — synteza danych

import numpy as np
import pandas as pd
import xarray as xr
 
rng = np.random.default_rng(seed=42)
 
# Osie:
times = pd.date_range("2025-01-01", periods=365, freq="D")
lats = np.linspace(49.0, 55.0, 20)   # Polska: ~49°N (Tatry) do ~55°N (Hel)
lons = np.linspace(14.0, 24.5, 20)   # ~14°E (granica zach.) do ~24.5°E (granica wsch.)
 
# Sezonowosc — wiecej opadow latem, mniej zima:
doy = np.arange(365)
seasonal = 0.5 + 4.5 * (1.0 + np.sin(2 * np.pi * (doy - 90) / 365)) / 2.0
# seasonal: minimum ~0.5 mm/d w styczniu, maksimum ~5.0 mm/d w lipcu
 
# Szum gamma — opady to typowo dystrybucja skosna
noise = rng.gamma(shape=1.0, scale=1.0, size=(365, 20, 20))
 
# Pole opadow = sezonowosc (broadcastowana) * szum
precip = seasonal[:, None, None] * noise
 
# DataArray:
da = xr.DataArray(
    precip,
    dims=("time", "lat", "lon"),
    coords={"time": times, "lat": lats, "lon": lons},
    name="precipitation",
    attrs={
        "units": "mm/day",
        "long_name": "Daily precipitation",
        "source": "syntetyczne dane — sin(doy) + szum gamma",
        "seed": "42",
    },
)
print(da)

Output:

<xarray.DataArray 'precipitation' (time: 365, lat: 20, lon: 20)> Size: 1MB
array([[[1.20335658e+00, 1.16931168e+00, 1.19362265e+00, ...,
         1.57753769e-01, 4.51069046e-01, 2.06707777e-01],
       ...
        [2.43294904e-01, 3.31466183e-01, 5.48327789e-01, ...,
         3.53306424e-02, 1.70866633e-01, 4.04886892e-01]]],
      shape=(365, 20, 20))
Coordinates:
  * time     (time) datetime64[us] 3kB 2025-01-01 2025-01-02 ... 2025-12-31
  * lat      (lat) float64 160B 49.0 49.32 49.63 49.95 ... 54.37 54.68 55.0
  * lon      (lon) float64 160B 14.0 14.55 15.11 15.66 ... 23.39 23.95 24.5
Attributes:
    units:      mm/day
    long_name:  Daily precipitation
    source:     syntetyczne dane — sin(doy) + szum gamma
    seed:       42

Sprawdzenie wymiarów i statystyk:

print(f"shape: {da.shape}")
# shape: (365, 20, 20)
print(f"globalna srednia: {float(da.mean()):.3f} mm/d")
# globalna srednia: 2.760 mm/d
print(f"globalne maksimum: {float(da.max()):.1f} mm/d")
# globalne maksimum: 59.2 mm/d

(Wartości deterministyczne dla seed=42.)

Krok 2 — zapis do NetCDF

Zapis wymaga backendu NetCDF

Ten krok wymaga pip install netCDF4 (lub h5netcdf lub scipy) — patrz wcześniej. Jeśli nie ma backendu, pomiń krok 2 i krok 3 (odczyt) — pole da jest w pamięci, dalsze kroki są niezależne.

# Wymaga zainstalowanego netCDF4 / h5netcdf / scipy
da.to_dataset().to_netcdf("/tmp/precip_2025.nc")
# Plik powinien miec ok. 1.2 MB (1.17 MB dla 365*20*20*8 bytes / czesc na metadane)

Krok 3 — odczyt z NetCDF

# Wymaga zainstalowanego netCDF4 / h5netcdf / scipy
ds = xr.open_dataset("/tmp/precip_2025.nc")
print(ds)
print(ds["precipitation"].attrs)
# {'units': 'mm/day', 'long_name': 'Daily precipitation', ...}
ds.close()

Krok 4 — agregacja miesięczna

# Suma miesięczna w każdym punkcie siatki:
miesieczne = da.resample(time="MS").sum()
print(miesieczne.shape)
# (12, 20, 20)
print(miesieczne.time.values[:3])
# ['2025-01-01T00:00:00.000000' '2025-02-01T00:00:00.000000' '2025-03-01T00:00:00.000000']
 
# Średnia z 12 miesięcznych sum (czyli „średni miesiąc" po całej Polsce):
print("Srednie miesieczne sumy (mm, srednia po obszarze):")
print(miesieczne.mean(dim=("lat", "lon")).values.round(1))

Output (deterministyczny):

Srednie miesieczne sumy (mm, srednia po obszarze):
[ 19.   32.9  66.9  99.1 133.7 147.6 153.  134.2 100.9  66.7  34.9  18.5]

Widać sezonowość: minimum w styczniu i grudniu (~19 mm), maksimum w czerwcu-lipcu (~148-153 mm). To efekt formuły 0.5 + 4.5*(1+sin(...))/2.

Krok 5 — średnia po szerokościach (groupby_bins)

# Trzy pasma szerokosci geograficznej: 49-51, 51-53, 53-55:
po_szerokosci = da.groupby_bins("lat", bins=[49, 51, 53, 55]).mean()
print(po_szerokosci.shape)
# (365, 3, 20)
print(po_szerokosci.coords["lat_bins"].values)
# [(49, 51] (51, 53] (53, 55]]
 
# Średnia roczna w każdym pasie (po czasie i długości):
roczna_w_pasach = po_szerokosci.mean(dim=("time", "lon"))
print(roczna_w_pasach.values.round(3))

Output:

[2.762 2.75  2.757]

W tym syntetycznym przykładzie pole opadu nie zależy od lat — średnie w pasach są niemal identyczne (różnice to fluktuacje losowe). W realnych danych zobaczylibyśmy gradient.

Krok 6 — okno kroczące 7-dniowe

# 7-dniowa suma krocząca opadów (wskaznik zwilzenia zlewni):
opad_7d = da.rolling(time=7, center=True).sum()
print(opad_7d.shape)
# (365, 20, 20)
 
# Maksymalna 7-dniowa suma na obszarze
# (sprawdzamy, kiedy spadl najwiekszy "epizod" w pierwszym punkcie):
punkt = opad_7d.isel(lat=0, lon=0)
max_idx = int(punkt.argmax())
print(f"Max 7-dniowa suma w punkcie (49°N, 14°E):")
print(f"  data: {punkt.time.values[max_idx]}")
print(f"  suma: {float(punkt.isel(time=max_idx)):.1f} mm")

Output (deterministyczne):

Max 7-dniowa suma w punkcie (49°N, 14°E):
  data: 2025-07-08T00:00:00.000000
  suma: 84.3 mm

Krok 7 — wizualizacja

xarray ma wbudowane nakładki na matplotlib (DataArray.plot()), które rozpoznają typ danych i wybierają sensowny styl:

import matplotlib
matplotlib.use("Agg")   # backend bez GUI — do skryptu
import matplotlib.pyplot as plt
 
# Wykres szeregu czasowego dla jednego punktu (49.32°N, 14.55°E):
fig, ax = plt.subplots(figsize=(10, 4))
da.isel(lat=0, lon=0).plot(ax=ax)
ax.set_title("Dzienny opad w punkcie (lat=49°N, lon=14°E)")
ax.set_ylabel("Opad [mm/dzień]")
fig.tight_layout()
fig.savefig("/tmp/opad_punkt.png", dpi=120)
plt.close(fig)
print("Zapisano /tmp/opad_punkt.png")

Mapa średniej rocznej:

import matplotlib.pyplot as plt
 
srednia_roczna = da.mean(dim="time")   # shape (20, 20)
 
fig, ax = plt.subplots(figsize=(8, 6))
srednia_roczna.plot(ax=ax, cmap="Blues")
ax.set_title("Srednia roczna opadu (synteza)")
fig.tight_layout()
fig.savefig("/tmp/mapa_srednia.png", dpi=120)
plt.close(fig)
print("Zapisano /tmp/mapa_srednia.png")

xarray automatycznie:

  • używa współrzędnych lat/lon jako osi (nie indeksów 0..19);
  • dodaje pasek koloru z opisem (z attrs["units"]);
  • podpisuje osie nazwami wymiarów.

Wykres miesięcznych sum (uśrednionych po obszarze):

import matplotlib.pyplot as plt
 
m = da.resample(time="MS").sum().mean(dim=("lat", "lon"))
 
# xarray.DataArray.plot ma .line/.hist/.imshow/.pcolormesh/.scatter/.step/.contour
# — nie ma natywnego .bar(). Najprosciej narysowac bar przez matplotlib:
fig, ax = plt.subplots(figsize=(10, 4))
miesiace = [str(t)[:7] for t in m.time.values]   # "YYYY-MM"
ax.bar(miesiace, m.values)
ax.set_title("Srednie miesieczne sumy opadu (synteza)")
ax.set_ylabel("Suma [mm]")
ax.tick_params(axis="x", rotation=45)
fig.tight_layout()
fig.savefig("/tmp/sumy_miesieczne.png", dpi=120)
plt.close(fig)
print("Zapisano /tmp/sumy_miesieczne.png")

Krok 8 — zapis pojedynczego rastra (średnia roczna) do GeoTIFF

import rioxarray  # noqa: F401  — rejestruje akcesor .rio
 
srednia_roczna = da.mean(dim="time")   # (lat, lon)
 
# rioxarray oczekuje osi nazwanych x, y
sr_geo = srednia_roczna.rename({"lon": "x", "lat": "y"})
sr_geo.rio.write_crs("EPSG:4326", inplace=True)
sr_geo.rio.to_raster("/tmp/srednia_roczna.tif")
print("Zapisano /tmp/srednia_roczna.tif")

Powstały plik można otworzyć w QGIS i wyświetlić jako warstwę rastrową.

Krok 9 — w jednym łańcuchu (styl funkcyjny)

xarray pozwala na płynne łączenie operacji w łańcuch:

# Najmokrejsze 5 dni w roku w polnocnej Polsce (lat >= 53):
top5 = (
    da
    .sel(lat=slice(53, 55))
    .mean(dim=("lat", "lon"))
    .sortby(lambda x: -x)
    .isel(time=slice(0, 5))
)
print(top5.time.values)
print(top5.values.round(2))

Output (deterministyczne):

['2025-07-02T00:00:00.000000' '2025-07-26T00:00:00.000000'
 '2025-07-13T00:00:00.000000' '2025-07-15T00:00:00.000000'
 '2025-06-16T00:00:00.000000']
[6.04 5.73 5.72 5.66 5.56]

.sortby(lambda x: -x) — sortowanie malejące. .isel(time=slice(0, 5)) — pierwszych 5.

Symetria z pandas

xarray był projektowany jako rozszerzenie wzorca pandas na wiele wymiarów — wiele elementów API jest celowo symetrycznych:

pandasxarray
DataFrameDataset
SeriesDataArray
df.loc[idx]da.sel(dim=idx)
df.iloc[i]da.isel(dim=i)
df.groupby("col").mean()da.groupby("col").mean()
df.resample("MS").sum()da.resample(time="MS").sum()
df.rolling(7).mean()da.rolling(time=7).mean()
df.fillna(0)da.fillna(0)
df.dropna()da.dropna(dim=...)
df.melt() / df.stack()da.stack(...) / da.to_dataframe()
df.to_csv(...)da.to_pandas().to_csv(...) (przez pd)
pd.merge(df1, df2)xr.merge([ds1, ds2])
pd.concat([df1, df2])xr.concat([da1, da2], dim=...)

Główne różnice:

  • pandas zakłada 2D z heterogenicznymi typami kolumn; xarray zakłada N-D z jednorodnym typem w obiekcie DataArray (a wiele typów obok siebie organizuje w obiekcie Dataset).
  • pandas indeksuje wszystkim (wiersz × kolumna); xarray indeksuje po nazwach wymiarów — bezpieczniej przy ≥3D.
  • pandas ma natywne CSV, JSON, SQL; xarray ma natywne NetCDF, Zarr i integrację z dask.

Dla bardziej szczegółowego materiału o pandas: pandas.

Pułapki i dobre praktyki

Kolejność wymiarów ma znaczenie

import xarray as xr
import numpy as np
 
a = xr.DataArray(np.zeros((3, 5)), dims=("x", "y"))
print(a.shape)
# (3, 5)
 
b = a.transpose("y", "x")
print(b.shape)
# (5, 3)

.transpose(...) zmienia kolejność wymiarów (jak np.transpose, ale po nazwie). Większość operacji w xarray jest „świadoma wymiarów” — suma po time przy układzie (lat, time, lon) daje to samo co przy (time, lat, lon). Ale przy zapisie do NetCDF, GeoTIFF lub przy wymianie z NumPy kolejność wymiarów musi być znana i zgodna z konwencją.

Konwencja CF/GeoTIFF: (time, [z], lat, lon) lub (time, [z], y, x). Trzymanie się tego ułatwia interoperacyjność.

Coords po operacjach

Po agregacji wzdłuż osi ta oś znika — ale jej współrzędna może zostać jako skalarna:

import xarray as xr
import pandas as pd
import numpy as np
 
times = pd.date_range("2025-01-01", periods=5)
da = xr.DataArray(np.arange(15).reshape(5, 3),
                  dims=("time", "site"),
                  coords={"time": times, "site": ["A", "B", "C"]})
 
# Mean po site — wymiar site znika
m = da.mean(dim="site")
print(m.dims)
# ('time',)
print("site" in m.coords)
# False

W zależności od operacji niektóre coords są zachowywane (skalarne), inne usuwane. W razie wątpliwości sprawdź przez print(result).

attrs nie zawsze propagują — wymuszaj

Patrz wcześniej. Jeśli jednostki, opis i źródło muszą zostać po obliczeniu — opakowuj w with xr.set_options(keep_attrs=True): albo ustaw globalnie na początku skryptu:

import xarray as xr
xr.set_options(keep_attrs=True)

Albo wprost po operacji:

import xarray as xr
import numpy as np
 
a = xr.DataArray([1.0, 2.0], dims="x", attrs={"units": "m"})
b = a * 0.001
b.attrs["units"] = "km"
b.attrs["long_name"] = "Distance"

Broadcasting po coords — przy każdej arytmetyce

Powtórzmy z naciskiem: gdy dwa obiekty DataArray mają wspólny wymiar, ale różne współrzędne, xarray automatycznie znajduje przecięcie. Może to dać niespodziewanie pustą tablicę:

import xarray as xr
 
a = xr.DataArray([1, 2, 3], dims="x", coords={"x": [1, 2, 3]})
b = xr.DataArray([10, 20, 30], dims="x", coords={"x": [10, 20, 30]})
 
print((a + b).values)
# []   — bo zaden x sie nie pokrywa
print((a + b).x.values)
# []

Jeśli chcemy zsumować po pozycji (a nie po etykiecie), trzeba albo zignorować coords (a.values + b.values), albo wyrównać etykiety przed operacją.

NaN w typach całkowitych — niemożliwe

import xarray as xr
import numpy as np
 
a = xr.DataArray([1, 2, 3], dims="x")           # int
print(a.dtype)
# int64
 
# Probujemy ustawic NaN — wymusza konwersje na float
b = a.where(a > 1)
print(b.dtype)
# float64
print(b.values)
# [nan  2.  3.]

Tak samo jak w NumPy: NaN istnieje tylko dla typów zmiennoprzecinkowych. xarray konwertuje automatycznie. Jeśli zależy nam na typie całkowitym z „brakiem danych” — używamy konkretnej wartości NoData (np. -9999) i własnej maski.

Wydajność: wektoryzuj, unikaj pętli

Tak samo jak w NumPy i pandas, w xarray pętla po wymiarach jest bardzo wolna. Wszystko, co jest agregacją lub operacją po wszystkich elementach, zrób bezpośrednio na obiekcie DataArray:

import xarray as xr
import numpy as np
import pandas as pd
 
# Przykladowe pole (czas x lat x lon)
da = xr.DataArray(
    np.random.default_rng(0).normal(0, 1, size=(365, 20, 20)),
    dims=("time", "lat", "lon"),
    coords={
        "time": pd.date_range("2025-01-01", periods=365),
        "lat": np.linspace(49, 55, 20),
        "lon": np.linspace(14, 24.5, 20),
    },
)
 
# ŹLE — petla po dniach:
# wyniki = []
# for t in range(365):
#     wyniki.append(da.isel(time=t).mean().values)
 
# DOBRZE — wektorowo:
wyniki = da.mean(dim=("lat", "lon")).values   # 365 wartosci, jedno wywolanie
print(wyniki.shape)
# (365,)

Pierwsza wersja może być zauważalnie wolniejsza dla siatki 20×20 i znacząco wolniejsza dla siatki 1000×1000.

Kiedy xarray, a kiedy pandas

Reguła praktyczna:

  • Tabela 2D z heterogenicznymi typami kolumn (np. CSV ze stacjami, datami, statusami) → pandas.
  • Pole numeryczne 3D i więcej z jednorodnym typem (czas × lat × lon lub czas × stacja × parametr) → xarray.
  • Pojedynczy raster 2D bez czasu → numpy + rasterio (xarray to przerost).
  • Hybryda (tabela ze stacjami + szereg pomiarowy) → pandas na zewnątrz, ewentualnie konwersja na xarray przy operacjach typu „interpoluj po czasie”.

Wersje pakietów — kompatybilność wstecz

API xarray jest stosunkowo stabilne, ale część szczegółów (m.in. domyślne wartości typu keep_attrs, niektóre nazwy parametrów, wycofywane elementy) zmienia się między wersjami. Przy migracji z xarray 2023.x lub 2024.x na 2026.x warto:

  • przeczytać whatsnew w oficjalnej dokumentacji;
  • sprawdzić FutureWarning w wynikach pytest;
  • jeśli kod ma być długoterminowy — zafiksować wersję (xarray==2026.4.0 w requirements.txt).

Pliki NetCDF — konwencja CF

Jeśli plik trafi do narzędzia innego niż Twoje (QGIS, CDO, Panoply, modele klimatyczne), warto trzymać się konwencji CF:

  • units — jednostki (np. mm, kg m-2 s-1, degC, K).
  • long_name — opis czytelny dla człowieka.
  • standard_name — jedna z zatwierdzonych nazw CF (np. precipitation_flux, air_temperature).
  • _FillValue — wartość oznaczająca „brak danych” (czytana automatycznie przez xarray/QGIS).
  • Conventions — "CF-1.8" lub nowsza wersja, w atrybutach całego datasetu.

To inwestycja, która zwraca się przy każdej wymianie pliku z inną aplikacją.

Zobacz także