pycontrails 0.58.0__cp314-cp314-win_amd64.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.

Potentially problematic release.


This version of pycontrails might be problematic. Click here for more details.

Files changed (122) hide show
  1. pycontrails/__init__.py +70 -0
  2. pycontrails/_version.py +34 -0
  3. pycontrails/core/__init__.py +30 -0
  4. pycontrails/core/aircraft_performance.py +679 -0
  5. pycontrails/core/airports.py +228 -0
  6. pycontrails/core/cache.py +889 -0
  7. pycontrails/core/coordinates.py +174 -0
  8. pycontrails/core/fleet.py +483 -0
  9. pycontrails/core/flight.py +2185 -0
  10. pycontrails/core/flightplan.py +228 -0
  11. pycontrails/core/fuel.py +140 -0
  12. pycontrails/core/interpolation.py +702 -0
  13. pycontrails/core/met.py +2931 -0
  14. pycontrails/core/met_var.py +387 -0
  15. pycontrails/core/models.py +1321 -0
  16. pycontrails/core/polygon.py +549 -0
  17. pycontrails/core/rgi_cython.cp314-win_amd64.pyd +0 -0
  18. pycontrails/core/vector.py +2249 -0
  19. pycontrails/datalib/__init__.py +12 -0
  20. pycontrails/datalib/_met_utils/metsource.py +746 -0
  21. pycontrails/datalib/ecmwf/__init__.py +73 -0
  22. pycontrails/datalib/ecmwf/arco_era5.py +345 -0
  23. pycontrails/datalib/ecmwf/common.py +114 -0
  24. pycontrails/datalib/ecmwf/era5.py +554 -0
  25. pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
  26. pycontrails/datalib/ecmwf/hres.py +804 -0
  27. pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
  28. pycontrails/datalib/ecmwf/ifs.py +287 -0
  29. pycontrails/datalib/ecmwf/model_levels.py +435 -0
  30. pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
  31. pycontrails/datalib/ecmwf/variables.py +268 -0
  32. pycontrails/datalib/geo_utils.py +261 -0
  33. pycontrails/datalib/gfs/__init__.py +28 -0
  34. pycontrails/datalib/gfs/gfs.py +656 -0
  35. pycontrails/datalib/gfs/variables.py +104 -0
  36. pycontrails/datalib/goes.py +757 -0
  37. pycontrails/datalib/himawari/__init__.py +27 -0
  38. pycontrails/datalib/himawari/header_struct.py +266 -0
  39. pycontrails/datalib/himawari/himawari.py +667 -0
  40. pycontrails/datalib/landsat.py +589 -0
  41. pycontrails/datalib/leo_utils/__init__.py +5 -0
  42. pycontrails/datalib/leo_utils/correction.py +266 -0
  43. pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
  44. pycontrails/datalib/leo_utils/search.py +250 -0
  45. pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
  46. pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
  47. pycontrails/datalib/leo_utils/vis.py +59 -0
  48. pycontrails/datalib/sentinel.py +650 -0
  49. pycontrails/datalib/spire/__init__.py +5 -0
  50. pycontrails/datalib/spire/exceptions.py +62 -0
  51. pycontrails/datalib/spire/spire.py +604 -0
  52. pycontrails/ext/bada.py +42 -0
  53. pycontrails/ext/cirium.py +14 -0
  54. pycontrails/ext/empirical_grid.py +140 -0
  55. pycontrails/ext/synthetic_flight.py +431 -0
  56. pycontrails/models/__init__.py +1 -0
  57. pycontrails/models/accf.py +425 -0
  58. pycontrails/models/apcemm/__init__.py +8 -0
  59. pycontrails/models/apcemm/apcemm.py +983 -0
  60. pycontrails/models/apcemm/inputs.py +226 -0
  61. pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
  62. pycontrails/models/apcemm/utils.py +437 -0
  63. pycontrails/models/cocip/__init__.py +29 -0
  64. pycontrails/models/cocip/cocip.py +2742 -0
  65. pycontrails/models/cocip/cocip_params.py +305 -0
  66. pycontrails/models/cocip/cocip_uncertainty.py +291 -0
  67. pycontrails/models/cocip/contrail_properties.py +1530 -0
  68. pycontrails/models/cocip/output_formats.py +2270 -0
  69. pycontrails/models/cocip/radiative_forcing.py +1260 -0
  70. pycontrails/models/cocip/radiative_heating.py +520 -0
  71. pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
  72. pycontrails/models/cocip/wake_vortex.py +396 -0
  73. pycontrails/models/cocip/wind_shear.py +120 -0
  74. pycontrails/models/cocipgrid/__init__.py +9 -0
  75. pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
  76. pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
  77. pycontrails/models/dry_advection.py +602 -0
  78. pycontrails/models/emissions/__init__.py +21 -0
  79. pycontrails/models/emissions/black_carbon.py +599 -0
  80. pycontrails/models/emissions/emissions.py +1353 -0
  81. pycontrails/models/emissions/ffm2.py +336 -0
  82. pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
  83. pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
  84. pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
  85. pycontrails/models/extended_k15.py +1327 -0
  86. pycontrails/models/humidity_scaling/__init__.py +37 -0
  87. pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
  88. pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
  89. pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
  90. pycontrails/models/issr.py +210 -0
  91. pycontrails/models/pcc.py +326 -0
  92. pycontrails/models/pcr.py +154 -0
  93. pycontrails/models/ps_model/__init__.py +18 -0
  94. pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
  95. pycontrails/models/ps_model/ps_grid.py +701 -0
  96. pycontrails/models/ps_model/ps_model.py +1000 -0
  97. pycontrails/models/ps_model/ps_operational_limits.py +525 -0
  98. pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
  99. pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
  100. pycontrails/models/sac.py +442 -0
  101. pycontrails/models/tau_cirrus.py +183 -0
  102. pycontrails/physics/__init__.py +1 -0
  103. pycontrails/physics/constants.py +117 -0
  104. pycontrails/physics/geo.py +1138 -0
  105. pycontrails/physics/jet.py +968 -0
  106. pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
  107. pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
  108. pycontrails/physics/thermo.py +551 -0
  109. pycontrails/physics/units.py +472 -0
  110. pycontrails/py.typed +0 -0
  111. pycontrails/utils/__init__.py +1 -0
  112. pycontrails/utils/dependencies.py +66 -0
  113. pycontrails/utils/iteration.py +13 -0
  114. pycontrails/utils/json.py +187 -0
  115. pycontrails/utils/temp.py +50 -0
  116. pycontrails/utils/types.py +163 -0
  117. pycontrails-0.58.0.dist-info/METADATA +180 -0
  118. pycontrails-0.58.0.dist-info/RECORD +122 -0
  119. pycontrails-0.58.0.dist-info/WHEEL +5 -0
  120. pycontrails-0.58.0.dist-info/licenses/LICENSE +178 -0
  121. pycontrails-0.58.0.dist-info/licenses/NOTICE +43 -0
  122. pycontrails-0.58.0.dist-info/top_level.txt +3 -0
