Skip to content
Merged
5 changes: 4 additions & 1 deletion schemas/input/agent_commodity_portions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion schemas/input/agent_cost_limits.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion schemas/input/agent_objectives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
5 changes: 4 additions & 1 deletion schemas/input/agent_search_space.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion schemas/input/commodity_levies.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion schemas/input/demand.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 4 additions & 1 deletion schemas/input/process_availabilities.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion schemas/input/process_flows.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion schemas/input/process_parameters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 71 additions & 6 deletions src/year.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,22 @@ pub fn parse_year_str(s: &str, valid_years: &[u32]) -> Result<Vec<u32>> {
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),
Expand All @@ -59,6 +69,52 @@ pub fn parse_year_str(s: &str, valid_years: &[u32]) -> Result<Vec<u32>> {
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<Vec<u32>> {
// 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::<u32>()
.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::<u32>()
.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::*;
Expand All @@ -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],
Expand All @@ -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],
Expand Down