Skip to content

Commit 5ac2eea

Browse files
authored
Add compute_fl_diagnostics_quantiles (OGGM#1746)
* adapt ranking of pixels for elevation bands * add compute_fl_diagnostics_quantiles with tests * pep8 * add whats-new
1 parent 8de5db4 commit 5ac2eea

File tree

5 files changed

+206
-12
lines changed

5 files changed

+206
-12
lines changed

docs/whats-new.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ v1.6.x (not released)
99
Enhancements
1010
~~~~~~~~~~~~
1111

12+
- Added ``tasks.compute_fl_diagnostics_quantiles``, this task is designed to
13+
calculate quantiles from multiple fl_diagnostic files. It enables users to
14+
compute metrics such as the median flowline across various GCM projections
15+
(:pull:`1746`).
16+
By `Patrick Schmitt <https://github.com/pat-schmitt>`_
17+
- New ranking options for the ``distribute.assign_points_to_band`` task, which
18+
determines the order in which pixels "melt away" when redistributing flowline
19+
model runs into 2D for visualization purposes. It is now possible to combine
20+
different variables from ``gridded_data`` (e.g. ``slope``,
21+
``dis_from_border``, ...) to define this ranking, providing greater flexibility
22+
and control over how the melting sequence is visualized (:pull:`1746`).
23+
By `Patrick Schmitt <https://github.com/pat-schmitt>`_
1224

1325
Bug fixes
1426
~~~~~~~~~

oggm/core/flowline.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4592,3 +4592,80 @@ def clean_merged_flowlines(gdir, buffer=None):
45924592

45934593
# Finally write the flowlines
45944594
gdir.write_pickle(allfls, 'model_flowlines')
4595+
4596+
4597+
@entity_task(log)
4598+
def compute_fl_diagnostics_quantiles(gdir,
4599+
input_filesuffixes,
4600+
quantiles=0.5,
4601+
output_filesuffix='_median',
4602+
):
4603+
"""Compute quantile fl_diagnostics (e.g. median flowline of projections)
4604+
4605+
This function takes a number of fl_diagnostic files and compute out of them
4606+
quantiles. This could be useful for example for calculating a median
4607+
flowline for different gcm projections which use the same scenario
4608+
(e.g. ssp126).
4609+
4610+
Parameters
4611+
----------
4612+
gdir : :py:class:`oggm.GlacierDirectory`
4613+
the glacier directory to process
4614+
input_filesuffixes : list
4615+
a list of fl_diagnostic filesuffixes which should be considered for
4616+
computing
4617+
quantiles : float or list
4618+
The quantiles to compute. Could be a float for a single quantile or a
4619+
list of floats for severel quantile calculations.
4620+
Default is 0.5 (the median).
4621+
output_filesuffix : str
4622+
The output_filesuffix of the resulting fl_diagnostics.
4623+
Default is '_median'.
4624+
"""
4625+
4626+
# if quantiles is a list with one element, only use this
4627+
if isinstance(quantiles, list):
4628+
if len(quantiles) == 1:
4629+
quantiles = quantiles[0]
4630+
4631+
# get filepath of all fl_diagnostic files
4632+
all_fl_diag_paths = []
4633+
for filesuffix in input_filesuffixes:
4634+
all_fl_diag_paths.append(gdir.get_filepath('fl_diagnostics',
4635+
filesuffix=filesuffix))
4636+
4637+
# assume all have the same number of flowlines, extract the base structure
4638+
# and number of fl_ids from the first file
4639+
with xr.open_dataset(all_fl_diag_paths[0]) as ds:
4640+
ds_base = ds.load()
4641+
fl_ids = ds.flowlines.data
4642+
4643+
# help function to open all fl_diag files as one with open_mfdataset
4644+
def add_filename_coord(ds):
4645+
filepath = ds.encoding["source"]
4646+
filename = os.path.basename(filepath).split(".")[0]
4647+
return ds.expand_dims({'filename': [filename]})
4648+
4649+
# now loop through all flowlines and save back in the same structure
4650+
fl_diag_dss = []
4651+
for fl_id in fl_ids:
4652+
with xr.open_mfdataset(all_fl_diag_paths,
4653+
preprocess=lambda ds: add_filename_coord(ds),
4654+
group=f'fl_{fl_id}') as ds_fl:
4655+
with warnings.catch_warnings():
4656+
# ignore warnings for NaNs
4657+
warnings.simplefilter("ignore", category=RuntimeWarning)
4658+
fl_diag_dss.append(ds_fl.load().quantile(quantiles,
4659+
dim='filename'))
4660+
4661+
# for writing into the nice output structure
4662+
output_filepath = gdir.get_filepath('fl_diagnostics',
4663+
filesuffix=output_filesuffix)
4664+
encode = {}
4665+
for v in fl_diag_dss[0]:
4666+
encode[v] = {'zlib': True, 'complevel': 5}
4667+
4668+
ds_base.to_netcdf(output_filepath, 'w')
4669+
for fl_id, ds in zip(fl_ids, fl_diag_dss):
4670+
ds.to_netcdf(output_filepath, 'a', group='fl_{}'.format(fl_id),
4671+
encoding=encode)

oggm/sandbox/distribute_2d.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ def add_smoothed_glacier_topo(gdir, outline_offset=-40,
9999

100100
@entity_task(log, writes=['gridded_data'])
101101
def assign_points_to_band(gdir, topo_variable='glacier_topo_smoothed',
102-
elevation_weight=1.003):
102+
ranking_variables=None,
103+
ranking_variables_weights=None,
104+
):
103105
"""Assigns glacier grid points to flowline elevation bands and ranks them.
104106
105107
Creates two variables in gridded_data.nc:
@@ -123,18 +125,25 @@ def assign_points_to_band(gdir, topo_variable='glacier_topo_smoothed',
123125
topo_variable : str
124126
the topography to read from `gridded_data.nc` (could be smoothed, or
125127
smoothed differently).
126-
elevation_weight : float
127-
how much weight to give to the elevation of the grid point versus the
128-
thickness. Arbitrary number, might be tuned differently.
128+
ranking_variables : list
129+
A list of distributed variables used for ranking pixels of each
130+
elevation band. All variables inside of 'gridded_data' can be used (e.g.
131+
'topo_smoothed', 'slope', 'dis_from_border', 'distributed_thickness',
132+
'aspect'). Default is 'distributed_thickness'.
133+
ranking_variables_weights : list
134+
A list of weights, corresponding to the ranking_variables. Larger values
135+
assign more weight to the corresponding ranking_variable. Must be in the
136+
same order as ranking_variables! Default is [1].
129137
"""
130138
# We need quite a few data from the gridded dataset
131139
with xr.open_dataset(gdir.get_filepath('gridded_data')) as ds:
140+
ds_grid = ds.load()
132141
topo_data = ds[topo_variable].data.copy()
133142
glacier_mask = ds.glacier_mask.data == 1
134143
topo_data_flat = topo_data[glacier_mask]
135144
band_index = topo_data * np.nan # container
136145
per_band_rank = topo_data * np.nan # container
137-
distrib_thick = ds.distributed_thickness.data
146+
weighting_per_pixel = topo_data * 0. # container
138147

139148
# For the flowline we need the model flowlines only
140149
fls = gdir.read_pickle('model_flowlines')
@@ -173,15 +182,36 @@ def assign_points_to_band(gdir, topo_variable='glacier_topo_smoothed',
173182
# assert np.nanmin(band_index) == 0
174183
assert np.all(np.isfinite(band_index[glacier_mask]))
175184

176-
# Ok now assign within band using ice thickness weighted by elevation
177-
# We rank the pixels within one band by elevation, but also add
178-
# a penalty is added to higher elevation grid points
179-
min_alt = np.nanmin(topo_data)
180-
weighted_thick = ((topo_data - min_alt + 1) * elevation_weight) * distrib_thick
185+
# Ok now we rank the pixels within each band, which variables are considered
186+
# can be defined by the user. All variables are normalized (between 0 and 1)
187+
# and multiplied by a weight and summed up. If the weight is negative the
188+
# normalized variable will be inverted (0 becomes 1 and vice versa).
189+
if ranking_variables is None:
190+
# the default variables to use
191+
ranking_variables = ['distributed_thickness']
192+
if ranking_variables_weights is None:
193+
# the default weights to use
194+
ranking_variables_weights = [1]
195+
assert len(ranking_variables) == len(ranking_variables_weights)
196+
197+
# normalize all values to the range of 0 to 1
198+
def min_max_normalization(data):
199+
data = np.where(glacier_mask, data, np.nan)
200+
return (data - np.nanmin(data)) / (np.nanmax(data) - np.nanmin(data))
201+
202+
for var, var_weight in zip(ranking_variables,
203+
ranking_variables_weights):
204+
normalized_var = min_max_normalization(ds_grid[var].data)
205+
# for negative weights we invert the normalized variable
206+
# (smallest becomes largest and vice versa)
207+
if var_weight < 0:
208+
normalized_var = 1 - normalized_var
209+
weighting_per_pixel += normalized_var * np.abs(var_weight)
210+
211+
# now rank the pixel of each band individually
181212
for band_id in np.unique(np.sort(band_index[glacier_mask])):
182-
# We work per band here
183213
is_band = band_index == band_id
184-
per_band_rank[is_band] = mstats.rankdata(weighted_thick[is_band])
214+
per_band_rank[is_band] = mstats.rankdata(weighting_per_pixel[is_band])
185215

186216
with ncDataset(gdir.get_filepath('gridded_data'), 'a') as nc:
187217
vn = 'band_index'

oggm/tasks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
from oggm.core.flowline import run_from_climate_data
6363
from oggm.core.flowline import run_constant_climate
6464
from oggm.core.flowline import run_with_hydro
65+
from oggm.core.flowline import compute_fl_diagnostics_quantiles
6566
from oggm.core.dynamic_spinup import run_dynamic_spinup
6667
from oggm.core.dynamic_spinup import run_dynamic_melt_f_calibration
6768
from oggm.utils import copy_to_basedir

oggm/tests/test_models.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3198,6 +3198,80 @@ def test_elevation_feedback(self, hef_gdir):
31983198
plt.legend()
31993199
plt.show()
32003200

3201+
@pytest.mark.slow
3202+
def test_fl_diag_quantiles(self, hef_gdir):
3203+
cfg.PARAMS['store_fl_diagnostics'] = True
3204+
3205+
# conduct three runs from which to calculate the quantiles
3206+
output_suffixes = ['_run1', '_run2', '_run3']
3207+
for i, output_suffix in enumerate(output_suffixes):
3208+
run_random_climate(hef_gdir, y0=1985-i*5, nyears=10,
3209+
output_filesuffix=output_suffix, seed=i)
3210+
3211+
# only calculate the median
3212+
workflow.execute_entity_task(tasks.compute_fl_diagnostics_quantiles,
3213+
hef_gdir,
3214+
input_filesuffixes=output_suffixes,
3215+
quantiles=0.5,
3216+
output_filesuffix='_median'
3217+
)
3218+
3219+
# calculate 5th and 95th quantiles together
3220+
workflow.execute_entity_task(tasks.compute_fl_diagnostics_quantiles,
3221+
hef_gdir,
3222+
input_filesuffixes=output_suffixes,
3223+
quantiles=[0.05, 0.95],
3224+
output_filesuffix='_iqr'
3225+
)
3226+
3227+
ft = 'fl_diagnostics'
3228+
with xr.open_dataset(
3229+
hef_gdir.get_filepath(ft, filesuffix=output_suffixes[0])) as ds:
3230+
fl_ids = ds.flowlines.data
3231+
3232+
for fl_id in fl_ids:
3233+
# open data of current flowline
3234+
ds_runs = []
3235+
for output_suffix in output_suffixes:
3236+
fp = hef_gdir.get_filepath(ft, filesuffix=output_suffix)
3237+
with xr.open_dataset(fp, group=f'fl_{fl_id}') as ds:
3238+
ds_runs.append(ds.load())
3239+
fp = hef_gdir.get_filepath(ft, filesuffix='_median')
3240+
with xr.open_dataset(fp, group=f'fl_{fl_id}') as ds:
3241+
ds_median = ds.load()
3242+
fp = hef_gdir.get_filepath(ft, filesuffix='_iqr')
3243+
with xr.open_dataset(fp, group=f'fl_{fl_id}') as ds:
3244+
ds_iqr = ds.load()
3245+
3246+
# the median flowline should never be the smallest or largest
3247+
# value, compared to the values of the runs (as we have three runs)
3248+
variables_to_check = ['volume_m3', 'area_m2', 'thickness_m']
3249+
for var in variables_to_check:
3250+
var_das = []
3251+
for ds_run in ds_runs:
3252+
var_das.append(ds_run[var])
3253+
var_stack = xr.concat(var_das, dim='runs')
3254+
3255+
var_min = var_stack.min(dim='runs')
3256+
var_max = var_stack.max(dim='runs')
3257+
3258+
var_median = ds_median[var]
3259+
is_median_equal_to_min = (var_median == var_min).any()
3260+
is_median_equal_to_max = (var_median == var_max).any()
3261+
3262+
assert is_median_equal_to_min
3263+
assert is_median_equal_to_max
3264+
3265+
# median should be larger/smaller than 5th/95th quantile
3266+
var_5th = ds_iqr.loc[{'quantile': 0.05}][var]
3267+
var_95th = ds_iqr.loc[{'quantile': 0.95}][var]
3268+
3269+
is_median_larger_than_5th_q = (var_median >= var_5th).all()
3270+
is_median_smaller_than_95th_q = (var_median <= var_95th).all()
3271+
3272+
assert is_median_larger_than_5th_q
3273+
assert is_median_smaller_than_95th_q
3274+
32013275

32023276
@pytest.mark.usefixtures('with_class_wd')
32033277
class TestDynamicSpinup:

0 commit comments

Comments
 (0)