@@ -0,0 +1,1138 @@
1
+ """Tools for spherical geometry, solar radiation, and wind advection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import numpy.typing as npt
7
+ import xarray as xr
8
+
9
+ from pycontrails.physics import constants, units
10
+ from pycontrails.utils.types import ArrayLike, ArrayOrFloat
11
+
12
+ # ------------------
13
+ # Spherical Geometry
14
+ # ------------------
15
+
16
+
17
+ def haversine(lons0: ArrayLike, lats0: ArrayLike, lons1: ArrayLike, lats1: ArrayLike) -> ArrayLike:
18
+ r"""Calculate haversine distance between points in (lons0, lats0) and (lons1, lats1).
19
+
20
+ Handles coordinates crossing the antimeridian line (-180, 180).
21
+
22
+ Parameters
23
+ ----------
24
+ lons0, lats0 : ArrayLike
25
+ Coordinates of initial points, [:math:`\deg`]
26
+ lons1, lats1 : ArrayLike
27
+ Coordinates of terminal points, [:math:`\deg`]
28
+
29
+ Returns
30
+ -------
31
+ ArrayLike
32
+ Distances between corresponding points. [:math:`m`]
33
+
34
+ Notes
35
+ -----
36
+ This formula does not take into account the non-spheroidal (ellipsoidal) shape of the Earth.
37
+ Originally referenced from https://andrew.hedges.name/experiments/haversine/.
38
+
39
+ References
40
+ ----------
41
+ - :cite:`CalculateDistanceBearing`
42
+
43
+ See Also
44
+ --------
45
+ :func:`sklearn.metrics.pairwise.haversine_distances`:
46
+ Compute the Haversine distance
47
+ :class:`pyproj.Geod`:
48
+ Performs forward and inverse geodetic, or Great Circle, computations
49
+ """
50
+ lats0_rad = units.degrees_to_radians(lats0)
51
+ lats1_rad = units.degrees_to_radians(lats1)
52
+
53
+ cos_lats0 = np.cos(lats0_rad)
54
+ cos_lats1 = np.cos(lats1_rad)
55
+
56
+ d_lons = units.degrees_to_radians(lons1) - units.degrees_to_radians(lons0)
57
+ d_lats = lats1_rad - lats0_rad
58
+
59
+ a = (np.sin(d_lats / 2.0)) ** 2 + cos_lats0 * cos_lats1 * ((np.sin(d_lons / 2.0)) ** 2)
60
+ cc = 2.0 * np.arctan2(a**0.5, (1.0 - a) ** 0.5)
61
+ return constants.radius_earth * cc
62
+
63
+
64
+ def segment_haversine(
65
+ longitude: npt.NDArray[np.floating], latitude: npt.NDArray[np.floating]
66
+ ) -> npt.NDArray[np.floating]:
67
+ r"""Calculate haversine distance between consecutive points along path.
68
+
69
+ Parameters
70
+ ----------
71
+ longitude : npt.NDArray[np.floating]
72
+ 1D Longitude values with index corresponding to latitude inputs, [:math:`\deg`]
73
+ latitude : npt.NDArray[np.floating]
74
+ 1D Latitude values with index corresponding to longitude inputs, [:math:`\deg`]
75
+
76
+ Returns
77
+ -------
78
+ npt.NDArray[np.floating]
79
+ Haversine distance between (lat_i, lon_i) and (lat_i+1, lon_i+1), [:math:`m`]
80
+ The final entry of the output is set to nan.
81
+
82
+ See Also
83
+ --------
84
+ :meth:`pyproj.Geod.line_lengths`
85
+ """
86
+ dtype = np.result_type(longitude, latitude, np.float32)
87
+ dist = np.empty(longitude.size, dtype=dtype)
88
+
89
+ lons0 = longitude[:-1]
90
+ lons1 = longitude[1:]
91
+ lats0 = latitude[:-1]
92
+ lats1 = latitude[1:]
93
+
94
+ dist[:-1] = haversine(lons0, lats0, lons1, lats1)
95
+ dist[-1] = np.nan
96
+ return dist
97
+
98
+
99
+ def azimuth_to_direction(
100
+ azimuth_: npt.NDArray[np.floating], latitude: npt.NDArray[np.floating]
101
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
102
+ r"""Calculate rectangular direction from spherical azimuth.
103
+
104
+ This implementation uses the equation
105
+
106
+ ``cos(latitude) / tan(azimuth) = sin_a / cos_a``
107
+
108
+ to solve for `sin_a` and `cos_a`.
109
+
110
+ Parameters
111
+ ----------
112
+ azimuth_ : npt.NDArray[np.floating]
113
+ Angle measured clockwise from true north, [:math:`\deg`]
114
+ latitude : npt.NDArray[np.floating]
115
+ Latitude value of the point, [:math:`\deg`]
116
+
117
+ Returns
118
+ -------
119
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
120
+ A tuple of sine and cosine values.
121
+ """
122
+ cos_lat = np.cos(units.degrees_to_radians(latitude))
123
+ tan_az = np.tan(units.degrees_to_radians(azimuth_))
124
+
125
+ num = cos_lat
126
+ denom = tan_az
127
+ mag = np.sqrt(num**2 + denom**2)
128
+
129
+ # For azimuth in [0, 90) and (270, 360], sin_a positive
130
+ sign_sin_a = np.where((azimuth_ - 90.0) % 360.0 - 180.0 >= 0.0, 1.0, -1.0)
131
+
132
+ # For azimuth in [0, 180), cos_a positive
133
+ sign_cos_a = np.where(azimuth_ % 360.0 - 180.0 <= 0.0, 1.0, -1.0)
134
+
135
+ sin_a = sign_sin_a * np.abs(num) / mag
136
+ cos_a = sign_cos_a * np.abs(denom) / mag
137
+ return sin_a, cos_a
138
+
139
+
140
+ def azimuth(
141
+ lons0: npt.NDArray[np.floating],
142
+ lats0: npt.NDArray[np.floating],
143
+ lons1: npt.NDArray[np.floating],
144
+ lats1: npt.NDArray[np.floating],
145
+ ) -> npt.NDArray[np.floating]:
146
+ r"""Calculate angle relative to true north for set of coordinates.
147
+
148
+ Parameters
149
+ ----------
150
+ lons0 : npt.NDArray[np.floating]
151
+ Longitude values of initial endpoints, [:math:`\deg`].
152
+ lats0 : npt.NDArray[np.floating]
153
+ Latitude values of initial endpoints, [:math:`\deg`].
154
+ lons1 : npt.NDArray[np.floating]
155
+ Longitude values of terminal endpoints, [:math:`\deg`].
156
+ lats1 : npt.NDArray[np.floating]
157
+ Latitude values of terminal endpoints, [:math:`\deg`].
158
+
159
+ References
160
+ ----------
161
+ - :cite:`wikipediacontributorsAzimuth2023`
162
+
163
+ Returns
164
+ -------
165
+ npt.NDArray[np.floating]
166
+ Azimuth relative to true north (:math:`0\deg`), [:math:`\deg`]
167
+
168
+ See Also
169
+ --------
170
+ :func:`longitudinal_angle`
171
+ """
172
+ lons0 = units.degrees_to_radians(lons0)
173
+ lons1 = units.degrees_to_radians(lons1)
174
+ lats0 = units.degrees_to_radians(lats0)
175
+ lats1 = units.degrees_to_radians(lats1)
176
+ d_lon = lons1 - lons0
177
+
178
+ num = np.sin(d_lon)
179
+ denom = np.cos(lats0) * np.tan(lats1) - np.sin(lats0) * np.cos(d_lon)
180
+
181
+ # outputs on [-180, 180] range
182
+ alpha = units.radians_to_degrees(np.arctan2(num, denom))
183
+
184
+ # return on [0, 360)
185
+ return alpha % 360.0
186
+
187
+
188
+ def segment_azimuth(
189
+ longitude: npt.NDArray[np.floating], latitude: npt.NDArray[np.floating]
190
+ ) -> npt.NDArray[np.floating]:
191
+ r"""Calculate the angle between coordinate segments and true north.
192
+
193
+ `np.nan` is added to the final value so the length of the output is the same as the inputs.
194
+
195
+ Parameters
196
+ ----------
197
+ longitude : npt.NDArray[np.floating]
198
+ Longitude values, [:math:`\deg`]
199
+ latitude : npt.NDArray[np.floating]
200
+ Latitude values, [:math:`\deg`]
201
+
202
+ Returns
203
+ -------
204
+ npt.NDArray[np.floating]
205
+ Azimuth relative to true north (:math:`0\deg`), [:math:`\deg`]
206
+ Final entry of each array is set to `np.nan`.
207
+
208
+ References
209
+ ----------
210
+ - :cite:`wikipediacontributorsAzimuth2023`
211
+
212
+ See Also
213
+ --------
214
+ :func:`azimuth`
215
+ """
216
+ dtype = np.result_type(longitude, latitude, np.float32)
217
+ az = np.empty(longitude.size, dtype=dtype)
218
+
219
+ lons0 = longitude[:-1]
220
+ lons1 = longitude[1:]
221
+ lats0 = latitude[:-1]
222
+ lats1 = latitude[1:]
223
+
224
+ az[:-1] = azimuth(lons0, lats0, lons1, lats1)
225
+ az[-1] = np.nan
226
+ return az
227
+
228
+
229
+ def longitudinal_angle(
230
+ lons0: npt.NDArray[np.floating],
231
+ lats0: npt.NDArray[np.floating],
232
+ lons1: npt.NDArray[np.floating],
233
+ lats1: npt.NDArray[np.floating],
234
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
235
+ r"""Calculate angle with longitudinal axis for sequence of segments.
236
+
237
+ Parameters
238
+ ----------
239
+ lons0 : npt.NDArray[np.floating]
240
+ Longitude values of initial endpoints, [:math:`\deg`].
241
+ lats0 : npt.NDArray[np.floating]
242
+ Latitude values of initial endpoints, [:math:`\deg`].
243
+ lons1 : npt.NDArray[np.floating]
244
+ Longitude values of terminal endpoints, [:math:`\deg`].
245
+ lats1 : npt.NDArray[np.floating]
246
+ Latitude values of terminal endpoints, [:math:`\deg`].
247
+
248
+ References
249
+ ----------
250
+ - :cite:`wikipediacontributorsAzimuth2023`
251
+
252
+ Returns
253
+ -------
254
+ sin_a : npt.NDArray[np.floating]
255
+ Sine values.
256
+ cos_a : npt.NDArray[np.floating]
257
+ Cosine values.
258
+ """
259
+ lons0 = units.degrees_to_radians(lons0)
260
+ lons1 = units.degrees_to_radians(lons1)
261
+ lats0 = units.degrees_to_radians(lats0)
262
+ lats1 = units.degrees_to_radians(lats1)
263
+ d_lon = lons1 - lons0
264
+
265
+ num = np.sin(d_lon)
266
+ denom = np.cos(lats0) * np.tan(lats1) - np.sin(lats0) * np.cos(d_lon)
267
+ mag = np.sqrt(num**2 + denom**2)
268
+
269
+ where = mag > 0.0
270
+ out = np.full_like(mag, np.nan)
271
+
272
+ sin_a = np.divide(denom, mag, out=out.copy(), where=where)
273
+ cos_a = np.divide(num, mag, out=out.copy(), where=where)
274
+ return sin_a, cos_a
275
+
276
+
277
+ def segment_angle(
278
+ longitude: npt.NDArray[np.floating], latitude: npt.NDArray[np.floating]
279
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
280
+ r"""Calculate the angle between coordinate segments and the longitudinal axis.
281
+
282
+ `np.nan` is added to the final value so the length of the output is the same as the inputs.
283
+
284
+ Parameters
285
+ ----------
286
+ longitude : npt.NDArray[np.floating]
287
+ Longitude values, [:math:`\deg`]
288
+ latitude : npt.NDArray[np.floating]
289
+ Latitude values, [:math:`\deg`]
290
+
291
+ Returns
292
+ -------
293
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
294
+ sin(a), cos(a), where ``a`` is the angle between the segment and the longitudinal axis.
295
+ Final entry of each array is set to `np.nan`.
296
+
297
+ References
298
+ ----------
299
+ - :cite:`wikipediacontributorsAzimuth2023`
300
+
301
+ Notes
302
+ -----
303
+ ::
304
+
305
+ (lon_2, lat_2) X
306
+ /|
307
+ / |
308
+ / |
309
+ / |
310
+ / |
311
+ / |
312
+ / |
313
+ (lon_1, lat_1) X -------> longitude (x-axis)
314
+
315
+ See Also
316
+ --------
317
+ :func:`longitudinal_angle`
318
+ """
319
+ dtype = np.result_type(longitude, latitude, np.float32)
320
+ sin_a = np.empty(longitude.size, dtype=dtype)
321
+ cos_a = np.empty(longitude.size, dtype=dtype)
322
+
323
+ lons0 = longitude[:-1]
324
+ lons1 = longitude[1:]
325
+ lats0 = latitude[:-1]
326
+ lats1 = latitude[1:]
327
+
328
+ sin_a[:-1], cos_a[:-1] = longitudinal_angle(lons0, lats0, lons1, lats1)
329
+ sin_a[-1] = np.nan
330
+ cos_a[-1] = np.nan
331
+ return sin_a, cos_a
332
+
333
+
334
+ def segment_length(
335
+ longitude: npt.NDArray[np.floating],
336
+ latitude: npt.NDArray[np.floating],
337
+ altitude: npt.NDArray[np.floating],
338
+ ) -> npt.NDArray[np.floating]:
339
+ r"""Calculate the segment length between coordinates by assuming a great circle distance.
340
+
341
+ Requires coordinates to be in EPSG:4326.
342
+ Lengths are calculated using both horizontal and vertical displacement of segments.
343
+
344
+ `np.nan` is added to the final value so the length of the output is the same as the inputs.
345
+
346
+ Parameters
347
+ ----------
348
+ longitude : npt.NDArray[np.floating]
349
+ Longitude values, [:math:`\deg`]
350
+ latitude : npt.NDArray[np.floating]
351
+ Latitude values, [:math:`\deg`]
352
+ altitude : npt.NDArray[np.floating]
353
+ Altitude values, [:math:`m`]
354
+
355
+ Returns
356
+ -------
357
+ npt.NDArray[np.floating]
358
+ Array of distances in [:math:`m`] between coordinates.
359
+ Final entry of each array is set to `np.nan`.
360
+
361
+ See Also
362
+ --------
363
+ :func:`haversine`
364
+ :func:`segment_haversine`
365
+ """
366
+ dist_horizontal = segment_haversine(longitude, latitude)
367
+ dist_vertical = np.empty_like(altitude)
368
+ dist_vertical[:-1] = np.diff(altitude)
369
+ dist_vertical[-1] = np.nan # last segment is set to nan
370
+ return (dist_horizontal**2 + dist_vertical**2) ** 0.5
371
+
372
+
373
+ def forward_azimuth(
374
+ lons: npt.NDArray[np.floating],
375
+ lats: npt.NDArray[np.floating],
376
+ az: ArrayOrFloat,
377
+ dist: ArrayOrFloat,
378
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
379
+ r"""Calculate coordinates along forward azimuth.
380
+
381
+ This function is identical to the `pyproj.Geod.fwd` method when working on
382
+ a spherical earth. Both signatures are also identical. This implementation
383
+ is generally more performant.
384
+
385
+ Parameters
386
+ ----------
387
+ lons : npt.NDArray[np.floating]
388
+ Array of longitude values.
389
+ lats : npt.NDArray[np.floating]
390
+ Array of latitude values.
391
+ az : npt.NDArray[np.floating] | float
392
+ Azimuth, measured in [:math:`\deg`].
393
+ dist : npt.NDArray[np.floating] | float
394
+ Distance [:math:`m`] between initial longitude latitude values and
395
+ point to be computed.
396
+
397
+ Returns
398
+ -------
399
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
400
+ Tuple of longitude latitude arrays.
401
+
402
+ See Also
403
+ --------
404
+ :meth:pyproj.Geod.fwd
405
+ """
406
+ az_rad = units.degrees_to_radians(az)
407
+ sin_az = np.sin(az_rad)
408
+ cos_az = np.cos(az_rad)
409
+
410
+ lats_rad = units.degrees_to_radians(lats)
411
+ sin_lats = np.sin(lats_rad)
412
+ cos_lats = np.cos(lats_rad)
413
+
414
+ dist_ratio = dist / constants.radius_earth
415
+ cos_dist_ratio = np.cos(dist_ratio)
416
+ sin_dist_ratio = np.sin(dist_ratio)
417
+
418
+ dest_lats_rad = np.arcsin(sin_lats * cos_dist_ratio + cos_lats * sin_dist_ratio * cos_az)
419
+ dest_lats = units.radians_to_degrees(dest_lats_rad)
420
+
421
+ delta_lons_rad = np.arctan2(
422
+ sin_az * sin_dist_ratio * cos_lats,
423
+ cos_dist_ratio - sin_lats * np.sin(dest_lats_rad),
424
+ )
425
+ dest_lons = lons + units.radians_to_degrees(delta_lons_rad)
426
+ dest_lons = (dest_lons + 180.0) % 360.0 - 180.0
427
+
428
+ return dest_lons, dest_lats
429
+
430
+
431
+ # ---------------
432
+ # Solar Radiation
433
+ # ---------------
434
+
435
+
436
+ def solar_direct_radiation(
437
+ longitude: ArrayLike, latitude: ArrayLike, time: ArrayLike, threshold_cos_sza: float = 0.0
438
+ ) -> np.ndarray:
439
+ r"""Calculate the instantaneous theoretical solar direct radiation (SDR).
440
+
441
+ Parameters
442
+ ----------
443
+ longitude : ArrayLike
444
+ Longitude, [:math:`\deg`]
445
+ latitude : ArrayLike
446
+ Latitude, [:math:`\deg`]
447
+ time : ArrayLike
448
+ Time, formatted as :class:`np.datetime64`
449
+ threshold_cos_sza : float, optional
450
+ Set the SDR to 0 when the :func:`cosine_solar_zenith_angle` is below a certain value.
451
+ By default, set to 0.
452
+
453
+ Returns
454
+ -------
455
+ ArrayLike
456
+ Solar direct radiation of incoming radiation, [:math:`W m^{-2}`]
457
+
458
+ References
459
+ ----------
460
+ - :cite:`uosolarradiationmonitoringlaboratoryUOSRMLSolar2022`
461
+ """
462
+ theta_rad = orbital_position(time)
463
+
464
+ # Use longitude and latitude to determine the dtype
465
+ dtype = np.result_type(longitude, latitude)
466
+ theta_rad = theta_rad.astype(dtype, copy=False)
467
+
468
+ _solar_constant = solar_constant(theta_rad)
469
+ cos_sza = cosine_solar_zenith_angle(longitude, latitude, time, theta_rad)
470
+
471
+ # Note that np.where is more performant than xr.where, even for large arrays
472
+ # (and especially for small arrays).
473
+ # BUT xr.where is "safer" in the sense that it will pass numpy arrays through as if
474
+ # they were pumped directly through np.where.
475
+ # For now, explicitly check if we're work with xarray instances or numpy arrays
476
+ # This will likely not work for native python numeric types
477
+ if isinstance(cos_sza, xr.DataArray):
478
+ return xr.where(cos_sza < threshold_cos_sza, 0.0, cos_sza * _solar_constant)
479
+ return np.where(cos_sza < threshold_cos_sza, 0.0, cos_sza * _solar_constant)
480
+
481
+
482
+ def solar_constant(theta_rad: ArrayLike) -> ArrayLike:
483
+ """Calculate the solar electromagnetic radiation per unit area from orbital position.
484
+
485
+ On average, the extraterrestrial irradiance is 1367 W/m**2
486
+ and varies by +- 3% as the Earth orbits the sun.
487
+
488
+ Parameters
489
+ ----------
490
+ theta_rad : ArrayLike
491
+ Orbital position, [:math:`rad`]. Use :func:`orbital_position` to calculate
492
+ the orbital position from time input.
493
+
494
+ Returns
495
+ -------
496
+ ArrayLike
497
+ Solar constant, [:math:`W m^{-2}`]
498
+
499
+ References
500
+ ----------
501
+ - :cite:`uosolarradiationmonitoringlaboratoryUOSRMLSolar2022`
502
+ - :cite:`paltridgeRadiativeProcessesMeteorology1976`
503
+ - :cite:`duffieSolarEngineeringThermal1991`
504
+
505
+ Notes
506
+ -----
507
+ :math:`orbital_effect = (R_{av} / R)^{2}`
508
+ where :math:`R` is the separation of Earth from the sun
509
+ and :math:`R_{av}` is the mean separation.
510
+ """
511
+ orbital_effect = (
512
+ 1.00011
513
+ + (0.034221 * np.cos(theta_rad))
514
+ + (0.001280 * np.sin(theta_rad))
515
+ + (0.000719 * np.cos(theta_rad * 2))
516
+ + (0.000077 * np.sin(theta_rad * 2))
517
+ )
518
+
519
+ return constants.solar_constant * orbital_effect # type: ignore[return-value]
520
+
521
+
522
+ def cosine_solar_zenith_angle(
523
+ longitude: ArrayLike,
524
+ latitude: ArrayLike,
525
+ time: ArrayLike,
526
+ theta_rad: ArrayLike,
527
+ ) -> ArrayLike:
528
+ r"""Calculate the cosine of the solar zenith angle.
529
+
530
+ Return (:math:`\cos(\theta)`), where :math:`\theta` is the angle between the sun and the
531
+ vertical direction.
532
+
533
+ Parameters
534
+ ----------
535
+ longitude : ArrayLike
536
+ Longitude, [:math:`\deg`]
537
+ latitude : ArrayLike
538
+ Latitude, [:math:`\deg`]
539
+ time : ArrayLike
540
+ Time, formatted as :class:`np.datetime64`
541
+ theta_rad : ArrayLike
542
+ Orbital position, [:math:`rad`]. Output of :func:`orbital_position`.
543
+
544
+ Returns
545
+ -------
546
+ ArrayLike
547
+ Cosine of the solar zenith angle
548
+
549
+ References
550
+ ----------
551
+ - :cite:`wikipediacontributorsSolarZenithAngle2023`
552
+
553
+ See Also
554
+ --------
555
+ :func:`orbital_position`
556
+ :func:`solar_declination_angle`
557
+ :func:`solar_hour_angle`
558
+ """
559
+ lat_rad = units.degrees_to_radians(latitude)
560
+ sdec_rad = units.degrees_to_radians(solar_declination_angle(theta_rad))
561
+ sha_rad = units.degrees_to_radians(solar_hour_angle(longitude, time, theta_rad))
562
+
563
+ return np.sin(lat_rad) * np.sin(sdec_rad) + (
564
+ np.cos(lat_rad) * np.cos(sdec_rad) * np.cos(sha_rad)
565
+ )
566
+
567
+
568
+ def orbital_position(time: ArrayLike) -> ArrayLike:
569
+ """Calculate the orbital position of Earth to a reference point set at the start of year.
570
+
571
+ Parameters
572
+ ----------
573
+ time : ArrayLike
574
+ ArrayLike of :class:`np.datetime64` times
575
+
576
+ Returns
577
+ -------
578
+ ArrayLike
579
+ Orbital position of Earth, [:math:`rad`]
580
+ """
581
+ dt_day = days_since_reference_year(time)
582
+ theta = 360.0 * (dt_day / 365.25)
583
+ return units.degrees_to_radians(theta)
584
+
585
+
586
+ def days_since_reference_year(time: ArrayLike, ref_year: int = 2000) -> ArrayLike:
587
+ """Calculate the days elapsed since the start of the reference year.
588
+
589
+ Parameters
590
+ ----------
591
+ time : ArrayLike
592
+ ArrayLike of :class:`np.datetime64` times
593
+ ref_year : int, optional
594
+ Year of reference
595
+
596
+ Returns
597
+ -------
598
+ ArrayLike
599
+ Days elapsed since the reference year. Output ``dtype`` is ``np.float64``.
600
+
601
+ Raises
602
+ ------
603
+ RuntimeError
604
+ Raises when reference year is greater than the time of `time` element
605
+ """
606
+ date_start = np.datetime64(ref_year - 1970, "Y")
607
+ dt_day = (time - date_start) / np.timedelta64(1, "D")
608
+
609
+ if np.any(dt_day < 0.0):
610
+ raise RuntimeError(
611
+ f"Reference year {ref_year} is greater than the time of one or more waypoints."
612
+ )
613
+
614
+ return dt_day
615
+
616
+
617
+ def hours_since_start_of_day(time: ArrayLike) -> ArrayLike:
618
+ """Calculate the hours elapsed since the start of day (00:00:00 UTC).
619
+
620
+ Parameters
621
+ ----------
622
+ time : ArrayLike
623
+ ArrayLike of :class:`np.datetime64` times
624
+
625
+ Returns
626
+ -------
627
+ ArrayLike
628
+ Hours elapsed since the start of today day. Output ``dtype`` is ``np.float64``.
629
+ """
630
+ return (time - time.astype("datetime64[D]")) / np.timedelta64(1, "h")
631
+
632
+
633
+ def solar_declination_angle(theta_rad: ArrayLike) -> ArrayLike:
634
+ r"""Calculate the solar declination angle from the orbital position in radians (theta_rad).
635
+
636
+ The solar declination angle is the angle between the rays of the Sun and the plane of the
637
+ Earth's equator.
638
+
639
+ It has a range of between -23.5 (winter solstice) and +23.5 (summer solstice) degrees.
640
+
641
+ Parameters
642
+ ----------
643
+ theta_rad : ArrayLike
644
+ Orbital position, [:math:`rad`]. Output of :func:`orbital_position`.
645
+
646
+ Returns
647
+ -------
648
+ ArrayLike
649
+ Solar declination angle, [:math:`\deg`]
650
+
651
+ References
652
+ ----------
653
+ - :cite:`paltridgeRadiativeProcessesMeteorology1976`
654
+
655
+ Notes
656
+ -----
657
+ Tested against :cite:`noaaSolarCalculationDetails`
658
+
659
+ See Also
660
+ --------
661
+ :func:`orbital_position`
662
+ :func:`cosine_solar_zenith_angle`
663
+ """
664
+ return (
665
+ 0.396372 # type: ignore[return-value]
666
+ - (22.91327 * np.cos(theta_rad))
667
+ + (4.02543 * np.sin(theta_rad))
668
+ - (0.387205 * np.cos(2 * theta_rad))
669
+ + (0.051967 * np.sin(2 * theta_rad))
670
+ - (0.154527 * np.cos(3 * theta_rad))
671
+ + (0.084798 * np.sin(3 * theta_rad))
672
+ )
673
+
674
+
675
+ def solar_hour_angle(longitude: ArrayLike, time: ArrayLike, theta_rad: ArrayLike) -> ArrayLike:
676
+ r"""Calculate the sun's East to West angular displacement around the polar axis.
677
+
678
+ The solar hour angle is an expression of time in angular measurements:
679
+ the value of the hour angle is zero at noon,
680
+ negative in the morning, and positive in the afternoon, increasing by 15 degrees per hour.
681
+
682
+ Parameters
683
+ ----------
684
+ longitude : ArrayLike
685
+ Longitude, [:math:`\deg`]
686
+ time : ArrayLike
687
+ ArrayLike of :class:`np.datetime64` times
688
+ theta_rad : ArrayLike
689
+ Orbital position, [:math:`rad`]. Output of :func:`orbital_position`.
690
+
691
+ Returns
692
+ -------
693
+ ArrayLike
694
+ Solar hour angle, [:math:`\deg`]
695
+
696
+ See Also
697
+ --------
698
+ :func:`orbital_position`
699
+ :func:`cosine_solar_zenith_angle`
700
+ :func:`orbital_correction_for_solar_hour_angle`
701
+ """
702
+ # Let the two float-like arrays dictate the dtype of the time conversion
703
+ dtype = np.result_type(longitude, theta_rad)
704
+ dt_hour = hours_since_start_of_day(time).astype(dtype)
705
+
706
+ orbital_correction = orbital_correction_for_solar_hour_angle(theta_rad)
707
+ return ((dt_hour - 12) * 15) + longitude + orbital_correction
708
+
709
+
710
+ def orbital_correction_for_solar_hour_angle(theta_rad: ArrayLike) -> ArrayLike:
711
+ r"""Calculate correction to the solar hour angle due to Earth's orbital location.
712
+
713
+ Parameters
714
+ ----------
715
+ theta_rad : ArrayLike
716
+ Orbital position, [:math:`rad`]
717
+
718
+ Returns
719
+ -------
720
+ ArrayLike
721
+ Correction to the solar hour angle as a result of Earth's orbital location, [:math:`\deg`]
722
+
723
+ References
724
+ ----------
725
+ - :cite:`paltridgeRadiativeProcessesMeteorology1976`
726
+
727
+ Notes
728
+ -----
729
+ Tested against :cite:`noaaSolarCalculationDetails`
730
+ """
731
+ return (
732
+ 0.004297 # type: ignore[return-value]
733
+ + (0.107029 * np.cos(theta_rad))
734
+ - (1.837877 * np.sin(theta_rad))
735
+ - (0.837378 * np.cos(2 * theta_rad))
736
+ - (2.340475 * np.sin(2 * theta_rad))
737
+ )
738
+
739
+
740
+ # ---------
741
+ # Advection
742
+ # ---------
743
+
744
+
745
+ def advect_longitude(
746
+ longitude: ArrayLike,
747
+ latitude: ArrayLike,
748
+ u_wind: ArrayLike,
749
+ dt: npt.NDArray[np.timedelta64] | np.timedelta64,
750
+ ) -> ArrayLike:
751
+ r"""Calculate the longitude of a particle after time `dt` caused by advection due to wind.
752
+
753
+ Automatically wrap over the antimeridian if necessary.
754
+
755
+ Parameters
756
+ ----------
757
+ longitude : ArrayLike
758
+ Original longitude, [:math:`\deg`]
759
+ latitude : ArrayLike
760
+ Original latitude, [:math:`\deg`]
761
+ u_wind : ArrayLike
762
+ Wind speed in the longitudinal direction, [:math:`m s^{-1}`]
763
+ dt : np.ndarray
764
+ Advection timestep
765
+
766
+ Returns
767
+ -------
768
+ ArrayLike
769
+ New longitude value, [:math:`\deg`]
770
+ """
771
+ # Use the same dtype as longitude, latitude, and u_wind
772
+ dtype = np.result_type(longitude, latitude, u_wind)
773
+ dt_s = units.dt_to_seconds(dt, dtype)
774
+
775
+ distance_m = u_wind * dt_s
776
+
777
+ new_longitude = longitude + units.m_to_longitude_distance(distance_m, latitude)
778
+ return (new_longitude + 180.0) % 360.0 - 180.0 # wrap antimeridian
779
+
780
+
781
+ def advect_latitude(
782
+ latitude: ArrayLike,
783
+ v_wind: ArrayLike,
784
+ dt: npt.NDArray[np.timedelta64] | np.timedelta64,
785
+ ) -> ArrayLike:
786
+ r"""Calculate the latitude of a particle after time ``dt`` caused by advection due to wind.
787
+
788
+ .. note::
789
+
790
+ It is possible for advected latitude values to lie outside of the WGS84 domain
791
+ ``[-90, 90]``. In :class:`Cocip` models, latitude values close to the poles
792
+ create an end of life condition, thereby avoiding this issue. In practice,
793
+ such situations are very rare.
794
+
795
+ These polar divergence issues could also be addressed by reflecting the
796
+ longitude values 180 degrees via a spherical equivalence such as
797
+ ``(lon, lat) ~ (lon + 180, 180 - lat)``. This approach is not currently taken.
798
+
799
+ Parameters
800
+ ----------
801
+ latitude : ArrayLike
802
+ Original latitude, [:math:`\deg`]
803
+ v_wind : ArrayLike
804
+ Wind speed in the latitudinal direction, [:math:`m s^{-1}`]
805
+ dt : np.ndarray
806
+ Advection time delta
807
+
808
+ Returns
809
+ -------
810
+ ArrayLike
811
+ New latitude value, [:math:`\deg`]
812
+ """
813
+ # Use the same dtype as latitude and v_wind
814
+ dtype = np.result_type(latitude, v_wind)
815
+ dt_s = units.dt_to_seconds(dt, dtype)
816
+
817
+ distance_m = v_wind * dt_s
818
+
819
+ return latitude + units.m_to_latitude_distance(distance_m)
820
+
821
+
822
+ def advect_level(
823
+ level: ArrayLike,
824
+ vertical_velocity: ArrayLike,
825
+ rho_air: ArrayLike | float,
826
+ terminal_fall_speed: ArrayLike | float,
827
+ dt: npt.NDArray[np.timedelta64] | np.timedelta64,
828
+ ) -> ArrayLike:
829
+ r"""Calculate the pressure level of a particle after time ``dt``.
830
+
831
+ This function calculates the new pressure level of a particle as a result of
832
+ vertical advection caused by the vertical velocity and terminal fall speed.
833
+
834
+ Parameters
835
+ ----------
836
+ level : ArrayLike
837
+ Pressure level, [:math:`hPa`]
838
+ vertical_velocity : ArrayLike
839
+ Vertical velocity, [:math:`Pa s^{-1}`]
840
+ rho_air : ArrayLike | float
841
+ Air density, [:math:`kg m^{-3}`]
842
+ terminal_fall_speed : ArrayLike | float
843
+ Terminal fall speed of the particle, [:math:`m s^{-1}`]
844
+ dt : npt.NDArray[np.timedelta64] | np.timedelta64
845
+ Time delta for each waypoint
846
+
847
+ Returns
848
+ -------
849
+ ArrayLike
850
+ New pressure level, [:math:`hPa`]
851
+ """
852
+ dt_s = units.dt_to_seconds(dt, level.dtype)
853
+ dp_dt = vertical_velocity + rho_air * terminal_fall_speed * constants.g
854
+
855
+ return (level * 100.0 + (dt_s * dp_dt)) / 100.0
856
+
857
+
858
+ def advect_longitude_and_latitude_near_poles(
859
+ longitude: npt.NDArray[np.floating],
860
+ latitude: npt.NDArray[np.floating],
861
+ u_wind: npt.NDArray[np.floating],
862
+ v_wind: npt.NDArray[np.floating],
863
+ dt: npt.NDArray[np.timedelta64] | np.timedelta64,
864
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
865
+ r"""Advect a particle near the poles.
866
+
867
+ This function calculates the longitude and latitude of a particle after time ``dt``
868
+ caused by advection due to wind near the poles (above 80 degrees North and South).
869
+
870
+ Automatically wrap over the antimeridian if necessary.
871
+
872
+ Parameters
873
+ ----------
874
+ longitude : npt.NDArray[np.floating]
875
+ Original longitude, [:math:`\deg`]
876
+ latitude : npt.NDArray[np.floating]
877
+ Original latitude, [:math:`\deg`]
878
+ u_wind : npt.NDArray[np.floating]
879
+ Wind speed in the longitudinal direction, [:math:`m s^{-1}`]
880
+ v_wind : npt.NDArray[np.floating]
881
+ Wind speed in the latitudinal direction, [:math:`m s^{-1}`]
882
+ dt : npt.NDArray[np.timedelta64] | np.timedelta64
883
+ Advection timestep
884
+
885
+ Returns
886
+ -------
887
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
888
+ New longitude and latitude values, [:math:`\deg`]
889
+
890
+ Notes
891
+ -----
892
+ Near the poles, the longitude and latitude is converted to a 2-D Cartesian-like coordinate
893
+ system to avoid numerical instabilities and singularities caused by convergence of meridians.
894
+
895
+ See Also
896
+ --------
897
+ advect_longitude
898
+ advect_latitude
899
+ advect_horizontal
900
+ """
901
+ # Determine hemisphere sign (1 for Northern Hemisphere, -1 for Southern Hemisphere)
902
+ hemisphere_sign = np.where(latitude > 0.0, 1.0, -1.0)
903
+
904
+ # Convert longitude and latitude to radians
905
+ sin_lon_rad = np.sin(units.degrees_to_radians(longitude))
906
+ cos_lon_rad = np.cos(units.degrees_to_radians(longitude))
907
+
908
+ # Convert longitude and latitude to 2-D Cartesian-like coordinate system, [:math:`\deg`]
909
+ polar_radius = 90.0 - np.abs(latitude)
910
+ x_cartesian = sin_lon_rad * polar_radius
911
+ y_cartesian = -cos_lon_rad * polar_radius * hemisphere_sign
912
+
913
+ # Convert winds from eastward and northward direction (u, v) to (X, Y), [:math:`\deg s^{-1}`]
914
+ x_wind = units.radians_to_degrees(
915
+ (u_wind * cos_lon_rad - v_wind * sin_lon_rad * hemisphere_sign) / constants.radius_earth
916
+ )
917
+ y_wind = units.radians_to_degrees(
918
+ (u_wind * sin_lon_rad * hemisphere_sign + v_wind * cos_lon_rad) / constants.radius_earth
919
+ )
920
+
921
+ # Advect contrails in 2-D Cartesian-like plane, [:math:`\deg`]
922
+ dtype = np.result_type(latitude, v_wind)
923
+ dt_s = units.dt_to_seconds(dt, dtype)
924
+ x_cartesian_new = x_cartesian + dt_s * x_wind
925
+ y_cartesian_new = y_cartesian + dt_s * y_wind
926
+
927
+ # Convert `y_cartesian_new` back to `latitude`, [:math:`\deg`]
928
+ dist_squared = x_cartesian_new**2 + y_cartesian_new**2
929
+ new_latitude = (90.0 - np.sqrt(dist_squared)) * hemisphere_sign
930
+
931
+ # Convert `x_cartesian_new` back to `longitude`, [:math:`\deg`]
932
+ new_lon_rad = np.arctan2(y_cartesian_new, x_cartesian_new)
933
+
934
+ new_longitude = np.where(
935
+ (x_wind == 0.0) & (y_wind == 0.0),
936
+ longitude,
937
+ 90.0 + units.radians_to_degrees(new_lon_rad) * hemisphere_sign,
938
+ )
939
+ # new_longitude = 90.0 + units.radians_to_degrees(new_lon_rad) * hemisphere_sign
940
+ new_longitude = (new_longitude + 180.0) % 360.0 - 180.0 # wrap antimeridian
941
+ return new_longitude, new_latitude
942
+
943
+
944
+ def advect_horizontal(
945
+ longitude: npt.NDArray[np.floating],
946
+ latitude: npt.NDArray[np.floating],
947
+ u_wind: npt.NDArray[np.floating],
948
+ v_wind: npt.NDArray[np.floating],
949
+ dt: npt.NDArray[np.timedelta64] | np.timedelta64,
950
+ ) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
951
+ r"""Advect a particle in the horizontal plane.
952
+
953
+ This function calls :func:`advect_longitude` and :func:`advect_latitude` when
954
+ the position is far from the poles (<= 80.0 degrees). When the position is near
955
+ the poles (> 80.0 degrees), :func:`advect_longitude_and_latitude_near_poles`
956
+ is used instead.
957
+
958
+ Parameters
959
+ ----------
960
+ longitude : npt.NDArray[np.floating]
961
+ Original longitude, [:math:`\deg`]
962
+ latitude : npt.NDArray[np.floating]
963
+ Original latitude, [:math:`\deg`]
964
+ u_wind : npt.NDArray[np.floating]
965
+ Wind speed in the longitudinal direction, [:math:`m s^{-1}`]
966
+ v_wind : npt.NDArray[np.floating]
967
+ Wind speed in the latitudinal direction, [:math:`m s^{-1}`]
968
+ dt : npt.NDArray[np.timedelta64] | np.timedelta64
969
+ Advection timestep
970
+
971
+ Returns
972
+ -------
973
+ tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
974
+ New longitude and latitude values, [:math:`\deg`]
975
+ """
976
+ near_poles = np.abs(latitude) > 80.0
977
+
978
+ longitude_out = np.empty_like(longitude)
979
+ latitude_out = np.empty_like(latitude)
980
+
981
+ # Use simple spherical advection if position is far from the poles (<= 80.0 degrees)
982
+ cond = ~near_poles
983
+ lon_cond = longitude[cond]
984
+ lat_cond = latitude[cond]
985
+ u_wind_cond = u_wind[cond]
986
+ v_wind_cond = v_wind[cond]
987
+ dt_cond = dt if isinstance(dt, np.timedelta64) else dt[cond]
988
+ longitude_out[cond] = advect_longitude(lon_cond, lat_cond, u_wind_cond, dt_cond)
989
+ latitude_out[cond] = advect_latitude(lat_cond, v_wind_cond, dt_cond)
990
+
991
+ # And use Cartesian-like advection if position is near the poles (> 80.0 degrees)
992
+ cond = near_poles
993
+ lon_cond = longitude[cond]
994
+ lat_cond = latitude[cond]
995
+ u_wind_cond = u_wind[cond]
996
+ v_wind_cond = v_wind[cond]
997
+ dt_cond = dt if isinstance(dt, np.timedelta64) else dt[cond]
998
+ lon_out_cond, lat_out_cond = advect_longitude_and_latitude_near_poles(
999
+ lon_cond, lat_cond, u_wind_cond, v_wind_cond, dt_cond
1000
+ )
1001
+ longitude_out[cond] = lon_out_cond
1002
+ latitude_out[cond] = lat_out_cond
1003
+
1004
+ return longitude_out, latitude_out
1005
+
1006
+
1007
+ # ---------------
1008
+ # Grid properties
1009
+ # ---------------
1010
+
1011
+
1012
+ def spatial_bounding_box(
1013
+ longitude: npt.NDArray[np.floating], latitude: npt.NDArray[np.floating], buffer: float = 1.0
1014
+ ) -> tuple[float, float, float, float]:
1015
+ r"""
1016
+ Construct rectangular spatial bounding box from a set of waypoints.
1017
+
1018
+ Parameters
1019
+ ----------
1020
+ longitude : np.ndarray
1021
+ 1D Longitude values with index corresponding to longitude inputs, [:math:`\deg`]
1022
+ latitude : np.ndarray
1023
+ 1D Latitude values with index corresponding to latitude inputs, [:math:`\deg`]
1024
+ buffer: float
1025
+ Add buffer to rectangular spatial bounding box, [:math:`\deg`]
1026
+
1027
+ Returns
1028
+ -------
1029
+ tuple[float, float, float, float]
1030
+ Spatial bounding box, ``(lon_min, lat_min, lon_max, lat_max)``, [:math:`\deg`]
1031
+
1032
+ Examples
1033
+ --------
1034
+ >>> rng = np.random.default_rng(654321)
1035
+ >>> lon = rng.uniform(-180, 180, size=30)
1036
+ >>> lat = rng.uniform(-90, 90, size=30)
1037
+ >>> spatial_bounding_box(lon, lat)
1038
+ (np.float64(-168.0), np.float64(-77.0), np.float64(155.0), np.float64(82.0))
1039
+ """
1040
+ lon_min = max(np.floor(np.min(longitude) - buffer), -180.0)
1041
+ lon_max = min(np.ceil(np.max(longitude) + buffer), 179.99)
1042
+ lat_min = max(np.floor(np.min(latitude) - buffer), -90.0)
1043
+ lat_max = min(np.ceil(np.max(latitude) + buffer), 90.0)
1044
+ return lon_min, lat_min, lon_max, lat_max
1045
+
1046
+
1047
+ def domain_surface_area(
1048
+ spatial_bbox: tuple[float, float, float, float] = (-180.0, -90.0, 180.0, 90.0),
1049
+ spatial_grid_res: float = 0.5,
1050
+ ) -> float:
1051
+ r"""
1052
+ Calculate surface area in the provided spatial bounding box.
1053
+
1054
+ Parameters
1055
+ ----------
1056
+ spatial_bbox : tuple[float, float, float, float]
1057
+ Spatial bounding box, ``(lon_min, lat_min, lon_max, lat_max)``, [:math:`\deg`]
1058
+ spatial_grid_res : float
1059
+ Spatial grid resolution, [:math:`\deg`]
1060
+
1061
+ Returns
1062
+ -------
1063
+ float
1064
+ Domain surface area, [:math:`m^{2}`]
1065
+ """
1066
+ assert spatial_grid_res > 0.01
1067
+ west, south, east, north = spatial_bbox
1068
+ longitude = np.arange(west, east + 0.01, spatial_grid_res)
1069
+ latitude = np.arange(south, north + 0.01, spatial_grid_res)
1070
+
1071
+ da_surface_area = grid_surface_area(longitude, latitude)
1072
+ return np.nansum(da_surface_area)
1073
+
1074
+
1075
+ def grid_surface_area(
1076
+ longitude: npt.NDArray[np.floating], latitude: npt.NDArray[np.floating]
1077
+ ) -> xr.DataArray:
1078
+ r"""
1079
+ Calculate surface area that is covered by each pixel in a longitude-latitude grid.
1080
+
1081
+ Parameters
1082
+ ----------
1083
+ longitude: npt.NDArray[np.floating]
1084
+ Longitude coordinates in a longitude-latitude grid, [:math:`\deg`].
1085
+ Must be in ascending order.
1086
+ latitude: npt.NDArray[np.floating]
1087
+ Latitude coordinates in a longitude-latitude grid, [:math:`\deg`].
1088
+ Must be in ascending order.
1089
+
1090
+ Returns
1091
+ -------
1092
+ xr.DataArray
1093
+ Surface area of each pixel in a longitude-latitude grid, [:math:`m^{2}`]
1094
+
1095
+ References
1096
+ ----------
1097
+ - https://www.pmel.noaa.gov/maillists/tmap/ferret_users/fu_2004/msg00023.html
1098
+ """
1099
+ # Ensure that grid spacing is uniform
1100
+ d_lon = np.diff(longitude)
1101
+ d_lon0 = d_lon[0]
1102
+ if np.any(d_lon != d_lon0):
1103
+ raise ValueError("Longitude grid spacing is not uniform.")
1104
+
1105
+ d_lat = np.diff(latitude)
1106
+ d_lat0 = d_lat[0]
1107
+ if np.all(d_lat != d_lat[0]):
1108
+ raise ValueError("Latitude grid spacing is not uniform.")
1109
+
1110
+ _, lat_2d = np.meshgrid(longitude, latitude)
1111
+
1112
+ area_lat_btm = _area_between_latitude_and_north_pole(lat_2d - d_lat0)
1113
+ area_lat_top = _area_between_latitude_and_north_pole(lat_2d)
1114
+
1115
+ area = (d_lon0 / 360.0) * (area_lat_btm - area_lat_top)
1116
+ area[area < 0.0] = np.nan # Prevent negative values at -90 degree latitude slice
1117
+
1118
+ return xr.DataArray(area.T, coords={"longitude": longitude, "latitude": latitude})
1119
+
1120
+
1121
+ def _area_between_latitude_and_north_pole(
1122
+ latitude: npt.NDArray[np.floating],
1123
+ ) -> npt.NDArray[np.floating]:
1124
+ r"""
1125
+ Calculate surface area from the provided latitude to the North Pole.
1126
+
1127
+ Parameters
1128
+ ----------
1129
+ latitude: npt.NDArray[np.floating]
1130
+ 1D Latitude values with index corresponding to latitude inputs, [:math:`\deg`]
1131
+
1132
+ Returns
1133
+ -------
1134
+ npt.NDArray[np.floating]
1135
+ Surface area from latitude to North Pole, [:math:`m^{2}`]
1136
+ """
1137
+ lat_radians = units.degrees_to_radians(latitude)
1138
+ return 2.0 * np.pi * constants.radius_earth**2 * (1.0 - np.sin(lat_radians))