diff --git a/.github/workflows/cli-test.yml b/.github/workflows/cli-test.yml new file mode 100644 index 0000000..9c0b907 --- /dev/null +++ b/.github/workflows/cli-test.yml @@ -0,0 +1,37 @@ +name: Can be run as CLI + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Use Node.js + uses: actions/setup-node@v2 + with: + node-version: '18.x' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Install CLI globally + run: npm install -g . + + - name: Test CLI + run: | + # Run the CLI with the example file + OUTPUT=$(ipssm test/data/IPSSMexample.csv test.csv) + echo "$OUTPUT" + + # Check the stout for the expected output + if ! echo "$OUTPUT" | grep -q "File annotated successfully."; then + echo "CLI execution didn't work" + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/risk-scores-test.yml b/.github/workflows/risk-scores-test.yml index e3e8628..719f5f5 100644 --- a/.github/workflows/risk-scores-test.yml +++ b/.github/workflows/risk-scores-test.yml @@ -1,10 +1,6 @@ name: Compute IPSS-M and IPSS-M Risks on IWG-PM Cohort (Bernard et al, 2022 NJEM Evid) -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] +on: [push] jobs: build: @@ -12,7 +8,7 @@ jobs: strategy: matrix: - node-version: [ 14.x ] + node-version: [ 18.x ] steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 36250d1..befa34b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ # Node modules node_modules -test/data/*-out.* \ No newline at end of file + +# Compiled JS files +dist + +# Data test files +test/data/*-out.* +test.csv \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f91af43..4121b73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,13 +10,18 @@ "license": "MIT", "dependencies": { "exceljs": "^4.3.0", + "ipssm": "^1.0.4", "papaparse": "^5.4.1", "yargs": "^17.7.2" }, "bin": { - "ipssm": "bin/cli.js" + "ipssm": "src/cli.js" }, "devDependencies": { + "@types/node": "^20.10.4", + "@types/papaparse": "^5.3.14", + "@types/yargs": "^17.0.32", + "typescript": "^5.3.3", "vitest": "^0.34.4" } }, @@ -449,9 +454,36 @@ } }, "node_modules/@types/node": { - "version": "20.6.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.6.1.tgz", - "integrity": "sha512-4LcJvuXQlv4lTHnxwyHQZ3uR9Zw2j7m1C9DfuwoTFQQP4Pmu04O6IfLYgMmHoOCt0nosItLLZAH+sOrRE0Bo8g==", + "version": "20.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.4.tgz", + "integrity": "sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, "node_modules/@vitest/expect": { @@ -1174,6 +1206,19 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ipssm": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ipssm/-/ipssm-1.0.4.tgz", + "integrity": "sha512-NXmxyIw7OlfWmzlVfNS5eub5Qz+ip2FEN+P/w6hWVHLie6iy7/J4pInYdh1P75b5VXVplEn9ErKk1B3WEVmtLA==", + "dependencies": { + "exceljs": "^4.3.0", + "papaparse": "^5.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "ipssm": "bin/cli.js" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1831,12 +1876,31 @@ "node": ">=4" } }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/ufo": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.0.tgz", "integrity": "sha512-bRn3CsoojyNStCZe0BG0Mt4Nr/4KF+rhFlnNXybgqt5pXHNFRlqinSoQaTrGyzE4X8aHplSb+TorH+COin9Yxw==", "dev": true }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unzipper": { "version": "0.10.14", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", diff --git a/package.json b/package.json index a2b0af1..db4d93a 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,16 @@ "version": "1.0.4", "description": "Javascript Package for the Molecular International Prognostic Scoring System (IPSS-M) for Myelodysplastic Syndromes.", "main": "index.js", - "type": "module", + "type": "commonjs", "bin": { - "ipssm": "./bin/cli.js" + "ipssm": "./dist/cli.js" }, "directories": { "test": "test" }, "scripts": { - "test": "vitest" + "test": "vitest", + "build": "tsc" }, "repository": { "type": "git", @@ -30,10 +31,15 @@ }, "homepage": "https://github.com/papaemmelab/ipssm-js#readme", "devDependencies": { + "@types/node": "^20.10.4", + "@types/papaparse": "^5.3.14", + "@types/yargs": "^17.0.32", + "typescript": "^5.3.3", "vitest": "^0.34.4" }, "dependencies": { "exceljs": "^4.3.0", + "ipssm": "^1.0.4", "papaparse": "^5.4.1", "yargs": "^17.7.2" } diff --git a/bin/cli.js b/src/cli.ts similarity index 85% rename from bin/cli.js rename to src/cli.ts index 1ecf7a6..b7aaefa 100755 --- a/bin/cli.js +++ b/src/cli.ts @@ -2,13 +2,13 @@ import yargs from 'yargs' import { hideBin } from 'yargs/helpers' -import { annotateFile } from '../index.js' +import { annotateFile } from './index' const argv = yargs(hideBin(process.argv)) .usage('Annotate file with IPSS-M and IPSS-R.\n\nUsage: $0 ') .command( '$0 ', - '', + 'Annotate with IPSS-M and IPSS-R a file with patients.', (yargs) => { yargs .positional('inputFile', { @@ -27,13 +27,14 @@ const argv = yargs(hideBin(process.argv)) .example('$0 in.csv out.csv', 'annotate a csv file.') .help('h') .alias('h', 'help') - .argv + .argv; (async () => { try { + // @ts-ignore await annotateFile(argv.inputFile, argv.outputFile) console.log('File annotated successfully.') } catch (error) { - console.error('Error annotating file:', error.message) + console.error('Error annotating file:', (error as Error).message) } })() \ No newline at end of file diff --git a/index.js b/src/index.ts similarity index 65% rename from index.js rename to src/index.ts index ce717f9..6324fe0 100644 --- a/index.js +++ b/src/index.ts @@ -1,21 +1,22 @@ -import { processInputs } from './utils/preprocess.js' -import { computeIpssm, computeIpssr as ipssr } from './utils/risk.js' -import { parseCsv, parseXlsx, writeCsv, writeXlsx } from './utils/parseFile.js' +import { PatientInput, PatientOutput, IpssmScores, PatientForIpssr, CsvData } from './types' +import { processInputs } from './utils/preprocess' +import { computeIpssm, computeIpssr as ipssr } from './utils/risk' +import { parseCsv, parseXlsx, writeCsv, writeXlsx } from './utils/parseFile' // IPSS-M risk score method -const ipssm = (patientInput) => { +const ipssm = (patientInput: PatientInput): IpssmScores => { const processed = processInputs(patientInput) return computeIpssm(processed) } // IPSS-M, IPSS-R, and IPSS-RA risks score from a csv/xlsx file method -const annotateFile = async (inputFile, outputFile, skipIpssr=false) => { +const annotateFile = async (inputFile: string, outputFile: string, skipIpssr: boolean = false): Promise => { if (!inputFile || !outputFile) { throw new Error('Input and output files are required') } - let patients = [] + let patients: PatientInput[] = [] if (inputFile.endsWith('.csv') || inputFile.endsWith('.tsv')) { patients = await parseCsv(inputFile) } else if (inputFile.endsWith('.xlsx')) { @@ -24,10 +25,11 @@ const annotateFile = async (inputFile, outputFile, skipIpssr=false) => { throw new Error('File type not supported') } - const annotatedPatients = patients.map((patient) => { + const annotatedPatients: PatientOutput[] = patients.map((patient) => { + // Calculate IPSS-M and add to patient object const ipssmResult = ipssm(patient) - // Add IPSS-M results to patient object - patient = { + + let annotatedPatient: PatientOutput = { ...patient, IPSSM_SCORE: ipssmResult.means.riskScore, IPSSM_CAT: ipssmResult.means.riskCat, @@ -37,27 +39,29 @@ const annotateFile = async (inputFile, outputFile, skipIpssr=false) => { IPSSM_CAT_WORST: ipssmResult.worst.riskCat, } - if (!skipIpssr) { - const ipssrResult = ipssr({ + if (!skipIpssr && patient.ANC !== undefined && patient.ANC !== null) { + // Calculate IPSS-R and add to patient object + const data: PatientForIpssr = { + bmblast: patient.BM_BLAST, hb: patient.HB, - anc: patient.ANC, plt: patient.PLT, - bmblast: patient.BM_BLAST, + anc: patient.ANC, cytoIpssr: patient.CYTO_IPSSR, age: patient.AGE, - }) + } + + const ipssrResult = ipssr(data) - // Add IPSS-R results to patient object - patient = { - ...patient, + annotatedPatient = { + ...annotatedPatient, IPSSR_SCORE: ipssrResult.IPSSR_SCORE, - IPSSR_CAT: ipssrResult.IPSSR, + IPSSR_CAT: ipssrResult.IPSSR_CAT, IPSSRA_SCORE: ipssrResult.IPSSRA_SCORE, - IPSSRA_CAT: ipssrResult.IPSSRA, + IPSSRA_CAT: ipssrResult.IPSSRA_CAT, } } - return patient + return annotatedPatient }) // Create new csv file with annotated patients diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2d60840 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,145 @@ +/* + Define export interfaces for patient data and IPSS scores +*/ + +export interface PatientInput { + // Clinical Data + BM_BLAST: number; + HB: number; + PLT: number; + ANC?: number; + AGE?: number; + // Cytogenetic Data + del5q: string; + del7_7q: string; + del17_17p: string; + complex: string; + CYTO_IPSSR: string; + // Molecular Data + TP53mut?: string; + TP53maxvaf?: number; + TP53loh?: string; + MLL_PTD?: string; + FLT3?: string; + ASXL1?: string; + CBL?: string; + DNMT3A?: string; + ETV6?: string; + EZH2?: string; + IDH2?: string; + KRAS?: string; + NPM1?: string; + NRAS?: string; + RUNX1?: string; + SF3B1?: string; + SRSF2?: string; + U2AF1?: string; + BCOR?: string; + BCORL1?: string; + CEBPA?: string; + ETNK1?: string; + GATA2?: string; + GNB1?: string; + IDH1?: string; + NF1?: string; + PHF6?: string; + PPM1D?: string; + PRPF8?: string; + PTPN11?: string; + SETBP1?: string; + STAG2?: string; + WT1?: string; + // Intermediate Variables + Nres2: {[key: string]: number}; + SF3B1_5q?: string; + SF3B1_alpha?: string; + TP53multi?: string; + HB1?: number; + TRANSF_PLT100?: number; + BLAST5?: number; + CYTOVEC?: number; +} + +/* + IPSS-R types +*/ + +interface PatientForIpssrWithCytoNumber { + // Clinical Data needed to compute Ippsr (Age is optional) + bmblast: number; + hb: number; + plt: number; + anc: number; + age?: number; + cytovec: number; + cytoIpssr?: string; +} +interface PatientForIpssrWithCytoString { + // Clinical Data needed to compute Ippsr (Age is optional) + bmblast: number; + hb: number; + plt: number; + anc: number; + age?: number; + cytovec?: number; + cytoIpssr: string; +} + +export type PatientForIpssr = PatientForIpssrWithCytoNumber | PatientForIpssrWithCytoString; + +/* + IPSS-M types +*/ + +interface IpssmScore { + riskScore: number; + riskCat: string; + contributions: {[key: string]: number}; +} + +export interface IpssmScores { + means: IpssmScore; + best: IpssmScore; + worst: IpssmScore; +} + +/* + Output of Annotated Patient Types +*/ + +export interface PatientWithIpssr { + // IPSS-R Risk Score + IPSSR_SCORE?: number; + IPSSR_CAT?: string; + // IPSS-RA Risk Score Age-Adjusted (Optional) + IPSSRA_SCORE?: number | null; + IPSSRA_CAT?: string | null; +} + +export interface PatientWithIpssm { + // IPSS-M Risk Score: Mean, Best, Worst + IPSSM_SCORE: number; + IPSSM_CAT: string; + IPSSM_SCORE_BEST: number; + IPSSM_CAT_BEST: string; + IPSSM_SCORE_WORST: number; + IPSSM_CAT_WORST: string; +} + +export interface PatientOutput extends PatientInput, PatientWithIpssr, PatientWithIpssm {} + +/* + Other types +*/ + +export interface BetaRiskScore { + name: string; + coeff: number; + means: number; + worst: number; + best: number; +} + +export interface CsvData { + [key: string]: number | string +} diff --git a/utils/betasRiskScore.js b/src/utils/betasRiskScore.ts similarity index 95% rename from utils/betasRiskScore.js rename to src/utils/betasRiskScore.ts index 44af380..185f407 100644 --- a/utils/betasRiskScore.js +++ b/src/utils/betasRiskScore.ts @@ -1,4 +1,6 @@ -const betas = [ +import { BetaRiskScore } from '../types' + +const betas: BetaRiskScore[] = [ { name: 'CYTOVEC', coeff: 0.287, means: 1.39, worst: 4, best: 0 }, { name: 'BLAST5', coeff: 0.352, means: 0.922, worst: 4, best: 0 }, { name: 'TRANSF_PLT100', coeff: -0.222, means: 1.41, worst: 0, best: 2.5 }, diff --git a/utils/general.js b/src/utils/general.ts similarity index 68% rename from utils/general.js rename to src/utils/general.ts index 757b550..d98d20f 100644 --- a/utils/general.js +++ b/src/utils/general.ts @@ -1,16 +1,16 @@ // Useful utilities import { genesMain, genesRes } from './genes.js' -const range = (start, stop, step = 1) => { +const range = (start: number, stop: number, step: number = 1): number[] => { return [...Array(stop - start).keys()] .filter((i) => !(i % Math.round(step))) .map((v) => start + v) } -const round = (value, digits = 4) => +const round = (value: number, digits: number = 4): number => Math.round(value * 10 ** digits) / 10 ** digits -const italicizeGeneNames = (string) => { +const italicizeGeneNames = (value: string): string => { const genes = [ ...genesMain, ...genesRes, @@ -19,14 +19,14 @@ const italicizeGeneNames = (string) => { 'KMT2A', 'TP53', ].reverse() - let italicized = string + let italicized = value genes.forEach( (gene) => (italicized = italicized.replace(gene, `${gene}`)) ) return italicized } -const toString = (value) => { +const toString = (value: string | null | undefined): string => { if (value === null || value === undefined) { return 'NA' } diff --git a/utils/genes.js b/src/utils/genes.ts similarity index 80% rename from utils/genes.js rename to src/utils/genes.ts index ba76881..9a3929d 100644 --- a/utils/genes.js +++ b/src/utils/genes.ts @@ -1,4 +1,4 @@ -export const genesMain = [ +export const genesMain: string[] = [ 'SF3B1', 'ASXL1', 'SRSF2', @@ -14,7 +14,7 @@ export const genesMain = [ 'NPM1', ] -export const genesRes = [ +export const genesRes: string[] = [ 'BCOR', 'STAG2', 'NF1', diff --git a/src/utils/parseFile.ts b/src/utils/parseFile.ts new file mode 100644 index 0000000..4803719 --- /dev/null +++ b/src/utils/parseFile.ts @@ -0,0 +1,109 @@ +import { PatientInput, PatientOutput, CsvData } from '../types' +import fs, { promises as asyncFs } from 'fs' +import Papa, { ParseResult } from 'papaparse' +import Excel from 'exceljs' + + +// Coerce numeric values to numbers +const coerceNumeric = (rows: CsvData[]): CsvData[] => { + return rows.map((row) => { + return Object.fromEntries( + Object.entries(row).map(([column, value]) => [ + // @ts-ignore + column, isNaN(value) ? value : Number(value), + ]) + ) + }) +} + +// Read csv or tsv file +const parseCsv = async (inputFile: string): Promise => { + const dataString = await asyncFs.readFile(inputFile, 'utf-8') + const result: ParseResult = Papa.parse(dataString, { header: true, skipEmptyLines: true }) + // @ts-ignore + return coerceNumeric(result.data) +} + +// Read xlsx file +const parseXlsx = async (inputFile: string): Promise => { + const workbook = new Excel.Workbook() + await workbook.xlsx.readFile(inputFile) + + let jsonData: { sheet: string, data: PatientInput[] }[] = [] + workbook.eachSheet((worksheet, _sheetId) => { + let sheetData: PatientInput[] = [] + let headers: any[] = [] + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + if (typeof row?.values?.slice === 'function') { + // If first row, consider it as header + if (rowNumber === 1) { + headers = row.values.slice(1) + return + } + + // Create an object based on the header and row data + const rowData: PatientInput = { + BM_BLAST: 0, + HB: 0, + PLT: 0, + del5q: '', + del7_7q: '', + del17_17p: '', + complex: '', + CYTO_IPSSR: '', + Nres2: {} + } + row?.values?.slice(1).forEach((value, index) => { + // @ts-ignore + rowData[headers[index]] = value + }) + sheetData.push(rowData) + } + }) + jsonData.push({sheet: worksheet.name, data: sheetData}) + }) + + // Find Worksheet with Patient Data + let data: PatientInput[] | null = null + const expectedKeys = ['SF3B1', 'U2AF1', 'IDH1'] + jsonData.forEach((sheet) => { + if (expectedKeys.every(key => sheet.data[0].hasOwnProperty(key))) { + data = sheet.data + } + }) + // @ts-ignore + return coerceNumeric(data || []) +} + +// Write annotated csv file +const writeCsv = async (outputFile: string, data: PatientOutput[]): Promise => { + const csvString = Papa.unparse(data) + fs.writeFileSync(outputFile, csvString, 'utf-8') + + if (!fs.existsSync(outputFile)) { + throw new Error(`Unable to write file ${outputFile}`) + } +} + +// Write annotated xlsx file +const writeXlsx = async (outputFile: string, data: PatientOutput[]): Promise => { + const workbook = new Excel.Workbook() + const worksheet = workbook.addWorksheet('Sheet 1') + + // Assuming `data` is an array of objects with consistent keys + const headers = Object.keys(data[0]) + worksheet.addRow(headers) + + // Add the rows from data + data.forEach(item => { + worksheet.addRow(headers.map(header => item[header as keyof PatientOutput])) + }) + + await workbook.xlsx.writeFile(outputFile) + + if (!fs.existsSync(outputFile)) { + throw new Error(`Unable to write file ${outputFile}`) + } +} + +export { parseCsv, parseXlsx, writeCsv, writeXlsx } \ No newline at end of file diff --git a/utils/preprocess.js b/src/utils/preprocess.ts similarity index 74% rename from utils/preprocess.js rename to src/utils/preprocess.ts index db52876..0d12462 100644 --- a/utils/preprocess.js +++ b/src/utils/preprocess.ts @@ -1,17 +1,18 @@ +import { PatientInput } from '../types.js' import betas from './betasRiskScore.js' import { genesRes } from './genes.js' /** * Calculate the residual genes weigth contribution to the IPPS-M score with missing values. - * @param {Array.} patientRes + * @param {(number | string)[]} patientRes * one entry per residual gene with value 0/1/NA - * @param {Array.} nRef + * @param {number} nRef * number of residual mutated from the reference patient (eg. average) - * @return {number} - + * @return {{ nResMean: number, nResWorst: number, nResBest: number }} * The"generalized" number of mutated genes from residual list */ -const calculateNResMissing = (patientRes, nRef = 0.388) => { +const calculateNResMissing = (patientRes: {[key: string]: string}, nRef: number = 0.388) => { // Number of missing genes const M = Object.values(patientRes).filter((value) => value === 'NA').length // Number of sequenced genes @@ -19,7 +20,7 @@ const calculateNResMissing = (patientRes, nRef = 0.388) => { // Sum of mutated genes within S const Ns = Object.values(patientRes) .filter((value) => value !== 'NA') - .reduce((sum, x) => sum + Number(x), 0) + .reduce((sum: number, x) => sum + Number(x), 0) // Worst scenario: all missing are mutated const nResWorst = Math.min(Ns + M, 2) @@ -36,15 +37,11 @@ const calculateNResMissing = (patientRes, nRef = 0.388) => { /** * Process inputs from user-based variable to model-based variables. - * @param {Object} patientInput = user-input variables - * @param {Object} genesMain = gene/variable names with attributed weights in risk score - * @param {Object} genesRes = gene/variable names in the "residual list" to construct a combined feature (called Nres2) - * @param {Object} betas = risk model variables/weights/scaling - * + * @param {Object} patientInput - user-input variables * @returns {Object} model-based variables */ -const processInputs = (patientInput) => { - const processed = { ...patientInput } +const processInputs = (patientInput: PatientInput): PatientInput => { + const processed: PatientInput = { ...patientInput } // Construction of SF3B1 features i.e SF3B1_5q processed.SF3B1_5q = @@ -53,10 +50,10 @@ const processInputs = (patientInput) => { Number(processed.del5q) === 1 && Number(processed.del7_7q) === 0 && Number(processed.complex) === 0 - ? 1 - : 0 + ? '1' + : '0' : Number(processed.del5q) === 0 - ? 0 + ? '0' : 'NA' processed.SF3B1_alpha = @@ -76,20 +73,20 @@ const processInputs = (patientInput) => { Number(processed.BCORL1) === 0 && Number(processed.RUNX1) === 0 && Number(processed.NRAS) === 0 - ? 1 - : 0 + ? '1' + : '0' : Number(processed.SRSF2) === 1 || Number(processed.STAG2) === 1 || Number(processed.BCOR) === 1 || Number(processed.BCORL1) === 1 || Number(processed.RUNX1) === 1 || Number(processed.NRAS) === 1 - ? 0 + ? '1' : 'NA' // Construction of TP53multi feature processed.TP53loh = - Number(processed.TP53maxvaf) / 100 > 0.55 || + (processed.TP53maxvaf ?? 0) / 100 > 0.55 || Number(processed.del17_17p) === 1 ? '1' : processed.TP53loh @@ -99,13 +96,13 @@ const processInputs = (patientInput) => { processed.TP53multi = processed.TP53mut === '0' - ? 0 + ? '0' : processed.TP53mut === '2 or more' - ? 1 - : (processed.TP53mut === '1') & (processed.TP53loh === '1') - ? 1 - : (processed.TP53mut === '1') & (processed.TP53loh === '0') - ? 0 + ? '1' + : (processed.TP53mut === '1') && (processed.TP53loh === '1') + ? '1' + : (processed.TP53mut === '1') && (processed.TP53loh === '0') + ? '0' : 'NA' // Transformation of clinical variables @@ -122,12 +119,11 @@ const processInputs = (patientInput) => { 'Very Poor': 4, }[processed.CYTO_IPSSR] - // Calculation of number of residual mutations Nres2 - // with generalization to allow some missing genes in the list of residual genes - const processedResGenes = Object.fromEntries( + // Calculate number of residual mutations Nres2 allowing missing genes in the list + const processedResGenes: {[key: string]: string} = Object.fromEntries( Object.entries(processed).filter(([key, _]) => genesRes.includes(key)) ) - const nRes2Means = betas.find((i) => i.name === 'Nres2').means + const nRes2Means = betas.find((i) => i.name === 'Nres2')?.means const { nResMean, nResWorst, nResBest } = calculateNResMissing( processedResGenes, nRes2Means @@ -138,9 +134,7 @@ const processInputs = (patientInput) => { worst: nResWorst, best: nResBest, } - return processed } - export { processInputs } \ No newline at end of file diff --git a/utils/risk.js b/src/utils/risk.ts similarity index 50% rename from utils/risk.js rename to src/utils/risk.ts index d140aa1..7cdfa99 100644 --- a/utils/risk.js +++ b/src/utils/risk.ts @@ -1,15 +1,16 @@ +import { PatientInput, PatientForIpssr, PatientWithIpssr, IpssmScores, BetaRiskScore } from '../types' import betas from './betasRiskScore.js' import { round } from './general.js' -const ipssrCat = [ +const ipssrCat: string[] = [ 'Very Low', 'Low', 'Int', 'High', 'Very High', ] -const ipssmCat = [ +const ipssmCat: string[] = [ 'Very Low', 'Low', 'Moderate Low', @@ -19,7 +20,12 @@ const ipssmCat = [ ] // Utility to find value between intervals, and map interval number to value -const cutBreak = (value, breaks, mapping, right = true) => { +const cutBreak = ( + value: number, + breaks: number[], + mapping: (number | string)[], + right: boolean = true, +): number | string => { for (let i = 1; i < breaks.length; i++) { if (right) { // Intervals are closed to the right, and open to the left @@ -33,14 +39,15 @@ const cutBreak = (value, breaks, mapping, right = true) => { } } } + return NaN // default value } /** * Compute IPSS-R risk score and risk categories + * @param {number} bmblast - bone marrow blasts, in % * @param {number} hb - hemoglobin, in gram per deciliter - * @param {number} anc - absolute neutrophile count, in giga per liter * @param {number} plt - platelet, in giga per liter - * @param {number} bmblast - bone marrow blasts, in % + * @param {number} anc - absolute neutrophile count, in giga per liter * @param {number} cytovec - cytogenetic category in numerical form * @param {number} cytoIpssr - cytogenetic category in categorical form * @param {number} [age] - Age, in years @@ -48,85 +55,63 @@ const cutBreak = (value, breaks, mapping, right = true) => { * @return {Object} dictionary of ipssr and ipssr-age adjusted, score and category. */ const computeIpssr = ( - { hb, anc, plt, bmblast, cytovec, cytoIpssr, age }, - rounding = true, - roundingDigits = 4 -) => { - // build category and score - const cytovecMap = {'Very Good': 0, 'Good': 1, 'Intermediate': 2, 'Poor': 3, 'Very Poor': 4} - + { bmblast, hb, plt, anc, cytovec, cytoIpssr, age }: PatientForIpssr, + rounding: boolean = true, + roundingDigits: number = 4 +) : PatientWithIpssr => { + // Get proper Cytogenetic category + const cytovecMap: { [key: string]: number } = {'Very Good': 0, 'Good': 1, 'Intermediate': 2, 'Poor': 3, 'Very Poor': 4} if (!cytovec && cytoIpssr) { cytovec = cytovecMap[cytoIpssr] } + if (cytovec === undefined || cytovec === null || cytovec < 0 || cytovec > 4) { + throw new Error('Cytogenetic category is not valid.') + } - const hbri = cutBreak( - hb, - [-Infinity, 8, 10, Infinity], - { - 0: 1.5, - 1: 1, - 2: 0, - }, - false - ) - const ancri = cutBreak( - anc, - [-Infinity, 0.8, Infinity], - { 0: 0.5, 1: 0 }, - false - ) - const pltri = cutBreak( - plt, - [-Infinity, 50, 100, Infinity], - { - 0: 1, - 1: 0.5, - 2: 0, - }, - false - ) - const bmblastri = cutBreak( - bmblast, - [-Infinity, 2, 4.99, 10, Infinity], - { - 0: 0, - 1: 1, - 2: 2, - 3: 3, - }, - true - ) + // Get Variable Ranges, defining each category breaks and value mapping + const bmblastBreak = [-Infinity, 2, 4.99, 10, Infinity] + const hbBreak = [-Infinity, 8, 10, Infinity] + const pltBreak = [-Infinity, 50, 100, Infinity] + const ancBreak = [-Infinity, 0.8, Infinity] + const ipssrgBreaks = [-Infinity, 1.5, 3, 4.5, 6, Infinity] - // build raw score - let ipssrRaw = hbri + ancri + pltri + bmblastri + cytovec + const bmblastMap = [0, 1, 2, 3] //{ 0: 0, 1: 1, 2: 2, 3: 3 } + const hbMap = [1.5, 1, 0] //{ 0: 1.5, 1: 1, 2: 0 } + const pltMap = [1, 0.5, 0] //{ 0: 1, 1: 0.5, 2: 0 } + const ancMap = [0.5, 0] //{ 0: 0.5, 1: 0 } + + const bmblastri = Number(cutBreak(bmblast, bmblastBreak, bmblastMap, true)) + const hbri = Number(cutBreak(hb, hbBreak, hbMap, false)) + const pltri = Number(cutBreak(plt, pltBreak, pltMap, false)) + const ancri = Number(cutBreak(anc, ancBreak, ancMap, false)) + + // Build IPSS-R raw score + let ipssr: string | null = null + let ipssrRaw: number | null = null + + ipssrRaw = bmblastri + hbri + pltri + ancri + cytovec if (rounding) { ipssrRaw = round(ipssrRaw, roundingDigits) } + ipssr = cutBreak(ipssrRaw, ipssrgBreaks, ipssrCat).toString() - // build categories - const ipssrgBreaks = [-Infinity, 1.5, 3, 4.5, 6, Infinity] - const ipssr = cutBreak(ipssrRaw, ipssrgBreaks, ipssrCat) + // Build IPSS-RA Age-Adjusted if available + let ipssra: string | null = null + let ipssraRaw: number | null = null - let ipssraRaw = null - let ipssra = null - if (![null, undefined].includes(age)) { - // if age is present build score with age interaction - const ageAdjust = (age - 70) * (0.05 - ipssrRaw * 0.005) + if (age !== null && age !== undefined) { + const ageAdjust = (Number(age) - 70) * (0.05 - ipssrRaw * 0.005) ipssraRaw = ipssrRaw + ageAdjust - if (rounding) { ipssraRaw = round(ipssraRaw, roundingDigits) } - - // build categories - ipssra = cutBreak(ipssraRaw, ipssrgBreaks, ipssrCat) + ipssra = cutBreak(ipssraRaw, ipssrgBreaks, ipssrCat).toString() } - return { IPSSR_SCORE: ipssrRaw, - IPSSR: ipssr, + IPSSR_CAT: ipssr, IPSSRA_SCORE: ipssraRaw, - IPSSRA: ipssra, + IPSSRA_CAT: ipssra, } } @@ -141,23 +126,39 @@ const computeIpssr = ( */ const computeIpssm = ( - patientValues, - rounding = true, - roundingDigits = 2, - cutpoints = [-1.5, -0.5, 0, 0.5, 1.5] -) => { + patientValues: PatientInput, + rounding: boolean = true, + roundingDigits: number = 2, + cutpoints: number[] = [-1.5, -0.5, 0, 0.5, 1.5] +) : IpssmScores => { // relative risk contribution of each variable. log2 is just a scaling factor - const scores = {} - const scenarios = ['means', 'worst', 'best'] + const scores: IpssmScores = { + means: { + riskScore: 0, + riskCat: '', + contributions: {}, + }, + worst: { + riskScore: 0, + riskCat: '', + contributions: {}, + }, + best: { + riskScore: 0, + riskCat: '', + contributions: {}, + }, + } - scenarios.forEach((scenario) => { - const contributions = {} + Object.keys(scores).forEach((scenario) => { + const contributions: {[key: string]: number} = {} betas.forEach((beta) => { + let value = patientValues[beta.name as keyof PatientInput] + // Impute if missing variable - let value = patientValues[beta.name] if (value === 'NA' || value === null) { - value = beta[scenario] + value = beta[scenario as keyof BetaRiskScore] } if (beta.name === 'Nres2') { value = patientValues.Nres2[scenario] @@ -165,11 +166,11 @@ const computeIpssm = ( // Contribution Normalization contributions[beta.name] = - ((value - beta.means) * beta.coeff) / Math.log(2) + ((Number(value) - beta.means) * beta.coeff) / Math.log(2) }) // risk score - let riskScore = Object.values(contributions).reduce((sum, x) => sum + x, 0) + let riskScore = Object.values(contributions).reduce((sum: number, x) => sum + x, 0) if (rounding) { riskScore = round(riskScore, roundingDigits) } @@ -179,9 +180,9 @@ const computeIpssm = ( riskScore, [-Infinity, ...cutpoints, Infinity], ipssmCat - ) + ).toString() - scores[scenario] = { + scores[scenario as keyof IpssmScores] = { riskScore: riskScore, riskCat: riskCat, contributions: contributions, diff --git a/test/parseFiles.test.js b/test/parseFiles.test.js index 468a113..260dc1f 100644 --- a/test/parseFiles.test.js +++ b/test/parseFiles.test.js @@ -1,7 +1,7 @@ -import { it, describe, expect } from 'vitest' -import { parseCsv, parseXlsx } from '../utils/parseFile.js' -import { assertExpectedResults } from './testUtils.js' -import { ipssm, ipssr } from '../index.js' +import { it, describe } from 'vitest' +import { assertExpectedResults } from './testUtils' +import { parseCsv, parseXlsx } from '../src/utils/parseFile' +import { ipssm, ipssr } from '../src/index' const runRiskOnPatients = (patients) => { @@ -28,9 +28,9 @@ const runRiskOnPatients = (patients) => { IPSSM_SCORE_WORST: ipssmResult.worst.riskScore, IPSSM_CAT_WORST: ipssmResult.worst.riskCat, IPSSR_SCORE: ipssrResult.IPSSR_SCORE, - IPSSR_CAT: ipssrResult.IPSSR, + IPSSR_CAT: ipssrResult.IPSSR_CAT, IPSSRA_SCORE: ipssrResult.IPSSRA_SCORE, - IPSSRA_CAT: ipssrResult.IPSSRA, + IPSSRA_CAT: ipssrResult.IPSSRA_CAT, } // Assert expected results diff --git a/test/riskCohort.test.js b/test/riskCohort.test.js index 36ebc60..cff2662 100644 --- a/test/riskCohort.test.js +++ b/test/riskCohort.test.js @@ -1,8 +1,8 @@ import { it, describe, expect } from 'vitest' import fs from 'fs' -import { assertScores, round, assertExpectedResults } from './testUtils.js' -import { ipssm, ipssr, annotateFile } from '../index.js' -import { parseCsv, parseXlsx } from '../utils/parseFile.js' +import { assertScores, round, assertExpectedResults } from './testUtils' +import { ipssm, ipssr, annotateFile } from '../src/index' +import { parseCsv, parseXlsx } from '../src/utils/parseFile' describe('Risk Calculations', () => { @@ -45,7 +45,7 @@ describe('Risk Calculations', () => { }, computed: { score: ipssrResult.IPSSR_SCORE, - category: ipssrResult.IPSSR, + category: ipssrResult.IPSSR_CAT, }, 'ID': patient.ID, }) @@ -57,7 +57,7 @@ describe('Risk Calculations', () => { }, computed: { score: ipssrResult.IPSSRA_SCORE, - category: ipssrResult.IPSSRA, + category: ipssrResult.IPSSRA_CAT, }, 'ID': patient.ID, }) diff --git a/test/testUtils.js b/test/testUtils.js index 3d19bf1..40d3f66 100644 --- a/test/testUtils.js +++ b/test/testUtils.js @@ -43,21 +43,21 @@ const expectedResults = { const assertExpectedResults = (patientFields, precision = 0.001) => { const expected = expectedResults[patientFields.ID] - const msg = Object.entries(patientFields).map(([k, v]) => `\n\t${k}: ${v}`).join(', ') + const msg = Object.entries(patientFields).map(([k, v]) => `\n\t${k}: ${v}`).join(', ') + '\n' // Assert IPSS-M means, best and worst - expect(expected.IPSSM_CAT, msg).to.equal(patientFields.IPSSM_CAT) - expect(expected.IPSSM_SCORE, msg).to.be.closeTo(patientFields.IPSSM_SCORE, precision) - expect(expected.IPSSM_CAT_BEST, msg).to.equal(patientFields.IPSSM_CAT_BEST) - expect(expected.IPSSM_SCORE_BEST, msg).to.be.closeTo(patientFields.IPSSM_SCORE_BEST, precision) - expect(expected.IPSSM_CAT_WORST, msg).to.equal(patientFields.IPSSM_CAT_WORST) - expect(expected.IPSSM_SCORE_WORST, msg).to.be.closeTo(patientFields.IPSSM_SCORE_WORST, precision) + expect(patientFields.IPSSM_CAT, msg).to.equal(expected.IPSSM_CAT) + expect(patientFields.IPSSM_CAT_BEST, msg).to.equal(expected.IPSSM_CAT_BEST) + expect(patientFields.IPSSM_CAT_WORST, msg).to.equal(expected.IPSSM_CAT_WORST) + expect(Number(patientFields.IPSSM_SCORE), msg).to.be.closeTo(expected.IPSSM_SCORE, precision) + expect(Number(patientFields.IPSSM_SCORE_BEST), msg).to.be.closeTo(expected.IPSSM_SCORE_BEST, precision) + expect(Number(patientFields.IPSSM_SCORE_WORST), msg).to.be.closeTo(expected.IPSSM_SCORE_WORST, precision) // Assert IPSS-R and IPSS-RA - expect(expected.IPSSR_CAT, msg).to.equal(patientFields.IPSSR_CAT) - expect(expected.IPSSR_SCORE, msg).to.be.closeTo(patientFields.IPSSR_SCORE, precision) - expect(expected.IPSSRA_CAT, msg).to.equal(patientFields.IPSSRA_CAT) - expect(expected.IPSSRA_SCORE, msg).to.be.closeTo(patientFields.IPSSRA_SCORE, precision) + expect(patientFields.IPSSR_CAT, msg).to.equal(expected.IPSSR_CAT) + expect(patientFields.IPSSRA_CAT, msg).to.equal(expected.IPSSRA_CAT) + expect(Number(patientFields.IPSSR_SCORE), msg).to.be.closeTo(expected.IPSSR_SCORE, precision) + expect(Number(patientFields.IPSSRA_SCORE), msg).to.be.closeTo(expected.IPSSRA_SCORE, precision) } const assertScores = ({ expected, computed, ID, type, precision = 0.001 }) => { @@ -66,8 +66,8 @@ const assertScores = ({ expected, computed, ID, type, precision = 0.001 }) => { `\n\tExpected ${expected.score} (${expected.category})` + `\n\tComputed ${computed.score} (${computed.category})` - expect(expected.category, msg).to.equal(computed.category) - expect(expected.score, msg).to.be.closeTo(computed.score, precision) + expect(computed.category, msg).to.equal(expected.category) + expect(computed.score, msg).to.be.closeTo(expected.score, precision) console.log('✅ ' + msg) } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e706966 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "outDir": "./dist" + } +} \ No newline at end of file diff --git a/utils/parseFile.js b/utils/parseFile.js deleted file mode 100644 index 8d862b3..0000000 --- a/utils/parseFile.js +++ /dev/null @@ -1,89 +0,0 @@ -import fs, { promises as asyncFs } from 'fs' -import Papa from 'papaparse' -import Excel from 'exceljs' - -// Coerce numeric values to numbers -const coerceNumeric = (patients) => { - return patients.map((patient) => { - return Object.fromEntries( - Object.entries(patient).map(([header, value]) => [ - header, isNaN(value) ? value : Number(value), - ]) - ) - }) -} - -// Read csv or tsv file -const parseCsv = async (inputFile) => { - const dataString = await asyncFs.readFile(inputFile, 'utf-8') - const { data } = Papa.parse(dataString, { header: true, skipEmptyLines: true }) - return coerceNumeric(data) -} - -// Read xlsx file -const parseXlsx = async (inputFile) => { - const workbook = new Excel.Workbook() - await workbook.xlsx.readFile(inputFile) - - let jsonData = [] - workbook.eachSheet((worksheet, sheetId) => { - let sheetData = [] - let headers = [] - worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { - // If first row, consider it as header - if (rowNumber === 1) { - headers = row.values.slice(1) - return - } - // Create an object based on the header and row data - const rowData = {} - row.values.slice(1).forEach((value, index) => { - rowData[headers[index]] = value - }) - sheetData.push(rowData) - }) - jsonData.push({sheet: worksheet.name, data: sheetData}) - }) - - // Find Worksheet with Patient Data - let data = null - const expectedKeys = ['BM_BLAST', 'HB', 'PLT'] - jsonData.forEach((sheet) => { - if (expectedKeys.every(key => sheet.data[0].hasOwnProperty(key))) { - data = sheet.data - } - }) - return coerceNumeric(data) -} - -// Write annotated csv file -const writeCsv = async (outputFile, data) => { - const csvString = Papa.unparse(data) - fs.writeFileSync(outputFile, csvString, 'utf-8') - - if (!fs.existsSync(outputFile)) { - throw new Error(`Unable to write file ${outputFile}`) - } -} - -// Write annotated xlsx file -const writeXlsx = async (outputFile, data) => { - const workbook = new Excel.Workbook() - const worksheet = workbook.addWorksheet('Sheet 1') - - // Assuming `data` is an array of objects with consistent keys - const headers = Object.keys(data[0]) - worksheet.addRow(headers) - - // Add the rows from data - data.forEach(item => { - worksheet.addRow(headers.map(header => item[header])) - }) - - await workbook.xlsx.writeFile(outputFile) - - if (!fs.existsSync(outputFile)) { - throw new Error(`Unable to write file ${outputFile}`) - } -} -export { parseCsv, parseXlsx, writeCsv, writeXlsx } \ No newline at end of file