Skip to content

Commit e52861e

Browse files
authored
Merge pull request #56 from Open-ET/et-fraction-grass-source-param
Add support for an ET fraction grass source parameter
2 parents d0a3df4 + 8974241 commit e52861e

File tree

5 files changed

+267
-121
lines changed

5 files changed

+267
-121
lines changed

openet/ssebop/image.py

Lines changed: 40 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(
5151
dt_min=5,
5252
dt_max=25,
5353
et_fraction_type='alfalfa',
54+
et_fraction_grass_source=None,
5455
reflectance_type='SR',
5556
**kwargs,
5657
):
@@ -85,7 +86,7 @@ def __init__(
8586
'DAYMET_MEDIAN_V2', 'CIMIS_MEDIAN_V1',
8687
collection ID, or float}, optional
8788
Maximum air temperature source. The default is
88-
'projects/usgs-ssebop/tmax/daymet_v3_median_1980_2018'.
89+
'projects/usgs-ssebop/tmax/daymet_v4_mean_1981_2010'.
8990
elr_flag : bool, str, optional
9091
If True, apply Elevation Lapse Rate (ELR) adjustment
9192
(the default is False).
@@ -94,10 +95,16 @@ def __init__(
9495
dt_max : float, optional
9596
Maximum allowable dT [K] (the default is 25).
9697
et_fraction_type : {'alfalfa', 'grass'}, optional
97-
ET fraction (the default is 'alfalfa').
98+
ET fraction reference type (the default is 'alfalfa').
99+
If set to "grass" the et_fraction_grass_source must also be set.
100+
et_fraction_grass_source : {'NASA/NLDAS/FORA0125_H002',
101+
'ECMWF/ERA5_LAND/HOURLY'}, float, optional
102+
Reference ET source for alfalfa to grass reference adjustment.
103+
Parameter must be set if et_fraction_type is 'grass'.
104+
The default is currently the NLDAS hourly collection,
105+
but having a default will likely be removed in a future version.
98106
reflectance_type : {'SR', 'TOA'}, optional
99-
Used to set the Tcorr NDVI thresholds
100-
(the default is 'SR').
107+
Used to set the Tcorr NDVI thresholds (the default is 'SR').
101108
kwargs : dict, optional
102109
dt_resample : {'nearest', 'bilinear'}
103110
tcorr_resample : {'nearest', 'bilinear'}
@@ -194,14 +201,28 @@ def __init__(
194201
raise ValueError(f'elr_flag "{self._elr_flag}" could not be interpreted as bool')
195202

196203
# ET fraction type
197-
# CGM - Should et_fraction_type be set as a kwarg instead?
198204
if et_fraction_type.lower() not in ['alfalfa', 'grass']:
199205
raise ValueError('et_fraction_type must "alfalfa" or "grass"')
200206
self.et_fraction_type = et_fraction_type.lower()
201-
# if 'et_fraction_type' in kwargs.keys():
202-
# self.et_fraction_type = kwargs['et_fraction_type'].lower()
203-
# else:
204-
# self.et_fraction_type = 'alfalfa'
207+
208+
# ET fraction alfalfa to grass reference adjustment
209+
# The NLDAS hourly collection will be used if a source value is not set
210+
if self.et_fraction_type.lower() == 'grass' and not et_fraction_grass_source:
211+
warnings.warn(
212+
'NLDAS is being set as the default ET fraction grass adjustment source. '
213+
'In a future version the parameter will need to be set explicitly as: '
214+
'et_fraction_grass_source="NASA/NLDAS/FORA0125_H002".',
215+
FutureWarning
216+
)
217+
et_fraction_grass_source = 'NASA/NLDAS/FORA0125_H002'
218+
self.et_fraction_grass_source = et_fraction_grass_source
219+
# if self.et_fraction_type.lower() == 'grass' and not et_fraction_grass_source:
220+
# raise ValueError(
221+
# 'et_fraction_grass_source parameter must be set if et_fraction_type==\'grass\''
222+
# )
223+
# # Should the supported source values be checked here instead of in model.py?
224+
# if et_fraction_grass_source not in et_fraction_grass_sources:
225+
# raise ValueError('unsupported et_fraction_grass_source')
205226

206227
self.reflectance_type = reflectance_type
207228
if reflectance_type not in ['SR', 'TOA']:
@@ -333,50 +354,16 @@ def et_fraction(self):
333354

334355
et_fraction = model.et_fraction(lst=self.lst, tmax=tmax, tcorr=self.tcorr, dt=dt)
335356

336-
# TODO: Add support for setting the conversion source dataset
337-
# TODO: Interpolate "instantaneous" ETo and ETr?
338-
# TODO: Move openet.refetgee import to top?
339-
# TODO: Check if etr/eto is right (I think it is)
340-
if self.et_fraction_type.lower() == 'grass':
341-
import openet.refetgee
342-
nldas_coll = (
343-
ee.ImageCollection('NASA/NLDAS/FORA0125_H002')
344-
.select(['temperature', 'specific_humidity', 'shortwave_radiation', 'wind_u', 'wind_v'])
345-
)
346-
347-
# Interpolating hourly NLDAS to the Landsat scene time
348-
# CGM - The 2 hour window is useful in case an image is missing
349-
# I think EEMETRIC is using a 4 hour window
350-
# CGM - Need to check if the NLDAS images are instantaneous
351-
# or some sort of average of the previous or next hour
352-
time_start = ee.Number(self._time_start)
353-
prev_img = ee.Image(
354-
nldas_coll
355-
.filterDate(time_start.subtract(2 * 60 * 60 * 1000), time_start)
356-
.limit(1, 'system:time_start', False)
357-
.first()
358-
)
359-
next_img = ee.Image(
360-
nldas_coll.filterDate(time_start, time_start.add(2 * 60 * 60 * 1000)).first()
361-
)
362-
prev_time = ee.Number(prev_img.get('system:time_start'))
363-
next_time = ee.Number(next_img.get('system:time_start'))
364-
time_ratio = time_start.subtract(prev_time).divide(next_time.subtract(prev_time))
365-
nldas_img = (
366-
next_img.subtract(prev_img).multiply(time_ratio).add(prev_img)
367-
.set({'system:time_start': self._time_start})
368-
)
369-
370-
# # DEADBEEF - Select NLDAS image before the Landsat scene time
371-
# nldas_img = ee.Image(nldas_coll
372-
# .filterDate(self._date.advance(-1, 'hour'), self._date)
373-
# .first())
374-
375-
et_fraction = (
376-
et_fraction
377-
.multiply(openet.refetgee.Hourly.nldas(nldas_img).etr)
378-
.divide(openet.refetgee.Hourly.nldas(nldas_img).eto)
379-
)
357+
# Convert the ET fraction to a grass reference fraction
358+
if self.et_fraction_type.lower() == 'grass' and self.et_fraction_grass_source:
359+
if utils.is_number(self.et_fraction_grass_source):
360+
et_fraction = et_fraction.multiply(self.et_fraction_grass_source)
361+
else:
362+
et_fraction = model.etf_grass_type_adjust(
363+
etf=et_fraction,
364+
src_coll_id=self.et_fraction_grass_source,
365+
time_start=self._time_start
366+
)
380367

381368
return et_fraction.set(self._properties)\
382369
.set({

openet/ssebop/model.py

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import ee
44

5+
import openet.refetgee
6+
57

68
def et_fraction(lst, tmax, tcorr, dt):
79
"""SSEBop fraction of reference ET (ETf)
@@ -31,17 +33,12 @@ def et_fraction(lst, tmax, tcorr, dt):
3133
3234
"""
3335

34-
et_fraction = lst.expression(
36+
etf = lst.expression(
3537
'(lst * (-1) + tmax * tcorr + dt) / dt',
3638
{'tmax': tmax, 'dt': dt, 'lst': lst, 'tcorr': tcorr}
3739
)
3840

39-
return (
40-
et_fraction
41-
.updateMask(et_fraction.lte(2.0))
42-
.clamp(0, 1.0)
43-
.rename(['et_fraction'])
44-
)
41+
return etf.updateMask(etf.lte(2.0)).clamp(0, 1.0).rename(['et_fraction'])
4542

4643

4744
def dt(tmax, tmin, elev, doy, lat=None, rs=None, ea=None):
@@ -147,9 +144,7 @@ def dt(tmax, tmin, elev, doy, lat=None, rs=None, ea=None):
147144
den = tmax.add(tmin).multiply(0.5).pow(-1).multiply(pair).multiply(3.486 / 1.01)
148145

149146
# Temperature difference [K] (Senay2018 A.5)
150-
dt = rn.divide(den).multiply(110.0 / ((1.013 / 1000) * 86400))
151-
152-
return dt
147+
return rn.divide(den).multiply(110.0 / ((1.013 / 1000) * 86400))
153148

154149

155150
def lapse_adjust(temperature, elev, lapse_threshold=1500):
@@ -191,7 +186,7 @@ def elr_adjust(temperature, elevation, radius=80):
191186
192187
Returns
193188
-------
194-
ee.Image of adjusted temperature
189+
ee.Image
195190
196191
Notes
197192
-----
@@ -233,3 +228,79 @@ def elr_adjust(temperature, elevation, radius=80):
233228
tmax_img = tmax_img.where(elr_mask, elr_adjust)
234229

235230
return tmax_img
231+
232+
233+
# TODO: Decide if using the instantaneous is the right/best approach
234+
# We could use the closest hour in time, an average of a few hours
235+
# or just switch to using the raw daily or bias corrected assets
236+
def etf_grass_type_adjust(etf, src_coll_id, time_start):
237+
""""Convert ET fraction from an alfalfa reference to grass reference
238+
239+
Parameters
240+
----------
241+
etf : ee.Image
242+
ET fraction (alfalfa reference).
243+
src_coll_id : str
244+
Hourly meteorology collection ID for computing reference ET.
245+
time_start : int, ee.Number
246+
Image system time start [millis].
247+
248+
Returns
249+
-------
250+
ee.Image
251+
252+
"""
253+
hourly_et_reference_sources = [
254+
'NASA/NLDAS/FORA0125_H002',
255+
'ECMWF/ERA5_LAND/HOURLY',
256+
]
257+
if src_coll_id not in hourly_et_reference_sources:
258+
raise ValueError(f'unsupported hourly ET reference source: {src_coll_id}')
259+
elif not src_coll_id:
260+
raise ValueError('hourly ET reference source not')
261+
else:
262+
src_coll = ee.ImageCollection(src_coll_id)
263+
264+
# Interpolating hourly NLDAS to the Landsat scene time
265+
# CGM - The 2 hour window is useful in case an image is missing
266+
# I think EEMETRIC is using a 4 hour window
267+
# CGM - Need to check if the NLDAS images are instantaneous
268+
# or some sort of average of the previous or next hour
269+
time_start = ee.Number(time_start)
270+
prev_img = ee.Image(
271+
src_coll
272+
.filterDate(time_start.subtract(2 * 60 * 60 * 1000), time_start)
273+
.limit(1, 'system:time_start', False)
274+
.first()
275+
)
276+
next_img = ee.Image(
277+
src_coll.filterDate(time_start, time_start.add(2 * 60 * 60 * 1000)).first()
278+
)
279+
prev_time = ee.Number(prev_img.get('system:time_start'))
280+
next_time = ee.Number(next_img.get('system:time_start'))
281+
time_ratio = time_start.subtract(prev_time).divide(next_time.subtract(prev_time))
282+
interp_img = (
283+
next_img.subtract(prev_img).multiply(time_ratio).add(prev_img)
284+
.set({'system:time_start': time_start})
285+
)
286+
287+
# # DEADBEEF - Select the NLDAS image before the Landsat scene time
288+
# interp_img = ee.Image(
289+
# hourly_coll.filterDate(self._date.advance(-1, 'hour'), self._date).first()
290+
# )
291+
292+
if src_coll_id.upper() == 'NASA/NLDAS/FORA0125_H002':
293+
etf_grass = (
294+
etf
295+
.multiply(openet.refetgee.Hourly.nldas(interp_img).etr)
296+
.divide(openet.refetgee.Hourly.nldas(interp_img).eto)
297+
)
298+
elif src_coll_id.upper() == 'ECMWF/ERA5_LAND/HOURLY':
299+
etf_grass = (
300+
etf
301+
.multiply(openet.refetgee.Hourly.era5_land(interp_img).etr)
302+
.divide(openet.refetgee.Hourly.era5_land(interp_img).eto)
303+
)
304+
# else:
305+
306+
return etf_grass

openet/ssebop/tests/test_b_model.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@
44
import openet.ssebop.model as model
55
import openet.ssebop.utils as utils
66

7+
COLL_ID = 'LANDSAT/LC08/C02/T1_L2'
8+
SCENE_ID = 'LC08_042035_20150713'
9+
# SCENE_DT = datetime.datetime.strptime(SCENE_ID[-8:], '%Y%m%d')
10+
# SCENE_DATE = SCENE_DT.strftime('%Y-%m-%d')
11+
# SCENE_DOY = int(SCENE_DT.strftime('%j'))
12+
# # SCENE_TIME = utils.millis(SCENE_DT)
13+
SCENE_TIME = 1436812419150
14+
SCENE_POINT = (-119.5, 36.0)
15+
TEST_POINT = (-119.44252382373145, 36.04047742246546)
16+
717

818
@pytest.mark.parametrize(
919
# Note: These are made up values
@@ -187,3 +197,48 @@ def test_Model_elr_adjust(xy, adjusted):
187197
assert output < original
188198
else:
189199
assert output == original
200+
201+
202+
def test_Image_et_reference_source_parameters():
203+
"""Check that the function parameter names and order don't change"""
204+
etf_img = (
205+
ee.Image(f'{COLL_ID}/{SCENE_ID}').select([0]).multiply(0).add(1.0)
206+
.rename(['et_fraction']).set('system:time_start', SCENE_TIME)
207+
)
208+
output = model.etf_grass_type_adjust(
209+
etf=etf_img, src_coll_id='NASA/NLDAS/FORA0125_H002', time_start=SCENE_TIME
210+
)
211+
assert utils.point_image_value(output, SCENE_POINT, scale=100)['et_fraction'] > 1
212+
213+
output = model.etf_grass_type_adjust(etf_img, 'NASA/NLDAS/FORA0125_H002', SCENE_TIME)
214+
assert utils.point_image_value(output, SCENE_POINT, scale=100)['et_fraction'] > 1
215+
216+
217+
@pytest.mark.parametrize(
218+
'src_coll_id, expected',
219+
[
220+
['NASA/NLDAS/FORA0125_H002', 1.23],
221+
['ECMWF/ERA5_LAND/HOURLY', 1.15],
222+
]
223+
)
224+
def test_Model_etf_grass_type_adjust(src_coll_id, expected, tol=0.01):
225+
"""Check alfalfa to grass reference adjustment factor"""
226+
etf_img = (
227+
ee.Image(f'{COLL_ID}/{SCENE_ID}').select([0]).multiply(0).add(1.0)
228+
.rename(['et_fraction']).set('system:time_start', SCENE_TIME)
229+
)
230+
output = model.etf_grass_type_adjust(
231+
etf=etf_img, src_coll_id=src_coll_id, time_start=SCENE_TIME
232+
)
233+
output = utils.point_image_value(output, SCENE_POINT, scale=100)
234+
assert abs(output['et_fraction'] - expected) <= tol
235+
236+
237+
def test_Model_etf_grass_type_adjust_src_coll_id_exception():
238+
"""Function should raise an exception for unsupported src_coll_id values"""
239+
with pytest.raises(ValueError):
240+
utils.getinfo(model.etf_grass_type_adjust(
241+
etf=ee.Image.constant(1), src_coll_id='DEADBEEF', time_start=SCENE_TIME
242+
))
243+
244+

0 commit comments

Comments
 (0)