tobac 1.6.2__py3-none-any.whl

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.
Files changed (53) hide show
  1. tobac/__init__.py +112 -0
  2. tobac/analysis/__init__.py +31 -0
  3. tobac/analysis/cell_analysis.py +628 -0
  4. tobac/analysis/feature_analysis.py +212 -0
  5. tobac/analysis/spatial.py +619 -0
  6. tobac/centerofgravity.py +226 -0
  7. tobac/feature_detection.py +1758 -0
  8. tobac/merge_split.py +324 -0
  9. tobac/plotting.py +2321 -0
  10. tobac/segmentation/__init__.py +10 -0
  11. tobac/segmentation/watershed_segmentation.py +1316 -0
  12. tobac/testing.py +1179 -0
  13. tobac/tests/segmentation_tests/test_iris_xarray_segmentation.py +0 -0
  14. tobac/tests/segmentation_tests/test_segmentation.py +1183 -0
  15. tobac/tests/segmentation_tests/test_segmentation_time_pad.py +104 -0
  16. tobac/tests/test_analysis_spatial.py +1109 -0
  17. tobac/tests/test_convert.py +265 -0
  18. tobac/tests/test_datetime.py +216 -0
  19. tobac/tests/test_decorators.py +148 -0
  20. tobac/tests/test_feature_detection.py +1321 -0
  21. tobac/tests/test_generators.py +273 -0
  22. tobac/tests/test_import.py +24 -0
  23. tobac/tests/test_iris_xarray_match_utils.py +244 -0
  24. tobac/tests/test_merge_split.py +351 -0
  25. tobac/tests/test_pbc_utils.py +497 -0
  26. tobac/tests/test_sample_data.py +197 -0
  27. tobac/tests/test_testing.py +747 -0
  28. tobac/tests/test_tracking.py +714 -0
  29. tobac/tests/test_utils.py +650 -0
  30. tobac/tests/test_utils_bulk_statistics.py +789 -0
  31. tobac/tests/test_utils_coordinates.py +328 -0
  32. tobac/tests/test_utils_internal.py +97 -0
  33. tobac/tests/test_xarray_utils.py +232 -0
  34. tobac/tracking.py +613 -0
  35. tobac/utils/__init__.py +27 -0
  36. tobac/utils/bulk_statistics.py +360 -0
  37. tobac/utils/datetime.py +184 -0
  38. tobac/utils/decorators.py +540 -0
  39. tobac/utils/general.py +753 -0
  40. tobac/utils/generators.py +87 -0
  41. tobac/utils/internal/__init__.py +2 -0
  42. tobac/utils/internal/coordinates.py +430 -0
  43. tobac/utils/internal/iris_utils.py +462 -0
  44. tobac/utils/internal/label_props.py +82 -0
  45. tobac/utils/internal/xarray_utils.py +439 -0
  46. tobac/utils/mask.py +364 -0
  47. tobac/utils/periodic_boundaries.py +419 -0
  48. tobac/wrapper.py +244 -0
  49. tobac-1.6.2.dist-info/METADATA +154 -0
  50. tobac-1.6.2.dist-info/RECORD +53 -0
  51. tobac-1.6.2.dist-info/WHEEL +5 -0
  52. tobac-1.6.2.dist-info/licenses/LICENSE +29 -0
  53. tobac-1.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1109 @@
