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