use arrow::array::Array;
use cobre_core::EntityId;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use std::fs::File;
use std::path::Path;
use crate::LoadError;
use crate::parquet_helpers::{extract_optional_float64, extract_required_int32};
#[derive(Debug, Clone, PartialEq)]
pub struct BusPenaltyOverrideRow {
pub bus_id: EntityId,
pub stage_id: i32,
pub excess_cost: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LinePenaltyOverrideRow {
pub line_id: EntityId,
pub stage_id: i32,
pub exchange_cost: Option<f64>,
}
#[allow(clippy::struct_field_names)]
#[derive(Debug, Clone, PartialEq)]
pub struct HydroPenaltyOverrideRow {
pub hydro_id: EntityId,
pub stage_id: i32,
pub spillage_cost: Option<f64>,
pub turbined_cost: Option<f64>,
pub diversion_cost: Option<f64>,
pub storage_violation_below_cost: Option<f64>,
pub filling_target_violation_cost: Option<f64>,
pub turbined_violation_below_cost: Option<f64>,
pub outflow_violation_below_cost: Option<f64>,
pub outflow_violation_above_cost: Option<f64>,
pub generation_violation_below_cost: Option<f64>,
pub evaporation_violation_cost: Option<f64>,
pub water_withdrawal_violation_cost: Option<f64>,
pub water_withdrawal_violation_pos_cost: Option<f64>,
pub water_withdrawal_violation_neg_cost: Option<f64>,
pub evaporation_violation_pos_cost: Option<f64>,
pub evaporation_violation_neg_cost: Option<f64>,
pub inflow_nonnegativity_cost: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NcsPenaltyOverrideRow {
pub source_id: EntityId,
pub stage_id: i32,
pub curtailment_cost: Option<f64>,
}
fn validate_optional_positive(
value: Option<f64>,
file_label: &str,
row_idx: usize,
column: &str,
path: &Path,
) -> Result<(), LoadError> {
if let Some(v) = value {
if !v.is_finite() {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{file_label}[{row_idx}].{column}"),
message: format!("value must be finite, got {v}"),
});
}
if v <= 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("{file_label}[{row_idx}].{column}"),
message: format!("value must be > 0.0, got {v}"),
});
}
}
Ok(())
}
pub fn parse_penalty_overrides_bus(path: &Path) -> Result<Vec<BusPenaltyOverrideRow>, LoadError> {
let file = File::open(path).map_err(|e| LoadError::io(path, e))?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)
.map_err(|e| LoadError::parse(path, e.to_string()))?;
let reader = builder
.build()
.map_err(|e| LoadError::parse(path, e.to_string()))?;
let mut rows: Vec<BusPenaltyOverrideRow> = Vec::new();
for batch_result in reader {
let batch = batch_result.map_err(|e| LoadError::parse(path, e.to_string()))?;
let bus_id_col = extract_required_int32(&batch, "bus_id", path)?;
let stage_id_col = extract_required_int32(&batch, "stage_id", path)?;
let excess_cost_col = extract_optional_float64(&batch, "excess_cost", path)?;
let n = batch.num_rows();
let base_idx = rows.len();
rows.reserve(n);
for i in 0..n {
let row_idx = base_idx + i;
let bus_id = EntityId::from(bus_id_col.value(i));
let stage_id = stage_id_col.value(i);
let excess_cost = excess_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
validate_optional_positive(
excess_cost,
"penalty_overrides_bus",
row_idx,
"excess_cost",
path,
)?;
rows.push(BusPenaltyOverrideRow {
bus_id,
stage_id,
excess_cost,
});
}
}
rows.sort_by(|a, b| {
a.bus_id
.0
.cmp(&b.bus_id.0)
.then_with(|| a.stage_id.cmp(&b.stage_id))
});
Ok(rows)
}
pub fn parse_penalty_overrides_line(path: &Path) -> Result<Vec<LinePenaltyOverrideRow>, LoadError> {
let file = File::open(path).map_err(|e| LoadError::io(path, e))?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)
.map_err(|e| LoadError::parse(path, e.to_string()))?;
let reader = builder
.build()
.map_err(|e| LoadError::parse(path, e.to_string()))?;
let mut rows: Vec<LinePenaltyOverrideRow> = Vec::new();
for batch_result in reader {
let batch = batch_result.map_err(|e| LoadError::parse(path, e.to_string()))?;
let line_id_col = extract_required_int32(&batch, "line_id", path)?;
let stage_id_col = extract_required_int32(&batch, "stage_id", path)?;
let exchange_cost_col = extract_optional_float64(&batch, "exchange_cost", path)?;
let n = batch.num_rows();
let base_idx = rows.len();
rows.reserve(n);
for i in 0..n {
let row_idx = base_idx + i;
let line_id = EntityId::from(line_id_col.value(i));
let stage_id = stage_id_col.value(i);
let exchange_cost = exchange_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
validate_optional_positive(
exchange_cost,
"penalty_overrides_line",
row_idx,
"exchange_cost",
path,
)?;
rows.push(LinePenaltyOverrideRow {
line_id,
stage_id,
exchange_cost,
});
}
}
rows.sort_by(|a, b| {
a.line_id
.0
.cmp(&b.line_id.0)
.then_with(|| a.stage_id.cmp(&b.stage_id))
});
Ok(rows)
}
#[allow(clippy::too_many_lines)]
pub fn parse_penalty_overrides_hydro(
path: &Path,
) -> Result<Vec<HydroPenaltyOverrideRow>, LoadError> {
let file = File::open(path).map_err(|e| LoadError::io(path, e))?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)
.map_err(|e| LoadError::parse(path, e.to_string()))?;
let reader = builder
.build()
.map_err(|e| LoadError::parse(path, e.to_string()))?;
let mut rows: Vec<HydroPenaltyOverrideRow> = Vec::new();
for batch_result in reader {
let batch = batch_result.map_err(|e| LoadError::parse(path, e.to_string()))?;
let hydro_id_col = extract_required_int32(&batch, "hydro_id", path)?;
let stage_id_col = extract_required_int32(&batch, "stage_id", path)?;
let spillage_cost_col = extract_optional_float64(&batch, "spillage_cost", path)?;
let turbined_cost_col = extract_optional_float64(&batch, "turbined_cost", path)?;
let diversion_cost_col = extract_optional_float64(&batch, "diversion_cost", path)?;
let storage_violation_below_cost_col =
extract_optional_float64(&batch, "storage_violation_below_cost", path)?;
let filling_target_violation_cost_col =
extract_optional_float64(&batch, "filling_target_violation_cost", path)?;
let turbined_violation_below_cost_col =
extract_optional_float64(&batch, "turbined_violation_below_cost", path)?;
let outflow_violation_below_cost_col =
extract_optional_float64(&batch, "outflow_violation_below_cost", path)?;
let outflow_violation_above_cost_col =
extract_optional_float64(&batch, "outflow_violation_above_cost", path)?;
let generation_violation_below_cost_col =
extract_optional_float64(&batch, "generation_violation_below_cost", path)?;
let evaporation_violation_cost_col =
extract_optional_float64(&batch, "evaporation_violation_cost", path)?;
let water_withdrawal_violation_cost_col =
extract_optional_float64(&batch, "water_withdrawal_violation_cost", path)?;
let water_withdrawal_violation_pos_cost_col =
extract_optional_float64(&batch, "water_withdrawal_violation_pos_cost", path)?;
let water_withdrawal_violation_neg_cost_col =
extract_optional_float64(&batch, "water_withdrawal_violation_neg_cost", path)?;
let evaporation_violation_pos_cost_col =
extract_optional_float64(&batch, "evaporation_violation_pos_cost", path)?;
let evaporation_violation_neg_cost_col =
extract_optional_float64(&batch, "evaporation_violation_neg_cost", path)?;
let inflow_nonnegativity_cost_col =
extract_optional_float64(&batch, "inflow_nonnegativity_cost", path)?;
let n = batch.num_rows();
let base_idx = rows.len();
rows.reserve(n);
for i in 0..n {
let row_idx = base_idx + i;
let hydro_id = EntityId::from(hydro_id_col.value(i));
let stage_id = stage_id_col.value(i);
let spillage_cost = spillage_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let turbined_cost = turbined_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let diversion_cost = diversion_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let storage_violation_below_cost = storage_violation_below_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let filling_target_violation_cost = filling_target_violation_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let turbined_violation_below_cost = turbined_violation_below_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let outflow_violation_below_cost = outflow_violation_below_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let outflow_violation_above_cost = outflow_violation_above_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let generation_violation_below_cost = generation_violation_below_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let evaporation_violation_cost = evaporation_violation_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let water_withdrawal_violation_cost = water_withdrawal_violation_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let water_withdrawal_violation_pos_cost = water_withdrawal_violation_pos_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let water_withdrawal_violation_neg_cost = water_withdrawal_violation_neg_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let evaporation_violation_pos_cost = evaporation_violation_pos_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let evaporation_violation_neg_cost = evaporation_violation_neg_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
let inflow_nonnegativity_cost = inflow_nonnegativity_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
validate_optional_positive(
spillage_cost,
"penalty_overrides_hydro",
row_idx,
"spillage_cost",
path,
)?;
validate_optional_positive(
turbined_cost,
"penalty_overrides_hydro",
row_idx,
"turbined_cost",
path,
)?;
validate_optional_positive(
diversion_cost,
"penalty_overrides_hydro",
row_idx,
"diversion_cost",
path,
)?;
validate_optional_positive(
storage_violation_below_cost,
"penalty_overrides_hydro",
row_idx,
"storage_violation_below_cost",
path,
)?;
validate_optional_positive(
filling_target_violation_cost,
"penalty_overrides_hydro",
row_idx,
"filling_target_violation_cost",
path,
)?;
validate_optional_positive(
turbined_violation_below_cost,
"penalty_overrides_hydro",
row_idx,
"turbined_violation_below_cost",
path,
)?;
validate_optional_positive(
outflow_violation_below_cost,
"penalty_overrides_hydro",
row_idx,
"outflow_violation_below_cost",
path,
)?;
validate_optional_positive(
outflow_violation_above_cost,
"penalty_overrides_hydro",
row_idx,
"outflow_violation_above_cost",
path,
)?;
validate_optional_positive(
generation_violation_below_cost,
"penalty_overrides_hydro",
row_idx,
"generation_violation_below_cost",
path,
)?;
validate_optional_positive(
evaporation_violation_cost,
"penalty_overrides_hydro",
row_idx,
"evaporation_violation_cost",
path,
)?;
validate_optional_positive(
water_withdrawal_violation_cost,
"penalty_overrides_hydro",
row_idx,
"water_withdrawal_violation_cost",
path,
)?;
validate_optional_positive(
water_withdrawal_violation_pos_cost,
"penalty_overrides_hydro",
row_idx,
"water_withdrawal_violation_pos_cost",
path,
)?;
validate_optional_positive(
water_withdrawal_violation_neg_cost,
"penalty_overrides_hydro",
row_idx,
"water_withdrawal_violation_neg_cost",
path,
)?;
validate_optional_positive(
evaporation_violation_pos_cost,
"penalty_overrides_hydro",
row_idx,
"evaporation_violation_pos_cost",
path,
)?;
validate_optional_positive(
evaporation_violation_neg_cost,
"penalty_overrides_hydro",
row_idx,
"evaporation_violation_neg_cost",
path,
)?;
validate_optional_positive(
inflow_nonnegativity_cost,
"penalty_overrides_hydro",
row_idx,
"inflow_nonnegativity_cost",
path,
)?;
rows.push(HydroPenaltyOverrideRow {
hydro_id,
stage_id,
spillage_cost,
turbined_cost,
diversion_cost,
storage_violation_below_cost,
filling_target_violation_cost,
turbined_violation_below_cost,
outflow_violation_below_cost,
outflow_violation_above_cost,
generation_violation_below_cost,
evaporation_violation_cost,
water_withdrawal_violation_cost,
water_withdrawal_violation_pos_cost,
water_withdrawal_violation_neg_cost,
evaporation_violation_pos_cost,
evaporation_violation_neg_cost,
inflow_nonnegativity_cost,
});
}
}
rows.sort_by(|a, b| {
a.hydro_id
.0
.cmp(&b.hydro_id.0)
.then_with(|| a.stage_id.cmp(&b.stage_id))
});
Ok(rows)
}
pub fn parse_penalty_overrides_ncs(path: &Path) -> Result<Vec<NcsPenaltyOverrideRow>, LoadError> {
let file = File::open(path).map_err(|e| LoadError::io(path, e))?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)
.map_err(|e| LoadError::parse(path, e.to_string()))?;
let reader = builder
.build()
.map_err(|e| LoadError::parse(path, e.to_string()))?;
let mut rows: Vec<NcsPenaltyOverrideRow> = Vec::new();
for batch_result in reader {
let batch = batch_result.map_err(|e| LoadError::parse(path, e.to_string()))?;
let source_id_col = extract_required_int32(&batch, "ncs_id", path)?;
let stage_id_col = extract_required_int32(&batch, "stage_id", path)?;
let curtailment_cost_col = extract_optional_float64(&batch, "curtailment_cost", path)?;
let n = batch.num_rows();
let base_idx = rows.len();
rows.reserve(n);
for i in 0..n {
let row_idx = base_idx + i;
let source_id = EntityId::from(source_id_col.value(i));
let stage_id = stage_id_col.value(i);
let curtailment_cost = curtailment_cost_col
.filter(|col| !col.is_null(i))
.map(|col| col.value(i));
validate_optional_positive(
curtailment_cost,
"penalty_overrides_ncs",
row_idx,
"curtailment_cost",
path,
)?;
rows.push(NcsPenaltyOverrideRow {
source_id,
stage_id,
curtailment_cost,
});
}
}
rows.sort_by(|a, b| {
a.source_id
.0
.cmp(&b.source_id.0)
.then_with(|| a.stage_id.cmp(&b.stage_id))
});
Ok(rows)
}
#[cfg(test)]
#[allow(
clippy::doc_markdown,
clippy::expect_used,
clippy::panic,
clippy::too_many_lines,
clippy::unwrap_used
)]
mod tests {
use super::*;
use arrow::array::{Float64Array, Int32Array};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use std::sync::Arc;
use tempfile::NamedTempFile;
fn write_parquet(batch: &RecordBatch) -> NamedTempFile {
let tmp = NamedTempFile::new().expect("tempfile");
let mut writer = ArrowWriter::try_new(tmp.reopen().expect("reopen"), batch.schema(), None)
.expect("ArrowWriter");
writer.write(batch).expect("write batch");
writer.close().expect("close writer");
tmp
}
fn bus_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("bus_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("excess_cost", DataType::Float64, true),
]))
}
fn make_bus_batch(
bus_ids: &[i32],
stage_ids: &[i32],
excess_cost: Vec<Option<f64>>,
) -> RecordBatch {
RecordBatch::try_new(
bus_schema(),
vec![
Arc::new(Int32Array::from(bus_ids.to_vec())),
Arc::new(Int32Array::from(stage_ids.to_vec())),
Arc::new(Float64Array::from(excess_cost)),
],
)
.expect("valid batch")
}
#[test]
fn test_bus_valid_3_rows_sorted() {
let batch = make_bus_batch(
&[3, 1, 1],
&[0, 1, 0],
vec![Some(500.0), Some(1000.0), None],
);
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_bus(tmp.path()).unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].bus_id, EntityId::from(1));
assert_eq!(rows[0].stage_id, 0);
assert!(rows[0].excess_cost.is_none());
assert_eq!(rows[1].bus_id, EntityId::from(1));
assert_eq!(rows[1].stage_id, 1);
assert!((rows[1].excess_cost.unwrap() - 1000.0).abs() < f64::EPSILON);
assert_eq!(rows[2].bus_id, EntityId::from(3));
assert_eq!(rows[2].stage_id, 0);
assert!((rows[2].excess_cost.unwrap() - 500.0).abs() < f64::EPSILON);
}
#[test]
fn test_bus_missing_bus_id() {
let schema = Arc::new(Schema::new(vec![
Field::new("stage_id", DataType::Int32, false),
Field::new("excess_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![0_i32])),
Arc::new(Float64Array::from(vec![Some(500.0_f64)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_bus(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("bus_id"),
"field should contain 'bus_id', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_bus_negative_excess_cost() {
let batch = make_bus_batch(&[1], &[0], vec![Some(-5.0)]);
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_bus(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("excess_cost"),
"field should contain 'excess_cost', got: {field}"
);
assert!(
message.contains("must be > 0.0"),
"message should contain 'must be > 0.0', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_bus_nan_excess_cost() {
let batch = make_bus_batch(&[1], &[0], vec![Some(f64::NAN)]);
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_bus(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("excess_cost"),
"field should contain 'excess_cost', got: {field}"
);
assert!(
message.contains("finite"),
"message should contain 'finite', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_bus_empty_parquet() {
let batch = make_bus_batch(&[], &[], vec![]);
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_bus(tmp.path()).unwrap();
assert!(rows.is_empty());
}
#[test]
fn test_load_bus_none() {
let rows = super::super::load_penalty_overrides_bus(None).unwrap();
assert!(rows.is_empty());
}
fn line_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("line_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("exchange_cost", DataType::Float64, true),
]))
}
fn make_line_batch(
line_ids: &[i32],
stage_ids: &[i32],
exchange_cost: Vec<Option<f64>>,
) -> RecordBatch {
RecordBatch::try_new(
line_schema(),
vec![
Arc::new(Int32Array::from(line_ids.to_vec())),
Arc::new(Int32Array::from(stage_ids.to_vec())),
Arc::new(Float64Array::from(exchange_cost)),
],
)
.expect("valid batch")
}
#[test]
fn test_line_valid_2_rows_sorted() {
let batch = make_line_batch(&[5, 5], &[1, 0], vec![Some(2000.0), Some(1500.0)]);
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_line(tmp.path()).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].line_id, EntityId::from(5));
assert_eq!(rows[0].stage_id, 0);
assert!((rows[0].exchange_cost.unwrap() - 1500.0).abs() < f64::EPSILON);
assert_eq!(rows[1].line_id, EntityId::from(5));
assert_eq!(rows[1].stage_id, 1);
assert!((rows[1].exchange_cost.unwrap() - 2000.0).abs() < f64::EPSILON);
}
#[test]
fn test_line_missing_line_id() {
let schema = Arc::new(Schema::new(vec![
Field::new("stage_id", DataType::Int32, false),
Field::new("exchange_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![0_i32])),
Arc::new(Float64Array::from(vec![Some(1000.0_f64)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_line(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("line_id"),
"field should contain 'line_id', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_line_negative_exchange_cost() {
let batch = make_line_batch(&[1], &[0], vec![Some(-5.0)]);
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_line(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("exchange_cost"),
"field should contain 'exchange_cost', got: {field}"
);
assert!(
message.contains("must be > 0.0"),
"message should contain 'must be > 0.0', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_line_nan_exchange_cost() {
let batch = make_line_batch(&[1], &[0], vec![Some(f64::NAN)]);
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_line(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("exchange_cost"),
"field should contain 'exchange_cost', got: {field}"
);
assert!(
message.contains("finite"),
"message should contain 'finite', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_line_empty_parquet() {
let batch = make_line_batch(&[], &[], vec![]);
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_line(tmp.path()).unwrap();
assert!(rows.is_empty());
}
#[test]
fn test_load_line_none() {
let rows = super::super::load_penalty_overrides_line(None).unwrap();
assert!(rows.is_empty());
}
fn hydro_full_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("spillage_cost", DataType::Float64, true),
Field::new("turbined_cost", DataType::Float64, true),
Field::new("diversion_cost", DataType::Float64, true),
Field::new("storage_violation_below_cost", DataType::Float64, true),
Field::new("filling_target_violation_cost", DataType::Float64, true),
Field::new("turbined_violation_below_cost", DataType::Float64, true),
Field::new("outflow_violation_below_cost", DataType::Float64, true),
Field::new("outflow_violation_above_cost", DataType::Float64, true),
Field::new("generation_violation_below_cost", DataType::Float64, true),
Field::new("evaporation_violation_cost", DataType::Float64, true),
Field::new("water_withdrawal_violation_cost", DataType::Float64, true),
]))
}
#[test]
fn test_hydro_two_columns_non_null_rest_none() {
let batch = RecordBatch::try_new(
hydro_full_schema(),
vec![
Arc::new(Int32Array::from(vec![7_i32, 7_i32])),
Arc::new(Int32Array::from(vec![0_i32, 1_i32])),
Arc::new(Float64Array::from(vec![Some(0.01_f64), Some(0.02_f64)])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
Arc::new(Float64Array::from(vec![Some(9999.0_f64), Some(8888.0_f64)])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>, None::<f64>])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_hydro(tmp.path()).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].hydro_id, EntityId::from(7));
assert_eq!(rows[0].stage_id, 0);
assert!((rows[0].spillage_cost.unwrap() - 0.01).abs() < f64::EPSILON);
assert!(rows[0].turbined_cost.is_none());
assert!(rows[0].diversion_cost.is_none());
assert!((rows[0].storage_violation_below_cost.unwrap() - 9999.0).abs() < f64::EPSILON);
assert!(rows[0].filling_target_violation_cost.is_none());
assert!(rows[0].turbined_violation_below_cost.is_none());
assert!(rows[0].outflow_violation_below_cost.is_none());
assert!(rows[0].outflow_violation_above_cost.is_none());
assert!(rows[0].generation_violation_below_cost.is_none());
assert!(rows[0].evaporation_violation_cost.is_none());
assert!(rows[0].water_withdrawal_violation_cost.is_none());
assert_eq!(rows[1].hydro_id, EntityId::from(7));
assert_eq!(rows[1].stage_id, 1);
assert!((rows[1].spillage_cost.unwrap() - 0.02).abs() < f64::EPSILON);
assert!((rows[1].storage_violation_below_cost.unwrap() - 8888.0).abs() < f64::EPSILON);
}
#[test]
fn test_hydro_all_11_columns_mixed() {
let batch = RecordBatch::try_new(
hydro_full_schema(),
vec![
Arc::new(Int32Array::from(vec![2_i32])),
Arc::new(Int32Array::from(vec![0_i32])),
Arc::new(Float64Array::from(vec![Some(0.01_f64)])),
Arc::new(Float64Array::from(vec![Some(100.0_f64)])),
Arc::new(Float64Array::from(vec![None::<f64>])),
Arc::new(Float64Array::from(vec![Some(9999.0_f64)])),
Arc::new(Float64Array::from(vec![Some(5000.0_f64)])),
Arc::new(Float64Array::from(vec![None::<f64>])),
Arc::new(Float64Array::from(vec![Some(2000.0_f64)])),
Arc::new(Float64Array::from(vec![Some(3000.0_f64)])),
Arc::new(Float64Array::from(vec![None::<f64>])),
Arc::new(Float64Array::from(vec![Some(50.0_f64)])),
Arc::new(Float64Array::from(vec![None::<f64>])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_hydro(tmp.path()).unwrap();
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r.hydro_id, EntityId::from(2));
assert_eq!(r.stage_id, 0);
assert!(r.spillage_cost.is_some());
assert!(r.turbined_cost.is_some());
assert!(r.diversion_cost.is_none());
assert!(r.storage_violation_below_cost.is_some());
assert!(r.filling_target_violation_cost.is_some());
assert!(r.turbined_violation_below_cost.is_none());
assert!(r.outflow_violation_below_cost.is_some());
assert!(r.outflow_violation_above_cost.is_some());
assert!(r.generation_violation_below_cost.is_none());
assert!(r.evaporation_violation_cost.is_some());
assert!(r.water_withdrawal_violation_cost.is_none());
}
#[test]
fn test_hydro_only_2_columns_in_schema() {
let schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("spillage_cost", DataType::Float64, true),
Field::new("storage_violation_below_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![3_i32, 3_i32])),
Arc::new(Int32Array::from(vec![0_i32, 1_i32])),
Arc::new(Float64Array::from(vec![Some(0.5_f64), None::<f64>])),
Arc::new(Float64Array::from(vec![None::<f64>, Some(100.0_f64)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_hydro(tmp.path()).unwrap();
assert_eq!(rows.len(), 2);
assert!(rows[0].turbined_cost.is_none());
assert!(rows[0].diversion_cost.is_none());
assert!(rows[0].filling_target_violation_cost.is_none());
assert!(rows[0].turbined_violation_below_cost.is_none());
assert!(rows[0].outflow_violation_below_cost.is_none());
assert!(rows[0].outflow_violation_above_cost.is_none());
assert!(rows[0].generation_violation_below_cost.is_none());
assert!(rows[0].evaporation_violation_cost.is_none());
assert!(rows[0].water_withdrawal_violation_cost.is_none());
assert!((rows[0].spillage_cost.unwrap() - 0.5).abs() < f64::EPSILON);
assert!(rows[0].storage_violation_below_cost.is_none());
assert!(rows[1].spillage_cost.is_none());
assert!((rows[1].storage_violation_below_cost.unwrap() - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_hydro_missing_hydro_id() {
let schema = Arc::new(Schema::new(vec![
Field::new("stage_id", DataType::Int32, false),
Field::new("spillage_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![0_i32])),
Arc::new(Float64Array::from(vec![Some(0.01_f64)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_hydro(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert!(
field.contains("hydro_id"),
"field should contain 'hydro_id', got: {field}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_hydro_negative_spillage_cost() {
let schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("spillage_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![1_i32])),
Arc::new(Int32Array::from(vec![0_i32])),
Arc::new(Float64Array::from(vec![Some(-0.01_f64)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_hydro(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("spillage_cost"),
"field should contain 'spillage_cost', got: {field}"
);
assert!(
message.contains("must be > 0.0"),
"message should contain 'must be > 0.0', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_hydro_nan_penalty() {
let schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("evaporation_violation_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![1_i32])),
Arc::new(Int32Array::from(vec![0_i32])),
Arc::new(Float64Array::from(vec![Some(f64::NAN)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_hydro(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("evaporation_violation_cost"),
"field should contain 'evaporation_violation_cost', got: {field}"
);
assert!(
message.contains("finite"),
"message should contain 'finite', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_hydro_empty_parquet() {
let schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![] as Vec<i32>)),
Arc::new(Int32Array::from(vec![] as Vec<i32>)),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_hydro(tmp.path()).unwrap();
assert!(rows.is_empty());
}
#[test]
fn test_load_hydro_none() {
let rows = super::super::load_penalty_overrides_hydro(None).unwrap();
assert!(rows.is_empty());
}
#[test]
fn test_hydro_directional_columns_parsed() {
let schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new(
"water_withdrawal_violation_pos_cost",
DataType::Float64,
true,
),
Field::new(
"water_withdrawal_violation_neg_cost",
DataType::Float64,
true,
),
Field::new("evaporation_violation_pos_cost", DataType::Float64, true),
Field::new("evaporation_violation_neg_cost", DataType::Float64, true),
Field::new("inflow_nonnegativity_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![4_i32])),
Arc::new(Int32Array::from(vec![2_i32])),
Arc::new(Float64Array::from(vec![Some(120.0_f64)])),
Arc::new(Float64Array::from(vec![Some(80.0_f64)])),
Arc::new(Float64Array::from(vec![Some(55.0_f64)])),
Arc::new(Float64Array::from(vec![None::<f64>])),
Arc::new(Float64Array::from(vec![Some(200.0_f64)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_hydro(tmp.path()).unwrap();
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r.hydro_id, EntityId::from(4));
assert_eq!(r.stage_id, 2);
assert!(
(r.water_withdrawal_violation_pos_cost.unwrap() - 120.0).abs() < f64::EPSILON,
"water_withdrawal_violation_pos_cost must be 120.0"
);
assert!(
(r.water_withdrawal_violation_neg_cost.unwrap() - 80.0).abs() < f64::EPSILON,
"water_withdrawal_violation_neg_cost must be 80.0"
);
assert!(
(r.evaporation_violation_pos_cost.unwrap() - 55.0).abs() < f64::EPSILON,
"evaporation_violation_pos_cost must be 55.0"
);
assert!(
r.evaporation_violation_neg_cost.is_none(),
"evaporation_violation_neg_cost must be None (null in file)"
);
assert!(
(r.inflow_nonnegativity_cost.unwrap() - 200.0).abs() < f64::EPSILON,
"inflow_nonnegativity_cost must be 200.0"
);
assert!(r.spillage_cost.is_none());
assert!(r.water_withdrawal_violation_cost.is_none());
}
#[test]
fn test_hydro_directional_column_negative_rejected() {
let schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("inflow_nonnegativity_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![1_i32])),
Arc::new(Int32Array::from(vec![0_i32])),
Arc::new(Float64Array::from(vec![Some(-5.0_f64)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_hydro(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("inflow_nonnegativity_cost"),
"field should contain 'inflow_nonnegativity_cost', got: {field}"
);
assert!(
message.contains("must be > 0.0"),
"message should contain 'must be > 0.0', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_hydro_declaration_order_invariance() {
let schema = Arc::new(Schema::new(vec![
Field::new("hydro_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("spillage_cost", DataType::Float64, true),
]));
let batch_scrambled = RecordBatch::try_new(
schema.clone(),
vec![
Arc::new(Int32Array::from(vec![5_i32, 1_i32, 5_i32, 1_i32])),
Arc::new(Int32Array::from(vec![1_i32, 1_i32, 0_i32, 0_i32])),
Arc::new(Float64Array::from(vec![
Some(5.1_f64),
Some(1.1_f64),
Some(5.0_f64),
Some(1.0_f64),
])),
],
)
.unwrap();
let batch_asc = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![1_i32, 1_i32, 5_i32, 5_i32])),
Arc::new(Int32Array::from(vec![0_i32, 1_i32, 0_i32, 1_i32])),
Arc::new(Float64Array::from(vec![
Some(1.0_f64),
Some(1.1_f64),
Some(5.0_f64),
Some(5.1_f64),
])),
],
)
.unwrap();
let tmp_scrambled = write_parquet(&batch_scrambled);
let tmp_asc = write_parquet(&batch_asc);
let rows_scrambled = parse_penalty_overrides_hydro(tmp_scrambled.path()).unwrap();
let rows_asc = parse_penalty_overrides_hydro(tmp_asc.path()).unwrap();
let keys_s: Vec<(i32, i32)> = rows_scrambled
.iter()
.map(|r| (r.hydro_id.0, r.stage_id))
.collect();
let keys_a: Vec<(i32, i32)> = rows_asc
.iter()
.map(|r| (r.hydro_id.0, r.stage_id))
.collect();
assert_eq!(keys_s, keys_a);
}
fn ncs_schema() -> Arc<Schema> {
Arc::new(Schema::new(vec![
Field::new("ncs_id", DataType::Int32, false),
Field::new("stage_id", DataType::Int32, false),
Field::new("curtailment_cost", DataType::Float64, true),
]))
}
fn make_ncs_batch(
source_ids: &[i32],
stage_ids: &[i32],
curtailment_cost: Vec<Option<f64>>,
) -> RecordBatch {
RecordBatch::try_new(
ncs_schema(),
vec![
Arc::new(Int32Array::from(source_ids.to_vec())),
Arc::new(Int32Array::from(stage_ids.to_vec())),
Arc::new(Float64Array::from(curtailment_cost)),
],
)
.expect("valid batch")
}
#[test]
fn test_ncs_valid_3_rows_sorted() {
let batch = make_ncs_batch(&[2, 1, 1], &[0, 1, 0], vec![Some(300.0), Some(250.0), None]);
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_ncs(tmp.path()).unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].source_id, EntityId::from(1));
assert_eq!(rows[0].stage_id, 0);
assert!(rows[0].curtailment_cost.is_none());
assert_eq!(rows[1].source_id, EntityId::from(1));
assert_eq!(rows[1].stage_id, 1);
assert!((rows[1].curtailment_cost.unwrap() - 250.0).abs() < f64::EPSILON);
assert_eq!(rows[2].source_id, EntityId::from(2));
assert_eq!(rows[2].stage_id, 0);
assert!((rows[2].curtailment_cost.unwrap() - 300.0).abs() < f64::EPSILON);
}
#[test]
fn test_ncs_missing_id_column_errors_new_spelling() {
let schema = Arc::new(Schema::new(vec![
Field::new("stage_id", DataType::Int32, false),
Field::new("curtailment_cost", DataType::Float64, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(vec![0_i32])),
Arc::new(Float64Array::from(vec![Some(250.0_f64)])),
],
)
.unwrap();
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_ncs(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, .. } => {
assert_eq!(field, "ncs_id", "field should be 'ncs_id', got: {field}");
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_ncs_negative_curtailment_cost() {
let batch = make_ncs_batch(&[1], &[0], vec![Some(-1.0)]);
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_ncs(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("curtailment_cost"),
"field should contain 'curtailment_cost', got: {field}"
);
assert!(
message.contains("must be > 0.0"),
"message should contain 'must be > 0.0', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_ncs_nan_curtailment_cost() {
let batch = make_ncs_batch(&[1], &[0], vec![Some(f64::NAN)]);
let tmp = write_parquet(&batch);
let err = parse_penalty_overrides_ncs(tmp.path()).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("curtailment_cost"),
"field should contain 'curtailment_cost', got: {field}"
);
assert!(
message.contains("finite"),
"message should contain 'finite', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_ncs_empty_parquet() {
let batch = make_ncs_batch(&[], &[], vec![]);
let tmp = write_parquet(&batch);
let rows = parse_penalty_overrides_ncs(tmp.path()).unwrap();
assert!(rows.is_empty());
}
#[test]
fn test_load_ncs_none() {
let rows = super::super::load_penalty_overrides_ncs(None).unwrap();
assert!(rows.is_empty());
}
}