sounding_analysis/
fire.rs

1//! Module for analysis related to fire weather and wildfire plumes.
2
3use crate::{
4    error::{AnalysisError, Result},
5    parcel::Parcel,
6    sounding::Sounding,
7};
8use itertools::{izip, Itertools};
9use metfor::{
10    Celsius, CelsiusDiff, GigaWatts, HectoPascal, Kelvin, KelvinDiff, Meters, MetersPSec, Quantity,
11    WindSpdDir,
12};
13
14/// The Hot-Dry-Windy index
15///
16/// # References
17///
18/// Srock AF, Charney JJ, Potter BE, Goodrick SL. The Hot-Dry-Windy Index: A New Fire Weather Index.
19///     Atmosphere. 2018; 9(7):279. https://doi.org/10.3390/atmos9070279
20///
21/// McDonald JM, Srock AF, Charney JJ. Development and Application of a Hot-Dry-Windy Index (HDW)
22/// Climatology. Atmosphere. 2018; 9: 285. https://doi.org/10.3390/atmos9070285
23#[inline]
24pub fn hot_dry_windy(snd: &Sounding) -> Result<f64> {
25    let elevation = if let Some(sfc_h) = snd.station_info().elevation().into_option() {
26        sfc_h
27    } else if let Some(lowest_h) = snd
28        .height_profile()
29        .iter()
30        .filter_map(|optd| optd.into_option())
31        .next()
32    {
33        lowest_h
34    } else {
35        return Err(AnalysisError::NotEnoughData);
36    };
37
38    let sfc_pressure: HectoPascal = snd
39        .pressure_profile()
40        .iter()
41        .filter_map(|p| p.into_option())
42        .next()
43        .ok_or(AnalysisError::NotEnoughData)?;
44
45    let p_profile = snd.pressure_profile();
46    let h_profile = snd.height_profile();
47    let t_profile = snd.temperature_profile();
48    let dp_profile = snd.dew_point_profile();
49    let ws_profile = snd.wind_profile();
50
51    let (vpd, ws) = izip!(p_profile, h_profile, t_profile, dp_profile, ws_profile)
52        // Remove rows with missing data
53        .filter(|(p, h, t, dp, ws)| {
54            p.is_some() && h.is_some() && t.is_some() && dp.is_some() && ws.is_some()
55        })
56        // Unpack from the Optioned type
57        .map(|(p, h, t, dp, ws)| {
58            (
59                p.unpack(),
60                h.unpack(),
61                t.unpack(),
62                dp.unpack(),
63                ws.unpack().speed,
64            )
65        })
66        // Only look up to 500 m above AGL
67        .take_while(|(_, h, _, _, _)| *h <= elevation + Meters(500.0))
68        // Calculate the surface adjusted temperature - for the surface adjusted VPD
69        .map(|(p, _, t, dp, ws)| {
70            (
71                p,
72                metfor::temperature_from_pot_temp(
73                    metfor::potential_temperature(p, t),
74                    sfc_pressure,
75                ),
76                dp,
77                ws,
78            )
79        })
80        // Calculate the surface adjusted dew point - for the surface adjusted VPD
81        .filter_map(|(p, t, dp, ws)| {
82            metfor::specific_humidity(dp, p)
83                .and_then(|q| metfor::dew_point_from_p_and_specific_humidity(sfc_pressure, q))
84                .map(|dp| (t, dp, ws))
85        })
86        // Convert t and dp to VPD
87        .filter_map(|(t, dp, ws)| {
88            metfor::vapor_pressure_water(t)
89                .and_then(|sat_vap| metfor::vapor_pressure_water(dp).map(|vap| (sat_vap - vap, ws)))
90        })
91        // Convert knots to m/s and unpack all values from their Quantity types
92        .map(|(vpd, ws)| (vpd.unpack(), MetersPSec::from(ws).unpack()))
93        // Choose the max.
94        .fold((0.0, 0.0), |(vpd_max, ws_max), (vpd, ws)| {
95            (vpd.max(vpd_max), ws.max(ws_max))
96        });
97
98    Ok(vpd * ws)
99}
100
101/// A collection of parameters associated with a Pyrocumulonimbus Firepower Threshold (PFT)
102/// analysis. See [pft] or [pft_analysis] for details.
103#[derive(Clone, Debug)]
104pub struct PFTAnalysis {
105    /// The fire power required to cause a pyrocumulonimbus in Gigawatts. This is not the power per
106    /// unit area, but the total power.
107    pub pft: GigaWatts,
108    /// The height weighted average potential temperature in the mixed layer.
109    pub theta_ml: Kelvin,
110    /// The height weighted average specific humidity in the mixed layer.
111    pub q_ml: f64,
112    /// The pressure at the top of the mixed layer.
113    pub p_top_ml: HectoPascal,
114    /// The pressure at the bottom of the pyroCb, where free convection starts.
115    pub p_fc: HectoPascal,
116    /// Coordinates along the SP-curve suitable for plotting on a skew-t log-p chart.
117    pub sp_curve: Vec<(HectoPascal, Celsius)>,
118    /// The minimum equivalent potential temperature of the plume element required to initiate a
119    /// pyrocumulonimbus cloud.
120    pub theta_e_fc: Kelvin,
121}
122
123/// Calculate the Pyrocumulonimbus Firepower Threshold (PFT).
124///
125/// The first reference below (Tory & Kepert, 2021) has most of the details about how to calculate
126/// the PFT, the other paper (Tory et. al, 2018) outlines the model in general.
127///
128/// # References
129///
130/// Tory, K. J., & Kepert, J. D. (2021). **Pyrocumulonimbus Firepower Threshold: Assessing the
131///     Atmospheric Potential for pyroCb**, Weather and Forecasting, 36(2), 439-456. Retrieved Jun 2,
132///     2021, from https://journals.ametsoc.org/view/journals/wefo/36/2/WAF-D-20-0027.1.xml
133///
134/// Tory, K. J., Thurston, W., & Kepert, J. D. (2018). **Thermodynamics of Pyrocumulus: A Conceptual
135///     Study**, Monthly Weather Review, 146(8), 2579-2598. Retrieved Jun 2, 2021, from
136///     https://journals.ametsoc.org/view/journals/mwre/146/8/mwr-d-17-0377.1.xml
137///
138/// # Arguments
139///  - snd is the sounding on which to do this calculation.
140///  - moisture_ratio is the amount of heating required to add 1 g/kg to the specific humidity.
141///
142pub fn pft_analysis(snd: &Sounding, moisture_ratio: f64) -> Result<PFTAnalysis> {
143    let (theta_ml, q_ml, _z_ml, p_sfc, p_top_ml) = entrained_mixed_layer(snd)?;
144
145    let (z_fc, p_fc, theta_fc, d_theta_fc, theta_e_fc, sp_curve) =
146        free_convection_level(snd, moisture_ratio, theta_ml, q_ml)?;
147
148    let u_ml: MetersPSec = entrained_layer_mean_wind_speed(z_fc, snd)?;
149
150    let pft = metfor::pft(z_fc, p_fc, u_ml, d_theta_fc, theta_fc, p_sfc);
151
152    Ok(PFTAnalysis {
153        pft,
154        theta_ml,
155        q_ml,
156        p_top_ml,
157        p_fc,
158        sp_curve,
159        theta_e_fc,
160    })
161}
162
163/// Calculate the Pyrocumulonimbus Firepower Threshold (PFT).
164///
165/// The first reference below (Tory & Kepert, 2021) has most of the details about how to calculate
166/// the PFT, the other paper (Tory et. al, 2018) outlines the model in general.
167///
168/// # References
169///
170/// Tory, K. J., & Kepert, J. D. (2021). **Pyrocumulonimbus Firepower Threshold: Assessing the
171///     Atmospheric Potential for pyroCb**, Weather and Forecasting, 36(2), 439-456. Retrieved Jun 2,
172///     2021, from https://journals.ametsoc.org/view/journals/wefo/36/2/WAF-D-20-0027.1.xml
173///
174/// Tory, K. J., Thurston, W., & Kepert, J. D. (2018). **Thermodynamics of Pyrocumulus: A Conceptual
175///     Study**, Monthly Weather Review, 146(8), 2579-2598. Retrieved Jun 2, 2021, from
176///     https://journals.ametsoc.org/view/journals/mwre/146/8/mwr-d-17-0377.1.xml
177///
178/// # Arguments
179///  - snd is the sounding on which to do this calculation.
180///  - moisture_ratio is the amount of heating required to add 1 g/kg to the specific humidity.
181///
182pub fn pft(snd: &Sounding, moisture_ratio: f64) -> Result<GigaWatts> {
183    let (theta_ml, q_ml, _z_ml, p_sfc, _p_top_ml) = entrained_mixed_layer(snd)?;
184
185    let (z_fc, p_fc, theta_fc, d_theta_fc, _theta_e, _sp_curve) =
186        free_convection_level(snd, moisture_ratio, theta_ml, q_ml)?;
187
188    let u_ml: MetersPSec = entrained_layer_mean_wind_speed(z_fc, snd)?;
189
190    Ok(metfor::pft(z_fc, p_fc, u_ml, d_theta_fc, theta_fc, p_sfc))
191}
192
193/// This is step 1 from page 11 of Tory & Kepert, 2021.
194///
195/// We also find the pressure level and height as they may be useful in later steps of the
196/// algorithm.
197///
198/// # Returns
199///
200/// A tuple with the linear-height-weighted average potential temperature, linear-height-weighted
201/// average specific humidity, height AGL of the top, and pressure of the top of the mixing layer.
202fn entrained_mixed_layer(
203    snd: &Sounding,
204) -> Result<(Kelvin, f64, Meters, HectoPascal, HectoPascal)> {
205    let elevation: Meters = snd
206        .station_info()
207        .elevation()
208        .ok_or(AnalysisError::NotEnoughData)?;
209
210    let mut ps = snd.pressure_profile();
211    let mut zs = snd.height_profile();
212    let mut ts = snd.temperature_profile();
213    let mut dps = snd.dew_point_profile();
214
215    // Check if the first two levels are the same level, and skip one if they are.
216    if zs.len() >= 2 && zs[0] == zs[1] {
217        ps = &ps[1..];
218        zs = &zs[1..];
219        ts = &ts[1..];
220        dps = &dps[1..];
221    }
222
223    let bottom_p = ps
224        .iter()
225        .filter_map(|&p| p.into_option())
226        .next()
227        .ok_or(AnalysisError::NotEnoughData)?;
228
229    let max_p = bottom_p - HectoPascal(50.0);
230
231    match itertools::izip!(ps, zs, ts, dps)
232        // Filter out levels with missing data
233        .filter(|(p, z, t, dp)| p.is_some() && z.is_some() && t.is_some() && dp.is_some())
234        // Unpack from the optional::Optioned type
235        .map(|(p, z, t, dp)| (p.unpack(), z.unpack(), t.unpack(), dp.unpack()))
236        // Map to height above ground.
237        .map(|(p, z, t, dp)| (p, z - elevation, t, dp))
238        // Transform to potential temperature
239        .map(|(p, h, t, dp)| (p, h, metfor::potential_temperature(p, t), dp))
240        // Transform to specific humidity
241        .filter_map(|(p, h, theta, dp)| metfor::specific_humidity(dp, p).map(|q| (p, h, theta, q)))
242        // Pair them up for integration with the trapezoid rule
243        .tuple_windows::<(_, _)>()
244        // Make a running integral
245        .scan(
246            (0.0, 0.0),
247            |state, ((_p0, h0, theta0, q0), (p1, h1, theta1, q1))| {
248                let (sum_theta, sum_q): (&mut f64, &mut f64) = (&mut state.0, &mut state.1);
249
250                let dh = (h1 - h0).unpack(); // meters
251                debug_assert!(dh > 0.0);
252
253                *sum_theta += (theta0.unpack() * h0.unpack() + theta1.unpack() * h1.unpack()) * dh;
254                *sum_q += (q0 * h0.unpack() + q1 * h1.unpack()) * dh;
255
256                // Divide by 2 for trapezoid rule and divide by 1/2 Z^2 for height weighting function
257                let h_sq = h1.unpack() * h1.unpack();
258                let avg_theta = Kelvin(*sum_theta / h_sq);
259                let avg_q = *sum_q / h_sq;
260
261                Some((p1, h1, avg_theta, avg_q))
262            },
263        )
264        // Don't even start looking until we have at least a minimum thickness mixed layer.
265        .filter(|(p1, _h1, _avg_theta, _avg_q)| *p1 <= max_p)
266        // Convert into a parcel so we can lift it..
267        .filter_map(|(p1, h1, avg_theta, avg_q)| {
268            let temperature = Celsius::from(metfor::temperature_from_pot_temp(avg_theta, bottom_p));
269            let dew_point = metfor::dew_point_from_p_and_specific_humidity(bottom_p, avg_q)?;
270
271            let pcl = Parcel {
272                temperature,
273                dew_point,
274                pressure: bottom_p,
275            };
276
277            Some((p1, h1, avg_theta, avg_q, pcl))
278        })
279        // Get the LCL pressure
280        .filter_map(|(p1, h1, avg_theta, avg_q, pcl)| {
281            metfor::pressure_at_lcl(pcl.temperature, pcl.dew_point, pcl.pressure)
282                .map(|lcl_pres| (p1, h1, avg_theta, avg_q, lcl_pres))
283        })
284        // Pair up to compare layers
285        .tuple_windows::<(_, _)>()
286        // find where the mixed layer depth is just below the LCL
287        .find(
288            |(
289                (p0, _h0, _avg_theta0, _avg_q0, lcl_pres0),
290                (p1, _h1, _avg_theta1, _avg_q1, lcl_pres1),
291            )| p0 >= lcl_pres0 && p1 < lcl_pres1,
292        ) {
293        Some(((p0, h0, avg_theta0, avg_q0, _), _)) => Ok((avg_theta0, avg_q0, h0, bottom_p, p0)),
294        None => Err(AnalysisError::FailedPrerequisite),
295    }
296}
297
298/// This is steps 3 and 4 from page 11 of Tory & Kepert, 2021.
299///
300/// We skipped step 2 to get here. Basically, use the max virtual temperature above z_ml
301/// (or p_top_ml) to find the theta-e that is above that temperature by the threshold amount AND
302/// tops out on the sounding at at least -20C or colder. Once we know what theta-e value we need,
303/// we can find the level where that intersects the S-P curve.
304///
305/// Once we have our point on the S-P curve, zfc and d_theta_fc are easy to calculate.
306fn free_convection_level(
307    snd: &Sounding,
308    moisture_ratio: f64,
309    theta_ml: Kelvin,
310    q_ml: f64,
311) -> Result<(
312    Meters,
313    HectoPascal,
314    Kelvin,
315    KelvinDiff,
316    Kelvin,
317    Vec<(HectoPascal, Celsius)>,
318)> {
319    const SP_BETA_MAX: f64 = 0.25;
320    const DELTA_BETA: f64 = 0.001;
321
322    let apply_beta = move |beta| {
323        let theta_sp = Kelvin((1.0 + beta) * theta_ml.unpack());
324        let q_sp = q_ml + beta / moisture_ratio / 1000.0 * theta_ml.unpack();
325
326        (theta_sp, q_sp)
327    };
328
329    let mut sp_curve: Vec<(HectoPascal, Celsius)> = Vec::with_capacity(100);
330
331    let mut low_beta: f64 = f64::NAN;
332    let mut high_beta: f64 = f64::NAN;
333    let mut beta = 0.0;
334    let mut i = 0;
335    while beta <= SP_BETA_MAX {
336        let (theta_sp, q_sp) = apply_beta(beta);
337
338        let skew_t_coords =
339            potential_t_and_specific_humidity_to_pressure_and_temperature(theta_sp, q_sp)?;
340
341        sp_curve.push(skew_t_coords);
342
343        let (p, t) = skew_t_coords;
344
345        let sp_theta_e = match metfor::equiv_pot_temperature(t, t, p) {
346            Some(val) => val,
347            None => continue,
348        };
349
350        let meets_theta_e_requirements = is_free_convecting(snd, p, sp_theta_e)?;
351
352        if !meets_theta_e_requirements && high_beta.is_nan() {
353            low_beta = beta;
354        } else if meets_theta_e_requirements && high_beta.is_nan() {
355            high_beta = beta;
356        }
357
358        if !high_beta.is_nan() && i >= 100 {
359            break;
360        }
361
362        beta += DELTA_BETA;
363        i += 1;
364    }
365
366    if high_beta.is_nan() || low_beta.is_nan() {
367        return Err(AnalysisError::FailedPrerequisite);
368    }
369
370    let beta = metfor::find_root(
371        &|b| {
372            let (theta_sp, q_sp) = apply_beta(b);
373            let (p, t) =
374                potential_t_and_specific_humidity_to_pressure_and_temperature(theta_sp, q_sp)
375                    .ok()?;
376            let sp_theta_e = metfor::equiv_pot_temperature(t, t, p)?;
377
378            Some(
379                (min_temperature_diff_to_max_cloud_top_temperature(snd, p, sp_theta_e).ok()?
380                    - TEMPERATURE_BUFFER)
381                    .unpack(),
382            )
383        },
384        low_beta,
385        high_beta,
386    )
387    .ok_or(AnalysisError::FailedPrerequisite)?;
388
389    let (theta_sp, q_sp) = apply_beta(beta);
390    let (p_lfc, t_lfc) =
391        potential_t_and_specific_humidity_to_pressure_and_temperature(theta_sp, q_sp)?;
392    let theta_e_lfc = metfor::equiv_pot_temperature(t_lfc, t_lfc, p_lfc)
393        .ok_or(AnalysisError::FailedPrerequisite)?;
394
395    let dtheta = theta_sp - theta_ml;
396
397    let heights = snd.height_profile();
398    let pressures = snd.pressure_profile();
399    let height_asl_lfc = crate::interpolation::linear_interpolate(pressures, heights, p_lfc)
400        .into_option()
401        .ok_or(AnalysisError::InterpolationError)?;
402    let sfc_height = heights
403        .iter()
404        .filter_map(|h| h.into_option())
405        .next()
406        .ok_or(AnalysisError::NotEnoughData)?;
407
408    Ok((
409        height_asl_lfc - sfc_height,
410        p_lfc,
411        theta_sp,
412        dtheta,
413        theta_e_lfc,
414        sp_curve,
415    ))
416}
417
418fn potential_t_and_specific_humidity_to_pressure_and_temperature(
419    theta: Kelvin,
420    specific_humidity: f64,
421) -> Result<(HectoPascal, Celsius)> {
422    let diff = |p_hpa: f64| -> Option<f64> {
423        let t = metfor::temperature_from_pot_temp(theta, HectoPascal(p_hpa));
424        let dp =
425            metfor::dew_point_from_p_and_specific_humidity(HectoPascal(p_hpa), specific_humidity)?;
426
427        Some((t - dp).unpack())
428    };
429
430    let p = metfor::find_root(
431        &diff,
432        HectoPascal(1080.0).unpack(),
433        HectoPascal(100.0).unpack(),
434    )
435    .map(HectoPascal)
436    .ok_or(AnalysisError::FailedPrerequisite)?;
437
438    let t = Celsius::from(metfor::temperature_from_pot_temp(theta, p));
439
440    Ok((p, t))
441}
442
443/// Get the minimum difference between this theta-e and all those up to -20C.
444fn min_temperature_diff_to_max_cloud_top_temperature(
445    snd: &Sounding,
446    starting_pressure: HectoPascal,
447    starting_theta_e: Kelvin,
448) -> Result<CelsiusDiff> {
449    const MAX_PLUME_TOP_T: Celsius = Celsius(-20.0);
450
451    let pp = snd.pressure_profile();
452    let tp = snd.temperature_profile();
453    let dpp = snd.dew_point_profile();
454
455    let min_diff: CelsiusDiff = izip!(pp, tp, dpp)
456        // Skip levels with missing data.
457        .filter(|(p, t, dp)| p.is_some() && t.is_some() && dp.is_some())
458        //Unpack optioned values.
459        .map(|(p, t, dp)| (p.unpack(), t.unpack(), dp.unpack()))
460        // Skip below the top of the mixed layer.
461        .skip_while(|(p, _t, _dp)| *p > starting_pressure)
462        // Get the parcel temperature
463        .filter_map(|(p, t, dp)| {
464            metfor::temperature_from_equiv_pot_temp_saturated_and_pressure(p, starting_theta_e)
465                .map(|pcl_t| (p, t, dp, pcl_t))
466        })
467        // Ensure we get a least 2 levels and only go up to MAX_PLUME_TOP_T
468        .enumerate()
469        .take_while(|(i, (_p, _t, _dp,  pcl_t))| *i < 2 || *pcl_t >= MAX_PLUME_TOP_T)
470        // Unpack the enumerate
471        .map(|(_i, row)| row)
472        // Get the virtual temperature of the environment
473        .filter_map(|(p, t, dp, pcl_t)| metfor::virtual_temperature(t, dp, p).map(|vt| (p, vt, pcl_t)))
474        // Get the virtual temperature of the parcel
475        .filter_map(|(p, vt, pcl_t)| metfor::virtual_temperature(pcl_t, pcl_t, p).map(|pcl_vt| (vt, pcl_vt)))
476        // Find the minimum difference
477        .fold(CelsiusDiff(500.0), |min_diff, (vt, pcl_vt)| {
478            CelsiusDiff::from(pcl_vt - vt).min(min_diff)
479        });
480
481    if min_diff == CelsiusDiff(500.0) {
482        Err(AnalysisError::FailedPrerequisite)
483    } else {
484        Ok(min_diff)
485    }
486}
487
488/// Find the maximum equivalent_potential_temperature above the mixed layer that interesects the
489/// sounding at or above the -20C level.
490const TEMPERATURE_BUFFER: CelsiusDiff = CelsiusDiff(0.5);
491fn is_free_convecting(
492    snd: &Sounding,
493    starting_pressure: HectoPascal,
494    starting_theta_e: Kelvin,
495) -> Result<bool> {
496    Ok(
497        min_temperature_diff_to_max_cloud_top_temperature(
498            snd,
499            starting_pressure,
500            starting_theta_e,
501        )? >= TEMPERATURE_BUFFER,
502    )
503}
504
505/// Find the mean wind in the entrainment layer.
506///
507/// Remember, zfc is the level AGL.
508fn entrained_layer_mean_wind_speed(zfc: Meters, snd: &Sounding) -> Result<MetersPSec> {
509    let layer = crate::layer_agl(snd, zfc)?;
510
511    let mean_wind = crate::wind::mean_wind(&layer, snd)?;
512    let mean_wind = WindSpdDir::from(mean_wind).speed;
513
514    Ok(mean_wind)
515}