diff --git a/api/src/engine/connectors/datashield/main.connector.ts b/api/src/engine/connectors/datashield/main.connector.ts index 454654e8e774617de4ef68b5e99f9e15a8b1918c..207900198b7094596015f5efc85a30b0b6881e29 100644 --- a/api/src/engine/connectors/datashield/main.connector.ts +++ b/api/src/engine/connectors/datashield/main.connector.ts @@ -27,7 +27,7 @@ import { ListExperiments } from 'src/engine/models/experiment/list-experiments.m import { RawResult } from 'src/engine/models/result/raw-result.model'; import { TableResult, - ThemeType, + TableStyle, } from 'src/engine/models/result/table-result.model'; import { User } from 'src/users/models/user.model'; import { @@ -141,7 +141,7 @@ export default class DataShieldService implements IEngineService { const table = transformToTable.evaluate(data); return { ...table, - theme: ThemeType.NORMAL, + tableStyle: TableStyle.NORMAL, }; } diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.spec.ts b/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.spec.ts index 9aa0115d8daa56a9ab36233472c86aa1c48025b9..f390eea800eb08fb341b1ee4b6e7158791855702 100644 --- a/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.spec.ts +++ b/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.spec.ts @@ -1,7 +1,7 @@ import { Experiment } from '../../../../models/experiment/experiment.model'; import { HeatMapResult } from '../../../../models/result/heat-map-result.model'; import handlers from '..'; -import { BarChartResult } from 'src/engine/models/result/bar-chart-result.model'; +import { BarChartResult } from '../../../../models/result/bar-chart-result.model'; const createExperiment = (): Experiment => ({ id: 'dummy-id', diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.spec.ts b/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4cd6dcf6a9f3cd8c1d837b8a46b4d310842b874 --- /dev/null +++ b/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.spec.ts @@ -0,0 +1,159 @@ +import handlers from '..'; +import { Experiment } from '../../../../models/experiment/experiment.model'; +import AnovaOneWayHandler from './anova-one-way.handler'; + +const createExperiment = (): Experiment => ({ + id: 'dummy-id', + name: 'Testing purpose', + algorithm: { + id: 'Anova_OnEway', + }, + datasets: ['desd-synthdata'], + domain: 'dementia', + variables: ['rightcerebralwhitematter'], + coVariables: ['ppmicategory'], + results: [], +}); + +describe('Anova oneway result handler', () => { + const anovaHandler = new AnovaOneWayHandler(); + const data = { + x_label: 'Variable X', + y_label: 'Variable Y', + df_residual: 1424.0, + df_explained: 3.0, + ss_residual: 1941.1517872154072, + ss_explained: 23.52938815624377, + ms_residual: 1.3631683898984601, + ms_explained: 7.843129385414589, + p_value: 0.0006542139533101455, + f_stat: 5.753602741623733, + tuckey_test: [ + { + groupA: 'GENPD', + groupB: 'HC', + meanA: 10.200898765432095, + meanB: 10.50253333333334, + diff: -0.3016345679012442, + se: 0.11017769051976001, + t_stat: -2.737710025308137, + p_tuckey: 0.03178790563153744, + }, + { + groupA: 'GENPD', + groupB: 'PD', + meanA: 10.200898765432095, + meanB: 10.530083456790125, + diff: -0.3291846913580301, + se: 0.10048653456497285, + t_stat: -3.2759084864767125, + p_tuckey: 0.005936908999390811, + }, + { + groupA: 'GENPD', + groupB: 'PRODROMA', + meanA: 10.200898765432095, + meanB: 10.161453333333334, + diff: 0.039445432098760946, + se: 0.1534957169892615, + t_stat: 0.2569806693793321, + p_tuckey: 0.9, + }, + { + groupA: 'HC', + groupB: 'PD', + meanA: 10.50253333333334, + meanB: 10.530083456790125, + diff: -0.02755012345678587, + se: 0.07353521425604895, + t_stat: -0.37465211375949203, + p_tuckey: 0.9, + }, + { + groupA: 'HC', + groupB: 'PRODROMA', + meanA: 10.50253333333334, + meanB: 10.161453333333334, + diff: 0.34108000000000516, + se: 0.13737110045731235, + t_stat: 2.4829094246500176, + p_tuckey: 0.0630887851749381, + }, + { + groupA: 'PD', + groupB: 'PRODROMA', + meanA: 10.530083456790125, + meanB: 10.161453333333334, + diff: 0.368630123456791, + se: 0.1297275582960786, + t_stat: 2.8415714309172655, + p_tuckey: 0.02355122851783331, + }, + ], + min_per_group: [ + { + GENPD: 7.2276, + HC: 7.2107, + PD: 7.0258, + PRODROMA: 6.3771, + }, + ], + max_per_group: [ + { + GENPD: 13.7312, + HC: 14.52, + PD: 14.4812, + PRODROMA: 12.3572, + }, + ], + ci_info: { + sample_stds: { + GENPD: 1.2338388511229372, + HC: 1.1276421260632183, + PD: 1.16245855322075, + PRODROMA: 1.197046185656396, + }, + means: { + GENPD: 10.200898765432095, + HC: 10.50253333333334, + PD: 10.530083456790125, + PRODROMA: 10.161453333333334, + }, + 'm-s': { + GENPD: 8.967059914309157, + HC: 9.374891207270121, + PD: 9.367624903569375, + PRODROMA: 8.964407147676939, + }, + 'm+s': { + GENPD: 11.434737616555033, + HC: 11.630175459396558, + PD: 11.692542010010875, + PRODROMA: 11.35849951898973, + }, + }, + }; + + it('Test anova 1 way handler', () => { + const exp = createExperiment(); + const table1 = anovaHandler.getSummaryTable(data, exp.coVariables[0]); + const table2 = anovaHandler.getTuckeyTable(data); + const meanPlot = anovaHandler.getMeanPlot(data); + + handlers(exp, data); + + expect(exp.results.length).toBeGreaterThanOrEqual(3); + expect(exp.results).toContainEqual(table1); + expect(exp.results).toContainEqual(table2); + expect(exp.results).toContainEqual(meanPlot); + + expect(table1.data[0].length).toEqual(6); + expect(table2.headers.length).toEqual(8); + expect(table2.data).toBeTruthy(); + + expect(meanPlot.pointCIs.length).toBeGreaterThan(1); + expect(meanPlot.name).toEqual( + `Mean Plot: ${data.y_label} ~ ${data.x_label}`, + ); + }); +}); diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.ts b/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8d9a6750785d57df2f2637572d306cd313935ee --- /dev/null +++ b/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.ts @@ -0,0 +1,114 @@ +import * as jsonata from 'jsonata'; // old import style needed due to 'export = jsonata' +import { MeanChartResult } from 'src/engine/models/result/means-chart-result.model'; +import { Experiment } from '../../../../models/experiment/experiment.model'; +import { + TableResult, + TableStyle, +} from '../../../../models/result/table-result.model'; +import BaseHandler from '../base.handler'; + +export default class AnovaOneWayHandler extends BaseHandler { + private static readonly tuckeyTransform = jsonata(` + { + "name": 'Tuckey Honest Significant Differences', + "headers": [ + {"name": 'A', "type": 'string'}, + {"name": 'B', "type": 'string'}, + {"name": 'Mean A', "type": 'string'}, + {"name": 'Mean B', "type": 'string'}, + {"name": 'Diff', "type": 'string'}, + {"name": 'Standard error', "type": 'string'}, + {"name": 'T value', "type": 'string'}, + {"name": 'P value', "type": 'string'} + ], + "data": tuckey_test.[$.groupA, $.groupB, $.meanA, $.meanB, $.diff, $.se, $.t_stat, $.p_tuckey] + } + `); + + private static readonly meanPlotTransform = jsonata(` + ( + $cats:= $keys(ci_info.means); + { + "name": "Mean Plot: " & y_label & ' ~ ' & x_label, + "xAxis": { + "label": x_label, + "categories": $cats + }, + "yAxis": { + "label": '95% CI: ' & y_label + }, + "pointCIs": $cats.[{ + "min": $lookup($$.ci_info.'m-s', $), + "mean": $lookup($$.ci_info.means, $), + "max": $lookup($$.ci_info.'m+s', $) + }] + }) + `); + + canHandle(algorithm: string): boolean { + return algorithm.toLocaleLowerCase() === 'anova_oneway'; + } + + getTuckeyTable(data: unknown): TableResult | undefined { + const tableData = AnovaOneWayHandler.tuckeyTransform.evaluate(data); + + if (!tableData) return undefined; + + const tableResult: TableResult = { + ...tableData, + tableStyle: TableStyle.NORMAL, + } as unknown as TableResult; + + return tableResult; + } + + getSummaryTable(data: unknown, varname: string): TableResult | undefined { + const tableSummary: TableResult = { + name: 'Annova summary', + tableStyle: TableStyle.NORMAL, + headers: ['', 'DF', 'SS', 'MS', 'F ratio', 'P value'].map((name) => ({ + name, + type: 'string', + })), + data: [ + [ + varname, + data['df_explained'], + data['ss_explained'], + data['ms_explained'], + data['p_value'], + data['f_stat'], + ], + [ + 'Residual', + data['df_residual'], + data['ss_residual'], + data['ms_residual'], + '', + '', + ], + ], + }; + + return tableSummary; + } + + getMeanPlot(data: unknown): MeanChartResult { + return AnovaOneWayHandler.meanPlotTransform.evaluate(data); + } + + handle(exp: Experiment, data: unknown): void { + if (!this.canHandle(exp.algorithm.id)) return super.handle(exp, data); + + const summaryTable = this.getSummaryTable(data, exp.coVariables[0]); + if (summaryTable) exp.results.push(summaryTable); + + const tuckeyTable = this.getTuckeyTable(data); + if (tuckeyTable) exp.results.push(tuckeyTable); + + const meanPlot = this.getMeanPlot(data); + if (meanPlot && meanPlot.pointCIs) exp.results.push(meanPlot); + + super.handle(exp, data); // continue request + } +} diff --git a/api/src/engine/connectors/exareme/handlers/index.ts b/api/src/engine/connectors/exareme/handlers/index.ts index f9c6c47a8a5dd4dd9b12a817fe77054baf9e85d5..2d7d607d5dac7ebc553536c30f1a3b4c2acd6514 100644 --- a/api/src/engine/connectors/exareme/handlers/index.ts +++ b/api/src/engine/connectors/exareme/handlers/index.ts @@ -1,4 +1,5 @@ import { Experiment } from '../../../../engine/models/experiment/experiment.model'; +import AnovaOneWayHandler from './algorithms/anova-one-way.handler'; import AreaHandler from './algorithms/area.handler'; import DescriptiveHandler from './algorithms/descriptive.handler'; import HeatMapHandler from './algorithms/heat-map.handler'; @@ -12,6 +13,7 @@ start .setNext(new AreaHandler()) .setNext(new DescriptiveHandler()) .setNext(new HeatMapHandler()) + .setNext(new AnovaOneWayHandler()) .setNext(new PCAHandler()) .setNext(new RawHandler()); // should be last handler as it works as a fallback (if other handlers could not process the results) diff --git a/api/src/engine/models/result/common/result-union.model.ts b/api/src/engine/models/result/common/result-union.model.ts index 5c2ffc69597c6b1afc39ea624972586285d6d3e4..0e385962665d0ad0708c84b4597273b4678f55d8 100644 --- a/api/src/engine/models/result/common/result-union.model.ts +++ b/api/src/engine/models/result/common/result-union.model.ts @@ -1,10 +1,11 @@ import { createUnionType } from '@nestjs/graphql'; -import { BarChartResult } from '../bar-chart-result.model'; import { GroupsResult } from '../groups-result.model'; import { HeatMapResult } from '../heat-map-result.model'; import { LineChartResult } from '../line-chart-result.model'; import { RawResult } from '../raw-result.model'; import { TableResult } from '../table-result.model'; +import { BarChartResult } from '../bar-chart-result.model'; +import { MeanChartResult } from '../means-chart-result.model'; export const ResultUnion = createUnionType({ name: 'ResultUnion', @@ -15,6 +16,7 @@ export const ResultUnion = createUnionType({ HeatMapResult, LineChartResult, BarChartResult, + MeanChartResult, ], resolveType(value) { if (value.headers) { @@ -37,6 +39,10 @@ export const ResultUnion = createUnionType({ return BarChartResult; } + if (value.pointCIs) { + return MeanChartResult; + } + return RawResult; }, }); diff --git a/api/src/engine/models/result/means-chart-result.model.ts b/api/src/engine/models/result/means-chart-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..12dd97416c4ab90afd28d3c26556ba66a8c9ebe5 --- /dev/null +++ b/api/src/engine/models/result/means-chart-result.model.ts @@ -0,0 +1,33 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { ChartAxis } from './common/chart-axis.model'; +import { Result } from './common/result.model'; + +@ObjectType() +export class PointCI { + @Field({ nullable: true }) + min?: number; + + @Field() + mean: number; + + @Field({ nullable: true }) + max?: number; +} + +@ObjectType() +export class MeanChartResult extends Result { + @Field() + name: string; + + @Field(() => ChartAxis, { nullable: true }) + xAxis?: ChartAxis; + + @Field(() => ChartAxis, { nullable: true }) + yAxis?: ChartAxis; + + @Field(() => [PointCI], { + description: 'List of points with confidence information: min, mean, max', + defaultValue: [], + }) + pointCIs: PointCI[]; +} diff --git a/api/src/engine/models/result/table-result.model.ts b/api/src/engine/models/result/table-result.model.ts index a733683d607f6f8bfbaa9d6cec5884620a76cceb..85fa6eeba8c7f2eba961fb9773159190f313923a 100644 --- a/api/src/engine/models/result/table-result.model.ts +++ b/api/src/engine/models/result/table-result.model.ts @@ -2,13 +2,13 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Header } from './common/header.model'; import { Result } from './common/result.model'; -export enum ThemeType { +export enum TableStyle { DEFAULT, NORMAL, } -registerEnumType(ThemeType, { - name: 'ThemeType', +registerEnumType(TableStyle, { + name: 'TableStyle', }); @ObjectType() @@ -22,6 +22,6 @@ export class TableResult extends Result { @Field(() => [Header]) headers: Header[]; - @Field(() => ThemeType, { defaultValue: ThemeType.DEFAULT, nullable: true }) - theme?: ThemeType; + @Field(() => TableStyle, { defaultValue: TableStyle.DEFAULT, nullable: true }) + tableStyle?: TableStyle; } diff --git a/api/src/main/app.module.ts b/api/src/main/app.module.ts index 56ef2df6fe32a0198a818a8b035418c44e54ad76..4f784efb09a185d903b603ad1ec5c677afd9c703 100644 --- a/api/src/main/app.module.ts +++ b/api/src/main/app.module.ts @@ -53,7 +53,6 @@ import { AppService } from './app.service'; migrations: ['dist/migrations/*{.ts,.js}'], migrationsRun: process.env.NODE_ENV !== 'dev', synchronize: process.env.NODE_ENV === 'dev', - loggerLevel: 'debug', autoLoadEntities: true, }), }), diff --git a/api/src/schema.gql b/api/src/schema.gql index 8a0e2400bf297c9d066ef68dc77ec2cee3b7672f..95da1492e47ee6d0470960adf26763505a6c1d4d 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -85,30 +85,22 @@ type Algorithm { description: String } -type ChartAxis { - """label of the Axis""" - label: String - - """label of each element on this Axis""" - categories: [String!] -} - type GroupResult { name: String! description: String results: [ResultUnion!]! } -union ResultUnion = TableResult | RawResult | GroupsResult | HeatMapResult | LineChartResult | BarChartResult +union ResultUnion = TableResult | RawResult | GroupsResult | HeatMapResult | LineChartResult | BarChartResult | MeanChartResult type TableResult { name: String! data: [[String!]!]! headers: [Header!]! - theme: ThemeType + tableStyle: TableStyle } -enum ThemeType { +enum TableStyle { DEFAULT NORMAL } @@ -157,6 +149,23 @@ type BarChartResult { hasConnectedBars: Boolean } +type MeanChartResult { + name: String! + xAxis: ChartAxis + yAxis: ChartAxis + + """List of points with confidence information: min, mean, max""" + pointCIs: [PointCI!]! +} + +type ChartAxis { + """label of the Axis""" + label: String + + """label of each element on this Axis""" + categories: [String!] +} + type ExtraLineInfo { label: String! values: [String!]! @@ -180,6 +189,12 @@ type Header { type: String! } +type PointCI { + min: Float + mean: Float! + max: Float +} + type Author { username: String fullname: String