pub mod convergence_reader;
pub mod dictionary;
pub mod error;
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 hydro_models::write_fpha_hyperplanes;
pub use manifest::{
DistributionInfo, MetadataConfiguration, MetadataConvergence, MetadataIterations,
MetadataProblemDimensions, MetadataRowPool, MetadataScenarios, OutputContext,
SimulationMetadata, TrainingMetadata, get_hostname, now_iso8601, read_simulation_metadata,
read_training_metadata, write_simulation_metadata, write_training_metadata,
};
pub use parquet_config::ParquetWriterConfig;
pub use provenance::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};
#[derive(Debug, Clone)]
pub struct IterationRecord {
pub iteration: u32,
pub lower_bound: f64,
pub upper_bound_mean: 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,
}
#[derive(Debug, Clone)]
pub struct RowPoolStatistics {
pub total_generated: u64,
pub total_active: u64,
pub peak_active: 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_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 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>,
}
#[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>,
}
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(),
};
}
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();
Self {
n_scenarios,
completed,
failed,
total_time_ms,
partitions_written,
}
}
}
#[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_mean: 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,
})
.collect();
let output = TrainingOutput {
convergence_records: records,
final_lower_bound: 50.0,
final_upper_bound: Some(52.0),
final_gap_percent: Some(3.85),
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,
},
cut_selection_records: vec![],
worker_timing_records: vec![],
};
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.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_mean: 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,
};
assert_eq!(record.iteration, 7);
assert_eq!(record.lower_bound, 10.5);
assert_eq!(record.upper_bound_mean, 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(),
],
};
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,
};
assert_eq!(stats.total_generated, 500);
assert_eq!(stats.total_active, 200);
assert_eq!(stats.peak_active, 250);
}
#[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()],
};
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(),
],
};
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()],
};
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(),
],
};
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(),
],
};
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);
}
}