From a0b945b4505452d09774ddc3c0096b12cf1e6e83 Mon Sep 17 00:00:00 2001
From: Steve Reis <stevereis93@gmail.com>
Date: Mon, 4 Apr 2022 10:14:30 +0000
Subject: [PATCH] feat(exareme): Add Anova one way integration

---
 .../connectors/datashield/main.connector.ts   |   4 +-
 .../handlers/algorithms/PCA.handler.spec.ts   |   2 +-
 .../algorithms/anova-one-way.handler.spec.ts  | 159 ++++++++++++++++++
 .../algorithms/anova-one-way.handler.ts       | 114 +++++++++++++
 .../connectors/exareme/handlers/index.ts      |   2 +
 .../result/common/result-union.model.ts       |   8 +-
 .../models/result/means-chart-result.model.ts |  33 ++++
 .../models/result/table-result.model.ts       |  10 +-
 api/src/main/app.module.ts                    |   1 -
 api/src/schema.gql                            |  37 ++--
 10 files changed, 349 insertions(+), 21 deletions(-)
 create mode 100644 api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.spec.ts
 create mode 100644 api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.ts
 create mode 100644 api/src/engine/models/result/means-chart-result.model.ts

diff --git a/api/src/engine/connectors/datashield/main.connector.ts b/api/src/engine/connectors/datashield/main.connector.ts
index 454654e..2079001 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 9aa0115..f390eea 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 0000000..c4cd6dc
--- /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 0000000..c8d9a67
--- /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 f9c6c47..2d7d607 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 5c2ffc6..0e38596 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 0000000..12dd974
--- /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 a733683..85fa6ee 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 56ef2df..4f784ef 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 8a0e240..95da149 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
-- 
GitLab