1use crate::EngineError;
7use dpm_model::{Key, Plan, Timeline, WorkItemId};
8use dpm_schedule::{
9 PERT_APPROXIMATION_NOTE, PertApproximation, RemainingOptions, SimulationSummary,
10 pert_approximation_remaining_with,
11};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15pub struct RiskForecast {
17 pub horizon_hours: Option<f64>,
19 pub probability_by_horizon: Option<f64>,
21 pub spread: ForecastSpread,
23 pub pert_approximation: PertApproximationReport,
25 pub milestones: Vec<MilestoneForecast>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
30pub struct ForecastSpread {
32 pub pert_sigma_hours: f64,
34 pub sampled_std_dev_hours: f64,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct PertApproximationReport {
41 pub mean_hours: f64,
43 pub variance_hours2: f64,
45 pub sigma_hours: f64,
47 pub probability_by_horizon: Option<f64>,
49 pub path: Vec<PathStep>,
51 pub note: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct PathStep {
58 pub id: WorkItemId,
60 pub key: Key,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct MilestoneForecast {
67 pub id: WorkItemId,
69 pub key: Key,
71 pub p50_finish_hours: f64,
73 pub p80_finish_hours: f64,
75 pub p95_finish_hours: f64,
77 pub probability_by_horizon: Option<f64>,
79}
80
81pub(crate) fn validate_horizon(horizon_hours: Option<f64>) -> Result<(), EngineError> {
83 match horizon_hours {
84 Some(hours) if !hours.is_finite() || hours < 0.0 => Err(EngineError::InvalidCommand {
85 entity: "status horizon".into(),
86 reason: format!("horizon must be finite and at least 0 hours, got {hours}"),
87 }),
88 _ => Ok(()),
89 }
90}
91
92pub(crate) fn risk_forecast(
95 plan: &Plan,
96 (forecast, timeline, options): (&Plan, &Timeline, RemainingOptions),
97 simulation: &SimulationSummary,
98 horizon_hours: Option<f64>,
99) -> Result<RiskForecast, EngineError> {
100 let approximation = pert_approximation_remaining_with(forecast, timeline, options)?;
101 let key = |id: &WorkItemId| plan.work_items.get(id).map(|w| w.key.clone());
102 let mut milestones: Vec<MilestoneForecast> = simulation
103 .milestones
104 .iter()
105 .filter_map(|(id, finish)| {
106 Some(MilestoneForecast {
107 id: *id,
108 key: key(id)?,
109 p50_finish_hours: finish.p50_finish_hours,
110 p80_finish_hours: finish.p80_finish_hours,
111 p95_finish_hours: finish.p95_finish_hours,
112 probability_by_horizon: horizon_hours.map(|h| finish.probability_by(h)),
113 })
114 })
115 .collect();
116 milestones.sort_by(|a, b| a.key.natural_cmp(&b.key));
117 Ok(RiskForecast {
118 horizon_hours,
119 probability_by_horizon: horizon_hours.map(|h| simulation.probability_by(h)),
120 spread: ForecastSpread {
121 pert_sigma_hours: approximation.sigma_hours,
122 sampled_std_dev_hours: simulation.sampled_std_dev_hours,
123 },
124 pert_approximation: report(&approximation, horizon_hours, key),
125 milestones,
126 })
127}
128
129fn report(
130 approximation: &PertApproximation,
131 horizon_hours: Option<f64>,
132 key: impl Fn(&WorkItemId) -> Option<Key>,
133) -> PertApproximationReport {
134 PertApproximationReport {
135 mean_hours: approximation.mean_hours,
136 variance_hours2: approximation.variance_hours2,
137 sigma_hours: approximation.sigma_hours,
138 probability_by_horizon: horizon_hours.map(|h| approximation.probability_by(h)),
139 path: approximation
140 .path
141 .iter()
142 .filter_map(|id| {
143 Some(PathStep {
144 id: *id,
145 key: key(id)?,
146 })
147 })
148 .collect(),
149 note: PERT_APPROXIMATION_NOTE.into(),
150 }
151}
152
153#[cfg(test)]
154mod tests;