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    region_id: &RegionID,
27    year: u32,
28    time_slice_selection: &TimeSliceSelection,
29) -> CommoditiesGraph {
30    let mut filtered_graph = base_graph.clone();
31
32    // Filter by process availability
33    // We keep edges if the process has availability > 0 in any time slice in the selection
34    let key = (region_id.clone(), year);
35    filtered_graph.retain_edges(|graph, edge_idx| {
36        // Get the process for the edge
37        let process_id = match graph.edge_weight(edge_idx).unwrap() {
38            GraphEdge::Primary(process_id) | GraphEdge::Secondary(process_id) => process_id,
39            GraphEdge::Demand => panic!("Demand edges should not be present in the base graph"),
40        };
41        let process = &processes[process_id];
42
43        // Check if the process has availability > 0 in any time slice in the selection
44        can_be_active(process, &key, time_slice_selection)
45    });
46
47    // Add demand edges
48    // We add edges to the `Demand` node for commodities that are demanded in the selection
49    // NOTE: we only do this for commodities with the same time_slice_level as the selection
50    let demand_node_index = filtered_graph.add_node(GraphNode::Demand);
51    for (commodity_id, commodity) in commodities {
52        if commodity
53            .demand
54            .get(&(region_id.clone(), year, time_slice_selection.clone()))
55            .is_some_and(|&v| v > Flow(0.0))
56        {
57            let commodity_node = GraphNode::Commodity(commodity_id.clone());
58            let commodity_node_index = filtered_graph
59                .node_indices()
60                .find(|&idx| filtered_graph.node_weight(idx) == Some(&commodity_node))
61                .unwrap_or_else(|| {
62                    filtered_graph.add_node(GraphNode::Commodity(commodity_id.clone()))
63                });
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 timeslice 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 timeslice. 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 for the node
117        let graph_node = graph.node_weight(node_idx).unwrap();
118        let GraphNode::Commodity(commodity_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} 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} 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} 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} 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 region and 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/// * `region_ids` - Collection of regions to model
195/// * `years` - Years to analyse
196/// * `time_slice_info` - Time slice configuration (seasons, day/night periods)
197///
198/// # Errors
199///
200/// Returns an error if:
201/// - Commodity type rules are violated (e.g., SVD commodities being consumed)
202/// - Demand cannot be satisfied
203pub fn validate_commodity_graphs_for_model(
204    commodity_graphs: &IndexMap<(RegionID, u32), CommoditiesGraph>,
205    processes: &ProcessMap,
206    commodities: &CommodityMap,
207    time_slice_info: &TimeSliceInfo,
208) -> Result<()> {
209    // Validate graphs at all time slice levels (taking into account process availability and demand)
210    for ((region_id, year), base_graph) in commodity_graphs {
211        for ts_level in TimeSliceLevel::iter() {
212            for ts_selection in time_slice_info.iter_selections_at_level(ts_level) {
213                let graph = prepare_commodities_graph_for_validation(
214                    base_graph,
215                    processes,
216                    commodities,
217                    region_id,
218                    *year,
219                    &ts_selection,
220                );
221                validate_commodities_graph(&graph, commodities, ts_level).with_context(|| {
222                    format!(
223                        "Error validating commodity graph for \
224                            {region_id} in {year} in {ts_selection}"
225                    )
226                })?;
227            }
228        }
229    }
230    Ok(())
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::commodity::Commodity;
237    use crate::fixture::{assert_error, other_commodity, sed_commodity, svd_commodity};
238    use petgraph::graph::Graph;
239    use rstest::rstest;
240    use std::rc::Rc;
241
242    #[rstest]
243    fn validate_commodities_graph_works(
244        other_commodity: Commodity,
245        sed_commodity: Commodity,
246        svd_commodity: Commodity,
247    ) {
248        let mut graph = Graph::new();
249        let mut commodities = CommodityMap::new();
250
251        // Add test commodities (all have DayNight time slice level)
252        commodities.insert("A".into(), Rc::new(other_commodity));
253        commodities.insert("B".into(), Rc::new(sed_commodity));
254        commodities.insert("C".into(), Rc::new(svd_commodity));
255
256        // Build valid graph: A(OTH) -> B(SED) -> C(SVD) ->D(DEMAND)
257        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
258        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
259        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
260        let node_d = graph.add_node(GraphNode::Demand);
261        graph.add_edge(node_a, node_b, GraphEdge::Primary("process1".into()));
262        graph.add_edge(node_b, node_c, GraphEdge::Primary("process2".into()));
263        graph.add_edge(node_c, node_d, GraphEdge::Demand);
264
265        // Validate the graph at DayNight level
266        validate_commodities_graph(&graph, &commodities, TimeSliceLevel::Annual).unwrap();
267    }
268
269    #[rstest]
270    fn validate_commodities_graph_invalid_svd_consumed(
271        svd_commodity: Commodity,
272        sed_commodity: Commodity,
273        other_commodity: Commodity,
274    ) {
275        let mut graph = Graph::new();
276        let mut commodities = CommodityMap::new();
277
278        // Add test commodities (all have DayNight time slice level)
279        commodities.insert("A".into(), Rc::new(svd_commodity));
280        commodities.insert("B".into(), Rc::new(sed_commodity));
281        commodities.insert("C".into(), Rc::new(other_commodity));
282
283        // Build invalid graph: C(OTH) -> A(SVD) -> B(SED) - SVD cannot be consumed
284        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
285        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
286        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
287        graph.add_edge(node_c, node_a, GraphEdge::Primary("process1".into()));
288        graph.add_edge(node_a, node_b, GraphEdge::Primary("process2".into()));
289
290        // Validate the graph at DayNight level
291        assert_error!(
292            validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight),
293            "SVD commodity A cannot be an input to a process"
294        );
295    }
296
297    #[rstest]
298    fn validate_commodities_graph_invalid_svd_not_produced(svd_commodity: Commodity) {
299        let mut graph = Graph::new();
300        let mut commodities = CommodityMap::new();
301
302        // Add test commodities (all have DayNight time slice level)
303        commodities.insert("A".into(), Rc::new(svd_commodity));
304
305        // Build invalid graph: A(SVD) -> B(DEMAND) - SVD must be produced
306        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
307        let node_b = graph.add_node(GraphNode::Demand);
308        graph.add_edge(node_a, node_b, GraphEdge::Demand);
309
310        // Validate the graph at DayNight level
311        assert_error!(
312            validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight),
313            "SVD commodity A is demanded but has no producers"
314        );
315    }
316
317    #[rstest]
318    fn validate_commodities_graph_invalid_sed(sed_commodity: Commodity) {
319        let mut graph = Graph::new();
320        let mut commodities = CommodityMap::new();
321
322        // Add test commodities (all have DayNight time slice level)
323        commodities.insert("A".into(), Rc::new(sed_commodity.clone()));
324        commodities.insert("B".into(), Rc::new(sed_commodity));
325
326        // Build invalid graph: B(SED) -> A(SED)
327        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
328        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
329        graph.add_edge(node_b, node_a, GraphEdge::Primary("process1".into()));
330
331        // Validate the graph at DayNight level
332        assert_error!(
333            validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight),
334            "SED commodity B may be consumed but has no producers"
335        );
336    }
337
338    #[rstest]
339    fn validate_commodities_graph_invalid_oth(
340        other_commodity: Commodity,
341        sed_commodity: Commodity,
342    ) {
343        let mut graph = Graph::new();
344        let mut commodities = CommodityMap::new();
345
346        // Add test commodities (all have DayNight time slice level)
347        commodities.insert("A".into(), Rc::new(other_commodity));
348        commodities.insert("B".into(), Rc::new(sed_commodity.clone()));
349        commodities.insert("C".into(), Rc::new(sed_commodity));
350
351        // Build invalid graph: B(SED) -> A(OTH) -> C(SED)
352        let node_a = graph.add_node(GraphNode::Commodity("A".into()));
353        let node_b = graph.add_node(GraphNode::Commodity("B".into()));
354        let node_c = graph.add_node(GraphNode::Commodity("C".into()));
355        graph.add_edge(node_b, node_a, GraphEdge::Primary("process1".into()));
356        graph.add_edge(node_a, node_c, GraphEdge::Primary("process2".into()));
357
358        // Validate the graph at DayNight level
359        assert_error!(
360            validate_commodities_graph(&graph, &commodities, TimeSliceLevel::DayNight),
361            "OTH commodity A cannot have both producers and consumers"
362        );
363    }
364}