Skip to main content

muse2/graph/
validate.rs

1//! Module for validating commodity graphs
2use super::{CommoditiesGraph, GraphEdge, GraphNode};
3use crate::commodity::{CommodityMap, CommodityType};
4use crate::process::{Process, ProcessMap};
5use crate::region::RegionID;
6use crate::time_slice::{TimeSliceInfo, TimeSliceLevel, TimeSliceSelection};
7use crate::units::{Dimensionless, Flow};
8use anyhow::{Context, Result, ensure};
9use indexmap::IndexMap;
10use strum::IntoEnumIterator;
11
12/// Prepares a graph for validation with [`validate_commodities_graph`].
13///
14/// It takes a base graph produced by `create_commodities_graph_for_year`, and modifies it to
15/// account for process availabilities and commodity demands within the given time slice selection,
16/// returning a new graph.
17///
18/// Commodity demands are represented by the `Demand` node. We only add edges to the `Demand` node
19/// for commodities with the same `time_slice_level` as the selection. Other demands can be ignored
20/// since this graph will only be validated for commodities with the same `time_slice_level` as the
21/// selection.
22fn prepare_commodities_graph_for_validation(
23    base_graph: &CommoditiesGraph,
24    processes: &ProcessMap,
25    commodities: &CommodityMap,
26    year: u32,
27    time_slice_selection: &TimeSliceSelection,
28) -> CommoditiesGraph {
29    let mut filtered_graph = base_graph.clone();
30
31    // Filter by process availability
32    // We keep edges if the process has availability > 0 in any time slice in the selection
33    // Each edge carries the asset's installing region, so we can look up the correct
34    // ActivityLimits for that (process, region, year) combination.
35    filtered_graph.retain_edges(|graph, edge_idx| {
36        // Get the process/region for the edge
37        let (process_id, region_id) = match graph.edge_weight(edge_idx).unwrap() {
38            GraphEdge::Primary(process_id, region_id)
39            | GraphEdge::Secondary(process_id, region_id) => (process_id, region_id),
40            GraphEdge::Demand => panic!("Demand edges should not be present in the base graph"),
41        };
42        let process = &processes[process_id];
43
44        // Check if the process in this region has availability > 0 in any time slice in the
45        // selection
46        can_be_active(process, &(region_id.clone(), year), time_slice_selection)
47    });
48
49    // Add demand edges
50    // We add an edge to the `Demand` node for each (commodity, region) pair that has positive
51    // demand for this year and time slice selection
52    // NOTE: we only do this for commodities with the same time_slice_level as the selection
53    let demand_node_index = filtered_graph.add_node(GraphNode::Demand);
54    for (commodity_id, commodity) in commodities {
55        for ((region_id, dem_year, dem_ts_sel), &flow) in &commodity.demand {
56            if *dem_year != year || dem_ts_sel != time_slice_selection || flow <= Flow(0.0) {
57                continue;
58            }
59            let commodity_node = GraphNode::Commodity(commodity_id.clone(), region_id.clone());
60            let commodity_node_index = filtered_graph
61                .node_indices()
62                .find(|&idx| filtered_graph.node_weight(idx) == Some(&commodity_node))
63                .unwrap_or_else(|| filtered_graph.add_node(commodity_node));
64            filtered_graph.add_edge(commodity_node_index, demand_node_index, GraphEdge::Demand);
65        }
66    }
67
68    filtered_graph
69}
70
71/// Checks if a process can be active for a particular time slice in a given year and region
72///
73/// It considers all commission years that can lead to a running process in the target region and
74/// year, accounting for the process lifetime, and then checks if, for any of those, the process
75/// is active in the required time slice. In other words, this checks if there is the _possibility_
76/// of having an active process, although there is no guarantee of that happening since it depends
77/// on the investment.
78fn can_be_active(
79    process: &Process,
80    target: &(RegionID, u32),
81    time_slice_selection: &TimeSliceSelection,
82) -> bool {
83    let (target_region, target_year) = target;
84
85    for ((region, year), value) in &process.parameters {
86        if region != target_region {
87            continue;
88        }
89        if year + value.lifetime >= *target_year {
90            let Some(limits_map) = process.activity_limits.get(target) else {
91                continue;
92            };
93            if limits_map.get_limit(time_slice_selection).end() > &Dimensionless(0.0) {
94                return true;
95            }
96        }
97    }
98    false
99}
100
101/// Validates that the commodity graph follows the rules for different commodity types.
102///
103/// It takes as input a graph prepared by `prepare_commodities_graph_for_validation`, which is built
104/// for a specific time slice selection (must match the `time_slice_level` passed to this function).
105///
106/// The validation is only performed for commodities with the specified time slice level. For full
107/// validation of all commodities in the model, we therefore need to run this function for all time
108/// slice selections at all time slice levels. This is handled by
109/// [`validate_commodity_graphs_for_model`].
110fn validate_commodities_graph(
111    graph: &CommoditiesGraph,
112    commodities: &CommodityMap,
113    time_slice_level: TimeSliceLevel,
114) -> Result<()> {
115    for node_idx in graph.node_indices() {
116        // Get the commodity ID and region for the node
117        let graph_node = graph.node_weight(node_idx).unwrap();
118        let GraphNode::Commodity(commodity_id, region_id) = graph_node else {
119            // Skip special nodes
120            continue;
121        };
122
123        // Only validate commodities with the specified time slice level
124        let commodity = &commodities[commodity_id];
125        if commodity.time_slice_level != time_slice_level {
126            continue;
127        }
128
129        // Count the incoming and outgoing edges for the commodity
130        let has_incoming = graph
131            .edges_directed(node_idx, petgraph::Direction::Incoming)
132            .next()
133            .is_some();
134        let has_outgoing = graph
135            .edges_directed(node_idx, petgraph::Direction::Outgoing)
136            .next()
137            .is_some();
138
139        // Match validation rules to commodity type
140        match commodity.kind {
141            CommodityType::ServiceDemand => {
142                // Cannot have outgoing `Primary`/`Secondary` (non-`Demand`) edges
143                let has_non_demand_outgoing = graph
144                    .edges_directed(node_idx, petgraph::Direction::Outgoing)
145                    .any(|edge| edge.weight() != &GraphEdge::Demand);
146                ensure!(
147                    !has_non_demand_outgoing,
148                    "SVD commodity {commodity_id} in region {region_id} cannot be an input to a process"
149                );
150
151                // If it has `Demand` edges, it must have at least one producer
152                let has_demand_edges = graph
153                    .edges_directed(node_idx, petgraph::Direction::Outgoing)
154                    .any(|edge| edge.weight() == &GraphEdge::Demand);
155                if has_demand_edges {
156                    ensure!(
157                        has_incoming,
158                        "SVD commodity {commodity_id} in region {region_id} is demanded but has no producers"
159                    );
160                }
161            }
162            CommodityType::SupplyEqualsDemand => {
163                // SED: if consumed (outgoing edges), must also be produced (incoming edges)
164                ensure!(
165                    !has_outgoing || has_incoming,
166                    "SED commodity {commodity_id} in region {region_id} may be consumed but has no producers"
167                );
168            }
169            CommodityType::Other => {
170                // OTH: cannot have both incoming and outgoing edges
171                ensure!(
172                    !(has_incoming && has_outgoing),
173                    "OTH commodity {commodity_id} in region {region_id} cannot have both producers and consumers"
174                );
175            }
176        }
177    }
178
179    Ok(())
180}
181
182/// Validates commodity graphs for the entire model.
183///
184/// The validation process checks three time slice levels:
185/// - **Annual**: Validates annual-level commodities and processes
186/// - **Seasonal**: Validates seasonal-level commodities and processes for each season
187/// - **Day/Night**: Validates day/night-level commodities and processes for each time slice
188///
189/// # Arguments
190///
191/// * `commodity_graphs` - Commodity graphs for each year, outputted from `build_commodity_graphs_for_model`
192/// * `processes` - All processes in the model with their flows and activity limits
193/// * `commodities` - All commodities with their types and demand specifications
194/// * `time_slice_info` - Time slice configuration (seasons, day/night periods)
195///
196/// # Errors
197///
198/// Returns an error if:
199/// - Commodity type rules are violated (e.g., SVD commodities being consumed)
200/// - Demand cannot be satisfied
201pub fn validate_commodity_graphs_for_model(
202    commodity_graphs: &IndexMap<u32, CommoditiesGraph>,
203    processes: &ProcessMap,
204    commodities: &CommodityMap,
205    time_slice_info: &TimeSliceInfo,
206) -> Result<()> {
207    // Validate graphs at all time slice levels (taking into account process availability and demand)
208    for (&year, base_graph) in commodity_graphs {
209        for ts_level in TimeSliceLevel::iter() {
210            for ts_selection in time_slice_info.iter_selections_at_level(ts_level) {
211                let graph = prepare_commodities_graph_for_validation(
212                    base_graph,
213                    processes,
214                    commodities,
215                    year,
216                    &ts_selection,
217                );
218                validate_commodities_graph(&graph, commodities, ts_level).with_context(|| {
219                    format!("Error validating commodity graph for {year} in {ts_selection}")
220                })?;
221            }
222        }
223    }
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::commodity::Commodity;
231    use crate::fixture::{assert_error, other_commodity, sed_commodity, svd_commodity};
232    use petgraph::graph::Graph;
233    use rstest::rstest;
234    use std::sync::Arc;
235
236    #[rstest]
237    fn validate_commodities_graph_works(
238        other_commodity: Commodity,
239        sed_commodity: Commodity,
240        svd_commodity: Commodity,
241    ) {
242        let mut graph = Graph::new();
243        let mut commodities = CommodityMap::new();
244
245        // Add test commodities (all have DayNight time slice level)
246        commodities.insert("A".into(), Arc::new(other_commodity));
247        commodities.insert("B".into(), Arc::new(sed_commodity));
248        commodities.insert("C".into(), Arc::new(svd_commodity));
249
250        // Build valid graph: A(OTH) -> B(SED) -> C(SVD) ->D(DEMAND)
251        let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into()));
252        let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into()));
253        let node_c = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into()));
254        let node_d = graph.add_node(GraphNode::Demand);
255        graph.add_edge(
256            node_a,
257            node_b,
258            GraphEdge::Primary("process1".into(), "GBR".into()),
259        );
260        graph.add_edge(
261            node_b,
262            node_c,
263            GraphEdge::Primary("process2".into(), "GBR".into()),
264        );
265        graph.add_edge(node_c, node_d, GraphEdge::Demand);
266
267        // Validate the graph at DayNight level
268        validate_commodities_graph(&graph, &commodities, TimeSliceLevel::Annual).unwrap();
269    }
270
271    #[rstest]
272    fn validate_commodities_graph_invalid_svd_consumed(
273        svd_commodity: Commodity,
274        sed_commodity: Commodity,
275        other_commodity: Commodity,
276    ) {
277        let mut graph = Graph::new();
278        let mut commodities = CommodityMap::new();
279
280        // Add test commodities (all have DayNight time slice level)
281        commodities.insert("A".into(), Arc::new(svd_commodity));
282        commodities.insert("B".into(), Arc::new(sed_commodity));
283        commodities.insert("C".into(), Arc::new(other_commodity));
284
285        // Build invalid graph: C(OTH) -> A(SVD) -> B(SED) - SVD cannot be consumed
286        let node_c = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into()));
287        let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into()));
288        let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into()));
289        graph.add_edge(
290            node_c,
291            node_a,
292            GraphEdge::Primary("process1".into(), "GBR".into()),
293        );
294        graph.add_edge(
295            node_a,
296            node_b,
297            GraphEdge::Primary("process2".into(), "GBR".into()),
298        );
299
300        // Validate the graph at DayNight level
301        assert_error!(
302            validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight),
303            "SVD commodity A in region GBR cannot be an input to a process"
304        );
305    }
306
307    #[rstest]
308    fn validate_commodities_graph_invalid_svd_not_produced(svd_commodity: Commodity) {
309        let mut graph = Graph::new();
310        let mut commodities = CommodityMap::new();
311
312        // Add test commodities (all have DayNight time slice level)
313        commodities.insert("A".into(), Arc::new(svd_commodity));
314
315        // Build invalid graph: A(SVD) -> B(DEMAND) - SVD must be produced
316        let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into()));
317        let node_b = graph.add_node(GraphNode::Demand);
318        graph.add_edge(node_a, node_b, GraphEdge::Demand);
319
320        // Validate the graph at DayNight level
321        assert_error!(
322            validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight),
323            "SVD commodity A in region GBR is demanded but has no producers"
324        );
325    }
326
327    #[rstest]
328    fn validate_commodities_graph_invalid_sed(sed_commodity: Commodity) {
329        let mut graph = Graph::new();
330        let mut commodities = CommodityMap::new();
331
332        // Add test commodities (all have DayNight time slice level)
333        commodities.insert("A".into(), Arc::new(sed_commodity.clone()));
334        commodities.insert("B".into(), Arc::new(sed_commodity));
335
336        // Build invalid graph: B(SED) -> A(SED)
337        let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into()));
338        let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into()));
339        graph.add_edge(
340            node_b,
341            node_a,
342            GraphEdge::Primary("process1".into(), "GBR".into()),
343        );
344
345        // Validate the graph at DayNight level
346        assert_error!(
347            validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight),
348            "SED commodity B in region GBR may be consumed but has no producers"
349        );
350    }
351
352    #[rstest]
353    fn validate_commodities_graph_invalid_oth(
354        other_commodity: Commodity,
355        sed_commodity: Commodity,
356    ) {
357        let mut graph = Graph::new();
358        let mut commodities = CommodityMap::new();
359
360        // Add test commodities (all have DayNight time slice level)
361        commodities.insert("A".into(), Arc::new(other_commodity));
362        commodities.insert("B".into(), Arc::new(sed_commodity.clone()));
363        commodities.insert("C".into(), Arc::new(sed_commodity));
364
365        // Build invalid graph: B(SED) -> A(OTH) -> C(SED)
366        let node_a = graph.add_node(GraphNode::Commodity("A".into(), "GBR".into()));
367        let node_b = graph.add_node(GraphNode::Commodity("B".into(), "GBR".into()));
368        let node_c = graph.add_node(GraphNode::Commodity("C".into(), "GBR".into()));
369        graph.add_edge(
370            node_b,
371            node_a,
372            GraphEdge::Primary("process1".into(), "GBR".into()),
373        );
374        graph.add_edge(
375            node_a,
376            node_c,
377            GraphEdge::Primary("process2".into(), "GBR".into()),
378        );
379
380        // Validate the graph at DayNight level
381        assert_error!(
382            validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight),
383            "OTH commodity A in region GBR cannot have both producers and consumers"
384        );
385    }
386}