use crate::EngineError;
use dpm_model::{Key, Plan, Timeline, WorkItemId};
use dpm_schedule::{
PERT_APPROXIMATION_NOTE, PertApproximation, RemainingOptions, SimulationSummary,
pert_approximation_remaining_with,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RiskForecast {
pub horizon_hours: Option<f64>,
pub probability_by_horizon: Option<f64>,
pub spread: ForecastSpread,
pub pert_approximation: PertApproximationReport,
pub milestones: Vec<MilestoneForecast>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ForecastSpread {
pub pert_sigma_hours: f64,
pub sampled_std_dev_hours: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PertApproximationReport {
pub mean_hours: f64,
pub variance_hours2: f64,
pub sigma_hours: f64,
pub probability_by_horizon: Option<f64>,
pub path: Vec<PathStep>,
pub note: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PathStep {
pub id: WorkItemId,
pub key: Key,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MilestoneForecast {
pub id: WorkItemId,
pub key: Key,
pub p50_finish_hours: f64,
pub p80_finish_hours: f64,
pub p95_finish_hours: f64,
pub probability_by_horizon: Option<f64>,
}
pub(crate) fn validate_horizon(horizon_hours: Option<f64>) -> Result<(), EngineError> {
match horizon_hours {
Some(hours) if !hours.is_finite() || hours < 0.0 => Err(EngineError::InvalidCommand {
entity: "status horizon".into(),
reason: format!("horizon must be finite and at least 0 hours, got {hours}"),
}),
_ => Ok(()),
}
}
pub(crate) fn risk_forecast(
plan: &Plan,
(forecast, timeline, options): (&Plan, &Timeline, RemainingOptions),
simulation: &SimulationSummary,
horizon_hours: Option<f64>,
) -> Result<RiskForecast, EngineError> {
let approximation = pert_approximation_remaining_with(forecast, timeline, options)?;
let key = |id: &WorkItemId| plan.work_items.get(id).map(|w| w.key.clone());
let mut milestones: Vec<MilestoneForecast> = simulation
.milestones
.iter()
.filter_map(|(id, finish)| {
Some(MilestoneForecast {
id: *id,
key: key(id)?,
p50_finish_hours: finish.p50_finish_hours,
p80_finish_hours: finish.p80_finish_hours,
p95_finish_hours: finish.p95_finish_hours,
probability_by_horizon: horizon_hours.map(|h| finish.probability_by(h)),
})
})
.collect();
milestones.sort_by(|a, b| a.key.natural_cmp(&b.key));
Ok(RiskForecast {
horizon_hours,
probability_by_horizon: horizon_hours.map(|h| simulation.probability_by(h)),
spread: ForecastSpread {
pert_sigma_hours: approximation.sigma_hours,
sampled_std_dev_hours: simulation.sampled_std_dev_hours,
},
pert_approximation: report(&approximation, horizon_hours, key),
milestones,
})
}
fn report(
approximation: &PertApproximation,
horizon_hours: Option<f64>,
key: impl Fn(&WorkItemId) -> Option<Key>,
) -> PertApproximationReport {
PertApproximationReport {
mean_hours: approximation.mean_hours,
variance_hours2: approximation.variance_hours2,
sigma_hours: approximation.sigma_hours,
probability_by_horizon: horizon_hours.map(|h| approximation.probability_by(h)),
path: approximation
.path
.iter()
.filter_map(|id| {
Some(PathStep {
id: *id,
key: key(id)?,
})
})
.collect(),
note: PERT_APPROXIMATION_NOTE.into(),
}
}
#[cfg(test)]
mod tests;