muse2/graph/investment.rs
1//! Module for solving the investment order of commodities
2use super::{CommoditiesGraph, GraphEdge, GraphNode};
3use crate::commodity::{CommodityMap, CommodityType};
4use crate::region::RegionID;
5use crate::simulation::investment::InvestmentSet;
6use highs::{Col, HighsModelStatus, RowProblem, Sense};
7use indexmap::IndexMap;
8use log::warn;
9use petgraph::algo::{condensation, toposort};
10use petgraph::graph::Graph;
11use petgraph::prelude::NodeIndex;
12use petgraph::visit::EdgeRef;
13use petgraph::{Directed, Direction};
14use std::collections::HashMap;
15
16type InvestmentGraph = Graph<InvestmentSet, GraphEdge, Directed>;
17
18/// Analyse the commodity graphs for a given year to determine the order in which investment
19/// decisions should be made.
20///
21/// Steps:
22/// 1. Initialise an `InvestmentGraph` from the set of original `CommodityGraph`s for the given
23/// year, filtering to only include SVD/SED commodities and primary edges. `CommodityGraph`s from
24/// all regions are combined into a single `InvestmentGraph`. TODO: at present there can be no
25/// edges between regions; in future we will want to implement trade as edges between regions,
26/// but this will have no impact on the following steps.
27/// 2. Condense strongly connected components (cycles) into `InvestmentSet::Cycle` nodes.
28/// 3. Perform a topological sort on the condensed graph.
29/// 4. Compute layers for investment based on the topological order, grouping independent sets into
30/// `InvestmentSet::Layer`s.
31///
32/// Arguments:
33/// * `graphs` - Commodity graphs for each region and year, outputted from `build_commodity_graphs_for_model`
34/// * `commodities` - All commodities with their types and demand specifications
35/// * `year` - The year to solve the investment order for
36///
37/// # Returns
38/// A Vec of `InvestmentSet`s in the order they should be solved, with cycles grouped into
39/// `InvestmentSet::Cycle`s and independent sets grouped into `InvestmentSet::Layer`s.
40fn solve_investment_order_for_year(
41 graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>,
42 commodities: &CommodityMap,
43 year: u32,
44) -> Vec<InvestmentSet> {
45 // Initialise InvestmentGraph for this year from the set of original `CommodityGraph`s
46 let mut investment_graph = init_investment_graph_for_year(graphs, year, commodities);
47
48 // Condense strongly connected components
49 investment_graph = compress_cycles(&investment_graph);
50
51 // Perform a topological sort on the condensed graph
52 // We can safely unwrap because `toposort` will only return an error in case of cycles, which
53 // should have been detected and compressed with `compress_cycles`
54 let order = toposort(&investment_graph, None).unwrap();
55
56 // Compute layers for investment
57 compute_layers(&investment_graph, &order)
58}
59
60/// Initialise an `InvestmentGraph` for the given year from a set of `CommodityGraph`s
61///
62/// Commodity graphs for each region are first filtered to only include SVD/SED commodities and
63/// primary edges. Each commodity node is then added to a global investment graph as an
64/// `InvestmentSet::Single`, with edges preserved from the original commodity graphs.
65fn init_investment_graph_for_year(
66 graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>,
67 year: u32,
68 commodities: &CommodityMap,
69) -> InvestmentGraph {
70 let mut combined = InvestmentGraph::new();
71
72 // Iterate over the graphs for the given year
73 for ((region_id, _), graph) in graphs.iter().filter(|((_, y), _)| *y == year) {
74 // Filter the graph to only include SVD/SED commodities and primary edges
75 let filtered = graph.filter_map(
76 |_, n| match n {
77 GraphNode::Commodity(cid) => {
78 let kind = &commodities[cid].kind;
79 matches!(
80 kind,
81 CommodityType::ServiceDemand | CommodityType::SupplyEqualsDemand
82 )
83 .then_some(GraphNode::Commodity(cid.clone()))
84 }
85 _ => None,
86 },
87 |_, e| matches!(e, GraphEdge::Primary(_)).then_some(e.clone()),
88 );
89
90 // Add nodes to the combined graph
91 let node_map: HashMap<_, _> = filtered
92 .node_indices()
93 .map(|ni| {
94 let GraphNode::Commodity(cid) = filtered.node_weight(ni).unwrap() else {
95 unreachable!()
96 };
97 (
98 ni,
99 combined.add_node(InvestmentSet::Single((cid.clone(), region_id.clone()))),
100 )
101 })
102 .collect();
103
104 // Add edges to the combined graph
105 for e in filtered.edge_references() {
106 combined.add_edge(
107 node_map[&e.source()],
108 node_map[&e.target()],
109 e.weight().clone(),
110 );
111 }
112 }
113
114 combined
115}
116
117/// Compresses cycles into `InvestmentSet::Cycle` nodes
118fn compress_cycles(graph: &InvestmentGraph) -> InvestmentGraph {
119 // Detect strongly connected components
120 let mut condensed_graph = condensation(graph.clone(), true);
121
122 // Order nodes within each strongly connected component
123 order_sccs(&mut condensed_graph, graph);
124
125 // Map to a new InvestmentGraph
126 condensed_graph.map(
127 // Map nodes to InvestmentSet
128 // If only one member, keep as-is; if multiple members, create Cycle
129 |_, node_weight| match node_weight.len() {
130 0 => unreachable!("Condensed graph node must have at least one member"),
131 1 => node_weight[0].clone(),
132 _ => InvestmentSet::Cycle(
133 node_weight
134 .iter()
135 .flat_map(|s| s.iter_markets())
136 .cloned()
137 .collect(),
138 ),
139 },
140 // Keep edges the same
141 |_, edge_weight| edge_weight.clone(),
142 )
143}
144
145/// Order the members of each strongly connected component using a mixed-integer linear program.
146///
147/// `condensed_graph` contains the SCCs detected in the original investment graph, stored as
148/// `Vec<InvestmentSet>` node weights. Single-element components are already acyclic, but components
149/// with multiple members require an internal ordering so that the investment algorithm can treat
150/// them as near-acyclic chains, minimising potential disruption.
151///
152/// To rank the members of each multi-node component, we construct a mixed integer linear program
153/// (MILP). This MILP is adapted from the classical Linear Ordering Problem:
154///
155/// Marti, Rafael, and G Reinelt.
156/// The Linear Ordering Problem: Exact and Heuristic Methods in Combinatorial Optimization.
157/// 1st ed. 2011. Berlin: Springer-Verlag, 2011. Web.
158///
159/// The main features of the MILP are:
160/// * Binary variables `x[i][j]` represent whether market `i` should appear before market `j`.
161/// * Antisymmetry constraints force each pair `(i, j)` to choose exactly one direction (i.e. if
162/// `i` comes before `j`, then `j` cannot be before `i`).
163/// * Transitivity constraints prevent 3-cycles, ensuring the resulting relation is acyclic (i.e. if
164/// `i` comes before `j` and `j` comes before `k`, then `k` cannot come before `i`).
165/// * The objective minimises the number of “forward” edges (edges that would point from an earlier
166/// market to a later one), counted within the original SCC and treated as unit penalties. A small
167/// bias (<1) is added to nudge exporters earlier without outweighing the main objective (a bias
168/// >1 would instead prioritise exporters even if it created extra conflicts in the final order).
169///
170/// Once the MILP is solved, markets are scored by the number of pairwise “wins” (how many other
171/// markets they precede). Sorting by this score — using the original index as a tiebreaker to keep
172/// relative order stable — yields the final sequence that replaces the SCC in the condensed graph.
173/// At least one pairwise mismatch is always inevitable (e.g. where X is solved before Y, but Y may
174/// consume X, so the demand for X cannot be guaranteed upfront).
175///
176/// # Example
177///
178/// Suppose three markets (A, B and C) form a cycle in the original graph with the following edges:
179///
180/// ```text
181/// A ← B ← C ← A
182/// ```
183///
184/// Additionally, C has an outgoing edge to a node outside the cycle.
185///
186/// The costs matrix in the MILP is set up to penalise any edge that points “forward” in the final
187/// order: if there's an edge from X to Y we prefer to place Y before X so the edge points backwards:
188///
189/// ```text
190/// | | A | B | C |
191/// | A | 0 | 0 | 1 |
192/// | B | 1 | 0 | 0 |
193/// | C | 0 | 1 | 0 |
194/// ```
195///
196/// On top of this, we give a small preference to markets that export outside the SCC, so nodes with
197/// outgoing edges beyond the cycle are pushed earlier. This is done via an `EXTERNAL_BIAS`
198/// parameter (B) applied to the cost matrix:
199///
200/// ```text
201/// | | A | B | C |
202/// | A | 0 | 0 | 1 + B | i.e. extra penalty for putting A before C
203/// | B | 1 | 0 | 0 + B | i.e. extra penalty for putting B before C
204/// | C | 0 | 1 | 0 |
205/// ```
206///
207/// Solving this problem with binary decision variables for each `x[i][j]`, and constraints to enforce
208/// antisymmetry and transitivity, yields optimal decision variables of:
209///
210/// ```text
211/// x[A][B] = 1 (A before B)
212/// x[A][C] = 0 (C before A)
213/// x[B][A] = 0 (A before B)
214/// x[B][C] = 0 (C before B)
215/// x[C][A] = 1 (C before A)
216/// x[C][B] = 1 (C before B)
217/// ```
218///
219/// From these, summing the number of times each market is preferred over another gives an optimal
220/// order of:
221///
222/// ```text
223/// C, A, B
224/// ```
225///
226/// * By scheduling C before A before B, the edges C ← A and A ← B incur no cost because their
227/// targets appear earlier than their sources.
228/// * The preference towards having exporter markets early in the order keeps C at the front.
229/// * As with any SCC, at least one pairwise violation is guaranteed. In this ordering, the only
230/// pairwise violation is between B and C, as C is solved before B, but B may consume C.
231///
232/// The resulting order replaces the original `InvestmentSet::Cycle` entry inside the condensed
233/// graph, providing a deterministic processing sequence for downstream logic.
234#[allow(clippy::too_many_lines)]
235fn order_sccs(
236 condensed_graph: &mut Graph<Vec<InvestmentSet>, GraphEdge>,
237 original_graph: &InvestmentGraph,
238) {
239 const EXTERNAL_BIAS: f64 = 0.1;
240
241 // Map each investment set back to the node index in the original graph so we can inspect edges.
242 let node_lookup: HashMap<InvestmentSet, NodeIndex> = original_graph
243 .node_indices()
244 .map(|idx| (original_graph.node_weight(idx).unwrap().clone(), idx))
245 .collect();
246
247 // Work through each SCC; groups with just one investment set don't need to be ordered.
248 for group in condensed_graph.node_indices() {
249 let scc = condensed_graph.node_weight_mut(group).unwrap();
250 let n = scc.len();
251 if n <= 1 {
252 continue;
253 }
254
255 // Capture current order and resolve each investment set back to its original graph index.
256 let original_order = scc.clone();
257 let original_indices = original_order
258 .iter()
259 .map(|set| {
260 node_lookup
261 .get(set)
262 .copied()
263 .expect("Condensed SCC node must exist in the original graph")
264 })
265 .collect::<Vec<_>>();
266
267 // Build a fast lookup from original node index to its position in the SCC slice.
268 let mut index_position = HashMap::new();
269 for (pos, idx) in original_indices.iter().copied().enumerate() {
270 index_position.insert(idx, pos);
271 }
272
273 // Record whether any edge inside the original SCC goes from market i to market j; these become penalties.
274 let mut penalties = vec![vec![0.0f64; n]; n];
275 let mut has_external_outgoing = vec![false; n];
276 for (i, &idx) in original_indices.iter().enumerate() {
277 // Loop over the edges going out of this node
278 for edge in original_graph.edges_directed(idx, Direction::Outgoing) {
279 // If the target j is inside this SCC, record a penalty for putting i before j
280 if let Some(&j) = index_position.get(&edge.target()) {
281 penalties[i][j] = 1.0;
282
283 // Otherwise, mark that i has an outgoing edge to outside the SCC
284 } else {
285 has_external_outgoing[i] = true;
286 }
287 }
288 }
289
290 // Bias: if market j has outgoing edges to nodes outside this SCC, we prefer to place it earlier.
291 for (j, has_external) in has_external_outgoing.iter().enumerate() {
292 if *has_external {
293 for (row_idx, row) in penalties.iter_mut().enumerate() {
294 // Add a small bias to all entries in column j, except the diagonal
295 // i.e. penalise putting any other market before market j
296 if row_idx != j {
297 row[j] += EXTERNAL_BIAS;
298 }
299 }
300 }
301 }
302
303 // Build a MILP whose binary variables x[i][j] indicate "i is ordered before j".
304 // Objective: minimise Σ penalty[i][j] · x[i][j], so forward edges (and the export bias) add cost.
305 let mut problem = RowProblem::default();
306 let mut vars: Vec<Vec<Option<Col>>> = vec![vec![None; n]; n];
307 for (i, row) in vars.iter_mut().enumerate() {
308 for (j, slot) in row.iter_mut().enumerate() {
309 if i == j {
310 continue;
311 }
312 let cost = penalties[i][j];
313
314 // Create binary variable x[i][j]
315 *slot = Some(problem.add_integer_column(cost, 0..=1));
316 }
317 }
318
319 // Enforce antisymmetry: for each pair (i, j), exactly one of x[i][j] and x[j][i] is 1.
320 // i.e. if i comes before j, then j cannot come before i.
321 for (i, row) in vars.iter().enumerate() {
322 for (j, _) in row.iter().enumerate().skip(i + 1) {
323 let Some(x_ij) = vars[i][j] else { continue };
324 let Some(x_ji) = vars[j][i] else { continue };
325 problem.add_row(1.0..=1.0, [(x_ij, 1.0), (x_ji, 1.0)]);
326 }
327 }
328
329 // Enforce transitivity to avoid 3-cycles: x[i][j] + x[j][k] + x[k][i] ≤ 2.
330 // i.e. if i comes before j and j comes before k, then k cannot come before i.
331 for (i, row) in vars.iter().enumerate() {
332 for (j, _) in row.iter().enumerate() {
333 if i == j {
334 continue;
335 }
336 for (k, _) in vars.iter().enumerate() {
337 if i == k || j == k {
338 continue;
339 }
340 let Some(x_ij) = vars[i][j] else { continue };
341 let Some(x_jk) = vars[j][k] else { continue };
342 let Some(x_ki) = vars[k][i] else { continue };
343 problem.add_row(..=2.0, [(x_ij, 1.0), (x_jk, 1.0), (x_ki, 1.0)]);
344 }
345 }
346 }
347
348 let model = problem.optimise(Sense::Minimise);
349 let solved = match model.try_solve() {
350 Ok(solved) => solved,
351 Err(status) => {
352 warn!("HiGHS failed while ordering an SCC: {status:?}");
353 continue;
354 }
355 };
356
357 if solved.status() != HighsModelStatus::Optimal {
358 let status = solved.status();
359 warn!("HiGHS returned a non-optimal status while ordering an SCC: {status:?}");
360 continue;
361 }
362
363 let solution = solved.get_solution();
364 // Score each market by the number of "wins" it achieves (times it must precede another).
365 let mut wins = vec![0usize; n];
366 for (i, row) in vars.iter().enumerate() {
367 for (j, var) in row.iter().enumerate() {
368 if i == j {
369 continue;
370 }
371 if var.is_some_and(|col| solution[col] > 0.5) {
372 wins[i] += 1;
373 }
374 }
375 }
376
377 // Sort by descending win count; break ties on the original index so equal-score nodes keep
378 // their relative order.
379 let mut order: Vec<usize> = (0..n).collect();
380 order.sort_by(|&a, &b| wins[b].cmp(&wins[a]).then_with(|| a.cmp(&b)));
381
382 // Rewrite the SCC in the new order
383 *scc = order
384 .into_iter()
385 .map(|idx| original_order[idx].clone())
386 .collect();
387 }
388}
389
390/// Compute layers of investment sets from the topological order
391///
392/// This function works by computing the rank of each node in the graph based on the longest path
393/// from any root node to that node. Any nodes with the same rank are independent and can be solved
394/// in parallel. Nodes with different rank must be solved in order from highest rank (leaf nodes)
395/// to lowest rank (root nodes).
396///
397/// This function computes the ranks of each node, groups nodes by rank, and then produces a final
398/// ordered Vec of `InvestmentSet`s which gives the order in which to solve the investment decisions.
399///
400/// Investment sets with the same rank (i.e., can be solved in parallel) are grouped into
401/// `InvestmentSet::Layer`. Investment sets that are alone in their rank remain as-is (i.e. either
402/// `Single` or `Cycle`). `Layer`s can contain a mix of `Single` and `Cycle` investment sets.
403///
404/// For example, given the following graph:
405///
406/// ```text
407/// A
408/// / \
409/// B C
410/// / \ \
411/// D E F
412/// ```
413///
414/// Rank 0: A -> `InvestmentSet::Single`
415/// Rank 1: B, C -> `InvestmentSet::Layer`
416/// Rank 2: D, E, F -> `InvestmentSet::Layer`
417///
418/// These are returned as a `Vec<InvestmentSet>` from highest rank to lowest (i.e. the D, E, F layer
419/// first, then the B, C layer, then the singleton A).
420///
421/// Arguments:
422/// * `graph` - The investment graph. Any cycles in the graph MUST have already been compressed.
423/// This will be necessary anyway as computing a topological sort to obtain the `order` requires
424/// an acyclic graph.
425/// * `order` - The topological order of the graph nodes. Computed using `petgraph::algo::toposort`.
426///
427/// Returns:
428/// A Vec of `InvestmentSet`s in the order they should be solved, with independent sets grouped into
429/// `InvestmentSet::Layer`s.
430fn compute_layers(graph: &InvestmentGraph, order: &[NodeIndex]) -> Vec<InvestmentSet> {
431 // Initialize all ranks to 0
432 let mut ranks: HashMap<_, usize> = graph.node_indices().map(|n| (n, 0)).collect();
433
434 // Calculate the rank of each node by traversing in topological order
435 // The algorithm works by iterating through each node in topological order and updating the ranks
436 // of its neighbors to be at least one more than the current node's rank.
437 for &u in order {
438 let current_rank = ranks[&u];
439 for v in graph.neighbors_directed(u, Direction::Outgoing) {
440 if let Some(r) = ranks.get_mut(&v) {
441 *r = (*r).max(current_rank + 1);
442 }
443 }
444 }
445
446 // Group nodes by rank
447 let max_rank = ranks.values().copied().max().unwrap_or(0);
448 let mut groups: Vec<Vec<InvestmentSet>> = vec![Vec::new(); max_rank + 1];
449 for node_idx in order {
450 let rank = ranks[node_idx];
451 let w = graph.node_weight(*node_idx).unwrap().clone();
452 groups[rank].push(w);
453 }
454
455 // Produce final ordered Vec<InvestmentSet>: ranks descending (leaf-first),
456 // compressing equal-rank nodes into an InvestmentSet::Layer.
457 let mut result = Vec::new();
458 for mut items in groups.into_iter().rev() {
459 if items.is_empty() {
460 unreachable!("Should be no gaps in the ranking")
461 }
462 // If only one InvestmentSet in the group, we do not need to compress into a layer, so just
463 // push the single item (this item may be a `Single` or `Cycle`).
464 if items.len() == 1 {
465 result.push(items.remove(0));
466 // Otherwise, create a layer. The items within the layer may be a mix of `Single` or `Cycle`.
467 } else {
468 result.push(InvestmentSet::Layer(items));
469 }
470 }
471
472 result
473}
474
475/// Determine investment ordering for each year
476///
477/// # Arguments
478///
479/// * `commodity_graphs` - Commodity graphs for each region and year, outputted from `build_commodity_graphs_for_model`
480/// * `commodities` - All commodities with their types and demand specifications
481///
482/// # Returns
483///
484/// A map from `year` to the ordered list of `InvestmentSet`s for investment decisions. The
485/// ordering ensures that leaf-node `InvestmentSet`s (those with no outgoing edges) are solved
486/// first.
487pub fn solve_investment_order_for_model(
488 commodity_graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>,
489 commodities: &CommodityMap,
490 years: &[u32],
491) -> HashMap<u32, Vec<InvestmentSet>> {
492 let mut investment_orders = HashMap::new();
493 for year in years {
494 let order = solve_investment_order_for_year(commodity_graphs, commodities, *year);
495 investment_orders.insert(*year, order);
496 }
497 investment_orders
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::commodity::Commodity;
504 use crate::fixture::{sed_commodity, svd_commodity};
505 use petgraph::graph::Graph;
506 use rstest::rstest;
507 use std::rc::Rc;
508
509 #[test]
510 fn order_sccs_simple_cycle() {
511 let markets = ["A", "B", "C"].map(|id| InvestmentSet::Single((id.into(), "GBR".into())));
512
513 // Create graph with cycle edges plus an extra dependency B ← D (see doc comment)
514 let mut original = InvestmentGraph::new();
515 let node_indices: Vec<_> = markets
516 .iter()
517 .map(|set| original.add_node(set.clone()))
518 .collect();
519 for &(src, dst) in &[(1, 0), (2, 1), (0, 2)] {
520 original.add_edge(
521 node_indices[src],
522 node_indices[dst],
523 GraphEdge::Primary("process1".into()),
524 );
525 }
526 // External market receiving exports from C; encourages C to appear early.
527 let external = original.add_node(InvestmentSet::Single(("X".into(), "GBR".into())));
528 original.add_edge(
529 node_indices[2],
530 external,
531 GraphEdge::Primary("process2".into()),
532 );
533
534 // Single SCC containing all markets.
535 let mut condensed: Graph<Vec<InvestmentSet>, GraphEdge> = Graph::new();
536 let component = condensed.add_node(markets.to_vec());
537
538 order_sccs(&mut condensed, &original);
539
540 // Expected order corresponds to the example in the doc comment.
541 // Note that C should be first, as it has an outgoing edge to the external market.
542 let expected = ["C", "A", "B"]
543 .map(|id| InvestmentSet::Single((id.into(), "GBR".into())))
544 .to_vec();
545
546 assert_eq!(condensed.node_weight(component).unwrap(), &expected);
547 }
548
549 #[rstest]
550 fn solve_investment_order_linear_graph(sed_commodity: Commodity, svd_commodity: Commodity) {
551 // Create a simple linear graph: A -> B -> C
552 let mut graph = Graph::new();
553
554 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
555 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
556 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
557
558 // Add edges: A -> B -> C
559 graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
560 graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into()));
561
562 // Create commodities map using fixtures
563 let mut commodities = CommodityMap::new();
564 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
565 commodities.insert("B".into(), Rc::new(sed_commodity));
566 commodities.insert("C".into(), Rc::new(svd_commodity));
567
568 let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]);
569 let result = solve_investment_order_for_year(&graphs, &commodities, 2020);
570
571 // Expected order: C, B, A (leaf nodes first)
572 // No cycles or layers, so all investment sets should be `Single`
573 assert_eq!(result.len(), 3);
574 assert_eq!(result[0], InvestmentSet::Single(("C".into(), "GBR".into())));
575 assert_eq!(result[1], InvestmentSet::Single(("B".into(), "GBR".into())));
576 assert_eq!(result[2], InvestmentSet::Single(("A".into(), "GBR".into())));
577 }
578
579 #[rstest]
580 fn solve_investment_order_cyclic_graph(sed_commodity: Commodity) {
581 // Create a simple cyclic graph: A -> B -> A
582 let mut graph = Graph::new();
583
584 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
585 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
586
587 // Add edges creating a cycle: A -> B -> A
588 graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
589 graph.add_edge(node_b, node_a, GraphEdge::Primary("process2".into()));
590
591 // Create commodities map using fixtures
592 let mut commodities = CommodityMap::new();
593 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
594 commodities.insert("B".into(), Rc::new(sed_commodity));
595
596 let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]);
597 let result = solve_investment_order_for_year(&graphs, &commodities, 2020);
598
599 // Should be a single `Cycle` investment set containing both commodities
600 assert_eq!(result.len(), 1);
601 assert_eq!(
602 result[0],
603 InvestmentSet::Cycle(vec![("A".into(), "GBR".into()), ("B".into(), "GBR".into())])
604 );
605 }
606
607 #[rstest]
608 fn solve_investment_order_layered_graph(sed_commodity: Commodity, svd_commodity: Commodity) {
609 // Create a graph with layers:
610 // A
611 // / \
612 // B C
613 // \ /
614 // D
615 let mut graph = Graph::new();
616
617 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
618 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
619 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
620 let node_d = graph.add_node(GraphNode::Commodity("D".into()));
621
622 // Add edges
623 graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
624 graph.add_edge(node_a, node_c, GraphEdge::Primary("process2".into()));
625 graph.add_edge(node_b, node_d, GraphEdge::Primary("process3".into()));
626 graph.add_edge(node_c, node_d, GraphEdge::Primary("process4".into()));
627
628 // Create commodities map using fixtures
629 let mut commodities = CommodityMap::new();
630 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
631 commodities.insert("B".into(), Rc::new(sed_commodity.clone()));
632 commodities.insert("C".into(), Rc::new(sed_commodity));
633 commodities.insert("D".into(), Rc::new(svd_commodity));
634
635 let graphs = IndexMap::from([(("GBR".into(), 2020), graph)]);
636 let result = solve_investment_order_for_year(&graphs, &commodities, 2020);
637
638 // Expected order: D, Layer(B, C), A
639 assert_eq!(result.len(), 3);
640 assert_eq!(result[0], InvestmentSet::Single(("D".into(), "GBR".into())));
641 assert_eq!(
642 result[1],
643 InvestmentSet::Layer(vec![
644 InvestmentSet::Single(("B".into(), "GBR".into())),
645 InvestmentSet::Single(("C".into(), "GBR".into()))
646 ])
647 );
648 assert_eq!(result[2], InvestmentSet::Single(("A".into(), "GBR".into())));
649 }
650
651 #[rstest]
652 fn solve_investment_order_multiple_regions(sed_commodity: Commodity, svd_commodity: Commodity) {
653 // Create a simple linear graph: A -> B -> C
654 let mut graph = Graph::new();
655
656 let node_a = graph.add_node(GraphNode::Commodity("A".into()));
657 let node_b = graph.add_node(GraphNode::Commodity("B".into()));
658 let node_c = graph.add_node(GraphNode::Commodity("C".into()));
659
660 // Add edges: A -> B -> C
661 graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
662 graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into()));
663
664 // Create commodities map using fixtures
665 let mut commodities = CommodityMap::new();
666 commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
667 commodities.insert("B".into(), Rc::new(sed_commodity));
668 commodities.insert("C".into(), Rc::new(svd_commodity));
669
670 // Duplicate the graph over two regions
671 let graphs = IndexMap::from([
672 (("GBR".into(), 2020), graph.clone()),
673 (("FRA".into(), 2020), graph),
674 ]);
675 let result = solve_investment_order_for_year(&graphs, &commodities, 2020);
676
677 // Expected order: Should have three layers, each with two commodities (one per region)
678 assert_eq!(result.len(), 3);
679 assert_eq!(
680 result[0],
681 InvestmentSet::Layer(vec![
682 InvestmentSet::Single(("C".into(), "GBR".into())),
683 InvestmentSet::Single(("C".into(), "FRA".into()))
684 ])
685 );
686 assert_eq!(
687 result[1],
688 InvestmentSet::Layer(vec![
689 InvestmentSet::Single(("B".into(), "GBR".into())),
690 InvestmentSet::Single(("B".into(), "FRA".into()))
691 ])
692 );
693 assert_eq!(
694 result[2],
695 InvestmentSet::Layer(vec![
696 InvestmentSet::Single(("A".into(), "GBR".into())),
697 InvestmentSet::Single(("A".into(), "FRA".into()))
698 ])
699 );
700 }
701}