Skip to main content

dpm_schedule/
cpm.rs

1use crate::network::{EPSILON, Network};
2use crate::placement::Placement;
3use crate::remaining_duration::RemainingDuration;
4use crate::{
5    ActivityCalendar, ActivitySchedule, RelationSchedule, RemainingOptions, Schedule, ScheduleError,
6};
7use dpm_model::{Dependency, Plan, Release, WorkItemId};
8use std::collections::{BTreeMap, BTreeSet};
9
10type Times = BTreeMap<WorkItemId, f64>;
11
12/// Project the full baseline using each task's weighted PERT duration.
13pub fn deterministic(plan: &Plan) -> Result<Schedule, ScheduleError> {
14    let durations = plan
15        .work_items
16        .iter()
17        .map(|(id, work)| (*id, work.expected_duration_hours()))
18        .collect();
19    deterministic_with_durations(plan, &durations)
20}
21
22/// Project outstanding applicable work, treating verified tasks as zero remaining duration.
23///
24/// A task with a recorded start event contributes the mean of its remaining duration conditional
25/// on still running after the hours elapsed since that start, the same distribution the remaining
26/// simulation samples. Waived constraints no longer bound outstanding work; the baseline
27/// projection keeps them. Work that is not applicable (not selected, undecided, awaiting a
28/// choice, stranded or an empty join) is absent from the result, so the projection covers the
29/// same active graph as execution gates. `now` is the adapter's clock reading, used to decide
30/// which milestones are reached and how long started work has run.
31pub fn deterministic_remaining(
32    plan: &Plan,
33    now: chrono::DateTime<chrono::Utc>,
34) -> Result<Schedule, ScheduleError> {
35    deterministic_remaining_at(plan, &dpm_model::Timeline::at(plan, now))
36}
37
38/// Remaining projection sharing the gate timeline for the same immutable plan snapshot.
39pub fn deterministic_remaining_at(
40    plan: &Plan,
41    timeline: &dpm_model::Timeline,
42) -> Result<Schedule, ScheduleError> {
43    deterministic_remaining_with(plan, timeline, RemainingOptions::default())
44}
45
46/// Remaining projection with caller-requested adjustments such as a review delay after the work
47/// of every task still awaiting verification.
48pub fn deterministic_remaining_with(
49    plan: &Plan,
50    timeline: &dpm_model::Timeline,
51    options: RemainingOptions,
52) -> Result<Schedule, ScheduleError> {
53    options.validate()?;
54    plan.validate()?;
55    let remaining = remaining_plan_at(plan, timeline)?;
56    let durations = remaining
57        .durations()?
58        .into_iter()
59        .map(|(id, duration)| (id, duration.expected()))
60        .collect();
61    let network = Network::compile(&remaining.plan)?;
62    let dense = dense(&network, &durations)?;
63    let mut placement = remaining.placement(&network, timeline, &dense, options)?;
64    let mut schedule = project(&network, &dense, placement.as_mut())?;
65    schedule
66        .activities
67        .retain(|id, _| !remaining.excluded.contains(id));
68    schedule
69        .critical_activities
70        .retain(|id| !remaining.excluded.contains(id));
71    Ok(schedule)
72}
73
74/// Outstanding constraints of the active graph, and the work excluded from it.
75pub(crate) struct Remaining {
76    /// Plan whose excluded work has no duration and no constraints.
77    pub(crate) plan: Plan,
78    /// Work that is not applicable and must not appear in any remaining projection.
79    pub(crate) excluded: BTreeSet<WorkItemId>,
80    /// Hours of each outstanding task's calendar from its recorded start event to the origin.
81    pub(crate) elapsed: BTreeMap<WorkItemId, f64>,
82    /// Applicable tasks whose verification is still outstanding.
83    pub(crate) awaiting: BTreeSet<WorkItemId>,
84}
85
86impl Remaining {
87    /// Remaining duration source of every activity, in work-item id order.
88    pub(crate) fn durations(&self) -> Result<Vec<(WorkItemId, RemainingDuration)>, ScheduleError> {
89        self.plan
90            .work_items
91            .iter()
92            .map(|(id, work)| {
93                let done = work.execution.status.satisfies_dependency();
94                let elapsed = self.elapsed.get(id).copied().unwrap_or(0.0);
95                Ok((*id, RemainingDuration::of(*id, work, done, elapsed)?))
96            })
97            .collect()
98    }
99
100    /// Calendar placement of this projection, or `None` for a plan without calendars and
101    /// without a review delay. The initial window covers several times the total work, lag and
102    /// review delay, so widening is rare.
103    pub(crate) fn placement(
104        &self,
105        network: &Network,
106        timeline: &dpm_model::Timeline,
107        durations: &[f64],
108        options: RemainingOptions,
109    ) -> Result<Option<Placement>, ScheduleError> {
110        let work: f64 = durations.iter().sum();
111        let lags: f64 = self
112            .plan
113            .dependencies
114            .iter()
115            .map(|d| d.lag_hours.abs())
116            .sum();
117        let reviews = options.review_delay_hours * self.awaiting.len() as f64;
118        Placement::new(
119            &self.plan,
120            network,
121            timeline,
122            &self.awaiting,
123            5.0 * (work + lags + reviews),
124            options,
125        )
126    }
127}
128
129/// Hours between each outstanding applicable task's recorded start and the origin: working hours
130/// of its calendar when the plan has calendars, elapsed hours otherwise.
131///
132/// Blocked intervals count, since estimates are durations and not effort. A start whose time was
133/// never recorded is absent, so that task keeps its whole duration rather than an assumed head
134/// start.
135fn elapsed_since_start(
136    plan: &Plan,
137    timeline: &dpm_model::Timeline,
138    excluded: &BTreeSet<WorkItemId>,
139) -> Result<BTreeMap<WorkItemId, f64>, ScheduleError> {
140    plan.work_items
141        .iter()
142        .filter(|(id, work)| {
143            work.is_executable()
144                && !excluded.contains(id)
145                && !work.execution.status.satisfies_dependency()
146        })
147        .filter_map(|(id, work)| match work.start_event()? {
148            dpm_model::EventTime::Recorded(at) => Some(
149                crate::placement::worked_between(plan, work, at, timeline.now())
150                    .map(|hours| (*id, hours)),
151            ),
152            dpm_model::EventTime::Unrecorded => None,
153        })
154        .collect()
155}
156
157/// Outstanding constraints measured from `now` as the projection origin.
158///
159/// Edges into completed work and waived edges no longer bound anything. An edge from completed
160/// work keeps only the part of its lag the shared gate evaluator still reports as elapsing, so the
161/// forecast never releases a constraint before execution does; a lag whose event time was never recorded is
162/// kept whole rather than assumed to have elapsed. The completed predecessor projects at the origin
163/// with zero duration, so the kept lag is measured from `now`. A start-based edge from work that has
164/// started is treated the same way from its start event; the started predecessor projects at the
165/// origin unless its own remaining constraints push it later, which only delays the forecast, and
166/// runs for its remaining duration from there. Work the shared evaluator reports
167/// as not applicable keeps no duration and no edge, so it cannot move applicable work.
168pub(crate) fn remaining_plan_at(
169    plan: &Plan,
170    timeline: &dpm_model::Timeline,
171) -> Result<Remaining, ScheduleError> {
172    let mut remaining = plan.clone();
173    let now = timeline.now();
174    let completed = timeline.completed();
175    let excluded: BTreeSet<_> = plan
176        .work_items
177        .keys()
178        .filter(|id| !timeline.applicability(**id).is_applicable())
179        .copied()
180        .collect();
181    for id in &excluded {
182        if let Some(work) = remaining.work_items.get_mut(id) {
183            work.schedule.estimate = None;
184        }
185    }
186    remaining.dependencies = plan
187        .enforced_dependencies()
188        .filter(|d| !excluded.contains(&d.predecessor) && !excluded.contains(&d.successor))
189        .filter(|d| !completed.contains(&d.successor))
190        .filter_map(|d| {
191            if !completed.contains(&d.predecessor) && !started_from(plan, timeline, d) {
192                return Some(d.clone());
193            }
194            // A lag already elapsing ends at a fixed instant, measured in elapsed hours from now.
195            let (lag_hours, lag_basis) = match timeline.edge(plan, d) {
196                Release::Released { .. }
197                | Release::SkippedBranch { .. }
198                | Release::AwaitingEvent => return None,
199                Release::Elapsing { opens_at, .. } => (
200                    (opens_at - now).num_milliseconds() as f64 / 3_600_000.0,
201                    dpm_model::LagBasis::Elapsed,
202                ),
203                Release::UnrecordedEventTime
204                | Release::LagOutOfRange { .. }
205                | Release::NotSelected => (d.lag_hours, d.lag_basis),
206            };
207            Some(Dependency {
208                lag_hours,
209                lag_basis,
210                ..d.clone()
211            })
212        })
213        .collect();
214    let awaiting = plan
215        .work_items
216        .values()
217        .filter(|w| w.is_executable() && !w.execution.status.satisfies_dependency())
218        .filter(|w| !excluded.contains(&w.id))
219        .map(|w| w.id)
220        .collect();
221    Ok(Remaining {
222        awaiting,
223        elapsed: elapsed_since_start(plan, timeline, &excluded)?,
224        plan: remaining,
225        excluded,
226    })
227}
228
229/// Whether a start-based edge (SS, SF) waits on a predecessor start that has already occurred.
230///
231/// Its lag then elapses from that start exactly as the gate evaluator measures it, rather than
232/// from the predecessor's projected start at the origin. Finish-based edges still wait for
233/// verification, so a submitted or provisionally relied-on predecessor keeps its whole edge.
234fn started_from(plan: &Plan, timeline: &dpm_model::Timeline, edge: &Dependency) -> bool {
235    edge.kind.predecessor_endpoint() == dpm_model::Endpoint::Start
236        && timeline
237            .event(plan, edge.predecessor, dpm_model::Endpoint::Start)
238            .is_some()
239}
240
241/// Project a validated graph with an explicit finite, non-negative duration for every activity.
242///
243/// All relationships are lower bounds in elapsed hours; negative lag permits planned overlap.
244pub fn deterministic_with_durations(
245    plan: &Plan,
246    durations: &Times,
247) -> Result<Schedule, ScheduleError> {
248    let network = Network::compile(plan)?;
249    let dense = dense(&network, durations)?;
250    project(&network, &dense, None)
251}
252
253/// Durations by topological position, each finite and non-negative.
254fn dense(network: &Network, durations: &Times) -> Result<Vec<f64>, ScheduleError> {
255    let mut dense = Vec::with_capacity(network.order().len());
256    for id in network.order() {
257        let duration = durations
258            .get(id)
259            .ok_or(ScheduleError::InvalidDuration(*id))?;
260        if !duration.is_finite() || *duration < 0.0 {
261            return Err(ScheduleError::InvalidDuration(*id));
262        }
263        dense.push(*duration);
264    }
265    Ok(dense)
266}
267
268/// Project a compiled network, on calendars when a placement is given.
269fn project(
270    network: &Network,
271    dense: &[f64],
272    mut placement: Option<&mut Placement>,
273) -> Result<Schedule, ScheduleError> {
274    let times = match placement.as_deref_mut() {
275        Some(placement) => network.times_placed(dense, placement)?,
276        None => network.times(dense)?,
277    };
278    let placement = placement.as_deref();
279    let mut activities = BTreeMap::new();
280    let mut critical_activities = Vec::new();
281    for (at, id) in network.order().iter().enumerate() {
282        let read = |values: &[f64]| {
283            values
284                .get(at)
285                .copied()
286                .ok_or(ScheduleError::UnknownPosition(at))
287        };
288        let total_float = network.total_float(&times, at)?;
289        let critical = total_float <= EPSILON;
290        if critical {
291            critical_activities.push(*id);
292        }
293        let (earliest_finish, latest_finish, review_wait) = times.finishes(network, dense, at)?;
294        let calendar =
295            placement
296                .and_then(|p| p.resolved.get(id))
297                .map(|resolved| ActivityCalendar {
298                    resolved: resolved.clone(),
299                    review_wait_hours: review_wait,
300                });
301        activities.insert(
302            *id,
303            ActivitySchedule {
304                earliest_start_hours: read(&times.earliest)?,
305                earliest_finish_hours: earliest_finish,
306                latest_start_hours: read(&times.latest)?,
307                latest_finish_hours: latest_finish,
308                total_float_hours: total_float,
309                free_float_hours: network
310                    .free_float(dense, &times, at, placement)?
311                    .min(total_float),
312                critical,
313                calendar,
314            },
315        );
316    }
317    let critical_at = |at: usize| {
318        network
319            .order()
320            .get(at)
321            .and_then(|id| activities.get(id))
322            .is_some_and(|a: &ActivitySchedule| a.critical)
323    };
324    let relations = network
325        .relation_slacks(dense, &times, placement)?
326        .into_iter()
327        .map(|(id, from, to, slack_hours)| {
328            let driving = slack_hours <= EPSILON;
329            let critical = driving && critical_at(from) && critical_at(to);
330            let relation = RelationSchedule {
331                slack_hours,
332                driving,
333                critical,
334            };
335            (id, relation)
336        })
337        .collect();
338    Ok(Schedule {
339        project_finish_hours: times.finish,
340        activities,
341        critical_activities,
342        relations,
343    })
344}
345
346#[cfg(test)]
347mod tests;