///|
/// Reading and writing winds and temperatures aloft (FD) bulletins.
///
/// A bulletin is a fixed-column text product: a few header lines, an `FT`
/// line listing the forecast levels, one line per station, and optionally
/// trailing notes. The column layout is *derived* from the `FT` line rather
/// than hard-coded, so the same code reads the nine-level low-level products
/// (`FD1US1`) and the two-level high-level ones (`FD8US7`).
///
/// The wire format compresses the numbers:
///
/// * wind: `ddss`, tens of degrees and knots. For speeds of 100 knots or
/// more the encoder adds 50 to the direction, so a direction above 36 is
/// folded back by 50 and the speed gains 100. `9900` means light and
/// variable.
/// * temperature: `+TT` or `-TT` in degrees Celsius below 24,000 ft. At and
/// above 24,000 ft temperatures are always negative and the sign is
/// omitted, so the field is a bare two-digit magnitude.
/// * the 3,000 ft column carries no temperature at all.
///
/// A real product is a few hundred station lines of nine columns each, so
/// parsing walks the text once and reads every field through a `StringView`:
/// nothing is copied out of the text until a value is kept, which is what
/// keeps a parse of a 12 kB product in the hundreds of microseconds.
///|
/// The end of the line that starts at `start`, and where the line after it
/// starts.
///
/// A carriage return before the newline is not part of the line, so a product
/// that comes back from a server with CRLF endings parses the same way.
fn line_bounds(text : StringView, start : Int32) -> (Int32, Int32) {
let n = text.length()
let mut end = start
while end < n && text.at(end) != '\n' {
end += 1
}
let mut content_end = end
if content_end > start && text.at(content_end - 1) == '\r' {
content_end -= 1
}
let next = if end < n { end + 1 } else { n }
(content_end, next)
}
///|
/// The part of `text` after `prefix`, or `None` when it does not start with
/// it.
fn after_prefix(text : StringView, prefix : StringView) -> StringView? {
if text.has_prefix(prefix) {
Some(text[prefix.length():])
} else {
None
}
}
///|
/// The text before the first blank, or the whole text when it has none.
fn first_token(text : StringView) -> StringView {
let n = text.length()
let mut i = 0
while i < n && text.at(i) != ' ' && text.at(i) != '\t' {
i += 1
}
text[0:i]
}
///|
/// Digits of `text`, or `None` when it is empty or holds anything else.
fn parse_int(text : StringView) -> Int32? {
let t = text.trim()
if t.length() == 0 {
return None
}
let mut v = 0
for i = 0; i < t.length(); i = i + 1 {
let c = t.at(i)
if c < '0' || c > '9' {
return None
}
v = v * 10 + (c.to_int() - '0'.to_int())
}
Some(v)
}
///|
/// A character of the station identifier alphabet: digits and capitals.
/// String indexing yields the UTF-16 code unit, hence the `UInt16`.
fn is_station_char(c : UInt16) -> Bool {
let v = c.to_int()
(v >= 'A'.to_int() && v <= 'Z'.to_int()) ||
(v >= '0'.to_int() && v <= '9'.to_int())
}
///|
/// Whether a line looks like a station data line: three identifier
/// characters followed by a blank.
fn is_station_line(line : StringView) -> Bool {
line.length() >= 4 &&
is_station_char(line.at(0)) &&
is_station_char(line.at(1)) &&
is_station_char(line.at(2)) &&
line.at(3) == ' '
}
///|
/// The column of each forecast level in a station line, as `(start, width)`.
///
/// The first field starts at column 4 (after the three-character station
/// identifier and a blank) and fields are separated by one blank. A field is
/// four characters wide when no temperature is carried (the 3,000 ft level),
/// six when the temperature is an unsigned magnitude above 24,000 ft, and
/// seven otherwise.
pub fn field_layout(altitudes : Array[Int32]) -> Array[(Int32, Int32)] {
let out : Array[(Int32, Int32)] = []
let mut start = 4
for altitude in altitudes {
let width = if altitude == 3000 {
4
} else if altitude > 24000 {
6
} else {
7
}
out.push((start, width))
start = start + width + 1
}
out
}
///|
/// Two decimal digits starting at `at`.
fn digits2(text : StringView, at : Int32) -> Int32 raise FdError {
if at < 0 || at + 2 > text.length() {
raise FdError::BadWindGroup(text.to_owned())
}
let hi = text.at(at)
let lo = text.at(at + 1)
if hi < '0' || hi > '9' || lo < '0' || lo > '9' {
raise FdError::BadWindGroup(text.to_owned())
}
(hi.to_int() - '0'.to_int()) * 10 + (lo.to_int() - '0'.to_int())
}
///|
/// Decode the four-character wind group at the start of `group`.
fn decode_wind_group(group : StringView) -> Wind raise FdError {
let dd = digits2(group, 0)
let ss = digits2(group, 2)
if dd == 99 && ss == 0 {
// Light and variable: below 5 knots, direction not meaningful.
{ direction: None, speed: 0, }
} else if dd > 36 {
// Speeds of 100 knots or more carry the direction offset by 50.
if dd < 50 || dd > 86 {
raise FdError::BadWindGroup(group.to_owned())
}
{ direction: Some((dd - 50) * 10), speed: ss + 100, }
} else {
{ direction: Some(dd * 10), speed: ss, }
}
}
///|
/// Decode a four-character wind group `ddss`.
pub fn decode_wind(group : String) -> Wind raise FdError {
let view : StringView = group
if view.length() != 4 {
raise FdError::BadWindGroup(group)
}
decode_wind_group(view)
}
///|
/// Decode a temperature field, which is the text after the wind group.
///
/// A signed field (`+18`, `-06`) is read as written. An unsigned field is a
/// magnitude above 24,000 ft, where temperatures are always negative.
fn decode_temperature(
text : StringView,
unsigned : Bool,
station : String,
altitude : Int32,
) -> Int32 raise FdError {
if text.length() == 0 {
raise FdError::BadTemperature("\{station} \{altitude}")
}
if !unsigned {
let sign = text.at(0)
if sign != '+' && sign != '-' {
raise FdError::BadTemperature("\{station} \{altitude} \{text}")
}
let magnitude = match parse_int(text[1:]) {
Some(v) => v
None => raise FdError::BadTemperature("\{station} \{altitude} \{text}")
}
if sign == '-' {
-magnitude
} else {
magnitude
}
} else {
match parse_int(text) {
Some(v) => -v
None => raise FdError::BadTemperature("\{station} \{altitude} \{text}")
}
}
}
///|
/// Parse one station line.
fn parse_station_line(
line : StringView,
altitudes : Array[Int32],
layout : Array[(Int32, Int32)],
) -> StationWinds raise FdError {
let station = line[0:3].to_owned()
let levels : Array[Level] = []
for i = 0; i < altitudes.length(); i = i + 1 {
let (start, width) = layout[i]
if start >= line.length() {
continue
}
let mut end = start + width
if end > line.length() {
end = line.length()
}
let field = line[start:end].trim()
if field.length() == 0 {
continue
}
if field.length() < 4 {
raise FdError::BadWindGroup(line.to_owned())
}
let wind = decode_wind_group(field)
let rest = field[4:].trim()
let temperature = if rest.length() == 0 {
None
} else {
Some(
decode_temperature(rest, altitudes[i] > 24000, station, altitudes[i]),
)
}
levels.push({ altitude: altitudes[i], wind, temperature, })
}
{ station, levels, }
}
///|
/// The levels listed in an `FT` header line.
fn parse_level_header(line : StringView) -> Array[Int32] raise FdError {
let rest = match after_prefix(line, "FT") {
Some(rest) => rest
None => raise FdError::BadLevelHeader(line.to_owned())
}
let altitudes : Array[Int32] = []
for token in rest.split(" ") {
match parse_int(token) {
Some(v) => altitudes.push(v)
None =>
if token.trim().length() > 0 {
raise FdError::BadLevelHeader(line.to_owned())
}
}
}
if altitudes.length() == 0 {
raise FdError::BadLevelHeader(line.to_owned())
}
altitudes
}
///|
/// Parse a winds and temperatures aloft bulletin.
///
/// Header lines, the `FT` line and any trailing lines are kept verbatim, so
/// `format_bulletin` reproduces the input byte for byte.
pub fn parse_bulletin(text : String) -> Bulletin raise FdError {
let view : StringView = text
let n = view.length()
let header : Array[String] = []
let mut path_start = -1
let mut path_end = 0
let mut rest = n
let mut start = 0
while start < n {
let (end, next) = line_bounds(view, start)
let line = view[start:end]
if line.has_prefix("FT ") || line == "FT" {
path_start = start
path_end = end
rest = next
break
}
header.push(line.to_owned())
start = next
}
if path_start < 0 {
raise FdError::MissingLevelHeader
}
let level_header = view[path_start:path_end].to_owned()
let altitudes = parse_level_header(view[path_start:path_end])
let layout = field_layout(altitudes)
// Header metadata.
let mut based_on : String? = None
let mut valid : String? = None
let mut use_window : String? = None
let mut temps_negative_above : Int32? = None
let mut wmo_header : String? = None
let mut designator : String? = None
for line in header {
let line : StringView = line
if line.length() == 0 {
continue
}
match after_prefix(line, "DATA BASED ON ") {
Some(found) => based_on = Some(first_token(found).to_owned())
None => ()
}
match after_prefix(line, "VALID ") {
Some(found) => {
valid = Some(first_token(found).to_owned())
match found.find("FOR USE ") {
// The window is followed by a full stop: `FOR USE 1400-2100Z.`
Some(at) =>
use_window = Some(
trim_end_char(first_token(found[at + 8:]).to_owned(), '.'),
)
None => ()
}
match found.find("TEMPS NEG ABV ") {
Some(at) =>
temps_negative_above = parse_int(first_token(found[at + 14:]))
None => ()
}
}
None => ()
}
if wmo_header is None && line.length() >= 6 && line.has_prefix("FB") {
wmo_header = Some(line.to_owned())
}
if designator is None && line.length() == 6 && line.has_prefix("FD") {
designator = Some(line.to_owned())
}
}
// Station data, then anything that follows it.
let stations : Array[StationWinds] = []
let index : @hashmap.HashMap[String, StationWinds] = @hashmap.HashMap([])
let mut i = rest
while i < n {
let (end, next) = line_bounds(view, i)
let line = view[i:end]
if is_station_line(line) {
let station = parse_station_line(line, altitudes, layout)
index.set(station.station, station)
stations.push(station)
i = next
} else if line.trim().length() == 0 && next < n {
// A blank line inside the data section is not data.
let (peek_end, _) = line_bounds(view, next)
if is_station_line(view[next:peek_end]) {
i = next
} else {
break
}
} else {
break
}
}
let trailing : Array[String] = []
while i < n {
let (end, next) = line_bounds(view, i)
trailing.push(view[i:end].to_owned())
i = next
}
{
header,
level_header,
altitudes,
based_on,
valid,
use_window,
temps_negative_above,
wmo_header,
designator,
stations,
index,
trailing,
}
}
///|
/// Two decimal digits, zero padded, written straight into `out`.
fn write_two_digits(out : StringBuilder, v : Int32) -> Unit {
out.write_char(('0'.to_int() + v / 10 % 10).unsafe_to_char())
out.write_char(('0'.to_int() + v % 10).unsafe_to_char())
}
///|
/// Write a wind as its four-character group.
fn write_wind(out : StringBuilder, wind : Wind) -> Unit raise FdError {
if wind.is_light_and_variable() {
out.write_string("9900")
return
}
let direction = match wind.direction {
Some(d) => d
None => raise FdError::BadWindGroup("wind has no direction and is not calm")
}
if direction < 0 || direction > 360 || direction % 10 != 0 {
raise FdError::BadWindGroup("direction must be a multiple of 10 in 0-360")
}
if wind.speed < 0 || wind.speed > 199 {
raise FdError::BadWindGroup("speed must be in 0-199 knots")
}
let tens = direction / 10
if wind.speed >= 100 {
write_two_digits(out, tens + 50)
write_two_digits(out, wind.speed - 100)
} else {
write_two_digits(out, tens)
write_two_digits(out, wind.speed)
}
}
///|
/// Encode a wind as its four-character group.
pub fn encode_wind(wind : Wind) -> String raise FdError {
let out = StringBuilder(size_hint=4)
write_wind(out, wind)
out.to_string()
}
///|
/// Write a temperature field for a level: unsigned above 24,000 ft, where
/// temperatures are always negative, and signed below.
fn write_temperature(
out : StringBuilder,
temperature : Int32,
altitude : Int32,
) -> Unit raise FdError {
let magnitude = if temperature < 0 { -temperature } else { temperature }
if magnitude > 99 {
raise FdError::BadTemperature("temperature must be in -99..99")
}
if altitude > 24000 {
if temperature > 0 {
raise FdError::BadTemperature("above 24,000 ft the sign is not carried")
}
write_two_digits(out, magnitude)
} else {
out.write_char(if temperature < 0 { '-' } else { '+' })
write_two_digits(out, magnitude)
}
}
///|
/// Encode a temperature field for a level: unsigned above 24,000 ft, where
/// temperatures are always negative, and signed below.
pub fn encode_temperature(
temperature : Int32,
altitude : Int32,
) -> String raise FdError {
let out = StringBuilder(size_hint=3)
write_temperature(out, temperature, altitude)
out.to_string()
}
///|
/// The width of a level's field: the four characters of the wind group, plus
/// three for a signed temperature, or two above 24,000 ft, where the sign is
/// not carried.
fn field_length(level : Level) -> Int32 {
match level.temperature {
None => 4
Some(_) => if level.altitude > 24000 { 6 } else { 7 }
}
}
///|
/// Encode one level as its column field, without padding.
pub fn encode_level(level : Level) -> String raise FdError {
let out = StringBuilder(size_hint=field_length(level))
write_wind(out, level.wind)
match level.temperature {
None => ()
Some(t) => write_temperature(out, t, level.altitude)
}
out.to_string()
}
///|
/// Format one station line, placing each level in its column.
pub fn format_station(
station : StationWinds,
altitudes : Array[Int32],
) -> String raise FdError {
let layout = field_layout(altitudes)
let out = StringBuilder(size_hint=80)
out.write_string(station.station)
let mut column = station.station.length()
while column < 3 {
out.write_char(' ')
column += 1
}
// The levels and the columns are both ascending, so they are walked in one
// pass, and a blank column is skipped without a search.
let mut index = 0
for i = 0; i < altitudes.length(); i = i + 1 {
let (start, width) = layout[i]
while index < station.levels.length() &&
station.levels[index].altitude < altitudes[i] {
index += 1
}
if index >= station.levels.length() {
break
}
let level = station.levels[index]
if level.altitude != altitudes[i] {
continue
}
if field_length(level) > width {
raise FdError::BadStationLine(
"field \{encode_level(level)} is wider than its \{width}-character column",
)
}
while column < start {
out.write_char(' ')
column += 1
}
write_wind(out, level.wind)
match level.temperature {
None => ()
Some(t) => write_temperature(out, t, level.altitude)
}
column += field_length(level)
}
// The line ends at the last reported level, matching the wire format, which
// stops at the last group written.
out.to_string()
}
///|
/// Strip trailing occurrences of `c`.
fn trim_end_char(s : String, c : Char) -> String {
let target = c.to_int()
let mut end = s.length()
while end > 0 && s[end - 1].to_int() == target {
end -= 1
}
s[0:end].to_owned()
}
///|
/// Write a parsed bulletin back as text.
///
/// For a bulletin produced by `parse_bulletin` this reproduces the input
/// byte for byte, apart from a final newline, which is always written.
pub fn format_bulletin(bulletin : Bulletin) -> String raise FdError {
let out = StringBuilder(size_hint=4096)
for line in bulletin.header {
out.write_string(line)
out.write_char('\n')
}
out.write_string(bulletin.level_header)
out.write_char('\n')
for station in bulletin.stations {
out.write_string(format_station(station, bulletin.altitudes))
out.write_char('\n')
}
for line in bulletin.trailing {
out.write_string(line)
out.write_char('\n')
}
out.to_string()
}