From 98ff084dcf984668e768bd37f8ad4d7917d0486f Mon Sep 17 00:00:00 2001 From: cRennert Date: Wed, 6 May 2026 20:01:23 +0200 Subject: [PATCH 1/9] Added functionality to translate a process tree into a Petri net --- .../process_tree/process_tree_struct.rs | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) diff --git a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs index 543f39e..d1ce48e 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs @@ -1,3 +1,5 @@ +use crate::core::process_models::petri_net::{ArcType, PlaceID}; +use crate::PetriNet; use serde::{Deserialize, Serialize}; /// @@ -64,6 +66,22 @@ impl Node { Node::Leaf(_) => true, } } + + /// + /// Calls either [`Operator::add_to_petri_net`] or [`Leaf::add_to_petri_net`] depending on the + /// [`Node`] type. + /// + pub fn add_to_petri_net( + &self, + net: &mut PetriNet, + in_place: Option, + out_place: Option, + ) -> (PlaceID, PlaceID) { + match self { + Node::Operator(op) => op.add_to_petri_net(net, in_place, out_place), + Node::Leaf(leaf) => leaf.add_to_petri_net(net, in_place, out_place), + } + } } /// @@ -170,6 +188,28 @@ impl ProcessTree { result } + + /// + /// Transforms a [`ProcessTree`] into a [`PetriNet`] according to the rules defined in + /// "Process Mining: Data Science in Action" by Wil van der Aalst. + /// Returns a workflow net, consisting of the [`PetriNet`], its input, and output place's [`PlaceID`] + /// + pub fn to_petri_net(&self) -> (PetriNet, PlaceID, PlaceID) { + let mut petri_net = PetriNet::new(); + let start_place; + let end_place; + + match &self.root { + Node::Operator(op) => { + (start_place, end_place) = op.add_to_petri_net(&mut petri_net, None, None); + } + Node::Leaf(leaf) => { + (start_place, end_place) = leaf.add_to_petri_net(&mut petri_net, None, None); + } + } + + (petri_net, start_place, end_place) + } } /// @@ -215,6 +255,144 @@ impl Operator { result } + + /// + /// Unfolds an operator and its descendants into corresponding place, transitions, and arcs and + /// adds them to the input [`PetriNet`]. This routine is executed recursively. + /// Optionally, the input and output place of the inserted workflow net can be defined. + /// + pub fn add_to_petri_net( + &self, + net: &mut PetriNet, + in_place: Option, + out_place: Option, + ) -> (PlaceID, PlaceID) { + let in_place = in_place.unwrap_or_else(|| net.add_place(None)); + let out_place = out_place.unwrap_or_else(|| net.add_place(None)); + + let num_of_children = self.children.len(); + + match self.operator_type { + // For a sequence operator, the workflow nets are sequentially connected using each + // previous output place as the input place of the following workflow net. + // The first and last children consider the operators input and output place as their + // input and output place, respectively. + OperatorType::Sequence => { + let mut last_in_place = in_place; + + self.children.iter().enumerate().for_each(|(pos, child)| { + let curr_out_place; + if pos == num_of_children - 1 { + curr_out_place = out_place; + } else { + curr_out_place = net.add_place(None); + } + + match child { + Node::Operator(op) => { + op.add_to_petri_net(net, Some(last_in_place), Some(curr_out_place)); + } + Node::Leaf(leaf) => { + leaf.add_to_petri_net(net, Some(last_in_place), Some(curr_out_place)); + } + } + last_in_place = curr_out_place; + }) + } + // Considers for each child the input and output place of the operator as their input + // and output place + OperatorType::ExclusiveChoice => self.children.iter().for_each(|child| { + child.add_to_petri_net(net, Some(in_place), Some(out_place)); + }), + // Inserts and connects additional silent transitions as start and end and each child + // creates new input and output places that are then connected to the silent start and + // silent end transition. + OperatorType::Concurrency => { + let tau_start_transition = net.add_transition(None, None); + let tau_end_transition = net.add_transition(None, None); + + net.add_arc( + ArcType::PlaceTransition(in_place.get_uuid(), tau_start_transition.get_uuid()), + None, + ); + net.add_arc( + ArcType::TransitionPlace(tau_end_transition.get_uuid(), out_place.get_uuid()), + None, + ); + + self.children.iter().for_each(|child| { + let (child_start, child_end) = child.add_to_petri_net(net, None, None); + + net.add_arc( + ArcType::PlaceTransition( + tau_start_transition.get_uuid(), + child_start.get_uuid(), + ), + None, + ); + net.add_arc( + ArcType::TransitionPlace( + child_end.get_uuid(), + tau_end_transition.get_uuid(), + ), + None, + ); + }) + } + // Inserts silent transitions to put the workflow net of the loop operator in choice + // if other operators are in choice. All workflow nets share the same input and output + // places. However, only the first child models the do-part going from input to output + // place and every other child going from output to input place modelling the redo-part. + OperatorType::Loop => { + let tau_start_transition = net.add_transition(None, None); + let tau_end_transition = net.add_transition(None, None); + + net.add_arc( + ArcType::PlaceTransition(in_place.get_uuid(), tau_start_transition.get_uuid()), + None, + ); + net.add_arc( + ArcType::TransitionPlace(tau_end_transition.get_uuid(), out_place.get_uuid()), + None, + ); + + let loop_start_place = net.add_place(None); + let loop_end_place = net.add_place(None); + + net.add_arc( + ArcType::TransitionPlace( + tau_start_transition.get_uuid(), + loop_start_place.get_uuid(), + ), + None, + ); + net.add_arc( + ArcType::PlaceTransition( + loop_end_place.get_uuid(), + tau_end_transition.get_uuid(), + ), + None, + ); + + self.children.iter().enumerate().for_each(|(pos, child)| { + let child_start; + let child_end; + + if pos == 0 { + child_start = loop_start_place; + child_end = loop_end_place; + } else { + child_start = loop_end_place; + child_end = loop_start_place; + } + + child.add_to_petri_net(net, Some(child_start), Some(child_end)); + }) + } + } + + (in_place, out_place) + } } #[derive(Debug, Serialize, Deserialize)] @@ -242,6 +420,43 @@ impl Leaf { } } } + + /// + /// Adds a (silent) transition to represent the leaf of a tree. Optionally, input and output + /// places can be given to connect the newly created (silent) transition to. The output is + /// the [`PlaceID`] of the input and output place, each. + /// + pub fn add_to_petri_net( + &self, + net: &mut PetriNet, + in_place: Option, + out_place: Option, + ) -> (PlaceID, PlaceID) { + let in_place = in_place.unwrap_or_else(|| net.add_place(None)); + let out_place = out_place.unwrap_or_else(|| net.add_place(None)); + + let leaf_transition; + + match &self.activity_label { + LeafLabel::Activity(label) => { + leaf_transition = net.add_transition(Some(label.clone()), None); + } + LeafLabel::Tau => { + leaf_transition = net.add_transition(None, None); + } + } + + net.add_arc( + ArcType::PlaceTransition(in_place.get_uuid(), leaf_transition.get_uuid()), + None, + ); + net.add_arc( + ArcType::TransitionPlace(leaf_transition.get_uuid(), out_place.get_uuid()), + None, + ); + + (in_place, out_place) + } } #[cfg(test)] @@ -328,4 +543,171 @@ mod tests { let pt = ProcessTree::new(Node::Operator(seq_node)); assert!(pt.is_valid()); } + + // Checking Seq(a,b,c) + #[test] + fn sequence_test() { + let mut seq = Operator::new(OperatorType::Sequence); + + let leaf_a = Leaf::new(Some("a".to_string())); + let leaf_b = Leaf::new(Some("b".to_string())); + let leaf_c = Leaf::new(Some("c".to_string())); + + seq.children.push(Node::Leaf(leaf_a)); + seq.children.push(Node::Leaf(leaf_b)); + seq.children.push(Node::Leaf(leaf_c)); + + let tree = ProcessTree::new(Node::Operator(seq)); + + let (net, _, _) = tree.to_petri_net(); + + assert_eq!(4, net.places.len()); + assert_eq!(3, net.transitions.len()); + assert_eq!(6, net.arcs.len()); + } + + // Checking Conc(a,b,c) + #[test] + fn concurrency_test() { + let mut conc = Operator::new(OperatorType::Concurrency); + + let leaf_a = Leaf::new(Some("a".to_string())); + let leaf_b = Leaf::new(Some("b".to_string())); + let leaf_c = Leaf::new(Some("c".to_string())); + + conc.children.push(Node::Leaf(leaf_a)); + conc.children.push(Node::Leaf(leaf_b)); + conc.children.push(Node::Leaf(leaf_c)); + + let tree = ProcessTree::new(Node::Operator(conc)); + + let (net, _, _) = tree.to_petri_net(); + + assert_eq!(8, net.places.len()); + assert_eq!(5, net.transitions.len()); + assert_eq!(14, net.arcs.len()); + } + + // Checking Loop(a,b,c) + #[test] + fn loop_test() { + let mut loop_op = Operator::new(OperatorType::Loop); + + let leaf_a = Leaf::new(Some("a".to_string())); + let leaf_b = Leaf::new(Some("b".to_string())); + let leaf_c = Leaf::new(Some("c".to_string())); + + loop_op.children.push(Node::Leaf(leaf_a)); + loop_op.children.push(Node::Leaf(leaf_b)); + loop_op.children.push(Node::Leaf(leaf_c)); + + let tree = ProcessTree::new(Node::Operator(loop_op)); + + let (net, _, _) = tree.to_petri_net(); + + assert_eq!(4, net.places.len()); + assert_eq!(5, net.transitions.len()); + assert_eq!(10, net.arcs.len()); + } + + // Checking Xor(a,b,c) + #[test] + fn choice_test() { + let mut choice = Operator::new(OperatorType::ExclusiveChoice); + + let leaf_a = Leaf::new(Some("a".to_string())); + let leaf_b = Leaf::new(Some("b".to_string())); + let leaf_c = Leaf::new(Some("c".to_string())); + + choice.children.push(Node::Leaf(leaf_a)); + choice.children.push(Node::Leaf(leaf_b)); + choice.children.push(Node::Leaf(leaf_c)); + + let tree = ProcessTree::new(Node::Operator(choice)); + + let (net, _, _) = tree.to_petri_net(); + + assert_eq!(2, net.places.len()); + assert_eq!(3, net.transitions.len()); + assert_eq!(6, net.arcs.len()); + } + + // Checking tau + #[test] + fn silent_test() { + let leaf_tau = Leaf::new(None); + + let tree = ProcessTree::new(Node::Leaf(leaf_tau)); + + let (net, _, _) = tree.to_petri_net(); + + assert_eq!(2, net.places.len()); + assert_eq!(1, net.transitions.len()); + assert_eq!(2, net.arcs.len()); + + net.transitions + .iter() + .for_each(|(_, t)| assert!(t.label.is_none())); + } + + // Checking a + #[test] + fn leaf_test() { + let leaf_a = Leaf::new(Some("a".to_string())); + + let tree = ProcessTree::new(Node::Leaf(leaf_a)); + + let (net, _, _) = tree.to_petri_net(); + + assert_eq!(2, net.places.len()); + assert_eq!(1, net.transitions.len()); + assert_eq!(2, net.arcs.len()); + + net.transitions + .iter() + .for_each(|(_, t)| assert_eq!("a", t.label.clone().unwrap())); + } + + // Checking Seq(a, Loop(e, Conc(a,b), f, tau), Xor(b, c, d)) + #[test] + fn all_op_test() { + let mut seq = Operator::new(OperatorType::Sequence); + let leaf_a = Leaf::new(Some("a".to_string())); + seq.children.push(Node::Leaf(leaf_a)); + + let mut conc = Operator::new(OperatorType::Concurrency); + let leaf_a = Leaf::new(Some("a".to_string())); + let leaf_b = Leaf::new(Some("b".to_string())); + + conc.children.push(Node::Leaf(leaf_a)); + conc.children.push(Node::Leaf(leaf_b)); + + let mut loop_op = Operator::new(OperatorType::Loop); + let leaf_e = Leaf::new(Some("e".to_string())); + let leaf_f = Leaf::new(Some("f".to_string())); + let leaf_silent = Leaf::new(None); + + loop_op.children.push(Node::Leaf(leaf_e)); + loop_op.children.push(Node::Operator(conc)); + loop_op.children.push(Node::Leaf(leaf_f)); + loop_op.children.push(Node::Leaf(leaf_silent)); + + let mut choice = Operator::new(OperatorType::ExclusiveChoice); + let leaf_b = Leaf::new(Some("b".to_string())); + let leaf_c = Leaf::new(Some("c".to_string())); + let leaf_d = Leaf::new(Some("d".to_string())); + choice.children.push(Node::Leaf(leaf_b)); + choice.children.push(Node::Leaf(leaf_c)); + choice.children.push(Node::Leaf(leaf_d)); + + seq.children.push(Node::Operator(loop_op)); + seq.children.push(Node::Operator(choice)); + let tree = ProcessTree::new(Node::Operator(seq)); + + let (net, _, _) = tree.to_petri_net(); + + assert_eq!(10, net.places.len()); + assert_eq!(13, net.transitions.len()); + assert_eq!(28, net.arcs.len()); + } } From 7d81757cdbff8cb0d6f9e2b8531aa3abed8be59b Mon Sep 17 00:00:00 2001 From: cRennert Date: Thu, 7 May 2026 10:17:56 +0200 Subject: [PATCH 2/9] Changed output process tree to directly contain the initial and final marking --- .../process_tree/process_tree_struct.rs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs index d1ce48e..34ae486 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs @@ -1,4 +1,4 @@ -use crate::core::process_models::petri_net::{ArcType, PlaceID}; +use crate::core::process_models::petri_net::{ArcType, Marking, PlaceID}; use crate::PetriNet; use serde::{Deserialize, Serialize}; @@ -194,7 +194,7 @@ impl ProcessTree { /// "Process Mining: Data Science in Action" by Wil van der Aalst. /// Returns a workflow net, consisting of the [`PetriNet`], its input, and output place's [`PlaceID`] /// - pub fn to_petri_net(&self) -> (PetriNet, PlaceID, PlaceID) { + pub fn to_petri_net(&self) -> PetriNet { let mut petri_net = PetriNet::new(); let start_place; let end_place; @@ -208,7 +208,15 @@ impl ProcessTree { } } - (petri_net, start_place, end_place) + let mut initial_marking = Marking::new(); + initial_marking.insert(start_place, 1); + petri_net.initial_marking = Some(initial_marking); + + let mut final_marking = Marking::new(); + final_marking.insert(end_place, 1); + petri_net.final_markings = Some(vec![final_marking]); + + petri_net } } @@ -559,7 +567,7 @@ mod tests { let tree = ProcessTree::new(Node::Operator(seq)); - let (net, _, _) = tree.to_petri_net(); + let net = tree.to_petri_net(); assert_eq!(4, net.places.len()); assert_eq!(3, net.transitions.len()); @@ -581,7 +589,7 @@ mod tests { let tree = ProcessTree::new(Node::Operator(conc)); - let (net, _, _) = tree.to_petri_net(); + let net = tree.to_petri_net(); assert_eq!(8, net.places.len()); assert_eq!(5, net.transitions.len()); @@ -603,7 +611,7 @@ mod tests { let tree = ProcessTree::new(Node::Operator(loop_op)); - let (net, _, _) = tree.to_petri_net(); + let net = tree.to_petri_net(); assert_eq!(4, net.places.len()); assert_eq!(5, net.transitions.len()); @@ -625,7 +633,7 @@ mod tests { let tree = ProcessTree::new(Node::Operator(choice)); - let (net, _, _) = tree.to_petri_net(); + let net = tree.to_petri_net(); assert_eq!(2, net.places.len()); assert_eq!(3, net.transitions.len()); @@ -639,7 +647,7 @@ mod tests { let tree = ProcessTree::new(Node::Leaf(leaf_tau)); - let (net, _, _) = tree.to_petri_net(); + let net = tree.to_petri_net(); assert_eq!(2, net.places.len()); assert_eq!(1, net.transitions.len()); @@ -657,7 +665,7 @@ mod tests { let tree = ProcessTree::new(Node::Leaf(leaf_a)); - let (net, _, _) = tree.to_petri_net(); + let net = tree.to_petri_net(); assert_eq!(2, net.places.len()); assert_eq!(1, net.transitions.len()); @@ -704,7 +712,7 @@ mod tests { seq.children.push(Node::Operator(choice)); let tree = ProcessTree::new(Node::Operator(seq)); - let (net, _, _) = tree.to_petri_net(); + let net = tree.to_petri_net(); assert_eq!(10, net.places.len()); assert_eq!(13, net.transitions.len()); From a325d1bd2798de1d9bf1c0dd6ed8460862ccb5b0 Mon Sep 17 00:00:00 2001 From: cRennert Date: Thu, 7 May 2026 10:32:05 +0200 Subject: [PATCH 3/9] Applied clippy changes --- .../process_tree/process_tree_struct.rs | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs index 34ae486..663b7c6 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs @@ -289,12 +289,13 @@ impl Operator { let mut last_in_place = in_place; self.children.iter().enumerate().for_each(|(pos, child)| { - let curr_out_place; - if pos == num_of_children - 1 { - curr_out_place = out_place; - } else { - curr_out_place = net.add_place(None); - } + let curr_out_place = { + if pos == num_of_children - 1 { + out_place + } else { + net.add_place(None) + } + }; match child { Node::Operator(op) => { @@ -443,16 +444,16 @@ impl Leaf { let in_place = in_place.unwrap_or_else(|| net.add_place(None)); let out_place = out_place.unwrap_or_else(|| net.add_place(None)); - let leaf_transition; - - match &self.activity_label { - LeafLabel::Activity(label) => { - leaf_transition = net.add_transition(Some(label.clone()), None); - } - LeafLabel::Tau => { - leaf_transition = net.add_transition(None, None); + let leaf_transition = { + match &self.activity_label { + LeafLabel::Activity(label) => { + net.add_transition(Some(label.clone()), None) + } + LeafLabel::Tau => { + net.add_transition(None, None) + } } - } + }; net.add_arc( ArcType::PlaceTransition(in_place.get_uuid(), leaf_transition.get_uuid()), From 9f7455fb595c8be629b3c4ac38c8c85b04b0ec0c Mon Sep 17 00:00:00 2001 From: cRennert Date: Thu, 7 May 2026 10:59:02 +0200 Subject: [PATCH 4/9] Fixed formatting --- .../case_centric/process_tree/process_tree_struct.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs index 663b7c6..9b4c756 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs @@ -446,12 +446,8 @@ impl Leaf { let leaf_transition = { match &self.activity_label { - LeafLabel::Activity(label) => { - net.add_transition(Some(label.clone()), None) - } - LeafLabel::Tau => { - net.add_transition(None, None) - } + LeafLabel::Activity(label) => net.add_transition(Some(label.clone()), None), + LeafLabel::Tau => net.add_transition(None, None), } }; From de1132117ce72ecbc2e2cef033cdfd29c064eb23 Mon Sep 17 00:00:00 2001 From: cRennert Date: Thu, 7 May 2026 21:10:08 +0200 Subject: [PATCH 5/9] Incorporated review of @cpitsch - Fixed minor errors - simplified code - improved documentation Co-Authored-By: cpitsch <43351758+cpitsch@users.noreply.github.com> --- .../petri_net/petri_net_struct.rs | 9 +++ .../process_tree/process_tree_struct.rs | 80 ++++++------------- 2 files changed, 34 insertions(+), 55 deletions(-) diff --git a/process_mining/src/core/process_models/case_centric/petri_net/petri_net_struct.rs b/process_mining/src/core/process_models/case_centric/petri_net/petri_net_struct.rs index 5c461df..7c1ac32 100644 --- a/process_mining/src/core/process_models/case_centric/petri_net/petri_net_struct.rs +++ b/process_mining/src/core/process_models/case_centric/petri_net/petri_net_struct.rs @@ -11,6 +11,8 @@ use crate::core::process_models::case_centric::petri_net::pnml::{ export_pnml, import_pnml::{self, PNMLParseError}, }; +use crate::core::process_models::process_tree::ProcessTree; + #[derive( Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, PartialOrd, Ord, JsonSchema, )] @@ -448,6 +450,13 @@ impl PetriNet { } } +/// Creates a [`PetriNet`] from a [`ProcessTree`] +impl From for PetriNet { + fn from(process_tree: ProcessTree) -> Self { + process_tree.to_petri_net() + } +} + #[cfg(test)] mod tests { pub const SAMPLE_JSON_NET: &str = r#" diff --git a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs index 9b4c756..f73e237 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs @@ -70,6 +70,10 @@ impl Node { /// /// Calls either [`Operator::add_to_petri_net`] or [`Leaf::add_to_petri_net`] depending on the /// [`Node`] type. + /// Either takes given in and out places as the start and end of the inserted workflow net. + /// Edits the given Petri net by inserting the corresponding places and transitions of the + /// (sub)tree. + /// Returns the start and end places of the workflow net. /// pub fn add_to_petri_net( &self, @@ -196,21 +200,10 @@ impl ProcessTree { /// pub fn to_petri_net(&self) -> PetriNet { let mut petri_net = PetriNet::new(); - let start_place; - let end_place; - match &self.root { - Node::Operator(op) => { - (start_place, end_place) = op.add_to_petri_net(&mut petri_net, None, None); - } - Node::Leaf(leaf) => { - (start_place, end_place) = leaf.add_to_petri_net(&mut petri_net, None, None); - } - } + let (start_place, end_place) = self.root.add_to_petri_net(&mut petri_net, None, None); - let mut initial_marking = Marking::new(); - initial_marking.insert(start_place, 1); - petri_net.initial_marking = Some(initial_marking); + petri_net.initial_marking = Some(Marking::from([(start_place, 1)])); let mut final_marking = Marking::new(); final_marking.insert(end_place, 1); @@ -269,7 +262,7 @@ impl Operator { /// adds them to the input [`PetriNet`]. This routine is executed recursively. /// Optionally, the input and output place of the inserted workflow net can be defined. /// - pub fn add_to_petri_net( + fn add_to_petri_net( &self, net: &mut PetriNet, in_place: Option, @@ -297,14 +290,8 @@ impl Operator { } }; - match child { - Node::Operator(op) => { - op.add_to_petri_net(net, Some(last_in_place), Some(curr_out_place)); - } - Node::Leaf(leaf) => { - leaf.add_to_petri_net(net, Some(last_in_place), Some(curr_out_place)); - } - } + child.add_to_petri_net(net, Some(last_in_place), Some(curr_out_place)); + last_in_place = curr_out_place; }) } @@ -321,11 +308,11 @@ impl Operator { let tau_end_transition = net.add_transition(None, None); net.add_arc( - ArcType::PlaceTransition(in_place.get_uuid(), tau_start_transition.get_uuid()), + ArcType::place_to_transition(in_place, tau_start_transition), None, ); net.add_arc( - ArcType::TransitionPlace(tau_end_transition.get_uuid(), out_place.get_uuid()), + ArcType::transition_to_place(tau_end_transition, out_place), None, ); @@ -333,17 +320,11 @@ impl Operator { let (child_start, child_end) = child.add_to_petri_net(net, None, None); net.add_arc( - ArcType::PlaceTransition( - tau_start_transition.get_uuid(), - child_start.get_uuid(), - ), + ArcType::transition_to_place(tau_start_transition, child_start), None, ); net.add_arc( - ArcType::TransitionPlace( - child_end.get_uuid(), - tau_end_transition.get_uuid(), - ), + ArcType::place_to_transition(child_end, tau_end_transition), None, ); }) @@ -357,11 +338,11 @@ impl Operator { let tau_end_transition = net.add_transition(None, None); net.add_arc( - ArcType::PlaceTransition(in_place.get_uuid(), tau_start_transition.get_uuid()), + ArcType::place_to_transition(in_place, tau_start_transition), None, ); net.add_arc( - ArcType::TransitionPlace(tau_end_transition.get_uuid(), out_place.get_uuid()), + ArcType::transition_to_place(tau_end_transition, out_place), None, ); @@ -369,31 +350,20 @@ impl Operator { let loop_end_place = net.add_place(None); net.add_arc( - ArcType::TransitionPlace( - tau_start_transition.get_uuid(), - loop_start_place.get_uuid(), - ), + ArcType::transition_to_place(tau_start_transition, loop_start_place), None, ); net.add_arc( - ArcType::PlaceTransition( - loop_end_place.get_uuid(), - tau_end_transition.get_uuid(), - ), + ArcType::place_to_transition(loop_end_place, tau_end_transition), None, ); self.children.iter().enumerate().for_each(|(pos, child)| { - let child_start; - let child_end; - - if pos == 0 { - child_start = loop_start_place; - child_end = loop_end_place; + let (child_start, child_end) = if pos == 0 { + (loop_start_place, loop_end_place) } else { - child_start = loop_end_place; - child_end = loop_start_place; - } + (loop_end_place, loop_start_place) + }; child.add_to_petri_net(net, Some(child_start), Some(child_end)); }) @@ -431,11 +401,11 @@ impl Leaf { } /// - /// Adds a (silent) transition to represent the leaf of a tree. Optionally, input and output + /// Adds a transition to represent the leaf of a tree. Optionally, input and output /// places can be given to connect the newly created (silent) transition to. The output is /// the [`PlaceID`] of the input and output place, each. /// - pub fn add_to_petri_net( + fn add_to_petri_net( &self, net: &mut PetriNet, in_place: Option, @@ -452,11 +422,11 @@ impl Leaf { }; net.add_arc( - ArcType::PlaceTransition(in_place.get_uuid(), leaf_transition.get_uuid()), + ArcType::place_to_transition(in_place, leaf_transition), None, ); net.add_arc( - ArcType::TransitionPlace(leaf_transition.get_uuid(), out_place.get_uuid()), + ArcType::transition_to_place(leaf_transition, out_place), None, ); From cfafbc5610feeb5fbc9978bbe8090228010de130 Mon Sep 17 00:00:00 2001 From: cRennert Date: Thu, 7 May 2026 21:14:33 +0200 Subject: [PATCH 6/9] Changed `add_to_petri_net` to be visible again for leaves and operators such that the comment references are enabled again --- .../case_centric/process_tree/process_tree_struct.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs index f73e237..45364b1 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs @@ -262,7 +262,7 @@ impl Operator { /// adds them to the input [`PetriNet`]. This routine is executed recursively. /// Optionally, the input and output place of the inserted workflow net can be defined. /// - fn add_to_petri_net( + pub fn add_to_petri_net( &self, net: &mut PetriNet, in_place: Option, @@ -405,7 +405,7 @@ impl Leaf { /// places can be given to connect the newly created (silent) transition to. The output is /// the [`PlaceID`] of the input and output place, each. /// - fn add_to_petri_net( + pub fn add_to_petri_net( &self, net: &mut PetriNet, in_place: Option, From 5cc3c634bee7db8955bdea2d651389d70ca7c056 Mon Sep 17 00:00:00 2001 From: cRennert Date: Thu, 7 May 2026 21:27:32 +0200 Subject: [PATCH 7/9] Implemented functionality to create GraphViz visualisations for process trees and to export them as SVG or PNG --- .../case_centric/process_tree/image_export.rs | 225 ++++++++++++++++++ .../case_centric/process_tree/mod.rs | 3 + .../process_tree/process_tree_struct.rs | 64 ++++- 3 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 process_mining/src/core/process_models/case_centric/process_tree/image_export.rs diff --git a/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs b/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs new file mode 100644 index 0000000..9456b27 --- /dev/null +++ b/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs @@ -0,0 +1,225 @@ +//! Image Export of process trees +//! +//! 🔐 Requires the `graphviz-export` feature to be enabled +use std::{fs::File, io::Write}; + +use crate::core::process_models::process_tree::{ + process_tree_struct, Leaf, LeafLabel, Operator, ProcessTree, +}; +use graphviz_rust::{ + cmd::Format, + dot_generator::{attr, edge, graph, id, node, node_id, stmt}, + dot_structures::*, + printer::{DotPrinter, PrinterContext}, +}; +use macros_process_mining::register_binding; +use uuid::Uuid; + +/// +/// Export the image of a [`ProcessTree`] +/// +/// Also see [`export_process_tree_image_svg`] and [`export_process_tree_image_png`] +/// +pub fn export_process_tree_image>( + process_tree: &ProcessTree, + path: P, + format: Format, + dpi_factor: Option, +) -> Result<(), std::io::Error> { + let g = export_pt_to_dot_graph(process_tree, dpi_factor); + + g.print(&mut PrinterContext::default()); + + let out = graphviz_rust::exec(g, &mut PrinterContext::default(), vec![format.into()])?; + + let mut f = File::create(path)?; + f.write_all(&out)?; + Ok(()) +} + +/// +/// Creates the GraphViz code for a [`Node`] and expects a [`Uuid`] that is consistent +/// throughout the graph +/// +fn tree_node_to_gviz_node(node: &process_tree_struct::Node, node_id: Uuid) -> Stmt { + match node { + process_tree_struct::Node::Operator(op) => operator_to_node(op, node_id), + process_tree_struct::Node::Leaf(leaf) => leaf_to_node(leaf, node_id), + } +} + +/// +/// Creates the GraphViz code for a [`Operator`] and expects a [`Uuid`] that is consistent +/// throughout the graph +/// +fn operator_to_node(op: &Operator, op_id: Uuid) -> Stmt { + let symbol = op.operator_type.to_string(); + let shape = "circle"; + let size = 0.5; + stmt!( + node!(esc op_id; attr!("label", esc symbol), attr!("shape", shape), attr!("fixedsize", true), attr!("width", size), attr!("height", size)) + ) +} + +/// +/// Creates the GraphViz code for a [`Leaf`] and expects a [`Uuid`] that is consistent +/// throughout the graph +/// +fn leaf_to_node(leaf: &Leaf, leaf_id: Uuid) -> Stmt { + let (label, is_silent) = match &leaf.activity_label { + LeafLabel::Activity(act_label) => (act_label.clone(), false), + LeafLabel::Tau => ("".to_string(), true), + }; + let (font_size, width, height) = (12, 1, 0.5); + let fill_color = if is_silent { "black" } else { "white" }; + stmt!( + node!(esc leaf_id; attr!("label", esc label), attr!("shape", "box"), attr!("fontsize", font_size), attr!("style", "filled"), attr!("fillcolor", fill_color), attr!("width", width), attr!("height", height)) + ) +} + +/// +/// Creates the GraphViz code for an edge between two process tree [`Node`]s and expects two [`Uuid`] +/// that are consistent throughout the graph for creating the edge +/// +fn arc_to_edge(from: Uuid, to: Uuid) -> Stmt { + let attrs = Vec::default(); + + stmt!(edge!(node_id!(esc from) => node_id!(esc to), attrs)) +} + +/// +/// Export a [`ProcessTree`] to a DOT graph (used in Graphviz) +/// +pub fn export_pt_to_dot_graph(pt: &ProcessTree, dpi_factor: Option) -> Graph { + let mut gviz_nodes = Vec::new(); + let mut gviz_edges = Vec::new(); + + let root_id = Uuid::new_v4(); + gviz_nodes.push(tree_node_to_gviz_node(&pt.root, root_id)); + + let mut curr_nodes = vec![(root_id, &pt.root)]; + let mut next_nodes = Vec::new(); + + while !curr_nodes.is_empty() { + curr_nodes.iter().for_each(|(from_id, node)| match node { + process_tree_struct::Node::Operator(op) => op.children.iter().for_each(|child| { + let child_id = Uuid::new_v4(); + gviz_nodes.push(tree_node_to_gviz_node(child, child_id)); + gviz_edges.push(arc_to_edge(*from_id, child_id)); + next_nodes.push((child_id, child)); + }), + process_tree_struct::Node::Leaf(_) => {} + }); + + curr_nodes = next_nodes; + next_nodes = Vec::new(); + } + + let mut global_graph_options = vec![stmt!(GraphAttributes::Node(vec![ + attr!("fontname", esc "DejaVu Sans") + ]))]; + if let Some(dpi_fac) = dpi_factor { + global_graph_options.push(stmt!(attr!("dpi", (dpi_fac * 96.0)))) + } + + let g = graph!(strict di id!(esc Uuid::new_v4()),vec![global_graph_options, gviz_nodes, gviz_edges].into_iter().flatten().collect()); + g +} + +/// +/// Convert a DOT graph to a String containing the DOT source +/// +pub fn graph_to_dot(g: &Graph) -> String { + g.print(&mut PrinterContext::default()) +} + +/// +/// Export the image of a [`ProcessTree`] as a SVG file +/// +/// Also consider using [`ProcessTree::export_svg`] for convenience. +#[register_binding(stringify_error)] +pub fn export_process_tree_image_svg( + process_tree: &ProcessTree, + path: impl AsRef, +) -> Result<(), std::io::Error> { + export_process_tree_image(process_tree, path, Format::Svg, None) +} + +/// +/// Export the image of a [`ProcessTree`] as a PNG file +/// +/// Also consider using [`ProcessTree::export_png`] for convenience. +#[register_binding(stringify_error)] +pub fn export_process_tree_image_png( + process_tree: &ProcessTree, + path: impl AsRef, +) -> Result<(), std::io::Error> { + export_process_tree_image(process_tree, path, Format::Png, Some(2.0)) +} + +#[cfg(test)] +mod test { + use crate::core::process_models::process_tree::image_export::{ + export_process_tree_image_png, export_process_tree_image_svg, + }; + use crate::core::process_models::process_tree::{ + Leaf, Node, Operator, OperatorType, ProcessTree, + }; + use crate::test_utils::get_test_data_path; + + fn create_example_tree() -> ProcessTree { + let mut seq = Operator::new(OperatorType::Sequence); + let leaf_a = Leaf::new(Some("a".to_string())); + seq.children.push(Node::Leaf(leaf_a)); + + let mut conc = Operator::new(OperatorType::Concurrency); + let leaf_a = Leaf::new(Some("a".to_string())); + let leaf_b = Leaf::new(Some("b".to_string())); + + conc.children.push(Node::Leaf(leaf_a)); + conc.children.push(Node::Leaf(leaf_b)); + + let mut loop_op = Operator::new(OperatorType::Loop); + let leaf_e = Leaf::new(Some("e".to_string())); + let leaf_f = Leaf::new(Some("f".to_string())); + let leaf_silent = Leaf::new(None); + + loop_op.children.push(Node::Leaf(leaf_e)); + loop_op.children.push(Node::Operator(conc)); + loop_op.children.push(Node::Leaf(leaf_f)); + loop_op.children.push(Node::Leaf(leaf_silent)); + + let mut choice = Operator::new(OperatorType::ExclusiveChoice); + let leaf_b = Leaf::new(Some("b".to_string())); + let leaf_c = Leaf::new(Some("c".to_string())); + let leaf_d = Leaf::new(Some("d".to_string())); + choice.children.push(Node::Leaf(leaf_b)); + choice.children.push(Node::Leaf(leaf_c)); + choice.children.push(Node::Leaf(leaf_d)); + + seq.children.push(Node::Operator(loop_op)); + seq.children.push(Node::Operator(choice)); + + ProcessTree::new(Node::Operator(seq)) + } + + #[test] + pub fn test_petri_net_png_export() { + let tree = create_example_tree(); + + let export_path = get_test_data_path() + .join("export") + .join("process-tree-export-test.png"); + export_process_tree_image_png(&tree, export_path).unwrap(); + } + + #[test] + pub fn test_petri_net_svg_export() { + let tree = create_example_tree(); + + let export_path = get_test_data_path() + .join("export") + .join("process-tree-export-test.svg"); + export_process_tree_image_svg(&tree, export_path).unwrap(); + } +} diff --git a/process_mining/src/core/process_models/case_centric/process_tree/mod.rs b/process_mining/src/core/process_models/case_centric/process_tree/mod.rs index e674091..db3b316 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/mod.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/mod.rs @@ -1,4 +1,7 @@ //! Process Tree +#[cfg(feature = "graphviz-export")] +mod image_export; pub(crate) mod process_tree_struct; + #[doc(inline)] pub use process_tree_struct::*; diff --git a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs index 45364b1..245cb8a 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs @@ -1,11 +1,16 @@ use crate::core::process_models::petri_net::{ArcType, Marking, PlaceID}; +use crate::core::process_models::process_tree::image_export::{ + export_process_tree_image_png, export_process_tree_image_svg, +}; use crate::PetriNet; +use core::fmt; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// /// Leaf in a process tree /// -#[derive(Debug, Serialize, Deserialize, Hash, Eq, PartialEq)] +#[derive(Debug, Serialize, Deserialize, Hash, Eq, PartialEq, JsonSchema)] pub enum LeafLabel { /// Non-silent activity leaf Activity(String), @@ -16,7 +21,7 @@ pub enum LeafLabel { /// /// Node in a process tree /// -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] pub enum Node { /// Operator node of a process tree Operator(Operator), @@ -91,7 +96,7 @@ impl Node { /// /// Operator type enum for [`Operator`] /// -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] pub enum OperatorType { /// Sequence operator Sequence, @@ -103,10 +108,31 @@ pub enum OperatorType { Loop, } +/// +/// Functionality to translate the operators into ASCII: →, X, ∧, and ↻ +/// +impl fmt::Display for OperatorType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OperatorType::Sequence => { + write!(f, "→") + } + OperatorType::ExclusiveChoice => { + write!(f, "X") + } + OperatorType::Concurrency => { + write!(f, "∧") + } + OperatorType::Loop => { + write!(f, "↻") + } + } + } +} /// /// Object-centric process tree struct that contains [`Node`] as root /// -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ProcessTree { /// The root of the object-centric process tree pub root: Node, @@ -211,12 +237,38 @@ impl ProcessTree { petri_net } + + #[cfg(feature = "graphviz-export")] + /// Export a process tree as a PNG image + /// + /// The PNG file is written to the specified filepath + /// + /// _Note_: This is an export method for __visualizing__ the process tree. + /// The resulting PNG file cannot be imported as a process tree again. + /// + /// Only available with the `graphviz-export` feature. + pub fn export_png>(&self, path: P) -> Result<(), std::io::Error> { + export_process_tree_image_png(self, path) + } + + #[cfg(feature = "graphviz-export")] + /// Export a process tree as an SVG image + /// + /// The SVG file is written to the specified filepath + /// + /// _Note_: This is an export method for __visualizing__ the process tree. + /// The resulting SVG file cannot be imported as a process tree again. + /// + /// Only available with the `graphviz-export` feature. + pub fn export_svg>(&self, path: P) -> Result<(), std::io::Error> { + export_process_tree_image_svg(self, path) + } } /// /// An operator node in a process tree /// -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct Operator { /// The [`OperatorType`] of the tree itself pub operator_type: OperatorType, @@ -374,7 +426,7 @@ impl Operator { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, JsonSchema)] /// /// A leaf in a process tree /// From 9f4cab45e3ddca2dbc6cba92b5accbb028922d6e Mon Sep 17 00:00:00 2001 From: cRennert Date: Thu, 7 May 2026 21:35:42 +0200 Subject: [PATCH 8/9] Fixed module visibility settings --- .../case_centric/process_tree/image_export.rs | 2 +- .../core/process_models/case_centric/process_tree/mod.rs | 2 +- .../case_centric/process_tree/process_tree_struct.rs | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs b/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs index 9456b27..a5237ea 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs @@ -159,7 +159,7 @@ pub fn export_process_tree_image_png( #[cfg(test)] mod test { - use crate::core::process_models::process_tree::image_export::{ + use super::super::image_export::{ export_process_tree_image_png, export_process_tree_image_svg, }; use crate::core::process_models::process_tree::{ diff --git a/process_mining/src/core/process_models/case_centric/process_tree/mod.rs b/process_mining/src/core/process_models/case_centric/process_tree/mod.rs index db3b316..87fe92e 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/mod.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/mod.rs @@ -1,6 +1,6 @@ //! Process Tree #[cfg(feature = "graphviz-export")] -mod image_export; +pub mod image_export; pub(crate) mod process_tree_struct; #[doc(inline)] diff --git a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs index 245cb8a..85cec14 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/process_tree_struct.rs @@ -1,7 +1,4 @@ use crate::core::process_models::petri_net::{ArcType, Marking, PlaceID}; -use crate::core::process_models::process_tree::image_export::{ - export_process_tree_image_png, export_process_tree_image_svg, -}; use crate::PetriNet; use core::fmt; use schemars::JsonSchema; @@ -248,7 +245,7 @@ impl ProcessTree { /// /// Only available with the `graphviz-export` feature. pub fn export_png>(&self, path: P) -> Result<(), std::io::Error> { - export_process_tree_image_png(self, path) + super::image_export::export_process_tree_image_png(self, path) } #[cfg(feature = "graphviz-export")] @@ -261,7 +258,7 @@ impl ProcessTree { /// /// Only available with the `graphviz-export` feature. pub fn export_svg>(&self, path: P) -> Result<(), std::io::Error> { - export_process_tree_image_svg(self, path) + super::image_export::export_process_tree_image_svg(self, path) } } From e892d9072faa532418877ab9c7d68a3dda328dea Mon Sep 17 00:00:00 2001 From: cRennert Date: Thu, 7 May 2026 21:59:15 +0200 Subject: [PATCH 9/9] Reapplied clippy --- .../case_centric/process_tree/image_export.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs b/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs index a5237ea..ab2b9c6 100644 --- a/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs +++ b/process_mining/src/core/process_models/case_centric/process_tree/image_export.rs @@ -38,7 +38,7 @@ pub fn export_process_tree_image>( } /// -/// Creates the GraphViz code for a [`Node`] and expects a [`Uuid`] that is consistent +/// Creates the `Graphviz` code for a [`Node`] and expects a [`Uuid`] that is consistent /// throughout the graph /// fn tree_node_to_gviz_node(node: &process_tree_struct::Node, node_id: Uuid) -> Stmt { @@ -49,7 +49,7 @@ fn tree_node_to_gviz_node(node: &process_tree_struct::Node, node_id: Uuid) -> St } /// -/// Creates the GraphViz code for a [`Operator`] and expects a [`Uuid`] that is consistent +/// Creates the `Graphviz` code for a [`Operator`] and expects a [`Uuid`] that is consistent /// throughout the graph /// fn operator_to_node(op: &Operator, op_id: Uuid) -> Stmt { @@ -62,7 +62,7 @@ fn operator_to_node(op: &Operator, op_id: Uuid) -> Stmt { } /// -/// Creates the GraphViz code for a [`Leaf`] and expects a [`Uuid`] that is consistent +/// Creates the `Graphviz` code for a [`Leaf`] and expects a [`Uuid`] that is consistent /// throughout the graph /// fn leaf_to_node(leaf: &Leaf, leaf_id: Uuid) -> Stmt { @@ -78,7 +78,7 @@ fn leaf_to_node(leaf: &Leaf, leaf_id: Uuid) -> Stmt { } /// -/// Creates the GraphViz code for an edge between two process tree [`Node`]s and expects two [`Uuid`] +/// Creates the `Graphviz` code for an edge between two process tree [`Node`]s and expects two [`Uuid`] /// that are consistent throughout the graph for creating the edge /// fn arc_to_edge(from: Uuid, to: Uuid) -> Stmt {