Skip to main content

sounding_analysis/layers/
temperature_layers.rs

1use super::{Layer, Layers};
2use crate::{
3    error::{AnalysisError, Result},
4    interpolation::{linear_interp, linear_interpolate_sounding},
5    sounding::Sounding,
6};
7use itertools::{izip, Itertools};
8use metfor::{
9    Celsius, CelsiusDiff, HectoPascal, JpKg, Kelvin, KelvinDiff, Meters, Quantity, FREEZING,
10};
11use optional::Optioned;
12
13use std::iter::once;
14
15/// Find the dendtritic growth zones throughout the profile. It is unusual, but possible there is
16/// more than one.
17///
18/// If there are none, then an empty vector is returned.
19pub fn dendritic_snow_zone(snd: &Sounding) -> Result<Layers> {
20    temperature_layer(snd, Celsius(-12.0), Celsius(-18.0), HectoPascal(300.0))
21}
22
23/// Find the hail growth zones throughout the profile. It is very unusual, but possible there is
24/// more than one.
25///
26/// If there are none, then an empty vector is returned.
27pub fn hail_growth_zone(snd: &Sounding) -> Result<Layers> {
28    temperature_layer(snd, Celsius(-10.0), Celsius(-30.0), HectoPascal(1.0))
29}
30
31#[inline]
32fn temperature_layer(
33    snd: &Sounding,
34    warm_side: Celsius,
35    cold_side: Celsius,
36    top_pressure: HectoPascal,
37) -> Result<Layers> {
38    let t_profile = snd.temperature_profile();
39    let p_profile = snd.pressure_profile();
40
41    if t_profile.is_empty() || p_profile.is_empty() {
42        return Err(AnalysisError::MissingProfile);
43    }
44
45    // Assuming were are iterating over the profile from the bottom (ground level) up, this type
46    // records levels where the sounding crosses the targeted temperature layer.
47    enum Crossing {
48        // The level where the sounding crosses into a layer.
49        Enter(HectoPascal),
50        // The level where the sounding crosses out of a layer.
51        Exit(HectoPascal),
52        // The first element must be greater than the second! These levels completely jumped over
53        // a temperature layer.
54        Over(HectoPascal, HectoPascal),
55        // This level is completely inside a temperature layer.
56        In(HectoPascal),
57    }
58
59    // Take two levels and detect if traversing from the 0th to the 1st level crosses the
60    // target temperature layer.
61    fn to_crossing_type(
62        pnt0: (HectoPascal, Celsius),
63        pnt1: (HectoPascal, Celsius),
64        warm_side: Celsius,
65        cold_side: Celsius,
66    ) -> Option<Crossing> {
67        let (p0, t0) = pnt0;
68        let (p1, t1) = pnt1;
69
70        debug_assert!(p0 >= p1, "Pressure must decrease with height.");
71
72        if t0 < cold_side && t1 >= cold_side && t1 <= warm_side {
73            // Crossing into a layer from the cold side
74            let cold_p = linear_interp(cold_side, t0, t1, p0, p1);
75            Some(Crossing::Enter(cold_p))
76        } else if t0 > warm_side && t1 <= warm_side && t1 >= cold_side {
77            // Crossing into layer from the warm side
78            let warm_p = linear_interp(warm_side, t0, t1, p0, p1);
79            Some(Crossing::Enter(warm_p))
80        } else if (t0 < cold_side && t1 > warm_side) || (t0 > warm_side && t1 < cold_side) {
81            // Crossed over a layer
82            let warm_p = linear_interp(warm_side, t0, t1, p0, p1);
83            let cold_p = linear_interp(cold_side, t0, t1, p0, p1);
84            Some(Crossing::Over(warm_p.max(cold_p), warm_p.min(cold_p)))
85        } else if t0 >= cold_side && t0 <= warm_side && t1 > warm_side {
86            // Crossed out of a layer into the warm side
87            let warm_p = linear_interp(warm_side, t0, t1, p0, p1);
88            Some(Crossing::Exit(warm_p))
89        } else if t0 >= cold_side && t0 <= warm_side && t1 < cold_side {
90            // Crossed out of a layer into the cold side
91            let cold_p = linear_interp(cold_side, t0, t1, p0, p1);
92            Some(Crossing::Exit(cold_p))
93        } else if t0 >= cold_side && t0 <= warm_side && t1 >= cold_side && t1 <= warm_side {
94            // We're in the midst of a layer
95            Some(Crossing::In(p0))
96        } else {
97            None
98        }
99    }
100
101    izip!(p_profile, t_profile)
102        // Remove levels with missing values
103        .filter(|(p, t)| p.is_some() && t.is_some())
104        // Unwrap from the `Optioned` type
105        .map(|(p, t)| (p.unpack(), t.unpack()))
106        // Only take values up to a certain level
107        .take_while(move |&(p, _)| p > top_pressure)
108        // Make adjacent points into pairs so we can look at them two at a time
109        .tuple_windows::<(_, _)>()
110        // Map to a crossing type and filter out those that we don't need
111        .filter_map(|(pnt0, pnt1)| to_crossing_type(pnt0, pnt1, warm_side, cold_side))
112        // Scan the iterator and coalesce crossings into levels
113        .scan(None, |bottom_p: &mut Option<_>, crossing_type: Crossing| {
114            match crossing_type {
115                Crossing::In(p) => {
116                    if bottom_p.is_none() {
117                        // to get here we started out in the layer and never had to CROSS into it
118                        *bottom_p = Some(p);
119                    }
120                    // Yield this to indicate we're not done iterating, but we don't yet have a
121                    // pair of values (top and bottom) to create a layer from.
122                    Some(None)
123                }
124                Crossing::Enter(p) => {
125                    *bottom_p = Some(p);
126                    Some(None)
127                }
128                Crossing::Exit(top_p) => {
129                    if let Some(bp) = bottom_p.take() {
130                        Some(Some((bp, top_p)))
131                    } else {
132                        // We had a single surface point in the layer, so we never triggered
133                        // the Crossing::In variant. This is really rare, and the surface layer
134                        // so thin, we can just ignore it.
135                        Some(None)
136                    }
137                }
138                Crossing::Over(p0, p1) => {
139                    debug_assert!(bottom_p.is_none());
140                    Some(Some((p0, p1)))
141                }
142            }
143        })
144        // Filter out and unwrap steps that didn't yield a complete layer
145        .flatten()
146        // Interpolate the sounding and create a layer.
147        .map(|(bottom_p, top_p)| {
148            linear_interpolate_sounding(snd, bottom_p).and_then(|bottom| {
149                linear_interpolate_sounding(snd, top_p).map(|top| Layer { bottom, top })
150            })
151        }) // Result<Layer>
152        .collect()
153}
154
155/// This will find the warm layers aloft using the dry bulb temperature.
156///
157/// A warm layer aloft is defined as a layer of above freezing temperatures with a below freezing
158/// layer below it.
159///
160/// Does not look above 500 hPa.
161pub fn warm_temperature_layer_aloft(snd: &Sounding) -> Result<Layers> {
162    warm_layer_aloft(snd, snd.temperature_profile())
163}
164
165/// This will find the warm layers aloft using the wet bulb temperature.
166///
167/// Does not look above 500 hPa.
168pub fn warm_wet_bulb_layer_aloft(snd: &Sounding) -> Result<Layers> {
169    warm_layer_aloft(snd, snd.wet_bulb_profile())
170}
171
172#[inline]
173fn warm_layer_aloft(snd: &Sounding, t_profile: &[Optioned<Celsius>]) -> Result<Layers> {
174    let p_profile = snd.pressure_profile();
175
176    if t_profile.is_empty() || p_profile.is_empty() {
177        return Err(AnalysisError::MissingProfile);
178    }
179
180    enum Crossing {
181        IntoWarmLayer(HectoPascal),
182        OutOfWarmLayer(HectoPascal),
183    }
184
185    fn crossing_type(
186        pnt0: (HectoPascal, Celsius),
187        pnt1: (HectoPascal, Celsius),
188    ) -> Option<Crossing> {
189        let (p0, t0) = pnt0;
190        let (p1, t1) = pnt1;
191
192        if t0 < FREEZING && t1 >= FREEZING {
193            let crossing_p = linear_interp(FREEZING, t0, t1, p0, p1);
194            Some(Crossing::IntoWarmLayer(crossing_p))
195        } else if t0 >= FREEZING && t1 < FREEZING {
196            let crossing_p = linear_interp(FREEZING, t0, t1, p0, p1);
197            Some(Crossing::OutOfWarmLayer(crossing_p))
198        } else {
199            None
200        }
201    }
202
203    izip!(p_profile, t_profile)
204        // Remove levels with any missing temperature or pressure data
205        .filter(|(p, t)| p.is_some() && t.is_some())
206        // Unpack from the `Optioned` type
207        .map(|(p, t)| (p.unpack(), t.unpack()))
208        // Skip any levels that start out in a surface based warm layer
209        .skip_while(|&(_, t)| t > FREEZING)
210        // Ignore anything above 500 hPa, extremely unlikely for a warm layer up there.
211        .take_while(|&(p, _)| p > HectoPascal(500.0))
212        // Pair them up to look at two adjacent points at a time
213        .tuple_windows::<(_, _)>()
214        // Map into the crossing type, and filter out any levels that aren't a crossing
215        .filter_map(|(pnt0, pnt1)| crossing_type(pnt0, pnt1))
216        // Scan the crossings to create layers
217        .scan(
218            None,
219            |bottom: &mut Option<_>, crossing: Crossing| match crossing {
220                Crossing::IntoWarmLayer(bottom_p) => {
221                    debug_assert!(bottom.is_none());
222                    *bottom = Some(bottom_p);
223                    Some(None)
224                }
225                Crossing::OutOfWarmLayer(top_p) => {
226                    // If bottom is None, this is a surface based warm layer, not a warm layer
227                    // aloft.
228                    Some(bottom.take().map(|bottom_p| (bottom_p, top_p)))
229                }
230            },
231        )
232        // Filter out and unwrap steps that didn't yield a complete layer
233        .flatten()
234        // Interpolate the sounding and create a layer.
235        .map(|(bottom_p, top_p)| {
236            linear_interpolate_sounding(snd, bottom_p).and_then(|bottom| {
237                linear_interpolate_sounding(snd, top_p).map(|top| Layer { bottom, top })
238            })
239        }) // Result<Layer>
240        .collect()
241}
242
243/// Assuming a warm layer aloft given by `warm_layers`, measure the cold surface layer. If there
244/// are no warm layers aloft, return `None` since cold surface layer extends the full depth of the
245/// atmosphere and is irrelevant.
246pub fn cold_surface_temperature_layer(
247    snd: &Sounding,
248    warm_layers: &[Layer],
249) -> Result<Option<Layer>> {
250    Ok(cold_surface_layer(
251        snd,
252        snd.temperature_profile(),
253        warm_layers,
254    ))
255}
256
257fn cold_surface_layer(
258    snd: &Sounding,
259    t_profile: &[Optioned<Celsius>],
260    warm_layers: &[Layer],
261) -> Option<Layer> {
262    if warm_layers.is_empty() {
263        return None;
264    }
265
266    let p_profile = snd.pressure_profile();
267
268    let layer: Option<Layer> = izip!(0usize.., p_profile, t_profile)
269        // Remove layers with missing data
270        .filter(|(_, p, t)| p.is_some() && t.is_some())
271        // Unpack from the `Optioned` type and discard the pressure, we don't need it anymore
272        .map(|(i, _, t)| (i, t.unpack()))
273        // For a surface based cold layer, we must start out below zero,
274        .take_while(|(_, t)| *t <= FREEZING)
275        // We only want the first one, the bottom of the layer, if it exists at all
276        .next() // Option<(i, t)>
277        // translate it to a full data row in the sounding
278        .and_then(|(i, _)| snd.data_row(i)) // Option<DataRow>
279        // Add the top level, if it exists
280        .and_then(|bottom| warm_layers.get(0).map(|lyr| (bottom, lyr.bottom)))
281        // Package it up in a layer
282        .map(|(bottom, top)| Layer { bottom, top });
283
284    layer
285}
286
287/// Find the surface layer above freezing.
288pub fn warm_surface_temperature_layer(snd: &Sounding) -> Result<Option<Layer>> {
289    warm_surface_layer(snd, snd.temperature_profile())
290}
291
292fn warm_surface_layer(snd: &Sounding, t_profile: &[Optioned<Celsius>]) -> Result<Option<Layer>> {
293    let p_profile = snd.pressure_profile();
294
295    if t_profile.is_empty() || p_profile.is_empty() {
296        return Err(AnalysisError::NotEnoughData);
297    }
298
299    let iter = izip!(0usize.., p_profile, t_profile)
300        // Remove layers with missing data
301        .filter(|(_, p, t)| p.is_some() && t.is_some())
302        // Unpack from the `Optioned` type and discard the pressure, we don't need it anymore
303        .map(|(i, p, t)| (i, p.unpack(), t.unpack()));
304
305    let bottom = iter
306        .clone()
307        .take_while(|(_, _, t)| *t >= FREEZING)
308        .next()
309        .and_then(|(i, _, _)| snd.data_row(i));
310
311    let top = iter
312        .tuple_windows::<(_, _)>()
313        // For a surface based warm layer, we must start out above zero,
314        .take_while(|((_, _, t), _)| *t >= FREEZING)
315        // We need the last one for the top of the layer.
316        .last()
317        // Interpolate to get the top of the warm layer
318        .map(|((_, p0, t0), (_, p1, t1))| linear_interp(FREEZING, t0, t1, p0, p1))
319        .map(|target_p| linear_interpolate_sounding(snd, target_p))
320        .transpose()?;
321
322    Ok(bottom.and_then(|bottom| top.map(|top| Layer { bottom, top })))
323}
324
325/// Calculate the thermal energy relative to freezing in a layer. This is used in the Bourgouin
326/// algorithm for precipitation type.
327///
328/// Positive values indicate a melting layer, negative values indicate a freezing layer.
329///
330/// This algortihm implements equation 2 from "A Method to Determine Precipitation Types" by
331/// PIERRE BOURGOUIN, Weather and Forecasting, 2000.
332pub fn melting_freezing_energy_area(snd: &Sounding, lyr: &Layer) -> Result<JpKg> {
333    let top = &lyr.top;
334    let bottom = &lyr.bottom;
335
336    // This is a shortcut for doing a closed path integral on a thermodynamic chart where the "top"
337    // and "bottom" of the path are always at freezing. In the intended use cases, the top and
338    // bottom of all layers, except the bottom of surface based layers, will be at FREEZING anyway.
339    // In the bottom of the surface layer case, we need to close the loop by adding a point at the
340    // bottom pressure and FREEZING temperature, which we do with theta_bottom.
341    let theta_top: Kelvin = top
342        .pressure
343        .map(|p| metfor::potential_temperature(p, FREEZING))
344        .ok_or(AnalysisError::MissingValue)?;
345
346    let theta_bottom: Kelvin = bottom
347        .pressure
348        .map(|p| metfor::potential_temperature(p, FREEZING))
349        .ok_or(AnalysisError::MissingValue)?;
350
351    let bottom_h = bottom.height.ok_or(AnalysisError::MissingValue)?;
352    let top_h = top.height.ok_or(AnalysisError::MissingValue)?;
353
354    let bottom_iter = once((bottom.height, bottom.temperature))
355        .filter(|(h, t)| h.is_some() && t.is_some())
356        .map(|(h, t)| (h.unpack(), t.unpack()));
357    let top_iter = once((top.height, top.temperature))
358        .filter(|(h, t)| h.is_some() && t.is_some())
359        .map(|(h, t)| (h.unpack(), t.unpack()));
360
361    let middle_iter = izip!(snd.height_profile(), snd.temperature_profile())
362        .filter(|(h, t)| h.is_some() && t.is_some())
363        .map(|(h, t)| (h.unpack(), t.unpack()))
364        .skip_while(|(h, _)| *h <= bottom_h)
365        .take_while(|(h, _)| *h < top_h);
366
367    let iter = bottom_iter
368        .chain(middle_iter)
369        .chain(top_iter)
370        .map(|(h, t)| (h, Kelvin::from(t)))
371        .tuple_windows::<(_, _)>();
372
373    let (sum_dz, sum_kelvin_dz) = iter.fold((Meters(0.0), 0.0), |acc, ((h0, t0), (h1, t1))| {
374        let (mut sum_dz, mut sum_kelvin_dz) = acc;
375
376        let dz: Meters = h1 - h0;
377        let dt0: KelvinDiff = t0 - Kelvin::from(FREEZING);
378        let dt1: KelvinDiff = t1 - Kelvin::from(FREEZING);
379        let kelvin_dz = (dt0 + dt1).unpack() * dz.unpack();
380
381        sum_dz += dz;
382        sum_kelvin_dz += kelvin_dz;
383
384        (sum_dz, sum_kelvin_dz)
385    });
386
387    let mean_temp = CelsiusDiff(sum_kelvin_dz / sum_dz.unpack() / 2.0);
388
389    let energy = metfor::cpd * mean_temp * f64::ln(theta_top / theta_bottom);
390
391    Ok(energy)
392}