Skip to content
Snippets Groups Projects
respiration_math.js 22.4 KiB
Newer Older
/*
respiration_math.js : COVID-19 Respiration Analysis Software
Copyright (C) 2020  Robert L. Read

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
*/


const CONVERT_PIRDS_TO_SLM = 1/1000;

function unpack(rows, key) {
    return rows.map(function(row) { return row[key]; });
}


function compute_transitions(vm,flows) {
  var transitions = [];
  var state = 0; // Let 1 mean inspiration, -1 mean expiration, 0 neither
  for(var i = 0; i < flows.length; i++) {
    var f = flows[i].val * CONVERT_PIRDS_TO_SLM;
    //    var ms = flows[i].ms-first_time;
    var ms = flows[i].ms;
    if (state == 0) {
      if (f > vm) {
	state = 1;
	transitions.push({ state: 1, sample: i, ms: ms})
      } else if (f < -vm) {
	state = -1;
	transitions.push({ state: -1, sample: i, ms: ms})
      }
    } else if (state == 1) {
      if (f < -vm) {
	state = -1;
	transitions.push({ state: -1, sample: i, ms: ms})
      } else if (f < vm) {
	state = 0;
	transitions.push({ state: 0, sample: i, ms: ms})
      }
    } else if (state == -1) {
      if (f > vm) {
	state = 1;
	transitions.push({ state: 1, sample: i, ms: ms})
      } else if (f > 0) {
	state = 0;
	transitions.push({ state: 0, sample: i, ms: ms})
      }
    }
  }
  return transitions;
}

// Return, [min,avg,max] pressures (no smoothing)!
function compute_pressures(secs,samples,alarms,limits) {
  var pressures = samples.filter(s => s.event == 'M' && s.type == 'D' && (s.loc == 'I' || s.loc == 'A'));

  if (pressures.length == 0) {
    return [0,0,0,[]];
  } else {
    const recent_ms = pressures[pressures.length - 1].ms;
    var cur_ms = recent_ms;
    var cnt = 0.0;
    var i = pressures.length - 1;
    var cur_sample = pressures[i];

    var min = Number.MAX_VALUE;
    var max = Number.MIN_VALUE;
    var sum = 0;
    var alarms = [];
    while((i >=0) && (pressures[i].ms > (cur_ms - secs*1000))) {
      var p = pressures[i].val / 10.0;  // this is now cm H2O
      if (p < min ) {
        min = p;
      }
      if (p > max) {
        max = p;
      }
      sum += p;
      cnt++;
      alarms = alarms.concat(check_alarms(limits,"max","h",p,(a,b) =>(a > b),pressures[i].ms));
      alarms = alarms.concat(check_alarms(limits,"max","l",p,(a,b) =>(a < b),pressures[i].ms));
      i--;
    }
    return [min,sum/cnt,max,alarms];
  }
}


function compute_fio2_mean(secs,samples) {
  var oxygens = samples.filter(s => s.event == 'M' && s.type == 'O' && (s.loc == 'I' || s.loc == 'A'));

  if (oxygens.length == 0) {
    return null;
  } else {
    const recent_ms = oxygens[oxygens.length - 1].ms;
    var cur_ms = recent_ms;
    var cnt = 0.0;
    var i = oxygens.length - 1;

    var sum = 0;
    var alarms = [];
    while((i >=0) && (oxygens[i].ms > (cur_ms - secs*1000))) {
      var oxy = oxygens[i].val; // oxygen concentration as a percentage
      sum += oxy;
      cnt++;
      i--;
    }

    var fio2_avg = sum / cnt;

    return fio2_avg;
  }
}


