use chrono::{Datelike, NaiveDate};
pub(crate) mod atomic;
pub mod convergence_reader;
pub mod dictionary;
pub mod error;
pub mod fixed_delivery;
pub mod generic_constraints_echo;
pub mod hydro_models;
pub mod manifest;
pub mod parquet_config;
pub mod policy;
pub mod provenance;
pub mod results_writer;
pub mod scaling_report;
pub(crate) mod schemas;
pub mod simulation_writer;
pub mod solver_stats_writer;
pub mod stochastic;
pub mod training_writer;
pub use convergence_reader::{
ConvergenceSummary, read_convergence_summary, read_initial_gap_percent,
};
pub use dictionary::write_dictionaries;
pub use error::OutputError;
pub use fixed_delivery::{FixedDeliveryRow, write_fixed_delivery};
pub use generic_constraints_echo::{GenericConstraintEchoRow, write_generic_constraint_echo};
pub use hydro_models::{
read_hydro_model_summary, write_evaporation_models, write_fpha_deviation_points,
write_fpha_hyperplanes, write_hydro_model_summary,
};
pub use manifest::{
DeviationSummary, DeviationWorstEntry, DistributionInfo, HostLayout, MetadataBounds,
MetadataConfiguration, MetadataConvergence, MetadataCost, MetadataIterations,
MetadataProblemDimensions, MetadataRowPool, MetadataScenarios, MetadataSimulationSolveStats,
MetadataTrainingSolveStats, OutputContext, SetupTimings, SimulationMetadata, TrainingMetadata,
default_bounds, get_hostname, now_iso8601, read_simulation_metadata, read_training_metadata,
write_simulation_metadata, write_training_metadata,
};
pub use parquet_config::ParquetWriterConfig;
pub use provenance::{read_provenance_report, write_provenance_report};
pub use results_writer::{write_results, write_simulation_results, write_training_results};
pub use scaling_report::write_scaling_report;
pub use simulation_writer::SimulationParquetWriter;
pub use solver_stats_writer::{SolverStatsRow, write_simulation_solver_stats, write_solver_stats};
pub use stochastic::{
FittingReductionEntry, FittingReport, HydroFittingEntry, write_correlation_json,
write_fitting_report, write_inflow_annual_component, write_inflow_ar_coefficients,
write_inflow_seasonal_stats, write_load_seasonal_stats, write_noise_openings,
};
pub use training_writer::{TrainingParquetWriter, write_row_selection_records};
pub(crate) fn date32_days(date: NaiveDate) -> i32 {
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).map_or(0, |e| e.num_days_from_ce());
date.num_days_from_ce() - epoch
}
#[derive(Debug, Clone)]
pub struct IterationRecord {
pub iteration: u32,
pub lower_bound: f64,
pub upper_bound: f64,
pub upper_bound_std: f64,
pub gap_percent: Option<f64>,
pub cuts_added: u32,
pub cuts_removed: u32,
pub cuts_active: u32,
pub time_forward_ms: u64,
pub time_backward_ms: u64,
pub time_total_ms: u64,
pub time_forward_wall_ms: u64,
pub time_backward_wall_ms: u64,
pub time_cut_selection_ms: u64,
pub time_mpi_allreduce_ms: u64,
pub time_cut_sync_ms: u64,
pub time_lower_bound_ms: u64,
pub time_state_exchange_ms: u64,
pub time_cut_batch_build_ms: u64,
pub time_bwd_setup_ms: u64,
pub time_bwd_load_imbalance_ms: u64,
pub time_bwd_scheduling_overhead_ms: u64,
pub time_fwd_setup_ms: u64,
pub time_fwd_load_imbalance_ms: u64,
pub time_fwd_scheduling_overhead_ms: u64,
pub time_overhead_ms: u64,
pub forward_passes: u32,
pub lp_solves: u32,
pub solve_time_ms: f64,
pub mean_rows_in_lp: f64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct RowPoolStatistics {
pub total_generated: u64,
pub total_active: u64,
pub peak_active: u64,
pub cuts_active: u64,
pub rows_in_lp_total: u64,
pub rows_in_lp_solve_count: u64,
pub rows_in_lp_max: u64,
pub total_loaded: u64,
}
#[derive(Debug, Clone)]
pub struct RowSelectionRecord {
pub iteration: u32,
pub stage: u32,
pub cuts_populated: u32,
pub cuts_active_before: u32,
pub cuts_deactivated: u32,
pub cuts_reactivated: u32,
pub cuts_active_after: u32,
pub selection_time_ms: f64,
pub budget_evicted: Option<u32>,
pub active_after_budget: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct WorkerTimingRecord {
pub iteration: u32,
pub rank: i32,
pub worker_id: Option<i32>,
pub timings: [u64; 16],
}
#[derive(Debug, Clone)]
pub struct TrainingOutput {
pub convergence_records: Vec<IterationRecord>,
pub final_lower_bound: f64,
pub final_upper_bound: Option<f64>,
pub final_gap_percent: Option<f64>,
pub final_upper_bound_std: Option<f64>,
pub final_upper_bound_kind: String,
pub iterations_completed: u32,
pub converged: bool,
pub termination_reason: String,
pub total_time_ms: u64,
pub cut_stats: RowPoolStatistics,
pub cut_selection_records: Vec<RowSelectionRecord>,
pub worker_timing_records: Vec<WorkerTimingRecord>,
pub training_solve_stats: MetadataTrainingSolveStats,
}
#[derive(Debug, Clone)]
pub struct SimulationOutput {
pub n_scenarios: u32,
pub completed: u32,
pub failed: u32,
pub total_time_ms: u64,
pub partitions_written: Vec<String>,
pub cost: Option<MetadataCost>,
pub solve_stats: MetadataSimulationSolveStats,
}
impl SimulationOutput {
#[must_use]
pub fn merge(outputs: &[Self]) -> Self {
if outputs.is_empty() {
return Self {
n_scenarios: 0,
completed: 0,
failed: 0,
total_time_ms: 0,
partitions_written: Vec::new(),
cost: None,
solve_stats: MetadataSimulationSolveStats::default(),
};
}
let n_scenarios = outputs.iter().map(|o| o.n_scenarios).sum();
let completed = outputs.iter().map(|o| o.completed).sum();
let failed = outputs.iter().map(|o| o.failed).sum();
let total_time_ms = outputs.iter().map(|o| o.total_time_ms).max().unwrap_or(0);
let mut partitions_written: Vec<String> = outputs
.iter()
.flat_map(|o| o.partitions_written.iter().cloned())
.collect();
partitions_written.sort();
let cost = outputs.iter().find_map(|o| o.cost.clone());
let solve_stats = merge_simulation_solve_stats(outputs);
Self {
n_scenarios,
completed,
failed,
total_time_ms,
partitions_written,
cost,
solve_stats,
}
}
}
fn sum_optional_u64(
outputs: &[SimulationOutput],
field: impl Fn(&MetadataSimulationSolveStats) -> Option<u64>,
) -> Option<u64> {
let mut any = false;
let mut total: u64 = 0;
for output in outputs {
if let Some(value) = field(&output.solve_stats) {
any = true;
total = total.saturating_add(value);
}
}
any.then_some(total)
}
fn merge_simulation_solve_stats(outputs: &[SimulationOutput]) -> MetadataSimulationSolveStats {
let mut solve_seconds_any = false;
let mut solve_seconds_total: f64 = 0.0;
for output in outputs {
if let Some(value) = output.solve_stats.solve_seconds {
solve_seconds_any = true;
solve_seconds_total += value;
}
}
let parallelism = outputs
.iter()
.filter_map(|o| o.solve_stats.parallelism)
.max();
MetadataSimulationSolveStats {
total_lp_solves: sum_optional_u64(outputs, |s| s.total_lp_solves),
first_try: sum_optional_u64(outputs, |s| s.first_try),
retried: sum_optional_u64(outputs, |s| s.retried),
failed: sum_optional_u64(outputs, |s| s.failed),
solve_seconds: solve_seconds_any.then_some(solve_seconds_total),
parallelism,
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::float_cmp,
clippy::cast_possible_truncation
)]
mod tests {
use super::*;
#[test]
fn training_output_construction_and_field_access() {
let records: Vec<IterationRecord> = (1..=5)
.map(|i| IterationRecord {
iteration: i,
lower_bound: 1.0,
upper_bound: 2.0,
upper_bound_std: 0.1,
gap_percent: Some(50.0),
cuts_added: 10,
cuts_removed: 2,
cuts_active: 8,
time_forward_ms: 100,
time_backward_ms: 200,
time_total_ms: 300,
forward_passes: 4,
lp_solves: 40,
time_forward_wall_ms: 100,
time_backward_wall_ms: 200,
time_cut_selection_ms: 0,
time_mpi_allreduce_ms: 0,
time_cut_sync_ms: 0,
time_lower_bound_ms: 0,
time_state_exchange_ms: 0,
time_cut_batch_build_ms: 0,
time_bwd_setup_ms: 0,
time_bwd_load_imbalance_ms: 0,
time_bwd_scheduling_overhead_ms: 0,
time_fwd_setup_ms: 0,
time_fwd_load_imbalance_ms: 0,
time_fwd_scheduling_overhead_ms: 0,
time_overhead_ms: 0,
solve_time_ms: 0.0,
mean_rows_in_lp: 0.0,
})
.collect();
let output = TrainingOutput {
convergence_records: records,
final_lower_bound: 50.0,
final_upper_bound: Some(52.0),
final_gap_percent: Some(3.85),
final_upper_bound_std: Some(0.5),
final_upper_bound_kind: "statistical".to_string(),
iterations_completed: 5,
converged: true,
termination_reason: "relative gap < 1%".to_string(),
total_time_ms: 12_000,
cut_stats: RowPoolStatistics {
total_generated: 300,
total_active: 120,
peak_active: 150,
cuts_active: 120,
rows_in_lp_total: 0,
rows_in_lp_solve_count: 0,
rows_in_lp_max: 0,
total_loaded: 0,
},
cut_selection_records: vec![],
worker_timing_records: vec![],
training_solve_stats: MetadataTrainingSolveStats::default(),
};
assert_eq!(output.convergence_records.len(), 5);
assert_eq!(output.final_lower_bound, 50.0);
assert_eq!(output.final_upper_bound, Some(52.0));
assert_eq!(output.final_gap_percent, Some(3.85));
assert_eq!(output.final_upper_bound_std, Some(0.5));
assert_eq!(output.iterations_completed, 5);
assert!(output.converged);
assert_eq!(output.termination_reason, "relative gap < 1%");
assert_eq!(output.total_time_ms, 12_000);
assert_eq!(output.cut_stats.total_generated, 300);
assert_eq!(output.cut_stats.total_active, 120);
assert_eq!(output.cut_stats.peak_active, 150);
}
#[test]
fn iteration_record_construction_and_field_access() {
let record = IterationRecord {
iteration: 7,
lower_bound: 10.5,
upper_bound: 11.0,
upper_bound_std: 0.25,
gap_percent: Some(4.55),
cuts_added: 15,
cuts_removed: 3,
cuts_active: 42,
time_forward_ms: 150,
time_backward_ms: 250,
time_total_ms: 400,
forward_passes: 8,
lp_solves: 80,
time_forward_wall_ms: 150,
time_backward_wall_ms: 250,
time_cut_selection_ms: 5,
time_mpi_allreduce_ms: 3,
time_cut_sync_ms: 2,
time_lower_bound_ms: 4,
time_state_exchange_ms: 0,
time_cut_batch_build_ms: 0,
time_bwd_setup_ms: 0,
time_bwd_load_imbalance_ms: 0,
time_bwd_scheduling_overhead_ms: 0,
time_fwd_setup_ms: 0,
time_fwd_load_imbalance_ms: 0,
time_fwd_scheduling_overhead_ms: 0,
time_overhead_ms: 400u64.saturating_sub(150 + 250 + 5 + 3 + 4),
solve_time_ms: 0.0,
mean_rows_in_lp: 0.0,
};
assert_eq!(record.iteration, 7);
assert_eq!(record.lower_bound, 10.5);
assert_eq!(record.upper_bound, 11.0);
assert_eq!(record.upper_bound_std, 0.25);
assert_eq!(record.gap_percent, Some(4.55));
assert_eq!(record.cuts_added, 15);
assert_eq!(record.cuts_removed, 3);
assert_eq!(record.cuts_active, 42);
assert_eq!(record.time_forward_ms, 150);
assert_eq!(record.time_backward_ms, 250);
assert_eq!(record.time_total_ms, 400);
assert_eq!(record.forward_passes, 8);
assert_eq!(record.lp_solves, 80);
assert_eq!(record.time_forward_wall_ms, 150);
assert_eq!(record.time_backward_wall_ms, 250);
assert_eq!(record.time_cut_selection_ms, 5);
assert_eq!(record.time_mpi_allreduce_ms, 3);
assert_eq!(record.time_cut_sync_ms, 2);
assert_eq!(record.time_lower_bound_ms, 4);
}
#[test]
fn simulation_output_construction_and_field_access() {
let output = SimulationOutput {
n_scenarios: 100,
completed: 100,
failed: 0,
total_time_ms: 3_200,
partitions_written: vec![
"simulation/costs/year=2030/part-00.parquet".to_string(),
"simulation/costs/year=2031/part-00.parquet".to_string(),
],
cost: None,
solve_stats: MetadataSimulationSolveStats::default(),
};
assert_eq!(output.n_scenarios, 100);
assert_eq!(output.completed, 100);
assert_eq!(output.failed, 0);
assert_eq!(output.total_time_ms, 3_200);
assert_eq!(output.partitions_written.len(), 2);
}
#[test]
fn row_pool_statistics_construction() {
let stats = RowPoolStatistics {
total_generated: 500,
total_active: 200,
peak_active: 250,
cuts_active: 200,
rows_in_lp_total: 0,
rows_in_lp_solve_count: 0,
rows_in_lp_max: 0,
total_loaded: 0,
};
assert_eq!(stats.total_generated, 500);
assert_eq!(stats.total_active, 200);
assert_eq!(stats.peak_active, 250);
assert_eq!(stats.cuts_active, 200);
}
#[test]
fn row_pool_statistics_serializes_with_new_fields() {
let stats = RowPoolStatistics {
total_generated: 10,
total_active: 7,
peak_active: 9,
cuts_active: 7,
rows_in_lp_total: 30,
rows_in_lp_solve_count: 6,
rows_in_lp_max: 8,
total_loaded: 3,
};
let json = serde_json::to_string(&stats).expect("serialization must succeed");
assert!(
!json.contains("\"cuts_in_lp\""),
"JSON must not contain cuts_in_lp key"
);
assert!(
json.contains("\"cuts_active\""),
"JSON must contain cuts_active key"
);
for key in [
"\"rows_in_lp_total\"",
"\"rows_in_lp_solve_count\"",
"\"rows_in_lp_max\"",
"\"total_loaded\"",
] {
assert!(json.contains(key), "JSON must contain {key}");
}
}
#[test]
fn test_merge_empty_slice() {
let merged = SimulationOutput::merge(&[]);
assert_eq!(merged.n_scenarios, 0);
assert_eq!(merged.completed, 0);
assert_eq!(merged.failed, 0);
assert_eq!(merged.total_time_ms, 0);
assert!(merged.partitions_written.is_empty());
}
#[test]
fn test_merge_single_output() {
let output = SimulationOutput {
n_scenarios: 5,
completed: 4,
failed: 1,
total_time_ms: 1000,
partitions_written: vec!["simulation/costs/scenario_id=0000/data.parquet".to_string()],
cost: None,
solve_stats: MetadataSimulationSolveStats::default(),
};
let merged = SimulationOutput::merge(std::slice::from_ref(&output));
assert_eq!(merged.n_scenarios, 5);
assert_eq!(merged.completed, 4);
assert_eq!(merged.failed, 1);
assert_eq!(merged.total_time_ms, 1000);
assert_eq!(merged.partitions_written, output.partitions_written);
}
#[test]
fn test_merge_two_outputs() {
let a = SimulationOutput {
n_scenarios: 3,
completed: 3,
failed: 0,
total_time_ms: 500,
partitions_written: vec![
"simulation/costs/scenario_id=0000/data.parquet".to_string(),
"simulation/costs/scenario_id=0001/data.parquet".to_string(),
],
cost: None,
solve_stats: MetadataSimulationSolveStats::default(),
};
let b = SimulationOutput {
n_scenarios: 2,
completed: 1,
failed: 1,
total_time_ms: 800,
partitions_written: vec!["simulation/costs/scenario_id=0002/data.parquet".to_string()],
cost: None,
solve_stats: MetadataSimulationSolveStats::default(),
};
let merged = SimulationOutput::merge(&[a, b]);
assert_eq!(merged.n_scenarios, 5);
assert_eq!(merged.completed, 4);
assert_eq!(merged.failed, 1);
assert_eq!(merged.total_time_ms, 800);
assert_eq!(merged.partitions_written.len(), 3);
}
#[test]
fn test_merge_partitions_sorted() {
let a = SimulationOutput {
n_scenarios: 1,
completed: 1,
failed: 0,
total_time_ms: 100,
partitions_written: vec![
"simulation/hydros/scenario_id=0002/data.parquet".to_string(),
"simulation/costs/scenario_id=0002/data.parquet".to_string(),
],
cost: None,
solve_stats: MetadataSimulationSolveStats::default(),
};
let b = SimulationOutput {
n_scenarios: 1,
completed: 1,
failed: 0,
total_time_ms: 200,
partitions_written: vec![
"simulation/costs/scenario_id=0001/data.parquet".to_string(),
"simulation/hydros/scenario_id=0001/data.parquet".to_string(),
],
cost: None,
solve_stats: MetadataSimulationSolveStats::default(),
};
let merged = SimulationOutput::merge(&[a, b]);
let expected = vec![
"simulation/costs/scenario_id=0001/data.parquet".to_string(),
"simulation/costs/scenario_id=0002/data.parquet".to_string(),
"simulation/hydros/scenario_id=0001/data.parquet".to_string(),
"simulation/hydros/scenario_id=0002/data.parquet".to_string(),
];
assert_eq!(merged.partitions_written, expected);
}
#[test]
fn simulation_output_merge_combines_solve_stats_order_invariant() {
let a = SimulationOutput {
n_scenarios: 2,
completed: 2,
failed: 0,
total_time_ms: 500,
partitions_written: vec![],
cost: Some(MetadataCost {
mean_cost: 100.0,
std_cost: 10.0,
}),
solve_stats: MetadataSimulationSolveStats {
total_lp_solves: Some(40),
first_try: Some(35),
retried: Some(5),
failed: Some(0),
solve_seconds: Some(1.5),
parallelism: Some(4),
},
};
let b = SimulationOutput {
n_scenarios: 3,
completed: 3,
failed: 0,
total_time_ms: 800,
partitions_written: vec![],
cost: Some(MetadataCost {
mean_cost: 200.0,
std_cost: 20.0,
}),
solve_stats: MetadataSimulationSolveStats {
total_lp_solves: Some(60),
first_try: Some(50),
retried: Some(8),
failed: Some(2),
solve_seconds: Some(2.5),
parallelism: Some(8),
},
};
let merged_ab = SimulationOutput::merge(&[a.clone(), b.clone()]);
let merged_ba = SimulationOutput::merge(&[b, a]);
assert_eq!(
merged_ab.solve_stats.total_lp_solves,
merged_ba.solve_stats.total_lp_solves
);
assert_eq!(merged_ab.solve_stats.total_lp_solves, Some(100));
assert_eq!(merged_ab.solve_stats.first_try, Some(85));
assert_eq!(
merged_ab.solve_stats.first_try,
merged_ba.solve_stats.first_try
);
assert_eq!(merged_ab.solve_stats.retried, Some(13));
assert_eq!(merged_ab.solve_stats.retried, merged_ba.solve_stats.retried);
assert_eq!(merged_ab.solve_stats.failed, Some(2));
assert_eq!(merged_ab.solve_stats.failed, merged_ba.solve_stats.failed);
assert_eq!(merged_ab.solve_stats.solve_seconds, Some(4.0));
assert_eq!(
merged_ab.solve_stats.solve_seconds,
merged_ba.solve_stats.solve_seconds
);
assert_eq!(merged_ab.solve_stats.parallelism, Some(8));
assert_eq!(
merged_ab.solve_stats.parallelism,
merged_ba.solve_stats.parallelism
);
assert_eq!(
merged_ab.cost.as_ref().map(|c| c.mean_cost),
Some(100.0),
"first-present cost wins in [a, b] order"
);
assert_eq!(
merged_ba.cost.as_ref().map(|c| c.mean_cost),
Some(200.0),
"first-present cost wins in [b, a] order"
);
}
#[test]
fn simulation_output_merge_solve_stats_none_when_no_input_records() {
let a = SimulationOutput {
n_scenarios: 1,
completed: 1,
failed: 0,
total_time_ms: 100,
partitions_written: vec![],
cost: None,
solve_stats: MetadataSimulationSolveStats::default(),
};
let merged = SimulationOutput::merge(std::slice::from_ref(&a));
assert_eq!(merged.solve_stats.total_lp_solves, None);
assert_eq!(merged.solve_stats.solve_seconds, None);
assert_eq!(merged.solve_stats.parallelism, None);
assert!(merged.cost.is_none());
}
}