1use rustkernel_core::error::Result;
14use rustkernel_core::traits::GpuKernel;
15use rustkernel_core::{domain::Domain, kernel::KernelMetadata};
16use std::collections::HashMap;
17
18#[derive(Debug)]
23pub struct DigitalTwin {
24 metadata: KernelMetadata,
25}
26
27impl DigitalTwin {
28 pub fn new() -> Self {
30 Self {
31 metadata: KernelMetadata::batch("procint/digital-twin", Domain::ProcessIntelligence)
32 .with_description("Process simulation for what-if analysis and optimization"),
33 }
34 }
35
36 pub fn simulate(
38 &self,
39 model: &ProcessModel,
40 config: &SimulationConfig,
41 ) -> Result<SimulationResult> {
42 let mut all_traces: Vec<SimulatedTrace> = Vec::new();
44 let mut resource_utilizations: HashMap<String, f64> = HashMap::new();
45 let mut activity_stats: HashMap<String, ActivityStats> = HashMap::new();
46 let mut bottleneck_scores: HashMap<String, f64> = HashMap::new();
47
48 for resource in &model.resources {
50 resource_utilizations.insert(resource.id.clone(), 0.0);
51 }
52
53 for activity in &model.activities {
55 activity_stats.insert(
56 activity.id.clone(),
57 ActivityStats {
58 count: 0,
59 total_duration: 0.0,
60 total_waiting: 0.0,
61 min_duration: f64::MAX,
62 max_duration: 0.0,
63 },
64 );
65 }
66
67 for replication in 0..config.replications {
69 let seed = config.seed.unwrap_or(42) + replication as u64;
70 let traces = self.run_replication(model, config, seed)?;
71
72 for trace in &traces {
74 for event in &trace.events {
75 if let Some(stats) = activity_stats.get_mut(&event.activity_id) {
77 stats.count += 1;
78 stats.total_duration += event.duration;
79 stats.total_waiting += event.waiting_time;
80 stats.min_duration = stats.min_duration.min(event.duration);
81 stats.max_duration = stats.max_duration.max(event.duration);
82 }
83
84 if let Some(resource_id) = &event.resource_id {
86 if let Some(util) = resource_utilizations.get_mut(resource_id) {
87 *util += event.duration;
88 }
89 }
90 }
91 }
92
93 all_traces.extend(traces);
94 }
95
96 let total_time = config.simulation_duration * config.replications as f64;
98
99 for util in resource_utilizations.values_mut() {
100 *util /= total_time;
101 }
102
103 for (activity_id, stats) in &activity_stats {
105 if stats.count > 0 {
106 let avg_waiting = stats.total_waiting / stats.count as f64;
107 let avg_duration = stats.total_duration / stats.count as f64;
108 let bottleneck_score = if avg_duration > 0.0 {
109 avg_waiting / (avg_waiting + avg_duration)
110 } else {
111 0.0
112 };
113 bottleneck_scores.insert(activity_id.clone(), bottleneck_score);
114 }
115 }
116
117 let primary_bottleneck = bottleneck_scores
119 .iter()
120 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
121 .map(|(k, _)| k.clone());
122
123 let kpis = self.compute_kpis(&all_traces, config);
125
126 Ok(SimulationResult {
127 traces: all_traces,
128 kpis,
129 resource_utilization: resource_utilizations,
130 bottleneck_scores,
131 primary_bottleneck,
132 replications_completed: config.replications,
133 })
134 }
135
136 fn run_replication(
137 &self,
138 model: &ProcessModel,
139 config: &SimulationConfig,
140 seed: u64,
141 ) -> Result<Vec<SimulatedTrace>> {
142 use rand::SeedableRng;
143 let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
144
145 let mut traces = Vec::new();
146 let mut current_time = 0.0;
147 let mut case_id = 0;
148
149 let mut resource_free_at: HashMap<String, f64> = HashMap::new();
151 for resource in &model.resources {
152 for _ in 0..resource.capacity {
153 let key = format!("{}_{}", resource.id, resource_free_at.len());
154 resource_free_at.insert(key, 0.0);
155 }
156 }
157
158 while current_time < config.simulation_duration {
160 let inter_arrival = self.sample_exponential(&mut rng, config.arrival_rate);
162 current_time += inter_arrival;
163
164 if current_time >= config.simulation_duration {
165 break;
166 }
167
168 let trace = self.process_case(
170 model,
171 case_id,
172 current_time,
173 &mut resource_free_at,
174 &mut rng,
175 );
176 traces.push(trace);
177 case_id += 1;
178 }
179
180 Ok(traces)
181 }
182
183 fn process_case(
184 &self,
185 model: &ProcessModel,
186 case_id: u32,
187 arrival_time: f64,
188 resource_free_at: &mut HashMap<String, f64>,
189 rng: &mut rand::rngs::StdRng,
190 ) -> SimulatedTrace {
191 let mut events = Vec::new();
192 let mut current_time = arrival_time;
193
194 for activity in &model.activities {
197 let duration = self.sample_duration(&activity.duration_dist, rng);
199
200 let (resource_key, waiting_time) =
202 self.find_resource(activity, current_time, resource_free_at);
203
204 let start_time = current_time + waiting_time;
205 let end_time = start_time + duration;
206
207 if let Some(key) = &resource_key {
209 resource_free_at.insert(key.clone(), end_time);
210 }
211
212 events.push(SimulatedEvent {
213 activity_id: activity.id.clone(),
214 start_time,
215 end_time,
216 duration,
217 waiting_time,
218 resource_id: resource_key.map(|k| k.split('_').next().unwrap_or(&k).to_string()),
219 });
220
221 current_time = end_time;
222 }
223
224 SimulatedTrace {
225 case_id,
226 arrival_time,
227 completion_time: current_time,
228 cycle_time: current_time - arrival_time,
229 events,
230 }
231 }
232
233 fn find_resource(
234 &self,
235 activity: &Activity,
236 current_time: f64,
237 resource_free_at: &HashMap<String, f64>,
238 ) -> (Option<String>, f64) {
239 if activity.required_resources.is_empty() {
240 return (None, 0.0);
241 }
242
243 let mut best_key: Option<String> = None;
245 let mut best_time = f64::MAX;
246
247 for required_resource in &activity.required_resources {
248 for (key, free_time) in resource_free_at {
249 if key.starts_with(required_resource) && *free_time < best_time {
250 best_time = *free_time;
251 best_key = Some(key.clone());
252 }
253 }
254 }
255
256 if let Some(key) = &best_key {
257 let waiting = (best_time - current_time).max(0.0);
258 (Some(key.clone()), waiting)
259 } else {
260 (None, 0.0)
261 }
262 }
263
264 fn sample_exponential(&self, rng: &mut rand::rngs::StdRng, rate: f64) -> f64 {
265 use rand::Rng;
266 let u: f64 = rng.random();
267 -u.ln() / rate
268 }
269
270 fn sample_duration(&self, dist: &DurationDistribution, rng: &mut rand::rngs::StdRng) -> f64 {
271 use rand::Rng;
272 match dist {
273 DurationDistribution::Constant(value) => *value,
274 DurationDistribution::Uniform { min, max } => rng.random_range(*min..=*max),
275 DurationDistribution::Normal { mean, std_dev } => {
276 let u1: f64 = rng.random();
278 let u2: f64 = rng.random();
279 let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
280 (mean + std_dev * z).max(0.0)
281 }
282 DurationDistribution::Exponential { mean } => {
283 let u: f64 = rng.random();
284 -mean * u.ln()
285 }
286 DurationDistribution::Triangular { min, mode, max } => {
287 let u: f64 = rng.random();
288 let fc = (mode - min) / (max - min);
289 if u < fc {
290 min + ((max - min) * (mode - min) * u).sqrt()
291 } else {
292 max - ((max - min) * (max - mode) * (1.0 - u)).sqrt()
293 }
294 }
295 }
296 }
297
298 fn compute_kpis(&self, traces: &[SimulatedTrace], config: &SimulationConfig) -> ProcessKPIs {
299 if traces.is_empty() {
300 return ProcessKPIs::default();
301 }
302
303 let cycle_times: Vec<f64> = traces.iter().map(|t| t.cycle_time).collect();
304 let n = cycle_times.len() as f64;
305
306 let avg_cycle_time = cycle_times.iter().sum::<f64>() / n;
307 let min_cycle_time = cycle_times.iter().cloned().fold(f64::INFINITY, f64::min);
308 let max_cycle_time = cycle_times
309 .iter()
310 .cloned()
311 .fold(f64::NEG_INFINITY, f64::max);
312
313 let variance = cycle_times
315 .iter()
316 .map(|t| (t - avg_cycle_time).powi(2))
317 .sum::<f64>()
318 / n;
319 let std_cycle_time = variance.sqrt();
320
321 let mut sorted = cycle_times.clone();
323 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
324
325 let p50 = self.percentile(&sorted, 0.5);
326 let p90 = self.percentile(&sorted, 0.9);
327 let p95 = self.percentile(&sorted, 0.95);
328 let p99 = self.percentile(&sorted, 0.99);
329
330 let throughput = traces.len() as f64 / config.simulation_duration;
332
333 let wip = throughput * avg_cycle_time;
335
336 let service_level = if let Some(target) = config.target_cycle_time {
338 let within_target = cycle_times.iter().filter(|&&t| t <= target).count();
339 within_target as f64 / n
340 } else {
341 1.0
342 };
343
344 ProcessKPIs {
345 avg_cycle_time,
346 min_cycle_time,
347 max_cycle_time,
348 std_cycle_time,
349 p50_cycle_time: p50,
350 p90_cycle_time: p90,
351 p95_cycle_time: p95,
352 p99_cycle_time: p99,
353 throughput,
354 work_in_progress: wip,
355 service_level,
356 total_cases: traces.len(),
357 }
358 }
359
360 fn percentile(&self, sorted: &[f64], p: f64) -> f64 {
361 if sorted.is_empty() {
362 return 0.0;
363 }
364 let idx = ((sorted.len() - 1) as f64 * p) as usize;
365 sorted[idx.min(sorted.len() - 1)]
366 }
367}
368
369impl Default for DigitalTwin {
370 fn default() -> Self {
371 Self::new()
372 }
373}
374
375impl GpuKernel for DigitalTwin {
376 fn metadata(&self) -> &KernelMetadata {
377 &self.metadata
378 }
379
380 fn validate(&self) -> Result<()> {
381 Ok(())
382 }
383}
384
385#[derive(Debug, Clone)]
387pub struct ProcessModel {
388 pub id: String,
390 pub activities: Vec<Activity>,
392 pub resources: Vec<Resource>,
394 pub transitions: Vec<ProcessTransition>,
396}
397
398#[derive(Debug, Clone)]
400pub struct Activity {
401 pub id: String,
403 pub name: String,
405 pub duration_dist: DurationDistribution,
407 pub required_resources: Vec<String>,
409}
410
411#[derive(Debug, Clone)]
413pub struct Resource {
414 pub id: String,
416 pub name: String,
418 pub capacity: u32,
420 pub cost_per_unit: f64,
422}
423
424#[derive(Debug, Clone)]
426pub struct ProcessTransition {
427 pub from: String,
429 pub to: String,
431 pub probability: f64,
433}
434
435#[derive(Debug, Clone)]
437pub enum DurationDistribution {
438 Constant(f64),
440 Uniform {
442 min: f64,
444 max: f64,
446 },
447 Normal {
449 mean: f64,
451 std_dev: f64,
453 },
454 Exponential {
456 mean: f64,
458 },
459 Triangular {
461 min: f64,
463 mode: f64,
465 max: f64,
467 },
468}
469
470#[derive(Debug, Clone)]
472pub struct SimulationConfig {
473 pub replications: u32,
475 pub simulation_duration: f64,
477 pub arrival_rate: f64,
479 pub seed: Option<u64>,
481 pub target_cycle_time: Option<f64>,
483 pub warmup_period: Option<f64>,
485}
486
487impl Default for SimulationConfig {
488 fn default() -> Self {
489 Self {
490 replications: 100,
491 simulation_duration: 480.0, arrival_rate: 0.1, seed: Some(42),
494 target_cycle_time: None,
495 warmup_period: None,
496 }
497 }
498}
499
500#[derive(Debug, Clone)]
502pub struct SimulationResult {
503 pub traces: Vec<SimulatedTrace>,
505 pub kpis: ProcessKPIs,
507 pub resource_utilization: HashMap<String, f64>,
509 pub bottleneck_scores: HashMap<String, f64>,
511 pub primary_bottleneck: Option<String>,
513 pub replications_completed: u32,
515}
516
517#[derive(Debug, Clone)]
519pub struct SimulatedTrace {
520 pub case_id: u32,
522 pub arrival_time: f64,
524 pub completion_time: f64,
526 pub cycle_time: f64,
528 pub events: Vec<SimulatedEvent>,
530}
531
532#[derive(Debug, Clone)]
534pub struct SimulatedEvent {
535 pub activity_id: String,
537 pub start_time: f64,
539 pub end_time: f64,
541 pub duration: f64,
543 pub waiting_time: f64,
545 pub resource_id: Option<String>,
547}
548
549#[derive(Debug, Clone, Default)]
551pub struct ProcessKPIs {
552 pub avg_cycle_time: f64,
554 pub min_cycle_time: f64,
556 pub max_cycle_time: f64,
558 pub std_cycle_time: f64,
560 pub p50_cycle_time: f64,
562 pub p90_cycle_time: f64,
564 pub p95_cycle_time: f64,
566 pub p99_cycle_time: f64,
568 pub throughput: f64,
570 pub work_in_progress: f64,
572 pub service_level: f64,
574 pub total_cases: usize,
576}
577
578struct ActivityStats {
580 count: usize,
581 total_duration: f64,
582 total_waiting: f64,
583 min_duration: f64,
584 max_duration: f64,
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 fn create_test_model() -> ProcessModel {
592 ProcessModel {
593 id: "test_process".to_string(),
594 activities: vec![
595 Activity {
596 id: "receive".to_string(),
597 name: "Receive Request".to_string(),
598 duration_dist: DurationDistribution::Normal {
599 mean: 5.0,
600 std_dev: 1.0,
601 },
602 required_resources: vec!["clerk".to_string()],
603 },
604 Activity {
605 id: "review".to_string(),
606 name: "Review Request".to_string(),
607 duration_dist: DurationDistribution::Triangular {
608 min: 10.0,
609 mode: 20.0,
610 max: 45.0,
611 },
612 required_resources: vec!["analyst".to_string()],
613 },
614 Activity {
615 id: "approve".to_string(),
616 name: "Approve Request".to_string(),
617 duration_dist: DurationDistribution::Exponential { mean: 8.0 },
618 required_resources: vec!["manager".to_string()],
619 },
620 ],
621 resources: vec![
622 Resource {
623 id: "clerk".to_string(),
624 name: "Clerk".to_string(),
625 capacity: 3,
626 cost_per_unit: 25.0,
627 },
628 Resource {
629 id: "analyst".to_string(),
630 name: "Analyst".to_string(),
631 capacity: 2,
632 cost_per_unit: 50.0,
633 },
634 Resource {
635 id: "manager".to_string(),
636 name: "Manager".to_string(),
637 capacity: 1,
638 cost_per_unit: 100.0,
639 },
640 ],
641 transitions: vec![
642 ProcessTransition {
643 from: "receive".to_string(),
644 to: "review".to_string(),
645 probability: 1.0,
646 },
647 ProcessTransition {
648 from: "review".to_string(),
649 to: "approve".to_string(),
650 probability: 1.0,
651 },
652 ],
653 }
654 }
655
656 #[test]
657 fn test_digital_twin_metadata() {
658 let kernel = DigitalTwin::new();
659 let metadata = kernel.metadata();
660 assert_eq!(metadata.id, "procint/digital-twin");
661 assert_eq!(metadata.domain, Domain::ProcessIntelligence);
662 }
663
664 #[test]
665 fn test_simulation_basic() {
666 let kernel = DigitalTwin::new();
667 let model = create_test_model();
668 let config = SimulationConfig {
669 replications: 10,
670 simulation_duration: 100.0,
671 arrival_rate: 0.2,
672 seed: Some(42),
673 ..Default::default()
674 };
675
676 let result = kernel.simulate(&model, &config).unwrap();
677
678 assert!(!result.traces.is_empty());
679 assert!(result.kpis.avg_cycle_time > 0.0);
680 assert!(result.kpis.throughput > 0.0);
681 assert_eq!(result.replications_completed, 10);
682 }
683
684 #[test]
685 fn test_resource_utilization() {
686 let kernel = DigitalTwin::new();
687 let model = create_test_model();
688 let config = SimulationConfig {
689 replications: 50,
690 simulation_duration: 200.0,
691 arrival_rate: 0.15,
692 seed: Some(123),
693 ..Default::default()
694 };
695
696 let result = kernel.simulate(&model, &config).unwrap();
697
698 assert!(!result.resource_utilization.is_empty());
700
701 for util in result.resource_utilization.values() {
703 assert!(*util >= 0.0);
704 }
705 }
706
707 #[test]
708 fn test_bottleneck_detection() {
709 let kernel = DigitalTwin::new();
710 let model = create_test_model();
711 let config = SimulationConfig {
712 replications: 20,
713 simulation_duration: 150.0,
714 arrival_rate: 0.25, seed: Some(456),
716 ..Default::default()
717 };
718
719 let result = kernel.simulate(&model, &config).unwrap();
720
721 assert!(!result.bottleneck_scores.is_empty());
723
724 for score in result.bottleneck_scores.values() {
726 assert!(*score >= 0.0 && *score <= 1.0);
727 }
728 }
729
730 #[test]
731 fn test_kpis_calculation() {
732 let kernel = DigitalTwin::new();
733 let model = create_test_model();
734 let config = SimulationConfig {
735 replications: 30,
736 simulation_duration: 300.0,
737 arrival_rate: 0.1,
738 seed: Some(789),
739 target_cycle_time: Some(100.0),
740 ..Default::default()
741 };
742
743 let result = kernel.simulate(&model, &config).unwrap();
744
745 assert!(result.kpis.min_cycle_time <= result.kpis.avg_cycle_time);
747 assert!(result.kpis.avg_cycle_time <= result.kpis.max_cycle_time);
748 assert!(result.kpis.p50_cycle_time <= result.kpis.p90_cycle_time);
749 assert!(result.kpis.p90_cycle_time <= result.kpis.p95_cycle_time);
750 assert!(result.kpis.service_level >= 0.0 && result.kpis.service_level <= 1.0);
751 }
752
753 #[test]
754 fn test_duration_distributions() {
755 let kernel = DigitalTwin::new();
756 use rand::SeedableRng;
757 let mut rng = rand::rngs::StdRng::seed_from_u64(42);
758
759 let constant = DurationDistribution::Constant(10.0);
761 assert_eq!(kernel.sample_duration(&constant, &mut rng), 10.0);
762
763 let uniform = DurationDistribution::Uniform {
765 min: 5.0,
766 max: 15.0,
767 };
768 let sample = kernel.sample_duration(&uniform, &mut rng);
769 assert!((5.0..=15.0).contains(&sample));
770
771 let normal = DurationDistribution::Normal {
773 mean: 10.0,
774 std_dev: 2.0,
775 };
776 let sample = kernel.sample_duration(&normal, &mut rng);
777 assert!(sample >= 0.0); let exponential = DurationDistribution::Exponential { mean: 10.0 };
781 let sample = kernel.sample_duration(&exponential, &mut rng);
782 assert!(sample >= 0.0);
783
784 let triangular = DurationDistribution::Triangular {
786 min: 5.0,
787 mode: 10.0,
788 max: 20.0,
789 };
790 let sample = kernel.sample_duration(&triangular, &mut rng);
791 assert!((5.0..=20.0).contains(&sample));
792 }
793}