function compute_respiration_rate(secs,samples,transitions,breaths) {
  // In order to compute the number of breaths
  // in the last s seconds, I compute those breaths
  // whose time stamp is s seconds from the most recent sample

  // We will compute respiration rate by counting breaths
  // and dividing (cnt - 1) by time the first and last inhalation
  var first_inhale_ms = -1;
  var last_inhale_ms = -1;
  if (breaths.length == 0) {
    return [0,0,0,"NA","NA"];
  } else {
    const recent_ms = samples[samples.length - 1].ms;
    var cur_ms = recent_ms;
    var cnt = 0.0;
    var vol_i = 0.0;
    var vol_e = 0.0;
    var i = breaths.length - 1;
    var time_inh = 0;
    var time_exh = 0;
    // fully completed inhalation volume does not include the
    // most recent breath; we need it to be able to accurately
    // divide by the inhlation_duration.
    var vol_ci = 0.0;

    var wob = 0.0;
    var wob_cnt = 0;

    while((i >=0) && (breaths[i].ms > (cur_ms - secs*1000))) {
      cnt++;
      vol_i += breaths[i].vol_i;
      if (i < (breaths.length -1)) {
        vol_ci += breaths[i].vol_i;
      }
      vol_e += breaths[i].vol_e;

      const inh_ms = transitions[breaths[i].trans_begin_inhale].ms;
      // note i is counting down in this loop...
      if (last_inhale_ms < 0) last_inhale_ms = inh_ms;
      first_inhale_ms = inh_ms;
      const exh_ms = transitions[breaths[i].trans_end_exhale].ms;
      const zero_ms = transitions[breaths[i].trans_cross_zero].ms;
      time_inh += (zero_ms - inh_ms);
      time_exh += (exh_ms -  zero_ms);

      if (breaths[i].work != null)  {
        wob += breaths[i].work / breaths[i].vol_i;
        wob_cnt++;
      }
      i--;
    }
    if ((cnt > 1) && (first_inhale_ms != last_inhale_ms)) {
      var inhalation_duration = last_inhale_ms - first_inhale_ms;
      var inhalation_duration_min = inhalation_duration / (60.0 * 1000.0);
      var rr = (cnt - 1) / inhalation_duration_min;
      var duration_minutes = secs / 60.0;

      // This is liters per minute. vol_ci is in liters.
      // inhalation_duration is in ms.
      var minute_volume =  vol_ci / inhalation_duration_min;

      var tidal_volume = 1000.0 * vol_i / cnt;

      var EIratio = (time_inh == 0) ? null : time_exh / time_inh;
      var WorkOfBreathing_J_per_L = wob / wob_cnt;
      return  [
        rr,
        tidal_volume,
        minute_volume,
        EIratio,
        WorkOfBreathing_J_per_L];
    } else {
      return [0,0,0,"NA","NA"];
    }
  }
}


// produces a set of rising signals, time in ms of the leading edge of the rise and the trailing edge of the rise
// an array of 2-tuple
// taip == true implies compute TAIP, else compute TRIP
// Possibly this routine should be generalized to a general rise-time routine.
function compute_TAIP_or_TRIP_signals(min,max,pressures,taip) {
  var pressures = pressures.filter(s => s.event == 'M' && s.type == 'D' && (s.loc == 'I' || s.loc == 'A'));
  const responseBegin = 0.1;
  const responseEnd = 0.9;

  var signals = [];
  var foundMinSignal = false;

  const highFence = (min + (responseEnd * (max - min)))*10;
  const lowFence = (min + (responseBegin * (max - min)))*10;

  var cur_signal_start;
  var state = -1; // Let 1 mean rising, -1 mean fallen, 0 risen, but not fallen
  var first_sample_index = taip ? 0 : pressures.length-1 ;
  var last_sample_index = taip ? pressures.length-1 : 0;
  var increment = taip ? 1 : -1;
  for(var i = first_sample_index; i != last_sample_index; i+=increment) {
    var p = pressures[i].val;
    var ms = pressures[i].ms;
    if (state == -1) {
      if (p >= lowFence) {
	      state = 1;
        cur_signal_start = pressures[i];
      }
      if (p >= highFence){
        signals.push([ms,ms]);
      }
    } else if (state == 1) {
      //console.log("state = 1",cur_signal_start); for debugging
      if (p >= highFence) {
        signals.push(taip ? [cur_signal_start.ms,ms] : [ms,cur_signal_start.ms])
        state = 0;
      } else if (p <= lowFence) {
        state = -1;
        cur_signal_start = null;
      }
    } else if (state == 0) {
      if (p <= lowFence) {
	state = -1;
      }
    }
  }
  return signals;
}

