OXYGENHUB
◆ v0.9.24 oxygenhub / crates / hub / src / board_stats.rs
11.0 KiB · Rust · last changed Board charts and forecasts from the board's history, and its timeline (closes #357, closes #361) 1 day ago source blame history raw
f2d2776b Andy McCall ↶ 1
//! A board's charts and forecasts from its own history (oxygenhub#357):
2
//! cumulative flow, cycle time, throughput and a Monte Carlo of when the
3
//! remaining cards will be done, with no estimates to keep. Everything here
4
//! is a pure function of the board's card moves (`project_card_moves`), so
5
//! the series and the forecast's percentiles are testable from a fixture
6
//! board and a fixed seed.
7
8
use std::collections::{BTreeMap, HashMap};
9
10
pub const DAY_MS: i64 = 86_400_000;
11
pub const WEEK_MS: i64 = 7 * DAY_MS;
12
13
/// A card's move: to a column, or off the board (`None`), at a time.
14
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15
pub struct Move {
16
    pub card: i64,
17
    pub column: Option<i64>,
18
    pub at: i64,
19
}
20
21
/// Cards per column at the end of each day from `first_day` (midnight UTC,
22
/// ms) for `days` days: one row a day, one count per column of `columns`
23
/// (in board order). Moves must be oldest first.
24
pub fn cumulative_flow(moves: &[Move], columns: &[i64], first_day: i64, days: usize) -> Vec<Vec<usize>> {
25
    let mut at: HashMap<i64, Option<i64>> = HashMap::new();
26
    let mut i = 0;
27
    let mut out = Vec::with_capacity(days);
28
    for d in 0..days {
29
        let end = first_day + (d as i64 + 1) * DAY_MS;
30
        while i < moves.len() && moves[i].at < end {
31
            at.insert(moves[i].card, moves[i].column);
32
            i += 1;
33
        }
34
        let mut counts = vec![0; columns.len()];
35
        for col in at.values().flatten() {
36
            if let Some(k) = columns.iter().position(|c| c == col) {
37
                counts[k] += 1;
38
            }
39
        }
40
        out.push(counts);
41
    }
42
    out
43
}
44
45
/// Each finished card's cycle time in ms: from when it first left the
46
/// board's first column (work started) to when it first reached `done`
47
/// after that. A card added straight into a later column starts there.
48
pub fn cycle_times(moves: &[Move], first_column: i64, done: i64) -> Vec<i64> {
49
    let mut started: HashMap<i64, i64> = HashMap::new();
50
    let mut finished: BTreeMap<i64, i64> = BTreeMap::new();
51
    for m in moves {
52
        let Some(col) = m.column else { continue };
53
        if col != first_column && !started.contains_key(&m.card) {
54
            started.insert(m.card, m.at);
55
        }
56
        if col == done && !finished.contains_key(&m.card) {
57
            if let Some(s) = started.get(&m.card) {
58
                finished.insert(m.card, m.at - s);
59
            }
60
        }
61
    }
62
    finished.into_values().collect()
63
}
64
65
/// The `p`th percentile (0 to 100) of `values`, nearest rank; `None` for none.
66
pub fn percentile(values: &[i64], p: u32) -> Option<i64> {
67
    if values.is_empty() {
68
        return None;
69
    }
70
    let mut v = values.to_vec();
71
    v.sort_unstable();
72
    let rank = ((p as f64 / 100.0) * v.len() as f64).ceil().max(1.0) as usize;
73
    Some(v[rank.min(v.len()) - 1])
74
}
75
76
/// Cards that first reached `done` in each of the `weeks` whole weeks
77
/// before `now`, oldest first.
78
pub fn weekly_throughput(moves: &[Move], done: i64, now: i64, weeks: usize) -> Vec<usize> {
79
    let mut first: HashMap<i64, i64> = HashMap::new();
80
    for m in moves {
81
        if m.column == Some(done) {
82
            first.entry(m.card).or_insert(m.at);
83
        }
84
    }
85
    let start = now - weeks as i64 * WEEK_MS;
86
    let mut out = vec![0; weeks];
87
    for at in first.values() {
88
        if *at >= start && *at < now {
89
            out[((at - start) / WEEK_MS) as usize] += 1;
90
        }
91
    }
92
    out
93
}
94
95
/// A forecast: in how many weeks the remaining cards are done at 50% and
96
/// 85% confidence, from how many weeks of history, and `None` when the
97
/// history holds no finished card to go on.
98
#[derive(Clone, Debug, PartialEq, Eq)]
99
pub struct Forecast {
100
    pub remaining: usize,
101
    pub weeks_of_history: usize,
102
    pub p50_weeks: Option<usize>,
103
    pub p85_weeks: Option<usize>,
104
}
105
106
/// A small, seeded generator (xorshift64*): the forecast is the same for
107
/// the same history and seed, so it is testable and does not flicker
108
/// between page loads.
109
struct Rng(u64);
110
111
impl Rng {
112
    fn next(&mut self) -> u64 {
113
        let mut x = self.0;
114
        x ^= x >> 12;
115
        x ^= x << 25;
116
        x ^= x >> 27;
117
        self.0 = x;
118
        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
119
    }
120
    fn below(&mut self, n: usize) -> usize {
121
        (self.next() % n as u64) as usize
122
    }
123
}
124
125
/// Weeks a run of trials can take at most before it is counted as never.
126
const HORIZON_WEEKS: usize = 520;
127
128
/// A Monte Carlo of when `remaining` cards are done: each trial draws
129
/// weeks from `throughput` (cards finished in each past week) until the
130
/// remaining cards are used up, `trials` times, seeded with `seed`.
131
pub fn forecast(throughput: &[usize], remaining: usize, trials: usize, seed: u64) -> Forecast {
132
    let weeks_of_history = throughput.len();
133
    if remaining == 0 {
134
        return Forecast { remaining, weeks_of_history, p50_weeks: Some(0), p85_weeks: Some(0) };
135
    }
136
    if throughput.iter().all(|t| *t == 0) || trials == 0 {
137
        return Forecast { remaining, weeks_of_history, p50_weeks: None, p85_weeks: None };
138
    }
139
    let mut rng = Rng(seed | 1);
140
    let mut taken: Vec<i64> = Vec::with_capacity(trials);
141
    for _ in 0..trials {
142
        let (mut left, mut weeks) = (remaining as i64, 0usize);
143
        while left > 0 && weeks < HORIZON_WEEKS {
144
            left -= throughput[rng.below(throughput.len())] as i64;
145
            weeks += 1;
146
        }
147
        taken.push(weeks as i64);
148
    }
149
    let at = |p| percentile(&taken, p).map(|w| w as usize).filter(|w| *w < HORIZON_WEEKS);
150
    Forecast { remaining, weeks_of_history, p50_weeks: at(50), p85_weeks: at(85) }
151
}
152
153
/// The open iteration's burn-down and scope: at the end of each day so
154
/// far, cards on the board not in `done` (left) and those plus the ones
155
/// that reached `done` since the iteration began (scope).
156
pub fn burn_down(moves: &[Move], done: i64, start: i64, days: usize) -> Vec<(usize, usize)> {
157
    let mut at: HashMap<i64, Option<i64>> = HashMap::new();
158
    let mut done_since: std::collections::HashSet<i64> = Default::default();
159
    let mut i = 0;
160
    let mut out = Vec::with_capacity(days);
161
    for d in 0..days {
162
        let end = start + (d as i64 + 1) * DAY_MS;
163
        while i < moves.len() && moves[i].at < end {
164
            let m = moves[i];
165
            at.insert(m.card, m.column);
166
            if m.column == Some(done) && m.at >= start {
167
                done_since.insert(m.card);
168
            }
169
            i += 1;
170
        }
171
        let left = at.values().filter(|c| c.is_some() && **c != Some(done)).count();
172
        // Done stays done when it leaves the board (an iteration closing).
173
        out.push((left, left + done_since.len()));
174
    }
175
    out
176
}
177
178
/// Points for an SVG polyline of `values` in a `w` × `h` box scaled to
179
/// `max` (at least 1), one step per value.
180
pub fn polyline(values: &[usize], max: usize, w: i64, h: i64) -> String {
181
    let max = max.max(1) as f64;
182
    let n = values.len().max(2) - 1;
183
    values
184
        .iter()
185
        .enumerate()
186
        .map(|(i, v)| format!("{},{}", (i as i64 * w) / n as i64, h - ((*v as f64 / max) * h as f64).round() as i64))
187
        .collect::<Vec<_>>()
188
        .join(" ")
189
}
190
191
/// The bands of a cumulative flow chart as SVG polygon points, one per
192
/// column (bottom band last in board order, Done at the bottom): each band
193
/// spans from the sum of the columns after it to that sum plus its own.
194
pub fn flow_bands(rows: &[Vec<usize>], w: i64, h: i64) -> Vec<String> {
195
    let cols = rows.first().map(|r| r.len()).unwrap_or(0);
196
    let max = rows.iter().map(|r| r.iter().sum::<usize>()).max().unwrap_or(0).max(1) as f64;
197
    let n = rows.len().max(2) - 1;
198
    let x = |i: usize| (i as i64 * w) / n as i64;
199
    let y = |v: usize| h - ((v as f64 / max) * h as f64).round() as i64;
200
    (0..cols)
201
        .map(|k| {
202
            let below = |r: &Vec<usize>| r[k + 1..].iter().sum::<usize>();
203
            let top: Vec<String> = rows.iter().enumerate().map(|(i, r)| format!("{},{}", x(i), y(below(r) + r[k]))).collect();
204
            let bottom: Vec<String> = rows.iter().enumerate().rev().map(|(i, r)| format!("{},{}", x(i), y(below(r)))).collect();
205
            [top, bottom].concat().join(" ")
206
        })
207
        .collect()
208
}
209
210
#[cfg(test)]
211
mod tests {
212
    use super::*;
213
214
    const T0: i64 = 1_000 * DAY_MS;
215
216
    fn m(card: i64, column: Option<i64>, day: i64) -> Move {
217
        Move { card, column, at: T0 + day * DAY_MS + 3_600_000 }
218
    }
219
220
    /// Backlog 1, Doing 2, Done 3: card 1 goes the whole way in four days,
221
    /// card 2 reaches Doing on day 2 and stays, card 3 is added straight
222
    /// into Doing on day 3 and done on day 5, card 4 is archived on day 4.
223
    fn board() -> Vec<Move> {
224
        vec![
225
            m(1, Some(1), 0),
226
            m(2, Some(1), 0),
227
            m(4, Some(1), 0),
228
            m(1, Some(2), 1),
229
            m(2, Some(2), 2),
230
            m(3, Some(2), 3),
231
            m(4, None, 4),
232
            m(1, Some(3), 5),
233
            m(3, Some(3), 5),
234
        ]
235
    }
236
237
    #[test]
238
    fn cumulative_flow_replays_the_moves() {
239
        let rows = cumulative_flow(&board(), &[1, 2, 3], T0, 6);
240
        assert_eq!(rows, vec![vec![3, 0, 0], vec![2, 1, 0], vec![1, 2, 0], vec![1, 3, 0], vec![0, 3, 0], vec![0, 1, 2]]);
241
        let bands = flow_bands(&rows, 100, 30);
242
        assert_eq!(bands.len(), 3);
243
        assert!(bands[2].starts_with("0,30"), "Done is the bottom band: {}", bands[2]);
244
    }
245
246
    #[test]
247
    fn cycle_time_and_throughput() {
248
        let mut times = cycle_times(&board(), 1, 3);
249
        times.sort();
250
        assert_eq!(times, vec![2 * DAY_MS, 4 * DAY_MS], "card 3: day 3 to 5; card 1: day 1 to 5");
251
        assert_eq!(percentile(&times, 50), Some(2 * DAY_MS));
252
        assert_eq!(percentile(&times, 85), Some(4 * DAY_MS));
253
        assert_eq!(percentile(&[], 50), None);
254
        let now = T0 + 7 * DAY_MS;
255
        assert_eq!(weekly_throughput(&board(), 3, now, 2), vec![0, 2]);
256
    }
257
258
    #[test]
259
    fn the_forecast_is_seeded_and_honest() {
260
        // Two cards a week, every week: ten cards take five weeks, always.
261
        let f = forecast(&[2, 2, 2, 2], 10, 1000, 7);
262
        assert_eq!((f.p50_weeks, f.p85_weeks, f.weeks_of_history), (Some(5), Some(5), 4));
263
        // Uneven weeks: the 85% date is no earlier than the 50% one, and the
264
        // same seed gives the same answer.
265
        let a = forecast(&[0, 1, 4, 2, 3], 12, 2000, 42);
266
        assert_eq!(a, forecast(&[0, 1, 4, 2, 3], 12, 2000, 42));
267
        let (p50, p85) = (a.p50_weeks.unwrap(), a.p85_weeks.unwrap());
268
        assert!((4..=7).contains(&p50) && p85 >= p50, "{a:?}");
269
        // Nothing finished yet: no forecast, said plainly.
270
        assert_eq!(forecast(&[0, 0], 5, 100, 1).p50_weeks, None);
271
        assert_eq!(forecast(&[], 5, 100, 1).p50_weeks, None);
272
        assert_eq!(forecast(&[1], 0, 100, 1).p85_weeks, Some(0));
273
    }
274
275
    #[test]
276
    fn burn_down_counts_left_and_scope() {
277
        let bd = burn_down(&board(), 3, T0, 6);
278
        assert_eq!(bd[0], (3, 3));
279
        assert_eq!(bd[3], (4, 4), "card 3 added on day 3 grows the scope");
280
        assert_eq!(bd[4], (3, 3), "card 4 archived on day 4 leaves it");
281
        assert_eq!(bd[5], (1, 3), "two done");
282
        assert_eq!(polyline(&[0, 2, 4], 4, 10, 10), "0,10 5,5 10,0");
283
    }
284
}