use cobre_core::{
EntityId,
entities::NonControllableSource,
penalty::{GlobalPenaltyDefaults, resolve_ncs_curtailment_cost},
};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path;
use super::parse_operational_start_date;
use crate::LoadError;
fn default_allow_curtailment() -> bool {
true
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct RawNcsFile {
#[serde(rename = "$schema")]
_schema: Option<String>,
non_controllable_sources: Vec<RawNcs>,
}
#[derive(Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub(crate) struct RawNcs {
id: i32,
name: String,
operational_start_date: String,
bus_id: i32,
#[serde(default)]
entry_stage_id: Option<i32>,
#[serde(default)]
exit_stage_id: Option<i32>,
max_generation_mw: f64,
#[serde(default = "default_allow_curtailment")]
allow_curtailment: bool,
#[serde(default)]
curtailment_cost: Option<f64>,
}
pub fn parse_non_controllable_sources(
path: &Path,
global_penalties: &GlobalPenaltyDefaults,
) -> Result<Vec<NonControllableSource>, LoadError> {
let raw_text = std::fs::read_to_string(path).map_err(|e| LoadError::io(path, e))?;
let raw: RawNcsFile =
serde_json::from_str(&raw_text).map_err(|e| LoadError::parse(path, e.to_string()))?;
validate_raw_ncs(&raw, path)?;
convert_ncs(raw, global_penalties, path)
}
fn validate_raw_ncs(raw: &RawNcsFile, path: &Path) -> Result<(), LoadError> {
validate_no_duplicate_ncs_ids(&raw.non_controllable_sources, path)?;
for (i, ncs) in raw.non_controllable_sources.iter().enumerate() {
validate_ncs_generation(ncs.max_generation_mw, i, path)?;
}
Ok(())
}
fn validate_no_duplicate_ncs_ids(sources: &[RawNcs], path: &Path) -> Result<(), LoadError> {
let mut seen: HashSet<i32> = HashSet::new();
for (i, ncs) in sources.iter().enumerate() {
if !seen.insert(ncs.id) {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("non_controllable_sources[{i}].id"),
message: format!("duplicate id {} in non_controllable_sources array", ncs.id),
});
}
}
Ok(())
}
fn validate_ncs_generation(
max_generation_mw: f64,
ncs_index: usize,
path: &Path,
) -> Result<(), LoadError> {
if max_generation_mw < 0.0 {
return Err(LoadError::SchemaError {
path: path.to_path_buf(),
field: format!("non_controllable_sources[{ncs_index}].max_generation_mw"),
message: format!("max_generation_mw must be >= 0.0, got {max_generation_mw}"),
});
}
Ok(())
}
fn convert_ncs(
raw: RawNcsFile,
global: &GlobalPenaltyDefaults,
path: &Path,
) -> Result<Vec<NonControllableSource>, LoadError> {
let mut sources: Vec<NonControllableSource> = raw
.non_controllable_sources
.into_iter()
.enumerate()
.map(|(i, raw_ncs)| {
let operational_start_date = parse_operational_start_date(
&raw_ncs.operational_start_date,
path,
&format!("non_controllable_sources[{i}].operational_start_date"),
)?;
let curtailment_cost = resolve_ncs_curtailment_cost(raw_ncs.curtailment_cost, global);
Ok(NonControllableSource {
id: EntityId(raw_ncs.id),
name: raw_ncs.name,
operational_start_date,
bus_id: EntityId(raw_ncs.bus_id),
entry_stage_id: raw_ncs.entry_stage_id,
exit_stage_id: raw_ncs.exit_stage_id,
max_generation_mw: raw_ncs.max_generation_mw,
allow_curtailment: raw_ncs.allow_curtailment,
curtailment_cost,
})
})
.collect::<Result<_, LoadError>>()?;
sources.sort_by_key(|s| s.id.0);
Ok(sources)
}
#[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_ncs() {
let json = r#"{
"non_controllable_sources": [
{
"id": 0,
"name": "Eólica Caetité",
"operational_start_date": "2024-01-01",
"bus_id": 2,
"max_generation_mw": 300.0,
"curtailment_cost": 0.01
},
{
"id": 1,
"name": "Solar Pirapora",
"operational_start_date": "2024-01-01",
"bus_id": 3,
"entry_stage_id": 12,
"max_generation_mw": 400.0
}
]
}"#;
let f = write_json(json);
let global = make_global();
let sources = parse_non_controllable_sources(f.path(), &global).unwrap();
assert_eq!(sources.len(), 2);
assert_eq!(sources[0].id, EntityId(0));
assert_eq!(sources[0].name, "Eólica Caetité");
assert_eq!(sources[0].bus_id, EntityId(2));
assert_eq!(sources[0].entry_stage_id, None);
assert_eq!(sources[0].exit_stage_id, None);
assert!((sources[0].max_generation_mw - 300.0).abs() < f64::EPSILON);
assert!(
(sources[0].curtailment_cost - 0.01).abs() < f64::EPSILON,
"expected entity curtailment_cost 0.01, got {}",
sources[0].curtailment_cost
);
assert_eq!(sources[1].id, EntityId(1));
assert_eq!(sources[1].name, "Solar Pirapora");
assert_eq!(sources[1].entry_stage_id, Some(12));
assert!((sources[1].max_generation_mw - 400.0).abs() < f64::EPSILON);
assert!(
(sources[1].curtailment_cost - 0.005).abs() < f64::EPSILON,
"expected global curtailment_cost 0.005, got {}",
sources[1].curtailment_cost
);
assert!(sources[0].allow_curtailment);
assert!(sources[1].allow_curtailment);
}
#[test]
fn test_parse_allow_curtailment() {
let json = r#"{
"non_controllable_sources": [
{
"id": 0,
"name": "PCH NE Aggregate",
"operational_start_date": "2024-01-01",
"bus_id": 0,
"max_generation_mw": 120.0,
"allow_curtailment": false
},
{
"id": 1,
"name": "Wind NE Partial",
"operational_start_date": "2024-01-01",
"bus_id": 0,
"max_generation_mw": 300.0,
"allow_curtailment": true
}
]
}"#;
let f = write_json(json);
let global = make_global();
let sources = parse_non_controllable_sources(f.path(), &global).unwrap();
assert!(!sources[0].allow_curtailment, "must-run source");
assert!(sources[1].allow_curtailment, "curtailable source");
}
#[test]
fn test_duplicate_ncs_id() {
let json = r#"{
"non_controllable_sources": [
{ "id": 3, "name": "Alpha", "operational_start_date": "2024-01-01", "bus_id": 0, "max_generation_mw": 100.0 },
{ "id": 3, "name": "Beta", "operational_start_date": "2024-01-01", "bus_id": 1, "max_generation_mw": 200.0 }
]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_non_controllable_sources(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("non_controllable_sources[1].id"),
"field should contain 'non_controllable_sources[1].id', got: {field}"
);
assert!(
message.contains("duplicate"),
"message should contain 'duplicate', got: {message}"
);
}
other => panic!("expected SchemaError, got: {other:?}"),
}
}
#[test]
fn test_negative_max_generation_mw() {
let json = r#"{
"non_controllable_sources": [
{ "id": 0, "name": "Bad", "operational_start_date": "2024-01-01", "bus_id": 0, "max_generation_mw": -10.0 }
]
}"#;
let f = write_json(json);
let global = make_global();
let err = parse_non_controllable_sources(f.path(), &global).unwrap_err();
match &err {
LoadError::SchemaError { field, message, .. } => {
assert!(
field.contains("max_generation_mw"),
"field should contain 'max_generation_mw', 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#"{
"non_controllable_sources": [
{ "id": 0, "name": "Wind", "operational_start_date": "2024-01-01", "bus_id": 0, "max_generation_mw": 100.0 },
{ "id": 1, "name": "Solar", "operational_start_date": "2024-01-01", "bus_id": 1, "max_generation_mw": 200.0 }
]
}"#;
let json_reversed = r#"{
"non_controllable_sources": [
{ "id": 1, "name": "Solar", "operational_start_date": "2024-01-01", "bus_id": 1, "max_generation_mw": 200.0 },
{ "id": 0, "name": "Wind", "operational_start_date": "2024-01-01", "bus_id": 0, "max_generation_mw": 100.0 }
]
}"#;
let global = make_global();
let f1 = write_json(json_forward);
let f2 = write_json(json_reversed);
let sources1 = parse_non_controllable_sources(f1.path(), &global).unwrap();
let sources2 = parse_non_controllable_sources(f2.path(), &global).unwrap();
assert_eq!(
sources1, sources2,
"results must be identical regardless of input ordering"
);
assert_eq!(sources1[0].id, EntityId(0));
assert_eq!(sources1[1].id, EntityId(1));
}
#[test]
fn test_file_not_found() {
let path = Path::new("/nonexistent/system/non_controllable_sources.json");
let global = make_global();
let err = parse_non_controllable_sources(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#"{"non_controllable_sources": [not valid json}}"#);
let global = make_global();
let err = parse_non_controllable_sources(f.path(), &global).unwrap_err();
assert!(
matches!(err, LoadError::ParseError { .. }),
"expected ParseError for invalid JSON, got: {err:?}"
);
}
#[test]
fn test_empty_ncs_array() {
let json = r#"{ "non_controllable_sources": [] }"#;
let f = write_json(json);
let global = make_global();
let sources = parse_non_controllable_sources(f.path(), &global).unwrap();
assert!(sources.is_empty());
}
}