use crate::network::{EPSILON, Network, Times};
use crate::placement::Placement;
use crate::remaining_duration::RemainingDuration;
use crate::{RemainingOptions, ScheduleError};
use dpm_model::{DependencyId, Endpoint, Plan, ThreePointEstimate, WorkItemId};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const PERT_APPROXIMATION_NOTE: &str = "approximation: classical PERT normal over one mean critical path; it ignores near-critical paths and therefore usually overstates the probability of finishing by the horizon";
#[must_use]
pub fn pert_variance_hours2(estimate: ThreePointEstimate) -> f64 {
let spread = (estimate.pessimistic_hours - estimate.optimistic_hours).max(0.0) / 6.0;
spread * spread
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PertApproximation {
pub mean_hours: f64,
pub variance_hours2: f64,
pub sigma_hours: f64,
pub path: Vec<WorkItemId>,
pub activity_variance_hours2: BTreeMap<WorkItemId, f64>,
}
impl PertApproximation {
#[must_use]
pub fn probability_by(&self, horizon_hours: f64) -> f64 {
if self.sigma_hours <= EPSILON {
return if horizon_hours + EPSILON >= self.mean_hours {
1.0
} else {
0.0
};
}
let z = (horizon_hours - self.mean_hours) / self.sigma_hours;
0.5 * (1.0 + libm::erf(z / std::f64::consts::SQRT_2))
}
}
pub fn pert_approximation(plan: &Plan) -> Result<PertApproximation, ScheduleError> {
plan.validate()?;
let durations = plan
.work_items
.iter()
.map(|(id, work)| Ok((*id, RemainingDuration::of(*id, work, false, 0.0)?)))
.collect::<Result<Vec<_>, ScheduleError>>()?;
let network = Network::compile(plan)?;
approximate(&network, &durations, None)
}
pub fn pert_approximation_remaining_with(
plan: &Plan,
timeline: &dpm_model::Timeline,
options: RemainingOptions,
) -> Result<PertApproximation, ScheduleError> {
options.validate()?;
plan.validate()?;
let remaining = crate::cpm::remaining_plan_at(plan, timeline)?;
let durations = remaining.durations()?;
let network = Network::compile(&remaining.plan)?;
let expected: Vec<f64> = durations.iter().map(|(_, d)| d.expected()).collect();
let mut placement = remaining.placement(&network, timeline, &expected, options)?;
let mut approximation = approximate(&network, &durations, placement.as_mut())?;
approximation
.activity_variance_hours2
.retain(|id, _| !remaining.excluded.contains(id));
approximation
.path
.retain(|id| !remaining.excluded.contains(id));
Ok(approximation)
}
pub fn pert_approximation_remaining_at(
plan: &Plan,
timeline: &dpm_model::Timeline,
) -> Result<PertApproximation, ScheduleError> {
pert_approximation_remaining_with(plan, timeline, RemainingOptions::default())
}
fn approximate(
network: &Network,
durations_by_id: &[(WorkItemId, RemainingDuration)],
mut placement: Option<&mut Placement>,
) -> Result<PertApproximation, ScheduleError> {
let count = network.order().len();
let (mut means, mut variances) = (vec![0.0; count], vec![0.0; count]);
for (id, source) in durations_by_id {
let at = network
.position(id)
.ok_or(ScheduleError::MissingWorkItem(*id))?;
if let (Some(mean), Some(variance)) = (means.get_mut(at), variances.get_mut(at)) {
*mean = source.expected();
*variance = source.pert_variance();
}
}
let times = match placement.as_deref_mut() {
Some(placement) => network.times_placed(&means, placement)?,
None => network.times(&means)?,
};
let critical = (0..count)
.map(|at| Ok(network.total_float(×, at)? <= EPSILON))
.collect::<Result<Vec<bool>, ScheduleError>>()?;
let critical_at = |at: usize| critical.get(at).copied().unwrap_or(false);
let critical_links: Vec<_> = network
.relation_slacks(&means, ×, placement.as_deref())?
.into_iter()
.filter(|(_, from, to, slack)| *slack <= EPSILON && critical_at(*from) && critical_at(*to))
.collect();
let links = Links {
ids: critical_links.iter().map(|(id, ..)| *id).collect(),
continued: critical_links.iter().map(|(_, from, ..)| *from).collect(),
};
let (path, variance) = mean_critical_path(network, ×, &means, &variances, &links)?;
Ok(PertApproximation {
mean_hours: times.finish,
variance_hours2: variance,
sigma_hours: variance.sqrt(),
path,
activity_variance_hours2: network.order().iter().copied().zip(variances).collect(),
})
}
struct Links {
ids: BTreeSet<DependencyId>,
continued: BTreeSet<usize>,
}
#[derive(Debug, Clone, Copy)]
struct Reach {
start: f64,
finish: f64,
from: Option<usize>,
}
fn mean_critical_path(
network: &Network,
times: &Times,
means: &[f64],
variances: &[f64],
links: &Links,
) -> Result<(Vec<WorkItemId>, f64), ScheduleError> {
let order = network.order();
let id_at = |at: usize| order.get(at).copied();
let mut reach: Vec<Reach> = Vec::with_capacity(order.len());
for at in 0..order.len() {
let own = variances.get(at).copied().unwrap_or(0.0);
let mut best: Option<(f64, usize, Endpoint, f64)> = None;
for edge in network
.incoming(at)
.iter()
.filter(|e| links.ids.contains(&e.id))
{
let Some(before) = reach.get(edge.other) else {
continue;
};
let carried = match edge.kind.predecessor_endpoint() {
Endpoint::Start => before.start,
Endpoint::Finish => before.finish,
};
let produced = match edge.kind.successor_endpoint() {
Endpoint::Start => carried + own,
Endpoint::Finish => carried,
};
let better = best.is_none_or(|(_, other, _, finish)| {
prefers((produced, id_at(edge.other)), (finish, id_at(other)))
});
if better {
best = Some((
carried,
edge.other,
edge.kind.successor_endpoint(),
produced,
));
}
}
reach.push(match best {
None => Reach {
start: 0.0,
finish: own,
from: None,
},
Some((carried, from, Endpoint::Start, _)) => Reach {
start: carried,
finish: carried + own,
from: Some(from),
},
Some((carried, from, Endpoint::Finish, _)) => Reach {
start: carried + own,
finish: carried,
from: Some(from),
},
});
}
let mut end: Option<(f64, usize)> = None;
for (at, reached) in reach.iter().enumerate() {
let finish = times.finishes(network, means, at)?.0;
let critical = network.total_float(times, at)? <= EPSILON;
if critical
&& !links.continued.contains(&at)
&& finish + EPSILON >= times.finish
&& end.is_none_or(|(variance, other)| {
prefers((reached.finish, id_at(at)), (variance, id_at(other)))
})
{
end = Some((reached.finish, at));
}
}
let Some((variance, mut at)) = end else {
return Ok((Vec::new(), 0.0));
};
let mut path = Vec::new();
while let Some(id) = id_at(at) {
path.push(id);
match reach.get(at).and_then(|r| r.from) {
Some(from) => at = from,
None => break,
}
}
path.reverse();
Ok((path, variance))
}
fn prefers(candidate: (f64, Option<WorkItemId>), current: (f64, Option<WorkItemId>)) -> bool {
match candidate.0.total_cmp(¤t.0) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Equal => candidate.1 < current.1,
std::cmp::Ordering::Less => false,
}
}
#[cfg(test)]
mod tests;