ETo 2.1.0__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ETo
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Class and functions to estimate reference ET
5
5
  Project-URL: Homepage, https://github.com/mullenkamp/ETo
6
6
  Project-URL: Documentation, https://mullenkamp.github.io/ETo/
@@ -2,4 +2,4 @@ from eto.core import ETo
2
2
  import eto.datasets
3
3
  import eto.methods
4
4
 
5
- __version__ = '2.1.0'
5
+ __version__ = '2.2.0'
@@ -3,6 +3,7 @@
3
3
  Class to estimate reference ET (ETo) from the FAO 56 paper using a minimum of T_min and T_max for daily estimates and T_mean and RH_mean for hourly, but utilizing the maximum number of available met parameters. The function prioritizes the estimation of specific parameters based on the available input data.
4
4
  """
5
5
  import numpy as np
6
+ from eto.util import is_hourly
6
7
  from eto.param_est import param_est
7
8
  from eto.methods.ETo import eto_fao
8
9
  from eto.methods.hargreaves import hargreaves
@@ -23,18 +24,20 @@ class ETo(object):
23
24
  T_min, T_max, T_mean, T_dew, RH_min, RH_max, RH_mean, n_sun, U_z,
24
25
  P, e_a). All arrays must have the same length.
25
26
  freq : str
26
- Time frequency: 'D' for daily, 'H' or 'h' for hourly.
27
+ Time frequency: 'D' for daily, 'h' (or 'H') for hourly, 'M' for monthly.
27
28
  day_of_year : np.ndarray of int, or None
28
29
  Day of year (1-366). Required if dates is not provided.
29
30
  hour : np.ndarray of int, or None
30
31
  Hour of day (0-23). Required for hourly frequency if dates is not provided.
31
32
  dates : np.ndarray of datetime64, or None
32
33
  Datetime array. Used to derive day_of_year and hour if they are not provided.
34
+ time_label, rs_rso_init
35
+ Hourly only; see :func:`eto.param_est.param_est`.
33
36
  """
34
37
 
35
38
  def __init__(self, data=None, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
36
39
  z_u=2, K_rs=0.16, a_s=0.25, b_s=0.5, alb=0.23,
37
- day_of_year=None, hour=None, dates=None, validate=True):
40
+ day_of_year=None, hour=None, dates=None, validate=True, time_label='start', rs_rso_init=None):
38
41
 
39
42
  if data is None:
40
43
  pass
@@ -51,7 +54,7 @@ class ETo(object):
51
54
  if dates is not None:
52
55
  dates = np.asarray(dates)
53
56
  day_of_year = (dates.astype('datetime64[D]') - dates.astype('datetime64[Y]')).astype(int) + 1
54
- if 'h' in freq.lower():
57
+ if is_hourly(freq):
55
58
  hour = (dates - dates.astype('datetime64[D]')).astype('timedelta64[h]').astype(int)
56
59
  elif day_of_year is not None:
57
60
  day_of_year = np.asarray(day_of_year)
@@ -61,7 +64,7 @@ class ETo(object):
61
64
  # Validate temporal array lengths
62
65
  if len(day_of_year) != n:
63
66
  raise ValueError('day_of_year length must match data array length')
64
- if 'h' in freq.lower():
67
+ if is_hourly(freq):
65
68
  if hour is None:
66
69
  raise ValueError('hour array or dates must be provided for hourly frequency')
67
70
  hour = np.asarray(hour)
@@ -69,7 +72,8 @@ class ETo(object):
69
72
  raise ValueError('hour length must match data array length')
70
73
 
71
74
  self.param_est(data, freq, z_msl, lat, lon, TZ_lon, z_u, K_rs, a_s, b_s, alb,
72
- day_of_year=day_of_year, hour=hour, validate=validate)
75
+ day_of_year=day_of_year, hour=hour, validate=validate,
76
+ time_label=time_label, rs_rso_init=rs_rso_init)
73
77
 
74
78
 
75
79
  ### Add in the ETo methods
@@ -3,20 +3,24 @@
3
3
  Function to estimate reference ET (ETo) from the FAO 56 paper using a minimum of T_min and T_max for daily estimates and T_mean and RH_mean for hourly, but utilizing the maximum number of available met parameters.
4
4
  """
5
5
  import numpy as np
6
+ from eto.util import is_hourly
6
7
 
7
8
 
8
- def eto_fao(self, max_ETo=15, min_ETo=0, ref_crop='short'):
9
+ def eto_fao(self, max_ETo=15, min_ETo=0, ref_crop='short', decimals=2):
9
10
  """
10
11
  Function to estimate reference ET (ETo) from the `FAO 56 paper <http://www.fao.org/docrep/X0490E/X0490E00.htm>`_ [1]_ using a minimum of T_min and T_max for daily estimates and T_mean and RH_mean for hourly, but optionally utilising the maximum number of available met parameters.
11
12
 
12
13
  Parameters
13
14
  ----------
