Skip to content

Commit 0a679ca

Browse files
committed
Untangled spatial and time resampling
1 parent e9f8577 commit 0a679ca

8 files changed

Lines changed: 193 additions & 74 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ SUBPLAN-insitu-readers.md
88
/.ruff_cache/
99
**/DATOS-INSITU/*
1010
CLAUDE.md
11-
test_data/results/
11+
test_data/results/
12+
USAGE.md

ClimateGraph/data/data.py

Lines changed: 40 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import cartopy.crs as ccrs
66
import numpy as np
7-
import pandas as pd
87
import xarray as xr
98

109
from ClimateGraph.reader import Reader
@@ -118,7 +117,6 @@ def __init__(
118117
self._path = None
119118
self._bbox = None # minlon, minlat, maxlon, maxlat
120119
self._dims = None
121-
self.resampled = None
122120

123121
# Provided by user
124122
self.name = name
@@ -145,7 +143,6 @@ def copy(self):
145143
new._geom = self._geom
146144
new._bbox = self._bbox # minlon, minlat, maxlon, maxlat
147145
new._dims = self._dims
148-
new.resampled = self.resampled
149146

150147
return new
151148

@@ -180,7 +177,6 @@ def obj(self, _obj: xr.Dataset):
180177
self._bbox = None
181178
self._geom = None
182179
self._dims = None
183-
self.resampled = None
184180

185181
@property
186182
def geom(self):
@@ -402,28 +398,27 @@ def resample_vars(
402398
other: "Data",
403399
vars: str | list[str],
404400
radius_of_influence: int = 10000,
405-
time_tolerance: str | None = "30min",
406401
engine: str | ResampleEngine = "pyresample",
407402
engine_kwargs: dict | None = None,
408-
) -> xr.DataArray | xr.Dataset:
409-
"""Project ``other``'s vars onto ``self``'s geometry (and time axis).
403+
) -> xr.Dataset:
404+
"""Project ``other``'s vars onto ``self``'s spatial geometry.
410405
411-
A single responsibility: spatial resampling with the requested engine, plus
412-
a nearest-neighbour time *alignment* of ``other`` onto ``self``'s time axis.
413-
It does NOT convert units or filter/resample time — those belong to the
414-
caller (``get_var`` / the plot's ``change_unit`` and ``time_resampling``).
406+
Purely spatial and stateless: each variable of ``other`` is reprojected
407+
onto ``self``'s geometry with the requested engine, keeping ``other``'s own
408+
time axis and any extra dims (z, member, …) untouched, and inheriting
409+
``self``'s spatial coordinates (site / latitude / longitude / region / …).
410+
It does NOT align time, convert units, or cache — those are the caller's
411+
concern. Each call is independent, so two sources with different time
412+
extents resampled onto the same target never clobber each other.
415413
416414
Parameters
417415
----------
418416
other : Data
419-
Other data object to resample into the "self" geometry.
417+
Data whose vars are reprojected onto ``self``'s geometry.
420418
vars : str | list[str]
421-
Variable name or list of names to resample (kept in their source units).
419+
Variable name or list of names to resample.
422420
radius_of_influence : int, optional
423421
Radius length in meters to use for resampling. by default 10000
424-
time_tolerance : str | None, optional
425-
Pandas-style timedelta used as the tolerance when snapping ``other``'s
426-
time axis onto ``self``'s via nearest-neighbour reindex. Default ``"30min"``.
427422
engine : str | ResampleEngine, optional
428423
Resample backend name or instance. Default ``"pyresample"``.
429424
engine_kwargs : dict | None, optional
@@ -432,40 +427,37 @@ def resample_vars(
432427
433428
Returns
434429
-------
435-
xr.DataArray | xr.Dataset
436-
Resampled data on ``self``'s geometry, aligned onto ``self``'s time axis.
430+
xr.Dataset
431+
One variable per input var, keyed ``"{var}__{other.name}"``, on
432+
``self``'s geometry and carrying ``other``'s time / extra dims.
437433
"""
438434
if isinstance(vars, str):
439435
vars = [vars]
440436

441437
resample_engine = get_engine(engine, **(engine_kwargs or {}))
442-
src_geom = other.geom
443-
dst_geom = self.geom
444-
445438
info = resample_engine.prepare(
446-
src_geom,
447-
dst_geom,
439+
other.geom,
440+
self.geom,
448441
radius_of_influence=radius_of_influence,
449442
)
450-
_resample = resample_engine.make_resampler(info, dst_geom.shape)
451-
452-
if self.resampled is None:
453-
self.resampled = self.obj.drop_vars(list(self.obj.data_vars))
454-
455-
new_vars = []
443+
_resample = resample_engine.make_resampler(info, self.geom.shape)
444+
445+
# Target geometry straight from self.obj (no data vars, no time axis): the
446+
# geom-dim ORDER follows self.obj so it matches self.geom.shape, and the
447+
# spatial coords (those depending only on the geom dims) are reattached to
448+
# the result — this is how the source inherits site/region/lat/lon.
449+
dst_geom_dims = [d for d in self.obj.dims if d in set(self.geom_dims)]
450+
dst_sizes = {d: self.obj.sizes[d] for d in dst_geom_dims}
451+
dst_coords = {
452+
name: coord
453+
for name, coord in self.obj.coords.items()
454+
if set(coord.dims) <= set(self.geom_dims)
455+
}
456+
457+
resampled_vars = {}
456458
for var in vars:
457-
var_dst_dims = self.get_var(var).sizes
458-
var_src = other.get_var(var)
459-
460-
if time_tolerance is not None and "time" in var_src.dims:
461-
var_src = var_src.reindex(
462-
time=self.resampled["time"],
463-
method="nearest",
464-
tolerance=pd.Timedelta(time_tolerance),
465-
)
466-
459+
var_src = other.get_var(var) # keeps other's own time + extra dims
467460
src_geom_dims = [d for d in var_src.dims if d in set(other.geom_dims)]
468-
dst_geom_dims = [d for d in var_dst_dims if d in set(self.geom_dims)]
469461
resampled = xr.apply_ufunc(
470462
_resample,
471463
var_src,
@@ -474,30 +466,22 @@ def resample_vars(
474466
vectorize=True,
475467
dask="parallelized",
476468
output_dtypes=[var_src.dtype],
477-
dask_gufunc_kwargs={
478-
"output_sizes": {
479-
name: value
480-
for name, value in var_dst_dims.items()
481-
if name in set(self.geom_dims)
482-
}
483-
},
469+
dask_gufunc_kwargs={"output_sizes": dst_sizes},
484470
)
471+
resampled_vars[f"{var}__{other.name}"] = resampled.assign_coords(dst_coords)
485472

486-
new_name = f"{var}__{other.name}"
487-
self.resampled[new_name] = resampled
488-
new_vars.append(new_name)
489-
473+
result = xr.Dataset(resampled_vars)
490474
_record(
491-
self.resampled,
492-
f"spatially resampled {new_vars} from {other.name} "
475+
result,
476+
f"spatially resampled {list(resampled_vars)} from {other.name} "
493477
f"({type(other).__name__}) to {self.name} ({type(self).__name__})",
494478
)
495479

496480
save_to = self.reader_kwargs.get("save_resampled_to")
497481
if save_to:
498482
target = Path(save_to)
499483
target.parent.mkdir(parents=True, exist_ok=True)
500-
self.resampled.to_netcdf(target)
501-
_record(self.resampled, f"saved resampled data to {target}")
484+
result.to_netcdf(target)
485+
_record(result, f"saved resampled data to {target}")
502486

503-
return self.resampled[new_vars]
487+
return result

ClimateGraph/domain/domain.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,10 @@ def apply(self, data: "Data") -> "Data":
122122
def _resample(self, data: "Data") -> "Data":
123123
"""_resample Reproject ``data`` onto the resample target's geometry.
124124
125-
Resampling runs on the REAL target so its ``.resampled`` cache accumulates
126-
and ``save_resampled_to`` fires as designed. The returned value is a cheap
127-
target-topology wrapper wearing the SOURCE's identity (name + vars); building
128-
it never disturbs the target's cache because the ``obj`` setter resets only
129-
the wrapper's own caches.
125+
``resample_vars`` is purely spatial and stateless, so the reprojected
126+
result keeps ``data``'s own time axis and inherits the target's spatial
127+
coords (site / region / lat / lon). The returned value is a cheap
128+
target-topology wrapper wearing the SOURCE's identity (name + vars).
130129
131130
Parameters
132131
----------

ClimateGraph/plot/plots.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import matplotlib as mpl
66
import matplotlib.pyplot as plt
77
import numpy as np
8+
import xarray as xr
89
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
910

1011
mpl.use("Agg")
@@ -324,6 +325,13 @@ def _plot_one(self, time_interval: str | None):
324325
reduced[ds_name] = da
325326

326327
x_var, y_var = reduced[x_name], reduced[y_name]
328+
# Scatter pairs the two datasets element-wise along `dimension`.
329+
# resample_vars is now purely spatial (it no longer snaps time), so
330+
# align the two on the shared axis here — the correct home for the
331+
# time-alignment concern. inner-join keeps only matching coords.
332+
if dimension in x_var.dims and dimension in y_var.dims:
333+
x_var, y_var = xr.align(x_var, y_var, join="inner")
334+
327335
figure = plt.figure(**self.figure_kwargs())
328336
ax = figure.add_subplot(1, 1, 1)
329337
min_val, max_val = (
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# ============================================================================
2+
# TEMPLATE — custom timeseries (`type: custom` + `series` primitives)
3+
# ----------------------------------------------------------------------------
4+
# A "custom" plot overlays rendering primitives on shared axes. A `series` with
5+
# `x: time` is a line over time — space is reduced away automatically. Copy a
6+
# block below and adapt `dataset` / `var` / `time` to your data.
7+
#
8+
# Runs as-is on the bundled WRF (grid) + DMC (stations) samples.
9+
# ============================================================================
10+
analysis:
11+
output_path: "./test_data/results/template-custom-ts/"
12+
debug: false
13+
14+
data:
15+
DMC: # in-situ stations (PointSurface)
16+
path: "./test_data/data/dmc*.nc"
17+
topology: PointSurface
18+
reader: DMC
19+
vars:
20+
Temperatura: {name: temperatura, unit: degC}
21+
WRF: # model grid (RegularGrid)
22+
path: "./test_data/data/wrf-20*.nc"
23+
topology: RegularGrid
24+
reader: wrf
25+
vars:
26+
Temperatura: {name: T2, unit: kelvin}
27+
28+
plots:
29+
30+
# 1) SIMPLEST — one line, one dataset.
31+
single:
32+
type: custom
33+
time: "1/1/2019 - 1/2/2019"
34+
vars: [Temperatura] # one figure per var in the list
35+
subplots:
36+
- type: series
37+
dataset: DMC
38+
x: time
39+
label: "Stations"
40+
41+
# 2) COMPARISON — two datasets, same var, on one figure.
42+
# Plot-level `vars` as a {var: unit} map converts every series to that unit
43+
# (WRF kelvin -> degC) so they share a y-axis. `grid: true` adds faded
44+
# dashed reference lines at each tick.
45+
obs_vs_model:
46+
type: custom
47+
time: "1/1/2019 - 1/2/2019"
48+
timestep: D # daily-mean before plotting (optional)
49+
vars: {Temperatura: degC}
50+
grid: true
51+
subplots:
52+
- type: series
53+
dataset: DMC
54+
x: time
55+
label: "Observations"
56+
color: black
57+
linewidth: 2
58+
- type: series
59+
dataset: WRF
60+
x: time
61+
label: "WRF model"
62+
color: tab:red
63+
64+
# 3) TWO PERIODS on one figure — per-subplot `time` override.
65+
# Omit plot-level `vars` and give each subplot its own `var` + `time`; the
66+
# plot renders once (no fan-out) with both windows overlaid.
67+
two_periods:
68+
type: custom
69+
subplots:
70+
- type: series
71+
dataset: DMC
72+
var: Temperatura
73+
x: time
74+
time: "1/1/2019 - 1/2/2019"
75+
label: "January"
76+
color: tab:blue
77+
- type: series
78+
dataset: DMC
79+
var: Temperatura
80+
x: time
81+
time: "1/2/2019 - 28/2/2019"
82+
label: "February"
83+
color: tab:orange
84+
title: "DMC temperature — January vs February"

tests/test_data_module/test_data_methods.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,34 @@ def test_resample_grid_to_point(self, regular_grid_data, point_surface_data):
9494
# Result should live on the destination (point) geometry.
9595
assert "site" in result.dims
9696

97-
def test_resample_caches_resampled(self, regular_grid_data, point_surface_data):
98-
assert point_surface_data.resampled is None
99-
point_surface_data.resample_vars(
97+
def test_resample_is_stateless(self, regular_grid_data, point_surface_data):
98+
# No cache: resampling never mutates the target, and each call is a fresh
99+
# independent Dataset (so different-time sources can't clobber each other).
100+
assert not hasattr(point_surface_data, "resampled")
101+
target_before = point_surface_data.obj
102+
r1 = point_surface_data.resample_vars(
100103
regular_grid_data, "Temperatura", radius_of_influence=500_000
101104
)
102-
assert point_surface_data.resampled is not None
105+
r2 = point_surface_data.resample_vars(
106+
regular_grid_data, "Temperatura", radius_of_influence=500_000
107+
)
108+
assert point_surface_data.obj is target_before # target untouched
109+
assert r1 is not r2
110+
111+
def test_resample_keeps_source_time_and_inherits_target_coords(
112+
self, regular_grid_data, point_surface_data
113+
):
114+
# The source keeps its OWN time axis (no snapping onto the target), and
115+
# inherits the target's spatial coords (site + region), which is what makes
116+
# region/site attribute filtering on a resampled grid possible.
117+
result = point_surface_data.resample_vars(
118+
regular_grid_data, "Temperatura", radius_of_influence=500_000
119+
)
120+
src_time = regular_grid_data.obj["time"]
121+
assert result.sizes["time"] == src_time.sizes["time"]
122+
assert result["time"].equals(src_time)
123+
assert "region" in result.coords # inherited from the point target
124+
assert result.sizes["site"] == point_surface_data.obj.sizes["site"]
103125

104126
def test_resample_4d_grid_preserves_z(self, point_surface_data):
105127
"""Resampling a 4D (time, z, y, x) RegularGrid should broadcast across z
@@ -173,13 +195,11 @@ def test_cached_state_is_propagated(self, regular_grid_data):
173195
_ = regular_grid_data.bbox
174196
_ = regular_grid_data.dims
175197
regular_grid_data._set_geom()
176-
regular_grid_data.resampled = "sentinel"
177198

178199
copy = regular_grid_data.copy()
179200
assert copy._bbox == regular_grid_data._bbox
180201
assert copy._dims == regular_grid_data._dims
181202
assert copy._geom is regular_grid_data._geom
182-
assert copy.resampled == "sentinel"
183203

184204
def test_obj_setter_on_copy_does_not_mutate_original(
185205
self, regular_grid_data, make_regular_grid

tests/test_plot/test_plot_runs.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,29 @@ def test_runs_with_grid_and_point(
336336
)
337337
plot.plot()
338338
assert _outputs(tmp_output_dir, "sc")
339+
340+
def test_aligns_datasets_with_mismatched_time(
341+
self, point_surface_data, tmp_output_dir
342+
):
343+
# resample_vars no longer snaps time, so Scatter aligns the two series
344+
# itself. Pair a 6-step dataset with a 4-step one: without the inner-join
345+
# align the reduced arrays would be different lengths and scatter would
346+
# raise; with it, they pair on the overlapping timestamps.
347+
short = point_surface_data.copy()
348+
short.obj = point_surface_data.obj.isel(time=slice(0, 4))
349+
350+
cfg = ScatterConfig(
351+
type="scatter",
352+
data=["A", "B"],
353+
time=TIME_INTERVAL,
354+
vars={"Temperatura": "degC"},
355+
)
356+
plot = Scatter(
357+
name="sc_align",
358+
plot_config=cfg,
359+
data_registry={"A": point_surface_data, "B": short},
360+
domain_registry={},
361+
output_path=tmp_output_dir,
362+
)
363+
plot.plot() # must not raise on the length mismatch
364+
assert _outputs(tmp_output_dir, "sc_align")

0 commit comments

Comments
 (0)