root/trunk/grails-app/services/dbnp/studycapturing/AssayService.groovy @ 1828

Revision 1828, 19.8 KB (checked in by s.h.sikkema@…, 3 years ago)

Fixed only being able to export to tab delimited; fixed 'null' values in export; fixed extra period between filename and extension

  • Property svn:keywords set to Rev Author Date
Line 
1/**
2 * AssayService Service
3 *
4 * @author  s.h.sikkema@gmail.com
5 * @since       20101216
6 * @package     dbnp.studycapturing
7 *
8 * Revision information:
9 * $Rev$
10 * $Author$
11 * $Date$
12 */
13package dbnp.studycapturing
14
15import org.apache.poi.ss.usermodel.*
16import org.apache.poi.xssf.usermodel.XSSFWorkbook
17import org.apache.poi.hssf.usermodel.HSSFWorkbook
18import org.codehaus.groovy.grails.web.json.JSONObject
19import org.dbnp.gdt.RelTime
20import org.dbnp.gdt.TemplateFieldType;
21
22class AssayService {
23
24        boolean transactional = false
25        def authenticationService
26        def moduleCommunicationService
27
28        /**
29         * Collects the assay field names per category in a map as well as the
30         * module's measurements.
31         *
32         * @param assay the assay for which to collect the fields
33         * @return a map of categories as keys and field names or measurements as
34         *  values
35         */
36        def collectAssayTemplateFields(assay) throws Exception {
37
38                def getUsedTemplateFields = { templateEntities ->
39
40                        // gather all unique and non null template fields that haves values
41                        templateEntities*.giveFields().flatten().unique().findAll{ field ->
42
43                                field && templateEntities.any { it?.fieldExists(field.name) && it.getFieldValue(field.name) != null }
44
45                        }.collect{[name: it.name, comment: it.comment, displayName: it.name + (it.unit ? " ($it.unit)" : '')]}
46                }
47
48                def samples = assay.samples
49                [               'Subject Data' :            getUsedTemplateFields( samples*."parentSubject".unique() ),
50                                        'Sampling Event Data' :     getUsedTemplateFields( samples*."parentEvent".unique() ),
51                                        'Sample Data' :             getUsedTemplateFields( samples ),
52                                        'Event Group' :             [[name: 'name', comment: 'Name of Event Group', displayName: 'name']],
53
54                                        // If module is not reachable, only the field 'module error' is returned, and is filled later on.
55                                        'Module Measurement Data':  moduleCommunicationService.isModuleReachable(assay.module.url) ? requestModuleMeasurementNames(assay) : [ [ name: "Module error" ] ]
56                ]
57
58        }
59
60        /**
61         * Gathers all assay related data, including measurements from the module,
62         * into 1 hash map containing: Subject Data, Sampling Event Data, Sample
63         * Data, and module specific measurement data.
64         * Data from each of the 4 hash map entries are themselves hash maps
65         * representing a descriptive header (field name) as key and the data as
66         * value.
67         *
68         * @param assay                                 the assay to collect data for
69         * @param fieldMap                              map with categories as keys and fields as values
70         * @param measurementTokens     selection of measurementTokens
71         * @return                              The assay data structure as described above.
72         */
73        def collectAssayData(assay, fieldMap, measurementTokens) throws Exception {
74
75                def collectFieldValuesForTemplateEntities = { headerFields, templateEntities ->
76
77                        // return a hash map with for each field name all values from the
78                        // template entity list
79                        headerFields.inject([:]) { map, headerField ->
80
81                                map + [(headerField.displayName): templateEntities.collect {
82
83                    // default to an empty string
84                    def val = ''
85
86                    def field
87                    try {
88
89                        val = it.getFieldValue(headerField.name)
90                       
91                        // Convert RelTime fields to human readable strings
92                        field = it.getField(headerField.name)
93                        if (field.type == TemplateFieldType.RELTIME)
94                            val = new RelTime( val as long )
95
96                    } catch (NoSuchFieldException e) { /* pass */ }
97
98                    val.toString()}]
99                        }
100                }
101
102                def getFieldValues = { templateEntities, headerFields, propertyName = '' ->
103
104                        def returnValue
105
106                        // if no property name is given, simply collect the fields and
107                        // values of the template entities themselves
108                        if (propertyName == '') {
109
110                                returnValue = collectFieldValuesForTemplateEntities(headerFields, templateEntities)
111
112                        } else {
113
114                                // if a property name is given, we'll have to do a bit more work
115                                // to ensure efficiency. The reason for this is that for a list
116                                // of template entities, the properties referred to by
117                                // propertyName can include duplicates. For example, for 10
118                                // samples, there may be less than 10 parent subjects. Maybe
119                                // there's only 1 parent subject. We don't want to collect field
120                                // values for this single subject 10 times ...
121                                def fieldValues
122
123                                // we'll get the unique list of properties to make sure we're
124                                // not getting the field values for identical template entity
125                                // properties more then once.
126                                def uniqueProperties = templateEntities*."$propertyName".unique()
127
128                                fieldValues = collectFieldValuesForTemplateEntities(headerFields, uniqueProperties)
129
130                                // prepare a lookup hashMap to be able to map an entities'
131                                // property (e.g. a sample's parent subject) to an index value
132                                // from the field values list
133                                int i = 0
134                                def propertyToFieldValueIndexMap = uniqueProperties.inject([:]) { map, item -> map + [(item):i++]}
135
136                                // prepare the return value so that it has an entry for field
137                                // name. This will be the column name (second header line).
138                                returnValue = headerFields*.displayName.inject([:]) { map, item -> map + [(item):[]] }
139
140                                // finally, fill map the unique field values to the (possibly
141                                // not unique) template entity properties. In our example with
142                                // 1 unique parent subject, this means copying that subject's
143                                // field values to all 10 samples.
144                                templateEntities.each{ te ->
145
146                                        headerFields*.displayName.each{
147
148                                                returnValue[it] << fieldValues[it][propertyToFieldValueIndexMap[te[propertyName]]]
149
150                                        }
151
152                                }
153
154                        }
155
156                        returnValue
157
158                }
159
160                // Find samples and sort by name
161                def samples = assay.samples.toList().sort { it.name }
162
163                def eventFieldMap = [:]
164
165                // check whether event group data was requested
166                if (fieldMap['Event Group']) {
167
168                        def names = samples*.parentEventGroup*.name.flatten()
169
170                        // only set name field when there's actual data
171                        if (!names.every {!it}) eventFieldMap['name'] = names
172
173                }
174
175                [       'Subject Data' :            getFieldValues(samples, fieldMap['Subject Data'], 'parentSubject'),
176                                'Sampling Event Data' :     getFieldValues(samples, fieldMap['Sampling Event Data'], 'parentEvent'),
177                'Sample Data' :             getFieldValues(samples, fieldMap['Sample Data']),
178                'Event Group' :             eventFieldMap,
179
180                // If module is not reachable, only the message 'module not reachable' is given for each sample
181                'Module Measurement Data':  moduleCommunicationService.isModuleReachable(assay.module.url) ?
182                                                ( measurementTokens ? requestModuleMeasurements(assay, measurementTokens, samples) : [:] ) :
183                                                [ "Module error": [ "Module not reachable" ] * samples.size() ]
184                                ]
185        }
186
187        /**
188         * Prepend data from study to the data structure
189         * @param assayData             Column wise data structure of samples
190         * @param assay                 Assay object the data should be selected from
191         * @param numValues             Number of values for this assay
192         * @return                              Extended column wise data structure
193         */
194        def prependStudyData( inputData, Assay assay, numValues ) {
195                if( !assay )
196                        return inputData;
197
198                // Retrieve study data
199                def studyData =[:]
200                assay.parent?.giveFields().each {
201                        def value = assay.parent.getFieldValue( it.name )
202                        if( value )
203                                studyData[ it.name ] = [value] * numValues
204                }
205
206                return [
207                        'Study Data': studyData
208                ] + inputData
209        }
210
211        /**
212         * Prepend data from assay to the data structure
213         * @param assayData             Column wise data structure of samples
214         * @param assay                 Assay object the data should be selected from
215         * @param numValues             Number of values for this assay
216         * @return                              Extended column wise data structure
217         */
218        def prependAssayData( inputData, Assay assay, numValues ) {
219                if( !assay )
220                        return inputData;
221
222                // Retrieve assay data
223                def assayData = [:]
224                assay.giveFields().each {
225                        def value = assay.getFieldValue( it.name )
226                        if( value )
227                                assayData[ it.name ] = [value] * numValues
228                }
229
230                return [
231                        'Assay Data': assayData
232                ] + inputData
233        }
234
235        /**
236         * Retrieves measurement names from the module through a rest call
237         *
238         * @param consumer the url of the module
239         * @param path path of the rest call to the module
240         * @return
241         */
242        def requestModuleMeasurementNames(assay) {
243
244                def moduleUrl = assay.module.url
245
246                def path = moduleUrl + "/rest/getMeasurements/query?assayToken=$assay.assayUUID"
247
248                def jsonArray = moduleCommunicationService.callModuleRestMethodJSON(moduleUrl, path)
249
250                jsonArray.collect {
251                        if( it == JSONObject.NULL )
252                                return ""
253                        else
254                                return it.toString()
255                }
256
257        }
258
259        /**
260         * Retrieves module measurement data through a rest call to the module
261         *
262         * @param assay                         Assay for which the module measurements should be retrieved
263         * @param measurementTokens     List with the names of the fields to be retrieved. Format: [ 'measurementName1', 'measurementName2' ]
264         * @param samples                       Samples for which the module
265         * @return
266         */
267        def requestModuleMeasurements(assay, inputMeasurementTokens, samples) {
268
269                def moduleUrl = assay.module.url
270
271                def tokenString = ''
272
273                inputMeasurementTokens.each{
274                        tokenString+="&measurementToken=${it.encodeAsURL()}"
275                }
276
277                def path = moduleUrl + "/rest/getMeasurementData/query?assayToken=$assay.assayUUID" + tokenString
278
279                def (sampleTokens, measurementTokens, moduleData) = moduleCommunicationService.callModuleRestMethodJSON(moduleUrl, path)
280
281                if (!sampleTokens?.size()) return []
282
283                // Convert the three different maps into a map like:
284                //
285                // [ "measurement 1": [ value1, value2, value3 ],
286                //   "measurement 2": [ value4, value5, value6 ] ]
287                //
288                // The returned values should be in the same order as the given samples-list
289                def map = [:]
290                def numSampleTokens = sampleTokens.size();
291
292                measurementTokens.eachWithIndex { measurementToken, measurementIndex ->
293                        def measurements = [];
294                        samples.each { sample ->
295
296                                // Do measurements for this sample exist? If not, a null value is returned
297                                // for this sample. Otherwise, the measurement is looked up in the list with
298                                // measurements, based on the sample token
299                                if( sampleTokens.collect{ it.toString() }.contains( sample.giveUUID() ) ) {
300                                        def tokenIndex = sampleTokens.indexOf( sample.giveUUID() );
301                                        def valueIndex = measurementIndex * numSampleTokens + tokenIndex;
302
303                                        // If the module data is in the wrong format, show an error in the log file
304                                        // and return a null value for this measurement.
305                                        if( valueIndex >= moduleData.size() ) {
306                                                log.error "Module measurements given by module " + assay.module.name + " are not in the right format: " + measurementTokens?.size() + " measurements, " + sampleTokens?.size() + " samples, " + moduleData?.size() + " values"
307                                                measurements << null
308                                        }  else {
309                                                measurements << ( moduleData[ valueIndex ] == JSONObject.NULL ? "" : moduleData[ valueIndex ].toString() );
310                                        }
311                                } else {
312                                        measurements << null
313                                }
314                        }
315                        map[ measurementToken.toString() ] = measurements
316                }
317
318                return map;
319        }
320
321        /**
322         * Merges the data from multiple studies into a structure that can be exported to an excel file. The format for each assay is
323         *
324         *      [Category1:
325         *      [Column1: [1,2,3], Column2: [4,5,6]],
326         *   Category2:
327         *      [Column3: [7,8,9], Column4: [10,11,12], Column5: [13,14,15]]]
328         *
329         * Where the category describes the category of data that is presented (e.g. subject, sample etc.) and the column names describe
330         * the fields that are present. Each entry in the lists shows the value for that column for an entity. In this case, 3 entities are described.
331         * Each field should give values for all entities, so the length of all value-lists should be the same.
332         *
333         * Example: If the following input is given (2 assays)
334         *
335         *      [
336         *    [Category1:
337         *      [Column1: [1,2,3], Column2: [4,5,6]],
338         *     Category2:
339         *      [Column3: [7,8,9], Column4: [10,11,12], Column5: [13,14,15]]],
340         *    [Category1:
341         *      [Column1: [16,17], Column6: [18,19]],
342         *     Category3:
343         *      [Column3: [20,21], Column8: [22,23]]]
344         * ]
345         *
346         * the output will be (5 entries for each column, empty values for fields that don't exist in some assays)
347         *
348         *      [
349         *    [Category1:
350         *      [Column1: [1,2,3,16,17], Column2: [4,5,6,,], Column6: [,,,18,19]],
351         *     Category2:
352         *      [Column3: [7,8,9,,], Column4: [10,11,12,,], Column5: [13,14,15,,]],
353         *     Category3:
354         *      [Column3: [,,,20,21], Column8: [,,,22,23]]
355         * ]
356         *
357         *
358         * @param columnWiseAssayData   List with each entry being the column wise data of an assay. The format for each
359         *                                                              entry is described above
360         * @return      Hashmap                         Combined assay data, in the same structure as each input entry. Empty values are given as an empty string.
361         *                                                              So for input entries
362         */
363        def mergeColumnWiseDataOfMultipleStudies(def columnWiseAssayData) {
364                // Compute the number of values that is expected for each assay. This number is
365                // used later on to determine the number of empty fields to add if a field is not present in this
366                // assay
367                def numValues = columnWiseAssayData.collect { assay ->
368                        for( cat in assay ) {
369                                if( cat ) {
370                                        for( field in cat.value ) {
371                                                if( field?.value?.size() > 0 ) {
372                                                        return field.value.size();
373                                                }
374                                        }
375                                }
376                        }
377
378                        return 0;
379                }
380
381                // Merge categories from all assays. Create a list for all categories
382                def categories = columnWiseAssayData*.keySet().toList().flatten().unique();
383                def mergedColumnWiseData = [:]
384                categories.each { category ->
385                        // Only work with this category for all assays
386                        def categoryData = columnWiseAssayData*.getAt( category );
387
388                        // Find the different fields in all assays
389                        def categoryFields = categoryData.findAll{ it }*.keySet().toList().flatten().unique();
390
391                        // Find data for all assays for these fields. If the fields do not exist, return an empty string
392                        def categoryValues = [:]
393                        categoryFields.each { field ->
394                                categoryValues[ field ] = [];
395
396                                // Loop through all assays
397                                categoryData.eachWithIndex { assayValues, idx ->
398                                        if( assayValues && assayValues.containsKey( field ) ) {
399                                                // Append the values if they exist
400                                                categoryValues[ field ] += assayValues[ field ];
401                                        } else {
402                                                // Append empty string for each entity if the field doesn't exist
403                                                categoryValues[ field ] += [""] * numValues[ idx ]
404                                        }
405                                }
406                        }
407
408                        mergedColumnWiseData[ category ] = categoryValues
409                }
410
411                return mergedColumnWiseData;
412        }
413
414        /**
415         * Converts column
416         * @param columnData multidimensional map containing column data.
417         * On the top level, the data must be grouped by category. Each key is the
418         * category title and the values are maps representing the columns. Each
419         * column also has a title (its key) and a list of values. Columns must be
420         * equally sized.
421         *
422         * For example, consider the following map:
423         * [Category1:
424         *      [Column1: [1,2,3], Column2: [4,5,6]],
425         *  Category2:
426         *      [Column3: [7,8,9], Column4: [10,11,12], Column5: [13,14,15]]]
427         *
428         * which will be written as:
429         *
430         * | Category1  |           | Category2 |           |           |
431         * | Column1    | Column2   | Column3   | Column4   | Column5   |
432         * | 1          | 4         | 7         | 10        | 13        |
433         * | 2          | 5         | 8         | 11        | 14        |
434         * | 3          | 6         | 9         | 12        | 15        |
435         *
436         * @return row wise data
437         */
438        def convertColumnToRowStructure(columnData) {
439
440                // check if all columns have the dimensionality 2
441                if (columnData.every { it.value.every { it.value instanceof ArrayList } }) {
442
443                        def headers = [[],[]]
444
445                        columnData.each { category ->
446
447                                if (category.value.size()) {
448
449                                        // put category keys into first row separated by null values
450                                        // wherever there are > 1 columns per category
451                                        headers[0] += [category.key] + [null] * (category.value.size() - 1)
452
453                                        // put non-category column headers into 2nd row
454                                        headers[1] += category.value.collect{it.key}
455
456                                }
457
458                        }
459
460                        def d = []
461
462                        // add all column wise data into 'd'
463                        columnData.each { it.value.each { d << it.value } }
464
465                        // transpose d into row wise data and combine with header rows
466                        headers + d.transpose()
467                }
468
469        }
470
471        /**
472         * Export column wise data in Excel format to a stream.
473         *
474         * @param columnData Multidimensional map containing column data
475         * @param outputStream Stream to write to
476         * @param useOfficeOpenXML Flag to specify xlsx (standard) or xls output
477         * @return
478         */
479        def exportColumnWiseDataToExcelFile(columnData, outputStream, useOfficeOpenXML = true) {
480
481                // transform data into row based structure for easy writing
482                def rows = convertColumnToRowStructure(columnData)
483
484                if (rows) {
485
486                        exportRowWiseDataToExcelFile(rows, outputStream, useOfficeOpenXML)
487
488                } else {
489
490                        throw new Exception('Wrong column data format.')
491
492                }
493
494        }
495
496        /**
497         * Export row wise data in Excel format to a stream
498         *
499         * @param rowData List of lists containing for each row all cell values
500         * @param outputStream Stream to write to
501         * @param useOfficeOpenXML Flag to specify xlsx (standard) or xls output
502         * @return
503         */
504        def exportRowWiseDataToExcelFile(rowData, outputStream, useOfficeOpenXML = true) {
505                Workbook wb = useOfficeOpenXML ? new XSSFWorkbook() : new HSSFWorkbook()
506                Sheet sheet = wb.createSheet()
507
508                exportRowWiseDataToExcelSheet( rowData, sheet );
509
510                wb.write(outputStream)
511                outputStream.close()
512        }
513
514        /**
515         * Export row wise data in CSV to a stream. All values are surrounded with
516     * double quotes (" ").
517         *
518         * @param rowData List of lists containing for each row all cell values
519         * @param outputStream Stream to write to
520         * @return
521         */
522        def exportRowWiseDataToCSVFile(rowData, outputStream, outputDelimiter = '\t') {
523
524        outputStream << rowData.collect { row ->
525          row.collect{
526
527              // omit quotes in case of numeric values
528              if (it instanceof Number) return it
529
530              def s = it?.toString() ?: ''
531
532              def addQuotes = false
533
534              // escape double quotes with double quotes if they exist and
535              // enable surround with quotes
536              if (s.contains('"')) {
537                  addQuotes = true
538                  s = s.replaceAll('"','""')
539              } else {
540                  // enable surround with quotes in case of comma's
541                  if (s.contains(',') || s.contains('\n')) addQuotes = true
542              }
543
544              addQuotes ? "\"$s\"" : s
545
546          }.join(outputDelimiter)
547        }.join('\n')
548
549                outputStream.close()
550        }
551
552        /**
553         * Export row wise data for multiple assays in Excel format (separate sheets) to a stream
554         *
555         * @param rowData       List of structures with rowwise data for each assay
556         * @param outputStream Stream to write to
557         * @param useOfficeOpenXML Flag to specify xlsx (standard) or xls output
558         * @return
559         */
560        def exportRowWiseDataForMultipleAssaysToExcelFile(assayData, outputStream, useOfficeOpenXML = true) {
561                Workbook wb = useOfficeOpenXML ? new XSSFWorkbook() : new HSSFWorkbook()
562
563                assayData.each { rowData ->
564                        Sheet sheet = wb.createSheet()
565
566                        exportRowWiseDataToExcelSheet( rowData, sheet );
567                }
568
569                wb.write(outputStream)
570                outputStream.close()
571        }
572
573        /**
574         * Export row wise data in Excel format to a given sheet in an excel workbook
575         *
576         * @param rowData       List of lists containing for each row all cell values
577         * @param sheet         Excel sheet to append the
578         * @return
579         */
580        def exportRowWiseDataToExcelSheet(rowData, Sheet sheet) {
581                // create all rows
582                rowData.size().times { sheet.createRow it }
583
584                sheet.eachWithIndex { Row row, ri ->
585                        if( rowData[ ri ] ) {
586                                // create appropriate number of cells for this row
587                                rowData[ri].size().times { row.createCell it }
588
589                                row.eachWithIndex { Cell cell, ci ->
590
591                                        // Numbers and values of type boolean, String, and Date can be
592                                        // written as is, other types need converting to String
593                                        def value = rowData[ri][ci]
594
595                                        value = (value instanceof Number | value?.class in [boolean.class, String.class, Date.class]) ? value : value?.toString()
596
597                                        // write the value (or an empty String if null) to the cell
598                                        cell.setCellValue(value ?: '')
599
600                                }
601                        }
602                }
603        }
604
605}
Note: See TracBrowser for help on using the browser.