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)]
14pub struct SimulationConfig {
16 pub iterations: usize,
18 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)]
32pub struct SimulationSummary {
34 pub iterations: usize,
36 pub p50_finish_hours: f64,
38 pub p80_finish_hours: f64,
40 pub p95_finish_hours: f64,
42 pub criticality: BTreeMap<WorkItemId, f64>,
44 #[serde(default)]
47 pub sampled_std_dev_hours: f64,
48 #[serde(default)]
50 pub milestones: BTreeMap<WorkItemId, MilestoneFinish>,
51 #[serde(skip)]
53 finishes: SortedSamples,
54}
55
56impl SimulationSummary {
57 #[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)]
65pub struct MilestoneFinish {
67 pub p50_finish_hours: f64,
69 pub p80_finish_hours: f64,
71 pub p95_finish_hours: f64,
73 #[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 #[must_use]
90 pub fn probability_by(&self, horizon_hours: f64) -> f64 {
91 self.samples.probability_by(horizon_hours)
92 }
93}
94
95fn 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#[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 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
134pub 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
149pub 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
161pub 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
170pub 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
196fn 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 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, ×)?;
233 }
234 Ok(samples.summarize(network, config.iterations))
235}
236
237#[cfg(test)]
238mod tests;