source: trunk/grails-app/controllers/nl/tno/massSequencing/integration/RestController.groovy @ 48

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

Improved getMeasurements and getMeasurementData methods

File size: 22.8 KB
Line 
1package nl.tno.massSequencing.integration
2
3import grails.converters.*
4import nl.tno.massSequencing.*
5
6import org.codehaus.groovy.grails.commons.ConfigurationHolder
7
8/** Expose the list of features within a certain assay
9 *
10 * @author Robert Horlings (robert@isdat.nl)
11 * @since 20101229
12 * @see   SAM.RestController
13 *
14 * $Rev$
15 *
16 * This class provides a REST-full service for getting and setting the data
17 * in the Metagenomics Module. The service consists of several
18 * resources listed below. So far, all resources are GET resoruces, i.e. we
19 * do not use PUT, POST or DELETE. Because we are using Grails' web libaries,
20 * each resource corresponds to exactly one action of this controller.
21 *
22 *
23 * The REST resources implemented in this controller are:
24 *
25 * metagenomics-host:port/metagenomics/rest/getMeasurements(assayToken)
26 * metagenomics-host:port/metagenomics/rest/getMeasurementMetadata(assayToken, measurementTokens)
27 * metagenomics-host:port/metagenomics/rest/getMeasurementData(assayToken, measurementTokens, sampleTokens)
28 * metagenomics-host:port/metagenomics/rest/getAssayURL(assayToken)
29 *
30 * Where 'metagenomics-host' is the url of the metagenomics server's host with port number 'port'.
31 *
32 */
33class RestController {
34        def synchronizationService
35       
36        /****************************************************************/
37        /* REST resource for handling study change in GSCF              */
38        /****************************************************************/
39       
40        /**
41         * Is called by GSCF when a study is added, changed or deleted.
42         * Sets the 'dirty' flag of a study to true, so that it will be updated
43         * next time the study is asked for.
44         *
45         * @param       studyToken
46         */
47        def notifyStudyChange = {
48                def studyToken = params.studyToken
49
50                if( !studyToken ) {
51                        response.sendError(400, "No studyToken given" )
52                        return
53                }
54
55                // Search for the changed study
56                def study = Study.findByStudyToken( studyToken );
57
58                // If the study is not found, it is added in GSCF. Add a dummy (dirty) study, in order to
59                // update it immediately when asked for
60                if( !study ) {
61                        log.info( "MassSequencing: GSCF notification for new study " + studyToken );
62                        study = new Study(
63                                        name: "",
64                                        studyToken: studyToken,
65                                        isDirty: true
66                                        )
67                } else {
68                        log.info( "MassSequencing: GSCF notification for existing study " + studyToken );
69                        study.isDirty = true;
70                }
71                study.save(flush:true);
72
73                def jsonData = [ 'studyToken': studyToken, message: "Notify succesful" ];
74
75                render jsonData as JSON
76        }
77
78        /**
79         * Return URL to view an assay.
80         *
81         * @param  assayToken
82         * @return URL to view an assay as single hash entry with key 'url'.
83         *
84         */
85        def getAssayURL = {
86                def assayToken = params.assayToken
87
88                if( !assayToken ) {
89                        render [] as JSON
90                        return
91                }
92
93                def assay = Assay.findByAssayToken( assayToken )
94
95                // If the assay is not found, try synchronizing
96                synchronizationService.sessionToken = session.sessionToken
97
98                if( !assay ) {
99                        synchronizationService.synchronizeStudies()
100                        assay = Assay.findByAssayToken( assayToken );
101
102                        if( !assay ) {
103                                response.sendError(404, "Not Found" )
104                                return;
105                        }
106                } else {
107                        try {
108                                synchronizationService.synchronizeAssay(assay);
109                        } catch( Exception e ) {
110                                response.sendError( 500, e.getMessage())
111                                return
112                        }
113
114                        def url = [ 'url' : ConfigurationHolder.config.grails.serverURL + '/assay/show/' + assay.id.toString() ]
115
116                        render url as JSON
117                }
118        }
119
120        /***************************************************/
121        /* REST resources related to the querying in GSCF  */
122        /***************************************************/
123
124        /**
125         * Retrieves a list of fields that could be queried when searching for a specific entity.
126         *
127         * The module is allowed to return different fields when the user searches for different entities
128         *
129         * Example call:                [moduleurl]/rest/getQueryableFields?entity=Study&entity=Sample
130         * Example response:    { "Study": [ "# sequences" ], "Sample": [ "# sequences", "# bacteria" ] }
131         *
132         * @param       params.entity   Entity that is searched for. Might be more than one. If no entity is given,
133         *                                                      a list of searchable fields for all entities is given
134         * @return      JSON                    List with the names of the fields
135         */
136        def getQueryableFields = {
137                // We don't really care about the entity. The only thing is that this module
138                // is only aware of studies, assays and samples, but doesn't know anything about
139                // subjects or events. If the user searches for those entities (maybe in the future)
140                // this module doesn't have anything to search for.
141
142                def entities = params.entity ?: []
143               
144                if( entities instanceof String )
145                        entities = [entities]
146                else
147                        entities = entities.toList()
148
149                if( !entities )
150                        entities = [ "Study", "Assay", "Sample" ]
151                       
152
153                def fields = [:];
154                entities.unique().each { entity -> 
155                        fields[ entity ] = _getQueryableFields( entity );
156                }
157               
158                render fields as JSON
159        }
160       
161        def _getQueryableFields( entity ) {
162                switch( entity ) {
163                        case "Study":
164                        case "Assay":
165                        case "Sample":
166                                return [ "# sequences", "Forward primer", "Mid name", "Oligo number", "Run name" ]
167                                break;
168                        default:
169                                // Do nothing
170                                break;
171                }
172        }
173       
174        /**
175         * Returns data for the given field and entities.
176         *
177         * Example call:                [moduleurl]/rest/getQueryableFieldData?entity=Study&tokens=abc1&tokens=abc2&fields=# sequences&fields=# bacteria
178         * Example response:    { "abc1": { "# sequences": 141, "# bacteria": 0 }, "abc2": { "#sequences": 412 } }
179         *
180         * @param       params.entity   Entity that is searched for
181         * @param       params.tokens   One or more tokens of the entities that the data should be returned for
182         * @param       params.fields   One or more field names of the data to be returned. If no fields are given, all fields are returned
183         * @return      JSON                    Map with keys being the entity tokens and the values being maps with entries [field] = [value]. Not all
184         *                                                      fields and tokens that are asked for have to be returned by the module (e.g. when a specific entity can
185         *                                                      not be found, or a value is not present for an entity)
186         */
187        def getQueryableFieldData = {
188                println "Get queryable Field data: " + params
189               
190                def entity = params.entity;
191                def tokens = params.tokens ?: []
192                def fields = params.fields ?: []
193
194                if( tokens instanceof String )
195                        tokens = [tokens]
196                else
197                        tokens = tokens.toList();
198
199                if( fields instanceof String )
200                        fields = [fields]
201                else
202                        fields = fields.toList();
203
204                if( fields.size() == 0 ) {
205                        fields = _getQueryableFields( entity );
206                }
207               
208                // Only search for unique tokens and fields
209                tokens = tokens.unique()
210                fields = fields.unique()
211
212                // Without tokens or fields we can only return an empty list
213                def map = [:]
214                if( tokens.size() == 0 || fields.size() == 0 ) {
215                        log.trace "Return empty string for getQueryableFieldData: #tokens: " + tokens.size() + " #fields: " + fields.size()
216                        render map as JSON
217                        return;
218                }
219
220                for( token in tokens ) {
221                        def object = getQueryableObject( entity, token );
222
223                        if( object ) {
224                                // Check whether the user has sufficient privileges:
225                                def study;
226                                switch( entity ) {
227                                        case "Study":
228                                                study = object; 
229                                                break;
230                                        case "Assay":
231                                        case "Sample":
232                                                study = object.study
233                                                break;
234                                        default:
235                                                log.error "Incorrect entity used: " + entity;
236                                                continue;
237                                }
238                               
239                                if( !study.canRead( session.user ) ) {
240                                        log.error "Data was requested for " + entity.toLowerCase() + " " + object.name + " but the user " + session.user.username + " doesn't have the right privileges."
241                                        continue;
242                                }
243                               
244                                map[ token ] = [:]
245                                fields.each { field ->
246                                        def v = getQueryableFieldValue( entity, object, field );
247                                        if( v != null )
248                                                map[ token ][ field ] = v
249                                }
250                        } else {
251                                log.trace "No " + entity + " with token " + token + " found."
252                        }
253                }
254               
255                render map as JSON
256        }
257
258        /**
259         * Searches for a specific entity
260         *
261         * @param entity                Entity to search in
262         * @param token         Token of the entity to search in
263         * @return
264         */
265        protected def getQueryableObject( def entity, def token ) {
266                switch( entity ) {
267                        case "Study":
268                                return Study.findByStudyToken( token );
269                        case "Assay":
270                                return Assay.findByAssayToken( token );
271                        case "Sample":
272                                return Sample.findBySampleToken( token );
273                        default:
274                        // Other entities can't be handled
275                                return null;
276                }
277        }
278
279        /**
280         * Searches for the value of a specific field in a specific entity
281         *
282         * @param entity        Entity of the given object
283         * @param object        Object to search in
284         * @param field         Field value to retrieve         
285         * @return
286         */
287        protected def getQueryableFieldValue( def entity, def object, def field ) {
288                if( !entity || !object || !field )
289                        return null;
290                       
291                // First determine all assaysamples involved, in order to return data later.
292                // All data that has to be returned is found in assaysamples
293                def assaySamples
294               
295                switch( entity ) {
296                        case "Study":
297                                assaySamples = object.assays*.assaySamples;
298                                if( assaySamples ) {
299                                        assaySamples = assaySamples.flatten()
300                                } 
301                                break;
302                        case "Assay":
303                        case "Sample":
304                                assaySamples = object.assaySamples;
305                                break;
306                        default:
307                        // Other entities can't be handled
308                                return null;
309                }
310               
311                // If no assaySamples are involved, return null as empty value
312                if( !assaySamples ) {
313                        return null;
314                }
315               
316                // Now determine the exact field to return
317                switch( field ) {
318                        // Returns the total number of sequences in this sample
319                        case "# sequences":
320                                return assaySamples.collect { it.numSequences() }.sum();
321                        // Returns the unique tag names
322                        case "Forward primer":
323                                return assaySamples.collect { it.fwPrimerSeq }.findAll { it != null }.unique();
324                        case "Mid name":
325                                return assaySamples.collect { it.fwMidName }.findAll { it != null }.unique();
326                        case "Oligo number":
327                                return assaySamples.collect { it.fwOligo }.findAll { it != null }.unique();
328                        case "Run name":
329                                return assaySamples.collect { it.run?.name }.findAll { it != null }.unique();
330                        // Other fields are not handled
331                        default:
332                                return null;
333                }
334
335        }
336       
337        private def checkAssayToken( def assayToken ) {
338                if( !assayToken || assayToken == null ) {
339                        return false
340                }
341                def list = []
342                def assay = Assay.findByAssayToken( assayToken )
343
344                if( !assay || assay == null ) {
345                        return false;
346                }
347
348                return assay;
349        }
350
351        /****************************************************************/
352        /* REST resources for exporting data from GSCF                          */
353        /****************************************************************/
354
355        /**
356         * Retrieves a list of actions that can be performed on data with a specific entity.
357         *
358         * The module is allowed to return different fields when the user searches for different entities
359         *
360         * Example call:                [moduleurl]/rest/getPossibleActions?entity=Assay&entity=Sample
361         * Example response:    { "Assay": [ { name: "excel", description: "Export as excel" } ],
362         *                                                "Sample": [ { name: "excel", description: "Export as excel" }, { name: "fasta", description: : "Export as fasta" } ] }
363         *
364         * @param       params.entity   Entity that is searched for. Might be more than one. If no entity is given,
365         *                                                      a list of searchable fields for all entities is given
366         * @return      JSON                    Hashmap with keys being the entities and the values are lists with the action this module can
367         *                                                      perform on this entity. The actions as hashmaps themselves, with keys 'name' and 'description'
368         */
369        def getPossibleActions = {
370                def entities = params.entity ?: []
371               
372                if( entities instanceof String )
373                        entities = [entities]
374                else
375                        entities = entities.toList()
376
377                if( !entities )
378                        entities = [ "Study", "Assay", "Sample" ]
379
380                def actions = [:];
381                entities.unique().each { entity ->
382                        switch( entity ) {
383                                case "Study":
384                                        actions[ entity ] = [ 
385                                                [ name: "excel", description: "Export metadata", url: createLink( controller: "study", action: "exportMetaData", absolute: true ) ], 
386                                                [ name: "fasta", description: "Export as fasta", url: createLink( controller: "study", action: "exportAsFasta", absolute: true ) ] 
387                                        ]
388                                        break;
389                                case "Assay":
390                                        actions[ entity ] = [ 
391                                                [ name: "fasta", description: "Export as fasta", url: createLink( controller: "assay", action: "exportAsFasta", absolute: true ) ],
392                                                [ name: "excel", description: "Export metadata", url: createLink( controller: "assay", action: "exportMetaData", absolute: true ) ]
393                                        ]
394                                        break;
395                                case "Sample":
396                                        actions[ entity ] = [ 
397                                                [ name: "fasta", description: "Export as fasta", url: createLink( controller: "sample", action: "exportAsFasta", absolute: true ) ], 
398                                                [ name: "excel", description: "Export metadata", url: createLink( controller: "sample", action: "exportMetaData", absolute: true ) ] 
399                                        ]
400                                        break;
401                                default:
402                                        // Do nothing
403                                        break;
404                        }
405                }
406               
407                render actions as JSON
408        }
409
410        /****************************************************************/
411        /* REST resources for providing basic data to the GSCF          */
412        /****************************************************************/
413        private getMeasurementTypes() {
414                return [ 
415                        "# sequences": null, "# qual scores": null, 
416                        "forward oligo number":         "fwOligo", 
417                        "forward mid name":             "fwMidName", 
418                        "forward total sequence":       "fwTotalSeq", 
419                        "forward mid sequence":         "fwMidSeq", 
420                        "forward primer sequence":      "fwMidSeq",
421                        "reverse oligo number":         "revOligo", 
422                        "reverse mid name":             "revMidName", 
423                        "reverse total sequence":       "revTotalSeq", 
424                        "reverse mid sequence":         "revMidSeq", 
425                        "reverse primer sequence":      "revMidSeq"
426                ]
427        }
428
429        /**
430         * Return a list of simple assay measurements matching the querying text.
431         *
432         * @param assayToken
433         * @return list of measurements for token. Each member of the list is a hash.
434         *                      the hash contains the three keys values pairs: value, sampleToken, and
435         *                      measurementMetadata.
436         *
437         * Example REST call:
438         * http://localhost:8184/metagenomics/rest/getMeasurements/query?assayToken=16S-5162
439         *
440         * Resulting JSON object:
441         *
442         * [ "# sequences", "average quality" ]
443         *
444         */
445        def getMeasurements = {
446                def assayToken = params.assayToken;
447
448                if( !checkAssayToken( assayToken ) ) {
449                        response.sendError(404)
450                        return false
451                }
452               
453                println "getMeasurements: " + getMeasurementTypes().keySet().asList()
454                render getMeasurementTypes().keySet().asList() as JSON
455        }
456
457        /**
458         * Return measurement metadata for measurement
459         *
460         * @param assayToken
461         * @param measurementTokens. List of measurements for which the metadata is returned.
462         *                           If this is not given, then return metadata for all
463         *                           measurements belonging to the specified assay.
464         * @return list of measurements
465         *
466         * Example REST call:
467         * http://localhost:8184/metagenomics/rest/getMeasurementMetadata/query?assayToken=16S-5162
468         *      &measurementToken=# sequences
469         *              &measurementToken=average quality
470         *
471         * Example resulting JSON object:
472         *
473         * [ {"name":"# sequences","type":"raw"},
474         *   {"name":"average quality", "unit":"Phred"} ]
475         */
476        def getMeasurementMetaData = {
477                def assayToken = params.assayToken
478                def measurementTokens = params.measurementToken
479               
480                if( !checkAssayToken( assayToken ) ) {
481                        response.sendError(404)
482                        return false
483                }
484
485                // For now, we don't have any metadata about the measurements
486                def measurements = getMeasurementTypes().keySet().asList();
487                def measurementMetadata = [ ]
488
489                // If no measurementTokens are given, all measurements are returned
490                if( !measurementTokens )
491                        measurementTokens = measurements
492               
493                measurementTokens.each { token ->
494                        if( measurements.contains( token ) )
495                                measurementMetadata << [ "name" : token ];
496                }
497
498                render measurementMetadata as JSON
499        }
500
501        /**
502         * Return list of measurement data.
503         *
504         * @param assayTokes
505         * @param measurementToken. Restrict the returned data to the measurementTokens specified here.
506         *                                              If this argument is not given, all samples for the measurementTokens are returned.
507         *                                              Multiple occurences of this argument are possible.
508         * @param sampleToken. Restrict the returned data to the samples specified here.
509         *                                              If this argument is not given, all samples for the measurementTokens are returned.
510         *                                              Multiple occurences of this argument are possible.
511         * @param boolean verbose. If this argument is not present or it's value is true, then return
512         *                      the date in a redundant format that is easier to process.
513         *                                              By default, return a more compact JSON object as follows.
514         *
515         *                                              The list contains three elements:
516         *
517         *                                              (1) a list of sampleTokens,
518         *                                              (2) a list of measurementTokens,
519         *                                              (3) a list of values.
520         *
521         *                                              The list of values is a matrix represented as a list. Each row of the matrix
522         *                                              contains the values of a measurementToken (in the order given in the measurement
523         *                                              token list, (2)). Each column of the matrix contains the values for the sampleTokens
524         *                                              (in the order given in the list of sampleTokens, (1)).
525         *                                              (cf. example below.)
526         *
527         *
528         * @return  table (as hash) with values for given samples and measurements
529         *
530         *
531         * List of examples.
532         *
533         *
534         * Example REST call:
535         * http://localhost:8184/metagenomics/rest/getMeasurementData/doit?assayToken=PPSH-Glu-A
536         *    &measurementToken=total carbon dioxide (tCO)
537         *    &sampleToken=5_A
538         *    &sampleToken=1_A
539         *    &verbose=true
540         *
541         * Resulting JSON object:
542         * [ {"sampleToken":"1_A","measurementToken":"total carbon dioxide (tCO)","value":28},
543         *   {"sampleToken":"5_A","measurementToken":"total carbon dioxide (tCO)","value":29} ]
544         *
545         *
546         *
547         * Example REST call without sampleToken, without measurementToken,
548         *    and with verbose representation:
549         * http://localhost:8184/metagenomics/rest/getMeasurementData/dossit?assayToken=PPSH-Glu-A
550         *    &verbose=true
551         *
552         * Resulting JSON object:
553         * [ {"sampleToken":"1_A","measurementToken":"sodium (Na+)","value":139},
554         *       {"sampleToken":"1_A","measurementToken":"potassium (K+)","value":4.5},
555         *       {"sampleToken":"1_A","measurementToken":"total carbon dioxide (tCO)","value":26},
556         *       {"sampleToken":"2_A","measurementToken":"sodium (Na+)","value":136},
557         *       {"sampleToken":"2_A","measurementToken":"potassium (K+)","value":4.3},
558         *       {"sampleToken":"2_A","measurementToken":"total carbon dioxide (tCO)","value":28},
559         *       {"sampleToken":"3_A","measurementToken":"sodium (Na+)","value":139},
560         *       {"sampleToken":"3_A","measurementToken":"potassium (K+)","value":4.6},
561         *       {"sampleToken":"3_A","measurementToken":"total carbon dioxide (tCO)","value":27},
562         *       {"sampleToken":"4_A","measurementToken":"sodium (Na+)","value":137},
563         *       {"sampleToken":"4_A","measurementToken":"potassium (K+)","value":4.6},
564         *       {"sampleToken":"4_A","measurementToken":"total carbon dioxide (tCO)","value":26},
565         *       {"sampleToken":"5_A","measurementToken":"sodium (Na+)","value":133},
566         *       {"sampleToken":"5_A","measurementToken":"potassium (K+)","value":4.5},
567         *       {"sampleToken":"5_A","measurementToken":"total carbon dioxide (tCO)","value":29} ]
568         *
569         *
570         *
571         * Example REST call with default (non-verbose) view and without sampleToken:
572         *
573         * Resulting JSON object:
574         * http://localhost:8184/metagenomics/rest/getMeasurementData/query?
575         *      assayToken=PPSH-Glu-A&
576         *      measurementToken=total carbon dioxide (tCO)
577         *
578         * Resulting JSON object:
579         * [ ["1_A","2_A","3_A","4_A","5_A"],
580         *   ["sodium (Na+)","potassium (K+)","total carbon dioxide (tCO)"],
581         *   [139,136,139,137,133,4.5,4.3,4.6,4.6,4.5,26,28,27,26,29] ]
582         *
583         * Explanation:
584         * The JSON object returned by default (i.e., unless verbose is set) is an array of three arrays.
585         * The first nested array gives the sampleTokens for which data was retrieved.
586         * The second nested array gives the measurementToken for which data was retrieved.
587         * The thrid nested array gives the data for sampleTokens and measurementTokens.
588         *
589         *
590         * In the example, the matrix represents the values of the above Example and
591         * looks like this:
592         *
593         *                      1_A             2_A             3_A             4_A             5_A
594         *
595         * Na+          139             136             139             137             133
596         *
597         * K+           4.5             4.3             4.6             4.6             4.5
598         *
599         * tCO          26              28              27              26              29
600         *
601         */
602        def getMeasurementData = {
603                def assayToken = params.assayToken
604                def measurementTokens = params.measurementToken
605                def sampleTokens = params.sampleToken
606                def verbose = false
607
608                if(params.verbose && (params.verbose=='true'||params.verbose==true) ) {
609                        verbose=true
610                }
611
612                def assay = checkAssayToken( assayToken );
613                if( !assay ) {
614                        response.sendError(404)
615                        return false
616                }
617
618                // Check which measurement tokens are asked for
619                if( !measurementTokens ) {
620                        measurementTokens = []
621                } else if( measurementTokens.class == java.lang.String ) {
622                        measurementTokens = [ measurementTokens ]
623                } else if( measurementTokens.class == java.lang.String[] ) {
624                        measurementTokens = measurementTokens.toList();
625                }
626
627                // Check which sample tokens are asked for
628                if( !sampleTokens ) {
629                        sampleTokens = []
630                } else if( sampleTokens.class == java.lang.String ) {
631                        sampleTokens = [ sampleTokens ]
632                } else if( sampleTokens.class == java.lang.String[] ) {
633                        sampleTokens = sampleTokens.toList();
634                }
635
636                def data = AssaySample.findAllByAssay( assay );
637                def measurements = getMeasurementTypes()
638
639                def results = []
640                data.each { assaySample ->
641                        measurements.each { type ->
642                                def sample = assaySample.sample.sampleToken
643                                def isMatch = false
644
645                                // Check if this measurement should be returned
646                                if( 
647                                        ( measurementTokens.size() == 0 || measurementTokens.contains( type.key ) ) &&
648                                        ( sampleTokens.size() == 0      || sampleTokens.contains( sample ) ) ) {
649                                       
650                                        def value
651                                        if( type.value == null ) {
652                                                switch( type.key ) {
653                                                        case "# sequences":
654                                                                value = assaySample.numSequences(); break;
655                                                        case "# qual scores":
656                                                                value = assaySample.numQualScores(); break;
657                                                }
658                                        } else {
659                                                value = assaySample[ type.value ];
660                                        }
661                                       
662                                        results.push( [ 'sampleToken': sample, 'measurementToken': type.key, 'value': value ] )
663                                }
664                        }
665                }
666
667                if(!verbose) {
668                        results = compactTable( results )
669                }
670
671                render results as JSON
672        }
673
674       
675        /* helper function for getSamples
676         *
677         * Return compact JSON object for data. The format of the returned array is as follows.
678         *
679         * The list contains three elements:
680         *
681         * (1) a list of sampleTokens,
682         * (2) a list of measurementTokens,
683         * (3) a list of values.
684         *
685         * The list of values is a matrix represented as a list. Each row of the matrix
686         * contains the values of a measurementToken (in the order given in the measurement
687         * token list, (2)). Each column of the matrix contains the values for the sampleTokens
688         * (in the order given in the list of sampleTokens, (1)).
689         */
690        def compactTable( results ) {
691                def i = 0
692                def sampleTokenIndex = [:]
693                def sampleTokens = results.collect( { it['sampleToken'] } ).unique()
694                sampleTokens.each{ sampleTokenIndex[it] = i++ }
695
696                i = 0
697                def measurementTokenIndex= [:]
698                def measurementTokens = results.collect( { it['measurementToken'] } ).unique()
699                measurementTokens.each{ measurementTokenIndex[it] = i++ }
700
701                def data = []
702                measurementTokens.each{ m ->
703                        sampleTokens.each{ s ->
704                                def item = results.find{ it['sampleToken']==s && it['measurementToken']==m }
705                                data.push item ? item['value'] : null
706                        }
707                }
708
709                return [ sampleTokens, measurementTokens, data ]
710        }
711
712}
Note: See TracBrowser for help on using the repository browser.