Skip to main content

dpm_engine/query/
forecast.rs

1//! The `status` risk forecast: milestone percentiles, the probability of finishing by a requested
2//! horizon, and the analytic PERT approximation reported beside the sampled forecast.
3//!
4//! The horizon is a query parameter, never plan state, so the same snapshot answers any horizon.
5
6use 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)]
15/// Sampled and analytic schedule risk of the remaining work.
16pub struct RiskForecast {
17    /// Requested horizon in elapsed hours after the clock reading; null when none was requested.
18    pub horizon_hours: Option<f64>,
19    /// Fraction of seeded simulations finishing the project by the horizon; the forecast.
20    pub probability_by_horizon: Option<f64>,
21    /// Spread of the project finish, analytic and sampled.
22    pub spread: ForecastSpread,
23    /// Classical PERT normal over one mean critical path, for comparison only.
24    pub pert_approximation: PertApproximationReport,
25    /// Sampled finish of every applicable milestone, in key order.
26    pub milestones: Vec<MilestoneForecast>,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
30/// Two measures of the finish's spread, which differ wherever near-critical paths matter.
31pub struct ForecastSpread {
32    /// Analytic standard deviation along the mean critical path, from `((P − O) / 6)²` variances.
33    pub pert_sigma_hours: f64,
34    /// Standard deviation of the sampled project finishes.
35    pub sampled_std_dev_hours: f64,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39/// The analytic approximation and the path it follows.
40pub struct PertApproximationReport {
41    /// Deterministic remaining finish; without calendars the path's PERT means plus lags.
42    pub mean_hours: f64,
43    /// Sum of the classical PERT variances along the path.
44    pub variance_hours2: f64,
45    /// Square root of the variance.
46    pub sigma_hours: f64,
47    /// `Φ((H − mean) / σ)` for the requested horizon; null without one.
48    pub probability_by_horizon: Option<f64>,
49    /// The mean critical path, first activity first.
50    pub path: Vec<PathStep>,
51    /// What the approximation ignores and which way that biases it.
52    pub note: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56/// One activity on the mean critical path.
57pub struct PathStep {
58    /// Stable identity.
59    pub id: WorkItemId,
60    /// Human-readable key.
61    pub key: Key,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65/// Sampled finish of one milestone, in elapsed hours after the clock reading.
66pub struct MilestoneForecast {
67    /// Stable identity.
68    pub id: WorkItemId,
69    /// Human-readable key.
70    pub key: Key,
71    /// Median sampled finish.
72    pub p50_finish_hours: f64,
73    /// 80th percentile sampled finish.
74    pub p80_finish_hours: f64,
75    /// 95th percentile sampled finish.
76    pub p95_finish_hours: f64,
77    /// Fraction of seeded simulations reaching the milestone by the horizon; null without one.
78    pub probability_by_horizon: Option<f64>,
79}
80
81/// A horizon must be a finite, non-negative number of hours after the clock reading.
82pub(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
92/// The forecast for a status whose simulation ran; `forecast` is the plan the simulation sampled,
93/// and `plan` the authoritative one whose keys are reported.
94pub(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;