diff --git a/README.md b/README.md index 1aacec04..1be26e55 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Each event modelled is unique, and the results generated therein are only as acc ## Authors CARA was developed by following members of CERN - European Council for Nuclear Research (visit https://home.cern/): -Andre Henriques1, Marco Andreini1, Gabriella Azzopardi2, James Devine3, Philip Elson4, Nicolas Mounet2, Markus Kongstein Rognlien2,6, Nicola Tarocco5 +Andre Henriques1, Luis Aleixo1, Marco Andreini1, Gabriella Azzopardi2, James Devine3, Philip Elson4, Nicolas Mounet2, Markus Kongstein Rognlien2,6, Nicola Tarocco5 1HSE Unit, Occupational Health & Safety Group, CERN
2Beams Department, Accelerators and Beam Physics Group, CERN
diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index 28f99b85..bf91281c 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -44,13 +44,13 @@ def calculate_report_data(model: models.ExposureModel): return { "times": list(times), + "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], "concentrations": concentrations, "highest_const": highest_const, "prob_inf": prob, "emission_rate": er, "exposed_occupants": exposed_occupants, "expected_new_cases": expected_new_cases, - "scenario_plot_src": img2base64(_figure2bytes(plot(times, concentrations, model))), } @@ -133,7 +133,7 @@ def plot(times, concentrations, model: models.ExposureModel): ax.set_ylim(0) return fig - + def minutes_to_time(minutes: int) -> str: minute_string = str(minutes % 60) diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js new file mode 100644 index 00000000..99499e8d --- /dev/null +++ b/cara/apps/calculator/static/js/report.js @@ -0,0 +1,190 @@ +/* Generate the concentration plot using d3 library. */ +function draw_concentration_plot(svg_id, times, concentrations, exposed_presence_intervals) { + var visBoundingBox = d3.select(svg_id) + .node() + .getBoundingClientRect(); + + var time_format = d3.timeFormat('%H:%M'); + + var data = [] + times.map((time, index) => data.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': concentrations[index] })) + + var vis = d3.select(svg_id), + width = visBoundingBox.width - 300, + height = visBoundingBox.height, + margins = { top: 30, right: 20, bottom: 50, left: 50 }, + + // H:M time format for x axis. + xRange = d3.scaleTime().range([margins.left, width - margins.right]).domain([data[0].hour, data[data.length - 1].hour]), + bisecHour = d3.bisector((d) => { return d.hour; }).left, + + yRange = d3.scaleLinear().range([height - margins.bottom, margins.top]).domain([data[0].concentration, data[data.length - 1].concentration]), + xTimeRange = d3.scaleLinear().range([margins.left, width - margins.right]).domain([data[0].time, data[data.length - 1].time]), + + xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)), + yAxis = d3.axisLeft(yRange); + + // Plot tittle. + vis.append('svg:foreignObject') + .attr('width', width) + .attr('height', margins.top) + .append('xhtml:body') + .style('text-align', 'center') + .html('Mean concentration of infectious quanta'); + + // Line representing the mean concentration. + var lineFunc = d3.line() + .defined(d => !isNaN(d.concentration)) + .x(d => xTimeRange(d.time)) + .y(d => yRange(d.concentration)) + .curve(d3.curveBasis); + + vis.append('svg:path') + .attr('d', lineFunc(data)) + .attr('stroke', '#1f77b4') + .attr('stroke-width', 2) + .attr('fill', 'none'); + + // X axis declaration. + vis.append('svg:g') + .attr('class', 'x axis') + .attr('transform', 'translate(0,' + (height - margins.bottom) + ')') + .call(xAxis); + + // X axis label. + vis.append('text') + .attr('class', 'x label') + .attr('fill', 'black') + .attr('text-anchor', 'middle') + .attr('x', (width + margins.right) / 2) + .attr('y', height * 0.97) + .text('Time of day') + + // Y axis declaration. + vis.append('svg:g') + .attr('class', 'y axis') + .attr('transform', 'translate(' + margins.left + ',0)') + .call(yAxis); + + // Y axis label. + vis.append('svg:text') + .attr('class', 'y label') + .attr('fill', 'black') + .attr('transform', 'rotate(-90, 0,' + height + ')') + .attr('text-anchor', 'middle') + .attr('x', (height + margins.bottom) / 2) + .attr('y', (height + margins.left) * 0.92) + .text('Mean concentration (q/m^3)'); + + // Area representing the presence of exposed person(s). + exposed_presence_intervals.forEach(b => { + vis.append('svg:path') + .attr('d', lineFunc(data.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))) + .attr('fill', 'none'); + + var curveFunc = d3.area() + .x(d => xTimeRange(d.time)) + .y0(height - margins.bottom) + .y1(d => yRange(d.concentration)); + + vis.append('svg:path') + .attr('d', curveFunc(data.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))) + .attr('fill', '#1f77b4') + .attr('fill-opacity', '0.1'); + }) + + // Legend for the plot elements - line and area. + var size = 20 + vis.append('rect') + .attr('x', width + size) + .attr('y', margins.top + size) + .attr('width', 20) + .attr('height', 3) + .style('fill', '#1f77b4'); + + vis.append('rect') + .attr('x', width + size) + .attr('y', 3 * size) + .attr('width', 20) + .attr('height', 20) + .attr('fill', '#1f77b4') + .attr('fill-opacity', '0.1'); + + vis.append('text') + .attr('x', width + 3 * size) + .attr('y', margins.top + size) + .text('Mean concentration') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + vis.append('text') + .attr('x', width + 3 * size) + .attr('y', margins.top + 2 * size) + .text('Presence of exposed person(s)') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + // Legend bounding box. + vis.append('rect') + .attr('width', 275) + .attr('height', 50) + .attr('x', width * 1.005) + .attr('y', margins.top + 5) + .attr('stroke', 'lightgrey') + .attr('stroke-width', '2') + .attr('rx', '5px') + .attr('ry', '5px') + .attr('stroke-linejoin', 'round') + .attr('fill', 'none'); + + // Tooltip. + var focus = vis.append('svg:g') + .style('display', 'none'); + + focus.append('circle') + .attr('r', 3); + + focus.append('rect') + .attr('fill', 'white') + .attr('stroke', '#000') + .attr('width', 80) + .attr('height', 50) + .attr('x', 10) + .attr('y', -22) + .attr('rx', 4) + .attr('ry', 4); + + focus.append('text') + .attr('id', 'tooltip-time') + .attr('x', 18) + .attr('y', -2); + + focus.append('text') + .attr('id', 'tooltip-concentration') + .attr('x', 18) + .attr('y', 18); + + vis.append('rect') + .attr('fill', 'none') + .attr('pointer-events', 'all') + .attr('width', width - margins.right) + .attr('height', height) + .on('mouseover', () => { focus.style('display', null); }) + .on('mouseout', () => { focus.style('display', 'none'); }) + .on('mousemove', mousemove); + + function mousemove() { + var x0 = xRange.invert(d3.pointer(event, this)[0]), + i = bisecHour(data, x0, 1), + d0 = data[i - 1], + d1 = data[i], + d = x0 - d0.hour > d1.hour - x0 ? d1 : d0; + focus.attr('transform', 'translate(' + xRange(d.hour) + ',' + yRange(d.concentration) + ')'); + focus.select('#tooltip-time').text('x = ' + time_format(d.hour)); + focus.select('#tooltip-concentration').text('y = ' + d.concentration.toFixed(2)); + } +} \ No newline at end of file diff --git a/cara/apps/calculator/templates/base/calculator.report.html.j2 b/cara/apps/calculator/templates/base/calculator.report.html.j2 index 61cf107c..d45a089a 100644 --- a/cara/apps/calculator/templates/base/calculator.report.html.j2 +++ b/cara/apps/calculator/templates/base/calculator.report.html.j2 @@ -8,6 +8,10 @@ + + + + @@ -216,7 +220,13 @@

[*] The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004

- + +

Alternative scenarios:

diff --git a/cara/apps/templates/index.html.j2 b/cara/apps/templates/index.html.j2 index dddba6e4..c774420c 100644 --- a/cara/apps/templates/index.html.j2 +++ b/cara/apps/templates/index.html.j2 @@ -43,7 +43,7 @@

Authors

-

Andre Henriques1, Marco Andreini1, Gabriella Azzopardi2, James Devine3, Philip Elson4, Nicolas Mounet2, Markus Kongstein Rognlien2,6, Nicola Tarocco5


+

Andre Henriques1, Luis Aleixo1, Marco Andreini1, Gabriella Azzopardi2, James Devine3, Philip Elson4, Nicolas Mounet2, Markus Kongstein Rognlien2,6, Nicola Tarocco5


1HSE Unit, Occupational Health & Safety Group, CERN
2Beams Department, Accelerators and Beam Physics Group, CERN
diff --git a/cara/models.py b/cara/models.py index 6e509207..240bac76 100644 --- a/cara/models.py +++ b/cara/models.py @@ -420,7 +420,7 @@ class Virus: #: RNA copies / mL viral_load_in_sputum: _VectorisedFloat - #: RNA-copies + #: Dose to initiate infection, in RNA copies infectious_dose: _VectorisedFloat #: Pre-populated examples of Viruses. diff --git a/cara/tests/models/test_exposure_model.py b/cara/tests/models/test_exposure_model.py index 4fe18af8..d8d80b40 100644 --- a/cara/tests/models/test_exposure_model.py +++ b/cara/tests/models/test_exposure_model.py @@ -50,12 +50,12 @@ populations = [ # A population with some array component for inhalation_rate. models.Population( 10, halftime, models.Mask.types['Type I'], - models.Activity(np.array([0.51,0.57]), 0.57), + models.Activity(np.array([0.51, 0.57]), 0.57), ), ] -dummyRoom = models.Room(50, 0.5) -dummyVentilation = models._VentilationBase() -dummyInfPopulation = models.InfectedPopulation( +dummy_room = models.Room(50, 0.5) +dummy_ventilation = models._VentilationBase() +dummy_infected_population = models.InfectedPopulation( number=1, presence=halftime, mask=models.Mask.types['Type I'], @@ -64,30 +64,31 @@ dummyInfPopulation = models.InfectedPopulation( expiration=models.Expiration.types['Talking'] ) + def known_concentrations(func): - return KnownConcentrations(dummyRoom, dummyVentilation, dummyInfPopulation, func) + return KnownConcentrations(dummy_room, dummy_ventilation, dummy_infected_population, func) @pytest.mark.parametrize( - "population, cm, f_dep, expected_exposure, expected_probability",[ - [populations[1], known_concentrations(lambda t: 36), 1., - np.array([432, 432]), np.array([99.6803184113, 99.5181053773])], + "population, cm, f_dep, expected_exposure, expected_probability", [ + [populations[1], known_concentrations(lambda t: 36), 1., + np.array([432, 432]), np.array([99.6803184113, 99.5181053773])], - [populations[2], known_concentrations(lambda t: 36), 1., - np.array([432, 432]), np.array([97.4574432074, 98.3493482895])], + [populations[2], known_concentrations(lambda t: 36), 1., + np.array([432, 432]), np.array([97.4574432074, 98.3493482895])], - [populations[0], known_concentrations(lambda t: np.array([36, 72])), 1., - np.array([432, 864]), np.array([98.3493482895, 99.9727534893])], + [populations[0], known_concentrations(lambda t: np.array([36, 72])), 1., + np.array([432, 864]), np.array([98.3493482895, 99.9727534893])], - [populations[1], known_concentrations(lambda t: np.array([36, 72])), 1., - np.array([432, 864]), np.array([99.6803184113, 99.9976777757])], + [populations[1], known_concentrations(lambda t: np.array([36, 72])), 1., + np.array([432, 864]), np.array([99.6803184113, 99.9976777757])], - [populations[0], known_concentrations(lambda t: 72), np.array([0.5, 1.]), - 864, np.array([98.3493482895, 99.9727534893])], + [populations[0], known_concentrations(lambda t: 72), np.array([0.5, 1.]), + 864, np.array([98.3493482895, 99.9727534893])], ]) def test_exposure_model_ndarray(population, cm, f_dep, expected_exposure, expected_probability): - model = ExposureModel(cm, population, fraction_deposited = f_dep) + model = ExposureModel(cm, population, fraction_deposited=f_dep) np.testing.assert_almost_equal( model.exposure(), expected_exposure ) @@ -103,7 +104,8 @@ def test_exposure_model_ndarray(population, cm, f_dep, @pytest.mark.parametrize("population", populations) def test_exposure_model_ndarray_and_float_mix(population): - cm = known_concentrations(lambda t: 0 if np.floor(t) % 2 else np.array([1.2, 1.2])) + cm = known_concentrations(lambda t: 0 if np.floor(t) % + 2 else np.array([1.2, 1.2])) model = ExposureModel(cm, population) expected_exposure = np.array([14.4, 14.4]) @@ -132,7 +134,8 @@ def test_exposure_model_compare_scalar_vector(population): @pytest.fixture def conc_model(): - interesting_times = models.SpecificInterval(([0, 1], [1.01, 1.02], [12, 24])) + interesting_times = models.SpecificInterval( + ([0, 1], [1.01, 1.02], [12, 24])) always = models.SpecificInterval(((0, 24),)) return models.ConcentrationModel( models.Room(25), @@ -149,14 +152,16 @@ def conc_model(): # expected exposure were computed with a trapezoidal integration, using # a mesh of 10'000 pts per exposed presence interval. + + @pytest.mark.parametrize("exposed_time_interval, expected_exposure", [ - [(0, 1), 266.67176], - [(1, 1.01), 3.0879539], - [(1.01, 1.02), 3.00082435], - [(12, 12.01), 0.095063235], - [(12, 24), 3775.65025], - [(0, 24), 4097.8494], - ] + [(0, 1), 266.67176], + [(1, 1.01), 3.0879539], + [(1.01, 1.02), 3.00082435], + [(12, 12.01), 0.095063235], + [(12, 24), 3775.65025], + [(0, 24), 4097.8494], +] ) def test_exposure_model_integral_accuracy(exposed_time_interval, expected_exposure, conc_model): @@ -168,19 +173,21 @@ def test_exposure_model_integral_accuracy(exposed_time_interval, model = ExposureModel(conc_model, population, fraction_deposited=1.) np.testing.assert_allclose(model.exposure(), expected_exposure) + def test_infectious_dose_vectorisation(): - infPopulation = models.InfectedPopulation( + infected_population = models.InfectedPopulation( number=1, presence=halftime, mask=models.Mask.types['Type I'], activity=models.Activity.types['Standing'], - virus = models.SARSCoV2( + virus=models.SARSCoV2( viral_load_in_sputum=1e9, infectious_dose=np.array([50, 20, 30]), ), expiration=models.Expiration.types['Talking'] ) - cm = KnownConcentrations(dummyRoom, dummyVentilation, infPopulation, lambda t: 1.2) + cm = KnownConcentrations( + dummy_room, dummy_ventilation, infected_population, lambda t: 1.2) presence_interval = models.SpecificInterval(((0, 1),)) population = models.Population( diff --git a/setup.cfg b/setup.cfg index 21bbf8d4..9b91c884 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,3 +25,6 @@ ignore_missing_imports = True [mypy-scipy.*] ignore_missing_imports = True + +[mypy-tqdm.*] +ignore_missing_imports = True