use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CfVersion {
pub major: u8,
pub minor: u8,
}
impl CfVersion {
#[must_use]
pub const fn v1_11() -> Self {
Self {
major: 1,
minor: 11,
}
}
#[must_use]
pub const fn v1_8() -> Self {
Self { major: 1, minor: 8 }
}
#[must_use]
pub fn parse_version(s: &str) -> Option<Self> {
let mut parts = s.splitn(2, '.');
let major: u8 = parts.next()?.parse().ok()?;
let minor: u8 = parts.next()?.parse().ok()?;
Some(Self { major, minor })
}
#[must_use]
pub fn is_at_least(&self, other: &Self) -> bool {
self >= other
}
}
impl fmt::Display for CfVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
#[derive(Debug, Clone, Copy)]
pub struct CfStandardName {
pub name: &'static str,
pub canonical_units: &'static str,
pub description: &'static str,
}
pub const CF_STANDARD_NAMES: &[CfStandardName] = &[
CfStandardName {
name: "air_temperature",
canonical_units: "K",
description: "Air temperature",
},
CfStandardName {
name: "air_pressure",
canonical_units: "Pa",
description: "Air pressure",
},
CfStandardName {
name: "eastward_wind",
canonical_units: "m s-1",
description: "Eastward wind component",
},
CfStandardName {
name: "northward_wind",
canonical_units: "m s-1",
description: "Northward wind component",
},
CfStandardName {
name: "relative_humidity",
canonical_units: "1",
description: "Relative humidity",
},
CfStandardName {
name: "specific_humidity",
canonical_units: "1",
description: "Specific humidity",
},
CfStandardName {
name: "precipitation_flux",
canonical_units: "kg m-2 s-1",
description: "Precipitation flux",
},
CfStandardName {
name: "surface_temperature",
canonical_units: "K",
description: "Surface temperature",
},
CfStandardName {
name: "sea_surface_temperature",
canonical_units: "K",
description: "Sea surface temperature",
},
CfStandardName {
name: "sea_surface_salinity",
canonical_units: "1e-3",
description: "Sea surface salinity (PSU)",
},
CfStandardName {
name: "ocean_heat_content",
canonical_units: "J m-2",
description: "Ocean heat content",
},
CfStandardName {
name: "land_area_fraction",
canonical_units: "1",
description: "Land area fraction",
},
CfStandardName {
name: "soil_temperature",
canonical_units: "K",
description: "Soil temperature",
},
CfStandardName {
name: "downwelling_shortwave_flux_in_air",
canonical_units: "W m-2",
description: "Downwelling shortwave radiation",
},
CfStandardName {
name: "upwelling_longwave_flux_in_air",
canonical_units: "W m-2",
description: "Upwelling longwave radiation",
},
CfStandardName {
name: "geopotential_height",
canonical_units: "m",
description: "Geopotential height",
},
CfStandardName {
name: "cloud_area_fraction",
canonical_units: "1",
description: "Cloud area fraction",
},
CfStandardName {
name: "toa_outgoing_longwave_flux",
canonical_units: "W m-2",
description: "TOA outgoing longwave flux",
},
CfStandardName {
name: "net_primary_productivity_of_biomass_expressed_as_carbon",
canonical_units: "kg m-2 s-1",
description: "Net primary productivity",
},
CfStandardName {
name: "mole_fraction_of_carbon_dioxide_in_air",
canonical_units: "mol mol-1",
description: "CO₂ mole fraction",
},
];
#[must_use]
pub fn lookup_standard_name(name: &str) -> Option<&'static CfStandardName> {
CF_STANDARD_NAMES.iter().find(|n| n.name == name)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CfCoordinateType {
Longitude,
Latitude,
Vertical,
Time,
Auxiliary,
Scalar,
}
impl CfCoordinateType {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Longitude => "longitude",
Self::Latitude => "latitude",
Self::Vertical => "vertical",
Self::Time => "time",
Self::Auxiliary => "auxiliary",
Self::Scalar => "scalar",
}
}
#[must_use]
pub fn is_spatial(&self) -> bool {
matches!(self, Self::Longitude | Self::Latitude | Self::Vertical)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CfGeometryType {
Point,
Line,
Polygon,
MultiPoint,
MultiLine,
MultiPolygon,
}
impl CfGeometryType {
#[must_use]
pub const fn cf_name(&self) -> &'static str {
match self {
Self::Point => "point",
Self::Line => "line",
Self::Polygon => "polygon",
Self::MultiPoint => "multipoint",
Self::MultiLine => "multiline",
Self::MultiPolygon => "multipolygon",
}
}
#[must_use]
pub fn from_cf_name(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"point" => Some(Self::Point),
"line" => Some(Self::Line),
"polygon" => Some(Self::Polygon),
"multipoint" => Some(Self::MultiPoint),
"multiline" => Some(Self::MultiLine),
"multipolygon" => Some(Self::MultiPolygon),
_ => None,
}
}
#[must_use]
pub fn is_multi(&self) -> bool {
matches!(
self,
Self::MultiPoint | Self::MultiLine | Self::MultiPolygon
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CellMethodName {
Point,
Sum,
Maximum,
Median,
Midrange,
Minimum,
Mean,
Mode,
StandardDeviation,
Variance,
Unknown(String),
}
impl CellMethodName {
#[must_use]
pub fn parse_method(s: &str) -> Self {
match s {
"point" => Self::Point,
"sum" => Self::Sum,
"maximum" => Self::Maximum,
"median" => Self::Median,
"midrange" => Self::Midrange,
"minimum" => Self::Minimum,
"mean" => Self::Mean,
"mode" => Self::Mode,
"standard_deviation" => Self::StandardDeviation,
"variance" => Self::Variance,
other => Self::Unknown(other.to_string()),
}
}
#[must_use]
pub fn to_cf_str(&self) -> &str {
match self {
Self::Point => "point",
Self::Sum => "sum",
Self::Maximum => "maximum",
Self::Median => "median",
Self::Midrange => "midrange",
Self::Minimum => "minimum",
Self::Mean => "mean",
Self::Mode => "mode",
Self::StandardDeviation => "standard_deviation",
Self::Variance => "variance",
Self::Unknown(s) => s.as_str(),
}
}
#[must_use]
pub fn is_known(&self) -> bool {
!matches!(self, Self::Unknown(_))
}
}
#[derive(Debug, Clone)]
pub struct CfCellMethod {
pub coordinates: Vec<String>,
pub method: CellMethodName,
pub where_type: Option<String>,
pub over_type: Option<String>,
pub interval: Option<String>,
pub comment: Option<String>,
}
impl CfCellMethod {
#[must_use]
pub fn simple(coordinate: impl Into<String>, method: CellMethodName) -> Self {
Self {
coordinates: vec![coordinate.into()],
method,
where_type: None,
over_type: None,
interval: None,
comment: None,
}
}
}
#[derive(Debug, Clone)]
pub struct CfGeometryContainer {
pub geometry_type: CfGeometryType,
pub node_coordinates: String,
pub node_count: Option<String>,
pub part_node_count: Option<String>,
pub interior_ring: Option<String>,
}
impl CfGeometryContainer {
#[must_use]
pub fn point(node_coordinates: impl Into<String>) -> Self {
Self {
geometry_type: CfGeometryType::Point,
node_coordinates: node_coordinates.into(),
node_count: None,
part_node_count: None,
interior_ring: None,
}
}
#[must_use]
pub fn polygon(node_coordinates: impl Into<String>, node_count: impl Into<String>) -> Self {
Self {
geometry_type: CfGeometryType::Polygon,
node_coordinates: node_coordinates.into(),
node_count: Some(node_count.into()),
part_node_count: None,
interior_ring: None,
}
}
#[must_use]
pub fn can_have_holes(&self) -> bool {
matches!(
self.geometry_type,
CfGeometryType::Polygon | CfGeometryType::MultiPolygon
)
}
}
#[must_use]
pub fn validate_cf_version(conventions: &str) -> Option<CfVersion> {
let mut best: Option<CfVersion> = None;
for part in conventions.split(',') {
let part = part.trim();
if let Some(stripped) = part.strip_prefix("CF-") {
if let Some(v) = CfVersion::parse_version(stripped) {
best = Some(match best {
None => v,
Some(existing) => {
if v > existing {
v
} else {
existing
}
}
});
}
}
}
best
}
#[must_use]
pub fn validate_units(units: &str) -> bool {
if units.is_empty() {
return false;
}
if units == "1" || units == "dimensionless" {
return true;
}
if units == "%" {
return true;
}
const KNOWN: &[&str] = &["K", "Pa", "m", "s", "kg", "mol", "W", "J", "N", "Hz", "A"];
units.split_whitespace().any(|token| {
let base = token
.find(|c: char| c == '-' || c.is_ascii_digit())
.map_or(token, |i| &token[..i]);
KNOWN.contains(&base)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cf_version_display() {
assert_eq!(CfVersion::v1_11().to_string(), "1.11");
assert_eq!(CfVersion::v1_8().to_string(), "1.8");
}
#[test]
fn test_cf_version_from_str_valid() {
let v = CfVersion::parse_version("1.11").expect("parse 1.11");
assert_eq!(v, CfVersion::v1_11());
}
#[test]
fn test_cf_version_from_str_invalid() {
assert!(CfVersion::parse_version("abc").is_none());
assert!(CfVersion::parse_version("1").is_none());
assert!(CfVersion::parse_version("").is_none());
}
#[test]
fn test_cf_version_ordering() {
assert!(CfVersion::v1_11() > CfVersion::v1_8());
assert!(CfVersion::v1_8() < CfVersion::v1_11());
assert_eq!(
CfVersion::v1_8(),
CfVersion::parse_version("1.8").expect("parse 1.8")
);
}
#[test]
fn test_cf_version_is_at_least() {
assert!(CfVersion::v1_11().is_at_least(&CfVersion::v1_8()));
assert!(!CfVersion::v1_8().is_at_least(&CfVersion::v1_11()));
assert!(CfVersion::v1_11().is_at_least(&CfVersion::v1_11()));
}
#[test]
fn test_standard_names_table_non_empty() {
assert!(!CF_STANDARD_NAMES.is_empty());
assert!(CF_STANDARD_NAMES.len() >= 20);
}
#[test]
fn test_lookup_standard_name_found() {
let entry = lookup_standard_name("air_temperature").expect("air_temperature lookup");
assert_eq!(entry.canonical_units, "K");
assert!(!entry.description.is_empty());
}
#[test]
fn test_lookup_standard_name_not_found() {
assert!(lookup_standard_name("nonexistent_variable_xyz").is_none());
}
#[test]
fn test_lookup_wind_components() {
assert!(lookup_standard_name("eastward_wind").is_some());
assert!(lookup_standard_name("northward_wind").is_some());
assert_eq!(
lookup_standard_name("eastward_wind")
.expect("eastward_wind lookup")
.canonical_units,
"m s-1"
);
}
#[test]
fn test_lookup_ocean_variables() {
assert!(lookup_standard_name("sea_surface_temperature").is_some());
assert!(lookup_standard_name("sea_surface_salinity").is_some());
}
#[test]
fn test_all_standard_names_have_non_empty_fields() {
for entry in CF_STANDARD_NAMES {
assert!(!entry.name.is_empty(), "empty name");
assert!(
!entry.canonical_units.is_empty(),
"empty units for {}",
entry.name
);
assert!(
!entry.description.is_empty(),
"empty desc for {}",
entry.name
);
}
}
#[test]
fn test_coordinate_type_labels() {
assert_eq!(CfCoordinateType::Longitude.label(), "longitude");
assert_eq!(CfCoordinateType::Time.label(), "time");
}
#[test]
fn test_coordinate_type_is_spatial() {
assert!(CfCoordinateType::Longitude.is_spatial());
assert!(CfCoordinateType::Latitude.is_spatial());
assert!(CfCoordinateType::Vertical.is_spatial());
assert!(!CfCoordinateType::Time.is_spatial());
assert!(!CfCoordinateType::Scalar.is_spatial());
}
#[test]
fn test_geometry_type_cf_names_roundtrip() {
let types = [
CfGeometryType::Point,
CfGeometryType::Line,
CfGeometryType::Polygon,
CfGeometryType::MultiPoint,
CfGeometryType::MultiLine,
CfGeometryType::MultiPolygon,
];
for t in &types {
let name = t.cf_name();
let back = CfGeometryType::from_cf_name(name).expect("roundtrip cf_name");
assert_eq!(&back, t);
}
}
#[test]
fn test_geometry_type_case_insensitive() {
assert_eq!(
CfGeometryType::from_cf_name("POLYGON"),
Some(CfGeometryType::Polygon)
);
assert_eq!(
CfGeometryType::from_cf_name("MultiPoint"),
Some(CfGeometryType::MultiPoint)
);
}
#[test]
fn test_geometry_type_unknown() {
assert!(CfGeometryType::from_cf_name("hexagon").is_none());
}
#[test]
fn test_geometry_type_is_multi() {
assert!(!CfGeometryType::Point.is_multi());
assert!(!CfGeometryType::Polygon.is_multi());
assert!(CfGeometryType::MultiPolygon.is_multi());
assert!(CfGeometryType::MultiLine.is_multi());
}
#[test]
fn test_cell_method_from_str_known() {
assert_eq!(CellMethodName::parse_method("mean"), CellMethodName::Mean);
assert_eq!(
CellMethodName::parse_method("maximum"),
CellMethodName::Maximum
);
assert_eq!(
CellMethodName::parse_method("standard_deviation"),
CellMethodName::StandardDeviation
);
}
#[test]
fn test_cell_method_from_str_unknown() {
let u = CellMethodName::parse_method("trimmed_mean");
assert!(!u.is_known());
assert!(matches!(u, CellMethodName::Unknown(_)));
}
#[test]
fn test_cell_method_roundtrip() {
let methods = [
"point",
"sum",
"maximum",
"median",
"midrange",
"minimum",
"mean",
"mode",
"standard_deviation",
"variance",
];
for &m in &methods {
let parsed = CellMethodName::parse_method(m);
assert!(parsed.is_known(), "{m} should be known");
assert_eq!(parsed.to_cf_str(), m);
}
}
#[test]
fn test_validate_cf_version_simple() {
let v = validate_cf_version("CF-1.11").expect("validate CF-1.11");
assert_eq!(v, CfVersion::v1_11());
}
#[test]
fn test_validate_cf_version_with_acdd() {
let v = validate_cf_version("CF-1.8, ACDD-1.3").expect("validate CF-1.8");
assert_eq!(v, CfVersion::v1_8());
}
#[test]
fn test_validate_cf_version_multiple_cf() {
let v = validate_cf_version("CF-1.6, CF-1.11").expect("validate multiple CF versions");
assert_eq!(v, CfVersion::v1_11());
}
#[test]
fn test_validate_cf_version_none() {
assert!(validate_cf_version("ACDD-1.3").is_none());
assert!(validate_cf_version("").is_none());
}
#[test]
fn test_validate_units_known_si() {
assert!(validate_units("K"));
assert!(validate_units("Pa"));
assert!(validate_units("m s-1"));
assert!(validate_units("kg m-2 s-1"));
assert!(validate_units("W m-2"));
assert!(validate_units("J m-2"));
assert!(validate_units("mol mol-1"));
}
#[test]
fn test_validate_units_dimensionless() {
assert!(validate_units("1"));
assert!(validate_units("dimensionless"));
assert!(validate_units("%"));
}
#[test]
fn test_validate_units_empty() {
assert!(!validate_units(""));
}
#[test]
fn test_validate_units_unknown_string() {
assert!(!validate_units("furlongs per fortnight"));
}
#[test]
fn test_geometry_container_point() {
let c = CfGeometryContainer::point("x y");
assert_eq!(c.geometry_type, CfGeometryType::Point);
assert_eq!(c.node_coordinates, "x y");
assert!(!c.can_have_holes());
}
#[test]
fn test_geometry_container_polygon_can_have_holes() {
let c = CfGeometryContainer::polygon("x y", "node_count");
assert!(c.can_have_holes());
assert_eq!(c.node_count.as_deref(), Some("node_count"));
}
#[test]
fn test_cell_method_simple() {
let cm = CfCellMethod::simple("time", CellMethodName::Mean);
assert_eq!(cm.coordinates, vec!["time"]);
assert_eq!(cm.method, CellMethodName::Mean);
assert!(cm.where_type.is_none());
}
}