pycontrails 0.59.0__cp314-cp314-macosx_10_15_x86_64.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.
- pycontrails/__init__.py +70 -0
- pycontrails/_version.py +34 -0
- pycontrails/core/__init__.py +30 -0
- pycontrails/core/aircraft_performance.py +679 -0
- pycontrails/core/airports.py +228 -0
- pycontrails/core/cache.py +889 -0
- pycontrails/core/coordinates.py +174 -0
- pycontrails/core/fleet.py +483 -0
- pycontrails/core/flight.py +2185 -0
- pycontrails/core/flightplan.py +228 -0
- pycontrails/core/fuel.py +140 -0
- pycontrails/core/interpolation.py +702 -0
- pycontrails/core/met.py +2936 -0
- pycontrails/core/met_var.py +387 -0
- pycontrails/core/models.py +1321 -0
- pycontrails/core/polygon.py +549 -0
- pycontrails/core/rgi_cython.cpython-314-darwin.so +0 -0
- pycontrails/core/vector.py +2249 -0
- pycontrails/datalib/__init__.py +12 -0
- pycontrails/datalib/_met_utils/metsource.py +746 -0
- pycontrails/datalib/ecmwf/__init__.py +73 -0
- pycontrails/datalib/ecmwf/arco_era5.py +345 -0
- pycontrails/datalib/ecmwf/common.py +114 -0
- pycontrails/datalib/ecmwf/era5.py +554 -0
- pycontrails/datalib/ecmwf/era5_model_level.py +490 -0
- pycontrails/datalib/ecmwf/hres.py +804 -0
- pycontrails/datalib/ecmwf/hres_model_level.py +466 -0
- pycontrails/datalib/ecmwf/ifs.py +287 -0
- pycontrails/datalib/ecmwf/model_levels.py +435 -0
- pycontrails/datalib/ecmwf/static/model_level_dataframe_v20240418.csv +139 -0
- pycontrails/datalib/ecmwf/variables.py +268 -0
- pycontrails/datalib/geo_utils.py +261 -0
- pycontrails/datalib/gfs/__init__.py +28 -0
- pycontrails/datalib/gfs/gfs.py +656 -0
- pycontrails/datalib/gfs/variables.py +104 -0
- pycontrails/datalib/goes.py +764 -0
- pycontrails/datalib/gruan.py +343 -0
- pycontrails/datalib/himawari/__init__.py +27 -0
- pycontrails/datalib/himawari/header_struct.py +266 -0
- pycontrails/datalib/himawari/himawari.py +671 -0
- pycontrails/datalib/landsat.py +589 -0
- pycontrails/datalib/leo_utils/__init__.py +5 -0
- pycontrails/datalib/leo_utils/correction.py +266 -0
- pycontrails/datalib/leo_utils/landsat_metadata.py +300 -0
- pycontrails/datalib/leo_utils/search.py +250 -0
- pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
- pycontrails/datalib/leo_utils/static/bq_roi_query.sql +6 -0
- pycontrails/datalib/leo_utils/vis.py +59 -0
- pycontrails/datalib/sentinel.py +650 -0
- pycontrails/datalib/spire/__init__.py +5 -0
- pycontrails/datalib/spire/exceptions.py +62 -0
- pycontrails/datalib/spire/spire.py +604 -0
- pycontrails/ext/bada.py +42 -0
- pycontrails/ext/cirium.py +14 -0
- pycontrails/ext/empirical_grid.py +140 -0
- pycontrails/ext/synthetic_flight.py +431 -0
- pycontrails/models/__init__.py +1 -0
- pycontrails/models/accf.py +425 -0
- pycontrails/models/apcemm/__init__.py +8 -0
- pycontrails/models/apcemm/apcemm.py +983 -0
- pycontrails/models/apcemm/inputs.py +226 -0
- pycontrails/models/apcemm/static/apcemm_yaml_template.yaml +183 -0
- pycontrails/models/apcemm/utils.py +437 -0
- pycontrails/models/cocip/__init__.py +29 -0
- pycontrails/models/cocip/cocip.py +2742 -0
- pycontrails/models/cocip/cocip_params.py +305 -0
- pycontrails/models/cocip/cocip_uncertainty.py +291 -0
- pycontrails/models/cocip/contrail_properties.py +1530 -0
- pycontrails/models/cocip/output_formats.py +2270 -0
- pycontrails/models/cocip/radiative_forcing.py +1260 -0
- pycontrails/models/cocip/radiative_heating.py +520 -0
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +508 -0
- pycontrails/models/cocip/wake_vortex.py +396 -0
- pycontrails/models/cocip/wind_shear.py +120 -0
- pycontrails/models/cocipgrid/__init__.py +9 -0
- pycontrails/models/cocipgrid/cocip_grid.py +2552 -0
- pycontrails/models/cocipgrid/cocip_grid_params.py +138 -0
- pycontrails/models/dry_advection.py +602 -0
- pycontrails/models/emissions/__init__.py +21 -0
- pycontrails/models/emissions/black_carbon.py +599 -0
- pycontrails/models/emissions/emissions.py +1353 -0
- pycontrails/models/emissions/ffm2.py +336 -0
- pycontrails/models/emissions/static/default-engine-uids.csv +239 -0
- pycontrails/models/emissions/static/edb-gaseous-v29b-engines.csv +596 -0
- pycontrails/models/emissions/static/edb-nvpm-v29b-engines.csv +215 -0
- pycontrails/models/extended_k15.py +1327 -0
- pycontrails/models/humidity_scaling/__init__.py +37 -0
- pycontrails/models/humidity_scaling/humidity_scaling.py +1075 -0
- pycontrails/models/humidity_scaling/quantiles/era5-model-level-quantiles.pq +0 -0
- pycontrails/models/humidity_scaling/quantiles/era5-pressure-level-quantiles.pq +0 -0
- pycontrails/models/issr.py +210 -0
- pycontrails/models/pcc.py +326 -0
- pycontrails/models/pcr.py +154 -0
- pycontrails/models/ps_model/__init__.py +18 -0
- pycontrails/models/ps_model/ps_aircraft_params.py +381 -0
- pycontrails/models/ps_model/ps_grid.py +701 -0
- pycontrails/models/ps_model/ps_model.py +1000 -0
- pycontrails/models/ps_model/ps_operational_limits.py +525 -0
- pycontrails/models/ps_model/static/ps-aircraft-params-20250328.csv +69 -0
- pycontrails/models/ps_model/static/ps-synonym-list-20250328.csv +104 -0
- pycontrails/models/sac.py +442 -0
- pycontrails/models/tau_cirrus.py +183 -0
- pycontrails/physics/__init__.py +1 -0
- pycontrails/physics/constants.py +117 -0
- pycontrails/physics/geo.py +1138 -0
- pycontrails/physics/jet.py +968 -0
- pycontrails/physics/static/iata-cargo-load-factors-20250221.csv +74 -0
- pycontrails/physics/static/iata-passenger-load-factors-20250221.csv +74 -0
- pycontrails/physics/thermo.py +551 -0
- pycontrails/physics/units.py +472 -0
- pycontrails/py.typed +0 -0
- pycontrails/utils/__init__.py +1 -0
- pycontrails/utils/dependencies.py +66 -0
- pycontrails/utils/iteration.py +13 -0
- pycontrails/utils/json.py +187 -0
- pycontrails/utils/temp.py +50 -0
- pycontrails/utils/types.py +163 -0
- pycontrails-0.59.0.dist-info/METADATA +179 -0
- pycontrails-0.59.0.dist-info/RECORD +123 -0
- pycontrails-0.59.0.dist-info/WHEEL +6 -0
- pycontrails-0.59.0.dist-info/licenses/LICENSE +178 -0
- pycontrails-0.59.0.dist-info/licenses/NOTICE +43 -0
- pycontrails-0.59.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))
|