From 6bfebc76d48729b40463b55e28c390a976b035ee Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Wed, 15 Feb 2023 14:53:45 +0100 Subject: [PATCH 01/11] added frontend visualisation for CO2 plot --- caimira/apps/calculator/static/js/report.js | 2 +- .../templates/base/calculator.report.html.j2 | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/caimira/apps/calculator/static/js/report.js b/caimira/apps/calculator/static/js/report.js index 49286914..5e5a6319 100644 --- a/caimira/apps/calculator/static/js/report.js +++ b/caimira/apps/calculator/static/js/report.js @@ -1163,4 +1163,4 @@ function export_csv() { link.setAttribute("download", "report_data.csv"); document.body.appendChild(link); link.click(); -} \ No newline at end of file +} diff --git a/caimira/apps/templates/base/calculator.report.html.j2 b/caimira/apps/templates/base/calculator.report.html.j2 index 020d40b5..8cddac1d 100644 --- a/caimira/apps/templates/base/calculator.report.html.j2 +++ b/caimira/apps/templates/base/calculator.report.html.j2 @@ -224,6 +224,27 @@ +
+
Predictive CO₂ Concentration Profile + +
+
+
+
+
+ +
+
+
+
+ {% if form.short_range_option == "short_range_no" %}
Alternative scenarios @@ -283,6 +304,7 @@
{% endif %} + {% endblock report_results %} From e67960e035082e8e23f4093b34320c52c0d4a780 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Wed, 15 Feb 2023 14:55:26 +0100 Subject: [PATCH 02/11] added methods to generate CO2 model --- caimira/apps/calculator/model_generator.py | 25 +++++++++++++++++---- caimira/apps/calculator/report_generator.py | 9 +++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index 747001ee..e64ffc35 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -304,9 +304,8 @@ class FormData: if total_percentage != 100: raise ValueError(f'The sum of all respiratory activities should be 100. Got {total_percentage}.') - - - def build_mc_model(self) -> mc.ExposureModel: + + def initialize_room(self) -> models.Room: # Initializes room with volume either given directly or as product of area and height if self.volume_type == 'room_volume_explicit': volume = self.room_volume @@ -323,7 +322,10 @@ class FormData: humidity = float(self.humidity) inside_temp = self.inside_temp - room = models.Room(volume=volume, inside_temp=models.PiecewiseConstant((0, 24), (inside_temp,)), humidity=humidity) + return models.Room(volume=volume, inside_temp=models.PiecewiseConstant((0, 24), (inside_temp,)), humidity=humidity) + + def build_mc_model(self) -> mc.ExposureModel: + room = self.initialize_room() infected_population = self.infected_population() @@ -357,6 +359,21 @@ class FormData: def build_model(self, sample_size=DEFAULT_MC_SAMPLE_SIZE) -> models.ExposureModel: return self.build_mc_model().build_model(size=sample_size) + def build_CO2_model(self, sample_size=DEFAULT_MC_SAMPLE_SIZE) -> models.CO2ConcentrationModel: + population = mc.Population( + number=self.total_people, + presence=self.infected_present_interval(), + mask=models.Mask.types[self.mask_type], + activity=activity_distributions[ACTIVITIES[ACTIVITY_TYPES.index(self.activity_type)]['activity']], + host_immunity=0., + ) + # Builds a CO2 concentration model based on model inputs + return mc.CO2ConcentrationModel( + room=self.initialize_room(), + ventilation=self.ventilation(), + CO2_emitters=population, + ).build_model(size=sample_size) + def tz_name_and_utc_offset(self) -> typing.Tuple[str, float]: """ Return the timezone name (e.g. CET), and offset, in hours, that need to diff --git a/caimira/apps/calculator/report_generator.py b/caimira/apps/calculator/report_generator.py index 0a289003..0fcbb939 100644 --- a/caimira/apps/calculator/report_generator.py +++ b/caimira/apps/calculator/report_generator.py @@ -133,7 +133,13 @@ def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing for time1, time2 in zip(times[:-1], times[1:]) ]) - prob = np.array(model.infection_probability()) + CO2_model: models.CO2ConcentrationModel = form.build_CO2_model() + CO2_concentrations = [ + np.array(CO2_model.concentration(float(time))).mean() + for time in times + ] + + prob = np.array(model.infection_probability()).mean() prob_dist_count, prob_dist_bins = np.histogram(prob/100, bins=100, density=True) prob_probabilistic_exposure = np.array(model.total_probability_rule()).mean() expected_new_cases = np.array(model.expected_new_cases()).mean() @@ -158,6 +164,7 @@ def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing "prob_probabilistic_exposure": prob_probabilistic_exposure, "expected_new_cases": expected_new_cases, "uncertainties_plot_src": uncertainties_plot_src, + "CO2_concentrations": CO2_concentrations, } From b17a1b991a0b59dffdad78392ea8bb15d7311d1e Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 16 Feb 2023 16:48:40 +0100 Subject: [PATCH 03/11] modified draw alternative scenarios method to be a generic plot and added export CO2 cvs data --- caimira/apps/calculator/report_generator.py | 6 +- caimira/apps/calculator/static/js/report.js | 73 ++++++++----------- .../templates/base/calculator.report.html.j2 | 29 +++++--- 3 files changed, 53 insertions(+), 55 deletions(-) diff --git a/caimira/apps/calculator/report_generator.py b/caimira/apps/calculator/report_generator.py index 0fcbb939..144a50e1 100644 --- a/caimira/apps/calculator/report_generator.py +++ b/caimira/apps/calculator/report_generator.py @@ -134,11 +134,11 @@ def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing ]) CO2_model: models.CO2ConcentrationModel = form.build_CO2_model() - CO2_concentrations = [ + CO2_concentrations = {'CO₂ concentrations': {'concentrations': [ np.array(CO2_model.concentration(float(time))).mean() for time in times - ] - + ]}} + prob = np.array(model.infection_probability()).mean() prob_dist_count, prob_dist_bins = np.histogram(prob/100, bins=100, density=True) prob_probabilistic_exposure = np.array(model.total_probability_rule()).mean() diff --git a/caimira/apps/calculator/static/js/report.js b/caimira/apps/calculator/static/js/report.js index 5e5a6319..eedd8112 100644 --- a/caimira/apps/calculator/static/js/report.js +++ b/caimira/apps/calculator/static/js/report.js @@ -542,26 +542,26 @@ function draw_plot(svg_id) { }); } -// Generate the alternative scenarios plot using d3 library. -// 'alternative_scenarios' is a dictionary with all the alternative scenarios +// Generate a scenarios plot using d3 library. +// 'list_of_scenarios' is a dictionary with all the scenarios // 'times' is a list of times for all the scenarios -// The method is prepared to consider short-range interactions if needed. -function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_plot_svg_id) { +function draw_generic_concentration_plot( + plot_svg_id, + y_axis_label, + ) { + + list_of_scenarios = (plot_svg_id === 'CO2_concentration_graph') ? CO2_concentrations : alternative_scenarios // H:M format var time_format = d3.timeFormat('%H:%M'); // D3 array of ten categorical colors represented as RGB hexadecimal strings. var colors = d3.schemeAccent; - // Used for controlling the short-range interactions - let button_full_exposure = document.getElementById("button_alternative_full_exposure"); - let button_hide_high_concentration = document.getElementById("button_alternative_hide_high_concentration"); - // Variable for the highest concentration for all the scenarios var highest_concentration = 0. var data_for_scenarios = {} - for (scenario in alternative_scenarios) { - scenario_concentrations = alternative_scenarios[scenario].concentrations; + for (scenario in list_of_scenarios) { + scenario_concentrations = list_of_scenarios[scenario].concentrations; highest_concentration = Math.max(highest_concentration, Math.max(...scenario_concentrations)) @@ -576,8 +576,8 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ var first_scenario = Object.values(data_for_scenarios)[0] // Add main SVG element - var alternative_plot_div = document.getElementById(alternative_plot_svg_id); - var vis = d3.select(alternative_plot_div).append('svg'); + var plot_div = document.getElementById(plot_svg_id); + var vis = d3.select(plot_div).append('svg'); var xRange = d3.scaleTime().domain([first_scenario[0].hour, first_scenario[first_scenario.length - 1].hour]); var xTimeRange = d3.scaleLinear().domain([times[0], times[times.length - 1]]); @@ -599,20 +599,21 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ // Y axis declaration. var yAxisEl = vis.append('svg:g') - .attr('class', 'y axis'); + .attr('class', 'y axis'); // Y axis label. var yAxisLabelEl = vis.append('svg:text') .attr('class', 'y label') .attr('fill', 'black') .attr('text-anchor', 'middle') - .text('Mean concentration (virions/m³)'); + .text(y_axis_label); // Legend bounding box. max_key_length = Math.max(...(Object.keys(data_for_scenarios).map(el => el.length))); + var legendBBox = vis.append('rect') - .attr('width', 8.25 * max_key_length ) - .attr('height', 25 * (Object.keys(data_for_scenarios).length)) + .attr('width', 10 * max_key_length ) + .attr('height', 30 * (Object.keys(data_for_scenarios).length)) .attr('stroke', 'lightgrey') .attr('stroke-width', '2') .attr('rx', '5px') @@ -687,11 +688,12 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ .on('mousemove', mousemove); } - function update_alternative_concentration_plot(concentration_data) { + function update_concentration_plot(concentration_data) { + list_of_scenarios = (plot_svg_id === 'CO2_concentration_graph') ? CO2_concentrations : alternative_scenarios var highest_concentration = 0. - for (scenario in alternative_scenarios) { - scenario_concentrations = alternative_scenarios[scenario][concentration_data]; + for (scenario in list_of_scenarios) { + scenario_concentrations = list_of_scenarios[scenario][concentration_data]; highest_concentration = Math.max(highest_concentration, Math.max(...scenario_concentrations)); } @@ -711,9 +713,10 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ var graph_height; function redraw() { - // Define width and height according to the screen size. - var div_width = document.getElementById(concentration_plot_svg_id).clientWidth; - var div_height = document.getElementById(concentration_plot_svg_id).clientHeight; + // Define width and height according to the screen size. Always use an already defined + var div_width = document.getElementById('concentration_plot').clientWidth; + var div_height = document.getElementById('concentration_plot').clientHeight; + graph_width = div_width; graph_height = div_height if (div_width >= 1200) { // For screens with width > 900px legend can be on the graph's right side. @@ -755,7 +758,7 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name) // Legend on right side. var size = 20 * (scenario_index + 1); - if (document.getElementById(concentration_plot_svg_id).clientWidth >= 900) { + if (div_width >= 900) { label_icons[scenario_name].attr('x', graph_width + legend_x_start) .attr('y', margins.top + size); label_text[scenario_name].attr('x', graph_width + legend_x_start + space_between_text_icon) @@ -789,7 +792,7 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ .attr('y', (graph_height + margins.left) * 0.90); // Legend on right side. - if (document.getElementById(concentration_plot_svg_id).clientWidth >= 900) { + if (div_width >= 900) { legendBBox.attr('x', graph_width * 1.02) .attr('y', margins.top * 1.15); @@ -807,21 +810,6 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ } } - if (button_full_exposure) { - button_full_exposure.addEventListener("click", () => { - update_alternative_concentration_plot('concentrations'); - button_full_exposure.disabled = true; - button_hide_high_concentration.disabled = false; - }); - } - if (button_hide_high_concentration) { - button_hide_high_concentration.addEventListener("click", () => { - update_alternative_concentration_plot('concentrations_zoomed'); - button_full_exposure.disabled = false; - button_hide_high_concentration.disabled = true; - }); - } - function mousemove() { for (const [scenario_name, data] of Object.entries(data_for_scenarios)) { if (d3.pointer(event)[0] < graph_width / 2) { @@ -847,13 +835,12 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_ // Draw for the first time to initialize. redraw(); - update_alternative_concentration_plot('concentrations'); + update_concentration_plot('concentrations'); // Redraw based on the new size whenever the browser window is resized. window.addEventListener("resize", e => { redraw(); - if (button_full_exposure && button_full_exposure.disabled) update_alternative_concentration_plot('concentrations'); - else update_alternative_concentration_plot('concentrations') + update_concentration_plot('concentrations'); }); } @@ -1130,6 +1117,7 @@ function export_csv() { var column_name = has_rename != '' ? has_rename : e.id; if (e.id == "Time") checked_names.push(`${column_name} \u2028(h)`); else if (e.id == "Concentration") checked_names.push(`${column_name} \u2028(virions m⁻³)`); + else if (e.id == "CO2_Concentration") checked_names.push(`${column_name} \u2028(ppm)`); checked_items.push(e.id); } } @@ -1143,6 +1131,7 @@ function export_csv() { checked_items.includes("Concentration") && this_row.push(concentrations[i]); checked_items.includes("Cumulative Dose") && this_row.push(cumulative_doses[i]); checked_items.includes("Long-Range Dose") && this_row.push(long_range_cumulative_doses[i]); + checked_items.includes("CO2_Concentration") && this_row.push(CO2_concentrations[Object.keys(CO2_concentrations)[0]].concentrations[i]); if (has_alternative_scenario) { Object.entries(alternative_scenarios).map((scenario) => { if (scenario[0] != 'Current scenario') { diff --git a/caimira/apps/templates/base/calculator.report.html.j2 b/caimira/apps/templates/base/calculator.report.html.j2 index 8cddac1d..5baab2a9 100644 --- a/caimira/apps/templates/base/calculator.report.html.j2 +++ b/caimira/apps/templates/base/calculator.report.html.j2 @@ -237,8 +237,11 @@
@@ -257,16 +260,13 @@
- {% if form.short_range_option == "short_range_yes" %} - {% if 'Speaking' in form.short_range_interactions|string or 'Shouting' in form.short_range_interactions|string %} - - - {% endif %} - {% endif %}

