Skip to main content

dpm_schedule/
simulation.rs

1use crate::network::Network;
2use crate::placement::Placement;
3use crate::remaining_duration::RemainingDuration;
4use crate::sampling::XorShift64;
5use crate::{RemainingOptions, ScheduleError};
6use dpm_model::{Plan, WorkItemId};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10mod samples;
11use samples::{Samples, SortedSamples};
12
13#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
14/// Reproducible Monte Carlo sampling configuration.
15pub struct SimulationConfig {
16    /// Number of independently sampled schedules.
17    pub iterations: usize,
18    /// Deterministic pseudo-random seed.
19    pub seed: u64,
20}
21
22impl Default for SimulationConfig {
23    fn default() -> Self {
24        Self {
25            iterations: 10_000,
26            seed: 0xDA6_51A9_2026,
27        }
28    }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32/// Completion percentiles and per-activity criticality from sampled schedules.
33pub struct SimulationSummary {
34    /// Number of independently sampled schedules.
35    pub iterations: usize,
36    /// Median sampled completion time in elapsed hours.
37    pub p50_finish_hours: f64,
38    /// 80th percentile sampled completion time in elapsed hours.
39    pub p80_finish_hours: f64,
40    /// 95th percentile sampled completion time in elapsed hours.
41    pub p95_finish_hours: f64,
42    /// Fraction of sampled schedules in which each activity is critical.
43    pub criticality: BTreeMap<WorkItemId, f64>,
44    /// Sample standard deviation of the project finish in elapsed hours: the sampled spread,
45    /// which includes every near-critical path.
46    #[serde(default)]
47    pub sampled_std_dev_hours: f64,
48    /// Sampled finish percentiles of every milestone in the projection.
49    #[serde(default)]
50    pub milestones: BTreeMap<WorkItemId, MilestoneFinish>,
51    /// Every sampled project finish, kept for horizon probabilities rather than serialized.
52    #[serde(skip)]
53    finishes: SortedSamples,
54}
55
56impl SimulationSummary {
57    /// Fraction of sampled schedules whose project finish is at or before `horizon_hours`.
58    #[must_use]
59    pub fn probability_by(&self, horizon_hours: f64) -> f64 {
60        self.finishes.probability_by(horizon_hours)
61    }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65/// Sampled finish percentiles of one milestone, in elapsed hours.
66pub struct MilestoneFinish {
67    /// Median sampled finish.
68    pub p50_finish_hours: f64,
69    /// 80th percentile sampled finish.
70    pub p80_finish_hours: f64,
71    /// 95th percentile sampled finish.
72    pub p95_finish_hours: f64,
73    /// Every sampled finish, kept for horizon probabilities rather than serialized.
74    #[serde(skip)]
75    samples: SortedSamples,
76}
77
78impl MilestoneFinish {
79    fn new(samples: SortedSamples) -> Self {
80        Self {
81            p50_finish_hours: samples.percentile(0.50),
82            p80_finish_hours: samples.percentile(0.80),
83            p95_finish_hours: samples.percentile(0.95),
84            samples,
85        }
86    }
87
88    /// Fraction of sampled schedules in which the milestone is reached by `horizon_hours`.
89    #[must_use]
90    pub fn probability_by(&self, horizon_hours: f64) -> f64 {
91        self.samples.probability_by(horizon_hours)
92    }
93}
94
95/// Milestones of a compiled plan with their topological positions.
96fn milestone_positions(
97    plan: &Plan,
98    network: &Network,
99) -> Result<Vec<(WorkItemId, usize)>, ScheduleError> {
100    plan.work_items
101        .values()
102        .filter(|work| work.kind == dpm_model::WorkKind::Milestone)
103        .map(|work| {
104            let at = network
105                .position(&work.id)
106                .ok_or(ScheduleError::MissingWorkItem(work.id))?;
107            Ok((work.id, at))
108        })
109        .collect()
110}
111
112/// Linearly interpolated percentile `p` (0..=1) of ascending values; 0 for no values.
113///
114/// Every consumer that summarizes samples uses this one definition, so results are reproducible.
115#[must_use]
116pub fn percentile(sorted: &[f64], p: f64) -> f64 {
117    if sorted.is_empty() {
118        return 0.0;
119    }
120    let rank = p.clamp(0.0, 1.0) * (sorted.len().saturating_sub(1) as f64);
121    let low = rank.floor() as usize;
122    let high = rank.ceil() as usize;
123    // Both ranks lie within the non-empty slice by construction.
124    match (sorted.get(low), sorted.get(high)) {
125        (Some(below), _) if low == high => *below,
126        (Some(below), Some(above)) => {
127            let fraction = rank - low as f64;
128            below * (1.0 - fraction) + above * fraction
129        }
130        _ => 0.0,
131    }
132}
133
134/// Sample beta-PERT task durations and project complete baseline schedules.
135///
136/// Each task's samples have the PERT expectation that `deterministic` projects as their mean.
137pub fn simulate(plan: &Plan, config: SimulationConfig) -> Result<SimulationSummary, ScheduleError> {
138    plan.validate()?;
139    let durations = plan
140        .work_items
141        .iter()
142        .map(|(id, work)| Ok((*id, RemainingDuration::of(*id, work, false, 0.0)?)))
143        .collect::<Result<Vec<_>, ScheduleError>>()?;
144    let network = Network::compile(plan)?;
145    let milestones = milestone_positions(plan, &network)?;
146    simulate_inner(&network, config, &durations, milestones, None)
147}
148
149/// Monte Carlo projection of remaining applicable work from the execution state at a clock reading.
150///
151/// It samples the same active graph and the same remaining durations as `deterministic_remaining`,
152/// whose values are the means of these samples; excluded work has no criticality.
153pub fn simulate_remaining(
154    plan: &Plan,
155    config: SimulationConfig,
156    now: chrono::DateTime<chrono::Utc>,
157) -> Result<SimulationSummary, ScheduleError> {
158    simulate_remaining_at(plan, config, &dpm_model::Timeline::at(plan, now))
159}
160
161/// Sample the remaining graph sharing the gate timeline for the same immutable plan snapshot.
162pub fn simulate_remaining_at(
163    plan: &Plan,
164    config: SimulationConfig,
165    timeline: &dpm_model::Timeline,
166) -> Result<SimulationSummary, ScheduleError> {
167    simulate_remaining_with(plan, config, timeline, RemainingOptions::default())
168}
169
170/// Sample the remaining graph with caller-requested adjustments, the same ones
171/// [`deterministic_remaining_with`](crate::deterministic_remaining_with) applies.
172pub fn simulate_remaining_with(
173    plan: &Plan,
174    config: SimulationConfig,
175    timeline: &dpm_model::Timeline,
176    options: RemainingOptions,
177) -> Result<SimulationSummary, ScheduleError> {
178    options.validate()?;
179    plan.validate()?;
180    let remaining = crate::cpm::remaining_plan_at(plan, timeline)?;
181    let durations = remaining.durations()?;
182    let network = Network::compile(&remaining.plan)?;
183    let expected: Vec<f64> = durations.iter().map(|(_, d)| d.expected()).collect();
184    let mut placement = remaining.placement(&network, timeline, &expected, options)?;
185    let milestones = milestone_positions(&remaining.plan, &network)?
186        .into_iter()
187        .filter(|(id, _)| !remaining.excluded.contains(id))
188        .collect();
189    let mut summary = simulate_inner(&network, config, &durations, milestones, placement.as_mut())?;
190    summary
191        .criticality
192        .retain(|id, _| !remaining.excluded.contains(id));
193    Ok(summary)
194}
195
196/// Sample every activity's duration source, in work-item id order, and project each sample.
197fn simulate_inner(
198    network: &Network,
199    config: SimulationConfig,
200    durations_by_id: &[(WorkItemId, RemainingDuration)],
201    milestones: Vec<(WorkItemId, usize)>,
202    mut placement: Option<&mut Placement>,
203) -> Result<SimulationSummary, ScheduleError> {
204    if config.iterations == 0 {
205        return Err(ScheduleError::NoSimulationIterations);
206    }
207    let mut rng = XorShift64::new(config.seed);
208    let mut samples = Samples::new(network, config.iterations, milestones);
209    let mut durations = vec![0.0; network.order().len()];
210    // Sampling follows work-item id order, so a seed draws the same numbers for the same plan.
211    let mut sampled = Vec::with_capacity(durations_by_id.len());
212    for (id, source) in durations_by_id {
213        let at = network
214            .position(id)
215            .ok_or(ScheduleError::MissingWorkItem(*id))?;
216        sampled.push((at, *id, source));
217    }
218    for _ in 0..config.iterations {
219        for (at, id, source) in &sampled {
220            let slot = durations
221                .get_mut(*at)
222                .ok_or(ScheduleError::UnknownPosition(*at))?;
223            *slot = source.sample(&mut rng);
224            if !slot.is_finite() || *slot < 0.0 {
225                return Err(ScheduleError::InvalidDuration(*id));
226            }
227        }
228        let times = match placement.as_deref_mut() {
229            Some(placement) => network.times_placed(&durations, placement)?,
230            None => network.times(&durations)?,
231        };
232        samples.record(network, &durations, &times)?;
233    }
234    Ok(samples.summarize(network, config.iterations))
235}
236
237#[cfg(test)]
238mod tests;