use super::calibration::{HistoryIndex, ascending, elapsed_hours, exclusions};
use crate::{Command, Exclusion, ExclusionReason, Operation};
use chrono::{DateTime, Datelike, TimeDelta, Utc};
use dpm_model::{ActorId, ActorKind, Key, Plan, WorkItem, WorkItemId, WorkStatus};
use dpm_schedule::percentile;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const THROUGHPUT_WEEKS: usize = 8;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FlowReport {
pub cycle_time: Durations,
pub lead_time: Durations,
pub throughput: Throughput,
pub aging: Vec<AgingWork>,
pub reliability: Reliability,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Durations {
pub count: usize,
pub median_hours: Option<f64>,
pub p85_hours: Option<f64>,
pub excluded: Vec<Exclusion>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Throughput {
pub last_7_days: usize,
pub last_28_days: usize,
pub weeks: Vec<WeekCount>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WeekCount {
pub week: String,
pub verified: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgingWork {
pub key: Key,
pub status: WorkStatus,
pub owner: Option<ActorId>,
pub hours_since_claim: Option<f64>,
pub hours_since_start: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Reliability {
pub total: ClaimOutcomes,
pub by_holder: Vec<HolderOutcomes>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HolderOutcomes {
pub holder: ActorKind,
#[serde(flatten)]
pub outcomes: ClaimOutcomes,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct ClaimOutcomes {
pub episodes: usize,
pub verified: usize,
pub verified_after_rejection: usize,
pub released: usize,
pub handed_off: usize,
pub open: usize,
pub verified_fraction: Option<f64>,
}
pub(crate) fn flow(
plan: &Plan,
index: &HistoryIndex<'_>,
bulk: &BTreeSet<WorkItemId>,
now: DateTime<Utc>,
) -> FlowReport {
let done: Vec<&WorkItem> = plan
.work_items
.values()
.filter(|w| w.is_executable() && w.execution.status.satisfies_dependency())
.collect();
let mut cycle = Measured::default();
let mut lead = Measured::default();
let mut verified = Vec::new();
for work in done {
let Some(at) = work.execution.events.verified_at else {
cycle.skip(ExclusionReason::VerificationUnrecorded, work);
lead.skip(ExclusionReason::VerificationUnrecorded, work);
continue;
};
verified.push(at);
if bulk.contains(&work.id) {
cycle.skip(ExclusionReason::BulkRecorded, work);
lead.skip(ExclusionReason::BulkRecorded, work);
continue;
}
match work.execution.events.started_at {
Some(started) => cycle.hours.push(elapsed_hours(started, at)),
None => cycle.skip(ExclusionReason::StartUnrecorded, work),
}
let claim = index
.of(work.id)
.iter()
.find(|op| matches!(op.command, Command::Claim { .. }));
match claim {
Some(claim) => lead.hours.push(elapsed_hours(claim.timestamp, at)),
None => lead.skip(ExclusionReason::ClaimedBeforeHistory, work),
}
}
FlowReport {
cycle_time: cycle.durations(),
lead_time: lead.durations(),
throughput: throughput(verified.iter().copied(), now),
aging: aging(plan, index, now),
reliability: reliability(index.work_operations()),
}
}
#[derive(Default)]
struct Measured {
hours: Vec<f64>,
skipped: Vec<(ExclusionReason, Key)>,
}
impl Measured {
fn skip(&mut self, reason: ExclusionReason, work: &WorkItem) {
self.skipped.push((reason, work.key.clone()));
}
fn durations(self) -> Durations {
let sorted = ascending(self.hours);
let known = !sorted.is_empty();
Durations {
count: sorted.len(),
median_hours: known.then(|| percentile(&sorted, 0.5)),
p85_hours: known.then(|| percentile(&sorted, 0.85)),
excluded: exclusions(self.skipped),
}
}
}
fn throughput(
verified: impl Iterator<Item = DateTime<Utc>> + Clone,
now: DateTime<Utc>,
) -> Throughput {
let within = |days: i64| {
verified
.clone()
.filter(|at| *at <= now && now - *at < TimeDelta::days(days))
.count()
};
let week_of = |at: DateTime<Utc>| {
let week = at.iso_week();
(week.year(), week.week())
};
let weeks = (0..THROUGHPUT_WEEKS)
.rev()
.filter_map(|back| now.checked_sub_signed(TimeDelta::weeks(back as i64)))
.map(|at| {
let (year, week) = week_of(at);
WeekCount {
week: format!("{year}-W{week:02}"),
verified: verified
.clone()
.filter(|v| week_of(*v) == (year, week))
.count(),
}
})
.collect();
Throughput {
last_7_days: within(7),
last_28_days: within(28),
weeks,
}
}
fn aging(plan: &Plan, index: &HistoryIndex<'_>, now: DateTime<Utc>) -> Vec<AgingWork> {
const HELD: [WorkStatus; 4] = [
WorkStatus::Claimed,
WorkStatus::InProgress,
WorkStatus::Blocked,
WorkStatus::Submitted,
];
let mut aging: Vec<AgingWork> = plan
.work_items
.values()
.filter(|w| w.is_executable() && HELD.contains(&w.execution.status))
.map(|w| {
let held_since = w.execution.events.claimed_at.or_else(|| {
index
.last(w.id, |c| {
matches!(c, Command::Claim { .. } | Command::Handoff { .. })
})
.map(|op| op.timestamp)
});
AgingWork {
key: w.key.clone(),
status: w.execution.status,
owner: w.execution.owner.clone(),
hours_since_claim: held_since.map(|at| elapsed_hours(at, now)),
hours_since_start: w
.execution
.events
.started_at
.map(|at| elapsed_hours(at, now)),
}
})
.collect();
aging.sort_by(|a, b| {
let age = |w: &AgingWork| w.hours_since_claim.unwrap_or(f64::NEG_INFINITY);
age(b)
.total_cmp(&age(a))
.then_with(|| a.key.natural_cmp(&b.key))
});
aging
}
#[derive(Clone, Copy)]
enum Ending {
Verified { rejected: bool },
Released,
HandedOff,
Open,
}
fn reliability(operations: &[&Operation]) -> Reliability {
let mut open: BTreeMap<WorkItemId, (ActorKind, bool)> = BTreeMap::new();
let mut ended: Vec<(ActorKind, Ending)> = Vec::new();
for operation in operations {
match &operation.command {
Command::Claim { work } => {
open.insert(*work, (operation.actor.kind, false));
}
Command::Handoff { work, to, .. } => {
if let Some((holder, _)) = open.insert(*work, (to.kind, false)) {
ended.push((holder, Ending::HandedOff));
}
}
Command::Release { work, .. } => {
if let Some((holder, _)) = open.remove(work) {
ended.push((holder, Ending::Released));
}
}
Command::Reject { work, .. } => {
if let Some(episode) = open.get_mut(work) {
episode.1 = true;
}
}
Command::Verify { work, .. } => {
if let Some((holder, rejected)) = open.remove(work) {
ended.push((holder, Ending::Verified { rejected }));
}
}
_ => {}
}
}
ended.extend(open.into_values().map(|(holder, _)| (holder, Ending::Open)));
let mut by_holder: BTreeMap<ActorKind, ClaimOutcomes> = BTreeMap::new();
let mut total = ClaimOutcomes::default();
for (holder, ending) in ended {
count(&mut total, ending);
count(by_holder.entry(holder).or_default(), ending);
}
Reliability {
total: finish(total),
by_holder: by_holder
.into_iter()
.map(|(holder, outcomes)| HolderOutcomes {
holder,
outcomes: finish(outcomes),
})
.collect(),
}
}
fn count(outcomes: &mut ClaimOutcomes, ending: Ending) {
outcomes.episodes = outcomes.episodes.wrapping_add(1);
let slot = match ending {
Ending::Verified { rejected } => {
if rejected {
outcomes.verified_after_rejection =
outcomes.verified_after_rejection.wrapping_add(1);
}
&mut outcomes.verified
}
Ending::Released => &mut outcomes.released,
Ending::HandedOff => &mut outcomes.handed_off,
Ending::Open => &mut outcomes.open,
};
*slot = slot.wrapping_add(1);
}
fn finish(mut outcomes: ClaimOutcomes) -> ClaimOutcomes {
let closed = outcomes.verified + outcomes.released;
outcomes.verified_fraction = (closed > 0).then(|| outcomes.verified as f64 / closed as f64);
outcomes
}
#[cfg(test)]
mod tests;