1 | # -*- coding: utf-8 -*- |
||
2 | """Test the apexpy.helper submodule |
||
3 | |||
4 | Notes |
||
5 | ----- |
||
6 | Whenever function outputs are tested against hard-coded numbers, the test |
||
7 | results (numbers) were obtained by running the code that is tested. Therefore, |
||
8 | these tests below only check that nothing changes when refactoring, etc., and |
||
9 | not if the results are actually correct. |
||
10 | |||
11 | These results are expected to change when IGRF is updated. |
||
12 | |||
13 | """ |
||
14 | |||
15 | import datetime as dt |
||
16 | import numpy as np |
||
17 | import pytest |
||
18 | |||
19 | from apexpy import helpers |
||
20 | |||
21 | |||
22 | def datetime64_to_datetime(dt64): |
||
23 | """Convert numpy datetime64 object to a datetime datetime object. |
||
24 | |||
25 | Parameters |
||
26 | ---------- |
||
27 | dt64 : np.datetime64 |
||
28 | Numpy datetime64 object |
||
29 | |||
30 | Returns |
||
31 | ------- |
||
32 | dt.datetime |
||
33 | Equivalent datetime object with a resolution of days |
||
34 | |||
35 | Notes |
||
36 | ----- |
||
37 | Works outside 32 bit int second range of 1970 |
||
38 | |||
39 | """ |
||
40 | year_floor = dt64.astype('datetime64[Y]') |
||
41 | month_floor = dt64.astype('datetime64[M]') |
||
42 | day_floor = dt64.astype('datetime64[D]') |
||
43 | year = year_floor.astype(int) + 1970 |
||
44 | month = (month_floor |
||
45 | - year_floor).astype('timedelta64[M]').astype(int) + 1 |
||
46 | day = (day_floor - month_floor).astype('timedelta64[D]').astype(int) + 1 |
||
47 | return dt.datetime(year, month, day) |
||
48 | |||
49 | |||
50 | class TestHelpers(object): |
||
51 | """Test class for the helper sub-module.""" |
||
52 | |||
53 | def setup_method(self): |
||
54 | """Set up a clean test environment.""" |
||
55 | self.in_shape = None |
||
56 | self.calc_val = None |
||
57 | self.test_val = None |
||
58 | |||
59 | def teardown_method(self): |
||
60 | """Clean up the test environment.""" |
||
61 | del self.in_shape, self.calc_val, self.test_val |
||
62 | |||
63 | def eval_output(self, rtol=1e-7, atol=0.0): |
||
64 | """Evaluate the values and shape of the calced and expected output.""" |
||
65 | np.testing.assert_allclose(self.calc_val, self.test_val, rtol=rtol, |
||
66 | atol=atol) |
||
67 | assert np.asarray(self.calc_val).shape == self.in_shape |
||
68 | return |
||
69 | |||
70 | @pytest.mark.parametrize('val', [90, np.nan, None, [20.0], True]) |
||
71 | def test_check_set_array_float_no_modify(self, val): |
||
72 | """Test `set_array_float` with inputs that won't be modified. |
||
73 | |||
74 | Parameters |
||
75 | ---------- |
||
76 | val : any but np.array |
||
77 | Value without a 'dtype' attribute |
||
78 | |||
79 | """ |
||
80 | self.calc_val = helpers.set_array_float(val) |
||
81 | |||
82 | if val is None: |
||
83 | assert self.calc_val is None |
||
84 | elif np.isnan(val): |
||
85 | assert np.isnan(self.calc_val) |
||
86 | else: |
||
87 | assert self.calc_val == val |
||
88 | return |
||
89 | |||
90 | @pytest.mark.parametrize('dtype', [int, float, bool, object]) |
||
91 | def test_check_set_array_float_success(self, dtype): |
||
92 | """Test `set_array_float` modifies array inputs. |
||
93 | |||
94 | Parameters |
||
95 | ---------- |
||
96 | dtype : dtype |
||
97 | Data type to use when creating input array |
||
98 | |||
99 | """ |
||
100 | self.in_shape = (2,) |
||
101 | self.calc_val = helpers.set_array_float( |
||
102 | np.ones(shape=self.in_shape, dtype=dtype)) |
||
103 | |||
104 | # Test that the output dtype is as expected |
||
105 | assert self.calc_val.dtype == np.float64 |
||
106 | |||
107 | # Ensure values are unity |
||
108 | self.test_val = np.ones(shape=self.in_shape, dtype=np.float64) |
||
109 | self.eval_output() |
||
110 | return |
||
111 | |||
112 | @pytest.mark.parametrize('lat', [90, 0, -90, np.nan]) |
||
113 | def test_checklat_scalar(self, lat): |
||
114 | """Test good latitude check with scalars. |
||
115 | |||
116 | Parameters |
||
117 | ---------- |
||
118 | lat : int or float |
||
119 | Latitude in degrees N |
||
120 | |||
121 | """ |
||
122 | self.calc_val = helpers.checklat(lat) |
||
123 | |||
124 | if np.isnan(lat): |
||
125 | assert np.isnan(self.calc_val) |
||
126 | else: |
||
127 | assert self.calc_val == lat |
||
128 | return |
||
129 | |||
130 | @pytest.mark.parametrize('lat', [(90 + 1e-5), (-90 - 1e-5)]) |
||
131 | def test_checklat_scalar_clip(self, lat): |
||
132 | """Test good latitude check with scalars just beyond the lat limits. |
||
133 | |||
134 | Parameters |
||
135 | ---------- |
||
136 | lat : int or float |
||
137 | Latitude in degrees N |
||
138 | |||
139 | """ |
||
140 | self.calc_val = helpers.checklat(lat) |
||
141 | self.test_val = np.sign(lat) * np.floor(abs(lat)) |
||
142 | assert self.calc_val == self.test_val |
||
143 | return |
||
144 | |||
145 | @pytest.mark.parametrize('in_args,msg', |
||
146 | [([90 + 1e-4], "lat must be in"), |
||
147 | ([-90 - 1e-4, 'glat'], "glat must be in"), |
||
148 | ([[-90 - 1e-5, -90, 0, 90, 90 + 1e-4], 'glat'], |
||
149 | "glat must be in"), |
||
150 | ([[-90 - 1e-4, -90, np.nan, np.nan, 90 + 1e-5]], |
||
151 | 'lat must be in')]) |
||
152 | def test_checklat_error(self, in_args, msg): |
||
153 | """Test bad latitude raises ValueError with appropriate message. |
||
154 | |||
155 | Parameters |
||
156 | ---------- |
||
157 | in_args : list |
||
158 | List of input arguments |
||
159 | msg : str |
||
160 | Expected error message |
||
161 | |||
162 | """ |
||
163 | with pytest.raises(ValueError) as verr: |
||
164 | helpers.checklat(*in_args) |
||
165 | |||
166 | assert str(verr.value).startswith(msg) |
||
167 | return |
||
168 | |||
169 | @pytest.mark.parametrize('lat,test_lat', |
||
170 | [(np.linspace(-90 - 1e-5, 90 + 1e-5, 3), |
||
171 | [-90, 0, 90]), |
||
172 | (np.linspace(-90, 90, 3), [-90, 0, 90]), |
||
173 | ([-90 - 1e-5, 0, 90, np.nan], |
||
174 | [-90, 0, 90, np.nan]), |
||
175 | ([[-90, 0], [0, 90]], [[-90, 0], [0, 90]]), |
||
176 | ([[-90], [0], [90]], [[-90], [0], [90]])]) |
||
177 | def test_checklat_array(self, lat, test_lat): |
||
178 | """Test good latitude with finite values. |
||
179 | |||
180 | Parameters |
||
181 | ---------- |
||
182 | lat : array-like |
||
183 | Latitudes in degrees N |
||
184 | test_lat : list-like |
||
185 | Output latitudes in degrees N |
||
186 | |||
187 | """ |
||
188 | self.calc_val = helpers.checklat(lat) |
||
189 | self.in_shape = np.asarray(lat).shape |
||
190 | self.test_val = test_lat |
||
191 | self.eval_output(atol=1e-8) |
||
192 | return |
||
193 | |||
194 | @pytest.mark.parametrize('lat,test_sin', [ |
||
195 | (60, 0.96076892283052284), (10, 0.33257924500670238), |
||
196 | ([60, 10], [0.96076892283052284, 0.33257924500670238]), |
||
197 | ([[60, 10], [60, 10]], [[0.96076892283052284, 0.33257924500670238], |
||
198 | [0.96076892283052284, 0.33257924500670238]])]) |
||
199 | def test_getsinIm(self, lat, test_sin): |
||
200 | """Test sin(Im) calculation for scalar and array inputs. |
||
201 | |||
202 | Parameters |
||
203 | ---------- |
||
204 | lat : float |
||
205 | Latitude in degrees N |
||
206 | test_sin : float |
||
207 | Output value |
||
208 | |||
209 | """ |
||
210 | self.calc_val = helpers.getsinIm(lat) |
||
211 | self.in_shape = np.asarray(lat).shape |
||
212 | self.test_val = test_sin |
||
213 | self.eval_output() |
||
214 | return |
||
215 | |||
216 | @pytest.mark.parametrize('lat,test_cos', [ |
||
217 | (60, 0.27735009811261463), (10, 0.94307531289434765), |
||
218 | ([60, 10], [0.27735009811261463, 0.94307531289434765]), |
||
219 | ([[60, 10], [60, 10]], [[0.27735009811261463, 0.94307531289434765], |
||
220 | [0.27735009811261463, 0.94307531289434765]])]) |
||
221 | def test_getcosIm(self, lat, test_cos): |
||
222 | """Test cos(Im) calculation for scalar and array inputs. |
||
223 | |||
224 | Parameters |
||
225 | ---------- |
||
226 | lat : float |
||
227 | Latitude in degrees N |
||
228 | test_cos : float |
||
229 | Expected output |
||
230 | |||
231 | """ |
||
232 | self.calc_val = helpers.getcosIm(lat) |
||
233 | self.in_shape = np.asarray(lat).shape |
||
234 | self.test_val = test_cos |
||
235 | self.eval_output() |
||
236 | return |
||
237 | |||
238 | @pytest.mark.parametrize('in_time,year', [ |
||
239 | (dt.datetime(2001, 1, 1), 2001), (dt.date(2001, 1, 1), 2001), |
||
240 | (dt.datetime(2002, 1, 1), 2002), |
||
241 | (dt.datetime(2005, 2, 3, 4, 5, 6), 2005.090877283105), |
||
242 | (dt.datetime(2005, 12, 11, 10, 9, 8), 2005.943624682902)]) |
||
243 | def test_toYearFraction(self, in_time, year): |
||
244 | """Test the datetime to fractional year calculation. |
||
245 | |||
246 | Parameters |
||
247 | ---------- |
||
248 | in_time : dt.datetime or dt.date |
||
249 | Input time in a datetime format |
||
250 | year : int or float |
||
251 | Output year with fractional values |
||
252 | |||
253 | """ |
||
254 | self.calc_val = helpers.toYearFraction(in_time) |
||
255 | np.testing.assert_allclose(self.calc_val, year) |
||
256 | return |
||
257 | |||
258 | @pytest.mark.parametrize('gc_lat,gd_lat', [ |
||
259 | (0, 0), (90, 90), (30, 30.166923849507356), (60, 60.166364190170931), |
||
260 | ([0, 90, 30], [0, 90, 30.166923849507356]), |
||
261 | ([[0, 30], [90, 60]], [[0, 30.16692384950735], |
||
262 | [90, 60.166364190170931]])]) |
||
263 | def test_gc2gdlat(self, gc_lat, gd_lat): |
||
264 | """Test geocentric to geodetic calculation. |
||
265 | |||
266 | Parameters |
||
267 | ---------- |
||
268 | gc_lat : int or float |
||
269 | Geocentric latitude in degrees N |
||
270 | gd_lat : int or float |
||
271 | Geodetic latitude in degrees N |
||
272 | |||
273 | """ |
||
274 | self.calc_val = helpers.gc2gdlat(gc_lat) |
||
275 | self.in_shape = np.asarray(gc_lat).shape |
||
276 | self.test_val = gd_lat |
||
277 | self.eval_output() |
||
278 | return |
||
279 | |||
280 | @pytest.mark.parametrize('in_time,test_loc', [ |
||
281 | (dt.datetime(2005, 2, 3, 4, 5, 6), (-16.505391672592904, |
||
282 | 122.17768157084515)), |
||
283 | (dt.datetime(2010, 12, 11, 10, 9, 8), (-23.001554595838947, |
||
284 | 26.008999999955023)), |
||
285 | (dt.datetime(2021, 11, 20, 12, 12, 12, 500000), |
||
286 | (-19.79733856741465, -6.635177076865062)), |
||
287 | (dt.datetime(1601, 1, 1, 0, 0, 0), (-23.06239721771427, |
||
288 | -178.90131731228584)), |
||
289 | (dt.datetime(2100, 12, 31, 23, 59, 59), (-23.021061422069053, |
||
290 | -179.23129780639425))]) |
||
291 | def test_subsol(self, in_time, test_loc): |
||
292 | """Test the subsolar location calculation. |
||
293 | |||
294 | Parameters |
||
295 | ---------- |
||
296 | in_time : dt.datetime |
||
297 | Input time |
||
298 | test_loc : tuple |
||
299 | Expected output |
||
300 | |||
301 | """ |
||
302 | self.calc_val = helpers.subsol(in_time) |
||
303 | np.testing.assert_allclose(self.calc_val, test_loc) |
||
304 | return |
||
305 | |||
306 | @pytest.mark.parametrize('in_time', [dt.datetime(1600, 12, 31, 23, 59, 59), |
||
307 | dt.datetime(2101, 1, 1, 0, 0, 0)]) |
||
308 | def test_bad_subsol_date(self, in_time): |
||
309 | """Test raises ValueError for bad time in subsolar calculation. |
||
310 | |||
311 | Parameters |
||
312 | ---------- |
||
313 | in_time : dt.datetime |
||
314 | Input time |
||
315 | |||
316 | """ |
||
317 | with pytest.raises(ValueError) as verr: |
||
318 | helpers.subsol(in_time) |
||
319 | |||
320 | assert str(verr.value).startswith('Year must be in') |
||
321 | return |
||
322 | |||
323 | @pytest.mark.parametrize('in_time', [None, 2015.0]) |
||
324 | def test_bad_subsol_input(self, in_time): |
||
325 | """Test raises ValueError for bad input type in subsolar calculation. |
||
326 | |||
327 | Parameters |
||
328 | ---------- |
||
329 | in_time : NoneType or float |
||
330 | Badly formatted input time |
||
331 | |||
332 | """ |
||
333 | with pytest.raises(ValueError) as verr: |
||
334 | helpers.subsol(in_time) |
||
335 | |||
336 | assert str(verr.value).startswith('input must be datetime') |
||
337 | return |
||
338 | |||
339 | @pytest.mark.parametrize('in_dates', [ |
||
340 | np.arange(np.datetime64("2000"), np.datetime64("2001"), |
||
341 | np.timedelta64(1, 'M')).astype('datetime64[s]').reshape((3, |
||
342 | 4)), |
||
343 | np.arange(np.datetime64("1601"), np.datetime64("2100"), |
||
344 | np.timedelta64(1, 'Y')).astype('datetime64[s]')]) |
||
345 | def test_subsol_datetime64_array(self, in_dates): |
||
346 | """Verify subsolar point calculation using an array of np.datetime64. |
||
347 | |||
348 | Parameters |
||
349 | ---------- |
||
350 | in_time : array-like |
||
351 | Array of input times |
||
352 | |||
353 | Notes |
||
354 | ----- |
||
355 | Tested by ensuring the array of np.datetime64 is equivalent to |
||
356 | converting using single dt.datetime values |
||
357 | |||
358 | """ |
||
359 | # Get the datetime64 output |
||
360 | ss_out = helpers.subsol(in_dates) |
||
361 | |||
362 | # Get the datetime scalar output for comparison |
||
363 | self.in_shape = in_dates.shape |
||
364 | true_out = [list(), list()] |
||
365 | for in_date in in_dates.flatten(): |
||
366 | dtime = datetime64_to_datetime(in_date) |
||
367 | out = helpers.subsol(dtime) |
||
368 | true_out[0].append(out[0]) |
||
369 | true_out[1].append(out[1]) |
||
370 | |||
371 | # Evaluate the two outputs |
||
372 | for i, self.calc_val in enumerate(ss_out): |
||
0 ignored issues
–
show
Comprehensibility
Best Practice
introduced
by
![]() |
|||
373 | self.test_val = np.array(true_out[i]).reshape(self.in_shape) |
||
374 | self.eval_output() |
||
375 | |||
376 | return |
||
377 |