graupel 0.1.0

Lossless time series compression: Gorilla, Chimp, Chimp128, Elf and decimal scaling, measured on real observation archives
Documentation
//! NOAA ISD-Lite hourly surface observations.
//!
//! NOAA stores temperature, dew point, pressure and wind speed as tenths, so those series
//! carry one decimal digit of precision — the shape the decimal codec is built to exploit.
//! Wind direction is the exception: whole degrees.
//!
//! <https://www.ncei.noaa.gov/pub/data/noaa/isd-lite/>

use alloc::vec::Vec;

use super::{to_epoch, Series};
use crate::Point;

const SOURCE: &str = "isd-lite";
const MISSING: i64 = -9999;

struct Variable {
    name: &'static str,
    unit: &'static str,
    column: usize,
    divisor: f64,
}

const VARIABLES: [Variable; 5] = [
    Variable {
        name: "air_temperature",
        unit: "degC",
        column: 4,
        divisor: 10.0,
    },
    Variable {
        name: "dew_point",
        unit: "degC",
        column: 5,
        divisor: 10.0,
    },
    Variable {
        name: "sea_level_pressure",
        unit: "hPa",
        column: 6,
        divisor: 10.0,
    },
    Variable {
        name: "wind_direction",
        unit: "deg",
        column: 7,
        divisor: 1.0,
    },
    Variable {
        name: "wind_speed",
        unit: "m/s",
        column: 8,
        divisor: 10.0,
    },
];

/// Rows flagged `-9999` are dropped rather than interpolated, so a real station's gaps reach
/// the benchmark instead of being smoothed away.
pub fn parse(text: &str) -> Vec<Series> {
    let mut series: Vec<Series> = VARIABLES
        .iter()
        .map(|v| Series {
            source: SOURCE,
            variable: v.name,
            unit: v.unit,
            points: Vec::new(),
        })
        .collect();

    for line in text.lines() {
        let fields: Vec<i64> = line
            .split_whitespace()
            .filter_map(|f| f.parse::<i64>().ok())
            .collect();
        if fields.len() < 9 {
            continue;
        }
        let timestamp = to_epoch(fields[0], fields[1] as u32, fields[2] as u32, fields[3]);
        for (index, variable) in VARIABLES.iter().enumerate() {
            let raw = fields[variable.column];
            if raw != MISSING {
                series[index]
                    .points
                    .push(Point::new(timestamp, raw as f64 / variable.divisor));
            }
        }
    }

    series.retain(|s| !s.points.is_empty());
    series
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE: &str = "\
2023 01 01 00    82    49 10241   150    10 -9999 -9999     0
2023 01 01 01    78    39 10237    80    10 -9999 -9999 -9999
2023 01 01 02 -9999    36 10236    80    15 -9999 -9999 -9999
";

    #[test]
    fn tenths_become_the_values_a_station_actually_reports() {
        let series = parse(SAMPLE);
        let temperature = series
            .iter()
            .find(|s| s.variable == "air_temperature")
            .unwrap();
        assert_eq!(temperature.points[0].value, 8.2);
        assert_eq!(temperature.points[1].value, 7.8);
    }

    #[test]
    fn missing_readings_leave_a_gap_instead_of_a_value() {
        let series = parse(SAMPLE);
        let temperature = series
            .iter()
            .find(|s| s.variable == "air_temperature")
            .unwrap();
        let pressure = series
            .iter()
            .find(|s| s.variable == "sea_level_pressure")
            .unwrap();
        assert_eq!(temperature.points.len(), 2);
        assert_eq!(pressure.points.len(), 3);
        assert_eq!(
            pressure.points[2].timestamp - pressure.points[1].timestamp,
            3_600
        );
    }

    #[test]
    fn short_or_junk_lines_are_skipped() {
        assert!(parse("not a row\n\n2023 01\n").is_empty());
    }
}