opr-calc 0.1.2 → 1.0.0
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 +4 -4
- data/.rspec +2 -0
- data/Gemfile +3 -0
- data/Guardfile +14 -0
- data/LICENSE +630 -0
- data/README.md +25 -0
- data/Rakefile +4 -0
- data/lib/opr-calc.rb +2 -2
- data/lib/opr-calc/matrix.rb +13 -4
- data/lib/opr-calc/score_set.rb +43 -44
- data/opr-calc.gemspec +26 -0
- data/spec/lib/opr-calc/matrix_spec.rb +61 -0
- data/spec/lib/opr-calc/score_set_spec.rb +102 -0
- data/spec/lib/opr-calc_spec.rb +7 -0
- data/spec/spec_helper.rb +1 -0
- metadata +89 -14
data/README.md
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# opr-calc
|
2
|
+
|
3
|
+
[](http://badge.fury.io/rb/opr-calc)
|
4
|
+
|
5
|
+
A rubygem for calculating the OPR, DPR, and CCWM of FRC teams.
|
6
|
+
|
7
|
+
## Usage
|
8
|
+
|
9
|
+
Define a new ScoreSet with `OPRCalc::ScoreSet.new`. This takes four matrices as arguments: `ared`, `ablue`, `scorered`, and `scoreblue`.
|
10
|
+
The `a` matrices define what teams are in what matches.
|
11
|
+
The `score` matrices define what the final match scores are.
|
12
|
+
For example, consider the following match.
|
13
|
+
The first, third, and fifth team are on the red alliance, which scores 26.
|
14
|
+
The zeroth, second, and fourth team are on the blue alliance, which scores 56.
|
15
|
+
This would result in the following rows the various matrices.
|
16
|
+
|
17
|
+
`ared`: `[0, 1, 0, 1, 0, 1]`
|
18
|
+
|
19
|
+
`scorered`: `[26]`
|
20
|
+
|
21
|
+
`ablue`: `[1, 0, 1, 0, 1, 0]`
|
22
|
+
|
23
|
+
`scoreblue`: `[56]`
|
24
|
+
|
25
|
+
For other examples, read the tests, which are in the `test/` directory.
|
data/Rakefile
ADDED
data/lib/opr-calc.rb
CHANGED
data/lib/opr-calc/matrix.rb
CHANGED
@@ -16,7 +16,7 @@
|
|
16
16
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
17
17
|
=end
|
18
18
|
|
19
|
-
require
|
19
|
+
require 'matrix'
|
20
20
|
|
21
21
|
# Props to http://rosettacode.org/wiki/Cholesky_decomposition#Ruby :L
|
22
22
|
class Matrix
|
@@ -27,7 +27,9 @@ class Matrix
|
|
27
27
|
|
28
28
|
(0...row_size).each do |i|
|
29
29
|
(0..i).each do |j|
|
30
|
+
|
30
31
|
return false if self[i,j] != self[j,i]
|
32
|
+
|
31
33
|
end
|
32
34
|
end
|
33
35
|
|
@@ -38,21 +40,26 @@ class Matrix
|
|
38
40
|
# To obtain [L]*, just transpose the matrix, it should work.
|
39
41
|
def cholesky_factor
|
40
42
|
# We need a symmetric matrix for Cholesky decomposition.
|
41
|
-
raise ArgumentError,
|
43
|
+
raise ArgumentError, 'must provide a symmetric matrix' unless symmetric?
|
42
44
|
|
43
45
|
# Make a new matrix to return.
|
44
46
|
l = Array.new(row_size) { Array.new(row_size, 0) }
|
45
47
|
|
46
48
|
(0...row_size).each do |k|
|
47
49
|
(0...row_size).each do |i|
|
50
|
+
|
48
51
|
if i == k
|
52
|
+
|
49
53
|
sum = (0..k-1).inject(0.0) { |sum, j| sum + l[k][j] ** 2 }
|
50
54
|
val = Math.sqrt(self[k,k] - sum)
|
51
55
|
l[k][k] = val
|
56
|
+
|
52
57
|
elsif i > k
|
58
|
+
|
53
59
|
sum = (0..k-1).inject(0.0) { |sum, j| sum + l[i][j] * l[k][j] }
|
54
60
|
val = (self[k,i] - sum) / l[k][k]
|
55
61
|
l[i][k] = val
|
62
|
+
|
56
63
|
end
|
57
64
|
end
|
58
65
|
end
|
@@ -64,10 +71,12 @@ class Matrix
|
|
64
71
|
def output
|
65
72
|
(0..self.row_size - 1).each do |row_number|
|
66
73
|
(0..self.column_size - 1).each do |column_number|
|
67
|
-
|
74
|
+
|
75
|
+
printf('%8.4f ', self[row_number, column_number])
|
76
|
+
|
68
77
|
end
|
69
78
|
|
70
|
-
printf(
|
79
|
+
printf('\n')
|
71
80
|
end
|
72
81
|
|
73
82
|
self
|
data/lib/opr-calc/score_set.rb
CHANGED
@@ -24,28 +24,28 @@ module OPRCalc
|
|
24
24
|
@ared = value
|
25
25
|
@opr_recalc = @dpr_recalc = @ccwm_recalc = true
|
26
26
|
end
|
27
|
-
|
27
|
+
|
28
28
|
def ablue=(value)
|
29
29
|
@ablue = value
|
30
30
|
@opr_recalc = @dpr_recalc = @ccwm_recalc = true
|
31
31
|
end
|
32
|
-
|
32
|
+
|
33
33
|
def scorered=(value)
|
34
34
|
@scorered = value
|
35
35
|
@opr_recalc = @dpr_recalc = @ccwm_recalc = true
|
36
36
|
end
|
37
|
-
|
37
|
+
|
38
38
|
def scoreblue=(value)
|
39
39
|
@scoreblue = value
|
40
40
|
@opr_recalc = @dpr_recalc = @ccwm_recalc = true
|
41
41
|
end
|
42
42
|
|
43
43
|
def initialize(ared, ablue, scorered, scoreblue)
|
44
|
-
raise TypeError,
|
45
|
-
raise TypeError,
|
46
|
-
raise TypeError,
|
47
|
-
raise TypeError,
|
48
|
-
|
44
|
+
raise TypeError, 'ared must be a Matrix' unless ared.is_a? Matrix
|
45
|
+
raise TypeError, 'ablue must be a Matrix' unless ablue.is_a? Matrix
|
46
|
+
raise TypeError, 'scorered must be a Matrix' unless scorered.is_a? Matrix
|
47
|
+
raise TypeError, 'scoreblue must be a Matrix' unless scoreblue.is_a? Matrix
|
48
|
+
|
49
49
|
@ared = ared
|
50
50
|
@ablue = ablue
|
51
51
|
@scorered = scorered
|
@@ -55,9 +55,12 @@ module OPRCalc
|
|
55
55
|
# A generic function for smooshing two matrices (one red, one blue).
|
56
56
|
# Each should have the same dimensions.
|
57
57
|
def alliance_smooshey(redmatrix, bluematrix)
|
58
|
-
throw ArgumentError
|
58
|
+
throw ArgumentError 'Matrices must have same dimensions' unless
|
59
|
+
(redmatrix.row_size == bluematrix.row_size) &&
|
60
|
+
(redmatrix.column_size == bluematrix.column_size)
|
59
61
|
|
60
|
-
# Then we just pull the column and row size
|
62
|
+
# Then we just pull the column and row size
|
63
|
+
# from the red matrix because we can.
|
61
64
|
column_count = redmatrix.column_size
|
62
65
|
row_count = redmatrix.row_size
|
63
66
|
|
@@ -72,9 +75,8 @@ module OPRCalc
|
|
72
75
|
bluematrix[row - row_count, column]
|
73
76
|
end
|
74
77
|
end
|
75
|
-
|
76
|
-
# This will end up looking like as follows:
|
77
78
|
|
79
|
+
# This will end up looking like as follows:
|
78
80
|
# [[red[0]],
|
79
81
|
# [red[1]],
|
80
82
|
# [red[2]],
|
@@ -92,19 +94,20 @@ module OPRCalc
|
|
92
94
|
|
93
95
|
# Solve equation of form [l][x] = [s] for [x]
|
94
96
|
# l must be a lower triangular matrix.
|
95
|
-
# Based off of algorithm given at
|
97
|
+
# Based off of algorithm given at
|
98
|
+
# `http://en.wikipedia.org/wiki/Triangular_matrix#Forward_and_back_substitution`.
|
96
99
|
def forward_substitute(l, s)
|
97
|
-
raise
|
100
|
+
raise 'l must be a lower triangular matrix' unless l.lower_triangular?
|
98
101
|
|
99
102
|
x = Array.new s.row_size
|
100
103
|
|
101
104
|
x.size.times do |i|
|
102
105
|
x[i] = s[i, 0]
|
103
|
-
|
106
|
+
|
104
107
|
i.times do |j|
|
105
108
|
x[i] -= l[i, j] * x[j]
|
106
109
|
end
|
107
|
-
|
110
|
+
|
108
111
|
x[i] /= l[i, i]
|
109
112
|
end
|
110
113
|
|
@@ -114,17 +117,17 @@ module OPRCalc
|
|
114
117
|
# Solve equation of form [u][x] = [s] for [x]
|
115
118
|
# u must be a upper triangular matrix.
|
116
119
|
def back_substitute(u, s)
|
117
|
-
raise
|
120
|
+
raise 'u must be an upper triangular matrix' unless u.upper_triangular?
|
118
121
|
|
119
122
|
x = Array.new s.row_size
|
120
123
|
|
121
124
|
(x.size - 1).downto 0 do |i|
|
122
125
|
x[i] = s[i, 0]
|
123
|
-
|
126
|
+
|
124
127
|
(i + 1).upto(x.size - 1) do |j|
|
125
128
|
x[i] -= u[i, j] * x[j]
|
126
129
|
end
|
127
|
-
|
130
|
+
|
128
131
|
x[i] /= u[i, i]
|
129
132
|
end
|
130
133
|
|
@@ -132,19 +135,6 @@ module OPRCalc
|
|
132
135
|
end
|
133
136
|
|
134
137
|
private :forward_substitute, :back_substitute
|
135
|
-
|
136
|
-
# base matrix equation: [A][OPR] = [SCORE]
|
137
|
-
# define [A]^t to be [A] transpose
|
138
|
-
# define [P] to be [A]^t[A]
|
139
|
-
# define [S] to be [A]^t[SCORE]
|
140
|
-
# equation is now [P][OPR] = [S]
|
141
|
-
# refactor [P] as [L][L]^t using cholesky
|
142
|
-
# [L] is a lower triangular matrix and [L]^t an upper
|
143
|
-
# Therefore [L][L]^t[OPR] = [S]
|
144
|
-
# define [Y] = [L]^t[OPR]
|
145
|
-
# equation is now [L][Y] = [S]
|
146
|
-
# find [Y] through forward substitution
|
147
|
-
# find [OPR] through back substitution
|
148
138
|
|
149
139
|
def opr_calculate(a, score)
|
150
140
|
p = a.t * a
|
@@ -159,44 +149,53 @@ module OPRCalc
|
|
159
149
|
back_substitute l.t, y
|
160
150
|
end
|
161
151
|
|
162
|
-
# Offensive power rating: the average amount of points that a team
|
152
|
+
# Offensive power rating: the average amount of points that a team
|
153
|
+
# contributes to their alliance's score.
|
154
|
+
#
|
163
155
|
# This is high for a good team.
|
164
156
|
def opr(recalc = false)
|
165
157
|
if !@opr || recalc || @opr_recalc
|
166
158
|
a = alliance_smooshey @ared, @ablue
|
167
159
|
score = alliance_smooshey @scorered, @scoreblue
|
168
|
-
|
160
|
+
|
169
161
|
@opr = opr_calculate a, score
|
170
162
|
@opr_recalc = false
|
171
163
|
end
|
172
|
-
|
164
|
+
|
173
165
|
@opr
|
174
166
|
end
|
175
167
|
|
176
|
-
# Defensive power rating: the average amount of points that a team lets
|
168
|
+
# Defensive power rating: the average amount of points that a team lets
|
169
|
+
# the other alliance score.
|
170
|
+
#
|
177
171
|
# This is low for a good team.
|
178
172
|
def dpr(recalc = false)
|
179
173
|
if !@dpr || recalc || @dpr_recalc
|
180
174
|
a = alliance_smooshey @ared, @ablue
|
181
|
-
|
182
|
-
|
175
|
+
|
176
|
+
# scoreblue and scorered are intentionally
|
177
|
+
# swapped; that's how dpr works.
|
178
|
+
score = alliance_smooshey @scoreblue, @scorered
|
179
|
+
|
183
180
|
@dpr = opr_calculate a, score
|
184
181
|
@dpr_recalc = false
|
185
182
|
end
|
186
|
-
|
183
|
+
|
187
184
|
@dpr
|
188
185
|
end
|
189
186
|
|
190
|
-
# Calculated contribution to winning margin: the average amount of
|
191
|
-
#
|
187
|
+
# Calculated contribution to winning margin: the average amount of
|
188
|
+
# points that a team contributes to their alliance's winning margin.
|
189
|
+
#
|
190
|
+
# This value is high for a good team.
|
192
191
|
def ccwm(recalc = false)
|
193
192
|
if !@ccwm || recalc || @ccwm_recalc
|
194
193
|
a = alliance_smooshey @ared, @ablue
|
195
|
-
|
194
|
+
|
196
195
|
red_wm = Matrix.build(@scorered.row_size, @scorered.column_size) do |row, column|
|
197
196
|
@scorered[row, column] - @scoreblue[row, column]
|
198
197
|
end
|
199
|
-
|
198
|
+
|
200
199
|
blue_wm = Matrix.build(@scoreblue.row_size, @scoreblue.column_size) do |row, column|
|
201
200
|
@scoreblue[row, column] - @scorered[row, column]
|
202
201
|
end
|
@@ -206,7 +205,7 @@ module OPRCalc
|
|
206
205
|
@ccwm = opr_calculate a, score
|
207
206
|
@ccwm_recalc = false
|
208
207
|
end
|
209
|
-
|
208
|
+
|
210
209
|
@ccwm
|
211
210
|
end
|
212
211
|
end
|
data/opr-calc.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'opr-calc'
|
3
|
+
s.email = 'kristofer.rye@gmail.com'
|
4
|
+
s.version = '1.0.0'
|
5
|
+
|
6
|
+
s.summary = 'A tool for calculating OPR and other scouting stats for FRC teams.'
|
7
|
+
s.description = "A tool for calculating the OPR, DPR, and CCWM of FRC teams, allowing for more detailed analysis of a team's projected performance. Uses the internal Ruby Matrix library for basic Matrix operations."
|
8
|
+
|
9
|
+
s.authors = ['Kristofer Rye',
|
10
|
+
'Christopher Cooper',
|
11
|
+
'Sam Craig']
|
12
|
+
|
13
|
+
s.homepage = 'https://github.com/frc461/opr-calc'
|
14
|
+
s.files = Dir['lib/**/*.rb'] + %W[Gemfile opr-calc.gemspec LICENSE README.md Rakefile]
|
15
|
+
s.test_files = Dir['spec/**/*.rb'] + %W[.rspec Guardfile]
|
16
|
+
s.required_ruby_version = '>= 1.9.3'
|
17
|
+
s.license = 'GPL-3.0'
|
18
|
+
|
19
|
+
s.add_development_dependency 'rake', '~> 11'
|
20
|
+
s.add_development_dependency 'rspec', '~> 3.4'
|
21
|
+
s.add_development_dependency 'guard', '~> 2.14'
|
22
|
+
s.add_development_dependency 'guard-rspec', '~> 4.7'
|
23
|
+
|
24
|
+
s.add_development_dependency 'pry', '~> 0'
|
25
|
+
s.add_development_dependency 'pry-doc', '~> 0.9'
|
26
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'opr-calc/matrix' do
|
4
|
+
it 'can be required without error' do
|
5
|
+
expect { require subject }.not_to raise_error
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
require 'opr-calc/matrix'
|
10
|
+
|
11
|
+
describe Matrix do
|
12
|
+
context 'when symmetric' do
|
13
|
+
subject :symmetric_matrix do
|
14
|
+
Matrix[[ 4, 12, -16 ],
|
15
|
+
[ 12, 37, -43 ],
|
16
|
+
[ -16, -43, 98 ]]
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '#symmetric?' do
|
20
|
+
it 'returns true' do
|
21
|
+
expect(symmetric_matrix.symmetric?).to be(true)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe '#cholesky_factor' do
|
26
|
+
let :expected_cholesky_factorization do
|
27
|
+
Matrix[[ 2, 0, 0],
|
28
|
+
[ 6, 1, 0],
|
29
|
+
[-8, 5, 3]]
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'returns a Matrix' do
|
33
|
+
expect(symmetric_matrix.cholesky_factor).to be_a(Matrix)
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'properly calculates the Cholesky factorization' do
|
37
|
+
expect(symmetric_matrix.cholesky_factor).to eq(expected_cholesky_factorization)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
context 'when asymmetric' do
|
43
|
+
subject :asymmetric_matrix do
|
44
|
+
Matrix[[3, 5, 3],
|
45
|
+
[2, 4, 2],
|
46
|
+
[5, 2, 1]]
|
47
|
+
end
|
48
|
+
|
49
|
+
describe '#symmetric?' do
|
50
|
+
it 'returns false' do
|
51
|
+
expect(asymmetric_matrix.symmetric?).to be(false)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
describe '#cholesky_factor' do
|
56
|
+
it 'raises an error' do
|
57
|
+
expect { asymmetric_matrix.cholesky_factor }.to raise_error(ArgumentError, 'must provide a symmetric matrix')
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe 'opr-calc/score_set' do
|
4
|
+
it 'can be required without error' do
|
5
|
+
expect { require subject }.not_to raise_error
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
require 'opr-calc/score_set'
|
10
|
+
require 'opr-calc/matrix'
|
11
|
+
|
12
|
+
describe OPRCalc::ScoreSet do
|
13
|
+
context 'taking 4 valid arguments' do
|
14
|
+
let :ared do
|
15
|
+
Matrix[[1, 0, 1, 0, 1, 0, 0, 0, 0, 0],
|
16
|
+
[0, 1, 0, 1, 0, 1, 0, 0, 0, 0],
|
17
|
+
[0, 0, 0, 0, 1, 1, 1, 0, 0, 0],
|
18
|
+
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1],
|
19
|
+
[0, 1, 0, 0, 0, 1, 1, 0, 0, 0],
|
20
|
+
[1, 0, 0, 1, 0, 0, 1, 0, 0, 0]]
|
21
|
+
end
|
22
|
+
|
23
|
+
let :ablue do
|
24
|
+
Matrix[[0, 0, 1, 0, 0, 0, 0, 1, 0, 1],
|
25
|
+
[1, 0, 0, 0, 1, 0, 0, 0, 1, 0],
|
26
|
+
[1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
|
27
|
+
[0, 0, 1, 0, 0, 1, 1, 0, 0, 0],
|
28
|
+
[0, 0, 0, 1, 0, 0, 0, 0, 1, 1],
|
29
|
+
[0, 1, 0, 0, 1, 0, 0, 1, 0, 0]]
|
30
|
+
end
|
31
|
+
|
32
|
+
let :scorered do
|
33
|
+
Matrix[[6],
|
34
|
+
[9],
|
35
|
+
[15],
|
36
|
+
[24],
|
37
|
+
[12],
|
38
|
+
[9]]
|
39
|
+
end
|
40
|
+
|
41
|
+
let :scoreblue do
|
42
|
+
Matrix[[18],
|
43
|
+
[12],
|
44
|
+
[3],
|
45
|
+
[13],
|
46
|
+
[20],
|
47
|
+
[12]]
|
48
|
+
end
|
49
|
+
|
50
|
+
subject do
|
51
|
+
OPRCalc::ScoreSet.new ared, ablue, scorered, scoreblue
|
52
|
+
end
|
53
|
+
|
54
|
+
describe '#opr' do
|
55
|
+
let :expected do
|
56
|
+
Matrix[[0],
|
57
|
+
[1],
|
58
|
+
[2],
|
59
|
+
[3],
|
60
|
+
[4],
|
61
|
+
[5],
|
62
|
+
[6],
|
63
|
+
[7],
|
64
|
+
[8],
|
65
|
+
[9]]
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'produces a fully Numeric OPR vector that is finite' do
|
69
|
+
subject.opr.each do |cell|
|
70
|
+
expect(cell).to be_a(Numeric)
|
71
|
+
expect(cell).to be_finite
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'produces the expected OPR' do
|
76
|
+
expect(subject.opr.round).to eq(expected)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe '#dpr' do
|
81
|
+
it 'produces a fully Numeric DPR vector that is finite' do
|
82
|
+
subject.dpr.each do |cell|
|
83
|
+
expect(cell).to be_a(Numeric)
|
84
|
+
expect(cell).to be_finite
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe '#ccwm' do
|
90
|
+
it 'produces a fully Numeric CCWM vector that is finite' do
|
91
|
+
subject.ccwm.each do |cell|
|
92
|
+
expect(cell).to be_a(Numeric)
|
93
|
+
expect(cell).to be_finite
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'produces the correct OPR-DPR values' do
|
98
|
+
expect(subject.ccwm.round(2)).to eq((subject.opr - subject.dpr).round(2))
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|