1
+ """
2
+ Test spatial analysis functions
3
+ """
4
+
5
+ from datetime import datetime
6
+ import pytest
7
+ import numpy as np
8
+ import pandas as pd
9
+ import xarray as xr
10
+ from iris.analysis.cartography import area_weights
11
+
12
+ from tobac.analysis.spatial import (
13
+ calculate_distance,
14
+ calculate_velocity_individual,
15
+ calculate_velocity,
16
+ calculate_nearestneighbordistance,
17
+ calculate_area,
18
+ calculate_areas_2Dlatlon,
19
+ )
20
+ from tobac.utils.datetime import to_cftime, to_datetime64
21
+
22
+
23
+ def test_calculate_distance_xy():
24
+ """
25
+ Test for tobac.analysis.spatial.calculate_distance with cartesian coordinates
26
+ """
27
+ test_features = pd.DataFrame(
28
+ {
29
+ "feature": [1, 2],
30
+ "frame": [0, 0],
31
+ "time": [
32
+ datetime(2000, 1, 1),
33
+ datetime(2000, 1, 1),
34
+ ],
35
+ "projection_x_coordinate": [0, 1000],
36
+ "projection_y_coordinate": [0, 0],
37
+ }
38
+ )
39
+
40
+ assert calculate_distance(test_features.iloc[0], test_features.iloc[1]) == 1000
41
+
42
+
43
+ def test_calculate_distance_latlon():
44
+ """
45
+ Test for tobac.analysis.spatial.calculate_distance with latitude/longitude
46
+ coordinates
47
+ """
48
+
49
+ test_features = pd.DataFrame(
50
+ {
51
+ "feature": [1, 2],
52
+ "frame": [0, 0],
53
+ "time": [
54
+ datetime(2000, 1, 1),
55
+ datetime(2000, 1, 1),
56
+ ],
57
+ "longitude": [0, 1],
58
+ "latitude": [0, 0],
59
+ }
60
+ )
61
+
62
+ assert calculate_distance(
63
+ test_features.iloc[0], test_features.iloc[1]
64
+ ) == pytest.approx(1.11e5, rel=1e4)
65
+
66
+
67
+ def test_calculate_distance_latlon_wrong_order():
68
+ """
69
+ Test for tobac.analysis.spatial.calculate_distance with latitude/longitude
70
+ coordinates provided in the wrong order. When lat/lon are provided with
71
+ standard naming the function should detect this and switch their order to
72
+ ensure that haversine distances are calculated correctly.
73
+ """
74
+
75
+ test_features = pd.DataFrame(
76
+ {
77
+ "feature": [1, 2],
78
+ "frame": [0, 0],
79
+ "time": [
80
+ datetime(2000, 1, 1),
81
+ datetime(2000, 1, 1),
82
+ ],
83
+ "longitude": [0, 1],
84
+ "latitude": [0, 0],
85
+ }
86
+ )
87
+ # Test that if latitude and longitude coord names are given in the wrong order, then they are swapped:
88
+ # (expectation is hdim1=y=latitude, hdim2=x=longitude, doesn't matter for x/y but does matter for lat/lon)
89
+ assert calculate_distance(
90
+ test_features.iloc[0],
91
+ test_features.iloc[1],
92
+ hdim1_coord="longitude",
93
+ hdim2_coord="latitude",
94
+ method_distance="latlon",
95
+ ) == pytest.approx(1.11e5, rel=1e4)
96
+
97
+
98
+ def test_calculate_distance_error_invalid_method():
99
+ """Test invalid method_distance"""
100
+ with pytest.raises(ValueError, match="method_distance invalid*"):
101
+ calculate_distance(
102
+ pd.DataFrame(), pd.DataFrame(), method_distance="invalid_method_distance"
103
+ )
104
+
105
+
106
+ def test_calculate_distance_error_no_coords():
107
+ """Test no horizontal coordinates in input dataframe"""
108
+ test_features = pd.DataFrame(
109
+ {
110
+ "feature": [1, 2],
111
+ "frame": [0, 0],
112
+ "time": [
113
+ datetime(2000, 1, 1),
114
+ datetime(2000, 1, 1),
115
+ ],
116
+ }
117
+ )
118
+
119
+ with pytest.raises(ValueError):
120
+ calculate_distance(test_features.iloc[0], test_features.iloc[1])
121
+
122
+
123
+ def test_calculate_distance_error_mismatched_coords():
124
+ """Test dataframes with mismatching coordinates"""
125
+ with pytest.raises(ValueError, match="Discovered coordinates*"):
126
+ calculate_distance(
127
+ pd.DataFrame(
128
+ {
129
+ "feature": [1],
130
+ "frame": [0],
131
+ "time": [datetime(2000, 1, 1)],
132
+ "projection_x_coordinate": [0],
133
+ "projection_y_coordinate": [0],
134
+ }
135
+ ),
136
+ pd.DataFrame(
137
+ {
138
+ "feature": [1],
139
+ "frame": [0],
140
+ "time": [datetime(2000, 1, 1)],
141
+ "longitude": [0],
142
+ "latitude": [0],
143
+ }
144
+ ),
145
+ )
146
+
147
+
148
+ def test_calculate_distance_error_no_method():
149
+ """Test hdim1_coord/hdim2_coord specified but no method_distance"""
150
+ test_features = pd.DataFrame(
151
+ {
152
+ "feature": [1, 2],
153
+ "frame": [0, 0],
154
+ "time": [
155
+ datetime(2000, 1, 1),
156
+ datetime(2000, 1, 1),
157
+ ],
158
+ "projection_x_coordinate": [0, 1000],
159
+ "projection_y_coordinate": [0, 0],
160
+ }
161
+ )
162
+
163
+ with pytest.raises(ValueError, match="method_distance parameter must*"):
164
+ calculate_distance(
165
+ test_features.iloc[0],
166
+ test_features.iloc[1],
167
+ hdim1_coord="projection_y_coordinate",
168
+ )
169
+
170
+ with pytest.raises(ValueError, match="method_distance parameter must*"):
171
+ calculate_distance(
172
+ test_features.iloc[0],
173
+ test_features.iloc[1],
174
+ hdim2_coord="projection_x_coordinate",
175
+ )
176
+
177
+
178
+ @pytest.mark.parametrize(
179
+ "x_coord, y_coord",
180
+ [("x", "y"), ("projection_x_coordinate", "projection_y_coordinate")],
181
+ )
182
+ def test_calculate_velocity_individual_xy(x_coord, y_coord):
183
+ """
184
+ Test calculate_velocity_individual gives the correct result for a single
185
+ track woth different x/y coordinate names
186
+ """
187
+ test_features = pd.DataFrame(
188
+ {
189
+ "feature": [1, 2],
190
+ "frame": [0, 1],
191
+ "time": [
192
+ datetime(2000, 1, 1, 0, 0),
193
+ datetime(2000, 1, 1, 0, 10),
194
+ ],
195
+ x_coord: [0, 6000],
196
+ y_coord: [0, 0],
197
+ }
198
+ )
199
+
200
+ assert (
201
+ calculate_velocity_individual(test_features.iloc[0], test_features.iloc[1])
202
+ == 10
203
+ )
204
+
205
+
206
+ @pytest.mark.parametrize(
207
+ "lat_coord, lon_coord", [("lat", "lon"), ("latitude", "longitude")]
208
+ )
209
+ def test_calculate_velocity_individual_latlon(lat_coord, lon_coord):
210
+ """
211
+ Test calculate_velocity_individual gives the correct result for a single
212
+ track woth different lat/lon coordinate names
213
+ """
214
+ test_features = pd.DataFrame(
215
+ {
216
+ "feature": [1, 2],
217
+ "frame": [0, 0],
218
+ "time": [
219
+ datetime(2000, 1, 1, 0, 0),
220
+ datetime(2000, 1, 1, 0, 10),
221
+ ],
222
+ lon_coord: [0, 1],
223
+ lat_coord: [0, 0],
224
+ }
225
+ )
226
+
227
+ assert calculate_velocity_individual(
228
+ test_features.iloc[0], test_features.iloc[1]
229
+ ) == pytest.approx(1.11e5 / 600, rel=1e2)
230
+
231
+
232
+ @pytest.mark.parametrize(
233
+ "time_format", ("datetime", "datetime64", "proleptic_gregorian", "360_day")
234
+ )
235
+ def test_calculate_velocity(time_format):
236
+ """
237
+ Test velocity calculation using different time formats
238
+ """
239
+ test_features = pd.DataFrame(
240
+ {
241
+ "feature": [1, 2, 3, 4],
242
+ "frame": [0, 0, 1, 1],
243
+ "time": [
244
+ datetime(2000, 1, 1, 0, 0),
245
+ datetime(2000, 1, 1, 0, 0),
246
+ datetime(2000, 1, 1, 0, 10),
247
+ datetime(2000, 1, 1, 0, 10),
248
+ ],
249
+ "x": [0, 0, 6000, 0],
250
+ "y": [0, 0, 0, 9000],
251
+ "cell": [1, 2, 1, 2],
252
+ }
253
+ )
254
+
255
+ if time_format == "datetime":
256
+ pass
257
+ elif time_format == "datetime64":
258
+ test_features["time"] = to_datetime64(test_features.time)
259
+ else:
260
+ test_features["time"] = to_cftime(test_features.time, calendar=time_format)
261
+
262
+ assert calculate_velocity(test_features).at[0, "v"] == 10
263
+ assert calculate_velocity(test_features).at[1, "v"] == 15
264
+
265
+
266
+ def test_calculate_nearestneighbordistance():
267
+ test_features = pd.DataFrame(
268
+ {
269
+ "feature": [1, 2, 3, 4],
270
+ "frame": [0, 0, 1, 1],
271
+ "time": [
272
+ datetime(2000, 1, 1, 0, 0),
273
+ datetime(2000, 1, 1, 0, 0),
274
+ datetime(2000, 1, 1, 0, 10),
275
+ datetime(2000, 1, 1, 0, 10),
276
+ ],
277
+ "projection_x_coordinate": [0, 1000, 0, 2000],
278
+ "projection_y_coordinate": [0, 0, 0, 0],
279
+ "cell": [1, 2, 1, 2],
280
+ }
281
+ )
282
+
283
+ assert calculate_nearestneighbordistance(test_features)[
284
+ "min_distance"
285
+ ].to_list() == [1000, 1000, 2000, 2000]
286
+
287
+ test_features = pd.DataFrame(
288
+ {
289
+ "feature": [1, 2],
290
+ "frame": [0, 1],
291
+ "time": [
292
+ datetime(2000, 1, 1, 0, 0),
293
+ datetime(2000, 1, 1, 0, 10),
294
+ ],
295
+ "projection_x_coordinate": [0, 6000],
296
+ "projection_y_coordinate": [0, 0],
297
+ "cell": [1, 1],
298
+ }
299
+ )
300
+
301
+ assert np.all(
302
+ np.isnan(calculate_nearestneighbordistance(test_features)["min_distance"])
303
+ )
304
+
305
+
306
+ def test_calculate_area():
307
+ """
308
+ Test the calculate_area function for 2D and 3D masks
309
+ """
310
+
311
+ test_labels = np.array(
312
+ [
313
+ [
314
+ [0, 0, 0, 0, 0],
315
+ [0, 1, 0, 2, 0],
316
+ [0, 1, 0, 2, 0],
317
+ [0, 1, 0, 0, 0],
318
+ [0, 0, 0, 0, 0],
319
+ ],
320
+ ],
321
+ dtype=int,
322
+ )
323
+
324
+ test_labels = xr.DataArray(
325
+ test_labels,
326
+ dims=("time", "projection_y_coordinate", "projection_x_coordinate"),
327
+ coords={
328
+ "time": [datetime(2000, 1, 1)],
329
+ "projection_y_coordinate": np.arange(5),
330
+ "projection_x_coordinate": np.arange(5),
331
+ },
332
+ )
333
+
334
+ # We need to do this to avoid round trip bug with xarray to iris conversion
335
+ test_cube = test_labels.to_iris()
336
+ test_cube = test_cube.copy(test_cube.core_data().filled())
337
+
338
+ test_features = pd.DataFrame(
339
+ {
340
+ "feature": [1, 2],
341
+ "frame": [0, 0],
342
+ "time": [
343
+ datetime(2000, 1, 1),
344
+ datetime(2000, 1, 1),
345
+ ],
346
+ }
347
+ )
348
+
349
+ expected_areas = np.array([3, 2])
350
+
351
+ area = calculate_area(test_features, test_cube)
352
+
353
+ assert np.all(area["area"] == expected_areas)
354
+
355
+ test_labels = np.array(
356
+ [
357
+ [
358
+ [
359
+ [0, 0, 0, 0, 0],
360
+ [0, 1, 0, 2, 0],
361
+ [0, 1, 0, 2, 0],
362
+ [0, 1, 0, 0, 0],
363
+ [0, 0, 0, 0, 0],
364
+ ],
365
+ [
366
+ [0, 0, 0, 0, 0],
367
+ [0, 1, 0, 0, 0],
368
+ [0, 1, 0, 3, 0],
369
+ [0, 1, 0, 3, 0],
370
+ [0, 0, 0, 0, 0],
371
+ ],
372
+ ],
373
+ ],
374
+ dtype=int,
375
+ )
376
+
377
+ test_labels = xr.DataArray(
378
+ test_labels,
379
+ dims=(
380
+ "time",
381
+ "model_level_number",
382
+ "projection_y_coordinate",
383
+ "projection_x_coordinate",
384
+ ),
385
+ coords={
386
+ "time": [datetime(2000, 1, 1)],
387
+ "model_level_number": np.arange(2),
388
+ "projection_y_coordinate": np.arange(5),
389
+ "projection_x_coordinate": np.arange(5),
390
+ },
391
+ )
392
+
393
+ # We need to do this to avoid round trip bug with xarray to iris conversion
394
+ test_cube = test_labels.to_iris()
395
+ test_cube = test_cube.copy(test_cube.core_data().filled())
396
+
397
+ test_features = pd.DataFrame(
398
+ {
399
+ "feature": [1, 2, 3],
400
+ "frame": [0, 0, 0],
401
+ "time": [
402
+ datetime(2000, 1, 1),
403
+ datetime(2000, 1, 1),
404
+ datetime(2000, 1, 1),
405
+ ],
406
+ }
407
+ )
408
+
409
+ expected_areas = np.array([3, 2, 2])
410
+
411
+ area = calculate_area(test_features, test_cube)
412
+
413
+ assert np.all(area["area"] == expected_areas)
414
+
415
+ test_labels = xr.DataArray(
416
+ test_labels,
417
+ dims=(
418
+ "time",
419
+ "model_level_number",
420
+ "hdim_0",
421
+ "hdim_1",
422
+ ),
423
+ coords={
424
+ "time": [datetime(2000, 1, 1)],
425
+ "model_level_number": np.arange(2),
426
+ },
427
+ )
428
+
429
+ # Test failure to find valid coordinates
430
+ with pytest.raises(ValueError):
431
+ calculate_area(test_features, test_labels)
432
+
433
+ # Test failure for invalid method
434
+ with pytest.raises(ValueError):
435
+ calculate_area(test_features, test_labels, method_area="invalid_method")
436
+
437
+
438
+ def test_calculate_area_latlon():
439
+ # Test with latitude/longitude
440
+ test_labels = np.array(
441
+ [
442
+ [
443
+ [0, 0, 0, 0, 0],
444
+ [0, 1, 0, 2, 0],
445
+ [0, 1, 0, 2, 0],
446
+ [0, 1, 0, 0, 0],
447
+ [0, 0, 0, 0, 0],
448
+ ],
449
+ [
450
+ [0, 0, 0, 0, 0],
451
+ [0, 4, 0, 0, 0],
452
+ [0, 4, 0, 3, 0],
453
+ [0, 4, 0, 3, 0],
454
+ [0, 0, 0, 0, 0],
455
+ ],
456
+ ],
457
+ dtype=int,
458
+ )
459
+
460
+ test_labels = xr.DataArray(
461
+ test_labels,
462
+ dims=(
463
+ "time",
464
+ "latitude",
465
+ "longitude",
466
+ ),
467
+ coords={
468
+ "time": [datetime(2000, 1, 1), datetime(2000, 1, 1, 1)],
469
+ "latitude": xr.DataArray(
470
+ np.arange(5), dims="latitude", attrs={"units": "degrees"}
471
+ ),
472
+ "longitude": xr.DataArray(
473
+ np.arange(5), dims="longitude", attrs={"units": "degrees"}
474
+ ),
475
+ },
476
+ )
477
+
478
+ test_features = pd.DataFrame(
479
+ {
480
+ "feature": [1, 2, 3, 4],
481
+ "frame": [0, 0, 1, 1],
482
+ "time": [
483
+ datetime(2000, 1, 1, 0),
484
+ datetime(2000, 1, 1, 0),
485
+ datetime(2000, 1, 1, 1),
486
+ datetime(2000, 1, 1, 1),
487
+ ],
488
+ }
489
+ )
490
+
491
+ area = calculate_area(test_features, test_labels)
492
+
493
+ expected_areas = np.array([3, 2, 2, 3]) * 1.11e5**2
494
+
495
+ assert np.all(np.isclose(area["area"], expected_areas, atol=1e8))
496
+
497
+ # Test invalid lat/lon dimensions
498
+
499
+ # Test 1D lat but 2D lon
500
+ test_labels = xr.DataArray(
501
+ test_labels.values,
502
+ dims=(
503
+ "time",
504
+ "y_dim",
505
+ "x_dim",
506
+ ),
507
+ coords={
508
+ "time": [datetime(2000, 1, 1), datetime(2000, 1, 1, 1)],
509
+ "latitude": xr.DataArray(
510
+ np.arange(5), dims="y_dim", attrs={"units": "degrees"} # 1D lat
511
+ ),
512
+ "longitude": xr.DataArray(
513
+ np.tile(np.arange(5), (5, 1)),
514
+ dims=("y_dim", "x_dim"), # 2D lon
515
+ attrs={"units": "degrees"},
516
+ ),
517
+ },
518
+ )
519
+
520
+ with pytest.raises(ValueError):
521
+ calculate_area(test_features, test_labels, method_area="latlon")
522
+
523
+ # Test 3D lat/lon
524
+ test_labels = xr.DataArray(
525
+ np.tile(test_labels.values[:, np.newaxis, ...], (1, 2, 1, 1)),
526
+ dims=(
527
+ "time",
528
+ "z_dim",
529
+ "y_dim",
530
+ "x_dim",
531
+ ),
532
+ coords={
533
+ "time": [datetime(2000, 1, 1), datetime(2000, 1, 1, 1)],
534
+ "latitude": xr.DataArray(
535
+ np.tile(np.arange(5)[:, np.newaxis], (2, 1, 5)),
536
+ dims=("z_dim", "y_dim", "x_dim"),
537
+ attrs={"units": "degrees"},
538
+ ),
539
+ "longitude": xr.DataArray(
540
+ np.tile(np.arange(5), (2, 5, 1)),
541
+ dims=("z_dim", "y_dim", "x_dim"),
542
+ attrs={"units": "degrees"},
543
+ ),
544
+ },
545
+ )
546
+
547
+ with pytest.raises(ValueError):
548
+ calculate_area(test_features, test_labels, method_area="latlon")
549
+
550
+
551
+ def test_calculate_area_1D_latlon():
552
+ """
553
+ Test area calculation using 1D lat/lon coords
554
+ """
555
+ test_labels = np.array(
556
+ [
557
+ [
558
+ [0, 0, 0, 0, 0],
559
+ [0, 1, 0, 2, 0],
560
+ [0, 1, 0, 2, 0],
561
+ [0, 1, 0, 0, 0],
562
+ [0, 0, 0, 0, 0],
563
+ ],
564
+ ],
565
+ dtype=int,
566
+ )
567
+
568
+ test_labels = xr.DataArray(
569
+ test_labels,
570
+ dims=("time", "latitude", "longitude"),
571
+ coords={
572
+ "time": [datetime(2000, 1, 1)],
573
+ "latitude": xr.DataArray(
574
+ np.arange(5), dims=("latitude",), attrs={"units": "degrees"}
575
+ ),
576
+ "longitude": xr.DataArray(
577
+ np.arange(5), dims=("longitude",), attrs={"units": "degrees"}
578
+ ),
579
+ },
580
+ )
581
+
582
+ # We need to do this to avoid round trip bug with xarray to iris conversion
583
+ test_cube = test_labels.to_iris()
584
+ test_cube = test_cube.copy(test_cube.core_data().filled())
585
+
586
+ test_features = pd.DataFrame(
587
+ {
588
+ "feature": [1, 2],
589
+ "frame": [0, 0],
590
+ "time": [
591
+ datetime(2000, 1, 1),
592
+ datetime(2000, 1, 1),
593
+ ],
594
+ }
595
+ )
596
+
597
+ # Calculate expected areas
598
+ copy_of_test_cube = test_cube.copy()
599
+ copy_of_test_cube.coord("latitude").guess_bounds()
600
+ copy_of_test_cube.coord("longitude").guess_bounds()
601
+ area_array = area_weights(copy_of_test_cube, normalize=False)
602
+
603
+ expected_areas = np.array(
604
+ [np.sum(area_array[test_labels.data == i]) for i in [1, 2]]
605
+ )
606
+
607
+ area = calculate_area(test_features, test_cube)
608
+
609
+ assert np.all(area["area"] == expected_areas)
610
+
611
+
612
+ def test_calculate_areas_2Dlatlon():
613
+ """
614
+ Test calculation of area array from 2D lat/lon coords
615
+ Note, in future this needs to be updated to account for non-orthogonal lat/lon arrays
616
+ """
617
+
618
+ test_labels = np.ones([1, 5, 5], dtype=int)
619
+
620
+ test_labels = xr.DataArray(
621
+ test_labels,
622
+ dims=("time", "latitude", "longitude"),
623
+ coords={
624
+ "time": [datetime(2000, 1, 1)],
625
+ "latitude": xr.DataArray(
626
+ np.arange(5), dims=("latitude",), attrs={"units": "degrees"}
627
+ ),
628
+ "longitude": xr.DataArray(
629
+ np.arange(5), dims=("longitude",), attrs={"units": "degrees"}
630
+ ),
631
+ },
632
+ )
633
+
634
+ test_cube = test_labels.to_iris()
635
+ test_cube = test_cube.copy(test_cube.core_data().filled())
636
+ copy_of_test_cube = test_cube.copy()
637
+ copy_of_test_cube.coord("latitude").guess_bounds()
638
+ copy_of_test_cube.coord("longitude").guess_bounds()
639
+ area_array = area_weights(copy_of_test_cube, normalize=False)
640
+
641
+ lat_2d = xr.DataArray(
642
+ np.stack([np.arange(5)] * 5, axis=1),
643
+ dims=("y", "x"),
644
+ attrs={"units": "degrees"},
645
+ )
646
+
647
+ lon_2d = xr.DataArray(
648
+ np.stack([np.arange(5)] * 5, axis=0),
649
+ dims=("y", "x"),
650
+ attrs={"units": "degrees"},
651
+ )
652
+
653
+ test_labels = xr.DataArray(
654
+ test_labels,
655
+ dims=("time", "y", "x"),
656
+ coords={
657
+ "time": [datetime(2000, 1, 1)],
658
+ "latitude": lat_2d,
659
+ "longitude": lon_2d,
660
+ },
661
+ )
662
+
663
+ test_cube = test_labels.to_iris()
664
+ test_cube = test_cube.copy(test_cube.core_data().filled())
665
+
666
+ assert np.allclose(
667
+ calculate_areas_2Dlatlon(
668
+ test_cube.coord("latitude"), test_cube.coord("longitude")
669
+ ),
670
+ area_array,
671
+ rtol=0.01,
672
+ )
673
+
674
+
675
+ def test_calculate_area_2D_latlon():
676
+ """
677
+ Test area calculation using 2D lat/lon coords
678
+ """
679
+
680
+ test_labels = np.array(
681
+ [
682
+ [
683
+ [0, 0, 0, 0, 0],
684
+ [0, 1, 0, 2, 0],
685
+ [0, 1, 0, 2, 0],
686
+ [0, 1, 0, 0, 0],
687
+ [0, 0, 0, 0, 0],
688
+ ],
689
+ ],
690
+ dtype=int,
691
+ )
692
+
693
+ lat_2d = xr.DataArray(
694
+ np.stack([np.arange(5)] * 5, axis=1),
695
+ dims=("y", "x"),
696
+ attrs={"units": "degrees"},
697
+ )
698
+
699
+ lon_2d = xr.DataArray(
700
+ np.stack([np.arange(5)] * 5, axis=0),
701
+ dims=("y", "x"),
702
+ attrs={"units": "degrees"},
703
+ )
704
+
705
+ test_labels = xr.DataArray(
706
+ test_labels,
707
+ dims=("time", "y", "x"),
708
+ coords={
709
+ "time": [datetime(2000, 1, 1)],
710
+ "latitude": lat_2d,
711
+ "longitude": lon_2d,
712
+ },
713
+ )
714
+
715
+ test_cube = test_labels.to_iris()
716
+ test_cube = test_cube.copy(test_cube.core_data().filled())
717
+
718
+ area_array = calculate_areas_2Dlatlon(
719
+ test_cube.coord("latitude"), test_cube.coord("longitude")
720
+ )
721
+
722
+ expected_areas = np.array(
723
+ [np.sum(area_array[test_labels[0].data == i]) for i in [1, 2]]
724
+ )
725
+
726
+ test_features = pd.DataFrame(
727
+ {
728
+ "feature": [1, 2],
729
+ "frame": [0, 0],
730
+ "time": [
731
+ datetime(2000, 1, 1),
732
+ datetime(2000, 1, 1),
733
+ ],
734
+ }
735
+ )
736
+
737
+ area = calculate_area(test_features, test_cube)
738
+
739
+ assert np.all(area["area"] == expected_areas)
740
+
741
+
742
+ def test_calculate_distance_xy_3d():
743
+ """
744
+ 3D distance for xy with use_3d flag and vertical coord
745
+ """
746
+ test_features = pd.DataFrame(
747
+ {
748
+ "feature": [1, 2],
749
+ "frame": [0, 0],
750
+ "time": [datetime(2000, 1, 1), datetime(2000, 1, 1)],
751
+ "projection_x_coordinate": [0, 1000],
752
+ "projection_y_coordinate": [0, 0],
753
+ "height": [0, 600],
754
+ }
755
+ )
756
+ d3d = calculate_distance(
757
+ test_features.iloc[0],
758
+ test_features.iloc[1],
759
+ method_distance="xy",
760
+ vertical_coord="height",
761
+ use_3d=True,
762
+ )
763
+ assert d3d == pytest.approx(np.sqrt(1000**2 + 600**2), rel=1e-9)
764
+
765
+ res = calculate_distance(
766
+ test_features.iloc[0],
767
+ test_features.iloc[1],
768
+ method_distance="xy",
769
+ vertical_coord="height",
770
+ use_3d=True,
771
+ return_components=True,
772
+ )
773
+ assert set(res.keys()) == {"distance_3d", "dx", "dy", "dz"}
774
+ assert res["distance_3d"] == pytest.approx(np.sqrt(1000**2 + 600**2), rel=1e-9)
775
+
776
+ assert res["dx"] == pytest.approx(1000, rel=1e-12)
777
+ assert res["dy"] == pytest.approx(0, rel=1e-12)
778
+ assert res["dz"] == pytest.approx(600, rel=1e-12)
779
+
780
+
781
+ def test_calculate_distance_latlon_3d():
782
+ """
783
+ 3D distance for lat/lon with use_3d flag and vertical coord
784
+ """
785
+ test_features = pd.DataFrame(
786
+ {
787
+ "feature": [1, 2],
788
+ "frame": [0, 0],
789
+ "time": [datetime(2000, 1, 1), datetime(2000, 1, 1)],
790
+ "longitude": [0, 1],
791
+ "latitude": [0, 0],
792
+ "height": [0, 1000],
793
+ }
794
+ )
795
+ d2d = calculate_distance(
796
+ test_features.iloc[0], test_features.iloc[1], method_distance="latlon"
797
+ )
798
+ d3d = calculate_distance(
799
+ test_features.iloc[0],
800
+ test_features.iloc[1],
801
+ method_distance="latlon",
802
+ vertical_coord="height",
803
+ use_3d=True,
804
+ )
805
+ assert d3d == pytest.approx(np.sqrt(d2d**2 + 1000**2), rel=1e-9)
806
+
807
+ res = calculate_distance(
808
+ test_features.iloc[0],
809
+ test_features.iloc[1],
810
+ method_distance="latlon",
811
+ vertical_coord="height",
812
+ use_3d=True,
813
+ return_components=True,
814
+ )
815
+
816
+ assert set(res.keys()) == {"distance_3d", "dx", "dy", "dz"}
817
+
818
+ assert res["distance_3d"] == pytest.approx(d3d, rel=1e-9)
819
+ horizontal = np.hypot(res["dx"], res["dy"])
820
+ assert horizontal == pytest.approx(d2d, rel=1e-9)
821
+
822
+ assert res["dx"] == pytest.approx(d2d, rel=1e-9)
823
+ assert res["dy"] == pytest.approx(0.0, abs=1e-6)
824
+ assert res["dz"] == pytest.approx(1000, rel=1e-12)
825
+
826
+
827
+ def test_calculate_velocity_individual_xy_3d():
828
+ """
829
+ 3D velocity for xy with vertical coord and use_3d=True/False
830
+ """
831
+ test_features = pd.DataFrame(
832
+ {
833
+ "feature": [1, 2],
834
+ "frame": [0, 1],
835
+ "time": [datetime(2000, 1, 1, 0, 0), datetime(2000, 1, 1, 0, 10)],
836
+ "projection_x_coordinate": [0, 6000],
837
+ "projection_y_coordinate": [0, 300],
838
+ "height": [0, 800],
839
+ }
840
+ )
841
+ v3d = calculate_velocity_individual(
842
+ test_features.iloc[0],
843
+ test_features.iloc[1],
844
+ method_distance="xy",
845
+ vertical_coord="height",
846
+ use_3d=True,
847
+ )
848
+ assert v3d == pytest.approx(np.sqrt(6000**2 + 300**2 + 800**2) / 600, rel=1e-9)
849
+
850
+ res = calculate_velocity_individual(
851
+ test_features.iloc[0],
852
+ test_features.iloc[1],
853
+ method_distance="xy",
854
+ vertical_coord="height",
855
+ use_3d=True,
856
+ return_components=True,
857
+ )
858
+ assert set(res.keys()) >= {"v_3d", "vx", "vy", "vz"}
859
+ assert res["v_3d"] == pytest.approx(
860
+ np.sqrt(6000**2 + 300**2 + 800**2) / 600, rel=1e-9
861
+ )
862
+
863
+ assert res["vx"] == pytest.approx(10.0, rel=1e-12)
864
+ assert res["vy"] == pytest.approx(0.5, rel=1e-12)
865
+ assert res["vz"] == pytest.approx(800 / 600, rel=1e-12)
866
+
867
+ v3d_from_components = np.sqrt(res["vx"] ** 2 + res["vy"] ** 2 + res["vz"] ** 2)
868
+ assert res["v_3d"] == pytest.approx(v3d_from_components, rel=1e-12)
869
+
870
+ res2d = calculate_velocity_individual(
871
+ test_features.iloc[0],
872
+ test_features.iloc[1],
873
+ method_distance="xy",
874
+ vertical_coord="height",
875
+ use_3d=False,
876
+ return_components=True,
877
+ )
878
+ assert set(res2d.keys()) >= {"v", "vx", "vy"}
879
+ assert res2d["v"] == pytest.approx(np.sqrt(6000**2 + 300**2) / 600, rel=1e-9)
880
+
881
+ assert res2d["vx"] == pytest.approx(10.0, rel=1e-12)
882
+ assert res2d["vy"] == pytest.approx(0.5, rel=1e-12)
883
+
884
+ v2d_from_components = np.sqrt(res2d["vx"] ** 2 + res2d["vy"] ** 2)
885
+ assert res2d["v"] == pytest.approx(v2d_from_components, rel=1e-12)
886
+
887
+
888
+ def test_calculate_velocity_individual_latlon_3d():
889
+ """
890
+ 3D velocity for lat/lon with vertical coord and use_3d=True
891
+ """
892
+ test_features = pd.DataFrame(
893
+ {
894
+ "feature": [1, 2],
895
+ "frame": [0, 0],
896
+ "time": [datetime(2000, 1, 1, 0, 0), datetime(2000, 1, 1, 0, 10)],
897
+ "longitude": [0, 1],
898
+ "latitude": [0, 0],
899
+ "height": [0, 1000],
900
+ }
901
+ )
902
+ d2d = calculate_distance(
903
+ test_features.iloc[0],
904
+ test_features.iloc[1],
905
+ method_distance="latlon",
906
+ return_components=True,
907
+ )
908
+ v3d = calculate_velocity_individual(
909
+ test_features.iloc[0],
910
+ test_features.iloc[1],
911
+ method_distance="latlon",
912
+ vertical_coord="height",
913
+ use_3d=True,
914
+ )
915
+ assert v3d == pytest.approx(np.sqrt(d2d["distance"] ** 2 + 1000**2) / 600, rel=1e-9)
916
+
917
+ res = calculate_velocity_individual(
918
+ test_features.iloc[0],
919
+ test_features.iloc[1],
920
+ method_distance="latlon",
921
+ vertical_coord="height",
922
+ use_3d=True,
923
+ return_components=True,
924
+ )
925
+ assert set(res.keys()) >= {"v_3d", "vx", "vy", "vz"}
926
+
927
+ dt = (test_features.iloc[1]["time"] - test_features.iloc[0]["time"]).total_seconds()
928
+ dx = d2d["dx"]
929
+ assert res["vx"] == pytest.approx(dx / dt, rel=1e-9)
930
+
931
+ assert res["vy"] == pytest.approx(0.0, abs=1e-6)
932
+ assert res["vz"] == pytest.approx(1000 / 600, rel=1e-12)
933
+
934
+ assert res["v_3d"] == pytest.approx(v3d, rel=1e-9)
935
+
936
+ v_horizontal = np.hypot(res["vx"], res["vy"])
937
+ assert v_horizontal == pytest.approx(d2d["distance"] / 600, rel=1e-9)
938
+
939
+ v3d_from_components = np.sqrt(res["vx"] ** 2 + res["vy"] ** 2 + res["vz"] ** 2)
940
+ assert res["v_3d"] == pytest.approx(v3d_from_components, rel=1e-12)
941
+
942
+
943
+ def test_calculate_velocity_3d_track():
944
+ """
945
+ Track with Z: use_3d=True -> 'v_3d' gets set
946
+ """
947
+ test_features = pd.DataFrame(
948
+ {
949
+ "feature": [1, 2, 3, 4],
950
+ "frame": [0, 0, 1, 1],
951
+ "time": [
952
+ datetime(2000, 1, 1, 0, 0),
953
+ datetime(2000, 1, 1, 0, 0),
954
+ datetime(2000, 1, 1, 0, 10),
955
+ datetime(2000, 1, 1, 0, 10),
956
+ ],
957
+ "projection_x_coordinate": [0, 0, 6000, 0],
958
+ "projection_y_coordinate": [0, 0, 0, 9000],
959
+ "height": [0, 0, 800, 1200],
960
+ "cell": [1, 2, 1, 2],
961
+ }
962
+ )
963
+ out = calculate_velocity(test_features, method_distance="xy", use_3d=True)
964
+ assert out.at[0, "v_3d"] == pytest.approx(np.sqrt(6000**2 + 800**2) / 600, rel=1e-9)
965
+ assert out.at[1, "v_3d"] == pytest.approx(
966
+ np.sqrt(9000**2 + 1200**2) / 600, rel=1e-9
967
+ )
968
+
969
+ out_w_components = calculate_velocity(
970
+ test_features,
971
+ method_distance="xy",
972
+ use_3d=True,
973
+ return_components=True,
974
+ )
975
+
976
+ dt = 600.0
977
+ # Expected values for cell 1
978
+ dx1 = 6000.0
979
+ dy1 = 0.0
980
+ dz1 = 800.0
981
+ v1_3d = np.sqrt(dx1**2 + dy1**2 + dz1**2) / dt
982
+ vx1 = dx1 / dt
983
+ vy1 = dy1 / dt
984
+ vz1 = dz1 / dt
985
+
986
+ # Expected values for cell 2
987
+ dx2 = 0.0
988
+ dy2 = 9000.0
989
+ dz2 = 1200.0
990
+ v2_3d = np.sqrt(dx2**2 + dy2**2 + dz2**2) / dt
991
+ vx2 = dx2 / dt
992
+ vy2 = dy2 / dt
993
+ vz2 = dz2 / dt
994
+
995
+ for col in ("v_3d", "vx", "vy", "vz"):
996
+ assert col in out_w_components.columns
997
+
998
+ # Cell 1:
999
+ assert out_w_components.at[0, "v_3d"] == pytest.approx(v1_3d, rel=1e-12)
1000
+ assert out_w_components.at[0, "vx"] == pytest.approx(vx1, rel=1e-12)
1001
+ assert out_w_components.at[0, "vy"] == pytest.approx(vy1, rel=1e-12)
1002
+ assert out_w_components.at[0, "vz"] == pytest.approx(vz1, rel=1e-12)
1003
+
1004
+ # Cell 2:
1005
+ assert out_w_components.at[1, "v_3d"] == pytest.approx(v2_3d, rel=1e-12)
1006
+ assert out_w_components.at[1, "vx"] == pytest.approx(vx2, rel=1e-12)
1007
+ assert out_w_components.at[1, "vy"] == pytest.approx(vy2, rel=1e-12)
1008
+ assert out_w_components.at[1, "vz"] == pytest.approx(vz2, rel=1e-12)
1009
+
1010
+ for idx in (2, 3):
1011
+ assert np.isnan(out_w_components.at[idx, "v_3d"])
1012
+ assert np.isnan(out_w_components.at[idx, "vx"])
1013
+ assert np.isnan(out_w_components.at[idx, "vy"])
1014
+ assert np.isnan(out_w_components.at[idx, "vz"])
1015
+
1016
+
1017
+ def test_latlon_3d_no_degree_components():
1018
+ """
1019
+ For lat/lon + use_3d + return_components: components dict must contain all components
1020
+ v_3d plus optional vx, vy, vz are expected.
1021
+ """
1022
+ test_features = pd.DataFrame(
1023
+ {
1024
+ "feature": [1, 2],
1025
+ "frame": [0, 0],
1026
+ "time": [datetime(2000, 1, 1), datetime(2000, 1, 1)],
1027
+ "longitude": [0.0, 1.0],
1028
+ "latitude": [0.0, 0.0],
1029
+ "height": [100.0, 500.0],
1030
+ }
1031
+ )
1032
+
1033
+ res = calculate_velocity_individual(
1034
+ test_features.iloc[0],
1035
+ test_features.iloc[1],
1036
+ method_distance="latlon",
1037
+ vertical_coord="height",
1038
+ use_3d=True,
1039
+ return_components=True,
1040
+ )
1041
+
1042
+ assert "v_3d" in res and "vx" in res and "vy" in res and "vz" in res
1043
+
1044
+
1045
+ def test_latlon_3d_dt_zero_returns_nan_scalar():
1046
+ """
1047
+ Δt = 0 should return NaN (not crash) for scalar output.
1048
+ """
1049
+ t = datetime(2000, 1, 1, 0, 0, 0)
1050
+ test_features = pd.DataFrame(
1051
+ {
1052
+ "feature": [1, 2],
1053
+ "frame": [0, 0],
1054
+ "time": [t, t],
1055
+ "longitude": [0.0, 1.0],
1056
+ "latitude": [0.0, 0.0],
1057
+ "height": [0.0, 1000.0],
1058
+ }
1059
+ )
1060
+
1061
+ v = calculate_velocity_individual(
1062
+ test_features.iloc[0],
1063
+ test_features.iloc[1],
1064
+ method_distance="latlon",
1065
+ vertical_coord="height",
1066
+ use_3d=True,
1067
+ return_components=False,
1068
+ )
1069
+ assert np.isnan(v)
1070
+
1071
+
1072
+ def test_latlon_3d_dt_zero_returns_nan_components():
1073
+ """
1074
+ Δt = 0 should return NaN values in the components dict (not raise).
1075
+ """
1076
+ t = datetime(2000, 1, 1, 0, 0, 0)
1077
+ test_features = pd.DataFrame(
1078
+ {
1079
+ "feature": [1, 2],
1080
+ "frame": [0, 0],
1081
+ "time": [t, t],
1082
+ "longitude": [0.0, 1.0],
1083
+ "latitude": [0.0, 0.0],
1084
+ "height": [0.0, 1000.0],
1085
+ }
1086
+ )
1087
+
1088
+ res = calculate_velocity_individual(
1089
+ test_features.iloc[0],
1090
+ test_features.iloc[1],
1091
+ method_distance="latlon",
1092
+ vertical_coord="height",
1093
+ use_3d=True,
1094
+ return_components=True,
1095
+ )
1096
+ # Keys present, values NaN
1097
+ assert "v_3d" in res and "vx" in res and "vy" in res and "vz" in res
1098
+ assert np.isnan(res["v_3d"])
1099
+ assert "v" not in res
1100
+
1101
+ res_2d = calculate_velocity_individual(
1102
+ test_features.iloc[0],
1103
+ test_features.iloc[1],
1104
+ method_distance="latlon",
1105
+ vertical_coord="height",
1106
+ use_3d=False,
1107
+ return_components=True,
1108
+ )
1109
+ assert np.isnan(res_2d["v"])