src_dicom_dicomPet.js

import {
  getDate,
  getTime,
  getDateTime
} from './dicomDate.js';
import {checkDataElement} from './dataElement.js';
import {logger} from '../utils/logger.js';

// doc imports
/* eslint-disable no-unused-vars */
import {DataElement} from './dataElement.js';
/* eslint-enable no-unused-vars */

/**
 * Related DICOM tag keys.
 */
const TagKeys = {
  SeriesDate: '00080021',
  AcquisitionDate: '00080022',
  SeriesTime: '00080031',
  AcquisitionTime: '00080032',
  RadiopharmaceuticalInformationSequence: '00540016',
  RadionuclideTotalDose: '00181074',
  RadionuclideHalfLife: '00181075',
  RadiopharmaceuticalStartDateTime: '00181078',
  RadiopharmaceuticalStartTime: '00181072',
  FrameReferenceTime: '00541300',
  ActualFrameDuration: '00181242',
  CorrectedImage: '00280051',
  DecayCorrection: '00541102',
  Units: '00541001',
  PatientWeight: '00101030'
};

/**
 * Get the decayed dose (Bq).
 *
 * @param {Object<string, DataElement>} elements The DICOM elements to check.
 * @returns {object} The value and a warning if
 *   the elements are not as expected.
 */
function getDecayedDose(elements) {
  let warning = '';
  let warn;

  // SeriesDate (type1)
  const seriesDateEl = elements[TagKeys.SeriesDate];
  const seriesDateObj = getDate(seriesDateEl);

  let totalDose;
  let halfLife;
  let radioStart;

  const radioInfoSqStr = 'RadiopharmaceuticalInformationSequence (00540016)';
  const radioInfoSq = elements[TagKeys.RadiopharmaceuticalInformationSequence];
  warning += checkDataElement(radioInfoSq, radioInfoSqStr);
  if (typeof radioInfoSq !== 'undefined') {
    if (radioInfoSq.value.length !== 1) {
      logger.warn(
        'Found more than 1 istopes in RadiopharmaceuticalInformation Sequence.'
      );
    }

    // RadionuclideTotalDose (type3, Bq)
    const totalDoseStr = 'RadionuclideTotalDose (00181074)';
    const totalDoseEl = radioInfoSq.value[0][TagKeys.RadionuclideTotalDose];
    warn = checkDataElement(totalDoseEl, totalDoseStr);
    if (warn.length === 0) {
      const dose = parseFloat(totalDoseEl.value[0]);
      if (!isNaN(dose)) {
        totalDose = dose;
      } else {
        warning += ' TotalDose is not a number';
      }
    } else {
      warning += warn;
    }

    // RadionuclideHalfLife (type3, seconds)
    const halfLifeStr = 'RadionuclideHalfLife (00181075)';
    const halfLifeEl = radioInfoSq.value[0][TagKeys.RadionuclideHalfLife];
    warn = checkDataElement(halfLifeEl, halfLifeStr);
    if (warn.length === 0) {
      const hl = parseFloat(halfLifeEl.value[0]);
      if (!isNaN(hl)) {
        halfLife = hl;
      } else {
        warning += ' HalfLife is not a number';
      }
    } else {
      warning += warn;
    }

    // RadiopharmaceuticalStartDateTime (type3)
    const radioStartDateTimeEl =
      radioInfoSq.value[0][TagKeys.RadiopharmaceuticalStartDateTime];
    let radioStartDateObj;
    let radioStartTimeObj;
    if (typeof radioStartDateTimeEl === 'undefined') {
      // use seriesDate as radioStartDate
      radioStartDateObj = seriesDateObj;
      // try RadiopharmaceuticalStartTime (type3)
      const radioStartTimeEl =
        radioInfoSq.value[0][TagKeys.RadiopharmaceuticalStartTime];
      radioStartTimeObj = getTime(radioStartTimeEl);
    } else {
      const radioStartDateTime = getDateTime(radioStartDateTimeEl);
      radioStartDateObj = radioStartDateTime.date;
      radioStartTimeObj = radioStartDateTime.time;
    }
    if (typeof radioStartTimeObj === 'undefined') {
      radioStartTimeObj = {
        hours: 0, minutes: 0, seconds: 0, milliseconds: 0
      };
    }
    radioStart = new Date(
      radioStartDateObj.year,
      radioStartDateObj.monthIndex,
      radioStartDateObj.day,
      radioStartTimeObj.hours,
      radioStartTimeObj.minutes,
      radioStartTimeObj.seconds,
      radioStartTimeObj.milliseconds
    );
  }

  // SeriesTime (type1)
  const seriesTimeEl = elements[TagKeys.SeriesTime];
  const seriesTimeObj = getTime(seriesTimeEl);
  // Series date/time
  let scanStart = new Date(
    seriesDateObj.year,
    seriesDateObj.monthIndex,
    seriesDateObj.day,
    seriesTimeObj.hours,
    seriesTimeObj.minutes,
    seriesTimeObj.seconds,
    seriesTimeObj.milliseconds
  );

  // scanStart Date check
  // AcquisitionDate (type3)
  const acqDateEl = elements[TagKeys.AcquisitionDate];
  // AcquisitionTime (type3)
  const acqTimeEl = elements[TagKeys.AcquisitionTime];
  if (typeof acqDateEl !== 'undefined' &&
    typeof acqTimeEl !== 'undefined') {
    const acqDateObj = getDate(acqDateEl);
    const acqTimeObj = getTime(acqTimeEl);
    const acqDate = new Date(
      acqDateObj.year,
      acqDateObj.monthIndex,
      acqDateObj.day,
      acqTimeObj.hours,
      acqTimeObj.minutes,
      acqTimeObj.seconds,
      acqTimeObj.milliseconds
    );

    if (scanStart > acqDate) {
      const diff = scanStart.getTime() - acqDate.getTime();
      const warn = 'Series date/time is after Aquisition date/time (diff=' +
        diff.toString() + 'ms) ';
      logger.debug(warn);

      // back compute from center (average count rate) of time window
      // for bed position (frame) in series (reliable in all cases)

      let frameRefTime = 0;
      const frameRefTimeElStr = 'FrameReferenceTime (00541300)';
      const frameRefTimeEl = elements[TagKeys.FrameReferenceTime];
      warning += checkDataElement(frameRefTimeEl, frameRefTimeElStr);
      if (typeof frameRefTimeEl !== 'undefined') {
        frameRefTime = frameRefTimeEl.value[0];
      }
      let actualFrameDuration = 0;
      const actualFrameDurationElStr = 'ActualFrameDuration (00181242)';
      const actualFrameDurationEl = elements[TagKeys.ActualFrameDuration];
      warning += checkDataElement(
        actualFrameDurationEl, actualFrameDurationElStr);
      if (typeof actualFrameDurationEl !== 'undefined') {
        actualFrameDuration = actualFrameDurationEl.value[0];
      }
      if (frameRefTime > 0 && actualFrameDuration > 0) {
        // convert to seconds
        actualFrameDuration = actualFrameDuration / 1000;
        frameRefTime = frameRefTime / 1000;
        const decayConstant = Math.log(2) / halfLife;
        const decayDuringFrame = decayConstant * actualFrameDuration;
        const averageCountRateTimeWithinFrame =
          1 /
          decayConstant *
          Math.log(decayDuringFrame / (1 - Math.exp(-decayDuringFrame)));
        const offsetSeconds = averageCountRateTimeWithinFrame - frameRefTime;
        scanStart = new Date(
          acqDateObj.year,
          acqDateObj.monthIndex,
          acqDateObj.day,
          acqTimeObj.hours,
          acqTimeObj.minutes,
          acqTimeObj.seconds + offsetSeconds,
          acqTimeObj.milliseconds
        );
      }
    }
  }

  // decayed dose (Bq)
  let decayedDose;
  if (typeof scanStart !== 'undefined' &&
    typeof radioStart !== 'undefined' &&
    typeof totalDose !== 'undefined' &&
    typeof halfLife !== 'undefined') {
    // decay time (s) (Date diff is in milliseconds)
    const decayTime = (scanStart.getTime() - radioStart.getTime()) / 1000;
    const decay = Math.pow(2, (-decayTime / halfLife));
    decayedDose = totalDose * decay;
  }

  return {
    value: decayedDose,
    warning: warning
  };
}

