├── .gitignore ├── src ├── decs.d.ts ├── calculateSUVbsaScalingFactor.ts ├── dateTimeToFullDateInterface.ts ├── index.ts ├── calculateStartTime.ts ├── types.ts ├── calculateSUVlbmScalingFactor.ts ├── parseTM.ts ├── parseDA.ts ├── combineDateTime.ts ├── calculateScanTimes.ts └── calculateSUVScalingFactors.ts ├── babel.config.js ├── .github └── workflows │ ├── size.yml │ ├── release.yml │ └── main.yml ├── jest.config.ts ├── test ├── calculateSuvBsaScalingFactor.test.ts ├── readNifti.ts ├── calculateSuvLbmScalingFactor.test.ts ├── calculateStartTime.test.ts ├── metadata │ ├── CPS_AND_BQML_AC_DT_-_S_DT-instances.json │ ├── BQML_AC_DT_lessThan_S_DT_SIEMENS-instances.json │ ├── RADIOPHARM_DATETIME_UNDEFINED-instances.json │ ├── PHILIPS_CNTS_AND_SUV-instances.json │ ├── SIEMENS-instances.json │ ├── PHILIPS_CNTS_AND_BQML_SUV-instances.json │ ├── GE_MEDICAL_AND_BQML-instances.json │ └── PHILIPS_BQML-instances.json ├── calculateSUV_SampleData.test.ts ├── calculateSUV_SampleData.test-not-used.ts ├── parseTM.test.ts ├── parseDA.test.ts ├── readDICOMFolder.ts ├── combineDateTime.test.ts ├── calculateScanTimes.test.ts └── calculateSUVScalingFactors.test.ts ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── tsconfig.json ├── package.json └── README-tsdx.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | coverage 5 | dist -------------------------------------------------------------------------------- /src/decs.d.ts: -------------------------------------------------------------------------------- 1 | // Only used for tests 2 | declare module 'nifti-js'; 3 | declare module 'dcmjs'; 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // babel.config.js 2 | module.exports = { 3 | presets: [ 4 | ['@babel/preset-env', {targets: {node: 'current'}}], 5 | '@babel/preset-typescript', 6 | ], 7 | plugins: [ 8 | '@babel/plugin-proposal-optional-chaining' 9 | ] 10 | }; -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '.(ts|tsx)': 'ts-jest' 4 | }, 5 | transformIgnorePatterns: [ '[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$' ], 6 | moduleFileExtensions: [ 'ts', 'tsx', 'js', 'jsx', 'json', 'node' ], 7 | collectCoverageFrom: [ 'src/**/*.{ts,tsx}' ], 8 | testMatch: [ '/**/*.(spec|test).{ts,tsx}' ], 9 | rootDir: '.', 10 | setupFilesAfterEnv: ['./jest.setup.js'], 11 | }; -------------------------------------------------------------------------------- /test/calculateSuvBsaScalingFactor.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | calculateSUVbsaScalingFactor, 3 | SUVbsaScalingFactorInput, 4 | } from '../src/calculateSUVbsaScalingFactor'; 5 | 6 | let input: SUVbsaScalingFactorInput = { 7 | PatientWeight: 75, // kg 8 | PatientSize: 1.85, // m 9 | }; 10 | 11 | describe('calculateSUVbsaScalingFactor', () => { 12 | it('calculates ', () => { 13 | expect(calculateSUVbsaScalingFactor(input)).toEqual(19813.758427117766); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /test/readNifti.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import niftijs from 'nifti-js'; 3 | 4 | export default function readNifti(path = './test/data/test.nii') { 5 | let rawData = fs.readFileSync(path); 6 | 7 | // TODO: May want to use nifti-reader-js instead so we can store gzipped data 8 | let data = niftijs.parse(rawData); 9 | 10 | // DICOM data is in LPS, but some of our ground truth is RAS 11 | // Maybe this is causing some of the issues with the sample data? 12 | return data.data; 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "name": "vscode-jest-tests", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 9 | "args": [ 10 | "--runInBand" 11 | ], 12 | "cwd": "${workspaceFolder}", 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "disableOptimisticBPs": true 16 | }, 17 | ] 18 | } -------------------------------------------------------------------------------- /src/calculateSUVbsaScalingFactor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Javascript object with patient properties size, sez, weight 3 | * 4 | * @export 5 | * @interface SUVbsaScalingFactorInput 6 | */ 7 | interface SUVbsaScalingFactorInput { 8 | PatientSize: number; 9 | PatientWeight: number; 10 | } 11 | 12 | function calculateSUVbsaScalingFactor( 13 | inputs: SUVbsaScalingFactorInput 14 | ): number { 15 | const { PatientWeight, PatientSize } = inputs; 16 | 17 | let BSA = 18 | Math.pow(PatientWeight, 0.425) * Math.pow(PatientSize * 100, 0.725) * 71.84; 19 | 20 | return BSA; 21 | } 22 | 23 | export { calculateSUVbsaScalingFactor, SUVbsaScalingFactorInput }; 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: 18.x 22 | 23 | - name: Install dependencies 24 | run: yarn install --frozen-lockfile 25 | 26 | - name: Release 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | run: npx semantic-release -------------------------------------------------------------------------------- /src/dateTimeToFullDateInterface.ts: -------------------------------------------------------------------------------- 1 | import combineDateTime, { FullDateInterface } from './combineDateTime'; 2 | import parseDA from './parseDA'; 3 | import parseTM from './parseTM'; 4 | 5 | /** 6 | * Utility to create a FullDateInterface object given a string formatted as yyyy-mm-ddTHH:MM:SS.FFFFFFZ 7 | * 8 | * @export 9 | * @param {string} dateTime 10 | * @returns {FullDateInterface} 11 | */ 12 | export default function dateTimeToFullDateInterface( 13 | dateTime: string 14 | ): FullDateInterface { 15 | if (dateTime === undefined || dateTime === null) { 16 | throw new Error('dateTimeToFullDateInterface : dateTime not defined.'); 17 | } 18 | 19 | const date = parseDA(dateTime.substring(0, 8)); 20 | const time = parseTM(dateTime.substring(8)); 21 | return combineDateTime(date, time); 22 | } 23 | 24 | export { dateTimeToFullDateInterface }; 25 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['16.x'] 11 | os: [ubuntu-latest] #, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Precision Imaging Metrics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import calculateSUVScalingFactors from './calculateSUVScalingFactors'; 2 | import { InstanceMetadata, PhilipsPETPrivateGroup } from './types'; 3 | 4 | export { calculateSUVScalingFactors }; 5 | 6 | // TODO: import type and export type are not working right with tsdx 7 | // we should stop using it. 8 | export { InstanceMetadata, PhilipsPETPrivateGroup }; 9 | 10 | /* 11 | 12 | RadiopharmaceuticalInformationSequence : 13 | Item #0 xfffee000 14 | Radiopharmaceutical : "Fluorodeoxyglucose" 15 | RadiopharmaceuticalStartTime : "083045.000000" 16 | RadionuclideTotalDose : "374000000" 17 | RadionuclideHalfLife : "6586.2" 18 | RadionuclidePositronFraction : "0.97" 19 | RadiopharmaceuticalStartDateTime : "20140618083045.000000" 20 | RadionuclideCodeSequence : 21 | Item #0 xfffee000 22 | CodeValue : "C-111A1" 23 | CodingSchemeDesignator : "SRT" 24 | CodeMeaning : "^18^Fluorine" 25 | MappingResource : "DCMR" 26 | ContextGroupVersion : "20070625000000.000000" 27 | ContextIdentifier : "4020" 28 | RadiopharmaceuticalCodeSequence : 29 | Item #0 xfffee000 30 | CodeValue : "C-B1031" 31 | CodingSchemeDesignator : "SRT" 32 | CodeMeaning : "Fluorodeoxyglucose F^18^" 33 | MappingResource : "DCMR" 34 | ContextGroupVersion : "20070625000000.000000" 35 | ContextIdentifier : "4021" 36 | 37 | */ 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @cornerstonejs/calculate-suv 2 | A tiny library for calculating Standardized Uptake Value (SUV) for Nuclear Medicine (i.e. PET, SPECT) 3 | 4 | This library follows the logic laid out by the [Quantitative Imaging Biomarkers Alliance (QIBA)](https://www.rsna.org/research/quantitative-imaging-biomarkers-alliance) on their [Wiki page](https://qibawiki.rsna.org/index.php/Standardized_Uptake_Value_(SUV)). 5 | 6 | Special thanks to [Salim Kanoun MD](https://github.com/salimkanoun) from [The Cancer University Institute of Toulouse - Oncopole](https://www.oncopole-toulouse.com/en/iuct-oncopole) and [Lysarc](https://lymphoma-research-experts.org/lysarc/) for their assistance. 7 | 8 | ### How does it work? 9 | You provide an array of instance/frame metadata corresponding to slices in a PET acquisition. The function returns an array of scaling factors, one per slice, which you can use to obtain SUV values. 10 | 11 | ### Why does the function require an array of metadata values? 12 | This is required because some PET acquisitions have metadata in individual frames which needs to be taken into account. 13 | 14 | ### What about private tags 15 | The logic in the library is laid out by QIBA, and takes into account private tags included by several vendors (GE, Siemens, Philips). If these tags are provided, they will be used, but they are not required to be provided. If you have values in these tags in your dataset, you must provide them because otherwise you will not obtain the correct result. -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // interop between ESM and CJS modules. Recommended by TS 25 | "esModuleInterop": true, 26 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 27 | "skipLibCheck": true, 28 | // error out if import and file system have a casing mismatch. Recommended by TS 29 | "forceConsistentCasingInFileNames": true, 30 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 31 | "noEmit": true, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/calculateStartTime.ts: -------------------------------------------------------------------------------- 1 | import combineDateTime, { FullDateInterface } from './combineDateTime'; 2 | import { parseDA, DateInterface } from './parseDA'; 3 | import { parseTM, TimeInterface } from './parseTM'; 4 | import dateTimeToFullDateInterface from './dateTimeToFullDateInterface'; 5 | 6 | /** 7 | * Calculate start time 8 | * 9 | * @export 10 | * @param {{ 11 | * RadiopharmaceuticalStartDateTime?: string; 12 | * RadiopharmaceuticalStartTime?: string; 13 | * SeriesDate?: string; 14 | * }} input 15 | * @returns {FullDateInterface} 16 | */ 17 | export default function calculateStartTime(input: { 18 | RadiopharmaceuticalStartDateTime?: string; 19 | RadiopharmaceuticalStartTime?: string; 20 | SeriesDate?: string; 21 | }): FullDateInterface { 22 | const { 23 | RadiopharmaceuticalStartDateTime, 24 | RadiopharmaceuticalStartTime, 25 | SeriesDate, 26 | } = input; 27 | 28 | let time: TimeInterface; 29 | let date: DateInterface; 30 | if (RadiopharmaceuticalStartDateTime) { 31 | return dateTimeToFullDateInterface(RadiopharmaceuticalStartDateTime); 32 | } else if (RadiopharmaceuticalStartTime && SeriesDate) { 33 | // start Date is not explicit - assume same as Series Date; 34 | // but consider spanning midnight 35 | // TODO: do we need some logic to check if the scan went over midnight? 36 | time = parseTM(RadiopharmaceuticalStartTime); 37 | date = parseDA(SeriesDate); 38 | 39 | return combineDateTime(date, time); 40 | } 41 | 42 | throw new Error(`Invalid input: ${input}`); 43 | } 44 | 45 | export { calculateStartTime }; 46 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Philips specific dicom header metadata 3 | * 4 | * @export 5 | * @interface PhilipsPETPrivateGroup 6 | */ 7 | export interface PhilipsPETPrivateGroup { 8 | SUVScaleFactor: number | undefined; // 0x7053,0x1000 9 | ActivityConcentrationScaleFactor: number | undefined; // 0x7053,0x1009 10 | } 11 | 12 | /** 13 | * Dicom header metadata 14 | * 15 | * @export 16 | * @interface InstanceMetadata 17 | */ 18 | export interface InstanceMetadata { 19 | CorrectedImage: string[] | string; // The dcmjs naturalize produces a string value for single item arrays :-( 20 | Units: string; // 'BQML' | 'CNTS' | 'GML'; // Units (0x0054,0x1001) 21 | RadionuclideHalfLife: number; // RadionuclideHalfLife(0x0018,0x1075) in Radiopharmaceutical Information Sequence(0x0054,0x0016) 22 | RadionuclideTotalDose: number; 23 | DecayCorrection: string; //'ADMIN' | 'START'; 24 | PatientWeight: number; 25 | SeriesDate: string; 26 | SeriesTime: string; 27 | AcquisitionDate: string; 28 | AcquisitionTime: string; 29 | 30 | // Marked as optional but at least either RadiopharmaceuticalStartDateTime 31 | // or both RadiopharmaceuticalStartTime and SeriesDate are required. 32 | RadiopharmaceuticalStartTime?: string; // From the old version of the DICOM standard 33 | RadiopharmaceuticalStartDateTime?: string; 34 | 35 | PhilipsPETPrivateGroup?: PhilipsPETPrivateGroup; 36 | GEPrivatePostInjectionDateTime?: string; // (0x0009,0x100d,“GEMS_PETD_01” 37 | 38 | // Only used in Siemens case 39 | FrameReferenceTime?: number; 40 | ActualFrameDuration?: number; 41 | 42 | // Only used for SUL 43 | PatientSize?: number; 44 | PatientSex?: string; //'M' | 'F'; 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cornerstonejs/calculate-suv", 3 | "version": "0.0.0-semantic-release", 4 | "license": "MIT", 5 | "main": "dist/index.js", 6 | "typings": "dist/index.d.ts", 7 | "module": "dist/calculate-suv.esm.js", 8 | "sideEffects": false, 9 | "files": [ 10 | "dist", 11 | "src" 12 | ], 13 | "engines": { 14 | "node": ">=10" 15 | }, 16 | "scripts": { 17 | "start": "tsdx watch", 18 | "build": "tsdx build", 19 | "test": "tsdx test", 20 | "coverage": "jest --collect-coverage", 21 | "lint": "tsdx lint", 22 | "prepare": "tsdx build", 23 | "size": "size-limit", 24 | "analyze": "size-limit --why" 25 | }, 26 | "peerDependencies": {}, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "tsdx lint" 30 | } 31 | }, 32 | "prettier": { 33 | "printWidth": 80, 34 | "semi": true, 35 | "singleQuote": true, 36 | "trailingComma": "es5" 37 | }, 38 | "size-limit": [ 39 | { 40 | "path": "dist/calculate-suv.cjs.production.min.js", 41 | "limit": "10 KB" 42 | }, 43 | { 44 | "path": "dist/calculate-suv.esm.js", 45 | "limit": "10 KB" 46 | } 47 | ], 48 | "release": { 49 | "branches": ["main"] 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.12.9", 53 | "@babel/plugin-proposal-optional-chaining": "^7.12.7", 54 | "@babel/preset-env": "^7.12.7", 55 | "@babel/preset-typescript": "^7.12.7", 56 | "@size-limit/preset-small-lib": "^4.9.0", 57 | "@types/jest": "^26.0.15", 58 | "babel-jest": "^26.6.3", 59 | "dcmjs": "^0.19.7", 60 | "husky": "^4.3.0", 61 | "loglevelnext": "^4.0.1", 62 | "nifti-js": "^1.0.1", 63 | "size-limit": "^4.9.0", 64 | "tsdx": "^0.14.1", 65 | "tslib": "^2.3.1", 66 | "typescript": "^4.6.2" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/calculateSuvLbmScalingFactor.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | calculateSUVlbmJanmahasatianScalingFactor, 3 | calculateSUVlbmScalingFactor, 4 | SUVlbmScalingFactorInput, 5 | } from '../src/calculateSUVlbmScalingFactor'; 6 | 7 | let input: SUVlbmScalingFactorInput = { 8 | PatientWeight: 75, // kg 9 | PatientSize: 1.85, // m 10 | PatientSex: 'M', 11 | }; 12 | 13 | describe('calculateSUVlbmScalingFactor', () => { 14 | it('calculates SUVlbm for M', () => { 15 | expect(calculateSUVlbmScalingFactor(input)).toEqual(62777.57487216947); 16 | }); 17 | 18 | it('calculates SUVlbm for F', () => { 19 | const inputData = { 20 | ...input, 21 | PatientSex: 'F', 22 | }; 23 | expect(calculateSUVlbmScalingFactor(inputData)).toEqual(55925.67567567568); 24 | }); 25 | 26 | it('ThrowError if gender is missing', () => { 27 | const inputData = { 28 | ...input, 29 | PatientSex: 'T', 30 | }; 31 | expect(() => { 32 | calculateSUVlbmScalingFactor(inputData); 33 | }).toThrowError(`PatientSex is an invalid value: T`); 34 | }); 35 | }); 36 | 37 | describe('calculateSUVlbmScalingFactorJanmahasatian', () => { 38 | it('calculates SUVlbmJanmahasatian for M', () => { 39 | expect(calculateSUVlbmJanmahasatianScalingFactor(input)).toEqual( 40 | 60915.335886519744 41 | ); 42 | }); 43 | 44 | it('calculates SUVlbmJanmahasatian for F', () => { 45 | const inputData = { 46 | ...input, 47 | PatientSex: 'F', 48 | }; 49 | expect(calculateSUVlbmJanmahasatianScalingFactor(inputData)).toEqual( 50 | 49214.37996837613 51 | ); 52 | }); 53 | 54 | it('ThrowError if gender is missing', () => { 55 | const inputData = { 56 | ...input, 57 | PatientSex: 'T', 58 | }; 59 | expect(() => { 60 | calculateSUVlbmJanmahasatianScalingFactor(inputData); 61 | }).toThrowError(`PatientSex is an invalid value: T`); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/calculateStartTime.test.ts: -------------------------------------------------------------------------------- 1 | import calculateStartTime from '../src/calculateStartTime'; 2 | import { FullDateInterface } from '../src/combineDateTime'; 3 | 4 | describe('calculateStartTime', () => { 5 | describe('when RadiopharmaceuticalStartDateTime is provided', () => { 6 | it('should return correct startDate', () => { 7 | // Arrange 8 | const data = { 9 | RadiopharmaceuticalStartDateTime: '20200927083045.000000', 10 | RadiopharmaceuticalStartTime: '083045.000000', 11 | SeriesDate: '20200927', 12 | }; 13 | 14 | // Act 15 | const startDateTime = calculateStartTime(data); 16 | 17 | // Assert 18 | const earliestDateTime = new FullDateInterface( 19 | '2020-09-27T08:30:45.000000Z' 20 | ); 21 | 22 | expect(startDateTime).toEqual(earliestDateTime); 23 | }); 24 | }); 25 | 26 | describe('when RadiopharmaceuticalStartDateTime is NOT provided', () => { 27 | it('should return correct startDate', () => { 28 | // Arrange 29 | const data = { 30 | RadiopharmaceuticalStartDateTime: undefined, 31 | RadiopharmaceuticalStartTime: '083045.000000', 32 | SeriesDate: '20200927', 33 | }; 34 | 35 | // Act 36 | const startDateTime = calculateStartTime(data); 37 | 38 | // Assert 39 | const earliestDateTime = new FullDateInterface( 40 | '2020-09-27T08:30:45.000000Z' 41 | ); 42 | 43 | expect(startDateTime).toEqual(earliestDateTime); 44 | }); 45 | }); 46 | 47 | describe('when input is invalid', () => { 48 | it('should throw an Error', () => { 49 | // Arrange 50 | const data = { 51 | RadiopharmaceuticalStartDateTime: undefined, 52 | RadiopharmaceuticalStartTime: undefined, 53 | SeriesDate: '20200927', 54 | }; 55 | 56 | // Act 57 | const invoker = () => calculateStartTime(data); 58 | 59 | // Assert 60 | expect(invoker).toThrowError(`Invalid input: ${data}`); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/calculateSUVlbmScalingFactor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Javascript object with patient properties size, sez, weight 3 | * 4 | * @export 5 | * @interface SUVlbmScalingFactorInput 6 | */ 7 | interface SUVlbmScalingFactorInput { 8 | PatientSize: number; // m 9 | PatientSex: string; //'M' | 'F' | 'O'; 10 | PatientWeight: number; // Kg 11 | } 12 | 13 | function calculateSUVlbmScalingFactor( 14 | inputs: SUVlbmScalingFactorInput 15 | ): number { 16 | const { PatientSex, PatientWeight, PatientSize } = inputs; 17 | 18 | let LBM; 19 | const weightSizeFactor = Math.pow(PatientWeight / (PatientSize * 100), 2); 20 | // reference: https://www.medicalconnections.co.uk/kb/calculating-suv-from-pet-images/ 21 | if (PatientSex === 'F') { 22 | LBM = 1.07 * PatientWeight - 148 * weightSizeFactor; 23 | } else if (PatientSex === 'M') { 24 | LBM = 1.1 * PatientWeight - 120 * weightSizeFactor; 25 | } else { 26 | throw new Error(`PatientSex is an invalid value: ${PatientSex}`); 27 | } 28 | 29 | return LBM * 1000; // convert in gr 30 | } 31 | 32 | /** 33 | * From https://link.springer.com/article/10.1007/s00259-014-2961-x 34 | * and https://link.springer.com/article/10.2165/00003088-200544100-00004 35 | * and 36 | * @param inputs 37 | * @returns 38 | */ 39 | function calculateSUVlbmJanmahasatianScalingFactor( 40 | inputs: SUVlbmScalingFactorInput 41 | ): number { 42 | const { PatientSex, PatientWeight, PatientSize } = inputs; 43 | 44 | let LBM; 45 | const bodyMassIndex = PatientWeight / Math.pow(PatientSize, 2); 46 | 47 | if (PatientSex === 'F') { 48 | LBM = (9270 * PatientWeight) / (8780 + 244 * bodyMassIndex); 49 | } else if (PatientSex === 'M') { 50 | LBM = (9270 * PatientWeight) / (6680 + 216 * bodyMassIndex); 51 | } else { 52 | throw new Error(`PatientSex is an invalid value: ${PatientSex}`); 53 | } 54 | return LBM * 1000; // convert in gr 55 | } 56 | 57 | export { 58 | calculateSUVlbmScalingFactor, 59 | calculateSUVlbmJanmahasatianScalingFactor, 60 | SUVlbmScalingFactorInput, 61 | }; 62 | -------------------------------------------------------------------------------- /src/parseTM.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Javascript object with properties for hours, minutes, seconds and fractionalSeconds 3 | * 4 | * @export 5 | * @interface TimeInterface 6 | */ 7 | export interface TimeInterface { 8 | hours?: number; 9 | minutes?: number; 10 | seconds?: number; 11 | fractionalSeconds?: number; 12 | } 13 | 14 | /** 15 | * Parses a TM formatted string into a javascript object with properties for hours, minutes, seconds and fractionalSeconds 16 | * @param {string} time - a string in the TM VR format 17 | * @returns {string} javascript object with properties for hours, minutes, seconds and fractionalSeconds or undefined if no element or data. Missing fields are set to undefined 18 | */ 19 | export default function parseTM(time: string): TimeInterface { 20 | if ( 21 | time === null || 22 | time === undefined || 23 | time.length < 2 || 24 | typeof time !== 'string' 25 | ) { 26 | // must at least have HH 27 | throw new Error(`invalid TM '${time}'`); 28 | } 29 | 30 | // 0123456789 31 | // HHMMSS.FFFFFF 32 | const hh = parseInt(time.substring(0, 2), 10); 33 | const mm = time.length >= 4 ? parseInt(time.substring(2, 4), 10) : undefined; 34 | const ss = time.length >= 6 ? parseInt(time.substring(4, 6), 10) : undefined; 35 | const fractionalStr = time.length >= 8 ? time.substring(7, 13) : undefined; 36 | const ffffff = fractionalStr 37 | ? parseInt(fractionalStr, 10) * Math.pow(10, 6 - fractionalStr.length) 38 | : undefined; 39 | 40 | if ( 41 | isNaN(hh) || 42 | (mm !== undefined && isNaN(mm)) || 43 | (ss !== undefined && isNaN(ss)) || 44 | (ffffff !== undefined && isNaN(ffffff)) || 45 | hh < 0 || 46 | hh > 23 || 47 | (mm && (mm < 0 || mm > 59)) || 48 | (ss && (ss < 0 || ss > 59)) || 49 | (ffffff && (ffffff < 0 || ffffff > 999999)) 50 | ) { 51 | throw new Error(`invalid TM '${time}'`); 52 | } 53 | 54 | return { 55 | hours: hh, 56 | minutes: mm, 57 | seconds: ss, 58 | fractionalSeconds: ffffff, 59 | }; 60 | } 61 | 62 | export { parseTM }; 63 | -------------------------------------------------------------------------------- /test/metadata/CPS_AND_BQML_AC_DT_-_S_DT-instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SeriesDate": "20201217", 4 | "SeriesTime": "134918.000000", 5 | "PatientSex": "M", 6 | "PatientWeight": 69, 7 | "AcquisitionDate": "20201217", 8 | "AcquisitionTime": "130348.234000", 9 | "FrameReferenceTime": 0, 10 | "DecayCorrection": "START", 11 | "Units": "BQML", 12 | "RadionuclideTotalDose": 388000000, 13 | "RadionuclideHalfLife": 6586.2, 14 | "RadiopharmaceuticalStartTime": "120037.000000", 15 | "CorrectedImage": [ 16 | "DECY", 17 | "SCAT", 18 | "ATTN", 19 | "NORM", 20 | "DTIM", 21 | "RAN" 22 | ], 23 | "ActualFrameDuration": 180000, 24 | "PhilipsPETPrivateGroup": {} 25 | }, 26 | { 27 | "SeriesDate": "20201217", 28 | "SeriesTime": "134918.000000", 29 | "PatientSex": "M", 30 | "PatientWeight": 69, 31 | "AcquisitionDate": "20201217", 32 | "AcquisitionTime": "130348.234000", 33 | "FrameReferenceTime": 0, 34 | "DecayCorrection": "START", 35 | "Units": "BQML", 36 | "RadionuclideTotalDose": 388000000, 37 | "RadionuclideHalfLife": 6586.2, 38 | "RadiopharmaceuticalStartTime": "120037.000000", 39 | "CorrectedImage": [ 40 | "DECY", 41 | "SCAT", 42 | "ATTN", 43 | "NORM", 44 | "DTIM", 45 | "RAN" 46 | ], 47 | "ActualFrameDuration": 180000, 48 | "PhilipsPETPrivateGroup": {} 49 | }, 50 | { 51 | "SeriesDate": "20201217", 52 | "SeriesTime": "134918.000000", 53 | "PatientSex": "M", 54 | "PatientWeight": 69, 55 | "AcquisitionDate": "20201217", 56 | "AcquisitionTime": "130348.234000", 57 | "FrameReferenceTime": 0, 58 | "DecayCorrection": "START", 59 | "Units": "BQML", 60 | "RadionuclideTotalDose": 388000000, 61 | "RadionuclideHalfLife": 6586.2, 62 | "RadiopharmaceuticalStartTime": "120037.000000", 63 | "CorrectedImage": [ 64 | "DECY", 65 | "SCAT", 66 | "ATTN", 67 | "NORM", 68 | "DTIM", 69 | "RAN" 70 | ], 71 | "ActualFrameDuration": 180000, 72 | "PhilipsPETPrivateGroup": {} 73 | } 74 | ] -------------------------------------------------------------------------------- /test/metadata/BQML_AC_DT_lessThan_S_DT_SIEMENS-instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SeriesDate": "20201217", 4 | "SeriesTime": "123515.000000", 5 | "PatientSex": "M", 6 | "PatientWeight": 65, 7 | "AcquisitionDate": "20201217", 8 | "AcquisitionTime": "123514.999995", 9 | "FrameReferenceTime": 59936.854672596, 10 | "DecayCorrection": "START", 11 | "Units": "BQML", 12 | "RadionuclideTotalDose": 225000000, 13 | "RadionuclideHalfLife": 6586.2, 14 | "RadiopharmaceuticalStartTime": "114100.000000", 15 | "CorrectedImage": [ 16 | "NORM", 17 | "DTIM", 18 | "ATTN", 19 | "SCAT", 20 | "DECY", 21 | "RAN" 22 | ], 23 | "ActualFrameDuration": 120000, 24 | "PhilipsPETPrivateGroup": {} 25 | }, 26 | { 27 | "SeriesDate": "20201217", 28 | "SeriesTime": "123515.000000", 29 | "PatientSex": "M", 30 | "PatientWeight": 65, 31 | "AcquisitionDate": "20201217", 32 | "AcquisitionTime": "123514.999995", 33 | "FrameReferenceTime": 59936.854672596, 34 | "DecayCorrection": "START", 35 | "Units": "BQML", 36 | "RadionuclideTotalDose": 225000000, 37 | "RadionuclideHalfLife": 6586.2, 38 | "RadiopharmaceuticalStartTime": "114100.000000", 39 | "CorrectedImage": [ 40 | "NORM", 41 | "DTIM", 42 | "ATTN", 43 | "SCAT", 44 | "DECY", 45 | "RAN" 46 | ], 47 | "ActualFrameDuration": 120000, 48 | "PhilipsPETPrivateGroup": {} 49 | }, 50 | { 51 | "SeriesDate": "20201217", 52 | "SeriesTime": "123515.000000", 53 | "PatientSex": "M", 54 | "PatientWeight": 65, 55 | "AcquisitionDate": "20201217", 56 | "AcquisitionTime": "123514.999995", 57 | "FrameReferenceTime": 59936.854672596, 58 | "DecayCorrection": "START", 59 | "Units": "BQML", 60 | "RadionuclideTotalDose": 225000000, 61 | "RadionuclideHalfLife": 6586.2, 62 | "RadiopharmaceuticalStartTime": "114100.000000", 63 | "CorrectedImage": [ 64 | "NORM", 65 | "DTIM", 66 | "ATTN", 67 | "SCAT", 68 | "DECY", 69 | "RAN" 70 | ], 71 | "ActualFrameDuration": 120000, 72 | "PhilipsPETPrivateGroup": {} 73 | } 74 | ] -------------------------------------------------------------------------------- /test/metadata/RADIOPHARM_DATETIME_UNDEFINED-instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SeriesDate": "20201217", 4 | "SeriesTime": "135431.843000", 5 | "PatientSex": "M", 6 | "PatientWeight": 102, 7 | "AcquisitionDate": "20201217", 8 | "AcquisitionTime": "141555.687000", 9 | "FrameReferenceTime": 1372857.9232493, 10 | "DecayCorrection": "START", 11 | "Units": "BQML", 12 | "RadionuclideTotalDose": 412000000, 13 | "RadionuclideHalfLife": 6586.2, 14 | "RadiopharmaceuticalStartTime": "125000.000000", 15 | "CorrectedImage": [ 16 | "NORM", 17 | "DTIM", 18 | "ATTN", 19 | "SCAT", 20 | "RADL", 21 | "DECY" 22 | ], 23 | "ActualFrameDuration": 180000, 24 | "PhilipsPETPrivateGroup": {} 25 | }, 26 | { 27 | "SeriesDate": "20201217", 28 | "SeriesTime": "135431.843000", 29 | "PatientSex": "M", 30 | "PatientWeight": 102, 31 | "AcquisitionDate": "20201217", 32 | "AcquisitionTime": "141555.687000", 33 | "FrameReferenceTime": 1372857.9232493, 34 | "DecayCorrection": "START", 35 | "Units": "BQML", 36 | "RadionuclideTotalDose": 412000000, 37 | "RadionuclideHalfLife": 6586.2, 38 | "RadiopharmaceuticalStartTime": "125000.000000", 39 | "CorrectedImage": [ 40 | "NORM", 41 | "DTIM", 42 | "ATTN", 43 | "SCAT", 44 | "RADL", 45 | "DECY" 46 | ], 47 | "ActualFrameDuration": 180000, 48 | "PhilipsPETPrivateGroup": {} 49 | }, 50 | { 51 | "SeriesDate": "20201217", 52 | "SeriesTime": "135431.843000", 53 | "PatientSex": "M", 54 | "PatientWeight": 102, 55 | "AcquisitionDate": "20201217", 56 | "AcquisitionTime": "141555.687000", 57 | "FrameReferenceTime": 1372857.9232493, 58 | "DecayCorrection": "START", 59 | "Units": "BQML", 60 | "RadionuclideTotalDose": 412000000, 61 | "RadionuclideHalfLife": 6586.2, 62 | "RadiopharmaceuticalStartTime": "125000.000000", 63 | "CorrectedImage": [ 64 | "NORM", 65 | "DTIM", 66 | "ATTN", 67 | "SCAT", 68 | "RADL", 69 | "DECY" 70 | ], 71 | "ActualFrameDuration": 180000, 72 | "PhilipsPETPrivateGroup": {} 73 | } 74 | ] -------------------------------------------------------------------------------- /test/metadata/PHILIPS_CNTS_AND_SUV-instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SeriesDate": "20201217", 4 | "SeriesTime": "134551", 5 | "PatientSex": "F", 6 | "PatientWeight": 47, 7 | "AcquisitionDate": "20201217", 8 | "AcquisitionTime": "141336", 9 | "FrameReferenceTime": 1755000, 10 | "DecayCorrection": "START", 11 | "Units": "CNTS", 12 | "RadionuclideTotalDose": 208230000, 13 | "RadionuclideHalfLife": 6588, 14 | "RadiopharmaceuticalStartTime": "123000", 15 | "CorrectedImage": [ 16 | "DECY", 17 | "RADL", 18 | "ATTN", 19 | "SCAT", 20 | "DTIM", 21 | "RAN", 22 | "NORM" 23 | ], 24 | "ActualFrameDuration": 180000, 25 | "PhilipsPETPrivateGroup": { 26 | "SUVScaleFactor": "0.000728" 27 | } 28 | }, 29 | { 30 | "SeriesDate": "20201217", 31 | "SeriesTime": "134551", 32 | "PatientSex": "F", 33 | "PatientWeight": 47, 34 | "AcquisitionDate": "20201217", 35 | "AcquisitionTime": "141336", 36 | "FrameReferenceTime": 1755000, 37 | "DecayCorrection": "START", 38 | "Units": "CNTS", 39 | "RadionuclideTotalDose": 208230000, 40 | "RadionuclideHalfLife": 6588, 41 | "RadiopharmaceuticalStartTime": "123000", 42 | "CorrectedImage": [ 43 | "DECY", 44 | "RADL", 45 | "ATTN", 46 | "SCAT", 47 | "DTIM", 48 | "RAN", 49 | "NORM" 50 | ], 51 | "ActualFrameDuration": 180000, 52 | "PhilipsPETPrivateGroup": { 53 | "SUVScaleFactor": "0.000728" 54 | } 55 | }, 56 | { 57 | "SeriesDate": "20201217", 58 | "SeriesTime": "134551", 59 | "PatientSex": "F", 60 | "PatientWeight": 47, 61 | "AcquisitionDate": "20201217", 62 | "AcquisitionTime": "141336", 63 | "FrameReferenceTime": 1755000, 64 | "DecayCorrection": "START", 65 | "Units": "CNTS", 66 | "RadionuclideTotalDose": 208230000, 67 | "RadionuclideHalfLife": 6588, 68 | "RadiopharmaceuticalStartTime": "123000", 69 | "CorrectedImage": [ 70 | "DECY", 71 | "RADL", 72 | "ATTN", 73 | "SCAT", 74 | "DTIM", 75 | "RAN", 76 | "NORM" 77 | ], 78 | "ActualFrameDuration": 180000, 79 | "PhilipsPETPrivateGroup": { 80 | "SUVScaleFactor": "0.000728" 81 | } 82 | } 83 | ] -------------------------------------------------------------------------------- /src/parseDA.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check the number of days for a picked month and year 3 | * algorithm based on http://stackoverflow.com/questions/1433030/validate-number-of-days-in-a-given-month 4 | * 5 | * @param {number} m 6 | * @param {number} y 7 | * @returns {number} number of days 8 | */ 9 | function daysInMonth(m: number, y: number): number { 10 | // m is 0 indexed: 0-11 11 | switch (m) { 12 | case 2: 13 | return (y % 4 === 0 && y % 100) || y % 400 === 0 ? 29 : 28; 14 | case 9: 15 | case 4: 16 | case 6: 17 | case 11: 18 | return 30; 19 | default: 20 | return 31; 21 | } 22 | } 23 | 24 | /** 25 | * Check if the date is valid 26 | * 27 | * @param {number} d 28 | * @param {number} m 29 | * @param {number} y 30 | * @returns {boolean} boolean result 31 | */ 32 | function isValidDate(d: number, m: number, y: number): boolean { 33 | // make year is a number 34 | if (isNaN(y)) { 35 | return false; 36 | } 37 | 38 | return m > 0 && m <= 12 && d > 0 && d <= daysInMonth(m, y); 39 | } 40 | 41 | /** 42 | * Javascript object with properties year, month and day 43 | * 44 | * @export 45 | * @interface DateInterface 46 | */ 47 | export interface DateInterface { 48 | year: number; 49 | month: number; 50 | day: number; 51 | } 52 | 53 | /** 54 | * Parses a DA formatted string into a Javascript object 55 | * @param {string} date a string in the DA VR format 56 | * @param {boolean} [validate] - true if an exception should be thrown if the date is invalid 57 | * @returns {DateInterface} Javascript object with properties year, month and day or undefined if not present or not 8 bytes long 58 | */ 59 | export default function parseDA(date: string): DateInterface { 60 | if ( 61 | date === undefined || 62 | date === null || 63 | date.length !== 8 || 64 | typeof date !== 'string' 65 | ) { 66 | throw new Error(`invalid DA '${date}'`); 67 | } 68 | 69 | const yyyy = parseInt(date.substring(0, 4), 10); 70 | const mm = parseInt(date.substring(4, 6), 10); 71 | const dd = parseInt(date.substring(6, 8), 10); 72 | 73 | if (isValidDate(dd, mm, yyyy) !== true) { 74 | throw new Error(`invalid DA '${date}'`); 75 | } 76 | 77 | return { 78 | year: yyyy, 79 | month: mm, 80 | day: dd, 81 | }; 82 | } 83 | 84 | export { parseDA }; 85 | -------------------------------------------------------------------------------- /test/metadata/SIEMENS-instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SeriesDate": "20201217", 4 | "SeriesTime": "104829.000000", 5 | "PatientSex": "M", 6 | "PatientWeight": 67, 7 | "AcquisitionDate": "20201217", 8 | "AcquisitionTime": "104830.351370", 9 | "FrameReferenceTime": 38513.36, 10 | "DecayCorrection": "START", 11 | "Units": "BQML", 12 | "RadionuclideTotalDose": 374000000, 13 | "RadionuclideHalfLife": 6586.2, 14 | "RadiopharmaceuticalStartTime": "083045.000000", 15 | "RadiopharmaceuticalStartDateTime": "20201217083045.000000 ", 16 | "CorrectedImage": [ 17 | "NORM", 18 | "DTIM", 19 | "ATTN", 20 | "SCAT", 21 | "SCAT", 22 | "DECY", 23 | "RAN" 24 | ], 25 | "ActualFrameDuration": 74324, 26 | "PhilipsPETPrivateGroup": {} 27 | }, 28 | { 29 | "SeriesDate": "20201217", 30 | "SeriesTime": "104829.000000", 31 | "PatientSex": "M", 32 | "PatientWeight": 67, 33 | "AcquisitionDate": "20201217", 34 | "AcquisitionTime": "104830.351370", 35 | "FrameReferenceTime": 39189.36, 36 | "DecayCorrection": "START", 37 | "Units": "BQML", 38 | "RadionuclideTotalDose": 374000000, 39 | "RadionuclideHalfLife": 6586.2, 40 | "RadiopharmaceuticalStartTime": "083045.000000", 41 | "RadiopharmaceuticalStartDateTime": "20201217083045.000000 ", 42 | "CorrectedImage": [ 43 | "NORM", 44 | "DTIM", 45 | "ATTN", 46 | "SCAT", 47 | "SCAT", 48 | "DECY", 49 | "RAN" 50 | ], 51 | "ActualFrameDuration": 75676, 52 | "PhilipsPETPrivateGroup": {} 53 | }, 54 | { 55 | "SeriesDate": "20201217", 56 | "SeriesTime": "104829.000000", 57 | "PatientSex": "M", 58 | "PatientWeight": 67, 59 | "AcquisitionDate": "20201217", 60 | "AcquisitionTime": "104830.351370", 61 | "FrameReferenceTime": 39864.86, 62 | "DecayCorrection": "START", 63 | "Units": "BQML", 64 | "RadionuclideTotalDose": 374000000, 65 | "RadionuclideHalfLife": 6586.2, 66 | "RadiopharmaceuticalStartTime": "083045.000000", 67 | "RadiopharmaceuticalStartDateTime": "20201217083045.000000 ", 68 | "CorrectedImage": [ 69 | "NORM", 70 | "DTIM", 71 | "ATTN", 72 | "SCAT", 73 | "SCAT", 74 | "DECY", 75 | "RAN" 76 | ], 77 | "ActualFrameDuration": 77027, 78 | "PhilipsPETPrivateGroup": {} 79 | } 80 | ] -------------------------------------------------------------------------------- /test/metadata/PHILIPS_CNTS_AND_BQML_SUV-instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SeriesDate": "20201217", 4 | "SeriesTime": "093556", 5 | "PatientSex": "F", 6 | "PatientWeight": 54, 7 | "AcquisitionDate": "20201217", 8 | "AcquisitionTime": "095439", 9 | "FrameReferenceTime": 1183000, 10 | "DecayCorrection": "START", 11 | "Units": "CNTS", 12 | "RadionuclideTotalDose": 238000000, 13 | "RadionuclideHalfLife": 6588, 14 | "RadiopharmaceuticalStartTime": "083000", 15 | "CorrectedImage": [ 16 | "DECY", 17 | "RADL", 18 | "ATTN", 19 | "SCAT", 20 | "DTIM", 21 | "RAN", 22 | "NORM" 23 | ], 24 | "ActualFrameDuration": 120000, 25 | "PhilipsPETPrivateGroup": { 26 | "SUVScaleFactor": "0.000551", 27 | "ActivityConcentrationScaleFactor": "1.602563" 28 | } 29 | }, 30 | { 31 | "SeriesDate": "20201217", 32 | "SeriesTime": "093556", 33 | "PatientSex": "F", 34 | "PatientWeight": 54, 35 | "AcquisitionDate": "20201217", 36 | "AcquisitionTime": "095439", 37 | "FrameReferenceTime": 1183000, 38 | "DecayCorrection": "START", 39 | "Units": "CNTS", 40 | "RadionuclideTotalDose": 238000000, 41 | "RadionuclideHalfLife": 6588, 42 | "RadiopharmaceuticalStartTime": "083000", 43 | "CorrectedImage": [ 44 | "DECY", 45 | "RADL", 46 | "ATTN", 47 | "SCAT", 48 | "DTIM", 49 | "RAN", 50 | "NORM" 51 | ], 52 | "ActualFrameDuration": 120000, 53 | "PhilipsPETPrivateGroup": { 54 | "SUVScaleFactor": "0.000551", 55 | "ActivityConcentrationScaleFactor": "1.602563" 56 | } 57 | }, 58 | { 59 | "SeriesDate": "20201217", 60 | "SeriesTime": "093556", 61 | "PatientSex": "F", 62 | "PatientWeight": 54, 63 | "AcquisitionDate": "20201217", 64 | "AcquisitionTime": "095439", 65 | "FrameReferenceTime": 1183000, 66 | "DecayCorrection": "START", 67 | "Units": "CNTS", 68 | "RadionuclideTotalDose": 238000000, 69 | "RadionuclideHalfLife": 6588, 70 | "RadiopharmaceuticalStartTime": "083000", 71 | "CorrectedImage": [ 72 | "DECY", 73 | "RADL", 74 | "ATTN", 75 | "SCAT", 76 | "DTIM", 77 | "RAN", 78 | "NORM" 79 | ], 80 | "ActualFrameDuration": 120000, 81 | "PhilipsPETPrivateGroup": { 82 | "SUVScaleFactor": "0.000551", 83 | "ActivityConcentrationScaleFactor": "1.602563" 84 | } 85 | } 86 | ] -------------------------------------------------------------------------------- /test/metadata/GE_MEDICAL_AND_BQML-instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SeriesDate": "20201217", 4 | "SeriesTime": "095621", 5 | "PatientSex": "F", 6 | "PatientSize": 1.65, 7 | "PatientWeight": 52, 8 | "AcquisitionDate": "20201217", 9 | "AcquisitionTime": "095621", 10 | "FrameReferenceTime": 0, 11 | "DecayCorrection": "START", 12 | "Units": "BQML", 13 | "RadionuclideTotalDose": 152022720, 14 | "RadionuclideHalfLife": 6586.2001953125, 15 | "RadiopharmaceuticalStartTime": "084500.00", 16 | "RadiopharmaceuticalStartDateTime": "20201217084500.00 ", 17 | "CorrectedImage": [ 18 | "DECY", 19 | "ATTN", 20 | "SCAT", 21 | "DTIM", 22 | "RANSNG", 23 | "DCAL", 24 | "SLSENS", 25 | "NORM" 26 | ], 27 | "ActualFrameDuration": 120000, 28 | "PhilipsPETPrivateGroup": {} 29 | }, 30 | { 31 | "SeriesDate": "20201217", 32 | "SeriesTime": "095621", 33 | "PatientSex": "F", 34 | "PatientSize": 1.65, 35 | "PatientWeight": 52, 36 | "AcquisitionDate": "20201217", 37 | "AcquisitionTime": "095621", 38 | "FrameReferenceTime": 0, 39 | "DecayCorrection": "START", 40 | "Units": "BQML", 41 | "RadionuclideTotalDose": 152022720, 42 | "RadionuclideHalfLife": 6586.2001953125, 43 | "RadiopharmaceuticalStartTime": "084500.00", 44 | "RadiopharmaceuticalStartDateTime": "20201217084500.00 ", 45 | "CorrectedImage": [ 46 | "DECY", 47 | "ATTN", 48 | "SCAT", 49 | "DTIM", 50 | "RANSNG", 51 | "DCAL", 52 | "SLSENS", 53 | "NORM" 54 | ], 55 | "ActualFrameDuration": 120000, 56 | "PhilipsPETPrivateGroup": {} 57 | }, 58 | { 59 | "SeriesDate": "20201217", 60 | "SeriesTime": "095621", 61 | "PatientSex": "F", 62 | "PatientSize": 1.65, 63 | "PatientWeight": 52, 64 | "AcquisitionDate": "20201217", 65 | "AcquisitionTime": "095621", 66 | "FrameReferenceTime": 0, 67 | "DecayCorrection": "START", 68 | "Units": "BQML", 69 | "RadionuclideTotalDose": 152022720, 70 | "RadionuclideHalfLife": 6586.2001953125, 71 | "RadiopharmaceuticalStartTime": "084500.00", 72 | "RadiopharmaceuticalStartDateTime": "20201217084500.00 ", 73 | "CorrectedImage": [ 74 | "DECY", 75 | "ATTN", 76 | "SCAT", 77 | "DTIM", 78 | "RANSNG", 79 | "DCAL", 80 | "SLSENS", 81 | "NORM" 82 | ], 83 | "ActualFrameDuration": 120000, 84 | "PhilipsPETPrivateGroup": {} 85 | } 86 | ] -------------------------------------------------------------------------------- /test/metadata/PHILIPS_BQML-instances.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SeriesDate": "20201217", 4 | "SeriesTime": "110155", 5 | "PatientSex": "F", 6 | "PatientSize": 1.75, 7 | "PatientWeight": 64, 8 | "AcquisitionDate": "20201217", 9 | "AcquisitionTime": "110156", 10 | "FrameReferenceTime": 1328700, 11 | "DecayCorrection": "START", 12 | "Units": "BQML", 13 | "RadionuclideTotalDose": 126000000, 14 | "RadionuclideHalfLife": 6586.199707, 15 | "RadiopharmaceuticalStartTime": "100058", 16 | "RadiopharmaceuticalStartDateTime": "20201217100058", 17 | "CorrectedImage": [ 18 | "DECY", 19 | "RADL", 20 | "ATTN", 21 | "SCAT", 22 | "DTIM", 23 | "RAN", 24 | "NORM", 25 | "CLN" 26 | ], 27 | "ActualFrameDuration": 120700, 28 | "PhilipsPETPrivateGroup": { 29 | "SUVScaleFactor": "0.003515736 ", 30 | "ActivityConcentrationScaleFactor": "4.709921" 31 | } 32 | }, 33 | { 34 | "SeriesDate": "20201217", 35 | "SeriesTime": "110155", 36 | "PatientSex": "F", 37 | "PatientSize": 1.75, 38 | "PatientWeight": 64, 39 | "AcquisitionDate": "20201217", 40 | "AcquisitionTime": "110156", 41 | "FrameReferenceTime": 1328700, 42 | "DecayCorrection": "START", 43 | "Units": "BQML", 44 | "RadionuclideTotalDose": 126000000, 45 | "RadionuclideHalfLife": 6586.199707, 46 | "RadiopharmaceuticalStartTime": "100058", 47 | "RadiopharmaceuticalStartDateTime": "20201217100058", 48 | "CorrectedImage": [ 49 | "DECY", 50 | "RADL", 51 | "ATTN", 52 | "SCAT", 53 | "DTIM", 54 | "RAN", 55 | "NORM", 56 | "CLN" 57 | ], 58 | "ActualFrameDuration": 120700, 59 | "PhilipsPETPrivateGroup": { 60 | "SUVScaleFactor": "0.003515736 ", 61 | "ActivityConcentrationScaleFactor": "4.709921" 62 | } 63 | }, 64 | { 65 | "SeriesDate": "20201217", 66 | "SeriesTime": "110155", 67 | "PatientSex": "F", 68 | "PatientSize": 1.75, 69 | "PatientWeight": 64, 70 | "AcquisitionDate": "20201217", 71 | "AcquisitionTime": "110156", 72 | "FrameReferenceTime": 1328700, 73 | "DecayCorrection": "START", 74 | "Units": "BQML", 75 | "RadionuclideTotalDose": 126000000, 76 | "RadionuclideHalfLife": 6586.199707, 77 | "RadiopharmaceuticalStartTime": "100058", 78 | "RadiopharmaceuticalStartDateTime": "20201217100058", 79 | "CorrectedImage": [ 80 | "DECY", 81 | "RADL", 82 | "ATTN", 83 | "SCAT", 84 | "DTIM", 85 | "RAN", 86 | "NORM", 87 | "CLN" 88 | ], 89 | "ActualFrameDuration": 120700, 90 | "PhilipsPETPrivateGroup": { 91 | "SUVScaleFactor": "0.003515736 ", 92 | "ActivityConcentrationScaleFactor": "4.709921" 93 | } 94 | } 95 | ] -------------------------------------------------------------------------------- /README-tsdx.md: -------------------------------------------------------------------------------- 1 | # TSDX User Guide 2 | 3 | Congrats! You just saved yourself hours of work by bootstrapping this project with TSDX. Let’s get you oriented with what’s here and how to use it. 4 | 5 | > This TSDX setup is meant for developing libraries (not apps!) that can be published to NPM. If you’re looking to build a Node app, you could use `ts-node-dev`, plain `ts-node`, or simple `tsc`. 6 | 7 | > If you’re new to TypeScript, checkout [this handy cheatsheet](https://devhints.io/typescript) 8 | 9 | ## Commands 10 | 11 | TSDX scaffolds your new library inside `/src`. 12 | 13 | To run TSDX, use: 14 | 15 | ```bash 16 | npm start # or yarn start 17 | ``` 18 | 19 | This builds to `/dist` and runs the project in watch mode so any edits you save inside `src` causes a rebuild to `/dist`. 20 | 21 | To do a one-off build, use `npm run build` or `yarn build`. 22 | 23 | To run tests, use `npm test` or `yarn test`. 24 | 25 | ## Configuration 26 | 27 | Code quality is set up for you with `prettier`, `husky`, and `lint-staged`. Adjust the respective fields in `package.json` accordingly. 28 | 29 | ### Jest 30 | 31 | Jest tests are set up to run with `npm test` or `yarn test`. 32 | 33 | ### Bundle Analysis 34 | 35 | [`size-limit`](https://github.com/ai/size-limit) is set up to calculate the real cost of your library with `npm run size` and visualize the bundle with `npm run analyze`. 36 | 37 | #### Setup Files 38 | 39 | This is the folder structure we set up for you: 40 | 41 | ```txt 42 | /src 43 | index.tsx # EDIT THIS 44 | /test 45 | blah.test.tsx # EDIT THIS 46 | .gitignore 47 | package.json 48 | README.md # EDIT THIS 49 | tsconfig.json 50 | ``` 51 | 52 | ### Rollup 53 | 54 | TSDX uses [Rollup](https://rollupjs.org) as a bundler and generates multiple rollup configs for various module formats and build settings. See [Optimizations](#optimizations) for details. 55 | 56 | ### TypeScript 57 | 58 | `tsconfig.json` is set up to interpret `dom` and `esnext` types, as well as `react` for `jsx`. Adjust according to your needs. 59 | 60 | ## Continuous Integration 61 | 62 | ### GitHub Actions 63 | 64 | Two actions are added by default: 65 | 66 | - `main` which installs deps w/ cache, lints, tests, and builds on all pushes against a Node and OS matrix 67 | - `size` which comments cost comparison of your library on every pull request using [`size-limit`](https://github.com/ai/size-limit) 68 | 69 | ## Optimizations 70 | 71 | Please see the main `tsdx` [optimizations docs](https://github.com/palmerhq/tsdx#optimizations). In particular, know that you can take advantage of development-only optimizations: 72 | 73 | ```js 74 | // ./types/index.d.ts 75 | declare var __DEV__: boolean; 76 | 77 | // inside your code... 78 | if (__DEV__) { 79 | console.log('foo'); 80 | } 81 | ``` 82 | 83 | You can also choose to install and use [invariant](https://github.com/palmerhq/tsdx#invariant) and [warning](https://github.com/palmerhq/tsdx#warning) functions. 84 | 85 | ## Module Formats 86 | 87 | CJS, ESModules, and UMD module formats are supported. 88 | 89 | The appropriate paths are configured in `package.json` and `dist/index.js` accordingly. Please report if any issues are found. 90 | 91 | ## Named Exports 92 | 93 | Per Palmer Group guidelines, [always use named exports.](https://github.com/palmerhq/typescript#exports) Code split inside your React app instead of your React library. 94 | 95 | ## Including Styles 96 | 97 | There are many ways to ship styles, including with CSS-in-JS. TSDX has no opinion on this, configure how you like. 98 | 99 | For vanilla CSS, you can include it at the root directory and add it to the `files` section in your `package.json`, so that it can be imported separately by your users and run through their bundler's loader. 100 | 101 | ## Publishing to NPM 102 | 103 | We recommend using [np](https://github.com/sindresorhus/np). 104 | -------------------------------------------------------------------------------- /test/calculateSUV_SampleData.test.ts: -------------------------------------------------------------------------------- 1 | // import readDICOMFolder from './readDICOMFolder'; 2 | import fs from 'fs'; 3 | import { calculateSUVScalingFactors } from '../src'; 4 | // import dcmjs from 'dcmjs'; 5 | 6 | // Temporarily diable dcmjs logging because it logs a lot of 7 | // VR errors 8 | // dcmjs.log.disable(); 9 | 10 | // Note: Converted everything to Implicit Little Endian Transfer Syntax: 11 | // find . -maxdepth 1 -type f -print0 | parallel -0 dcmconv +ti {1} {1} 12 | const sampleDataPaths: string[] = [ 13 | // Standard, "Units": "BQML", contains RadiopharmaceuticalStartDateTime 14 | 'SIEMENS', 15 | 'GE_MEDICAL_AND_BQML', 16 | 17 | // Units: BQML, contains Philips private tag group (which should not be used) 18 | 'PHILIPS_BQML', 19 | 20 | // Units: CNTS, Philips private tag group with both SUVScaleFactor and ActivityConcentrationScaleFactor 21 | 'PHILIPS_CNTS_AND_BQML_SUV', 22 | 23 | // Units: CNTS, Philips private tag group with valid SUVScaleFactor 24 | 'PHILIPS_CNTS_AND_SUV', 25 | 26 | // Acqusition Date Time is earlier than Series Date Time 27 | 'BQML_AC_DT_lessThan_S_DT_SIEMENS', 28 | 29 | // Missing RadiopharmaceuticalStartDateTime, only uses RadiopharmaceuticalStartTime 30 | 'CPS_AND_BQML_AC_DT_-_S_DT', 31 | 32 | // Missing RadiopharmaceuticalStartDateTime, only uses RadiopharmaceuticalStartTime 33 | 'RADIOPHARM_DATETIME_UNDEFINED', 34 | 35 | // Need to find: Real-world dataset that requires Syngo3.x multi-inject pathway 36 | // Real-world dataset with GEPrivatePostInjectionDateTime 37 | ]; 38 | 39 | // Note: sample data must be organized as 40 | // folderName / dicom / all dicom files 41 | 42 | sampleDataPaths.forEach(folder => { 43 | //const dicomFolder = `./test/data/${folder}/dicom`; 44 | const precomputedSUVFactors = new Map(); 45 | precomputedSUVFactors.set('PHILIPS_BQML', 0.0007463747013889488); 46 | precomputedSUVFactors.set('PHILIPS_CNTS_AND_BQML_SUV', 0.000551); 47 | precomputedSUVFactors.set('PHILIPS_CNTS_AND_SUV', 0.000728); 48 | precomputedSUVFactors.set('SIEMENS', 0.00042748316187197236); 49 | precomputedSUVFactors.set('GE_MEDICAL_AND_BQML', 0.0005367387681819742); 50 | precomputedSUVFactors.set( 51 | 'BQML_AC_DT_lessThan_S_DT_SIEMENS', 52 | 0.0004069156854009332 53 | ); 54 | precomputedSUVFactors.set( 55 | 'CPS_AND_BQML_AC_DT_-_S_DT', 56 | 0.00026503312764157046 57 | ); 58 | precomputedSUVFactors.set( 59 | 'RADIOPHARM_DATETIME_UNDEFINED', 60 | 0.0003721089202818729 61 | ); 62 | /// at the moment for a dataset, each frame has always the same SUV factor. 63 | /// 'BQML_AC_DT_lessThan_S_DT_SIEMENS' will have eventually a SUV factor value for each frame, 64 | /// in that case this test will need to be update by comparing the SUV factors of the frames with precomputed ones. 65 | 66 | describe(`calculateSUVScalingFactors from dicom: ${folder}`, () => { 67 | it('matches the known, precomputed, SUV values', () => { 68 | // Arrange 69 | // 1. Read underlying input dicom data 70 | //let { instanceMetadata } = readDICOMFolder(dicomFolder); 71 | /*instanceMetadata = instanceMetadata.slice(0,3); 72 | 73 | console.log(`./metadata/${folder}.json`) 74 | fs.writeFileSync(`./metadata/${folder}-instances.json`, JSON.stringify(instanceMetadata, null, 2));*/ 75 | 76 | // TODO: Make this async? 77 | const filename = `${folder}-instances`; 78 | const instanceMetadata = JSON.parse( 79 | fs.readFileSync(`./test/metadata/${filename}.json`, 'utf8') 80 | ); 81 | 82 | // Act 83 | // 2. Calculate scaleFactor from the metadata 84 | const scalingFactors = calculateSUVScalingFactors(instanceMetadata); 85 | 86 | // Assert 87 | // 3. Check approximate equality between ground truth SUV and our result 88 | expect( 89 | Math.abs( 90 | scalingFactors[0].suvbw - precomputedSUVFactors.get(`${folder}`) 91 | ) < 1e-6 92 | ).toEqual(true); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /test/calculateSUV_SampleData.test-not-used.ts: -------------------------------------------------------------------------------- 1 | import readDICOMFolder from './readDICOMFolder'; 2 | import readNifti from './readNifti'; 3 | 4 | import { calculateSUVScalingFactors } from '../src'; 5 | import dcmjs from 'dcmjs'; 6 | 7 | // Temporarily diable dcmjs logging because it logs a lot of 8 | // VR errors 9 | dcmjs.log.disable(); 10 | 11 | // Note: Converted everything to Implicit Little Endian Transfer Syntax: 12 | // find . -maxdepth 1 -type f -print0 | parallel -0 dcmconv +ti {1} {1} 13 | const sampleDataPaths: string[] = [ 14 | // Working: 15 | 'PHILIPS_BQML', // Units = BQML. Philips Private Group present, but intentionally not used 16 | 'PHILIPS_CNTS_&_BQML_SUV', // Units = CNTS (TBD: Not sure what & BQML refers to? includes private grp?) 17 | 'PHILIPS_CNTS_AND_SUV', // Units = CNTS 18 | 'SIEMENS', // TODO: Write down characteristics of this data 19 | 'GE_MEDICAL_AND_BQML', // TODO: Write down characteristics of this data 20 | 'BQML_AC_DT_<_S_DT + SIEMENS', 21 | 'CPS_AND_BQML_AC_DT_-_S_DT', 22 | 'RADIOPHARM_DATETIME_UNDEFINED', 23 | // last three do not match Salim's ground truth, because he truncates the time at seconds precision, 24 | // while we recover the time at microseconds precision. Lowering the precision replicates Salim's ground truth. 25 | // reference: https://github.com/wendyrvllr/Dicom-To-CNN/blob/wendy/library_dicom/dicom_processor/model/SeriesPT.py 26 | ]; 27 | 28 | // Note: sample data must be organized as 29 | // folderName / dicom / all dicom files 30 | // folderName / groundTruth.nii 31 | 32 | function approximatelyEqual( 33 | arr1: Float64Array, 34 | arr2: Float64Array, 35 | epsilon = 1e-3 // Way too large 36 | ) { 37 | if (arr1.length !== arr2.length) { 38 | throw new Error('Arrays are not the same length?'); 39 | } 40 | 41 | const len = arr1.length; 42 | 43 | for (let i = 0; i < len; i++) { 44 | const diff = Math.abs(arr1[i] - arr2[i]); 45 | if (diff > epsilon) { 46 | return false; 47 | } 48 | } 49 | 50 | return true; 51 | } 52 | sampleDataPaths.forEach(folder => { 53 | const groundTruthPath = `./test/data/${folder}/groundTruth.nii`; 54 | const dicomFolder = `./test/data/${folder}/dicom`; 55 | 56 | describe(`calculateSUVScalingFactors: ${folder}`, () => { 57 | it('matches the ground truth SUV values', () => { 58 | // Arrange 59 | // 1. Read underlying ground truth and input data 60 | const groundTruthSUV = readNifti(groundTruthPath); 61 | const { 62 | instanceMetadata, 63 | instanceRescale, 64 | pixelDataTypedArray, 65 | frameLength, 66 | numFrames, 67 | } = readDICOMFolder(dicomFolder); 68 | 69 | // Act 70 | // 2. Calculate scaleFactor from the metadata 71 | // 3. Scale original data and insert into an Arraybuffer 72 | const scalingFactors = calculateSUVScalingFactors(instanceMetadata); 73 | const scaledPixelData = new Float64Array(frameLength * numFrames); 74 | let groundTruthTotal = 0; 75 | let scaledTotal = 0; 76 | let diff = 0; 77 | 78 | for (let i = 0; i < numFrames; i++) { 79 | for (let j = 0; j < frameLength; j++) { 80 | const voxelIndex = i * frameLength + j; 81 | 82 | // Multiply by the per-frame scaling factor 83 | const rescaled = 84 | pixelDataTypedArray[voxelIndex] * instanceRescale[i].RescaleSlope + 85 | instanceRescale[i].RescaleIntercept; 86 | scaledPixelData[voxelIndex] = rescaled * scalingFactors[i].suvFactor; 87 | 88 | groundTruthTotal += groundTruthSUV[voxelIndex]; 89 | scaledTotal += scaledPixelData[voxelIndex]; 90 | diff += Math.abs( 91 | scaledPixelData[voxelIndex] - groundTruthSUV[voxelIndex] 92 | ); 93 | } 94 | } 95 | 96 | console.log('avg diff across all voxels'); 97 | console.log(diff / (frameLength * numFrames)); 98 | 99 | console.log('total of scaled / total of ground truth voxels'); 100 | console.log(scaledTotal / groundTruthTotal); 101 | 102 | // Assert 103 | // 4. Check approximate equality between ground truth SUV and our result 104 | expect(approximatelyEqual(groundTruthSUV, scaledPixelData)).toEqual(true); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/parseTM.test.ts: -------------------------------------------------------------------------------- 1 | import { parseTM, TimeInterface } from '../src/parseTM'; 2 | 3 | describe('parseTM', () => { 4 | describe('when parsing a full TM', () => { 5 | let val: TimeInterface; 6 | 7 | beforeEach(() => { 8 | // Arrange 9 | const tmString = '081236.531000'; 10 | 11 | // Act 12 | val = parseTM(tmString); 13 | }); 14 | 15 | it('should return the right hours', () => { 16 | // Assert 17 | expect(val.hours).toEqual(8); 18 | }); 19 | 20 | it('should return the right minutes', () => { 21 | // Assert 22 | expect(val.minutes).toEqual(12); 23 | }); 24 | 25 | it('should return the right seconds', () => { 26 | // Assert 27 | expect(val.seconds).toEqual(36); 28 | }); 29 | 30 | it('should return the right fractionalSeconds', () => { 31 | // Assert 32 | expect(val.fractionalSeconds).toEqual(531000); 33 | }); 34 | }); 35 | 36 | describe('when parsing a partial TM', () => { 37 | let val: TimeInterface; 38 | 39 | beforeEach(() => { 40 | // Arrange 41 | const tmString = '08'; 42 | 43 | // Act 44 | val = parseTM(tmString); 45 | }); 46 | 47 | it('should return the right hours', () => { 48 | // Assert 49 | expect(val.hours).toEqual(8); 50 | }); 51 | 52 | it('should return the right minutes', () => { 53 | // Assert 54 | expect(val.minutes).toBeUndefined(); 55 | }); 56 | 57 | it('should return the right seconds', () => { 58 | // Assert 59 | expect(val.seconds).toBeUndefined(); 60 | }); 61 | 62 | it('should return the right fractionalSeconds', () => { 63 | // Assert 64 | expect(val.fractionalSeconds).toBeUndefined(); 65 | }); 66 | }); 67 | 68 | describe('when parsing a partial fractional TM', () => { 69 | it('should return the expected value for no zeros', () => { 70 | // Arrange 71 | const tmString = '081236.5'; 72 | 73 | // Act 74 | const val = parseTM(tmString); 75 | 76 | // Assert 77 | expect(val.hours).toEqual(8); 78 | expect(val.minutes).toEqual(12); 79 | expect(val.seconds).toEqual(36); 80 | expect(val.fractionalSeconds).toEqual(500000); 81 | }); 82 | 83 | it('should return the expected value for leading and following zeros', () => { 84 | // Arrange 85 | const tmString = '081236.00500'; 86 | 87 | // Act 88 | const val = parseTM(tmString); 89 | 90 | // Assert 91 | expect(val.hours).toEqual(8); 92 | expect(val.minutes).toEqual(12); 93 | expect(val.seconds).toEqual(36); 94 | expect(val.fractionalSeconds).toEqual(5000); 95 | }); 96 | }); 97 | 98 | describe('when parsing an invalid TM', () => { 99 | it('should throw an exception if the time is greater than 23h', () => { 100 | // Arrange 101 | const tmString = '241236.531000'; 102 | const invoker = () => parseTM(tmString); 103 | 104 | // Act / Asset 105 | expect(invoker).toThrow(); 106 | }); 107 | 108 | it('should throw an exception if the TM is too short', () => { 109 | // Arrange 110 | const tmString = '1'; 111 | const invoker = () => parseTM(tmString); 112 | 113 | // Act / Asset 114 | expect(invoker).toThrow(); 115 | }); 116 | }); 117 | 118 | describe('when parsing a TM with bad seconds', () => { 119 | it('shoud throw an exception', () => { 120 | // Arrange 121 | const tmString = '236036.531000'; 122 | const invoker = () => parseTM(tmString); 123 | 124 | // Act / Asset 125 | expect(invoker).toThrow(); 126 | }); 127 | }); 128 | 129 | describe('when parsing a TM with bad seconds', () => { 130 | it('should throw an exception', () => { 131 | // Arrange 132 | const tmString = '232260.531000'; 133 | const invoker = () => parseTM(tmString); 134 | 135 | // Act 136 | expect(invoker).toThrow(); 137 | }); 138 | }); 139 | 140 | describe('when parsing a TM with bad fractional', () => { 141 | it('should throw an exception', () => { 142 | // Arrange 143 | const tmString = '232259.AA'; 144 | const invoker = () => parseTM(tmString); 145 | 146 | // Act / Asset 147 | expect(invoker).toThrow(); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/combineDateTime.ts: -------------------------------------------------------------------------------- 1 | import { DateInterface } from './parseDA'; 2 | import { TimeInterface } from './parseTM'; 3 | 4 | /** 5 | * Javascript object that handles dates and compute the time. 6 | * 7 | * @export 8 | * @class FullDateInterface 9 | */ 10 | export class FullDateInterface { 11 | fullDate: string; 12 | 13 | /** 14 | * Creates an instance of FullDateInterface. 15 | * @param {string} date formatted as yyyy-mm-ddTHH:MM:SS.FFFFFFZ 16 | * @memberof FullDateInterface 17 | */ 18 | constructor(date: string) { 19 | this.fullDate = date; 20 | } 21 | 22 | /** 23 | * returns time since 1 january 1970 24 | * 25 | * @returns {number} time in sec 26 | * @memberof FullDateInterface 27 | */ 28 | getTimeInSec(): number { 29 | // yyyy-mm-ddTHH:MM:SS.FFFFFFZ 30 | const dateString = this.fullDate.substring(0, 10); 31 | const timeString = this.fullDate.substring(11, 28); 32 | 33 | // yyyy-mm-dd 34 | const yyyy = parseInt(dateString.substring(0, 4), 10); 35 | const mm = 36 | dateString.length >= 7 37 | ? parseInt(dateString.substring(5, 7), 10) 38 | : undefined; 39 | const dd = 40 | dateString.length >= 10 41 | ? parseInt(dateString.substring(8, 10), 10) 42 | : undefined; 43 | 44 | if ( 45 | isNaN(yyyy) || 46 | (mm !== undefined && isNaN(mm)) || 47 | (dd !== undefined && isNaN(dd)) || 48 | yyyy > 3000 || 49 | (mm && (mm < 1 || mm > 12)) || 50 | (dd && (dd < 1 || dd > 31)) 51 | ) { 52 | throw new Error(`invalid date '${dateString}'`); 53 | } 54 | 55 | const dateJS = new Date(`${dateString}T00:00:00.000000Z`); 56 | 57 | // HHMMSS.FFFFFF 58 | const HH = parseInt(timeString.substring(0, 2), 10); 59 | const MM = 60 | timeString.length >= 5 61 | ? parseInt(timeString.substring(3, 5), 10) 62 | : undefined; 63 | const SS = 64 | timeString.length >= 8 65 | ? parseInt(timeString.substring(6, 8), 10) 66 | : undefined; 67 | const fractionalStr = timeString.substring(9, 15); 68 | const FFFFFF = fractionalStr 69 | ? parseInt(fractionalStr, 10) * Math.pow(10, -fractionalStr.length) 70 | : undefined; 71 | 72 | if ( 73 | isNaN(HH) || 74 | (MM !== undefined && isNaN(MM)) || 75 | (SS !== undefined && isNaN(SS)) || 76 | (FFFFFF !== undefined && isNaN(FFFFFF)) || 77 | HH < 0 || 78 | HH > 23 || 79 | (MM && (MM < 0 || MM > 59)) || 80 | (SS && (SS < 0 || SS > 59)) || 81 | (FFFFFF && (FFFFFF < 0 || FFFFFF > 999999)) 82 | ) { 83 | throw new Error(`invalid time '${timeString}'`); 84 | } 85 | 86 | let timeInSec = dateJS.getTime() / 1000; 87 | 88 | timeInSec += HH * 3600; 89 | if (MM !== undefined) { 90 | timeInSec += MM * 60; 91 | } 92 | if (SS !== undefined) { 93 | timeInSec += SS; 94 | } 95 | if (FFFFFF !== undefined) { 96 | timeInSec += FFFFFF; 97 | } 98 | 99 | return timeInSec; 100 | } 101 | 102 | /** 103 | * returns time since 1 january 1970 104 | * 105 | * @returns {number} time in microsec 106 | * @memberof FullDateInterface 107 | */ 108 | getTimeInMicroSec(): number { 109 | const timeInMicroSec = this.getTimeInSec() * 1e6; 110 | return timeInMicroSec; 111 | } 112 | } 113 | 114 | export interface FullDateInterface { 115 | date: string; 116 | } 117 | 118 | /** 119 | * Combines two javascript objects containing the date and time information 120 | * 121 | * @export 122 | * @param {DateInterface} date 123 | * @param {TimeInterface} time 124 | * @returns {FullDateInterface} 125 | */ 126 | export default function combineDateTime( 127 | date: DateInterface, 128 | time: TimeInterface 129 | ): FullDateInterface { 130 | const hours = `${time.hours || '00'}`.padStart(2, '0'); 131 | const minutes = `${time.minutes || '00'}`.padStart(2, '0'); 132 | const seconds = `${time.seconds || '00'}`.padStart(2, '0'); 133 | const month = `${date.month}`.padStart(2, '0'); 134 | const day = `${date.day}`.padStart(2, '0'); 135 | const fractionalSeconds = `${time.fractionalSeconds || '000000'}`.padEnd( 136 | 6, 137 | '0' 138 | ); 139 | const dateString = `${date.year}-${month}-${day}`; 140 | const timeString = `T${hours}:${minutes}:${seconds}.${fractionalSeconds}Z`; 141 | const fullDateString = `${dateString}${timeString}`; 142 | 143 | return new FullDateInterface(fullDateString); 144 | } 145 | 146 | export { combineDateTime }; 147 | -------------------------------------------------------------------------------- /test/parseDA.test.ts: -------------------------------------------------------------------------------- 1 | import parseDA from '../src/parseDA'; 2 | 3 | describe('parseDA', () => { 4 | describe('when parsing a valid DA', () => { 5 | it('should return the expected value', () => { 6 | // Arrange 7 | const daString = '20140329'; 8 | 9 | // Act 10 | const val = parseDA(daString); 11 | 12 | // Assert 13 | expect(val.year).toEqual(2014); 14 | expect(val.month).toEqual(3); 15 | expect(val.day).toEqual(29); 16 | }); 17 | 18 | it('should return the expected value for leap years', () => { 19 | // Arrange 20 | const daString = '20200228'; 21 | 22 | // Act 23 | const val = parseDA(daString); 24 | 25 | // Assert 26 | expect(val.year).toEqual(2020); 27 | expect(val.month).toEqual(2); 28 | expect(val.day).toEqual(28); 29 | }); 30 | 31 | it('should return the expected value for non-leap years', () => { 32 | // Arrange 33 | const daString = '20190228'; 34 | 35 | // Act 36 | const val = parseDA(daString); 37 | 38 | // Assert 39 | expect(val.year).toEqual(2019); 40 | expect(val.month).toEqual(2); 41 | expect(val.day).toEqual(28); 42 | }); 43 | 44 | it('should return the expected value for months with 30 days', () => { 45 | // Arrange 46 | const daString = '20190228'; 47 | 48 | // Act 49 | const val = parseDA(daString); 50 | 51 | // Assert 52 | expect(val.year).toEqual(2019); 53 | expect(val.month).toEqual(2); 54 | expect(val.day).toEqual(28); 55 | }); 56 | 57 | it('should return the expected value for months with 31 days', () => { 58 | // Arrange 59 | const daString = '20190128'; 60 | 61 | // Act 62 | const val = parseDA(daString); 63 | 64 | // Assert 65 | expect(val.year).toEqual(2019); 66 | expect(val.month).toEqual(1); 67 | expect(val.day).toEqual(28); 68 | }); 69 | }); 70 | 71 | describe('when parsing a DA with a bad month', () => { 72 | it('should throw an exception', () => { 73 | // Arrange 74 | const daString = '20150001'; 75 | const invoker = () => parseDA(daString); 76 | 77 | // Act / Asset 78 | expect(invoker).toThrow(); 79 | }); 80 | }); 81 | 82 | describe('when parsing a DA with a bad day', () => { 83 | it('should throw an exception', () => { 84 | // Arrange 85 | const daString = '20150100'; 86 | const invoker = () => parseDA(daString); 87 | 88 | // Act 89 | expect(invoker).toThrow(); 90 | }); 91 | }); 92 | 93 | describe('when parsing a DA that is not a leap year', () => { 94 | it('should throw an exception', () => { 95 | // Arrange 96 | const daString = '20150229'; 97 | const invoker = () => parseDA(daString); 98 | 99 | // Act / Asset 100 | expect(invoker).toThrow(); 101 | }); 102 | }); 103 | 104 | describe('when parsing DA that is a leap year', () => { 105 | it('should return the expected value', () => { 106 | // Arrange 107 | const daString = '20160229'; 108 | 109 | // Act 110 | const val = parseDA(daString); 111 | 112 | // Assert 113 | expect(val.year).toEqual(2016); 114 | expect(val.month).toEqual(2); 115 | expect(val.day).toEqual(29); 116 | }); 117 | }); 118 | 119 | describe('when parsing a DA with non-number characters on "day" positions', () => { 120 | it('should throw an exception', () => { 121 | // Arrange 122 | const daString = '201500AA'; 123 | const invoker = () => parseDA(daString); 124 | 125 | // Act / Assert 126 | expect(invoker).toThrow(); 127 | }); 128 | }); 129 | 130 | describe('when parsing a DA with non-number characters on "year" positions', () => { 131 | it('should throw an exception', () => { 132 | // Arrange 133 | const daString = 'AAAA0102'; 134 | const invoker = () => parseDA(daString); 135 | 136 | // Act / Assert 137 | expect(invoker).toThrow(); 138 | }); 139 | }); 140 | 141 | describe('when parsing a DA with non-number characters on "month" positions', () => { 142 | it('parseDA month not number', () => { 143 | // Arrange 144 | const daString = '2015AA02'; 145 | const invoker = () => parseDA(daString); 146 | 147 | // Act / Assert 148 | expect(invoker).toThrow(); 149 | }); 150 | }); 151 | 152 | describe('when parsing a date with invalid length', () => { 153 | it('should throw an exception', () => { 154 | // Arrange 155 | const daString = '201501'; 156 | const invoker = () => parseDA(daString); 157 | 158 | // Act / Assert 159 | expect(invoker).toThrow(); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /test/readDICOMFolder.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import dcmjs from 'dcmjs'; 4 | import { InstanceMetadata } from '../src/types'; 5 | 6 | interface DatasetReadResults { 7 | instanceMetadata: InstanceMetadata[]; 8 | instanceRescale: InstanceRescaleData[]; 9 | pixelDataTypedArray: Int16Array | Uint16Array | Int8Array | Uint8Array; 10 | frameLength: number; 11 | numFrames: number; 12 | } 13 | 14 | interface InstanceRescaleData { 15 | RescaleSlope: number; 16 | RescaleIntercept: number; 17 | } 18 | 19 | export default function readDICOMFolder(folder: string): DatasetReadResults { 20 | let files = fs.readdirSync(folder); 21 | files = files.filter(file => file !== '.DS_Store'); 22 | 23 | const datasets = files.map(file => { 24 | const fullFilePath = path.resolve(path.join(folder, file)); 25 | const buffer = fs.readFileSync(fullFilePath); 26 | const dicomData = dcmjs.data.DicomMessage.readFile(buffer.buffer); 27 | const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset( 28 | dicomData.dict 29 | ); 30 | dataset._meta = dcmjs.data.DicomMetaDictionary.namifyDataset( 31 | dicomData.meta 32 | ); 33 | 34 | return dataset; 35 | }); 36 | 37 | datasets.sort((a, b) => { 38 | if (a.SliceLocation && b.SliceLocation) { 39 | return a.SliceLocation - b.SliceLocation; 40 | } else if (a.ImageIndex && b.ImageIndex) { 41 | return a.ImageIndex - b.ImageIndex; 42 | } else { 43 | // TODO: check if we are sorting the same as normalizeToDataset is 44 | console.log(a); 45 | throw new Error('SliceLocation and ImageIndex are not present'); 46 | } 47 | }); 48 | 49 | const frameLength = datasets[0].Rows * datasets[0].Columns; 50 | const numFrames = datasets.length; 51 | 52 | // TODO: probably needs to be another type in some cases? 53 | let TypedArray: 54 | | Int16ArrayConstructor 55 | | Uint16ArrayConstructor 56 | | Int8ArrayConstructor 57 | | Uint8ArrayConstructor; 58 | if (datasets[0].BitsAllocated === 16) { 59 | if (datasets[0].PixelRepresentation === 1) { 60 | TypedArray = Int16Array; 61 | } else { 62 | TypedArray = Uint16Array; 63 | } 64 | } else if (datasets[0].BitsAllocated === 8) { 65 | if (datasets[0].PixelRepresentation === 1) { 66 | TypedArray = Int8Array; 67 | } else { 68 | TypedArray = Uint8Array; 69 | } 70 | } else { 71 | throw new Error( 72 | `Cannot create typed array: ${datasets[0].BitsAllocated} ${datasets[0].PixelRepresentation}` 73 | ); 74 | } 75 | 76 | const pixelDataTypedArray = new TypedArray(numFrames * frameLength); 77 | 78 | const instanceMetadata = datasets.map( 79 | (dataset: any, index: number): InstanceMetadata => { 80 | pixelDataTypedArray.set( 81 | new TypedArray(dataset.PixelData), 82 | index * frameLength 83 | ); 84 | 85 | return { 86 | SeriesDate: dataset.SeriesDate, 87 | SeriesTime: dataset.SeriesTime, 88 | PatientSex: dataset.PatientSex, 89 | PatientSize: dataset.PatientSize, 90 | PatientWeight: dataset.PatientWeight, 91 | AcquisitionDate: dataset.AcquisitionDate, 92 | AcquisitionTime: dataset.AcquisitionTime, 93 | FrameReferenceTime: dataset.FrameReferenceTime, 94 | DecayCorrection: dataset.DecayCorrection, 95 | Units: dataset.Units, 96 | RadionuclideTotalDose: 97 | dataset.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose, 98 | RadionuclideHalfLife: 99 | dataset.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife, 100 | RadiopharmaceuticalStartTime: 101 | dataset.RadiopharmaceuticalInformationSequence 102 | .RadiopharmaceuticalStartTime, // from old version of standard 103 | RadiopharmaceuticalStartDateTime: 104 | dataset.RadiopharmaceuticalInformationSequence 105 | .RadiopharmaceuticalStartDateTime, 106 | CorrectedImage: dataset.CorrectedImage, 107 | ActualFrameDuration: dataset.ActualFrameDuration, 108 | PhilipsPETPrivateGroup: { 109 | SUVScaleFactor: dataset['70531000'], 110 | ActivityConcentrationScaleFactor: dataset['70531009'], 111 | }, 112 | GEPrivatePostInjectionDateTime: dataset['0009100d'], 113 | }; 114 | } 115 | ); 116 | 117 | const instanceRescale = datasets.map( 118 | (dataset: any): InstanceRescaleData => { 119 | return { 120 | RescaleSlope: dataset.RescaleSlope, 121 | RescaleIntercept: dataset.RescaleIntercept, 122 | }; 123 | } 124 | ); 125 | 126 | return { 127 | instanceMetadata, 128 | instanceRescale, 129 | pixelDataTypedArray, 130 | frameLength, 131 | numFrames, 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /test/combineDateTime.test.ts: -------------------------------------------------------------------------------- 1 | import combineDateTime, { FullDateInterface } from '../src/combineDateTime'; 2 | 3 | describe('combineDateTime', () => { 4 | it('should properly combine date and time objects into a JS Date', () => { 5 | // Arrange 6 | const date = { 7 | year: 2020, 8 | month: 2, 9 | day: 27, 10 | }; 11 | 12 | const time = { 13 | hours: 9, 14 | minutes: 47, 15 | seconds: 10, 16 | fractionalSeconds: 12004, 17 | }; 18 | 19 | // Act 20 | const combinedDateTime = combineDateTime(date, time); 21 | 22 | // Assert 23 | const expected = new FullDateInterface('2020-02-27T09:47:10.120040Z'); 24 | expect(combinedDateTime).toEqual(expected); 25 | }); 26 | 27 | it('should properly combine date and time, even if some time values are missing', () => { 28 | // Arrange 29 | const date = { 30 | year: 2020, 31 | month: 11, 32 | day: 27, 33 | }; 34 | 35 | const time = {}; 36 | 37 | // Act 38 | const combinedDateTime = combineDateTime(date, time); 39 | 40 | // Assert 41 | const expected = new FullDateInterface('2020-11-27T00:00:00.000000Z'); 42 | expect(combinedDateTime).toEqual(expected); 43 | }); 44 | 45 | it('should properly return the time in sec', () => { 46 | // Arrange 47 | const date = { 48 | year: 2020, 49 | month: 2, 50 | day: 27, 51 | }; 52 | 53 | const time = { 54 | hours: 9, 55 | minutes: 47, 56 | seconds: 10, 57 | fractionalSeconds: 12004, 58 | }; 59 | 60 | // Act 61 | const combinedDateTime = combineDateTime(date, time); 62 | const timeInSec = combinedDateTime.getTimeInSec(); 63 | const dateJS = new Date(`2020-02-27T00:00:00.000000Z`); 64 | 65 | // Assert 66 | const expected = 67 | dateJS.getTime() / 1000 + 9 * 3600 + 47 * 60 + 10 + 0.12004; 68 | expect(timeInSec).toEqual(expected); 69 | }); 70 | 71 | it('should properly return the time in sec', () => { 72 | // Arrange 73 | const string = '2020-02-27T01:20Z'; 74 | 75 | // Act 76 | const date = new FullDateInterface(string); 77 | const timeInSec = date.getTimeInSec(); 78 | 79 | // Assert 80 | const dateJS = new Date('2020-02-27T00:00:00.000000Z'); 81 | const expected = dateJS.getTime() / 1000 + 1 * 3600 + 20 * 60; 82 | expect(timeInSec).toEqual(expected); 83 | }); 84 | 85 | it('should properly return the time in sec', () => { 86 | // Arrange 87 | const string = '2020-02-27T01Z'; 88 | 89 | // Act 90 | const date = new FullDateInterface(string); 91 | const timeInSec = date.getTimeInSec(); 92 | 93 | // Assert 94 | const dateJS = new Date('2020-02-27T00:00:00.000000Z'); 95 | const expected = dateJS.getTime() / 1000 + 1 * 3600; 96 | expect(timeInSec).toEqual(expected); 97 | }); 98 | 99 | it('should properly return the time in microsec', () => { 100 | // Arrange 101 | const date = { 102 | year: 2020, 103 | month: 2, 104 | day: 27, 105 | }; 106 | 107 | const time = { 108 | hours: 9, 109 | minutes: 47, 110 | seconds: 10, 111 | fractionalSeconds: 12004, 112 | }; 113 | 114 | // Act 115 | const combinedDateTime = combineDateTime(date, time); 116 | const timeInMicroSec = combinedDateTime.getTimeInMicroSec(); 117 | const dateJS = new Date(`2020-02-27T00:00:00.000000Z`); 118 | 119 | // Assert 120 | const expected = 121 | (dateJS.getTime() / 1000 + 9 * 3600 + 47 * 60 + 10 + 0.12004) * 1e6; 122 | expect(timeInMicroSec).toEqual(expected); 123 | }); 124 | 125 | it('should return error for invalid date', () => { 126 | // Arrange 127 | const date = { 128 | year: 4000, 129 | month: 31, 130 | day: -2, 131 | }; 132 | 133 | const time = { 134 | hours: 9, 135 | minutes: 47, 136 | seconds: 10, 137 | fractionalSeconds: 12004, 138 | }; 139 | 140 | // Act 141 | const combinedDateTime = combineDateTime(date, time); 142 | 143 | // Assert 144 | expect(() => { 145 | combinedDateTime.getTimeInSec(); 146 | }).toThrowError(`invalid date`); 147 | }); 148 | 149 | it('should return error for invalid time', () => { 150 | // Arrange 151 | const date = { 152 | year: 2020, 153 | month: 2, 154 | day: 27, 155 | }; 156 | 157 | const time = { 158 | hours: 58, 159 | minutes: -32, 160 | seconds: 100, 161 | fractionalSeconds: 12004, 162 | }; 163 | 164 | // Act 165 | const combinedDateTime = combineDateTime(date, time); 166 | 167 | // Assert 168 | expect(() => { 169 | combinedDateTime.getTimeInMicroSec(); 170 | }).toThrowError(`invalid time`); 171 | }); 172 | 173 | it('FullDateInterface should return error for bad date formatting', () => { 174 | // Arrange 175 | const string = '2TZ'; 176 | 177 | // Act 178 | const date = new FullDateInterface(string); 179 | 180 | // Assert 181 | expect(() => { 182 | date.getTimeInSec(); 183 | }).toThrowError(`invalid time`); 184 | }); 185 | 186 | it('FullDateInterface should return error for bad time formatting', () => { 187 | // Arrange 188 | const string = '2020-02-27TZ'; 189 | 190 | // Act 191 | const date = new FullDateInterface(string); 192 | 193 | // Assert 194 | expect(() => { 195 | date.getTimeInSec(); 196 | }).toThrowError(`invalid time`); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /src/calculateScanTimes.ts: -------------------------------------------------------------------------------- 1 | import combineDateTime, { FullDateInterface } from './combineDateTime'; 2 | import parseDA, { DateInterface } from './parseDA'; 3 | import parseTM, { TimeInterface } from './parseTM'; 4 | import dateTimeToFullDateInterface from './dateTimeToFullDateInterface'; 5 | 6 | /** 7 | * Javascript object with scan properties 8 | * 9 | * @interface InstanceMetadataForScanTimes 10 | */ 11 | interface InstanceMetadataForScanTimes { 12 | SeriesDate: string; 13 | SeriesTime: string; 14 | AcquisitionDate: string; 15 | AcquisitionTime: string; 16 | 17 | GEPrivatePostInjectionDateTime?: string; 18 | 19 | // Only used in Siemens case 20 | RadionuclideHalfLife?: number; // RadionuclideHalfLife(0x0018,0x1075) in Radiopharmaceutical Information Sequence(0x0054,0x0016) 21 | RadionuclideTotalDose?: number; 22 | FrameReferenceTime?: number; 23 | ActualFrameDuration?: number; 24 | } 25 | 26 | /** 27 | * Calculate the scan times 28 | * 29 | * @export 30 | * @param {InstanceMetadataForScanTimes[]} instances 31 | * @returns {FullDateInterface[]} 32 | */ 33 | export default function calculateScanTimes( 34 | instances: InstanceMetadataForScanTimes[] 35 | ): FullDateInterface[] { 36 | const { 37 | SeriesDate, 38 | SeriesTime, 39 | GEPrivatePostInjectionDateTime, 40 | } = instances[0]; 41 | const results = new Array(instances.length); 42 | const seriesDate: DateInterface = parseDA(SeriesDate); 43 | const seriesTime: TimeInterface = parseTM(SeriesTime); 44 | const seriesDateTime: FullDateInterface = combineDateTime( 45 | seriesDate, 46 | seriesTime 47 | ); 48 | 49 | let earliestAcquisitionDateTime = new FullDateInterface( 50 | `3000-01-01T00:00:00.000000Z` 51 | ); 52 | let timeError = earliestAcquisitionDateTime.getTimeInSec(); 53 | instances.forEach(instance => { 54 | const { AcquisitionDate, AcquisitionTime } = instance; 55 | 56 | const acquisitionDate: DateInterface = parseDA(AcquisitionDate); 57 | const acquisitionTime: TimeInterface = parseTM(AcquisitionTime); 58 | const acquisitionDateTime: FullDateInterface = combineDateTime( 59 | acquisitionDate, 60 | acquisitionTime 61 | ); 62 | 63 | if (earliestAcquisitionDateTime.getTimeInSec() >= timeError) { 64 | earliestAcquisitionDateTime = acquisitionDateTime; 65 | } else { 66 | earliestAcquisitionDateTime = 67 | acquisitionDateTime.getTimeInSec() < 68 | earliestAcquisitionDateTime.getTimeInSec() 69 | ? acquisitionDateTime 70 | : earliestAcquisitionDateTime; 71 | } 72 | }); 73 | 74 | if (earliestAcquisitionDateTime.getTimeInSec() >= timeError) { 75 | throw new Error('Earliest acquisition time or date could not be parsed.'); 76 | } 77 | 78 | if ( 79 | seriesDateTime.getTimeInSec() <= earliestAcquisitionDateTime.getTimeInSec() 80 | ) { 81 | return results.fill(seriesDateTime); 82 | } else { 83 | if (GEPrivatePostInjectionDateTime) { 84 | // GE Private scan 85 | return results.fill( 86 | dateTimeToFullDateInterface(GEPrivatePostInjectionDateTime) 87 | ); 88 | } else { 89 | /*const hasValidFrameTimes = instances.every(instance => { 90 | return ( 91 | instance.FrameReferenceTime && 92 | instance.FrameReferenceTime > 0 && 93 | instance.ActualFrameDuration && 94 | instance.ActualFrameDuration > 0 95 | ); 96 | });*/ 97 | 98 | // TODO: Temporarily commented out the checks and logic below to 99 | // investigate the BQML_AC_DT_lessThan_S_DT_SIEMENS-instances case 100 | //if (!hasValidFrameTimes) { 101 | return results.fill(earliestAcquisitionDateTime); 102 | //} 103 | 104 | /* Siemens PETsyngo 3.x multi-injection logic 105 | - backcompute from center (average count rate ) of time window for bed position (frame) in series (reliable in all cases) 106 | - Acquisition Date (0x0008,0x0022) and Time (0x0008,0x0032) are the start of the bed position (frame) 107 | - Frame Reference Time (0x0054,0x1300) is the offset (ms) from the scan Date and Time we want to the average count rate time 108 | */ 109 | /*return instances.map(instance => { 110 | const { 111 | FrameReferenceTime, 112 | ActualFrameDuration, 113 | RadionuclideHalfLife, 114 | AcquisitionDate, 115 | AcquisitionTime, 116 | } = instance; 117 | // Some of these checks are only here because the compiler is complaining 118 | // We could potentially use the ! operator instead 119 | if (!FrameReferenceTime || FrameReferenceTime <= 0) { 120 | throw new Error( 121 | `FrameReferenceTime is invalid: ${FrameReferenceTime}` 122 | ); 123 | } 124 | 125 | if (!ActualFrameDuration || ActualFrameDuration <= 0) { 126 | throw new Error( 127 | `ActualFrameDuration is invalid: ${ActualFrameDuration}` 128 | ); 129 | } 130 | 131 | if (!RadionuclideHalfLife) { 132 | throw new Error('RadionuclideHalfLife is required'); 133 | } 134 | 135 | if (!AcquisitionDate) { 136 | throw new Error('AcquisitionDate is required'); 137 | } 138 | 139 | if (!AcquisitionTime) { 140 | throw new Error('AcquisitionTime is required'); 141 | } 142 | 143 | const acquisitionDate: DateInterface = parseDA(AcquisitionDate); 144 | const acquisitionTime: TimeInterface = parseTM(AcquisitionTime); 145 | const acquisitionDateTime: FullDateInterface = combineDateTime( 146 | acquisitionDate, 147 | acquisitionTime 148 | ); 149 | 150 | const frameDurationInSec = ActualFrameDuration / 1000; 151 | const decayConstant = Math.log(2) / RadionuclideHalfLife; 152 | const decayDuringFrame = decayConstant * frameDurationInSec; 153 | // TODO: double check this is correctly copied from QIBA pseudocode 154 | const averageCountRateTimeWithinFrameInSec = 155 | (1 / decayConstant) * 156 | Math.log(decayDuringFrame / (1 - Math.exp(-decayConstant))); 157 | const scanDateTimeAsNumber = 158 | Number(acquisitionDateTime) - 159 | FrameReferenceTime / 1000 + 160 | averageCountRateTimeWithinFrameInSec; 161 | 162 | const scanDate = new Date(scanDateTimeAsNumber); 163 | console.log('SIEMENS PATH'); 164 | console.log(new Date(scanDateTimeAsNumber)); 165 | return scanDate; 166 | });*/ 167 | } 168 | } 169 | } 170 | 171 | export { calculateScanTimes }; 172 | -------------------------------------------------------------------------------- /test/calculateScanTimes.test.ts: -------------------------------------------------------------------------------- 1 | import calculateScanTimes from '../src/calculateScanTimes'; 2 | import dateTimeToFullDateInterface from '../src/dateTimeToFullDateInterface'; 3 | 4 | describe('calculateScanTimes', () => { 5 | describe('when SeriesData and AcquisitionDate match and SeriesTime is earlier than AcquisitionTime', () => { 6 | it('should return the earliest DateTime among all series', () => { 7 | // Arrange 8 | const SeriesDate = '20201127'; 9 | const SeriesTime = '093010.10001'; 10 | const instances = [ 11 | { 12 | SeriesDate, 13 | AcquisitionDate: SeriesDate, 14 | AcquisitionTime: '095010.12001', 15 | SeriesTime, 16 | }, 17 | { 18 | SeriesDate, 19 | AcquisitionDate: SeriesDate, 20 | AcquisitionTime: '094710.12004', // This is the earliest value 21 | SeriesTime, 22 | }, 23 | { 24 | SeriesDate, 25 | AcquisitionDate: SeriesDate, 26 | AcquisitionTime: '095340.12001', 27 | SeriesTime, 28 | }, 29 | ]; 30 | 31 | // Act 32 | const scanDateTimes = calculateScanTimes(instances); 33 | 34 | // Assert 35 | const earliestDateTime = dateTimeToFullDateInterface( 36 | `${SeriesDate}${SeriesTime}` 37 | ); 38 | 39 | scanDateTimes.forEach(scanDateTime => { 40 | expect(scanDateTime).toEqual(earliestDateTime); 41 | }); 42 | }); 43 | }); 44 | 45 | describe('when SeriesData and AcquisitionDate do not match', () => { 46 | // (i.e. AcquisitionDate is the next day, due to acquiring near midnight or 47 | // long half-life tracers such as Iodine 124) 48 | it('should return the earliest DateTime among all series', () => { 49 | // Arrange 50 | const SeriesDate = '20201127'; 51 | const AcquisitionDate = '20201128'; 52 | const SeriesTime = '113010'; 53 | const instances = [ 54 | { 55 | SeriesDate, 56 | AcquisitionDate, 57 | AcquisitionTime: '115010', 58 | SeriesTime, 59 | }, 60 | { 61 | SeriesDate, 62 | AcquisitionDate, 63 | AcquisitionTime: '113010', // This is the earliest value 64 | SeriesTime, 65 | }, 66 | { 67 | SeriesDate, 68 | AcquisitionDate, 69 | AcquisitionTime: '123010', 70 | SeriesTime, 71 | }, 72 | ]; 73 | 74 | // Act 75 | const scanDateTimes = calculateScanTimes(instances); 76 | 77 | // Assert 78 | const earliestDateTime = dateTimeToFullDateInterface( 79 | `${SeriesDate}${SeriesTime}` 80 | ); 81 | 82 | scanDateTimes.forEach(scanDateTime => { 83 | expect(scanDateTime).toEqual(earliestDateTime); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('SeriesData and AcquisitionDate do not match and SeriesTime is later than AcquisitionTime', () => { 89 | it('should return the earliest DateTime among all series', () => { 90 | // Arrange 91 | const SeriesDate = '20201127'; 92 | const AcquisitionDate = '20201128'; 93 | const SeriesTime = '173010'; 94 | const instances = [ 95 | { 96 | SeriesDate, 97 | AcquisitionDate, 98 | AcquisitionTime: '115010', 99 | SeriesTime, 100 | }, 101 | { 102 | SeriesDate, 103 | AcquisitionDate, 104 | AcquisitionTime: '113010', // This is the earliest value 105 | SeriesTime, 106 | }, 107 | { 108 | SeriesDate, 109 | AcquisitionDate, 110 | AcquisitionTime: '123010', 111 | SeriesTime, 112 | }, 113 | ]; 114 | 115 | // Act 116 | const scanDateTimes = calculateScanTimes(instances); 117 | 118 | // Assert 119 | const earliestDateTime = dateTimeToFullDateInterface( 120 | `${SeriesDate}${SeriesTime}` 121 | ); 122 | 123 | scanDateTimes.forEach(scanDateTime => { 124 | expect(scanDateTime).toEqual(earliestDateTime); 125 | }); 126 | }); 127 | }); 128 | 129 | describe('SeriesData and AcquisitionDate match and SeriesTime is earlier than AcquisitionTime', () => { 130 | it('should return the earliest DateTime among all series', () => { 131 | // Arrange 132 | const SeriesDate = '20201127'; 133 | const AcquisitionDate = SeriesDate; 134 | const SeriesTime = '113010'; 135 | const instances = [ 136 | { 137 | SeriesDate, 138 | AcquisitionDate, 139 | AcquisitionTime: '115010', 140 | SeriesTime, 141 | }, 142 | { 143 | SeriesDate, 144 | AcquisitionDate, 145 | AcquisitionTime: '113010', // This is the earliest value 146 | SeriesTime, 147 | }, 148 | { 149 | SeriesDate, 150 | AcquisitionDate, 151 | AcquisitionTime: '123010', 152 | SeriesTime, 153 | }, 154 | ]; 155 | 156 | // Act 157 | const scanDateTimes = calculateScanTimes(instances); 158 | 159 | // Assert 160 | const earliestDateTime = dateTimeToFullDateInterface( 161 | `${SeriesDate}${SeriesTime}` 162 | ); 163 | 164 | scanDateTimes.forEach(scanDateTime => { 165 | expect(scanDateTime).toEqual(earliestDateTime); 166 | }); 167 | }); 168 | }); 169 | 170 | describe('GEPrivatePostInjectionDateTime', () => { 171 | it('should return the GEPrivatePostInjection DateTime', () => { 172 | // Arrange 173 | const SeriesDate = '20201127'; 174 | const AcquisitionDate = SeriesDate; 175 | const SeriesTime = '133010'; 176 | const GEPrivatePostInjectionDateTime = '20201128183010'; 177 | const instances = [ 178 | { 179 | SeriesDate, 180 | SeriesTime, 181 | AcquisitionDate, 182 | AcquisitionTime: '123010', 183 | GEPrivatePostInjectionDateTime, 184 | }, 185 | ]; 186 | 187 | // Act 188 | const scanDateTimes = calculateScanTimes(instances); 189 | 190 | // Assert 191 | const GEDateTime = dateTimeToFullDateInterface( 192 | GEPrivatePostInjectionDateTime 193 | ); 194 | 195 | scanDateTimes.forEach(scanDateTime => { 196 | expect(scanDateTime).toEqual(GEDateTime); 197 | }); 198 | }); 199 | }); 200 | }); 201 | 202 | describe('calculateScanTimes Error Handling', () => { 203 | it('throws an Error if earliest acquisition scan time could not be calculated', () => { 204 | // Arrange 205 | const instances = [ 206 | { 207 | SeriesDate: '20201127', 208 | SeriesTime: '133010', 209 | AcquisitionDate: '30001127', 210 | AcquisitionTime: '133010', 211 | }, 212 | ]; 213 | 214 | // Act 215 | expect(() => { 216 | calculateScanTimes(instances); 217 | }).toThrowError('Earliest acquisition time or date could not be parsed.'); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /test/calculateSUVScalingFactors.test.ts: -------------------------------------------------------------------------------- 1 | import { calculateSUVScalingFactors } from '../src'; 2 | import { InstanceMetadata } from '../src/types'; 3 | 4 | let input: InstanceMetadata[]; 5 | let inputPhilips: InstanceMetadata[]; 6 | let multiInput: InstanceMetadata[]; 7 | let inputlbmbsaFactor: InstanceMetadata[]; 8 | 9 | describe('calculateSUVScalingFactors', () => { 10 | beforeEach(() => { 11 | input = [ 12 | { 13 | CorrectedImage: ['ATTN', 'DECY'], 14 | Units: 'BQML', 15 | RadionuclideHalfLife: 10, 16 | RadiopharmaceuticalStartDateTime: '20201023095417', 17 | RadionuclideTotalDose: 100, 18 | DecayCorrection: 'ADMIN', 19 | PatientWeight: 100, 20 | SeriesTime: '095417', 21 | SeriesDate: '20201023', 22 | AcquisitionTime: '095417', 23 | AcquisitionDate: '20201023', 24 | PhilipsPETPrivateGroup: { 25 | SUVScaleFactor: 0.000551, 26 | ActivityConcentrationScaleFactor: 1.602563, 27 | }, 28 | }, 29 | ]; 30 | inputlbmbsaFactor = [ 31 | { 32 | CorrectedImage: ['ATTN', 'DECY'], 33 | Units: 'BQML', 34 | RadionuclideHalfLife: 10, 35 | RadiopharmaceuticalStartDateTime: '20201023095417', 36 | RadionuclideTotalDose: 100, 37 | DecayCorrection: 'ADMIN', 38 | PatientWeight: 75, 39 | SeriesTime: '095417', 40 | SeriesDate: '20201023', 41 | AcquisitionTime: '095417', 42 | AcquisitionDate: '20201023', 43 | PatientSex: 'M', 44 | PatientSize: 1.85, 45 | }, 46 | ]; 47 | inputPhilips = [ 48 | { 49 | CorrectedImage: ['ATTN', 'DECY'], 50 | Units: 'CNTS', 51 | RadionuclideHalfLife: 10, 52 | RadiopharmaceuticalStartDateTime: '20201023095417', 53 | RadionuclideTotalDose: 100, 54 | DecayCorrection: 'ADMIN', 55 | PatientWeight: 100, 56 | SeriesTime: '095417', 57 | SeriesDate: '20201023', 58 | AcquisitionTime: '095417', 59 | AcquisitionDate: '20201023', 60 | PhilipsPETPrivateGroup: { 61 | SUVScaleFactor: 0, 62 | ActivityConcentrationScaleFactor: 1.602563, 63 | }, 64 | }, 65 | ]; 66 | }); 67 | 68 | it('returns suvbw 1.0 if Units are GML', () => { 69 | input[0].Units = 'GML'; 70 | 71 | expect(calculateSUVScalingFactors(input)).toEqual([{ suvbw: 1.0 }]); 72 | }); 73 | 74 | it('calculates suvbw if Units are CNTS (PhilipsPETPrivateGroup SUVScaleFactor available)', () => { 75 | input[0].Units = 'CNTS'; 76 | 77 | expect(calculateSUVScalingFactors(input)).toEqual([{ suvbw: 0.000551 }]); 78 | }); 79 | 80 | it('calculates suvbw if Units are CNTS (PhilipsPETPrivateGroup SUVScaleFactor not available)', () => { 81 | expect(calculateSUVScalingFactors(inputPhilips)).toEqual([ 82 | { suvbw: 1602.5629999999999 }, 83 | ]); 84 | }); 85 | 86 | it('calculates suvbsa, suvlbm and suvFactor if weight, size and sex known', () => { 87 | expect(calculateSUVScalingFactors(inputlbmbsaFactor)).toEqual([ 88 | { 89 | suvbw: 750, 90 | suvbsa: 198.13758427117767, 91 | suvlbm: 627.7757487216948, 92 | suvlbmJanma: 609.1533588651974, 93 | }, 94 | ]); 95 | }); 96 | }); 97 | 98 | describe('calculateSUVScalingFactor Error Handling', () => { 99 | beforeEach(() => { 100 | input = [ 101 | { 102 | CorrectedImage: ['ATTN', 'DECY'], 103 | Units: 'BQML', 104 | RadionuclideHalfLife: 10, 105 | RadiopharmaceuticalStartDateTime: '20201023095417', 106 | RadionuclideTotalDose: 100, 107 | DecayCorrection: 'ADMIN', 108 | PatientWeight: 100, 109 | SeriesTime: '095417', 110 | SeriesDate: '20201023', 111 | AcquisitionTime: '095417', 112 | AcquisitionDate: '20201023', 113 | }, 114 | ]; 115 | multiInput = [ 116 | { 117 | CorrectedImage: ['ATTN', 'DECY'], 118 | Units: 'BQML', 119 | RadionuclideHalfLife: 10, 120 | RadiopharmaceuticalStartDateTime: '20201023095417', 121 | RadionuclideTotalDose: 100, 122 | DecayCorrection: 'ADMIN', 123 | PatientWeight: 100, 124 | SeriesTime: '095417', 125 | SeriesDate: '20201023', 126 | AcquisitionTime: '095417', 127 | AcquisitionDate: '20201023', 128 | }, 129 | { 130 | CorrectedImage: ['ATTN', 'DECY'], 131 | Units: 'BQML', 132 | RadionuclideHalfLife: 11, 133 | RadiopharmaceuticalStartDateTime: '20201023095417', 134 | RadionuclideTotalDose: 100, 135 | DecayCorrection: 'ADMIN', 136 | PatientWeight: 100, 137 | SeriesTime: '095417', 138 | SeriesDate: '20201023', 139 | AcquisitionTime: '095417', 140 | AcquisitionDate: '20201023', 141 | }, 142 | ]; 143 | inputPhilips = [ 144 | { 145 | CorrectedImage: ['ATTN', 'DECY'], 146 | Units: 'CNTS', 147 | RadionuclideHalfLife: 10, 148 | RadiopharmaceuticalStartDateTime: '20201023095417', 149 | RadionuclideTotalDose: 100, 150 | DecayCorrection: 'ADMIN', 151 | PatientWeight: 100, 152 | SeriesTime: '095417', 153 | SeriesDate: '20201023', 154 | AcquisitionTime: '095417', 155 | AcquisitionDate: '20201023', 156 | PhilipsPETPrivateGroup: { 157 | SUVScaleFactor: 0, 158 | ActivityConcentrationScaleFactor: 0, 159 | }, 160 | }, 161 | ]; 162 | }); 163 | 164 | it("throws an Error if CorrectedImage value does not contain 'ATTN' and 'DECY'", () => { 165 | input[0].CorrectedImage = ['Blah']; 166 | 167 | expect(() => { 168 | calculateSUVScalingFactors(input); 169 | }).toThrowError( 170 | `CorrectedImage must contain "ATTN" and "DECY": ${input[0].CorrectedImage}` 171 | ); 172 | }); 173 | 174 | it('throws an Error if Units is not CNTS, BQML, or GML', () => { 175 | input[0].Units = 'Blah'; 176 | 177 | expect(() => { 178 | calculateSUVScalingFactors(input); 179 | }).toThrowError(`Units has an invalid value: ${input[0].Units}`); 180 | }); 181 | 182 | it('throws an Error if Decay time is negative', () => { 183 | input[0].RadiopharmaceuticalStartDateTime = '20201023115417'; 184 | 185 | expect(() => { 186 | calculateSUVScalingFactors(input); 187 | }).toThrowError('Decay time cannot be less than zero'); 188 | }); 189 | 190 | it('throws an Error if the PatientWeight is zero', () => { 191 | input[0].PatientWeight = 0; 192 | 193 | expect(() => { 194 | calculateSUVScalingFactors(input); 195 | }).toThrowError( 196 | `PatientWeight value is missing. It is not possible to calculate the SUV factors` 197 | ); 198 | }); 199 | 200 | it('throws an Error if series-level metadata are different', () => { 201 | expect(() => { 202 | calculateSUVScalingFactors(multiInput); 203 | }).toThrowError( 204 | 'The set of instances does not appear to come from one Series. Every instance must have identical values for series-level metadata properties' 205 | ); 206 | }); 207 | 208 | it('throws an Error if Philips has no ValidSUVScaleFactor and ValidActivityConcentrationScaleFactor', () => { 209 | expect(() => { 210 | calculateSUVScalingFactors(inputPhilips); 211 | }).toThrowError( 212 | `Units are in CNTS, but PhilipsPETPrivateGroup has invalid values: ${JSON.stringify( 213 | inputPhilips[0].PhilipsPETPrivateGroup 214 | )}` 215 | ); 216 | }); 217 | }); 218 | -------------------------------------------------------------------------------- /src/calculateSUVScalingFactors.ts: -------------------------------------------------------------------------------- 1 | import { FullDateInterface } from './combineDateTime'; 2 | import { calculateScanTimes } from './calculateScanTimes'; 3 | import { 4 | calculateSUVlbmJanmahasatianScalingFactor, 5 | calculateSUVlbmScalingFactor, 6 | SUVlbmScalingFactorInput, 7 | } from './calculateSUVlbmScalingFactor'; 8 | import { 9 | calculateSUVbsaScalingFactor, 10 | SUVbsaScalingFactorInput, 11 | } from './calculateSUVbsaScalingFactor'; 12 | import { calculateStartTime } from './calculateStartTime'; 13 | import { InstanceMetadata } from './types'; 14 | 15 | /** 16 | * Javascript object containing the SUV and SUL factors. 17 | * TODO, the result property names may changes 18 | * 19 | * @interface ScalingFactorResult 20 | */ 21 | interface ScalingFactorResult { 22 | suvbw: number; 23 | suvlbm?: number; 24 | suvlbmJanma?: number; 25 | suvbsa?: number; 26 | } 27 | 28 | /** 29 | * The injected dose used to calculate SUV is corrected for the 30 | * decay that occurs between the time of injection and the start of the scan 31 | * 32 | * @param {InstanceMetadata[]} instances 33 | * @returns {number[]} 34 | */ 35 | function calculateDecayCorrection(instances: InstanceMetadata[]): number[] { 36 | const { 37 | RadionuclideTotalDose, 38 | RadionuclideHalfLife, 39 | RadiopharmaceuticalStartDateTime, 40 | RadiopharmaceuticalStartTime, 41 | SeriesDate, 42 | } = instances[0]; 43 | 44 | if (RadionuclideTotalDose === undefined || RadionuclideTotalDose === null) { 45 | throw new Error( 46 | 'calculateDecayCorrection : RadionuclideTotalDose value not found.' 47 | ); 48 | } 49 | 50 | if (RadionuclideHalfLife === undefined || RadionuclideHalfLife === null) { 51 | throw new Error( 52 | 'calculateDecayCorrection : RadionuclideHalfLife value not found.' 53 | ); 54 | } 55 | 56 | const scanTimes: FullDateInterface[] = calculateScanTimes(instances); 57 | const startTime: FullDateInterface = calculateStartTime({ 58 | RadiopharmaceuticalStartDateTime, 59 | RadiopharmaceuticalStartTime, 60 | SeriesDate, 61 | }); 62 | 63 | return instances.map((_, index) => { 64 | const scanTime = scanTimes[index]; 65 | const decayTimeInSec: number = 66 | scanTime.getTimeInSec() - startTime.getTimeInSec(); 67 | if (decayTimeInSec < 0) { 68 | throw new Error('Decay time cannot be less than zero'); 69 | } 70 | 71 | const decayedDose: number = 72 | RadionuclideTotalDose * 73 | Math.pow(2, -decayTimeInSec / RadionuclideHalfLife); 74 | 75 | return 1 / decayedDose; 76 | }); 77 | } 78 | 79 | /** 80 | * 81 | * @param a Simple value or array of simple values 82 | * @param b Simple value or array of simple values 83 | * @returns boolean true if the values are equal. 84 | */ 85 | const deepEquals = ( 86 | a: string | number | any[], 87 | b: string | number | any[] 88 | ): boolean => { 89 | return ( 90 | a === b || 91 | (Array.isArray(a) && 92 | Array.isArray(b) && 93 | a.length === b.length && 94 | a.every((val, index) => val === b[index])) 95 | ); 96 | }; 97 | 98 | /** 99 | * Calculate the SUV factor 100 | * 101 | * Note: Rescale Slope / Intercept must still be applied. These must be applied 102 | * on a per-Frame basis, since some scanners may have different values per Frame. 103 | * 104 | * @export 105 | * @param {InstanceMetadata[]} instances 106 | * @returns {ScalingFactorResult[]} 107 | */ 108 | export default function calculateSUVScalingFactors( 109 | instances: InstanceMetadata[] 110 | ): ScalingFactorResult[] { 111 | const { 112 | CorrectedImage, 113 | Units, 114 | PhilipsPETPrivateGroup, 115 | PatientWeight, 116 | PatientSex, 117 | PatientSize, 118 | } = instances[0]; 119 | 120 | if (!CorrectedImage.includes('ATTN') || !CorrectedImage.includes('DECY')) { 121 | throw new Error( 122 | `CorrectedImage must contain "ATTN" and "DECY": ${CorrectedImage}` 123 | ); 124 | } 125 | 126 | // Sanity check that every instance provided has identical 127 | // values for series-level metadata. If not, the provided 128 | // data is invalid. 129 | const isSingleSeries = instances.every(instance => { 130 | return ( 131 | instance.Units === Units && 132 | deepEquals(instance.CorrectedImage, CorrectedImage) && 133 | instance.PatientWeight === PatientWeight && 134 | instance.PatientSex === PatientSex && 135 | instance.PatientSize === PatientSize && 136 | instance.RadionuclideHalfLife === instances[0].RadionuclideHalfLife && 137 | instance.RadionuclideTotalDose === instances[0].RadionuclideTotalDose && 138 | instance.DecayCorrection === instances[0].DecayCorrection && 139 | instance.SeriesDate === instances[0].SeriesDate && 140 | instance.SeriesTime === instances[0].SeriesTime 141 | ); 142 | }); 143 | 144 | if (!isSingleSeries) { 145 | throw new Error( 146 | 'The set of instances does not appear to come from one Series. Every instance must have identical values for series-level metadata properties' 147 | ); 148 | } 149 | 150 | // Treat null, undefined and zero as a missing PatientWeight. 151 | if (!PatientWeight) { 152 | throw new Error( 153 | 'PatientWeight value is missing. It is not possible to calculate the SUV factors' 154 | ); 155 | } 156 | 157 | let decayCorrectionArray: number[] = new Array(instances.length); 158 | decayCorrectionArray = calculateDecayCorrection(instances); 159 | 160 | let results: number[] = new Array(instances.length); 161 | const weightInGrams: number = PatientWeight * 1000; 162 | 163 | if (Units === 'BQML') { 164 | results = decayCorrectionArray.map(function(value) { 165 | return value * weightInGrams; 166 | }); 167 | } else if (Units === 'CNTS') { 168 | const hasValidSUVScaleFactor: boolean = instances.every(instance => { 169 | return ( 170 | instance.PhilipsPETPrivateGroup && 171 | instance.PhilipsPETPrivateGroup?.SUVScaleFactor !== null && 172 | instance.PhilipsPETPrivateGroup?.SUVScaleFactor !== undefined && 173 | instance.PhilipsPETPrivateGroup?.SUVScaleFactor !== 0 174 | ); 175 | }); 176 | 177 | const hasValidActivityConcentrationScaleFactor: boolean = instances.every( 178 | instance => { 179 | return ( 180 | instance.PhilipsPETPrivateGroup && 181 | !instance.PhilipsPETPrivateGroup?.SUVScaleFactor && 182 | instance.PhilipsPETPrivateGroup?.ActivityConcentrationScaleFactor !== 183 | undefined && 184 | instance.PhilipsPETPrivateGroup?.ActivityConcentrationScaleFactor !== 185 | 0 186 | ); 187 | } 188 | ); 189 | 190 | //console.log(`hasValidSUVScaleFactor: ${hasValidSUVScaleFactor}`); 191 | //console.log(`hasValidActivityConcentrationScaleFactor: ${hasValidActivityConcentrationScaleFactor}`); 192 | 193 | if (hasValidSUVScaleFactor) { 194 | results = instances.map( 195 | // Added ! to tell Typescript that this can't be undefined, since we are testing it 196 | // in the .every loop above. 197 | instance => instance.PhilipsPETPrivateGroup!.SUVScaleFactor! 198 | ); 199 | } else if (hasValidActivityConcentrationScaleFactor) { 200 | // if (0x7053,0x1000) not present, but (0x7053,0x1009) is present, then (0x7053,0x1009) * Rescale Slope, 201 | // scales pixels to Bq/ml, and proceed as if Units are BQML 202 | results = instances.map((instance, index) => { 203 | // Added ! to tell Typescript that this can't be undefined, since we are testing it 204 | // in the .every loop above. 205 | return ( 206 | instance.PhilipsPETPrivateGroup!.ActivityConcentrationScaleFactor! * 207 | decayCorrectionArray[index] * 208 | weightInGrams 209 | ); 210 | }); 211 | } else { 212 | throw new Error( 213 | `Units are in CNTS, but PhilipsPETPrivateGroup has invalid values: ${JSON.stringify( 214 | PhilipsPETPrivateGroup 215 | )}` 216 | ); 217 | } 218 | } else if (Units === 'GML') { 219 | // assumes that GML indicates SUVbw instead of SUVlbm 220 | results.fill(1); 221 | } else { 222 | throw new Error(`Units has an invalid value: ${Units}`); 223 | } 224 | 225 | // get BSA 226 | let suvbsaFactor: number | undefined; 227 | if (PatientSize === null || PatientSize === undefined) { 228 | console.warn( 229 | 'PatientSize value is missing. It is not possible to calculate the SUV bsa factors' 230 | ); 231 | } else { 232 | const sulInputs: SUVbsaScalingFactorInput = { 233 | PatientWeight, 234 | PatientSize, 235 | }; 236 | 237 | suvbsaFactor = calculateSUVbsaScalingFactor(sulInputs); 238 | } 239 | 240 | // get LBM 241 | let suvlbmFactor: number | undefined; 242 | let suvlbmJenmaFactor: number | undefined; 243 | if (PatientSize === null || PatientSize === undefined) { 244 | console.warn( 245 | 'PatientSize value is missing. It is not possible to calculate the SUV lbm factors' 246 | ); 247 | } else if (PatientSex === null || PatientSex === undefined) { 248 | console.warn( 249 | 'PatientSex value is missing. It is not possible to calculate the SUV lbm factors' 250 | ); 251 | } else { 252 | const suvlbmInputs: SUVlbmScalingFactorInput = { 253 | PatientWeight, 254 | PatientSex, 255 | PatientSize, 256 | }; 257 | 258 | suvlbmFactor = calculateSUVlbmScalingFactor(suvlbmInputs); 259 | suvlbmJenmaFactor = calculateSUVlbmJanmahasatianScalingFactor(suvlbmInputs); 260 | } 261 | 262 | return results.map(function(result, index) { 263 | const factors: ScalingFactorResult = { 264 | suvbw: result, 265 | }; 266 | 267 | if (suvbsaFactor) { 268 | // multiply for BSA 269 | factors.suvbsa = decayCorrectionArray[index] * suvbsaFactor; 270 | } 271 | 272 | if (suvlbmFactor) { 273 | // multiply for LBM 274 | factors.suvlbm = decayCorrectionArray[index] * suvlbmFactor; 275 | } 276 | 277 | if (suvlbmJenmaFactor) { 278 | factors.suvlbmJanma = decayCorrectionArray[index] * suvlbmJenmaFactor; 279 | } 280 | 281 | // factor formulaes taken from: 282 | // https://www.medicalconnections.co.uk/kb/calculating-suv-from-pet-images/ 283 | 284 | return factors; 285 | }); 286 | } 287 | 288 | export { calculateSUVScalingFactors }; 289 | --------------------------------------------------------------------------------