muse2/
graph.rs

1//! Module for creating and analysing commodity graphs
2use crate::commodity::{CommodityID, CommodityMap, CommodityType};
3use crate::process::{ProcessID, ProcessMap};
4use crate::region::RegionID;
5use crate::time_slice::{TimeSliceInfo, TimeSliceLevel, TimeSliceSelection};
6use crate::units::{Dimensionless, Flow};
7use anyhow::{Context, Result, anyhow, ensure};
8use indexmap::IndexSet;
9use itertools::{Itertools, iproduct};
10use petgraph::Directed;
11use petgraph::algo::toposort;
12use petgraph::dot::Dot;
13use petgraph::graph::{EdgeReference, Graph};
14use petgraph::visit::EdgeFiltered;
15use std::collections::HashMap;
16use std::fmt::Display;
17use std::fs::File;
18use std::io::Write as IoWrite;
19use std::path::Path;
20use strum::IntoEnumIterator;
21
22/// A graph of commodity flows for a given region and year
23pub type CommoditiesGraph = Graph<GraphNode, GraphEdge, Directed>;
24
25#[derive(Eq, PartialEq, Clone, Hash)]
26/// A node in the commodity graph
27pub enum GraphNode {
28    /// A node representing a commodity
29    Commodity(CommodityID),
30    /// A source node for processes that have no inputs
31    Source,
32    /// A sink node for processes that have no outputs
33    Sink,
34    /// A demand node for commodities with service demands
35    Demand,
36}
37
38impl Display for GraphNode {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            GraphNode::Commodity(id) => write!(f, "{id}"),
42            GraphNode::Source => write!(f, "SOURCE"),
43            GraphNode::Sink => write!(f, "SINK"),
44            GraphNode::Demand => write!(f, "DEMAND"),
45        }
46    }
47}
48
49#[derive(Eq, PartialEq, Clone, Hash)]
50/// An edge in the commodity graph
51pub enum GraphEdge {
52    /// An edge representing a primary flow of a process
53    Primary(ProcessID),
54    /// An edge representing a secondary (non-primary) flow of a process
55    Secondary(ProcessID),
56    /// An edge representing a service demand
57    Demand,
58}
59
60impl Display for GraphEdge {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            GraphEdge::Primary(process_id) | GraphEdge::Secondary(process_id) => {
64                write!(f, "{process_id}")
65            }
66            GraphEdge::Demand => write!(f, "DEMAND"),
67        }
68    }
69}
70
71/// Creates a directed graph of commodity flows for a given region and year.
72///
73/// The graph contains nodes for all commodities that may be consumed/produced by processes in the
74/// specified region/year. There will be an edge from commodity A to B if there exists a process
75/// that consumes A and produces B.
76///
77/// There are also special `Source` and `Sink` nodes, which are used for processes that have no
78/// inputs or outputs.
79///
80/// The graph does not take into account process availabilities or commodity demands, both of which
81/// can vary by time slice. See `prepare_commodities_graph_for_validation`.
82fn create_commodities_graph_for_region_year(
83    processes: &ProcessMap,
84    region_id: &RegionID,
85    year: u32,
86) -> CommoditiesGraph {
87    let mut graph = Graph::new();
88    let mut commodity_to_node_index = HashMap::new();
89
90    let key = (region_id.clone(), year);
91    for process in processes.values() {
92        let Some(flows) = process.flows.get(&key) else {
93            // Process doesn't operate in this region/year
94            continue;
95        };
96
97        // Get output nodes for the process
98        let mut outputs: Vec<_> = flows
99            .values()
100            .filter(|flow| flow.is_output())
101            .map(|flow| GraphNode::Commodity(flow.commodity.id.clone()))
102            .collect();
103
104        // Get input nodes for the process
105        let mut inputs: Vec<_> = flows
106            .values()
107            .filter(|flow| flow.is_input())
108            .map(|flow| GraphNode::Commodity(flow.commodity.id.clone()))
109            .collect();
110
111        // Use `Source` node if no inputs, `Sink` node if no outputs
112        if inputs.is_empty() {
113            inputs.push(GraphNode::Source);
114        }
115        if outputs.is_empty() {
116            outputs.push(GraphNode::Sink);
117        }
118
119        // Get primary output for the process
120        let primary_output = &process.primary_output;
121
122        // Create edges from all inputs to all outputs
123        // We also create nodes the first time they are encountered
124        for (input, output) in iproduct!(inputs, outputs) {
125            let source_node_index = *commodity_to_node_index
126                .entry(input.clone())
127                .or_insert_with(|| graph.add_node(input.clone()));
128            let target_node_index = *commodity_to_node_index
129                .entry(output.clone())
130                .or_insert_with(|| graph.add_node(output.clone()));
131            let is_primary = match &output {
132                GraphNode::Commodity(commodity_id) => primary_output.as_ref() == Some(commodity_id),
133                _ => false,
134            };
135
136            graph.add_edge(
137                source_node_index,
138                target_node_index,
139                if is_primary {
140                    GraphEdge::Primary(process.id.clone())
141                } else {
142                    GraphEdge::Secondary(process.id.clone())
143                },
144            );
145        }
146    }
147
148    graph
149}
150
151/// Prepares a graph for validation with [`validate_commodities_graph`].
152///
153/// It takes a base graph produced by `create_commodities_graph_for_region_year`, and modifies it to
154/// account for process availabilities and commodity demands within the given time slice selection,
155/// returning a new graph.
156///
157/// Commodity demands are represented by the `Demand` node. We only add edges to the `Demand` node
158/// for commodities with the same `time_slice_level` as the selection. Other demands can be ignored
159/// since this graph will only be validated for commodities with the same `time_slice_level` as the
160/// selection.
161fn prepare_commodities_graph_for_validation(
162    base_graph: &CommoditiesGraph,
163    processes: &ProcessMap,
164    commodities: &CommodityMap,
165    time_slice_info: &TimeSliceInfo,
166    region_id: &RegionID,
167    year: u32,
168    time_slice_selection: &TimeSliceSelection,
169) -> CommoditiesGraph {
170    let mut filtered_graph = base_graph.clone();
171
172    // Filter by process availability
173    // We keep edges if the process has availability > 0 in any time slice in the selection
174    let key = (region_id.clone(), year);
175    filtered_graph.retain_edges(|graph, edge_idx| {
176        // Get the process for the edge
177        let process_id = match graph.edge_weight(edge_idx).unwrap() {
178            GraphEdge::Primary(process_id) | GraphEdge::Secondary(process_id) => process_id,
179            GraphEdge::Demand => panic!("Demand edges should not be present in the base graph"),
180        };
181        let process = &processes[process_id];
182
183        // Check if the process has availability > 0 in any time slice in the selection
184        time_slice_selection
185            .iter(time_slice_info)
186            .any(|(time_slice, _)| {
187                let Some(limits_map) = process.activity_limits.get(&key) else {
188                    return false;
189                };
190                limits_map
191                    .get(time_slice)
192                    .is_some_and(|avail| *avail.end() > Dimensionless(0.0))
193            })
194    });
195
196    // Add demand edges
197    // We add edges to the `Demand` node for commodities that are demanded in the selection
198    // NOTE: we only do this for commodities with the same time_slice_level as the selection
199    let demand_node_index = filtered_graph.add_node(GraphNode::Demand);
200    for (commodity_id, commodity) in commodities {
201        if time_slice_selection.level() == commodity.time_slice_level
202            && commodity
203                .demand
204                .get(&(region_id.clone(), year, time_slice_selection.clone()))
205                .is_some_and(|&v| v > Flow(0.0))
206        {
207            let commodity_node = GraphNode::Commodity(commodity_id.clone());
208            let commodity_node_index = filtered_graph
209                .node_indices()
210                .find(|&idx| filtered_graph.node_weight(idx) == Some(&commodity_node))
211                .unwrap_or_else(|| {
212                    filtered_graph.add_node(GraphNode::Commodity(commodity_id.clone()))
213                });
214            filtered_graph.add_edge(commodity_node_index, demand_node_index, GraphEdge::Demand);
215        }
216    }
217
218    filtered_graph
219}
220
221/// Validates that the commodity graph follows the rules for different commodity types.
222///
223/// It takes as input a graph created by `create_commodities_graph_for_validation`, which is built
224/// for a specific time slice selection (must match the `time_slice_level` passed to this function).
225///
226/// The validation is only performed for commodities with the specified time slice level. For full
227/// validation of all commodities in the model, we therefore need to run this function for all time
228/// slice selections at all time slice levels. This is handled by
229/// [`validate_commodity_graphs_for_model`].
230fn validate_commodities_graph(
231    graph: &CommoditiesGraph,
232    commodities: &CommodityMap,
233    time_slice_level: TimeSliceLevel,
234) -> Result<()> {
235    for node_idx in graph.node_indices() {
236        // Get the commodity ID for the node
237        let graph_node = graph.node_weight(node_idx).unwrap();
238        let GraphNode::Commodity(commodity_id) = graph_node else {
239            // Skip special nodes
240            continue;
241        };
242
243        // Only validate commodities with the specified time slice level
244        let commodity = &commodities[commodity_id];
245        if commodity.time_slice_level != time_slice_level {
246            continue;
247        }
248
249        // Count the incoming and outgoing edges for the commodity
250        let has_incoming = graph
251            .edges_directed(node_idx, petgraph::Direction::Incoming)
252            .next()
253            .is_some();
254        let has_outgoing = graph
255            .edges_directed(node_idx, petgraph::Direction::Outgoing)
256            .next()
257            .is_some();
258
259        // Match validation rules to commodity type
260        match commodity.kind {
261            CommodityType::ServiceDemand => {
262                // Cannot have outgoing `Primary`/`Secondary` (non-`Demand`) edges
263                let has_non_demand_outgoing = graph
264                    .edges_directed(node_idx, petgraph::Direction::Outgoing)
265                    .any(|edge| edge.weight() != &GraphEdge::Demand);
266                ensure!(
267                    !has_non_demand_outgoing,
268                    "SVD commodity {commodity_id} cannot be an input to a process"
269                );
270
271                // If it has `Demand` edges, it must have at least one producer
272                let has_demand_edges = graph
273                    .edges_directed(node_idx, petgraph::Direction::Outgoing)
274                    .any(|edge| edge.weight() == &GraphEdge::Demand);
275                if has_demand_edges {
276                    ensure!(
277                        has_incoming,
278                        "SVD commodity {commodity_id} is demanded but has no producers"
279                    );
280                }
281            }
282            CommodityType::SupplyEqualsDemand => {
283                // SED: if consumed (outgoing edges), must also be produced (incoming edges)
284                ensure!(
285                    !has_outgoing || has_incoming,
286                    "SED commodity {commodity_id} may be consumed but has no producers"
287                );
288            }
289            CommodityType::Other => {
290                // OTH: cannot have both incoming and outgoing edges
291                ensure!(
292                    !(has_incoming && has_outgoing),
293                    "OTH commodity {commodity_id} cannot have both producers and consumers"
294                );
295            }
296        }
297    }
298
299    Ok(())
300}
301
302/// Performs topological sort on the commodity graph to get the ordering for investments
303///
304/// The returned Vec only includes SVD and SED commodities.
305fn topo_sort_commodities(
306    graph: &CommoditiesGraph,
307    commodities: &CommodityMap,
308) -> Result<Vec<CommodityID>> {
309    // We only consider primary edges
310    let primary_graph =
311        EdgeFiltered::from_fn(graph, |edge| matches!(edge.weight(), GraphEdge::Primary(_)));
312
313    // Perform a topological sort on the graph
314    let order = toposort(&primary_graph, None).map_err(|cycle| {
315        let cycle_commodity = graph.node_weight(cycle.node_id()).unwrap().clone();
316        anyhow!("Cycle detected in commodity graph for commodity {cycle_commodity}")
317    })?;
318
319    // We return the order in reverse so that leaf-node commodities are solved first
320    // We also filter to only include SVD and SED commodities
321    let order = order
322        .iter()
323        .rev()
324        .filter_map(|node_idx| {
325            // Get the commodity for the node
326            let GraphNode::Commodity(commodity_id) = graph.node_weight(*node_idx).unwrap() else {
327                // Skip special nodes
328                return None;
329            };
330            let commodity = &commodities[commodity_id];
331
332            // Only include SVD and SED commodities
333            matches!(
334                commodity.kind,
335                CommodityType::ServiceDemand | CommodityType::SupplyEqualsDemand
336            )
337            .then(|| commodity_id.clone())
338        })
339        .collect();
340
341    Ok(order)
342}
343
344/// Builds base commodity graphs for each region and year
345///
346/// These do not take into account demand and process availability
347pub fn build_commodity_graphs_for_model(
348    processes: &ProcessMap,
349    region_ids: &IndexSet<RegionID>,
350    years: &[u32],
351) -> Result<HashMap<(RegionID, u32), CommoditiesGraph>> {
352    let commodity_graphs: HashMap<(RegionID, u32), CommoditiesGraph> =
353        iproduct!(region_ids, years.iter())
354            .map(|(region_id, year)| {
355                let graph = create_commodities_graph_for_region_year(processes, region_id, *year);
356                ((region_id.clone(), *year), graph)
357            })
358            .collect();
359
360    Ok(commodity_graphs)
361}
362
363/// Validates commodity graphs for the entire model.
364///
365/// This function creates commodity flow graphs for each region/year combination in the model,
366/// validates the graph structure against commodity type rules, and determines the optimal
367/// investment order for commodities.
368///
369/// The validation process checks three time slice levels:
370/// - **Annual**: Validates annual-level commodities and processes
371/// - **Seasonal**: Validates seasonal-level commodities and processes for each season
372/// - **Day/Night**: Validates day/night-level commodities and processes for each time slice
373///
374/// # Arguments
375///
376/// * `processes` - All processes in the model with their flows and activity limits
377/// * `commodities` - All commodities with their types and demand specifications
378/// * `region_ids` - Collection of regions to model
379/// * `years` - Years to analyse
380/// * `time_slice_info` - Time slice configuration (seasons, day/night periods)
381///
382/// # Returns
383///
384/// A map from `(region, year)` to the ordered list of commodities for investment decisions. The
385/// ordering ensures that leaf-node commodities (those with no outgoing edges) are solved first.
386///
387/// # Errors
388///
389/// Returns an error if:
390/// - Any commodity graph contains cycles
391/// - Commodity type rules are violated (e.g., SVD commodities being consumed)
392/// - Demand cannot be satisfied
393pub fn validate_commodity_graphs_for_model(
394    commodity_graphs: &HashMap<(RegionID, u32), CommoditiesGraph>,
395    processes: &ProcessMap,
396    commodities: &CommodityMap,
397    time_slice_info: &TimeSliceInfo,
398) -> Result<HashMap<(RegionID, u32), Vec<CommodityID>>> {
399    // Determine commodity ordering for each region and year
400    let commodity_order: HashMap<(RegionID, u32), Vec<CommodityID>> = commodity_graphs
401        .iter()
402        .map(|((region_id, year), graph)| -> Result<_> {
403            let order = topo_sort_commodities(graph, commodities).with_context(|| {
404                format!("Error validating commodity graph for {region_id} in {year}")
405            })?;
406            Ok(((region_id.clone(), *year), order))
407        })
408        .try_collect()?;
409
410    // Validate graphs at all time slice levels (taking into account process availability and demand)
411    for ((region_id, year), base_graph) in commodity_graphs {
412        for ts_level in TimeSliceLevel::iter() {
413            for ts_selection in time_slice_info.iter_selections_at_level(ts_level) {
414                let graph = prepare_commodities_graph_for_validation(
415                    base_graph,
416                    processes,
417                    commodities,
418                    time_slice_info,
419                    region_id,
420                    *year,
421                    &ts_selection,
422                );
423                validate_commodities_graph(&graph, commodities, ts_level).with_context(|| {
424                    format!(
425                        "Error validating commodity graph for \
426                            {region_id} in {year} in {ts_selection}"
427                    )
428                })?;
429            }
430        }
431    }
432
433    // If all the validation passes, return the commodity ordering
434    Ok(commodity_order)
435}
436
437/// Gets custom DOT attributes for edges in a commodity graph
438fn get_edge_attributes(_: &CommoditiesGraph, edge_ref: EdgeReference<GraphEdge>) -> String {
439    match edge_ref.weight() {
440        // Use dashed lines for secondary flows
441        GraphEdge::Secondary(_) => "style=dashed".to_string(),
442        // Other edges use default attributes
443        _ => String::new(),
444    }
445}
446
447/// Saves commodity graphs to file
448///
449/// The graphs are saved as DOT files to the specified output path
450pub fn save_commodity_graphs_for_model(
451    commodity_graphs: &HashMap<(RegionID, u32), CommoditiesGraph>,
452    output_path: &Path,
453) -> Result<()> {
454    for ((region_id, year), graph) in commodity_graphs {
455        let dot = Dot::with_attr_getters(
456            graph,
457            &[],
458            &get_edge_attributes,  // Custom attributes for edges
459            &|_, _| String::new(), // Use default attributes for nodes
460        );
461        let mut file = File::create(output_path.join(format!("{region_id}_{year}.dot")))?;
462        write!(file, "{dot}")?;
463    }
464    Ok(())
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use crate::commodity::Commodity;
471    use crate::fixture::{assert_error, other_commodity, sed_commodity, svd_commodity};
472    use petgraph::graph::Graph;
473    use rstest::rstest;
474    use std::rc::Rc;
475
476    #[rstest]
477    fn test_topo_sort_linear_graph(sed_commodity: Commodity, svd_commodity: Commodity) {
478        // Create a simple linear graph: A -> B -> C
479        let mut graph = Graph::new();
480
481        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
482        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
483        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
484
485        // Add edges: A -> B -> C
486        graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
487        graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into()));
488
489        // Create commodities map using fixtures
490        let mut commodities = CommodityMap::new();
491        commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
492        commodities.insert("B".into(), Rc::new(sed_commodity));
493        commodities.insert("C".into(), Rc::new(svd_commodity));
494
495        let result = topo_sort_commodities(&graph, &commodities).unwrap();
496
497        // Expected order: C, B, A (leaf nodes first)
498        assert_eq!(result.len(), 3);
499        assert_eq!(result[0], "C".into());
500        assert_eq!(result[1], "B".into());
501        assert_eq!(result[2], "A".into());
502    }
503
504    #[rstest]
505    fn test_topo_sort_cyclic_graph(sed_commodity: Commodity) {
506        // Create a simple cyclic graph: A -> B -> A
507        let mut graph = Graph::new();
508
509        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
510        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
511
512        // Add edges creating a cycle: A -> B -> A
513        graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
514        graph.add_edge(node_b, node_a, GraphEdge::Primary("process2".into()));
515
516        // Create commodities map using fixtures
517        let mut commodities = CommodityMap::new();
518        commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
519        commodities.insert("B".into(), Rc::new(sed_commodity));
520
521        // This should return an error due to the cycle
522        // The error message should flag commodity B
523        // Note: A is also involved in the cycle, but B is flagged as it is encountered first
524        let result = topo_sort_commodities(&graph, &commodities);
525        assert_error!(result, "Cycle detected in commodity graph for commodity B");
526    }
527
528    #[rstest]
529    fn test_validate_commodities_graph(
530        other_commodity: Commodity,
531        sed_commodity: Commodity,
532        svd_commodity: Commodity,
533    ) {
534        let mut graph = Graph::new();
535        let mut commodities = CommodityMap::new();
536
537        // Add test commodities (all have DayNight time slice level)
538        commodities.insert("A".into(), Rc::new(other_commodity));
539        commodities.insert("B".into(), Rc::new(sed_commodity));
540        commodities.insert("C".into(), Rc::new(svd_commodity));
541
542        // Build valid graph: A(OTH) -> B(SED) -> C(SVD) ->D(DEMAND)
543        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
544        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
545        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
546        let node_d = graph.add_node(GraphNode::Demand);
547        graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
548        graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into()));
549        graph.add_edge(node_c, node_d, GraphEdge::Demand);
550
551        // Validate the graph at DayNight level
552        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::Annual);
553        assert!(result.is_ok());
554    }
555
556    #[rstest]
557    fn test_validate_commodities_graph_invalid_svd_consumed(
558        svd_commodity: Commodity,
559        sed_commodity: Commodity,
560        other_commodity: Commodity,
561    ) {
562        let mut graph = Graph::new();
563        let mut commodities = CommodityMap::new();
564
565        // Add test commodities (all have DayNight time slice level)
566        commodities.insert("A".into(), Rc::new(svd_commodity));
567        commodities.insert("B".into(), Rc::new(sed_commodity));
568        commodities.insert("C".into(), Rc::new(other_commodity));
569
570        // Build invalid graph: C(OTH) -> A(SVD) -> B(SED) - SVD cannot be consumed
571        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
572        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
573        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
574        graph.add_edge(node_c, node_a, GraphEdge::Primary("process1".into()));
575        graph.add_edge(node_a, node_b, GraphEdge::Primary("process2".into()));
576
577        // Validate the graph at DayNight level
578        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
579        assert_error!(result, "SVD commodity A cannot be an input to a process");
580    }
581
582    #[rstest]
583    fn test_validate_commodities_graph_invalid_svd_not_produced(svd_commodity: Commodity) {
584        let mut graph = Graph::new();
585        let mut commodities = CommodityMap::new();
586
587        // Add test commodities (all have DayNight time slice level)
588        commodities.insert("A".into(), Rc::new(svd_commodity));
589
590        // Build invalid graph: A(SVD) -> B(DEMAND) - SVD must be produced
591        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
592        let node_b = graph.add_node(GraphNode::Demand);
593        graph.add_edge(node_a, node_b, GraphEdge::Demand);
594
595        // Validate the graph at DayNight level
596        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
597        assert_error!(result, "SVD commodity A is demanded but has no producers");
598    }
599
600    #[rstest]
601    fn test_validate_commodities_graph_invalid_sed(sed_commodity: Commodity) {
602        let mut graph = Graph::new();
603        let mut commodities = CommodityMap::new();
604
605        // Add test commodities (all have DayNight time slice level)
606        commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
607        commodities.insert("B".into(), Rc::new(sed_commodity));
608
609        // Build invalid graph: B(SED) -> A(SED)
610        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
611        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
612        graph.add_edge(node_b, node_a, GraphEdge::Primary("process1".into()));
613
614        // Validate the graph at DayNight level
615        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
616        assert_error!(
617            result,
618            "SED commodity B may be consumed but has no producers"
619        );
620    }
621
622    #[rstest]
623    fn test_validate_commodities_graph_invalid_oth(
624        other_commodity: Commodity,
625        sed_commodity: Commodity,
626    ) {
627        let mut graph = Graph::new();
628        let mut commodities = CommodityMap::new();
629
630        // Add test commodities (all have DayNight time slice level)
631        commodities.insert("A".into(), Rc::new(other_commodity));
632        commodities.insert("B".into(), Rc::new(sed_commodity.clone()));
633        commodities.insert("C".into(), Rc::new(sed_commodity));
634
635        // Build invalid graph: B(SED) -> A(OTH) -> C(SED)
636        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
637        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
638        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
639        graph.add_edge(node_b, node_a, GraphEdge::Primary("process1".into()));
640        graph.add_edge(node_a, node_c, GraphEdge::Primary("process2".into()));
641
642        // Validate the graph at DayNight level
643        let result = validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight);
644        assert_error!(
645            result,
646            "OTH commodity A cannot have both producers and consumers"
647        );
648    }
649}