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}