spout 0.7.0 → 0.8.0.beta1
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/CHANGELOG.md +11 -0
- data/README.md +27 -0
- data/lib/spout/actions.rb +6 -0
- data/lib/spout/commands/json_charts_and_tables.rb +202 -0
- data/lib/spout/helpers/array_statistics.rb +93 -0
- data/lib/spout/helpers/chart_types.rb +395 -0
- data/lib/spout/helpers/table_formatting.rb +52 -0
- data/lib/spout/models/subject.rb +17 -0
- data/lib/spout/tasks/engine.rake +7 -0
- data/lib/spout/templates/gitignore +1 -0
- data/lib/spout/version.rb +2 -2
- data/spout.gemspec +1 -1
- metadata +11 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 55751ba1e111b174e9501fb52b1dd9a484b9ee1c
|
4
|
+
data.tar.gz: ca81a991a37e425a4166dd28e6aeffa183378b1c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6fec0a99eeb2b26af88652c7b6b79b3d5de8b750593aac2196275e1a1d365dda0814f47343ac312adaf9bb41bd29065a5d6dee8092de8927bdd41136d6ffbca0
|
7
|
+
data.tar.gz: 1d80934213ba8633506850aeabc142764f578d6fa727ffe7ac5ac76cc07422b4e0759d53e7200c5c686075077d3a37cd1101d7b650294d147db5a2f38f1e43eb
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,14 @@
|
|
1
|
+
## 0.8.0
|
2
|
+
|
3
|
+
### Enhancements
|
4
|
+
- Added `spout json` command that generates JSON charts and tables of each variable in a dataset
|
5
|
+
- This command requires a .spout.yml file to be specified to identify the following variables:
|
6
|
+
- `visit`: This variable is used to separate subject encounters in a histogram
|
7
|
+
- `charts`: Array of choices, numeric, or integer variables for charts
|
8
|
+
- **Gem Changes**
|
9
|
+
- Updated to colorize 0.7.2
|
10
|
+
- Use of Ruby 2.1.2 is now recommended
|
11
|
+
|
1
12
|
## 0.7.0 (April 16, 2014)
|
2
13
|
|
3
14
|
### Enhancements
|
data/README.md
CHANGED
@@ -170,6 +170,33 @@ For specific variables the following can be used:
|
|
170
170
|
|
171
171
|
Generated graphs are placed in: `./graphs/`
|
172
172
|
|
173
|
+
|
174
|
+
### Generate charts and tables for data in your dataset
|
175
|
+
|
176
|
+
```
|
177
|
+
spout json
|
178
|
+
```
|
179
|
+
|
180
|
+
This command generates JSON charts and tables of each variable in a dataset
|
181
|
+
|
182
|
+
Requires a Spout YAML configuration file, `.spout.yml`, in the root of the data dictionary that defines the variables used to create the charts:
|
183
|
+
|
184
|
+
- `visit`: This variable is used to separate subject encounters in a histogram
|
185
|
+
- `charts`: Array of choices, numeric, or integer variables for charts
|
186
|
+
|
187
|
+
Example `.spout.yml` file:
|
188
|
+
|
189
|
+
```yml
|
190
|
+
visit: visitnumber
|
191
|
+
charts:
|
192
|
+
- age
|
193
|
+
- gender
|
194
|
+
- race
|
195
|
+
```
|
196
|
+
|
197
|
+
This will generate charts and tables for each variable in the dataset plotted against the variables listed under `charts`.
|
198
|
+
|
199
|
+
|
173
200
|
### Export to the Hybrid Data Dictionary format from your JSON repository
|
174
201
|
|
175
202
|
Exporting to a format compatible with [Hybrid](https://github.com/sleepepi/hybrid) is also available.
|
data/lib/spout/actions.rb
CHANGED
@@ -23,6 +23,8 @@ module Spout
|
|
23
23
|
coverage_report(argv)
|
24
24
|
when 'graphs', '-graphs', '--graphs', 'g', '-g'
|
25
25
|
generate_graphs(argv.last(argv.size - 1))
|
26
|
+
when 'json', 'j'
|
27
|
+
generate_charts_and_tables(argv.last(argv.size - 1))
|
26
28
|
else
|
27
29
|
help
|
28
30
|
end
|
@@ -140,6 +142,10 @@ EOT
|
|
140
142
|
system "bundle exec rake spout:graphs #{params_string}"
|
141
143
|
end
|
142
144
|
|
145
|
+
def generate_charts_and_tables(variables)
|
146
|
+
system "bundle exec rake spout:json variables=#{variables.join(',')}"
|
147
|
+
end
|
148
|
+
|
143
149
|
private
|
144
150
|
|
145
151
|
def copy_file(template_file, file_name = '')
|
@@ -0,0 +1,202 @@
|
|
1
|
+
require 'csv'
|
2
|
+
require 'fileutils'
|
3
|
+
require 'rubygems'
|
4
|
+
require 'json'
|
5
|
+
require 'yaml'
|
6
|
+
|
7
|
+
|
8
|
+
require 'spout/models/subject'
|
9
|
+
require 'spout/helpers/array_statistics'
|
10
|
+
require 'spout/helpers/chart_types'
|
11
|
+
|
12
|
+
|
13
|
+
module Spout
|
14
|
+
module Commands
|
15
|
+
class JsonChartsAndTables
|
16
|
+
def initialize(variables)
|
17
|
+
spout_config = YAML.load_file('.spout.yml')
|
18
|
+
|
19
|
+
_visit = ''
|
20
|
+
|
21
|
+
if spout_config.kind_of?(Hash)
|
22
|
+
_visit = spout_config['visit'].to_s.strip
|
23
|
+
|
24
|
+
chart_variables = if spout_config['charts'].kind_of?(Array)
|
25
|
+
spout_config['charts'].collect{|c| c.to_s.strip}.select{|c| c != ''}
|
26
|
+
else
|
27
|
+
[]
|
28
|
+
end
|
29
|
+
else
|
30
|
+
puts "The YAML file needs to be in the following format:"
|
31
|
+
puts "histogram: visitnumber # VISIT_VARIABLE\ncharts:\n - age_s1\n - gender\n - race\n"
|
32
|
+
exit
|
33
|
+
end
|
34
|
+
|
35
|
+
if Spout::Helpers::ChartTypes::get_json(_visit, 'variable') == nil
|
36
|
+
if _visit == ''
|
37
|
+
puts "The visit variable in .spout.yml can't be blank."
|
38
|
+
else
|
39
|
+
puts "Could not find the following visit variable: #{_visit}"
|
40
|
+
end
|
41
|
+
exit
|
42
|
+
end
|
43
|
+
missing_variables = chart_variables.select{|c| Spout::Helpers::ChartTypes::get_json(c, 'variable') == nil}
|
44
|
+
if missing_variables.count > 0
|
45
|
+
puts "Could not find the following chart variable#{'s' unless missing_variables.size == 1}: #{missing_variables.join(', ')}"
|
46
|
+
exit
|
47
|
+
end
|
48
|
+
|
49
|
+
argv_string = variables.join(',')
|
50
|
+
number_of_rows = nil
|
51
|
+
|
52
|
+
if match_data = argv_string.match(/-rows=(\d*)/)
|
53
|
+
number_of_rows = match_data[1].to_i
|
54
|
+
argv_string.gsub!(match_data[0], '')
|
55
|
+
end
|
56
|
+
|
57
|
+
valid_ids = argv_string.split(',').compact.reject{|s| s == ''}
|
58
|
+
|
59
|
+
@visit = _visit
|
60
|
+
|
61
|
+
chart_lookup = { _visit => "Histogram" }
|
62
|
+
|
63
|
+
chart_variables.each do |chart_variable|
|
64
|
+
json = Spout::Helpers::ChartTypes::get_json(chart_variable, 'variable')
|
65
|
+
chart_lookup[chart_variable] = json['display_name']
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
|
70
|
+
t = Time.now
|
71
|
+
|
72
|
+
|
73
|
+
version = standard_version
|
74
|
+
|
75
|
+
subjects = []
|
76
|
+
|
77
|
+
FileUtils.mkpath "charts/#{version}"
|
78
|
+
|
79
|
+
csv_files = Dir.glob("csvs/#{version}/*.csv")
|
80
|
+
|
81
|
+
csv_files.each_with_index do |csv_file, index|
|
82
|
+
count = 0
|
83
|
+
puts "Parsing: #{csv_file}"
|
84
|
+
CSV.parse( File.open(csv_file, 'r:iso-8859-1:utf-8'){|f| f.read}, headers: true, header_converters: lambda { |h| h.to_s.downcase } ) do |line|
|
85
|
+
|
86
|
+
row = line.to_hash
|
87
|
+
count += 1
|
88
|
+
puts "Line: #{count}" if (count % 1000 == 0)
|
89
|
+
subjects << Spout::Models::Subject.create do |t|
|
90
|
+
|
91
|
+
t._visit = row[_visit] #.to_s.strip
|
92
|
+
|
93
|
+
row.each do |key,value|
|
94
|
+
unless t.respond_to?(key)
|
95
|
+
t.class.send(:define_method, "#{key}") { instance_variable_get("@#{key}") }
|
96
|
+
t.class.send(:define_method, "#{key}=") { |value| instance_variable_set("@#{key}", value) }
|
97
|
+
end
|
98
|
+
|
99
|
+
unless value == nil
|
100
|
+
t.send("#{key}=", value)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
# puts "Memory Used: " + (`ps -o rss -p #{$$}`.strip.split.last.to_i / 1024).to_s + " MB" if count % 1000 == 0
|
105
|
+
# break if count >= 1000
|
106
|
+
break if number_of_rows != nil and count >= number_of_rows
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
variable_files = Dir.glob('variables/**/*.json')
|
111
|
+
variable_files_count = variable_files.count
|
112
|
+
|
113
|
+
variable_files.each do |variable_file|
|
114
|
+
json = JSON.parse(File.read(variable_file)) rescue json = nil
|
115
|
+
next unless json
|
116
|
+
next unless valid_ids.include?(json["id"].to_s.downcase) or valid_ids.size == 0
|
117
|
+
next unless ["numeric", "integer"].include?(json["type"])
|
118
|
+
method = json['id'].to_s.downcase
|
119
|
+
next unless Spout::Models::Subject.method_defined?(method)
|
120
|
+
|
121
|
+
subjects.each{ |s| s.send(method) != nil ? s.send("#{method}=", s.send("#{method}").to_f) : nil }
|
122
|
+
end
|
123
|
+
|
124
|
+
variable_files.each_with_index do |variable_file, file_index|
|
125
|
+
json = JSON.parse(File.read(variable_file)) rescue json = nil
|
126
|
+
next unless json
|
127
|
+
next unless valid_ids.include?(json["id"].to_s.downcase) or valid_ids.size == 0
|
128
|
+
next unless ["numeric", "integer", "choices"].include?(json["type"])
|
129
|
+
variable_name = json['id'].to_s.downcase
|
130
|
+
next unless Spout::Models::Subject.method_defined?(variable_name)
|
131
|
+
|
132
|
+
puts "#{file_index+1} of #{variable_files_count}: #{variable_file.gsub(/(^variables\/|\.json$)/, '').gsub('/', ' / ')}"
|
133
|
+
|
134
|
+
|
135
|
+
stats = {
|
136
|
+
charts: {},
|
137
|
+
tables: {}
|
138
|
+
}
|
139
|
+
|
140
|
+
chart_types = case json['type'] when 'integer', 'numeric', 'choices'
|
141
|
+
chart_lookup.keys
|
142
|
+
else
|
143
|
+
[]
|
144
|
+
end
|
145
|
+
|
146
|
+
chart_types.each do |chart_type|
|
147
|
+
if chart_type == _visit
|
148
|
+
filtered_subjects = subjects.select{ |s| s.send(chart_type) != nil } # and s.send(variable_name) != nil
|
149
|
+
if filtered_subjects.count > 0
|
150
|
+
stats[:charts][chart_lookup[chart_type].downcase] = Spout::Helpers::ChartTypes::chart_histogram(chart_type, filtered_subjects, json, variable_name)
|
151
|
+
stats[:tables][chart_lookup[chart_type].downcase] = Spout::Helpers::ChartTypes::table_arbitrary(chart_type, filtered_subjects, json, variable_name)
|
152
|
+
end
|
153
|
+
else
|
154
|
+
filtered_subjects = subjects.select{ |s| s.send(chart_type) != nil } # and s.send(variable_name) != nil
|
155
|
+
if filtered_subjects.count > 0
|
156
|
+
stats[:charts][chart_lookup[chart_type].downcase] = Spout::Helpers::ChartTypes::chart_arbitrary(chart_type, filtered_subjects, json, variable_name, visits)
|
157
|
+
stats[:tables][chart_lookup[chart_type].downcase] = visits.collect do |visit_display_name, visit_value|
|
158
|
+
visit_subjects = filtered_subjects.select{ |s| s._visit == visit_value }
|
159
|
+
unknown_subjects = visit_subjects.select{ |s| s.send(variable_name) == nil }
|
160
|
+
(visit_subjects.count > 0 && visit_subjects.count != unknown_subjects.count) ? Spout::Helpers::ChartTypes::table_arbitrary(chart_type, visit_subjects, json, variable_name, visit_display_name) : nil
|
161
|
+
end.compact
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
chart_json_file = File.join('charts', version, "#{json['id']}.json")
|
167
|
+
File.open(chart_json_file, 'w') { |file| file.write( JSON.pretty_generate(stats) + "\n" ) }
|
168
|
+
|
169
|
+
end
|
170
|
+
|
171
|
+
puts "Took #{Time.now - t} seconds."
|
172
|
+
|
173
|
+
|
174
|
+
end
|
175
|
+
|
176
|
+
# [["Visit 1", "1"], ["Visit 2", "2"], ["CVD Outcomes", "3"]]
|
177
|
+
def visits
|
178
|
+
@visits ||= begin
|
179
|
+
Spout::Commands::JsonChartsAndTables::domain_array(@visit)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# This is directly from Spout
|
184
|
+
def self.standard_version
|
185
|
+
version = File.open('VERSION', &:readline).strip rescue ''
|
186
|
+
version == '' ? '1.0.0' : version
|
187
|
+
end
|
188
|
+
|
189
|
+
def self.domain_array(variable_name)
|
190
|
+
variable_file = Dir.glob("variables/**/#{variable_name}.json").first
|
191
|
+
json = JSON.parse(File.read(variable_file)) rescue json = nil
|
192
|
+
if json
|
193
|
+
domain_json = Spout::Helpers::ChartTypes::get_domain(json)
|
194
|
+
domain_json ? domain_json.collect{|option_hash| [option_hash['display_name'], option_hash['value']]} : []
|
195
|
+
else
|
196
|
+
[]
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# array_statistics.rb
|
2
|
+
|
3
|
+
class Array
|
4
|
+
def n
|
5
|
+
self.compact.count
|
6
|
+
end
|
7
|
+
|
8
|
+
def mean
|
9
|
+
array = self.compact
|
10
|
+
return nil if array.size == 0
|
11
|
+
array.inject(:+).to_f / array.size
|
12
|
+
end
|
13
|
+
|
14
|
+
def sample_variance
|
15
|
+
array = self.compact
|
16
|
+
m = array.mean
|
17
|
+
sum = array.inject(0){|accum, i| accum +(i-m)**2 }
|
18
|
+
sum / (array.length - 1).to_f
|
19
|
+
end
|
20
|
+
|
21
|
+
def standard_deviation
|
22
|
+
array = self.compact
|
23
|
+
return nil if array.size < 2
|
24
|
+
return Math.sqrt(array.sample_variance)
|
25
|
+
end
|
26
|
+
|
27
|
+
def median
|
28
|
+
array = self.compact.sort
|
29
|
+
return nil if array.size == 0
|
30
|
+
len = array.size
|
31
|
+
len % 2 == 1 ? array[len/2] : (array[len/2 - 1] + array[len/2]).to_f / 2
|
32
|
+
end
|
33
|
+
|
34
|
+
def unknown
|
35
|
+
self.select{|s| s == nil}.count
|
36
|
+
end
|
37
|
+
|
38
|
+
def quartile_sizes
|
39
|
+
quartile_size = self.count / 4
|
40
|
+
quartile_fraction = self.count % 4
|
41
|
+
|
42
|
+
quartile_sizes = [quartile_size] * 4
|
43
|
+
(0..quartile_fraction - 1).to_a.each do |index|
|
44
|
+
quartile_sizes[index] += 1
|
45
|
+
end
|
46
|
+
|
47
|
+
quartile_sizes
|
48
|
+
end
|
49
|
+
|
50
|
+
def quartile_one
|
51
|
+
self[0..(self.quartile_sizes[0] - 1)]
|
52
|
+
end
|
53
|
+
|
54
|
+
def quartile_two
|
55
|
+
sizes = self.quartile_sizes
|
56
|
+
start = sizes[0]
|
57
|
+
stop = start + sizes[1] - 1
|
58
|
+
self[start..stop]
|
59
|
+
end
|
60
|
+
|
61
|
+
def quartile_three
|
62
|
+
sizes = self.quartile_sizes
|
63
|
+
start = sizes[0] + sizes[1]
|
64
|
+
stop = start + sizes[2] - 1
|
65
|
+
self[start..stop]
|
66
|
+
end
|
67
|
+
|
68
|
+
def quartile_four
|
69
|
+
sizes = self.quartile_sizes
|
70
|
+
start = sizes[0] + sizes[1] + sizes[2]
|
71
|
+
stop = start + sizes[3] - 1
|
72
|
+
self[start..stop]
|
73
|
+
end
|
74
|
+
|
75
|
+
def compact_min
|
76
|
+
self.compact.min
|
77
|
+
end
|
78
|
+
|
79
|
+
def compact_max
|
80
|
+
self.compact.max
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
module Spout
|
86
|
+
module Helpers
|
87
|
+
class ArrayStatistics
|
88
|
+
def self.calculations
|
89
|
+
[["N", :n, :count], ["Mean", :mean, :decimal], ["StdDev", :standard_deviation, :decimal, "± %s"], ["Median", :median, :decimal], ["Min", :compact_min, :decimal], ["Max", :compact_max, :decimal], ["Unknown", :unknown, :count]]
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,395 @@
|
|
1
|
+
require 'spout/helpers/table_formatting'
|
2
|
+
|
3
|
+
module Spout
|
4
|
+
module Helpers
|
5
|
+
class ChartTypes
|
6
|
+
def self.get_bucket(buckets, value)
|
7
|
+
buckets.each do |b|
|
8
|
+
return "#{b[0].round(1)} to #{b[1].round(1)}" if value >= b[0] and value <= b[1]
|
9
|
+
end
|
10
|
+
nil
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.continuous_buckets(values)
|
14
|
+
return [] if values.count == 0
|
15
|
+
minimum_bucket = values.min
|
16
|
+
maximum_bucket = values.max
|
17
|
+
|
18
|
+
max_buckets = [(maximum_bucket - minimum_bucket), 12].min
|
19
|
+
bucket_size = (maximum_bucket - minimum_bucket) / max_buckets
|
20
|
+
buckets = []
|
21
|
+
(0..(max_buckets-1)).to_a.each do |index|
|
22
|
+
start = minimum_bucket + index * bucket_size
|
23
|
+
stop = start + bucket_size
|
24
|
+
buckets << [start,stop]
|
25
|
+
end
|
26
|
+
buckets
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.get_json(file_name, file_type)
|
30
|
+
file = Dir.glob("#{file_type}s/**/#{file_name}.json").first
|
31
|
+
json = JSON.parse(File.read(file)) rescue json = nil
|
32
|
+
json
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.get_variable(variable_name)
|
36
|
+
get_json(variable_name, 'variable')
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.get_domain(json)
|
40
|
+
get_json(json['domain'], 'domain')
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
def self.chart_arbitrary_choices_by_quartile(chart_type, subjects, json, method)
|
45
|
+
# CHART TYPE IS THE QUARTILE VARIABLE
|
46
|
+
return unless chart_variable_json = get_variable(chart_type)
|
47
|
+
return unless domain_json = get_domain(json)
|
48
|
+
|
49
|
+
title = "#{json['display_name']} by #{chart_variable_json['display_name']}"
|
50
|
+
subtitle = "By Visit"
|
51
|
+
# categories = ["Quartile One", "Quartile Two", "Quartile Three", "Quartile Four"]
|
52
|
+
units = 'percent'
|
53
|
+
series = []
|
54
|
+
|
55
|
+
filtered_subjects = subjects.select{ |s| s.send(method) != nil and s.send(chart_type) != nil }.sort_by(&chart_type.to_sym)
|
56
|
+
|
57
|
+
categories = [:quartile_one, :quartile_two, :quartile_three, :quartile_four].collect do |quartile|
|
58
|
+
bucket = filtered_subjects.send(quartile).collect(&chart_type.to_sym)
|
59
|
+
"#{bucket.min} to #{bucket.max}"
|
60
|
+
end
|
61
|
+
|
62
|
+
domain_json.each do |option_hash|
|
63
|
+
data = [:quartile_one, :quartile_two, :quartile_three, :quartile_four].collect do |quartile|
|
64
|
+
filtered_subjects.send(quartile).select{ |s| s.send(method) == option_hash['value'] }.count
|
65
|
+
end
|
66
|
+
|
67
|
+
series << { name: option_hash['display_name'], data: data } unless filtered_subjects.size == 0
|
68
|
+
end
|
69
|
+
|
70
|
+
{ title: title, subtitle: subtitle, categories: categories, units: units, series: series, stacking: 'percent' }
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.chart_arbitrary_by_quartile(chart_type, subjects, json, method, visits)
|
74
|
+
# CHART TYPE IS THE QUARTILE VARIABLE
|
75
|
+
return unless chart_variable_json = get_variable(chart_type)
|
76
|
+
return chart_arbitrary_choices_by_quartile(chart_type, subjects, json, method) if json['type'] == 'choices'
|
77
|
+
|
78
|
+
title = "#{json['display_name']} by #{chart_variable_json['display_name']}"
|
79
|
+
subtitle = "By Visit"
|
80
|
+
categories = ["Quartile One", "Quartile Two", "Quartile Three", "Quartile Four"]
|
81
|
+
units = json["units"]
|
82
|
+
series = []
|
83
|
+
|
84
|
+
series = []
|
85
|
+
|
86
|
+
visits.each do |visit_display_name, visit_value|
|
87
|
+
data = []
|
88
|
+
filtered_subjects = subjects.select{ |s| s._visit == visit_value and s.send(method) != nil and s.send(chart_type) != nil }.sort_by(&chart_type.to_sym)
|
89
|
+
|
90
|
+
[:quartile_one, :quartile_two, :quartile_three, :quartile_four].each do |quartile|
|
91
|
+
array = filtered_subjects.send(quartile).collect(&method.to_sym)
|
92
|
+
data << { y: (array.mean.round(1) rescue 0.0),
|
93
|
+
stddev: ("%0.1f" % array.standard_deviation rescue ''),
|
94
|
+
median: ("%0.1f" % array.median rescue ''),
|
95
|
+
min: ("%0.1f" % array.min rescue ''),
|
96
|
+
max: ("%0.1f" % array.max rescue ''),
|
97
|
+
n: array.n }
|
98
|
+
end
|
99
|
+
|
100
|
+
series << { name: visit_display_name, data: data } unless filtered_subjects.size == 0
|
101
|
+
end
|
102
|
+
|
103
|
+
{ title: title, subtitle: subtitle, categories: categories, units: units, series: series }
|
104
|
+
end
|
105
|
+
|
106
|
+
def self.table_arbitrary_by_quartile(chart_type, subjects, json, method, subtitle = nil)
|
107
|
+
return table_arbitrary_choices_by_quartile(chart_type, subjects, json, method, subtitle) if json['type'] == 'choices'
|
108
|
+
# CHART TYPE IS THE QUARTILE VARIABLE
|
109
|
+
return unless chart_variable_json = get_variable(chart_type)
|
110
|
+
|
111
|
+
|
112
|
+
headers = [
|
113
|
+
[""] + Spout::Helpers::ArrayStatistics::calculations.collect{|calculation_label, calculation_method| calculation_label} + ["Total"]
|
114
|
+
]
|
115
|
+
|
116
|
+
filtered_subjects = subjects.select{ |s| s.send(method) != nil and s.send(chart_type) != nil }.sort_by(&chart_type.to_sym)
|
117
|
+
|
118
|
+
rows = [:quartile_one, :quartile_two, :quartile_three, :quartile_four].collect do |quartile|
|
119
|
+
bucket = filtered_subjects.send(quartile)
|
120
|
+
row_subjects = bucket.collect(&method.to_sym)
|
121
|
+
data = Spout::Helpers::ArrayStatistics::calculations.collect do |calculation_label, calculation_method, calculation_type, calculation_format|
|
122
|
+
TableFormatting::format_number(row_subjects.send(calculation_method), calculation_type, calculation_format)
|
123
|
+
end
|
124
|
+
|
125
|
+
row_name = if row_subjects.size == 0
|
126
|
+
quartile.to_s.capitalize.gsub('_one', ' One').gsub('_two', ' Two').gsub('_three', ' Three').gsub('_four', ' Four')
|
127
|
+
else
|
128
|
+
"#{bucket.collect(&chart_type.to_sym).min} to #{bucket.collect(&chart_type.to_sym).max} #{chart_variable_json['units']}"
|
129
|
+
end
|
130
|
+
|
131
|
+
[row_name] + data + [{ text: TableFormatting::format_number(row_subjects.count, :count), style: 'font-weight:bold'}]
|
132
|
+
end
|
133
|
+
|
134
|
+
total_values = Spout::Helpers::ArrayStatistics::calculations.collect do |calculation_label, calculation_method, calculation_type, calculation_format|
|
135
|
+
total_count = filtered_subjects.collect(&method.to_sym).send(calculation_method)
|
136
|
+
{ text: TableFormatting::format_number(total_count, calculation_type, calculation_format), style: "font-weight:bold" }
|
137
|
+
end
|
138
|
+
|
139
|
+
footers = [
|
140
|
+
[{ text: "Total", style: "font-weight:bold" }] + total_values + [{ text: TableFormatting::format_number(filtered_subjects.count, :count), style: 'font-weight:bold'}]
|
141
|
+
]
|
142
|
+
|
143
|
+
{ title: "#{chart_variable_json['display_name']} vs #{json['display_name']}", subtitle: subtitle, headers: headers, footers: footers, rows: rows }
|
144
|
+
|
145
|
+
end
|
146
|
+
|
147
|
+
def self.table_arbitrary_choices_by_quartile(chart_type, subjects, json, method, subtitle)
|
148
|
+
# CHART TYPE IS THE QUARTILE VARIABLE
|
149
|
+
return unless chart_variable_json = get_variable(chart_type)
|
150
|
+
return unless domain_json = get_domain(json)
|
151
|
+
|
152
|
+
filtered_subjects = subjects.select{ |s| s.send(method) != nil and s.send(chart_type) != nil }.sort_by(&chart_type.to_sym)
|
153
|
+
|
154
|
+
categories = [:quartile_one, :quartile_two, :quartile_three, :quartile_four].collect do |quartile|
|
155
|
+
bucket = filtered_subjects.send(quartile).collect(&chart_type.to_sym)
|
156
|
+
"#{bucket.min} to #{bucket.max} #{chart_variable_json['units']}"
|
157
|
+
end
|
158
|
+
|
159
|
+
headers = [
|
160
|
+
[""] + categories + ["Total"]
|
161
|
+
]
|
162
|
+
|
163
|
+
rows = []
|
164
|
+
|
165
|
+
rows = domain_json.collect do |option_hash|
|
166
|
+
row_subjects = filtered_subjects.select{ |s| s.send(method) == option_hash['value'] }
|
167
|
+
|
168
|
+
data = [:quartile_one, :quartile_two, :quartile_three, :quartile_four].collect do |quartile|
|
169
|
+
bucket = filtered_subjects.send(quartile).select{ |s| s.send(method) == option_hash['value'] }
|
170
|
+
TableFormatting::format_number(bucket.count, :count)
|
171
|
+
end
|
172
|
+
|
173
|
+
[option_hash['display_name']] + data + [{ text: TableFormatting::format_number(row_subjects.count, :count), style: 'font-weight:bold'}]
|
174
|
+
end
|
175
|
+
|
176
|
+
|
177
|
+
total_values = [:quartile_one, :quartile_two, :quartile_three, :quartile_four].collect do |quartile|
|
178
|
+
{ text: TableFormatting::format_number(filtered_subjects.send(quartile).count, :count), style: "font-weight:bold" }
|
179
|
+
end
|
180
|
+
|
181
|
+
footers = [
|
182
|
+
[{ text: "Total", style: "font-weight:bold" }] + total_values + [{ text: TableFormatting::format_number(filtered_subjects.count, :count), style: 'font-weight:bold'}]
|
183
|
+
]
|
184
|
+
|
185
|
+
{ title: "#{json['display_name']} vs #{chart_variable_json['display_name']}", subtitle: subtitle, headers: headers, footers: footers, rows: rows }
|
186
|
+
end
|
187
|
+
|
188
|
+
|
189
|
+
def self.chart_arbitrary_choices(chart_type, subjects, json, method)
|
190
|
+
return unless chart_variable_json = get_variable(chart_type)
|
191
|
+
return unless chart_variable_domain = Spout::Commands::JsonChartsAndTables::domain_array(chart_type)
|
192
|
+
return unless domain_json = get_domain(json)
|
193
|
+
|
194
|
+
|
195
|
+
title = "#{json['display_name']} by #{chart_variable_json['display_name']}"
|
196
|
+
subtitle = "By Visit"
|
197
|
+
categories = chart_variable_domain.collect{|a| a[0]}
|
198
|
+
units = 'percent'
|
199
|
+
series = []
|
200
|
+
|
201
|
+
|
202
|
+
domain_json.each do |option_hash|
|
203
|
+
domain_values = subjects.select{ |s| s.send(method) == option_hash['value'] }
|
204
|
+
|
205
|
+
data = chart_variable_domain.collect do |display_name, value|
|
206
|
+
domain_values.select{ |s| s.send(chart_type) == value }.count
|
207
|
+
end
|
208
|
+
series << { name: option_hash['display_name'], data: data }
|
209
|
+
end
|
210
|
+
|
211
|
+
{ title: title, subtitle: subtitle, categories: categories, units: units, series: series, stacking: 'percent' }
|
212
|
+
end
|
213
|
+
|
214
|
+
def self.chart_arbitrary(chart_type, subjects, json, method, visits)
|
215
|
+
return unless chart_variable_json = get_variable(chart_type)
|
216
|
+
return unless chart_variable_domain = Spout::Commands::JsonChartsAndTables::domain_array(chart_type)
|
217
|
+
return chart_arbitrary_by_quartile(chart_type, subjects, json, method, visits) if ['numeric', 'integer'].include?(chart_variable_json['type'])
|
218
|
+
|
219
|
+
return chart_arbitrary_choices(chart_type, subjects, json, method) if json['type'] == 'choices'
|
220
|
+
|
221
|
+
title = "#{json['display_name']} by #{chart_variable_json['display_name']}"
|
222
|
+
subtitle = "By Visit"
|
223
|
+
categories = []
|
224
|
+
units = json["units"]
|
225
|
+
series = []
|
226
|
+
|
227
|
+
data = []
|
228
|
+
|
229
|
+
visits.each do |visit_display_name, visit_value|
|
230
|
+
visit_subjects = subjects.select{ |s| s._visit == visit_value and s.send(method) != nil }
|
231
|
+
if visit_subjects.count > 0
|
232
|
+
chart_variable_domain.each_with_index do |(display_name, value), index|
|
233
|
+
values = visit_subjects.select{|s| s.send(chart_type) == value }.collect(&method.to_sym)
|
234
|
+
data[index] ||= []
|
235
|
+
data[index] << (values.mean.round(2) rescue 0.0)
|
236
|
+
end
|
237
|
+
categories << visit_display_name
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
chart_variable_domain.each_with_index do |(display_name, value), index|
|
242
|
+
series << { name: display_name, data: data[index] }
|
243
|
+
end
|
244
|
+
|
245
|
+
{ title: title, subtitle: subtitle, categories: categories, units: units, series: series }
|
246
|
+
end
|
247
|
+
|
248
|
+
|
249
|
+
def self.table_arbitrary(chart_type, subjects, json, method, subtitle = nil)
|
250
|
+
return unless chart_variable_json = get_variable(chart_type)
|
251
|
+
return unless chart_variable_domain = Spout::Commands::JsonChartsAndTables::domain_array(chart_type)
|
252
|
+
return table_arbitrary_by_quartile(chart_type, subjects, json, method, subtitle) if ['numeric', 'integer'].include?(chart_variable_json['type'])
|
253
|
+
return table_arbitrary_choices(chart_type, subjects, json, method, subtitle) if json['type'] == 'choices'
|
254
|
+
|
255
|
+
headers = [
|
256
|
+
[""] + Spout::Helpers::ArrayStatistics::calculations.collect{|calculation_label, calculation_method| calculation_label} + ["Total"]
|
257
|
+
]
|
258
|
+
|
259
|
+
filtered_subjects = subjects.select{ |s| s.send(chart_type) != nil }
|
260
|
+
|
261
|
+
rows = chart_variable_domain.collect do |display_name, value|
|
262
|
+
row_subjects = filtered_subjects.select{ |s| s.send(chart_type) == value }
|
263
|
+
|
264
|
+
row_cells = Spout::Helpers::ArrayStatistics::calculations.collect do |calculation_label, calculation_method, calculation_type, calculation_format|
|
265
|
+
count = row_subjects.collect(&method.to_sym).send(calculation_method)
|
266
|
+
(count == 0 && calculation_method == :count) ? { text: '-', class: 'text-muted' } : TableFormatting::format_number(count, calculation_type, calculation_format)
|
267
|
+
end
|
268
|
+
|
269
|
+
[display_name] + row_cells + [{ text: TableFormatting::format_number(row_subjects.count, :count), style: 'font-weight:bold'}]
|
270
|
+
end
|
271
|
+
|
272
|
+
total_values = Spout::Helpers::ArrayStatistics::calculations.collect do |calculation_label, calculation_method, calculation_type, calculation_format|
|
273
|
+
total_count = filtered_subjects.collect(&method.to_sym).send(calculation_method)
|
274
|
+
{ text: TableFormatting::format_number(total_count, calculation_type, calculation_format), style: "font-weight:bold" }
|
275
|
+
end
|
276
|
+
|
277
|
+
footers = [
|
278
|
+
[{ text: "Total", style: "font-weight:bold" }] + total_values + [{ text: TableFormatting::format_number(filtered_subjects.count, :count), style: 'font-weight:bold'}]
|
279
|
+
]
|
280
|
+
|
281
|
+
{ title: "#{chart_variable_json['display_name']} vs #{json['display_name']}", subtitle: subtitle, headers: headers, footers: footers, rows: rows }
|
282
|
+
|
283
|
+
end
|
284
|
+
|
285
|
+
def self.table_arbitrary_choices(chart_type, subjects, json, method, subtitle)
|
286
|
+
return unless chart_variable_json = get_variable(chart_type)
|
287
|
+
return unless chart_variable_domain = Spout::Commands::JsonChartsAndTables::domain_array(chart_type)
|
288
|
+
return unless domain_json = get_domain(json)
|
289
|
+
|
290
|
+
headers = [
|
291
|
+
[""] + chart_variable_domain.collect{|display_name, value| display_name} + ["Total"]
|
292
|
+
]
|
293
|
+
|
294
|
+
filtered_subjects = subjects.select{ |s| s.send(chart_type) != nil }
|
295
|
+
|
296
|
+
rows = domain_json.collect do |option_hash|
|
297
|
+
row_subjects = filtered_subjects.select{ |s| s.send(method) == option_hash['value'] }
|
298
|
+
row_cells = chart_variable_domain.collect do |display_name, value|
|
299
|
+
count = row_subjects.select{ |s| s.send(chart_type) == value }.count
|
300
|
+
count > 0 ? TableFormatting::format_number(count, :count) : { text: '-', class: 'text-muted' }
|
301
|
+
end
|
302
|
+
|
303
|
+
total = row_subjects.count
|
304
|
+
|
305
|
+
[option_hash['display_name']] + row_cells + [total == 0 ? { text: '-', class: 'text-muted' } : { text: TableFormatting::format_number(total, :count), style: 'font-weight:bold'}]
|
306
|
+
end
|
307
|
+
|
308
|
+
if filtered_subjects.select{|s| s.send(method) == nil }.count > 0
|
309
|
+
unknown_values = chart_variable_domain.collect do |display_name, value|
|
310
|
+
{ text: TableFormatting::format_number(filtered_subjects.select{ |s| s.send(chart_type) == value and s.send(method) == nil }.count, :count), class: 'text-muted' }
|
311
|
+
end
|
312
|
+
rows << [{ text: 'Unknown', class: 'text-muted'}] + unknown_values + [ { text: filtered_subjects.select{|s| s.send(method) == nil}.count.to_s, style: 'font-weight:bold', class: 'text-muted' } ]
|
313
|
+
end
|
314
|
+
|
315
|
+
|
316
|
+
|
317
|
+
total_values = chart_variable_domain.collect do |display_name, value|
|
318
|
+
total_count = filtered_subjects.select{|s| s.send(chart_type) == value }.count
|
319
|
+
{ text: (total_count == 0 ? "-" : TableFormatting::format_number(total_count, :count)), style: "font-weight:bold" }
|
320
|
+
end
|
321
|
+
|
322
|
+
footers = [
|
323
|
+
[{ text: "Total", style: "font-weight:bold" }] + total_values + [{ text: TableFormatting::format_number(filtered_subjects.count, :count), style: 'font-weight:bold'}]
|
324
|
+
]
|
325
|
+
|
326
|
+
{ title: "#{json['display_name']} vs #{chart_variable_json['display_name']}", subtitle: subtitle, headers: headers, footers: footers, rows: rows }
|
327
|
+
end
|
328
|
+
|
329
|
+
|
330
|
+
def self.chart_histogram_choices(chart_type, subjects, json, method)
|
331
|
+
return unless domain_json = get_domain(json)
|
332
|
+
return unless chart_variable_json = get_variable(chart_type)
|
333
|
+
return unless chart_variable_domain = Spout::Commands::JsonChartsAndTables::domain_array(chart_type)
|
334
|
+
|
335
|
+
|
336
|
+
title = "#{json['display_name']}"
|
337
|
+
subtitle = "By Visit"
|
338
|
+
|
339
|
+
categories = domain_json.collect{|option_hash| option_hash['display_name']}
|
340
|
+
|
341
|
+
units = "Subjects"
|
342
|
+
series = []
|
343
|
+
|
344
|
+
chart_variable_domain.each do |display_name, value|
|
345
|
+
visit_subjects = subjects.select{ |s| s.send(chart_type) == value and s.send(method) != nil }.collect(&method.to_sym)
|
346
|
+
next unless visit_subjects.size > 0
|
347
|
+
|
348
|
+
data = []
|
349
|
+
|
350
|
+
domain_json.each do |option_hash|
|
351
|
+
data << visit_subjects.select{ |v| v == option_hash['value'] }.count
|
352
|
+
end
|
353
|
+
|
354
|
+
series << { name: display_name, data: data }
|
355
|
+
end
|
356
|
+
|
357
|
+
{ title: title, subtitle: subtitle, categories: categories, units: units, series: series }
|
358
|
+
end
|
359
|
+
|
360
|
+
def self.chart_histogram(chart_type, subjects, json, method)
|
361
|
+
return chart_histogram_choices(chart_type, subjects, json, method) if json['type'] == 'choices'
|
362
|
+
return unless chart_variable_json = get_variable(chart_type)
|
363
|
+
return unless chart_variable_domain = Spout::Commands::JsonChartsAndTables::domain_array(chart_type)
|
364
|
+
|
365
|
+
title = "#{json['display_name']}"
|
366
|
+
subtitle = "By Visit"
|
367
|
+
categories = []
|
368
|
+
units = "Subjects"
|
369
|
+
series = []
|
370
|
+
|
371
|
+
all_subject_values = subjects.collect(&method.to_sym).compact.sort
|
372
|
+
return nil if all_subject_values.count == 0
|
373
|
+
buckets = continuous_buckets(all_subject_values)
|
374
|
+
|
375
|
+
categories = buckets.collect{|b| "#{b[0].round(1)} to #{b[1].round(1)}"}
|
376
|
+
|
377
|
+
chart_variable_domain.each do |display_name, value|
|
378
|
+
visit_subjects = subjects.select{ |s| s.send(chart_type) == value and s.send(method) != nil }.collect(&method.to_sym).sort
|
379
|
+
next unless visit_subjects.size > 0
|
380
|
+
|
381
|
+
data = []
|
382
|
+
|
383
|
+
visit_subjects.group_by{|v| get_bucket(buckets, v) }.each do |key, values|
|
384
|
+
data[categories.index(key)] = values.count if categories.index(key)
|
385
|
+
end
|
386
|
+
|
387
|
+
series << { name: display_name, data: data }
|
388
|
+
end
|
389
|
+
|
390
|
+
{ title: title, subtitle: subtitle, categories: categories, units: units, series: series }
|
391
|
+
end
|
392
|
+
|
393
|
+
end
|
394
|
+
end
|
395
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Spout
|
2
|
+
module Helpers
|
3
|
+
class TableFormatting
|
4
|
+
|
5
|
+
# def initialize(number)
|
6
|
+
# @number = number
|
7
|
+
# end
|
8
|
+
|
9
|
+
def self.number_with_delimiter(number, delimiter = ",")
|
10
|
+
number.to_s.reverse.scan(/(?:\d*\.)?\d{1,3}-?/).join(',').reverse
|
11
|
+
end
|
12
|
+
|
13
|
+
# type: :count or :decimal
|
14
|
+
def self.format_number(number, type, format = nil)
|
15
|
+
if number == nil
|
16
|
+
format_nil(number)
|
17
|
+
elsif type == :count
|
18
|
+
format_count(number)
|
19
|
+
else
|
20
|
+
format_decimal(number, format)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.format_nil(number)
|
25
|
+
'-'
|
26
|
+
end
|
27
|
+
|
28
|
+
# count:
|
29
|
+
# 0 -> '-'
|
30
|
+
# 10 -> '10'
|
31
|
+
# 1000 -> '1,000'
|
32
|
+
# Input (Numeric) -> Output (String)
|
33
|
+
def self.format_count(number)
|
34
|
+
(number == 0 || number == nil) ? '-' : number_with_delimiter(number)
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
# decimal:
|
39
|
+
# 0 -> '0.0'
|
40
|
+
# 10 -> '10.0'
|
41
|
+
# -50.2555 -> '-50.3'
|
42
|
+
# 1000 -> '1,000.0'
|
43
|
+
# 12412423.42252525 -> '12,412,423.4'
|
44
|
+
# Input (Numeric) -> Output (String)
|
45
|
+
def self.format_decimal(number, format)
|
46
|
+
number = self.number_with_delimiter(number.round(1))
|
47
|
+
number = format % number if format
|
48
|
+
number
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
data/lib/spout/tasks/engine.rake
CHANGED
@@ -131,6 +131,13 @@ namespace :spout do
|
|
131
131
|
Spout::Commands::Graphs.new(types, variable_ids, sizes)
|
132
132
|
end
|
133
133
|
|
134
|
+
desc 'Generate JSON charts and tables'
|
135
|
+
task :json do
|
136
|
+
require 'spout/commands/json_charts_and_tables'
|
137
|
+
variables = ENV['variables'].to_s.split(',').collect{|s| s.to_s.downcase}
|
138
|
+
Spout::Commands::JsonChartsAndTables.new(variables)
|
139
|
+
end
|
140
|
+
|
134
141
|
end
|
135
142
|
|
136
143
|
class SpoutCoverageResult
|
data/lib/spout/version.rb
CHANGED
data/spout.gemspec
CHANGED
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
|
|
29
29
|
spec.add_dependency "rake"
|
30
30
|
spec.add_dependency "turn"
|
31
31
|
spec.add_dependency "json"
|
32
|
-
spec.add_dependency "colorize", "~> 0.
|
32
|
+
spec.add_dependency "colorize", "~> 0.7.2"
|
33
33
|
|
34
34
|
spec.add_development_dependency "bundler", "~> 1.3"
|
35
35
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: spout
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0.beta1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Remo Mueller
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-
|
11
|
+
date: 2014-05-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rake
|
@@ -58,14 +58,14 @@ dependencies:
|
|
58
58
|
requirements:
|
59
59
|
- - "~>"
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version: 0.
|
61
|
+
version: 0.7.2
|
62
62
|
type: :runtime
|
63
63
|
prerelease: false
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
65
65
|
requirements:
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
|
-
version: 0.
|
68
|
+
version: 0.7.2
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
70
|
name: bundler
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -98,7 +98,12 @@ files:
|
|
98
98
|
- lib/spout/actions.rb
|
99
99
|
- lib/spout/application.rb
|
100
100
|
- lib/spout/commands/graphs.rb
|
101
|
+
- lib/spout/commands/json_charts_and_tables.rb
|
102
|
+
- lib/spout/helpers/array_statistics.rb
|
103
|
+
- lib/spout/helpers/chart_types.rb
|
104
|
+
- lib/spout/helpers/table_formatting.rb
|
101
105
|
- lib/spout/hidden_reporter.rb
|
106
|
+
- lib/spout/models/subject.rb
|
102
107
|
- lib/spout/support/javascripts/data.js
|
103
108
|
- lib/spout/support/javascripts/highcharts-convert.js
|
104
109
|
- lib/spout/support/javascripts/highcharts-more.js
|
@@ -142,9 +147,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
142
147
|
version: '0'
|
143
148
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
144
149
|
requirements:
|
145
|
-
- - "
|
150
|
+
- - ">"
|
146
151
|
- !ruby/object:Gem::Version
|
147
|
-
version:
|
152
|
+
version: 1.3.1
|
148
153
|
requirements: []
|
149
154
|
rubyforge_project:
|
150
155
|
rubygems_version: 2.2.2
|