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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/bin/predictability-engine +19 -0
  3. data/bin/predictability-engine.bat +2 -0
  4. data/bin/setup +47 -0
  5. data/data/samples/sample_data.csv +19 -0
  6. data/data/samples/sample_data_large.csv +201 -0
  7. data/data/samples/wip_data.csv +5 -0
  8. data/lib/predictability_engine/agents/assistant.rb +29 -0
  9. data/lib/predictability_engine/agents/tools.rb +98 -0
  10. data/lib/predictability_engine/calculators/aging.rb +36 -0
  11. data/lib/predictability_engine/calculators/cfd.rb +80 -0
  12. data/lib/predictability_engine/calculators/cfd_forecaster.rb +112 -0
  13. data/lib/predictability_engine/calculators/cycle_time.rb +26 -0
  14. data/lib/predictability_engine/calculators/throughput.rb +42 -0
  15. data/lib/predictability_engine/cli.rb +414 -0
  16. data/lib/predictability_engine/config.rb +167 -0
  17. data/lib/predictability_engine/data_generator.rb +53 -0
  18. data/lib/predictability_engine/data_manager.rb +28 -0
  19. data/lib/predictability_engine/data_sources/base.rb +93 -0
  20. data/lib/predictability_engine/data_sources/csv.rb +62 -0
  21. data/lib/predictability_engine/data_sources/excel.rb +18 -0
  22. data/lib/predictability_engine/data_sources/factory.rb +20 -0
  23. data/lib/predictability_engine/data_sources/jira.rb +201 -0
  24. data/lib/predictability_engine/data_sources/jira_yaml.rb +103 -0
  25. data/lib/predictability_engine/duration.rb +16 -0
  26. data/lib/predictability_engine/excel_exporter.rb +48 -0
  27. data/lib/predictability_engine/html_style.rb +63 -0
  28. data/lib/predictability_engine/html_templates.rb +70 -0
  29. data/lib/predictability_engine/jira_auth/base.rb +26 -0
  30. data/lib/predictability_engine/jira_auth/basic.rb +15 -0
  31. data/lib/predictability_engine/jira_auth/bearer.rb +11 -0
  32. data/lib/predictability_engine/jira_auth/cookie.rb +15 -0
  33. data/lib/predictability_engine/jira_auth/mfa_api.rb +36 -0
  34. data/lib/predictability_engine/jira_auth/mfa_browser.rb +85 -0
  35. data/lib/predictability_engine/jira_auth.rb +22 -0
  36. data/lib/predictability_engine/jira_config_prompter.rb +48 -0
  37. data/lib/predictability_engine/jira_workflow.rb +137 -0
  38. data/lib/predictability_engine/logger.rb +45 -0
  39. data/lib/predictability_engine/mermaid_visualizer.rb +94 -0
  40. data/lib/predictability_engine/models/work_item.rb +43 -0
  41. data/lib/predictability_engine/pdf_visualizer/primitives.rb +83 -0
  42. data/lib/predictability_engine/pdf_visualizer.rb +64 -0
  43. data/lib/predictability_engine/raw_data_exporter.rb +46 -0
  44. data/lib/predictability_engine/report/constants.rb +70 -0
  45. data/lib/predictability_engine/report/image_generator.rb +36 -0
  46. data/lib/predictability_engine/report/text_renderer.rb +84 -0
  47. data/lib/predictability_engine/report.rb +328 -0
  48. data/lib/predictability_engine/report_generator.rb +170 -0
  49. data/lib/predictability_engine/setup_manager.rb +127 -0
  50. data/lib/predictability_engine/simulators/monte_carlo.rb +57 -0
  51. data/lib/predictability_engine/simulators/monte_carlo_validator.rb +85 -0
  52. data/lib/predictability_engine/summary_visualizer/helpers.rb +71 -0
  53. data/lib/predictability_engine/summary_visualizer/renderer.rb +88 -0
  54. data/lib/predictability_engine/summary_visualizer.rb +36 -0
  55. data/lib/predictability_engine/terminal_visualizer/cfd_renderer.rb +52 -0
  56. data/lib/predictability_engine/terminal_visualizer.rb +97 -0
  57. data/lib/predictability_engine/vega_visualizer/aging_wip_visualizer.rb +44 -0
  58. data/lib/predictability_engine/vega_visualizer/basic_charts.rb +121 -0
  59. data/lib/predictability_engine/vega_visualizer/cfd_charts.rb +82 -0
  60. data/lib/predictability_engine/vega_visualizer/cfd_layout.rb +132 -0
  61. data/lib/predictability_engine/vega_visualizer/tooltip_helpers.rb +34 -0
  62. data/lib/predictability_engine/vega_visualizer.rb +106 -0
  63. data/lib/predictability_engine/version.rb +5 -0
  64. data/lib/predictability_engine/visualizer.rb +114 -0
  65. data/lib/predictability_engine.rb +117 -0
  66. 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