source: trunk/grails-app/services/dbnp/studycapturing/AssayService.groovy @ 1936

Last change on this file since 1936 was 1936, checked in by robert@…, 11 years ago

Added trace statements in assay exporter

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