14
- max_ETo : float or int
15
- The max realistic value of ETo (mm).
16
- min_ETo : float or int
17
- The min realistic value of ETo (mm).
15
+ max_ETo : float, int or None
16
+ The max realistic value of ETo (mm); higher values become NaN. None disables the check.
17
+ min_ETo : float, int or None
18
+ The min realistic value of ETo (mm); lower values are raised to it. None keeps negative
19
+ values (raw FAO-56, e.g. night-time dew).
18
20
  ref_crop : str
19
21
  Reference crop type: 'short' (FAO 56 grass) or 'tall' (ASCE alfalfa).
22
+ decimals : int or None
23
+ Decimal places of the result; None returns it unrounded.
20
24
 
21
25
  Returns
22
26
  -------
@@ -31,7 +35,7 @@ def eto_fao(self, max_ETo=15, min_ETo=0, ref_crop='short'):
31
35
 
32
36
  ######
33
37
  ## ETo equation — select Cn and Cd based on ref_crop and frequency
34
- if 'h' in self.freq.lower():
38
+ if is_hourly(self.freq):
35
39
  if ref_crop == 'tall':
36
40
  Cn = 66
37
41
  Cd = np.where(self.ts_param['R_n'] > 0, 0.25, 1.7)
@@ -49,7 +53,9 @@ def eto_fao(self, max_ETo=15, min_ETo=0, ref_crop='short'):
49
53
  ETo_FAO = (0.408*self.ts_param['delta']*(self.ts_param['R_n'] - self.ts_param['G']) + self.ts_param['gamma']*Cn/(self.ts_param['T_mean'] + 273)*self.ts_param['U_2']*(self.ts_param['e_s'] - self.ts_param['e_a']))/(self.ts_param['delta'] + self.ts_param['gamma']*(1 + Cd*self.ts_param['U_2']))
50
54
 
51
55
  ## Clamp negatives to min_ETo, NaN for suspect highs
52
- ETo_FAO = np.maximum(ETo_FAO, min_ETo)
53
- ETo_FAO[ETo_FAO > max_ETo] = np.nan
56
+ if min_ETo is not None:
57
+ ETo_FAO = np.maximum(ETo_FAO, min_ETo)
58
+ if max_ETo is not None:
59
+ ETo_FAO = np.where(ETo_FAO > max_ETo, np.nan, ETo_FAO)
54
60
 
55
- return np.round(ETo_FAO, 2)
61
+ return ETo_FAO if decimals is None else np.round(ETo_FAO, decimals)
@@ -5,6 +5,7 @@ Created on Sun May 13 09:45:39 2018
5
5
  @author: MichaelEK
6
6
  """
7
7
  import numpy as np
8
+ from eto.util import is_hourly
8
9
 
9
10
 
10
11
  def hargreaves(self, max_ETo=15, min_ETo=0):
@@ -27,7 +28,7 @@ def hargreaves(self, max_ETo=15, min_ETo=0):
27
28
  ######
28
29
  ## ETo equation
29
30
 
30
- if 'h' in self.freq.lower():
31
+ if is_hourly(self.freq):
31
32
  raise ValueError('Hargreaves should not be calculated at time frequencies of less than a day.')
32
33
 
33
34
  ETo_Har = 0.0023*(self.ts_param['T_mean'] + 17.8)*((self.ts_param['T_max'] - self.ts_param['T_min']) **0.5)*self.ts_param['R_a']*0.408
@@ -4,9 +4,10 @@ Function for parameter estimation.
4
4
  """
5
5
  import warnings
6
6
  import numpy as np
7
+ from eto.util import is_hourly, TIME_LABELS, RS_RSO_MIN
7
8
 
8
9
 
