quantmet-tools 0.4.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
quantmet/thermo.py ADDED
@@ -0,0 +1,860 @@
1
+ """Moist thermodynamics, column integrals and apparent temperature, numpy-first.
2
+
3
+ UNITS. Thermodynamic functions take temperature in K, pressure in Pa, specific humidity q and the condensate mass
4
+ fractions ql, qi in kg/kg of moist air, mixing ratios r in kg/kg of dry air, and relative humidity in PERCENT (0-100),
5
+ and return SI. The two apparent-temperature indices keep the units their agencies define them in (heat index:
6
+ degF and %; wind chill: degF and mph); the unit helpers at the end convert. Every function broadcasts.
7
+
8
+ CONSTANTS come from ``quantmet.constants``: RD 287.04, RV 461.5, EPSILON = RD/RV, CPD 1004.7, CPV 1846, CL 4218,
9
+ CI 2106, LV0 2.501e6, LF0 3.337e5, G 9.80665, T0 273.15, P0 1e5. Coefficients that belong to a published fit
10
+ (Magnus, Bolton's 0.2854, Romps's constant set) stay with their formula so that it reproduces the paper.
11
+
12
+ Numerical notes that matter in practice:
13
+ - MAGNUS IS A FIT, AND WHICH FIT MATTERS. Alduchov & Eskridge (1996) coefficients (the default) stay within 0.4 % of
14
+ the reference vapour pressure over -40..50 degC; Bolton's (1980) are within 0.3 % over -30..35 degC and are the ones
15
+ his theta_e formula was fitted with, so ``equivalent_potential_temperature`` uses them internally. Over ice the
16
+ Magnus-type fit uses its own coefficients; "auto" switches at 0 degC. Murphy & Koop (2005) is the reference-quality
17
+ option (and what the Magnus accuracy claims are tested against).
18
+ - PRESSURE-AWARE HUMIDITY. q = eps e / (p - (1 - eps) e) and r = eps e / (p - e) are exact; the familiar
19
+ q ~ r ~ eps e/p is low by (1 - eps) e/p for q and e/p for r: 1.6 % and 4.2 % at a 30 degC dewpoint and 1000 hPa,
20
+ more at lower pressure. Where e >= p (a hot parcel at stratospheric pressure) the saturation humidities are NaN,
21
+ not negative.
22
+ - STULL'S WET BULB ASSUMES SEA-LEVEL PRESSURE. Stull (2011) is a fit at 1013.25 hPa, valid for RH 5-99 % and
23
+ T -20..50 degC except where it is both cold and dry (``wet_bulb_stull`` returns NaN outside RH 5-99 %,
24
+ T -20..50 degC unless strict=False). The psychrometric ``wet_bulb_temperature`` solves the isobaric enthalpy
25
+ balance at the actual pressure (it agrees with the WMO psychrometer formula and with Normand's rule to 0.1-0.3 degC).
26
+ Even at sea level Stull runs up to ~1 degC warm in hot, moderately dry air (+0.8 degC at 40 degC and 30 %); on a
27
+ high plateau the pressure adds to that: at 840 hPa, 40 degC and 15 % the wet bulb is 19.2 degC against Stull's
28
+ 20.8 degC (20.4 degC at sea level, so 1.1 degC of the 1.5 degC is pressure alone) -- in heat-stress work the
29
+ difference between a dangerous and a survivable wet bulb.
30
+ - HEAT INDEX: SIMPLE FORMULA FIRST. The NWS computes Steadman's simple formula, averages it with T, and only if that
31
+ average is >= 80 degF uses the Rothfusz regression with its two adjustments. Rothfusz everywhere is wrong below
32
+ ~80 degF, where it was never fitted: 76.9 degF at 70 degF and 50 % (NWS: 69.1), 78.7 at 60 degF and 30 % (57.1).
33
+ - COLUMN INTEGRALS ARE 0 -> p_s. Layer edges halfway between levels, the top edge at 0 Pa (or ``top``), the bottom at
34
+ the surface pressure; levels below ground get zero weight, so the values a reanalysis extrapolates (or NaN) there
35
+ never enter. Without p_s the lowest level is taken as the surface (half a layer below it).
36
+
37
+ References: Alduchov & Eskridge (1996, J. Appl. Meteor. 35, 601-609); Bolton (1980, Mon. Wea. Rev. 108, 1046-1053);
38
+ Murphy & Koop (2005, Q. J. R. Meteorol. Soc. 131, 1539-1565); Emanuel (1994, Atmospheric Convection, ch. 4);
39
+ Betts (1973, Q. J. R. Meteorol. Soc. 99, 178-196); Tripoli & Cotton (1981, Mon. Wea. Rev. 109, 1094-1102);
40
+ Davies-Jones (2008, Mon. Wea. Rev. 136, 2764-2785); Romps (2017, J. Atmos. Sci. 74, 3891-3900); Stull (2011,
41
+ J. Appl. Meteor. Climatol. 50, 2267-2269); AMS Glossary, "pseudoadiabatic lapse rate"; Rothfusz (1990, NWS SR
42
+ Technical Attachment 90-23) and the NWS heat index equation page; Steadman (1979, J. Appl. Meteor. 18, 861-873);
43
+ Osczevski & Bluestein (2005, Bull. Amer. Meteor. Soc. 86, 1453-1458) for the 2001 wind chill.
44
+
45
+ Requires: numpy, scipy (Lambert W for the Romps LCL).
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import numpy as np
51
+
52
+ from .constants import CI, CL, CPD, CPV, EPSILON, G, KAPPA, LF0, LV0, P0, RD, RV, T0
53
+
54
+ __all__ = [
55
+ # vapour pressure and humidity
56
+ "saturation_vapour_pressure",
57
+ "vapour_pressure",
58
+ "dewpoint",
59
+ "relative_humidity",
60
+ "dewpoint_from_rh",
61
+ "mixing_ratio",
62
+ "specific_humidity",
63
+ "vapour_pressure_from_mixing_ratio",
64
+ "vapour_pressure_from_specific_humidity",
65
+ "specific_humidity_from_mixing_ratio",
66
+ "mixing_ratio_from_specific_humidity",
67
+ "specific_humidity_from_dewpoint",
68
+ "dewpoint_from_specific_humidity",
69
+ "relative_humidity_from_specific_humidity",
70
+ "specific_humidity_from_relative_humidity",
71
+ "saturation_mixing_ratio",
72
+ "saturation_specific_humidity",
73
+ "dewpoint_depression",
74
+ "vapour_pressure_deficit",
75
+ "latent_heat",
76
+ # temperatures
77
+ "exner",
78
+ "potential_temperature",
79
+ "temperature_from_potential_temperature",
80
+ "virtual_temperature",
81
+ "virtual_potential_temperature",
82
+ "density_temperature",
83
+ "air_density",
84
+ "equivalent_temperature",
85
+ "equivalent_potential_temperature",
86
+ "saturation_equivalent_potential_temperature",
87
+ "liquid_water_potential_temperature",
88
+ "ice_liquid_potential_temperature",
89
+ "wet_bulb_potential_temperature",
90
+ "wet_bulb_stull",
91
+ "stull_valid",
92
+ "wet_bulb_temperature",
93
+ # LCL, energies, adiabats
94
+ "lcl_romps",
95
+ "lcl_bolton",
96
+ "dry_static_energy",
97
+ "moist_static_energy",
98
+ "enthalpy",
99
+ "moist_lapse_rate",
100
+ "moist_adiabat",
101
+ # columns
102
+ "column_integral",
103
+ "precipitable_water",
104
+ "column_water",
105
+ "column_moist_static_energy",
106
+ "column_enthalpy",
107
+ "layer_mean_temperature",
108
+ "thickness",
109
+ # apparent temperature and units
110
+ "heat_index",
111
+ "wind_chill",
112
+ "c_to_f",
113
+ "f_to_c",
114
+ "c_to_k",
115
+ "k_to_c",
116
+ "f_to_k",
117
+ "k_to_f",
118
+ "ms_to_kt",
119
+ "kt_to_ms",
120
+ "ms_to_mph",
121
+ "mph_to_ms",
122
+ "kt_to_mph",
123
+ "mph_to_kt",
124
+ ]
125
+
126
+ # --------------------------------------------------------------------------
127
+ # Saturation vapour pressure
128
+ # --------------------------------------------------------------------------
129
+ # Magnus form e_s = c exp(a t / (b + t)), t in degC, c in Pa
130
+ _MAGNUS = {
131
+ ("magnus", "liquid"): (610.94, 17.625, 243.04), # Alduchov & Eskridge (1996) AERK
132
+ ("magnus", "ice"): (611.21, 22.587, 273.86), # Alduchov & Eskridge (1996) AERKi
133
+ ("bolton", "liquid"): (611.2, 17.67, 243.5),
134
+ } # Bolton (1980) eq. 10
135
+
136
+
137
+ def _as(x):
138
+ return np.asarray(x, dtype=float)
139
+
140
+
141
+ def _es_phase(t, phase, formula):
142
+ if formula == "murphy-koop":
143
+ lt = np.log(t)
144
+ if phase == "liquid": # Murphy & Koop (2005) eq. 10, 123 < T < 332 K
145
+ return np.exp(
146
+ 54.842763
147
+ - 6763.22 / t
148
+ - 4.210 * lt
149
+ + 0.000367 * t
150
+ + np.tanh(0.0415 * (t - 218.8)) * (53.878 - 1331.22 / t - 9.44523 * lt + 0.014025 * t)
151
+ )
152
+ if phase == "ice": # Murphy & Koop (2005) eq. 7, T > 110 K
153
+ return np.exp(9.550426 - 5723.265 / t + 3.53068 * lt - 0.00728332 * t)
154
+ raise ValueError(f"phase must be 'liquid', 'ice' or 'auto', not {phase!r}")
155
+ try:
156
+ c, a, b = _MAGNUS[(formula, phase)]
157
+ except KeyError:
158
+ raise ValueError(
159
+ f"no {formula!r} formula over {phase!r} (formulas: magnus [liquid, ice], "
160
+ "bolton [liquid], murphy-koop [liquid, ice])"
161
+ ) from None
162
+ tc = t - T0
163
+ return c * np.exp(a * tc / (b + tc))
164
+
165
+
166
+ def saturation_vapour_pressure(t, phase: str = "liquid", formula: str = "magnus"):
167
+ """Saturation vapour pressure (Pa) at temperature t (K).
168
+
169
+ phase : "liquid", "ice", or "auto" (ice below T0 = 273.15 K, liquid above)
170
+ formula : "magnus" (Alduchov & Eskridge 1996: liquid c, a, b = 610.94 Pa, 17.625, 243.04 degC; ice 611.21 Pa,
171
+ 22.587, 273.86 degC), "bolton" (Bolton 1980: 611.2 Pa, 17.67, 243.5 degC; liquid only) or
172
+ "murphy-koop" (Murphy & Koop 2005, reference accuracy, liquid 123-332 K, ice > 110 K)."""
173
+ t = _as(t)
174
+ if phase == "auto":
175
+ return np.where(t < T0, _es_phase(t, "ice", formula), _es_phase(t, "liquid", formula))
176
+ return _es_phase(t, phase, formula)
177
+
178
+
179
+ def vapour_pressure(td, formula: str = "magnus"):
180
+ """Vapour pressure (Pa) from the dewpoint td (K): e = e_s,liquid(td)."""
181
+ return saturation_vapour_pressure(td, "liquid", formula)
182
+
183
+
184
+ def dewpoint(e, phase: str = "liquid", formula: str = "magnus"):
185
+ """Dewpoint (K) from vapour pressure e (Pa), the inverse of the saturation vapour pressure; phase="ice" gives the
186
+ frost point. Magnus and Bolton invert analytically; Murphy-Koop by Newton iteration from the Magnus value."""
187
+ e = _as(e)
188
+ if phase not in ("liquid", "ice"):
189
+ raise ValueError("dewpoint inverts over 'liquid' (dewpoint) or 'ice' (frost point)")
190
+ base = "bolton" if formula == "bolton" else "magnus"
191
+ if (base, phase) not in _MAGNUS:
192
+ raise ValueError(f"no {formula!r} formula over {phase!r}")
193
+ c, a, b = _MAGNUS[(base, phase)]
194
+ with np.errstate(divide="ignore", invalid="ignore"):
195
+ x = np.log(e / c)
196
+ td = T0 + b * x / (a - x)
197
+ if formula == "murphy-koop":
198
+ for _ in range(6):
199
+ h = 1e-3
200
+ f0 = np.log(_es_phase(td, phase, formula))
201
+ df = (np.log(_es_phase(td + h, phase, formula)) - f0) / h
202
+ td = td - (f0 - np.log(e)) / df
203
+ elif formula not in ("magnus", "bolton"):
204
+ raise ValueError(f"unknown formula {formula!r}")
205
+ return td
206
+
207
+
208
+ def relative_humidity(t, td, phase: str = "liquid", formula: str = "magnus"):
209
+ """Relative humidity (%) = 100 e / e_s(t), with e = e_s,liquid(td). phase selects the saturation reference
210
+ ("liquid", "ice" or "auto"); the dewpoint is always over liquid."""
211
+ return 100.0 * vapour_pressure(td, formula) / saturation_vapour_pressure(t, phase, formula)
212
+
213
+
214
+ def dewpoint_from_rh(t, rh, formula: str = "magnus"):
215
+ """Dewpoint (K) from temperature (K) and relative humidity (%, over liquid)."""
216
+ return dewpoint(_as(rh) / 100.0 * saturation_vapour_pressure(t, "liquid", formula), "liquid", formula)
217
+
218
+
219
+ def mixing_ratio(e, p):
220
+ """Mixing ratio (kg/kg of dry air) r = eps e / (p - e); NaN where e >= p."""
221
+ e = _as(e)
222
+ p = _as(p)
223
+ with np.errstate(divide="ignore", invalid="ignore"):
224
+ return np.where(e < p, EPSILON * e / (p - e), np.nan)
225
+
226
+
227
+ def specific_humidity(e, p):
228
+ """Specific humidity (kg/kg of moist air) q = eps e / (p - (1 - eps) e); NaN where e >= p."""
229
+ e = _as(e)
230
+ p = _as(p)
231
+ with np.errstate(divide="ignore", invalid="ignore"):
232
+ return np.where(e < p, EPSILON * e / (p - (1.0 - EPSILON) * e), np.nan)
233
+
234
+
235
+ def vapour_pressure_from_mixing_ratio(r, p):
236
+ """e = r p / (eps + r) (Pa)."""
237
+ r = _as(r)
238
+ return r * _as(p) / (EPSILON + r)
239
+
240
+
241
+ def vapour_pressure_from_specific_humidity(q, p):
242
+ """e = q p / (eps + (1 - eps) q) (Pa)."""
243
+ q = _as(q)
244
+ return q * _as(p) / (EPSILON + (1.0 - EPSILON) * q)
245
+
246
+
247
+ def specific_humidity_from_mixing_ratio(r):
248
+ """q = r / (1 + r)."""
249
+ r = _as(r)
250
+ return r / (1.0 + r)
251
+
252
+
253
+ def mixing_ratio_from_specific_humidity(q):
254
+ """r = q / (1 - q)."""
255
+ q = _as(q)
256
+ return q / (1.0 - q)
257
+
258
+
259
+ def specific_humidity_from_dewpoint(td, p, formula: str = "magnus"):
260
+ """q (kg/kg) from dewpoint (K) and pressure (Pa)."""
261
+ return specific_humidity(vapour_pressure(td, formula), p)
262
+
263
+
264
+ def dewpoint_from_specific_humidity(q, p, formula: str = "magnus"):
265
+ """Dewpoint (K) from q (kg/kg) and pressure (Pa)."""
266
+ return dewpoint(vapour_pressure_from_specific_humidity(q, p), "liquid", formula)
267
+
268
+
269
+ def relative_humidity_from_specific_humidity(t, q, p, phase: str = "liquid", formula: str = "magnus"):
270
+ """RH (%) = 100 e / e_s(t) from q (kg/kg), t (K), p (Pa)."""
271
+ return 100.0 * vapour_pressure_from_specific_humidity(q, p) / saturation_vapour_pressure(t, phase, formula)
272
+
273
+
274
+ def specific_humidity_from_relative_humidity(t, rh, p, phase: str = "liquid", formula: str = "magnus"):
275
+ """q (kg/kg) from RH (%), t (K), p (Pa)."""
276
+ return specific_humidity(_as(rh) / 100.0 * saturation_vapour_pressure(t, phase, formula), p)
277
+
278
+
279
+ def saturation_mixing_ratio(t, p, phase: str = "liquid", formula: str = "magnus"):
280
+ """Saturation mixing ratio r_s (kg/kg) over liquid, ice or "auto"; NaN where e_s >= p."""
281
+ return mixing_ratio(saturation_vapour_pressure(t, phase, formula), p)
282
+
283
+
284
+ def saturation_specific_humidity(t, p, phase: str = "liquid", formula: str = "magnus"):
285
+ """Saturation specific humidity q_s (kg/kg) over liquid, ice or "auto"; NaN where e_s >= p."""
286
+ return specific_humidity(saturation_vapour_pressure(t, phase, formula), p)
287
+
288
+
289
+ def dewpoint_depression(t, td):
290
+ """T - Td (K)."""
291
+ return _as(t) - _as(td)
292
+
293
+
294
+ def vapour_pressure_deficit(t, td=None, rh=None, formula: str = "magnus"):
295
+ """Vapour pressure deficit e_s(t) - e (Pa), from the dewpoint td (K) or the relative humidity rh (%)."""
296
+ es = saturation_vapour_pressure(t, "liquid", formula)
297
+ if td is not None:
298
+ return es - vapour_pressure(td, formula)
299
+ if rh is not None:
300
+ return es * (1.0 - _as(rh) / 100.0)
301
+ raise ValueError("give td or rh")
302
+
303
+
304
+ def latent_heat(t, kind: str = "vaporisation"):
305
+ """Latent heat (J/kg) at t (K) by Kirchhoff's law with constant heat capacities:
306
+ vaporisation LV0 + (CPV - CL)(t - T0), fusion LF0 + (CL - CI)(t - T0), sublimation the sum."""
307
+ t = _as(t)
308
+ lv = LV0 + (CPV - CL) * (t - T0)
309
+ lf = LF0 + (CL - CI) * (t - T0)
310
+ if kind == "vaporisation":
311
+ return lv
312
+ if kind == "fusion":
313
+ return lf
314
+ if kind == "sublimation":
315
+ return lv + lf
316
+ raise ValueError("kind is 'vaporisation', 'fusion' or 'sublimation'")
317
+
318
+
319
+ # --------------------------------------------------------------------------
320
+ # Potential, virtual, density and equivalent temperatures
321
+ # --------------------------------------------------------------------------
322
+ def _kappa(q):
323
+ if q is None:
324
+ return KAPPA
325
+ q = _as(q)
326
+ return ((1.0 - q) * RD + q * RV) / ((1.0 - q) * CPD + q * CPV)
327
+
328
+
329
+ def exner(p, p0: float = P0, q=None):
330
+ """Exner function (p / p0)^kappa, kappa = RD/CPD, or the moist kappa when q is given."""
331
+ return (_as(p) / p0) ** _kappa(q)
332
+
333
+
334
+ def potential_temperature(t, p, q=None, p0: float = P0):
335
+ """theta = T (p0 / p)^kappa (K). Dry kappa = RD/CPD = 0.2857 by default; with q (kg/kg) the exact Poisson exponent
336
+ of moist air, kappa_m = ((1-q) RD + q RV) / ((1-q) CPD + q CPV), conserved by an unsaturated adiabat of fixed q."""
337
+ return _as(t) * (p0 / _as(p)) ** _kappa(q)
338
+
339
+
340
+ def temperature_from_potential_temperature(theta, p, q=None, p0: float = P0):
341
+ """T = theta (p / p0)^kappa (K), the inverse of potential_temperature."""
342
+ return _as(theta) * (_as(p) / p0) ** _kappa(q)
343
+
344
+
345
+ def virtual_temperature(t, q):
346
+ """T_v = T (1 + (1/eps - 1) q) (K), exact for specific humidity q (no condensate)."""
347
+ return _as(t) * (1.0 + (1.0 / EPSILON - 1.0) * _as(q))
348
+
349
+
350
+ def virtual_potential_temperature(t, p, q, p0: float = P0):
351
+ """theta_v = T_v (p0 / p)^(RD/CPD) (K)."""
352
+ return potential_temperature(virtual_temperature(t, q), p, p0=p0)
353
+
354
+
355
+ def density_temperature(t, q, ql=0.0, qi=0.0):
356
+ """Density temperature T_rho = T (1 - q_t + q/eps) (K), q_t = q + ql + qi: the temperature dry air would need to
357
+ have the density of the cloudy air (condensate loading included). Equals T_v when ql = qi = 0 (Emanuel 1994)."""
358
+ q = _as(q)
359
+ qt = q + _as(ql) + _as(qi)
360
+ return _as(t) * (1.0 - qt + q / EPSILON)
361
+
362
+
363
+ def air_density(p, t, q=0.0, ql=0.0, qi=0.0):
364
+ """Density of moist (and cloudy) air (kg/m3) = p / (RD T_rho)."""
365
+ return _as(p) / (RD * density_temperature(t, q, ql, qi))
366
+
367
+
368
+ def equivalent_temperature(t, q):
369
+ """Isobaric equivalent temperature T_e = T + LV0 r / CPD (K), r = q/(1-q) (AMS Glossary)."""
370
+ return _as(t) + LV0 * mixing_ratio_from_specific_humidity(q) / CPD
371
+
372
+
373
+ def _t_lcl_bolton(t, td):
374
+ """Bolton (1980) eq. 15: temperature (K) at the lifting condensation level from T and Td (K)."""
375
+ return 1.0 / (1.0 / (td - 56.0) + np.log(t / td) / 800.0) + 56.0
376
+
377
+
378
+ def equivalent_potential_temperature(t, td, p, method: str = "bolton", rt=None):
379
+ """Equivalent potential temperature (K) from temperature t (K), dewpoint td (K) and pressure p (Pa).
380
+
381
+ method "bolton" -- pseudo-adiabatic, Bolton (1980) eq. 39, the most accurate of his formulas:
382
+ theta_E = T (p0/(p - e))^0.2854 (T/T_L)^(0.28 r) exp[(3036/T_L - 1.78) r (1 + 0.448 r)],
383
+ r in kg/kg, T_L from eq. 15, e from Bolton's own Magnus coefficients (the ones the fit used).
384
+ method "reversible" -- Emanuel (1994, sec. 4.5), conserved in reversible moist adiabatic processes:
385
+ theta_e = T (p0/p_d)^(RD/c) H^(-r RV/c) exp[L_v(T) r / (c T)], c = CPD + r_t CL,
386
+ H = e/e_s(T), p_d = p - e, r_t the total-water mixing ratio (default r: no condensate).
387
+ The two differ by a few K at high water content: a pseudo-adiabat drops its condensate (and its heat capacity)."""
388
+ t = _as(t)
389
+ td = _as(td)
390
+ p = _as(p)
391
+ if method == "bolton":
392
+ e = saturation_vapour_pressure(td, "liquid", "bolton")
393
+ r = EPSILON * e / (p - e)
394
+ tl = _t_lcl_bolton(t, td)
395
+ theta_dl = t * (P0 / (p - e)) ** 0.2854 * (t / tl) ** (0.28 * r)
396
+ return theta_dl * np.exp((3036.0 / tl - 1.78) * r * (1.0 + 0.448 * r))
397
+ if method == "reversible":
398
+ e = vapour_pressure(td)
399
+ r = EPSILON * e / (p - e)
400
+ rt = r if rt is None else _as(rt)
401
+ c = CPD + rt * CL
402
+ h = e / saturation_vapour_pressure(t)
403
+ return t * (P0 / (p - e)) ** (RD / c) * h ** (-r * RV / c) * np.exp(latent_heat(t) * r / (c * t))
404
+ raise ValueError("method is 'bolton' or 'reversible'")
405
+
406
+
407
+ def saturation_equivalent_potential_temperature(t, p, method: str = "bolton"):
408
+ """theta_e of saturated air at (t, p): equivalent_potential_temperature with td = t."""
409
+ return equivalent_potential_temperature(t, t, p, method)
410
+
411
+
412
+ def liquid_water_potential_temperature(t, p, ql, p0: float = P0):
413
+ """Liquid-water potential temperature (Betts 1973), theta_l = theta - (theta/T)(LV0/CPD) ql (K): conserved to
414
+ first order under reversible condensation and evaporation; the shallow-cloud LES/boundary-layer variable."""
415
+ th = potential_temperature(t, p, p0=p0)
416
+ return th - th / _as(t) * (LV0 / CPD) * _as(ql)
417
+
418
+
419
+ def ice_liquid_potential_temperature(t, p, ql, qi, p0: float = P0):
420
+ """Ice-liquid water potential temperature (Tripoli & Cotton 1981),
421
+ theta_il = theta / (1 + (LV0 ql + LS0 qi) / (CPD max(T, 253 K))) (K); the 253 K floor is theirs."""
422
+ th = potential_temperature(t, p, p0=p0)
423
+ return th / (1.0 + (LV0 * _as(ql) + (LV0 + LF0) * _as(qi)) / (CPD * np.maximum(_as(t), 253.0)))
424
+
425
+
426
+ def wet_bulb_potential_temperature(t, td, p):
427
+ """Wet-bulb potential temperature theta_w (K): the 1000 hPa temperature of the pseudo-adiabat through the parcel,
428
+ by Davies-Jones (2008, eq. 3.8) from the Bolton (1980, eq. 39) theta_E: with X = theta_E / 273.15,
429
+ theta_w = theta_E - exp(A(X) / B(X)), A, B quartics (coefficients below), theta_w = theta_E below 173.15 K.
430
+ Accurate to ~0.001 K against the inversion of Bolton's theta_E (tested)."""
431
+ te = equivalent_potential_temperature(t, td, p, "bolton")
432
+ x = te / 273.15
433
+ a = 7.101574 - 20.68208 * x + 16.11182 * x**2 + 2.574631 * x**3 - 5.205688 * x**4
434
+ b = 1.0 - 3.552497 * x + 3.781782 * x**2 - 0.6899655 * x**3 - 0.5929340 * x**4
435
+ return np.where(te <= 173.15, te, te - np.exp(a / b))
436
+
437
+
438
+ # --------------------------------------------------------------------------
439
+ # Wet bulb
440
+ # --------------------------------------------------------------------------
441
+ def stull_valid(t, rh):
442
+ """Validity domain of Stull (2011): RH 5-99 % and T -20..50 degC (t in K). Outside it the fit's error grows
443
+ quickly (and it never knew about pressure: it is a fit at 1013.25 hPa)."""
444
+ tc = _as(t) - T0
445
+ rh = _as(rh)
446
+ return (rh >= 5.0) & (rh <= 99.0) & (tc >= -20.0) & (tc <= 50.0)
447
+
448
+
449
+ def wet_bulb_stull(t, rh, strict: bool = True):
450
+ """Wet-bulb temperature (K) by Stull (2011) from t (K) and RH (%):
451
+ Tw = T atan(0.151977 (RH + 8.313659)^1/2) + atan(T + RH) - atan(RH - 1.676331)
452
+ + 0.00391838 RH^3/2 atan(0.023101 RH) - 4.686035 (T, Tw in degC)
453
+ Stull quotes -1 to +0.65 degC against his reference; against the isobaric psychrometric solution
454
+ (wet_bulb_temperature) at 1013.25 hPa it is -1.2 to +1.1 degC for T >= 0 degC (mean |error| 0.34 degC), and worse
455
+ in cold dry air. It is a fit AT SEA-LEVEL PRESSURE. strict: NaN outside RH 5-99 %, T -20..50 degC (stull_valid);
456
+ strict=False evaluates the fit everywhere. For any other pressure use wet_bulb_temperature."""
457
+ tc = _as(t) - T0
458
+ rh = _as(rh)
459
+ tw = (
460
+ tc * np.arctan(0.151977 * np.sqrt(rh + 8.313659))
461
+ + np.arctan(tc + rh)
462
+ - np.arctan(rh - 1.676331)
463
+ + 0.00391838 * rh**1.5 * np.arctan(0.023101 * rh)
464
+ - 4.686035
465
+ ) + T0
466
+ return np.where(stull_valid(t, rh), tw, np.nan) if strict else tw
467
+
468
+
469
+ def wet_bulb_temperature(t, p, td=None, rh=None, formula: str = "magnus", tol: float = 1e-6):
470
+ """Isobaric (psychrometric) wet-bulb temperature (K) at pressure p (Pa), from t (K) and the dewpoint td (K) or RH
471
+ (%). Solves the enthalpy balance of adiabatic saturation at constant pressure,
472
+ (CPD + r CPV)(T - Tw) = (r_s(Tw, p) - r) L_v(Tw),
473
+ exact for ideal gases with constant heat capacities; bracketed between Td (where the right side is 0) and T, and
474
+ bisected to `tol` K. Vectorised."""
475
+ t = _as(t)
476
+ p = _as(p)
477
+ if td is None:
478
+ if rh is None:
479
+ raise ValueError("give td or rh")
480
+ td = dewpoint_from_rh(t, rh, formula)
481
+ td = np.minimum(_as(td), t)
482
+ r = mixing_ratio(vapour_pressure(td, formula), p)
483
+ lo, hi = np.broadcast_arrays(td, t)
484
+ lo = lo.copy()
485
+ hi = hi.copy()
486
+ cp = CPD + r * CPV
487
+
488
+ def f(tw):
489
+ return cp * (t - tw) - (saturation_mixing_ratio(tw, p, "liquid", formula) - r) * latent_heat(tw)
490
+
491
+ n = int(np.ceil(np.log2(max(float(np.nanmax(hi - lo, initial=0.0)), tol) / tol))) + 1
492
+ for _ in range(n):
493
+ mid = 0.5 * (lo + hi)
494
+ pos = f(mid) > 0
495
+ lo = np.where(pos, mid, lo)
496
+ hi = np.where(pos, hi, mid)
497
+ return 0.5 * (lo + hi)
498
+
499
+
500
+ # --------------------------------------------------------------------------
501
+ # Lifting condensation level
502
+ # --------------------------------------------------------------------------
503
+ # Romps (2017) constant set: his exact expression is exact for HIS saturation vapour pressure (Rankine-Kirchhoff
504
+ # with these constants), so they are kept together to reproduce the paper's values.
505
+ ROMPS2017 = dict(
506
+ ttrip=273.16,
507
+ ptrip=611.65,
508
+ e0v=2.3740e6,
509
+ e0s=0.3337e6,
510
+ g=9.81,
511
+ rgasa=287.04,
512
+ rgasv=461.0,
513
+ cva=719.0,
514
+ cvv=1418.0,
515
+ cvl=4119.0,
516
+ cvs=1861.0,
517
+ )
518
+
519
+
520
+ def lcl_romps(t, p, rh, phase: str = "liquid"):
521
+ """Exact lifting condensation level (Romps 2017) from t (K), p (Pa) and RH (%).
522
+
523
+ Returns (t_lcl K, p_lcl Pa, z_lcl m above the parcel). phase "liquid" gives the LCL; "ice" the lifting
524
+ deposition level (RH then over ice); "auto" ice below the triple point. Uses Romps's own constants (ROMPS2017):
525
+ exactness is relative to his saturation vapour pressure, which RH is taken against (differences from the
526
+ Magnus RH are ~0.1 %); the Lambert W branch -1 is evaluated by scipy.special.lambertw."""
527
+ from scipy.special import lambertw
528
+
529
+ c_ = ROMPS2017
530
+ t = _as(t)
531
+ p = _as(p)
532
+ rh = _as(rh) / 100.0
533
+ cpa = c_["cva"] + c_["rgasa"]
534
+ cpv = c_["cvv"] + c_["rgasv"]
535
+
536
+ def branch(cvx, e0):
537
+ pstar = (
538
+ c_["ptrip"]
539
+ * (t / c_["ttrip"]) ** ((cpv - cvx) / c_["rgasv"])
540
+ * np.exp((e0 - (c_["cvv"] - cvx) * c_["ttrip"]) / c_["rgasv"] * (1.0 / c_["ttrip"] - 1.0 / t))
541
+ )
542
+ pv = rh * pstar
543
+ qv = c_["rgasa"] * pv / (c_["rgasv"] * p + (c_["rgasa"] - c_["rgasv"]) * pv)
544
+ rgasm = (1.0 - qv) * c_["rgasa"] + qv * c_["rgasv"]
545
+ cpm = (1.0 - qv) * cpa + qv * cpv
546
+ a = cpm / rgasm + (cvx - cpv) / c_["rgasv"]
547
+ b = -(e0 - (c_["cvv"] - cvx) * c_["ttrip"]) / (c_["rgasv"] * t)
548
+ c = b / a
549
+ tl = t * c / np.real(lambertw(rh ** (1.0 / a) * c * np.exp(c), -1))
550
+ return tl, p * (tl / t) ** (cpm / rgasm), cpm * (t - tl) / c_["g"]
551
+
552
+ liq = branch(c_["cvl"], c_["e0v"])
553
+ if phase == "liquid":
554
+ return liq
555
+ ice = branch(c_["cvs"], c_["e0v"] + c_["e0s"])
556
+ if phase == "ice":
557
+ return ice
558
+ if phase == "auto":
559
+ cold = t < c_["ttrip"]
560
+ return tuple(np.where(cold, i, w) for i, w in zip(ice, liq))
561
+ raise ValueError("phase is 'liquid', 'ice' or 'auto'")
562
+
563
+
564
+ def lcl_bolton(t, td, p):
565
+ """LCL temperature (Bolton 1980 eq. 15) and pressure (Poisson with Bolton's 0.2854 (1 - 0.28 r)) from t, td (K),
566
+ p (Pa): returns (t_lcl K, p_lcl Pa). Within ~0.1 K of the exact (Romps) T_LCL at ordinary humidities."""
567
+ t = _as(t)
568
+ td = _as(td)
569
+ p = _as(p)
570
+ tl = _t_lcl_bolton(t, td)
571
+ r = mixing_ratio(saturation_vapour_pressure(td, "liquid", "bolton"), p)
572
+ return tl, p * (tl / t) ** (1.0 / (0.2854 * (1.0 - 0.28 * r)))
573
+
574
+
575
+ # --------------------------------------------------------------------------
576
+ # Static energy, enthalpy, the pseudo-adiabat
577
+ # --------------------------------------------------------------------------
578
+ def dry_static_energy(t, z):
579
+ """s = CPD T + G z (J/kg), z the geopotential height (m)."""
580
+ return CPD * _as(t) + G * _as(z)
581
+
582
+
583
+ def moist_static_energy(t, z, q):
584
+ """h = CPD T + G z + LV0 q (J/kg), z the geopotential height (m), q specific humidity: the conventional form,
585
+ conserved to within a few tenths of a per cent in moist adiabatic processes."""
586
+ return CPD * _as(t) + G * _as(z) + LV0 * _as(q)
587
+
588
+
589
+ def enthalpy(t, q=0.0, ql=0.0, qi=0.0, t_ref: float = T0):
590
+ """Specific enthalpy of moist air (J per kg of moist air), constant heat capacities.
591
+
592
+ REFERENCE STATE: dry air and liquid water have zero enthalpy at t_ref (default T0 = 273.15 K); vapour then carries
593
+ L_v(t_ref) and ice -L_f(t_ref) (Kirchhoff latent heats), so
594
+ h = q_d CPD (T - Tr) + q (CPV (T - Tr) + L_v(Tr)) + ql CL (T - Tr) + qi (CI (T - Tr) - L_f(Tr)),
595
+ q_d = 1 - q - ql - qi. Differences and fluxes do not depend on the reference when water is conserved; totals do
596
+ -- state it whenever a column total is shown. With q = ql = qi = 0 this is the dry enthalpy CPD (T - Tr)."""
597
+ t = _as(t)
598
+ q = _as(q)
599
+ ql = _as(ql)
600
+ qi = _as(qi)
601
+ dt = t - t_ref
602
+ qd = 1.0 - q - ql - qi
603
+ return (
604
+ qd * CPD * dt
605
+ + q * (CPV * dt + latent_heat(t_ref))
606
+ + ql * CL * dt
607
+ + qi * (CI * dt - latent_heat(t_ref, "fusion"))
608
+ )
609
+
610
+
611
+ def _dt_dlnp_pseudo(t, p, formula):
612
+ """dT/dln p along the pseudo-adiabat (liquid saturation), the AMS Glossary form with L_v(T) and p_d = p - e_s."""
613
+ es = saturation_vapour_pressure(t, "liquid", formula)
614
+ r = EPSILON * es / (p - es)
615
+ lv = latent_heat(t)
616
+ num = RD * t + lv * r
617
+ den = CPD + r * CPV + lv * lv * r * (EPSILON + r) / (RD * t * t)
618
+ return p / (p - es) * num / den
619
+
620
+
621
+ def moist_lapse_rate(t, p, formula: str = "magnus"):
622
+ """Pseudo-adiabatic (saturated) lapse rate -dT/dz (K/m) at t (K), p (Pa) (AMS Glossary):
623
+ G (1 + r)(1 + L_v r/(RD T)) / (CPD + r CPV + L_v^2 r (eps + r)/(RD T^2)), r = r_s(T, p), L_v = L_v(T).
624
+ 9.8 K/km for dry air (r -> 0: G/CPD), ~3.5 K/km in warm saturated boundary layers."""
625
+ t = _as(t)
626
+ p = _as(p)
627
+ r = saturation_mixing_ratio(t, p, "liquid", formula)
628
+ lv = latent_heat(t)
629
+ return G * (1.0 + r) * (1.0 + lv * r / (RD * t)) / (CPD + r * CPV + lv * lv * r * (EPSILON + r) / (RD * t * t))
630
+
631
+
632
+ def moist_adiabat(t_start, p_start, p, formula: str = "magnus", dlnp: float = 0.002):
633
+ """Temperature (K) along the pseudo-adiabat through (t_start K, p_start Pa) at the pressures p (Pa, 1-D, any
634
+ order, either side of p_start). RK4 in ln p with steps of at most `dlnp`; t_start and p_start broadcast, giving
635
+ output (len(p),) + their broadcast shape."""
636
+ t0, p0 = np.broadcast_arrays(_as(t_start), _as(p_start))
637
+ p = np.atleast_1d(_as(p))
638
+ out = np.empty((p.size, *t0.shape))
639
+ tc = t0.astype(float).copy()
640
+ lnp = np.log(p0.astype(float))
641
+ # The pseudo-adiabat is a first-order ODE in ln p, so the levels can be visited in any order (each leg may run up
642
+ # or down, point by point); descending pressure keeps the legs short.
643
+ for k in np.argsort(-p):
644
+ target = np.log(p[k])
645
+ n = max(1, int(np.ceil(np.max(np.abs(target - lnp)) / dlnp)))
646
+ h = (target - lnp) / n
647
+ for _ in range(n):
648
+ pp = np.exp(lnp)
649
+ k1 = _dt_dlnp_pseudo(tc, pp, formula)
650
+ k2 = _dt_dlnp_pseudo(tc + 0.5 * h * k1, pp * np.exp(0.5 * h), formula)
651
+ k3 = _dt_dlnp_pseudo(tc + 0.5 * h * k2, pp * np.exp(0.5 * h), formula)
652
+ k4 = _dt_dlnp_pseudo(tc + h * k3, pp * np.exp(h), formula)
653
+ tc = tc + h * (k1 + 2 * k2 + 2 * k3 + k4) / 6.0
654
+ lnp = lnp + h
655
+ out[k] = tc
656
+ return out
657
+
658
+
659
+ # --------------------------------------------------------------------------
660
+ # Column integrals on pressure (or model) levels
661
+ # --------------------------------------------------------------------------
662
+ def _layer_dp(p, ps, top, shape):
663
+ """dp (Pa) per level, level axis first, broadcastable to `shape`: edges midway between levels, the top edge at
664
+ `top`, the bottom at ps (clipped) -- or at the lowest level when ps is None."""
665
+ p = _as(p)
666
+ if p.ndim == 1:
667
+ p = p.reshape((-1,) + (1,) * (len(shape) - 1))
668
+ o = np.argsort(p, axis=0)
669
+ q = np.take_along_axis(p, o, 0)
670
+ mid = 0.5 * (q[1:] + q[:-1])
671
+ top_e = np.full_like(q[:1], float(top))
672
+ bot_e = q[-1:].copy() if ps is None else np.full_like(q[:1], np.inf)
673
+ edges = np.concatenate([top_e, mid, bot_e], 0)
674
+ if ps is not None:
675
+ edges = np.minimum(edges, _as(ps)[None])
676
+ dps = np.clip(np.diff(edges, axis=0), 0.0, None)
677
+ dp = np.empty(np.broadcast_shapes(dps.shape, o.shape))
678
+ np.put_along_axis(dp, np.broadcast_to(o, dp.shape), np.broadcast_to(dps, dp.shape), 0)
679
+ return dp
680
+
681
+
682
+ def column_integral(x, p, ps=None, axis: int = 0, top: float = 0.0):
683
+ """Mass-weighted vertical integral sum_k x_k dp_k / G (x units times kg/m2).
684
+
685
+ x : (..., lev, ...) with the level axis `axis`
686
+ p : level pressures (Pa), 1-D (pressure levels) or x's shape (model levels; any order along the axis)
687
+ ps : surface pressure (Pa), x's shape without the level axis; None = the lowest level is the surface.
688
+ Edges are midway between levels, the top edge at `top` (Pa), the bottom at ps; levels below ground get zero weight
689
+ and whatever they hold (extrapolated values, NaN) is ignored. NaN above ground propagates."""
690
+ x0 = np.moveaxis(_as(x), axis, 0)
691
+ pp = _as(p)
692
+ if pp.ndim > 1:
693
+ pp = np.moveaxis(pp, axis, 0)
694
+ dp = np.broadcast_to(_layer_dp(pp, ps, top, x0.shape), x0.shape)
695
+ with np.errstate(invalid="ignore"):
696
+ return np.where(dp > 0, x0 * dp, 0.0).sum(0) / G
697
+
698
+
699
+ def precipitable_water(q, p, ps=None, axis: int = 0, top: float = 0.0):
700
+ """Total column water vapour / precipitable water (kg/m2 = mm), the integral of q dp/G from `top` to p_s."""
701
+ return column_integral(q, p, ps, axis, top)
702
+
703
+
704
+ def column_water(p, ps=None, q=None, ql=None, qi=None, axis: int = 0, top: float = 0.0) -> dict:
705
+ """Column vapour, liquid and ice (kg/m2) for whichever of q, ql, qi are given, plus their 'total'."""
706
+ out = {}
707
+ for name, v in (("vapour", q), ("liquid", ql), ("ice", qi)):
708
+ if v is not None:
709
+ out[name] = column_integral(v, p, ps, axis, top)
710
+ if not out:
711
+ raise ValueError("give at least one of q, ql, qi")
712
+ out["total"] = sum(out.values())
713
+ return out
714
+
715
+
716
+ def column_moist_static_energy(t, z, q, p, ps=None, axis: int = 0, top: float = 0.0):
717
+ """Column-integrated moist static energy (J/m2), the integral of (CPD T + G z + LV0 q) dp/G."""
718
+ return column_integral(moist_static_energy(t, z, q), p, ps, axis, top)
719
+
720
+
721
+ def column_enthalpy(t, p, ps=None, q=0.0, ql=0.0, qi=0.0, axis: int = 0, top: float = 0.0, t_ref: float = T0):
722
+ """Column-integrated moist enthalpy (J/m2) with the reference state of `enthalpy` (zero for dry air and liquid
723
+ water at t_ref)."""
724
+ return column_integral(enthalpy(t, q, ql, qi, t_ref), p, ps, axis, top)
725
+
726
+
727
+ def _lnp_weights(p, p_bottom, p_top):
728
+ """Weights c_k with sum_k c_k f_k = integral of f d(ln p) from p_top to p_bottom, f linear in ln p between
729
+ levels. No extrapolation."""
730
+ lp = np.log(_as(p))
731
+ o = np.argsort(lp)
732
+ x = lp[o]
733
+ a, b = np.log(p_top), np.log(p_bottom)
734
+ if not (b > a):
735
+ raise ValueError("p_bottom must exceed p_top")
736
+ if a < x[0] - 1e-12 or b > x[-1] + 1e-12:
737
+ raise ValueError("the layer must lie within the levels given (no extrapolation)")
738
+ c = np.zeros(x.size)
739
+ for k in range(x.size - 1):
740
+ u, v = max(a, x[k]), min(b, x[k + 1])
741
+ if v <= u:
742
+ continue
743
+ h = x[k + 1] - x[k]
744
+ c[k] += ((x[k + 1] - u) ** 2 - (x[k + 1] - v) ** 2) / (2 * h)
745
+ c[k + 1] += ((v - x[k]) ** 2 - (u - x[k]) ** 2) / (2 * h)
746
+ out = np.empty_like(c)
747
+ out[o] = c
748
+ return out, b - a
749
+
750
+
751
+ def layer_mean_temperature(t, p, p_bottom: float, p_top: float, q=None, axis: int = 0):
752
+ """ln p-weighted mean temperature of the layer p_top..p_bottom (Pa), the hypsometric mean: the integral of T d ln p
753
+ over ln(p_bottom/p_top), T linear in ln p between the 1-D levels p (Pa). Virtual temperature when q is given."""
754
+ tt = _as(t) if q is None else virtual_temperature(t, q)
755
+ c, span = _lnp_weights(p, p_bottom, p_top)
756
+ return np.tensordot(c, np.moveaxis(tt, axis, 0), axes=(0, 0)) / span
757
+
758
+
759
+ def thickness(t, p, p_bottom: float, p_top: float, q=None, axis: int = 0):
760
+ """Hypsometric thickness (m) of the layer p_top..p_bottom (Pa): (RD/G) <T_v> ln(p_bottom/p_top), with <T_v> from
761
+ layer_mean_temperature (pass q for the virtual correction; without it, the dry thickness)."""
762
+ return RD / G * layer_mean_temperature(t, p, p_bottom, p_top, q, axis) * np.log(p_bottom / p_top)
763
+
764
+
765
+ # --------------------------------------------------------------------------
766
+ # Apparent temperature (NWS)
767
+ # --------------------------------------------------------------------------
768
+ def heat_index(t_f, rh):
769
+ """NWS heat index (degF) from air temperature (degF) and RH (%), exactly as the NWS specifies:
770
+ 1. the simple formula HI = 0.5 (T + 61 + 1.2 (T - 68) + 0.094 RH) (Steadman 1979);
771
+ 2. if the average of that and T is >= 80 degF, the Rothfusz (1990) regression instead,
772
+ HI = -42.379 + 2.04901523 T + 10.14333127 RH - 0.22475541 T RH - 6.83783e-3 T^2 - 5.481717e-2 RH^2
773
+ + 1.22874e-3 T^2 RH + 8.5282e-4 T RH^2 - 1.99e-6 T^2 RH^2,
774
+ minus ((13 - RH)/4) sqrt((17 - |T - 95|)/17) if RH < 13 % and 80 <= T <= 112 degF,
775
+ plus ((RH - 85)/10) ((87 - T)/5) if RH > 85 % and 80 <= T <= 87 degF."""
776
+ t = _as(t_f)
777
+ r = _as(rh)
778
+ simple = 0.5 * (t + 61.0 + (t - 68.0) * 1.2 + r * 0.094)
779
+ full = (
780
+ -42.379
781
+ + 2.04901523 * t
782
+ + 10.14333127 * r
783
+ - 0.22475541 * t * r
784
+ - 6.83783e-3 * t * t
785
+ - 5.481717e-2 * r * r
786
+ + 1.22874e-3 * t * t * r
787
+ + 8.5282e-4 * t * r * r
788
+ - 1.99e-6 * t * t * r * r
789
+ )
790
+ dry = (r < 13.0) & (t >= 80.0) & (t <= 112.0)
791
+ full = full - np.where(dry, (13.0 - r) / 4.0 * np.sqrt(np.clip(17.0 - np.abs(t - 95.0), 0.0, None) / 17.0), 0.0)
792
+ wet = (r > 85.0) & (t >= 80.0) & (t <= 87.0)
793
+ full = full + np.where(wet, (r - 85.0) / 10.0 * (87.0 - t) / 5.0, 0.0)
794
+ return np.where(0.5 * (simple + t) >= 80.0, full, simple)
795
+
796
+
797
+ def wind_chill(t_f, wind_mph):
798
+ """NWS (2001) wind chill temperature (degF) from air temperature (degF) and 10 m wind (mph):
799
+ WC = 35.74 + 0.6215 T - 35.75 V^0.16 + 0.4275 T V^0.16 for T <= 50 degF and V >= 3 mph, else T
800
+ (Osczevski & Bluestein 2005; the wind is the 10 m value, the formula reduces it to face height itself)."""
801
+ t = _as(t_f)
802
+ v = _as(wind_mph)
803
+ v16 = np.power(np.maximum(v, 0.0), 0.16)
804
+ wc = 35.74 + 0.6215 * t - 35.75 * v16 + 0.4275 * t * v16
805
+ return np.where((t <= 50.0) & (v >= 3.0), wc, t)
806
+
807
+
808
+ # --------------------------------------------------------------------------
809
+ # Units
810
+ # --------------------------------------------------------------------------
811
+ _KT = 1852.0 / 3600.0 # m/s per knot (exact)
812
+ _MPH = 0.44704 # m/s per mph (exact)
813
+
814
+
815
+ def c_to_f(x):
816
+ return _as(x) * 9.0 / 5.0 + 32.0
817
+
818
+
819
+ def f_to_c(x):
820
+ return (_as(x) - 32.0) * 5.0 / 9.0
821
+
822
+
823
+ def c_to_k(x):
824
+ return _as(x) + T0
825
+
826
+
827
+ def k_to_c(x):
828
+ return _as(x) - T0
829
+
830
+
831
+ def f_to_k(x):
832
+ return f_to_c(x) + T0
833
+
834
+
835
+ def k_to_f(x):
836
+ return c_to_f(_as(x) - T0)
837
+
838
+
839
+ def ms_to_kt(x):
840
+ return _as(x) / _KT
841
+
842
+
843
+ def kt_to_ms(x):
844
+ return _as(x) * _KT
845
+
846
+
847
+ def ms_to_mph(x):
848
+ return _as(x) / _MPH
849
+
850
+
851
+ def mph_to_ms(x):
852
+ return _as(x) * _MPH
853
+
854
+
855
+ def kt_to_mph(x):
856
+ return _as(x) * _KT / _MPH
857
+
858
+
859
+ def mph_to_kt(x):
860
+ return _as(x) * _MPH / _KT