sounding_analysis/
parcel.rs

1//! Functions for doing parcel analysis on a sounding, specifically related to convection.
2//!
3use crate::{
4    error::{AnalysisError, Result},
5    interpolation::{linear_interp, linear_interpolate_sounding},
6    layers::{effective_inflow_layer, Layer},
7    profile::equivalent_potential_temperature,
8    sounding::{DataRow, Sounding},
9};
10use itertools::{izip, Itertools};
11use metfor::{self, Celsius, HectoPascal, Kelvin, Quantity};
12use optional::Optioned;
13
14/// Variables defining a parcel as used in parcel analysis.
15#[derive(Debug, Clone, Copy)]
16pub struct Parcel {
17    /// Temperature in C
18    pub temperature: Celsius,
19    /// Pressure in hPa
20    pub pressure: HectoPascal,
21    /// Dew point in C
22    pub dew_point: Celsius,
23}
24
25impl Parcel {
26    /// Get the potential temperatures of the parcel
27    pub fn theta(&self) -> Kelvin {
28        metfor::potential_temperature(self.pressure, self.temperature)
29    }
30
31    /// Get the equivalent potential temperature of the parcel
32    pub fn theta_e(&self) -> Result<Kelvin> {
33        metfor::equiv_pot_temperature(self.temperature, self.dew_point, self.pressure)
34            .ok_or(AnalysisError::MetForError)
35    }
36
37    /// Get the mixing ratio of the parcel
38    pub fn mixing_ratio(&self) -> Result<f64> {
39        metfor::mixing_ratio(self.dew_point, self.pressure).ok_or(AnalysisError::MetForError)
40    }
41
42    /// Get the virtual temperature of the parcel
43    pub fn virtual_temperature(&self) -> Result<Kelvin> {
44        metfor::virtual_temperature(self.temperature, self.dew_point, self.pressure)
45            .ok_or(AnalysisError::MetForError)
46    }
47
48    /// Try to convert a `DataRow` to a `Parcel`.
49    pub fn from_datarow(mut dr: DataRow) -> Option<Self> {
50        let temperature = dr.temperature.take()?;
51        let pressure = dr.pressure.take()?;
52        let dew_point = dr.dew_point.take()?;
53
54        Some(Parcel {
55            temperature,
56            pressure,
57            dew_point,
58        })
59    }
60}
61
62/// Create a mixed layer parcel.
63///
64/// Calculated by averaging potential temperature and mixing ratio in the lowest 100 hPa of the
65/// sounding and calculating a temperature and dew point at the lowest pressure level using those
66/// averaged values. This is a pressure weighted average.
67pub fn mixed_layer_parcel(snd: &Sounding) -> Result<Parcel> {
68    let press = snd.pressure_profile();
69    let t = snd.temperature_profile();
70    let dp = snd.dew_point_profile();
71
72    if press.is_empty() || t.is_empty() || dp.is_empty() {
73        return Err(AnalysisError::MissingProfile);
74    }
75
76    let bottom_p = press
77        .iter()
78        // Remove None values
79        .filter_map(|&p| p.into_option())
80        // Get the first of the non-None values
81        .next()
82        // Check to make sure we got one, return error if not.
83        .ok_or(AnalysisError::NoDataProfile)?;
84
85    let top_p = bottom_p - HectoPascal(100.0);
86
87    let (sum_p, sum_t, sum_mw) = izip!(press, t, dp)
88        // remove levels with missing values
89        .filter(|(p, t, dp)| p.is_some() && t.is_some() && dp.is_some())
90        // Unpack from the `Optioned` type
91        .map(|(p, t, dp)| (p.unpack(), t.unpack(), dp.unpack()))
92        // only go up 100 hPa above the lowest level
93        .take_while(|&(p, _, _)| p >= top_p)
94        // Get theta
95        .map(|(p, t, dp)| (p, dp, metfor::potential_temperature(p, t)))
96        // convert to mw
97        .filter_map(|(p, dp, th)| metfor::mixing_ratio(dp, p).map(|mw| (p, th, mw)))
98        // calculate the sums and count needed for the average
99        .fold((HectoPascal(0.0), 0.0f64, 0.0f64), |acc, (p, theta, mw)| {
100            let (sum_p, sum_t, sum_mw) = acc;
101            (
102                sum_p + p,
103                sum_t + theta.unpack() * p.unpack(),
104                sum_mw + mw * p.unpack(),
105            )
106        });
107
108    if sum_p == HectoPascal(0.0) {
109        return Err(AnalysisError::NotEnoughData);
110    }
111
112    // convert back to temperature and dew point at the lowest pressure level.
113    let pressure = bottom_p;
114    let temperature = Celsius::from(metfor::temperature_from_pot_temp(
115        Kelvin(sum_t / sum_p.unpack()),
116        bottom_p,
117    ));
118    let dew_point = metfor::dew_point_from_p_and_mw(bottom_p, sum_mw / sum_p.unpack())
119        .ok_or(AnalysisError::InvalidInput)?;
120
121    Ok(Parcel {
122        temperature,
123        pressure,
124        dew_point,
125    })
126}
127
128/// Get a surface parcel.
129pub fn surface_parcel(snd: &Sounding) -> Result<Parcel> {
130    let pressure = snd.station_pressure().ok_or(AnalysisError::MissingValue)?;
131    let temperature = snd.sfc_temperature().ok_or(AnalysisError::MissingValue)?;
132    let dew_point = snd.sfc_dew_point().ok_or(AnalysisError::MissingValue)?;
133
134    Ok(Parcel {
135        temperature,
136        pressure,
137        dew_point,
138    })
139}
140
141/// Get the lowest level parcel. This should be the surface parcel, but some files do not have
142/// complete information at the surface, so the first level above the ground is best you can do.
143pub fn lowest_level_parcel(snd: &Sounding) -> Result<Parcel> {
144    snd.bottom_up()
145        .filter_map(Parcel::from_datarow)
146        .next()
147        .ok_or(AnalysisError::NotEnoughData)
148}
149
150/// Get the parcel at a specific pressure.
151pub fn pressure_parcel(snd: &Sounding, pressure: HectoPascal) -> Result<Parcel> {
152    let row = linear_interpolate_sounding(snd, pressure)?;
153
154    Parcel::from_datarow(row).ok_or(AnalysisError::MissingValue)
155}
156
157/// Get the most unstable parcel.
158///
159/// This is defined as the parcel in the lowest 300 hPa of the sounding with the highest equivalent
160/// potential temperature.
161pub fn most_unstable_parcel(snd: &Sounding) -> Result<Parcel> {
162    let temp_vec: Vec<Optioned<Kelvin>>;
163
164    let theta_e = if !snd.theta_e_profile().is_empty() {
165        snd.theta_e_profile()
166    } else {
167        temp_vec = equivalent_potential_temperature(snd);
168        &temp_vec
169    };
170    let press = snd.pressure_profile();
171
172    let top_pressure = press
173        .iter()
174        .filter_map(|p| p.into_option())
175        .next()
176        .ok_or(AnalysisError::NotEnoughData)?
177        - HectoPascal(300.0);
178
179    let (idx, _) = izip!(0.., press, theta_e)
180        .filter(|(_, p, theta_e)| p.is_some() && theta_e.is_some())
181        .map(|(i, p, theta_e)| (i, p.unpack(), theta_e.unpack()))
182        .take_while(|&(_, p, _)| p >= top_pressure)
183        .fold(
184            (::std::usize::MAX, metfor::ABSOLUTE_ZERO),
185            |(max_idx, max_val), (i, _, theta_e)| {
186                if theta_e > max_val {
187                    (i, theta_e)
188                } else {
189                    (max_idx, max_val)
190                }
191            },
192        );
193
194    snd.data_row(idx)
195        .and_then(Parcel::from_datarow)
196        .ok_or(AnalysisError::MissingValue)
197}
198
199/// Get the convective parcel - this is the surface parcel that will rise without any lifting.
200///
201/// This is based off the mixing ratio of the mixed layer parcel.
202pub fn convective_parcel(snd: &Sounding) -> Result<Parcel> {
203    let Parcel {
204        dew_point: dp,
205        pressure: initial_p,
206        temperature: initial_t,
207    } = mixed_layer_parcel(snd)?;
208
209    let tgt_mw = metfor::mixing_ratio(dp, initial_p).ok_or(AnalysisError::MetForError)?;
210
211    let pressure = snd.pressure_profile();
212    let temperature = snd.temperature_profile();
213
214    let (tgt_p, tgt_t) = izip!(pressure, temperature)
215        // Filter out levels with missing values
216        .filter(|(p, t)| p.is_some() && t.is_some())
217        // Unpack from the `Optioned` type
218        .map(|(p, t)| (p.unpack(), t.unpack()))
219        // Convert to mixing ratio and filter out any errors.
220        .filter_map(|(p, t)| {
221            let mw = metfor::mixing_ratio(t, p)?;
222
223            Some((p, t, mw))
224        })
225        // Get past a cool surface layer where mw < tgt_mw possible due to using a mixed parcel for
226        // the target mw in combination with a surface based inversion.
227        .skip_while(|(_, _, mw)| *mw <= tgt_mw)
228        // Look at them in pairs to detect a crossing
229        .tuple_windows::<(_, _)>()
230        // Look for a crossing, filter out all the rest.
231        .filter_map(|((p0, t0, mw0), (p1, t1, mw1))| {
232            if mw0 >= tgt_mw && mw1 <= tgt_mw {
233                let tgt_p = linear_interp(tgt_mw, mw0, mw1, p0, p1);
234                let tgt_t = linear_interp(tgt_mw, mw0, mw1, t0, t1);
235                Some((tgt_p, tgt_t))
236            } else {
237                None
238            }
239        })
240        // Take the first (and probably only) one, if it exists.
241        .next()
242        // Probably a bad choice to use an error to signal this, but it is impossible to tell if
243        // we never found it because there wasn't enough data, or it just didn't exist.
244        .ok_or(AnalysisError::NotEnoughData)?;
245
246    // Extrapolate dry adiabatically back to the parcel level.
247    let tgt_theta = metfor::potential_temperature(tgt_p, tgt_t);
248    let tgt_t =
249        Celsius::from(metfor::temperature_from_pot_temp(tgt_theta, initial_p)).max(initial_t);
250
251    Ok(Parcel {
252        pressure: initial_p,
253        temperature: tgt_t,
254        dew_point: dp,
255    })
256}
257
258/// Get the effective parcel which is the parcel with the mean values (pressure, temperature, etc)
259/// of the sounding from the effective layer.
260pub fn effective_layer_parcel(snd: &Sounding) -> Result<Parcel> {
261    let effective_layer = &effective_inflow_layer(snd).ok_or(AnalysisError::FailedPrerequisite)?;
262    average_parcel(snd, effective_layer)
263}
264
265/// Get the parcel with mean values for the given layer.
266pub fn average_parcel(snd: &Sounding, layer: &Layer) -> Result<Parcel> {
267    let press = snd.pressure_profile();
268    let t = snd.temperature_profile();
269    let dp = snd.dew_point_profile();
270
271    if press.is_empty() || t.is_empty() || dp.is_empty() {
272        return Err(AnalysisError::MissingProfile);
273    }
274
275    let bottom_p = layer.bottom.pressure.ok_or(AnalysisError::NoDataProfile)?;
276    let top_p = layer.top.pressure.ok_or(AnalysisError::NoDataProfile)?;
277
278    let (sum_p, sum_t, sum_mw, count) = izip!(press, t, dp)
279        .filter(|(p, t, dp)| p.is_some() && t.is_some() && dp.is_some())
280        // Unpack from the `Optioned` type
281        .map(|(p, t, dp)| (p.unpack(), t.unpack(), dp.unpack()))
282        .skip_while(|&(p, _, _)| p > bottom_p)
283        .take_while(|&(p, _, _)| p >= top_p)
284        .map(|(p, t, dp)| (p, dp, metfor::potential_temperature(p, t)))
285        .filter_map(|(p, dp, th)| metfor::mixing_ratio(dp, p).map(|mw| (p, th, mw)))
286        .fold(
287            (HectoPascal(0.0), 0.0f64, 0.0f64, 0),
288            |acc, (p, theta, mw)| {
289                let (sum_p, sum_t, sum_mw, count) = acc;
290                (
291                    sum_p + p,
292                    sum_t + theta.unpack() * p.unpack(),
293                    sum_mw + mw * p.unpack(),
294                    count + 1,
295                )
296            },
297        );
298
299    if sum_p == HectoPascal(0.0) {
300        return Err(AnalysisError::NotEnoughData);
301    }
302
303    // convert back to temperature and dew point at the mean pressure level.
304    let pressure = HectoPascal(sum_p.unpack() / f64::from(count));
305    let temperature = Celsius::from(metfor::temperature_from_pot_temp(
306        Kelvin(sum_t / sum_p.unpack()),
307        pressure,
308    ));
309    let dew_point = metfor::dew_point_from_p_and_mw(pressure, sum_mw / sum_p.unpack())
310        .ok_or(AnalysisError::InvalidInput)?;
311
312    Ok(Parcel {
313        temperature,
314        pressure,
315        dew_point,
316    })
317}