Merge branch 'master' into remotes/origin/feature/website

This commit is contained in:
Luis Aleixo 2021-10-08 14:05:13 +02:00
commit b9f8c93b47
16 changed files with 383 additions and 295 deletions

View file

@ -33,7 +33,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 CARA version (found at ``cara.__version__``).
__version__ = "3.0.0"
__version__ = "3.0.1"
class BaseRequestHandler(RequestHandler):

View file

@ -1,19 +1,15 @@
import concurrent.futures
import base64
import dataclasses
from datetime import datetime, timedelta
from datetime import datetime
import io
import json
import typing
import urllib
import zlib
import loky
import jinja2
import matplotlib
matplotlib.use('agg')
import matplotlib.pyplot as plt
import numpy as np
import qrcode
from cara import models
from ... import monte_carlo as mc
@ -125,7 +121,7 @@ def calculate_report_data(model: models.ExposureModel):
}
def generate_qr_code(base_url, calculator_prefix, form: FormData):
def generate_permalink(base_url, calculator_prefix, form: FormData):
form_dict = FormData.to_dict(form, strip_defaults=True)
# Generate the calculator URL arguments that would be needed to re-create this
@ -138,20 +134,9 @@ def generate_qr_code(base_url, calculator_prefix, form: FormData):
qr_url = f"{base_url}/_c/{compressed_args}"
url = f"{base_url}{calculator_prefix}?{args}"
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=10,
border=4,
)
qr.add_data(qr_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert('RGB')
return {
'image': img2base64(_img2bytes(img)),
'link': url,
'qr_url': qr_url,
'shortened': qr_url,
}
@ -162,50 +147,13 @@ def _img2bytes(figure):
return img_data
def _figure2bytes(figure):
# Draw the image
img_data = io.BytesIO()
figure.savefig(img_data, format='png', bbox_inches="tight", transparent=True)
return img_data
def img2base64(img_data) -> str:
plt.close()
img_data.seek(0)
pic_hash = base64.b64encode(img_data.read()).decode('ascii')
# A src suitable for a tag such as f'<img id="scenario_concentration_plot" src="{result}">.
return f'data:image/png;base64,{pic_hash}'
def plot(times, concentrations, model: models.ExposureModel):
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
datetimes = [datetime(1970, 1, 1) + timedelta(hours=time) for time in times]
ax.plot(datetimes, concentrations, lw=2, color='#1f77b4', label='Mean concentration')
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.set_xlabel('Time of day')
ax.set_ylabel('Mean concentration ($virions/m^{3}$)')
ax.set_title('Mean concentration of virions')
ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M"))
# Plot presence of exposed person
for i, (presence_start, presence_finish) in enumerate(model.exposed.presence.boundaries()):
plt.fill_between(
datetimes, concentrations, 0,
where=(np.array(times) > presence_start) & (np.array(times) < presence_finish),
color="#1f77b4", alpha=0.1,
label="Presence of exposed person(s)" if i == 0 else ""
)
# Place a legend outside of the axes itself.
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
ax.set_ylim(0)
return fig
def minutes_to_time(minutes: int) -> str:
minute_string = str(minutes % 60)
minute_string = "0" * (2 - len(minute_string)) + minute_string
@ -281,39 +229,7 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.Exp
return scenarios
def comparison_plot(scenarios: typing.Dict[str, dict], sample_times: typing.List[float]):
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
dash_styled_scenarios = [
'Base scenario with FFP2 masks',
'Base scenario with HEPA filter',
'Base scenario with HEPA and FFP2 masks',
]
sample_dts = [datetime(1970, 1, 1) + timedelta(hours=time) for time in sample_times]
for name, statistics in scenarios.items():
concentrations = statistics['concentrations']
if name in dash_styled_scenarios:
ax.plot(sample_dts, concentrations, label=name, linestyle='--')
else:
ax.plot(sample_dts, concentrations, label=name, linestyle='-', alpha=0.5)
# Place a legend outside of the axes itself.
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M"))
ax.set_xlabel('Time of day')
ax.set_ylabel('Mean concentration ($virions/m^{3}$)')
ax.set_title('Mean concentration of virions')
return fig
def scenario_statistics(mc_model: mc.ExposureModel, sample_times: typing.List[float]):
def scenario_statistics(mc_model: mc.ExposureModel, sample_times: np.ndarray):
model = mc_model.build_model(size=_DEFAULT_MC_SAMPLE_SIZE)
return {
'probability_of_infection': np.mean(model.infection_probability()),
@ -342,7 +258,6 @@ def comparison_report(
for (name, model), model_stats in zip(scenarios.items(), results):
statistics[name] = model_stats
return {
'plot': img2base64(_figure2bytes(comparison_plot(statistics, sample_times))),
'stats': statistics,
}
@ -385,7 +300,7 @@ class ReportGenerator:
context['alternative_scenarios'] = comparison_report(
alternative_scenarios, scenario_sample_times, executor_factory=executor_factory,
)
context['qr_code'] = generate_qr_code(base_url, self.calculator_prefix, form)
context['permalink'] = generate_permalink(base_url, self.calculator_prefix, form)
context['calculator_prefix'] = self.calculator_prefix
context['scale_warning'] = {
'level': 'yellow-2',
@ -405,6 +320,7 @@ class ReportGenerator:
env.filters['minutes_to_time'] = minutes_to_time
env.filters['float_format'] = "{0:.2f}".format
env.filters['int_format'] = "{:0.0f}".format
env.filters['JSONify'] = json.dumps
return env
def render(self, context: dict) -> str:

View file

@ -56,7 +56,7 @@ p.notes {
margin: 1%
}
#pdf-qr-code {
#pdf_qrcode_aref {
margin-right: 1%;
width: 100pt;
}
@ -102,11 +102,6 @@ p.notes {
border-radius: 5px;
}
.print-button {
margin-left: auto;
margin-right: 1%;
}
/* @media (width: 1200px) { */
@media print {
/* #body {
@ -120,7 +115,7 @@ p.notes {
#link_reproduce_results {
display: none!important;
}
#pdf-qr-code {
#pdf_qrcode_aref {
visibility: inherit!important;
}
.collapse {
@ -138,9 +133,6 @@ p.notes {
.icon_button {
display: none!important;
}
.print-button {
display: none!important;
}
.card {
page-break-inside: avoid;
}

View file

@ -1,29 +0,0 @@
function generate_pdf_version(qr_link) {
const pdf_version = this.document.getElementById("body");
// PDF styling
var opt = {
filename: 'myfile.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, width: 1200, windowWidth: 1200 },
enableLinks: false,
jsPDF: {
unit: 'pt',
format: 'letter',
orientation: 'portrait',
},
pagebreak: { mode: '', avoid: '.break-avoid' },
};
html2pdf().set(opt).from(pdf_version).toPdf().get('pdf').then(function(pdf) {
var totalPages = pdf.internal.getNumberOfPages();
pdf.setPage(1);
pdf.link(530, 25, 60, 60, { url: qr_link }); //Hyperlink to reproduce results
for (i = 1; i <= totalPages; i++) {
pdf.setPage(i);
pdf.setFontSize(10);
pdf.setTextColor(150);
pdf.text('Page ' + i + ' of ' + totalPages, (pdf.internal.pageSize.getWidth() / 2.25), (pdf.internal.pageSize.getHeight() - 10));
}
}).save();
};

View file

@ -25,56 +25,16 @@ function draw_concentration_plot(svg_id, times, concentrations, exposed_presence
yAxis = d3.axisLeft(yRange);
// Plot tittle.
vis.append('svg:foreignObject')
.attr("background-color", "transparent")
.attr('width', width)
.attr('height', margins.top)
.style('text-align', 'center')
.html('<b>Mean concentration of virions</b>');
plot_title(vis, width, margins.top, 'Mean concentration of virions');
// 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);
plot_scenario_data(vis, data, xTimeRange, yRange, '#1f77b4');
vis.append('svg:path')
.attr('d', lineFunc(data))
.attr('stroke', '#1f77b4')
.attr('stroke-width', 2)
.attr('fill', 'none');
// X axis.
plot_x_axis(vis, height, width, margins, xAxis, 'Time of day');
// 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 (virions/m³)');
// Y axis
plot_y_axis(vis, height, width, margins, yAxis, 'Mean concentration (virions/m³)')
// Area representing the presence of exposed person(s).
exposed_presence_intervals.forEach(b => {
@ -181,4 +141,178 @@ function draw_concentration_plot(svg_id, times, concentrations, exposed_presence
focus.select('#tooltip-time').text('x = ' + time_format(d.hour));
focus.select('#tooltip-concentration').text('y = ' + d.concentration.toFixed(2));
}
}
// Generate the alternative scenarios plot using d3 library.
// 'alternative_scenarios' is a dictionary with all the alternative scenarios
// 'times' is a list of times for all the scenarios
function draw_alternative_scenarios_plot(svg_id, width, height, alternative_scenarios, times) {
// 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;
// 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
highest_concentration = Math.max(highest_concentration, Math.max(...scenario_concentrations))
var data = []
times.map((time, index) => data.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': scenario_concentrations[index] }))
// Add data into lines dictionary
data_for_scenarios[scenario] = data
}
// We need one scenario to get the time range
var first_scenario = Object.values(data_for_scenarios)[0]
var vis = d3.select(svg_id),
width = width,
height = 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([first_scenario[0].hour, first_scenario[first_scenario.length - 1].hour]),
xTimeRange = d3.scaleLinear().range([margins.left, width - margins.right]).domain([times[0], times[times.length - 1]]),
yRange = d3.scaleLinear().range([height - margins.bottom, margins.top]).domain([0., highest_concentration]),
xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)),
yAxis = d3.axisLeft(yRange);
// Plot title.
plot_title(vis, width, margins.top, 'Mean concentration of virions');
// Line representing the mean concentration for each scenario.
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
// Line representing the mean concentration.
plot_scenario_data(vis, data, xTimeRange, yRange, colors[scenario_index])
// Legend for the plot elements - lines.
var size = 20 * (scenario_index + 1)
vis.append('rect')
.attr('x', width + 20)
.attr('y', margins.top + size)
.attr('width', 20)
.attr('height', 3)
.style('fill', colors[scenario_index]);
vis.append('text')
.attr('x', width + 3 * 20)
.attr('y', margins.top + size)
.text(scenario_name)
.style('font-size', '15px')
.attr('alignment-baseline', 'central');
}
// X axis.
plot_x_axis(vis, height, width, margins, xAxis, "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 (virions/m³)');
// Legend bounding box.
vis.append('rect')
.attr('width', 275)
.attr('height', 25 * (Object.keys(data_for_scenarios).length))
.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');
}
// Functions used to build the plots' components
function plot_title(vis, width, margin_top, title) {
vis.append('svg:foreignObject')
.attr('width', width)
.attr('height', margin_top)
.attr('fill', 'none')
.append('xhtml:div')
.style('text-align', 'center')
.html(title);
return vis;
}
function plot_x_axis(vis, height, width, margins, xAxis, label) {
// 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(label);
return vis;
}
function plot_y_axis(vis, height, width, margins, yAxis, label) {
// 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(label);
return vis;
}
function plot_scenario_data(vis, data, xTimeRange, yRange, line_color) {
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", line_color)
.attr('stroke-width', 2)
.attr('fill', 'none');
return vis;
}

View file

@ -23,9 +23,8 @@
<h2 class="text-component-title mb-0">CARA - CALCULATOR REPORT</h1>
<p class="mb-0"> Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}</p>
</div>
<button type="button" class="btn btn-outline-dark align-self-center" style="margin-right: -100pt" id="download-pdf" onclick="print()">Print Report</button>
{# To be replaced by "Generate PDF" #}
<img id="pdf-qr-code" class="align-self-center invisible" src="{{ qr_code.image }}"/>
<button type="button" class="btn btn-outline-dark align-self-center" id="print-button" style="margin-right: -100pt" onclick="print()">Print Report</button>
<a href="{{ permalink.link }}" style="float: left;" id="pdf_qrcode_aref" class="align-self-center invisible"><div id="pdf_qrcode"></div></a>
</div>
{% endblock report_header %}
@ -88,9 +87,9 @@
<p id="section1">* The results are based on the parameters and assumptions published in the CERN Open Report <a href="https://cds.cern.ch/record/2756083"> CERN-OPEN-2021-004</a>.</p>
<svg id="result_plot" width="900" height="400"></svg>
<script type="application/javascript">
var times = {{times}}
var concentrations = {{concentrations}}
var exposed_presence_intervals = {{exposed_presence_intervals}}
var times = {{ times | JSONify }}
var concentrations = {{ concentrations | JSONify }}
var exposed_presence_intervals = {{ exposed_presence_intervals | JSONify }}
draw_concentration_plot("#result_plot", times, concentrations, exposed_presence_intervals);
</script>
</p>
@ -108,8 +107,12 @@
<div class="collapse" id="collapseAlternativeScenarios">
<div class="card-body">
<div>
<img id="scenario_concentration_plot" src="{{ alternative_scenarios.plot }}" />
<svg id="alternative_scenario_plot" width="900" height="400"></svg>
<script type="application/javascript">
var alternative_scenarios = {{ alternative_scenarios.stats | JSONify }}
var times = {{ times | JSONify }}
draw_alternative_scenarios_plot("#alternative_scenario_plot", width=600, height=400, alternative_scenarios, times);
</script>
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<thead class="thead-light">
@ -158,10 +161,10 @@
<div class="collapse show" id="collapseQRcode">
<div class="card-body">
<div>
<a href="{{ qr_code.link }}" style="float: left;"><img style="width:250pt;" id="qr_code" src="{{ qr_code.image }}"/></a>
<a href="{{ permalink.link }}" style="float: left;"><div id="qrcode"></div></a>
<span style="float: left; min-height: 250pt; line-height: 250pt; vertical-align: middle; display: inline-block;">
<p style="display: inline-block; vertical-align: middle; line-height: normal;">
Click the QR code to regenerate the report and get a shareable link.<br>Alternatively, scan to regenerate the report.<br> Mobile-friendly app coming soon!
Click the QR code to regenerate the report and get a shareable link.<br>Alternatively, scan to regenerate the report.<br>
</p>
</span>
</div>
@ -429,11 +432,26 @@
</div>
{% endblock disclaimer_container %}
<script src="{{ calculator_prefix }}/static/js/pdf.js"></script>
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.9.2/html2pdf.bundle.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="text/javascript">
new QRCode(document.getElementById("qrcode"), {
text: "{{ permalink.shortened }}",
width: 330,
height: 330,
correctLevel : QRCode.CorrectLevel.L
});
new QRCode(document.getElementById("pdf_qrcode"), {
text: "{{ permalink.shortened }}",
width: 133,
height: 133,
correctLevel : QRCode.CorrectLevel.L
});
</script>
</body>
</html>

View file

@ -57,21 +57,16 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<div data-tooltip="Choose the SARS-CoV-2 Variant of Concern (VOC).">
<span class="tooltip_text">?</span>
</div>
<br>
<br>
</div><br>
<div class="form-group row">
<label class="col-sm-2 col-form-label">Variant:</label>
<div class="col-sm-8">
<select id="Variant" name="virus_type" class="form-control">
<option value="SARS_CoV_2">SARS-CoV-2 (nominal strain)</option>
<option value="SARS_CoV_2_B117">SARS-CoV-2 (Alpha VOC)</option>
<option value="SARS_CoV_2_P1">SARS-CoV-2 (Gamma VOC)</option>
<option value="SARS_CoV_2_B16172">SARS-CoV-2 (Delta VOC)</option>
</select>
</div>
<div class="row">
<label class="col-xl-3 col-lg-4 col-sm-3 col-form-label">Variant:</label>
<select id="Variant" name="virus_type" class="col-xl-5 col-lg-7 col-sm-7 col-7">
<option value="SARS_CoV_2">SARS-CoV-2 (nominal strain)</option>
<option value="SARS_CoV_2_B117">SARS-CoV-2 (Alpha VOC)</option>
<option value="SARS_CoV_2_P1">SARS-CoV-2 (Gamma VOC)</option>
<option selected value="SARS_CoV_2_B16172">SARS-CoV-2 (Delta VOC)</option>
</select>
</div>
<hr width="80%">
@ -456,13 +451,13 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<b>Quick Guide:</b><br>
This tool simulates the long range airborne spread SARS-CoV-2 virus in a finite volume and estimates the risk of COVID-19 infection. It is based on current scientific data and can be used to compare the effectiveness of different mitigation measures.<br>
<b>Virus data:</b> <br>
SARS-CoV-2 covers typical strains of the virus and three variants of concern (VOC):<br>
SARS-CoV-2 covers the original "wild type" strain of the virus and three variants of concern (VOC):<br>
<ul>
<li>Alpha (also known as B.1.1.7, first identified in UK, Dec 2020),</li>
<li>Gamma (also known as P.1, first identified in Brazil/Japan, Jan 2021).</li>
<li>Delta (also known as B.1.617.2, first identified in India, Oct 2020).</li>
</ul>
Choose variant according to local area prevalence, e.g. for <a href="https://www.covid19.admin.ch/fr/epidemiologic/virus-variants?detGeo=GE">Geneva</a>
Modify the default as necessary, according to local area prevalence e.g. for <a href="https://www.covid19.admin.ch/fr/epidemiologic/virus-variants?detGeo=GE">Geneva</a>
or <a href="https://www.santepubliquefrance.fr/dossiers/coronavirus-covid-19/covid-19-cartographie-des-variants-en-france-donnees-par-region-et-par-departement">Ain (France)</a>.<br>
<b>Ventilation data:</b> <br>
<ul>

View file

@ -620,7 +620,6 @@ _ExpirationBase.types = {
'Talking': Expiration((1., 1., 1.)),
'Shouting': Expiration((1., 5., 5.)),
'Singing': Expiration((1., 5., 5.)),
'Superspreading event': Expiration((np.inf, 0., 0.)),
}
@ -669,44 +668,21 @@ class Population:
@dataclass(frozen=True)
class InfectedPopulation(Population):
class _PopulationWithVirus(Population):
#: The virus with which the population is infected.
virus: Virus
#: The type of expiration that is being emitted whilst doing the activity.
expiration: _ExpirationBase
@method_cache
def emission_rate_when_present(self) -> _VectorisedFloat:
"""
The emission rate if the infected population is present.
Note that the rate is not currently time-dependent.
The emission rate if the infected population is present
(in virions / h). It should not be a function of time.
"""
# Emission Rate (virions / h)
# Note on units: exhalation rate is in m^3/h, aerosols in mL/cm^3
# and viral load in virus/mL -> 1e6 conversion factor
aerosols = self.expiration.aerosols(self.mask)
raise NotImplementedError("Subclass must implement")
ER = (self.virus.viral_load_in_sputum *
self.activity.exhalation_rate *
10 ** 6 *
aerosols)
# For superspreading event, where ejection_factor is infinite we fix the ER
# based on Miller et al. (2020).
if isinstance(aerosols, np.ndarray):
ER[np.isinf(aerosols)] = 970 * self.virus.infectious_dose
elif np.isinf(aerosols):
ER = 970 * self.virus.infectious_dose
return ER
def individual_emission_rate(self, time) -> _VectorisedFloat:
def emission_rate(self, time) -> _VectorisedFloat:
"""
The emission rate of a single individual in the population.
The emission rate of the population vs time.
"""
# Note: The original model avoids time dependence on the emission rate
# at the cost of implementing a piecewise (on time) concentration function.
@ -721,19 +697,50 @@ class InfectedPopulation(Population):
return self.emission_rate_when_present()
def emission_rate(self, time) -> _VectorisedFloat:
"""
The emission rate of the entire population.
@dataclass(frozen=True)
class EmittingPopulation(_PopulationWithVirus):
#: The emission rate of a single individual, in virions / h.
known_individual_emission_rate: float
@method_cache
def emission_rate_when_present(self) -> _VectorisedFloat:
"""
return self.individual_emission_rate(time) * self.number
The emission rate if the infected population is present.
"""
return self.known_individual_emission_rate * self.number
@dataclass(frozen=True)
class InfectedPopulation(_PopulationWithVirus):
#: The type of expiration that is being emitted whilst doing the activity.
expiration: _ExpirationBase
@method_cache
def emission_rate_when_present(self) -> _VectorisedFloat:
"""
The emission rate if the infected population is present.
Note that the rate is not currently time-dependent.
"""
# Emission Rate (virions / h)
# Note on units: exhalation rate is in m^3/h, aerosols in mL/cm^3
# and viral load in virus/mL -> 1e6 conversion factor
aerosols = self.expiration.aerosols(self.mask)
ER = (self.virus.viral_load_in_sputum *
self.activity.exhalation_rate *
10 ** 6 *
aerosols)
return ER * self.number
@dataclass(frozen=True)
class ConcentrationModel:
room: Room
ventilation: _VentilationBase
infected: InfectedPopulation
infected: _PopulationWithVirus
@property
def virus(self):
@ -753,16 +760,22 @@ class ConcentrationModel:
)
@method_cache
def _concentration_limit(self, time: float) -> _VectorisedFloat:
def _normed_concentration_limit(self, time: float) -> _VectorisedFloat:
"""
Provides a constant that represents the theoretical asymptotic
value reached by the concentration when time goes to infinity,
if all parameters were to stay time-independent.
This is normalized by the emission rate, the latter acting as a
multiplicative constant factor for the concentration model that
can be put back in front of the concentration after the time
dependence has been solved for.
"""
if not self.infected.person_present(time):
return 0.
V = self.room.volume
IVRR = self.infectious_virus_removal_rate(time)
return (self.infected.emission_rate(time)) / (IVRR * V)
return 1. / (IVRR * V)
@method_cache
def state_change_times(self) -> typing.List[float]:
@ -776,6 +789,14 @@ class ConcentrationModel:
state_change_times.update(self.ventilation.transition_times())
return sorted(state_change_times)
@method_cache
def _first_presence_time(self) -> float:
"""
First presence time. Before that, the concentration is zero.
"""
return self.infected.presence.boundaries()[0][0]
def last_state_change(self, time: float) -> float:
"""
Find the most recent/previous state change.
@ -807,15 +828,16 @@ class ConcentrationModel:
)
@method_cache
def _concentration_cached(self, time: float) -> _VectorisedFloat:
# A cached version of the concentration method. Use this method if you
# expect that there may be multiple concentration calculations for the
# same time (e.g. at state change times).
return self.concentration(time)
def _normed_concentration_cached(self, time: float) -> _VectorisedFloat:
# A cached version of the _normed_concentration method. Use this
# method if you expect that there may be multiple concentration
# calculations for the same time (e.g. at state change times).
return self._normed_concentration(time)
def concentration(self, time: float) -> _VectorisedFloat:
def _normed_concentration(self, time: float) -> _VectorisedFloat:
"""
Virus exposure concentration, as a function of time.
Virus exposure concentration, as a function of time, and
normalized by the emission rate.
The formulas used here assume that all parameters (ventilation,
emission rate) are constant between two state changes - only
the value of these parameters at the next state change, are used.
@ -823,27 +845,42 @@ class ConcentrationModel:
Note that time is not vectorised. You can only pass a single float
to this method.
"""
if time == 0:
# The model always starts at t=0, but we avoid running concentration calculations
# before the first presence as an optimisation.
if time <= self._first_presence_time():
return 0.0
next_state_change_time = self._next_state_change(time)
IVRR = self.infectious_virus_removal_rate(next_state_change_time)
concentration_limit = self._concentration_limit(next_state_change_time)
conc_limit = self._normed_concentration_limit(next_state_change_time)
t_last_state_change = self.last_state_change(time)
concentration_at_last_state_change = self._concentration_cached(t_last_state_change)
conc_at_last_state_change = self._normed_concentration_cached(t_last_state_change)
delta_time = time - t_last_state_change
fac = np.exp(-IVRR * delta_time)
return concentration_limit * (1 - fac) + concentration_at_last_state_change * fac
return conc_limit * (1 - fac) + conc_at_last_state_change * fac
def concentration(self, time: float) -> _VectorisedFloat:
"""
Virus exposure concentration, as a function of time.
Note that time is not vectorised. You can only pass a single float
to this method.
"""
return (self._normed_concentration(time) *
self.infected.emission_rate_when_present())
@method_cache
def integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat:
def normed_integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat:
"""
Get the integrated concentration dose between the times start and stop.
Get the integrated concentration of viruses in the air between the times start and stop,
normalized by the emission rate.
"""
if stop <= self._first_presence_time():
return 0.0
state_change_times = self.state_change_times()
req_start, req_stop = start, stop
total_concentration = 0.
total_normed_concentration = 0.
for interval_start, interval_stop in zip(state_change_times[:-1], state_change_times[1:]):
if req_start > interval_stop or req_stop < interval_start:
continue
@ -851,17 +888,24 @@ class ConcentrationModel:
start = max([interval_start, req_start])
stop = min([interval_stop, req_stop])
conc_start = self._concentration_cached(start)
conc_start = self._normed_concentration_cached(start)
next_conc_state = self._next_state_change(stop)
conc_limit = self._concentration_limit(next_conc_state)
conc_limit = self._normed_concentration_limit(next_conc_state)
IVRR = self.infectious_virus_removal_rate(next_conc_state)
delta_time = stop - start
total_concentration += (
total_normed_concentration += (
conc_limit * delta_time +
(conc_limit - conc_start) * (np.exp(-IVRR*delta_time)-1) / IVRR
)
return total_concentration
return total_normed_concentration
def integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat:
"""
Get the integrated concentration of viruses in the air between the times start and stop.
"""
return (self.normed_integrated_concentration(start, stop) *
self.infected.emission_rate_when_present())
@dataclass(frozen=True)
@ -878,14 +922,22 @@ class ExposureModel:
#: The fraction of viruses actually deposited in the respiratory tract
fraction_deposited: _VectorisedFloat = 0.6
def exposure(self) -> _VectorisedFloat:
"""The number of virus per meter^3."""
exposure = 0.0
def _normed_exposure(self) -> _VectorisedFloat:
"""
The number of virus per meter^3, normalized by the emission rate
of the infected population.
"""
normed_exposure = 0.0
for start, stop in self.exposed.presence.boundaries():
exposure += self.concentration_model.integrated_concentration(start, stop)
normed_exposure += self.concentration_model.normed_integrated_concentration(start, stop)
return exposure * self.repeats
return normed_exposure * self.repeats
def exposure(self) -> _VectorisedFloat:
"""The number of virus per meter^3."""
return (self._normed_exposure() *
self.concentration_model.infected.emission_rate_when_present())
def infection_probability(self) -> _VectorisedFloat:
exposure = self.exposure()

View file

@ -4,7 +4,7 @@ import pytest
import tornado.testing
import cara.apps.calculator
from cara.apps.calculator.report_generator import generate_qr_code
from cara.apps.calculator.report_generator import generate_permalink
_TIMEOUT = 20.
@ -97,26 +97,26 @@ class TestOpenApp(tornado.testing.AsyncHTTPTestCase):
assert response.code == 404
async def test_qrcode_urls(http_server_client, baseline_form):
async def test_permalink_urls(http_server_client, baseline_form):
base_url = 'proto://hostname/prefix'
qr_data = generate_qr_code(base_url, "/calculator", baseline_form)
permalink_data = generate_permalink(base_url, "/calculator", baseline_form)
expected = f'{base_url}/calculator?exposed_coffee_break_option={baseline_form.exposed_coffee_break_option}&'
assert qr_data['link'].startswith(expected)
assert permalink_data['link'].startswith(expected)
# We should get a 200 for the link.
response = await http_server_client.fetch(qr_data['link'].replace(base_url, ''))
response = await http_server_client.fetch(permalink_data['link'].replace(base_url, ''))
assert response.code == 200
# And a 302 for the QR url itself. The redirected URL should be the same as
# in the link.
assert qr_data['qr_url'].startswith(base_url)
assert permalink_data['shortened'].startswith(base_url)
response = await http_server_client.fetch(
qr_data['qr_url'].replace(base_url, ''),
permalink_data['shortened'].replace(base_url, ''),
max_redirects=0,
raise_error=False,
)
assert response.code == 302
assert response.headers['Location'] == qr_data['link'].replace(base_url, '')
assert response.headers['Location'] == permalink_data['link'].replace(base_url, '')
async def test_invalid_compressed_url(http_server_client, baseline_form):

View file

@ -13,13 +13,15 @@ def baseline_model():
active=models.SpecificInterval(((0., 24.), )),
air_exch=30.,
),
infected=models.InfectedPopulation(
infected=models.EmittingPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((0., 4.), (5., 8.))),
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Light activity'],
expiration=models.Expiration.types['Superspreading event'],
known_individual_emission_rate=970 * 50,
# superspreading event, where ejection factor is fixed based
# on Miller et al. (2020) - 50 represents the infectious dose.
),
)
return model

View file

@ -121,6 +121,10 @@ def test_next_state_change_time_out_of_range(simple_conc_model: models.Concentra
simple_conc_model._next_state_change(3.1)
def test_first_presence_time(simple_conc_model):
assert simple_conc_model._first_presence_time() == 0.5
def test_integrated_concentration(simple_conc_model):
c1 = simple_conc_model.integrated_concentration(0, 2)
c2 = simple_conc_model.integrated_concentration(0, 1)

View file

@ -11,20 +11,20 @@ from cara.dataclass_utils import replace
@dataclass(frozen=True)
class KnownConcentrations(models.ConcentrationModel):
class KnownNormedconcentration(models.ConcentrationModel):
"""
A ConcentrationModel which is based on pre-known exposure concentrations and
which therefore doesn't need other components. Useful for testing.
"""
concentration_function: typing.Callable
normed_concentration_function: typing.Callable
def infectious_virus_removal_rate(self, time: float) -> models._VectorisedFloat:
# very large decay constant -> same as constant concentration
return 1.e50
def _concentration_limit(self, time: float) -> models._VectorisedFloat:
return self.concentration_function(time)
def _normed_concentration_limit(self, time: float) -> models._VectorisedFloat:
return self.normed_concentration_function(time)
def state_change_times(self):
return [0., 24.]
@ -32,8 +32,8 @@ class KnownConcentrations(models.ConcentrationModel):
def _next_state_change(self, time: float):
return 24.
def concentration(self, time: float) -> models._VectorisedFloat: # noqa
return self.concentration_function(time)
def _normed_concentration(self, time: float) -> models._VectorisedFloat: # noqa
return self.normed_concentration_function(time)
halftime = models.PeriodicInterval(120, 60)
@ -67,7 +67,9 @@ def known_concentrations(func):
virus=models.Virus.types['SARS_CoV_2_B117'],
expiration=models.Expiration.types['Talking']
)
return KnownConcentrations(dummy_room, dummy_ventilation, dummy_infected_population, func)
normed_func = lambda x: func(x) / dummy_infected_population.emission_rate_when_present()
return KnownNormedconcentration(dummy_room, dummy_ventilation,
dummy_infected_population, normed_func)
@pytest.mark.parametrize(
@ -142,13 +144,15 @@ def conc_model():
return models.ConcentrationModel(
models.Room(25),
models.AirChange(always, 5),
models.InfectedPopulation(
models.EmittingPopulation(
number=1,
presence=interesting_times,
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Seated'],
virus=models.Virus.types['SARS_CoV_2'],
expiration=models.Expiration.types['Superspreading event'],
known_individual_emission_rate=970 * 50,
# superspreading event, where ejection factor is fixed based
# on Miller et al. (2020) - 50 represents the infectious dose.
)
)

View file

@ -66,13 +66,15 @@ def build_model(interval_duration):
active=models.PeriodicInterval(period=120, duration=interval_duration),
q_air_mech=500.,
),
infected=models.InfectedPopulation(
infected=models.EmittingPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((0., 4.), (5., 8.))),
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Light activity'],
expiration=models.Expiration.types['Superspreading event'],
known_individual_emission_rate=970 * 50,
# superspreading event, where ejection factor is fixed based
# on Miller et al. (2020) - 50 represents the infectious dose.
),
)
return model
@ -226,13 +228,13 @@ def build_hourly_dependent_model(
outside_temp=outside_temp,
window_height=1.6, opening_length=0.6,
),
infected=models.InfectedPopulation(
infected=models.EmittingPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(intervals_presence_infected),
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Light activity'],
expiration=models.Expiration.types['Superspreading event'],
known_individual_emission_rate=970 * 50,
),
)
return model
@ -247,13 +249,13 @@ def build_constant_temp_model(outside_temp, intervals_open=((7.5, 8.5),)):
outside_temp=models.PiecewiseConstant((0., 24.), (outside_temp,)),
window_height=1.6, opening_length=0.6,
),
infected=models.InfectedPopulation(
infected=models.EmittingPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((0., 4.), (5., 7.5))),
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Light activity'],
expiration=models.Expiration.types['Superspreading event'],
known_individual_emission_rate=970 * 50,
),
)
return model
@ -275,13 +277,13 @@ def build_hourly_dependent_model_multipleventilation(month, intervals_open=((7.5
model = models.ConcentrationModel(
room=models.Room(volume=75),
ventilation=vent,
infected=models.InfectedPopulation(
infected=models.EmittingPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((0., 4.), (5., 7.5))),
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Light activity'],
expiration=models.Expiration.types['Superspreading event'],
known_individual_emission_rate=970 * 50,
),
)
return model

View file

@ -238,7 +238,7 @@ def skagit_chorale_mc():
["shared_office_mc", 10.7, 0.32, 57.24, 654],
["classroom_mc", 36.1, 6.85, 780.0, 28464],
["ski_cabin_mc", 16.3, 0.49, 35.94, 7404],
["gym_mc", 2.25, 0.63, 0.7842, 984],
["gym_mc", 2.25, 0.63, 0.7842, 1968],
["waiting_room_mc", 9.72, 1.36, 34.26, 3534],
["skagit_chorale_mc",29.9, 17.9, 190.0, 141400],
]

View file

@ -62,7 +62,6 @@ pyparsing==2.4.7
pyrsistent==0.18.0
python-dateutil==2.8.2
pyzmq==22.1.0
qrcode==7.2
requests==2.26.0
requests-unixsocket==0.2.0
scikit-learn==0.24.2

View file

@ -20,7 +20,7 @@ REQUIREMENTS: dict = {
'core': [
'dataclasses; python_version < "3.7"',
'ipykernel',
'ipympl',
'ipympl != 0.8.0',
'ipywidgets',
'Jinja2',
'loky',
@ -30,7 +30,6 @@ REQUIREMENTS: dict = {
'numpy',
'psutil',
'python-dateutil',
'qrcode[pil]',
'scipy',
'sklearn',
'timezonefinder',