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 mean wind speed in the mixed layer
113    pub u_ml: MetersPSec,
114    /// The pressure at the top of the mixed layer.
115    pub p_top_ml: HectoPascal,
116    /// The pressure at the bottom of the pyroCb, where free convection starts.
117    pub p_fc: HectoPascal,
118    /// The height above ground ob the bottom of the pyroCb, where free convection starts.
119    pub z_fc: Meters,
120    /// The extra potential temperature needed for a pyroCb to form.
121    pub d_theta: KelvinDiff,
122    /// Coordinates along the SP-curve suitable for plotting on a skew-t log-p chart.
123    pub sp_curve: Vec<(HectoPascal, Celsius)>,
124    /// The minimum equivalent potential temperature of the plume element required to initiate a
125    /// pyrocumulonimbus cloud.
126    pub theta_e_fc: Kelvin,
127    /// Coordinates dry parcel that intersects the SP-curve at the free convection level
128    /// suitable for plotting on a skew-t log-p chart.
129    pub theta_curve: Vec<(HectoPascal, Celsius)>,
130}
131
132/// Calculate the Pyrocumulonimbus Firepower Threshold (PFT).
133///
134/// The first reference below (Tory & Kepert, 2021) has most of the details about how to calculate
135/// the PFT, the other paper (Tory et. al, 2018) outlines the model in general.
136///
137/// # References
138///
139/// Tory, K. J., & Kepert, J. D. (2021). **Pyrocumulonimbus Firepower Threshold: Assessing the
140///     Atmospheric Potential for pyroCb**, Weather and Forecasting, 36(2), 439-456. Retrieved Jun 2,
141///     2021, from https://journals.ametsoc.org/view/journals/wefo/36/2/WAF-D-20-0027.1.xml
142///
143/// Tory, K. J., Thurston, W., & Kepert, J. D. (2018). **Thermodynamics of Pyrocumulus: A Conceptual
144///     Study**, Monthly Weather Review, 146(8), 2579-2598. Retrieved Jun 2, 2021, from
145///     https://journals.ametsoc.org/view/journals/mwre/146/8/mwr-d-17-0377.1.xml
146///
147/// # Arguments
148///  - snd is the sounding on which to do this calculation.
149///  - moisture_ratio is the amount of heating required to add 1 g/kg to the specific humidity.
150///
151pub fn pft_analysis(snd: &Sounding, moisture_ratio: f64) -> Result<PFTAnalysis> {
152    let (theta_ml, q_ml, _z_ml, p_sfc, p_top_ml) = entrained_mixed_layer(snd)?;
153
154    let (z_fc, p_fc, theta_fc, d_theta_fc, theta_e_fc, sp_curve, theta_curve) =
155        free_convection_level(snd, moisture_ratio, theta_ml, q_ml)?;
156
157    let u_ml: MetersPSec = entrained_layer_mean_wind_speed(z_fc, snd)?;
158
159    let pft = metfor::pft(z_fc, p_fc, u_ml, d_theta_fc, theta_fc, p_sfc);
160
161    Ok(PFTAnalysis {
162        pft,
163        theta_ml,
164        q_ml,
165        u_ml,
166        p_top_ml,
167        p_fc,
168        z_fc,
169        d_theta: d_theta_fc,
170        sp_curve,
171        theta_e_fc,
172        theta_curve,
173    })
174}
175
176/// Calculate the Pyrocumulonimbus Firepower Threshold (PFT).
177///
178/// The first reference below (Tory & Kepert, 2021) has most of the details about how to calculate
179/// the PFT, the other paper (Tory et. al, 2018) outlines the model in general.
180///
181/// # References
182///
183/// Tory, K. J., & Kepert, J. D. (2021). **Pyrocumulonimbus Firepower Threshold: Assessing the
184///     Atmospheric Potential for pyroCb**, Weather and Forecasting, 36(2), 439-456. Retrieved Jun 2,
185///     2021, from https://journals.ametsoc.org/view/journals/wefo/36/2/WAF-D-20-0027.1.xml
186///
187/// Tory, K. J., Thurston, W., & Kepert, J. D. (2018). **Thermodynamics of Pyrocumulus: A Conceptual
188///     Study**, Monthly Weather Review, 146(8), 2579-2598. Retrieved Jun 2, 2021, from
189///     https://journals.ametsoc.org/view/journals/mwre/146/8/mwr-d-17-0377.1.xml
190///
191/// # Arguments
192///  - snd is the sounding on which to do this calculation.
193///  - moisture_ratio is the amount of heating required to add 1 g/kg to the specific humidity.
194///
195pub fn pft(snd: &Sounding, moisture_ratio: f64) -> Result<GigaWatts> {
196    let (theta_ml, q_ml, _z_ml, p_sfc, _p_top_ml) = entrained_mixed_layer(snd)?;
197
198    let (z_fc, p_fc, theta_fc, d_theta_fc, _theta_e, _sp_curve, _theta_curve) =
199        free_convection_level(snd, moisture_ratio, theta_ml, q_ml)?;
200
201    let u_ml: MetersPSec = entrained_layer_mean_wind_speed(z_fc, snd)?;
202
203    Ok(metfor::pft(z_fc, p_fc, u_ml, d_theta_fc, theta_fc, p_sfc))
204}
205
206/// This is step 1 from page 11 of Tory & Kepert, 2021.
207///
208/// We also find the pressure level and height as they may be useful in later steps of the
209/// algorithm.
210///
211/// # Returns
212///
213/// A tuple with the linear-height-weighted average potential temperature, linear-height-weighted
214/// average specific humidity, height AGL of the top, and pressure of the bottom and top of the
215/// mixing layer.
216pub(crate) fn entrained_mixed_layer(
217    snd: &Sounding,
218) -> Result<(Kelvin, f64, Meters, HectoPascal, HectoPascal)> {
219    let elevation: Meters = snd
220        .station_info()
221        .elevation()
222        .ok_or(AnalysisError::NotEnoughData)?;
223
224    let mut ps = snd.pressure_profile();
225    let mut zs = snd.height_profile();
226    let mut ts = snd.temperature_profile();
227    let mut dps = snd.dew_point_profile();
228
229    // Check if the first two levels are the same level, and skip one if they are.
230    if zs.len() >= 2 && zs[0] == zs[1] {
231        ps = &ps[1..];
232        zs = &zs[1..];
233        ts = &ts[1..];
234        dps = &dps[1..];
235    }
236
237    let bottom_p = ps
238        .iter()
239        .filter_map(|&p| p.into_option())
240        .next()
241        .ok_or(AnalysisError::NotEnoughData)?;
242
243    let max_p = bottom_p - HectoPascal(50.0);
244
245    match itertools::izip!(ps, zs, ts, dps)
246        // Filter out levels with missing data
247        .filter(|(p, z, t, dp)| p.is_some() && z.is_some() && t.is_some() && dp.is_some())
248        // Unpack from the optional::Optioned type
249        .map(|(p, z, t, dp)| (p.unpack(), z.unpack(), t.unpack(), dp.unpack()))
250        // Map to height above ground.
251        .map(|(p, z, t, dp)| (p, z - elevation, t, dp))
252        // Transform to potential temperature
253        .map(|(p, h, t, dp)| (p, h, metfor::potential_temperature(p, t), dp))
254        // Transform to specific humidity
255        .filter_map(|(p, h, theta, dp)| metfor::specific_humidity(dp, p).map(|q| (p, h, theta, q)))
256        // Pair them up for integration with the trapezoid rule
257        .tuple_windows::<(_, _)>()
258        // Make a running integral
259        .scan(
260            (0.0, 0.0),
261            |state, ((_p0, h0, theta0, q0), (p1, h1, theta1, q1))| {
262                let (sum_theta, sum_q): (&mut f64, &mut f64) = (&mut state.0, &mut state.1);
263
264                let dh = (h1 - h0).unpack(); // meters
265                debug_assert!(dh > 0.0);
266
267                *sum_theta += (theta0.unpack() * h0.unpack() + theta1.unpack() * h1.unpack()) * dh;
268                *sum_q += (q0 * h0.unpack() + q1 * h1.unpack()) * dh;
269
270                // Divide by 2 for trapezoid rule and divide by 1/2 Z^2 for height weighting function
271                let h_sq = h1.unpack() * h1.unpack();
272                let avg_theta = Kelvin(*sum_theta / h_sq);
273                let avg_q = *sum_q / h_sq;
274
275                Some((p1, h1, avg_theta, avg_q))
276            },
277        )
278        // Don't even start looking until we have at least a minimum thickness mixed layer.
279        .filter(|(p1, _h1, _avg_theta, _avg_q)| *p1 <= max_p)
280        // Convert into a parcel so we can lift it..
281        .filter_map(|(p1, h1, avg_theta, avg_q)| {
282            let temperature = Celsius::from(metfor::temperature_from_pot_temp(avg_theta, bottom_p));
283            let dew_point = metfor::dew_point_from_p_and_specific_humidity(bottom_p, avg_q)?;
284
285            let pcl = Parcel {
286                temperature,
287                dew_point,
288                pressure: bottom_p,
289            };
290
291            Some((p1, h1, avg_theta, avg_q, pcl))
292        })
293        // Get the LCL pressure
294        .filter_map(|(p1, h1, avg_theta, avg_q, pcl)| {
295            metfor::pressure_at_lcl(pcl.temperature, pcl.dew_point, pcl.pressure)
296                .map(|lcl_pres| (p1, h1, avg_theta, avg_q, lcl_pres))
297        })
298        // Pair up to compare layers
299        .tuple_windows::<(_, _)>()
300        // find where the mixed layer depth is just below the LCL
301        .find(
302            |(
303                (p0, _h0, _avg_theta0, _avg_q0, lcl_pres0),
304                (p1, _h1, _avg_theta1, _avg_q1, lcl_pres1),
305            )| p0 >= lcl_pres0 && p1 < lcl_pres1,
306        ) {
307        Some(((p0, h0, avg_theta0, avg_q0, _), _)) => Ok((avg_theta0, avg_q0, h0, bottom_p, p0)),
308        None => Err(AnalysisError::FailedPrerequisite),
309    }
310}
311
312/// This is steps 3 and 4 from page 11 of Tory & Kepert, 2021.
313///
314/// We skipped step 2 to get here. Basically, use the max virtual temperature above z_ml
315/// (or p_top_ml) to find the theta-e that is above that temperature by the threshold amount AND
316/// tops out on the sounding at at least -20C or colder. Once we know what theta-e value we need,
317/// we can find the level where that intersects the S-P curve.
318///
319/// Once we have our point on the S-P curve, zfc and d_theta_fc are easy to calculate.
320fn free_convection_level(
321    snd: &Sounding,
322    moisture_ratio: f64,
323    theta_ml: Kelvin,
324    q_ml: f64,
325) -> Result<(
326    Meters,
327    HectoPascal,
328    Kelvin,
329    KelvinDiff,
330    Kelvin,
331    Vec<(HectoPascal, Celsius)>,
332    Vec<(HectoPascal, Celsius)>,
333)> {
334    const SP_BETA_MAX: f64 = 0.20;
335    const DELTA_BETA: f64 = 0.001;
336
337    let apply_beta = move |beta| {
338        let theta_sp = Kelvin((1.0 + beta) * theta_ml.unpack());
339        let q_sp = q_ml + beta / moisture_ratio / 1000.0 * theta_ml.unpack();
340
341        (theta_sp, q_sp)
342    };
343
344    let mut sp_curve: Vec<(HectoPascal, Celsius)> = Vec::with_capacity(100);
345
346    let mut low_beta: f64 = f64::NAN;
347    let mut high_beta: f64 = f64::NAN;
348    let mut beta = 0.0;
349    let mut i = 0;
350    while beta <= SP_BETA_MAX {
351        let (theta_sp, q_sp) = apply_beta(beta);
352
353        let skew_t_coords =
354            potential_t_and_specific_humidity_to_pressure_and_temperature(theta_sp, q_sp)?;
355
356        sp_curve.push(skew_t_coords);
357
358        let (p, t) = skew_t_coords;
359
360        let sp_theta_e = match metfor::equiv_pot_temperature(t, t, p) {
361            Some(val) => val,
362            None => continue,
363        };
364
365        let meets_theta_e_requirements = is_free_convecting(snd, p, sp_theta_e)?;
366
367        if !meets_theta_e_requirements && high_beta.is_nan() {
368            low_beta = beta;
369        } else if meets_theta_e_requirements && high_beta.is_nan() {
370            high_beta = beta;
371        }
372
373        if !high_beta.is_nan() && i >= 100 {
374            break;
375        }
376
377        beta += DELTA_BETA;
378        i += 1;
379    }
380
381    if high_beta.is_nan() || low_beta.is_nan() {
382        return Err(AnalysisError::FailedPrerequisite);
383    }
384
385    let beta = metfor::find_root(
386        &|b| {
387            let (theta_sp, q_sp) = apply_beta(b);
388            let (p, t) =
389                potential_t_and_specific_humidity_to_pressure_and_temperature(theta_sp, q_sp)
390                    .ok()?;
391            let sp_theta_e = metfor::equiv_pot_temperature(t, t, p)?;
392
393            Some(
394                (min_temperature_diff_to_max_cloud_top_temperature(snd, p, sp_theta_e).ok()?
395                    - TEMPERATURE_BUFFER)
396                    .unpack(),
397            )
398        },
399        low_beta,
400        high_beta,
401    )
402    .ok_or(AnalysisError::FailedPrerequisite)?;
403
404    let (theta_sp, q_sp) = apply_beta(beta);
405    let (p_lfc, t_lfc) =
406        potential_t_and_specific_humidity_to_pressure_and_temperature(theta_sp, q_sp)?;
407    let theta_e_lfc = metfor::equiv_pot_temperature(t_lfc, t_lfc, p_lfc)
408        .ok_or(AnalysisError::FailedPrerequisite)?;
409
410    let dtheta = theta_sp - theta_ml;
411
412    let heights = snd.height_profile();
413    let pressures = snd.pressure_profile();
414    let height_asl_lfc = crate::interpolation::linear_interpolate(pressures, heights, p_lfc)
415        .into_option()
416        .ok_or(AnalysisError::InterpolationError)?;
417    let sfc_height = heights
418        .iter()
419        .filter_map(|h| h.into_option())
420        .next()
421        .ok_or(AnalysisError::NotEnoughData)?;
422
423    let mut theta_curve: Vec<(HectoPascal, Celsius)> = pressures
424        .iter()
425        .filter_map(|p| {
426            p.into_option()
427                .map(|p| (p, metfor::temperature_from_pot_temp(theta_sp, p)))
428        })
429        .take_while(|&(p, _)| p > p_lfc)
430        .map(|(p, t)| (p, Celsius::from(t)))
431        .collect();
432    theta_curve.push((p_lfc, t_lfc));
433
434    Ok((
435        height_asl_lfc - sfc_height,
436        p_lfc,
437        theta_sp,
438        dtheta,
439        theta_e_lfc,
440        sp_curve,
441        theta_curve,
442    ))
443}
444
445pub(crate) fn potential_t_and_specific_humidity_to_pressure_and_temperature(
446    theta: Kelvin,
447    specific_humidity: f64,
448) -> Result<(HectoPascal, Celsius)> {
449    let diff = |p_hpa: f64| -> Option<f64> {
450        let t = metfor::temperature_from_pot_temp(theta, HectoPascal(p_hpa));
451        let dp =
452            metfor::dew_point_from_p_and_specific_humidity(HectoPascal(p_hpa), specific_humidity)?;
453
454        Some((t - dp).unpack())
455    };
456
457    let p = metfor::find_root(
458        &diff,
459        HectoPascal(1080.0).unpack(),
460        HectoPascal(100.0).unpack(),
461    )
462    .map(HectoPascal)
463    .ok_or(AnalysisError::FailedPrerequisite)?;
464
465    let t = Celsius::from(metfor::temperature_from_pot_temp(theta, p));
466
467    Ok((p, t))
468}
469
470/// Get the minimum difference between this theta-e and all those up to -20C.
471fn min_temperature_diff_to_max_cloud_top_temperature(
472    snd: &Sounding,
473    starting_pressure: HectoPascal,
474    starting_theta_e: Kelvin,
475) -> Result<CelsiusDiff> {
476    const MAX_PLUME_TOP_T: Celsius = Celsius(-20.0);
477
478    let pp = snd.pressure_profile();
479    let tp = snd.temperature_profile();
480    let dpp = snd.dew_point_profile();
481
482    let min_diff: CelsiusDiff = izip!(pp, tp, dpp)
483        // Skip levels with missing data.
484        .filter(|(p, t, dp)| p.is_some() && t.is_some() && dp.is_some())
485        //Unpack optioned values.
486        .map(|(p, t, dp)| (p.unpack(), t.unpack(), dp.unpack()))
487        // Skip below the top of the mixed layer.
488        .skip_while(|(p, _t, _dp)| *p > starting_pressure)
489        // Get the parcel temperature
490        .filter_map(|(p, t, dp)| {
491            metfor::temperature_from_equiv_pot_temp_saturated_and_pressure(p, starting_theta_e)
492                .map(|pcl_t| (p, t, dp, pcl_t))
493        })
494        // Ensure we get a least 2 levels and only go up to MAX_PLUME_TOP_T
495        .enumerate()
496        .take_while(|(i, (_p, _t, _dp, pcl_t))| *i < 2 || *pcl_t >= MAX_PLUME_TOP_T)
497        // Unpack the enumerate
498        .map(|(_i, row)| row)
499        // Get the virtual temperature of the environment
500        .filter_map(|(p, t, dp, pcl_t)| {
501            metfor::virtual_temperature(t, dp, p).map(|vt| (p, vt, pcl_t))
502        })
503        // Get the virtual temperature of the parcel
504        .filter_map(|(p, vt, pcl_t)| {
505            metfor::virtual_temperature(pcl_t, pcl_t, p).map(|pcl_vt| (vt, pcl_vt))
506        })
507        // Find the minimum difference
508        .fold(CelsiusDiff(500.0), |min_diff, (vt, pcl_vt)| {
509            CelsiusDiff::from(pcl_vt - vt).min(min_diff)
510        });
511
512    if min_diff == CelsiusDiff(500.0) {
513        Err(AnalysisError::FailedPrerequisite)
514    } else {
515        Ok(min_diff)
516    }
517}
518
519/// Find the maximum equivalent_potential_temperature above the mixed layer that interesects the
520/// sounding at or above the -20C level.
521const TEMPERATURE_BUFFER: CelsiusDiff = CelsiusDiff(0.5);
522fn is_free_convecting(
523    snd: &Sounding,
524    starting_pressure: HectoPascal,
525    starting_theta_e: Kelvin,
526) -> Result<bool> {
527    Ok(
528        min_temperature_diff_to_max_cloud_top_temperature(
529            snd,
530            starting_pressure,
531            starting_theta_e,
532        )? >= TEMPERATURE_BUFFER,
533    )
534}
535
536/// Find the mean wind in the entrainment layer.
537///
538/// Remember, zfc is the level AGL.
539pub(crate) fn entrained_layer_mean_wind_speed(zfc: Meters, snd: &Sounding) -> Result<MetersPSec> {
540    let layer = crate::layer_agl(snd, zfc)?;
541
542    let mean_wind = crate::wind::mean_wind(&layer, snd)?;
543    let mean_wind = WindSpdDir::from(mean_wind).speed;
544
545    Ok(mean_wind)
546}