1use crate::{EngineError, Transition, gates};
2use chrono::{DateTime, Utc};
3use dpm_model::{
4 Applicability, BasisStatus, DecisionStatus, Plan, Timeline, WorkItem, WorkItemId, WorkStatus,
5 basis_status,
6};
7use dpm_schedule::{
8 RemainingOptions, SimulationConfig, SimulationSummary, deterministic_remaining_with,
9 simulate_remaining_with,
10};
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, BTreeSet, VecDeque};
13
14pub(crate) mod choices;
15pub use choices::{InapplicableWork, MAX_SCENARIOS, OpenChoices, ScenarioForecast};
16mod estimates;
17pub use estimates::{is_unestimated, unestimated};
18mod explain;
19pub use explain::{BasisReport, WorkExplanation, explain_work};
20mod advisory;
21pub use advisory::{Advisory, advisories};
22mod calibration;
23pub use calibration::{
24 AppliedCalibration, AppliedFactor, AppliedReviewDelay, BULK_WINDOW_SECONDS, CalibrationReport,
25 CalibrationRules, EstimateCalibration, EstimateSample, Exclusion, ExclusionReason,
26 HistoryCoverage, HourBasis, MAX_APPLIED_RATIO, MIN_ACTUAL_SECONDS, MIN_AGENT_APPLIED_RATIO,
27 MIN_APPLIED_RATIO, MIN_SAMPLES, REVIEW_FLOOR_SECONDS, RatioGroup, ReasonCount, WaitGroup,
28 WaitReport, calibration, min_applied_ratio,
29};
30mod forecast;
31pub use forecast::{
32 ForecastSpread, MilestoneForecast, PathStep, PertApproximationReport, RiskForecast,
33};
34mod next;
35pub use next::{CriticalityBasis, NextWorkCandidate, NextWorkQuery, next_work};
36mod flow;
37pub use flow::{
38 AgingWork, ClaimOutcomes, Durations, FlowReport, HolderOutcomes, Reliability, THROUGHPUT_WEEKS,
39 Throughput, WeekCount,
40};
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct StatusSummary {
44 pub gates: BTreeMap<WorkItemId, crate::GateReport>,
46 pub progress: crate::ProgressSummary,
48 pub revision: u64,
50 pub total_work: usize,
52 pub ready: usize,
54 pub blocked: usize,
56 pub in_flight: usize,
58 pub awaiting_verification: usize,
60 #[serde(default)]
66 pub excluded_in_flight: usize,
67 #[serde(default)]
69 pub basis_invalidated: usize,
70 pub complete: usize,
72 pub open_decisions: usize,
74 pub expected_finish_hours: f64,
77 pub p50_finish_hours: Option<f64>,
79 pub p80_finish_hours: Option<f64>,
81 pub p95_finish_hours: Option<f64>,
83 #[serde(default)]
86 pub forecast: Option<RiskForecast>,
87 #[serde(default)]
90 pub unestimated: Vec<dpm_model::Key>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub not_applicable: Vec<InapplicableWork>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub open_choices: Option<OpenChoices>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub calibration: Option<AppliedCalibration>,
100}
101
102pub(crate) fn simulation_config() -> SimulationConfig {
103 SimulationConfig {
104 iterations: 2_000,
105 ..SimulationConfig::default()
106 }
107}
108
109fn claim_reports(plan: &Plan, timeline: &Timeline) -> BTreeMap<WorkItemId, crate::GateReport> {
111 plan.work_items
112 .values()
113 .filter(|w| w.is_executable())
114 .map(|w| (w.id, gates::evaluate(plan, w, Transition::Claim, timeline)))
115 .collect()
116}
117
118pub fn status(
120 plan: &Plan,
121 probabilistic: bool,
122 now: DateTime<Utc>,
123) -> Result<StatusSummary, EngineError> {
124 status_with(plan, probabilistic, now, None, None)
125}
126
127pub fn status_at_horizon(
130 plan: &Plan,
131 probabilistic: bool,
132 now: DateTime<Utc>,
133 horizon_hours: Option<f64>,
134) -> Result<StatusSummary, EngineError> {
135 status_with(plan, probabilistic, now, None, horizon_hours)
136}
137
138pub fn status_calibrated(
143 plan: &Plan,
144 probabilistic: bool,
145 now: DateTime<Utc>,
146 report: &CalibrationReport,
147) -> Result<StatusSummary, EngineError> {
148 status_with(plan, probabilistic, now, Some(report), None)
149}
150
151pub fn status_calibrated_at_horizon(
153 plan: &Plan,
154 probabilistic: bool,
155 now: DateTime<Utc>,
156 report: &CalibrationReport,
157 horizon_hours: Option<f64>,
158) -> Result<StatusSummary, EngineError> {
159 status_with(plan, probabilistic, now, Some(report), horizon_hours)
160}
161
162fn sampled(
165 (plan, forecast): (&Plan, &Plan),
166 timeline: &Timeline,
167 options: RemainingOptions,
168 probabilistic: bool,
169 open_choices: Option<&OpenChoices>,
170) -> Result<Option<SimulationSummary>, EngineError> {
171 let estimated = plan
172 .work_items
173 .values()
174 .any(|w| w.schedule.estimate.is_some());
175 if !probabilistic || open_choices.is_some() || !estimated {
176 return Ok(None);
177 }
178 Ok(Some(simulate_remaining_with(
179 forecast,
180 simulation_config(),
181 timeline,
182 options,
183 )?))
184}
185
186fn status_with(
187 plan: &Plan,
188 probabilistic: bool,
189 now: DateTime<Utc>,
190 report: Option<&CalibrationReport>,
191 horizon_hours: Option<f64>,
192) -> Result<StatusSummary, EngineError> {
193 plan.validate()?;
194 forecast::validate_horizon(horizon_hours)?;
195 let timeline = Timeline::at(plan, now);
196 let calibrated = report.map(|r| calibration::calibrated_forecast(plan, r));
197 let (forecast, options) = match &calibrated {
198 Some((scaled, options, _)) => (scaled, *options),
199 None => (plan, RemainingOptions::default()),
200 };
201 let forecast_timeline = Timeline::at(forecast, now);
202 let schedule = deterministic_remaining_with(forecast, &forecast_timeline, options)?;
203 let open_choices = choices::open_choices(forecast, probabilistic, now, options)?;
204 let simulation = sampled(
205 (plan, forecast),
206 &forecast_timeline,
207 options,
208 probabilistic,
209 open_choices.as_ref(),
210 )?;
211 let risk = simulation
212 .as_ref()
213 .map(|sampled| {
214 let inputs = (forecast, &forecast_timeline, options);
215 forecast::risk_forecast(plan, inputs, sampled, horizon_hours)
216 })
217 .transpose()?;
218 let gates = claim_reports(plan, &timeline);
219
220 Ok(StatusSummary {
221 ready: gates.values().filter(|report| report.ready).count(),
222 gates,
223 progress: crate::progress::progress_with(plan, &timeline).overall,
224 revision: plan.revision,
225 total_work: plan.work_items.len(),
226 blocked: count(plan, &timeline, true, &[WorkStatus::Blocked]),
227 in_flight: count(
228 plan,
229 &timeline,
230 true,
231 &[WorkStatus::Claimed, WorkStatus::InProgress],
232 ),
233 awaiting_verification: count(plan, &timeline, true, &[WorkStatus::Submitted]),
234 excluded_in_flight: excluded_in_flight(plan, &timeline).len(),
235 basis_invalidated: plan
236 .work_items
237 .values()
238 .filter(|w| basis_status(plan, w).iter().any(BasisStatus::gates))
239 .count(),
240 complete: timeline.completed().len(),
241 open_decisions: plan
242 .decisions
243 .values()
244 .filter(|decision| decision.status == DecisionStatus::Open)
245 .count(),
246 expected_finish_hours: schedule.project_finish_hours,
247 p50_finish_hours: simulation.as_ref().map(|s| s.p50_finish_hours),
248 p80_finish_hours: simulation.as_ref().map(|s| s.p80_finish_hours),
249 p95_finish_hours: simulation.as_ref().map(|s| s.p95_finish_hours),
250 forecast: risk,
251 unestimated: unestimated(plan, &timeline),
252 not_applicable: choices::not_applicable(plan, &timeline),
253 open_choices,
254 calibration: calibrated.map(|(_, _, applied)| applied),
255 })
256}
257
258fn count(plan: &Plan, timeline: &Timeline, counted: bool, statuses: &[WorkStatus]) -> usize {
260 plan.work_items
261 .values()
262 .filter(|w| statuses.contains(&w.execution.status))
263 .filter(|w| in_status_scope(timeline, w.id) == counted)
264 .count()
265}
266
267#[must_use]
271pub fn in_status_scope(timeline: &Timeline, work: WorkItemId) -> bool {
272 timeline.applicability(work).condition_selected()
273}
274
275#[must_use]
279pub fn excluded_in_flight<'a>(plan: &'a Plan, timeline: &Timeline) -> Vec<&'a WorkItem> {
280 const IN_FLIGHT: [WorkStatus; 4] = [
281 WorkStatus::Claimed,
282 WorkStatus::InProgress,
283 WorkStatus::Blocked,
284 WorkStatus::Submitted,
285 ];
286 plan.work_items
287 .values()
288 .filter(|w| IN_FLIGHT.contains(&w.execution.status) && !in_status_scope(timeline, w.id))
289 .collect()
290}
291
292fn downstream_counts(
296 plan: &Plan,
297 timeline: &Timeline,
298 starts: impl IntoIterator<Item = WorkItemId>,
299) -> BTreeMap<WorkItemId, usize> {
300 let mut outgoing = BTreeMap::<WorkItemId, Vec<WorkItemId>>::new();
301 for dep in plan.enforced_dependencies().filter(|d| {
302 let state = timeline.applicability(d.successor);
303 !(state.is_not_selected()
304 || matches!(
305 state,
306 Applicability::Stranded { .. }
307 | Applicability::ChildrenStranded { .. }
308 | Applicability::EmptyJoin
309 ))
310 }) {
311 outgoing
312 .entry(dep.predecessor)
313 .or_default()
314 .push(dep.successor);
315 }
316
317 let mut result = BTreeMap::new();
318 for start in starts {
319 let mut seen = BTreeSet::new();
320 let mut queue = VecDeque::from([start]);
321 while let Some(node) = queue.pop_front() {
322 if let Some(next) = outgoing.get(&node) {
323 for child in next {
324 if seen.insert(*child) {
325 queue.push_back(*child);
326 }
327 }
328 }
329 }
330 result.insert(start, seen.len());
331 }
332 result
333}