pycontrails 0.54.11__cp311-cp311-macosx_11_0_arm64.whl → 0.55.0__cp311-cp311-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.

@@ -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: npt.NDArray[np.floating],
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 : npt.NDArray[np.floating]
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, vpm_ei_n = np.atleast_1d(
475
+ specific_humidity, T_ambient, T_exhaust, air_pressure, G, nvpm_ei_n, vpm_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: npt.NDArray[np.floating],
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 : npt.NDArray[np.floating]
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],
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]
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)