{% block report_scenarios_summary_table %} @@ -349,6 +349,15 @@
+ +
+ + + +
{% if form.short_range_option == "short_range_no" %}
  • From bc7dae36108a049a733180a14bacfaae290e6332 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 17 Feb 2023 15:05:39 +0100 Subject: [PATCH 04/11] changed CO2 label and added hlines with legends --- caimira/apps/calculator/report_generator.py | 2 +- caimira/apps/calculator/static/js/report.js | 62 +++++++++++++++++-- .../templates/base/calculator.report.html.j2 | 12 ++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/caimira/apps/calculator/report_generator.py b/caimira/apps/calculator/report_generator.py index 144a50e1..a086310a 100644 --- a/caimira/apps/calculator/report_generator.py +++ b/caimira/apps/calculator/report_generator.py @@ -134,7 +134,7 @@ def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing ]) CO2_model: models.CO2ConcentrationModel = form.build_CO2_model() - CO2_concentrations = {'CO₂ concentrations': {'concentrations': [ + CO2_concentrations = {'CO₂': {'concentrations': [ np.array(CO2_model.concentration(float(time))).mean() for time in times ]}} diff --git a/caimira/apps/calculator/static/js/report.js b/caimira/apps/calculator/static/js/report.js index eedd8112..66358aba 100644 --- a/caimira/apps/calculator/static/js/report.js +++ b/caimira/apps/calculator/static/js/report.js @@ -548,6 +548,7 @@ function draw_plot(svg_id) { function draw_generic_concentration_plot( plot_svg_id, y_axis_label, + h_lines, ) { list_of_scenarios = (plot_svg_id === 'CO2_concentration_graph') ? CO2_concentrations : alternative_scenarios @@ -609,11 +610,13 @@ function draw_generic_concentration_plot( .text(y_axis_label); // Legend bounding box. - max_key_length = Math.max(...(Object.keys(data_for_scenarios).map(el => el.length))); + let h_lines_lenght = h_lines ? h_lines.length : 0 + let h_line_max_key = h_lines ? Math.max(...(h_lines.map(el => el.label.length))) : 0 + max_key_length = Math.max(Math.max(...(Object.keys(data_for_scenarios).map(el => el.length))), h_line_max_key); var legendBBox = vis.append('rect') - .attr('width', 10 * max_key_length ) - .attr('height', 30 * (Object.keys(data_for_scenarios).length)) + .attr('width', 9 * max_key_length ) + .attr('height', 25 * ((Object.keys(data_for_scenarios).length) + h_lines_lenght)) .attr('stroke', 'lightgrey') .attr('stroke-width', '2') .attr('rx', '5px') @@ -650,7 +653,25 @@ function draw_generic_concentration_plot( label_text[scenario_name] = vis.append('text') .text(scenario_name) .style('font-size', '15px'); + } + if (h_lines) { + var h_lines_draw = {}, h_line_label_icon = {}, h_line_label_text = {}; + h_lines.map((line) => { + h_lines_draw[line.label] = draw_area.append('svg:line') + .attr('stroke', line.color) + .attr('stroke-width', 2) + .attr('stroke-dasharray', line.style == 'dashed' ? (5, 5) : 0) + // Legend for each of the horizontal lines + + h_line_label_icon[line.label] = vis.append('line') + .style("stroke-dasharray", line.style == 'dashed' ? (5, 5) : 0) + .attr('stroke-width', '2') + .style("stroke", line.color); + h_line_label_text[line.label] = vis.append('text') + .text(line.label) + .style('font-size', '15px'); + }) } // Tooltip. @@ -707,6 +728,16 @@ function draw_generic_concentration_plot( .y(d => yRange(d.concentration)); draw_lines[scenario_name].transition().duration(1000).attr("d", lineFuncs[scenario_name](data)); } + + if (h_lines) { + h_lines.map((line) => { + h_lines_draw[line.label] + .attr("x1", xTimeRange(times[0])) + .attr("y1", yRange(line.y)) + .attr("x2", xTimeRange(times[times.length - 1])) + .attr("y2", yRange(line.y)); + }) + } } var graph_width; @@ -767,13 +798,34 @@ function draw_generic_concentration_plot( // Legend on the bottom. else { legend_x_start = 10; - label_icons[scenario_name].attr('x', legend_x_start) .attr('y', graph_height + size); label_text[scenario_name].attr('x', legend_x_start + space_between_text_icon) .attr('y', graph_height + size + text_height); } - + } + if (h_lines) { + h_lines.map((line, index) => { + size = 20 * (scenario_index + index + 2); // account for previous legend elements + if (div_width >= 900) { + h_line_label_icon[line.label].attr("x1", graph_width + legend_x_start) + .attr("x2", graph_width + legend_x_start + 20) + .attr("y1", margins.top + size) + .attr("y2", margins.top + size); + h_line_label_text[line.label].attr('x', graph_width + legend_x_start + space_between_text_icon) + .attr('y', margins.top + size + text_height); + } + // Legend on the bottom. + else { + legend_x_start = 10; + h_line_label_icon[line.label].attr("x1", legend_x_start) + .attr("x2", legend_x_start + 20) + .attr("y1", graph_height + size) + .attr("y2", graph_height + size); + h_line_label_text[line.label].attr('x', legend_x_start + space_between_text_icon) + .attr('y', graph_height + size + text_height); + } + }) } // Axis. diff --git a/caimira/apps/templates/base/calculator.report.html.j2 b/caimira/apps/templates/base/calculator.report.html.j2 index 5baab2a9..08a4596f 100644 --- a/caimira/apps/templates/base/calculator.report.html.j2 +++ b/caimira/apps/templates/base/calculator.report.html.j2 @@ -241,6 +241,18 @@ draw_generic_concentration_plot( "CO2_concentration_graph", "Mean concentration (ppm)", + h_lines = [ + {'label': 'Acceptable level', + 'y': 800, + 'color': 'forestgreen', + 'style': 'dashed' + }, + {'label': 'Insufficient level', + 'y': 1500, + 'color': 'firebrick', + 'style': 'dashed' + }, + ] );
  • From 34cdfa536c5c660b9bdf27f3482bb7885cddad29 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Fri, 17 Feb 2023 16:14:31 +0100 Subject: [PATCH 05/11] changed color scheme for build generic graph --- caimira/apps/calculator/static/js/report.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caimira/apps/calculator/static/js/report.js b/caimira/apps/calculator/static/js/report.js index 66358aba..13548cf4 100644 --- a/caimira/apps/calculator/static/js/report.js +++ b/caimira/apps/calculator/static/js/report.js @@ -555,7 +555,7 @@ function draw_generic_concentration_plot( // H:M format var time_format = d3.timeFormat('%H:%M'); // D3 array of ten categorical colors represented as RGB hexadecimal strings. - var colors = d3.schemeAccent; + var colors = d3.schemeCategory10; // Variable for the highest concentration for all the scenarios var highest_concentration = 0. From 800578f0319c3ca84a6912fb9de64b18be5935a2 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 4 Apr 2023 15:37:15 +0200 Subject: [PATCH 06/11] adjustments to d3 plot --- caimira/apps/calculator/report_generator.py | 2 +- caimira/apps/calculator/static/js/report.js | 144 ++++++++++---------- 2 files changed, 70 insertions(+), 76 deletions(-) diff --git a/caimira/apps/calculator/report_generator.py b/caimira/apps/calculator/report_generator.py index a086310a..097fec11 100644 --- a/caimira/apps/calculator/report_generator.py +++ b/caimira/apps/calculator/report_generator.py @@ -139,7 +139,7 @@ def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing for time in times ]}} - prob = np.array(model.infection_probability()).mean() + prob = np.array(model.infection_probability()) prob_dist_count, prob_dist_bins = np.histogram(prob/100, bins=100, density=True) prob_probabilistic_exposure = np.array(model.total_probability_rule()).mean() expected_new_cases = np.array(model.expected_new_cases()).mean() diff --git a/caimira/apps/calculator/static/js/report.js b/caimira/apps/calculator/static/js/report.js index 13548cf4..6de30cae 100644 --- a/caimira/apps/calculator/static/js/report.js +++ b/caimira/apps/calculator/static/js/report.js @@ -308,18 +308,17 @@ function draw_plot(svg_id) { var div_height = plot_div.clientHeight; graph_width = div_width; graph_height = div_height + var margins = { top: 30, right: 20, bottom: 50, left: 60 }; if (div_width >= 900) { // For screens with width > 900px legend can be on the graph's right side. - var margins = { top: 30, right: 20, bottom: 50, left: 60 }; div_width = 900; graph_width = div_width * (2/3); const svg_margins = {'margin-left': '0rem'}; Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val)); } else { - var margins = { top: 30, right: 20, bottom: 50, left: 40 }; div_width = div_width * 1.1 graph_width = div_width * .9; - graph_height = div_height * 0.65; // On mobile screen sizes we want the legend to be on the bottom of the graph. + graph_height = div_height * .75; // On mobile screen sizes we want the legend to be on the bottom of the graph. const svg_margins = {'margin-left': '-1rem'}; Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val)); }; @@ -353,23 +352,19 @@ function draw_plot(svg_id) { .attr('y', graph_height * 0.97); yAxisEl.attr('transform', 'translate(' + margins.left + ',0)'); - yAxisLabelEl.attr('x', (graph_height * 0.9 + margins.bottom) / 2) + yAxisLabelEl.attr('x', (graph_height + margins.bottom * .55) / 2) .attr('y', (graph_height + margins.left) * 0.9) .attr('transform', 'rotate(-90, 0,' + graph_height + ')'); yAxisCumEl.attr('transform', 'translate(' + (graph_width - margins.right) + ',0)').call(yCumulativeAxis); yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') - .attr('x', (graph_height + margins.bottom) / 2); + .attr('x', (graph_height + margins.bottom) / 2.1); if (plot_div.clientWidth >= 900) { - yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') - .attr('x', (graph_height + margins.bottom) / 2) - .attr('y', 1.71 * graph_width); + yAxisCumLabelEl.attr('y', graph_width * 1.7); } else { - yAxisCumLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') - .attr('x', (graph_height + margins.bottom * 0.55) / 2) - .attr('y', graph_width + 290); + yAxisCumLabelEl.attr('y', graph_width + 325); } const size = 20; @@ -415,8 +410,7 @@ function draw_plot(svg_id) { } // Legend on the bottom. else { - legend_x_start = 10; - + legend_x_start = margins.left + 10; legendLineIcon.attr('x', legend_x_start) .attr('y', graph_height + size); legendLineText.attr('x', legend_x_start + space_between_text_icon) @@ -449,7 +443,7 @@ function draw_plot(svg_id) { .attr('y', graph_height + (4 + sr_unique_activities.length) * size + text_height); } - legendBBox.attr('x', 1) + legendBBox.attr('x', margins.left) .attr('y', graph_height + 6); } @@ -745,23 +739,22 @@ function draw_generic_concentration_plot( function redraw() { // Define width and height according to the screen size. Always use an already defined - var div_width = document.getElementById('concentration_plot').clientWidth; + var window_width = document.getElementById('concentration_plot').clientWidth; + var div_width = window_width; var div_height = document.getElementById('concentration_plot').clientHeight; - graph_width = div_width; - graph_height = div_height - if (div_width >= 1200) { // For screens with width > 900px legend can be on the graph's right side. - var margins = { top: 30, right: 20, bottom: 50, left: 60 }; - div_width = 1200; - graph_width = 600; + graph_height = div_height; + var margins = { top: 30, right: 20, bottom: 50, left: 60 }; + if (window_width >= 900) { // For screens with width > 900px legend can be on the graph's right side. + div_width = 900; + graph_width = div_width * (2/3); const svg_margins = {'margin-left': '0rem'}; Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val)); } else { - var margins = { top: 30, right: 20, bottom: 50, left: 40 }; div_width = div_width * 1.1 - graph_width = div_width * .95; - graph_height = div_height * 0.65; // On mobile screen sizes we want the legend to be on the bottom of the graph. + graph_width = div_width * .9; + graph_height = div_height * .75; // On mobile screen sizes we want the legend to be on the bottom of the graph. const svg_margins = {'margin-left': '-1rem'}; Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val)); }; @@ -782,52 +775,6 @@ function draw_generic_concentration_plot( xTimeRange.range([margins.left, graph_width - margins.right]); yRange.range([graph_height - margins.bottom, margins.top]); - var legend_x_start = 25; - const space_between_text_icon = 30; - const text_height = 6; - for (const [scenario_name, data] of Object.entries(data_for_scenarios)) { - var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name) - // Legend on right side. - var size = 20 * (scenario_index + 1); - if (div_width >= 900) { - label_icons[scenario_name].attr('x', graph_width + legend_x_start) - .attr('y', margins.top + size); - label_text[scenario_name].attr('x', graph_width + legend_x_start + space_between_text_icon) - .attr('y', margins.top + size + text_height); - } - // Legend on the bottom. - else { - legend_x_start = 10; - label_icons[scenario_name].attr('x', legend_x_start) - .attr('y', graph_height + size); - label_text[scenario_name].attr('x', legend_x_start + space_between_text_icon) - .attr('y', graph_height + size + text_height); - } - } - if (h_lines) { - h_lines.map((line, index) => { - size = 20 * (scenario_index + index + 2); // account for previous legend elements - if (div_width >= 900) { - h_line_label_icon[line.label].attr("x1", graph_width + legend_x_start) - .attr("x2", graph_width + legend_x_start + 20) - .attr("y1", margins.top + size) - .attr("y2", margins.top + size); - h_line_label_text[line.label].attr('x', graph_width + legend_x_start + space_between_text_icon) - .attr('y', margins.top + size + text_height); - } - // Legend on the bottom. - else { - legend_x_start = 10; - h_line_label_icon[line.label].attr("x1", legend_x_start) - .attr("x2", legend_x_start + 20) - .attr("y1", graph_height + size) - .attr("y2", graph_height + size); - h_line_label_text[line.label].attr('x', legend_x_start + space_between_text_icon) - .attr('y', graph_height + size + text_height); - } - }) - } - // Axis. var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)); yAxis.scale(yRange); @@ -839,19 +786,66 @@ function draw_generic_concentration_plot( yAxisEl.attr('transform', 'translate(' + margins.left + ',0)') .call(yAxis); - yAxisLabelEl.attr('transform', 'rotate(-90, 0,' + graph_height + ')') - .attr('x', (graph_height * 0.9 + margins.bottom) / 2) - .attr('y', (graph_height + margins.left) * 0.90); + yAxisLabelEl.attr('x', (graph_height + margins.bottom * .55) / 2) + .attr('y', (graph_height + margins.left) * 0.9) + .attr('transform', 'rotate(-90, 0,' + graph_height + ')'); + + // Legend items + var legend_x_start = 25; + const space_between_text_icon = 30; + const text_height = 6; + for (const [scenario_name, data] of Object.entries(data_for_scenarios)) { + var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name) + // Legend on right side. + var size = 20 * (scenario_index + 1); + if (window_width >= 900) { + label_icons[scenario_name].attr('x', graph_width + legend_x_start) + .attr('y', margins.top + size); + label_text[scenario_name].attr('x', graph_width + legend_x_start + space_between_text_icon) + .attr('y', margins.top + size + text_height); + } + // Legend on the bottom. + else { + legend_x_start = margins.left + 10; + label_icons[scenario_name].attr('x', legend_x_start) + .attr('y', graph_height + size); + label_text[scenario_name].attr('x', legend_x_start + space_between_text_icon) + .attr('y', graph_height + size + text_height); + } + } + if (h_lines) { + h_lines.map((line, index) => { + size = 21 * (scenario_index + index + 2); // account for previous legend elements + if (window_width >= 900) { + h_line_label_icon[line.label].attr("x1", graph_width + legend_x_start) + .attr("x2", graph_width + legend_x_start + 20) + .attr("y1", margins.top + size) + .attr("y2", margins.top + size); + h_line_label_text[line.label].attr('x', graph_width + legend_x_start + space_between_text_icon) + .attr('y', margins.top + size + text_height); + } + // Legend on the bottom. + else { + legend_x_start = margins.left + 10; + h_line_label_icon[line.label].attr("x1", legend_x_start) + .attr("x2", legend_x_start + 20) + .attr("y1", graph_height + size) + .attr("y2", graph_height + size); + h_line_label_text[line.label].attr('x', legend_x_start + space_between_text_icon) + .attr('y', graph_height + size + text_height); + } + }) + } // Legend on right side. - if (div_width >= 900) { + if (window_width >= 900) { legendBBox.attr('x', graph_width * 1.02) .attr('y', margins.top * 1.15); } // Legend on the bottom. else { - legendBBox.attr('x', 1) + legendBBox.attr('x', margins.left) .attr('y', graph_height * 1.02) } From 4f6a967864ac20cb08006529f76b5ad46bf2dcc4 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 4 Apr 2023 16:58:18 +0200 Subject: [PATCH 07/11] added way to determine size of tooltip --- caimira/apps/calculator/static/js/report.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/caimira/apps/calculator/static/js/report.js b/caimira/apps/calculator/static/js/report.js index 6de30cae..5f97a781 100644 --- a/caimira/apps/calculator/static/js/report.js +++ b/caimira/apps/calculator/static/js/report.js @@ -209,6 +209,7 @@ function draw_plot(svg_id) { // Tooltip. var focus = {}, tooltip_rect = {}, tooltip_time = {}, tooltip_concentration = {}, toolBox = {}; for (const [concentration, data] of Object.entries(tooltip_data_for_graphs)) { + let data_length = String(Math.floor(Math.max(...data.map(el => el.concentration !== undefined ? el.concentration : 0)))).length; focus[concentration] = vis.append('svg:g') .style('display', 'none'); @@ -219,7 +220,7 @@ function draw_plot(svg_id) { tooltip_rect[concentration] = focus[concentration].append('rect') .attr('fill', 'white') .attr('stroke', '#000') - .attr('width', 85) + .attr('width', 65 + data_length * 8) .attr('height', 50) .attr('y', -22) .attr('rx', 4) @@ -495,9 +496,9 @@ function draw_plot(svg_id) { tooltip_concentration[scenario].attr('x', 18); } else { - tooltip_rect[scenario].attr('x', -90) - tooltip_time[scenario].attr('x', -82) - tooltip_concentration[scenario].attr('x', -82) + tooltip_rect[scenario].attr('x', -10 - tooltip_rect[scenario].attr('width')) + tooltip_time[scenario].attr('x', -2 - tooltip_rect[scenario].attr('width')) + tooltip_concentration[scenario].attr('x', -2 - tooltip_rect[scenario].attr('width')) } } // Concentration line @@ -671,7 +672,7 @@ function draw_generic_concentration_plot( // Tooltip. var focus = {}, tooltip_rect = {}, tooltip_time = {}, tooltip_concentration = {}, toolBox = {}; for (const [scenario_name, data] of Object.entries(data_for_scenarios)) { - + let data_length = String(Math.floor(Math.max(...data.map(el => el.concentration !== undefined ? el.concentration : 0)))).length; focus[scenario_name] = vis.append('svg:g') .style('display', 'none'); @@ -681,7 +682,7 @@ function draw_generic_concentration_plot( tooltip_rect[scenario_name] = focus[scenario_name].append('rect') .attr('fill', 'white') .attr('stroke', '#000') - .attr('width', 80) + .attr('width', 65 + data_length * 8) .attr('height', 50) .attr('y', -22) .attr('rx', 4) @@ -864,9 +865,9 @@ function draw_generic_concentration_plot( tooltip_concentration[scenario_name].attr('x', 18); } else { - tooltip_rect[scenario_name].attr('x', -90) - tooltip_time[scenario_name].attr('x', -82) - tooltip_concentration[scenario_name].attr('x', -82) + tooltip_rect[scenario_name].attr('x', -10 - tooltip_rect[scenario_name].attr('width')) + tooltip_time[scenario_name].attr('x', - tooltip_rect[scenario_name].attr('width')) + tooltip_concentration[scenario_name].attr('x', - tooltip_rect[scenario_name].attr('width')) } var x0 = xRange.invert(d3.pointer(event, this)[0]), i = bisecHour(data, x0, 1), From a261ac4bf0cba6fec16e9e40eac014e016c3df38 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 2 May 2023 15:14:07 +0100 Subject: [PATCH 08/11] added method to combine different present intervals --- caimira/apps/calculator/model_generator.py | 52 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index e64ffc35..26f14dff 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -305,6 +305,36 @@ class FormData: if total_percentage != 100: raise ValueError(f'The sum of all respiratory activities should be 100. Got {total_percentage}.') + def merge_intervals(self, exposed_interval, infected_interval): + stack = sorted(exposed_interval + infected_interval, reverse=True) + while len(stack) > 1: + first = stack.pop() + second = stack.pop() + if first == second: # identical intervals can be merged + yield first + elif first[1] <= second[0]: # no overlapping, yield first interval, put back second + yield first + stack.append(second) + elif first[0] == second[0]: # overlap at start, yield shorter, put back rest of longer + if first[1] > second[1]: + first, second = second, first + yield first + stack.append((first[1], second[1])) + elif first[1] < second[1]: # partial overlap, yield first two parts, put back rest + yield first[0], second[0] + yield second[0], first[1] + stack.append((first[1], second[1])) + else: # first[1] >= second[1] # total envelopment + yield first[0], second[0] + yield second + if first[1] != second[1]: + stack.append((second[1], first[1])) + + yield from stack # there may or may not be one element left over + + def merge_total_people(self): + return self.total_people + def initialize_room(self) -> models.Room: # Initializes room with volume either given directly or as product of area and height if self.volume_type == 'room_volume_explicit': @@ -360,13 +390,31 @@ class FormData: return self.build_mc_model().build_model(size=sample_size) def build_CO2_model(self, sample_size=DEFAULT_MC_SAMPLE_SIZE) -> models.CO2ConcentrationModel: + infected_interval = self.infected_present_interval() + exposed_interval = self.exposed_present_interval() + total_merged_presence = list(self.merge_intervals( + infected_interval.present_times, # type: ignore + exposed_interval.present_times, # type: ignore + )) + + if isinstance(self.infected_people, int): # TODO when exposed occupants feature is implemented + total_people = self.total_people + total_presence = models.SpecificInterval(tuple(total_merged_presence)) + else: + total_people = models.IntPiecewiseConstant( + transition_times=models.SpecificInterval(tuple(total_merged_presence)), + values=((self.total_people) * len(total_merged_presence)) + ) + total_presence = None + population = mc.Population( - number=self.total_people, - presence=self.infected_present_interval(), + number=total_people, + presence=total_presence, mask=models.Mask.types[self.mask_type], activity=activity_distributions[ACTIVITIES[ACTIVITY_TYPES.index(self.activity_type)]['activity']], host_immunity=0., ) + # Builds a CO2 concentration model based on model inputs return mc.CO2ConcentrationModel( room=self.initialize_room(), From f45ca3904d2fa167fba6222dcbc91041a58ae070 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 13 Jun 2023 15:58:27 +0200 Subject: [PATCH 09/11] added logic with IntPiecewiseConstant for dynamic CO2 --- caimira/apps/calculator/model_generator.py | 27 ++++++++-------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index 26f14dff..297f480e 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -390,26 +390,19 @@ class FormData: return self.build_mc_model().build_model(size=sample_size) def build_CO2_model(self, sample_size=DEFAULT_MC_SAMPLE_SIZE) -> models.CO2ConcentrationModel: - infected_interval = self.infected_present_interval() - exposed_interval = self.exposed_present_interval() - total_merged_presence = list(self.merge_intervals( - infected_interval.present_times, # type: ignore - exposed_interval.present_times, # type: ignore - )) + infected_population: models.InfectedPopulation = self.infected_population().build_model(sample_size) + exposed_population: models.Population = self.exposed_population().build_model(sample_size) - if isinstance(self.infected_people, int): # TODO when exposed occupants feature is implemented - total_people = self.total_people - total_presence = models.SpecificInterval(tuple(total_merged_presence)) - else: - total_people = models.IntPiecewiseConstant( - transition_times=models.SpecificInterval(tuple(total_merged_presence)), - values=((self.total_people) * len(total_merged_presence)) - ) - total_presence = None + state_change_times = set(infected_population.presence_interval().transition_times()) + state_change_times.update(exposed_population.presence_interval().transition_times()) + transition_times = sorted(state_change_times) + + total_people = [infected_population.people_present(stop) + exposed_population.people_present(stop) + for _, stop in zip(transition_times[:-1], transition_times[1:])] population = mc.Population( - number=total_people, - presence=total_presence, + number=models.IntPiecewiseConstant(transition_times=tuple(transition_times), values=tuple(total_people)), + presence=None, mask=models.Mask.types[self.mask_type], activity=activity_distributions[ACTIVITIES[ACTIVITY_TYPES.index(self.activity_type)]['activity']], host_immunity=0., From 706c35239290c5601d75f9f3719a7815a9696469 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Tue, 13 Jun 2023 16:00:30 +0200 Subject: [PATCH 10/11] removed unused methods --- caimira/apps/calculator/model_generator.py | 30 ---------------------- 1 file changed, 30 deletions(-) diff --git a/caimira/apps/calculator/model_generator.py b/caimira/apps/calculator/model_generator.py index 297f480e..981e1e67 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/apps/calculator/model_generator.py @@ -304,36 +304,6 @@ class FormData: if total_percentage != 100: raise ValueError(f'The sum of all respiratory activities should be 100. Got {total_percentage}.') - - def merge_intervals(self, exposed_interval, infected_interval): - stack = sorted(exposed_interval + infected_interval, reverse=True) - while len(stack) > 1: - first = stack.pop() - second = stack.pop() - if first == second: # identical intervals can be merged - yield first - elif first[1] <= second[0]: # no overlapping, yield first interval, put back second - yield first - stack.append(second) - elif first[0] == second[0]: # overlap at start, yield shorter, put back rest of longer - if first[1] > second[1]: - first, second = second, first - yield first - stack.append((first[1], second[1])) - elif first[1] < second[1]: # partial overlap, yield first two parts, put back rest - yield first[0], second[0] - yield second[0], first[1] - stack.append((first[1], second[1])) - else: # first[1] >= second[1] # total envelopment - yield first[0], second[0] - yield second - if first[1] != second[1]: - stack.append((second[1], first[1])) - - yield from stack # there may or may not be one element left over - - def merge_total_people(self): - return self.total_people def initialize_room(self) -> models.Room: # Initializes room with volume either given directly or as product of area and height From 96da4cc980c74d2b3d0d82b7fe8b3ed315a782d5 Mon Sep 17 00:00:00 2001 From: Luis Aleixo Date: Thu, 15 Jun 2023 14:53:52 +0200 Subject: [PATCH 11/11] version update --- caimira/apps/calculator/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/caimira/apps/calculator/__init__.py b/caimira/apps/calculator/__init__.py index 4c56cee1..b7e11c4c 100644 --- a/caimira/apps/calculator/__init__.py +++ b/caimira/apps/calculator/__init__.py @@ -38,7 +38,7 @@ from .user import AuthenticatedUser, AnonymousUser # calculator version. If the calculator needs to make breaking changes (e.g. change # form attributes) then it can also increase its MAJOR version without needing to # increase the overall CAiMIRA version (found at ``caimira.__version__``). -__version__ = "4.10" +__version__ = "4.11" LOG = logging.getLogger(__name__)