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