Skip to content

Commit 8879107

Browse files
committed
Cumulative skillscores along trajectory are now calculated when providing cumulative=skill to skill method (e.g. Liu-Weisberg). example_parcels updated to illustrate
1 parent 4e875e6 commit 8879107

4 files changed

Lines changed: 133 additions & 7 deletions

File tree

examples/example_parcels.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,15 @@
5353
plt.colorbar(mappable=mappable, orientation='horizontal', label='Skillscore per trajectory')
5454
plt.legend()
5555
plt.show()
56+
57+
#%%
58+
# Illustration of cumulative skillscore:
59+
# Plotting another random trajectory ("modeled"), colored by the cumulative skillscore
60+
# where the first trajectory is regarded as truth
61+
ds_model = ds.isel(trajectory=4)
62+
skillscore = ds_model.traj.skill(expected=ds_true, method='liu-weissberg', cumulative=True)
63+
ds_true.traj.plot(color='k', linewidth=3, label='"True" trajectory', land='mask')
64+
mappable = ds_model.traj.plot(linewidth=3, color=skillscore, label='"Modeled" trajectory')
65+
plt.colorbar(mappable=mappable, orientation='horizontal', label='Cumulative skillscore')
66+
plt.legend()
67+
plt.show()

tests/test_skill_score.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,78 @@ def test_skillscores():
7171
skill_lw = ta.skill.liu_weissberg(lon_obs, lat_obs, lon_model, lat_model)
7272
np.testing.assert_almost_equal(skill_lw, 0.99099, 5)
7373

74+
def test_skillscores_cumulative():
75+
lon_obs = np.array([0, 1, 2, 3, 4, 5], dtype=float)
76+
lat_obs = np.array([0, 0, 0, 0, 0, 0], dtype=float)
77+
lon_model = lon_obs.copy()
78+
km2deg = 111
79+
lon_model[-1] = lon_obs[-1] + 1.8/km2deg
80+
lat_model = np.array([0, 1.2/km2deg, -3.4/km2deg, 6.3/km2deg, 4.2/km2deg, 0])
81+
82+
skill_cum = ta.skill.liu_weissberg(lon_obs, lat_obs, lon_model, lat_model, cumulative=True)
83+
84+
# Output length must match input length
85+
assert len(skill_cum) == len(lon_obs)
86+
# First value is NaN (no arc length at t=0); second value is always valid
87+
assert np.isnan(skill_cum[0])
88+
assert not np.isnan(skill_cum[1])
89+
# All other values are in [0, 1]
90+
assert np.all(skill_cum[1:] >= 0) and np.all(skill_cum[1:] <= 1)
91+
# Last value equals the non-cumulative score
92+
skill_scalar = ta.skill.liu_weissberg(lon_obs, lat_obs, lon_model, lat_model)
93+
np.testing.assert_almost_equal(skill_cum[-1], skill_scalar, 10)
94+
# Score decreases when divergence accelerates (d_k/L_k exceeds running average)
95+
lon_div = np.array([0, 1, 2, 3, 4, 5], dtype=float)
96+
lat_div = np.zeros(6)
97+
lat_model_div = np.array([0, 0.5/111, 2.0/111, 5.0/111, 10.0/111, 18.0/111]) # super-linear divergence
98+
skill_div = ta.skill.liu_weissberg(lon_div, lat_div, lon_div, lat_div + lat_model_div, cumulative=True)
99+
assert skill_div[-1] < skill_div[-2], "Score should decrease for accelerating divergence"
100+
101+
102+
def test_skillscores_cumulative_2d():
103+
lon_obs = np.array([[0, 1, 2, 3, 4, 5], [0, 1, 2, 3, 4, 5]], dtype=float).T
104+
lat_obs = np.zeros_like(lon_obs)
105+
lon_model = lon_obs.copy()
106+
km2deg = 111
107+
lon_model[:, -1] = lon_obs[:, -1] + 1.8/km2deg
108+
lat_model = np.array([[0, 1.2/km2deg, -3.4/km2deg, 6.3/km2deg, 4.2/km2deg, 0],
109+
[0, 1.2/km2deg, -3.4/km2deg, 6.3/km2deg, 4.2/km2deg, 0]]).T
110+
111+
skill_cum = ta.skill.liu_weissberg(lon_obs, lat_obs, lon_model, lat_model, cumulative=True)
112+
113+
assert skill_cum.shape == (6, 2)
114+
assert np.all(np.isnan(skill_cum[0, :]))
115+
assert not np.any(np.isnan(skill_cum[1, :]))
116+
assert np.all(skill_cum[1:, :] >= 0) and np.all(skill_cum[1:, :] <= 1)
117+
# Last value matches non-cumulative score for each trajectory
118+
skill_scalar = ta.skill.liu_weissberg(lon_obs, lat_obs, lon_model, lat_model)
119+
np.testing.assert_array_almost_equal(skill_cum[-1, :], skill_scalar)
120+
121+
122+
def test_skillscores_cumulative_xarray(barents):
123+
barents = barents.traj.gridtime('1h')
124+
b0 = barents.isel(trajectory=0).dropna('time')
125+
b1 = barents.isel(trajectory=1).sel(time=slice(b0.time[0], b0.time[-1]))
126+
b1 = b1.traj.gridtime(b0.time)
127+
128+
skill_cum = b0.traj.skill(b1, cumulative=True)
129+
130+
# Cumulative result has exactly the same dims and coords as self's internal dataset
131+
assert skill_cum.dims == b0.traj.ds.lon.dims
132+
assert skill_cum.sizes == {d: b0.traj.ds.sizes[d] for d in b0.traj.ds.lon.dims}
133+
np.testing.assert_array_equal(skill_cum.coords['time'].values, b0.coords['time'].values)
134+
assert np.all(np.isnan(skill_cum.isel(time=0).values))
135+
assert not np.any(np.isnan(skill_cum.isel(time=1).values))
136+
# Cumulative array values are in [0, 1] beyond t=0
137+
assert np.all(skill_cum.isel(time=slice(1, None)).values >= 0)
138+
assert np.all(skill_cum.isel(time=slice(1, None)).values <= 1)
139+
# Last value equals the non-cumulative score
140+
skill_scalar = b0.traj.skill(b1)
141+
np.testing.assert_array_almost_equal(
142+
skill_cum.isel(time=-1).values.squeeze(),
143+
skill_scalar.values.squeeze(), 5)
144+
145+
74146
def test_skillscores_2d():
75147
lon_obs = np.array([[0, 1, 2, 3, 4, 5], [0, 1, 2, 3, 4, 5]]).T
76148
lat_obs = np.array([[0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0]]).T

