use crate::common::{BoundingBox, TemporalExtent};
use crate::error::{MetadataError, Result};
use crate::extract::ExtractedMetadata;
use std::path::Path;
pub trait HasAttributes {
fn get_attribute_string(&self, name: &str) -> Option<String>;
fn attribute_names(&self) -> Vec<String>;
}
pub trait HasVariables {
fn variable_names(&self) -> Vec<String>;
fn variable_attribute_string(&self, var: &str, attr: &str) -> Option<String>;
fn variable_min_max(&self, var: &str) -> Option<(f64, f64)>;
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CfGlobals {
pub title: Option<String>,
pub summary: Option<String>,
pub keywords: Option<String>,
pub institution: Option<String>,
pub conventions: Option<String>,
pub history: Option<String>,
pub source: Option<String>,
pub references: Option<String>,
pub comment: Option<String>,
}
pub fn extract_cf_globals<D: HasAttributes + ?Sized>(ds: &D) -> CfGlobals {
CfGlobals {
title: ds.get_attribute_string("title"),
summary: ds.get_attribute_string("summary"),
keywords: ds.get_attribute_string("keywords"),
institution: ds.get_attribute_string("institution"),
conventions: ds.get_attribute_string("Conventions"),
history: ds.get_attribute_string("history"),
source: ds.get_attribute_string("source"),
references: ds.get_attribute_string("references"),
comment: ds.get_attribute_string("comment"),
}
}
pub fn extract_bbox_from_lon_lat<D: HasVariables + ?Sized>(ds: &D) -> Option<BoundingBox> {
let mut lon_var: Option<String> = None;
let mut lat_var: Option<String> = None;
for name in ds.variable_names() {
if let Some(sn) = ds.variable_attribute_string(&name, "standard_name") {
match sn.as_str() {
"longitude" if lon_var.is_none() => lon_var = Some(name.clone()),
"latitude" if lat_var.is_none() => lat_var = Some(name.clone()),
_ => {}
}
}
}
if lon_var.is_none() || lat_var.is_none() {
for name in ds.variable_names() {
if let Some(ax) = ds.variable_attribute_string(&name, "axis") {
match ax.as_str() {
"X" if lon_var.is_none() => lon_var = Some(name.clone()),
"Y" if lat_var.is_none() => lat_var = Some(name.clone()),
_ => {}
}
}
}
}
let lv = lon_var?;
let la = lat_var?;
let (xmin, xmax) = ds.variable_min_max(&lv)?;
let (ymin, ymax) = ds.variable_min_max(&la)?;
Some(BoundingBox::new(xmin, xmax, ymin, ymax))
}
pub fn extract_temporal_extent<D: HasVariables + ?Sized>(ds: &D) -> Option<TemporalExtent> {
let names = ds.variable_names();
let time_var = names.iter().find(|n| {
ds.variable_attribute_string(n, "standard_name").as_deref() == Some("time")
|| n.eq_ignore_ascii_case("time")
})?;
let units = ds.variable_attribute_string(time_var, "units")?;
let (min, max) = ds.variable_min_max(time_var)?;
let (multiplier_secs, ref_date) = parse_cf_time_units(&units)?;
use chrono::{Duration, TimeZone, Utc};
let start_naive = ref_date + Duration::seconds((min * multiplier_secs as f64) as i64);
let end_naive = ref_date + Duration::seconds((max * multiplier_secs as f64) as i64);
let start = Utc.from_utc_datetime(&start_naive);
let end = Utc.from_utc_datetime(&end_naive);
Some(TemporalExtent {
start: Some(start),
end: Some(end),
})
}
pub fn parse_cf_time_units(units: &str) -> Option<(i64, chrono::NaiveDateTime)> {
use chrono::{NaiveDate, NaiveDateTime};
let lower = units.to_lowercase();
let parts: Vec<&str> = lower.splitn(2, " since ").collect();
if parts.len() != 2 {
return None;
}
let unit = parts[0].trim();
let multiplier = match unit {
"days" | "day" => 86_400_i64,
"hours" | "hour" | "hr" | "hrs" => 3_600_i64,
"minutes" | "minute" | "min" | "mins" => 60_i64,
"seconds" | "second" | "sec" | "secs" | "s" => 1_i64,
_ => return None,
};
let ref_str = parts[1].trim();
for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"] {
if let Ok(dt) = NaiveDateTime::parse_from_str(ref_str, fmt) {
return Some((multiplier, dt));
}
}
if let Ok(d) = NaiveDate::parse_from_str(ref_str, "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0)?;
return Some((multiplier, dt));
}
None
}
pub fn extract_grid_mapping_crs<D: HasVariables + ?Sized>(ds: &D) -> Option<String> {
for name in ds.variable_names() {
if let Some(gm) = ds.variable_attribute_string(&name, "grid_mapping") {
if let Some(gmn) = ds.variable_attribute_string(&gm, "grid_mapping_name") {
return Some(match gmn.as_str() {
"latitude_longitude" => "EPSG:4326".to_string(),
"transverse_mercator" => TRANSVERSE_MERCATOR_WKT.to_string(),
"mercator" => "EPSG:3395".to_string(),
"polar_stereographic" => "EPSG:3413".to_string(),
"lambert_conformal_conic" => "EPSG:9802".to_string(),
"albers_conical_equal_area" => "EPSG:9822".to_string(),
"rotated_latitude_longitude" => "EPSG:4326".to_string(),
"stereographic" => "EPSG:3995".to_string(),
other => format!("GRID_MAPPING:{}", other),
});
}
}
}
None
}
const TRANSVERSE_MERCATOR_WKT: &str = concat!(
"PROJCS[\"Transverse_Mercator\",",
"GEOGCS[\"WGS 84\",",
"DATUM[\"WGS_1984\",",
"SPHEROID[\"WGS 84\",6378137,298.257223563]],",
"PRIMEM[\"Greenwich\",0],",
"UNIT[\"degree\",0.0174532925199433]],",
"PROJECTION[\"Transverse_Mercator\"]]"
);
pub struct NetCdfCfExtractor;
impl NetCdfCfExtractor {
pub fn extract<P: AsRef<Path>>(path: P) -> Result<ExtractedMetadata> {
let path_ref = path.as_ref();
let path_str = path_ref.to_string_lossy().to_string();
#[cfg(feature = "netcdf")]
{
match real_dataset::NetCdfReaderShim::open(path_ref) {
Ok(shim) => Ok(build_extracted_metadata(&shim, &path_str)),
Err(e) => Err(MetadataError::ExtractionError(format!(
"Cannot open NetCDF '{}': {}",
path_str, e
))),
}
}
#[cfg(not(feature = "netcdf"))]
{
let mut attributes = std::collections::HashMap::new();
attributes.insert("file_path".to_string(), path_str);
Ok(ExtractedMetadata {
format: Some("NetCDF".to_string()),
attributes,
..Default::default()
})
}
}
}
pub(crate) fn build_extracted_metadata<D: HasAttributes + HasVariables + ?Sized>(
ds: &D,
path_str: &str,
) -> ExtractedMetadata {
let globals = extract_cf_globals(ds);
let bbox = extract_bbox_from_lon_lat(ds);
let temporal_extent = extract_temporal_extent(ds);
let crs = extract_grid_mapping_crs(ds);
let keywords = globals
.keywords
.as_deref()
.map(|s| {
s.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut attributes = std::collections::HashMap::new();
attributes.insert("file_path".to_string(), path_str.to_string());
if let Some(v) = &globals.conventions {
attributes.insert("Conventions".to_string(), v.clone());
}
if let Some(v) = &globals.institution {
attributes.insert("institution".to_string(), v.clone());
}
if let Some(v) = &globals.history {
attributes.insert("history".to_string(), v.clone());
}
if let Some(v) = &globals.source {
attributes.insert("source".to_string(), v.clone());
}
if let Some(v) = &globals.references {
attributes.insert("references".to_string(), v.clone());
}
if let Some(v) = &globals.comment {
attributes.insert("comment".to_string(), v.clone());
}
ExtractedMetadata {
title: globals.title,
abstract_text: globals.summary,
bbox,
temporal_extent,
crs,
spatial_resolution: None,
format: Some("NetCDF".to_string()),
keywords,
attributes,
}
}
#[cfg(feature = "netcdf")]
pub(crate) mod real_dataset {
use super::{HasAttributes, HasVariables};
use oxigdal_netcdf::{AttributeValue, NetCdfReader};
use std::path::Path;
pub struct NetCdfReaderShim {
reader: NetCdfReader,
}
impl NetCdfReaderShim {
pub fn open(path: &Path) -> Result<Self, String> {
NetCdfReader::open(path)
.map(|reader| Self { reader })
.map_err(|e| e.to_string())
}
}
fn attribute_value_to_string(value: &AttributeValue) -> String {
match value {
AttributeValue::Text(s) => s.clone(),
AttributeValue::I8(v) => render_array_i64(v.iter().map(|x| i64::from(*x))),
AttributeValue::U8(v) => render_array_u64(v.iter().map(|x| u64::from(*x))),
AttributeValue::I16(v) => render_array_i64(v.iter().map(|x| i64::from(*x))),
AttributeValue::U16(v) => render_array_u64(v.iter().map(|x| u64::from(*x))),
AttributeValue::I32(v) => render_array_i64(v.iter().map(|x| i64::from(*x))),
AttributeValue::U32(v) => render_array_u64(v.iter().map(|x| u64::from(*x))),
AttributeValue::I64(v) => render_array_i64(v.iter().copied()),
AttributeValue::U64(v) => render_array_u64(v.iter().copied()),
AttributeValue::F32(v) => render_array_f64(v.iter().map(|x| f64::from(*x))),
AttributeValue::F64(v) => render_array_f64(v.iter().copied()),
}
}
fn render_array_i64<I: IntoIterator<Item = i64>>(iter: I) -> String {
iter.into_iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
}
fn render_array_u64<I: IntoIterator<Item = u64>>(iter: I) -> String {
iter.into_iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
}
fn render_array_f64<I: IntoIterator<Item = f64>>(iter: I) -> String {
iter.into_iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(",")
}
impl HasAttributes for NetCdfReaderShim {
fn get_attribute_string(&self, name: &str) -> Option<String> {
self.reader
.global_attributes()
.get_value(name)
.map(attribute_value_to_string)
}
fn attribute_names(&self) -> Vec<String> {
self.reader
.global_attributes()
.iter()
.map(|a| a.name().to_string())
.collect()
}
}
impl HasVariables for NetCdfReaderShim {
fn variable_names(&self) -> Vec<String> {
self.reader
.variables()
.iter()
.map(|v| v.name().to_string())
.collect()
}
fn variable_attribute_string(&self, var: &str, attr: &str) -> Option<String> {
self.reader
.variables()
.get(var)
.and_then(|v| v.attributes().get_value(attr))
.map(attribute_value_to_string)
}
fn variable_min_max(&self, var: &str) -> Option<(f64, f64)> {
let variable = self.reader.variables().get(var)?;
match variable.data_type() {
oxigdal_netcdf::DataType::F32 => {
let data = self.reader.read_f32(var).ok()?;
min_max_f64(data.iter().map(|v| f64::from(*v)))
}
oxigdal_netcdf::DataType::F64 => {
let data = self.reader.read_f64(var).ok()?;
min_max_f64(data.iter().copied())
}
oxigdal_netcdf::DataType::I32 => {
let data = self.reader.read_i32(var).ok()?;
min_max_f64(data.iter().map(|v| f64::from(*v)))
}
_ => None,
}
}
}
fn min_max_f64<I: IntoIterator<Item = f64>>(iter: I) -> Option<(f64, f64)> {
let mut iter = iter.into_iter().filter(|v| v.is_finite());
let first = iter.next()?;
let (min, max) = iter.fold((first, first), |(lo, hi), v| (lo.min(v), hi.max(v)));
Some((min, max))
}
impl NetCdfReaderShim {
#[allow(dead_code)]
pub(crate) fn reader(&self) -> &NetCdfReader {
&self.reader
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_minimal_ds() -> InModuleFake {
InModuleFake::default()
}
#[derive(Default)]
struct InModuleFake {
attrs: HashMap<String, String>,
}
impl HasAttributes for InModuleFake {
fn get_attribute_string(&self, name: &str) -> Option<String> {
self.attrs.get(name).cloned()
}
fn attribute_names(&self) -> Vec<String> {
self.attrs.keys().cloned().collect()
}
}
#[test]
fn parse_cf_time_units_days_since() {
let parsed = parse_cf_time_units("days since 2000-01-01");
assert!(parsed.is_some());
let (multiplier, _) = parsed.expect("days unit must parse");
assert_eq!(multiplier, 86_400);
}
#[test]
fn parse_cf_time_units_unknown_unit_returns_none() {
assert!(parse_cf_time_units("years since 2000-01-01").is_none());
}
#[test]
fn extract_cf_globals_returns_defaults_when_empty() {
let ds = make_minimal_ds();
let g = extract_cf_globals(&ds);
assert!(g.title.is_none());
assert!(g.conventions.is_none());
}
}