9
- def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None, z_u=2, K_rs=0.16, a_s=0.25, b_s=0.5, alb=0.23, day_of_year=None, hour=None, validate=True):
10
+ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None, z_u=2, K_rs=0.16, a_s=0.25, b_s=0.5, alb=0.23, day_of_year=None, hour=None, validate=True, time_label='start', rs_rso_init=None):
10
11
  """
11
12
  Function to estimate the parameters necessary to calculate reference ET (ETo) from the `FAO 56 paper <http://www.fao.org/docrep/X0490E/X0490E00.htm>`_ using a minimum of T_min and T_max for daily estimates and T_mean and RH_mean for hourly, but optionally utilising the maximum number of available met parameters.
12
13
 
@@ -21,9 +22,13 @@ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
21
22
  lat : float, int, or None
22
23
  Latitude (decimal degrees).
23
24
  lon : float, int, or None
24
- Longitude (decimal degrees).
25
+ Longitude in decimal degrees, **east-positive** (e.g. 172.5 for Canterbury, -16.25 for
26
+ 16°15'W). Since 2.2.0; earlier versions applied FAO-56's west-positive formula to this
27
+ argument, so east-positive input put hourly solar time about 2·lon/15 h out (about an hour
28
+ for NZ with ``TZ_lon`` = 0).
25
29
  TZ_lon : float, int, or None
26
- Longitude of the center of the time zone (decimal degrees).
30
+ Longitude of the centre of the time zone of ``hour``, east-positive (0 for UTC, 180 for
31
+ NZST).
27
32
  z_u : float or int
28
33
  Height of wind speed measurement (m). Default is 2 m.
29
34
  K_rs : float
@@ -41,11 +46,32 @@ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
41
46
  validate : bool
42
47
  If True (default), warn when input values are outside physically
43
48
  reasonable ranges.
49
+ time_label : str
50
+ Hourly only. Whether ``hour`` labels the **start** of its one-hour period ('start', default:
51
+ the period is [hour, hour+1)) or its **end** ('end': (hour-1, hour]).
52
+ rs_rso_init : float or None
53
+ Hourly only. The Rs/Rso ratio carried into the first night of the series, i.e. the value from
54
+ the previous chunk (see ``self.rs_rso_last``). If None and the series starts at night, 1.0 is
55
+ used for the hours before the first reference period, with a warning.
44
56
 
45
57
  Returns
46
58
  -------
47
- None (populates self.ts_param and self.est_val)
59
+ None (populates self.ts_param and self.est_val, and for hourly data self.rs_rso_last)
60
+
61
+ Notes
62
+ -----
63
+ **Night-time cloudiness (hourly).** FAO-56 (Ch. 3, hourly Rnl) sets Rs/Rso at night equal to the
64
+ value for the period 2-3 hours before sunset, where the solar time angle at the period's midpoint
65
+ satisfies ``ws - 0.79 <= w <= ws - 0.52``. That value is carried forward through every hour with
66
+ Rso = 0 until the next such period. It is state: pass the previous chunk's ``rs_rso_last`` as
67
+ ``rs_rso_init`` so a series computed in chunks equals one computed whole. (Versions before 2.2.0
68
+ set Rs/Rso = 1 at night, i.e. assumed clear nights.) Hourly Rs/Rso is limited to [0.3, 1] (ASCE-EWRI
69
+ 2005; FAO-56 gives only the upper limit; daily keeps FAO-56's [0, 1]), and hourly extraterrestrial radiation integrates only the sunlit
70
+ part of an hour that straddles sunrise or sunset (ASCE-EWRI).
48
71
  """
72
+ hourly = is_hourly(freq)
73
+ if time_label not in TIME_LABELS:
74
+ raise ValueError(f'time_label must be one of {TIME_LABELS}, got {time_label!r}')
49
75
 
50
76
  met_names = ['R_n', 'R_s', 'G', 'T_min', 'T_max', 'T_mean', 'T_dew', 'RH_min', 'RH_max', 'RH_mean', 'n_sun', 'U_z', 'P', 'e_a']
51
77
  self.freq = freq
@@ -85,7 +111,7 @@ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
85
111
 
86
112
  ####################################
87
113
  ###### Check to make sure minimum requirements are met
88
- if 'h' in freq.lower():
114
+ if hourly:
89
115
  T_mean_bool = np.isnan(self.ts_param['T_mean']).any()
90
116
  RH_mean_bool = np.isnan(self.ts_param['RH_mean']).any()
91
117
  e_a_bool = np.isnan(self.ts_param['e_a']).any()
@@ -122,7 +148,7 @@ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
122
148
  self.ts_param['T_mean'][mask] = (self.ts_param['T_max'][mask] + self.ts_param['T_min'][mask])/2
123
149
 
124
150
  ## Vapor pressures
125
- if 'h' in freq.lower():
151
+ if hourly:
126
152
  self.ts_param['e_mean'] = 0.6108*np.exp(17.27*self.ts_param['T_mean']/(self.ts_param['T_mean']+237.3))
127
153
  mask = np.isnan(self.ts_param['e_a'])
128
154
  self.ts_param['e_a'][mask] = self.ts_param['e_mean'][mask]*self.ts_param['RH_mean'][mask]/100
@@ -162,7 +188,7 @@ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
162
188
  self.ts_param['T_dew'][mask] = 237.3 * ln_ratio / (17.27 - ln_ratio)
163
189
 
164
190
  # VPD
165
- if 'h' in freq.lower():
191
+ if hourly:
166
192
  self.ts_param['VPD'] = self.ts_param['e_mean'] - self.ts_param['e_a']
167
193
  else:
168
194
  self.ts_param['VPD'] = self.ts_param['e_s'] - self.ts_param['e_a']
@@ -176,21 +202,37 @@ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
176
202
 
177
203
  # R_a
178
204
  phi = lat*np.pi/180
179
- delta = 0.409*np.sin(2*np.pi*Day/365-1.39)
180
- d_r = 1+0.033*np.cos(2*np.pi*Day/365)
181
- w_s = np.arccos(-np.tan(phi)*np.tan(delta))
182
205
 
183
- if 'h' in freq.lower():
184
- hour_vec = hour
206
+ if hourly:
207
+ if lon is None or TZ_lon is None:
208
+ raise ValueError('lon and TZ_lon are required for hourly data')
209
+ # Solar time at the period midpoint (FAO-56 Eq 31-33, east-positive longitudes). Labels may
210
+ # be in any time zone (e.g. UTC), so the solar time can fall on the previous or next day:
211
+ # the declination and sunset angle use that solar day.
212
+ t_mid = np.asarray(hour, dtype=np.float64) + (0.5 if time_label == 'start' else -0.5)
185
213
  b = (2*np.pi*(Day - 81))/364
