use crate::ScheduleError;
use crate::sampling::{BetaPert, XorShift64};
use dpm_model::{WorkItem, WorkItemId};
const CELLS: usize = 1024;
#[derive(Debug, Clone)]
pub(crate) enum RemainingDuration {
Fixed(f64),
Shifted {
distribution: BetaPert,
expected: f64,
elapsed: f64,
},
Truncated(Truncated),
}
#[derive(Debug, Clone)]
pub(crate) struct Truncated {
distribution: BetaPert,
elapsed: f64,
from: f64,
width: f64,
cumulative: Vec<f64>,
mean_fraction: f64,
}
impl RemainingDuration {
pub(crate) fn of(
id: WorkItemId,
work: &WorkItem,
done: bool,
elapsed: f64,
) -> Result<Self, ScheduleError> {
if !work.is_executable() || done {
return Ok(Self::Fixed(0.0));
}
let Some(estimate) = work.schedule.estimate else {
return Ok(Self::Fixed(work.expected_duration_hours()));
};
estimate
.validate()
.map_err(|_| ScheduleError::InvalidDuration(id))?;
if !elapsed.is_finite() {
return Err(ScheduleError::InvalidDuration(id));
}
let elapsed = elapsed.max(0.0);
let expected = estimate.pert_expected_hours();
let Some(distribution) = BetaPert::new(estimate) else {
return Ok(Self::Fixed((expected - elapsed).max(0.0)));
};
let from = distribution.fraction_of(elapsed);
Ok(if from <= 0.0 {
Self::Shifted {
distribution,
expected,
elapsed,
}
} else if from >= 1.0 {
Self::Fixed(0.0)
} else {
Self::Truncated(Truncated::new(distribution, elapsed, from))
})
}
pub(crate) fn expected(&self) -> f64 {
match self {
Self::Fixed(hours) => *hours,
Self::Shifted {
expected, elapsed, ..
} => (expected - elapsed).max(0.0),
Self::Truncated(table) => table.remaining_at(table.mean_fraction),
}
}
pub(crate) fn pert_variance(&self) -> f64 {
match self {
Self::Fixed(_) => 0.0,
Self::Shifted {
distribution,
elapsed,
..
} => distribution.pert_variance_after(*elapsed),
Self::Truncated(table) => table.distribution.pert_variance_after(table.elapsed),
}
}
pub(crate) fn sample(&self, rng: &mut XorShift64) -> f64 {
match self {
Self::Fixed(hours) => *hours,
Self::Shifted {
distribution,
elapsed,
..
} => (distribution.sample(rng) - elapsed).max(0.0),
Self::Truncated(table) => table.sample(rng),
}
}
}
impl Truncated {
fn new(distribution: BetaPert, elapsed: f64, from: f64) -> Self {
let width = (1.0 - from) / CELLS as f64;
let mut masses: Vec<f64> = (0..CELLS)
.map(|cell| distribution.relative_density(from + width * (cell as f64 + 0.5)))
.collect();
let total: f64 = masses.iter().sum();
if !(total.is_finite() && total > 0.0) {
masses.fill(1.0);
}
let mut cumulative = Vec::with_capacity(CELLS + 1);
let mut running = 0.0;
let mut moment = 0.0;
cumulative.push(running);
for (cell, mass) in masses.iter().enumerate() {
running += mass;
moment += mass * (from + width * (cell as f64 + 0.5));
cumulative.push(running);
}
Self {
distribution,
elapsed,
from,
width,
cumulative,
mean_fraction: moment / running,
}
}
fn remaining_at(&self, fraction: f64) -> f64 {
(self.distribution.hours_at(fraction) - self.elapsed).max(0.0)
}
fn sample(&self, rng: &mut XorShift64) -> f64 {
let total = self.cumulative.last().copied().unwrap_or(0.0);
let target = rng.unit_f64() * total;
let cell = self
.cumulative
.partition_point(|mass| *mass <= target)
.saturating_sub(1)
.min(CELLS - 1);
let (low, mass) = match self.cumulative.get(cell..cell.saturating_add(2)) {
Some(&[low, high]) => (low, high - low),
_ => (0.0, 0.0),
};
let within = if mass > 0.0 {
((target - low) / mass).clamp(0.0, 1.0)
} else {
0.5
};
self.remaining_at(self.from + self.width * (cell as f64 + within))
}
}
#[cfg(test)]
mod tests;