predictability-engine 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/bin/predictability-engine +19 -0
- data/bin/predictability-engine.bat +2 -0
- data/bin/setup +47 -0
- data/data/samples/sample_data.csv +19 -0
- data/data/samples/sample_data_large.csv +201 -0
- data/data/samples/wip_data.csv +5 -0
- data/lib/predictability_engine/agents/assistant.rb +29 -0
- data/lib/predictability_engine/agents/tools.rb +98 -0
- data/lib/predictability_engine/calculators/aging.rb +36 -0
- data/lib/predictability_engine/calculators/cfd.rb +80 -0
- data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
- data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
- data/lib/predictability_engine/calculators/throughput.rb +42 -0
- data/lib/predictability_engine/cli.rb +414 -0
- data/lib/predictability_engine/config.rb +167 -0
- data/lib/predictability_engine/data_generator.rb +53 -0
- data/lib/predictability_engine/data_manager.rb +28 -0
- data/lib/predictability_engine/data_sources/base.rb +93 -0
- data/lib/predictability_engine/data_sources/csv.rb +62 -0
- data/lib/predictability_engine/data_sources/excel.rb +18 -0
- data/lib/predictability_engine/data_sources/factory.rb +20 -0
- data/lib/predictability_engine/data_sources/jira.rb +201 -0
- data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
- data/lib/predictability_engine/duration.rb +16 -0
- data/lib/predictability_engine/excel_exporter.rb +48 -0
- data/lib/predictability_engine/html_style.rb +63 -0
- data/lib/predictability_engine/html_templates.rb +70 -0
- data/lib/predictability_engine/jira_auth/base.rb +26 -0
- data/lib/predictability_engine/jira_auth/basic.rb +15 -0
- data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
- data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
- data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
- data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
- data/lib/predictability_engine/jira_auth.rb +22 -0
- data/lib/predictability_engine/jira_config_prompter.rb +48 -0
- data/lib/predictability_engine/jira_workflow.rb +137 -0
- data/lib/predictability_engine/logger.rb +45 -0
- data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
- data/lib/predictability_engine/models/work_item.rb +43 -0
- data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
- data/lib/predictability_engine/pdf_visualizer.rb +64 -0
- data/lib/predictability_engine/raw_data_exporter.rb +46 -0
- data/lib/predictability_engine/report/constants.rb +70 -0
- data/lib/predictability_engine/report/image_generator.rb +36 -0
- data/lib/predictability_engine/report/text_renderer.rb +84 -0
- data/lib/predictability_engine/report.rb +328 -0
- data/lib/predictability_engine/report_generator.rb +170 -0
- data/lib/predictability_engine/setup_manager.rb +127 -0
- data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
- data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
- data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
- data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
- data/lib/predictability_engine/summary_visualizer.rb +36 -0
- data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
- data/lib/predictability_engine/terminal_visualizer.rb +97 -0
- data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
- data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
- data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
- data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
- data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
- data/lib/predictability_engine/vega_visualizer.rb +106 -0
- data/lib/predictability_engine/version.rb +5 -0
- data/lib/predictability_engine/visualizer.rb +114 -0
- data/lib/predictability_engine.rb +117 -0
- metadata +566 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'bundler'
|
|
5
|
+
|
|
6
|
+
module PredictabilityEngine
|
|
7
|
+
class SetupManager
|
|
8
|
+
NODE_MIN_MAJOR = 18
|
|
9
|
+
private_constant :NODE_MIN_MAJOR
|
|
10
|
+
|
|
11
|
+
def run
|
|
12
|
+
install_ruby_dependencies
|
|
13
|
+
ensure_node
|
|
14
|
+
install_or_update_playwright
|
|
15
|
+
configure_git_hooks
|
|
16
|
+
PredictabilityEngine.logger.info { <<~MSG }
|
|
17
|
+
Setup complete. Try:
|
|
18
|
+
predictability-engine summary data/samples/sample_data.csv
|
|
19
|
+
MSG
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def install_ruby_dependencies
|
|
25
|
+
PredictabilityEngine.logger.info { '==> Installing Ruby dependencies' }
|
|
26
|
+
Bundler.with_unbundled_env do
|
|
27
|
+
system('bundle', 'install', '--jobs', '4', '--retry', '3') || raise(Error, 'bundle install failed')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ensure_node
|
|
32
|
+
current = node_major_version
|
|
33
|
+
if current.nil?
|
|
34
|
+
PredictabilityEngine.logger.info { '==> Installing Node.js' }
|
|
35
|
+
manage_node(:install)
|
|
36
|
+
elsif current < NODE_MIN_MAJOR
|
|
37
|
+
PredictabilityEngine.logger.info { "==> Upgrading Node.js (found v#{current}, need v#{NODE_MIN_MAJOR}+)" }
|
|
38
|
+
manage_node(:upgrade)
|
|
39
|
+
else
|
|
40
|
+
PredictabilityEngine.logger.info { "==> Node.js v#{current} — already sufficient" }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def node_major_version
|
|
45
|
+
raw = `node --version 2>/dev/null`.strip
|
|
46
|
+
return nil if raw.empty?
|
|
47
|
+
|
|
48
|
+
raw.delete_prefix('v').split('.').first.to_i
|
|
49
|
+
rescue Errno::ENOENT
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def manage_node(action)
|
|
54
|
+
cmd = node_cmd_for(action)
|
|
55
|
+
system(*cmd) || raise(Error, "Node.js #{action} failed on #{Gem::Platform.local.os}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def node_cmd_for(action)
|
|
59
|
+
case Gem::Platform.local.os
|
|
60
|
+
when 'darwin'
|
|
61
|
+
action == :install ? %w[brew install node] : %w[brew upgrade node]
|
|
62
|
+
when 'linux'
|
|
63
|
+
[linux_node_package_manager, 'install', '-y', 'nodejs']
|
|
64
|
+
when /mingw/
|
|
65
|
+
action == :install ? %w[choco install nodejs -y] : %w[choco upgrade nodejs -y]
|
|
66
|
+
else
|
|
67
|
+
os = Gem::Platform.local.os
|
|
68
|
+
raise Error, "Cannot auto-install Node.js on '#{os}' — install Node #{NODE_MIN_MAJOR}+ manually"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def linux_node_package_manager
|
|
73
|
+
%w[apt-get dnf].each do |pm|
|
|
74
|
+
return pm if system(pm, '--version', out: File::NULL, err: File::NULL)
|
|
75
|
+
end
|
|
76
|
+
raise Error, 'No supported package manager found (tried apt-get, dnf) — install Node.js manually'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def install_or_update_playwright
|
|
80
|
+
if !playwright_installed?
|
|
81
|
+
PredictabilityEngine.logger.info { '==> Installing Playwright (first run)' }
|
|
82
|
+
system('npm', 'install') || raise(Error, 'npm install failed')
|
|
83
|
+
elsif playwright_outdated?
|
|
84
|
+
PredictabilityEngine.logger.info { '==> Updating Playwright' }
|
|
85
|
+
system('npm', 'update', 'playwright') || raise(Error, 'npm update playwright failed')
|
|
86
|
+
else
|
|
87
|
+
PredictabilityEngine.logger.info { '==> Playwright — already up to date' }
|
|
88
|
+
end
|
|
89
|
+
install_chromium_browser
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def playwright_installed?
|
|
93
|
+
File.exist?(File.join(gem_root, 'node_modules', '.bin', 'playwright'))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def playwright_outdated?
|
|
97
|
+
raw = IO.popen(%w[npm outdated --json], err: File::NULL, &:read)
|
|
98
|
+
JSON.parse(raw).key?('playwright')
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def install_chromium_browser
|
|
104
|
+
if ENV['PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD']
|
|
105
|
+
PredictabilityEngine.logger.info { '==> Chromium install skipped (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD set)' }
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
system('npx', 'playwright', 'install', 'chromium', '--with-deps') ||
|
|
110
|
+
raise(Error, 'playwright install chromium failed')
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def gem_root
|
|
114
|
+
File.expand_path('../..', __dir__)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def configure_git_hooks
|
|
118
|
+
if system('git', 'rev-parse', '--is-inside-work-tree', out: File::NULL, err: File::NULL)
|
|
119
|
+
system('git', 'config', 'core.hooksPath', '.githooks') ||
|
|
120
|
+
raise(Error, 'git config core.hooksPath failed')
|
|
121
|
+
PredictabilityEngine.logger.info { '==> Git hooks configured (.githooks)' }
|
|
122
|
+
else
|
|
123
|
+
PredictabilityEngine.logger.info { '==> Git hooks skipped (not a git repository)' }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module Simulators
|
|
5
|
+
class MonteCarlo
|
|
6
|
+
DEFAULT_TRIALS = 10_000
|
|
7
|
+
|
|
8
|
+
def self.when_will_it_be_done(backlog_count, historical_throughput, trials: DEFAULT_TRIALS)
|
|
9
|
+
validate_and_run(historical_throughput, trials) do
|
|
10
|
+
remaining = backlog_count
|
|
11
|
+
days = 0
|
|
12
|
+
|
|
13
|
+
while remaining.positive?
|
|
14
|
+
remaining -= historical_throughput.sample
|
|
15
|
+
days += 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
days
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.how_many_will_be_done(days_to_forecast, historical_throughput, trials: DEFAULT_TRIALS)
|
|
23
|
+
validate_and_run(historical_throughput, trials) do
|
|
24
|
+
total_done = 0
|
|
25
|
+
days_to_forecast.times do
|
|
26
|
+
total_done += historical_throughput.sample
|
|
27
|
+
end
|
|
28
|
+
total_done
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.validate_and_run(historical_throughput, trials, &)
|
|
33
|
+
return [] if historical_throughput.empty?
|
|
34
|
+
|
|
35
|
+
run_simulation(trials, &)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.percentile(results, percentile_value)
|
|
39
|
+
return nil if results.empty?
|
|
40
|
+
|
|
41
|
+
index = (results.size * percentile_value / 100.0).ceil - 1
|
|
42
|
+
results[index]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.run_simulation(trials)
|
|
46
|
+
srand(42)
|
|
47
|
+
results = []
|
|
48
|
+
trials.times { results << yield }
|
|
49
|
+
results.sort!
|
|
50
|
+
ensure
|
|
51
|
+
srand
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method :run_simulation, :validate_and_run
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module Simulators
|
|
5
|
+
class MonteCarloValidator
|
|
6
|
+
DEFAULT_VALIDATION_TRIALS = 200
|
|
7
|
+
MIN_COMPLETED_ITEMS = 10
|
|
8
|
+
|
|
9
|
+
# Hindcast calibration: randomly samples historical as-of dates, runs the primary
|
|
10
|
+
# Monte Carlo at each, and checks whether the predicted percentile days covered
|
|
11
|
+
# the actual outcome. Returns coverage ratios per percentile, or nil when there
|
|
12
|
+
# is insufficient data for any valid trial.
|
|
13
|
+
def self.calibration(
|
|
14
|
+
work_items,
|
|
15
|
+
percentiles: PredictabilityEngine::DEFAULT_PERCENTILES,
|
|
16
|
+
validation_trials: DEFAULT_VALIDATION_TRIALS,
|
|
17
|
+
primary_trials: MonteCarlo::DEFAULT_TRIALS
|
|
18
|
+
)
|
|
19
|
+
completed = work_items.select(&:completed?)
|
|
20
|
+
dates = candidate_dates(completed, validation_trials)
|
|
21
|
+
return nil if dates.empty?
|
|
22
|
+
|
|
23
|
+
trial_results = dates.map { |d| run_trial(completed, d, percentiles, primary_trials) }
|
|
24
|
+
aggregate_coverage(trial_results, percentiles, dates.size)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.date_range(completed)
|
|
28
|
+
sorted_ends = completed.map(&:end_date).sort
|
|
29
|
+
return nil if sorted_ends.size < MIN_COMPLETED_ITEMS
|
|
30
|
+
|
|
31
|
+
earliest_valid = sorted_ends[MIN_COMPLETED_ITEMS - 1]
|
|
32
|
+
latest_valid = sorted_ends.last - 1
|
|
33
|
+
return nil if earliest_valid > latest_valid
|
|
34
|
+
|
|
35
|
+
[earliest_valid, latest_valid]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.candidate_dates(completed, count)
|
|
39
|
+
range = date_range(completed)
|
|
40
|
+
return [] unless range
|
|
41
|
+
|
|
42
|
+
earliest, latest = range
|
|
43
|
+
span = (latest - earliest).to_i
|
|
44
|
+
return [] if span.zero?
|
|
45
|
+
|
|
46
|
+
srand(7)
|
|
47
|
+
dates = count.times.map { earliest + rand(span + 1) }
|
|
48
|
+
srand
|
|
49
|
+
dates
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.run_trial(completed, as_of_date, percentiles, primary_trials)
|
|
53
|
+
in_flight = completed.select { |i| i.in_progress?(as_of_date) }
|
|
54
|
+
historical = valid_historical(completed, as_of_date, in_flight) or return nil
|
|
55
|
+
|
|
56
|
+
actual_days = (in_flight.map(&:end_date).max - as_of_date).to_i
|
|
57
|
+
results = MonteCarlo.when_will_it_be_done(in_flight.size, historical, trials: primary_trials)
|
|
58
|
+
|
|
59
|
+
percentiles.to_h { |p| [p, actual_days <= MonteCarlo.percentile(results, p)] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.valid_historical(completed, as_of_date, in_flight)
|
|
63
|
+
return nil if in_flight.empty?
|
|
64
|
+
|
|
65
|
+
historical_items = completed.select { |i| i.end_date <= as_of_date }
|
|
66
|
+
return nil if historical_items.size < MIN_COMPLETED_ITEMS
|
|
67
|
+
|
|
68
|
+
historical = Calculators::Throughput.daily(historical_items).values
|
|
69
|
+
historical unless historical.all?(&:zero?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.aggregate_coverage(trial_results, percentiles, total_sampled)
|
|
73
|
+
valid = trial_results.compact
|
|
74
|
+
return nil if valid.empty?
|
|
75
|
+
|
|
76
|
+
coverage = percentiles.to_h do |p|
|
|
77
|
+
[p, valid.count { |t| t[p] }.to_f / valid.size]
|
|
78
|
+
end
|
|
79
|
+
coverage.merge(trials_run: valid.size, trials_skipped: total_sampled - valid.size)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private_class_method :date_range, :candidate_dates, :run_trial, :valid_historical, :aggregate_coverage
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module SummaryVisualizer
|
|
5
|
+
module Helpers
|
|
6
|
+
def self.metric_lines(work_items, metrics, prefix: '', bold: '')
|
|
7
|
+
shared_metrics(work_items, metrics).map { |k, v| "#{prefix}#{bold}#{k}:#{bold} #{v}" }.join("\n")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.html_metric_li(label, value)
|
|
11
|
+
"<li><strong>#{label}:</strong> <span class='metric-value'>#{value}</span></li>"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.metric_list(work_items, metrics)
|
|
15
|
+
shared_metrics(work_items, metrics).map do |k, v|
|
|
16
|
+
if v.to_s.include?("\n")
|
|
17
|
+
items = v.to_s.strip.split("\n").map { |e| "<li>#{e.strip}</li>" }.join
|
|
18
|
+
"<li class='breakdown'><strong>#{k}:</strong><ul>#{items}</ul></li>"
|
|
19
|
+
else
|
|
20
|
+
html_metric_li(k, v)
|
|
21
|
+
end
|
|
22
|
+
end.join("\n")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.percentile_lines(metrics, percentiles, prefix: '', bold: '', suffix: '')
|
|
26
|
+
percentiles.map do |p|
|
|
27
|
+
"#{prefix}#{bold}#{p}th Percentile:#{bold} #{metrics[:"p#{p}"]} days#{suffix}"
|
|
28
|
+
end.join("\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.shared_metrics(work_items, metrics)
|
|
32
|
+
result = {
|
|
33
|
+
'Total Items': work_items.size,
|
|
34
|
+
'Completed Items': metrics[:completed].size,
|
|
35
|
+
'Average Throughput': "#{metrics[:tp_avg].round(2)} items/day"
|
|
36
|
+
}
|
|
37
|
+
Report::FACETS.each do |facet|
|
|
38
|
+
breakdown = facet_breakdown(metrics[:completed], facet)
|
|
39
|
+
result[:"#{facet[:label]} Breakdown"] = breakdown if breakdown
|
|
40
|
+
end
|
|
41
|
+
result
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.facet_breakdown(completed_items, facet)
|
|
45
|
+
counts = completed_items.filter_map { |i| i.public_send(facet[:accessor]) }.tally
|
|
46
|
+
return nil if counts.size <= 1
|
|
47
|
+
|
|
48
|
+
"\n#{ordered_facet_entries(counts, facet).map { |e| " #{e}" }.join("\n")}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.ordered_facet_entries(counts, facet)
|
|
52
|
+
return priority_ordered_entries(counts) if facet[:key] == :priority
|
|
53
|
+
|
|
54
|
+
counts.sort_by { |_, v| -v }.map { |k, v| "#{k}: #{v}" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.priority_ordered_entries(counts)
|
|
58
|
+
priority_order = Report::Constants::PRIORITY_ORDER
|
|
59
|
+
ordered = priority_order.filter_map { |p| "#{p}: #{counts[p]}" if counts[p] }
|
|
60
|
+
others = (counts.keys - priority_order).sort.map { |p| "#{p}: #{counts[p]}" }
|
|
61
|
+
ordered + others
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private_class_method :facet_breakdown, :ordered_facet_entries, :priority_ordered_entries
|
|
65
|
+
|
|
66
|
+
def self.terminal_colors(color)
|
|
67
|
+
color ? ["\e[1m", "\e[36m", "\e[0m"] : ['', '', '']
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'helpers'
|
|
4
|
+
|
|
5
|
+
module PredictabilityEngine
|
|
6
|
+
module SummaryVisualizer
|
|
7
|
+
module Renderer
|
|
8
|
+
def self.render_html_summary(work_items, metrics, percentiles)
|
|
9
|
+
html = <<~HTML
|
|
10
|
+
<h2>Flow Metrics Summary</h2>
|
|
11
|
+
<ul>
|
|
12
|
+
#{Helpers.metric_list(work_items, metrics)}
|
|
13
|
+
</ul>
|
|
14
|
+
HTML
|
|
15
|
+
|
|
16
|
+
if metrics[:aging]
|
|
17
|
+
items = aging_pairs(metrics[:aging]).map { |l, v| Helpers.html_metric_li(l, v) }.join("\n ")
|
|
18
|
+
html += <<~HTML
|
|
19
|
+
<h3>Aging WIP Summary</h3>
|
|
20
|
+
<ul>
|
|
21
|
+
#{items}
|
|
22
|
+
</ul>
|
|
23
|
+
HTML
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
html += <<~HTML
|
|
27
|
+
<h3>Cycle Time Percentiles</h3>
|
|
28
|
+
<ul>
|
|
29
|
+
#{Helpers.percentile_lines(metrics, percentiles, prefix: '<li><strong>', bold: '</strong>', suffix: '</li>')}
|
|
30
|
+
</ul>
|
|
31
|
+
HTML
|
|
32
|
+
html
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.render_terminal_summary(work_items, metrics, color, percentiles)
|
|
36
|
+
bold, cyan, reset = Helpers.terminal_colors(color)
|
|
37
|
+
out = [
|
|
38
|
+
"#{bold}Flow Metrics Summary#{reset}",
|
|
39
|
+
'--------------------',
|
|
40
|
+
Helpers.metric_lines(work_items, metrics), ''
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
out += aging_summary_lines(metrics, "#{cyan}Aging WIP Summary:#{reset}", ' ') if metrics[:aging]
|
|
44
|
+
|
|
45
|
+
out += [
|
|
46
|
+
"#{cyan}Cycle Time Percentiles:#{reset}",
|
|
47
|
+
Helpers.percentile_lines(metrics, percentiles, prefix: ' '), ''
|
|
48
|
+
]
|
|
49
|
+
out.join("\n")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.aging_pairs(aging)
|
|
53
|
+
[['Active WIP', "#{aging[:count]} items"],
|
|
54
|
+
['Average WIP Age', "#{aging[:avg_age]} days"],
|
|
55
|
+
['Oldest Item Age', "#{aging[:max_age]} days"]]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.aging_summary_lines(metrics, title, prefix, bold = '')
|
|
59
|
+
lines = aging_pairs(metrics[:aging]).map { |label, value| "#{prefix}#{bold}#{label}:#{bold} #{value}" }
|
|
60
|
+
[title, '', *lines, '']
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.render_markdown_summary(work_items, metrics, percentiles)
|
|
64
|
+
render_markup_summary(work_items, metrics, percentiles, { bold: '**', head2: '##', head3: '###' })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.render_confluence_summary(work_items, metrics, percentiles)
|
|
68
|
+
render_markup_summary(work_items, metrics, percentiles, { bold: '*', head2: 'h2.', head3: 'h3.' })
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.render_markup_summary(work_items, metrics, percentiles, styling)
|
|
72
|
+
head2, head3, bold = styling.values_at(:head2, :head3, :bold)
|
|
73
|
+
out = [
|
|
74
|
+
"#{head2} Flow Metrics Summary", '',
|
|
75
|
+
Helpers.metric_lines(work_items, metrics, prefix: '* ', bold: bold), ''
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
out += aging_summary_lines(metrics, "#{head3} Aging WIP Summary", '* ', bold) if metrics[:aging]
|
|
79
|
+
|
|
80
|
+
out += [
|
|
81
|
+
"#{head3} Cycle Time Percentiles", '',
|
|
82
|
+
Helpers.percentile_lines(metrics, percentiles, prefix: '* ', bold: bold)
|
|
83
|
+
]
|
|
84
|
+
out.join("\n")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module SummaryVisualizer
|
|
5
|
+
# Dynamically define metrics methods for all supported formats
|
|
6
|
+
%i[html terminal markdown confluence].each do |fmt|
|
|
7
|
+
define_singleton_method("metrics_#{fmt}") do |work_items, **opts|
|
|
8
|
+
render(work_items, fmt, **opts)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.render(work_items, format, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES, **options)
|
|
13
|
+
stats = calculate_metrics(work_items, percentiles: percentiles)
|
|
14
|
+
case format.to_sym
|
|
15
|
+
when :html then Renderer.render_html_summary(work_items, stats, percentiles)
|
|
16
|
+
when :terminal then Renderer.render_terminal_summary(work_items, stats, options[:color], percentiles)
|
|
17
|
+
when :markdown then Renderer.render_markdown_summary(work_items, stats, percentiles)
|
|
18
|
+
when :confluence then Renderer.render_confluence_summary(work_items, stats, percentiles)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.calculate_metrics(work_items, percentiles: PredictabilityEngine::DEFAULT_PERCENTILES)
|
|
23
|
+
metrics = {
|
|
24
|
+
completed: PredictabilityEngine.completed_items(work_items),
|
|
25
|
+
tp_avg: Calculators::Throughput.average(work_items),
|
|
26
|
+
aging: Calculators::Aging.summary_metrics(work_items)
|
|
27
|
+
}
|
|
28
|
+
percentiles.each do |p|
|
|
29
|
+
metrics[:"p#{p}"] = Calculators::CycleTime.percentile(work_items, p)
|
|
30
|
+
end
|
|
31
|
+
metrics
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :calculate_metrics
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module TerminalVisualizer
|
|
5
|
+
module CfdRenderer
|
|
6
|
+
def self.build_forecast_params(data)
|
|
7
|
+
start = data[:dates].first
|
|
8
|
+
{
|
|
9
|
+
start: start,
|
|
10
|
+
x_coords: data[:dates].map { |d| (d - start).to_i },
|
|
11
|
+
hist_size: data[:departed].size,
|
|
12
|
+
total_items: data[:summary][:total_items],
|
|
13
|
+
max_x: data[:dates].map { |d| (d - start).to_i }.max || 0,
|
|
14
|
+
arrivals: data[:arrivals]
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.add_forecast_layers!(plot, data, params, percentiles)
|
|
19
|
+
add_historical_departures!(plot, data, params)
|
|
20
|
+
f_colors = { 50 => :yellow, 75 => :red, 85 => :magenta, 95 => :cyan, 98 => :white }
|
|
21
|
+
percentiles.sort.reverse.each do |p|
|
|
22
|
+
add_confidence_layer!(plot, data, params, p, sorted_pcts: percentiles.sort,
|
|
23
|
+
color: f_colors[p] || :white)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.add_historical_departures!(plot, data, params)
|
|
28
|
+
UnicodePlot.stairs!(plot, params[:x_coords].take(params[:hist_size]), data[:departed],
|
|
29
|
+
name: 'Departures', color: :green)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.add_confidence_layer!(plot, data, params, percentile, **opts)
|
|
33
|
+
color = opts[:color]
|
|
34
|
+
f_x = params[:x_coords].drop(params[:hist_size] - 1)
|
|
35
|
+
f_y = data[:forecasts][percentile].drop(params[:hist_size] - 1)
|
|
36
|
+
UnicodePlot.lineplot!(plot, f_x, f_y, name: "#{percentile}% Confidence", color: color)
|
|
37
|
+
draw_deadline!(plot, data, params, percentile, opts[:sorted_pcts])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.draw_deadline!(plot, data, params, percentile, sorted_pcts)
|
|
41
|
+
idx = sorted_pcts.index(percentile)
|
|
42
|
+
target_p = idx < sorted_pcts.size - 1 ? sorted_pcts[idx + 1] : percentile
|
|
43
|
+
deadline_idx = params[:hist_size] - 1 + data[:summary][:"p#{target_p}"]
|
|
44
|
+
deadline_x = params[:x_coords][deadline_idx]
|
|
45
|
+
forecast_at_deadline = data[:forecasts][percentile][deadline_idx]
|
|
46
|
+
UnicodePlot.lineplot!(plot, [deadline_x, deadline_x], [0, forecast_at_deadline], color: :normal)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private_class_method :add_historical_departures!, :add_confidence_layer!, :draw_deadline!
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'unicode_plot'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require_relative 'terminal_visualizer/cfd_renderer'
|
|
6
|
+
|
|
7
|
+
module PredictabilityEngine
|
|
8
|
+
module TerminalVisualizer
|
|
9
|
+
def self.aging_wip(items, color: false, pcts: DEFAULT_PERCENTILES, **)
|
|
10
|
+
age_data = Calculators::Aging.item_age_data(items)
|
|
11
|
+
return 'No items currently in progress.' if age_data.empty?
|
|
12
|
+
|
|
13
|
+
PredictabilityEngine.mapped_percentiles(items, pcts)
|
|
14
|
+
plot = UnicodePlot.barplot(age_data.map { |d| d[:id].to_s }, age_data.map { |d| d[:age] },
|
|
15
|
+
title: 'Aging Work In Progress (Days)', color: color ? :blue : nil)
|
|
16
|
+
render_to_string(plot, color: color)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.cycle_time_scatter(items, title: 'Cycle Time Scatter Plot', color: false,
|
|
20
|
+
pcts: DEFAULT_PERCENTILES, **)
|
|
21
|
+
completed = Calculators::CycleTime.completed_sorted(items)
|
|
22
|
+
return 'No completed items to plot.' if completed.empty?
|
|
23
|
+
|
|
24
|
+
start = completed.first.end_date
|
|
25
|
+
x = completed.map { |i| (i.end_date - start).to_i }
|
|
26
|
+
xlabel = "Days since #{PredictabilityEngine.format_date(start)}"
|
|
27
|
+
plot = UnicodePlot.scatterplot(x, completed.map(&:cycle_time), title: title,
|
|
28
|
+
xlabel: xlabel,
|
|
29
|
+
ylabel: 'Cycle Time (days)')
|
|
30
|
+
PredictabilityEngine.mapped_percentiles(items, pcts).each do |p|
|
|
31
|
+
UnicodePlot.lineplot!(plot, x.minmax, [p[:val], p[:val]], name: p[:label])
|
|
32
|
+
end
|
|
33
|
+
render_to_string(plot, color: color)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.throughput_histogram(items, title: 'Throughput Histogram', color: false, **)
|
|
37
|
+
daily = Calculators::Throughput.daily(items).values
|
|
38
|
+
return 'No throughput data to plot.' if daily.empty?
|
|
39
|
+
|
|
40
|
+
plot = UnicodePlot.histogram(daily, title: title, xlabel: 'Items per day', ylabel: 'Frequency')
|
|
41
|
+
render_to_string(plot, color: color)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.cycle_time_bands(items, title: 'Cycle Time Bands Over Time', color: false, **)
|
|
45
|
+
completed = PredictabilityEngine.completed_items(items)
|
|
46
|
+
return 'No completed items to plot.' if completed.empty?
|
|
47
|
+
|
|
48
|
+
labels = RawDataExporter::DONE_THRESHOLD_LABELS
|
|
49
|
+
counts = Array.new(labels.size, 0)
|
|
50
|
+
completed.each { |item| counts[RawDataExporter.threshold_index(item.cycle_time)] += 1 }
|
|
51
|
+
plot = UnicodePlot.barplot(labels, counts, title: title, xlabel: 'Items Completed')
|
|
52
|
+
render_to_string(plot, color: color)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.cfd_plot(work_items, title: 'Cumulative Flow Diagram', color: false, **_opts)
|
|
56
|
+
cfd = Calculators::Cfd.calculate(work_items)
|
|
57
|
+
return 'No CFD data to plot.' if cfd.empty?
|
|
58
|
+
|
|
59
|
+
start = cfd.first[:date]
|
|
60
|
+
coords = Calculators::Cfd.to_coordinates(cfd, start)
|
|
61
|
+
max_y = coords[:arrived].max || 0
|
|
62
|
+
max_x = coords[:dates].max || 0
|
|
63
|
+
|
|
64
|
+
# Arrivals first for legend and top boundary
|
|
65
|
+
plot = UnicodePlot.stairs(coords[:dates], coords[:arrived],
|
|
66
|
+
title: title, name: 'Arrivals',
|
|
67
|
+
xlabel: "Days since #{PredictabilityEngine.format_date(start)}", ylabel: 'Total Items',
|
|
68
|
+
color: :blue, xlim: [0, max_x], ylim: [0, max_y])
|
|
69
|
+
# Departures next
|
|
70
|
+
UnicodePlot.stairs!(plot, coords[:dates], coords[:departed], name: 'Departures', color: :green)
|
|
71
|
+
render_to_string(plot, color: color)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.forecasted_cfd_plot(work_items, title: 'Forecasted Cumulative Flow Diagram', color: false,
|
|
75
|
+
percentiles: PredictabilityEngine::DEFAULT_PERCENTILES, **_opts)
|
|
76
|
+
data = Calculators::Cfd.forecast_series(work_items, percentiles: percentiles)
|
|
77
|
+
return cfd_plot(work_items, title: title, color: color) unless data
|
|
78
|
+
|
|
79
|
+
params = CfdRenderer.build_forecast_params(data)
|
|
80
|
+
plot = UnicodePlot.stairs(params[:x_coords], params[:arrivals],
|
|
81
|
+
title: title, name: 'Arrivals', ylabel: 'Total Items',
|
|
82
|
+
xlabel: "Days since #{PredictabilityEngine.format_date(params[:start])}", color: :blue,
|
|
83
|
+
xlim: [0, params[:max_x]], ylim: [0, params[:total_items]])
|
|
84
|
+
|
|
85
|
+
CfdRenderer.add_forecast_layers!(plot, data, params, percentiles)
|
|
86
|
+
render_to_string(plot, color: color)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.render_to_string(plot, color: false)
|
|
90
|
+
sio = StringIO.new
|
|
91
|
+
plot.render(sio, color: color)
|
|
92
|
+
sio.string
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method :render_to_string
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PredictabilityEngine
|
|
4
|
+
module VegaVisualizer
|
|
5
|
+
module AgingWipVisualizer
|
|
6
|
+
def self.aging_wip(items, title: 'Aging Work In Progress',
|
|
7
|
+
pcts: PredictabilityEngine::DEFAULT_PERCENTILES, **)
|
|
8
|
+
raw = Calculators::Aging.item_age_data(items)
|
|
9
|
+
return Vega.lite.data([]).title(title || 'Aging Work In Progress') if raw.empty?
|
|
10
|
+
|
|
11
|
+
data = raw.map { |row| row.merge(title_display: VegaVisualizer.wrap_tooltip_title(row[:title].to_s)) }
|
|
12
|
+
mapped = PredictabilityEngine.mapped_percentiles(items, pcts)
|
|
13
|
+
VegaVisualizer.apply_standard_dims(
|
|
14
|
+
Vega.lite.data(data)
|
|
15
|
+
.layer([aging_bar_layer, *aging_rule_layers(mapped)]),
|
|
16
|
+
title: title
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.aging_bar_layer
|
|
21
|
+
{ mark: { type: 'bar', stroke: 'white', strokeWidth: 0.2 },
|
|
22
|
+
encoding: { x: { field: 'id', type: 'nominal', title: 'Work Item ID', sort: '-y',
|
|
23
|
+
axis: VegaVisualizer::LABEL_AXIS },
|
|
24
|
+
y: VegaVisualizer.quantitative_y_axis('age', title: 'Age (days)'),
|
|
25
|
+
color: { field: 'age', type: 'quantitative', scale: { scheme: 'yelloworangered' },
|
|
26
|
+
legend: { orient: 'bottom', title: 'Age' } },
|
|
27
|
+
**VegaVisualizer.item_href_and_tooltip(
|
|
28
|
+
[{ field: 'age', type: 'quantitative', title: 'Age (days)' }]
|
|
29
|
+
) } }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.aging_rule_layers(mapped_pcts)
|
|
33
|
+
mapped_pcts.map do |p|
|
|
34
|
+
{ data: { values: [{ val: p[:val], label: p[:label] }] },
|
|
35
|
+
mark: { type: 'rule', strokeDash: [4, 4] },
|
|
36
|
+
encoding: { y: VegaVisualizer.quantitative_y_axis('val', title: 'Age (days)'),
|
|
37
|
+
color: { value: '#e45756' },
|
|
38
|
+
tooltip: [{ field: 'label', type: 'nominal', title: 'Percentile' },
|
|
39
|
+
{ field: 'val', type: 'quantitative', title: 'Age (days)' }] } }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|