diff --git a/schemas/input/agent_commodity_portions.yaml b/schemas/input/agent_commodity_portions.yaml index f29d4ea78..87cc26f31 100644 --- a/schemas/input/agent_commodity_portions.yaml +++ b/schemas/input/agent_commodity_portions.yaml @@ -20,7 +20,10 @@ fields: - name: years type: string description: The year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: commodity_portion type: number description: Portion of commodity demand diff --git a/schemas/input/agent_cost_limits.yaml b/schemas/input/agent_cost_limits.yaml index 6c44fd5b0..e40a1b892 100644 --- a/schemas/input/agent_cost_limits.yaml +++ b/schemas/input/agent_cost_limits.yaml @@ -12,7 +12,10 @@ fields: - name: years type: string description: The year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: capex_limit type: number description: Maximum capital cost the agent will pay diff --git a/schemas/input/agent_objectives.yaml b/schemas/input/agent_objectives.yaml index 6ba5aa336..ffe5a7e6e 100644 --- a/schemas/input/agent_objectives.yaml +++ b/schemas/input/agent_objectives.yaml @@ -16,7 +16,10 @@ fields: - name: years type: string description: The year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: objective_type type: string enum: [lcox, npv] diff --git a/schemas/input/agent_search_space.yaml b/schemas/input/agent_search_space.yaml index a5b9aaf48..f7677bb34 100644 --- a/schemas/input/agent_search_space.yaml +++ b/schemas/input/agent_search_space.yaml @@ -16,7 +16,10 @@ fields: - name: years type: string description: The year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: search_space type: string description: The processes in which this agent will invest diff --git a/schemas/input/commodity_levies.yaml b/schemas/input/commodity_levies.yaml index ad0103d57..7dcd07caf 100644 --- a/schemas/input/commodity_levies.yaml +++ b/schemas/input/commodity_levies.yaml @@ -20,7 +20,10 @@ fields: - name: years type: string description: The year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: time_slice type: string description: The time slices(s) to which this entry applies diff --git a/schemas/input/demand.yaml b/schemas/input/demand.yaml index 818e9f81e..5b6382cd6 100644 --- a/schemas/input/demand.yaml +++ b/schemas/input/demand.yaml @@ -17,7 +17,10 @@ fields: - name: year type: string description: The year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: demand type: number description: Total demand for this year diff --git a/schemas/input/process_availabilities.yaml b/schemas/input/process_availabilities.yaml index d9f7378f3..b4e29cbd5 100644 --- a/schemas/input/process_availabilities.yaml +++ b/schemas/input/process_availabilities.yaml @@ -17,7 +17,10 @@ fields: - name: years type: string description: The milestone year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: time_slice type: string description: The time slices(s) to which this entry applies diff --git a/schemas/input/process_flows.yaml b/schemas/input/process_flows.yaml index 33b1262df..7e8bc922d 100644 --- a/schemas/input/process_flows.yaml +++ b/schemas/input/process_flows.yaml @@ -23,7 +23,10 @@ fields: - name: years type: string description: The year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: coeff type: number description: The flow for this commodity diff --git a/schemas/input/process_parameters.yaml b/schemas/input/process_parameters.yaml index fc5590703..93d839bd4 100644 --- a/schemas/input/process_parameters.yaml +++ b/schemas/input/process_parameters.yaml @@ -19,7 +19,10 @@ fields: - name: years type: string description: The milestone year(s) to which this entry applies - notes: One or more milestone years separated by semicolons or `all` + notes: One or more milestone years separated by semicolons, `all` to select all years or a year + range in the form 'start..end' to select all valid years within range, inclusive. Either 'start' + 'end' or both can be omitted, which will set the corresponding limit to the minimum or maximum + valid year, respectively. - name: capital_cost type: number description: Overnight capital cost per unit capacity diff --git a/src/year.rs b/src/year.rs index 325b71274..aeeb5f3ba 100644 --- a/src/year.rs +++ b/src/year.rs @@ -44,12 +44,22 @@ pub fn parse_year_str(s: &str, valid_years: &[u32]) -> Result> { return Ok(Vec::from_iter(valid_years.iter().copied())); } - let years: Vec<_> = s - .split(';') - .map(|y| { - parse_and_validate_year(y, valid_years).with_context(|| format!("Invalid year: {y}")) - }) - .try_collect()?; + ensure!( + !(s.contains(';') && s.contains("..")), + "Both ';' and '..' found in year string {s}. Discrete years and ranges cannot be mixed." + ); + + // We first process ranges + let years: Vec<_> = if s.contains("..") { + parse_years_range(s, valid_years)? + } else { + s.split(';') + .map(|y| { + parse_and_validate_year(y, valid_years) + .with_context(|| format!("Invalid year: {y}")) + }) + .try_collect()? + }; ensure!( is_sorted_and_unique(&years), @@ -59,6 +69,52 @@ pub fn parse_year_str(s: &str, valid_years: &[u32]) -> Result> { Ok(years) } +/// Parse a year string that is defined as a range, selecting the valid years within that range. +/// +/// It should be of the form start..end. If either of the limits are omitted, they will default to +/// the first and last years of the `valid_years`. If both limits are missing, this is equivalent to +/// passing all. +fn parse_years_range(s: &str, valid_years: &[u32]) -> Result> { + // Require exactly one ".." separator so only forms start..end, start.. or ..end are allowed. + let parts: Vec<&str> = s.split("..").collect(); + ensure!( + parts.len() == 2, + "Year range must be of the form 'start..end', 'start..' or '..end'. Invalid: {s}" + ); + let left = parts[0].trim(); + let right = parts[1].trim(); + + // If the range start is open, we assign the first valid year + let start = if left.is_empty() { + valid_years[0] + } else { + left.parse::() + .ok() + .with_context(|| format!("Invalid start year in range: {left}"))? + }; + + // If the range end is open, we assign the last valid year + let end = if right.is_empty() { + *valid_years.last().unwrap() + } else { + right + .parse::() + .ok() + .with_context(|| format!("Invalid end year in range: {right}"))? + }; + + ensure!( + end > start, + "End year must be biger than start year in range {s}" + ); + let years: Vec<_> = (start..=end).filter(|y| valid_years.contains(y)).collect(); + ensure!( + !years.is_empty(), + "No valid years found in year range string {s}" + ); + Ok(years) +} + #[cfg(test)] mod tests { use super::*; @@ -72,6 +128,10 @@ mod tests { #[case(" ALL ", &[2020, 2021], &[2020,2021])] #[case("2020;2021", &[2020, 2021], &[2020,2021])] #[case(" 2020; 2021", &[2020, 2021], &[2020,2021])] // whitespace should be stripped + #[case("2019..2026", &[2020,2025], &[2020,2025])] + #[case("..2023", &[2020,2025], &[2020])] // Empty start + #[case("2021..", &[2020,2025], &[2025])] // Empty end + #[case("..", &[2020,2025], &[2020,2025])] fn test_parse_year_str_valid( #[case] input: &str, #[case] milestone_years: &[u32], @@ -86,6 +146,11 @@ mod tests { #[case("a;2020", &[2020], "Invalid year: a")] #[case("2021;2020", &[2020, 2021],"Years must be in order and unique")] // out of order #[case("2021;2020;2021", &[2020, 2021],"Years must be in order and unique")] // duplicate + #[case("2021;2020..2021", &[2020, 2021],"Both ';' and '..' found in year string 2021;2020..2021. Discrete years and ranges cannot be mixed.")] + #[case("2021..2020", &[2020, 2021],"End year must be biger than start year in range 2021..2020")] // out of order + #[case("2021..2024", &[2020,2025], "No valid years found in year range string 2021..2024")] + #[case("..2020..2025", &[2020,2025], "Year range must be of the form 'start..end', 'start..' or '..end'. Invalid: ..2020..2025")] + #[case("2020...2025", &[2020,2025], "Invalid end year in range: .2025")] fn test_parse_year_str_invalid( #[case] input: &str, #[case] milestone_years: &[u32],