186
214
  S_c = 0.1645*np.sin(2*b) - 0.1255*np.cos(b) - 0.025*np.sin(b)
187
- w = np.pi/12*(((hour_vec+0.5) + 0.06667*(TZ_lon - lon) + S_c) - 12)
188
- w_1 = w - (np.pi*1)/24
189
- w_2 = w + (np.pi*1)/24
215
+ # lon - TZ_lon wrapped to [-180, 180): the same place given as 183.5 or -176.5 is the same offset.
216
+ dlon = (lon - TZ_lon + 180) % 360 - 180
217
+ t_solar = t_mid + 0.06667*dlon + S_c
218
+ day_shift = np.floor(t_solar/24)
219
+ Day = Day + day_shift
220
+ t_solar = t_solar - 24*day_shift
221
+ w = np.pi/12*(t_solar - 12)
222
+ delta = 0.409*np.sin(2*np.pi*Day/365-1.39)
223
+ d_r = 1+0.033*np.cos(2*np.pi*Day/365)
224
+ w_s = np.arccos(-np.tan(phi)*np.tan(delta))
225
+ # Period start and end angles clipped to sunrise/sunset (ASCE-EWRI 2005), so an hour that
226
+ # straddles the horizon integrates only its sunlit part instead of netting night against day.
227
+ w_1 = np.clip(w - (np.pi*1)/24, -w_s, w_s)
228
+ w_2 = np.clip(w + (np.pi*1)/24, -w_s, w_s)
190
229
 
191
230
  self.ts_param['R_a'] = 12*60/np.pi*0.082*d_r*((w_2 - w_1)*np.sin(phi)*np.sin(delta) + np.cos(phi)*np.cos(delta)*(np.sin(w_2) - np.sin(w_1)))
192
231
  self.ts_param['R_a'] = np.maximum(self.ts_param['R_a'], 0)
193
232
  else:
233
+ delta = 0.409*np.sin(2*np.pi*Day/365-1.39)
234
+ d_r = 1+0.033*np.cos(2*np.pi*Day/365)
235
+ w_s = np.arccos(-np.tan(phi)*np.tan(delta))
194
236
  self.ts_param['R_a'] = 24*60/np.pi*0.082*d_r*(w_s*np.sin(phi)*np.sin(delta) + np.cos(phi)*np.cos(delta)*np.sin(w_s))
195
237
 
196
238
  # Daylight hours
@@ -215,9 +257,32 @@ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
215
257
  # R_ns from R_s
216
258
  R_ns = (1 - alb)*self.ts_param['R_s']
217
259
 
218
- # R_nl — safe R_s/R_so ratio to avoid division by zero when R_so=0
219
- Rs_Rso = np.where(R_so > 0, np.minimum(self.ts_param['R_s'] / np.where(R_so > 0, R_so, 1.0), 1.0), 1.0)
220
- if 'h' in freq.lower():
260
+ # R_nl — R_s/R_so capped at 1 (FAO-56); hourly also floored at RS_RSO_MIN (ASCE-EWRI), because a dark
261
+ # hour's ratio is carried through the night. Daily is unchanged (FAO-56 as published). No division
262
+ # where R_so = 0.
263
+ rs_rso_min = RS_RSO_MIN if hourly else 0.0
264
+ Rs_Rso = np.where(R_so > 0, np.clip(self.ts_param['R_s'] / np.where(R_so > 0, R_so, 1.0), rs_rso_min, 1.0), np.nan)
265
+ if hourly:
266
+ # Night (R_so = 0): the ratio from the last period 2-3 h before sunset (FAO-56), carried as state.
267
+ ref = (w >= w_s - 0.79) & (w <= w_s - 0.52) & np.isfinite(Rs_Rso)
268
+ carried = np.where(ref, Rs_Rso, np.nan)
269
+ idx = np.where(np.isfinite(carried), np.arange(n), -1)
270
+ np.maximum.accumulate(idx, out=idx)
271
+ if rs_rso_init is None:
272
+ if ((idx < 0) & ~(R_so > 0)).any():
273
+ warnings.warn('Hourly series starts at night with no prior Rs/Rso (rs_rso_init); using 1.0 '
274
+ '(clear sky) until the first period 2-3 h before sunset')
275
+ init = 1.0
276
+ else:
277
+ init = float(np.clip(rs_rso_init, RS_RSO_MIN, 1.0))
278
+ carried = np.where(idx >= 0, carried[np.maximum(idx, 0)], init)
279
+ Rs_Rso = np.where(R_so > 0, Rs_Rso, carried)
280
+ self.rs_rso_last = float(carried[-1]) if n else init
281
+ else:
282
+ Rs_Rso = np.where(R_so > 0, Rs_Rso, 1.0)
283
+ self.ts_param['R_so'] = R_so
284
+ self.ts_param['Rs_Rso'] = Rs_Rso
285
+ if hourly:
221
286
  R_nl = (2.043e-10)*((self.ts_param['T_mean'] + 273.16)**4)*(0.34-0.14*self.ts_param['e_a']**0.5)*(1.35*Rs_Rso - 0.35)
