use crate::product::Product;
use super::data::{
DataReport, compare_variable_data, format_variable_detail, global_relative_score,
};
use super::flags::{FlagReport, compare_flag_variables, global_flag_score as median_flag_score};
use super::options::CompareOptions;
use super::product_label;
use super::structure::{
StructureReport, StructureStatus, collect_data_variables, collect_flag_variables,
compare_structure, is_comparable_for_comparison,
};
pub use super::options::CompareOptions as ComparisonOptions;
#[derive(Clone, Debug)]
pub struct ComparisonResult {
pub reference_label: String,
pub new_label: String,
pub summary: String,
pub success: bool,
pub isomorphic: bool,
pub skip_data: bool,
pub structure: StructureReport,
pub data: DataReport,
pub flags: FlagReport,
pub global_score: Option<f64>,
pub global_flag_score: Option<f64>,
pub threshold_nb_outliers: f64,
pub threshold_coverage: f64,
}
impl ComparisonResult {
pub fn formatted_summary(&self, verbose: bool) -> String {
format_summary(self, verbose)
}
}
pub fn compare_products(left: &Product, right: &Product) -> ComparisonResult {
compare_products_with_options(left, right, &CompareOptions::default())
}
pub fn compare_products_with_options(
left: &Product,
right: &Product,
options: &CompareOptions,
) -> ComparisonResult {
compare_product_trees(left, right, options)
}
fn compare_product_trees(
left: &Product,
right: &Product,
options: &CompareOptions,
) -> ComparisonResult {
let left_label = product_label(left);
let right_label = product_label(right);
let isomorphic = is_comparable_for_comparison(&left.tree().root, &right.tree().root);
if !isomorphic {
let result = ComparisonResult {
reference_label: left_label,
new_label: right_label,
summary: String::new(),
success: false,
isomorphic: false,
skip_data: true,
structure: StructureReport::default(),
data: DataReport::default(),
flags: FlagReport::default(),
global_score: None,
global_flag_score: None,
threshold_nb_outliers: options.threshold_nb_outliers,
threshold_coverage: options.threshold_coverage,
};
return ComparisonResult {
summary: result.formatted_summary(false),
..result
};
}
let mut skip_data = false;
let structure = if options.structure {
let report = compare_structure(left, right, options);
skip_data = report.skip_data_comparison;
report
} else {
StructureReport::default()
};
let data_paths = collect_data_variables(&left.tree().root);
let flag_paths = collect_flag_variables(&left.tree().root);
let data = if options.data && !skip_data {
compare_variable_data(left, right, &data_paths, options)
} else {
DataReport::default()
};
let flags = if options.flags && !skip_data {
compare_flag_variables(left, right, &flag_paths, options)
} else {
FlagReport::default()
};
let global_score = if options.relative {
global_relative_score(&data)
} else {
None
};
let global_flag_score = median_flag_score(&flags);
let success = isomorphic
&& structure.failed_count() == 0
&& data.failed_count() == 0
&& flags.failed_count() == 0
&& !skip_data;
let mut result = ComparisonResult {
reference_label: left_label,
new_label: right_label,
summary: String::new(),
success,
isomorphic,
skip_data,
structure,
data,
flags,
global_score,
global_flag_score,
threshold_nb_outliers: options.threshold_nb_outliers,
threshold_coverage: options.threshold_coverage,
};
result.summary = result.formatted_summary(false);
result
}
fn format_summary(result: &ComparisonResult, verbose: bool) -> String {
let left = &result.reference_label;
let right = &result.new_label;
let structure = &result.structure;
let data = &result.data;
let flags = &result.flags;
let mut lines = vec![
format!("Compare “{right}” to reference “{left}”"),
format!(
"Overall: {}",
if result.success { "PASSED" } else { "FAILED" }
),
];
if !result.isomorphic {
lines.push(
"Products are not comparable (reference arrays missing in new product).".to_string(),
);
return lines.join("\n");
}
if result.skip_data {
lines.push(
"Fatal structure differences — variable data comparison was skipped.".to_string(),
);
}
lines.push(format!(
"Structure: {} passed, {} warnings, {} failed",
structure.passed_count(),
structure.warning_count(),
structure.failed_count()
));
for issue in structure
.issues
.iter()
.filter(|i| i.status == StructureStatus::Warning)
.take(8)
{
lines.push(format!(
" ⚠ [{}][{}] {}",
issue.path, issue.field, issue.detail
));
}
for issue in structure
.issues
.iter()
.filter(|i| {
matches!(
i.status,
StructureStatus::Failed | StructureStatus::MissingInNew
)
})
.take(12)
{
lines.push(format!(
" ✗ [{}][{}] {}",
issue.path, issue.field, issue.detail
));
}
if structure.failed_count() > 12 {
lines.push(format!(
" … and {} more structure issues",
structure.failed_count() - 12
));
}
lines.push(format!(
"Variables: {} passed, {} failed, {} skipped",
data.passed_count(),
data.failed_count(),
data.skipped_count()
));
for skipped in data.skipped.iter().take(8) {
lines.push(format!(" ⊘ {} — {}", skipped.path, skipped.reason));
}
if data.skipped_count() > 8 {
lines.push(format!(
" … and {} more skipped variables",
data.skipped_count() - 8
));
}
let failed_vars: Vec<_> = data.variables.iter().filter(|v| !v.passed).collect();
let passed_vars: Vec<_> = data.variables.iter().filter(|v| v.passed).collect();
let failed_limit = if verbose {
failed_vars.len()
} else {
failed_vars.len().min(12)
};
let passed_limit = if verbose {
passed_vars.len()
} else {
passed_vars.len().min(6)
};
for var in failed_vars.iter().take(failed_limit) {
lines.push(format!(
" ✗ {}",
format_variable_detail(var, result.threshold_nb_outliers, result.threshold_coverage)
));
}
if failed_vars.len() > failed_limit {
lines.push(format!(
" … and {} more failed variables",
failed_vars.len() - failed_limit
));
}
if !passed_vars.is_empty() && !verbose && failed_vars.is_empty() {
lines.push(" (passed variables omitted — use --verbose to list them)".to_string());
} else if !passed_vars.is_empty() && !verbose && !failed_vars.is_empty() {
lines.push(" Passed:".to_string());
}
for var in passed_vars.iter().take(passed_limit) {
if verbose {
lines.push(format!(
" ✓ {}",
format_variable_detail(
var,
result.threshold_nb_outliers,
result.threshold_coverage
)
));
} else {
lines.push(format!(" ✓ {}", var.path));
}
}
if passed_vars.len() > passed_limit {
lines.push(format!(
" … and {} more passed variables (use --verbose to list all)",
passed_vars.len() - passed_limit
));
}
lines.push(format!(
"Flags: {} bits passed, {} bits failed",
flags.passed_count(),
flags.failed_count()
));
for var in flags.variables.iter() {
for bit in var.bits.iter().filter(|b| !b.passed) {
lines.push(format!(
" ✗ {} — {} different {:.2}%",
var.path, bit.meaning, bit.different_percentage
));
}
}
if verbose {
for var in flags.variables.iter() {
for bit in var.bits.iter().filter(|b| b.passed) {
lines.push(format!(
" ✓ {} — {} equal {:.2}%",
var.path, bit.meaning, bit.equal_percentage
));
}
}
}
if let Some(score) = result.global_score {
lines.push(format!("Global relative score: {score:.6}%"));
}
if let Some(score) = result.global_flag_score {
lines.push(format!("Global flag score (median): {score:.6}%"));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::product::open_product;
use std::path::Path;
#[test]
fn identical_sample_products_pass() {
let path = Path::new("sample_data/S03OLCEFR_sample.zarr");
if !path.exists() {
return;
}
let left = open_product(path.to_str().unwrap()).expect("open left");
let right = open_product(path.to_str().unwrap()).expect("open right");
let result = compare_products(&left, &right);
assert!(result.isomorphic);
assert!(result.success, "{}", result.summary);
}
#[test]
fn slstr_products_compare_data_variables() {
let ref_path = Path::new(
"/tmp/s3slstr_data_verification/reference/fixed/S03SLSLST_20230514T074253_0180_A377_SBBE.zarr",
);
let new_path = Path::new(
"/tmp/s3slstr_data_verification/output_dpr/S03SLSLST_20230514T074253_0180_A377_S000.zarr",
);
if !ref_path.exists() || !new_path.exists() {
return;
}
let left = open_product(ref_path.to_str().unwrap()).expect("open reference");
let right = open_product(new_path.to_str().unwrap()).expect("open new");
let result = compare_products(&left, &right);
assert!(result.isomorphic, "{}", result.summary);
assert!(
!result.skip_data,
"data comparison should not be skipped: {}",
result.summary
);
assert!(
!result.data.variables.is_empty(),
"expected compared variables: {}",
result.summary
);
}
#[cfg(feature = "safe")]
#[test]
fn slstr_zarr_and_safe_products_are_comparable() {
let ref_path =
Path::new("/home/vincent/Data/SLSTR/S03SLSLST_20260622T102053_0180_A008_T96C.zarr");
let new_path = Path::new(
"/home/vincent/Data/SLSTR/S3A_SL_2_LST____20260622T102053_20260622T102353_20260622T123949_0179_141_008_2160_PS1_O_NR_005.SEN3",
);
if !ref_path.exists() || !new_path.exists() {
return;
}
let left = open_product(ref_path.to_str().unwrap()).expect("open zarr reference");
let right = open_product(new_path.to_str().unwrap()).expect("open safe new");
let result = compare_products(&left, &right);
assert!(result.isomorphic, "{}", result.summary);
assert!(
!result.skip_data,
"data comparison should not be skipped: {}",
result.summary
);
assert!(
!result.data.variables.is_empty(),
"expected compared variables: {}",
result.summary
);
}
}