Skip to main content

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