Skip to content

Commit d1dfee1

Browse files
authored
Merge pull request #570 from EnergySystemsModellingLab/fix-parse-year-str
`parse_year_str`: Handle out-of-order and duplicate years
2 parents 0ae972a + 2b57b22 commit d1dfee1

5 files changed

Lines changed: 114 additions & 26 deletions

File tree

src/input.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ where
138138
Ok(())
139139
}
140140

141+
/// Check whether an iterator contains values that are sorted and unique
142+
pub fn is_sorted_and_unique<T, I>(iter: I) -> bool
143+
where
144+
T: PartialOrd + Clone,
145+
I: IntoIterator<Item = T>,
146+
{
147+
iter.into_iter().tuple_windows().all(|(a, b)| a < b)
148+
}
149+
141150
/// Inserts a key-value pair into a HashMap if the key does not already exist.
142151
///
143152
/// If the key already exists, it returns an error with a message indicating the key's existence.
@@ -200,9 +209,9 @@ pub fn load_model<P: AsRef<Path>>(model_dir: P) -> Result<(Model, AssetPool)> {
200209

201210
#[cfg(test)]
202211
mod tests {
203-
use crate::id::GenericID;
204-
205212
use super::*;
213+
use crate::id::GenericID;
214+
use rstest::rstest;
206215
use serde::de::value::{Error as ValueError, F64Deserializer};
207216
use serde::de::IntoDeserializer;
208217
use serde::Deserialize;
@@ -324,4 +333,16 @@ mod tests {
324333
assert!(check_fractions_sum_to_one([f64::INFINITY].into_iter()).is_err());
325334
assert!(check_fractions_sum_to_one([f64::NAN].into_iter()).is_err());
326335
}
336+
337+
#[rstest]
338+
#[case(&[], true)]
339+
#[case(&[1], true)]
340+
#[case(&[1,2], true)]
341+
#[case(&[1,2,3,4], true)]
342+
#[case(&[2,1],false)]
343+
#[case(&[1,1],false)]
344+
#[case(&[1,3,2,4], false)]
345+
fn test_is_sorted_and_unique(#[case] values: &[u32], #[case] expected: bool) {
346+
assert_eq!(is_sorted_and_unique(values), expected)
347+
}
327348
}

src/input/process/availability.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,15 @@ where
115115
.with_context(|| format!("Process {id} not found"))?;
116116

117117
// Get regions
118-
let process_regions = process.regions.clone();
118+
let process_regions = &process.regions;
119119
let record_regions =
120-
parse_region_str(&record.regions, &process_regions).with_context(|| {
120+
parse_region_str(&record.regions, process_regions).with_context(|| {
121121
format!("Invalid region for process {id}. Valid regions are {process_regions:?}")
122122
})?;
123123

124124
// Get years
125-
let process_years = process.years.clone();
126-
let record_years = parse_year_str(&record.years, &process_years).with_context(|| {
125+
let process_years = &process.years;
126+
let record_years = parse_year_str(&record.years, process_years).with_context(|| {
127127
format!("Invalid year for process {id}. Valid years are {process_years:?}")
128128
})?;
129129

src/input/process/flow.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,15 @@ where
8888
.with_context(|| format!("Process {id} not found"))?;
8989

9090
// Get regions
91-
let process_regions = process.regions.clone();
91+
let process_regions = &process.regions;
9292
let record_regions =
93-
parse_region_str(&record.regions, &process_regions).with_context(|| {
93+
parse_region_str(&record.regions, process_regions).with_context(|| {
9494
format!("Invalid region for process {id}. Valid regions are {process_regions:?}")
9595
})?;
9696

9797
// Get years
98-
let process_years = process.years.clone();
99-
let record_years = parse_year_str(&record.years, &process_years).with_context(|| {
98+
let process_years = &process.years;
99+
let record_years = parse_year_str(&record.years, process_years).with_context(|| {
100100
format!("Invalid year for process {id}. Valid years are {process_years:?}")
101101
})?;
102102

src/model.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! The model represents the static input data provided by the user.
22
use crate::agent::AgentMap;
33
use crate::commodity::CommodityMap;
4-
use crate::input::{input_err_msg, read_toml};
4+
use crate::input::{input_err_msg, is_sorted_and_unique, read_toml};
55
use crate::process::ProcessMap;
66
use crate::region::{RegionID, RegionMap};
77
use crate::time_slice::TimeSliceInfo;
@@ -54,10 +54,7 @@ fn check_milestone_years(years: &[u32]) -> Result<()> {
5454
ensure!(!years.is_empty(), "`milestone_years` is empty");
5555

5656
ensure!(
57-
years[..years.len() - 1]
58-
.iter()
59-
.zip(years[1..].iter())
60-
.all(|(y1, y2)| y1 < y2),
57+
is_sorted_and_unique(years),
6158
"`milestone_years` must be composed of unique values in order"
6259
);
6360

src/year.rs

Lines changed: 81 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,96 @@
11
//! Code for working with years.
2-
use anyhow::{ensure, Result};
2+
use crate::input::is_sorted_and_unique;
3+
use anyhow::{ensure, Context, Result};
4+
use itertools::Itertools;
5+
6+
/// Parse a single year from a string and check it is in `valid_years`
7+
fn parse_and_validate_year(s: &str, valid_years: &[u32]) -> Option<u32> {
8+
let year = s.trim().parse::<u32>().ok()?;
9+
if valid_years.binary_search(&year).is_ok() {
10+
Some(year)
11+
} else {
12+
None
13+
}
14+
}
315

416
/// Parse a string of years separated by semicolons into a vector of u32 years.
517
///
618
/// The string can be either "all" (case-insensitive), a single year, or a semicolon-separated list
719
/// of years (e.g. "2020;2021;2022" or "2020; 2021; 2022")
8-
pub fn parse_year_str(s: &str, milestone_years: &[u32]) -> Result<Vec<u32>> {
20+
///
21+
/// # Arguments
22+
///
23+
/// - `s` - Input string to parse
24+
/// - `valid_years` - The possible years which can be referenced in `s` (must be sorted and unique)
25+
///
26+
/// # Returns
27+
///
28+
/// A [`Vec`] of years or an error.
29+
///
30+
/// # Panics
31+
///
32+
/// If `valid_years` is unsorted or non-unique.
33+
pub fn parse_year_str(s: &str, valid_years: &[u32]) -> Result<Vec<u32>> {
34+
// We depend on this in `parse_and_validate_year`
35+
assert!(
36+
is_sorted_and_unique(valid_years),
37+
"`valid_years` must be sorted and unique"
38+
);
39+
940
let s = s.trim();
1041
ensure!(!s.is_empty(), "No years provided");
1142

1243
if s.eq_ignore_ascii_case("all") {
13-
return Ok(Vec::from_iter(milestone_years.iter().copied()));
44+
return Ok(Vec::from_iter(valid_years.iter().copied()));
1445
}
1546

16-
s.split(";")
47+
let years: Vec<_> = s
48+
.split(";")
1749
.map(|y| {
18-
let year = y.trim().parse::<u32>()?;
19-
ensure!(
20-
milestone_years.binary_search(&year).is_ok(),
21-
"Invalid year {year}"
22-
);
23-
Ok(year)
50+
parse_and_validate_year(y, valid_years).with_context(|| format!("Invalid year: {y}"))
2451
})
25-
.collect()
52+
.try_collect()?;
53+
54+
ensure!(
55+
is_sorted_and_unique(&years),
56+
"Years must be in order and unique"
57+
);
58+
59+
Ok(years)
60+
}
61+
62+
#[cfg(test)]
63+
mod tests {
64+
use super::*;
65+
use crate::fixture::assert_error;
66+
use rstest::rstest;
67+
68+
#[rstest]
69+
#[case("2020", &[2020, 2021], &[2020])]
70+
#[case("all", &[2020, 2021], &[2020,2021])]
71+
#[case("ALL", &[2020, 2021], &[2020,2021])]
72+
#[case(" ALL ", &[2020, 2021], &[2020,2021])]
73+
#[case("2020;2021", &[2020, 2021], &[2020,2021])]
74+
#[case(" 2020; 2021", &[2020, 2021], &[2020,2021])] // whitespace should be stripped
75+
fn test_parse_year_str_valid(
76+
#[case] input: &str,
77+
#[case] milestone_years: &[u32],
78+
#[case] expected: &[u32],
79+
) {
80+
assert_eq!(parse_year_str(input, milestone_years).unwrap(), expected);
81+
}
82+
83+
#[rstest]
84+
#[case("", &[2020], "No years provided")]
85+
#[case("2021", &[2020], "Invalid year: 2021")]
86+
#[case("a;2020", &[2020], "Invalid year: a")]
87+
#[case("2021;2020", &[2020, 2021],"Years must be in order and unique")] // out of order
88+
#[case("2021;2020;2021", &[2020, 2021],"Years must be in order and unique")] // duplicate
89+
fn test_parse_year_str_invalid(
90+
#[case] input: &str,
91+
#[case] milestone_years: &[u32],
92+
#[case] error_msg: &str,
93+
) {
94+
assert_error!(parse_year_str(input, milestone_years), error_msg);
95+
}
2696
}

0 commit comments

Comments
 (0)