pycontrails 0.54.12__cp312-cp312-macosx_11_0_arm64.whl → 0.56.0__cp312-cp312-macosx_11_0_arm64.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/_version.py +3 -3
- pycontrails/core/airports.py +1 -1
- pycontrails/core/cache.py +3 -3
- pycontrails/core/fleet.py +1 -1
- pycontrails/core/flight.py +47 -43
- pycontrails/core/met_var.py +1 -1
- pycontrails/core/rgi_cython.cpython-312-darwin.so +0 -0
- pycontrails/core/vector.py +28 -30
- pycontrails/datalib/landsat.py +49 -26
- 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 → leo_utils}/search.py +1 -1
- pycontrails/datalib/leo_utils/sentinel_metadata.py +748 -0
- pycontrails/datalib/sentinel.py +236 -93
- pycontrails/models/cocip/cocip.py +30 -13
- pycontrails/models/cocip/cocip_params.py +9 -3
- pycontrails/models/cocip/cocip_uncertainty.py +4 -4
- pycontrails/models/cocip/contrail_properties.py +27 -27
- pycontrails/models/cocip/radiative_forcing.py +4 -4
- pycontrails/models/cocip/unterstrasser_wake_vortex.py +5 -5
- pycontrails/models/cocip/wake_vortex.py +6 -6
- pycontrails/models/cocipgrid/cocip_grid.py +60 -32
- pycontrails/models/dry_advection.py +3 -3
- pycontrails/models/extended_k15.py +1327 -0
- pycontrails/physics/constants.py +1 -1
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/METADATA +3 -1
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/RECORD +34 -29
- /pycontrails/datalib/{_leo_utils → leo_utils}/static/bq_roi_query.sql +0 -0
- /pycontrails/datalib/{_leo_utils → leo_utils}/vis.py +0 -0
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/WHEEL +0 -0
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/licenses/LICENSE +0 -0
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/licenses/NOTICE +0 -0
- {pycontrails-0.54.12.dist-info → pycontrails-0.56.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
"""Support for volatile particulate matter (vPM) modeling via the extended K15 model.
|
|
2
|
+
|
|
3
|
+
See the :func:`droplet_apparent_emission_index` function for the main entry point.
|
|
4
|
+
|
|
5
|
+
A preprint is available :cite:`ponsonbyUpdatedMicrophysicalModel2025`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import enum
|
|
10
|
+
import warnings
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
import scipy.optimize
|
|
16
|
+
import scipy.special
|
|
17
|
+
|
|
18
|
+
from pycontrails.physics import constants, thermo
|
|
19
|
+
|
|
20
|
+
# See upcoming Teoh et. al paper "Impact of Volatile Particulate Matter on Global Contrail
|
|
21
|
+
# Radiative Forcing and Mitigation Assessment" for details on these default parameters.
|
|
22
|
+
DEFAULT_VPM_EI_N = 2.0e17 # vPM number emissions index, [kg^-1]
|
|
23
|
+
DEFAULT_EXHAUST_T = 600.0 # Exhaust temperature, [K]
|
|
24
|
+
EXPERIMENTAL_WARNING = True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ParticleType(enum.Enum):
|
|
28
|
+
"""Enumeration of particle types."""
|
|
29
|
+
|
|
30
|
+
NVPM = "nvPM"
|
|
31
|
+
VPM = "vPM"
|
|
32
|
+
AMBIENT = "ambient"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclasses.dataclass(frozen=True)
|
|
36
|
+
class Particle:
|
|
37
|
+
"""Representation of a particle with hygroscopic and size distribution properties.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
type : ParticleType
|
|
42
|
+
One of ``ParticleType.NVPM``, ``ParticleType.VPM``, or ``ParticleType.AMBIENT``.
|
|
43
|
+
kappa : float
|
|
44
|
+
Hygroscopicity parameter, dimensionless.
|
|
45
|
+
gmd : float
|
|
46
|
+
Geometric mean diameter of the lognormal size distribution, [:math:`m`].
|
|
47
|
+
gsd : float
|
|
48
|
+
Geometric standard deviation of the lognormal size distribution, dimensionless.
|
|
49
|
+
n_ambient : float
|
|
50
|
+
Ambient particle number concentration, [:math:`m^{-3}`].
|
|
51
|
+
For ambient or background particles, this specifies the number
|
|
52
|
+
concentration entrained in the contrail plume. For emission particles,
|
|
53
|
+
this should be set to ``0.0``.
|
|
54
|
+
|
|
55
|
+
Notes
|
|
56
|
+
-----
|
|
57
|
+
The parameters ``gmd`` and ``gsd`` define a lognormal size distribution.
|
|
58
|
+
The hygroscopicity parameter ``kappa`` follows :cite:`pettersSingleParameterRepresentation2007`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
type: ParticleType
|
|
62
|
+
kappa: float
|
|
63
|
+
gmd: float
|
|
64
|
+
gsd: float
|
|
65
|
+
n_ambient: float
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
ptype = self.type
|
|
69
|
+
if ptype != ParticleType.AMBIENT and self.n_ambient:
|
|
70
|
+
raise ValueError(f"n_ambient must be 0 for aircraft-emitted {ptype.value} particles")
|
|
71
|
+
if ptype == ParticleType.AMBIENT and self.n_ambient < 0.0:
|
|
72
|
+
raise ValueError("n_ambient must be non-negative for ambient particles")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _default_particles() -> list[Particle]:
|
|
76
|
+
"""Define particle types representing nvPM, vPM, and ambient particles.
|
|
77
|
+
|
|
78
|
+
See upcoming Teoh et. al paper "Impact of Volatile Particulate Matter on Global Contrail
|
|
79
|
+
Radiative Forcing and Mitigation Assessment" for details on these default parameters.
|
|
80
|
+
"""
|
|
81
|
+
return [
|
|
82
|
+
Particle(type=ParticleType.NVPM, kappa=0.005, gmd=30.0e-9, gsd=2.0, n_ambient=0.0),
|
|
83
|
+
Particle(type=ParticleType.VPM, kappa=0.2, gmd=1.8e-9, gsd=1.5, n_ambient=0.0),
|
|
84
|
+
Particle(type=ParticleType.AMBIENT, kappa=0.5, gmd=30.0e-9, gsd=2.3, n_ambient=600.0e6),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclasses.dataclass
|
|
89
|
+
class DropletActivation:
|
|
90
|
+
"""Store the computed statistics on the water droplet activation for each particle.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
particle : Particle | None
|
|
95
|
+
Source particle type, or ``None`` if this is the aggregate result.
|
|
96
|
+
r_act : npt.NDArray[np.floating]
|
|
97
|
+
Activation radius for a given water saturation ratio and temperature, [:math:`m`].
|
|
98
|
+
phi : npt.NDArray[np.floating]
|
|
99
|
+
Fraction of particles that activate to form water droplets, between 0 and 1.
|
|
100
|
+
n_total : npt.NDArray[np.floating]
|
|
101
|
+
Total particle number concentration, [:math:`m^{-3}`].
|
|
102
|
+
n_available : npt.NDArray[np.floating]
|
|
103
|
+
Particle number concentration available for activation, [:math:`m^{-3}`].
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
particle: Particle | None
|
|
107
|
+
r_act: npt.NDArray[np.floating]
|
|
108
|
+
phi: npt.NDArray[np.floating]
|
|
109
|
+
n_total: npt.NDArray[np.floating]
|
|
110
|
+
n_available: npt.NDArray[np.floating]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def S(
|
|
114
|
+
D: npt.NDArray[np.floating],
|
|
115
|
+
Dd: npt.NDArray[np.floating],
|
|
116
|
+
kappa: npt.NDArray[np.floating],
|
|
117
|
+
A: npt.NDArray[np.floating],
|
|
118
|
+
) -> npt.NDArray[np.floating]:
|
|
119
|
+
"""Compute the supersaturation ratio at diameter ``D``.
|
|
120
|
+
|
|
121
|
+
Implements equation (6) in :cite:`pettersSingleParameterRepresentation2007`.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
D : npt.NDArray[np.floating]
|
|
126
|
+
Droplet diameter, [:math:`m`]. Should be greater than ``Dd``.
|
|
127
|
+
Dd : npt.NDArray[np.floating]
|
|
128
|
+
Dry particle diameter, [:math:`m`].
|
|
129
|
+
kappa : npt.NDArray[np.floating]
|
|
130
|
+
Hygroscopicity parameter, dimensionless.
|
|
131
|
+
A : npt.NDArray[np.floating]
|
|
132
|
+
Kelvin term coefficient, [:math:`m`].
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
npt.NDArray[np.floating]
|
|
137
|
+
Supersaturation ratio at diameter ``D``, dimensionless.
|
|
138
|
+
"""
|
|
139
|
+
D3 = D * D * D # D**3, avoid power operation
|
|
140
|
+
Dd3 = Dd * Dd * Dd # Dd**3, avoid power operation
|
|
141
|
+
return (D3 - Dd3) / (D3 - Dd3 * (1.0 - kappa)) * np.exp(A / D)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _func(
|
|
145
|
+
D: npt.NDArray[np.floating],
|
|
146
|
+
Dd: npt.NDArray[np.floating],
|
|
147
|
+
kappa: npt.NDArray[np.floating],
|
|
148
|
+
A: npt.NDArray[np.floating],
|
|
149
|
+
) -> npt.NDArray[np.floating]:
|
|
150
|
+
"""Compute a term in the derivative of ``log(S)`` with respect to ``D``.
|
|
151
|
+
|
|
152
|
+
The full derivative of ``log(S)`` is ``_func / D^2``.
|
|
153
|
+
"""
|
|
154
|
+
D2 = D**2
|
|
155
|
+
D3 = D2 * D # D**3, avoid power operation
|
|
156
|
+
D4 = D2 * D2 # D**4, avoid power operation
|
|
157
|
+
Dd3 = Dd * Dd * Dd # Dd**3, avoid power operation
|
|
158
|
+
|
|
159
|
+
N = D3 - Dd3
|
|
160
|
+
c = kappa * Dd3
|
|
161
|
+
|
|
162
|
+
return (3.0 * D4 * c) / (N * (N + c)) - A
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _func_prime(
|
|
166
|
+
D: npt.NDArray[np.floating],
|
|
167
|
+
Dd: npt.NDArray[np.floating],
|
|
168
|
+
kappa: npt.NDArray[np.floating],
|
|
169
|
+
A: npt.NDArray[np.floating],
|
|
170
|
+
) -> npt.NDArray[np.floating]:
|
|
171
|
+
"""Compute the derivative of ``_func`` with respect to D."""
|
|
172
|
+
D2 = D**2
|
|
173
|
+
D3 = D2 * D # D**3, avoid power operation
|
|
174
|
+
Dd3 = Dd * Dd * Dd # Dd**3, avoid power operation
|
|
175
|
+
N = D3 - Dd3
|
|
176
|
+
c = kappa * Dd3
|
|
177
|
+
|
|
178
|
+
num = 3.0 * D3 * c * (4.0 * N * (N + c) - 3.0 * D3 * (2.0 * N + c))
|
|
179
|
+
den = (N * (N + c)) ** 2
|
|
180
|
+
|
|
181
|
+
return num / den
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _newton_seed(
|
|
185
|
+
Dd: npt.NDArray[np.floating],
|
|
186
|
+
kappa: npt.NDArray[np.floating],
|
|
187
|
+
) -> npt.NDArray[np.floating]:
|
|
188
|
+
"""Estimate a seed value for Newton's method to find the critical diameter.
|
|
189
|
+
|
|
190
|
+
This is a crude approach, but it probably works well enough for common values of kappa, Dd,
|
|
191
|
+
and temperature. The coefficients below were derived from fitting a linear model to
|
|
192
|
+
approximate eps (defined by S(D) = (1 + eps) * Dd) as a function of log(kappa) and log(Dd).
|
|
193
|
+
(Dd = 1e-9, 1e-8, 1e-7, 1e-6; kappa = 0.005, 0.05, 0.5; temperature ~= 220 K)
|
|
194
|
+
"""
|
|
195
|
+
b0 = 12.21
|
|
196
|
+
b_kappa = 0.5883
|
|
197
|
+
b_Dd = 0.6319
|
|
198
|
+
|
|
199
|
+
log_eps = b0 + b_kappa * np.log(kappa) + b_Dd * np.log(Dd)
|
|
200
|
+
eps = np.exp(log_eps)
|
|
201
|
+
return (1.0 + eps) * Dd
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _density_liq_water(T: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
|
|
205
|
+
"""Calculate the density of liquid water as a function of temperature.
|
|
206
|
+
|
|
207
|
+
The estimate below is equation (A1) in Marcolli 2020
|
|
208
|
+
https://doi.org/10.5194/acp-20-3209-2020
|
|
209
|
+
"""
|
|
210
|
+
c = [
|
|
211
|
+
1864.3535, # T^0
|
|
212
|
+
-72.5821489, # T^1
|
|
213
|
+
2.5194368, # T^2
|
|
214
|
+
-0.049000203, # T^3
|
|
215
|
+
5.860253e-4, # T^4
|
|
216
|
+
-4.5055151e-6, # T^5
|
|
217
|
+
2.2616353e-8, # T^6
|
|
218
|
+
-7.3484974e-11, # T^7
|
|
219
|
+
1.4862784e-13, # T^8
|
|
220
|
+
-1.6984748e-16, # T^9
|
|
221
|
+
8.3699379e-20, # T^10
|
|
222
|
+
]
|
|
223
|
+
return np.polynomial.polynomial.polyval(T, c)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def critical_supersaturation(
|
|
227
|
+
Dd: npt.NDArray[np.floating],
|
|
228
|
+
kappa: npt.NDArray[np.floating],
|
|
229
|
+
T: npt.NDArray[np.floating],
|
|
230
|
+
tol: float = 1e-12,
|
|
231
|
+
maxiter: int = 25,
|
|
232
|
+
) -> npt.NDArray[np.floating]:
|
|
233
|
+
"""Compute the critical supersaturation ratio for a given particle size.
|
|
234
|
+
|
|
235
|
+
The critical supersaturation ratio is the maximum of the supersaturation ratio ``S(D)``
|
|
236
|
+
as a function of the droplet diameter ``D`` for a given dry diameter ``Dd``.
|
|
237
|
+
This maximum is found by solving for the root of the derivative of ``log(S)`` with
|
|
238
|
+
respect to ``D`` using Newton's method.
|
|
239
|
+
|
|
240
|
+
Parameters
|
|
241
|
+
----------
|
|
242
|
+
Dd : npt.NDArray[np.floating]
|
|
243
|
+
Dry diameter of the particle, [:math:`m`].
|
|
244
|
+
kappa : npt.NDArray[np.floating]
|
|
245
|
+
Hygroscopicity parameter, dimensionless. Expected to satisfy ``0 < kappa < 1``.
|
|
246
|
+
T : npt.NDArray[np.floating]
|
|
247
|
+
The temperature at which to compute the critical supersaturation, [:math:`K`].
|
|
248
|
+
tol : float, optional
|
|
249
|
+
Convergence tolerance for Newton's method, by default 1e-12.
|
|
250
|
+
Should be significantly smaller than the values in ``Dd``.
|
|
251
|
+
maxiter : int, optional
|
|
252
|
+
Maximum number of iterations for Newton's method, by default 25.
|
|
253
|
+
|
|
254
|
+
Returns
|
|
255
|
+
-------
|
|
256
|
+
npt.NDArray[np.floating]
|
|
257
|
+
The critical supersaturation ratio, dimensionless.
|
|
258
|
+
"""
|
|
259
|
+
sigma = 0.0761 - 1.55e-4 * (T + constants.absolute_zero)
|
|
260
|
+
A = (4.0 * sigma * constants.M_v) / (constants.R * T * _density_liq_water(T))
|
|
261
|
+
|
|
262
|
+
x0 = _newton_seed(Dd, kappa)
|
|
263
|
+
D = scipy.optimize.newton(
|
|
264
|
+
func=_func,
|
|
265
|
+
x0=x0,
|
|
266
|
+
fprime=_func_prime,
|
|
267
|
+
args=(Dd, kappa, A),
|
|
268
|
+
maxiter=maxiter,
|
|
269
|
+
tol=tol,
|
|
270
|
+
)
|
|
271
|
+
return S(D, Dd, kappa, A)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _geometric_bisection(
|
|
275
|
+
func: Callable[[npt.NDArray[np.floating]], npt.NDArray[np.floating]],
|
|
276
|
+
lo: npt.NDArray[np.floating],
|
|
277
|
+
hi: npt.NDArray[np.floating],
|
|
278
|
+
rtol: float,
|
|
279
|
+
maxiter: int,
|
|
280
|
+
) -> npt.NDArray[np.floating]:
|
|
281
|
+
"""Find root of function func in ``[lo, hi]`` using geometric bisection.
|
|
282
|
+
|
|
283
|
+
The arrays ``lo`` and ``hi`` must be such that ``func(lo)`` and ``func(hi)`` have
|
|
284
|
+
opposite signs. These two arrays are freely modified in place during the algorithm.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
f_lo = func(lo)
|
|
288
|
+
f_hi = func(hi)
|
|
289
|
+
|
|
290
|
+
out_mask = np.sign(f_lo) == np.sign(f_hi)
|
|
291
|
+
|
|
292
|
+
for _ in range(maxiter):
|
|
293
|
+
mid = np.sqrt(lo * hi)
|
|
294
|
+
f_mid = func(mid)
|
|
295
|
+
|
|
296
|
+
# Where f_mid has same sign as f_lo, move lo up; else move hi down
|
|
297
|
+
mask_lo = np.sign(f_mid) == np.sign(f_lo)
|
|
298
|
+
lo[mask_lo] = mid[mask_lo]
|
|
299
|
+
f_lo[mask_lo] = f_mid[mask_lo]
|
|
300
|
+
|
|
301
|
+
hi[~mask_lo] = mid[~mask_lo]
|
|
302
|
+
f_hi[~mask_lo] = f_mid[~mask_lo]
|
|
303
|
+
|
|
304
|
+
if np.all(hi / lo - 1.0 < rtol):
|
|
305
|
+
break
|
|
306
|
+
|
|
307
|
+
return np.where(out_mask, np.nan, np.sqrt(lo * hi))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def activation_radius(
|
|
311
|
+
S_w: npt.NDArray[np.floating],
|
|
312
|
+
kappa: npt.NDArray[np.floating] | float,
|
|
313
|
+
temperature: npt.NDArray[np.floating],
|
|
314
|
+
rtol: float = 1e-6,
|
|
315
|
+
maxiter: int = 30,
|
|
316
|
+
) -> npt.NDArray[np.floating]:
|
|
317
|
+
"""Calculate activation radius for a given supersaturation ratio and temperature.
|
|
318
|
+
|
|
319
|
+
The activation radius is defined as the droplet radius at which the
|
|
320
|
+
critical supersaturation equals the ambient water supersaturation ``S_w``.
|
|
321
|
+
Mathematically, it is the root of the equation::
|
|
322
|
+
|
|
323
|
+
critical_supersaturation(2 * r) - S_w = 0
|
|
324
|
+
|
|
325
|
+
Parameters
|
|
326
|
+
----------
|
|
327
|
+
S_w : npt.NDArray[np.floating]
|
|
328
|
+
Water saturation ratio in the aircraft plume after droplet condensation, dimensionless.
|
|
329
|
+
kappa : npt.NDArray[np.floating] | float
|
|
330
|
+
Hygroscopicity parameter, dimensionless. Expected to satisfy ``0 < kappa < 1``.
|
|
331
|
+
temperature : npt.NDArray[np.floating]
|
|
332
|
+
Temperature at which to compute the activation radius, [:math:`K`].
|
|
333
|
+
rtol : float, optional
|
|
334
|
+
Relative tolerance for geometric-bisection root-finding algorithm, by default 1e-6.
|
|
335
|
+
maxiter : int, optional
|
|
336
|
+
Maximum number of iterations for geometric-bisection root-finding algorithm, by default 30.
|
|
337
|
+
|
|
338
|
+
Returns
|
|
339
|
+
-------
|
|
340
|
+
npt.NDArray[np.floating]
|
|
341
|
+
The activation radius, [:math:`m`]. Entries where ``S_w <= 1.0`` return ``nan``. Only
|
|
342
|
+
supersaturation ratios greater than 1.0 are physically meaningful for activation. The
|
|
343
|
+
returned activation radius is the radius at which the droplet would first activate
|
|
344
|
+
to form a water droplet in the emissions plume.
|
|
345
|
+
|
|
346
|
+
"""
|
|
347
|
+
cond = S_w > 1.0
|
|
348
|
+
S_w, kappa, temperature = np.broadcast_arrays(S_w, kappa, temperature)
|
|
349
|
+
|
|
350
|
+
S_w_cond = S_w[cond]
|
|
351
|
+
kappa_cond = kappa[cond]
|
|
352
|
+
temperature_cond = temperature[cond]
|
|
353
|
+
|
|
354
|
+
def func(r: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
|
|
355
|
+
# radius -> diameter
|
|
356
|
+
return critical_supersaturation(2.0 * r, kappa_cond, temperature_cond) - S_w_cond
|
|
357
|
+
|
|
358
|
+
lo = np.full_like(S_w_cond, 5e-10)
|
|
359
|
+
hi = np.full_like(S_w_cond, 1e-6)
|
|
360
|
+
|
|
361
|
+
r_act_cond = _geometric_bisection(func, lo=lo, hi=hi, rtol=rtol, maxiter=maxiter)
|
|
362
|
+
|
|
363
|
+
out = np.full_like(S_w, np.nan)
|
|
364
|
+
out[cond] = r_act_cond
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _t_plume_test_points(
|
|
369
|
+
specific_humidity: npt.NDArray[np.floating],
|
|
370
|
+
T_ambient: npt.NDArray[np.floating],
|
|
371
|
+
air_pressure: npt.NDArray[np.floating],
|
|
372
|
+
G: npt.NDArray[np.floating],
|
|
373
|
+
n_points: int,
|
|
374
|
+
) -> npt.NDArray[np.floating]:
|
|
375
|
+
"""Determine test points for the plume temperature along the mixing line."""
|
|
376
|
+
target_shape = (1,) * T_ambient.ndim + (-1,)
|
|
377
|
+
step = 0.005
|
|
378
|
+
|
|
379
|
+
# Initially we take a shotgun approach
|
|
380
|
+
# We could use some optimization technique here as well, but it's not obviously worth it
|
|
381
|
+
T_plume_test = np.arange(190.0, 300.0, step, dtype=float).reshape(target_shape)
|
|
382
|
+
p_mw = thermo.water_vapor_partial_pressure_along_mixing_line(
|
|
383
|
+
specific_humidity=specific_humidity[..., np.newaxis],
|
|
384
|
+
air_pressure=air_pressure[..., np.newaxis],
|
|
385
|
+
T_plume=T_plume_test,
|
|
386
|
+
T_ambient=T_ambient[..., np.newaxis],
|
|
387
|
+
G=G[..., np.newaxis],
|
|
388
|
+
)
|
|
389
|
+
S_mw = plume_water_saturation_ratio_no_condensation(T_plume_test, p_mw)
|
|
390
|
+
|
|
391
|
+
# Each row of S_mw has a single maximum somewhere above 1
|
|
392
|
+
# For the lower bound, take this maximum
|
|
393
|
+
i_T_lb = np.nanargmax(S_mw, axis=-1, keepdims=True)
|
|
394
|
+
T_lb = np.take_along_axis(T_plume_test, i_T_lb, axis=-1) - step
|
|
395
|
+
|
|
396
|
+
# For the upper bound, take the maximum T_plume where S_mw > 1
|
|
397
|
+
filt = S_mw > 1.0
|
|
398
|
+
i_T_ub = np.where(filt, np.arange(T_plume_test.shape[-1]), -1).max(axis=-1, keepdims=True)
|
|
399
|
+
T_ub = np.take_along_axis(T_plume_test, i_T_ub, axis=-1) + step
|
|
400
|
+
|
|
401
|
+
# Now create n_points linearly-spaced values from T_ub down to T_lb
|
|
402
|
+
# (We assume later that T_plume is sorted in descending order, so we slice [::-1])
|
|
403
|
+
points = np.linspace(0.0, 1.0, n_points, dtype=float)
|
|
404
|
+
return (T_lb + (T_ub - T_lb) * points)[..., ::-1]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def droplet_apparent_emission_index(
|
|
408
|
+
specific_humidity: npt.NDArray[np.floating],
|
|
409
|
+
T_ambient: npt.NDArray[np.floating],
|
|
410
|
+
T_exhaust: npt.NDArray[np.floating],
|
|
411
|
+
air_pressure: npt.NDArray[np.floating],
|
|
412
|
+
nvpm_ei_n: npt.NDArray[np.floating],
|
|
413
|
+
vpm_ei_n: float,
|
|
414
|
+
G: npt.NDArray[np.floating],
|
|
415
|
+
particles: list[Particle] | None = None,
|
|
416
|
+
n_plume_points: int = 50,
|
|
417
|
+
) -> npt.NDArray[np.floating]:
|
|
418
|
+
"""Calculate the droplet apparent emissions index from nvPM, vPM and ambient particles.
|
|
419
|
+
|
|
420
|
+
Parameters
|
|
421
|
+
----------
|
|
422
|
+
specific_humidity : npt.NDArray[np.floating]
|
|
423
|
+
Specific humidity at each waypoint, [:math:`kg_{H_{2}O} / kg_{air}`]
|
|
424
|
+
T_ambient : npt.NDArray[np.floating]
|
|
425
|
+
Ambient temperature at each waypoint, [:math:`K`]
|
|
426
|
+
T_exhaust : npt.NDArray[np.floating]
|
|
427
|
+
Aircraft exhaust temperature for each waypoint, [:math:`K`]
|
|
428
|
+
air_pressure : npt.NDArray[np.floating]
|
|
429
|
+
Pressure altitude at each waypoint, [:math:`Pa`]
|
|
430
|
+
nvpm_ei_n : npt.NDArray[np.floating]
|
|
431
|
+
nvPM number emissions index, [:math:`kg^{-1}`]
|
|
432
|
+
vpm_ei_n : float
|
|
433
|
+
vPM number emissions index, [:math:`kg^{-1}`]
|
|
434
|
+
G : npt.NDArray[np.floating]
|
|
435
|
+
Slope of the mixing line in a temperature-humidity diagram.
|
|
436
|
+
particles : list[Particle] | None, optional
|
|
437
|
+
List of particle types to consider. If ``None``, defaults to a list of
|
|
438
|
+
``Particle`` instances representing nvPM, vPM, and ambient particles.
|
|
439
|
+
n_plume_points : int
|
|
440
|
+
Number of points to evaluate the plume temperature along the mixing line.
|
|
441
|
+
Increasing this value can improve accuracy. Values above 40 are typically
|
|
442
|
+
sufficient. See the :func:`droplet_activation` for numerical considerations.
|
|
443
|
+
|
|
444
|
+
Returns
|
|
445
|
+
-------
|
|
446
|
+
npt.NDArray[np.floating]
|
|
447
|
+
Activated droplet apparent ice emissions index, [:math:`kg^{-1}`]
|
|
448
|
+
|
|
449
|
+
Notes
|
|
450
|
+
-----
|
|
451
|
+
All input arrays must be broadcastable to the same shape. For better performance
|
|
452
|
+
when evaluating multiple points or grids, it is helpful to arrange the arrays so that
|
|
453
|
+
meteorological variables (``specific_humidity``, ``T_ambient``, ``air_pressure``, ``G``)
|
|
454
|
+
correspond to dimension 0, while aircraft emissions (``nvpm_ei_n``, ``vpm_ei_n``) correspond
|
|
455
|
+
to dimension 1. This setup allows the plume temperature calculation to be computed once
|
|
456
|
+
and reused for multiple emissions values.
|
|
457
|
+
|
|
458
|
+
"""
|
|
459
|
+
if EXPERIMENTAL_WARNING:
|
|
460
|
+
warnings.warn(
|
|
461
|
+
"""This model is a minimal framework used to approximate the apparent
|
|
462
|
+
emission index of contrail ice crystals in the jet regime. It does not fully
|
|
463
|
+
represent the complexity of microphysical plume processes, including the
|
|
464
|
+
formation and growth of vPM. Instead, vPM properties are prescribed as model
|
|
465
|
+
inputs, which strongly impact model outputs. Therefore, the model should
|
|
466
|
+
only be used for research purposes, together with thorough sensitivity
|
|
467
|
+
analyses or explicit reference to the limitations outlined above.
|
|
468
|
+
"""
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
particles = particles or _default_particles()
|
|
472
|
+
|
|
473
|
+
# Confirm all parameters are broadcastable
|
|
474
|
+
specific_humidity, T_ambient, T_exhaust, air_pressure, G, nvpm_ei_n = np.atleast_1d(
|
|
475
|
+
specific_humidity, T_ambient, T_exhaust, air_pressure, G, nvpm_ei_n
|
|
476
|
+
)
|
|
477
|
+
try:
|
|
478
|
+
np.broadcast(specific_humidity, T_ambient, T_exhaust, air_pressure, G, nvpm_ei_n, vpm_ei_n)
|
|
479
|
+
except ValueError as e:
|
|
480
|
+
raise ValueError(
|
|
481
|
+
"Input arrays must be broadcastable to the same shape. "
|
|
482
|
+
"Check the dimensions of specific_humidity, T_ambient, T_exhaust, "
|
|
483
|
+
"air_pressure, G, nvpm_ei_n, and vpm_ei_n."
|
|
484
|
+
) from e
|
|
485
|
+
|
|
486
|
+
# Determine plume temperature limits
|
|
487
|
+
T_plume = _t_plume_test_points(specific_humidity, T_ambient, air_pressure, G, n_plume_points)
|
|
488
|
+
|
|
489
|
+
# Fixed parameters -- these could be made configurable if needed
|
|
490
|
+
tau_m = 10.0e-3
|
|
491
|
+
beta = 0.9
|
|
492
|
+
nu_0 = 60.0
|
|
493
|
+
vol_molecule_h2o = (18.0e-3 / 6.022e23) / 1000.0 # volume of a supercooled water molecule / m^3
|
|
494
|
+
|
|
495
|
+
p_mw = thermo.water_vapor_partial_pressure_along_mixing_line(
|
|
496
|
+
specific_humidity=specific_humidity[..., np.newaxis],
|
|
497
|
+
air_pressure=air_pressure[..., np.newaxis],
|
|
498
|
+
T_plume=T_plume,
|
|
499
|
+
T_ambient=T_ambient[..., np.newaxis],
|
|
500
|
+
G=G[..., np.newaxis],
|
|
501
|
+
)
|
|
502
|
+
S_mw = plume_water_saturation_ratio_no_condensation(T_plume, p_mw)
|
|
503
|
+
|
|
504
|
+
dilution = plume_dilution_factor(
|
|
505
|
+
T_plume=T_plume,
|
|
506
|
+
T_exhaust=T_exhaust[..., np.newaxis],
|
|
507
|
+
T_ambient=T_ambient[..., np.newaxis],
|
|
508
|
+
tau_m=tau_m,
|
|
509
|
+
beta=beta,
|
|
510
|
+
)
|
|
511
|
+
rho_air = thermo.rho_d(T_plume, air_pressure[..., np.newaxis])
|
|
512
|
+
|
|
513
|
+
particle_droplets = water_droplet_activation(
|
|
514
|
+
particles=particles,
|
|
515
|
+
T_plume=T_plume,
|
|
516
|
+
T_ambient=T_ambient[..., np.newaxis],
|
|
517
|
+
nvpm_ei_n=nvpm_ei_n[..., np.newaxis],
|
|
518
|
+
vpm_ei_n=vpm_ei_n,
|
|
519
|
+
S_mw=S_mw,
|
|
520
|
+
dilution=dilution,
|
|
521
|
+
rho_air=rho_air,
|
|
522
|
+
nu_0=nu_0,
|
|
523
|
+
)
|
|
524
|
+
particle_droplets_all = water_droplet_activation_across_all_particles(particle_droplets)
|
|
525
|
+
n_w_sat = droplet_number_concentration_at_saturation(T_plume)
|
|
526
|
+
b_1, b_2 = particle_growth_coefficients(
|
|
527
|
+
T_plume=T_plume,
|
|
528
|
+
air_pressure=air_pressure[..., np.newaxis],
|
|
529
|
+
S_mw=S_mw,
|
|
530
|
+
n_w_sat=n_w_sat,
|
|
531
|
+
vol_molecule_h2o=vol_molecule_h2o,
|
|
532
|
+
)
|
|
533
|
+
P_w = water_supersaturation_production_rate(
|
|
534
|
+
T_plume=T_plume,
|
|
535
|
+
T_exhaust=T_exhaust[..., np.newaxis],
|
|
536
|
+
T_ambient=T_ambient[..., np.newaxis],
|
|
537
|
+
dilution=dilution,
|
|
538
|
+
S_mw=S_mw,
|
|
539
|
+
tau_m=tau_m,
|
|
540
|
+
beta=beta,
|
|
541
|
+
)
|
|
542
|
+
kappa_w = dynamical_regime_parameter(
|
|
543
|
+
particle_droplets_all.n_available, S_mw, P_w, particle_droplets_all.r_act, b_1, b_2
|
|
544
|
+
)
|
|
545
|
+
R_w = supersaturation_loss_rate_per_droplet(
|
|
546
|
+
kappa_w, particle_droplets_all.r_act, n_w_sat, b_1, b_2, vol_molecule_h2o
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
return droplet_activation(
|
|
550
|
+
n_available_all=particle_droplets_all.n_available,
|
|
551
|
+
P_w=P_w,
|
|
552
|
+
R_w=R_w,
|
|
553
|
+
rho_air=rho_air,
|
|
554
|
+
dilution=dilution,
|
|
555
|
+
nu_0=nu_0,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def plume_water_saturation_ratio_no_condensation(
|
|
560
|
+
T_plume: npt.NDArray[np.floating],
|
|
561
|
+
p_mw: npt.NDArray[np.floating],
|
|
562
|
+
) -> npt.NDArray[np.floating]:
|
|
563
|
+
"""Calculate water saturation ratio in the exhaust plume without droplet condensation.
|
|
564
|
+
|
|
565
|
+
Parameters
|
|
566
|
+
----------
|
|
567
|
+
T_plume : npt.NDArray[np.floating]
|
|
568
|
+
Plume temperature evolution along mixing line, [:math:`K`]
|
|
569
|
+
p_mw : npt.NDArray[np.floating]
|
|
570
|
+
PWater vapour partial pressure along mixing line, [:math:`Pa`]
|
|
571
|
+
|
|
572
|
+
Returns
|
|
573
|
+
-------
|
|
574
|
+
npt.NDArray[np.floating]
|
|
575
|
+
Water saturation ratio in the aircraft plume without droplet condensation (``S_mw``).
|
|
576
|
+
|
|
577
|
+
References
|
|
578
|
+
----------
|
|
579
|
+
Page 7894 of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
580
|
+
|
|
581
|
+
Notes
|
|
582
|
+
-----
|
|
583
|
+
- When expressed in percentage terms, ``S_mw`` is identical to relative humidity.
|
|
584
|
+
- Water saturation ratio in the aircraft plume with droplet condensation (``S_w``)
|
|
585
|
+
- In contrail-forming conditions, ``S_w <= S_mw`` because the supersaturation in the contrail
|
|
586
|
+
plume is quenched from droplet formation and growth.
|
|
587
|
+
"""
|
|
588
|
+
return p_mw / thermo.e_sat_liquid(T_plume)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def plume_dilution_factor(
|
|
592
|
+
T_plume: npt.NDArray[np.floating],
|
|
593
|
+
T_exhaust: npt.NDArray[np.floating],
|
|
594
|
+
T_ambient: npt.NDArray[np.floating],
|
|
595
|
+
tau_m: float,
|
|
596
|
+
beta: float,
|
|
597
|
+
) -> npt.NDArray[np.floating]:
|
|
598
|
+
"""Calculate the plume dilution factor.
|
|
599
|
+
|
|
600
|
+
Parameters
|
|
601
|
+
----------
|
|
602
|
+
T_plume : npt.NDArray[np.floating]
|
|
603
|
+
Plume temperature evolution along mixing line, [:math:`K`].
|
|
604
|
+
T_exhaust : npt.NDArray[np.floating]
|
|
605
|
+
Aircraft exhaust temperature for each waypoint, [:math:`K`].
|
|
606
|
+
T_ambient : npt.NDArray[np.floating]
|
|
607
|
+
Ambient temperature for each waypoint, [:math:`K`].
|
|
608
|
+
tau_m : float
|
|
609
|
+
Mixing timescale, i.e., the time for an exhaust volume element at the center of the
|
|
610
|
+
jet plume to remain unaffected by ambient air entrainment, [:math:`s`].
|
|
611
|
+
beta : float
|
|
612
|
+
Plume dilution parameter, set to 0.9.
|
|
613
|
+
|
|
614
|
+
Returns
|
|
615
|
+
-------
|
|
616
|
+
npt.NDArray[np.floating]
|
|
617
|
+
Plume dilution factor.
|
|
618
|
+
|
|
619
|
+
References
|
|
620
|
+
----------
|
|
621
|
+
Eq. (12) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
622
|
+
"""
|
|
623
|
+
t_plume = _plume_age_timescale(T_plume, T_exhaust, T_ambient, tau_m, beta)
|
|
624
|
+
return np.where(t_plume > tau_m, (tau_m / t_plume) ** beta, 1.0)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _plume_age_timescale(
|
|
628
|
+
T_plume: npt.NDArray[np.floating],
|
|
629
|
+
T_exhaust: npt.NDArray[np.floating],
|
|
630
|
+
T_ambient: npt.NDArray[np.floating],
|
|
631
|
+
tau_m: float,
|
|
632
|
+
beta: float,
|
|
633
|
+
) -> npt.NDArray[np.floating]:
|
|
634
|
+
"""Calculate plume age timescale from the change in plume temperature.
|
|
635
|
+
|
|
636
|
+
Parameters
|
|
637
|
+
----------
|
|
638
|
+
T_plume : npt.NDArray[np.floating]
|
|
639
|
+
Plume temperature evolution along mixing line, [:math:`K`].
|
|
640
|
+
T_exhaust : npt.NDArray[np.floating]
|
|
641
|
+
Aircraft exhaust temperature for each waypoint, [:math:`K`].
|
|
642
|
+
T_ambient : npt.NDArray[np.floating]
|
|
643
|
+
Ambient temperature for each waypoint, [:math:`K`].
|
|
644
|
+
tau_m : float
|
|
645
|
+
Mixing timescale, i.e., the time for an exhaust volume element at the center of the
|
|
646
|
+
jet plume to remain unaffected by ambient air entrainment, [:math:`s`].
|
|
647
|
+
beta : float
|
|
648
|
+
Plume dilution parameter, set to 0.9.
|
|
649
|
+
|
|
650
|
+
Returns
|
|
651
|
+
-------
|
|
652
|
+
npt.NDArray[np.floating]
|
|
653
|
+
Plume age timescale, [:math:`s`].
|
|
654
|
+
|
|
655
|
+
References
|
|
656
|
+
----------
|
|
657
|
+
Eq. (15) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
658
|
+
"""
|
|
659
|
+
ratio = (T_exhaust - T_ambient) / (T_plume - T_ambient)
|
|
660
|
+
return tau_m * np.power(ratio, 1 / beta, where=ratio >= 0.0, out=np.full_like(ratio, np.nan))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def water_droplet_activation(
|
|
664
|
+
particles: list[Particle],
|
|
665
|
+
T_plume: npt.NDArray[np.floating],
|
|
666
|
+
T_ambient: npt.NDArray[np.floating],
|
|
667
|
+
nvpm_ei_n: npt.NDArray[np.floating],
|
|
668
|
+
vpm_ei_n: float,
|
|
669
|
+
S_mw: npt.NDArray[np.floating],
|
|
670
|
+
dilution: npt.NDArray[np.floating],
|
|
671
|
+
rho_air: npt.NDArray[np.floating],
|
|
672
|
+
nu_0: float,
|
|
673
|
+
) -> list[DropletActivation]:
|
|
674
|
+
"""Calculate statistics on the water droplet activation for different particle types.
|
|
675
|
+
|
|
676
|
+
Parameters
|
|
677
|
+
----------
|
|
678
|
+
particles : list[Particle]
|
|
679
|
+
Properties of different particles in the contrail plume.
|
|
680
|
+
T_plume : npt.NDArray[np.floating]
|
|
681
|
+
Plume temperature evolution along mixing line, [:math:`K`].
|
|
682
|
+
T_ambient : npt.NDArray[np.floating]
|
|
683
|
+
Ambient temperature for each waypoint, [:math:`K`].
|
|
684
|
+
nvpm_ei_n : npt.NDArray[np.floating]
|
|
685
|
+
nvPM number emissions index, [:math:`kg^{-1}`].
|
|
686
|
+
vpm_ei_n : float
|
|
687
|
+
vPM number emissions index, [:math:`kg^{-1}`].
|
|
688
|
+
S_mw : npt.NDArray[np.floating]
|
|
689
|
+
Water saturation ratio in the aircraft plume without droplet condensation.
|
|
690
|
+
dilution : npt.NDArray[np.floating]
|
|
691
|
+
Plume dilution factor, see :func:`plume_dilution_factor`.
|
|
692
|
+
rho_air : npt.NDArray[np.floating]
|
|
693
|
+
Density of air, [:math:`kg / m^{-3}`].
|
|
694
|
+
nu_0 : float
|
|
695
|
+
Initial mass-based plume mixing factor, i.e., air-to-fuel ratio, set to 60.0.
|
|
696
|
+
|
|
697
|
+
Returns
|
|
698
|
+
-------
|
|
699
|
+
list[DropletActivation]
|
|
700
|
+
Computed statistics on the water droplet activation for each particle type.
|
|
701
|
+
"""
|
|
702
|
+
res = []
|
|
703
|
+
|
|
704
|
+
for particle in particles:
|
|
705
|
+
r_act_p = activation_radius(S_mw, particle.kappa, T_plume)
|
|
706
|
+
phi_p = fraction_of_water_activated_particles(particle.gmd, particle.gsd, r_act_p)
|
|
707
|
+
|
|
708
|
+
# Calculate total number concentration for a given particle type
|
|
709
|
+
if particle.type == ParticleType.AMBIENT:
|
|
710
|
+
n_total_p = entrained_ambient_droplet_number_concentration(
|
|
711
|
+
particle.n_ambient, T_plume, T_ambient, dilution
|
|
712
|
+
)
|
|
713
|
+
elif particle.type == ParticleType.NVPM:
|
|
714
|
+
n_total_p = emissions_index_to_number_concentration(nvpm_ei_n, rho_air, dilution, nu_0)
|
|
715
|
+
elif particle.type == ParticleType.VPM:
|
|
716
|
+
n_total_p = emissions_index_to_number_concentration(vpm_ei_n, rho_air, dilution, nu_0)
|
|
717
|
+
else:
|
|
718
|
+
raise ValueError("Particle type unknown")
|
|
719
|
+
|
|
720
|
+
res_p = DropletActivation(
|
|
721
|
+
particle=particle,
|
|
722
|
+
r_act=r_act_p,
|
|
723
|
+
phi=phi_p,
|
|
724
|
+
n_total=n_total_p,
|
|
725
|
+
n_available=(n_total_p * phi_p),
|
|
726
|
+
)
|
|
727
|
+
res.append(res_p)
|
|
728
|
+
|
|
729
|
+
return res
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def fraction_of_water_activated_particles(
|
|
733
|
+
gmd: npt.NDArray[np.floating] | float,
|
|
734
|
+
gsd: npt.NDArray[np.floating] | float,
|
|
735
|
+
r_act: npt.NDArray[np.floating] | float,
|
|
736
|
+
) -> npt.NDArray[np.floating]:
|
|
737
|
+
"""Calculate the fraction of particles that activate to form water droplets.
|
|
738
|
+
|
|
739
|
+
Parameters
|
|
740
|
+
----------
|
|
741
|
+
gmd : npt.NDArray[np.floating] | float
|
|
742
|
+
Geometric mean diameter, [:math:`m`]
|
|
743
|
+
gsd : npt.NDArray[np.floating] | float
|
|
744
|
+
Geometric standard deviation
|
|
745
|
+
r_act : npt.NDArray[np.floating] | float
|
|
746
|
+
Droplet activation threshold radius for a given supersaturation (s_w), [:math:`m`]
|
|
747
|
+
|
|
748
|
+
Returns
|
|
749
|
+
-------
|
|
750
|
+
npt.NDArray[np.floating]
|
|
751
|
+
Fraction of particles that activate to form water droplets (phi)
|
|
752
|
+
|
|
753
|
+
Notes
|
|
754
|
+
-----
|
|
755
|
+
The cumulative distribution is estimated directly using the SciPy error function.
|
|
756
|
+
"""
|
|
757
|
+
z = (np.log(r_act * 2.0) - np.log(gmd)) / (2.0**0.5 * np.log(gsd))
|
|
758
|
+
return 0.5 - 0.5 * scipy.special.erf(z)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def entrained_ambient_droplet_number_concentration(
|
|
762
|
+
n_ambient: npt.NDArray[np.floating] | float,
|
|
763
|
+
T_plume: npt.NDArray[np.floating],
|
|
764
|
+
T_ambient: npt.NDArray[np.floating],
|
|
765
|
+
dilution: npt.NDArray[np.floating],
|
|
766
|
+
) -> npt.NDArray[np.floating]:
|
|
767
|
+
"""Calculate ambient droplet number concentration entrained in the contrail plume.
|
|
768
|
+
|
|
769
|
+
Parameters
|
|
770
|
+
----------
|
|
771
|
+
n_ambient : npt.NDArray[np.floating] | float
|
|
772
|
+
Ambient particle number concentration, [:math:`m^{-3}`].
|
|
773
|
+
T_plume : npt.NDArray[np.floating]
|
|
774
|
+
Plume temperature evolution along mixing line, [:math:`K`].
|
|
775
|
+
T_ambient : npt.NDArray[np.floating]
|
|
776
|
+
Ambient temperature for each waypoint, [:math:`K`].
|
|
777
|
+
dilution : npt.NDArray[np.floating]
|
|
778
|
+
Plume dilution factor.
|
|
779
|
+
|
|
780
|
+
Returns
|
|
781
|
+
-------
|
|
782
|
+
npt.NDArray[np.floating]
|
|
783
|
+
Ambient droplet number concentration entrained in the contrail plume, [:math:`m^{-3}`].
|
|
784
|
+
|
|
785
|
+
References
|
|
786
|
+
----------
|
|
787
|
+
Eq. (37) of :cite:`karcherMicrophysicalPathwayContrail2015` without the phi term.
|
|
788
|
+
"""
|
|
789
|
+
return n_ambient * (T_ambient / T_plume) * (1.0 - dilution)
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def emissions_index_to_number_concentration(
|
|
793
|
+
number_ei: npt.NDArray[np.floating] | float,
|
|
794
|
+
rho_air: npt.NDArray[np.floating],
|
|
795
|
+
dilution: npt.NDArray[np.floating],
|
|
796
|
+
nu_0: float,
|
|
797
|
+
) -> npt.NDArray[np.floating]:
|
|
798
|
+
"""Convert particle number emissions index to number concentration.
|
|
799
|
+
|
|
800
|
+
Parameters
|
|
801
|
+
----------
|
|
802
|
+
number_ei : npt.NDArray[np.floating] | float
|
|
803
|
+
Particle number emissions index, [:math:`kg^{-1}`].
|
|
804
|
+
rho_air : npt.NDArray[np.floating]
|
|
805
|
+
Air density at each waypoint, [:math:`kg m^{-3}`].
|
|
806
|
+
dilution : npt.NDArray[np.floating]
|
|
807
|
+
Plume dilution factor.
|
|
808
|
+
nu_0 : float
|
|
809
|
+
Initial mass-based plume mixing factor, i.e., air-to-fuel ratio, set to 60.0.
|
|
810
|
+
|
|
811
|
+
Returns
|
|
812
|
+
-------
|
|
813
|
+
npt.NDArray[np.floating]
|
|
814
|
+
Particle number concentration entrained in the contrail plume, [:math:`m^{-3}`]
|
|
815
|
+
|
|
816
|
+
References
|
|
817
|
+
----------
|
|
818
|
+
Eq. (37) of :cite:`karcherMicrophysicalPathwayContrail2015` without the phi term.
|
|
819
|
+
"""
|
|
820
|
+
return number_ei * rho_air * (dilution / nu_0)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def water_droplet_activation_across_all_particles(
|
|
824
|
+
particle_droplets: list[DropletActivation],
|
|
825
|
+
) -> DropletActivation:
|
|
826
|
+
"""Calculate the total and weighted water droplet activation outputs across all particle types.
|
|
827
|
+
|
|
828
|
+
Parameters
|
|
829
|
+
----------
|
|
830
|
+
particle_droplets : list[DropletActivation]
|
|
831
|
+
Computed statistics on the water droplet activation for each particle type.
|
|
832
|
+
See :class:`DropletActivation` and :func:`water_droplet_activation`.
|
|
833
|
+
|
|
834
|
+
Returns
|
|
835
|
+
-------
|
|
836
|
+
DropletActivation
|
|
837
|
+
Total and weighted water droplet activation outputs across all particle types.
|
|
838
|
+
|
|
839
|
+
References
|
|
840
|
+
----------
|
|
841
|
+
Eq. (37) and Eq. (43) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
842
|
+
"""
|
|
843
|
+
# Initialise variables
|
|
844
|
+
target = particle_droplets[0].n_total
|
|
845
|
+
weights_numer = np.zeros_like(target)
|
|
846
|
+
weights_denom = np.zeros_like(target)
|
|
847
|
+
n_total_all = np.zeros_like(target)
|
|
848
|
+
n_available_all = np.zeros_like(target)
|
|
849
|
+
|
|
850
|
+
for particle in particle_droplets:
|
|
851
|
+
if not particle.particle:
|
|
852
|
+
raise ValueError("Each DropletActivation must have an associated Particle.")
|
|
853
|
+
|
|
854
|
+
weights_numer += np.nan_to_num(particle.r_act) * particle.n_available
|
|
855
|
+
weights_denom += particle.n_available
|
|
856
|
+
|
|
857
|
+
# Total particles
|
|
858
|
+
n_total_all += particle.n_total
|
|
859
|
+
n_available_all += particle.n_available
|
|
860
|
+
|
|
861
|
+
# Calculate number weighted activation radius
|
|
862
|
+
r_act_nw = np.divide(
|
|
863
|
+
weights_numer,
|
|
864
|
+
weights_denom,
|
|
865
|
+
out=np.full_like(weights_numer, np.nan),
|
|
866
|
+
where=weights_denom != 0.0,
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
return DropletActivation(
|
|
870
|
+
particle=None,
|
|
871
|
+
r_act=r_act_nw,
|
|
872
|
+
phi=n_available_all / n_total_all,
|
|
873
|
+
n_total=n_total_all,
|
|
874
|
+
n_available=n_available_all,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def droplet_number_concentration_at_saturation(
|
|
879
|
+
T_plume: npt.NDArray[np.floating],
|
|
880
|
+
) -> npt.NDArray[np.floating]:
|
|
881
|
+
"""Calculate water vapour concentration at saturation.
|
|
882
|
+
|
|
883
|
+
Parameters
|
|
884
|
+
----------
|
|
885
|
+
T_plume : npt.NDArray[np.floating]
|
|
886
|
+
Plume temperature evolution along mixing line, [:math:`K`]
|
|
887
|
+
|
|
888
|
+
Returns
|
|
889
|
+
-------
|
|
890
|
+
npt.NDArray[np.floating]
|
|
891
|
+
Water vapour concentration at water saturated conditions, [:math:`m^{-3}`]
|
|
892
|
+
|
|
893
|
+
Notes
|
|
894
|
+
-----
|
|
895
|
+
- This is approximated based on the ideal gas law: p V = N k_b T, so (N/v) = p / (k_b * T).
|
|
896
|
+
"""
|
|
897
|
+
k_b = 1.381e-23 # Boltzmann constant in m^2 kg s^-2 K^-1
|
|
898
|
+
return thermo.e_sat_liquid(T_plume) / (k_b * T_plume)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def particle_growth_coefficients(
|
|
902
|
+
T_plume: npt.NDArray[np.floating],
|
|
903
|
+
air_pressure: npt.NDArray[np.floating],
|
|
904
|
+
S_mw: npt.NDArray[np.floating],
|
|
905
|
+
n_w_sat: npt.NDArray[np.floating],
|
|
906
|
+
vol_molecule_h2o: float,
|
|
907
|
+
) -> tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]:
|
|
908
|
+
"""Calculate particle growth coefficients, ``b_1`` and ``b_2`` in Karcher et al. (2015).
|
|
909
|
+
|
|
910
|
+
Parameters
|
|
911
|
+
----------
|
|
912
|
+
T_plume : npt.NDArray[np.floating]
|
|
913
|
+
Plume temperature evolution along mixing line, [:math:`K`]
|
|
914
|
+
air_pressure : npt.NDArray[np.floating]
|
|
915
|
+
Pressure altitude at each waypoint, [:math:`Pa`]
|
|
916
|
+
S_mw : npt.NDArray[np.floating]
|
|
917
|
+
Water saturation ratio in the aircraft plume without droplet condensation
|
|
918
|
+
n_w_sat : npt.NDArray[np.floating]
|
|
919
|
+
Droplet number concentration at water saturated conditions, [:math:`m^{-3}`]
|
|
920
|
+
vol_molecule_h2o : float
|
|
921
|
+
Volume of a supercooled water molecule, [:math:`m^{3}`]
|
|
922
|
+
|
|
923
|
+
Returns
|
|
924
|
+
-------
|
|
925
|
+
tuple[npt.NDArray[np.floating], npt.NDArray[np.floating]]
|
|
926
|
+
Particle growth coefficients ``b_1`` and ``b_2``, [:math:`m s^{-1}`]
|
|
927
|
+
|
|
928
|
+
References
|
|
929
|
+
----------
|
|
930
|
+
- ``b_1`` equation is below Eq. (48) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
931
|
+
- ``b_2`` equation is below Eq. (34) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
932
|
+
"""
|
|
933
|
+
r_g = 8.3145 # Global gas constant in m^3 Pa mol^-1 K^-1
|
|
934
|
+
m_w = 18.0e-3 # Molar mass of water in kg mol^-1
|
|
935
|
+
|
|
936
|
+
# Calculate `v_thermal_h2o`, mean thermal velocity of water molecule / m/s
|
|
937
|
+
v_thermal_h2o = np.sqrt((8.0 * r_g * T_plume) / (np.pi * m_w))
|
|
938
|
+
|
|
939
|
+
# Calculate `b_1`
|
|
940
|
+
b_1 = (vol_molecule_h2o * v_thermal_h2o * (S_mw - 1.0) * n_w_sat) / 4.0
|
|
941
|
+
|
|
942
|
+
# Calculate `b_2`
|
|
943
|
+
d_h2o = _water_vapor_molecular_diffusion_coefficient(T_plume, air_pressure)
|
|
944
|
+
b_2 = v_thermal_h2o / (4.0 * d_h2o)
|
|
945
|
+
|
|
946
|
+
return b_1, b_2
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _water_vapor_molecular_diffusion_coefficient(
|
|
950
|
+
T_plume: npt.NDArray[np.floating],
|
|
951
|
+
air_pressure: npt.NDArray[np.floating],
|
|
952
|
+
) -> npt.NDArray[np.floating]:
|
|
953
|
+
"""Calculate water vapor molecular diffusion coefficient.
|
|
954
|
+
|
|
955
|
+
Parameters
|
|
956
|
+
----------
|
|
957
|
+
T_plume : npt.NDArray[np.floating]
|
|
958
|
+
Plume temperature evolution along mixing line, [:math:`K`]
|
|
959
|
+
air_pressure : npt.NDArray[np.floating]
|
|
960
|
+
Pressure altitude at each waypoint, [:math:`Pa`]
|
|
961
|
+
|
|
962
|
+
Returns
|
|
963
|
+
-------
|
|
964
|
+
npt.NDArray[np.floating]
|
|
965
|
+
Water vapor molecular diffusion coefficient
|
|
966
|
+
|
|
967
|
+
References
|
|
968
|
+
----------
|
|
969
|
+
Rogers & Yau: A Short Course in Cloud Physics
|
|
970
|
+
"""
|
|
971
|
+
return (
|
|
972
|
+
0.211
|
|
973
|
+
* (T_plume / (-constants.absolute_zero)) ** 1.94
|
|
974
|
+
* (constants.p_surface / air_pressure)
|
|
975
|
+
* 1e-4
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def water_supersaturation_production_rate(
|
|
980
|
+
T_plume: npt.NDArray[np.floating],
|
|
981
|
+
T_exhaust: npt.NDArray[np.floating],
|
|
982
|
+
T_ambient: npt.NDArray[np.floating],
|
|
983
|
+
dilution: npt.NDArray[np.floating],
|
|
984
|
+
S_mw: npt.NDArray[np.floating],
|
|
985
|
+
tau_m: float,
|
|
986
|
+
beta: float,
|
|
987
|
+
) -> npt.NDArray[np.floating]:
|
|
988
|
+
"""Calculate water supersaturation production rate.
|
|
989
|
+
|
|
990
|
+
Parameters
|
|
991
|
+
----------
|
|
992
|
+
T_plume : npt.NDArray[np.floating]
|
|
993
|
+
Plume temperature evolution along mixing line, [:math:`K`]
|
|
994
|
+
T_exhaust : npt.NDArray[np.floating]
|
|
995
|
+
Aircraft exhaust temperature for each waypoint, [:math:`K`]
|
|
996
|
+
T_ambient : npt.NDArray[np.floating]
|
|
997
|
+
Ambient temperature for each waypoint, [:math:`K`]
|
|
998
|
+
dilution : npt.NDArray[np.floating]
|
|
999
|
+
Plume dilution factor, see `plume_dilution_factor`
|
|
1000
|
+
S_mw : npt.NDArray[np.floating]
|
|
1001
|
+
Water saturation ratio in the aircraft plume without droplet condensation
|
|
1002
|
+
tau_m : float
|
|
1003
|
+
Mixing timescale, i.e., the time for an exhaust volume element at the center of the
|
|
1004
|
+
jet plume to remain unaffected by ambient air entrainment, [:math:`s`]
|
|
1005
|
+
beta : float
|
|
1006
|
+
Plume dilution parameter, set to 0.9
|
|
1007
|
+
|
|
1008
|
+
Returns
|
|
1009
|
+
-------
|
|
1010
|
+
npt.NDArray[np.floating]
|
|
1011
|
+
Water supersaturation production rate (P_w = dS_mw/dt), [:math:`s^{-1}`]
|
|
1012
|
+
"""
|
|
1013
|
+
dT_dt = _plume_cooling_rate(T_exhaust, T_ambient, dilution, tau_m, beta)
|
|
1014
|
+
dS_mw_dT = np.gradient(S_mw, axis=-1) / np.gradient(T_plume, axis=-1)
|
|
1015
|
+
return dS_mw_dT * dT_dt
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _plume_cooling_rate(
|
|
1019
|
+
T_exhaust: npt.NDArray[np.floating],
|
|
1020
|
+
T_ambient: npt.NDArray[np.floating],
|
|
1021
|
+
dilution: npt.NDArray[np.floating],
|
|
1022
|
+
tau_m: float,
|
|
1023
|
+
beta: float,
|
|
1024
|
+
) -> npt.NDArray[np.floating]:
|
|
1025
|
+
"""
|
|
1026
|
+
Calculate plume cooling rate.
|
|
1027
|
+
|
|
1028
|
+
Parameters
|
|
1029
|
+
----------
|
|
1030
|
+
T_exhaust : npt.NDArray[np.floating]
|
|
1031
|
+
Aircraft exhaust temperature for each waypoint, [:math:`K`]
|
|
1032
|
+
T_ambient : npt.NDArray[np.floating]
|
|
1033
|
+
Ambient temperature for each waypoint, [:math:`K`]
|
|
1034
|
+
dilution : npt.NDArray[np.floating]
|
|
1035
|
+
Plume dilution factor, see `plume_dilution_factor`
|
|
1036
|
+
tau_m : float
|
|
1037
|
+
Mixing timescale, i.e., the time for an exhaust volume element at the center of the
|
|
1038
|
+
jet plume to remain unaffected by ambient air entrainment, [:math:`s`]
|
|
1039
|
+
beta : float
|
|
1040
|
+
Plume dilution parameter, set to 0.9
|
|
1041
|
+
|
|
1042
|
+
Returns
|
|
1043
|
+
-------
|
|
1044
|
+
npt.NDArray[np.floating]
|
|
1045
|
+
Plume cooling rate (dT_dt), [:math:`K s^{-1}`]
|
|
1046
|
+
|
|
1047
|
+
References
|
|
1048
|
+
----------
|
|
1049
|
+
Eq. (14) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
1050
|
+
"""
|
|
1051
|
+
return -beta * ((T_exhaust - T_ambient) / tau_m) * dilution ** (1.0 + 1.0 / beta)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def dynamical_regime_parameter(
|
|
1055
|
+
n_available_all: npt.NDArray[np.floating],
|
|
1056
|
+
S_mw: npt.NDArray[np.floating],
|
|
1057
|
+
P_w: npt.NDArray[np.floating],
|
|
1058
|
+
r_act_nw: npt.NDArray[np.floating],
|
|
1059
|
+
b_1: npt.NDArray[np.floating],
|
|
1060
|
+
b_2: npt.NDArray[np.floating],
|
|
1061
|
+
) -> npt.NDArray[np.floating]:
|
|
1062
|
+
"""Calculate dynamical regime parameter.
|
|
1063
|
+
|
|
1064
|
+
Parameters
|
|
1065
|
+
----------
|
|
1066
|
+
n_available_all : npt.NDArray[np.floating]
|
|
1067
|
+
Particle number concentration that can be activated across all particles, [:math:`m^{-3}`]
|
|
1068
|
+
S_mw : npt.NDArray[np.floating]
|
|
1069
|
+
Water saturation ratio in the aircraft plume without droplet condensation
|
|
1070
|
+
P_w : npt.NDArray[np.floating]
|
|
1071
|
+
Water supersaturation production rate (P_w = dS_mw/dt), [:math:`s^{-1}`]
|
|
1072
|
+
r_act_nw : npt.NDArray[np.floating]
|
|
1073
|
+
Number-weighted droplet activation radius, [:math:`m`]
|
|
1074
|
+
b_1 : npt.NDArray[np.floating]
|
|
1075
|
+
Particle growth coefficient, [:math:`m s^{-1}`]
|
|
1076
|
+
b_2 : npt.NDArray[np.floating]
|
|
1077
|
+
Particle growth coefficient, [:math:`m s^{-1}`]
|
|
1078
|
+
|
|
1079
|
+
Returns
|
|
1080
|
+
-------
|
|
1081
|
+
npt.NDArray[np.floating]
|
|
1082
|
+
Dynamical regime parameter (kappa_w)
|
|
1083
|
+
|
|
1084
|
+
References
|
|
1085
|
+
----------
|
|
1086
|
+
Eq. (49) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
1087
|
+
"""
|
|
1088
|
+
tau_act = _droplet_activation_timescale(n_available_all, S_mw, P_w)
|
|
1089
|
+
tau_gw = _droplet_growth_timescale(r_act_nw, b_1, b_2)
|
|
1090
|
+
kappa_w = ((2.0 * b_2 * r_act_nw) / (1.0 + b_2 * r_act_nw)) * (tau_act / tau_gw)
|
|
1091
|
+
kappa_w[kappa_w <= 0.0] = np.nan
|
|
1092
|
+
return kappa_w
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _droplet_activation_timescale(
|
|
1096
|
+
n_available_all: npt.NDArray[np.floating],
|
|
1097
|
+
S_mw: npt.NDArray[np.floating],
|
|
1098
|
+
P_w: npt.NDArray[np.floating],
|
|
1099
|
+
) -> npt.NDArray[np.floating]:
|
|
1100
|
+
"""Calculate water droplet activation timescale.
|
|
1101
|
+
|
|
1102
|
+
Parameters
|
|
1103
|
+
----------
|
|
1104
|
+
n_available_all : npt.NDArray[np.floating]
|
|
1105
|
+
Particle number concentration that can be activated across all particles, [:math:`m^{-3}`]
|
|
1106
|
+
S_mw : npt.NDArray[np.floating]
|
|
1107
|
+
Water saturation ratio in the aircraft plume without droplet condensation
|
|
1108
|
+
P_w : npt.NDArray[np.floating]
|
|
1109
|
+
Water supersaturation production rate (P_w = dS_mw/dt), [:math:`s^{-1}`]
|
|
1110
|
+
|
|
1111
|
+
Returns
|
|
1112
|
+
-------
|
|
1113
|
+
npt.NDArray[np.floating]
|
|
1114
|
+
Water droplet activation timescale (tau_act), [:math:`s`]
|
|
1115
|
+
|
|
1116
|
+
References
|
|
1117
|
+
----------
|
|
1118
|
+
Eq. (47) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
1119
|
+
"""
|
|
1120
|
+
dln_nw_ds_w = np.gradient(np.log(n_available_all), axis=-1) / np.gradient(S_mw - 1.0, axis=-1)
|
|
1121
|
+
return 1.0 / (P_w * dln_nw_ds_w)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _droplet_growth_timescale(
|
|
1125
|
+
r_act_nw: npt.NDArray[np.floating],
|
|
1126
|
+
b_1: npt.NDArray[np.floating],
|
|
1127
|
+
b_2: npt.NDArray[np.floating],
|
|
1128
|
+
) -> npt.NDArray[np.floating]:
|
|
1129
|
+
"""
|
|
1130
|
+
Calculate water droplet growth timescale.
|
|
1131
|
+
|
|
1132
|
+
Parameters
|
|
1133
|
+
----------
|
|
1134
|
+
r_act_nw : npt.NDArray[np.floating]
|
|
1135
|
+
Number-weighted droplet activation radius, [:math:`m`]
|
|
1136
|
+
b_1 : npt.NDArray[np.floating]
|
|
1137
|
+
Particle growth coefficient, [:math:`m s^{-1}`]
|
|
1138
|
+
b_2 : npt.NDArray[np.floating]
|
|
1139
|
+
Particle growth coefficient, [:math:`m s^{-1}`]
|
|
1140
|
+
|
|
1141
|
+
Returns
|
|
1142
|
+
-------
|
|
1143
|
+
npt.NDArray[np.floating]
|
|
1144
|
+
Water droplet growth timescale (tau_gw), [:math:`s`]
|
|
1145
|
+
|
|
1146
|
+
References
|
|
1147
|
+
----------
|
|
1148
|
+
Eq. (48) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
1149
|
+
"""
|
|
1150
|
+
return (1.0 + b_2 * r_act_nw) * (r_act_nw / b_1)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def supersaturation_loss_rate_per_droplet(
|
|
1154
|
+
kappa_w: npt.NDArray[np.floating],
|
|
1155
|
+
r_act_nw: npt.NDArray[np.floating],
|
|
1156
|
+
n_w_sat: npt.NDArray[np.floating],
|
|
1157
|
+
b_1: npt.NDArray[np.floating],
|
|
1158
|
+
b_2: npt.NDArray[np.floating],
|
|
1159
|
+
vol_molecule_h2o: float,
|
|
1160
|
+
) -> npt.NDArray[np.floating]:
|
|
1161
|
+
"""
|
|
1162
|
+
Calculate supersaturation loss rate per droplet.
|
|
1163
|
+
|
|
1164
|
+
Parameters
|
|
1165
|
+
----------
|
|
1166
|
+
kappa_w : npt.NDArray[np.floating]
|
|
1167
|
+
Dynamical regime parameter. See `dynamical_regime_parameter`
|
|
1168
|
+
r_act_nw : npt.NDArray[np.floating]
|
|
1169
|
+
Number-weighted droplet activation radius, [:math:`m`]
|
|
1170
|
+
n_w_sat : npt.NDArray[np.floating]
|
|
1171
|
+
Droplet number concentration at water saturated conditions, [:math:`m^{-3}`]
|
|
1172
|
+
b_1 : npt.NDArray[np.floating]
|
|
1173
|
+
Particle growth coefficient, [:math:`m s^{-1}`]
|
|
1174
|
+
b_2 : npt.NDArray[np.floating]
|
|
1175
|
+
Particle growth coefficient, [:math:`m s^{-1}`]
|
|
1176
|
+
vol_molecule_h2o : float
|
|
1177
|
+
Volume of a supercooled water molecule, [:math:`m^{3}`]
|
|
1178
|
+
|
|
1179
|
+
Returns
|
|
1180
|
+
-------
|
|
1181
|
+
npt.NDArray[np.floating]
|
|
1182
|
+
Supersaturation loss rate per droplet (R_w), [:math:`m^{3} s^{-1}`]
|
|
1183
|
+
|
|
1184
|
+
Notes
|
|
1185
|
+
-----
|
|
1186
|
+
Originally calculated using Eq. (50) of :cite:`karcherMicrophysicalPathwayContrail2015`,
|
|
1187
|
+
but has been updated in :cite:`ponsonbyUpdatedMicrophysicalModel2025` and is now calculated
|
|
1188
|
+
using Eq. (6) and Eq. (7) of :cite:`karcherPhysicallyBasedParameterization2006`.
|
|
1189
|
+
"""
|
|
1190
|
+
delta = b_2 * r_act_nw
|
|
1191
|
+
f_kappa = (3.0 * np.sqrt(kappa_w)) / (
|
|
1192
|
+
2.0 * np.sqrt(1.0 / kappa_w) + np.sqrt((1.0 / kappa_w) + (9.0 / np.pi))
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
c_1 = (4.0 * np.pi * b_1) / (vol_molecule_h2o * n_w_sat * b_2**2)
|
|
1196
|
+
c_2 = delta**2 / (1.0 + delta)
|
|
1197
|
+
c_3 = (
|
|
1198
|
+
1.0
|
|
1199
|
+
- (1.0 / (delta**2))
|
|
1200
|
+
+ (1.0 / (delta**2)) * (((1.0 + delta) ** 2 / 2.0 + (1.0 / kappa_w)) * f_kappa)
|
|
1201
|
+
)
|
|
1202
|
+
return c_1 * c_2 * c_3
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def droplet_activation(
|
|
1206
|
+
n_available_all: npt.NDArray[np.floating],
|
|
1207
|
+
P_w: npt.NDArray[np.floating],
|
|
1208
|
+
R_w: npt.NDArray[np.floating],
|
|
1209
|
+
rho_air: npt.NDArray[np.floating],
|
|
1210
|
+
dilution: npt.NDArray[np.floating],
|
|
1211
|
+
nu_0: float,
|
|
1212
|
+
) -> npt.NDArray[np.floating]:
|
|
1213
|
+
"""
|
|
1214
|
+
Calculate available particles that activate to form water droplets.
|
|
1215
|
+
|
|
1216
|
+
Parameters
|
|
1217
|
+
----------
|
|
1218
|
+
n_available_all : npt.NDArray[np.floating]
|
|
1219
|
+
Particle number concentration entrained in the contrail plume, [:math:`m^{-3}`]
|
|
1220
|
+
P_w : npt.NDArray[np.floating]
|
|
1221
|
+
Water supersaturation production rate (P_w = dS_mw/dt), [:math:`s^{-1}`]
|
|
1222
|
+
R_w : npt.NDArray[np.floating]
|
|
1223
|
+
Supersaturation loss rate per droplet (R_w), [:math:`m^{3} s^{-1}`]
|
|
1224
|
+
rho_air : npt.NDArray[np.floating]
|
|
1225
|
+
Air density at each waypoint, [:math:`kg m^{-3}`]
|
|
1226
|
+
dilution : npt.NDArray[np.floating]
|
|
1227
|
+
Plume dilution factor, see `plume_dilution_factor`
|
|
1228
|
+
nu_0 : float
|
|
1229
|
+
Initial mass-based plume mixing factor, i.e., air-to-fuel ratio, set to 60.0.
|
|
1230
|
+
|
|
1231
|
+
Returns
|
|
1232
|
+
-------
|
|
1233
|
+
npt.NDArray[np.floating]
|
|
1234
|
+
Activated droplet apparent emissions index, [:math:`kg^{-1}`]
|
|
1235
|
+
|
|
1236
|
+
References
|
|
1237
|
+
----------
|
|
1238
|
+
n_2_w -> Eq. (51) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
1239
|
+
f -> Eq. (44) of :cite:`karcherMicrophysicalPathwayContrail2015`.
|
|
1240
|
+
"""
|
|
1241
|
+
# Droplet number concentration required to cause supersaturation relaxation at a given `S_w`
|
|
1242
|
+
n_2_w = P_w / R_w
|
|
1243
|
+
|
|
1244
|
+
# Calculate the droplet activation that is required to quench the plume supersaturation
|
|
1245
|
+
# We will be seeking a root of f := n_available_all - n_2_w, but it's slightly more
|
|
1246
|
+
# economic to work in the log-space (ie, we can get by with fewer T_plume points).
|
|
1247
|
+
f = np.log(n_available_all) - np.log(n_2_w)
|
|
1248
|
+
|
|
1249
|
+
# For some rows, f never changes sign. This is the case when the T_plume range
|
|
1250
|
+
# does not bracket the zero crossing (for example, if T_plume is too coarse).
|
|
1251
|
+
# It's also common that f never attains a positive value when the ambient temperature
|
|
1252
|
+
# is very close to the SAC T_critical saturation unless T_plume is extremely fine.
|
|
1253
|
+
# In this case, n_available_all is essentially constant near the zero crossing,
|
|
1254
|
+
# and we can just take the last value of n_available_all. In the code below,
|
|
1255
|
+
# we fill any nans in the case that attains_positive is False, but we propagate
|
|
1256
|
+
# nans when attains_negative is False.
|
|
1257
|
+
attains_positive = np.any(f > 0.0, axis=-1)
|
|
1258
|
+
attains_negative = np.any(f < 0.0, axis=-1)
|
|
1259
|
+
if not attains_negative.all():
|
|
1260
|
+
n_failures = np.sum(~attains_negative)
|
|
1261
|
+
warnings.warn(
|
|
1262
|
+
f"{n_failures} profiles never attain negative f values, so a zero crossing "
|
|
1263
|
+
"cannot be found. Increase the range of T_plume by setting a higher "
|
|
1264
|
+
"'n_plume_points' value.",
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
# Find the first positive value, then interpolate to estimate the fractional index
|
|
1268
|
+
# at which the zero crossing occurs.
|
|
1269
|
+
i1 = np.argmax(f > 0.0, axis=-1, keepdims=True)
|
|
1270
|
+
i0 = i1 - 1
|
|
1271
|
+
val1 = np.take_along_axis(f, i1, axis=-1)
|
|
1272
|
+
val0 = np.take_along_axis(f, i0, axis=-1)
|
|
1273
|
+
dist = val0 / (val0 - val1)
|
|
1274
|
+
|
|
1275
|
+
# When f never attains a positive value, set i0 and i1 to last negative value
|
|
1276
|
+
# If f never attains a negative value, we pass through a nan
|
|
1277
|
+
cond = attains_negative & ~attains_positive
|
|
1278
|
+
last_negative = np.nanargmax(f[cond], axis=-1, keepdims=True)
|
|
1279
|
+
i0[cond] = last_negative
|
|
1280
|
+
i1[cond] = last_negative
|
|
1281
|
+
dist[cond] = 0.0
|
|
1282
|
+
|
|
1283
|
+
# Extract properties at the point where the supersaturation is quenched by interpolating
|
|
1284
|
+
n_activated_w0 = np.take_along_axis(n_available_all, i0, axis=-1)
|
|
1285
|
+
n_activated_w1 = np.take_along_axis(n_available_all, i1, axis=-1)
|
|
1286
|
+
n_activated_w = (n_activated_w0 + dist * (n_activated_w1 - n_activated_w0))[..., 0]
|
|
1287
|
+
|
|
1288
|
+
rho_air_w0 = np.take_along_axis(rho_air, i0, axis=-1)
|
|
1289
|
+
rho_air_w1 = np.take_along_axis(rho_air, i1, axis=-1)
|
|
1290
|
+
rho_air_w = (rho_air_w0 + dist * (rho_air_w1 - rho_air_w0))[..., 0]
|
|
1291
|
+
|
|
1292
|
+
dilution_w0 = np.take_along_axis(dilution, i0, axis=-1)
|
|
1293
|
+
dilution_w1 = np.take_along_axis(dilution, i1, axis=-1)
|
|
1294
|
+
dilution_w = (dilution_w0 + dist * (dilution_w1 - dilution_w0))[..., 0]
|
|
1295
|
+
|
|
1296
|
+
out = number_concentration_to_emissions_index(n_activated_w, rho_air_w, dilution_w, nu_0=nu_0)
|
|
1297
|
+
out[~attains_negative] = np.nan
|
|
1298
|
+
|
|
1299
|
+
return out
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def number_concentration_to_emissions_index(
|
|
1303
|
+
n_conc: npt.NDArray[np.floating],
|
|
1304
|
+
rho_air: npt.NDArray[np.floating],
|
|
1305
|
+
dilution: npt.NDArray[np.floating],
|
|
1306
|
+
nu_0: float,
|
|
1307
|
+
) -> npt.NDArray[np.floating]:
|
|
1308
|
+
"""
|
|
1309
|
+
Convert particle number concentration to apparent emissions index.
|
|
1310
|
+
|
|
1311
|
+
Parameters
|
|
1312
|
+
----------
|
|
1313
|
+
n_conc : npt.NDArray[np.floating]
|
|
1314
|
+
Particle number concentration entrained in the contrail plume, [:math:`m^{-3}`]
|
|
1315
|
+
rho_air : npt.NDArray[np.floating]
|
|
1316
|
+
Air density at each waypoint, [:math:`kg m^{-3}`]
|
|
1317
|
+
dilution : npt.NDArray[np.floating]
|
|
1318
|
+
Plume dilution factor, see `plume_dilution_factor`
|
|
1319
|
+
nu_0 : float
|
|
1320
|
+
Initial mass-based plume mixing factor, i.e., air-to-fuel ratio, set to 60.0.
|
|
1321
|
+
|
|
1322
|
+
Returns
|
|
1323
|
+
-------
|
|
1324
|
+
npt.NDArray[np.floating]
|
|
1325
|
+
Particle apparent number emissions index, [:math:`kg^{-1}`]
|
|
1326
|
+
"""
|
|
1327
|
+
return (n_conc * nu_0) / (rho_air * dilution)
|