trajan/skill/__init__.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,23 @@ def distance_along_trajectory(lon, lat):
2222

2323
return distance
2424

25-
def liu_weissberg(lon_obs, lat_obs, lon_model, lat_model, tolerance_threshold=1):
25+
def liu_weissberg(lon_obs, lat_obs, lon_model, lat_model, tolerance_threshold=1, cumulative=False):
2626
'''
27-
Calculate skill score from normalized cumulative seperation distance. Liu and Weisberg 2011.
27+
Calculate skill score from normalized cumulative separation distance. Liu and Weisberg 2011.
28+
29+
Parameters:
30+
cumulative: If True, return an array of skill scores at each time step
31+
(shape same as input, time first axis), with NaN at t=0.
32+
At step k the score is max(0, 1 - s_k/n₀) where
33+
s_k = (Σ_{i=0}^{k} d_i) / (Σ_{i=1}^{k} L_i), i.e. the
34+
Liu-Weisberg formula evaluated on the data up to step k.
35+
The last value therefore equals the non-cumulative score.
36+
The score decreases at step k when d_k/L_k exceeds the
37+
running average s_{k-1} (accelerating divergence).
2838
2939
Returns:
3040
31-
Skill score between 0. and 1.
41+
Skill score between 0. and 1., or array thereof if cumulative=True.
3242
'''
3343

3444
lon_obs = np.array(lon_obs)
@@ -38,12 +48,30 @@ def liu_weissberg(lon_obs, lat_obs, lon_model, lat_model, tolerance_threshold=1)
3848

3949
d = distance_between_trajectories(lon_obs, lat_obs, lon_model, lat_model)
4050
l = distance_along_trajectory(lon_obs, lat_obs)
41-
s = np.nansum(d, axis=0) / np.nansum(np.nancumsum(l, axis=0), axis=0)
4251

43-
if tolerance_threshold==0:
44-
skillscore = 0
52+
if cumulative:
53+
# Running Liu-Weisberg: at step k, evaluate the formula on data [0..k].
54+
# Numerator: Σ d_i, Denominator: Σ L_i (sum of cumulative arc lengths).
55+
# At k=0 the denominator is 0 → NaN; at k=N-1 the value equals the
56+
# non-cumulative score. Score decreases when d_k/L_k > running average
57+
# (accelerating divergence).
58+
cum_d = np.nancumsum(d, axis=0) # shape (N, ...)
59+
cum_L = np.nancumsum(np.nancumsum(l, axis=0), axis=0) # shape (N-1, ...)
60+
# s[k] = Σ_{0..k+1} d / Σ_{1..k+1} L → shape (N-1, ...)
61+
s = cum_d[1:] / cum_L
62+
if tolerance_threshold == 0:
63+
skillscore = np.where(np.isfinite(s), 0.0, np.nan)
64+
else:
65+
skillscore = np.maximum(0, 1 - s / tolerance_threshold)
66+
# Prepend NaN for t=0 (denominator undefined, no arc length yet)
67+
nan_row = np.full((1,) + skillscore.shape[1:], np.nan)
68+
skillscore = np.concatenate([nan_row, skillscore], axis=0)
4569
else:
46-
skillscore = np.maximum(0, 1 - s/tolerance_threshold)
70+
s = np.nansum(d, axis=0) / np.nansum(np.nancumsum(l, axis=0), axis=0)
71+
if tolerance_threshold == 0:
72+
skillscore = 0
73+
else:
74+
skillscore = np.maximum(0, 1 - s / tolerance_threshold)
4775

4876
return skillscore
4977

trajan/traj1d.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,20 @@ def skill(self, expected, method='liu-weissberg', **kwargs) -> xr.DataArray:
254254
s = skill_method(expected.traj.tlon, expected.traj.tlat, ds.traj.tlon,
255255
ds.traj.tlat, **kwargs)
256256

257+
if kwargs.get('cumulative', False):
258+
# Build DataArray with explicit dims from the (broadcasted) ds,
259+
# then drop any size-1 dims that are not in self, and reorder to
260+
# match self.ds.lon dimension order.
261+
result = xr.DataArray(s, dims=ds.lon.dims, coords=ds.lon.coords,
262+
name='Skillscore', attrs={'method': method})
263+
self_dims = set(self.ds.lon.dims)
264+
for dim in list(result.dims):
265+
if dim not in self_dims and result.sizes[dim] == 1:
266+
result = result.squeeze(dim=dim, drop=True)
267+
ordered = [d for d in self.ds.lon.dims if d in result.dims]
268+
extra = [d for d in result.dims if d not in ordered]
269+
return result.transpose(*(ordered + extra))
270+
257271
newcoords = dict(ds.lon.sizes)
258272
newcoords.pop('time')
259273
for dim in newcoords:

0 commit comments

Comments
 (0)