use cobre_core::{
EntityId,
entities::Line,
penalty::{GlobalPenaltyDefaults, resolve_line_exchange_cost},
};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use super::parse_operational_start_date;
use crate::LoadError;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub(crate) struct RawLineFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
lines: Vec<RawLine>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct RawLine {
id: i32,
name: String,
operational_start_date: String,
source_bus_id: i32,
target_bus_id: i32,
#[serde(default)]
entry_stage_id: Option<i32>,
#[serde(default)]
exit_stage_id: Option<i32>,
capacity: RawLineCapacity,
#[serde(default)]
exchange_cost: Option<f64>,
#[serde(default)]
losses_percent: f64,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct RawLineCapacity {
direct_mw: f64,
reverse_mw: f64,
}
pub fn parse_lines(
path: &Path,
global_penalties: &GlobalPenaltyDefaults,
) -> Result<Vec<Line>, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawLineFile =
serde_json::from_str(&raw_text).map_err(|e| LoadError::parse(path, e.to_string()))?;
validate_raw_lines(&raw, path)?;
convert_lines(raw, global_penalties, path)
}
fn validate_raw_lines(raw: &RawLineFile, path: &Path) -> Result<(), LoadError> {
validate_no_duplicate_line_ids(&raw.lines, path)?;
for (i, line) in raw.lines.iter().enumerate() {
validate_line_capacity(&line.capacity, i, path)?;
validate_losses_percent(line.losses_percent, i, path)?;
}
Ok(())
}
fn validate_no_duplicate_line_ids(lines: &[RawLine], path: &Path) -> Result<(), LoadError> {
let mut seen: HashSet<i32> = HashSet::new();
for (i, line) in lines.iter().enumerate() {
if !seen.insert(line.id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("lines[{i}].id"),
message: format!("duplicate id {} in lines array", line.id),
});
}
}
Ok(())
}
fn validate_line_capacity(
capacity: &RawLineCapacity,
line_index: usize,
path: &Path,
) -> Result<(), LoadError> {
if capacity.direct_mw < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("lines[{line_index}].capacity.direct_mw"),
message: format!("direct_mw must be >= 0.0, got {}", capacity.direct_mw),
});
}
if capacity.reverse_mw < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("lines[{line_index}].capacity.reverse_mw"),
message: format!("reverse_mw must be >= 0.0, got {}", capacity.reverse_mw),
});
}
Ok(())
}
fn validate_losses_percent(
losses_percent: f64,
line_index: usize,
path: &Path,
) -> Result<(), LoadError> {
if losses_percent < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("lines[{line_index}].losses_percent"),
message: format!("losses_percent must be >= 0.0, got {losses_percent}"),
});
}
Ok(())
}
fn convert_lines(
raw: RawLineFile,
global: &GlobalPenaltyDefaults,
path: &Path,
) -> Result<Vec<Line>, LoadError> {
let mut lines: Vec<Line> = raw
.lines
.into_iter()
.enumerate()
.map(|(i, raw_line)| {
let operational_start_date = parse_operational_start_date(
&raw_line.operational_start_date,
path,
&format!("lines[{i}].operational_start_date"),
)?;
let exchange_cost = resolve_line_exchange_cost(raw_line.exchange_cost, global);
Ok(Line {
id: EntityId(raw_line.id),
name: raw_line.name,
operational_start_date,
source_bus_id: EntityId(raw_line.source_bus_id),
target_bus_id: EntityId(raw_line.target_bus_id),
entry_stage_id: raw_line.entry_stage_id,
exit_stage_id: raw_line.exit_stage_id,
direct_capacity_mw: raw_line.capacity.direct_mw,
reverse_capacity_mw: raw_line.capacity.reverse_mw,
losses_percent: raw_line.losses_percent,
exchange_cost,
})
})
.collect::<Result<_, LoadError>>()?;
lines.sort_by_key(|l| l.id.0);
Ok(lines)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::too_many_lines)]
mod tests {
use super::*;
use cobre_core::entities::{DeficitSegment, HydroPenalties};
use std::io::Write;
use tempfile::NamedTempFile;
fn write_json(content: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
fn make_global() -> GlobalPenaltyDefaults {
GlobalPenaltyDefaults {
bus_deficit_segments: vec![
DeficitSegment {
depth_mw: Some(500.0),
cost_per_mwh: 1000.0,
},
DeficitSegment {
depth_mw: None,
cost_per_mwh: 5000.0,
},
],
bus_excess_cost: 100.0,
line_exchange_cost: 2.0,
hydro: HydroPenalties {
spillage_cost: 0.01,
turbined_cost: 0.05,
diversion_cost: 0.1,
storage_violation_below_cost: 10_000.0,
filling_target_violation_cost: 50_000.0,
turbined_violation_below_cost: 500.0,
outflow_violation_below_cost: 500.0,
outflow_violation_above_cost: 500.0,
generation_violation_below_cost: 1_000.0,
evaporation_violation_cost: 5_000.0,
water_withdrawal_violation_cost: 1_000.0,
water_withdrawal_violation_pos_cost: 1_000.0,
water_withdrawal_violation_neg_cost: 1_000.0,
evaporation_violation_pos_cost: 5_000.0,
evaporation_violation_neg_cost: 5_000.0,
inflow_nonnegativity_cost: 1000.0,
},
ncs_curtailment_cost: 0.005,
}
}
#[test]
fn test_parse_valid_lines() {
let json = r#"{
"$schema": "https://raw.githubusercontent.com/cobre-rs/cobre/refs/heads/main/schemas/lines.schema.json",
"lines": [
{
"id": 0,
"name": "SE-S",
"operational_start_date": "2024-01-01",
"source_bus_id": 0,
"target_bus_id": 1,
"capacity": { "direct_mw": 2500.0, "reverse_mw": 2000.0 },
"exchange_cost": 1.5,
"losses_percent": 0.5
},
{
"id": 1,
"name": "SE-NE",
"operational_start_date": "2024-01-01",
"source_bus_id": 0,
"target_bus_id": 2,
"entry_stage_id": 1,
"exit_stage_id": 60,
"capacity": { "direct_mw": 4000.0, "reverse_mw": 3500.0 }
}
]
}"#;
let f = write_json(json);
let global = make_global();
let lines = parse_lines(f.path(), &global).unwrap();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].id, EntityId(0));
assert_eq!(lines[0].name, "SE-S");
assert_eq!(lines[0].source_bus_id, EntityId(0));
assert_eq!(lines[0].target_bus_id, EntityId(1));
assert_eq!(lines[0].entry_stage_id, None);
assert_eq!(lines[0].exit_stage_id, None);
assert!((lines[0].direct_capacity_mw - 2500.0).abs() < f64::EPSILON);
assert!((lines[0].reverse_capacity_mw - 2000.0).abs() < f64::EPSILON);
assert!((lines[0].losses_percent - 0.5).abs() < f64::EPSILON);
assert!(
(lines[0].exchange_cost - 1.5).abs() < f64::EPSILON,
"expected entity-level exchange_cost 1.5, got {}",
lines[0].exchange_cost
);
assert_eq!(lines[1].id, EntityId(1));
assert_eq!(lines[1].name, "SE-NE");
assert_eq!(lines[1].entry_stage_id, Some(1));
assert_eq!(lines[1].exit_stage_id, Some(60));
assert!((lines[1].direct_capacity_mw - 4000.0).abs() < f64::EPSILON);
assert!((lines[1].reverse_capacity_mw - 3500.0).abs() < f64::EPSILON);
assert!((lines[1].losses_percent - 0.0).abs() < f64::EPSILON);
assert!(
(lines[1].exchange_cost - 2.0).abs() < f64::EPSILON,
"expected global exchange_cost 2.0, got {}",
lines[1].exchange_cost
);
}
#[test]
fn test_duplicate_line_id() {
let json = r#"{
"lines": [
{
"id": 5, "name": "Alpha", "operational_start_date": "2024-01-01", "source_bus_id": 0, "target_bus_id": 1,
"capacity": { "direct_mw": 100.0, "reverse_mw": 100.0 }
},
{
"id": 5, "name": "Beta", "operational_start_date": "2024-01-01", "source_bus_id": 1, "target_bus_id": 2,
"capacity": { "direct_mw": 200.0, "reverse_mw": 200.0 }
}
]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_lines(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("lines[1].id"),
"field should contain 'lines[1].id', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should contain 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_negative_direct_mw() {
let json = r#"{
"lines": [
{
"id": 0, "name": "Alpha", "operational_start_date": "2024-01-01", "source_bus_id": 0, "target_bus_id": 1,
"capacity": { "direct_mw": -100.0, "reverse_mw": 100.0 }
}
]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_lines(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("direct_mw"),
"field should contain 'direct_mw', got: {field}"
);
assert!(
message.contains(">= 0.0"),
"message should mention >= 0.0, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_negative_reverse_mw() {
let json = r#"{
"lines": [
{
"id": 0, "name": "Alpha", "operational_start_date": "2024-01-01", "source_bus_id": 0, "target_bus_id": 1,
"capacity": { "direct_mw": 100.0, "reverse_mw": -50.0 }
}
]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_lines(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("reverse_mw"),
"field should contain 'reverse_mw', got: {field}"
);
assert!(
message.contains(">= 0.0"),
"message should mention >= 0.0, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_negative_losses_percent() {
let json = r#"{
"lines": [
{
"id": 0, "name": "Alpha", "operational_start_date": "2024-01-01", "source_bus_id": 0, "target_bus_id": 1,
"capacity": { "direct_mw": 100.0, "reverse_mw": 100.0 },
"losses_percent": -1.0
}
]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_lines(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("losses_percent"),
"field should contain 'losses_percent', got: {field}"
);
assert!(
message.contains(">= 0.0"),
"message should mention >= 0.0, got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_declaration_order_invariance() {
let json_forward = r#"{
"lines": [
{
"id": 0, "name": "Alpha", "operational_start_date": "2024-01-01", "source_bus_id": 0, "target_bus_id": 1,
"capacity": { "direct_mw": 100.0, "reverse_mw": 100.0 }
},
{
"id": 1, "name": "Beta", "operational_start_date": "2024-01-01", "source_bus_id": 1, "target_bus_id": 2,
"capacity": { "direct_mw": 200.0, "reverse_mw": 200.0 }
}
]
}"#;
let json_reversed = r#"{
"lines": [
{
"id": 1, "name": "Beta", "operational_start_date": "2024-01-01", "source_bus_id": 1, "target_bus_id": 2,
"capacity": { "direct_mw": 200.0, "reverse_mw": 200.0 }
},
{
"id": 0, "name": "Alpha", "operational_start_date": "2024-01-01", "source_bus_id": 0, "target_bus_id": 1,
"capacity": { "direct_mw": 100.0, "reverse_mw": 100.0 }
}
]
}"#;
let global = make_global();
let f1 = write_json(json_forward);
let f2 = write_json(json_reversed);
let lines1 = parse_lines(f1.path(), &global).unwrap();
let lines2 = parse_lines(f2.path(), &global).unwrap();
assert_eq!(
lines1, lines2,
"results must be identical regardless of input ordering"
);
assert_eq!(lines1[0].id, EntityId(0));
assert_eq!(lines1[1].id, EntityId(1));
}
#[test]
fn test_file_not_found() {
let path = Path::new("/nonexistent/system/lines.json");
let global = make_global();
let err = parse_lines(path, &global).unwrap_err();
match &err {
LoadError::IoError { path: p, .. } => {
assert_eq!(p, path);
}
other => panic!("expected IoError, got: {other:?}"),
}
}
#[test]
fn test_invalid_json() {
let f = write_json(r#"{"lines": [not valid json}}"#);
let global = make_global();
let err = parse_lines(f.path(), &global).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for invalid JSON, got: {err:?}"
);
}
#[test]
fn test_empty_lines_array() {
let json = r#"{ "lines": [] }"#;
let f = write_json(json);
let global = make_global();
let lines = parse_lines(f.path(), &global).unwrap();
assert!(lines.is_empty());
}
#[test]
fn test_losses_percent_defaults_to_zero() {
let json = r#"{
"lines": [
{
"id": 0, "name": "Alpha", "operational_start_date": "2024-01-01", "source_bus_id": 0, "target_bus_id": 1,
"capacity": { "direct_mw": 100.0, "reverse_mw": 100.0 }
}
]
}"#;
let f = write_json(json);
let global = make_global();
let lines = parse_lines(f.path(), &global).unwrap();
assert!(
(lines[0].losses_percent - 0.0).abs() < f64::EPSILON,
"losses_percent should default to 0.0, got {}",
lines[0].losses_percent
);
}
#[test]
fn test_zero_capacity_is_valid() {
let json = r#"{
"lines": [
{
"id": 0, "name": "Alpha", "operational_start_date": "2024-01-01", "source_bus_id": 0, "target_bus_id": 1,
"capacity": { "direct_mw": 0.0, "reverse_mw": 0.0 }
}
]
}"#;
let f = write_json(json);
let global = make_global();
let result = parse_lines(f.path(), &global);
assert!(
result.is_ok(),
"zero-capacity line should be valid, got: {result:?}"
);
}
}