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