source: trunk/grails-app/controllers/dbnp/visualization/VisualizeController.groovy @ 2126

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

Solved ticket #519 with null values being retrieved from the modules

File size: 63.5 KB
Line 
1/**
2 * Visualize Controller
3 *
4 * This controller enables the user to visualize his data
5 *
6 * @author  robert@thehyve.nl
7 * @since       20110825
8 * @package     dbnp.visualization
9 *
10 * Revision information:
11 * $Rev$
12 * $Author$
13 * $Date$
14 */
15package dbnp.visualization
16
17import dbnp.studycapturing.*;
18import grails.converters.JSON
19
20import org.dbnp.gdt.*
21
22class VisualizeController {
23        def authenticationService
24        def moduleCommunicationService
25    def infoMessage = []
26    def offlineModules = []
27    def infoMessageOfflineModules = []
28    final int CATEGORICALDATA = 0
29    final int NUMERICALDATA = 1
30    final int RELTIME = 2
31    final int DATE = 3
32
33        /**
34         * Shows the visualization screen
35         */
36        def index = {
37                [ studies: Study.giveReadableStudies( authenticationService.getLoggedInUser() )]
38        }
39
40        def getStudies = {
41                def studies = Study.giveReadableStudies( authenticationService.getLoggedInUser() );
42        return sendResults(studies)
43        }
44
45        /**
46         * Based on the study id contained in the parameters given by the user, a list of 'fields' is returned. This list can be used to select what data should be visualized
47         * @return List containing fields
48     * @see parseGetDataParams
49         * @see getFields
50         */
51    def getFields = {
52                def input_object
53                def studies
54
55                try{
56                        input_object = parseGetDataParams();
57                } catch(Exception e) {
58                        log.error("VisualizationController: getFields: "+e)
59            return returnError(400, "An error occured while retrieving the user input.")
60                }
61
62        // Check to see if we have enough information
63        if(input_object==null || input_object?.studyIds==null){
64            setInfoMessage("Please select a study.")
65            return sendInfoMessage()
66        } else {
67            studies = input_object.studyIds[0]
68        }
69
70                def fields = [];
71
72        /*
73         Gather fields related to this study from GSCF.
74         This requires:
75         - a study.
76         - a category variable, e.g. "events".
77         - a type variable, either "domainfields" or "templatefields".
78         */
79        // TODO: Handle multiple studies
80        def study = Study.get(studies)
81
82        if(study!=null){
83            fields += getFields(study, "subjects", "domainfields")
84            fields += getFields(study, "subjects", "templatefields")
85            /*fields += getFields(study, "events", "domainfields")
86            fields += getFields(study, "events", "templatefields")*/
87            fields += getFields(study, "samplingEvents", "domainfields")
88            fields += getFields(study, "samplingEvents", "templatefields")
89            fields += getFields(study, "assays", "domainfields")
90            fields += getFields(study, "assays", "templatefields")
91            fields += getFields(study, "samples", "domainfields")
92            fields += getFields(study, "samples", "templatefields")
93
94                        // Also make sure the user can select eventGroup to visualize
95                        fields += formatGSCFFields( "domainfields", [ name: "name" ], "GSCF", "eventGroups" );
96                       
97            /*
98            Gather fields related to this study from modules.
99            This will use the getMeasurements RESTful service. That service returns measurement types, AKA features.
100            It does not actually return measurements (the getMeasurementData call does).
101            The getFields method (or rather, the getMeasurements service) requires one or more assays and will return all measurement
102            types related to these assays.
103            So, the required variables for such a call are:
104              - a source variable, which can be obtained from AssayModule.list() (use the 'name' field)
105              - an assay, which can be obtained with study.getAssays()
106             */
107            study.getAssays().each { assay ->
108                def list = []
109                if(!offlineModules.contains(assay.module.id)){
110                    list = getFields(assay.module.toString(), assay)
111                    if(list!=null){
112                        if(list.size()!=0){
113                            fields += list
114                        }
115                    }
116                }
117            }
118            offlineModules = []
119
120            // Make sure any informational messages regarding offline modules are submitted to the client
121            setInfoMessageOfflineModules()
122
123
124            // TODO: Maybe we should add study's own fields
125        } else {
126            log.error("VisualizationController: getFields: The requested study could not be found. Id: "+studies)
127            return returnError(404, "The requested study could not be found.")
128        }
129
130        fields.unique() // Todo: find out root cause of why some fields occur more than once
131        fields.sort { a, b ->
132            def sourceEquality = a.source.toString().toLowerCase().compareTo(b.source.toString().toLowerCase())
133            if( sourceEquality == 0 ) {
134                def categoryEquality = a.category.toString().toLowerCase().compareTo(b.category.toString().toLowerCase())
135                if( categoryEquality == 0 ){
136                    a.name.toString().toLowerCase().compareTo(b.name.toString().toLowerCase())
137                } else return categoryEquality
138            } else return sourceEquality
139        }
140                return sendResults(['studyIds': studies, 'fields': fields])
141        }
142
143        /**
144         * Based on the field ids contained in the parameters given by the user, a list of possible visualization types is returned. This list can be used to select how data should be visualized.
145         * @return List containing the possible visualization types, with each element containing
146     *          - a unique id
147     *          - a unique name
148     *         For example: ["id": "barchart", "name": "Barchart"]
149     * @see parseGetDataParams
150         * @see determineFieldType
151     * @see determineVisualizationTypes
152         */
153        def getVisualizationTypes = {
154        def inputData = parseGetDataParams();
155
156        if(inputData.columnIds == null || inputData.columnIds == [] || inputData.columnIds[0] == null || inputData.columnIds[0] == ""){
157            setInfoMessage("Please select a data source for the x-axis.")
158            return sendInfoMessage()
159        }
160        if(inputData.rowIds == null || inputData.rowIds == [] ||  inputData.rowIds[0] == null ||   inputData.rowIds[0] == ""){
161            setInfoMessage("Please select a data source for the y-axis.")
162            return sendInfoMessage()
163        }
164
165        // TODO: handle the case of multiple fields on an axis
166        // Determine data types
167        log.trace "Determining rowType: "+inputData.rowIds[0]
168        def rowType = determineFieldType(inputData.studyIds[0], inputData.rowIds[0])
169       
170                log.trace "Determining columnType: "+inputData.columnIds[0]
171        def columnType = determineFieldType(inputData.studyIds[0], inputData.columnIds[0])
172
173                log.trace "Determining groupType: "+inputData.groupIds[0]
174                def groupType = determineFieldType(inputData.studyIds[0], inputData.groupIds[0])
175               
176                        // Determine possible visualization- and aggregationtypes
177        def visualizationTypes = determineVisualizationTypes(rowType, columnType)
178                def aggregationTypes = determineAggregationTypes(rowType, columnType, groupType)
179               
180        log.trace  "visualization types: " + visualizationTypes + ", determined this based on "+rowType+" and "+columnType
181                log.trace  "aggregation   types: " + aggregationTypes + ", determined this based on "+rowType+" and "+columnType + " and " + groupType
182               
183                def fieldData = [ 'x': parseFieldId( inputData.columnIds[ 0 ] ), 'y': parseFieldId( inputData.rowIds[ 0 ] ) ];
184               
185        return sendResults([
186                        'types': visualizationTypes,
187                        'aggregations': aggregationTypes,
188                       
189                        // TODO: Remove these ids when the view has been updated. Use xaxis.id and yaxis.id instead
190                        'rowIds':inputData.rowIds[0],
191                        'columnIds':inputData.columnIds[0],
192                       
193                        'xaxis': [ 
194                                'id': fieldData.x.fieldId,
195                                'name': fieldData.x.name,
196                                'unit': fieldData.x.unit,
197                                'type': dataTypeString( columnType )
198                        ],
199                        'yaxis': [
200                                'id': fieldData.y.fieldId,
201                                'name': fieldData.y.name,
202                                'unit': fieldData.y.unit,
203                                'type': dataTypeString( rowType )
204                        ],
205
206                ])
207        }
208
209    /**
210     * Gather fields related to this study from modules.
211        This will use the getMeasurements RESTful service. That service returns measurement types, AKA features.
212        getMeasurements does not actually return measurements (the getMeasurementData call does).
213     * @param source    The id of the module that is the source of the requested fields, as can be obtained from AssayModule.list() (use the 'id' field)
214     * @param assay     The assay that the source module and the requested fields belong to
215     * @return  A list of map objects, containing the following:
216     *           - a key 'id' with a value formatted by the createFieldId function
217     *           - a key 'source' with a value equal to the input parameter 'source'
218     *           - a key 'category' with a value equal to the 'name' field of the input paramater 'assay'
219     *           - a key 'name' with a value equal to the name of the field in question, as determined by the source value
220     */
221    def getFields(source, assay) {
222        def fields = []
223        def callUrl = ""
224
225        // Making a different call for each assay
226        def urlVars = "assayToken="+assay.assayUUID
227        try {
228            callUrl = ""+assay.module.url + "/rest/getMeasurementMetaData/query?"+urlVars
229            def json = moduleCommunicationService.callModuleRestMethodJSON( assay.module.url /* consumer */, callUrl );
230
231            def collection = []
232            json.each{ jason ->
233                collection.add(jason)
234            }
235            // Formatting the data
236            collection.each { field ->
237                // For getting this field from this assay
238                fields << [ "id": createFieldId( id: field.name, name: field.name, source: ""+assay.id, type: ""+assay.name, unit: (field.unit?:"")), "source": source, "category": ""+assay.name, "name": field.name + (field.unit?" ("+field.unit+")":"")  ]
239            }
240        } catch(Exception e){
241            //returnError(404, "An error occured while trying to collect field data from a module. Most likely, this module is offline.")
242            offlineModules.add(assay.module.id)
243            infoMessageOfflineModules.add(assay.module.name)
244            log.error("VisualizationController: getFields: "+e)
245        }
246
247        return fields
248    }
249
250    /**
251     * Gather fields related to this study from GSCF.
252     * @param study The study that is the source of the requested fields
253     * @param category  The domain that a field (a property in this case) belongs to, e.g. "subjects", "samplingEvents"
254     * @param type A string that indicates the type of field, either "domainfields" or "templatefields".
255     * @return A list of map objects, formatted by the formatGSCFFields function
256     */
257    def getFields(study, category, type){
258        // Collecting the data from it's source
259        def collection = []
260        def fields = []
261        def source = "GSCF"
262               
263                if( type == "domainfields" ) 
264                        collection = domainObjectCallback( category )?.giveDomainFields();
265                else
266                        collection = templateObjectCallback( category, study )?.template?.fields
267
268        collection?.unique()
269
270        // Formatting the data
271        fields += formatGSCFFields(type, collection, source, category)
272
273        // Here we will remove those fields, whose set of datapoints only contain null
274        def fieldsToBeRemoved = []
275        fields.each{ field ->
276            def fieldData = getFieldData( study, study.samples, field.id )
277            fieldData.removeAll([null])
278            if(fieldData==[]){
279                // Field only contained nulls, so don't show it as a visualization option
280                fieldsToBeRemoved << field
281            }
282        }
283        fields.removeAll(fieldsToBeRemoved)
284
285        return fields
286    }
287
288    /**
289     * Format the data contained in the input parameter 'collection' for use as so-called fields, that will be used by the user interface to allow the user to select data from GSCF for visualization
290     * @param type A string that indicates the type of field, either "domainfields" or "templatefields".
291     * @param collectionOfFields A collection of fields, which could also contain only one item
292     * @param source Likely to be "GSCF"
293     * @param category The domain that a field (a property in this case) belongs to, e.g. "subjects", "samplingEvents"
294     * @return A list containing list objects, containing the following:
295     *           - a key 'id' with a value formatted by the createFieldId function
296     *           - a key 'source' with a value equal to the input parameter 'source'
297     *           - a key 'category' with a value equal to the input parameter 'category'
298     *           - a key 'name' with a value equal to the name of the field in question, as determined by the source value
299     */
300    def formatGSCFFields(type, collectionOfFields, source, category){
301
302        if(collectionOfFields==null || collectionOfFields == []){
303            return []
304        }
305        def fields = []
306        if(collectionOfFields instanceof Collection){
307            // Apparently this field is actually a list of fields.
308            // We will call ourselves again with the list's elements as input.
309            // These list elements will themselves go through this check again, effectively flattening the original input
310            for(int i = 0; i < collectionOfFields.size(); i++){
311                fields += formatGSCFFields(type, collectionOfFields[i], source, category)
312            }
313            return fields
314        } else {
315            // This is a single field. Format it and return the result.
316            if(type=="domainfields"){
317                fields << [ "id": createFieldId( id: collectionOfFields.name, name: collectionOfFields.name, source: source, type: category, unit: (collectionOfFields.unit?:"") ), "source": source, "category": category, "name": collectionOfFields.name + (collectionOfFields.unit?" ("+collectionOfFields.unit+")":"") ]
318            }
319            if(type=="templatefields"){
320                fields << [ "id": createFieldId( id: collectionOfFields.id.toString(), name: collectionOfFields.name, source: source, type: category, unit: (collectionOfFields.unit?:"") ), "source": source, "category": category, "name": collectionOfFields.name + (collectionOfFields.unit?" ("+collectionOfFields.unit+")":"")]
321            }
322            return fields
323        }
324    }
325
326        /**
327         * Retrieves data for the visualization itself.
328     * Returns, based on the field ids contained in the parameters given by the user, a map containing the actual data and instructions on how the data should be visualized.
329     * @return A map containing containing (at least, in the case of a barchart) the following:
330     *           - a key 'type' containing the type of chart that will be visualized
331     *           - a key 'xaxis' containing the title and unit that should be displayed for the x-axis
332     *           - a key 'yaxis' containing the title and unit that should be displayed for the y-axis*
333     *           - a key 'series' containing a list, that contains one or more maps, which contain the following:
334     *                - a key 'name', containing, for example, a feature name or field name
335     *                - a key 'y', containing a list of y-values
336     *                - a key 'error', containing a list of, for example, standard deviation or standard error of the mean values, each having the same index as the 'y'-values they are associated with
337         */
338        def getData = {
339                // Extract parameters
340                // TODO: handle erroneous input data
341                def inputData = parseGetDataParams();
342
343        if(inputData.columnIds == null || inputData.rowIds == null){
344            infoMessage = "Please select data sources for the y- and x-axes."
345            return sendInfoMessage()
346        }
347
348                // TODO: handle the case that we have multiple studies
349                def studyId = inputData.studyIds[ 0 ];
350                def study = Study.get( studyId as Integer );
351
352                // Find out what samples are involved
353                def samples = study.samples
354
355        // If the user is requesting data that concerns only subjects, then make sure those subjects appear only once
356        if(parseFieldId( inputData.columnIds[ 0 ] ).type=='subjects' && parseFieldId( inputData.rowIds[ 0 ] ).type=='subjects'){
357            samples.unique { it.parentSubject }
358        }
359       
360                // Retrieve the data for both axes for all samples
361                // TODO: handle the case of multiple fields on an axis
362                def fields = [ "x": inputData.columnIds[ 0 ], "y": inputData.rowIds[ 0 ], "group": inputData.groupIds[ 0 ] ];
363                def fieldInfo = [:]
364                fields.each { 
365                        fieldInfo[ it.key ] = parseFieldId( it.value ) 
366                       
367                        if( fieldInfo[ it.key ] )
368                                fieldInfo[ it.key ].fieldType = determineFieldType( study.id, it.value );
369                }
370               
371                // If the groupAxis is numerical, we should ignore it, unless a table is asked for
372                if( fieldInfo.group && fieldInfo.group.fieldType == NUMERICALDATA && inputData.visualizationType != "table" ) {
373                        fields.group = null;
374                        fieldInfo.group = null;
375                }
376               
377                // Fetch all data from the system. data will be in the format:
378                //              [ "x": [ 3, 6, null, 10 ], "y": [ "male", "male", "female", "female" ], "group": [ "US", "NL", "NL", "NL" ]
379                //      If a field is not given, the data will be NULL
380                def data = getAllFieldData( study, samples, fields );
381
382                // Aggregate the data based on the requested aggregation 
383                def aggregatedData = aggregateData( data, fieldInfo, inputData.aggregation );
384               
385                println "Aggregated Data: "
386                aggregatedData.each { println it }
387               
388                // No convert the aggregated data into a format we can use
389                def returnData = formatData( inputData.visualizationType, aggregatedData, fieldInfo );
390
391                println "Returndata: " 
392                returnData.each { println it }
393               
394        // Make sure no changes are written to the database
395        study.discard()
396        samples*.discard()
397       
398        return sendResults(returnData)
399        }
400
401        /**
402         * Parses the parameters given by the user into a proper list
403         * @return Map with 4 keys:
404         *              studyIds:       list with studyIds selected
405         *              rowIds:         list with fieldIds selected for the rows
406         *              columnIds:      list with fieldIds selected for the columns
407         *              visualizationType:      String with the type of visualization required
408         * @see getFields
409         * @see getVisualizationTypes
410         */
411        def parseGetDataParams() {
412                def studyIds = params.list( 'study' );
413                def rowIds = params.list( 'rows' );
414                def columnIds = params.list( 'columns' );
415                def groupIds = params.list( 'groups' ); 
416                def visualizationType = params.get( 'types');
417                def aggregation = params.get( 'aggregation' );
418
419                return [ "studyIds" : studyIds, "rowIds": rowIds, "columnIds": columnIds, "groupIds": groupIds, "visualizationType": visualizationType, "aggregation": aggregation ];
420        }
421
422        /**
423         * Retrieve the field data for the selected fields
424         * @param study         Study for which the data should be retrieved
425         * @param samples       Samples for which the data should be retrieved
426         * @param fields        Map with key-value pairs determining the name and fieldId to retrieve data for. Example:
427         *                                              [ "x": "field-id-1", "y": "field-id-3", "group": "field-id-6" ]
428         * @return                      A map with the same keys as the input fields. The values in the map are lists of values of the
429         *                                      selected field for all samples. If a value could not be retrieved for a sample, null is returned. Example:
430         *                                              [ "numValues": 4, "x": [ 3, 6, null, 10 ], "y": [ "male", "male", "female", "female" ], "group": [ "US", "NL", "NL", "NL" ] ]
431         */
432        def getAllFieldData( study, samples, fields ) {
433                def fieldData = [:]
434                def numValues = 0;
435                fields.each{ field ->
436                        def fieldId = field.value ?: null;
437                        fieldData[ field.key ] = getFieldData( study, samples, fieldId );
438                       
439                        if( fieldData[ field.key ] )
440                                numValues = Math.max( numValues, fieldData[ field.key ].size() );
441                }
442               
443                fieldData.numValues = numValues;
444               
445                return fieldData;
446        }
447       
448        /**
449        * Retrieve the field data for the selected field
450        * @param study          Study for which the data should be retrieved
451        * @param samples        Samples for which the data should be retrieved
452        * @param fieldId        ID of the field to return data for
453        * @return                       A list of values of the selected field for all samples. If a value
454        *                                       could not be retrieved for a sample, null is returned. Examples:
455        *                                               [ 3, 6, null, 10 ] or [ "male", "male", "female", "female" ]
456        */
457        def getFieldData( study, samples, fieldId ) {
458                if( !fieldId )
459                        return null
460                       
461                // Parse the fieldId as given by the user
462                def parsedField = parseFieldId( fieldId );
463               
464                def data = []
465               
466                if( parsedField.source == "GSCF" ) {
467                        // Retrieve data from GSCF itself
468                        def closure = valueCallback( parsedField.type )
469
470                        if( closure ) {
471                                samples.each { sample ->
472                                        // Retrieve the value for the selected field for this sample
473                                        def value = closure( sample, parsedField.name );
474
475                    data << value;
476                                }
477                        } else {
478                                // TODO: Handle error properly
479                                // Closure could not be retrieved, probably because the type is incorrect
480                                data = samples.collect { return null }
481                log.error("VisualizationController: getFieldData: Requested wrong field type: "+parsedField.type+". Parsed field: "+parsedField)
482                        }
483                } else {
484                        // Data must be retrieved from a module
485                        data = getModuleData( study, samples, parsedField.source, parsedField.name );
486                }
487               
488                return data
489        }
490       
491        /**
492         * Retrieve data for a given field from a data module
493         * @param study                 Study to retrieve data for
494         * @param samples               Samples to retrieve data for
495         * @param source_module Name of the module to retrieve data from
496         * @param fieldName             Name of the measurement type to retrieve (i.e. measurementToken)
497         * @return                              A list of values of the selected field for all samples. If a value
498         *                                              could not be retrieved for a sample, null is returned. Examples:
499         *                                                      [ 3, 6, null, 10 ] or [ "male", "male", "female", "female" ]
500         */
501        def getModuleData( study, samples, assay_id, fieldName ) {
502                def data = []
503               
504                // TODO: Handle values that should be retrieved from multiple assays
505        def assay = Assay.get(assay_id);
506
507        if( assay ) {
508            // Request for a particular assay and a particular feature
509            def urlVars = "assayToken=" + assay.assayUUID + "&measurementToken="+fieldName.encodeAsURL()
510            urlVars += "&" + samples.collect { "sampleToken=" + it.sampleUUID }.join( "&" );
511
512            def callUrl
513            try {
514                callUrl = assay.module.url + "/rest/getMeasurementData"
515                def json = moduleCommunicationService.callModuleMethod( assay.module.url, callUrl, urlVars, "POST" );
516
517                if( json ) {
518                    // First element contains sampletokens
519                    // Second element contains the featurename
520                    // Third element contains the measurement value
521                    def sampleTokens = json[ 0 ]
522                    def measurements = json[ 2 ]
523
524                    // Loop through the samples
525                    samples.each { sample ->
526                        // Search for this sampletoken
527                        def sampleToken = sample.sampleUUID;
528                        def index = sampleTokens.findIndexOf { it == sampleToken }
529                                               
530                                                // Store the measurement value if found and if it is not JSONObject$Null
531                                                // See http://grails.1312388.n4.nabble.com/The-groovy-truth-of-JSONObject-Null-td3661040.html
532                                                // for this comparison
533                        if( index > -1  && !measurements[ index ].equals( null ) ) {
534                            data << measurements[ index ];
535                        } else {
536                            data << null
537                        }
538                    }
539                } else {
540                    // TODO: handle error
541                    // Returns an empty list with as many elements as there are samples
542                    data = samples.collect { return null }
543                }
544
545            } catch(Exception e){
546                log.error("VisualizationController: getFields: "+e)
547                //return returnError(404, "An error occured while trying to collect data from a module. Most likely, this module is offline.")
548                return returnError(404, "Unfortunately, "+assay.module.name+" could not be reached. As a result, we cannot at this time visualize data contained in this module.")
549            }
550        } else {
551            // TODO: Handle error correctly
552            // Returns an empty list with as many elements as there are samples
553            data = samples.collect { return null }
554        }
555
556        //println "\t data request: "+data
557                return data
558        }
559       
560        /**
561         * Aggregates the data based on the requested aggregation on the categorical fields
562         * @param data                  Map with data for each dimension as retrieved using getAllFieldData. For example:
563         *                                                      [ "x": [ 3, 6, 8, 10 ], "y": [ "male", "male", "female", "female" ], "group": [ "US", "NL", "NL", "NL" ] ]
564         * @param fieldInfo             Map with field information for each dimension. For example:
565         *                                                      [ "x": [ id: "abc", "type": NUMERICALDATA ], "y": [ "id": "def", "type": CATEGORICALDATA ] ]
566         * @param aggregation   Kind of aggregation requested
567         * @return                              Data that is aggregated on the categorical fields
568         *                                                      [ "x": [ 3, 6, null, 9 ], "y": [ "male", "male", "female", "female" ], "group": [ "US", "NL", "US", "NL" ] ]
569         *
570         */
571        def aggregateData( data, fieldInfo, aggregation ) {
572                // If no aggregation is requested, we just return the original object (but filtering out null values)
573                if( aggregation == "none" ) {
574                        def filteredData = [:];
575                       
576                        // Create a copy of the data object
577                        data.each { 
578                                filteredData[ it.key ] = it.value;
579                        }
580                       
581                        // Loop through all values and return all null-values (and the same indices on other elements
582                        fieldInfo.keySet().each { fieldName ->
583                                // If values are found, do filtering. If not, skip this one
584                                if( fieldInfo[ fieldName ] != null ) {
585                                        // Find all non-null values in this list
586                                        def indices = filteredData[ fieldName ].findIndexValues { it != null };
587                                       
588                                        // Remove the non-null values from each data object
589                                        filteredData.each { key, value ->
590                                                if( value && value instanceof Collection )
591                                                        filteredData[ key ] = value[ indices ];
592                                        }
593                                }
594                        }
595                         
596                        return filteredData
597                }
598               
599                // Determine the categorical fields
600                def dimensions = [ "categorical": [], "numerical": [] ];
601                fieldInfo.each { 
602                        // If fieldInfo value is NULL, the field is not requested
603                        if( it && it.value ) {
604                                if( [ CATEGORICALDATA, RELTIME, DATE ].contains( it.value.fieldType ) ) {
605                                        dimensions.categorical << it.key
606                                } else {
607                                        dimensions.numerical << it.key
608                                }
609                        }
610                }
611               
612                // Compose a map with aggregated data
613                def aggregatedData = [:];
614                fieldInfo.each { aggregatedData[ it.key ] = [] }
615               
616                // Loop through all categorical fields and aggregate the values for each combination
617                if( dimensions.categorical.size() > 0 ) {
618                        return aggregate( data, dimensions.categorical, dimensions.numerical, aggregation, fieldInfo );
619                } else {
620                        // No categorical dimensions. Just compute the aggregation for all values together
621                        def returnData = [ "count": [ data.numValues ] ];
622                 
623                        // Now compute the correct aggregation for each numerical dimension.
624                        dimensions.numerical.each { numericalDimension ->
625                                def currentData = data[ numericalDimension ];
626                                returnData[ numericalDimension ] = [ computeAggregation( aggregation, currentData ).value ];
627                        }
628                       
629                        return returnData;
630                }
631        }
632       
633        /**
634         * Aggregates the given data on the categorical dimensions.
635         * @param data                                  Initial data
636         * @param categoricalDimensions List of categorical dimensions to group  by
637         * @param numericalDimensions   List of all numerical dimensions to compute the aggregation for
638         * @param aggregation                   Type of aggregation requested
639         * @param fieldInfo                             Information about the fields requested by the user      (e.g. [ "x": [ "id": 1, "fieldType": CATEGORICALDATA ] ] )
640         * @param criteria                              The criteria the current aggregation must keep (e.g. "x": "male")
641         * @param returnData                    Initial return object with the same keys as the data object, plus 'count'
642         * @return
643         */
644        protected def aggregate( Map data, Collection categoricalDimensions, Collection numericalDimensions, String aggregation, fieldInfo, criteria = [:], returnData = null ) {
645                if( !categoricalDimensions )
646                        return data;
647                       
648                // If no returndata is given, initialize the map
649                if( returnData == null ) {
650                        returnData = [ "count": [] ]
651                        data.each { returnData[ it.key ] = [] }
652                }
653               
654                def dimension = categoricalDimensions.head();
655               
656                // Determine the unique values on the categorical axis and sort by toString method
657                def unique = data[ dimension ].flatten()
658                                        .unique { it == null ? "null" : it.class.name + it.toString() }
659                                        .sort {
660                                                // Sort categoricaldata on its string value, but others (numerical, reltime, date)
661                                                // on its real value
662                                                switch( fieldInfo[ dimension ].fieldType ) {
663                                                        case CATEGORICALDATA:
664                                                                return it.toString()
665                                                        default:
666                                                                return it
667                                                } 
668                                        };
669                                       
670                // Make sure the null category is last
671                unique = unique.findAll { it != null } + unique.findAll { it == null }
672               
673                unique.each { el ->
674                        // Use this element to search on
675                        criteria[ dimension ] = el;
676                       
677                        // If the list of categoricalDimensions is empty after this dimension, do the real work
678                        if( categoricalDimensions.size() == 1 ) {
679                                // Search for all elements in the numericaldimensions that belong to the current group
680                                // The current group is defined by the criteria object
681                               
682                                // We start with all indices belonging to this group
683                                def indices = 0..data.numValues;
684                                criteria.each { criterion ->
685                                        // Find the indices of the samples that belong to this group. if a sample belongs to multiple groups (i.e. if
686                                        // the samples groupAxis contains multiple values, is a collection), the value should be used in all groups.
687                                        def currentIndices = data[ criterion.key ].findIndexValues { it instanceof Collection ? it.contains( criterion.value ) : it == criterion.value };
688                                        indices = indices.intersect( currentIndices );
689                                       
690                                        // Store the value for the criterion in the returnData object
691                                        returnData[ criterion.key ] << criterion.value;
692                                }
693                               
694                                // If no numericalDimension is asked for, no aggregation is possible. For that reason, we
695                                // also return counts
696                                returnData[ "count" ] << indices.size();
697                                 
698                                // Now compute the correct aggregation for each numerical dimension.
699                                numericalDimensions.each { numericalDimension ->
700                                        def currentData = data[ numericalDimension ][ indices ]; 
701                                        returnData[ numericalDimension ] << computeAggregation( aggregation, currentData ).value;
702                                }
703                               
704                        } else {
705                                returnData = aggregate( data, categoricalDimensions.tail(), numericalDimensions, aggregation, fieldInfo, criteria, returnData );
706                        }
707                }
708               
709                return returnData;
710        }
711       
712        /**
713         * Compute the aggregation for a list of values
714         * @param aggregation
715         * @param currentData
716         * @return
717         */
718        def computeAggregation( String aggregation, List currentData ) {
719                switch( aggregation ) {
720                        case "count":
721                                return computeCount( currentData );
722                                break;
723                        case "median":
724                                return computeMedian( currentData );
725                                break;
726                        case "sum":
727                                return computeSum( currentData );
728                                break;
729                        case "average":
730                        default:
731                                // Default is "average"
732                                return computeMeanAndError( currentData );
733                                break;
734                }
735        }
736
737        /**
738         * Formats the grouped data in such a way that the clientside visualization method
739         * can handle the data correctly.
740         * @param groupedData   Data that has been grouped using the groupFields method
741         * @param fieldData             Map with key-value pairs determining the name and fieldId to retrieve data for. Example:
742         *                                                      [ "x": { "id": ... }, "y": { "id": "field-id-3" }, "group": { "id": "field-id-6" } ]
743         * @param errorName             Key in the output map where 'error' values (SEM) are stored. Defaults to "error"         *
744         * @return                              A map like the following:
745         *
746                        {
747                                "type": "barchart",
748                                "xaxis": { "title": "quarter 2011", "unit": "" },
749                                "yaxis": { "title": "temperature", "unit": "degrees C" },
750                                "series": [
751                                        {
752                                                "name": "series name",
753                                                "y": [ 5.1, 3.1, 20.6, 15.4 ],
754                        "x": [ "Q1", "Q2", "Q3", "Q4" ],
755                                                "error": [ 0.5, 0.2, 0.4, 0.5 ]
756                                        },
757                                ]
758                        }
759         *
760         */
761        def formatData( type, groupedData, fieldInfo, xAxis = "x", yAxis = "y", serieAxis = "group", errorName = "error" ) {
762                // Format categorical axes by setting the names correct
763                fieldInfo.each { field, info ->
764                        if( field && info ) {
765                                groupedData[ field ] = renderFieldsHumanReadable( groupedData[ field ], info.fieldType)
766                        }
767                }
768               
769                // TODO: Handle name and unit of fields correctly
770                def xAxisTypeString = dataTypeString( fieldInfo[ xAxis ]?.fieldType )
771                def yAxisTypeString = dataTypeString( fieldInfo[ yAxis ]?.fieldType )
772                def serieAxisTypeString = dataTypeString( fieldInfo[ serieAxis ]?.fieldType )
773               
774                // Create a return object
775                def return_data = [:]
776                return_data[ "type" ] = type
777                return_data.put("xaxis", ["title" : fieldInfo[ xAxis ]?.name, "unit": fieldInfo[ xAxis ]?.unit, "type": xAxisTypeString ])
778                return_data.put("yaxis", ["title" : fieldInfo[ yAxis ]?.name, "unit" : fieldInfo[ yAxis ]?.unit, "type": yAxisTypeString ])
779                return_data.put("groupaxis", ["title" : fieldInfo[ serieAxis ]?.name, "unit" : fieldInfo[ serieAxis ]?.unit, "type": serieAxisTypeString ])
780               
781                if(type=="table"){
782                        // Determine the lists on both axes. The strange addition is done because the unique() method
783                        // alters the object itself, instead of only returning a unique list
784                        def xAxisData = ([] + groupedData[ xAxis ]).unique()
785                        def yAxisData = ([] + groupedData[ yAxis ]).unique()
786
787                        if( !fieldInfo[ serieAxis ] ) {
788                                // If no value has been chosen on the serieAxis, we should show the counts for only one serie
789                                def tableData = formatTableData( groupedData, xAxisData, yAxisData, xAxis, yAxis, "count" );
790                               
791                                return_data.put("series", [[
792                                        "name": "count",
793                                        "x": xAxisData,
794                                        "y": yAxisData,
795                                        "data": tableData
796                                ]])
797                        } else if( fieldInfo[ serieAxis ].fieldType == NUMERICALDATA ) {
798                                // If no value has been chosen on the serieAxis, we should show the counts for only one serie
799                                def tableData = formatTableData( groupedData, xAxisData, yAxisData, xAxis, yAxis, serieAxis );
800
801                                // If a numerical field has been chosen on the serieAxis, we should show the requested aggregation
802                                // for only one serie
803                                return_data.put("series", [[
804                                        "name": fieldInfo[ xAxis ].name,
805                                        "x": xAxisData,
806                                        "y": yAxisData,
807                                        "data": tableData
808                                ]])
809                        } else {
810                                // If a categorical field has been chosen on the serieAxis, we should create a table for each serie
811                                // with counts as data. That table should include all data for that serie
812                                return_data[ "series" ] = [];
813                               
814                                // The strange addition is done because the unique() method
815                                // alters the object itself, instead of only returning a unique list
816                                def uniqueSeries = ([] + groupedData[ serieAxis ]).unique();
817                               
818                                uniqueSeries.each { serie -> 
819                                        def indices = groupedData[ serieAxis ].findIndexValues { it == serie }
820                                       
821                                        // If no value has been chosen on the serieAxis, we should show the counts for only one serie
822                                        def tableData = formatTableData( groupedData, xAxisData, yAxisData, xAxis, yAxis, "count", indices );
823       
824                                        return_data[ "series" ] << [
825                                                "name": serie,
826                                                "x": xAxisData,
827                                                "y": yAxisData,
828                                                "data": tableData,
829                                        ]
830                                }
831                        }
832                       
833                } else {
834                        // For a horizontal barchart, the two axes should be swapped
835                        if( type == "horizontal_barchart" ) {
836                                def tmp = xAxis
837                                xAxis = yAxis
838                                yAxis = tmp
839                        }
840               
841                        if( !fieldInfo[ serieAxis ] ) {
842                                // If no series field has defined, we return all data in one serie
843                                return_data.put("series", [[
844                                        "name": "count",
845                                        "x": groupedData[ xAxis ],
846                                        "y": groupedData[ yAxis ],
847                                ]])
848                        } else if( fieldInfo[ serieAxis ].fieldType == NUMERICALDATA ) {
849                                // No numerical series field is allowed in a chart.
850                                throw new Exception( "No numerical series field is allowed here." );
851                        } else {
852                                // If a categorical field has been chosen on the serieAxis, we should create a group for each serie
853                                // with the correct values, belonging to that serie.
854                                return_data[ "series" ] = [];
855                               
856                                // The unique method alters the original object, so we
857                                // create a new object
858                                def uniqueSeries = ([] + groupedData[ serieAxis ]).unique();
859                               
860                                uniqueSeries.each { serie ->
861                                        def indices = groupedData[ serieAxis ].findIndexValues { it == serie }
862                                        return_data[ "series" ] << [
863                                                "name": serie,
864                                                "x": groupedData[ xAxis ][ indices ],
865                                                "y": groupedData[ yAxis ][ indices ]
866                                        ]
867                                }
868                        }
869                }
870               
871                return return_data;
872        }
873
874        /**
875         * Formats the requested data for a table       
876         * @param groupedData
877         * @param xAxisData
878         * @param yAxisData
879         * @param xAxis
880         * @param yAxis
881         * @param dataAxis
882         * @return
883         */
884        def formatTableData( groupedData, xAxisData, yAxisData, xAxis, yAxis, dataAxis, serieIndices = null ) {
885                def tableData = []
886               
887                xAxisData.each { x ->
888                        def colData = []
889                       
890                        def indices = groupedData[ xAxis ].findIndexValues { it == x }
891                       
892                        // If serieIndices are given, intersect the indices
893                        if( serieIndices != null )
894                                indices = indices.intersect( serieIndices );
895                       
896                        yAxisData.each { y ->
897                                def index = indices.intersect( groupedData[ yAxis ].findIndexValues { it == y } );
898                               
899                                if( index.size() ) {
900                                        colData << groupedData[ dataAxis ][ (int) index[ 0 ] ]
901                                }
902                        }
903                        tableData << colData;
904                }
905               
906                return tableData;
907        }
908
909    /**
910     * If the input variable 'data' contains dates or times according to input variable 'fieldInfo', these dates and times are converted to a human-readable version.
911     * @param data  The list of items that needs to be checked/converted
912     * @param axisType As determined by determineFieldType
913     * @return The input variable 'data', with it's date and time elements converted.
914     * @see determineFieldType
915     */
916    def renderFieldsHumanReadable(data, axisType){
917        switch( axisType ) {
918                        case RELTIME:
919                                return renderTimesHumanReadable(data)
920                        case DATE:
921                                return renderDatesHumanReadable(data)
922                        case CATEGORICALDATA:
923                                return data.collect { it.toString() }
924                        case NUMERICALDATA:
925                        default:
926                                return data;
927                }
928    }
929
930    /**
931     * Takes a one-dimensional list, returns the list with the appropriate items converted to a human readable string
932     * @param data
933     * @return
934     */
935    def renderTimesHumanReadable(data){
936        def tmpTimeContainer = []
937        data. each {
938            if(it instanceof Number) {
939                try{
940                    tmpTimeContainer << new RelTime( it ).toPrettyString()
941                } catch(IllegalArgumentException e){
942                    tmpTimeContainer << it
943                }
944            } else {
945                tmpTimeContainer << it // To handle items such as 'unknown'
946            }
947        }
948        return tmpTimeContainer
949    }
950
951    /**
952     * Takes a one-dimensional list, returns the list with the appropriate items converted to a human readable string
953     * @param data
954     * @return
955     */
956    def renderDatesHumanReadable(data) {
957        def tmpDateContainer = []
958        data. each {
959            if(it instanceof Number) {
960                try{
961                    tmpDateContainer << new java.util.Date( (Long) it ).toString()
962                } catch(IllegalArgumentException e){
963                    tmpDateContainer << it
964                }
965            } else {
966                tmpDateContainer << it // To handle items such as 'unknown'
967            }
968        }
969        return tmpDateContainer
970    }
971        /**
972         * Returns a closure for the given entitytype that determines the value for a criterion
973         * on the given object. The closure receives two parameters: the sample and a field.
974         *
975         * For example:
976         *              How can one retrieve the value for subject.name, given a sample? This can be done by
977         *              returning the field values sample.parentSubject:
978         *                      { sample, field -> return getFieldValue( sample.parentSubject, field ) }
979         * @return      Closure that retrieves the value for a field and the given field
980         */
981        protected Closure valueCallback( String entity ) {
982                switch( entity ) {
983                        case "Study":
984                        case "studies":
985                                return { sample, field -> return getFieldValue( sample.parent, field ) }
986                        case "Subject":
987                        case "subjects":
988                                return { sample, field -> return getFieldValue( sample.parentSubject, field ); }
989                        case "Sample":
990                        case "samples":
991                                return { sample, field -> return getFieldValue( sample, field ) }
992                        case "Event":
993                        case "events":
994                                return { sample, field ->
995                                        if( !sample || !sample.parentEventGroup || !sample.parentEventGroup.events || sample.parentEventGroup.events.size() == 0 )
996                                                return null
997
998                                        return sample.parentEventGroup.events?.collect { getFieldValue( it, field ) };
999                                }
1000                        case "EventGroup":
1001                        case "eventGroups":
1002                                return { sample, field ->
1003                                        if( !sample || !sample.parentEventGroup )
1004                                                return null
1005
1006                                        // For eventgroups only the name is supported
1007                                        if( field == "name" )
1008                                                return sample.parentEventGroup.name
1009                                        else
1010                                                return null 
1011                                }
1012       
1013                        case "SamplingEvent":
1014                        case "samplingEvents":
1015                                return { sample, field -> return getFieldValue( sample.parentEvent, field ); }
1016                        case "Assay":
1017                        case "assays":
1018                                return { sample, field ->
1019                                        def sampleAssays = Assay.findByParent( sample.parent ).findAll { it.samples?.contains( sample ) };
1020                                        if( sampleAssays && sampleAssays.size() > 0 )
1021                                                return sampleAssays.collect { getFieldValue( it, field ) }
1022                                        else
1023                                                return null
1024                                }
1025                }
1026        }
1027       
1028        /**
1029        * Returns the domain object that should be used with the given entity string
1030        *
1031        * For example:
1032        *               What object should be consulted if the user asks for "studies"
1033        *               Response: Study
1034        * @return       Domain object that should be used with the given entity string
1035        */
1036   protected def domainObjectCallback( String entity ) {
1037           switch( entity ) {
1038                   case "Study":
1039                   case "studies":
1040                           return Study
1041                   case "Subject":
1042                   case "subjects":
1043                           return Subject
1044                   case "Sample":
1045                   case "samples":
1046                           return Sample
1047                   case "Event":
1048                   case "events":
1049                        return Event
1050                   case "SamplingEvent":
1051                   case "samplingEvents":
1052                           return SamplingEvent
1053                   case "Assay":
1054                   case "assays":
1055                                return Assay
1056                   case "EventGroup":
1057                   case "eventGroups":
1058                                   return EventGroup
1059               
1060           }
1061   }
1062
1063    /**
1064    * Returns the objects within the given study that should be used with the given entity string
1065    *
1066    * For example:
1067    *           What object should be consulted if the user asks for "samples"
1068    *           Response: study.samples
1069    * @return   List of domain objects that should be used with the given entity string
1070    */
1071    protected def templateObjectCallback( String entity, Study study ) {
1072      switch( entity ) {
1073          case "Study":
1074          case "studies":
1075              return study
1076          case "Subject":
1077          case "subjects":
1078              return study?.subjects
1079          case "Sample":
1080          case "samples":
1081              return study?.samples
1082          case "Event":
1083          case "events":
1084               return study?.events
1085          case "SamplingEvent":
1086          case "samplingEvents":
1087              return study?.samplingEvents
1088          case "Assay":
1089          case "assays":
1090                  return study?.assays
1091      }
1092    }
1093       
1094        /**
1095         * Computes the mean value and Standard Error of the mean (SEM) for the given values
1096         * @param values        List of values to compute the mean and SEM for. Strings and null
1097         *                                      values are ignored
1098         * @return                      Map with two keys: 'value' and 'error'
1099         */
1100        protected Map computeMeanAndError( values ) {
1101                // TODO: Handle the case that one of the values is a list. In that case,
1102                // all values should be taken into account.     
1103                def mean = computeMean( values );
1104                def error = computeSEM( values, mean );
1105               
1106                return [ 
1107                        "value": mean,
1108                        "error": error
1109                ]
1110        }
1111       
1112        /**
1113         * Computes the mean of the given values. Values that can not be parsed to a number
1114         * are ignored. If no values are given, null is returned.
1115         * @param values        List of values to compute the mean for
1116         * @return                      Arithmetic mean of the values
1117         */
1118        protected def computeMean( List values ) {
1119                def sumOfValues = 0;
1120                def sizeOfValues = 0;
1121                values.each { value ->
1122                        def num = getNumericValue( value );
1123                        if( num != null ) {
1124                                sumOfValues += num;
1125                                sizeOfValues++
1126                        }
1127                }
1128
1129                if( sizeOfValues > 0 )
1130                        return sumOfValues / sizeOfValues;
1131                else
1132                        return null;
1133        }
1134
1135        /**
1136        * Computes the standard error of mean of the given values. 
1137        * Values that can not be parsed to a number are ignored. 
1138        * If no values are given, null is returned.
1139        * @param values         List of values to compute the standard deviation for
1140        * @param mean           Mean of the list (if already computed). If not given, the mean
1141        *                                       will be computed using the computeMean method
1142        * @return                       Standard error of the mean of the values or 0 if no values can be used.
1143        */
1144    protected def computeSEM( List values, def mean = null ) {
1145       if( mean == null )
1146            mean = computeMean( values )
1147
1148       def sumOfDifferences = 0;
1149       def sizeOfValues = 0;
1150       values.each { value ->
1151           def num = getNumericValue( value );
1152           if( num != null ) {
1153               sumOfDifferences += Math.pow( num - mean, 2 );
1154               sizeOfValues++
1155           }
1156       }
1157
1158       if( sizeOfValues > 0 ) {
1159           def std = Math.sqrt( sumOfDifferences / sizeOfValues );
1160           return std / Math.sqrt( sizeOfValues );
1161       } else {
1162           return null;
1163       }
1164    }
1165
1166    /**
1167         * Computes the median of the given values. Values that can not be parsed to a number
1168         * are ignored. If no values are given, null is returned.
1169         * @param values        List of values to compute the median for
1170         * @return                      Median of the values
1171         */
1172        protected def computeMedian( List values ) {
1173                def listOfValues = [];
1174                values.each { value ->
1175                        def num = getNumericValue( value );
1176                        if( num != null ) {
1177                                listOfValues << num;
1178                        }
1179                }
1180
1181        listOfValues.sort();
1182
1183        def listSize = listOfValues.size();
1184
1185        def objReturn = null;
1186
1187                if( listSize > 0 ) {
1188            def listHalf = (int) Math.abs(listSize/2);
1189            if(listSize%2==0) {
1190                // If the list is of an even size, take the mean of the middle two value's
1191                objReturn = (listOfValues.get(listHalf)+listOfValues.get(listHalf-1))/2;
1192            } else {
1193                // If the list is of an odd size, take the middle value
1194                objReturn = listOfValues.get(listHalf);
1195            }
1196        }
1197
1198                return ["value": objReturn];
1199        }
1200
1201    /**
1202         * Computes the count of the given values. Values that can not be parsed to a number
1203         * are ignored. If no values are given, null is returned.
1204         * @param values        List of values to compute the count for
1205         * @return                      Count of the values
1206         */
1207        protected def computeCount( List values ) {
1208                def sumOfValues = 0;
1209                def sizeOfValues = 0;
1210                values.each { value ->
1211                        def num = getNumericValue( value );
1212                        if( num != null ) {
1213                                sumOfValues += num;
1214                                sizeOfValues++
1215                        }
1216                }
1217
1218                if( sizeOfValues > 0 )
1219                        return ["value": sizeOfValues];
1220                else
1221                        return ["value": null];
1222        }
1223
1224    /**
1225         * Computes the sum of the given values. Values that can not be parsed to a number
1226         * are ignored. If no values are given, null is returned.
1227         * @param values        List of values to compute the sum for
1228         * @return                      Arithmetic sum of the values
1229         */
1230        protected def computeSum( List values ) {
1231                def sumOfValues = 0;
1232                def sizeOfValues = 0;
1233                values.each { value ->
1234                        def num = getNumericValue( value );
1235                        if( num != null ) {
1236                                sumOfValues += num;
1237                                sizeOfValues++
1238                        }
1239                }
1240
1241                if( sizeOfValues > 0 )
1242                        return ["value": sumOfValues];
1243                else
1244                        return ["value": null];
1245        }
1246   
1247        /**
1248         * Return the numeric value of the given object, or null if no numeric value could be returned
1249         * @param       value   Object to return the value for
1250         * @return                      Number that represents the given value
1251         */
1252        protected Number getNumericValue( value ) {
1253                // TODO: handle special types of values
1254                if( value instanceof Number ) {
1255                        return value;
1256                } else if( value instanceof RelTime ) {
1257                        return value.value;
1258                }
1259               
1260                return null
1261        }
1262
1263        /** 
1264         * Returns a field for a given templateentity
1265         * @param object        TemplateEntity (or subclass) to retrieve data for
1266         * @param fieldName     Name of the field to return data for.
1267         * @return                      Value of the field or null if the value could not be retrieved
1268         */
1269        protected def getFieldValue( TemplateEntity object, String fieldName ) {
1270                if( !object || !fieldName )
1271                        return null;
1272               
1273                try {
1274                        return object.getFieldValue( fieldName );
1275                } catch( Exception e ) {
1276                        return null;
1277                }
1278        }
1279
1280        /**
1281         * Parses a fieldId that has been created earlier by createFieldId
1282         * @param fieldId       FieldId to parse
1283         * @return                      Map with attributes of the selected field. Keys are 'name', 'id', 'source' and 'type'
1284         * @see createFieldId
1285         */
1286        protected Map parseFieldId( String fieldId ) {
1287                def attrs = [:]
1288
1289                if( !fieldId )
1290                        return null;
1291               
1292                def parts = fieldId.split(",",5)
1293               
1294                attrs = [
1295                        "id": new String(parts[ 0 ].decodeBase64()),
1296                        "name": new String(parts[ 1 ].decodeBase64()),
1297                        "source": new String(parts[ 2 ].decodeBase64()),
1298                        "type": new String(parts[ 3 ].decodeBase64()),
1299            "unit": parts.length>4? new String(parts[ 4 ].decodeBase64()) : null,
1300                        "fieldId": fieldId
1301                ]
1302
1303        return attrs
1304        }
1305       
1306        /**
1307         * Returns a string representation of the given fieldType, which can be sent to the userinterface
1308         * @param fieldType     CATEGORICALDATA, DATE, RELTIME, NUMERICALDATA
1309         * @return      String representation
1310         */
1311        protected String dataTypeString( fieldType ) {
1312                return (fieldType==CATEGORICALDATA || fieldType==DATE || fieldType==RELTIME ? "categorical" : "numerical")
1313        }
1314       
1315        /**
1316         * Create a fieldId based on the given attributes
1317         * @param attrs         Map of attributes for this field. Keys may be 'name', 'id', 'source' and 'type'
1318         * @return                      Unique field ID for these parameters
1319         * @see parseFieldId
1320         */
1321        protected String createFieldId( Map attrs ) {
1322                // TODO: What if one of the attributes contains a comma?
1323                def name = attrs.name.toString();
1324                def id = (attrs.id ?: name).toString();
1325                def source = attrs.source.toString();
1326                def type = (attrs.type ?: "").toString();
1327        def unit = (attrs.unit ?: "").toString();
1328
1329                return id.bytes.encodeBase64().toString() + "," +
1330                name.bytes.encodeBase64().toString() + "," +
1331                source.bytes.encodeBase64().toString() + "," +
1332                type.bytes.encodeBase64().toString() + "," +
1333                unit.bytes.encodeBase64().toString();
1334        }
1335
1336    /**
1337     * Set the response code and an error message
1338     * @param code HTTP status code
1339     * @param msg Error message, string
1340     */
1341    protected void returnError(code, msg){
1342        response.sendError(code , msg)
1343    }
1344
1345    /**
1346     * Determines what type of data a field contains
1347     * @param studyId An id that can be used with Study.get/1 to retrieve a study from the database
1348     * @param fieldId The field id as returned from the client, will be used to retrieve the data required to determine the type of data a field contains
1349     * @param inputData Optional parameter that contains the data we are computing the type of. When including in the function call we do not need to request data from a module, should the data belong to a module
1350     * @return Either CATEGORICALDATA, NUMERICALDATA, DATE or RELTIME
1351     */
1352    protected int determineFieldType(studyId, fieldId, inputData = null){
1353                def parsedField = parseFieldId( fieldId );
1354        def study = Study.get(studyId)
1355                def data = []
1356               
1357                // If the fieldId is incorrect, or the field is not asked for, return
1358                // CATEGORICALDATA
1359                if( !parsedField )
1360                        return CATEGORICALDATA;
1361
1362        try{
1363            if( parsedField.source == "GSCF" ) {
1364                if(parsedField.id.isNumber()){
1365                        return determineCategoryFromTemplateFieldId(parsedField.id)
1366                } else { // Domainfield or memberclass
1367                    def callback = domainObjectCallback( parsedField.type )
1368                                       
1369                    // Can the field be found in the domainFields as well? If so, treat it as a template field, so that dates and times can be properly rendered in a human-readable fashion
1370                    if(callback.metaClass.methods.contains( "giveDomainFields" ) && callback?.giveDomainFields()?.name?.contains(parsedField.name.toString())){
1371                        // Use the associated templateField to determine the field type
1372                        return determineCategoryFromTemplateField(
1373                                callback?.giveDomainFields()[
1374                                    callback?.giveDomainFields().name.indexOf(parsedField.name.toString())
1375                                ]
1376                        )
1377                    }
1378                    // Apparently it is not a templatefield as well as a memberclass
1379
1380                    def field = callback?.declaredFields.find { it.name == parsedField.name };
1381                    if( field ) {
1382                        return determineCategoryFromClass( field.getType() )
1383                    } else {
1384                        // TODO: how do we communicate this to the user? Do we allow the process to proceed?
1385                        log.error( "The user asked for field " + parsedField.type + " - " + parsedField.name + ", but it doesn't exist." );
1386                    }
1387                }
1388            } else {
1389                if(inputData == null){ // If we did not get data, we need to request it from the module first
1390                    data = getModuleData( study, study.getSamples(), parsedField.source, parsedField.name );
1391                    return determineCategoryFromData(data)
1392                } else {
1393                    return determineCategoryFromData(inputData)
1394                }
1395            }
1396        } catch(Exception e){
1397            log.error("VisualizationController: determineFieldType: "+e)
1398            e.printStackTrace()
1399            // If we cannot figure out what kind of a datatype a piece of data is, we treat it as categorical data
1400            return CATEGORICALDATA
1401        }
1402    }
1403
1404    /**
1405     * Determines a field category, based on the input parameter 'classObject', which is an instance of type 'class'
1406     * @param classObject
1407     * @return Either CATEGORICALDATA of NUMERICALDATA
1408     */
1409    protected int determineCategoryFromClass(classObject){
1410        log.trace "Determine category from class: "+classObject+", of class: "+classObject?.class
1411        if(classObject==java.lang.String){
1412            return CATEGORICALDATA
1413        } else {
1414            return NUMERICALDATA
1415        }
1416    }
1417
1418    /**
1419     * Determines a field category based on the actual data contained in the field. The parameter 'inputObject' can be a single item with a toString() function, or a collection of such items.
1420     * @param inputObject Either a single item, or a collection of items
1421     * @return Either CATEGORICALDATA of NUMERICALDATA
1422     */
1423    protected int determineCategoryFromData(inputObject){
1424        def results = []
1425               
1426        if(inputObject instanceof Collection){
1427            // This data is more complex than a single value, so we will call ourselves again so we c
1428            inputObject.each {
1429                                if( it != null )
1430                        results << determineCategoryFromData(it)
1431            }
1432        } else {
1433                        // Unfortunately, the JSON null object doesn't resolve to false or equals null. For that reason, we
1434                        // exclude those objects explicitly here.
1435                        if( inputObject != null && inputObject?.class != org.codehaus.groovy.grails.web.json.JSONObject$Null ) {
1436                    if(inputObject.toString().isDouble()){
1437                        results << NUMERICALDATA
1438                    } else {
1439                        results << CATEGORICALDATA
1440                    }
1441                        }
1442        }
1443
1444        results.unique()
1445
1446        if(results.size() > 1) {
1447            // If we cannot figure out what kind of a datatype a piece of data is, we treat it as categorical data
1448            results[0] = CATEGORICALDATA
1449        } else if( results.size() == 0 ) {
1450                        // If the list is empty, return the numerical type. If it is the only value, if will
1451                        // be discarded later on. If there are more entries (e.g part of a collection)
1452                        // the values will be regarded as numerical, if the other values are numerical 
1453                        results[ 0 ] = NUMERICALDATA
1454        }
1455
1456                return results[0]
1457    }
1458
1459    /**
1460     * Determines a field category, based on the TemplateFieldId of a Templatefield
1461     * @param id A database ID for a TemplateField
1462     * @return Either CATEGORICALDATA of NUMERICALDATA
1463     */
1464    protected int determineCategoryFromTemplateFieldId(id){
1465        TemplateField tf = TemplateField.get(id)
1466        return determineCategoryFromTemplateField(tf)
1467    }
1468
1469    /**
1470     * Determines a field category, based on the TemplateFieldType of a Templatefield
1471     * @param id A database ID for a TemplateField
1472     * @return Either CATEGORICALDATA of NUMERICALDATA
1473     */
1474    protected int determineCategoryFromTemplateField(tf){
1475        if(tf.type==TemplateFieldType.DOUBLE || tf.type==TemplateFieldType.LONG){
1476            log.trace "GSCF templatefield: NUMERICALDATA ("+NUMERICALDATA+") (based on "+tf.type+")"
1477            return NUMERICALDATA
1478        }
1479        if(tf.type==TemplateFieldType.DATE){
1480            log.trace "GSCF templatefield: DATE ("+DATE+") (based on "+tf.type+")"
1481            return DATE
1482        }
1483        if(tf.type==TemplateFieldType.RELTIME){
1484            log.trace "GSCF templatefield: RELTIME ("+RELTIME+") (based on "+tf.type+")"
1485            return RELTIME
1486        }
1487        log.trace "GSCF templatefield: CATEGORICALDATA ("+CATEGORICALDATA+") (based on "+tf.type+")"
1488        return CATEGORICALDATA
1489    }
1490    /**
1491     * Properly formats the object that will be returned to the client. Also adds an informational message, if that message has been set by a function. Resets the informational message to the empty String.
1492     * @param returnData The object containing the data
1493     * @return results A JSON object
1494     */
1495    protected void sendResults(returnData){
1496        def results = [:]
1497        if(infoMessage.size()!=0){
1498            results.put("infoMessage", infoMessage)
1499            infoMessage = []
1500        }
1501        results.put("returnData", returnData)
1502        render results as JSON
1503    }
1504
1505    /**
1506     * Properly formats an informational message that will be returned to the client. Resets the informational message to the empty String.
1507     * @param returnData The object containing the data
1508     * @return results A JSON object
1509     */
1510    protected void sendInfoMessage(){
1511        def results = [:]
1512        results.put("infoMessage", infoMessage)
1513        infoMessage = []
1514        render results as JSON
1515    }
1516
1517    /**
1518     * Adds a new message to the infoMessage
1519     * @param message The information that needs to be added to the infoMessage
1520     */
1521    protected void setInfoMessage(message){
1522        infoMessage.add(message)
1523        log.trace "setInfoMessage: "+infoMessage
1524    }
1525
1526    /**
1527     * Adds a message to the infoMessage that gives the client information about offline modules
1528     */
1529    protected void setInfoMessageOfflineModules(){
1530        infoMessageOfflineModules.unique()
1531        if(infoMessageOfflineModules.size()>0){
1532            String message = "Unfortunately"
1533            infoMessageOfflineModules.eachWithIndex{ it, index ->
1534                if(index==(infoMessageOfflineModules.size()-2)){
1535                    message += ', the '+it+' and '
1536                } else {
1537                    if(index==(infoMessageOfflineModules.size()-1)){
1538                        message += ' the '+it
1539                    } else {
1540                        message += ', the '+it
1541                    }
1542                }
1543            }
1544            message += " could not be reached. As a result, we cannot at this time visualize data contained in "
1545            if(infoMessageOfflineModules.size()>1){
1546                message += "these modules."
1547            } else {
1548                message += "this module."
1549            }
1550            setInfoMessage(message)
1551        }
1552        infoMessageOfflineModules = []
1553    }
1554
1555    /**
1556     * Combine several blocks of formatted data into one. These blocks have been formatted by the formatData function.
1557     * @param inputData Contains a list of maps, of the following format
1558     *          - a key 'series' containing a list, that contains one or more maps, which contain the following:
1559     *            - a key 'name', containing, for example, a feature name or field name
1560     *            - a key 'y', containing a list of y-values
1561     *            - a key 'error', containing a list of, for example, standard deviation or standard error of the mean values,
1562     */
1563    protected def formatCategoryData(inputData){
1564        // NOTE: This function is no longer up to date with the current inputData layout.
1565        def series = []
1566        inputData.eachWithIndex { it, i ->
1567            series << ['name': it['yaxis']['title'], 'y': it['series']['y'][0], 'error': it['series']['error'][0]]
1568        }
1569        def ret = [:]
1570        ret.put('type', inputData[0]['type'])
1571        ret.put('x', inputData[0]['x'])
1572        ret.put('yaxis',['title': 'title', 'unit': ''])
1573        ret.put('xaxis', inputData[0]['xaxis'])
1574        ret.put('series', series)
1575        return ret
1576    }
1577
1578    /**
1579     * Given two objects of either CATEGORICALDATA or NUMERICALDATA
1580     * @param rowType The type of the data that has been selected for the row, either CATEGORICALDATA or NUMERICALDATA
1581     * @param columnType The type of the data that has been selected for the column, either CATEGORICALDATA or NUMERICALDATA
1582     * @return
1583     */
1584    protected def determineVisualizationTypes(rowType, columnType){
1585                def types = []
1586               
1587        if(rowType == CATEGORICALDATA || rowType == DATE || rowType == RELTIME){
1588            if(columnType == CATEGORICALDATA || columnType == DATE || columnType == RELTIME){
1589                                types = [ [ "id": "table", "name": "Table"] ];
1590            } else {    // NUMERICALDATA
1591                types = [ [ "id": "horizontal_barchart", "name": "Horizontal barchart"] ];
1592            }
1593        } else {        // NUMERICALDATA
1594            if(columnType == CATEGORICALDATA || columnType == DATE || columnType == RELTIME){
1595                types = [ [ "id": "barchart", "name": "Barchart"], [ "id": "linechart", "name": "Linechart"], [ "id": "boxplot", "name": "Boxplot"] ];
1596            } else {
1597                types = [ [ "id": "scatterplot", "name": "Scatterplot"], [ "id": "linechart", "name": "Linechart"] ];
1598            }
1599        }
1600        return types
1601    }
1602       
1603        /**
1604        * Returns the types of aggregation possible for the given two objects of either CATEGORICALDATA or NUMERICALDATA
1605        * @param rowType The type of the data that has been selected for the row, either CATEGORICALDATA or NUMERICALDATA
1606        * @param columnType The type of the data that has been selected for the column, either CATEGORICALDATA or NUMERICALDATA
1607        * @param groupType The type of the data that has been selected for the grouping, either CATEGORICALDATA or NUMERICALDATA
1608        * @return
1609        */
1610        protected def determineAggregationTypes(rowType, columnType, groupType = null ){
1611                // A list of all aggregation types. By default, every item is possible
1612                def types = [
1613                        [ "id": "average", "name": "Average", "disabled": false ],
1614                        [ "id": "count", "name": "Count", "disabled": false ],
1615                        [ "id": "median", "name": "Median", "disabled": false ],
1616                        [ "id": "none", "name": "No aggregation", "disabled": false ],
1617                        [ "id": "sum", "name": "Sum", "disabled": false ],
1618                ]
1619
1620                // Normally, all aggregation types are possible, with three exceptions:
1621                //              Categorical data on both axes. In that case, we don't have anything to aggregate, so we can only count
1622                //              Grouping on a numerical field is not possible. In that case, it is ignored
1623                //                      Grouping on a numerical field with categorical data on both axes (table) enabled aggregation,
1624                //                      In that case we can aggregate on the numerical field.
1625               
1626                if(rowType == CATEGORICALDATA || rowType == DATE || rowType == RELTIME){
1627                        if(columnType == CATEGORICALDATA || columnType == DATE || columnType == RELTIME){
1628                               
1629                                if( groupType == NUMERICALDATA ) {
1630                                        // Disable 'none', since that can not be visualized
1631                                        types.each {
1632                                                if( it.id == "none" )
1633                                                        it.disabled = true
1634                                        }
1635                                } else {
1636                                        // Disable everything but 'count'
1637                                        types.each { 
1638                                                if( it.id != "count" ) 
1639                                                        it.disabled = true
1640                                        }
1641                                }
1642                        }
1643                }
1644               
1645                return types
1646   }
1647}
Note: See TracBrowser for help on using the repository browser.