METAR and TAF
METAR and TAF aviation weather decoded: wind in knots whatever it came in, visibility in both units, runway visual range, weather, clouds and ceiling, temperature, altimeter in hPa and inHg, flight category, trends and forecast groups, with remarks left readable and read out where known.
Version 1.0.0 · 3 functions · 1 crate
Functions
What use metar.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use metar_decode, metar_taf from metar (Quillo 0.6).
metar_decode(report: str) → anyor an errorDecodes a METAR or SPECI: { type, station, day, time, auto, corrected, nil, wind, visibility, runway_visual_range, weather, clouds, vertical_visibility_ft, sky, temperature_c, dewpoint_c, humidity, altimeter: { hpa, inhg }, recent_weather, wind_shear, category, trends, remarks, remarks_decoded, unread, raw }. Wind carries speed_kt whatever it was given in. category is VFR, MVFR, IFR or LIFR. remarks stays as the station wrote it, with what is known read out in remarks_decoded. A group it cannot read goes in unread, never lost.
metar_taf(report: str) → anyor an errorDecodes a TAF: { station, amended, corrected, issued, valid, forecast, groups, max, min, unread, raw }. forecast is the opening conditions, groups the changes in order: { type, from, to, probability, wind, visibility, weather, clouds, category }, type being FM, BECMG, TEMPO or PROB, with a PROB30 TEMPO as TEMPO and its probability.
metar_text(report: str) → stror an errorA decoded METAR as plain sentences, for people who do not read the code.
Get it
$ quillo install metar # metar.qwer into this folder$ quillo install metar --example # and its example, metar.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/metar.qwer$ curl -O https://quillolang.org/plugins/files/metar.qweThen, in your program, all of it or only what you call:
use metar.qweruse metar_decode, metar_taf from metar // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }Example
// A flight planner's weather: METAR reports and a TAF decoded, wind in knots, pressure both ways,// flight category worked out, and a report that a pilot would read as it stands left readable.//// quillo run metar.qwe then open http://localhost:8080/us, /europe, /fog, /calm, /words, /taf and /refused//// Live reports come from NOAA's aviationweather.gov as plain text; the same text goes in here.use "metar.qwer"let US = "METAR KJFK 121651Z 24015G25KT 210V270 10SM FEW045 SCT250 22/14 A2992 RMK AO2 PK WND 26032/1622 SLP132 P0003 T02220139 SNOW SHOVELING NEARBY $"let EU = "SPECI EGLL 281120Z AUTO 18012KT 150V210 3000 1200NE R27L/1100D R09R/0900V1500U +TSRA BR FEW008 BKN015CB 12/11 Q0998 RERA WS R27L TEMPO FM1200 TL1300 1500 TSRA BECMG 24010KT 9999 NSW="let FOG = "KSFO 130056Z 00000KT 1/4SM R28L/1000V1800FT/N FG VV002 M01/M02 A3001"let MIXED = "KBOS 130056Z 07012KT 1 1/2SM -FZRA BKN009 OVC015 02/01 A2954"route GET /us { let m = metar_decode(US)? { station: m.station, day: m.day, time: m.time, wind: m.wind, visibility: m.visibility, clouds: m.clouds, ceiling: m.ceiling_ft, temperature: m.temperature_c, dew: m.dewpoint_c, humidity: m.humidity, altimeter: m.altimeter, category: m.category, remarks: m.remarks, read_out: m.remarks_decoded, unread: m.unread, }}route GET /europe { let m = metar_decode(EU)? { type: m.type, auto: m.auto, wind: m.wind.variable_from, visibility: m.visibility, rvr: m.runway_visual_range, weather: m.weather, clouds: m.clouds, altimeter: m.altimeter, recent: m.recent_weather, shear: m.wind_shear, trends: m.trends, category: m.category, }}route GET /fog { let m = metar_decode(FOG)? let b = metar_decode(MIXED)? { calm: m.wind.calm, visibility: m.visibility, rvr: m.runway_visual_range, vv: m.vertical_visibility_ft, ceiling: m.ceiling_ft, temperature: m.temperature_c, category: m.category, fraction: b.visibility.miles, freezing: b.weather[0].text, boston: b.category, }}route GET /calm { let c = metar_decode("LFPG 121200Z 24008KT CAVOK 18/09 Q1021 NOSIG")? let k = metar_decode("UUEE 121200Z 03005MPS 9999 NSC M12/M15 Q1033")? let none = metar_decode("METAR KXYZ 121200Z NIL")? let odd = metar_decode("EGLL 121200Z 24008KT 9999 FEW020 15/10 Q1020 MYSTERY")? { cavok: [c.cavok, c.visibility.meters, c.category, c.trends], mps: [k.wind.speed, k.wind.speed_kt], sky: k.sky, no_report: none.nil, unread: odd.unread, altimeter: c.altimeter }}route GET /words = { us: metar_text(US)?, fog: metar_text(FOG)?, calm: metar_text("LFPG 121200Z 00000KT CAVOK 18/09 Q1021")?,}route GET /taf { let t = metar_taf("TAF AMD KJFK 121130Z 1212/1318 24012KT P6SM SCT030 FM121800 28015G25KT 5SM -SHRA BKN020 TEMPO 1220/1224 2SM TSRA BKN015CB PROB30 1300/1306 1/2SM FG OVC002 PROB40 TEMPO 1306/1308 3SM BR BECMG 1308/1310 VRB03KT TX25/1218Z TN12/1306Z")? { amended: t.amended, issued: t.issued, valid: t.valid, opening: t.forecast.wind.speed_kt, opening_category: t.forecast.category, groups: t.groups | map((g) => { type: g.type, probability: g.probability, from: g.from, to: g.to, category: g.category, weather: g.weather | map((w) => w.text) }), max: t.max, min: t.min, unread: t.unread, }}route GET /refused = { station: metar_decode("hello world").error, time: metar_decode("KJFK noon").error, not_taf: metar_taf("KJFK 121130Z").error, no_valid: metar_taf("TAF KJFK 121130Z tomorrow").error,}$ quillo run metar.qwe$ curl 'localhost:8080/us'$ curl 'localhost:8080/europe'$ curl 'localhost:8080/fog'$ curl 'localhost:8080/calm'$ curl 'localhost:8080/words'$ curl 'localhost:8080/taf'$ curl 'localhost:8080/refused'Source
//! METAR and TAF aviation weather decoded by hand: wind, visibility, runway visual range, weather, clouds, temperature, altimeter in both units, flight category, trends and forecast groups, with remarks left readable.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};const HPA_PER_INHG: f64 = 33.8639;const MILE_M: f64 = 1609.344;const DESCRIPTORS: &[(&str, &str)] = &[("MI", "shallow"), ("PR", "partial"), ("BC", "patches of"), ("DR", "low drifting"), ("BL", "blowing"), ("SH", "showers of"), ("TS", "thunderstorm"), ("FZ", "freezing")];const PHENOMENA: &[(&str, &str)] = &[ ("DZ", "drizzle"), ("RA", "rain"), ("SN", "snow"), ("SG", "snow grains"), ("IC", "ice crystals"), ("PL", "ice pellets"), ("GR", "hail"), ("GS", "small hail"), ("UP", "unknown precipitation"), ("BR", "mist"), ("FG", "fog"), ("FU", "smoke"), ("VA", "volcanic ash"), ("DU", "widespread dust"), ("SA", "sand"), ("HZ", "haze"), ("PY", "spray"), ("PO", "dust whirls"), ("SQ", "squalls"), ("FC", "funnel cloud"), ("SS", "sandstorm"), ("DS", "duststorm"),];const COVER: &[(&str, &str, u32)] = &[("FEW", "few", 2), ("SCT", "scattered", 4), ("BKN", "broken", 7), ("OVC", "overcast", 8)];fn digits(x: &str) -> bool { !x.is_empty() && x.bytes().all(|b| b.is_ascii_digit())}fn round(x: f64, places: i32) -> f64 { let p = 10f64.powi(places); (x * p).round() / p}/// `dddffGggKT`, also `VRB`, `MPS`, `KMH` and a missing `/////`.fn wind(t: &str) -> Option<Value> { let (body, unit, to_kt) = if let Some(b) = t.strip_suffix("KT") { (b, "KT", 1.0) } else if let Some(b) = t.strip_suffix("MPS") { (b, "MPS", 1.94384) } else if let Some(b) = t.strip_suffix("KMH") { (b, "KMH", 0.539957) } else { return None }; if body.len() < 5 { return None; } let (dir, rest) = body.split_at(3); let (speed, gust) = match rest.split_once('G') { Some((a, b)) => (a, Some(b)), None => (rest, None), }; if body.starts_with("/////") { return Some(json!({ "missing": true, "unit": unit })); } let dir_ok = dir == "VRB" || digits(dir); let plus = speed.strip_prefix('P'); if !dir_ok || !(digits(speed) || plus.is_some_and(digits)) { return None; } let sp: f64 = plus.unwrap_or(speed).parse().ok()?; let g: Option<f64> = match gust { Some(x) => Some(x.trim_start_matches('P').parse().ok()?), None => None, }; Some(json!({ "direction": if dir == "VRB" { Value::Null } else { json!(dir.parse::<i64>().ok()?) }, "variable": dir == "VRB", "speed": sp, "gust": g, "unit": unit, "speed_kt": round(sp * to_kt, 0), "gust_kt": g.map(|x| round(x * to_kt, 0)), "calm": sp == 0.0, "above": plus.is_some(), }))}fn wind_range(t: &str) -> Option<(i64, i64)> { let (a, b) = t.split_once('V')?; if a.len() == 3 && b.len() == 3 && digits(a) && digits(b) { Some((a.parse().ok()?, b.parse().ok()?)) } else { None }}/// `1/2`, `3`, `1 1/2`'s pieces: a number of miles.fn miles(x: &str) -> Option<f64> { match x.split_once('/') { Some((a, b)) if digits(a) && digits(b) && b != "0" => Some(a.parse::<f64>().ok()? / b.parse::<f64>().ok()?), None if digits(x) => x.parse().ok(), _ => None, }}fn vis_miles(t: &str, whole: Option<f64>) -> Option<Value> { let b = t.strip_suffix("SM")?; let (bound, b) = if let Some(r) = b.strip_prefix('M') { ("less", r) } else if let Some(r) = b.strip_prefix('P') { ("more", r) } else { ("", b) }; let m = miles(b)? + whole.unwrap_or(0.0); let mut v = json!({ "miles": round(m, 3), "meters": round(m * MILE_M, 0) }); if !bound.is_empty() { v["bound"] = json!(bound); } Some(v)}fn vis_meters(t: &str) -> Option<Value> { let (num, dir) = t.split_at(t.len().min(4)); if num.len() != 4 || !digits(num) { return None; } let m: f64 = num.parse().ok()?; let (dir, ndv) = match dir { "" => ("", false), "NDV" => ("", true), "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW" => (dir, false), _ => return None, }; let mut v = json!({ "meters": m, "miles": round(m / MILE_M, 2) }); if m >= 9999.0 { v["bound"] = json!("more"); } if !dir.is_empty() { v["direction"] = json!(dir); } if ndv { v["no_directional"] = json!(true); } Some(v)}fn rvr(t: &str) -> Option<Value> { let (runway, rest) = t.strip_prefix('R')?.split_once('/')?; if runway.len() < 2 || !digits(&runway[..2]) || (runway.len() == 3 && !"LRC".contains(&runway[2..])) || runway.len() > 3 { return None; } let (rest, tendency) = match rest.rsplit_once('/') { Some((a, b)) if ["U", "D", "N", ""].contains(&b) => (a, b), _ => match rest.strip_suffix(['U', 'D', 'N']) { Some(a) if !a.ends_with('/') => (a, &rest[a.len()..]), _ => (rest, ""), }, }; let (rest, feet) = rest.strip_suffix("FT").map_or((rest, false), |r| (r, true)); if rest.chars().all(|c| c == '/') { return Some(json!({ "runway": runway, "missing": true })); } let one = |x: &str| -> Option<(f64, &str)> { let (b, n) = if let Some(r) = x.strip_prefix('M') { ("less", r) } else if let Some(r) = x.strip_prefix('P') { ("more", r) } else { ("", x) }; if n.len() != 4 || !digits(n) { None } else { Some((n.parse().ok()?, b)) } }; let unit = if feet { 0.3048 } else { 1.0 }; let (low, high) = match rest.split_once('V') { Some((a, b)) => (one(a)?, Some(one(b)?)), None => (one(rest)?, None), }; Some(json!({ "runway": runway, "meters": round(low.0 * unit, 0), "feet": if feet { json!(low.0) } else { Value::Null }, "max_meters": high.map(|h| round(h.0 * unit, 0)), "bound": if low.1.is_empty() { Value::Null } else { json!(low.1) }, "tendency": match tendency { "U" => json!("rising"), "D" => json!("falling"), "N" => json!("steady"), _ => Value::Null }, }))}fn weather(t: &str) -> Option<Value> { let (intensity, mut r) = if let Some(x) = t.strip_prefix('+') { ("heavy", x) } else if let Some(x) = t.strip_prefix('-') { ("light", x) } else if let Some(x) = t.strip_prefix("VC") { ("nearby", x) } else { ("", t) }; if r.is_empty() || r.len() % 2 == 1 { return None; } let mut descriptor = None; if let Some(d) = DESCRIPTORS.iter().find(|d| r.starts_with(d.0)) { descriptor = Some(d.1); r = &r[2..]; } let mut words = vec![]; let mut codes = r; while !codes.is_empty() { words.push(PHENOMENA.iter().find(|p| p.0 == &codes[..2])?.1); codes = &codes[2..]; } if words.is_empty() && descriptor != Some("thunderstorm") { return None; } let mut text = String::new(); if !intensity.is_empty() && intensity != "nearby" { text.push_str(intensity); text.push(' '); } if let Some(d) = descriptor { text.push_str(d); if d == "thunderstorm" && !words.is_empty() { text.push_str(" with"); } if !words.is_empty() { text.push(' '); } } text.push_str(&words.join(" and ")); if intensity == "nearby" { text.push_str(" nearby"); } Some(json!({ "code": t, "intensity": if intensity.is_empty() { Value::Null } else { json!(intensity) }, "text": text }))}fn cloud(t: &str) -> Option<Value> { if t.len() >= 6 { let (kind, rest) = t.split_at(3); let (height, extra) = rest.split_at(3); let cover = COVER.iter().find(|c| c.0 == kind); if (cover.is_some() || kind == "///") && (digits(height) || height == "///") { let tp = match extra { "CB" => json!("cumulonimbus"), "TCU" => json!("towering cumulus"), "" | "///" => Value::Null, _ => return None }; return Some(json!({ "cover": cover.map(|c| c.1), "oktas": cover.map(|c| c.2), "code": kind, "feet": height.parse::<i64>().ok().map(|h| h * 100), "type": tp, })); } } None}fn temperature(t: &str) -> Option<(Option<i64>, Option<i64>)> { let (a, b) = t.split_once('/')?; let one = |x: &str| -> Option<Option<i64>> { if x.is_empty() || x == "//" || x == "XX" { return Some(None); } let (neg, n) = x.strip_prefix('M').or(x.strip_prefix('-')).map_or((false, x), |r| (true, r)); if n.len() != 2 || !digits(n) { return None; } let v: i64 = n.parse().ok()?; Some(Some(if neg { -v } else { v })) }; if a.is_empty() && b.is_empty() { return None; } Some((one(a)?, one(b)?))}fn humidity(t: i64, d: i64) -> f64 { let e = |x: f64| (17.625 * x / (243.04 + x)).exp(); round(100.0 * e(d as f64) / e(t as f64), 0)}/// A group of weather, as METAR, TREND and TAF share them. Gives what is left of the tokens.#[derive(Default)]struct Group { wind: Option<Value>, visibility: Option<Value>, other_visibility: Vec<Value>, rvr: Vec<Value>, weather: Vec<Value>, clouds: Vec<Value>, vertical_feet: Option<i64>, cavok: bool, no_significant_weather: bool, no_significant_cloud: Option<&'static str>,}impl Group { /// Takes the token at `i` if it fits, giving how many were used, 0 if none. fn take(&mut self, t: &[&str], i: usize) -> usize { let x = t[i]; if let Some(w) = wind(x) { if self.wind.is_none() { self.wind = Some(w); return 1; } } if let (Some(w), Some((a, b))) = (self.wind.as_mut(), wind_range(x)) { w["variable_from"] = json!(a); w["variable_to"] = json!(b); return 1; } if x == "CAVOK" { self.cavok = true; return 1; } if x == "NSW" { self.no_significant_weather = true; return 1; } if matches!(x, "NSC" | "NCD" | "SKC" | "CLR") { self.no_significant_cloud = Some(match x { "NSC" => "no significant cloud", "NCD" => "no cloud detected", "SKC" => "sky clear", _ => "clear below 12000 ft" }); return 1; } if digits(x) && x.len() <= 2 { // `1 1/2SM`: whole miles, then the fraction. if let Some(next) = t.get(i + 1) { if let Some(v) = vis_miles(next, x.parse().ok()) { self.visibility = Some(v); return 2; } } } if let Some(v) = vis_miles(x, None) { self.visibility = Some(v); return 1; } if x == "////" { self.visibility = Some(json!({ "missing": true })); return 1; } if let Some(v) = vis_meters(x) { if self.visibility.is_none() { self.visibility = Some(v); } else { self.other_visibility.push(v); } return 1; } if let Some(v) = rvr(x) { self.rvr.push(v); return 1; } if let Some(h) = x.strip_prefix("VV") { if h.len() == 3 { self.vertical_feet = h.parse::<i64>().ok().map(|f| f * 100).or(Some(-1)); return 1; } } if let Some(c) = cloud(x) { self.clouds.push(c); return 1; } if let Some(w) = weather(x) { self.weather.push(w); return 1; } 0 } fn to_json(&self) -> Map<String, Value> { let mut m = Map::new(); m.insert("wind".into(), self.wind.clone().unwrap_or(Value::Null)); let mut vis = self.visibility.clone().unwrap_or(Value::Null); if self.cavok { vis = json!({ "meters": 10000, "miles": 6.21, "bound": "more", "cavok": true }); } if !self.other_visibility.is_empty() { vis["others"] = json!(self.other_visibility); } m.insert("visibility".into(), vis); if !self.rvr.is_empty() { m.insert("runway_visual_range".into(), json!(self.rvr)); } m.insert("weather".into(), json!(self.weather)); m.insert("clouds".into(), json!(self.clouds)); if let Some(v) = self.vertical_feet { m.insert("vertical_visibility_ft".into(), if v < 0 { Value::Null } else { json!(v) }); } if let Some(c) = self.no_significant_cloud { m.insert("sky".into(), json!(c)); } if self.cavok { m.insert("cavok".into(), json!(true)); } if self.no_significant_weather { m.insert("no_significant_weather".into(), json!(true)); } m } fn ceiling(&self) -> Option<i64> { let layer = self.clouds.iter().filter(|c| matches!(c["code"].as_str(), Some("BKN" | "OVC"))).filter_map(|c| c["feet"].as_i64()).min(); match (layer, self.vertical_feet.filter(|v| *v >= 0)) { (Some(a), Some(b)) => Some(a.min(b)), (a, b) => a.or(b), } }}fn category(g: &Group) -> Value { let vis = if g.cavok { Some(10.0) } else { g.visibility.as_ref().and_then(|v| v["miles"].as_f64()) }; let Some(vis) = vis else { return Value::Null }; let ceil = if g.cavok { 99999 } else { g.ceiling().unwrap_or(99999) }; json!(if ceil < 500 || vis < 1.0 { "LIFR" } else if ceil < 1000 || vis < 3.0 { "IFR" } else if ceil <= 3000 || vis <= 5.0 { "MVFR" } else { "VFR" })}fn trend_time(t: &str) -> Option<(&'static str, &str)> { if t.len() != 6 || !digits(&t[2..]) { return None; } match &t[..2] { "FM" => Some(("from", &t[2..])), "TL" => Some(("until", &t[2..])), "AT" => Some(("at", &t[2..])), _ => None, }}fn hhmm(x: &str) -> String { format!("{}:{}", &x[..2], &x[2..])}fn remarks(r: &str) -> Vec<Value> { let t: Vec<&str> = r.split_whitespace().collect(); let mut out = vec![]; let tenths = |x: &str| -> Option<f64> { let s = x.chars().next()?; let v: f64 = x[1..].parse().ok()?; Some(round(if s == '1' { -v / 10.0 } else { v / 10.0 }, 1)) }; let mut i = 0; while i < t.len() { let x = t[i]; let note = match x { "AO1" => Some(json!({ "code": x, "text": "automatic station without a precipitation sensor" })), "AO2" => Some(json!({ "code": x, "text": "automatic station with a precipitation sensor" })), "PRESFR" => Some(json!({ "code": x, "text": "pressure falling rapidly" })), "PRESRR" => Some(json!({ "code": x, "text": "pressure rising rapidly" })), "TSNO" => Some(json!({ "code": x, "text": "thunderstorm sensor out of service" })), "$" => Some(json!({ "code": x, "text": "the station needs maintenance" })), _ => None, }; if let Some(n) = note { out.push(n); } else if let Some(p) = x.strip_prefix("SLP").filter(|p| p.len() == 3 && digits(p)) { let v: f64 = p.parse().unwrap_or(0.0) / 10.0; let hpa = v + if v < 50.0 { 1000.0 } else { 900.0 }; out.push(json!({ "code": x, "text": format!("sea level pressure {:.1} hPa", hpa), "hpa": round(hpa, 1) })); } else if x == "SLPNO" { out.push(json!({ "code": x, "text": "sea level pressure not available" })); } else if x.starts_with('T') && (x.len() == 9 || x.len() == 5) && digits(&x[1..]) { let a = tenths(&x[1..5]); let b = if x.len() == 9 { tenths(&x[5..9]) } else { None }; out.push(json!({ "code": x, "text": format!("temperature {:.1} C{}", a.unwrap_or(0.0), b.map_or(String::new(), |d| format!(", dew point {:.1} C", d))), "temperature_c": a, "dewpoint_c": b })); } else if x.starts_with('P') && x.len() == 5 && digits(&x[1..]) { let inches = x[1..].parse::<f64>().unwrap_or(0.0) / 100.0; out.push(json!({ "code": x, "text": format!("{:.2} inches of precipitation in the last hour", inches), "inches": inches })); } else if x == "PK" && t.get(i + 1) == Some(&"WND") { if let Some(w) = t.get(i + 2).and_then(|w| w.split_once('/')).filter(|(a, _)| a.len() >= 5) { let (dir, speed) = w.0.split_at(3); out.push(json!({ "code": format!("PK WND {}", t[i + 2]), "text": format!("peak wind {} at {} kt at {}", dir, speed, w.1) })); i += 2; } } i += 1; } out}/// Decodes a METAR or SPECI: `{ type, station, day, time, auto, corrected, nil, wind, visibility, runway_visual_range, weather, clouds, vertical_visibility_ft, sky, temperature_c, dewpoint_c, humidity, altimeter: { hpa, inhg }, recent_weather, wind_shear, category, trends, remarks, remarks_decoded, unread, raw }`. Wind carries `speed_kt` whatever it was given in. `category` is VFR, MVFR, IFR or LIFR. `remarks` stays as the station wrote it, with what is known read out in `remarks_decoded`. A group it cannot read goes in `unread`, never lost.pub fn metar_decode(report: &str) -> Result<Value, String> { let raw = report.split_whitespace().collect::<Vec<_>>().join(" "); let clean = raw.trim_end_matches('=').trim(); let (body, rmk) = match clean.split_once(" RMK") { Some((a, b)) => (a, Some(b.trim())), None => (clean, None), }; let t: Vec<&str> = body.split(' ').collect(); let mut i = 0; let mut kind = "METAR"; if t[i] == "METAR" || t[i] == "SPECI" { kind = t[i]; i += 1; } if t.get(i) == Some(&"COR") { i += 1; } let station = t.get(i).copied().unwrap_or(""); if station.len() != 4 || !station.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) { return Err(format!("`{}` is not a four-letter station", station)); } i += 1; let mut m = Map::new(); m.insert("type".into(), json!(kind)); m.insert("station".into(), json!(station)); let stamp = t.get(i).copied().unwrap_or(""); if stamp.len() != 7 || !stamp.ends_with('Z') || !digits(&stamp[..6]) { return Err(format!("{}: `{}` is not a time like 121651Z", station, stamp)); } m.insert("day".into(), json!(stamp[..2].parse::<i64>().unwrap_or(0))); m.insert("time".into(), json!(format!("{}Z", hhmm(&stamp[2..6])))); i += 1; let (mut auto, mut corrected, mut nil) = (false, t.contains(&"COR"), false); let mut g = Group::default(); let (mut temp, mut dew) = (None, None); let (mut hpa, mut inhg) = (None, None); let mut recent = vec![]; let mut shear = vec![]; let mut unread = vec![]; let mut trends = vec![]; while i < t.len() { let x = t[i]; match x { "AUTO" => { auto = true; i += 1; continue; } "COR" => { corrected = true; i += 1; continue; } "NIL" => { nil = true; i += 1; continue; } "NOSIG" => { trends.push(json!({ "type": "NOSIG", "text": "no significant change expected" })); i += 1; continue; } "BECMG" | "TEMPO" => { let mut tr = Group::default(); let mut times = Map::new(); let mut left = vec![]; i += 1; while i < t.len() && !matches!(t[i], "BECMG" | "TEMPO" | "NOSIG") { if let Some((word, at)) = trend_time(t[i]) { times.insert(word.to_string(), json!(format!("{}Z", hhmm(at)))); i += 1; continue; } let n = tr.take(&t, i); if n == 0 { left.push(t[i]); i += 1; } else { i += n; } } let mut v = tr.to_json(); v.insert("type".into(), json!(if x == "TEMPO" { "TEMPO" } else { "BECMG" })); v.extend(times); if !left.is_empty() { v.insert("unread".into(), json!(left)); } trends.push(Value::Object(v)); continue; } "WS" => { if t.get(i + 1) == Some(&"ALL") { shear.push("all runways".to_string()); i += 3; } else if let Some(r) = t.get(i + 1).filter(|r| r.starts_with('R')) { shear.push(r.trim_start_matches('R').trim_start_matches("WY").to_string()); i += 2; } else { unread.push(x); i += 1; } continue; } _ => {} } if let Some(r) = x.strip_prefix("RE").filter(|r| weather(r).is_some()) { recent.push(weather(r).unwrap()); i += 1; continue; } if x.len() == 5 && digits(&x[1..]) && (x.starts_with('Q') || x.starts_with('A')) { let n: f64 = x[1..].parse().unwrap_or(0.0); if x.starts_with('Q') { hpa = Some(n) } else { inhg = Some(n / 100.0) } i += 1; continue; } if x.contains('/') && !x.ends_with("SM") && !x.starts_with('R') && g.visibility.is_some() | g.cavok | !x.starts_with('/') { if let Some((a, b)) = temperature(x) { temp = a; dew = b; i += 1; continue; } } let n = g.take(&t, i); if n == 0 { unread.push(x); i += 1; } else { i += n; } } m.insert("auto".into(), json!(auto)); m.insert("corrected".into(), json!(corrected)); m.insert("nil".into(), json!(nil)); m.extend(g.to_json()); m.insert("ceiling_ft".into(), json!(g.ceiling())); m.insert("temperature_c".into(), json!(temp)); m.insert("dewpoint_c".into(), json!(dew)); m.insert("humidity".into(), match (temp, dew) { (Some(a), Some(b)) => json!(humidity(a, b)), _ => Value::Null }); let altimeter = match (hpa, inhg) { (Some(h), Some(a)) => json!({ "hpa": h, "inhg": a }), (Some(h), None) => json!({ "hpa": h, "inhg": round(h / HPA_PER_INHG, 2) }), (None, Some(a)) => json!({ "hpa": round(a * HPA_PER_INHG, 1), "inhg": a }), _ => Value::Null, }; m.insert("altimeter".into(), altimeter); m.insert("recent_weather".into(), json!(recent)); m.insert("wind_shear".into(), json!(shear)); m.insert("category".into(), category(&g)); m.insert("trends".into(), json!(trends)); m.insert("remarks".into(), json!(rmk)); m.insert("remarks_decoded".into(), json!(rmk.map(remarks).unwrap_or_default())); m.insert("unread".into(), json!(unread)); m.insert("raw".into(), json!(raw)); Ok(Value::Object(m))}fn validity(x: &str) -> Option<Value> { let (a, b) = x.split_once('/')?; if a.len() != 4 || b.len() != 4 || !digits(a) || !digits(b) { return None; } Some(json!({ "from": { "day": a[..2].parse::<i64>().ok()?, "hour": a[2..].parse::<i64>().ok()? }, "to": { "day": b[..2].parse::<i64>().ok()?, "hour": b[2..].parse::<i64>().ok()? } }))}fn taf_extreme(x: &str) -> Option<(&'static str, Value)> { let (kind, r) = if let Some(r) = x.strip_prefix("TX") { ("max", r) } else if let Some(r) = x.strip_prefix("TN") { ("min", r) } else { return None }; let (temp, when) = r.split_once('/')?; let (neg, n) = temp.strip_prefix('M').map_or((false, temp), |t| (true, t)); let v: i64 = n.parse().ok()?; let w = when.strip_suffix('Z')?; Some((kind, json!({ "temperature_c": if neg { -v } else { v }, "day": w.get(..2)?.parse::<i64>().ok()?, "hour": w.get(2..4)?.parse::<i64>().ok()? })))}/// Decodes a TAF: `{ station, amended, corrected, issued, valid, forecast, groups, max, min, unread, raw }`. `forecast` is the opening conditions, `groups` the changes in order: `{ type, from, to, probability, wind, visibility, weather, clouds, category }`, `type` being FM, BECMG, TEMPO or PROB, with a PROB30 TEMPO as TEMPO and its probability.pub fn metar_taf(report: &str) -> Result<Value, String> { let raw = report.split_whitespace().collect::<Vec<_>>().join(" "); let clean = raw.trim_end_matches('=').trim(); let t: Vec<&str> = clean.split(' ').collect(); let mut i = 0; if t[0] != "TAF" { return Err("a TAF starts with the word TAF".into()); } i += 1; let (mut amended, mut corrected) = (false, false); while matches!(t.get(i), Some(&"AMD") | Some(&"COR") | Some(&"RTD")) { amended |= t[i] == "AMD"; corrected |= t[i] == "COR"; i += 1; } let station = t.get(i).copied().unwrap_or(""); if station.len() != 4 || !station.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) { return Err(format!("`{}` is not a four-letter station", station)); } i += 1; let mut issued = Value::Null; if let Some(x) = t.get(i).filter(|x| x.len() == 7 && x.ends_with('Z') && digits(&x[..6])) { issued = json!({ "day": x[..2].parse::<i64>().unwrap_or(0), "time": format!("{}Z", hhmm(&x[2..6])) }); i += 1; } let valid = t.get(i).and_then(|x| validity(x)).ok_or(format!("{}: no validity like 1212/1318", station))?; i += 1; let mut base = Group::default(); let mut unread = vec![]; let (mut max, mut min) = (Value::Null, Value::Null); let mut groups: Vec<Value> = vec![]; let starts = |x: &str| x == "BECMG" || x == "TEMPO" || x.starts_with("PROB") || (x.starts_with("FM") && x.len() >= 8); let mut extremes = |x: &str| -> bool { match taf_extreme(x) { Some(("max", v)) => { max = v; true } Some((_, v)) => { min = v; true } None => false, } }; while i < t.len() && !starts(t[i]) { if extremes(t[i]) { i += 1; continue; } let n = base.take(&t, i); if n == 0 { unread.push(t[i]); i += 1; } else { i += n; } } while i < t.len() { let x = t[i]; let mut v = Map::new(); let mut probability = Value::Null; i += 1; let mut kind = x.to_string(); if let Some(p) = x.strip_prefix("PROB") { probability = json!(p.parse::<i64>().unwrap_or(0)); kind = "PROB".into(); if t.get(i) == Some(&"TEMPO") { kind = "TEMPO".into(); i += 1; } } if x.starts_with("FM") && x.len() >= 8 { kind = "FM".into(); let w = &x[2..]; v.insert("from".into(), json!({ "day": w[..2].parse::<i64>().unwrap_or(0), "hour": w[2..4].parse::<i64>().unwrap_or(0), "minute": w[4..6].parse::<i64>().unwrap_or(0) })); } else if let Some(p) = t.get(i).and_then(|y| validity(y)) { v.insert("from".into(), p["from"].clone()); v.insert("to".into(), p["to"].clone()); i += 1; } let mut g = Group::default(); let mut left = vec![]; while i < t.len() && !starts(t[i]) { if extremes(t[i]) { i += 1; continue; } let n = g.take(&t, i); if n == 0 { left.push(t[i]); i += 1; } else { i += n; } } v.insert("type".into(), json!(kind)); v.insert("probability".into(), probability); v.extend(g.to_json()); v.insert("category".into(), category(&g)); if !left.is_empty() { v.insert("unread".into(), json!(left)); } groups.push(Value::Object(v)); } let mut forecast = base.to_json(); forecast.insert("category".into(), category(&base)); Ok(json!({ "station": station, "amended": amended, "corrected": corrected, "issued": issued, "valid": valid, "forecast": forecast, "groups": groups, "max": max, "min": min, "unread": unread, "raw": raw }))}fn compass(deg: i64) -> &'static str { const N: [&str; 16] = ["north", "north-northeast", "northeast", "east-northeast", "east", "east-southeast", "southeast", "south-southeast", "south", "south-southwest", "southwest", "west-southwest", "west", "west-northwest", "northwest", "north-northwest"]; N[(((deg as f64 / 22.5).round() as i64).rem_euclid(16)) as usize]}/// A decoded METAR as plain sentences, for people who do not read the code.pub fn metar_text(report: &str) -> Result<String, String> { let m = metar_decode(report)?; if m["nil"] == true { return Ok(format!("{}: no report.", m["station"].as_str().unwrap_or(""))); } let mut parts = vec![format!("{} at {}", m["station"].as_str().unwrap_or(""), m["time"].as_str().unwrap_or(""))]; let w = &m["wind"]; if w["calm"] == true { parts.push("calm".into()); } else if w["speed_kt"].is_number() { let from = match w["direction"].as_i64() { Some(d) => format!("from the {}", compass(d)), None => "variable".to_string() }; let gust = w["gust_kt"].as_f64().map_or(String::new(), |g| format!(", gusting {}", g)); parts.push(format!("wind {} at {} kt{}", from, w["speed_kt"].as_f64().unwrap_or(0.0), gust)); } let v = &m["visibility"]; if v["cavok"] == true { parts.push("ceiling and visibility OK".into()); } else if let Some(mi) = v["miles"].as_f64() { let bound = v["bound"].as_str().map_or("", |b| if b == "less" { "under " } else { "over " }); parts.push(format!("visibility {}{} mi", bound, mi)); } for x in m["weather"].as_array().into_iter().flatten() { parts.push(x["text"].as_str().unwrap_or("").to_string()); } for c in m["clouds"].as_array().into_iter().flatten() { parts.push(format!("{} at {} ft", c["cover"].as_str().unwrap_or("cloud"), c["feet"])); } if let Some(t) = m["temperature_c"].as_i64() { parts.push(format!("{} C, dew point {} C", t, m["dewpoint_c"])); } if let (Some(h), Some(a)) = (m["altimeter"]["hpa"].as_f64(), m["altimeter"]["inhg"].as_f64()) { parts.push(format!("pressure {} hPa ({} inHg)", h, a)); } if m["category"].is_string() { parts.push(m["category"].as_str().unwrap_or("").to_string()); } Ok(format!("{}.", parts.join(", ")))}Related
Thread 0 posts
On METAR and TAF: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.