diff --git a/api/src/engine/connectors/datashield/datashield.connector.ts b/api/src/engine/connectors/datashield/datashield.connector.ts index 1f0cfd9000523cc293962565a26a4762fe6a1d19..0bbc8deb2e1ac039ae76f26b33ff79d982513a1a 100644 --- a/api/src/engine/connectors/datashield/datashield.connector.ts +++ b/api/src/engine/connectors/datashield/datashield.connector.ts @@ -15,10 +15,6 @@ import { Algorithm } from '../../../engine/models/experiment/algorithm.model'; import { AllowedLink } from '../../../engine/models/experiment/algorithm/nominal-parameter.model'; import { Experiment } from '../../../engine/models/experiment/experiment.model'; import { AlertLevel } from '../../../engine/models/result/alert-result.model'; -import { - TableResult, - TableStyle, -} from '../../../engine/models/result/table-result.model'; import { Variable } from '../../../engine/models/variable.model'; import { ExperimentCreateInput } from '../../../experiments/models/input/experiment-create.input'; import { User } from '../../../users/models/user.model'; @@ -28,8 +24,6 @@ import { dsGroup, transformToDomain, transformToHisto, - transformToTable, - transformToTableNominal, transfoToHistoNominal as transformToHistoNominal, } from './transformations'; @@ -184,59 +178,30 @@ export default class DataShieldConnector implements Connector { } async getDescriptiveStats( - variable: Variable, - datasets: string[], - cookie?: string, - ): Promise<TableResult> { - const url = new URL(this.options.baseurl + 'quantiles'); + experiment: Experiment, + vars: Variable[], + cookie: string, + ) { + const url = new URL(this.options.baseurl + 'descriptivestats'); + const { variables, coVariables, datasets } = experiment; - url.searchParams.append('var', variable.id); - url.searchParams.append('type', 'split'); - url.searchParams.append('cohorts', datasets.join(',')); + const inputData = { + variables, + covariables: coVariables, + datasets, + }; const path = url.href; - const response = await firstValueFrom( - this.httpService.get(path, { + const { data } = await firstValueFrom( + this.httpService.post(path, inputData, { headers: { cookie, }, }), ); - const title = variable.label ?? variable.id; - const data = { ...response.data, title }; - - const table = ( - variable.enumerations - ? transformToTableNominal.evaluate(data) - : transformToTable.evaluate(data) - ) as TableResult; - - if ( - table && - table.headers && - variable.type === 'nominal' && - variable.enumerations - ) { - table.headers = table.headers.map((header) => { - const category = variable.enumerations.find( - (v) => v.value === header.name, - ); - - if (!category || !category.label) return header; - - return { - ...header, - name: category.label, - }; - }); - } - - return { - ...table, - tableStyle: TableStyle.DEFAULT, - }; + handlers(experiment, data, vars); } async runExperiment( @@ -258,6 +223,7 @@ export default class DataShieldConnector implements Connector { name: data.name, domain: data.domain, datasets: data.datasets, + results: [], algorithm: { name: data.algorithm.id, parameters: data.algorithm.parameters.map((p) => ({ @@ -285,23 +251,10 @@ export default class DataShieldConnector implements Connector { break; } case 'DESCRIPTIVE_STATS': { - // Cannot be done in parallel because Datashield API has an issue with parallel request (response mismatching) - const results = []; - for (const variable of allVariables) { - const result = await this.getDescriptiveStats( - variable, - expResult.datasets, - cookie, - ); - - results.push(result); - } - - expResult.results = results; + await this.getDescriptiveStats(expResult, allVariables, cookie); break; } default: { - expResult.results = []; await this.runAlgorithm(expResult, allVariables, cookie); } } @@ -320,33 +273,33 @@ export default class DataShieldConnector implements Connector { const path = new URL('/runAlgorithm', this.options.baseurl); // Covariable and variable are inversed in Datashield API - const coVariable = + const variable = experiment.variables.length > 0 ? experiment.variables[0] : undefined; const expToInput = { algorithm: { id: experiment.algorithm.name, - coVariable, - variables: experiment.coVariables, + variable, + covariables: experiment.coVariables, }, datasets: experiment.datasets, }; experiment.algorithm.parameters?.forEach((param) => { if (!expToInput.algorithm[param.name]) { - // FIXME: the parameter should be added in a specific key entry (e.g. expToInput.algorithm.parameters') + // FIXME: Parameters should be added in a specific key entry (e.g. expToInput.algorithm.parameters') // Should be fixed inside the Datashield API expToInput.algorithm[param.name] = param.value; } }); - const result = await firstValueFrom( + const { data } = await firstValueFrom( this.httpService.post(path.href, expToInput, { headers: { cookie, 'Content-Type': 'application/json' }, }), ); - handlers(experiment, result.data, vars); + handlers(experiment, data, vars); } async logout(request: Request): Promise<void> { diff --git a/api/src/engine/connectors/datashield/handlers/algorithms/descriptive.handler.spec.ts b/api/src/engine/connectors/datashield/handlers/algorithms/descriptive.handler.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e3620eb8175b19ae4e67bda4fdcc081eca9f24b --- /dev/null +++ b/api/src/engine/connectors/datashield/handlers/algorithms/descriptive.handler.spec.ts @@ -0,0 +1,81 @@ +import { Experiment } from '../../../../models/experiment/experiment.model'; +import { HeatMapResult } from '../../../../models/result/heat-map-result.model'; +import DescriptiveHandler from './descriptive.handler'; + +const data = { + quants: { + 'Albumin..Mass.volume..in.Serum.or.Plasma.global': [ + 3.805, 3.91, 4.1, 4.2, 4.3, 4.6, 5.095, 4.2525, + ], + 'Alanine.aminotransferase..Enzymatic.activity.volume..in.Serum.or.Plasma.global': + [14.205, 14.9, 16.325, 20.5, 24.15, 35.08, 40.76, 22.4257], + }, + heatmaps: [ + [ + { + '1': [ + [0, 1, -2], + [-3, 4, 5], + [6, -7, 8], + ], + xlab: 'Albumin..Mass.volume..in.Serum.or.Plasma', + ylab: 'Alanine.aminotransferase..Enzymatic.activity.volume..in.Serum.or.Plasma', + x: [ + 3.4371, 3.5494, 3.6617, 3.774, 3.8863, 3.9986, 4.1109, 4.2233, 4.3356, + 4.4479, + ], + y: [5.2062, 11.7548, 18.3034, 24.852, 31.4006, 37.9492, 44.4978], + }, + ], + ], +}; + +const invMatrix = [ + [0, -3, 6], + [1, 4, -7], + [-2, 5, 8], +]; + +const createExp = (): Experiment => ({ + id: 'dummy-id', + name: 'Testing purpose', + algorithm: { + name: DescriptiveHandler.ALGO_NAME, + }, + datasets: ['dataset1', 'dataset2'], + domain: 'sophia', + variables: [ + 'Alanine.aminotransferase..Enzymatic.activity.volume..in.Serum.or.Plasma', + ], + coVariables: ['Albumin..Mass.volume..in.Serum.or.Plasma.global'], + results: [], +}); + +describe('Descriptive handler', () => { + let descriptiveHandler: DescriptiveHandler; + let exp: Experiment; + + beforeEach(() => { + descriptiveHandler = new DescriptiveHandler(); + exp = createExp(); + }); + + describe('handle standard', () => { + it('should output 3 results', () => { + descriptiveHandler.handle(exp, data, []); + const heatmapResult = exp.results[2] as HeatMapResult; + + expect(exp.results).toHaveLength(3); + expect(heatmapResult.matrix).toStrictEqual(invMatrix); + }); + }); + + describe('handle without heatmaps', () => { + it('should output only 2 results', () => { + const specificData = { ...data, heatmaps: [] }; + descriptiveHandler.handle(exp, specificData, []); + + expect(exp.results).toHaveLength(2); + }); + }); +}); diff --git a/api/src/engine/connectors/datashield/handlers/algorithms/descriptive.handler.ts b/api/src/engine/connectors/datashield/handlers/algorithms/descriptive.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0f017443294f0fa89ad80c1f9bbcbabfafe231c --- /dev/null +++ b/api/src/engine/connectors/datashield/handlers/algorithms/descriptive.handler.ts @@ -0,0 +1,123 @@ +import * as jsonata from 'jsonata'; +import { Experiment } from '../../../../models/experiment/experiment.model'; +import { + AlertLevel, + AlertResult, +} from '../../../../models/result/alert-result.model'; +import { Variable } from '../../../../models/variable.model'; +import BaseHandler from '../base.handler'; + +const transformToDescriptiveStats = jsonata(` +( + $clearLabel := function($label) { + $trim($replace($label, '.', ' ')) + }; + + $histoNumber := function($v, $k) { + { + "name": $clearLabel($k), + "headers": ['5%','10%','25%','50%','75%','90%','95%','Mean'].{ + "name": $, + "type": "string" + }, + "data": [[$v]] + } + }; + + $histoNominal := function($v, $k) { + { + "name": $clearLabel($k), + "headers": $keys($v).{ + "name": $, + "type": "string" + }, + "data": [[$v.*]] + } + }; + + $append(quants.$each(function($v, $k) {( + $t := $type($v); + $t = "array" ? $histoNumber($v, $k) : ($t = "object" ? $histoNominal($v, $k) : undefined) + )}), heatmaps.( + $.'1' ? { + "name": '', + "xAxis": { + "label": $clearLabel($.xlab), + "categories": $.x + }, + "yAxis": { + "label": $clearLabel($.ylab), + "categories": $.y + }, + "matrix": $transposeMat($.'1') + } : [])) +) +`); + +transformToDescriptiveStats.registerFunction( + 'transposeMat', + (matrix) => { + if (!matrix) return [[]]; + + const invMatrix = []; + + for (let i = 0; i < matrix[0].length; i++) { + invMatrix.push([]); + for (const elem of matrix) { + invMatrix[i].push(elem[i]); + } + } + + return invMatrix; + }, + '<a<a<n>:a<a<n>>', +); + +export default class DescriptiveHandler extends BaseHandler { + public static readonly ALGO_NAME = 'descriptive_stats'; + + canHandle(algorithm: string, data: unknown): boolean { + return ( + algorithm.toLowerCase() === DescriptiveHandler.ALGO_NAME && + data && + (data['quants'] || data['heatmaps']) + ); + } + + getErrors(data: unknown): AlertResult[] { + const errors = []; + if ( + data['heatmaps'] && + data['heatmaps'][0] && + typeof data['heatmaps'][0][0] === 'string' + ) { + errors.push({ + message: 'Heatmaps error: ' + data['heatmaps'][0][0], + level: AlertLevel.ERROR, + }); + } + + if (data['quants']) { + for (const [key, value] of Object.entries(data['quants'])) { + if (typeof value === 'string') { + errors.push({ + message: `Table '${key}' error: ` + value, + level: AlertLevel.ERROR, + }); + } + } + } + + return errors; + } + + handle(experiment: Experiment, data: unknown, vars: Variable[]): void { + if (!this.canHandle(experiment.algorithm.name, data)) + return this.next?.handle(experiment, data, vars); + + const results = transformToDescriptiveStats.evaluate(data); + const errors = this.getErrors(data); + + experiment.results.push(...errors, ...results); + } +} diff --git a/api/src/engine/connectors/datashield/handlers/index.ts b/api/src/engine/connectors/datashield/handlers/index.ts index 9948a2fcf9d6e9a9c6db071210202530a7a418e1..f15627552879b6f8755d3bee2403665e5036ffca 100644 --- a/api/src/engine/connectors/datashield/handlers/index.ts +++ b/api/src/engine/connectors/datashield/handlers/index.ts @@ -1,5 +1,6 @@ import { Variable } from 'src/engine/models/variable.model'; import { Experiment } from '../../../../engine/models/experiment/experiment.model'; +import DescriptiveHandler from './algorithms/descriptive.handler'; import ErrorAlgorithmHandler from './algorithms/error-algorithm.handler'; import LinearRegressionHandler from './algorithms/linear-regression.handler'; import LogisticRegressionHandler from './algorithms/logistic-regression.handler'; @@ -8,6 +9,7 @@ import TerminalAlgorithmHandler from './algorithms/terminal-algorithm.handler'; const start = new ErrorAlgorithmHandler(); start + .setNext(new DescriptiveHandler()) .setNext(new LinearRegressionHandler()) .setNext(new LogisticRegressionHandler()) .setNext(new TerminalAlgorithmHandler());