use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::path::Path;
use polars::prelude::*;
use crate::cli::ReportFormat;
use crate::convert::common;
use crate::dupkey::KeyOpts;
use crate::report::format::write_report;
const EPOCH_1950_MS: i64 = -631_152_000_000;
type CmpKey = (Option<String>, String, i64, i64);
#[derive(Clone, Debug)]
pub struct CompareOpts {
pub key: KeyOpts,
pub platform_col: String,
pub time_col: String,
pub lon_col: String,
pub lat_col: String,
pub use_platform: bool,
}
impl Default for CompareOpts {
fn default() -> Self {
Self {
key: KeyOpts::default(),
platform_col: "platform_code".to_string(),
time_col: "profile_time".to_string(),
lon_col: "longitude".to_string(),
lat_col: "latitude".to_string(),
use_platform: true,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum TimeKind {
Days1950,
Datetime,
}
fn days1950_to_ms(days: f64) -> Option<i64> {
if !days.is_finite() {
return None;
}
Some((days * 86_400_000.0).round() as i64 + EPOCH_1950_MS)
}
struct Scanned {
profiles: Vec<(Option<CmpKey>, u64)>,
platforms: HashSet<String>,
observations: u64,
}
type KeyIndex = HashMap<CmpKey, HashSet<u64>>;
impl Scanned {
fn index(&self) -> KeyIndex {
let mut idx: KeyIndex = HashMap::new();
for (key, n_obs) in &self.profiles {
if let Some(k) = key {
idx.entry(k.clone()).or_default().insert(*n_obs);
}
}
idx
}
}
pub fn run(
opts: &CompareOpts,
format: ReportFormat,
a: &Path,
b: &Path,
dest: Option<&Path>,
) -> Result<(), Box<dyn Error>> {
opts.key.validate()?;
let scanned_a = scan_file(opts, a)?;
let scanned_b = scan_file(opts, b)?;
let index_a = scanned_a.index();
let index_b = scanned_b.index();
let label_a = label(a);
let label_b = label(b);
let rows = vec![
direction(&label_b, &label_a, &scanned_b, &index_a, &scanned_a.platforms),
direction(&label_a, &label_b, &scanned_a, &index_b, &scanned_b.platforms),
];
let df = build_df(&rows)?;
write_report(&df, format, dest)?;
Ok(())
}
struct Row {
reference: String,
compared: String,
ref_platforms: u32,
common_platforms: u32,
platform_cov_pct: f64,
ref_profiles: u32,
ref_unkeyed_profiles: u32,
matched_profiles: u32,
profile_cov_pct: f64,
same_nobs: u32,
diff_nobs: u32,
nobs_agree_pct: f64,
ref_observations: u64,
matched_observations: u64,
}
fn pct(num: u64, den: u64) -> f64 {
if den == 0 {
return f64::NAN;
}
((num as f64 / den as f64) * 10_000.0).round() / 100.0
}
fn direction(
reference: &str,
compared: &str,
r: &Scanned,
other: &KeyIndex,
other_platforms: &HashSet<String>,
) -> Row {
let common_platforms = r.platforms.iter().filter(|p| other_platforms.contains(*p)).count();
let mut unkeyed = 0u32;
let mut matched = 0u32;
let mut same_nobs = 0u32;
let mut matched_obs = 0u64;
for (key, n_obs) in &r.profiles {
let Some(k) = key else {
unkeyed += 1;
continue;
};
if let Some(counts) = other.get(k) {
matched += 1;
matched_obs += n_obs;
if counts.contains(n_obs) {
same_nobs += 1;
}
}
}
Row {
reference: reference.to_string(),
compared: compared.to_string(),
ref_platforms: r.platforms.len() as u32,
common_platforms: common_platforms as u32,
platform_cov_pct: pct(common_platforms as u64, r.platforms.len() as u64),
ref_profiles: r.profiles.len() as u32,
ref_unkeyed_profiles: unkeyed,
matched_profiles: matched,
profile_cov_pct: pct(matched as u64, r.profiles.len() as u64),
same_nobs,
diff_nobs: matched - same_nobs,
nobs_agree_pct: pct(same_nobs as u64, matched as u64),
ref_observations: r.observations,
matched_observations: matched_obs,
}
}
fn build_df(rows: &[Row]) -> Result<DataFrame, Box<dyn Error>> {
let df = df![
"reference" => rows.iter().map(|r| r.reference.clone()).collect::<Vec<_>>(),
"compared" => rows.iter().map(|r| r.compared.clone()).collect::<Vec<_>>(),
"ref_platforms" => rows.iter().map(|r| r.ref_platforms).collect::<Vec<_>>(),
"common_platforms" => rows.iter().map(|r| r.common_platforms).collect::<Vec<_>>(),
"platform_cov_pct" => rows.iter().map(|r| r.platform_cov_pct).collect::<Vec<_>>(),
"ref_profiles" => rows.iter().map(|r| r.ref_profiles).collect::<Vec<_>>(),
"ref_unkeyed_profiles" => rows.iter().map(|r| r.ref_unkeyed_profiles).collect::<Vec<_>>(),
"matched_profiles" => rows.iter().map(|r| r.matched_profiles).collect::<Vec<_>>(),
"profile_cov_pct" => rows.iter().map(|r| r.profile_cov_pct).collect::<Vec<_>>(),
"same_nobs" => rows.iter().map(|r| r.same_nobs).collect::<Vec<_>>(),
"diff_nobs" => rows.iter().map(|r| r.diff_nobs).collect::<Vec<_>>(),
"nobs_agree_pct" => rows.iter().map(|r| r.nobs_agree_pct).collect::<Vec<_>>(),
"ref_observations" => rows.iter().map(|r| r.ref_observations).collect::<Vec<_>>(),
"matched_observations" => rows.iter().map(|r| r.matched_observations).collect::<Vec<_>>(),
]?;
Ok(df)
}
fn label(p: &Path) -> String {
p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| p.display().to_string())
}
fn scan_file(opts: &CompareOpts, src: &Path) -> Result<Scanned, Box<dyn Error>> {
let scan = || {
LazyFrame::scan_parquet(src, common::seq_scan_args())
.map_err(|e| format!("Cannot scan {}: {}", src.display(), e))
};
let empty = scan()?.slice(0, 0).collect()?;
let time_kind = check_columns(opts, &empty.schema(), src)?;
let total = scan()?
.select([len().alias("n")])
.collect()?
.column("n")?
.u32()?
.get(0)
.unwrap_or(0) as usize;
let mut profiles: HashMap<(String, u32), (Option<CmpKey>, u64)> = HashMap::new();
let mut platforms: HashSet<String> = HashSet::new();
let time_expr = match time_kind {
TimeKind::Days1950 => col(opts.time_col.as_str()).cast(DataType::Float64).alias("_t"),
TimeKind::Datetime => col(opts.time_col.as_str()).cast(DataType::Int64).alias("_t"),
};
let step = common::chunk_rows();
let mut offset = 0usize;
while offset < total {
let count = step.min(total - offset);
let df = scan()?
.slice(offset as i64, count as IdxSize)
.select([
col(opts.platform_col.as_str()).alias("_pc"),
col("profile_no").alias("_pn"),
time_expr.clone(),
col(opts.lon_col.as_str()).cast(DataType::Float64).alias("_lon"),
col(opts.lat_col.as_str()).cast(DataType::Float64).alias("_lat"),
])
.collect()?;
let pc = df.column("_pc")?.str()?;
let pn = df.column("_pn")?.u32()?;
let lon = df.column("_lon")?.f64()?;
let lat = df.column("_lat")?.f64()?;
let t_days = if time_kind == TimeKind::Days1950 { Some(df.column("_t")?.f64()?) } else { None };
let t_ms = if time_kind == TimeKind::Datetime { Some(df.column("_t")?.i64()?) } else { None };
for i in 0..df.height() {
let platform = pc.get(i).unwrap_or("").to_string();
let id = (platform.clone(), pn.get(i).unwrap_or(0));
let entry = profiles.entry(id).or_insert_with(|| {
let ms = match time_kind {
TimeKind::Days1950 => {
t_days.as_ref().and_then(|c| c.get(i)).and_then(days1950_to_ms)
}
TimeKind::Datetime => t_ms.as_ref().and_then(|c| c.get(i)),
};
let lo = lon.get(i).unwrap_or(f64::NAN);
let la = lat.get(i).unwrap_or(f64::NAN);
let base = opts.key.key(ms, lo, la);
let key = base.map(|(time, x, y)| {
(opts.use_platform.then(|| platform.clone()), time, x, y)
});
(key, 0u64)
});
entry.1 += 1;
platforms.insert(platform);
}
offset += count;
}
let observations = profiles.values().map(|(_, n)| *n).sum();
Ok(Scanned {
profiles: profiles.into_values().collect(),
platforms,
observations,
})
}
fn check_columns(
opts: &CompareOpts,
schema: &Schema,
src: &Path,
) -> Result<TimeKind, Box<dyn Error>> {
let missing: Vec<&str> = [
opts.platform_col.as_str(),
"profile_no",
opts.time_col.as_str(),
opts.lon_col.as_str(),
opts.lat_col.as_str(),
]
.into_iter()
.filter(|c| schema.get(c).is_none())
.collect();
if !missing.is_empty() {
let available: Vec<String> = schema.iter_names().map(|n| n.to_string()).collect();
return Err(format!(
"{} has no column {} (available: {})",
src.display(),
missing.join(", "),
available.join(", ")
)
.into());
}
match schema.get(opts.time_col.as_str()) {
Some(DataType::Datetime(_, _)) => Ok(TimeKind::Datetime),
Some(DataType::Float64) | Some(DataType::Float32) => Ok(TimeKind::Days1950),
Some(other) => Err(format!(
"{}: time column '{}' is {:?}, expected a datetime or a float of days since 1950",
src.display(),
opts.time_col,
other
)
.into()),
None => unreachable!("checked above"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days1950_matches_the_oceanographic_epoch() {
assert_eq!(days1950_to_ms(0.0), Some(EPOCH_1950_MS));
assert_eq!(days1950_to_ms(7305.0), Some(0));
assert_eq!(days1950_to_ms(0.5), Some(EPOCH_1950_MS + 43_200_000));
}
#[test]
fn days1950_rejects_non_finite() {
assert_eq!(days1950_to_ms(f64::NAN), None);
assert_eq!(days1950_to_ms(f64::INFINITY), None);
}
#[test]
fn pct_of_an_empty_denominator_is_nan() {
assert!(pct(0, 0).is_nan());
assert_eq!(pct(1, 4), 25.0);
assert_eq!(pct(2, 3), 66.67);
}
}