dipr/
lib.rs

1//! Convert the National Weather Service's (NWS) Digital Instantaneous Precipitation Rate (DIPR)
2//! radar product from its native data format into more common vector GIS formats
3//!
4//! The DIPR radar product is useful for observing and predicting precipitation on small time and
5//! distance scales (less than 10 km or 60 minutes). This forecasting niche is called nowcasting.
6//!
7//! NWS defines the DIPR format in [this specification document][spec].
8//!
9//! [spec]: https://www.roc.noaa.gov/public-documents/icds/2620001T.pdf
10
11use std::{fmt::Display, io};
12
13use chrono::{DateTime, Utc};
14use geo::{CoordsIter, Point as GeoPoint, Polygon as GeoPolygon, polygon};
15use geojson::{Feature, JsonObject, JsonValue};
16use product_description::{OperationalMode, ProductDescription};
17use product_symbology::ProductSymbology;
18use shapefile::{
19    Point as ShapefilePoint, Polygon as ShapefilePolygon, PolygonRing,
20    dbase::{self, FieldValue},
21    record::polygon::GenericPolygon,
22};
23use uom::si::{
24    angle::radian,
25    f32::{Length, Velocity},
26    length::meter,
27};
28
29#[macro_use]
30extern crate uom;
31
32mod error;
33mod product_description;
34mod product_symbology;
35mod radials;
36mod utils;
37
38pub use error::DiprError;
39use product_description::product_description;
40use product_symbology::product_symbology;
41pub use radials::Radial;
42use utils::*;
43
44/// Convenient wrapper around [`Result`]
45///
46/// The `Ok` case returns some `T` that the returning function intends to parse, along with the
47/// remaining input which has advanced past the parsed value. This is similar to how [`nom`][nom]
48/// works, but less fancy.
49///
50/// [nom]: https://docs.rs/nom/latest/nom/
51pub type ParseResult<'a, T> = Result<(T, &'a [u8]), DiprError>;
52
53#[derive(Clone, Debug, PartialEq)]
54/// Semantically useful representation of a DIPR product file
55///
56/// Create this struct with [parse_dipr].
57pub struct PrecipRate {
58    /// Radar station where this file was generated
59    ///
60    /// Station codes are usually four letters long. A list is available [here][station codes].
61    ///
62    /// [station codes]: https://www.weather.gov/media/tg/wsr88d-radar-list.pdf
63    pub station_code: String,
64    /// Moment when the scan in this file began
65    pub capture_time: DateTime<Utc>,
66    /// Incrementing counter to disambiguate scans
67    ///
68    /// This value is always between 1 and 80 inclusive.
69    pub scan_number: u8,
70    /// Longitude/latitude coordinates of the radar station in degrees
71    ///
72    /// Note that the coordinates are reversed from the perhaps more typical latitude/longitude.
73    /// This is to match the underlying convention of the `geo` crate, which ensures that the first
74    /// coordinate `x` maps to the "horizontal" value (longitude) and the second coordinate `y` maps
75    /// to the "vertical" value (latitude).
76    pub location: GeoPoint<f32>,
77    /// Condition of the radar station
78    pub operational_mode: OperationalMode,
79    /// Whether the radar station measured any precipitation anywhere in its coverage area
80    pub precip_detected: bool,
81    /// Highest precipitation rate found in this file
82    pub max_precip_rate: Velocity,
83    /// Distance between the inner and outer extents of each bin measured radially
84    pub bin_size: Length,
85    /// Distance between the radar station and the center of the nearest bin
86    pub range_to_first_bin: Length,
87    /// All precipitation data contained in this DIPR file organized by azimuth
88    pub radials: Vec<Radial>,
89}
90
91unit! {
92    system: uom::si;
93    quantity: uom::si::velocity;
94
95    @inch_per_hour: 0.09144; "in/hr", "inch per hour", "inches per hour";
96}
97
98fn destination(
99    origin_rad: GeoPoint<f32>,
100    origin_lat_sin: f32,
101    origin_lat_cos: f32,
102    bearing_rad: f32,
103    meters: f32,
104) -> GeoPoint<f32> {
105    const EARTH_RADIUS_METERS: f32 = 6371008.8;
106
107    let origin_lng = origin_rad.x();
108
109    let rad = meters / EARTH_RADIUS_METERS;
110
111    let lat =
112        { origin_lat_sin * rad.cos() + origin_lat_cos * rad.sin() * bearing_rad.cos() }.asin();
113    let y = bearing_rad.sin() * rad.sin() * origin_lat_cos;
114    let x = rad.cos() - origin_lat_sin * lat.sin();
115    let y_div_x = y / x;
116    // approximate atan2 with the identity function for small values; accurate within 0.01%
117    let lng = if y_div_x < 0.017322 {
118        y_div_x
119    } else {
120        y.atan2(x)
121    } + origin_lng;
122
123    // normalize longitude
124    let lng = if lng > 180. {
125        lng - 180.
126    } else if lng < -180. {
127        lng + 180.
128    } else {
129        lng
130    };
131
132    GeoPoint::new(lng.to_degrees(), lat.to_degrees())
133}
134
135impl PrecipRate {
136    /// Iterate over all bins, giving each of their boundaries and precipitation rates in a tuple
137    ///
138    /// Note that while the bins are officially bounded by circle sectors, this function
139    /// approximates the bin shapes with polygons composed of line segments. Order is not guaranteed
140    /// but is likely to be identical to the input file. That is, bins within each radial are given
141    /// in increasing order of distance from the radar station, and radials are given in increasing
142    /// order of azimuth angle.
143    ///
144    /// Note that DIPR product files don't seem to specify a coordinate reference system (CRS), so
145    /// this method uses a spherical Earth approximation. It's likely that this is [good enough for
146    /// most purposes][xkcd 2170].
147    ///
148    /// [xkcd 2170]: https://xkcd.com/2170/
149    pub fn into_bins_iter(
150        self,
151        skip_zeros: bool,
152    ) -> impl Iterator<Item = (GeoPolygon<f32>, Velocity)> {
153        let PrecipRate {
154            location,
155            bin_size,
156            range_to_first_bin,
157            radials,
158            ..
159        } = self;
160        let origin = location;
161        radials.into_iter().flat_map(move |radial| {
162            let Radial {
163                azimuth,
164                width,
165                precip_rates,
166                ..
167            } = radial;
168            let origin_rad = origin.to_radians();
169            let origin_lat_sin = origin_rad.y().sin();
170            let origin_lat_cos = origin_rad.y().cos();
171            let center_azimuth = azimuth;
172            let left_azimuth = center_azimuth - width / 2.;
173            let right_azimuth = center_azimuth + width / 2.;
174            precip_rates
175                .into_iter()
176                .enumerate()
177                .flat_map(move |(bin_idx, precip_rate)| {
178                    if skip_zeros && precip_rate.get::<inch_per_hour>() == 0. {
179                        return None;
180                    }
181
182                    let distance_inner_meters = range_to_first_bin.get::<meter>()
183                        + bin_size.get::<meter>() * (bin_idx as f32 - 0.5);
184                    let distance_outer_meters = range_to_first_bin.get::<meter>()
185                        + bin_size.get::<meter>() * (bin_idx as f32 + 0.5);
186
187                    let center_inner = destination(
188                        origin_rad,
189                        origin_lat_sin,
190                        origin_lat_cos,
191                        center_azimuth.get::<radian>(),
192                        distance_inner_meters,
193                    );
194                    let center_outer = destination(
195                        origin_rad,
196                        origin_lat_sin,
197                        origin_lat_cos,
198                        center_azimuth.get::<radian>(),
199                        distance_outer_meters,
200                    );
201
202                    let left_inner = destination(
203                        origin_rad,
204                        origin_lat_sin,
205                        origin_lat_cos,
206                        left_azimuth.get::<radian>(),
207                        distance_inner_meters,
208                    );
209                    let left_outer = destination(
210                        origin_rad,
211                        origin_lat_sin,
212                        origin_lat_cos,
213                        left_azimuth.get::<radian>(),
214                        distance_outer_meters,
215                    );
216
217                    let right_inner = destination(
218                        origin_rad,
219                        origin_lat_sin,
220                        origin_lat_cos,
221                        right_azimuth.get::<radian>(),
222                        distance_inner_meters,
223                    );
224                    let right_outer = destination(
225                        origin_rad,
226                        origin_lat_sin,
227                        origin_lat_cos,
228                        right_azimuth.get::<radian>(),
229                        distance_outer_meters,
230                    );
231
232                    let bin_shape = if center_inner == right_inner || center_inner == left_inner {
233                        polygon!(center_inner.into(), right_outer.into(), left_outer.into(),)
234                    } else {
235                        polygon!(
236                            center_inner.into(),
237                            right_inner.into(),
238                            right_outer.into(),
239                            center_outer.into(),
240                            left_outer.into(),
241                            left_inner.into()
242                        )
243                    };
244                    Some((bin_shape, precip_rate))
245                })
246        })
247    }
248    /// Iterate over all precipitation bins as in [`PrecipRate::into_bins_iter`], but also convert
249    /// the results into values that are useful with the [`shapefile`] crate
250    pub fn into_shapefile_iter(
251        self,
252        skip_zeros: bool,
253    ) -> impl Iterator<Item = (GenericPolygon<ShapefilePoint>, FieldValue)> {
254        self.into_bins_iter(skip_zeros)
255            .map(|(polygon, precip_rate)| {
256                (
257                    ShapefilePolygon::new(PolygonRing::Outer(
258                        polygon
259                            .coords_iter()
260                            .map(|c| ShapefilePoint::new(c.x.into(), c.y.into()))
261                            .collect::<Vec<ShapefilePoint>>(),
262                    )),
263                    dbase::FieldValue::Float(Some(precip_rate.get::<inch_per_hour>())),
264                )
265            })
266    }
267    /// Iterate over all precipitation bins as in [`PrecipRate::into_bins_iter`], but also convert
268    /// the results into values that are useful with the [`geojson`] crate
269    pub fn into_geojson_iter(self, skip_zeros: bool) -> impl Iterator<Item = Feature> {
270        self.into_bins_iter(skip_zeros)
271            .map(|(polygon, precip_rate)| {
272                let mut properties = JsonObject::new();
273                properties.insert(
274                    "precipRate".to_string(),
275                    JsonValue::from(precip_rate.get::<inch_per_hour>()),
276                );
277                Feature {
278                    geometry: Some((&polygon).into()),
279                    properties: Some(properties),
280                    ..Default::default()
281                }
282            })
283    }
284}
285
286impl Display for PrecipRate {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        writeln!(f, "Station Code:        {}", self.station_code)?;
289        writeln!(f, "Capture Time:        {}", self.capture_time)?;
290        writeln!(f, "Operational Mode:    {}", self.operational_mode)?;
291        writeln!(
292            f,
293            "Precip Detected:     {}",
294            if self.precip_detected { "Yes" } else { "No" }
295        )?;
296        writeln!(f, "Scan Number:         {}", self.scan_number)?;
297        writeln!(
298            f,
299            "Max Precip Rate:     {:.3} in/hr",
300            self.max_precip_rate.get::<inch_per_hour>()
301        )?;
302        writeln!(
303            f,
304            "Bin Size:            {: >3} m",
305            self.bin_size.get::<meter>()
306        )?;
307        writeln!(f, "Number of Radials:  {: >4}", self.radials.len())?;
308        write!(
309            f,
310            "Range to First Bin:  {: >3} m",
311            self.range_to_first_bin.get::<meter>()
312        )
313    }
314}
315
316fn text_header(input: &[u8]) -> ParseResult<String> {
317    let (_, tail) = take_bytes(input, 7)?;
318    let (station_code, tail) = take_bytes(tail, 4)?;
319    let (_, tail) = take_bytes(tail, 19)?;
320    match String::from_utf8(station_code.to_vec()) {
321        Ok(s) => Ok((s, tail)),
322        Err(e) => Err(e.into()),
323    }
324}
325
326fn message_header(input: &[u8]) -> ParseResult<()> {
327    let (_, tail) = take_bytes(input, 18)?;
328    Ok(((), tail))
329}
330
331/// Convert a byte slice into a [`PrecipRate`] or return an error
332pub fn parse_dipr(input: &[u8]) -> Result<PrecipRate, DiprError> {
333    let (station_code, tail) = text_header(input)?;
334    let (_, tail) = message_header(tail)?;
335
336    let (
337        ProductDescription {
338            location,
339            operational_mode,
340            precip_detected,
341            max_precip_rate,
342            uncompressed_size,
343        },
344        tail,
345    ) = product_description(tail)?;
346
347    // decompress remaining input, which should all be compressed with bzip2
348    let mut uncompressed_payload = Vec::with_capacity(uncompressed_size as usize);
349    let mut reader = bzip2_rs::DecoderReader::new(tail);
350    io::copy(&mut reader, &mut uncompressed_payload)?;
351
352    let (
353        ProductSymbology {
354            range_to_first_bin,
355            bin_size,
356            scan_number,
357            capture_time,
358            radials,
359        },
360        _,
361    ) = product_symbology(&uncompressed_payload)?;
362
363    Ok(PrecipRate {
364        station_code,
365        capture_time,
366        scan_number,
367        location,
368        operational_mode,
369        precip_detected,
370        max_precip_rate,
371        bin_size,
372        range_to_first_bin,
373        radials,
374    })
375}