/**
 * Get the PET SUV factor.
 *
 * Ref:
 * - {@link https://qibawiki.rsna.org/index.php/Standardized_Uptake_Value_(SUV)#SUV_Calculation},
 * - {@link https://qibawiki.rsna.org/images/6/62/SUV_vendorneutral_pseudocode_happypathonly_20180626_DAC.pdf},
 * - {@link https://qibawiki.rsna.org/images/8/86/SUV_vendorneutral_pseudocode_20180626_DAC.pdf}.
 *
 * @param {Object<string, DataElement>} elements The DICOM elements.
 * @returns {object} The value and a warning if
 *   the elements are not as expected.
 */
export function getSuvFactor(elements) {
  let warning = '';
  const result = {};


  // CorrectedImage (type2): must contain ATTN and DECY
  const corrImageTagStr = 'Corrected Image (00280051)';
  const corrImageEl = elements[TagKeys.CorrectedImage];
  warning += checkDataElement(corrImageEl, corrImageTagStr, ['ATTN', 'DECY']);
  // DecayCorrection (type1): must be START
  const decayCorrTagStr = 'Decay Correction (00541102)';
  const decayCorrEl = elements[TagKeys.DecayCorrection];
  warning += checkDataElement(decayCorrEl, decayCorrTagStr, ['START']);
  // Units (type1): must be BQML
  const unitTagStr = 'Units (00541001)';
  const unitEl = elements[TagKeys.Units];
  warning += checkDataElement(unitEl, unitTagStr, ['BQML']);

  // PatientWeight (type3, kg)
  let patWeight;
  const patientWeightStr = ' PatientWeight (00101030)';
  const patWeightEl = elements[TagKeys.PatientWeight];
  const warn = checkDataElement(patWeightEl, patientWeightStr);
  if (warn.length === 0) {
    const weight = parseFloat(patWeightEl.value[0]);
    if (!isNaN(weight)) {
      patWeight = weight;
    } else {
      warning += ' PatientWeight is not a number';
    }
  } else {
    warning += warn;
  }

  // Decayed dose (Bq)
  const decayedDose = getDecayedDose(elements);
  warning += decayedDose.warning;


  if (warning.length !== 0) {
    result.warning = 'Cannot calculate PET SUV:' + warning;
  } else {
    // SUV factor (grams/Bq)
    result.value = (patWeight * 1000) / decayedDose.value;
  }

  return result;
}