hlsv 1.0.0 → 2.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/CHANGELOG.md +44 -3
- data/README.md +36 -29
- data/lib/hlsv/analysis_registry.rb +47 -0
- data/lib/hlsv/analysis_runner.rb +193 -0
- data/lib/hlsv/config_manager.rb +125 -0
- data/lib/hlsv/html2word.rb +18 -18
- data/lib/hlsv/path_guard.rb +32 -0
- data/lib/hlsv/sdtm_validation/dataset.rb +169 -0
- data/lib/hlsv/sdtm_validation/define.rb +142 -0
- data/lib/hlsv/sdtm_validation/report.rb +357 -0
- data/lib/hlsv/sdtm_validation.rb +390 -0
- data/lib/hlsv/url_helper.rb +28 -0
- data/lib/hlsv/version.rb +1 -1
- data/lib/hlsv/web_app.rb +264 -418
- data/lib/hlsv.rb +12 -5
- data/public/css/accessibility/accessibility.css +33 -0
- data/public/css/base/layout.css +33 -0
- data/public/css/base/reset.css +23 -0
- data/public/css/base/typography.css +31 -0
- data/public/css/components/buttons.css +142 -0
- data/public/css/components/file-tree.css +107 -0
- data/public/css/components/footer.css +43 -0
- data/public/css/components/forms.css +56 -0
- data/public/css/components/header.css +52 -0
- data/public/css/components/status.css +56 -0
- data/public/css/features/csv-table.css +204 -0
- data/public/css/features/file-browser.css +208 -0
- data/public/css/responsive/responsive.css +133 -0
- data/public/css/styles.css +25 -0
- data/public/css/styles_csv.css +23 -0
- data/public/favicon.ico +0 -0
- data/public/js/analysis.js +201 -0
- data/public/js/app.js +63 -0
- data/public/js/browser.js +172 -0
- data/public/js/config.js +214 -0
- data/public/js/results.js +240 -0
- data/public/js/utils.js +57 -0
- data/views/csv_view.erb +11 -12
- data/views/index.erb +70 -19
- data/views/{report_template.erb → report.erb} +203 -188
- metadata +39 -41
- data/lib/hlsv/find_keys.rb +0 -979
- data/lib/hlsv/mon_script.rb +0 -169
- data/public/app.js +0 -569
- data/public/styles.css +0 -586
- data/public/styles_csv.css +0 -448
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'nokogiri'
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'csv'
|
|
5
|
+
|
|
6
|
+
require_relative 'xpt'
|
|
7
|
+
|
|
8
|
+
require_relative "html2word"
|
|
9
|
+
|
|
10
|
+
require_relative 'sdtm_validation/dataset'
|
|
11
|
+
require_relative 'sdtm_validation/define'
|
|
12
|
+
require_relative 'sdtm_validation/report'
|
|
13
|
+
|
|
14
|
+
module Hlsv
|
|
15
|
+
class SdtmValidation
|
|
16
|
+
|
|
17
|
+
attr_reader :web_mode
|
|
18
|
+
attr_reader :verbose
|
|
19
|
+
|
|
20
|
+
attr_reader :config
|
|
21
|
+
|
|
22
|
+
attr_reader :define
|
|
23
|
+
attr_reader :datasets
|
|
24
|
+
attr_reader :ds_path
|
|
25
|
+
|
|
26
|
+
def initialize(config_file, web_mode: false, verbose: false)
|
|
27
|
+
@web_mode = web_mode
|
|
28
|
+
@verbose = verbose
|
|
29
|
+
|
|
30
|
+
# load the config
|
|
31
|
+
@config = load_config(config_file)
|
|
32
|
+
|
|
33
|
+
# load define
|
|
34
|
+
@define = Define.new(@config['define_path'])
|
|
35
|
+
|
|
36
|
+
# load data
|
|
37
|
+
# get all xpt files in the directory present in the config file (data_directory parameter)
|
|
38
|
+
@ds_path = @config['data_directory'].gsub('\\', '/')
|
|
39
|
+
|
|
40
|
+
unless File.directory? @ds_path
|
|
41
|
+
raise Hlsv::Error, "⚠ dataset directory is not a directory, analysis not possible: #{@ds_path}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# present of excluded datasets
|
|
45
|
+
puts "Excluded dataset: #{excluded_dataset.join(', ')}" unless excluded_dataset.empty?
|
|
46
|
+
|
|
47
|
+
@datasets = Dir["#{@ds_path}/*"]
|
|
48
|
+
.select { |f| File.extname(f) == '.xpt' }
|
|
49
|
+
.reject { |f| excluded_dataset.include? File.basename(f, '.xpt').upcase }
|
|
50
|
+
.to_h { |ds| [File.basename(ds, '.xpt').upcase, Dataset.from_xpt(ds)] }
|
|
51
|
+
|
|
52
|
+
if @datasets.empty?
|
|
53
|
+
raise Hlsv::Error, "⚠ no xpt files detected, analysis not possible"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# refresh the output directory
|
|
57
|
+
prepare_output_dir
|
|
58
|
+
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate
|
|
62
|
+
ascii_search
|
|
63
|
+
check_define_key if @define.status
|
|
64
|
+
check_data_key
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# check config key in the data
|
|
68
|
+
def check_data_key
|
|
69
|
+
|
|
70
|
+
puts "\n=== data check"
|
|
71
|
+
|
|
72
|
+
@datasets.each do |ds, dataset|
|
|
73
|
+
puts ds
|
|
74
|
+
|
|
75
|
+
# get candidate keys
|
|
76
|
+
candidates = dataset.candidates_keys(ds, @config)
|
|
77
|
+
|
|
78
|
+
# get the dataset records
|
|
79
|
+
records = @datasets[ds].records
|
|
80
|
+
|
|
81
|
+
# no records present
|
|
82
|
+
if records.empty?
|
|
83
|
+
puts '- no records present'
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# search the minimal combination of variables to avoid duplicates
|
|
88
|
+
candidates.each_with_index do |candidate, i|
|
|
89
|
+
|
|
90
|
+
if candidates.size == 1
|
|
91
|
+
idx = i;
|
|
92
|
+
else
|
|
93
|
+
idx = i+1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
duplicate_records = look_keys(ds, candidate, records)
|
|
97
|
+
|
|
98
|
+
unless duplicate_records.empty?
|
|
99
|
+
dup_file = write_csv(ds, duplicate_records, :data, idx)
|
|
100
|
+
|
|
101
|
+
@datasets[ds].data_duplicate_file << dup_file
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private def look_keys(ds, keys_candidates, records)
|
|
108
|
+
# initialize the number of duplicate with the records of records present in the dataset
|
|
109
|
+
prev_records_max = records.size
|
|
110
|
+
prev_nb_sub = records.size
|
|
111
|
+
# initialize the useless variables as an empty array
|
|
112
|
+
useless_vars = []
|
|
113
|
+
|
|
114
|
+
# display the variables check during the search
|
|
115
|
+
puts "- the key search is based on the following variables in the given order: #{keys_candidates.join(', ')}"
|
|
116
|
+
|
|
117
|
+
# loop on all candidate keys variables
|
|
118
|
+
keys_candidates.size.times do |i|
|
|
119
|
+
|
|
120
|
+
# tested key
|
|
121
|
+
keys_vars = keys_candidates[..i]
|
|
122
|
+
# remove useless variables
|
|
123
|
+
useless_vars.each do |v|
|
|
124
|
+
keys_vars.delete(v)
|
|
125
|
+
end
|
|
126
|
+
puts " - The key tried is: #{keys_vars.join(', ')}" if @verbose
|
|
127
|
+
|
|
128
|
+
# array of values of the variables
|
|
129
|
+
keys_records = records.group_by do |p|
|
|
130
|
+
keys_vars.map { |var| p[var] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# get the maximum of records by key
|
|
134
|
+
records_max = 0
|
|
135
|
+
nb_sub = 0
|
|
136
|
+
keys_records.each do |values, list|
|
|
137
|
+
records_max = list.size if list.size > records_max
|
|
138
|
+
if list.size > 1
|
|
139
|
+
nb_sub += 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# break if no duplicate
|
|
144
|
+
if records_max == 1
|
|
145
|
+
puts "- the minimal valid key have been found: #{keys_vars.join(', ')}"
|
|
146
|
+
@datasets[ds].minimal_key << keys_vars
|
|
147
|
+
@datasets[ds].minimal_key_status << true
|
|
148
|
+
return []
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# duplicate still present
|
|
152
|
+
puts " - failure: #{nb_sub} subjects have a maximum #{records_max} records for #{keys_vars.join(', ')}" if @verbose
|
|
153
|
+
# if the number of current duplicate is superior or equal to the previous
|
|
154
|
+
# number of duplicate then the variable is not useful for the key search and
|
|
155
|
+
# the previous number of maximum duplicate stay as is
|
|
156
|
+
# the current variable will be remove for the next search
|
|
157
|
+
if prev_records_max <= records_max && prev_nb_sub <= nb_sub
|
|
158
|
+
puts " - #{keys_vars.last} not useful: The number of subjects and the maximum number of duplicates are the same as for the previous tested key." if @verbose
|
|
159
|
+
useless_vars << keys_vars.last
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# current duplicate number become the current number of duplicate
|
|
163
|
+
prev_records_max = records_max
|
|
164
|
+
prev_nb_sub = nb_sub
|
|
165
|
+
|
|
166
|
+
# duplicate if no more variables
|
|
167
|
+
if i == (keys_candidates.size-1)
|
|
168
|
+
# duplicates records with the last valid key
|
|
169
|
+
last_valid_keys = keys_candidates.reject { |kc| useless_vars.include? kc }
|
|
170
|
+
puts "- The minimal key has not been found. The last key that was tested is: #{last_valid_keys.join(', ')}"
|
|
171
|
+
@datasets[ds].minimal_key << last_valid_keys
|
|
172
|
+
@datasets[ds].minimal_key_status << false
|
|
173
|
+
|
|
174
|
+
# compile all duplicates with the last valid keys
|
|
175
|
+
duplicate_records = records.group_by do |p|
|
|
176
|
+
last_valid_keys.map { |var| p[var] }
|
|
177
|
+
end
|
|
178
|
+
@datasets[ds].data_duplicate_records << duplicate_records
|
|
179
|
+
|
|
180
|
+
return duplicate_records
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# check define key validity
|
|
187
|
+
def check_define_key
|
|
188
|
+
|
|
189
|
+
puts "\n=== define check"
|
|
190
|
+
|
|
191
|
+
# get all datasets: present in data & in define
|
|
192
|
+
ds_list = (@datasets.values.map(&:name) + @define.datasets).uniq
|
|
193
|
+
|
|
194
|
+
ds_list.each do |ds|
|
|
195
|
+
|
|
196
|
+
# no check if excluded dataset
|
|
197
|
+
if excluded_dataset.include? ds
|
|
198
|
+
@define.keys.delete(ds)
|
|
199
|
+
next
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# get the define keys
|
|
203
|
+
define_key = @define.define_key_for(ds)
|
|
204
|
+
|
|
205
|
+
# no define key
|
|
206
|
+
if define_key.nil?
|
|
207
|
+
puts "#{ds}: no define key"
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
# transform key in symbol
|
|
211
|
+
define_key.map!(&:to_sym)
|
|
212
|
+
|
|
213
|
+
# add into dataset
|
|
214
|
+
if @datasets.keys.include? ds
|
|
215
|
+
@datasets[ds].define_keys = define_key
|
|
216
|
+
else
|
|
217
|
+
puts "#{ds}: present in define.xml but not in dataset"
|
|
218
|
+
@datasets[ds] = Dataset.new(ds, nil, [])
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# get the records
|
|
222
|
+
records = @datasets[ds].records
|
|
223
|
+
|
|
224
|
+
# no records
|
|
225
|
+
if records.empty?
|
|
226
|
+
puts "#{ds}: no records for define datasets"
|
|
227
|
+
next
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# define key & records are present
|
|
231
|
+
keys_records = records.group_by do |record|
|
|
232
|
+
define_key.map { |var| record[var] }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
records_max = keys_records.map { |_, list| list.size }.max || 0
|
|
236
|
+
|
|
237
|
+
if records_max == 1
|
|
238
|
+
puts "#{ds}: define key is valid"
|
|
239
|
+
@datasets[ds].define_status = true
|
|
240
|
+
else
|
|
241
|
+
puts "#{ds}: define key is invalid"
|
|
242
|
+
@datasets[ds].define_status = false
|
|
243
|
+
@datasets[ds].define_duplicate_records = keys_records
|
|
244
|
+
|
|
245
|
+
dup_file = write_csv(ds, keys_records, :define, 0)
|
|
246
|
+
@datasets[ds].define_duplicate_file = dup_file
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# search non-ASCII character
|
|
252
|
+
def ascii_search
|
|
253
|
+
|
|
254
|
+
puts "\n=== Non-ASCII Characters Search"
|
|
255
|
+
|
|
256
|
+
@datasets.values.each do |ds|
|
|
257
|
+
records = ds.records
|
|
258
|
+
# no records no test
|
|
259
|
+
next if records.empty?
|
|
260
|
+
# initialize list of exception
|
|
261
|
+
non_ascii_list = []
|
|
262
|
+
# read all characters
|
|
263
|
+
records.each do |record|
|
|
264
|
+
record.each do |var, value|
|
|
265
|
+
next if value.nil? || value.is_a?(Integer) || value.is_a?(Float)
|
|
266
|
+
if value.match(/[^[:ascii:]]/)
|
|
267
|
+
non_ascii_list << "#{var}: #{value.inspect}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
ds.non_ascii_values = non_ascii_list
|
|
273
|
+
|
|
274
|
+
if non_ascii_list.empty?
|
|
275
|
+
puts "#{ds.name}: non-ASCII absent"
|
|
276
|
+
else
|
|
277
|
+
puts "#{ds.name}: non-ASCII present", non_ascii_list.map { |v| "- #{v}" }
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# report creation
|
|
283
|
+
def report
|
|
284
|
+
@report ||= Report.new(study_name, @ds_path, @datasets, @define, excluded_dataset, web_mode: @web_mode)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
####
|
|
288
|
+
# load YAML file
|
|
289
|
+
# the validation on the config is done by the web app
|
|
290
|
+
# return hash: parameter => value
|
|
291
|
+
def load_config(file)
|
|
292
|
+
YAML.load_file(file)
|
|
293
|
+
rescue Psych::SyntaxError => e
|
|
294
|
+
raise Hlsv::Error, "❌ Invalid configuration\n\n> #{e.message}"
|
|
295
|
+
rescue Errno::ENOENT
|
|
296
|
+
raise Hlsv::Error, "❌ File not found\n\n> `#{file}` does not exist"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
###
|
|
300
|
+
# display
|
|
301
|
+
#
|
|
302
|
+
# create a CVS with all duplicates
|
|
303
|
+
def write_csv(ds, duplicate_records, type, index)
|
|
304
|
+
|
|
305
|
+
if index == 0 then
|
|
306
|
+
display_out = "#{dir_out}/#{type.to_s}_#{ds}.csv"
|
|
307
|
+
else
|
|
308
|
+
display_out = "#{dir_out}/#{type.to_s}_#{index}_#{ds}.csv"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# TODO: hum ça marche ouais mais non, faut pas laisser comme ça
|
|
312
|
+
header = duplicate_records.first.last.first.keys
|
|
313
|
+
header = header.insert(0, 'No')
|
|
314
|
+
|
|
315
|
+
duplicate = duplicate_records.reject { |key, list| list.size == 1 }
|
|
316
|
+
|
|
317
|
+
CSV.open(display_out, "w") do |csv|
|
|
318
|
+
# header
|
|
319
|
+
csv << header
|
|
320
|
+
# body
|
|
321
|
+
duplicate.each do |key, list|
|
|
322
|
+
idx = duplicate.keys.index(key) + 1
|
|
323
|
+
list.each do |l|
|
|
324
|
+
row = l.values.insert(0, idx)
|
|
325
|
+
csv << row
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
display_out
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
###
|
|
334
|
+
# Helpers
|
|
335
|
+
|
|
336
|
+
# study name
|
|
337
|
+
def study_name
|
|
338
|
+
@config['study_name']
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# excluded ds
|
|
342
|
+
def excluded_dataset
|
|
343
|
+
@excluded_dataset ||= begin
|
|
344
|
+
exclus = @config['excluded_ds']&.gsub(/[,;]/, ' ')
|
|
345
|
+
if exclus.nil?
|
|
346
|
+
[]
|
|
347
|
+
else
|
|
348
|
+
exclus.split.map(&:upcase)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# output directory
|
|
354
|
+
def prepare_output_dir
|
|
355
|
+
dir = "#{study_name}/#{config['output_directory']}"
|
|
356
|
+
|
|
357
|
+
# no value, current repository
|
|
358
|
+
if dir.nil? || dir.strip.empty?
|
|
359
|
+
dir = '.'
|
|
360
|
+
|
|
361
|
+
# repository present, delete all csv file
|
|
362
|
+
elsif Dir.exist?(dir)
|
|
363
|
+
Dir.glob("#{dir}/**/*.csv").each { |f| File.delete(f) }
|
|
364
|
+
|
|
365
|
+
# no repository, creation
|
|
366
|
+
else
|
|
367
|
+
FileUtils.mkdir_p(dir)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
dir
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def dir_out
|
|
374
|
+
dir = "#{study_name}/#{config['output_directory']}"
|
|
375
|
+
dir = '.' if dir.nil? || dir.strip.empty?
|
|
376
|
+
dir
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# sv = SdtmValidation.new('../../config.yaml')
|
|
383
|
+
# sv.validate
|
|
384
|
+
# sv.report.html
|
|
385
|
+
# sv.report.docx
|
|
386
|
+
# sv.report.excel_export('SV')
|
|
387
|
+
# sv.report.excel_export('DA')
|
|
388
|
+
# sv.report.excel_export('DM')
|
|
389
|
+
# sv.report.excel_export('DC')
|
|
390
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# lib/hlsv/url_helper.rb
|
|
2
|
+
module Hlsv
|
|
3
|
+
module UrlHelper
|
|
4
|
+
# Builds an encoded query string from a parameter hash.
|
|
5
|
+
# nil values are ignored (Useful for the optional `last_valid_key` option, etc.)
|
|
6
|
+
def build_query(params)
|
|
7
|
+
params.compact.map { |k, v| "#{k}=#{::URI.encode_www_form_component(v.to_s)}" }.join('&')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def csv_view_url(file:, study:, dataset:, last_valid_key: nil)
|
|
11
|
+
"/csv_view?#{build_query(
|
|
12
|
+
file: file,
|
|
13
|
+
study: study,
|
|
14
|
+
dataset: dataset,
|
|
15
|
+
last_valid_key: last_valid_key&.join(',')
|
|
16
|
+
)}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def export_csv_excel_url(file:, study:, dataset:, last_valid_key: nil)
|
|
20
|
+
"/export_csv_excel?#{build_query(
|
|
21
|
+
file: file,
|
|
22
|
+
study: study,
|
|
23
|
+
dataset: dataset,
|
|
24
|
+
last_valid_key: last_valid_key&.join(',')
|
|
25
|
+
)}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/hlsv/version.rb
CHANGED