Skip to content

Commit d10ecca

Browse files
committed
Check for circularities at validation stage
1 parent ba08d68 commit d10ecca

3 files changed

Lines changed: 45 additions & 30 deletions

File tree

src/graph/investment.rs

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
//! Module for solving the investment order of commodities
22
use super::{CommoditiesGraph, GraphEdge, GraphNode};
3-
use crate::commodity::{CommodityMap, CommodityType};
3+
use crate::commodity::{CommodityMap, CommodityType, PricingStrategy};
44
use crate::region::RegionID;
55
use crate::simulation::investment::InvestmentSet;
6+
use anyhow::{Result, ensure};
67
use highs::{Col, HighsModelStatus, RowProblem, Sense};
78
use indexmap::IndexMap;
89
use log::warn;
@@ -41,22 +42,22 @@ fn solve_investment_order_for_year(
4142
graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>,
4243
commodities: &CommodityMap,
4344
year: u32,
44-
) -> Vec<InvestmentSet> {
45+
) -> Result<Vec<InvestmentSet>> {
4546
// Initialise InvestmentGraph for this year from the set of original `CommodityGraph`s
4647
let mut investment_graph = init_investment_graph_for_year(graphs, year, commodities);
4748

4849
// TODO: condense sibling commodities (commodities that share at least one producer)
4950

5051
// Condense strongly connected components
51-
investment_graph = compress_cycles(&investment_graph);
52+
investment_graph = compress_cycles(&investment_graph, commodities)?;
5253

5354
// Perform a topological sort on the condensed graph
5455
// We can safely unwrap because `toposort` will only return an error in case of cycles, which
5556
// should have been detected and compressed with `compress_cycles`
5657
let order = toposort(&investment_graph, None).unwrap();
5758

5859
// Compute layers for investment
59-
compute_layers(&investment_graph, &order)
60+
Ok(compute_layers(&investment_graph, &order))
6061
}
6162

6263
/// Initialise an `InvestmentGraph` for the given year from a set of `CommodityGraph`s
@@ -117,15 +118,39 @@ fn init_investment_graph_for_year(
117118
}
118119

119120
/// Compresses cycles into `InvestmentSet::Cycle` nodes
120-
fn compress_cycles(graph: &InvestmentGraph) -> InvestmentGraph {
121+
fn compress_cycles(graph: &InvestmentGraph, commodities: &CommodityMap) -> Result<InvestmentGraph> {
121122
// Detect strongly connected components
122123
let mut condensed_graph = condensation(graph.clone(), true);
123124

124125
// Order nodes within each strongly connected component
125126
order_sccs(&mut condensed_graph, graph);
126127

128+
// Pre-scan SCCs for offending pricing strategies (FullCost / MarginalCost).
129+
for node_weight in condensed_graph.node_weights() {
130+
if node_weight.len() <= 1 {
131+
continue;
132+
}
133+
let offenders: Vec<_> = node_weight
134+
.iter()
135+
.flat_map(|s| s.iter_markets())
136+
.filter(|(cid, _)| {
137+
matches!(
138+
commodities[cid].pricing_strategy.clone(),
139+
PricingStrategy::MarginalCost | PricingStrategy::FullCost
140+
)
141+
})
142+
.map(|(cid, _)| cid.clone())
143+
.collect();
144+
145+
ensure!(
146+
offenders.is_empty(),
147+
"Cannot use FullCost/MarginalCost pricing strategies for commodities with circular \
148+
dependencies. Offending commodities: {offenders:?}"
149+
);
150+
}
151+
127152
// Map to a new InvestmentGraph
128-
condensed_graph.map(
153+
let mapped = condensed_graph.map(
129154
// Map nodes to InvestmentSet
130155
// If only one member, keep as-is; if multiple members, create Cycle
131156
|_, node_weight| match node_weight.len() {
@@ -141,7 +166,9 @@ fn compress_cycles(graph: &InvestmentGraph) -> InvestmentGraph {
141166
},
142167
// Keep edges the same
143168
|_, edge_weight| edge_weight.clone(),
144-
)
169+
);
170+
171+
Ok(mapped)
145172
}
146173

147174
/// Order the members of each strongly connected component using a mixed-integer linear program.
@@ -490,13 +517,13 @@ pub fn solve_investment_order_for_model(
490517
commodity_graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>,
491518
commodities: &CommodityMap,
492519
years: &[u32],
493-
) -> HashMap<u32, Vec<InvestmentSet>> {
520+
) -> Result<HashMap<u32, Vec<InvestmentSet>>> {
494521
let mut investment_orders = HashMap::new();
495522
for year in years {
496-
let order = solve_investment_order_for_year(commodity_graphs, commodities, *year);
523+
let order = solve_investment_order_for_year(commodity_graphs, commodities, *year)?;
497524
investment_orders.insert(*year, order);
498525
}
499-
investment_orders
526+
Ok(investment_orders)
500527
}
501528

502529
#[cfg(test)]
@@ -568,7 +595,7 @@ mod tests {
568595
commodities.insert("C".into(), Rc::new(svd_commodity));
569596

570597
let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]);
571-
let result = solve_investment_order_for_year(&graphs, &commodities, 2020);
598+
let result = solve_investment_order_for_year(&graphs, &commodities, 2020).unwrap();
572599

573600
// Expected order: C, B, A (leaf nodes first)
574601
// No cycles or layers, so all investment sets should be `Single`
@@ -596,7 +623,7 @@ mod tests {
596623
commodities.insert("B".into(), Rc::new(sed_commodity));
597624

598625
let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]);
599-
let result = solve_investment_order_for_year(&graphs, &commodities, 2020);
626+
let result = solve_investment_order_for_year(&graphs, &commodities, 2020).unwrap();
600627

