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