sounding_analysis/
parcel_profile.rs

1//! Create and analyze a profile from lifting or descending a parcel.
2use crate::{
3    error::{AnalysisError, Result},
4    interpolation::linear_interpolate_sounding,
5    parcel::Parcel,
6    sounding::{DataRow, Sounding},
7};
8use itertools::izip;
9use metfor::{self, Celsius, CelsiusDiff, HectoPascal, JpKg, Kelvin, Meters, MetersPSec, Quantity};
10use optional::Optioned;
11
12/// Hold profiles for a parcel and it's environment.
13#[derive(Debug, Clone)]
14pub struct ParcelProfile {
15    /// Pressure profile
16    pub pressure: Vec<HectoPascal>,
17    /// Height profile
18    pub height: Vec<Meters>,
19    /// Parcel virtual temperature profile
20    pub parcel_t: Vec<Celsius>,
21    /// Environment virtual temperature profile
22    pub environment_t: Vec<Celsius>,
23}
24
25pub(crate) mod lift;
26
27/// Parcel analysis, this is a way to package the analysis of a parcel.
28///
29/// These are done by converting the profiles to virtual temperature. It is assumed the reason for
30/// lifting the parcel and doing the analysis is related to bouyancy and some kind of convection
31/// or stability analysis.
32#[derive(Debug, Clone)]
33pub struct ParcelAscentAnalysis {
34    // The orginal parcel and profile
35    parcel: Parcel,
36    profile: ParcelProfile,
37
38    // Indicies from analysis
39    cape: Optioned<JpKg>,
40    hail_cape: Optioned<JpKg>,
41    ncape: Optioned<f64>,
42    lcl_height_agl: Optioned<Meters>,    // cloud base for aviation
43    lcl_pressure: Optioned<HectoPascal>, // plotting on skew-t
44    lcl_temperature: Optioned<Celsius>,  // ice or ice/water cloud?
45    cin: Optioned<JpKg>,
46    el_pressure: Optioned<HectoPascal>,      // plotting on skew-t
47    el_height_asl: Optioned<Meters>,         // Calculating convective cloud tops for aviation
48    el_temperature: Optioned<Celsius>,       // useful for comparing to satellite
49    lfc_pressure: Optioned<HectoPascal>,     // plotting on skew-t
50    lfc_virt_temperature: Optioned<Celsius>, // plotting on skew-t
51}
52
53impl ParcelAscentAnalysis {
54    /// Get the CAPE.
55    pub fn cape(&self) -> Optioned<JpKg> {
56        self.cape
57    }
58
59    /// Get the CAPE in the hail growth zone.
60    pub fn hail_cape(&self) -> Optioned<JpKg> {
61        self.hail_cape
62    }
63
64    /// Get the normalized cape.
65    pub fn ncape(&self) -> Optioned<f64> {
66        self.ncape
67    }
68
69    /// Get the LCL height AGL.
70    pub fn lcl_height_agl(&self) -> Optioned<Meters> {
71        self.lcl_height_agl
72    }
73
74    /// Get the LCL pressrue level.
75    pub fn lcl_pressure(&self) -> Optioned<HectoPascal> {
76        self.lcl_pressure
77    }
78
79    /// Get the temperature at the LCL.
80    pub fn lcl_temperature(&self) -> Optioned<Celsius> {
81        self.lcl_temperature
82    }
83
84    /// Get the CIN.
85    pub fn cin(&self) -> Optioned<JpKg> {
86        self.cin
87    }
88
89    /// Get the pressure at the equilibrium level.
90    pub fn el_pressure(&self) -> Optioned<HectoPascal> {
91        self.el_pressure
92    }
93
94    /// Get the height ASL of the equilibrium level.
95    pub fn el_height_asl(&self) -> Optioned<Meters> {
96        self.el_height_asl
97    }
98
99    /// Get the temperature at the equilibrium level.
100    pub fn el_temperature(&self) -> Optioned<Celsius> {
101        self.el_temperature
102    }
103
104    /// Get the pressure at the LFC.
105    pub fn lfc_pressure(&self) -> Optioned<HectoPascal> {
106        self.lfc_pressure
107    }
108
109    /// Get the virtual temperature at the LFC.
110    pub fn lfc_virt_temperature(&self) -> Optioned<Celsius> {
111        self.lfc_virt_temperature
112    }
113
114    /// Retrieve the parcel's profile
115    #[inline]
116    pub fn profile(&self) -> &ParcelProfile {
117        &self.profile
118    }
119
120    /// Retrieve the original parcel.
121    #[inline]
122    pub fn parcel(&self) -> &Parcel {
123        &self.parcel
124    }
125
126    /// Calculate the parcel vertical speed at the equilibrium level. Note that this is an over
127    /// estimate of updraft speed due to the effects of entrainment and water/ice loading.
128    #[inline]
129    pub fn calculate_cape_speed(&self) -> Option<MetersPSec> {
130        self.cape
131            .map(|cape| MetersPSec::pack(f64::sqrt(2.0 * cape.unpack())))
132    }
133}
134
135/// Lift a parcel for a convective parcel analysis.
136///
137/// The resulting `ParcelProfile` and analysis are based off of virtual temperatures and the idea
138/// that if there is no *moist* convection, or convective cloud, then there is no CAPE or CIN.
139pub fn lift_parcel(parcel: Parcel, snd: &Sounding) -> Result<ParcelAscentAnalysis> {
140    lift::lift_parcel(parcel, snd)
141}
142
143/// In order for parcel lifting to work and create a parallel environmental profile, we need to
144/// start at a level in the sounding with pressure, height, temperature, and dew point. Otherwise
145/// we end up with too much missing data in the sounding.
146pub(crate) fn find_parcel_start_data(snd: &Sounding, parcel: &Parcel) -> Result<(DataRow, Parcel)> {
147    let good_row = |row: &DataRow| -> bool {
148        row.temperature.is_some()
149            && row.dew_point.is_some()
150            && row.pressure.is_some()
151            && row.height.is_some()
152    };
153
154    let first_guess = linear_interpolate_sounding(snd, parcel.pressure)?;
155    if good_row(&first_guess) {
156        return Ok((first_guess, *parcel));
157    }
158
159    let second_guess = snd
160        .bottom_up()
161        .find(good_row)
162        .ok_or(AnalysisError::NotEnoughData)?;
163
164    let pressure = second_guess.pressure.ok_or(AnalysisError::InvalidInput)?;
165    let theta = parcel.theta();
166    let temperature = Celsius::from(metfor::temperature_from_pot_temp(theta, pressure));
167    let mw = parcel.mixing_ratio()?;
168    let dew_point =
169        metfor::dew_point_from_p_and_mw(pressure, mw).ok_or(AnalysisError::MetForError)?;
170    let new_parcel = Parcel {
171        pressure,
172        temperature,
173        dew_point,
174    };
175
176    Ok((second_guess, new_parcel))
177}
178
179/// A more robust convective parcel analysis.
180///
181/// Some approximations are used in many algorithms which are usually good enough. However,
182/// sometimes they are close but miss the mark. Not to mention we are using linear interpolation
183/// in so many places. Convective parcel analysis is one of those areas where sometimes this comes
184/// up and we have a "convective parcel" with the equilibrium level below the lifting condensation
185/// level. It's almost always very close though.
186///
187/// This algorithm finds the convective parcel the fast way, and if it is good, then it just uses
188/// that parcel. Otherwise it tweaks the parcel to find a better convective parcel. Better meaning
189/// that the EL is above or equal to the LCL. This algorithm  is MUCH slower in cases where the
190/// 'fast way' doesn't work.
191pub fn robust_convective_parcel_ascent(snd: &Sounding) -> Result<ParcelAscentAnalysis> {
192    let mut start_parcel = crate::parcel::convective_parcel(snd)?;
193    let mut analysis = lift_parcel(start_parcel, snd)?;
194
195    if analysis.lcl_pressure <= analysis.el_pressure {
196        // We've got a problem, so refine our parcel
197        let mut warmer_t = start_parcel.temperature + CelsiusDiff(1.0);
198        let mut warmer_parcel = Parcel {
199            temperature: warmer_t,
200            ..start_parcel
201        };
202        let mut anal = lift_parcel(warmer_parcel, snd)?;
203
204        // Bracket the convective t in a 1 C range
205        while anal.lcl_pressure <= anal.el_pressure {
206            start_parcel = warmer_parcel;
207            warmer_t += CelsiusDiff(1.0);
208            warmer_parcel = Parcel {
209                temperature: warmer_t,
210                ..start_parcel
211            };
212            anal = lift_parcel(warmer_parcel, snd)?;
213        }
214        analysis = anal;
215
216        let mut diff = 1.0;
217        while diff > 0.1 {
218            // Within 0.1 is probably overkill
219            diff = (warmer_parcel.temperature - start_parcel.temperature).unpack() / 2.0;
220            let mid_t = start_parcel.temperature + CelsiusDiff(diff);
221            let mid_parcel = Parcel {
222                temperature: mid_t,
223                ..start_parcel
224            };
225            anal = lift_parcel(mid_parcel, snd)?;
226            if anal.lcl_pressure <= anal.el_pressure {
227                start_parcel = mid_parcel;
228            } else {
229                warmer_parcel = mid_parcel;
230                analysis = anal;
231            }
232        }
233    }
234
235    Ok(analysis)
236}
237
238/// Descend a parcel dry adiabatically.
239///
240/// The resulting `ParcelProfile` has actual temperatures and not virtual temperatures. This is for
241/// analyzing inversions and visualizing what a sounding would look like if deep, dry mixing were
242/// to occur from surface heating alone.
243pub fn mix_down(parcel: Parcel, snd: &Sounding) -> Result<ParcelProfile> {
244    let theta = parcel.theta();
245    let theta_func = |theta_val, press| {
246        Some(Celsius::from(metfor::temperature_from_pot_temp(
247            theta_val, press,
248        )))
249    };
250
251    descend_parcel(parcel, snd, theta, theta_func, false, false)
252}
253
254/// Descend a parcel moist adiabatically.
255///
256/// The resulting `ParcelProfile` has virtual temperatures and is intended for calculating
257/// DCAPE.
258fn descend_moist(parcel: Parcel, snd: &Sounding) -> Result<ParcelProfile> {
259    let theta = parcel.theta_e()?;
260
261    let theta_func = |theta_e, press| {
262        metfor::temperature_from_equiv_pot_temp_saturated_and_pressure(press, theta_e)
263    };
264
265    descend_parcel(parcel, snd, theta, theta_func, true, true)
266}
267
268#[inline]
269fn descend_parcel<F>(
270    parcel: Parcel,
271    snd: &Sounding,
272    theta: Kelvin,
273    theta_func: F,
274    saturated: bool,
275    virtual_t: bool,
276) -> Result<ParcelProfile>
277where
278    F: Fn(Kelvin, HectoPascal) -> Option<Celsius>,
279{
280    let mut pressure = Vec::new();
281    let mut height = Vec::new();
282    let mut parcel_t = Vec::new();
283    let mut environment_t = Vec::new();
284
285    // Actually start at the bottom and work up.
286    let press = snd.pressure_profile();
287    let env_t = snd.temperature_profile();
288    let env_dp = snd.dew_point_profile();
289    let hght = snd.height_profile();
290
291    let pcl_mw = parcel.mixing_ratio()?;
292
293    // Nested scope to limit borrows
294    {
295        // Helper function to add row to parcel profile
296        let mut add_row = |pp, hh, pcl_tt, env_tt| {
297            pressure.push(pp);
298            height.push(hh);
299            parcel_t.push(pcl_tt);
300            environment_t.push(env_tt);
301        };
302
303        izip!(press, hght, env_t, env_dp)
304            .take_while(|(p_opt, _, _, _)| {
305                if p_opt.is_some() {
306                    p_opt.unpack() >= parcel.pressure
307                } else {
308                    true // Just skip over levels with missing data
309                }
310            })
311            // Remove levels with missing data
312            .filter_map(|(p_opt, h_opt, e_t_opt, e_dp_opt)| {
313                if p_opt.is_some() && h_opt.is_some() && e_t_opt.is_some() && e_dp_opt.is_some() {
314                    Some((
315                        p_opt.unpack(),
316                        h_opt.unpack(),
317                        e_t_opt.unpack(),
318                        e_dp_opt.unpack(),
319                    ))
320                } else {
321                    None
322                }
323            })
324            // Get the parcel temperature
325            .filter_map(|(p, h, e_t, e_dp)| {
326                theta_func(theta, p).map(|pcl_t| (p, h, pcl_t, e_t, e_dp))
327            })
328            // Get the parcel dew point
329            .filter_map(|(p, h, pcl_t, e_t, e_dp)| {
330                let p_dp = if saturated {
331                    pcl_t
332                } else {
333                    metfor::dew_point_from_p_and_mw(p, pcl_mw)?
334                };
335
336                Some((p, h, pcl_t, p_dp, e_t, e_dp))
337            })
338            // Convert to virtual temperature if needed.
339            .filter_map(|(p, h, pcl_t, p_dp, e_t, e_dp)| {
340                let pcl_t = if virtual_t {
341                    Celsius::from(metfor::virtual_temperature(pcl_t, p_dp, p)?)
342                } else {
343                    pcl_t
344                };
345
346                let e_t = if virtual_t {
347                    Celsius::from(metfor::virtual_temperature(e_t, e_dp, p)?)
348                } else {
349                    e_t
350                };
351
352                Some((p, h, pcl_t, e_t))
353            })
354            .for_each(|(p, h, pt, et)| {
355                add_row(p, h, pt, et);
356            });
357
358        // Add the parcel layer also
359        let parcel_level = linear_interpolate_sounding(snd, parcel.pressure)?;
360        let parcel_height = parcel_level.height.ok_or(AnalysisError::MissingValue)?;
361        let env_t = parcel_level
362            .temperature
363            .ok_or(AnalysisError::MissingValue)?;
364        add_row(parcel.pressure, parcel_height, parcel.temperature, env_t);
365    }
366
367    Ok(ParcelProfile {
368        pressure,
369        height,
370        parcel_t,
371        environment_t,
372    })
373}
374
375/// Downdraft CAPE.
376///
377/// Defined as the net area between a parcel descended moist adiabatically from the level of the
378/// lowest theta-e in the lowest 400 hPa of the sounding.
379///
380/// Returns the profile, downdraft cape, and downrush temperature in a tuple.
381pub fn dcape(snd: &Sounding) -> Result<(ParcelProfile, JpKg, Celsius)> {
382    let t = snd.temperature_profile();
383    let dp = snd.dew_point_profile();
384    let p = snd.pressure_profile();
385
386    // Find the lowest pressure, 400 hPa above the surface (or starting level)
387    let top_p = p
388        .iter()
389        .filter_map(|p| p.into_option())
390        .next()
391        .ok_or(AnalysisError::NotEnoughData)?
392        - HectoPascal(400.0);
393
394    // Check that pressure is positive. This is theoretically possible if the surface pressure is
395    // less than 400 hPa. Maybe in the Himalayas? Moisture is insignficant at 100 hPa anyway. This
396    // is a really gross error check.
397    debug_assert!(
398        top_p.into_option().is_some() && top_p > HectoPascal(100.0),
399        "surface pressure is below 500 mb"
400    );
401
402    // Find the starting parcel.
403    let pcl = izip!(p, t, dp)
404        .filter_map(|(p, t, dp)| {
405            if p.is_some() && t.is_some() && dp.is_some() {
406                Some((p.unpack(), t.unpack(), dp.unpack()))
407            } else {
408                None
409            }
410        })
411        .take_while(|(p, _, _)| *p >= top_p)
412        .fold(
413            Err(AnalysisError::NotEnoughData),
414            |acc: Result<Parcel>, (p, t, dp)| match metfor::equiv_pot_temperature(t, dp, p) {
415                Some(th_e) => match acc {
416                    Ok(parcel) => {
417                        if let Ok(old_theta) = parcel.theta_e() {
418                            if old_theta < th_e {
419                                Ok(parcel)
420                            } else {
421                                Ok(Parcel {
422                                    temperature: t,
423                                    dew_point: dp,
424                                    pressure: p,
425                                })
426                            }
427                        } else {
428                            Ok(Parcel {
429                                temperature: t,
430                                dew_point: dp,
431                                pressure: p,
432                            })
433                        }
434                    }
435                    Err(_) => Ok(Parcel {
436                        temperature: t,
437                        dew_point: dp,
438                        pressure: p,
439                    }),
440                },
441                None => acc,
442            },
443        )?;
444
445    let profile = descend_moist(pcl, snd)?;
446
447    let mut dcape = 0.0;
448    let mut h0 = Meters(std::f64::MAX); // Big number
449    let mut pt0 = Kelvin(0.0);
450    let mut et0 = Kelvin(0.0);
451    for (&p, &h, &pt, &et) in izip!(
452        &profile.pressure,
453        &profile.height,
454        &profile.parcel_t,
455        &profile.environment_t
456    ) {
457        let pt = metfor::potential_temperature(p, pt);
458        let et = metfor::potential_temperature(p, et);
459
460        let dz = h - h0;
461        // we must be starting out, becuase h0 starts as a large positive number
462        if dz <= Meters(0.0) {
463            h0 = h;
464            pt0 = pt;
465            et0 = et;
466            continue;
467        }
468
469        dcape +=
470            ((pt - et).unpack() / et.unpack() + (pt0 - et0).unpack() / et0.unpack()) * dz.unpack();
471
472        h0 = h;
473        pt0 = pt;
474        et0 = et;
475    }
476
477    // - for integration direction should be top down, 9.81 for gravity, and 2.0 for trapezoid rule.
478    dcape *= metfor::g / 2.0;
479
480    let downrush_t = *profile.parcel_t.get(0).ok_or(AnalysisError::MissingValue)?;
481
482    Ok((profile, JpKg(dcape), downrush_t))
483}