601628
// Should be a single `Cycle` investment set containing both commodities
602629
assert_eq!(result.len(), 1);
@@ -635,7 +662,7 @@ mod tests {
635662
commodities.insert("D".into(), Rc::new(svd_commodity));
636663

637664
let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]);
638-
let result = solve_investment_order_for_year(&graphs, &commodities, 2020);
665+
let result = solve_investment_order_for_year(&graphs, &commodities, 2020).unwrap();
639666

640667
// Expected order: D, Layer(B, C), A
641668
assert_eq!(result.len(), 3);
@@ -674,7 +701,7 @@ mod tests {
674701
(("GBR".into(), 2020), graph.clone()),
675702
(("FRA".into(), 2020), graph),
676703
]);
677-
let result = solve_investment_order_for_year(&graphs, &commodities, 2020);
704+
let result = solve_investment_order_for_year(&graphs, &commodities, 2020).unwrap();
678705

679706
// Expected order: Should have three layers, each with two commodities (one per region)
680707
assert_eq!(result.len(), 3);

src/input.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,8 @@ pub fn load_model<P: AsRef<Path>>(model_dir: P) -> Result<Model> {
263263
)?;
264264

265265
// Solve investment order for each region/year
266-
let investment_order = solve_investment_order_for_model(&commodity_graphs, &commodities, years);
266+
let investment_order =
267+
solve_investment_order_for_model(&commodity_graphs, &commodities, years)?;
267268

268269
let model_path = model_dir
269270
.as_ref()

src/simulation/prices.rs

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ use crate::asset::AssetRef;
33
use crate::commodity::{CommodityID, PricingStrategy};
44
use crate::model::Model;
55
use crate::region::RegionID;
6-
use crate::simulation::investment::InvestmentSet;
76
use crate::simulation::optimisation::Solution;
87
use crate::time_slice::{TimeSliceID, TimeSliceInfo, TimeSliceSelection};
98
use crate::units::{Activity, Dimensionless, MoneyPerActivity, MoneyPerFlow, Year};
10-
use anyhow::{Result, ensure};
9+
use anyhow::Result;
1110
use indexmap::IndexMap;
1211
use std::collections::{HashMap, HashSet};
1312

@@ -38,7 +37,7 @@ pub fn calculate_prices(model: &Model, solution: &Solution, year: u32) -> Result
3837

3938
// Iterate over investment sets in reverse order. Markets within the same set can be priced
4039
// simultaneously, since they are independent (apart from Cycle sets when using the "marginal"
41-
// and "full" strategies, which we bail on below).
40+
// and "full" strategies, which get flagged at the validation stage).
4241
for investment_set in investment_order.iter().rev() {
4342
// Partition markets by pricing strategy into a map keyed by `PricingStrategy`.
4443
// For now, commodities use a single strategy for all regions, but this may change in the future.
@@ -54,18 +53,6 @@ pub fn calculate_prices(model: &Model, solution: &Solution, year: u32) -> Result
5453
.insert((commodity_id.clone(), region_id.clone()));
5554
}
5655

57-
// Bail if the investment set is type Cycle and commodities have "marginal" or "full"
58-
// pricing strategies, since we don't know how to handle this scenario.
59-
if pricing_sets.contains_key(&PricingStrategy::MarginalCost)
60-
|| pricing_sets.contains_key(&PricingStrategy::FullCost)
61-
{
62-
ensure!(
63-
!matches!(investment_set, InvestmentSet::Cycle(_)),
64-
"Cannot calculate prices using the `marginal` and `full` pricing strategies \
65-
for markets with cyclical commodity dependencies."
66-
);
67-
}
68-
6956
// Add prices for shadow-priced commodities
7057
if let Some(shadow_set) = pricing_sets.get(&PricingStrategy::Shadow) {
7158
for (commodity_id, region_id, time_slice) in shadow_prices.keys() {

0 commit comments

Comments
 (0)