diff --git a/api/src/auth/auth.resolver.ts b/api/src/auth/auth.resolver.ts index d01434af79f15ea82ac7168abc5f1b1b7c64e31d..4742be80f7a9237c6787596412a7618aa3bbb8b4 100644 --- a/api/src/auth/auth.resolver.ts +++ b/api/src/auth/auth.resolver.ts @@ -10,7 +10,6 @@ import { Response, Request } from 'express'; import { CurrentUser } from '../common/decorators/user.decorator'; import { GQLRequest } from '../common/decorators/gql-request.decoractor'; import { GQLResponse } from '../common/decorators/gql-response.decoractor'; -import { parseToBoolean } from '../common/utilities'; import { ENGINE_MODULE_OPTIONS, ENGINE_SERVICE, @@ -23,6 +22,7 @@ import { GlobalAuthGuard } from './guards/global-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard'; import { AuthenticationInput } from './inputs/authentication.input'; import { AuthenticationOutput } from './outputs/authentication.output'; +import { parseToBoolean } from '../common/utils/shared.utils'; //Custom defined type because Pick<CookieOptions, 'sameSite'> does not work type SameSiteType = boolean | 'lax' | 'strict' | 'none' | undefined; diff --git a/api/src/common/utilities.ts b/api/src/common/utilities.ts deleted file mode 100644 index 92d045784438e0181d5e80c85e85c20e82234fbf..0000000000000000000000000000000000000000 --- a/api/src/common/utilities.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - HttpException, - InternalServerErrorException, - NotFoundException, - RequestTimeoutException, - UnauthorizedException, -} from '@nestjs/common'; -import axios from 'axios'; - -export const errorAxiosHandler = (e: any) => { - if (!axios.isAxiosError(e)) throw new InternalServerErrorException(e); - - if (e.response) { - if (e.response.status === 401) throw new UnauthorizedException(); - if (e.response.status === 404) throw new NotFoundException(); - if (e.response.status === 408) throw new RequestTimeoutException(); - if (e.response.status === 500) throw new InternalServerErrorException(); - if (e.response.status && e.response.status) - throw new HttpException(e.response.data, e.response.status); - } - - throw new InternalServerErrorException('Unknown error'); -}; - -/** - * Parse a string to a boolean - * @param {string} value - The value to parse. - * @param [defaultValue=false] - The default value to return if the value is not a valid boolean. - * @returns A boolean value. - */ -export const parseToBoolean = ( - value: string, - defaultValue = false, -): boolean => { - try { - if (value.toLowerCase() == 'true') return true; - return value.toLowerCase() == 'false' ? false : defaultValue; - } catch { - return defaultValue; - } -}; diff --git a/api/src/common/utilities.spec.ts b/api/src/common/utils/shared.utils.spec.ts similarity index 96% rename from api/src/common/utilities.spec.ts rename to api/src/common/utils/shared.utils.spec.ts index 88f5fb323c1fe3c5de33008cabf3060fa141b8fe..b521a2e27cd3dde32c0dea92ba4f6f97eb5f374f 100644 --- a/api/src/common/utilities.spec.ts +++ b/api/src/common/utils/shared.utils.spec.ts @@ -6,8 +6,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import axios from 'axios'; -import { response } from 'express'; -import { errorAxiosHandler, parseToBoolean } from './utilities'; +import { errorAxiosHandler, parseToBoolean } from './shared.utils'; describe('Utility parseToBoolean testing', () => { it('Parse true string to boolean', () => { diff --git a/api/src/common/utils/shared.utils.ts b/api/src/common/utils/shared.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f1ed083dd7caeee9ccbe56f82c94dd77d0c10b2 --- /dev/null +++ b/api/src/common/utils/shared.utils.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/ban-types */ +/* eslint-disable @typescript-eslint/no-use-before-define */ + +import { + BadRequestException, + HttpException, + InternalServerErrorException, + NotFoundException, + RequestTimeoutException, + UnauthorizedException, +} from '@nestjs/common'; +import axios from 'axios'; + +export const errorAxiosHandler = (e: any) => { + if (!axios.isAxiosError(e)) throw new InternalServerErrorException(e); + + if (e.response) { + if (e.response.status === 401) throw new UnauthorizedException(); + if (e.response.status === 404) throw new NotFoundException(); + if (e.response.status === 408) throw new RequestTimeoutException(); + if (e.response.status === 500) throw new InternalServerErrorException(); + if (e.response.status) + throw new HttpException(e.response.data, e.response.status); + } + + throw new InternalServerErrorException('Unknown error'); +}; + +/** + * It rounds a number to a given number of decimal places + * @param {number} val - the number to be rounded + * @param [decimal=2] - The number of decimal places to round to. + * @param [keepSmallNumber=true] - If true, it will keep the number in exponential form if it's smaller + * than 1/coef. + * @returns Formatted string number + */ +export const floatRound = ( + val: number, + decimal = 2, + keepSmallNumber = true, +) => { + const n = Math.trunc(decimal); + + if (n < 0) throw new Error('decimal cannot be negative number'); + + const coef = Math.pow(10, n); + + if (keepSmallNumber && val !== 0 && val < 1 / coef) { + return val.toExponential(n); + } + + return (Math.round(val * coef) / coef).toString(); +}; + +/** + * Parse a string to a boolean + * @param {string} value - The value to parse. + * @param [defaultValue=false] - The default value to return if the value is not a valid boolean. + * @returns A boolean value. + */ +export const parseToBoolean = ( + value: string, + defaultValue = false, +): boolean => { + try { + if (value.toLowerCase() == 'true') return true; + return value.toLowerCase() == 'false' ? false : defaultValue; + } catch { + return defaultValue; + } +}; + +export const isUndefined = (obj: any): obj is undefined => + typeof obj === 'undefined'; + +export const isObject = (fn: any): fn is object => + !isNil(fn) && typeof fn === 'object'; + +export const isPlainObject = (fn: any): fn is object => { + if (!isObject(fn)) { + return false; + } + const proto = Object.getPrototypeOf(fn); + if (proto === null) { + return true; + } + const ctor = + Object.prototype.hasOwnProperty.call(proto, 'constructor') && + proto.constructor; + return ( + typeof ctor === 'function' && + ctor instanceof ctor && + Function.prototype.toString.call(ctor) === + Function.prototype.toString.call(Object) + ); +}; + +export const isFunction = (val: any): boolean => typeof val === 'function'; +export const isString = (val: any): val is string => typeof val === 'string'; +export const isNumber = (val: any): val is number => typeof val === 'number'; +export const isConstructor = (val: any): boolean => val === 'constructor'; +export const isNil = (val: any): val is null | undefined => + isUndefined(val) || val === null; +export const isEmpty = (array: any): boolean => !(array && array.length > 0); +export const isSymbol = (val: any): val is symbol => typeof val === 'symbol'; diff --git a/api/src/config/matomo.config.ts b/api/src/config/matomo.config.ts index 5e7fa269a4913509846da9d367fc607954c7ee52..dacfd178f90073e950116aedf8e296dfaa557808 100644 --- a/api/src/config/matomo.config.ts +++ b/api/src/config/matomo.config.ts @@ -1,5 +1,5 @@ import { registerAs } from '@nestjs/config'; -import { parseToBoolean } from 'src/common/utilities'; +import { parseToBoolean } from 'src/common/utils/shared.utils'; export default registerAs('matomo', () => { return { diff --git a/api/src/engine/connectors/datashield/main.connector.ts b/api/src/engine/connectors/datashield/main.connector.ts index 153ceac93cb2968d1da019977b2b147345d3cd1b..487da9bbdd57964ce1824d5f69f20b484d861fe3 100644 --- a/api/src/engine/connectors/datashield/main.connector.ts +++ b/api/src/engine/connectors/datashield/main.connector.ts @@ -11,7 +11,7 @@ import { ExperimentResult, MIME_TYPES, } from 'src/common/interfaces/utilities.interface'; -import { errorAxiosHandler } from 'src/common/utilities'; +import { errorAxiosHandler } from 'src/common/utils/shared.utils'; import { ENGINE_MODULE_OPTIONS } from 'src/engine/engine.constants'; import { IConfiguration, 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 index 5b25340ea67578d4367e003807f19a76f190fc59..42ff6da38781827fe8393159aac7c8e35840a8af 100644 --- 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 @@ -109,6 +109,6 @@ export default class AnovaOneWayHandler extends BaseHandler { const meanPlot = this.getMeanPlot(data); if (meanPlot && meanPlot.pointCIs) exp.results.push(meanPlot); - super.handle(exp, data); // continue request + return super.handle(exp, data); // continue request } } diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/linear-regression.handler.spec.ts b/api/src/engine/connectors/exareme/handlers/algorithms/linear-regression.handler.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c29918a110de73ac3b02b7d3a0994ce607efe24 --- /dev/null +++ b/api/src/engine/connectors/exareme/handlers/algorithms/linear-regression.handler.spec.ts @@ -0,0 +1,60 @@ +import { Experiment } from '../../../../models/experiment/experiment.model'; +import LinearRegressionHandler from './linear-regression.handler'; + +const data = { + dependent_var: 'lefthippocampus', + n_obs: 15431, + df_resid: 1540.0, + df_model: 2.0, + rse: 0.1270107560405171, + r_squared: 0.8772983534917347, + r_squared_adjusted: 0.8771390007040616, + f_stat: 5505.38441342865, + f_pvalue: 0.0, + indep_vars: ['Intercept', 'righthippocampus', 'leftamygdala'], + coefficients: [0.2185676251985193, 0.611894589820809, 1.0305204881766319], + std_err: [0.029052606790014847, 0.016978263425746872, 0.05180007458246667], + t_stats: [7.523167431352125, 36.03988078621131, 19.894189274496593], + pvalues: [ + 9.04278019740564e-14, 8.833386164556705e-207, 1.4580450464941301e-78, + ], + lower_ci: [0.16158077395909892, 0.5785916308422961, 0.9289143512210847], + upper_ci: [0.2755544764379397, 0.6451975487993219, 1.132126625132179], +}; + +const createExperiment = (): Experiment => ({ + id: 'dummy-id', + name: 'Testing purpose', + algorithm: { + name: 'LINEAR_REGRESSION', + }, + datasets: ['desd-synthdata'], + domain: 'dementia', + variables: ['righthippocampus'], + coVariables: ['leftamygdala'], + results: [], +}); + +describe('Linear regression result handler', () => { + let linearHandler: LinearRegressionHandler; + let experiment: Experiment; + + beforeEach(() => { + linearHandler = new LinearRegressionHandler(); + experiment = createExperiment(); + }); + + describe('Handle', () => { + it('with standard linear algo data', () => { + linearHandler.handle(experiment, data); + + expect(experiment.results.length === 2); + }); + it('Should be empty with another algo', () => { + experiment.algorithm.name = 'dummy_algo'; + linearHandler.handle(experiment, data); + + expect(experiment.results.length === 0); + }); + }); +}); diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/linear-regression.handler.ts b/api/src/engine/connectors/exareme/handlers/algorithms/linear-regression.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc9236ff40ee25e7522d629c4cea568e75fa00d5 --- /dev/null +++ b/api/src/engine/connectors/exareme/handlers/algorithms/linear-regression.handler.ts @@ -0,0 +1,101 @@ +import { isNumber } from '../../../../../common/utils/shared.utils'; +import { Experiment } from '../../../../models/experiment/experiment.model'; +import { + TableResult, + TableStyle, +} from '../../../../models/result/table-result.model'; +import BaseHandler from '../base.handler'; + +const NUMBER_PRECISION = 4; +const ALGO_NANE = 'linear_regression'; +const lookupDict = { + dependent_var: 'Dependent variable', + n_obs: 'Number of observations', + df_resid: 'Residual degrees of freedom', + df_model: 'Model degrees of freedom', + rse: 'Residual standard error', + r_squared: 'R-squared', + r_squared_adjusted: 'Adjusted R-squared', + f_stat: 'F statistic', + f_pvalue: 'P{>F}', + indep_vars: 'Independent variables', + coefficients: 'Coefficients', + std_err: 'Std.Err.', + t_stats: 't-statistics', + pvalues: 'P{>|t|}', + lower_ci: 'Lower 95% c.i.', + upper_ci: 'Upper 95% c.i.', +}; + +export default class LinearRegressionHandler extends BaseHandler { + private getModel(data: any): TableResult | undefined { + const excepts = ['n_obs']; + const tableModel: TableResult = { + name: 'Model', + tableStyle: TableStyle.NORMAL, + headers: ['name', 'value'].map((name) => ({ name, type: 'string' })), + data: [ + 'dependent_var', + 'n_obs', + 'df_resid', + 'df_model', + 'rse', + 'r_squared', + 'r_squared_adjusted', + 'f_stat', + 'f_pvalue', + ].map((name) => [ + lookupDict[name], + isNumber(data[name]) && !excepts.includes(name) + ? data[name].toPrecision(NUMBER_PRECISION) + : data[name], + ]), + }; + + return tableModel; + } + + private getCoefficients(data: any): TableResult | undefined { + const keys = [ + 'indep_vars', + 'coefficients', + 'std_err', + 't_stats', + 'pvalues', + 'lower_ci', + 'upper_ci', + ]; + const tabKeys = keys.slice(1); + + const tableCoef: TableResult = { + name: 'Coefficients', + tableStyle: TableStyle.NORMAL, + headers: keys.map((name) => ({ + name: lookupDict[name], + type: 'string', + })), + data: data.indep_vars.map((variable, i) => { + const row = tabKeys + .map((key) => data[key][i]) + .map((val) => + isNumber(val) ? val.toPrecision(NUMBER_PRECISION) : val, + ); + row.unshift(variable); + return row; + }), + }; + + return tableCoef; + } + + handle(experiment: Experiment, data: any): void { + if (experiment.algorithm.name.toLowerCase() !== ALGO_NANE) + return super.handle(experiment, data); + + const model = this.getModel(data); + if (model) experiment.results.push(model); + + const coefs = this.getCoefficients(data); + if (coefs) experiment.results.push(coefs); + } +} diff --git a/api/src/engine/connectors/exareme/handlers/base.handler.ts b/api/src/engine/connectors/exareme/handlers/base.handler.ts index 6f84123e0514cfe937c48366bccbacd7763d99cb..5934cf82008004b964a52601b2af21c08d9357d3 100644 --- a/api/src/engine/connectors/exareme/handlers/base.handler.ts +++ b/api/src/engine/connectors/exareme/handlers/base.handler.ts @@ -12,7 +12,7 @@ export default abstract class BaseHandler implements ResultHandler { return h; } - handle(partialExperiment: Experiment, data: unknown): void { - this.next?.handle(partialExperiment, data); + handle(experiment: Experiment, data: unknown): void { + this.next?.handle(experiment, data); } } diff --git a/api/src/engine/connectors/exareme/handlers/index.ts b/api/src/engine/connectors/exareme/handlers/index.ts index 2d7d607d5dac7ebc553536c30f1a3b4c2acd6514..1dca0fce5e88d570f1f7f754be57a30b766d1045 100644 --- a/api/src/engine/connectors/exareme/handlers/index.ts +++ b/api/src/engine/connectors/exareme/handlers/index.ts @@ -3,6 +3,7 @@ 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'; +import LinearRegressionHandler from './algorithms/linear-regression.handler'; import PCAHandler from './algorithms/PCA.handler'; import PearsonHandler from './algorithms/pearson.handler'; import RawHandler from './algorithms/raw.handler'; @@ -15,6 +16,7 @@ start .setNext(new HeatMapHandler()) .setNext(new AnovaOneWayHandler()) .setNext(new PCAHandler()) + .setNext(new LinearRegressionHandler()) .setNext(new RawHandler()); // should be last handler as it works as a fallback (if other handlers could not process the results) export default (exp: Experiment, data: unknown): Experiment => { diff --git a/api/src/engine/engine.resolver.ts b/api/src/engine/engine.resolver.ts index 9a20cc5db5cfca459099aeabbc722e824cc55085..84ff2afbb7f04f97af6072d29fe25fd7d380079c 100644 --- a/api/src/engine/engine.resolver.ts +++ b/api/src/engine/engine.resolver.ts @@ -4,10 +4,10 @@ import { Args, Query, Resolver } from '@nestjs/graphql'; import { Request } from 'express'; import { Public } from 'src/auth/decorators/public.decorator'; import { GlobalAuthGuard } from 'src/auth/guards/global-auth.guard'; +import { parseToBoolean } from 'src/common/utils/shared.utils'; import { Md5 } from 'ts-md5'; import { authConstants } from '../auth/auth-constants'; import { GQLRequest } from '../common/decorators/gql-request.decoractor'; -import { parseToBoolean } from '../common/utilities'; import { ENGINE_MODULE_OPTIONS, ENGINE_ONTOLOGY_URL,