#[allow(unused_imports)]
pub use cobre_sddp::{HydroModelSummary, ModelProvenanceReport, ProvenanceSource};
use console::Term;
pub fn print_hydro_model_summary(stderr: &Term, summary: &HydroModelSummary) {
let _ = stderr.write_line(&format!("{}", console::style("Hydro models").bold()));
let _ = stderr.write_line(&format!(
" Production: {}",
format_production_line(summary)
));
let _ = stderr.write_line(&format!(
" Evaporation: {}",
format_evaporation_line(summary)
));
}
enum FphaSourceLabel {
AllPrecomputed,
AllComputed,
Mixed {
n_precomputed: usize,
n_computed: usize,
},
}
fn fpha_source_label(summary: &HydroModelSummary) -> FphaSourceLabel {
use cobre_sddp::ProductionModelSource;
let n_precomputed = summary
.fpha_details
.iter()
.filter(|d| d.source == ProductionModelSource::PrecomputedHyperplanes)
.count();
let n_computed = summary
.fpha_details
.iter()
.filter(|d| d.source == ProductionModelSource::ComputedFromGeometry)
.count();
match (n_precomputed, n_computed) {
(_, 0) => FphaSourceLabel::AllPrecomputed,
(0, _) => FphaSourceLabel::AllComputed,
_ => FphaSourceLabel::Mixed {
n_precomputed,
n_computed,
},
}
}
fn format_production_line(summary: &HydroModelSummary) -> String {
match (summary.n_constant, summary.n_fpha) {
(0, 0) => "0 hydros".to_string(),
(n_const, 0) => format!("{n_const} constant"),
(0, n_fpha) => {
let source_detail = match fpha_source_label(summary) {
FphaSourceLabel::AllPrecomputed => {
"loaded from fpha_hyperplanes.parquet".to_string()
}
FphaSourceLabel::AllComputed => "computed from geometry".to_string(),
FphaSourceLabel::Mixed {
n_precomputed,
n_computed,
} => format!("{n_precomputed} precomputed, {n_computed} computed from geometry"),
};
format!(
"{n_fpha} FPHA ({} planes, {source_detail})",
summary.total_planes
)
}
(n_const, n_fpha) => {
let source_detail = match fpha_source_label(summary) {
FphaSourceLabel::AllPrecomputed => "loaded".to_string(),
FphaSourceLabel::AllComputed => "computed from geometry".to_string(),
FphaSourceLabel::Mixed {
n_precomputed,
n_computed,
} => format!("{n_precomputed} precomputed, {n_computed} computed from geometry"),
};
format!(
"{n_const} constant, {n_fpha} FPHA ({} planes, {source_detail})",
summary.total_planes
)
}
}
}
fn hydro_plural(count: usize) -> &'static str {
if count == 1 { "hydro" } else { "hydros" }
}
fn format_evaporation_line(summary: &HydroModelSummary) -> String {
if summary.n_evaporation == 0 {
return format!(
"0 hydros linearized, {} {} without",
summary.n_no_evaporation,
hydro_plural(summary.n_no_evaporation),
);
}
let ref_detail = match (summary.n_user_supplied_ref, summary.n_default_midpoint_ref) {
(0, _) => "midpoint v_ref".to_string(),
(_, 0) => "user v_ref".to_string(),
(u, m) => format!("{u} user v_ref, {m} midpoint v_ref"),
};
format!(
"{} {} linearized (from geometry, {ref_detail}), {} {} without",
summary.n_evaporation,
hydro_plural(summary.n_evaporation),
summary.n_no_evaporation,
hydro_plural(summary.n_no_evaporation),
)
}
#[cfg(test)]
pub fn format_hydro_model_summary_string(summary: &HydroModelSummary) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("Hydro models".to_string());
lines.push(format!(
" Production: {}",
format_production_line(summary)
));
lines.push(format!(
" Evaporation: {}",
format_evaporation_line(summary)
));
lines.join("\n")
}
fn provenance_ar_detail(report: &ModelProvenanceReport) -> String {
match (&report.ar_method, report.ar_max_order) {
(Some(method), Some(max_order)) => format!(" ({method}, max order {max_order})"),
_ => String::new(),
}
}
pub fn print_provenance_summary(stderr: &Term, report: &ModelProvenanceReport) {
let _ = stderr.write_line(&format!("{}", console::style("Model provenance").bold()));
let _ = stderr.write_line(&format!(" Estimation path: {}", report.estimation_path));
let _ = stderr.write_line(&format!(
" Seasonal stats: {}",
report.seasonal_stats_source
));
let ar_detail = provenance_ar_detail(report);
let _ = stderr.write_line(&format!(
" AR coefficients: {}{}",
report.ar_coefficients_source, ar_detail
));
let _ = stderr.write_line(&format!(" Correlation: {}", report.correlation_source));
let _ = stderr.write_line(&format!(
" Opening tree: {}",
report.opening_tree_source
));
}
#[cfg(test)]
pub fn format_provenance_summary_string(report: &ModelProvenanceReport) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("Model provenance".to_string());
lines.push(format!(" Estimation path: {}", report.estimation_path));
lines.push(format!(
" Seasonal stats: {}",
report.seasonal_stats_source
));
let ar_detail = provenance_ar_detail(report);
lines.push(format!(
" AR coefficients: {}{}",
report.ar_coefficients_source, ar_detail
));
lines.push(format!(" Correlation: {}", report.correlation_source));
lines.push(format!(" Opening tree: {}", report.opening_tree_source));
lines.join("\n")
}
fn fmt_sci(v: f64) -> String {
let raw = format!("{v:.5e}");
if let Some(pos) = raw.find('e') {
let mantissa = &raw[..pos];
let exp_str = &raw[pos + 1..];
if let Ok(exp) = exp_str.parse::<i32>() {
return format!("{mantissa}e{exp}");
}
}
raw
}
pub struct TrainingSummary {
pub iterations: u64,
pub converged: bool,
pub converged_at: Option<u64>,
pub reason: String,
pub lower_bound: f64,
pub upper_bound: f64,
pub upper_bound_std: f64,
pub gap_percent: f64,
pub total_cuts_active: u64,
pub total_cuts_generated: u64,
pub total_lp_solves: u64,
pub total_time_ms: u64,
pub total_first_try: u64,
pub total_retried: u64,
pub total_failed: u64,
pub total_solve_time_seconds: f64,
pub total_basis_offered: u64,
pub total_basis_rejections: u64,
pub total_simplex_iterations: u64,
}
pub struct SimulationSummary {
pub n_scenarios: u32,
pub completed: u32,
pub failed: u32,
pub total_time_ms: u64,
pub mean_cost: Option<f64>,
pub std_cost: Option<f64>,
pub total_lp_solves: u64,
pub total_first_try: u64,
pub total_retried: u64,
pub total_failed_solves: u64,
pub total_solve_time_seconds: f64,
pub total_basis_offered: u64,
pub total_basis_rejections: u64,
pub total_simplex_iterations: u64,
}
#[cfg(test)]
pub struct RunSummary {
pub training: TrainingSummary,
pub simulation: Option<SimulationSummary>,
pub output_dir: std::path::PathBuf,
}
fn format_duration(ms: u64) -> String {
let total_secs = ms / 1000;
if total_secs < 60 {
let frac = (ms % 1000) / 100;
format!("{total_secs}.{frac}s")
} else if total_secs < 3600 {
let mins = total_secs / 60;
let secs = total_secs % 60;
format!("{mins}m {secs}s")
} else {
let hours = total_secs / 3600;
let mins = (total_secs % 3600) / 60;
format!("{hours}h {mins}m")
}
}
fn format_convergence_detail(converged: bool, converged_at: Option<u64>, reason: &str) -> String {
if converged {
if let Some(iter) = converged_at {
return format!("converged at iter {iter}");
}
}
reason.to_string()
}
#[cfg(test)]
pub fn format_summary_string(summary: &RunSummary) -> String {
let t = &summary.training;
let duration = format_duration(t.total_time_ms);
let convergence_detail = format_convergence_detail(t.converged, t.converged_at, &t.reason);
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"Training complete in {duration} ({} iterations, {convergence_detail})",
t.iterations
));
lines.push(format!(
" Lower bound: {} $/stage",
fmt_sci(t.lower_bound)
));
lines.push(format!(
" Upper bound: {} +/- {} $/stage",
fmt_sci(t.upper_bound),
fmt_sci(t.upper_bound_std)
));
lines.push(format!(" Gap: {:.1}%", t.gap_percent));
lines.push(format!(
" Cuts: {} active / {} generated",
t.total_cuts_active, t.total_cuts_generated
));
lines.push(format!(" LP solves: {}", t.total_lp_solves));
if let Some(sim) = &summary.simulation {
let sim_duration = format_duration(sim.total_time_ms);
lines.push(String::new());
lines.push(format!(
"Simulation complete in {sim_duration} ({} scenarios)",
sim.n_scenarios
));
lines.push(format!(
" Completed: {} Failed: {}",
sim.completed, sim.failed
));
}
lines.push(String::new());
lines.push(format!(
"Output written to {}/",
summary.output_dir.display()
));
lines.join("\n")
}
pub fn print_training_summary(stderr: &Term, t: &TrainingSummary) {
let duration = format_duration(t.total_time_ms);
let convergence_detail = format_convergence_detail(t.converged, t.converged_at, &t.reason);
let _ = stderr.write_line(&format!(
"{} ({} iterations, {convergence_detail})",
console::style(format!("Training complete in {duration}")).bold(),
t.iterations
));
let _ = stderr.write_line(&format!(
" Lower bound: {} $/stage",
fmt_sci(t.lower_bound)
));
let _ = stderr.write_line(&format!(
" Upper bound: {} +/- {} $/stage",
fmt_sci(t.upper_bound),
fmt_sci(t.upper_bound_std)
));
let _ = stderr.write_line(&format!(" Gap: {:.1}%", t.gap_percent));
let _ = stderr.write_line(&format!(
" Cuts: {} active / {} generated",
t.total_cuts_active, t.total_cuts_generated
));
let _ = stderr.write_line(&format!(
" LP solves: {} ({} first-try, {} retried, {} failed)",
t.total_lp_solves, t.total_first_try, t.total_retried, t.total_failed
));
#[allow(clippy::cast_precision_loss)]
let avg_ms = if t.total_lp_solves > 0 {
t.total_solve_time_seconds * 1000.0 / t.total_lp_solves as f64
} else {
0.0
};
let _ = stderr.write_line(&format!(
" LP time: {:.1}s total, {avg_ms:.1}ms avg",
t.total_solve_time_seconds
));
if t.total_basis_offered > 0 {
#[allow(clippy::cast_precision_loss)]
let hit_pct =
(1.0 - t.total_basis_rejections as f64 / t.total_basis_offered as f64) * 100.0;
let _ = stderr.write_line(&format!(
" Basis reuse: {hit_pct:.1}% hit ({} rejections / {} offered)",
t.total_basis_rejections, t.total_basis_offered
));
}
let _ = stderr.write_line(&format!(" Simplex iter: {}", t.total_simplex_iterations));
}
pub fn print_simulation_summary(stderr: &Term, sim: &SimulationSummary) {
let duration = format_duration(sim.total_time_ms);
let _ = stderr.write_line(&format!(
"{} ({} scenarios)",
console::style(format!("Simulation complete in {duration}")).bold(),
sim.n_scenarios
));
let _ = stderr.write_line(&format!(
" Completed: {} Failed: {}",
sim.completed, sim.failed
));
if let (Some(mean), Some(std)) = (sim.mean_cost, sim.std_cost) {
#[allow(clippy::cast_precision_loss)]
let ci95 = if sim.n_scenarios >= 2 {
1.96 * std / (f64::from(sim.n_scenarios)).sqrt()
} else {
0.0
};
let _ = stderr.write_line(&format!(
" Expected cost: {mean:.5e} +/- {ci95:.5e} (std: {std:.5e})"
));
}
let _ = stderr.write_line(&format!(
" LP solves: {} ({} first-try, {} retried, {} failed)",
sim.total_lp_solves, sim.total_first_try, sim.total_retried, sim.total_failed_solves
));
#[allow(clippy::cast_precision_loss)]
let avg_ms = if sim.total_lp_solves > 0 {
sim.total_solve_time_seconds * 1000.0 / sim.total_lp_solves as f64
} else {
0.0
};
let _ = stderr.write_line(&format!(
" LP time: {:.1}s total, {avg_ms:.1}ms avg",
sim.total_solve_time_seconds
));
if sim.total_basis_offered > 0 {
#[allow(clippy::cast_precision_loss)]
let hit_pct =
(1.0 - sim.total_basis_rejections as f64 / sim.total_basis_offered as f64) * 100.0;
let _ = stderr.write_line(&format!(
" Basis reuse: {hit_pct:.1}% hit ({} rejections / {} offered)",
sim.total_basis_rejections, sim.total_basis_offered
));
}
let _ = stderr.write_line(&format!(" Simplex iter: {}", sim.total_simplex_iterations));
}
pub fn print_output_path(stderr: &Term, output_dir: &std::path::Path, write_secs: f64) {
let _ = stderr.write_line(&format!(
"{} {}/ {}",
console::style("Output written to").bold(),
console::style(output_dir.display()).dim(),
console::style(format!("({write_secs:.1}s)")).dim()
));
}
#[cfg(test)]
pub fn print_summary(stderr: &Term, summary: &RunSummary) {
print_training_summary(stderr, &summary.training);
if let Some(sim) = &summary.simulation {
let _ = stderr.write_line("");
print_simulation_summary(stderr, sim);
}
let _ = stderr.write_line("");
print_output_path(stderr, &summary.output_dir, 0.0);
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use console::Term;
use super::{
RunSummary, SimulationSummary, TrainingSummary, format_duration, format_summary_string,
print_summary,
};
fn make_training_summary() -> TrainingSummary {
TrainingSummary {
iterations: 50,
converged: false,
converged_at: None,
reason: "iteration_limit".to_string(),
lower_bound: 100.0,
upper_bound: 105.0,
upper_bound_std: 2.5,
gap_percent: 4.8,
total_cuts_active: 480,
total_cuts_generated: 1200,
total_lp_solves: 36_000,
total_time_ms: 5_000,
total_first_try: 35_900,
total_retried: 100,
total_failed: 0,
total_solve_time_seconds: 28.8,
total_basis_offered: 34_000,
total_basis_rejections: 200,
total_simplex_iterations: 1_800_000,
}
}
fn make_run_summary(simulation: Option<SimulationSummary>) -> RunSummary {
RunSummary {
training: make_training_summary(),
simulation,
output_dir: PathBuf::from("/results/study-001"),
}
}
#[test]
fn test_format_duration_seconds() {
assert_eq!(format_duration(12_300), "12.3s");
}
#[test]
fn test_format_duration_minutes() {
assert_eq!(format_duration(222_000), "3m 42s");
}
#[test]
fn test_format_duration_hours() {
assert_eq!(format_duration(4_980_000), "1h 23m");
}
#[test]
fn test_format_duration_exactly_zero() {
assert_eq!(format_duration(0), "0.0s");
}
#[test]
fn test_format_duration_exactly_60s() {
assert_eq!(format_duration(60_000), "1m 0s");
}
#[test]
fn test_format_duration_exactly_1h() {
assert_eq!(format_duration(3_600_000), "1h 0m");
}
#[test]
fn test_format_summary_training_only() {
let summary = make_run_summary(None);
let s = format_summary_string(&summary);
assert!(
s.contains("Training complete"),
"summary must contain 'Training complete'"
);
assert!(
!s.contains("Simulation"),
"summary must NOT contain 'Simulation' when simulation is None, got: {s}"
);
}
#[test]
fn test_format_summary_with_simulation() {
let sim = SimulationSummary {
n_scenarios: 200,
completed: 198,
failed: 2,
total_time_ms: 10_000,
mean_cost: None,
std_cost: None,
total_lp_solves: 0,
total_first_try: 0,
total_retried: 0,
total_failed_solves: 0,
total_solve_time_seconds: 0.0,
total_basis_offered: 0,
total_basis_rejections: 0,
total_simplex_iterations: 0,
};
let summary = make_run_summary(Some(sim));
let s = format_summary_string(&summary);
assert!(
s.contains("Training complete"),
"summary must contain 'Training complete'"
);
assert!(
s.contains("Simulation complete"),
"summary must contain 'Simulation complete' when simulation is Some"
);
}
#[test]
fn test_format_summary_contains_bounds() {
let summary = RunSummary {
training: TrainingSummary {
lower_bound: 100.5,
..make_training_summary()
},
simulation: None,
output_dir: PathBuf::from("/tmp/out"),
};
let s = format_summary_string(&summary);
assert!(
s.contains("1.00500e2"),
"summary must contain '1.00500e2' (scientific notation) for lower_bound = 100.5, got: {s}"
);
}
#[test]
fn test_format_summary_converged_detail() {
let summary = RunSummary {
training: TrainingSummary {
converged: true,
converged_at: Some(38),
reason: "bound_stalling".to_string(),
..make_training_summary()
},
simulation: None,
output_dir: PathBuf::from("/tmp/out"),
};
let s = format_summary_string(&summary);
assert!(
s.contains("converged at iter 38"),
"summary must contain 'converged at iter 38', got: {s}"
);
}
#[test]
fn test_format_summary_non_converged_shows_reason() {
let summary = RunSummary {
training: TrainingSummary {
converged: false,
converged_at: None,
reason: "iteration_limit".to_string(),
..make_training_summary()
},
simulation: None,
output_dir: PathBuf::from("/tmp/out"),
};
let s = format_summary_string(&summary);
assert!(
s.contains("iteration_limit"),
"summary must contain the termination reason when not converged, got: {s}"
);
}
#[test]
fn test_format_summary_time_3m42s() {
let summary = RunSummary {
training: TrainingSummary {
total_time_ms: 222_000,
..make_training_summary()
},
simulation: None,
output_dir: PathBuf::from("/tmp/out"),
};
let s = format_summary_string(&summary);
assert!(
s.contains("3m 42s"),
"summary must contain '3m 42s' for total_time_ms = 222_000, got: {s}"
);
}
#[test]
fn test_format_summary_scientific_notation() {
let summary = RunSummary {
training: TrainingSummary {
lower_bound: 45230.41,
..make_training_summary()
},
simulation: None,
output_dir: PathBuf::from("/tmp/out"),
};
let s = format_summary_string(&summary);
assert!(
s.contains("4.52304e4"),
"summary must contain '4.52304e4' (scientific notation) for lower_bound = 45230.41, got: {s}"
);
}
#[test]
fn test_format_summary_output_dir() {
let summary = RunSummary {
training: make_training_summary(),
simulation: None,
output_dir: PathBuf::from("/my/output/dir"),
};
let s = format_summary_string(&summary);
assert!(
s.contains("/my/output/dir"),
"summary must contain the output_dir path, got: {s}"
);
}
#[test]
fn test_format_summary_cut_stats() {
let summary = RunSummary {
training: TrainingSummary {
total_cuts_active: 480,
total_cuts_generated: 1200,
..make_training_summary()
},
simulation: None,
output_dir: PathBuf::from("/tmp/out"),
};
let s = format_summary_string(&summary);
assert!(
s.contains("480 active / 1200 generated"),
"summary must contain cut counts, got: {s}"
);
}
#[test]
fn test_print_summary_does_not_panic() {
let summary = make_run_summary(None);
print_summary(&Term::buffered_stderr(), &summary);
}
#[test]
fn test_print_summary_with_simulation_does_not_panic() {
let sim = SimulationSummary {
n_scenarios: 100,
completed: 100,
failed: 0,
total_time_ms: 5_000,
mean_cost: None,
std_cost: None,
total_lp_solves: 0,
total_first_try: 0,
total_retried: 0,
total_failed_solves: 0,
total_solve_time_seconds: 0.0,
total_basis_offered: 0,
total_basis_rejections: 0,
total_simplex_iterations: 0,
};
let summary = make_run_summary(Some(sim));
print_summary(&Term::buffered_stderr(), &summary);
}
use super::{HydroModelSummary, format_hydro_model_summary_string, print_hydro_model_summary};
use cobre_core::EntityId;
use cobre_sddp::FphaHydroDetail;
fn make_hydro_model_summary_mixed() -> HydroModelSummary {
HydroModelSummary {
n_constant: 2,
n_fpha: 2,
total_planes: 10,
fpha_details: vec![
FphaHydroDetail {
hydro_id: EntityId(3),
name: "Hydro3".to_string(),
source: cobre_sddp::ProductionModelSource::PrecomputedHyperplanes,
n_planes: 5,
},
FphaHydroDetail {
hydro_id: EntityId(4),
name: "Hydro4".to_string(),
source: cobre_sddp::ProductionModelSource::PrecomputedHyperplanes,
n_planes: 5,
},
],
n_evaporation: 3,
n_no_evaporation: 1,
n_user_supplied_ref: 0,
n_default_midpoint_ref: 3,
kappa_warnings: Vec::new(),
}
}
fn make_hydro_model_summary_all_constant() -> HydroModelSummary {
HydroModelSummary {
n_constant: 4,
n_fpha: 0,
total_planes: 0,
fpha_details: vec![],
n_evaporation: 0,
n_no_evaporation: 4,
n_user_supplied_ref: 0,
n_default_midpoint_ref: 0,
kappa_warnings: Vec::new(),
}
}
fn make_hydro_model_summary_all_fpha() -> HydroModelSummary {
HydroModelSummary {
n_constant: 0,
n_fpha: 165,
total_planes: 825,
fpha_details: vec![],
n_evaporation: 162,
n_no_evaporation: 3,
n_user_supplied_ref: 0,
n_default_midpoint_ref: 162,
kappa_warnings: Vec::new(),
}
}
#[test]
fn format_hydro_model_summary_with_fpha_contains_key_terms() {
let summary = make_hydro_model_summary_mixed();
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("2 FPHA"),
"mixed summary must contain '2 FPHA', got: {s}"
);
assert!(
s.contains("planes"),
"mixed summary must contain 'planes', got: {s}"
);
assert!(
s.contains("loaded"),
"mixed summary must contain 'loaded', got: {s}"
);
}
#[test]
fn format_hydro_model_summary_without_fpha_contains_constant_not_fpha() {
let summary = make_hydro_model_summary_all_constant();
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("constant"),
"all-constant summary must contain 'constant', got: {s}"
);
assert!(
!s.contains("FPHA"),
"all-constant summary must NOT contain 'FPHA', got: {s}"
);
}
#[test]
fn format_hydro_model_summary_contains_header() {
let summary = make_hydro_model_summary_mixed();
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("Hydro models"),
"summary must contain 'Hydro models' header, got: {s}"
);
}
#[test]
fn format_hydro_model_summary_mixed_production_line() {
let summary = make_hydro_model_summary_mixed();
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("10"),
"mixed summary must contain plane count '10', got: {s}"
);
assert!(
s.contains("2 constant"),
"mixed summary must contain '2 constant', got: {s}"
);
}
#[test]
fn format_hydro_model_summary_all_fpha_shows_filename() {
let summary = make_hydro_model_summary_all_fpha();
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("165 FPHA"),
"all-fpha summary must contain '165 FPHA', got: {s}"
);
assert!(
s.contains("825"),
"all-fpha summary must contain '825' (plane count), got: {s}"
);
assert!(
s.contains("fpha_hyperplanes.parquet"),
"all-fpha summary must contain the filename, got: {s}"
);
}
#[test]
fn format_hydro_model_summary_singular_evaporation() {
let summary = HydroModelSummary {
n_constant: 3,
n_fpha: 0,
total_planes: 0,
fpha_details: vec![],
n_evaporation: 1,
n_no_evaporation: 2,
n_user_supplied_ref: 0,
n_default_midpoint_ref: 1,
kappa_warnings: Vec::new(),
};
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("1 hydro linearized"),
"singular evaporation must use 'hydro' not 'hydros', got: {s}"
);
}
#[test]
fn format_hydro_model_summary_plural_evaporation() {
let summary = make_hydro_model_summary_mixed();
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("3 hydros linearized"),
"plural evaporation must use 'hydros', got: {s}"
);
}
#[test]
fn print_hydro_model_summary_does_not_panic() {
let summary = make_hydro_model_summary_mixed();
print_hydro_model_summary(&Term::buffered_stderr(), &summary);
}
#[test]
fn print_hydro_model_summary_all_constant_does_not_panic() {
let summary = make_hydro_model_summary_all_constant();
print_hydro_model_summary(&Term::buffered_stderr(), &summary);
}
#[test]
fn print_hydro_model_summary_all_fpha_does_not_panic() {
let summary = make_hydro_model_summary_all_fpha();
print_hydro_model_summary(&Term::buffered_stderr(), &summary);
}
#[test]
fn test_evaporation_line_all_midpoint() {
let summary = HydroModelSummary {
n_constant: 2,
n_fpha: 0,
total_planes: 0,
fpha_details: vec![],
n_evaporation: 2,
n_no_evaporation: 0,
n_user_supplied_ref: 0,
n_default_midpoint_ref: 2,
kappa_warnings: Vec::new(),
};
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("midpoint v_ref"),
"all-midpoint must contain 'midpoint v_ref', got: {s}"
);
assert!(
!s.contains("user v_ref"),
"all-midpoint must NOT contain 'user v_ref', got: {s}"
);
}
#[test]
fn test_evaporation_line_all_user_supplied() {
let summary = HydroModelSummary {
n_constant: 3,
n_fpha: 0,
total_planes: 0,
fpha_details: vec![],
n_evaporation: 3,
n_no_evaporation: 1,
n_user_supplied_ref: 3,
n_default_midpoint_ref: 0,
kappa_warnings: Vec::new(),
};
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("user v_ref"),
"all-user-supplied must contain 'user v_ref', got: {s}"
);
assert!(
!s.contains("midpoint v_ref"),
"all-user-supplied must NOT contain 'midpoint v_ref', got: {s}"
);
assert!(
s.contains("3 hydros linearized"),
"all-user-supplied must contain '3 hydros linearized', got: {s}"
);
assert!(
s.contains("1 hydro without"),
"all-user-supplied must contain '1 hydro without', got: {s}"
);
}
#[test]
fn test_evaporation_line_mixed() {
let summary = HydroModelSummary {
n_constant: 3,
n_fpha: 0,
total_planes: 0,
fpha_details: vec![],
n_evaporation: 3,
n_no_evaporation: 1,
n_user_supplied_ref: 2,
n_default_midpoint_ref: 1,
kappa_warnings: Vec::new(),
};
let s = format_hydro_model_summary_string(&summary);
assert!(
s.contains("2 user v_ref"),
"mixed must contain '2 user v_ref', got: {s}"
);
assert!(
s.contains("1 midpoint v_ref"),
"mixed must contain '1 midpoint v_ref', got: {s}"
);
assert!(
s.contains("3 hydros linearized"),
"mixed must contain '3 hydros linearized', got: {s}"
);
}
#[test]
fn test_evaporation_line_no_evaporation() {
let summary = make_hydro_model_summary_all_constant();
let s = format_hydro_model_summary_string(&summary);
assert!(
!s.contains("v_ref"),
"zero-evaporation must NOT contain 'v_ref', got: {s}"
);
assert!(
s.contains("0 hydros linearized"),
"zero-evaporation must contain '0 hydros linearized', got: {s}"
);
}
use super::{
ModelProvenanceReport, ProvenanceSource, format_provenance_summary_string,
print_provenance_summary,
};
fn make_provenance_report_full_estimation() -> ModelProvenanceReport {
ModelProvenanceReport {
estimation_path: "full_estimation".to_string(),
seasonal_stats_source: ProvenanceSource::Estimated,
ar_coefficients_source: ProvenanceSource::Estimated,
correlation_source: ProvenanceSource::Estimated,
opening_tree_source: ProvenanceSource::Estimated,
n_hydros: 3,
ar_method: Some("PACF".to_string()),
ar_max_order: Some(6),
white_noise_fallbacks: vec![],
}
}
fn make_provenance_report_deterministic() -> ModelProvenanceReport {
ModelProvenanceReport {
estimation_path: "deterministic".to_string(),
seasonal_stats_source: ProvenanceSource::NotApplicable,
ar_coefficients_source: ProvenanceSource::NotApplicable,
correlation_source: ProvenanceSource::NotApplicable,
opening_tree_source: ProvenanceSource::NotApplicable,
n_hydros: 0,
ar_method: None,
ar_max_order: None,
white_noise_fallbacks: vec![],
}
}
#[test]
fn print_provenance_summary_does_not_panic() {
let report = make_provenance_report_full_estimation();
print_provenance_summary(&Term::buffered_stderr(), &report);
}
#[test]
fn print_provenance_summary_deterministic_does_not_panic() {
let report = make_provenance_report_deterministic();
print_provenance_summary(&Term::buffered_stderr(), &report);
}
#[test]
fn format_provenance_summary_contains_all_section_keys() {
let report = make_provenance_report_full_estimation();
let s = format_provenance_summary_string(&report);
assert!(
s.contains("Model provenance"),
"output must contain header 'Model provenance', got: {s}"
);
assert!(
s.contains("Estimation path:"),
"output must contain 'Estimation path:' line, got: {s}"
);
assert!(
s.contains("Seasonal stats:"),
"output must contain 'Seasonal stats:' line, got: {s}"
);
assert!(
s.contains("AR coefficients:"),
"output must contain 'AR coefficients:' line, got: {s}"
);
assert!(
s.contains("Correlation:"),
"output must contain 'Correlation:' line, got: {s}"
);
assert!(
s.contains("Opening tree:"),
"output must contain 'Opening tree:' line, got: {s}"
);
}
#[test]
fn format_provenance_summary_full_estimation_includes_ar_detail() {
let report = make_provenance_report_full_estimation();
let s = format_provenance_summary_string(&report);
assert!(
s.contains("full_estimation"),
"output must contain 'full_estimation' estimation path, got: {s}"
);
assert!(
s.contains("(PACF, max order 6)"),
"output must include AR method and max order parenthetical, got: {s}"
);
}
#[test]
fn format_provenance_summary_deterministic_no_ar_detail() {
let report = make_provenance_report_deterministic();
let s = format_provenance_summary_string(&report);
assert!(
s.contains("deterministic"),
"output must contain 'deterministic' estimation path, got: {s}"
);
assert!(
s.contains("n/a"),
"output must contain 'n/a' for NotApplicable sources, got: {s}"
);
assert!(
!s.contains("max order"),
"output must NOT contain 'max order' for deterministic case, got: {s}"
);
}
#[test]
fn format_provenance_summary_user_file_source() {
let report = ModelProvenanceReport {
estimation_path: "user_provided_no_history".to_string(),
seasonal_stats_source: ProvenanceSource::UserFile,
ar_coefficients_source: ProvenanceSource::UserFile,
correlation_source: ProvenanceSource::Estimated,
opening_tree_source: ProvenanceSource::Estimated,
n_hydros: 2,
ar_method: None,
ar_max_order: None,
white_noise_fallbacks: vec![],
};
let s = format_provenance_summary_string(&report);
assert!(
s.contains("user_file"),
"output must contain 'user_file' for UserFile source, got: {s}"
);
assert!(
!s.contains("max order"),
"output must NOT contain 'max order' when ar_method is None, got: {s}"
);
}
}