nws_forecast_zones/
lib.rs

1//! NWS Public Forecast Zones
2//!
3//! NWS's description of forecast zones:
4//!
5//! > The NWS issues forecasts and some watches and warnings for public
6//! > zones which usually are the same as counties but in many cases are
7//! > subsets of counties.  Counties are subset into zones to allow for
8//! > more accurate forecasts because of the differences in weather within
9//! > a county due to such things as elevation or proximity to large
10//! > bodies of water.
11//!
12//! This crate contains information about all 3882 public [forecast zones](ForecastZone),
13//! based on the April 5, 2022 data dump from [NWS's website](https://www.weather.gov/gis/PublicZones).
14//!
15//! And also 632 [coastal](CoastalMarineZone) and [offshore](OffshoreMarineZone) marine zones
16//! (last updated March 22, 2022, downloaded from [here](https://www.weather.gov/gis/MarineZones))
17//!
18//! You can get a [ForecastZone] by calling [`from_str`](ForecastZone::from_str), or by referencing one
19//! of the enum variants.  Note that the variants are not docmented (because there are so many of them),
20//! but you can still access them.
21//!
22//!
23//!
24//! # Example
25//!
26//! ```rust
27//! # use nws_forecast_zones::*;
28//! let parsed = parse_zoneset("MAZ012-013-017-RIZALL").unwrap();
29//! assert!(parsed.contains(ForecastZone::RI001));
30//! assert!(parsed.contains(ForecastZone::MA013));
31//! assert!(! parsed.contains(ForecastZone::MA014));
32//! ```
33//!
34//! This crate uses data published from NWS, but is otherwise unaffiliated with the National Weather Service,
35//! and is not an official NWS library.
36
37use std::num::ParseIntError;
38
39mod gen;
40mod gen_fz;
41mod gen_mz;
42mod gen_oz;
43
44pub use gen::ForecastZone;
45pub use gen_fz::FireZone;
46pub use gen_mz::CoastalMarineZone;
47pub use gen_oz::OffshoreMarineZone;
48
49/// An onshore, offshore, or coastal marine zone
50#[derive(Debug, Copy, Clone, Eq, PartialEq)]
51pub enum Zone {
52    /// An onshore forecast zone
53    ///
54    /// See <https://www.weather.gov/gis/PublicZones>
55    Forecast(ForecastZone),
56    /// A coastal marine forecast zone
57    ///
58    /// See <https://www.weather.gov/gis/MarineZones>
59    CoastalMarine(CoastalMarineZone),
60    /// An offshore marine forecast zone
61    ///
62    /// See <https://www.weather.gov/gis/MarineZones>
63    Offshore(OffshoreMarineZone),
64
65    /// A fire weather zone
66    ///
67    /// See <https://www.weather.gov/gis/FireZones>
68    FireZone(FireZone),
69}
70
71impl Zone {
72    /// Get details about this particular zone
73    pub fn details(&self) -> ZoneDetails {
74        match self {
75            Zone::Forecast(z) => z.details(),
76            Zone::CoastalMarine(z) => z.details(),
77            Zone::Offshore(z) => z.details(),
78            Zone::FireZone(z) => z.details(),
79        }
80    }
81    /// Try to create a new zone
82    ///
83    /// If the given zone isn't a known onshore, offshore, or coastal zone, `None` is returned
84    pub fn new(two: &str, numeric: u16) -> Option<Self> {
85        if let Some(z) = ForecastZone::new(two, numeric) {
86            Some(Zone::Forecast(z))
87        } else if let Some(z) = CoastalMarineZone::new(two, numeric) {
88            Some(Zone::CoastalMarine(z))
89        } else if let Some(z) = OffshoreMarineZone::new(two, numeric) {
90            Some(Zone::Offshore(z))
91        } else if let Some(z) = FireZone::new(two, numeric) {
92            Some(Zone::FireZone(z))
93        } else {
94            None
95        }
96    }
97}
98impl From<ForecastZone> for Zone {
99    fn from(z: ForecastZone) -> Self {
100        Zone::Forecast(z)
101    }
102}
103
104impl From<CoastalMarineZone> for Zone {
105    fn from(z: CoastalMarineZone) -> Self {
106        Zone::CoastalMarine(z)
107    }
108}
109
110impl From<OffshoreMarineZone> for Zone {
111    fn from(z: OffshoreMarineZone) -> Self {
112        Zone::Offshore(z)
113    }
114}
115
116impl From<FireZone> for Zone {
117    fn from(z: FireZone) -> Self {
118        Zone::FireZone(z)
119    }
120}
121
122/// Details about an NWS forecast zone
123#[derive(Debug, Copy, Clone)]
124pub struct ZoneDetails {
125    /// Two-letter state abbreviation, or a two-letter code for marine forecast zones
126    pub state: &'static str,
127    /// Three-digit zone number
128    pub zone: &'static str,
129    /// The zone number, as a u16
130    pub zone_numeric: u16,
131    /// Name of this zone
132    pub name: &'static str,
133    /// WFO (Weather forecasting office) for this zone
134    pub wfo: &'static str,
135}
136
137#[cfg_attr(test, derive(Debug))]
138enum ZoneSetEnum {
139    /// All zones in a specific state
140    ///
141    /// Only applies for land-based forecast zones (i.e. not Coastal or Offshore zones)
142    All(String),
143    /// A inclusive range of zones for a specific state
144    Range(String, u16, u16),
145    /// A specific forecast zone
146    Specific(Zone),
147    /// A specifc zone, but that is unknown
148    UnknownZone(String, u16),
149    /// A list of zones
150    List(Vec<ZoneSetEnum>),
151}
152impl ZoneSetEnum {
153    fn contains(&self, zone: Zone) -> bool {
154        match self {
155            ZoneSetEnum::All(st) => {
156                if let Zone::Forecast(zone) = zone {
157                    zone.details().state == st
158                } else {
159                    false
160                }
161            }
162            ZoneSetEnum::Range(st, lo, hi) => {
163                let d = zone.details();
164                d.state == st && d.zone_numeric >= *lo && d.zone_numeric <= *hi
165            }
166            ZoneSetEnum::Specific(a) => *a == zone,
167            ZoneSetEnum::List(l) => {
168                for sub in l {
169                    if sub.contains(zone) {
170                        return true;
171                    }
172                }
173                false
174            }
175            ZoneSetEnum::UnknownZone(..) => false,
176        }
177    }
178}
179
180/// A set of one or more forecast zones
181#[cfg_attr(test, derive(Debug))]
182pub struct ZoneSet {
183    inner: ZoneSetEnum,
184}
185
186impl ZoneSet {
187    /// Is a specific forecast zone in this zone set?
188
189    pub fn contains(&self, zone: impl Into<Zone>) -> bool {
190        self.inner.contains(zone.into())
191    }
192}
193
194/// Error type for [parse_zoneset]
195#[derive(Debug)]
196pub enum ZoneSetError {
197    /// We needed to read another character, but we were out!
198    OutOfChars,
199    /// We encountered some unexpected character
200    UnexpectedChar(char),
201
202    /// We encountered an unexpected string
203    UnexpectedString(String),
204
205    ParsingError,
206
207    NoSuchZone(String, u16),
208}
209
210impl From<ParseIntError> for ZoneSetError {
211    fn from(_: ParseIntError) -> Self {
212        ZoneSetError::ParsingError
213    }
214}
215
216/// Parse a range of forecast zones
217///
218/// Often NWS forecasts will list a ranges of zones that apply to a given forecast.  This range might look something
219/// like this:
220///
221/// ```test
222/// MAZ017>019-RIZ001>003-140815-
223/// ```
224///
225/// This range includes MA zones 017 through 019 (inclusive) and RI zones 001 through 003
226///
227/// This function will parse a string of this form into a [ZoneSet], which can be used to query
228/// if a specific zone is in the parsed set.
229pub fn parse_zoneset(mut range: &str) -> Result<ZoneSet, ZoneSetError> {
230    macro_rules! read1 {
231        ($chars:expr) => {{
232            $chars.next().ok_or(ZoneSetError::OutOfChars)?
233        }};
234    }
235    macro_rules! read3 {
236        ($chars:expr) => {{
237            let mut n = String::with_capacity(3);
238            for _ in 0..3 {
239                n.push($chars.next().ok_or(ZoneSetError::OutOfChars)?);
240            }
241            n
242        }};
243    }
244
245    #[derive(Eq, PartialEq, Debug)]
246    enum State {
247        /// We want to read 3 chars, which we expect to be state in the form "__Z"
248        ExpectingStateZ,
249        /// We want 3 chars, which are either 'ALL' or 3 digits
250        ExpectingTricode(String),
251
252        /// We expect either a '-' or '>'
253        ///
254        /// We've already parsed a numeric zone, and now we expect either '-' or '>'
255        ExpectingListOrRange(String, u16),
256
257        ExpectingEndOfRange(String, u16),
258
259        /// We are expecting either a new 3-char state, or a 3-digit numeric code
260        ExpectingStateOrCode(String),
261    }
262
263    // if the last 8 chars look to be of the form "-123456-", then remove them
264    let l = range.len();
265    if l > 8
266        && range[l - 8..]
267            .chars()
268            .all(|c| c == '-' || c.is_ascii_digit())
269    {
270        range = &range[..l - 8];
271    }
272
273    // let mut cur = Cursor::new(range);
274    let mut chars = range.chars().peekable();
275
276    let mut list = Vec::new();
277
278    let mut parsing_state = State::ExpectingStateZ;
279
280    // let mut state = String::new();
281    // let mut numeric = 0;
282
283    loop {
284        parsing_state = match parsing_state {
285            State::ExpectingStateZ => {
286                let t = read3!(chars);
287                assert!(t.ends_with('Z'));
288                State::ExpectingTricode(t)
289            }
290            State::ExpectingTricode(state) => {
291                let n = read3!(chars);
292                if n == "ALL" {
293                    list.push(ZoneSetEnum::All(state[0..2].to_string()));
294                    if chars.peek().is_none() {
295                        break;
296                    }
297                    let x = read1!(chars);
298                    if x != '-' {
299                        return Err(ZoneSetError::UnexpectedChar(x));
300                    }
301                    State::ExpectingStateZ
302                } else if n.chars().all(|c| c.is_ascii_digit()) {
303                    let numeric = n.parse()?;
304                    // we now need to read 1 more character, which should either be a '-' or '>'
305
306                    State::ExpectingListOrRange(state, numeric)
307                } else {
308                    return Err(ZoneSetError::UnexpectedString(n));
309                }
310            }
311            State::ExpectingListOrRange(state, numeric) => {
312                match chars.next() {
313                    None => {
314                        // end of data, so we know we're not the start of a range.  thus we are a specific zone
315                        if let Some(z) = Zone::new(&state[..2], numeric) {
316                            list.push(ZoneSetEnum::Specific(z));
317                        } else {
318                            list.push(ZoneSetEnum::UnknownZone(state, numeric));
319                            // return Err(ZoneSetError::NoSuchZone(state, numeric));
320                        }
321                        break;
322                    }
323                    Some('>') => {
324                        // we've already read the first part of the range (in 'numeric'), now
325                        // we expect to read another 3 digits, which indicate the ending zone of a range
326                        State::ExpectingEndOfRange(state, numeric)
327                    }
328                    Some('-') => {
329                        // we've already read the first part of the range (in 'numeric') and we
330                        // know we're not the start of a range (nor the end).  so we have a specific zone
331                        if let Some(z) = Zone::new(&state[..2], numeric) {
332                            list.push(ZoneSetEnum::Specific(z));
333                        } else {
334                            list.push(ZoneSetEnum::UnknownZone(state.clone(), numeric));
335                            // return Err(ZoneSetError::NoSuchZone(state, numeric));
336                        }
337
338                        State::ExpectingStateOrCode(state)
339                    }
340                    Some(x) => {
341                        return Err(ZoneSetError::UnexpectedChar(x));
342                    }
343                }
344            }
345            State::ExpectingEndOfRange(state, numeric) => {
346                let code = read3!(chars);
347                let end_numeric = code.parse()?;
348                list.push(ZoneSetEnum::Range(
349                    state[0..2].to_string(),
350                    numeric,
351                    end_numeric,
352                ));
353
354                match chars.next() {
355                    None => break,
356                    Some('-') => State::ExpectingStateOrCode(state),
357                    Some(x) => return Err(ZoneSetError::UnexpectedChar(x)),
358                }
359            }
360            State::ExpectingStateOrCode(state) => {
361                let t = read3!(chars);
362                if t.ends_with('Z') {
363                    State::ExpectingTricode(t)
364                } else if t.chars().all(|c| c.is_ascii_digit()) {
365                    let numeric = t.parse()?;
366                    State::ExpectingListOrRange(state, numeric)
367                } else {
368                    return Err(ZoneSetError::UnexpectedString(t));
369                }
370            }
371        };
372    }
373
374    let inner = if list.len() == 1 {
375        list.pop().unwrap()
376    } else {
377        ZoneSetEnum::List(list)
378    };
379
380    Ok(ZoneSet { inner })
381}
382
383#[cfg(test)]
384mod tests {
385
386    use crate::parse_zoneset;
387    use crate::CoastalMarineZone;
388    use crate::ForecastZone;
389    use crate::OffshoreMarineZone;
390    use crate::Zone;
391
392    #[test]
393    fn test_parsing() {
394        let a = parse_zoneset("RIZALL").unwrap();
395        println!("{a:?}");
396        assert!(a.contains(ForecastZone::RI004));
397        assert!(!a.contains(ForecastZone::AK017));
398
399        let a = parse_zoneset("MEZALL-NHZALL-142200-").unwrap();
400        println!("{a:?}");
401        assert!(a.contains(ForecastZone::ME001));
402        assert!(a.contains(ForecastZone::NH001));
403        assert!(!a.contains(ForecastZone::RI001));
404
405        let a = parse_zoneset("RIZ001-002").unwrap();
406        println!("{a:?}");
407        assert!(a.contains(ForecastZone::RI001));
408        assert!(a.contains(ForecastZone::RI002));
409        assert!(!a.contains(ForecastZone::RI003));
410
411        let a = parse_zoneset("RIZ001-002-MAZ003").unwrap();
412        println!("{a:?}");
413        assert!(a.contains(ForecastZone::RI001));
414        assert!(a.contains(ForecastZone::RI002));
415        assert!(!a.contains(ForecastZone::RI003));
416        assert!(a.contains(ForecastZone::MA003));
417
418        let a = parse_zoneset("RIZ001>002").unwrap();
419        println!("{a:?}");
420        assert!(a.contains(ForecastZone::RI001));
421        assert!(a.contains(ForecastZone::RI002));
422        assert!(!a.contains(ForecastZone::RI003));
423
424        let a = parse_zoneset("MAZ017>019-RIZ001>003-140815-").unwrap();
425        println!("{a:?}");
426        assert!(!a.contains(ForecastZone::MA016));
427        assert!(a.contains(ForecastZone::MA017));
428        assert!(a.contains(ForecastZone::MA018));
429        assert!(a.contains(ForecastZone::MA019));
430        assert!(!a.contains(ForecastZone::MA020));
431
432        assert!(a.contains(ForecastZone::RI001));
433        assert!(a.contains(ForecastZone::RI002));
434        assert!(a.contains(ForecastZone::RI003));
435        assert!(!a.contains(ForecastZone::RI004));
436
437        let a =
438            parse_zoneset("CTZ002>004-MAZ002>006-008>014-017-020-026-RIZ001>007-110000-").unwrap();
439        println!("{a:?}");
440        assert!(a.contains(ForecastZone::CT002));
441        assert!(a.contains(ForecastZone::MA006));
442        assert!(!a.contains(ForecastZone::MA007));
443        assert!(a.contains(ForecastZone::MA008));
444        assert!(a.contains(ForecastZone::MA009));
445        assert!(a.contains(ForecastZone::MA014));
446        assert!(!a.contains(ForecastZone::MA015));
447        assert!(!a.contains(ForecastZone::MA016));
448        assert!(a.contains(ForecastZone::MA017));
449        assert!(a.contains(ForecastZone::RI001));
450        assert!(a.contains(ForecastZone::RI007));
451        assert!(!a.contains(ForecastZone::RI008));
452
453        let a = parse_zoneset("ILZ095>097-MOZ018-019-026-027-034>036-142200-").unwrap();
454        println!("{a:?}");
455    }
456
457    #[test]
458    fn test_parsing_marine() {
459        let a = parse_zoneset("GMZ634>636-650-655-675-222130-").unwrap();
460        println!("{a:?}");
461        assert!(a.contains(CoastalMarineZone::GMZ634));
462        assert!(a.contains(Zone::CoastalMarine(CoastalMarineZone::GMZ675)));
463        assert!(a.contains(CoastalMarineZone::GMZ650));
464        assert!(!a.contains(ForecastZone::MA001));
465        assert!(!a.contains(OffshoreMarineZone::AMZ040));
466
467        let a =
468            parse_zoneset("NYZ176-178-MAZ016-IL014-TNZ027-NJZ106-GAZ044-MD014-230500-").unwrap();
469        println!("{a:?}");
470    }
471
472    #[test]
473    fn test_nopanic() {
474        let a = parse_zoneset("AAZ000-123456-").unwrap();
475        println!("{a:?}");
476
477        let a = parse_zoneset("RIZ999-123456-").unwrap();
478        println!("{a:?}");
479
480        let a =
481            parse_zoneset("ANZ835-935-AMZ111-154-330-350-352-354-370-450-454-472-230500-").unwrap();
482        println!("{a:?}");
483
484        let a = parse_zoneset("ANZ600-241415-").unwrap();
485        println!("{a:?}");
486
487        parse_zoneset("LEZ161-240830-").unwrap();
488        parse_zoneset("LSZ261-241000-").unwrap();
489        parse_zoneset("LMZ867-665-643>646-240300-").unwrap();
490    }
491}
492
493// Test docs
494//  RIZALL-131000-
495//  MEZ002-015-021-024-NHZ003-005-008-011-012-014-VTZ005-008-100300-
496//  MAZ017>019-RIZ001>003-140815-
497//  CTZ002-004>006-009-010-012-RIZ004-006-007-MAZ004-011-015-142100-
498//  CTZ002>004-MAZ002>006-008>014-017-020-026-RIZ001>007-110000-
499//  MEZALL-NHZALL-142200-