<!-- Breath Plot: 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/>. --> <!doctype html> <html lang="en"> <head> <!-- Required meta tags --> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <!-- Bootstrap CSS --> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous"> <!-- Load plotly.js into the DOM --> <script src='https://cdn.plot.ly/plotly-latest.min.js'></script> <title>Public Invention Respiration Analysis</title> </head> <style> #calcarea { flex-direction:column; background: AliceBlue; } .calcnum { color: blue; font-size: xx-large; } .fence { width: 3em; } .value_and_fences { display: flex; flex-direction: row; justify-content: space-between; } .limit { display: flex; flex-direction: row; } .limit label { width: 1em; } .alarmred { background: red; } </style> <!-- Style for toggle switch --> <style> .switch { position: relative; display: inline-block; width: 60px; height: 34px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; -webkit-transition: .4s; transition: .4s; } .slider:before { position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px; background-color: white; -webkit-transition: .4s; transition: .4s; } input:checked + .slider { background-color: #2196F3; } input:focus + .slider { box-shadow: 0 0 1px #2196F3; } input:checked + .slider:before { -webkit-transform: translateX(26px); -ms-transform: translateX(26px); transform: translateX(26px); } /* Rounded sliders */ .slider.round { border-radius: 34px; } .slider.round:before { border-radius: 50%; } </style> <body> <div class="container-fluid"> <div class="jumbotron"> <h1 class="display-4">VentMon Respiration Analysis</h1> <p class="lead">This is a work in progress of <a href="https://www.pubinv.org">Public Invention</a>. It can be attached to a data server to produce an interactive or static analysis of a respiration. It's primary purpose is to test pandemic ventilators, but it is free software meant to be reused for other purposes. </p> </div> <div class="input-group mb-3"> <div class="input-group-prepend"> <span class="input-group-text" id="basic-addon3">PIRDS data server url:</span> </div> <input type="text" class="form-control" id="dserverurl" aria-describedby="basic-addon3"> <div class="input-group-append"> <a class="btn btn-outline-dark btn-sm" href="#" role="button" id="useofficial">Use Ventmon Data Lake: ventmon.coslabs.com</a> </div> </div> <div class="input-group mb-3"> <div class="input-group-prepend"> <span class="input-group-text" id="basic-addon3">Trace ID:</span> </div> <input type="text" class="form-control" id="traceid" aria-describedby="basic-addon3"> </div> <div class="input-group mb-3"> <div class="input-group-prepend"> <span class="input-group-text" for="samples_to_plot">Number of Samples (~10s per 15000 samples):</span> </div> <input type="text" class="form-control" id="samples_to_plot" aria-describedby="samples_to_plot"> </div> <label for="livetoggle">Plot Live:</label> <label class="switch"> <input type="checkbox" id="livetoggle" checked> <span class="slider round"></span> </label> <div class="container-fluid"> <div class="row"> <div class="col-9"> <div id='PFGraph'><!-- Pressure and Flow Graph --></div> <div id='EventsGraph'><!-- Events --></div> </div> <div class="col-3"> <div class="alert alert-danger" role="alert" id="main_alert"> Danger! Flow limits exceeded; volumes will be incorrect. </div> <div class="container" id="calcarea"> <div> <label for="max">PIP (max): </label> <div class="value_and_fences"> <div class="calcnum"> <label id="max"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="max_h">H:</label> <input class="fence" id="max_h" type='text'> </input> </div> <div class="limit min"> <label for="max_l">L:</label> <input class="fence" id="max_l" type='text'> </input> </div> </div> </div> </div> <div> <label for="avg">P. Mean: </label> <div class="value_and_fences"> <div class="calcnum"> <label id="avg"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="avg_h">H:</label> <input class="fence" id="avg_h" type='text'> </input> </div> <div class="limit min"> <label for="avg_l">L:</label> <input class="fence" id="avg_l" type='text'> </input> </div> </div> </div> </div> <div> <label for="min">PEEP (min): </label> <div class="value_and_fences"> <div class="calcnum"> <label id="min"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="min_h">H:</label> <input class="fence" id="min_h" type='text'> </input> </div> <div class="limit min"> <label for="min_l">L:</label> <input class="fence" id="min_l" type='text'> </input> </div> </div> </div> </div> <div> <label for="mv">MVs (l/min): </label> <div class="value_and_fences"> <div class="calcnum"> <label id="mv"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="mv_h">H:</label> <input class="fence" id="mv_h" type='text'> </input> </div> <div class="limit min"> <label for="mv_l">L:</label> <input class="fence" id="mv_l" type='text'> </input> </div> </div> </div> </div> <div> <label for="bpm">RR: </label> <div class="value_and_fences"> <div class="calcnum"> <label id="bpm"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="bmp_h">H:</label> <input class="fence" id="bpm_h" type='text'> </input> </div> <div class="limit min"> <label for="bpm_l">L:</label> <input class="fence" id="bpm_l" type='text'> </input> </div> </div> </div> </div> <div> <label for="ier">I:E ratio: </label> <div class="value_and_fences"> <div class="calcnum"> <label id="ier"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="ier_h">H:</label> <input class="fence" id="ier_h" type='text'> </input> </div> <div class="limit min"> <label for="ier_l">L:</label> <input class="fence" id="ier_l" type='text'> </input> </div> </div> </div> </div> <div> <label for="tv">VTd (ml): </label> <div class="value_and_fences"> <div class="calcnum"> <label id="tv"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="tv_h">H:</label> <input class="fence" id="tv_h" type='text'> </input> </div> <div class="limit min"> <label for="tv_l">L:</label> <input class="fence" id="tv_l" type='text'> </input> </div> </div> </div> </div> <div> <label for="fio2">FiO2 Mean (%): </label> <div class="value_and_fences"> <div class="calcnum"> <label id="fio2"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="fio2_h">H:</label> <input class="fence" id="fio2_h" type='text'> </input> </div> <div class="limit min"> <label for="fio2_l">L:</label> <input class="fence" id="fio2_l" type='text'> </input> </div> </div> </div> <div> <label for="taip">TAIP (ms): </label> <div class="value_and_fences"> <div class="calcnum"> <label id="taip"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="taip_h">H:</label> <input class="fence" id="taip_h" type='text'> </input> </div> <div class="limit min"> <label for="taip_l">L:</label> <input class="fence" id="taip_l" type='text'> </input> </div> </div> </div> </div> <div> <label for="taip">TRIP (ms): </label> <div class="value_and_fences"> <div class="calcnum"> <label id="trip"> </label> </div> <div class="vertical_alarms"> <div class="limit max"> <label for="trip_h">H:</label> <input class="fence" id="trip_h" type='text'> </input> </div> <div class="limit min"> <label for="trip_l">L:</label> <input class="fence" id="trip_l" type='text'> </input> </div> </div> </div> </div> </div> </div> </div> </div> </div> <!-- end main area --> </div> <div> Parameters: <div> <label for="tarip_h">TAIP/TRIP High Pressure (cm H2O):</label> <input class="fence" id="tarip_h" type='text'> </input> </div> <div> <label for="tarip_l">TAIP/TRIP Low Pressure (cm H2O):</label> <input class="fence" id="tarip_l" type='text'> </input> </div> </div> <div> <button id="import">Import Trace</button> <button id="export">Export Trace</button> </div> <div> <textarea id="json_trace" rows="30" cols="80"></textarea> </div> <p> This is a work in progress of <a href="https://www.pubinv.org">Public Invention</a>. <p> This is a tester tool for open-source ventilators. It uses the <a href="https://github.com/PubInv/respiration-data-standard">PIRDS data format</a>. <p> The basic operation is receive data from web server specified in the URL above. Probably for now that will be the VentMon Python web server that listens on a serial port for the VentMon device or any other device that streams PIRDS events. </div> </body> <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script> <script> var TAIP_AND_TRIP_MIN = 2; var TAIP_AND_TRIP_MAX = 8; function getParameterByName(name, url) { if (!url) url = window.location.href; name = name.replace(/[\[\]]/g, '\\$&'); var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'), results = regex.exec(url); if (!results) return null; if (!results[2]) return ''; return decodeURIComponent(results[2].replace(/\+/g, ' ')); } // These are defined in PIRDS.h as "constant" key words of special meaning... var FLOW_LIMITS_EXCEEDED = false; const FLOW_TOO_HIGH = "FLOW OUT OF RANGE HIGH"; const FLOW_TOO_LOW = "FLOW OUT OF RANGE LOW"; const VENTMON_DATA_LAKE = "http://ventmon.coslabs.com"; const queryString = window.location.search; const urlParams = new URLSearchParams(queryString); var TRACE_ID = urlParams.get('i') var DSERVER_URL = window.location.protocol + "//" + window.location.host; var NUM_TO_READ = 500; var DATA_RETRIEVAL_PERIOD = 50; var RESPIRATION_RATE_WINDOW_SECONDS = 20; var intervalID = null; // This sould be better as time, but is easier // to do as number of samples. var MAX_SAMPLES_TO_STORE_S = 32000; var samples = []; var INITS_ONLY = true; // This is just to get the party started! function init_samples() { return [ { "event": "M", "type": "P", "ms": 19673310, "loc": "A", "num": "0", "val": 10111 }, { "event": "M", "type": "D", "ms": 19673310, "loc": "A", "num": "0", "val": 4 }, { "event": "M", "type": "F", "ms": 19673310, "loc": "A", "num": "0", "val": 0 }, { "event": "M", "type": "P", "ms": 19673376, "loc": "A", "num": "0", "val": 10111 }, { "event": "M", "type": "D", "ms": 19673376, "loc": "A", "num": "0", "val": 4 }, { "event": "M", "type": "F", "ms": 19673376, "loc": "A", "num": "0", "val": 0 }, { "event": "M", "type": "P", "ms": 19673442, "loc": "A", "num": "0", "val": 10110 }, { "event": "M", "type": "D", "ms": 19673442, "loc": "A", "num": "0", "val": 3 }, ]; } function unpack(rows, key) { return rows.map(function(row) { return row[key]; }); } const CONVERT_PIRDS_TO_SLM = 1/1000; // we have now changed this, there will be flow and // pressure in the same samples, and we should filter. // TODO: I need to add maximal start and end // samples to equalize all the plots. function samplesToLine(samples) { var flows = samples.filter(s => s.event == 'M' && s.type == 'F'); // These are slm/1000, or ml/minute... // so we multiply by 1000 to get liters per minute var flow_values = unpack(flows,"val").map(v => v * CONVERT_PIRDS_TO_SLM); var fmillis = unpack(flows, 'ms'); // Convert to seconds... var fmin = Math.min(...fmillis); var fzeroed = fmillis.map(m =>(m-fmin)/1000.0); var pressures = samples.filter(s => s.event == 'M' && s.type == 'D' && s.loc == 'A'); var pmillis = unpack(pressures, 'ms'); var pmin = Math.min(...pmillis); var pzeroed = pmillis.map(m =>(m-pmin)/1000.0); // the PIRDS standard is integral mm H2O, so we divide by 10 var delta_p = unpack(pressures, 'val').map(p => p / 10); var diff_p = {type: "scatter", mode: "lines", name: "pressure", x: pzeroed, y: delta_p, line: {color: "#FF0000"} }; var flow = {type: "scatter", mode: "lines", name: "flow", x: fzeroed, y: flow_values, xaxis: 'x2', yaxis: 'y2', fill: 'tozeroy', line: {color: '#0000FF'} }; var max_flow = flow_values.reduce( function(a, b) { return Math.max(Math.abs(a), Math.abs(b)); } ,0); var scaled_flow = flow_values.map(f => 100.0 * (f / max_flow)); var flow_hollow = {type: "scatter", mode: "lines", name: "flow ghost", x: fzeroed, // Convert to a percentage y: scaled_flow, line: {color: '#8888FF'} }; return [diff_p,flow,flow_hollow]; } function plot(samples, trans, breaths) { var new_data = samplesToLine(samples); var millis = unpack(samples, 'ms'); var min = Math.min(...millis); var zeroed = millis.map(m =>(m-min)/1000.0); { var layout = { title: 'VentMon Breath Analysis', showlegend: false, xaxis: {domain: [0.0,1.0]}, yaxis: { title: 'Airway P(cm H2O)', titlefont: {color: 'red'}, tickfont: {color: 'red'}, }, xaxis2: {domain: [0.0,1.0]}, yaxis2: { title: 'Flow l/minute', titlefont: {color: 'blue'}, tickfont: {color: 'blue'} }, grid: { rows: 2, columns: 1, pattern: 'independent', roworder: 'top to bottom'} } var double_plot = [new_data[0],new_data[1]]; Plotly.newPlot('PFGraph', double_plot, layout); } // The Y-axis for the events will be percentage. // This is somewhat abstract; each trace has // a different meaning. In general it will be // % as a function of some known value, which // will be either a limit or a min or max. { var event_graph = []; if (trans) { // Transitions are simply scaled to 50%. var tmillis = unpack(trans, 'ms'); var tzeroed = tmillis.map(m =>(m-min)/1000.0); var tstates = unpack(trans, 'state'); var tstates_amped = tstates.map(m =>m*50); var transPlot = {type: "scatter", mode: "lines+markers", name: "trans", x: tzeroed, y: tstates_amped, line: {shape: 'hv', color: 'dkGreen'}, }; // We add a hollow flow line to see in position.. event_graph.push(new_data[2]); event_graph.push(transPlot); } if (breaths) { var bmillis = unpack(breaths, 'ms'); var bzeroed = bmillis.map(m =>(m-min)/1000.0); // I'm goint to a add start and end transition to make the plot // come out right var ys = breaths.map( b => 0); var breathPlot = {type: "scatter", mode: "markers", name: "Transitions", x: bzeroed, y: ys, marker: { size: 8, color: "red",symbol: "diamond" }, textposition: 'bottom center', text: bzeroed }; event_graph.push(breathPlot); // Now I attempt to extract volumes... // Our breaths mark the END of a breath.. // so we want to draw the volumes that way. const volume_ht_factor = 20; var max_exh = unpack(breaths,'vol_e').reduce( function(a, b) { return Math.max(Math.abs(a), Math.abs(b)); } ,0); var max_inh = unpack(breaths,'vol_i').reduce( function(a, b) { return Math.max(Math.abs(a), Math.abs(b)); } ,0); var max_v = Math.max(max_inh,max_exh); var exh_v = unpack(breaths, 'vol_e').map(e => 100 * e / max_v); var t_exh_v = unpack(breaths, 'vol_e').map(e => Math.round(e*1000.0)+"ml exh"); var inh_v = unpack(breaths, 'vol_i').map(i => 100 * i / max_v); var t_inh_v = unpack(breaths, 'vol_i').map(e => Math.round(e*1000.0)+"ml inh"); // now to graph properly, I must find the center of an inhalation. // I have packed these into the breaths... var inhale_centers = breaths.map(b => ((trans[b.trans_begin_inhale].ms + trans[b.trans_cross_zero].ms) - 2*min) / (2.0 * 1000.0)); var exhale_centers = breaths.map(b => ((trans[b.trans_cross_zero].ms + trans[b.trans_end_exhale].ms) - 2*min) / (2.0 * 1000.0)); var inhPlot = {type: "scatter", mode: "markers+text", name: "Inh. ml", textposition: 'bottom center', x: inhale_centers, y: inh_v, text: t_inh_v, marker: { size: 8, color: 'black', symbol: 'triangle-down'} }; var exhPlot = {type: "scatter", mode: "markers+text", name: "Exh. ml", textposition: 'top center', marker : { sizer: 12, color: 'green', symbol: 'triangle-up' }, x: exhale_centers, y: exh_v, text: t_exh_v, }; event_graph.push(inhPlot); event_graph.push(exhPlot); } // Now I will attempt to add other markers, such as Humidity, Altitude, and other events, // including possible warning events. function gen_graph_measurement(samples, name, color, textposition, tp, lc, vf, tf) { var selected = samples.filter(s => s.event == 'M' && s.type == tp && s.loc == lc); return gen_graph(selected, name, color, textposition, vf, tf); } function gen_graph(selected,name,color, textposition, vf, tf) { var millis = unpack(selected, 'ms'); var zeroed = millis.map(m =>(m-min)/1000.0); var vs = selected.map(vf); var vs_t = vs.map(tf); var plot = {type: "scatter", mode: "markers+text", name: name, textposition: textposition, x: zeroed, y: vs, text: vs_t, marker: { size: 10, color: color } }; return plot; } function gen_message_events(samples,name,color, textposition) { var messages = samples.filter(s => s.event == 'E' && s.type == 'M'); // Now I want to filter our all flow error messages... // There are two special ones, "FLOW OUT OF RANGE LOW" and // "FLOW OUT OF RANGE HIGH" // Basically, we will show only the first of these in a "run" // This is rather difficult; we will instead just use 200 ms // as a window. const time_windwow_ms = 200; var lows = []; var highs = []; var others = []; for(var i = 0; i < messages.length; i++) { var m = messages[i]; if (m.buff == FLOW_TOO_LOW) { if ((lows.length == 0) || ((lows.length > 0) && (lows[lows.length-1].ms < (m.ms - 200)))) lows.push(m); } else if (m.buff == FLOW_TOO_HIGH) { if ((highs.length == 0) || ((highs.length > 0) && (highs[highs.length-1].ms < (m.ms - 200)))) highs.push(m); } else { others.push(m); } } if (lows.length > 0 || highs.length > 0) { set_flow_alert(); } else { unset_flow_alert(); } var lowsPlot = gen_graph(lows,"Low Range","blue",'top center', (s => -90.0), (v => "LOW")); var highsPlot = gen_graph(highs,"High Range","red",'bottom center', (s => 90.0), (v => "HIGH")); var othersPlot = gen_graph(others,"messages","Aqua",'top center', (s => -75.0), (v => v.buff)); return [lowsPlot,highsPlot,othersPlot]; } { var fio2AirwayPlot = gen_graph_measurement(samples,"FiO2 (%)","Black",'top center','O','A', (s => s.val), (v => "FiO2 (A): "+v.toFixed(1)+"%")); event_graph.push(fio2AirwayPlot); } { var humAirwayPlot = gen_graph_measurement(samples,"Hum (%)","Aqua",'top center','H','A', (s => s.val/100.0), (v => "H2O (A): "+v.toFixed(0)+"%")); event_graph.push(humAirwayPlot); } { var humAmbientPlot = gen_graph_measurement(samples,"Hum (%)","CornflowerBlue",'bottom center','H','B', (s => -s.val/100.0), (v => "H2O (B): "+(-v).toFixed(0)+"%")); event_graph.push(humAmbientPlot); } { var tempAirwayPlot = gen_graph_measurement(samples,"Temp A","red",'bottom center','T','A', (s => s.val/100.0), (v => "T (A): "+v.toFixed(1)+"C")); event_graph.push(tempAirwayPlot); } { var tempAmbientPlot = gen_graph_measurement(samples,"Temp B","orange",'top center','T','B', (s => -s.val/100.0), (v => "T (B): "+(-v).toFixed(1)+"C")); event_graph.push(tempAmbientPlot); } // Altitude is really just a check that the system is working { var altAmbientPlot = gen_graph_measurement(samples,"Altitude","purple",'right','A','B', (s => s.val/20.0), (v => "Alt: "+(v*20.0).toFixed(0)+"m")); event_graph.push(altAmbientPlot); } { var [l,h,o] = gen_message_events(samples,"Message","red",'right'); event_graph.push(l); event_graph.push(h); event_graph.push(o); } // The BME680 sensor detects VOC gases. I don't think has any clinical value // compared // { // var gasAirwayPlot = gen_graph_measurement(samples,"Gas A","yellow",'bottom center','G','A', // (s => s.val/1000.0), // (v => "G (A): "+(v*100).toFixed(1)+"Ohms")); // event_graph.push(gasAirwayPlot); // } // { // var gasAmbientPlot = gen_graph_measurement(samples,"Gas B","gold",'top center','G','B', // (s => -s.val/1000.0), // (v => "G (B): "+(-v*100).toFixed(1)+"Ohms")); // event_graph.push(gasAmbientPlot); // } // I'm going to try putting the pressure // in faintly to make the graphs match var event_layout = { title: 'Events', showlegend: false, xaxis: {domain: [0.0,1.0]}, yaxis: { range: [-100.0, 100.0] }, }; Plotly.newPlot('EventsGraph', event_graph,event_layout); } } function set_flow_alert() { $("#main_alert").show(); } function unset_flow_alert() { $("#main_alert").hide(); } function check_alarms(limits,key,limit,val,f,ms) { var alarms = []; if (limits[key][limit] && (f(val,limits[key][limit]))) { alarms.push({param: key, limit: limit, val: val, ms: ms}); } return alarms; } // Return, [min,avg,max] pressures (no smoothing)! function compute_pressures(secs,samples) { var pressures = samples.filter(s => s.event == 'M' && s.type == 'D' && 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 == '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"]; } 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; 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); 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; return [ rr, tidal_volume, minute_volume, EIratio]; } else { return [0,0,0,"NA"]; } } } var LIMITS = { max: { h: 40, l: 0}, avg: { h: 30, l: 0}, min: { h: 10, l: -10}, mv: { h: 10, l: 1}, bpm: { h: 40, l: 5}, ier: { h: 4, l: 0.25}, tv: { h: 1000, l: 200}, fio2: { h: 100, l: 20}, taip: { h: 100, // These are in ms l: 0}, trip: { h: 100, // These are in ms l: 0}, } function load_ui_with_defaults(limits) { var Lkeys = Object.keys(limits); Lkeys.forEach((key,index) => { ["h","l"].forEach(limit => { $("#"+key+"_"+limit).val(limits[key][limit]); }) }); } function set_rise_time_pressures() { $("#tarip_l").val(TAIP_AND_TRIP_MIN); $('#tarip_l').change(function () { TAIP_AND_TRIP_MIN = $("#tarip_l").val(); }); $("#tarip_h").val(TAIP_AND_TRIP_MAX); $('#tarip_h').change(function () { TAIP_AND_TRIP_MAX = $("#tarip_h").val(); }); } function reflectAlarmsInGUI(alarms) { var Lkeys = Object.keys(LIMITS); Lkeys.forEach((key,index) => { ["h","l"].forEach(limit => { var alarms_for_key = alarms.filter(s => s.param == key && s.limit == limit); if (alarms_for_key.length > 0) { $("#"+key).addClass("alarmred"); $("#"+key+"_"+limit).addClass("alarmred"); } else { $("#"+key).removeClass("alarmred"); $("#"+key+"_"+limit).removeClass("alarmred"); } }) }); } function process(samples) { const t = 200; // size of the window is 200ms const v = 50; // min volume in ml var [transitions,breaths] = computeMovingWindowTrace(samples,t,v); plot(samples,transitions,breaths); // How many seconds backwards should we look? Perhaps 20? var [bpm,tv,mv,EIratio,fio2] = compute_respiration_rate(RESPIRATION_RATE_WINDOW_SECONDS,samples,transitions,breaths); var alarms = []; $("#bpm").text(bpm.toFixed(1)); $("#tv").text(tv.toFixed(0)); $("#mv").text((mv).toFixed(2)); if (EIratio == "NA") { $("#ier").text("NA"); } else { $("#ier").text((1.0 / EIratio).toFixed(1)); } var final_ms = samples[samples.length -1].ms; var b_ms = 0; if (breaths.length > 0) { b_ms = breaths[breaths.length -1].ms; } else { b_ms = final_ms; } function check_high_and_low(limits,key,v,ms) { var al1 = check_alarms(limits,key,"h",v,(a,b) =>(a > b),ms); var al2 = check_alarms(limits,key,"l",v,(a,b) =>(a < b),ms); return al1.concat(al2); } alarms = alarms.concat(check_high_and_low(LIMITS,"bpm",bpm.toFixed(1),b_ms)); alarms = alarms.concat(check_high_and_low(LIMITS,"tv",tv.toFixed(0),b_ms)); alarms = alarms.concat(check_high_and_low(LIMITS,"mv",(mv).toFixed(2),b_ms)); alarms = alarms.concat(check_high_and_low(LIMITS,"ier",(1.0 / EIratio).toFixed(1),b_ms)); // These must be moved out; in fact, // they must be made configurable! var taip = compute_mean_TRIP_or_TAIP( TAIP_AND_TRIP_MIN, TAIP_AND_TRIP_MAX, samples, true); if (taip != "NA") { $("#taip").text(taip.toFixed(1)); alarms = alarms.concat( check_high_and_low(LIMITS,"taip",taip,b_ms)); } else { $("#taip").text("NA"); } var trip = compute_mean_TRIP_or_TAIP( TAIP_AND_TRIP_MIN, TAIP_AND_TRIP_MAX, samples, false); if (trip != "NA") { $("#trip").text(trip.toFixed(1)); alarms = alarms.concat( check_high_and_low(LIMITS,"trip",trip,b_ms)); } else { $("#trip").text("NA"); } var [min,avg,max,palarms] = compute_pressures(RESPIRATION_RATE_WINDOW_SECONDS,samples); alarms = alarms.concat(palarms); $("#min").text(min.toFixed(1)); $("#avg").text(avg.toFixed(1)); $("#max").text(max.toFixed(1)); var fio2 = compute_fio2_mean(RESPIRATION_RATE_WINDOW_SECONDS,samples); if(fio2 == null){ $("#fio2").text("NA"); } else { alarms = alarms.concat(check_high_and_low(LIMITS,"fio2",fio2.toFixed(1),b_ms)); $("#fio2").text(fio2.toFixed(1)); } reflectAlarmsInGUI(alarms); } // WARNING: This is a hack...if the timestamp is negative, // we treat it as a limited (beyond range of sensor) measurement. // Our goal is to warn about this, but for now we will just // ignore and correct. function sanitize_samples(samples) { samples.forEach(s => { if (s.event == "M") { if ("string" == (typeof s.ms)) s.ms = parseInt(s.ms); if ("string" == (typeof s.val)) s.val = parseInt(s.val); if ("string" == (typeof s.num)) s.num = parseInt(s.num); if (s.ms < 0) { s.ms = -s.ms; } else if (s.event == "E") { } } }); return samples; } function retrieveAndPlot(){ var trace_piece = (TRACE_ID) ? "/" + TRACE_ID : ""; DSERVER_URL = $("#dserverurl").val(); var url = DSERVER_URL + trace_piece + "/json?n="+ NUM_TO_READ; $.ajax({url: url, success: function(cur_sam){ // WARNING: This is a hack...if the timestamp is negative, // we treat it as a limited (beyond range of sensor) measurement. // Our goal is to warn about this, but for now we will just // ignore and correct. if (cur_sam && cur_sam.length == 0) { console.log("no samples; potential misconfiguration!"); } else { cur_sam = sanitize_samples(cur_sam); if (INITS_ONLY) { samples = cur_sam; INITS_ONLY = false; } else { var discard = Math.max(0, samples.length + cur_sam.length - MAX_SAMPLES_TO_STORE_S); samples = samples.slice(discard); } samples = samples.concat(cur_sam); // We are not guaranteeed to get samples in order // we sort them.... samples = samples.sort((a,b) => a.ms - b.ms); process(samples); } }, error: function(xhr, ajaxOptions, thrownError) { console.log("Error!" + xhr.status); console.log(thrownError); // clearInterval(intervalID); stop_interval_timer(); $("#livetoggle").prop("checked",false); } }); } 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; } // 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 == '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:'A',ms:ms + 0,type:'D',val: 0}; data[i*5+1] = {event:'M',loc:'A',ms:ms + 10,type:'D',val: 100}; data[i*5+2] = {event:'M',loc:'A',ms:ms + 20,type:'D',val: 200}; data[i*5+3] = {event:'M',loc:'A',ms:ms + 30,type:'D',val: 100}; data[i*5+4] = {event:'M',loc:'A',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:'A',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; } } // net Pressure-Volume Work: // integral under P*V curve function PressureVolumeWork(samples, a,z) { // -1 for quadilateral approximation var flows = samples.filter(s => s.event == 'M' && s.type == 'F'); var pressures = samples.filter(s => s.event == 'M' && s.type == 'D' && s.loc == 'A'); var pressureVolume_prod= 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*pressures[j+1].val) + (flows[j].val*pressures[j+1].val ))/2) * CONVERT_PIRDS_TO_SLM; // Flow in standard liters per minute, // divide by 60000 to get liters/s pressureVolume_prod += ms * ht/60000; if (isNaN(pressureVolume_prod)) { debugger; } } // pressure cm H2O --> atm (divide by 1033) return pressureVolume_prod/1033 } 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); } // 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, } ); 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, } ); 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]; } $("#useofficial").click(function() { DSERVER_URL = VENTMON_DATA_LAKE; $("#dserverurl").val(DSERVER_URL); }); $("#import").click(function() { var input_trace = $("#json_trace").val(); samples = JSON.parse(input_trace); }); $("#export").click(function() { $("#json_trace").val(JSON.stringify(samples,null,2)); }); // Experimental timing against the data server function stop_interval_timer() { clearInterval(intervalID); intervalID = null; $("#livetoggle").prop("checked",false); } function start_interval_timer() { if (intervalID) { stop_interval_timer(); } intervalID = setInterval( function() { retrieveAndPlot(); }, DATA_RETRIEVAL_PERIOD); $("#livetoggle").prop("checked",true); } function toggle_interval_timer() { if (intervalID) { stop_interval_timer(); } else { start_interval_timer(); } } function setLimit(ed) { if(ed.which == 13) { var id = ed.target.id; var v = $("#"+id).val(); var vf = parseFloat(v); var destruct = id.split("_"); LIMITS[destruct[0]][destruct[1]] = isNaN(vf) ? null : vf; } } $(".fence").keypress(setLimit); $("#livetoggle").change(toggle_interval_timer); $("#startoperation").click(start_interval_timer); $("#stopoperation").click(stop_interval_timer); $( document ).ready(function() { if (window.location.protocol == "http:") DSERVER_URL = window.location.protocol + "//" + window.location.host; else DSERVER_URL = "http://localhost"; $( "#dserverurl" ).val( DSERVER_URL ); $('#dserverurl').change(function () { DSERVER_URL = $("#dserverurl").val(); start_interval_timer(); }); // $( "#num_to_read" ).val( NUM_TO_READ ); // $('#num_to_read').change(function () { // NUM_TO_READ = $("#num_to_read").val(); // }); $( "#traceid" ).val( TRACE_ID ); $('#traceid').change(function () { TRACE_ID = $("#traceid").val(); start_interval_timer(); }); $( "#samples_to_plot" ).val( MAX_SAMPLES_TO_STORE_S ); $('#samples_to_plot').change(function () { MAX_SAMPLES_TO_STORE_S = $("#samples_to_plot").val(); }); samples = init_samples(); process(samples); start_interval_timer(); load_ui_with_defaults(LIMITS); set_rise_time_pressures( TAIP_AND_TRIP_MIN, TAIP_AND_TRIP_MAX, ); }); </script>