diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index d4147be2..4b8c0be0 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -245,11 +245,17 @@ class FormData: else: humidity = 0.5 room = models.Room(volume=volume, humidity=humidity) + + if self.short_range_option == "short_range_yes": + sr_presence=self.short_range_intervals() + sr_activities=self.short_range_activities() + short_range_expirations = tuple(short_range_expiration_distributions[activity] for activity in sr_activities) + dilutions=dilution_factor(activities=sr_activities, distance=np.random.uniform(0.5, 1.5, 250000)) + else: + sr_presence=() + short_range_expirations=() + dilutions=() - sr_presence=self.short_range_intervals() - sr_activities=self.short_range_activities() - short_range_expirations = tuple(short_range_expiration_distributions[activity] for activity in sr_activities) - # Initializes and returns a model with the attributes defined above return mc.ExposureModel( concentration_model=mc.ConcentrationModel( @@ -261,7 +267,7 @@ class FormData: short_range = mc.ShortRangeModel( presence=sr_presence, expirations=short_range_expirations, - dilutions=dilution_factor(activities=sr_activities, distance=np.random.uniform(0.5, 1.5, 250000)), + dilutions=dilutions, ), exposed=self.exposed_population(), ) @@ -639,15 +645,13 @@ class FormData: ) def short_range_intervals(self) -> typing.Tuple[typing.Tuple[str, models.SpecificInterval], ...]: - if (self.short_range_interactions): - short_range_intervals = [] - for interaction in self.short_range_interactions: - start_time = time_string_to_minutes(interaction['start_time']) - duration = float(interaction['duration']) - short_range_intervals.append((interaction['activity'], models.SpecificInterval((start_time/60, (start_time + duration)/60)))) - return tuple(short_range_intervals) - else: - return () + short_range_intervals = [] + for interaction in self.short_range_interactions: + start_time = time_string_to_minutes(interaction['start_time']) + duration = float(interaction['duration']) + short_range_intervals.append((interaction['activity'], models.SpecificInterval((start_time/60, (start_time + duration)/60)))) + return tuple(short_range_intervals) + def exposed_present_interval(self) -> models.Interval: return self.present_interval( diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index 58b6c5e1..204377a1 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -127,12 +127,18 @@ def calculate_report_data(model: models.ExposureModel): np.array(model.deposited_exposure_between_bounds(float(time1), float(time2))).mean() for time1, time2 in zip(times[:-1], times[1:]) ]) + long_range_cumulative_doses = np.cumsum([ + np.array(model.long_range_deposited_exposure_between_bounds(float(time1), float(time2))).mean() + for time1, time2 in zip(times[:-1], times[1:]) + ]) return { "times": list(times), "short_range_intervals": short_range_intervals, + "short_range_activities": short_range_activities, "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], "cumulative_doses": list(cumulative_doses), + "long_range_cumulative_doses": list(long_range_cumulative_doses), "short_range_concentrations": short_range_concentrations, "concentrations": sr_breathing_concentrations, "highest_const": highest_const, diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js index 59cb888a..675cad6e 100644 --- a/cara/apps/calculator/static/js/form.js +++ b/cara/apps/calculator/static/js/form.js @@ -405,7 +405,7 @@ function validate_form(form) { } // Generate the short range interactions list - let short_range_interactions = []; + var short_range_interactions = []; $(".form_field_outer_row").each(function (index, element){ let obj = {}; obj.activity = $(element).find("[name='short_range_activity']").val(); @@ -413,6 +413,11 @@ function validate_form(form) { obj.duration = $(element).find("[name='short_range_duration']").val(); short_range_interactions.push(JSON.stringify(obj)); }); + + // Sort list by time + short_range_interactions.sort(function (a, b) { + return JSON.parse(a).start_time.localeCompare(JSON.parse(b).start_time); + }); $("input[type=text][name=short_range_interactions]").val('[' + short_range_interactions + ']'); if (short_range_interactions.length == 0) { $("input[type=radio][id=short_range_no]").prop("checked", true); diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js index a4a7c32a..638e00b7 100644 --- a/cara/apps/calculator/static/js/report.js +++ b/cara/apps/calculator/static/js/report.js @@ -1,17 +1,22 @@ /* Generate the concentration plot using d3 library. */ -function draw_plot(svg_id, times, concentrations, short_range_concentrations, cumulative_doses, exposed_presence_intervals, short_range_intervals) { +function draw_plot(svg_id, times, concentrations, short_range_concentrations, + cumulative_doses, long_range_cumulative_doses, exposed_presence_intervals, + short_range_intervals, short_range_activities) { // Used for controlling the short range interactions let button_full_exposure = document.getElementById("button_full_exposure"); let button_long_exposure = document.getElementById("button_long_exposure"); + let long_range_checkbox = document.getElementById('long_range_cumulative_checkbox') let show_sr_legend = button_full_exposure || Math.round(Math.max(...concentrations)) == Math.round(Math.max(...short_range_concentrations)) var data_for_graphs = { 'concentrations': [], 'cumulative_doses': [], + 'long_range_cumulative_doses': [], } times.map((time, index) => data_for_graphs.concentrations.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': short_range_concentrations[index]})); times.map((time, index) => data_for_graphs.cumulative_doses.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': cumulative_doses[index]})); + times.map((time, index) => data_for_graphs.long_range_cumulative_doses.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': long_range_cumulative_doses[index]})); // Add main SVG element var plot_div = document.getElementById(svg_id); @@ -24,10 +29,10 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu bisecHour = d3.bisector((d) => { return d.hour; }).left, yRange = d3.scaleLinear(), - yCumulativeRange = d3.scaleLinear().domain([0., Math.max(...cumulative_doses)*1.1]), + yCumulativeRange = d3.scaleLinear(), - yAxis = d3.axisLeft(); - yCumulativeAxis = d3.axisRight(yCumulativeRange).ticks(4); + yAxis = d3.axisLeft(), + yCumulativeAxis = d3.axisRight(); // X axis declaration. var xAxisEl = vis.append('svg:g') @@ -54,7 +59,6 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu // Y cumulative concentration axis declaration. var yAxisCumEl = vis.append('svg:g') .attr('class', 'y axis') - .style('font-size', 14) .style("stroke-dasharray", "5 5"); // Y cumulated concentration axis label. @@ -81,12 +85,17 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu .attr('fill', '#1f77b4') .attr('fill-opacity', '0.1'); + sr_unique_activities = [...new Set(short_range_activities)] if (show_sr_legend) { - var legendShortRangeAreaIcon = vis.append('rect') + var legendShortRangeAreaIcon = {}; + sr_unique_activities.forEach((b, index) => { + legendShortRangeAreaIcon[index] = vis.append('rect') .attr('width', 20) - .attr('height', 15) - .attr('fill', '#1f00b4') - .attr('fill-opacity', '0.1'); + .attr('height', 15); + if (sr_unique_activities[index] == 'Breathing') legendShortRangeAreaIcon[index].attr('fill', 'red').attr('fill-opacity', '0.2'); + else if (sr_unique_activities[index] == 'Speaking') legendShortRangeAreaIcon[index].attr('fill', 'green').attr('fill-opacity', '0.1'); + else legendShortRangeAreaIcon[index].attr('fill', 'blue').attr('fill-opacity', '0.1'); + }); } var legendLineText = vis.append('text') @@ -105,14 +114,17 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu .attr('alignment-baseline', 'central'); if (show_sr_legend) { - var legendShortRangeText = vis.append('text') - .text('Short range interaction(s)') + var legendShortRangeText = {}; + sr_unique_activities.forEach((b, index) => { + legendShortRangeText[index] = vis.append('text') + .text('Short range - ' + sr_unique_activities[index]) .style('font-size', '15px') .attr('alignment-baseline', 'central'); + }); } // Legend bounding - if (show_sr_legend) legendBBox_height = 90; + if (show_sr_legend) legendBBox_height = 68 + 20 * sr_unique_activities.length; else legendBBox_height = 68; var legendBBox = vis.append('rect') .attr('width', 255) @@ -140,12 +152,23 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu // Line representing the cumulative concentration. var lineCumulative = d3.line(); - var draw_cumulative_line = vis.append('svg:path') + var draw_cumulative_line = draw_area.append('svg:path') .attr('stroke', '#1f77b4') .attr('stroke-width', 2) .style("stroke-dasharray", "5 5") .attr('fill', 'none'); + // Line representing the long range cumulative concentration. + if (show_sr_legend) { + var longRangeCumulative = d3.line(); + var draw_long_range_cumulative_line = draw_area.append('svg:path') + .attr('stroke', 'purple') + .attr('stroke-width', 2) + .style("stroke-dasharray", "5 5") + .attr('fill', 'none') + .attr('opacity', 0); + } + // Area representing the presence of exposed person(s). var exposedArea = {}; var drawArea = {}; @@ -161,10 +184,11 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu var drawShortRangeArea = {}; short_range_intervals.forEach((b, index) => { shortRangeArea[index] = d3.area(); - drawShortRangeArea[index] = draw_area.append('svg:path') - .attr('class', 'draw_short_range_area') - .attr('fill', '#1f00b4') - .attr('fill-opacity', '0.1'); + drawShortRangeArea[index] = draw_area.append('svg:path'); + + if (short_range_activities[index] == 'Breathing') drawShortRangeArea[index].attr('fill', 'red').attr('fill-opacity', '0.2'); + else if (short_range_activities[index] == 'Speaking') drawShortRangeArea[index].attr('fill', 'green').attr('fill-opacity', '0.1'); + else drawShortRangeArea[index].attr('fill', 'blue').attr('fill-opacity', '0.1'); }); // Tooltip. @@ -202,9 +226,12 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu .attr('pointer-events', 'all'); } - function update_concentration_plot(data) { - yRange.domain([0., Math.max(...data)]); + function update_concentration_plot(concentration_data, cumulative_data) { + yRange.domain([0., Math.max(...concentration_data)*1.1]); yAxisEl.transition().duration(1000).call(yAxis); + + yCumulativeRange.domain([0., Math.max(...cumulative_data)*1.1]); + yAxisCumEl.transition().duration(1000).call(yCumulativeAxis) // Concentration line lineFunc.defined(d => !isNaN(d.concentration)) @@ -215,6 +242,24 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu .duration(1000) .attr("d", lineFunc(data_for_graphs.concentrations)); + // Cumulative line. + lineCumulative.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yCumulativeRange(d.concentration)); + draw_cumulative_line.transition() + .duration(1000) + .attr("d", lineCumulative(data_for_graphs.cumulative_doses)); + + // Long range cumulative line. + if (show_sr_legend) { + longRangeCumulative.defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yCumulativeRange(d.concentration)); + draw_long_range_cumulative_line.transition() + .duration(1000) + .attr("d", lineCumulative(data_for_graphs.long_range_cumulative_doses)); + } + // Area. exposed_presence_intervals.forEach((b, index) => { exposedArea[index].x(d => xTimeRange(d.time)) @@ -327,6 +372,7 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu // Axis. var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)); yAxis.scale(yRange); + yCumulativeAxis.scale(yCumulativeRange); xAxisEl.attr('transform', 'translate(0,' + (graph_height - margins.bottom) + ')') .call(xAxis); @@ -374,10 +420,12 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu .attr('y', margins.top + 3 * size); if (show_sr_legend) { - legendShortRangeAreaIcon.attr('x', graph_width + size * 2.5) - .attr('y', margins.top + 3.6 * size); - legendShortRangeText.attr('x', graph_width + 4 * size) - .attr('y', margins.top + 4 * size); + sr_unique_activities.forEach((b, index) => { + legendShortRangeAreaIcon[index].attr('x', graph_width + size * 2.5) + .attr('y', margins.top + 3.6 * size + index * size); + legendShortRangeText[index].attr('x', graph_width + 4 * size) + .attr('y', margins.top + 4 * size + index * size); + }); } legendBBox.attr('x', graph_width * 1.07) @@ -403,10 +451,12 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu .attr('y', graph_height + 2.6 * size); if (show_sr_legend) { - legendShortRangeAreaIcon.attr('x', size * 0.50) - .attr('y', graph_height * 1.175 + size); - legendShortRangeText.attr('x', 2 * size) - .attr('y', graph_height + 3.65 * size); + sr_unique_activities.forEach((b, index) => { + legendShortRangeAreaIcon[index].attr('x', size * 0.50) + .attr('y', graph_height * 1.175 + size + index * size); + legendShortRangeText[index].attr('x', 2 * size) + .attr('y', graph_height + 3.65 * size + index * size); + }); } legendBBox.attr('x', 1) @@ -419,23 +469,25 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu .attr('height', graph_height); } - // Cumulative line. - lineCumulative.defined(d => !isNaN(d.concentration)) - .x(d => xTimeRange(d.time)) - .y(d => yCumulativeRange(d.concentration)); - draw_cumulative_line.attr("d", lineCumulative(data_for_graphs.cumulative_doses)); } + if (long_range_checkbox) { + long_range_checkbox.addEventListener("click", () => { + if (long_range_checkbox.checked) draw_long_range_cumulative_line.transition().duration(1000).attr("opacity", 1); + else draw_long_range_cumulative_line.transition().duration(1000).attr("opacity", 0); + }); + }; + if (button_full_exposure) { button_full_exposure.addEventListener("click", () => { - update_concentration_plot(short_range_concentrations); + update_concentration_plot(short_range_concentrations, cumulative_doses); button_full_exposure.disabled = true; button_long_exposure.disabled = false; }); } if (button_long_exposure) { button_long_exposure.addEventListener("click", () => { - update_concentration_plot(concentrations); + update_concentration_plot(concentrations, long_range_cumulative_doses); button_full_exposure.disabled = false; button_long_exposure.disabled = true; }); @@ -443,13 +495,13 @@ function draw_plot(svg_id, times, concentrations, short_range_concentrations, cu // Draw for the first time to initialize. redraw(); - update_concentration_plot(short_range_concentrations); + update_concentration_plot(short_range_concentrations, cumulative_doses); // Redraw based on the new size whenever the browser window is resized. window.addEventListener("resize", e => { redraw(); - if (button_full_exposure.disabled) update_concentration_plot(short_range_concentrations); - else update_concentration_plot(concentrations) + if (button_full_exposure.disabled) update_concentration_plot(short_range_concentrations, cumulative_doses); + else update_concentration_plot(concentrations, long_range_cumulative_doses) }); diff --git a/cara/apps/templates/base/calculator.report.html.j2 b/cara/apps/templates/base/calculator.report.html.j2 index 8350f884..23df0423 100644 --- a/cara/apps/templates/base/calculator.report.html.j2 +++ b/cara/apps/templates/base/calculator.report.html.j2 @@ -89,19 +89,26 @@ {% endblock report_summary_footnote %}
* The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004.
- {% if (form.short_range_option == "short_range_yes" and concentrations|max|int != short_range_concentrations|max|int ) %} - - - {% endif %} + {% if form.short_range_option == "short_range_yes" %} + {% if concentrations|max|int != short_range_concentrations|max|int %} + + + {% endif %} + + + {% endif %} diff --git a/cara/models.py b/cara/models.py index 9f1ac43d..8cedd029 100644 --- a/cara/models.py +++ b/cara/models.py @@ -1212,6 +1212,38 @@ class ExposureModel: return (self.concentration_model.concentration(time) + self.short_range.short_range_concentration(self.concentration_model, time)) + def long_range_deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat: + deposited_exposure = 0. + + emission_rate_per_aerosol = self.concentration_model.infected.emission_rate_per_aerosol_when_present() + aerosols = self.concentration_model.infected.aerosols() + f_inf = self.concentration_model.infected.fraction_of_infectious_virus() + fdep = self.long_range_fraction_deposited() + + diameter = self.concentration_model.infected.particle.diameter + + if not np.isscalar(diameter) and diameter is not None: + # we compute first the mean of all diameter-dependent quantities + # to perform properly the Monte-Carlo integration over + # particle diameters (doing things in another order would + # lead to wrong results). + dep_exposure_integrated = np.array(self._long_range_normed_exposure_between_bounds(time1, time2) * + aerosols * + fdep).mean() + else: + # in the case of a single diameter or no diameter defined, + # one should not take any mean at this stage. + dep_exposure_integrated = self._long_range_normed_exposure_between_bounds(time1, time2)*aerosols*fdep + + # then we multiply by the diameter-independent quantity emission_rate_per_aerosol, + # and parameters of the vD equation (i.e. BR_k and n_in). + deposited_exposure += (dep_exposure_integrated * emission_rate_per_aerosol * + self.exposed.activity.inhalation_rate * + (1 - self.exposed.mask.inhale_efficiency())) + + # In the end we multiply the final results by the fraction of infectious virus of the vD equation. + return deposited_exposure * f_inf + def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat: """ The number of virus per m^3 deposited on the respiratory tract @@ -1257,36 +1289,11 @@ class ExposureModel: # then we multiply by the diameter-independent quantity virus viral load deposited_exposure *= self.concentration_model.virus.viral_load_in_sputum - - # Long range concentration - emission_rate_per_aerosol = self.concentration_model.infected.emission_rate_per_aerosol_when_present() + # long range concentration f_inf = self.concentration_model.infected.fraction_of_infectious_virus() - aerosols = self.concentration_model.infected.aerosols() - fdep = self.long_range_fraction_deposited() + deposited_exposure += self.long_range_deposited_exposure_between_bounds(time1, time2)/f_inf - diameter = self.concentration_model.infected.particle.diameter - - if not np.isscalar(diameter) and diameter is not None: - # we compute first the mean of all diameter-dependent quantities - # to perform properly the Monte-Carlo integration over - # particle diameters (doing things in another order would - # lead to wrong results). - dep_exposure_integrated = np.array(self._long_range_normed_exposure_between_bounds(time1, time2) * - aerosols * - fdep).mean() - else: - # in the case of a single diameter or no diameter defined, - # one should not take any mean at this stage. - dep_exposure_integrated = self._long_range_normed_exposure_between_bounds(time1, time2)*aerosols*fdep - - # then we multiply by the diameter-independent quantity emission_rate_per_aerosol, - # and parameters of the vD equation (i.e. BR_k and n_in). - deposited_exposure += (dep_exposure_integrated * emission_rate_per_aerosol * - self.exposed.activity.inhalation_rate * - (1 - self.exposed.mask.inhale_efficiency())) - - # In the end we multiply the final results by the fraction of infectious virus of the vD equation. - return f_inf * deposited_exposure + return deposited_exposure * f_inf def deposited_exposure(self) -> _VectorisedFloat: """