function compute_mean_TRIP_or_TAIP_sigs(sigs,min,max,pressures,taip){
  if (sigs.length == 0){
    return "NA";
  } else {
    var sum = 0;
    for(var i = 0; i < sigs.length; i++) {
      sum += sigs[i][1] - sigs[i][0];  // time in ms
    }
    return sum / sigs.length;
  }
}

function testdata(){
  //0 (not good enough), 10 (rising), 20 (above threshold) all 10 ms apart
  //saw tooth function
  var data = []; // pushing 50 things into it
  for(var i = 0; i < 10; i++) {
    var ms = i*5*10;
    data[i*5+0] = {event:'M',loc:'I',ms:ms + 0,type:'D',val: 0};
    data[i*5+1] = {event:'M',loc:'I',ms:ms + 10,type:'D',val: 100};
    data[i*5+2] = {event:'M',loc:'I',ms:ms + 20,type:'D',val: 200};
    data[i*5+3] = {event:'M',loc:'I',ms:ms + 30,type:'D',val: 100};
    data[i*5+4] = {event:'M',loc:'I',ms:ms + 40,type:'D',val: 0};
  }
  return data;
}

function testdataSine(period_sm){ // period expressed in # of samples, each sample 10 ms
  //0 (not good enough), 10 (rising), 20 (above threshold) all 10 ms apart
  //sine tooth function
  var data = []; // pushing 50 things into it
  for(var i = 0; i < 1000; i++) {
    var ms = i*10;
    data[i] = {event:'M',loc:'I',ms:ms + 20,type:'D',val: 200*Math.sin(2*Math.PI*i/period_sm)};
  }
  return data;
}

function compute_mean_TRIP_or_TAIP(min,max,samples,taip) {
  return compute_mean_TRIP_or_TAIP_sigs(
    compute_TAIP_or_TRIP_signals(min,max,samples,taip),
    min,max,samples,taip);
}

function test_compute_TAIP() {
  var samples = testdata();
  const TAIP_min = 0; // cm of H2O
  const TAIP_max = 20; // cm of H2O
  var TAIP_m = compute_mean_TRIP_or_TAIP(min,max,samples,true);
  console.assert(TAIP_m == 10);
  for (i = 50; i<150; i+=10) {
    var sinewave = testdataSine(i);
    var TAIP_m = compute_mean_TRIP_or_TAIP(min,max,sinewave,true);
  }
}

function compute_current_TAIP(TAIP_min,TAIP_max){ //uses samples from a global var
  var TAIP_m = compute_mean_TRIP_or_TAIP(TAIP_min,TAIP_max,samples,true);
  return TAIP_m;
}

// Because TAIP and TRIP are symmetric when viewed from
// from the direction of the samples; this tests that
// as a prelude to computing a single way.

function reverseArray(arr) {
  var newArray = [];
  for (var i = arr.length - 1; i >= 0; i--) {
    newArray.push(arr[i]);
  }
  return newArray;
}
function test_TRIP_and_TAIP_are_symmetric() {
  var samples = testdata();
  var rsamples = reverseArray(samples);
  const min = 0;
  const max = 20;
  var TRIP_m = compute_mean_TRIP_or_TAIP(min,max,samples,false);
  var TRIP_m_r = -compute_mean_TRIP_or_TAIP(min,max,rsamples,true);
  console.assert(TRIP_m == TRIP_m_r);
  console.assert(TRIP_m == 10);
  for (i = 50; i<150; i+=10) {
    var sinewave = testdataSine(i);
    var rsinewave = reverseArray(sinewave);
    var TRIP_m = compute_mean_TRIP_or_TAIP(min,max,samples,false);
    var TRIP_m_r = -compute_mean_TRIP_or_TAIP(min,max,rsamples,true);
    console.assert(TRIP_m == TRIP_m_r);
    var TAIP_m = compute_mean_TRIP_or_TAIP(min,max,samples,true);
    var TAIP_m_r = -compute_mean_TRIP_or_TAIP(min,max,rsamples,false);
    console.assert(TAIP_m == TAIP_m_r);
  }
}

function compute_current_TRIP(TRIP_min,TRIP_max, samples)
{ //uses samples from a global var
  if (samples.length == 0){
    return "NA";
  }
  else {
    var TRIP_m = compute_mean_TAIP_or_TRIP(TRIP_min,TRIP_max,false);
    return TRIP_m;
  }
}




  // A routine to calculate work per breath
function PressureVolumeWork(breath, transitions, samples) {
  // -1 for quadilateral approximation
  if (breath.vol_i == 0) {
    return null;
  } else {
    var beginTransition = transitions[breath.trans_begin_inhale];
    var beginTime_ms = beginTransition.ms;
    var endTransition = transitions[breath.trans_cross_zero];
    var endTime_ms = endTransition.ms;
    var flows = samples.filter(s => s.event == 'M' && s.type == 'F' &&
                               s.ms >= beginTime_ms && s.ms <= endTime_ms);
    var pressures = samples.filter(s => s.event == 'M' && s.type == 'D' &&
                                   (s.loc == 'I' || s.loc == 'A') && s.ms >= beginTime_ms && s.ms <= endTime_ms);

    // Note: The algorithm below relies on the fact that there is
    // only one flow or pressure with a single ms value; and that
    // increvementing an index necessarily incremenst the .ms value.

    // Without two samples, we have no duration and can't define
    // work.
    if (pressures.length < 2 || flows.length < 2) return null;

    var ct = Math.min(flows[0].ms,pressures[0].ms);
    var lfp = { val : flows[0].val, ms: flows[0].ms } ; // last flow point
    var lpp = { val : pressures[0].val, ms: pressures[0].ms }; // last pressure_point
    var fi = increment_past(flows,flows[0].ms,0); // Index of next flow sample
    var pi = increment_past(pressures,pressures[0].ms,0); // Index of next pressure sample
    var w = 0; // current work

    // compute flow at time ms give index and last point
    // This is just a simple linear interpolation
    function f(ms,cur,last) {
      var ms0 = last.ms;
      var ms1 = cur.ms;
      return last.val + (cur.val - last.val)*(ms - ms0)/(ms1 - ms0);
    }
    function increment_past(array,ms,index) {
      var begin = index;
      while(index < array.length && array[index].ms <= ms)
        index++;
      if (index == begin) debugger;
      if (index >= array.length)
        return null;
      else
        return index;
    }

    // A fundamental invariant:
    // pressures[pi].ms > lpp.ms
    // flows[pi].ms > lfp.ms
    while ((fi + pi) < (flows.length +  pressures.length)) {
      // Invariant always increment fi or pi
      // fi and pi point to unprocessed value
      console.assert(pressures[pi].ms > lpp.ms);
      console.assert(flows[fi].ms > lfp.ms);
      var ms;
      if (pressures[pi].ms <= flows[fi].ms) { // process pressure
        ms = pressures[pi].ms;
        pi = increment_past(pressures,ms,pi);
        if (flows[fi].ms <= ms) {
          fi = increment_past(flows,ms,fi);
        }
      } else {
        ms = flows[fi].ms;
        fi = increment_past(flows,ms,fi);
        if (pressures[pi].ms <= ms) {
          pi = increment_past(pressures,ms,pi);
        }
      }
      if ((fi === null) || (pi === null))
        break;
      var dur_s = (ms - ct) / 1000;
      console.assert(pressures[pi].ms > lpp.ms);
      console.assert(flows[fi].ms > lfp.ms);
      var nf = f(ms,flows[fi],lfp);
      var np = f(ms,pressures[pi],lpp);
      var f1 = (lfp.val + nf)/2;
      var p1 = (lpp.val + np)/2;
      // convert 10ths of cm H2O to pascals..
      var p1_pa = (p1 * 98.0665) / 10;

      // convert flows in lpm to cubic meters per seconds
      var f1_m_cubed_per_s = f1 / (1000 * 1000 * 60);

      // work is now in Joules!
      w += dur_s * p1_pa * f1_m_cubed_per_s;
      lfp = { val : f1, ms: ms };
      lpp = { val : p1, ms: ms };
      ct = ms;
    }
    return w;
  }
}

// Let's first set up a perfectly square 1-second
function generate_synthetic_trace() {
  const SAMPLES_PER_PHASE = 1000;
  const SAMPLES_MS_PER_SAMPLE = 1;
  var trace = [];
  var cur = 0;
  // p is a probability of occuring.
  function push_samples(start,num,d,f,pd,pf) {
    for(var i = start; i < start+num; i++) {
      if (Math.random() < pd) {
        trace.push(
          {
            event: "M",
            loc: "A",
            ms: i,
            num: 0,
            type: "D",
            val: d
          });
      }
      if (Math.random() < pf) {
        trace.push(
          {
            event: "M",
            loc: "A",
            ms: i,
            num: 0,
            type: "F",
            val: f
          });
      }
    }
  }
  const P1 = 1/2;
  const P2 = 1/2;
  push_samples(SAMPLES_PER_PHASE,SAMPLES_PER_PHASE,200,50000,P1,P2);
  push_samples(0,SAMPLES_PER_PHASE,-200,-50000,P1,P2);
  push_samples(SAMPLES_PER_PHASE,SAMPLES_PER_PHASE,200,50000,P1,P2);
  push_samples(SAMPLES_PER_PHASE*2,SAMPLES_PER_PHASE,-200,-50000,P1,P2);
  push_samples(SAMPLES_PER_PHASE*3,SAMPLES_PER_PHASE,200,50000,P1,P2);
  return trace;
}

function nearp(x,y,d) {
  return Math.abs(x - y) <= d;
}

function testWorkSynthetic(){ // breaths give us inspiration transition points
  var samples = generate_synthetic_trace();

  const JOULES_IN_BREATH = 1 * 1961.33 * 50000 / (60e+6);

  var flows = samples.filter(s => s.event == 'M' && s.type == 'F');
    var first_time = flows[0].ms;
    var last_time = flows[flows.length - 1].ms;
    var duration = last_time - first_time;
    console.log(flows);


  const vm = 10;
  // There is a problem here that this does not create a transition at the beginning.
  var transitions = compute_transitions(vm,flows);
    var breaths = compute_breaths_based_without_negative_flow(transitions,flows);
    console.log(breaths);
    for(i = 0; i<breaths.length; i++) {
      var w = PressureVolumeWork(breaths[i], transitions, samples);
      console.assert((w == null) || (nearp(w,JOULES_IN_BREATH),0.1));
      console.log("final (Joules) = ",w);
    }
  return true;
}


  function testWork(samples){ // breaths give us inspiration transition points
    var flows = samples.filter(s => s.event == 'M' && s.type == 'F');
    var first_time = flows[0].ms;
    var last_time = flows[flows.length - 1].ms;
    var duration = last_time - first_time;
    console.log(flows);

    const vm = 10;
    var transitions = compute_transitions(vm,flows);
    var breaths = compute_breaths_based_without_negative_flow(transitions,flows);
    console.log(breaths);
    for(i = 0; i<breaths.length; i++) {
      var w = PressureVolumeWork(breaths[i], transitions, samples);
      console.log(w);
    }
   }



  // This should be in liters...
  function integrateSamples(a,z,flows) {
    // -1 for quadilateral approximation
    var vol = 0;
    for(var j = a; j < z-1; j++) {
      // I'll use qadrilateral approximation.
      // We'll form each quadrilateral between two samples.
      var ms = flows[j+1].ms - flows[j].ms;
      var ht = ((flows[j+1].val + flows[j].val )/2) * CONVERT_PIRDS_TO_SLM;
      // Flow is actually in standard liters per minute,
      // so to get liters we divide by 60 to it l/s,
      // and and divde by 1000 to convert ms to seconds.
      // We could do that here, but will move constants
      // to end...
      vol += ms * ht;
      if (isNaN(vol)) {
        debugger;
      }
    }
    return vol/(60*1000);
  }

  // This is based only on inhalations, and
  // is therefore functional when there is a check valve
  // in place. Such a system will rarely
  // have negative flows, and we must mark
  // the beginning of a breath from a transition to a "1"
  // state from any other state.
  // This algorithm is simple: A breath begins on a trasition
  // to 1 from a not 1 state. This algorithm is susceptible
  // to "stutter" near the boundary point, but if necessary
  // a digital filter would sove that; we have not yet found
  // that level of sophistication needed.
  // We still want to track zeros, but now must strack them
  // as a falling signal.

  function compute_breaths_based_without_negative_flow(transitions,flows) {
    var beg = 0;
    var zero = 0;
    var last = 0;
    var voli = 0;
    var vole = 0;

    var breaths = [];
    var expiring = true;

    for(var i = 0; i < transitions.length; i++) {
      // We're looking for the end of the inhalation here!!
      if (((i -1) >= 0) && transitions[i-1].state == 1 &&
          (transitions[i].state == 0 || transitions[i].state == -1 )) {
        zero = i;
      }
      if (expiring && transitions[i].state == 1) {
        breaths.push({ ms: transitions[i].ms,
		       sample: transitions[i].sample,
		       vol_e: vole,
		       vol_i: voli,
                       trans_begin_inhale: beg,
                       trans_cross_zero: zero,
                       trans_end_exhale: i,
		     }
		    );
        var w = PressureVolumeWork(breaths[breaths.length-1], transitions, samples);
        breaths[breaths.length-1].work = w;
        beg = i;
        expiring = false;
        vole = integrateSamples(last,transitions[i].sample,flows);

        last = transitions[i].sample;
      }
      if (!expiring && ((transitions[i].state == -1) || (transitions[i].state == 0)))  {
        expiring = true;
        voli = integrateSamples(last,transitions[i].sample,flows);
        last = transitions[i].sample;
      }
    }
    return breaths;
  }


// A simple computation of a moving window trace
// computing [A + -B], where A is volume to left
// of sample int time window t, and B is volume to right
// t is in milliseconds
function computeMovingWindowTrace(samples,t,v) {

  var flows = samples.filter(s => s.event == 'M' && s.type == 'F');
  var first_time = flows[0].ms;
  var last_time = flows[flows.length - 1].ms;
  var duration = last_time - first_time;

  // Here is an idea...
  // We define you to be in one of three states:
  // Inspiring, expiring, or neither.
  // Every transition between these states is logged.
  // Having two inspirations between an expiration is
  // weird but could happen.
  // We record transitions.
  // When the time series crossed a fixed threshold
  // or zero, it causes a transition. If you are inspiring,
  // you have to cross zero to transition to neither,
  // and you start expiring when you cross the treshold.

  // This is measured in standard liters per minute.
  const vm = 10; // previously used 4

  // We will model this as a list of transitions.
  // A breath is any number of inspirations followed by
  // any number of expirations. (I+)(E+)

  var transitions = compute_transitions(vm,flows);

  // Now that we have transitions, we can apply a
  // diferrent algorithm to try to define "breaths".
  // Because a breath is defined as an inspiration
  // and then an expiration, we will define a breath
  // as from the first inspiration, until there has
  // been one expiration, until the next inspiration.
  var breaths = [];
  var expiring = false;

  function compute_breaths_based_on_exhalations(transitions) {
    var beg = 0;
    var zero = 0;
    var last = 0;
    var voli = 0;
    var vole = 0;

    for(var i = 0; i < transitions.length; i++) {
      // We're looking for the end of the inhalation here!!
      if (((i -1) >= 0) && transitions[i-1].state == 1 && (transitions[i].state == 0 || transitions[i].state == -1 )) {
        zero = i;
      }
      if (expiring && transitions[i].state == 1) {
        breaths.push({ ms: transitions[i].ms,
		       sample: transitions[i].sample,
		       vol_e: vole,
		       vol_i: voli,
                       trans_begin_inhale: beg,
                       trans_cross_zero: zero,
                       trans_end_exhale: i,
		     }
		    );
        var w = PressureVolumeWork(breaths[0], transitions, samples);
        breaths[0].work = w;
        beg = i;
        expiring = false;
        vole = integrateSamples(last,transitions[i].sample,flows);
        last = transitions[i].sample;
      }
      if (!expiring && transitions[i].state == -1) {
        expiring = true;
        voli = integrateSamples(last,transitions[i].sample,flows);
        last = transitions[i].sample;
      }
    }
  }

  breaths = compute_breaths_based_without_negative_flow(transitions,flows);

  return [transitions,breaths];
}