222
287
  else:
223
288
  R_nl = (4.903e-9)*(((self.ts_param['T_max'] + 273.16)**4 + (self.ts_param['T_min'] + 273.16)**4)/2)*(0.34-0.14*self.ts_param['e_a']**0.5)*(1.35*Rs_Rso - 0.35)
@@ -230,12 +295,12 @@ def param_est(self, data, freq='D', z_msl=None, lat=None, lon=None, TZ_lon=None,
230
295
  # G
231
296
  mask = np.isnan(self.ts_param['G'])
232
297
  self.est_val[mask] += 10
233
- if 'h' in freq.lower():
298
+ if hourly:
234
299
  day_mask = mask & (self.ts_param['R_n'] > 0)
235
300
  night_mask = mask & (self.ts_param['R_n'] <= 0)
236
301
  self.ts_param['G'][day_mask] = 0.1 * self.ts_param['R_n'][day_mask]
237
302
  self.ts_param['G'][night_mask] = 0.5 * self.ts_param['R_n'][night_mask]
238
- elif freq.upper() == 'M':
303
+ elif str(freq).lower() in ('m', '1m'):
239
304
  G_monthly = np.zeros(n)
240
305
  G_monthly[1:] = 0.14 * (self.ts_param['T_mean'][1:] - self.ts_param['T_mean'][:-1])
241
306
  self.ts_param['G'][mask] = G_monthly[mask]
@@ -0,0 +1,245 @@
1
+ """
2
+ Hourly FAO-56 checks added in 2.2.0: Example 19, solar time with east-positive longitude and
3
+ interval labels, and the night-time Rs/Rso carried as state.
4
+ """
5
+ import warnings
6
+
7
+ import numpy as np
8
+ import pytest
9
+
10
+ from eto import ETo
11
+
12
+ # FAO-56 Example 19: N'Diaye, Senegal, 16°13'N 16°15'W, 8 m, 1 October (J = 274), Lz = 15° W.
13
+ EX19 = dict(z_msl=8, lat=16 + 13 / 60, lon=-16.25, TZ_lon=-15.0)
14
+
15
+
16
+ def ex19(hour, T, RH, u2, Rs, **kw):
17
+ data = {'T_mean': np.array([T]), 'RH_mean': np.array([RH]), 'U_z': np.array([u2]), 'R_s': np.array([Rs])}
18
+ return ETo(data, 'h', day_of_year=np.array([274]), hour=np.array([hour]), **EX19, **kw)
19
+
20
+
21
+ def test_example19_day_period():
22
+ et = ex19(14, 38.0, 52.0, 3.3, 2.450)
23
+ p = et.ts_param
24
+ assert p['R_a'][0] == pytest.approx(3.543, abs=0.002)
25
+ assert p['R_so'][0] == pytest.approx(2.658, abs=0.002)
26
+ assert p['Rs_Rso'][0] == pytest.approx(0.922, abs=0.001)
27
+ assert p['R_n'][0] == pytest.approx(1.749, abs=0.003)
28
+ assert et.eto_fao()[0] == pytest.approx(0.63)
29
+
30
+
31
+ def test_example19_night_period_uses_the_carried_ratio():
32
+ et = ex19(2, 28.0, 90.0, 1.9, 0.0, rs_rso_init=0.8)
33
+ p = et.ts_param
34
+ assert p['Rs_Rso'][0] == 0.8
35
+ assert p['R_n'][0] == pytest.approx(-0.100, abs=0.002)
36
+ assert et.eto_fao()[0] == pytest.approx(0.0)
37
+
38
+
39
+ def nz_day(time_label='start', lon=171.5, TZ_lon=0.0):
40
+ """One January day of hourly UTC labels at a Canterbury point."""
41
+ n = 24
42
+ data = {'T_mean': np.full(n, 20.0), 'RH_mean': np.full(n, 60.0), 'U_z': np.full(n, 2.0), 'R_s': np.full(n, 0.5)}
43
+ hours = np.arange(n) + (1 if time_label == 'end' else 0)
44
+ return ETo(data, 'h', z_msl=300, lat=-43.5, lon=lon, TZ_lon=TZ_lon, day_of_year=np.full(n, 15),
45
+ hour=hours, time_label=time_label, rs_rso_init=1.0)
46
+
47
+
48
+ def test_extraterrestrial_radiation_peaks_in_the_hour_holding_solar_noon():
49
+ # Solar noon at 171.5°E on 15 January is about 00:40 UTC (equation of time about -9 min). With
50
+ # west-positive arithmetic on east-positive input the peak moves to the 23 UTC hour.
51
+ et = nz_day('start')
52
+ assert int(np.argmax(et.ts_param['R_a'])) == 0
53
+ # The same instant expressed in NZST labels (TZ 180°E) peaks in the 12 h period.
54
+ data = {'T_mean': np.full(24, 20.0), 'RH_mean': np.full(24, 60.0), 'R_s': np.full(24, 0.5)}
55
+ nzst = ETo(data, 'h', z_msl=300, lat=-43.5, lon=171.5, TZ_lon=180.0, day_of_year=np.full(24, 15),
56
+ hour=np.arange(24), rs_rso_init=1.0)
57
+ assert int(np.argmax(nzst.ts_param['R_a'])) == 12
58
+
59
+
60
+ def test_period_ending_labels_shift_by_one_hour():
61
+ start = nz_day('start').ts_param['R_a']
62
+ end = nz_day('end').ts_param['R_a']
63
+ np.testing.assert_allclose(end, start) # labels 1..24 'end' are the same periods as 0..23 'start'
64
+
65
+
66
+ def test_time_label_is_validated():
67
+ with pytest.raises(ValueError, match='time_label'):
68
+ nz_day('middle')
69
+
70
+
71
+ @pytest.mark.parametrize('freq', ['3h', 'month', '30min', '60min', 'T', 'x'])
72
+ def test_frequency_must_match_exactly(freq):
73
+ data = {'T_mean': np.full(3, 20.0), 'RH_mean': np.full(3, 60.0), 'T_min': np.full(3, 10.0), 'T_max': np.full(3, 25.0)}
74
+ with pytest.raises(ValueError, match='Unsupported frequency'):
75
+ ETo(data, freq, z_msl=10, lat=-43.5, lon=171.5, TZ_lon=0.0, day_of_year=np.array([1, 2, 3]),
76
+ hour=np.array([0, 1, 2]))
77
+
78
+
79
+ def three_days(cloud=(0.5, 0.9, 0.3)):
80
+ """Hourly UTC series at 171.5°E: R_s = cloud factor of the day times clear-sky Rso."""
81
+ n = 72
82
+ hours = np.arange(n) % 24
83
+ doy = 15 + np.arange(n) // 24
84
+ clear = ETo({'T_mean': np.full(n, 15.0), 'RH_mean': np.full(n, 70.0)}, 'h', z_msl=300, lat=-43.5,
85
+ lon=171.5, TZ_lon=0.0, day_of_year=doy, hour=hours, rs_rso_init=1.0).ts_param['R_so']
86
+ # UTC hours 0-5 of day d are the NZ afternoon of that day (NZST = UTC+12): the day's cloud factor.
87
+ # Sunset is near 08 UTC, so the FAO reference period (2-3 h before sunset) is the 05 UTC hour.
88
+ # The dusk hours 06-07 get 0.7 and the NZ morning (UTC 12-23) 0.95, so a ratio taken from the
89
+ # wrong hour (a dusk hour, the morning) is visible.
90
+ day_cloud = np.array(cloud)[np.arange(n) // 24]
91
+ rs = clear * np.select([hours <= 5, hours <= 11], [day_cloud, 0.7], 0.95)
92
+ data = {'T_mean': np.full(n, 15.0), 'RH_mean': np.full(n, 70.0), 'U_z': np.full(n, 2.0), 'R_s': rs}
93
+ return data, doy, hours
94
+
95
+
96
+ def run(data, doy, hours, sl, init):
97
+ sub = {k: v[sl] for k, v in data.items()}
98
+ return ETo(sub, 'h', z_msl=300, lat=-43.5, lon=171.5, TZ_lon=0.0, day_of_year=doy[sl], hour=hours[sl],
99
+ rs_rso_init=init)
100
+
101
+
102
+ def test_night_uses_the_ratio_from_two_to_three_hours_before_sunset():
103
+ data, doy, hours = three_days()
104
+ et = run(data, doy, hours, slice(None), 1.0)
105
+ p = et.ts_param
106
+ night = p['R_so'] <= 0
107
+ # Every night hour after the first evening carries that evening's ratio, never the morning's
108
+ # 0.95 or the clear-sky 1.0.
109
+ after_first_evening = night & (np.arange(72) > 12)
110
+ assert after_first_evening.sum() > 15
111
+ # In January the whole NZ night (about 09:30-18:00 UTC) lies inside one UTC day, whose afternoon
112
+ # is the evening before it.
113
+ expect = np.array([0.5, 0.9, 0.3])[np.arange(72) // 24]
114
+ np.testing.assert_allclose(p['Rs_Rso'][after_first_evening], expect[after_first_evening])
115
+
116
+
117
+ def test_chunked_equals_unchunked_with_boundaries_at_night():
118
+ # Boundaries placed in NZ night and low-sun hours (UTC 10-18 is 22:00-06:00 NZST), where the
119
+ # carried state is used. A boundary at UTC 00 (NZ noon) would test nothing.
120
+ data, doy, hours = three_days()
121
+ whole = run(data, doy, hours, slice(None), 1.0)
122
+ # Also every single cut, including those between the reference hour and dark, where the state
123
+ # must be the reference ratio and not the last hour's own ratio.
124
+ for cuts in ([14], [11, 40], [17, 38, 62], [19, 43], *([c] for c in range(1, 72))):
125
+ edges = [0, *cuts, 72]
126
+ init = 1.0
127
+ parts = []
128
+ for a, b in zip(edges[:-1], edges[1:]):
129
+ et = run(data, doy, hours, slice(a, b), init)
130
+ parts.append(et.ts_param['R_n'])
131
+ init = et.rs_rso_last
132
+ np.testing.assert_array_equal(np.concatenate(parts), whole.ts_param['R_n'])
133
+
134
+
135
+ def test_series_starting_at_night_warns_without_an_initial_ratio_and_not_with_one():
136
+ data, doy, hours = three_days()
137
+ with pytest.warns(UserWarning, match='starts at night'):
138
+ run(data, doy, hours, slice(14, 30), None)
139
+ with warnings.catch_warnings():
140
+ warnings.simplefilter('error')
141
+ run(data, doy, hours, slice(14, 30), 0.7)
142
+
143
+
144
+ def test_raw_mode_keeps_negative_unrounded_values():
145
+ # A saturated night (RH 100 %, so no vapour deficit) with net longwave loss: FAO-56 gives a small
146
+ # negative value (dew). Example 19's own night value is +0.004 mm, shown as 0.00.
147
+ et = ex19(2, 10.0, 100.0, 1.0, 0.0, rs_rso_init=0.8)
148
+ raw = et.eto_fao(min_ETo=None, max_ETo=None, decimals=None)
149
+ assert raw[0] < 0
150
+ assert raw[0] != np.round(raw[0], 2)
151
+ assert et.eto_fao()[0] == 0.0
152
+
153
+
154
+ def test_west_longitude_with_utc_labels_finds_the_evening_reference():
155
+ # At 120°W the local evening falls after 00 UTC, so solar time must wrap into the previous day;
156
+ # without the wrap the reference period is never found and nights keep the initial 1.0.
157
+ n = 48
158
+ hours = np.arange(n) % 24
159
+ doy = 180 + np.arange(n) // 24
160
+ base = dict(z_msl=100, lat=40.0, lon=-120.0, TZ_lon=0.0, day_of_year=doy, hour=hours)
161
+ clear = ETo({'T_mean': np.full(n, 20.0), 'RH_mean': np.full(n, 50.0)}, 'h', rs_rso_init=1.0, **base).ts_param['R_so']
162
+ et = ETo({'T_mean': np.full(n, 20.0), 'RH_mean': np.full(n, 50.0), 'R_s': 0.6 * clear}, 'h', rs_rso_init=1.0, **base)
163
+ night = et.ts_param['R_so'] <= 0
164
+ later_nights = night & (np.arange(n) > 24)
165
+ assert later_nights.any()
166
+ np.testing.assert_allclose(et.ts_param['Rs_Rso'][later_nights], 0.6)
167
+
168
+
169
+
170
+ # --- review thalweg-step0-code-1 ----------------------------------------------------------------
171
+
172
+
173
+ def test_reference_is_the_hour_two_to_three_hours_before_sunset():
174
+ # Defect caught: a reference window one hour off (3-4 h before sunset). Each afternoon hour gets its
175
+ # own cloud factor, so the carried night value identifies the hour it came from.
176
+ n = 24
177
+ hours = np.arange(n)
178
+ doy = np.full(n, 15)
179
+ base = dict(z_msl=300, lat=-43.5, lon=171.5, TZ_lon=0.0, day_of_year=doy, hour=hours, rs_rso_init=1.0)
180
+ clear = ETo({'T_mean': np.full(n, 15.0), 'RH_mean': np.full(n, 70.0)}, 'h', **base).ts_param['R_so']
181
+ factor = np.full(n, 0.95)
182
+ factor[:8] = [0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70, 0.75] # UTC 0-7 = NZST 12-19
183
+ et = ETo({'T_mean': np.full(n, 15.0), 'RH_mean': np.full(n, 70.0), 'R_s': factor * clear}, 'h', **base)
184
+ p = et.ts_param
185
+ night = p['R_so'] <= 0
186
+ # Which afternoon hour satisfies FAO-56's window: compute it from the solar angle directly.
187
+ phi = np.deg2rad(-43.5)
188
+ delta = 0.409 * np.sin(2 * np.pi * 15 / 365 - 1.39)
189
+ ws = np.arccos(-np.tan(phi) * np.tan(delta))
190
+ b = 2 * np.pi * (15 - 81) / 364
191
+ sc = 0.1645 * np.sin(2 * b) - 0.1255 * np.cos(b) - 0.025 * np.sin(b)
192
+ w = np.pi / 12 * ((hours + 0.5) + 0.06667 * (171.5 - 0.0) + sc - 24 * ((hours + 0.5 + 0.06667 * 171.5 + sc) >= 24) - 12)
193
+ ref = (w >= ws - 0.79) & (w <= ws - 0.52)
194
+ assert ref.sum() == 1
195
+ k = int(np.flatnonzero(ref)[0])
196
+ np.testing.assert_allclose(p['Rs_Rso'][night & (hours > k)], factor[k])
197
+
198
+
199
+ def test_carried_ratio_is_the_capped_value():
200
+ # Defect caught: carrying the raw ratio. A reference hour with R_s above clear sky carries 1.0.
201
+ data, doy, hours = three_days(cloud=(1.4, 1.4, 1.4))
202
+ et = run(data, doy, hours, slice(None), 0.5)
203
+ p = et.ts_param
204
+ later_nights = (p['R_so'] <= 0) & (np.arange(72) > 12)
205
+ np.testing.assert_allclose(p['Rs_Rso'][later_nights], 1.0)
206
+
207
+
208
+ def test_rs_rso_is_floored_at_0_3():
209
+ data, doy, hours = three_days(cloud=(0.05, 0.05, 0.05))
210
+ et = run(data, doy, hours, slice(None), 0.05)
211
+ r = et.ts_param['Rs_Rso']
212
+ assert np.nanmin(r) == pytest.approx(0.3)
213
+ later_nights = (et.ts_param['R_so'] <= 0) & (np.arange(72) > 12)
214
+ np.testing.assert_allclose(r[later_nights], 0.3)
215
+ assert (et.ts_param['R_n'][later_nights] < 0).all() # net longwave stays a loss at night
216
+
217
+
218
+ def test_longitude_difference_wraps_across_the_antimeridian():
219
+ a = nz_day('start', lon=183.5).ts_param['R_a']
220
+ b = nz_day('start', lon=-176.5).ts_param['R_a']
221
+ np.testing.assert_allclose(a, b)
222
+ c = nz_day('start', lon=171.5, TZ_lon=180.0).ts_param['R_a']
223
+ d = nz_day('start', lon=171.5, TZ_lon=-180.0).ts_param['R_a']
224
+ np.testing.assert_allclose(c, d)
225
+
226
+
227
+ def test_sunrise_and_sunset_hours_integrate_only_the_sunlit_part():
228
+ # Defect caught: integrating the whole hour, netting night (negative) against day. Compare each
229
+ # hour's R_a with a fine numerical integral of the instantaneous extraterrestrial radiation.
230
+ et = nz_day('start')
231
+ ra = et.ts_param['R_a']
232
+ phi = np.deg2rad(-43.5)
233
+ for h in range(24):
234
+ # UTC labels at 171.5°E: local solar time = UTC + 11.4 h, so UTC 13-23 fall on the next solar day.
235
+ b0 = 2 * np.pi * (15 - 81) / 364
236
+ sc0 = 0.1645 * np.sin(2 * b0) - 0.1255 * np.cos(b0) - 0.025 * np.sin(b0)
237
+ day = 15 + int(np.floor((h + 0.5 + 0.06667 * 171.5 + sc0) / 24))
238
+ delta = 0.409 * np.sin(2 * np.pi * day / 365 - 1.39)
239
+ dr = 1 + 0.033 * np.cos(2 * np.pi * day / 365)
240
+ # The equation of time (S_c) is taken from the label's day, as the package does; its change
241
+ # across one day is under a minute.
242
+ t = h + (np.arange(600) + 0.5) / 600
243
+ w = np.pi / 12 * (((t + 0.06667 * 171.5 + sc0) % 24) - 12)
244
+ inst = 0.082 * dr * np.maximum(np.sin(phi) * np.sin(delta) + np.cos(phi) * np.cos(delta) * np.cos(w), 0)
245
+ assert ra[h] == pytest.approx(inst.mean() * 60, abs=2e-3), h
eto-2.2.0/eto/util.py ADDED
@@ -0,0 +1,28 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Utility functions.
4
+ """
5
+
6
+ HOURLY = ('h', '1h')
7
+ DAILY = ('d', '1d')
8
+ MONTHLY = ('m', '1m')
9
+ TIME_LABELS = ('start', 'end')
10
+ RS_RSO_MIN = 0.3
11
+ """Lower limit on Rs/Rso (ASCE-EWRI 2005). FAO-56 states only the upper limit of 1; below about 0.26 its
12
+ cloudiness factor (1.35 Rs/Rso - 0.35) turns negative, i.e. net longwave becomes a gain."""
13
+
14
+
15
+ def is_hourly(freq):
16
+ """
17
+ True for an hourly frequency, False for daily or monthly; anything else raises.
18
+
19
+ Accepted (case-insensitive): 'h' / '1h' (hourly), 'D' / '1D' (daily), 'M' / '1M' (monthly). The
20
+ equations assume exactly these periods, so other strings ('3h', '30min', 'T', 'month') are refused
21
+ rather than silently computed with the wrong period.
22
+ """
23
+ f = str(freq).lower()
24
+ if f in HOURLY:
25
+ return True
26
+ if f in DAILY or f in MONTHLY:
27
+ return False
28
+ raise ValueError(f'Unsupported frequency {freq!r}: use "h" (hourly), "D" (daily) or "M" (monthly)')
eto-2.1.0/eto/util.py DELETED
@@ -1,4 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- """
3
- Utility functions.
4
- """
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes