Merge branch 'master' into feature/virions_plot

This commit is contained in:
Luis Aleixo 2021-09-02 10:36:50 +02:00
commit f2b1335beb
32 changed files with 1280 additions and 650 deletions

View file

@ -141,5 +141,5 @@ oci_calculator:
entrypoint: [""]
script:
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE/calculator:latest
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/app-config/cara-public-docker-image/Dockerfile --destination $CI_REGISTRY_IMAGE/calculator:latest

View file

@ -1,7 +0,0 @@
FROM python:3.9
COPY ./ /opt/cara/src
RUN python -m venv /opt/cara/app
RUN cd /opt/cara/src && /opt/cara/app/bin/pip install -r /opt/cara/src/requirements.txt
EXPOSE 8080
ENTRYPOINT ["/bin/sh", "-c", "echo 'CARA is running on http://localhost:8080' && echo 'Please see https://gitlab.cern.ch/cara/cara for terms of use.' && /opt/cara/app/bin/python -m cara.apps.calculator --no-debug"]

View file

@ -0,0 +1,21 @@
FROM python:3.9
# Copy just the requirements.txt initially, allowing Docker effectively to cache the build (good for dev).
COPY ./requirements.txt /tmp/requirements.txt
RUN python -m venv /opt/cara/app
RUN sed '/\.\[/d' -i /tmp/requirements.txt && /opt/cara/app/bin/pip install -r /tmp/requirements.txt
RUN apt-get update && apt-get install -y nginx
# Now that we have done the installation of the dependencies, copy the cara source.
COPY ./ /opt/cara/src
COPY ./app-config/cara-public-docker-image/run_cara.sh /opt/cara/start.sh
# To ensure that we have installed the full requirements, re-run the pip install.
# In the best case this will be a no-op.
RUN cd /opt/cara/src/ && /opt/cara/app/bin/pip install -r /opt/cara/src/requirements.txt
RUN /opt/cara/app/bin/jupyter trust /opt/cara/src/cara/apps/expert/*.ipynb
COPY ./app-config/cara-public-docker-image/nginx.conf /opt/cara/nginx.conf
EXPOSE 8080
ENTRYPOINT ["/bin/sh", "-c", "/opt/cara/start.sh"]

View file

@ -0,0 +1,57 @@
worker_processes auto;
error_log /var/log/nginx/error.log;
pid /run/nginx.pid;
include /usr/share/nginx/modules/*.conf;
events {
worker_connections 1024;
}
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
large_client_header_buffers 4 16k;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
server {
listen 8080 default_server;
listen [::]:8080 default_server;
server_name _;
root /opt/cara/src;
# Load configuration files for the default server block.
include /opt/app-root/etc/nginx.default.d/*.conf;
large_client_header_buffers 4 16k;
location /voila-server/ {
proxy_pass http://localhost:8082/voila-server/;
}
rewrite ^/expert-app$ /voila-server/ last;
rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last;
location / {
proxy_pass http://localhost:8081;
}
}
}

View file

@ -0,0 +1,16 @@
echo 'CARA is running on http://localhost:8080'
echo 'Please see https://gitlab.cern.ch/cara/cara for terms of use.'
# Run a proxy for the apps (listening on 8080).
nginx -c /opt/cara/nginx.conf
# Run the expert app in the background.
cd /opt/cara/src/cara
/opt/cara/app/bin/python -m voila /opt/cara/src/cara/apps/expert/cara.ipynb \
--port=8082 --no-browser --base_url=/voila-server/ \
--Voila.tornado_settings 'allow_origin=*' \
>> /var/log/expert-app.log 2>&1 &
# Run the calculator in the foreground.
/opt/cara/app/bin/python -m cara.apps.calculator --port 8081 --no-debug

View file

@ -37,7 +37,7 @@
sourceStrategy:
from:
kind: ImageStreamTag
name: 'nginx:1.12'
name: 'nginx:1.18-ubi8'
namespace: openshift
type: Source
triggers:
@ -49,41 +49,6 @@
name: gitlab-cara-webhook-secret
type: Generic
nodeSelector: null
-
kind: BuildConfig
apiVersion: v1
metadata:
name: cara-webservice
labels:
template: "cara-application"
spec:
source:
type: Git
git:
ref: ${GIT_BRANCH}
uri: ${GIT_REPO}
sourceSecret:
name: sshdeploykey
postCommit: {}
resources: {}
runPolicy: Serial
output:
to:
kind: ImageStreamTag
name: 'cara-webservice:latest'
strategy:
sourceStrategy:
from:
kind: ImageStreamTag
name: 'python:3.6'
namespace: openshift
type: Source
triggers:
- generic:
secretReference:
name: gitlab-cara-webhook-secret
type: Generic
nodeSelector: null
parameters:
- name: GIT_REPO
description: The GIT repo URL

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__ = "2.0.0"
__version__ = "2.1.0"
class BaseRequestHandler(RequestHandler):

View file

@ -21,6 +21,11 @@ def configure_parser(parser) -> argparse.ArgumentParser:
help="Change the URL path prefix to the calculator app",
default="/calculator"
)
parser.add_argument(
"--port",
help="The port to listen on",
default="8080"
)
return parser
@ -33,7 +38,7 @@ def main():
assert theme_dir.exists()
assert (theme_dir / 'templates').exists()
app = make_app(debug=args.no_debug, calculator_prefix=args.prefix, theme_dir=theme_dir)
app.listen(8080)
app.listen(args.port)
IOLoop.instance().start()

View file

@ -550,7 +550,6 @@ class FormData:
if current_time < finish:
LOG.debug("trailing interval")
present_intervals.append((current_time / 60, finish / 60))
return models.SpecificInterval(tuple(present_intervals))
def infected_present_interval(self) -> models.Interval:

View file

@ -29,13 +29,84 @@ def model_start_end(model: models.ExposureModel):
return t_start, t_end
def calculate_report_data(model: models.ExposureModel):
resolution = 600
def fill_big_gaps(array, gap_size):
"""
Insert values into the given sorted list if there is a gap of more than ``gap_size``.
All values in the given array are preserved, even if they are within the ``gap_size`` of one another.
>>> fill_big_gaps([1, 2, 4], gap_size=0.75)
[1, 1.75, 2, 2.75, 3.5, 4]
"""
result = []
if len(array) == 0:
raise ValueError("Input array must be len > 0")
last_value = array[0]
for value in array:
while value - last_value > gap_size + 1e-15:
last_value = last_value + gap_size
result.append(last_value)
result.append(value)
last_value = value
return result
def non_temp_transition_times(model: models.ExposureModel):
"""
Return the non-temperature (and PiecewiseConstant) based transition times.
"""
def walk_model(model, name=""):
# Extend walk_dataclass to handle lists of dataclasses
# (e.g. in MultipleVentilation).
for name, obj in dataclass_utils.walk_dataclass(model, name=name):
if name.endswith('.ventilations') and isinstance(obj, (list, tuple)):
for i, item in enumerate(obj):
fq_name_i = f'{name}[{i}]'
yield fq_name_i, item
if dataclasses.is_dataclass(item):
yield from dataclass_utils.walk_dataclass(item, name=fq_name_i)
else:
yield name, obj
t_start, t_end = model_start_end(model)
times = np.linspace(t_start, t_end, resolution)
concentrations = [np.array(model.concentration_model.concentration(time)).mean()
for time in times]
change_times = {t_start, t_end}
for name, obj in walk_model(model, name="exposure"):
if isinstance(obj, models.Interval):
change_times |= obj.transition_times()
# Only choose times that are in the range of the model (removes things
# such as PeriodicIntervals, which extend beyond the model itself).
return sorted(time for time in change_times if (t_start <= time <= t_end))
def interesting_times(model: models.ExposureModel, approx_n_pts=100) -> typing.List[float]:
"""
Pick approximately ``approx_n_pts`` time points which are interesting for the
given model.
Initially the times are seeded by important state change times (excluding
outside temperature), and the times are then subsequently expanded to ensure
that the step size is at most ``(t_end - t_start) / approx_n_pts``.
"""
times = non_temp_transition_times(model)
# Expand the times list to ensure that we have a maximum gap size between
# the key times.
nice_times = fill_big_gaps(times, gap_size=(max(times) - min(times)) / approx_n_pts)
return nice_times
def calculate_report_data(model: models.ExposureModel):
times = interesting_times(model)
concentrations = [
np.array(model.concentration_model.concentration(float(time))).mean()
for time in times
]
highest_const = max(concentrations)
prob = np.array(model.infection_probability()).mean()
er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean()
@ -94,7 +165,7 @@ def _img2bytes(figure):
def _figure2bytes(figure):
# Draw the image
img_data = io.BytesIO()
figure.savefig(img_data, format='png', bbox_inches="tight")
figure.savefig(img_data, format='png', bbox_inches="tight", transparent=True)
return img_data
@ -115,7 +186,7 @@ def plot(times, concentrations, model: models.ExposureModel):
ax.spines['top'].set_visible(False)
ax.set_xlabel('Time of day')
ax.set_ylabel('Mean concentration ($q/m^3$)')
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"))
@ -210,7 +281,7 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.Exp
return scenarios
def comparison_plot(scenarios: typing.Dict[str, dict], sample_times: np.ndarray):
def comparison_plot(scenarios: typing.Dict[str, dict], sample_times: typing.List[float]):
fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
@ -236,13 +307,13 @@ def comparison_plot(scenarios: typing.Dict[str, dict], sample_times: np.ndarray)
ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M"))
ax.set_xlabel('Time of day')
ax.set_ylabel('Mean concentration ($q/m^3$)')
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: np.ndarray):
def scenario_statistics(mc_model: mc.ExposureModel, sample_times: typing.List[float]):
model = mc_model.build_model(size=_DEFAULT_MC_SAMPLE_SIZE)
return {
'probability_of_infection': np.mean(model.infection_probability()),
@ -256,7 +327,7 @@ def scenario_statistics(mc_model: mc.ExposureModel, sample_times: np.ndarray):
def comparison_report(
scenarios: typing.Dict[str, mc.ExposureModel],
sample_times: np.ndarray,
sample_times: typing.List[float],
executor_factory: typing.Callable[[], concurrent.futures.Executor],
):
statistics = {}
@ -307,8 +378,7 @@ class ReportGenerator:
'creation_date': time,
}
t_start, t_end = model_start_end(model)
scenario_sample_times = np.linspace(t_start, t_end, 350)
scenario_sample_times = interesting_times(model)
context.update(calculate_report_data(model))
alternative_scenarios = manufacture_alternative_scenarios(form)
@ -318,10 +388,10 @@ class ReportGenerator:
context['qr_code'] = generate_qr_code(base_url, self.calculator_prefix, form)
context['calculator_prefix'] = self.calculator_prefix
context['scale_warning'] = {
'level': 'Yellow - 2',
'level': 'yellow-2',
'incidence_rate': 'lower than 25 new cases per 100 000 inhabitants',
'onsite_access': 'of about 8000',
'threshold' : ''
'threshold': ''
}
return context

View file

@ -1,87 +1,250 @@
#body {
top: 10px;
left: 20px;
bottom: 20px;
right: 20px;
padding: 20px;
top: 10px;
left: 20px;
bottom: 20px;
right: 20px;
padding: 20px;
}
h1 {
text-align: center;
text-align: center;
}
.subtitle {
text-align: center;
font-size: 13pt;
padding-bottom: 15pt;
text-align: center;
font-size: 17px;
padding-bottom: 20px;
}
p.data_italic {
font-style: italic;
font-style: italic;
}
p.data_title {
font-weight: bold;
font-weight: bold;
}
p.data_text {
padding-left: 30px;
padding-left: 30px;
}
p.data_subtext {
padding-left: 60px;
margin-left: -3em;
padding-left: 60px;
margin-left: -3em;
}
p.result_title {
font-weight: bold;
font-size: 15pt;
font-weight: bold;
font-size: 20px;
}
p.image {
text-align: center;
font-size: 13pt;
text-align: center;
font-size: 17px;
}
p.disclaimer {
font-size: 12pt;
font-size: 16px;
}
p.notes {
font-size: 10pt;
font-size: 13px;
}
#cara_logo {
height: 150px;
margin: 1%
}
#pdf-qr-code {
margin-right: 1%;
width: 100pt;
}
.red_bkg {
color: #000000;
background-color: #CD5C5C;
text-decoration: none;
color: #000000;
background-color: #CD5C5C;
text-decoration: none;
}
.yellow_bkg {
color: #000000;
background-color: #FFFF00;
text-decoration: none;
color: #000000;
background-color: #FFFF00;
text-decoration: none;
}
.green_bkg {
color: #000000;
background-color: #90EE90;
text-decoration: none;
color: #000000;
background-color: #90EE90;
text-decoration: none;
}
.warning_image_png {
margin-bottom: 1rem;
padding-left: 30px;
border-radius: .25rem
.icon_button {
border: none;
background: none;
}
.warning_text {
height:fit-content;
align-self: center;
margin-left: 5%
.icon_button:focus {
outline: 0 !important;
}
/* Flexbox layouts */
.flex {
display: flex;
.nav-tabs .nav-item .nav-link.active {
font-weight: bold;
}
.nav-tabs .nav-item .nav-link {
color: black;
}
.tabs-div {
margin: 1%;
border: #DFDFDF 1px solid;
border-radius: 5px;
}
.print-button {
margin-left: auto;
margin-right: 1%;
}
/* @media (width: 1200px) { */
@media print {
/* #body {
min-width: 1200px;
} */
#results,
#rules,
#data {
display: contents!important;
}
#link_reproduce_results {
display: none!important;
}
#pdf-qr-code {
visibility: inherit!important;
}
.collapse {
display: block!important;
}
.tab-content {
border-top: none!important;
}
.nav-tabs {
display: none!important;
}
.tabs-div {
border: none!important;
}
.icon_button {
display: none!important;
}
.print-button {
display: none!important;
}
.card {
page-break-inside: avoid;
}
/* CSS styling to avoid page breaks. */
.break-after {
page-break-after: always;
}
.break-avoid {
page-break-inside: avoid;
}
}
/* CSS for the animation */
.intro-banner-vdo-play-btn {
height: 60px;
width: 60px;
position: relative;
text-align: center;
border-radius: 100px;
z-index: 1
}
.intro-banner-vdo-play-btn i {
line-height: 56px;
font-size: 30px
}
.intro-banner-vdo-play-btn .ripple {
position: absolute;
width: 160px;
height: 160px;
z-index: -1;
left: 50%;
top: 50%;
opacity: 0;
margin: -80px 0 0 -80px;
border-radius: 100px;
-webkit-animation: ripple 1.8s infinite;
animation: ripple 1.8s infinite
}
@-webkit-keyframes ripple {
0% {
opacity: 1;
-webkit-transform: scale(0);
transform: scale(0)
}
100% {
opacity: 0;
-webkit-transform: scale(1);
transform: scale(1)
}
}
@keyframes ripple {
0% {
opacity: 1;
-webkit-transform: scale(0);
transform: scale(0)
}
100% {
opacity: 0;
-webkit-transform: scale(1);
transform: scale(1)
}
}
.intro-banner-vdo-play-btn .ripple:nth-child(2) {
animation-delay: .3s;
-webkit-animation-delay: .3s
}
.intro-banner-vdo-play-btn .ripple:nth-child(3) {
animation-delay: .6s;
-webkit-animation-delay: .6s
}
.split {
/* flex: 1; */
clear: both;
display: inline-flex;
flex-direction: column;
width: 100%;
}
@media screen and (min-width: 40em) {
.split {
flex-direction: row;
}
.split>* {
flex-basis: 100%;
}
.header-text {
text-align: left;
}
.split>*+* {
margin-left: 2em;
}
.bigButton {
width: 25%;
}
.logo {
float: left;
}
}

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,29 @@
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

@ -16,21 +16,21 @@ function draw_concentration_plot(svg_id, times, concentrations, exposed_presence
// 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]),
xTimeRange = d3.scaleLinear().range([margins.left, width - margins.right]).domain([data[0].time, data[data.length - 1].time]),
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]),
yRange = d3.scaleLinear().range([height - margins.bottom, margins.top]).domain([0., Math.max(...concentrations)]),
xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)),
yAxis = d3.axisLeft(yRange);
// Plot tittle.
vis.append('svg:foreignObject')
.attr("background-color", "transparent")
.attr('width', width)
.attr('height', margins.top)
.append('xhtml:body')
.style('text-align', 'center')
.html('Mean concentration of infectious quanta');
.html('<b>Mean concentration of virions</b>');
// Line representing the mean concentration.
var lineFunc = d3.line()
@ -74,16 +74,10 @@ function draw_concentration_plot(svg_id, times, concentrations, exposed_presence
.attr('text-anchor', 'middle')
.attr('x', (height + margins.bottom) / 2)
.attr('y', (height + margins.left) * 0.92)
.text('Mean concentration (q/m^3)');
.text('Mean concentration (virions/m³)');
// 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)

View file

@ -8,7 +8,6 @@
<link rel="stylesheet" type="text/css" href="{{ calculator_prefix }}/static/css/report.css">
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="{{ calculator_prefix }}/static/js/report.js" type="application/javascript"></script>
@ -18,309 +17,419 @@
{% block report_header %}
<div style="position:relative; text-align:center; margin-left:-200pt; max-height:180pt; margin-bottom: 1em;">
<img src="/static/images/cara_logo_text.png" style="height:150px; display:inline-block; vertical-align:middle; object-fit:cover;">
<h1 style="display:inline; vertical-align:middle; margin-left:1em;">Report</h1>
<div id="report-header-div" class="d-flex flex-row">
<img id="cara_logo" src="/static/images/cara_logo.200x200.png" class="d-inline-block align-middle">
<div class="d-flex align-self-center flex-column mr-auto" style="margin-left: 1em;">
<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 }}"/>
</div>
<p class=subtitle> Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}</p><br>
{% endblock report_header %}
{% block report_preamble %}
{% endblock report_preamble %}
{% block simulation_overview %}
<p><strong>Simulation:</strong></p>
<p>Simulation Name: {{ form.simulation_name }}</p>
<p>Room Number: {{ form.room_number }}</p>
<p class="data_title">Input data:</p>
<ul>
<li><p class="data_text">Virus variant:
{% if form.virus_type == "SARS_CoV_2" %}
SARS-CoV-2 (nominal strain)
{% elif form.virus_type == "SARS_CoV_2_B117" %}
<a href="https://www.ecdc.europa.eu/en/publications-data/covid-19-risk-assessment-spread-new-sars-cov-2-variants-eueea">SARS-CoV-2 (Alpha VOC) </a>
{% elif form.virus_type == "SARS_CoV_2_P1" %}
<a href="https://github.com/CADDE-CENTRE/Novel-SARS-CoV-2-P1-Lineage-in-Brazil/blob/main/manuscript/FINAL_P1_MANUSCRIPT_25-02-2021_combined.pdf">SARS-CoV-2 (Gamma VOC)</a>
{% elif form.virus_type == "SARS_CoV_2_B16172" %}
<a href="https://www.bmj.com/content/373/bmj.n1513">SARS-CoV-2 (Delta VOC)</a>
{% endif %}
</p></li>
<li><p class="data_text">Room Volume: {{ model.concentration_model.room.volume }} m³</p></li>
<li><p class="data_text">Room Central Heating: {{ "On" if form.room_heating_option else "Off" }}</p></li>
<div class="tabs-div">
<ul class="nav nav-tabs" role="tablist" style="margin: 0.5% 0% 0% 1%">
<li class="nav-item">
<a class="nav-link active" id="results-tab" data-toggle="tab" href="#results" role="tab" aria-controls="results" aria-selected="true">Results</a>
</li>
<li class="nav-item">
<a class="nav-link" id="data-tab" data-toggle="tab" href="#data" role="tab" aria-controls="data" aria-selected="false">Input Data</a>
</li>
{% block report_preamble_navtab %}
{% endblock report_preamble_navtab %}
</ul>
<p class="data_title">Ventilation data:</p>
<ul>
<li><p class="data_text">Mechanical ventilation:
{% if form.ventilation_type == "mechanical_ventilation" %}
Yes </p></li>
<ul>
<li><p class="data_subtext">
{% if form.mechanical_ventilation_type == "mech_type_air_supply"%}
Air supply flow rate: {{ form.air_supply }} m³ / hour
{% elif form.mechanical_ventilation_type == "mech_type_air_changes"%}
Air changes per hour: {{ form.air_changes }} h⁻¹
{% endif %}
</p></li>
</ul>
{% else %}
No </li>
{% endif %}
<li><p class="data_text">Natural ventilation:
{% if form.ventilation_type == "natural_ventilation"%}
Yes </p></li>
<ul>
<li><p class="data_subtext">Number of windows: {{ form.windows_number }}</p></li>
<li><p class="data_subtext">Height of window: {{ form.window_height }} m</p></li>
<li><p class="data_subtext">Window type:
{% if form.window_type == "window_hinged" %}
Top- or Bottom-Hung</p></li>
<li><p class="data_subtext">Width of window: {{ form.window_width }} m</p></li>
{% elif form.window_type == "window_sliding" %}
Sliding / Side-Hung</p></li>
{% endif %}
<li><p class="data_subtext">Opening distance: {{ form.opening_distance }} m</p></li>
<li><p class="data_subtext">Windows open:
{% if form.window_opening_regime == "windows_open_periodically" %}
Periodically for {{ form.windows_duration | readable_minutes}}
every {{ form.windows_frequency | readable_minutes}}
{% elif form.window_opening_regime == "windows_open_permanently" %}
Permanently
{% endif %}
</p></li>
</ul>
<p class="data_subtext data_italic">When using the natural ventilation option, air flows are calculated using averaged hourly temperatures for the Geneva region, based on historical data for the month selected.</p>
{% else %}
No </p></li>
{% endif %}
<li><p class="data_text">HEPA Filtration: {{ 'Yes' if form.hepa_option else 'No' }}</li>
{% if form.hepa_option %}
<ul>
<li><p class="data_text">HEPA amount: {{ form.hepa_amount }} m³ / hour</p></li>
</ul>
{% endif %}
</ul>
<div class="tab-content" style="border-top: #dee2e6 1px solid; margin-top: -1px" >
<div class="tab-pane show active" id="results" role="tabpanel" aria-labelledby="results-tab" style="padding: 1%">
{% block report_results %}
<div class="card bg-light mb-3" id="results-div">
<div class="card-header"><strong>Results </strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseResults" role="button" aria-expanded="true" aria-controls="collapseResults">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<div class="collapse show" id="collapseResults">
<div class="card-body">
<p class="card-text">
<div class="align-self-center">
<div class="d-flex">
<div style="min-width: 25%">
<div style="text-align:center"><b>Probability of infection (%)</b></div>
<div class="d-flex" style="min-height: 160px">
{% block warning_animation %}
<div class="intro-banner-vdo-play-btn bg-secondary m-auto d-flex align-items-center justify-content-center">
<b>{{prob_inf | non_zero_percentage}}</b>
<i class="glyphicon glyphicon-play whiteText" aria-hidden="true"></i>
<span class="ripple bg-secondary"></span>
<span class="ripple bg-secondary"></span>
<span class="ripple bg-secondary"></span>
</div>
{% endblock warning_animation %}
</div>
</div>
{% block report_summary %}
<div class="align-self-center alert alert-dark" role="alert">
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
</div>
{% endblock report_summary %}
</div>
{% block report_summary_footnote %}
{% endblock report_summary_footnote %}
</div>
<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}}
draw_concentration_plot("#result_plot", times, concentrations, exposed_presence_intervals);
</script>
</p>
</div>
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header"><strong>Alternative scenarios</strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseAlternativeScenarios" role="button" aria-expanded="false" aria-controls="collapseAlternativeScenarios">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<div class="collapse" id="collapseAlternativeScenarios">
<div class="card-body">
<div>
<img id="scenario_concentration_plot" src="{{ alternative_scenarios.plot }}" />
<p class="data_title">Event data:</p>
<ul>
<li><p class="data_text">Number of attendees and infected people: {{ form.total_people }} in attendance, of whom {{ form.infected_people }}
{{ "is" if form.infected_people == 1 else "are" }}
infected.</p></li>
<li><p class="data_text">
Activity type:
{% if form.activity_type == "office" %}
Office typical scenario with all persons seated, talking occasionally (talking assumed for 1/3rd of the time).
{% elif form.activity_type == "meeting" %}
Meeting typical scenario with all persons seated, one person talking at a time.
{% elif form.activity_type == "callcentre" %}
Call Centre = typical office-like scenario with all persons seated, all talking continuously.
{% elif form.activity_type == "controlroom-day" %}
Control Room (Day Shift) = specific control room scenario, all persons seated, all talking 50% of the time.
{% elif form.activity_type == "controlroom-night" %}
Control Room (Night Shift) = specific control room scenario with all persons seated, all talking for 10% of the time.
{% elif form.activity_type == "library" %}
Library = Library scenario with all persons seated, breathing and not talking.
{% elif form.activity_type == "workshop" %}
Workshop = assembly workshop environment, all persons doing moderate physical activity, talking 50% of the time.
{% elif form.activity_type == "training" %}
Training one person (the trainer) standing, talking, all others seated, talking quietly (whispering). It is assumed the trainer is the infected person, for the worst case scenario.
{% elif form.activity_type == "lab" %}
Laboratory = Lab or technical environment, all persons doing light physical activity, talking 50% of the time.
{% elif form.activity_type == "gym" %}
Gym = For comparison only, all persons doing heavy physical exercise, breathing and not talking.
{% endif %}
</p></li>
<li><p class="data_text">Exposed occupant(s) activity time:</p></li>
<ul>
<li><p class="data_subtext">Start time: {{ form.exposed_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.exposed_finish | minutes_to_time }}</p></li>
</ul>
<li><p class="data_text">Infected occupant(s) activity time:</p></li>
<ul>
<li><p class="data_subtext">Start time: {{ form.infected_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.infected_finish | minutes_to_time }}</p></li>
</ul>
<li><p class="data_text">Event for the month of {{ form.event_month }}</p></li>
</ul>
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<thead class="thead-light">
<tr>
<th>Scenario</th>
<th>P(I)</th>
<th>Expected new cases</th>
</tr>
</thead>
<tbody>
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
<tr>
<td> {{ scenario_name }}</td>
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock report_scenarios_summary_table %}
</div>
<br/>
<p class="data_text"> <strong> Notes for alternative scenarios: </strong><br>
<ol>
<li>This graph shows the concentration of infectious quanta in the air. The filtration of Type I and FFP2 masks, if worn, applies not only to the emission rate but also to the individual exposure (i.e. inhalation).
For this reason, scenarios with different types of mask will show the same concentration on the graph but have different absorbed doses and infection probabilities.</li>
<li>If you have selected more sophisticated options, such as HEPA filtration or FFP2 masks, this will be indicated in the plot as the "base scenario", representing the inputs inserted in the form.<br>
The other alternative scenarios shown for comparison will not include either HEPA filtration or FFP2 masks.</li>
</ol>
<br>
</p>
</div>
</div>
</div>
{% endblock report_results %}
<p class="data_title">Break data:</p>
{% if form.infected_dont_have_breaks_with_exposed %}
<p style="padding-left:15px;"> Exposed occupant(s):</p>
{% endif %}
<ul>
<li><p class="data_text">Lunch break:
{% if form.exposed_lunch_option%}
Yes</li>
<ul>
<li><p class="data_subtext">Start time: {{ form.exposed_lunch_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.exposed_lunch_finish | minutes_to_time }}</p></li>
</ul>
{% else%}
No
{% endif %}
</p></li>
<li><p class="data_text">Coffee breaks: {{ form.exposed_number_of_coffee_breaks() }}
{% if form.exposed_number_of_coffee_breaks() > 0 %}
each of {{ form.exposed_coffee_duration }} minutes duration
</p></li>
<ul>
{%- for start_time, end_time in form.exposed_coffee_break_times() %}
<li><p class="data_subtext">Coffee break {{ loop.index }}: Start: {{ start_time | minutes_to_time }} &nbsp&nbsp End: {{ end_time | minutes_to_time }}</p></li>
{%- endfor %}
</ul>
{% endif %}
</ul>
{% block report_footer %}
<div class="card bg-light mb-3" id="link_reproduce_results">
<div class="card-header"><strong>Link to reproduce results </strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseQRcode" role="button" aria-expanded="true" aria-controls="collapseQRcode">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<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>
<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!
</p>
</span>
</div>
</div>
</div>
</div>
<div style="clear: both;"></div>
{% endblock report_footer %}
</div>
{% if form.infected_dont_have_breaks_with_exposed %}
<p style="padding-left:15px;"> Infected occupant(s):</p>
<ul>
<li><p class="data_text">Lunch break:
{% if form.infected_lunch_option%}
Yes</li>
<ul>
<li><p class="data_subtext">Start time: {{ form.infected_lunch_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.infected_lunch_finish | minutes_to_time }}</p></li>
</ul>
{% else%}
No
{% endif %}
</p></li>
<li><p class="data_text">Coffee breaks: {{ form.infected_number_of_coffee_breaks() }}
{% if form.infected_number_of_coffee_breaks() > 0 %}
each of {{ form.infected_coffee_duration }} minutes duration
</p></li>
<ul>
{%- for start_time, end_time in form.infected_coffee_break_times() %}
<li><p class="data_subtext">Coffee break {{ loop.index }}: Start: {{ start_time | minutes_to_time }} &nbsp&nbsp End: {{ end_time | minutes_to_time }}</p></li>
{%- endfor %}
</ul>
{% endif %}
</ul>
{% endif %}
<p class="data_title">Mask wearing:</p>
<ul>
<li><p class="data_text">Masks worn at workstations? {{ 'Yes' if form.mask_wearing_option == "mask_on" else 'No' }} </p></li>
{% if form.mask_wearing_option == "mask_on" %}
<li><p class="data_text">Mask type: {{ form.mask_type }}</p></li>
{% endif %}
</ul>
{% endblock simulation_overview %}
{% block report_results %}
<p class="result_title">Results:</p>
<p class="data_text">
{% block report_summary %}
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b><a href="#section1">[*]</a> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>.
{% endblock report_summary %}
<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>
</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}}
draw_concentration_plot("#result_plot", times, concentrations, exposed_presence_intervals);
</script>
<p class="data_title">Alternative scenarios:</p>
<p class="data_text">
<img id="scenario_concentration_plot" src="{{ alternative_scenarios.plot }}" align="left" />
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<thead class="thead-light">
<tr>
<th>Scenario</th>
<th>P(I)</th>
<th>Expected new cases</th>
</tr>
</thead>
<tbody>
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
<tr>
<td> {{ scenario_name }}</td>
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock report_scenarios_summary_table %}
</p>
<div style="clear: both;">
<p class="data_text"> <strong> Notes for alternative scenarios: </strong><br>
<ol>
<li>This graph shows the concentration of virions in the air. The filtration of Type I and FFP2 masks, if worn, applies not only to the emission rate but also to the individual exposure (i.e. inhalation).
For this reason, scenarios with different types of mask will show the same concentration on the graph but have different absorbed doses and infection probabilities.</li>
<li>If you have selected more sophisticated options, such as HEPA filtration or FFP2 masks, alternatives will be indicated in the plot as the "base scenario with/without...", representing a variation on the inputs inserted in the form.<br>
The other alternative scenarios shown for comparison will not include either HEPA filtration or FFP2 masks.</li>
</ol>
<br>
</p>
{% endblock report_results %}
{% block report_footer %}
<div>
<a href="{{ qr_code.link }}" style="float: left;"><img style="width:250pt;" id="qr_code" src="{{ qr_code.image }}"/></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!
</p>
</span>
<div class="tab-pane" id="data" role="tabpanel" aria-labelledby="data-tab" style="padding: 1%">
{% block simulation_overview %}
<div class="card">
<div class="card-header"><strong>Simulation:</strong></div>
<div class="card-body">
<p>Simulation Name: {{ form.simulation_name }}</p>
<p>Room Number: {{ form.room_number }}</p>
</div>
</div>
<br>
<div class="card">
<div class="card-header"> <strong>Input data:</strong></div>
<div class="card-body">
<ul>
<li><p class="data_text">Virus variant:
{% if form.virus_type == "SARS_CoV_2" %}
SARS-CoV-2 (nominal strain)
{% elif form.virus_type == "SARS_CoV_2_B117" %}
<a href="https://www.ecdc.europa.eu/en/publications-data/covid-19-risk-assessment-spread-new-sars-cov-2-variants-eueea">SARS-CoV-2 (Alpha VOC) </a>
{% elif form.virus_type == "SARS_CoV_2_P1" %}
<a href="https://doi.org/10.1126/science.abh2644">SARS-CoV-2 (Gamma VOC)</a>
{% elif form.virus_type == "SARS_CoV_2_B16172" %}
<a href="https://www.bmj.com/content/373/bmj.n1513">SARS-CoV-2 (Delta VOC)</a>
{% endif %}
</p></li>
<li><p class="data_text">Room Volume: {{ model.concentration_model.room.volume }} m³</p></li>
<li><p class="data_text">Room Central Heating: {{ "On" if form.room_heating_option else "Off" }}</p></li>
</ul>
</div>
</div>
<br>
<div class="card">
<div class="card-header"><strong>Ventilation data:</strong></div>
<div class="card-body">
<ul>
<li><p class="data_text">Mechanical ventilation:
{% if form.ventilation_type == "mechanical_ventilation" %}
Yes </p></li>
<ul>
<li><p class="data_subtext">
{% if form.mechanical_ventilation_type == "mech_type_air_supply"%}
Air supply flow rate: {{ form.air_supply }} m³ / hour
{% elif form.mechanical_ventilation_type == "mech_type_air_changes"%}
Air changes per hour: {{ form.air_changes }} h⁻¹
{% endif %}
</p></li>
</ul>
{% else %}
No </li>
{% endif %}
<li><p class="data_text">Natural ventilation:
{% if form.ventilation_type == "natural_ventilation"%}
Yes </p></li>
<ul>
<li><p class="data_subtext">Number of windows: {{ form.windows_number }}</p></li>
<li><p class="data_subtext">Height of window: {{ form.window_height }} m</p></li>
<li><p class="data_subtext">Window type:
{% if form.window_type == "window_hinged" %}
Top- or Bottom-Hung</p></li>
<li><p class="data_subtext">Width of window: {{ form.window_width }} m</p></li>
{% elif form.window_type == "window_sliding" %}
Sliding / Side-Hung</p></li>
{% endif %}
<li><p class="data_subtext">Opening distance: {{ form.opening_distance }} m</p></li>
<li><p class="data_subtext">Windows open:
{% if form.window_opening_regime == "windows_open_periodically" %}
Periodically for {{ form.windows_duration | readable_minutes}}
every {{ form.windows_frequency | readable_minutes}}
{% elif form.window_opening_regime == "windows_open_permanently" %}
Permanently
{% endif %}
</p></li>
</ul>
<p class="data_subtext data_italic">When using the natural ventilation option, air flows are calculated using averaged hourly temperatures for the Geneva region, based on historical data for the month selected.</p>
{% else %}
No </p></li>
{% endif %}
<li><p class="data_text">HEPA Filtration: {{ 'Yes' if form.hepa_option else 'No' }}</li>
{% if form.hepa_option %}
<ul>
<li><p class="data_text">HEPA amount: {{ form.hepa_amount }} m³ / hour</p></li>
</ul>
{% endif %}
</ul>
</div>
</div>
<br>
<div class="card">
<div class="card-header"><strong>Event data:</strong></div>
<div class="card-body">
<ul>
<li><p class="data_text">Number of attendees and infected people: {{ form.total_people }} in attendance, of whom {{ form.infected_people }}
{{ "is" if form.infected_people == 1 else "are" }}
infected.</p></li>
<li><p class="data_text">
Activity type:
{% if form.activity_type == "office" %}
Office typical scenario with all persons seated, talking occasionally (talking assumed for 1/3rd of the time).
{% elif form.activity_type == "meeting" %}
Meeting typical scenario with all persons seated, one person talking at a time.
{% elif form.activity_type == "callcentre" %}
Call Centre = typical office-like scenario with all persons seated, all talking continuously.
{% elif form.activity_type == "controlroom-day" %}
Control Room (Day Shift) = specific control room scenario, all persons seated, all talking 50% of the time.
{% elif form.activity_type == "controlroom-night" %}
Control Room (Night Shift) = specific control room scenario with all persons seated, all talking for 10% of the time.
{% elif form.activity_type == "library" %}
Library = Library scenario with all persons seated, breathing and not talking.
{% elif form.activity_type == "workshop" %}
Workshop = assembly workshop environment, all persons doing moderate physical activity, talking 50% of the time.
{% elif form.activity_type == "training" %}
Training one person (the trainer) standing, talking, all others seated, talking quietly (whispering). It is assumed the trainer is the infected person, for the worst case scenario.
{% elif form.activity_type == "lab" %}
Laboratory = Lab or technical environment, all persons doing light physical activity, talking 50% of the time.
{% elif form.activity_type == "gym" %}
Gym = For comparison only, all persons doing heavy physical exercise, breathing and not talking.
{% endif %}
</p></li>
<li><p class="data_text">Exposed occupant(s) activity time:</p></li>
<ul>
<li><p class="data_subtext">Start time: {{ form.exposed_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.exposed_finish | minutes_to_time }}</p></li>
</ul>
<li><p class="data_text">Infected occupant(s) activity time:</p></li>
<ul>
<li><p class="data_subtext">Start time: {{ form.infected_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.infected_finish | minutes_to_time }}</p></li>
</ul>
<li><p class="data_text">Event for the month of {{ form.event_month }}</p></li>
</ul>
</div>
</div>
<br>
<div class="card">
<div class="card-header"><strong>Break data:</strong></div>
<div class="card-body">
{% if form.infected_dont_have_breaks_with_exposed %}
<p style="padding-left:15px;"> Exposed occupant(s):</p>
{% endif %}
<ul>
<li><p class="data_text">Lunch break:
{% if form.exposed_lunch_option%}
Yes</li>
<ul>
<li><p class="data_subtext">Start time: {{ form.exposed_lunch_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.exposed_lunch_finish | minutes_to_time }}</p></li>
</ul>
{% else%}
No
{% endif %}
</p></li>
<li><p class="data_text">Coffee breaks: {{ form.exposed_number_of_coffee_breaks() }}
{% if form.exposed_number_of_coffee_breaks() > 0 %}
each of {{ form.exposed_coffee_duration }} minutes duration
</p></li>
<ul>
{%- for start_time, end_time in form.exposed_coffee_break_times() %}
<li><p class="data_subtext">Coffee break {{ loop.index }}: Start: {{ start_time | minutes_to_time }} &nbsp&nbsp End: {{ end_time | minutes_to_time }}</p></li>
{%- endfor %}
</ul>
{% endif %}
</ul>
{% if form.infected_dont_have_breaks_with_exposed %}
<p style="padding-left:15px;"> Infected occupant(s):</p>
<ul>
<li><p class="data_text">Lunch break:
{% if form.infected_lunch_option%}
Yes</li>
<ul>
<li><p class="data_subtext">Start time: {{ form.infected_lunch_start | minutes_to_time }} &nbsp&nbsp End time: {{ form.infected_lunch_finish | minutes_to_time }}</p></li>
</ul>
{% else%}
No
{% endif %}
</p></li>
<li><p class="data_text">Coffee breaks: {{ form.infected_number_of_coffee_breaks() }}
{% if form.infected_number_of_coffee_breaks() > 0 %}
each of {{ form.infected_coffee_duration }} minutes duration
</p></li>
<ul>
{%- for start_time, end_time in form.infected_coffee_break_times() %}
<li><p class="data_subtext">Coffee break {{ loop.index }}: Start: {{ start_time | minutes_to_time }} &nbsp&nbsp End: {{ end_time | minutes_to_time }}</p></li>
{%- endfor %}
</ul>
{% endif %}
</ul>
{% else %}
<i>Same breaks taken by the exposed and infected persons.</i>
{% endif %}
</div>
</div>
<br>
<div class="card">
<div class="card-header"><strong>Mask wearing:</strong></div>
<div class="card-body">
<ul>
<li><p class="data_text">Masks worn at workstations? {{ 'Yes' if form.mask_wearing_option == "mask_on" else 'No' }} </p></li>
{% if form.mask_wearing_option == "mask_on" %}
<li><p class="data_text">Mask type: {{ form.mask_type }}</p></li>
{% endif %}
</ul>
</div>
</div>
<br>
{% endblock simulation_overview %}
</div>
{% block report_preamble %}
{% endblock report_preamble %}
</div>
<div style="clear: both;"></div>
</div>
{% block disclaimer_container %}
{% block disclaimer_container %}
<br><br><br>
<div style="border: 2px solid black; padding: 15px;">
{% block disclaimer %}
{% block disclaimer %}
<p class="image"> <img align="middle" src="{{ calculator_prefix }}/static/images/disclaimer.jpg" width="40" height="40"><b>Disclaimer:</b><br><br></p>
<p>
CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.
</p>
<p>
CARA models the concentration profile of potential virions in enclosed spaces with clear and intuitive graphs.
The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation.
The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.
</p>
<p>
The risk assessment tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 infection therein.
The results DO NOT include short-range airborne exposure (where the physical distance is a significant factor) nor the other known modes of SARS-CoV-2 transmission.
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.
</p>
<p>
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021.
It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
</p>
<p>
Note that this model applies a deterministic approach, i.e., it is assumed at least one person is infected and shedding viruses into the simulated volume.
Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event.
The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and
the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings.
</p>
<p>
This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities.
The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk.
While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist.
Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions.
</p>
<p>
CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered
as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled.
</p>
<p>
CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.
</p>
<p>
CARA models the concentration profile of potential infectious viruses in enclosed spaces with clear and intuitive graphs.
The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation.
The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.
</p>
<p>
The risk assessment tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 infection therein.
The results DO NOT include short-range airborne exposure (where the physical distance is a significant factor) nor the other known modes of SARS-CoV-2 transmission.
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.
</p>
<p>
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021.
It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
</p>
<p>
Note that this model applies a deterministic approach, i.e., it is assumed at least one person is infected and shedding viruses into the simulated volume.
Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event.
The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and
the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings.
</p>
<p>
This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities.
The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk.
While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist.
Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions.
</p>
<p>
CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered
as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled.
</p>
{% endblock disclaimer %}
{% endblock disclaimer %}
</div>
{% endblock disclaimer_container %}
{% endblock report_footer %}
{% 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>
</body>
</html>

View file

@ -1,74 +1,85 @@
{% extends "base/calculator.report.html.j2" %}
{% block report_preamble_navtab %}
<li class="nav-item">
<a class="nav-link" id="rules-tab" data-toggle="tab" href="#rules" role="tab" aria-controls="rules" aria-selected="false">Applicable Rules</a>
</li>
{% endblock report_preamble_navtab %}
{% block report_preamble %}
<p><strong>Applicable rules: <br>
Please ensure that this scenario conforms to current COVID-related <a href="https://hse.cern/covid-19-information"> Health & Safety requirements</a>, under the applicable COVID Scale and measures in force at the time of the CARA assessment.</strong> <br>
The results of this simulation are colour coded according to the risk values authorized at CERN (approved in December 2020):
<ul><li>Events with a <span class="green_bkg">P(i) less than 5%</span> may go ahead without further mitigation measures.</li>
<li>Events with a <span class="yellow_bkg">P(i) between 5% and 15%</span> shall be subject to ALARA principles (see footnote) to minimise the risk before proceeding.</li>
<li>Events with a <span class="red_bkg">P(i) exceeding 15% or a number of expected new cases that exceeds 1</span> may not take place until additional measures are in place and a risk reduction has been performed.</li>
</ul>
</p>
{% endblock report_preamble %}
{% block warning_animation %}
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %} {% set warning_color= 'bg-danger' %}
{% elif (5 <= prob_inf <= 15) %} {% set warning_color = 'bg-warning' %}
{% elif (prob_inf < 5) %} {% set warning_color = 'bg-success' %}
{% endif %}
<div class="intro-banner-vdo-play-btn {{warning_color}} m-auto d-flex align-items-center justify-content-center">
<b>{{prob_inf | non_zero_percentage}}</b>
<i class="glyphicon glyphicon-play whiteText" aria-hidden="true"></i>
<span class="ripple {{warning_color}}"></span>
<span class="ripple {{warning_color}}"></span>
<span class="ripple {{warning_color}}"></span>
</div>
{% endblock warning_animation %}
{% block report_summary %}
<span
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
class="red_bkg"><strong>Not Acceptable:</strong>
{% elif 5 <= prob_inf <= 15 %}
class="yellow_bkg"><strong> Attention:</strong>
{% elif prob_inf < 5 %}
class="green_bkg">Acceptable:
{% endif %}
</span>
<div class="flex-row w-75 align-self-center">
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
<div class="alert alert-danger" role="alert">
<strong>Not Acceptable:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
{{ super() }}
</div>
{% elif 5 <= prob_inf <= 15 %}
<div class="alert alert-warning" role="alert">
<strong>Attention:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
</div>
{% elif prob_inf < 5 %}
<div class="alert alert-success" role="alert">
<strong>Acceptable:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
</div>
{% endif %}
{% if (prob_inf > 5) %}
<div class="flex" style="width:60%; align-self:center">
{% if scale_warning.level == "Green - 1" %}
<img class="warning_image_png" src="{{ calculator_prefix }}/static/images/warning_scale/Level1.png">
<div class="alert alert-dark warning_text" role="alert">
Note: the current CERN COVID Scale is <b>Green - 1</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. Align your risk assessment with the guidance and instructions provided by the HSE Unit.
</div>
{% elif scale_warning.level == "Yellow - 2" %}
<img class="warning_image_png" src="{{ calculator_prefix }}/static/images/warning_scale/Level2.png">
<div class="alert alert-dark warning_text" role="alert">
Note: the current CERN COVID Scale is <b>Yellow - 2</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a reduced chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. See with your supervisor if this scenario is acceptable.
</div>
{% elif scale_warning.level == "Orange - 3" %}
<img class="warning_image_png" src="{{ calculator_prefix }}/static/images/warning_scale/Level3.png">
<div class="alert alert-dark warning_text" role="alert">
Warning: the current CERN COVID Scale is <b>Orange - 3</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a slight chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. See with your supervisor if any additional measures can be applied (ALARA).
</div>
{% elif scale_warning.level == "Red - 4" %}
<img class="warning_image_png" src="{{ calculator_prefix }}/static/images/warning_scale/Level4.png">
<div class="alert alert-dark warning_text" role="alert">
Warning: the current CERN COVID Scale is <b>Red - 4</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a strong chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. Please reduce the value below the threshold of <b>{{scale_warning.threshold}}</b>.
</div>
{% else %}
<p><b>Note:</b> The CERN COVID Level is not specified.</p>
{% endif %}
{% if (prob_inf > 5) %}
{% if scale_warning.level == "green-1" %}
<div class="alert alert-dark" role="alert" style="height:fit-content">
Note: the current CERN COVID Scale is <b>Green - 1</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. Align your risk assessment with the guidance and instructions provided by the HSE Unit.
</div>
{% elif scale_warning.level == "yellow-2" %}
<div class="alert alert-dark" role="alert" style="height:fit-content">
Note: the current CERN COVID Scale is <b>Yellow - 2</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a reduced chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. See with your supervisor if this scenario is acceptable.
</div>
{% elif scale_warning.level == "orange-3" %}
<div class="alert alert-dark" role="alert" style="height:fit-content">
Warning: the current CERN COVID Scale is <b>Orange - 3</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a slight chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. See with your supervisor if any additional measures can be applied (ALARA).
</div>
{% elif scale_warning.level == "red-4" %}
<div class="alert alert-dark" role="alert" style="height:fit-content">
Warning: the current CERN COVID Scale is <b>Red - 4</b>, which means the incidence rate in the local community is <b>{{scale_warning.incidence_rate}}</b>. There is a strong chance that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site which, during this stage, corresponds to an average daily on-site access {{scale_warning.onsite_access}}. Please reduce the value below the threshold of <b>{{scale_warning.threshold}}</b>.
</div>
{% else %}
<p><b>Note:</b> The CERN COVID Level is not specified.</p>
{% endif %}
{% endif %}
</div>
{% endif %}
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
This exceeds the authorised risk threshold or number of expected new cases.
The risk level must be reduced before this activity can be undertaken.
{% elif (5 <= prob_inf <= 15) %}
This activity has an elevated level of risk, ALARA principles must be applied to minimise the level of risk before undertaking the activity.
See the footnotes for more details on the ALARA principles.
{% elif (prob_inf < 5) %}
This level of risk is within acceptable parameters, no further actions are required.
{% endif %}
{% endblock report_summary %}
{% block report_summary_footnote %}
{% if ((prob_inf > 15) or (expected_new_cases >= 1)) %}
This exceeds the authorised risk threshold or number of expected new cases.
The risk level must be reduced before this activity can be undertaken.
{% elif (5 <= prob_inf <= 15) %}
This activity has an elevated level of risk, ALARA principles must be applied to minimise the level of risk before undertaking the activity.
See the footnotes for more details on the ALARA principles.
{% elif (prob_inf < 5) %}
This level of risk is within acceptable parameters, no further actions are required.
{% endif %}
{% endblock report_summary_footnote %}
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<table class="table w-auto" style="height: fit-content;">
<thead class="thead-light">
<tr>
<th>Scenario</th>
@ -94,21 +105,51 @@
</table>
{% endblock report_scenarios_summary_table %}
{% block report_preamble %}
<div class="tab-pane" id="rules" role="tabpanel" aria-labelledby="rules-tab" style="padding: 1%">
<div class="card bg-light mb-3">
<div class="card-header"><strong>Applicable rules </strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseRules" role="button" aria-expanded="true" aria-controls="collapseRules">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<div class="collapse show" id="collapseRules">
<div class="card-body">
<p class="card-text">
Please ensure that this scenario conforms to current COVID-related <a href="https://hse.cern/covid-19-information"> Health & Safety requirements</a>, under the applicable COVID Scale and measures in force at the time of the CARA assessment.</strong> <br>
The results of this simulation are colour coded according to the risk values authorized at CERN (approved in December 2020):
</p>
<div class="d-flex">
<div class="d-flex flex-column justify-content-between">
<div class="alert alert-success ml-3" role="alert">Events with a <strong>P(i) less than 5%</strong> may go ahead without further mitigation measures.</div>
<div class="alert alert-warning ml-3" role="alert">Events with a <strong>P(i) between 5% and 15%</strong> shall be subject to ALARA principles (see footnote) to minimise the risk before proceeding.</div>
<div class="alert alert-danger ml-3 mb-0" role="alert">Events with a <strong>P(i) exceeding 15% or a number of expected new cases that exceeds 1</strong> may not take place until additional measures are in place and a risk reduction has been performed.</div>
</div>
<img class="rounded ml-4 align-self-center" src="{{ calculator_prefix }}/static/images/warning_scale/{{scale_warning.level}}.png">
</div>
<br>
<p class="data_text">
<strong> Footnotes for ALARA: </strong><br>
ALARA stands for As Low As Reasonably Achievable. It can be summarised based on 3 main points:
<ol>
<li>Justification - any exposure of persons has to be justified </li>
<li>Limitation - the personal doses have to be kept below the legal limits (in this case the CERN exposure limits)</li>
<li>Optimisation - the personal doses and collective doses have to be kept as low as reasonably achievable (ALARA).</li>
</ol>
For more information, please refer to <a href="https://cds.cern.ch/record/1533023/files/CERN-2013-001-p415.pdf"> this document from CERN HSE </a> and <a href="https://www.cdc.gov/nceh/radiation/safety.html#:~:text=ALARA%20stands%20for%20%E2%80%9Cas%20low,time%2C%20distance%2C%20and%20shielding."> this publication from the CDC.</a>
<br>
</p>
</div>
</div>
</div>
</div>
<div class="break-avoid fourth_page"></div>
{% endblock report_preamble %}
{% block report_footer %}
<p class="data_text">
<strong> Footnotes for ALARA: </strong><br>
ALARA stands for As Low As Reasonably Achievable. It can be summarised based on 3 main points:
<ol>
<li>Justification - any exposure of persons has to be justified </li>
<li>Limitation - the personal doses have to be kept below the legal limits (in this case the CERN exposure limits)</li>
<li>Optimisation - the personal doses and collective doses have to be kept as low as reasonably achievable (ALARA).</li>
</ol>
For more information, please refer to <a href="https://cds.cern.ch/record/1533023/files/CERN-2013-001-p415.pdf"> this document from CERN HSE </a> and <a href="https://www.cdc.gov/nceh/radiation/safety.html#:~:text=ALARA%20stands%20for%20%E2%80%9Cas%20low,time%2C%20distance%2C%20and%20shielding."> this publication from the CDC.</a>
<br><br>
</p>
{{ super() }}
{% endblock report_footer %}
{% block disclaimer %}
@ -118,6 +159,5 @@
</p>
{% endblock disclaimer %}
</body>
</html>

View file

@ -140,7 +140,7 @@ class ExposureModelResult(View):
ax.spines['top'].set_visible(False)
ax.set_xlabel('Time (hours)')
ax.set_ylabel('Concentration ($q/m^3$)')
ax.set_ylabel('Concentration ($virions/m^{3}$)')
ax.set_title('Concentration of virions')
else:
self.ax.ignore_existing_data_limits = True
@ -185,7 +185,7 @@ class ExposureComparissonResult(View):
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
ax.set_xlabel('Time (hours)')
ax.set_ylabel('Concentration ($q/m^3$)')
ax.set_ylabel('Concentration ($virions/m^{3}$)')
ax.set_title('Concentration of virions')
return ax
@ -488,14 +488,14 @@ baseline_model = models.ExposureModel(
room=models.Room(volume=75),
ventilation=models.SlidingWindow(
active=models.PeriodicInterval(period=120, duration=15),
inside_temp=models.PiecewiseConstant((0,24),(293.15,)),
outside_temp=models.PiecewiseConstant((0,24),(283.15,)),
inside_temp=models.PiecewiseConstant((0., 24.), (293.15,)),
outside_temp=models.PiecewiseConstant((0., 24.), (283.15,)),
window_height=1.6, opening_length=0.6,
),
infected=models.InfectedPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((8, 12), (13, 17))),
presence=models.SpecificInterval(((8., 12.), (13., 17.))),
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Seated'],
expiration=models.Expiration.types['Talking'],
@ -503,7 +503,7 @@ baseline_model = models.ExposureModel(
),
exposed=models.Population(
number=10,
presence=models.SpecificInterval(((8, 12), (13, 17))),
presence=models.SpecificInterval(((8., 12.), (13., 17.))),
activity=models.Activity.types['Seated'],
mask=models.Mask.types['No mask'],
),

View file

@ -30,15 +30,20 @@ Geneva_hourly_temperatures_celsius_per_hour = {
}
# Geneva hourly temperatures as piecewise constant function (in Kelvin)
# Geneva hourly temperatures as piecewise constant function (in Kelvin).
GenevaTemperatures_hourly = {
month: models.PiecewiseConstant(tuple(np.arange(25.)),
tuple(273.15+np.array(temperatures)))
for month,temperatures in Geneva_hourly_temperatures_celsius_per_hour.items()
}
# same temperatures on a finer temperature mesh
GenevaTemperatures = {
month: GenevaTemperatures_hourly[month].refine(refine_factor=4)
for month,temperatures in Geneva_hourly_temperatures_celsius_per_hour.items()
month: models.PiecewiseConstant(
# NOTE: It is important that the time type is float, not np.float, in
# order to allow hashability (for caching).
tuple(float(time) for time in range(25)),
tuple(273.15 + np.array(temperatures)),
)
for month, temperatures in Geneva_hourly_temperatures_celsius_per_hour.items()
}
# Same temperatures on a finer temperature mesh (every 6 minutes).
GenevaTemperatures = {
month: GenevaTemperatures_hourly[month].refine(refine_factor=10)
for month, temperatures in Geneva_hourly_temperatures_celsius_per_hour.items()
}

View file

@ -43,3 +43,24 @@ def replace(obj, **changes):
new = dataclasses.replace(obj, **changes)
object.__setattr__(obj, '__dataclass_fields__', orig)
return new
def walk_dataclass(model, name=""):
"""
Recursively walk a dataclass instance, generating (name, obj) pairs for
attributes and decending into nested dataclasses.
>>> list(walk_dataclass(obj), 'my_obj')
[('my_obj.attr_a', <dataclass instance>), ('my_obj.attr_a.sub_attr', <dataclass instance>)]
"""
if name:
name = name + '.'
if not dataclasses.is_dataclass(model):
raise TypeError(f'Not a dataclass based model: {type(model)}')
for field in dataclasses.fields(model):
obj = getattr(model, field.name)
fq_name = f'{name}{field.name}'
yield fq_name, obj
if dataclasses.is_dataclass(obj):
yield from walk_dataclass(obj, name=fq_name)

View file

@ -46,6 +46,8 @@ else:
# by providing a no-op cache decorator when type-checking.
cached = lambda *cached_args, **cached_kwargs: lambda function: function # noqa
from .utils import method_cache
from .dataclass_utils import nested_replace
@ -62,7 +64,7 @@ class Room:
volume: _VectorisedFloat
#: The humidity in the room (from 0 to 1 - e.g. 0.5 is 50% humidity)
humidity: _VectorisedFloat=0.5
humidity: _VectorisedFloat = 0.5
Time_t = typing.TypeVar('Time_t', float, int)
@ -127,7 +129,9 @@ class PeriodicInterval(Interval):
return tuple()
result = []
for i in np.arange(0, 24, self.period / 60):
result.append((i, i+self.duration/60))
# NOTE: It is important that the time type is float, not np.float, in
# order to allow hashability (for caching).
result.append((float(i), float(i+self.duration/60)))
return tuple(result)
@ -183,7 +187,9 @@ class PiecewiseConstant:
np.concatenate([self.values, self.values[-1:]], axis=0),
axis=0)
return PiecewiseConstant(
tuple(refined_times),
# NOTE: It is important that the time type is float, not np.float, in
# order to allow hashability (for caching).
tuple(float(time) for time in refined_times),
tuple(interpolator(refined_times)[:-1]),
)
@ -740,9 +746,12 @@ class ConcentrationModel:
# Deposition rate (h^-1)
k = (vg * 3600) / h
return k + self.virus.decay_constant(self.room.humidity
) + self.ventilation.air_exchange(self.room, time)
return (
k + self.virus.decay_constant(self.room.humidity)
+ self.ventilation.air_exchange(self.room, time)
)
@method_cache
def _concentration_limit(self, time: float) -> _VectorisedFloat:
"""
Provides a constant that represents the theoretical asymptotic
@ -754,29 +763,36 @@ class ConcentrationModel:
return (self.infected.emission_rate(time)) / (IVRR * V)
def state_change_times(self):
@method_cache
def state_change_times(self) -> typing.List[float]:
"""
All time dependent entities on this model must provide information about
the times at which their state changes.
"""
state_change_times = set()
state_change_times = {0.}
state_change_times.update(self.infected.presence.transition_times())
state_change_times.update(self.ventilation.transition_times())
return sorted(state_change_times)
def last_state_change(self, time: float):
def last_state_change(self, time: float) -> float:
"""
Find the most recent state change.
Find the most recent/previous state change.
Find the nearest time less than the given one. If there is a state
change exactly at ``time`` the previous state change is returned
(except at ``time == 0``).
"""
for change_time in self.state_change_times()[::-1]:
if change_time < time:
return change_time
return 0
times = self.state_change_times()
t_index: int = np.searchsorted(times, time) # type: ignore
# Search sorted gives us the index to insert the given time. Instead we
# want to get the index of the most recent time, so reduce the index by
# one unless we are already at 0.
t_index = max([t_index - 1, 0])
return times[t_index]
def _next_state_change(self, time: float):
def _next_state_change(self, time: float) -> float:
"""
Find the nearest future state change.
@ -789,16 +805,11 @@ class ConcentrationModel:
f"state change time ({change_time})"
)
def _is_interval_between_state_changes(self, start: float, stop: float) -> bool:
"""
Check that the times start and stop are in-between two state
changes of the concentration model (to ensure sure that all
model parameters stay constant between start and stop).
"""
return (self.last_state_change(stop) <= start)
@cached()
def _concentration_at_state_change(self, time: float) -> _VectorisedFloat:
@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 concentration(self, time: float) -> _VectorisedFloat:
@ -811,7 +822,6 @@ class ConcentrationModel:
Note that time is not vectorised. You can only pass a single float
to this method.
"""
if time == 0:
return 0.0
next_state_change_time = self._next_state_change(time)
@ -819,12 +829,13 @@ class ConcentrationModel:
concentration_limit = self._concentration_limit(next_state_change_time)
t_last_state_change = self.last_state_change(time)
concentration_at_last_state_change = self._concentration_at_state_change(t_last_state_change)
concentration_at_last_state_change = self._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
@method_cache
def integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat:
"""
Get the integrated concentration dose between the times start and stop.
@ -839,7 +850,7 @@ class ConcentrationModel:
start = max([interval_start, req_start])
stop = min([interval_stop, req_stop])
conc_start = self.concentration(start)
conc_start = self._concentration_cached(start)
next_conc_state = self._next_state_change(stop)
conc_limit = self._concentration_limit(next_conc_state)

View file

@ -2,10 +2,13 @@ import concurrent.futures
from functools import partial
import time
import numpy.testing
import numpy as np
import pytest
from cara.apps.calculator.report_generator import ReportGenerator, readable_minutes
from cara.apps.calculator import make_app
from cara.apps.calculator.report_generator import ReportGenerator, readable_minutes
import cara.apps.calculator.report_generator as rep_gen
def test_generate_report(baseline_form):
@ -38,3 +41,50 @@ def test_generate_report(baseline_form):
)
def test_readable_minutes(test_input, expected):
assert readable_minutes(test_input) == expected
def test_fill_big_gaps():
expected = [1, 1.75, 2, 2.75, 3.5, 4]
assert rep_gen.fill_big_gaps([1, 2, 4], gap_size=0.75) == expected
def test_fill_big_gaps__float_tolerance():
# Ensure that there is some float tolerance to the gap size check.
assert rep_gen.fill_big_gaps([0, 2 + 1e-15, 4], gap_size=2) == [0, 2 + 1e-15, 4]
assert rep_gen.fill_big_gaps([0, 2 + 1e-14, 4], gap_size=2) == [0, 2, 2 + 1e-14, 4]
def test_non_temp_transition_times(baseline_exposure_model):
expected = [0.0, 4.0, 5.0, 8.0]
result = rep_gen.non_temp_transition_times(baseline_exposure_model)
assert result == expected
def test_interesting_times_many(baseline_exposure_model):
result = rep_gen.interesting_times(baseline_exposure_model, approx_n_pts=100)
assert 100 <= len(result) <= 120
assert np.abs(np.diff(result)).max() < 8.1/100.
def test_interesting_times_small(baseline_exposure_model):
expected = [0.0, 0.8, 1.6, 2.4, 3.2, 4.0, 4.8, 5.0, 5.8, 6.6, 7.4, 8.0]
# Ask for more data than there is in the transition times.
result = rep_gen.interesting_times(baseline_exposure_model, approx_n_pts=10)
np.testing.assert_allclose(result, expected, atol=1e-04)
def test_interesting_times_w_temp(exposure_model_w_outside_temp_changes):
# Ensure that the state change times are returned (minus the temperature changes) by
# requesting n_points=1.
result = rep_gen.interesting_times(exposure_model_w_outside_temp_changes, approx_n_pts=1)
expected = [0., 1.8, 2.2, 4., 4.4, 5., 6.2, 6.6, 8.]
np.testing.assert_allclose(result, expected)
# Now request more than the state-change times.
result = rep_gen.interesting_times(exposure_model_w_outside_temp_changes, approx_n_pts=20)
expected = [
0., 0.4, 0.8, 1.2, 1.6, 1.8, 2.2, 2.6, 3., 3.4, 3.8, 4., 4.4, 4.8,
5., 5.4, 5.8, 6.2, 6.6, 7., 7.4, 7.8, 8.
]
np.testing.assert_allclose(result, expected)

View file

@ -1,4 +1,6 @@
from cara import models
import cara.data
import cara.dataclass_utils
import pytest
@ -8,13 +10,13 @@ def baseline_model():
model = models.ConcentrationModel(
room=models.Room(volume=75),
ventilation=models.AirChange(
active=models.SpecificInterval(((0,24),)),
active=models.SpecificInterval(((0., 24.), )),
air_exch=30.,
),
infected=models.InfectedPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((0, 4), (5, 8))),
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'],
@ -33,5 +35,20 @@ def baseline_exposure_model(baseline_model):
activity=baseline_model.infected.activity,
mask=baseline_model.infected.mask,
),
fraction_deposited = 1.,
fraction_deposited=1.,
)
@pytest.fixture
def exposure_model_w_outside_temp_changes(baseline_exposure_model: models.ExposureModel):
exp_model = cara.dataclass_utils.nested_replace(
baseline_exposure_model, {
'concentration_model.ventilation': models.SlidingWindow(
active=models.PeriodicInterval(2.2 * 60, 1.8 * 60),
inside_temp=models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=cara.data.GenevaTemperatures['Jan'],
window_height=1.6,
opening_length=0.6,
)
})
return exp_model

View file

@ -53,7 +53,7 @@ def test_concentration_model_vectorisation(override_params):
@pytest.fixture
def simple_conc_model():
interesting_times = models.SpecificInterval(([0, 1], [1.1, 1.999], [2, 3]), )
interesting_times = models.SpecificInterval(([0.5, 1.], [1.1, 2], [2., 3.]), )
return models.ConcentrationModel(
models.Room(75),
models.AirChange(interesting_times, 100),
@ -68,14 +68,38 @@ def simple_conc_model():
)
@pytest.mark.parametrize(
"time, expected_last_state_change", [
[-15., 0.], # Out of range goes to the first state.
[0., 0.],
[0.5, 0.0],
[0.51, 0.5],
[1., 0.5],
[1.05, 1.],
[1.1, 1.],
[1.11, 1.1],
[2., 1.1],
[2.1, 2],
[3., 2],
[15., 3.], # Out of range goes to the last state.
]
)
def test_last_state_change_time(
simple_conc_model: models.ConcentrationModel,
time,
expected_last_state_change,
):
assert simple_conc_model.last_state_change(float(time)) == expected_last_state_change
@pytest.mark.parametrize(
"time, expected_next_state_change", [
[0, 0],
[0.0, 0.0],
[0.5, 0.5],
[1, 1],
[1.05, 1.1],
[1.1, 1.1],
[1.11, 1.999],
[1.9991, 2],
[1.11, 2],
[2, 2],
[2.1, 3],
[3, 3],
@ -86,35 +110,17 @@ def test_next_state_change_time(
time,
expected_next_state_change,
):
assert simple_conc_model._next_state_change(time) == expected_next_state_change
assert simple_conc_model._next_state_change(float(time)) == expected_next_state_change
def test_next_state_change_time_out_of_range(simple_conc_model: models.ConcentrationModel):
with pytest.raises(
ValueError,
match=re.escape("The requested time (3.1) is greater than last available state change time (3)")
match=re.escape("The requested time (3.1) is greater than last available state change time (3.0)")
):
simple_conc_model._next_state_change(3.1)
@pytest.mark.parametrize(
"start, stop, is_valid", [
[0, 1.05, False],
[0.99, 1.1, False],
[0.5, 1.01, False],
[0, 1, True],
[1.01, 1.1, True],
[0.01, 1, True],
[1.11, 1.99, True],
]
)
def test_valid_interval(
start, stop, is_valid,
simple_conc_model: models.ConcentrationModel
):
assert simple_conc_model._is_interval_between_state_changes(start, stop) == is_valid
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

@ -7,6 +7,7 @@ from dataclasses import dataclass
from cara import models
from cara.models import ExposureModel
from cara.dataclass_utils import replace
@dataclass(frozen=True)
@ -26,10 +27,10 @@ class KnownConcentrations(models.ConcentrationModel):
return self.concentration_function(time)
def state_change_times(self):
return [0, 24]
return [0., 24.]
def _next_state_change(self, time: float):
return 24
return 24.
def concentration(self, time: float) -> models._VectorisedFloat: # noqa
return self.concentration_function(time)
@ -53,37 +54,37 @@ populations = [
models.Activity(np.array([0.51, 0.57]), 0.57),
),
]
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'],
activity=models.Activity.types['Standing'],
virus=models.Virus.types['SARS_CoV_2_B117'],
expiration=models.Expiration.types['Talking']
)
def known_concentrations(func):
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'],
activity=models.Activity.types['Standing'],
virus=models.Virus.types['SARS_CoV_2_B117'],
expiration=models.Expiration.types['Talking']
)
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.,
[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.,
[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.,
[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.,
[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.]),
[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,
@ -104,8 +105,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])
@ -135,8 +136,9 @@ 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]))
always = models.SpecificInterval(((0, 24),))
([0., 1.], [1.01, 1.02], [12., 24.]),
)
always = models.SpecificInterval(((0., 24.), ))
return models.ConcentrationModel(
models.Room(25),
models.AirChange(always, 5),
@ -150,18 +152,19 @@ def conc_model():
)
)
# expected exposure were computed with a trapezoidal integration, using
# 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],
]
@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],
]
)
def test_exposure_model_integral_accuracy(exposed_time_interval,
expected_exposure, conc_model):
@ -186,10 +189,10 @@ def test_infectious_dose_vectorisation():
),
expiration=models.Expiration.types['Talking']
)
cm = KnownConcentrations(
dummy_room, dummy_ventilation, infected_population, lambda t: 1.2)
cm = known_concentrations(lambda t: 1.2)
cm = replace(cm, infected=infected_population)
presence_interval = models.SpecificInterval(((0, 1),))
presence_interval = models.SpecificInterval(((0., 1.),))
population = models.Population(
10, presence_interval, models.Mask.types['Type I'],
models.Activity.types['Standing'],

View file

@ -1,6 +1,6 @@
import dataclasses
from cara.dataclass_utils import nested_replace
from cara.dataclass_utils import nested_replace, walk_dataclass
@dataclasses.dataclass(frozen=True)
@ -25,3 +25,15 @@ def test_nested_replace():
inst = One(1, two=Two(3, Four(4)))
new_inst = nested_replace(inst, {'two.four': Four(5)})
assert new_inst == One(1, two=Two(3, Four(5)))
def test_walk():
inst = One(1, two=Two(3, Four(4)))
expected = [
('inst.one', inst.one),
('inst.two', inst.two),
('inst.two.three', inst.two.three),
('inst.two.four', inst.two.four),
('inst.two.four.four', inst.two.four.four),
]
assert list(walk_dataclass(inst, name='inst')) == expected

View file

@ -9,7 +9,7 @@ import cara.data as data
def test_no_mask_superspeading_emission_rate(baseline_model):
expected_rate = 48500.
npt.assert_allclose(
[baseline_model.infected.emission_rate(t) for t in [0, 1, 4, 4.5, 5, 8, 9]],
[baseline_model.infected.emission_rate(float(t)) for t in [0, 1, 4, 4.5, 5, 8, 9]],
[0, expected_rate, expected_rate, 0, 0, expected_rate, 0],
rtol=1e-12
)
@ -19,8 +19,8 @@ def test_no_mask_superspeading_emission_rate(baseline_model):
def baseline_periodic_window():
return models.SlidingWindow(
active=models.PeriodicInterval(period=120, duration=15),
inside_temp=models.PiecewiseConstant((0,24),(293,)),
outside_temp=models.PiecewiseConstant((0,24),(283,)),
inside_temp=models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=models.PiecewiseConstant((0., 24.), (283,)),
window_height=1.6, opening_length=0.6,
)
@ -41,7 +41,7 @@ def baseline_periodic_hepa():
def test_concentrations(baseline_model):
# expected concentrations were computed analytically
ts = [0, 4, 5, 7, 10]
concentrations = [baseline_model.concentration(t) for t in ts]
concentrations = [baseline_model.concentration(float(t)) for t in ts]
npt.assert_allclose(
concentrations,
[0.000000e+00, 20.805628, 6.602814e-13, 20.805628, 2.09545e-26],
@ -55,7 +55,7 @@ def test_smooth_concentrations(baseline_model):
dx = 0.002
dy_limit = 0.2 # Anything more than this (in relative) is a bit steep.
ts = np.arange(0, 10, dx)
concentrations = [baseline_model.concentration(t) for t in ts]
concentrations = [baseline_model.concentration(float(t)) for t in ts]
assert np.abs(np.diff(concentrations)).max()/np.mean(concentrations) < dy_limit
@ -69,7 +69,7 @@ def build_model(interval_duration):
infected=models.InfectedPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((0, 4), (5, 8))),
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'],
@ -78,7 +78,7 @@ def build_model(interval_duration):
return model
def test_concentrations_startup(baseline_model):
def test_concentrations_startup():
# The concentrations should be the same until the beginning of the
# first time that the ventilation is disabled.
m1 = build_model(interval_duration=120)
@ -183,28 +183,38 @@ def test_multiple_ventilation_HEPA_HVAC_AirChange(volume, expected_value):
],
)
def test_windowopening(time, expected_value):
tempOutside = models.PiecewiseConstant((0,10,24),(273.15,283.15))
tempInside = models.PiecewiseConstant((0,24),(293.15,))
w = models.SlidingWindow(active=models.SpecificInterval([(0,24)]),
inside_temp=tempInside,outside_temp=tempOutside,
window_height=1.,opening_length=0.6)
npt.assert_allclose(w.air_exchange(models.Room(volume=68),time),
expected_value,rtol=1e-5)
tempOutside = models.PiecewiseConstant((0., 10., 24.),(273.15, 283.15))
tempInside = models.PiecewiseConstant((0., 24.), (293.15,))
w = models.SlidingWindow(
active=models.SpecificInterval([(0., 24.)]),
inside_temp=tempInside,outside_temp=tempOutside,
window_height=1., opening_length=0.6,
)
npt.assert_allclose(
w.air_exchange(models.Room(volume=68), time), expected_value, rtol=1e-5
)
def build_hourly_dependent_model(month, intervals_open=((7.5, 8.5),),
intervals_presence_infected=((0, 4), (5, 7.5)),
artificial_refinement=False,
temperatures=data.GenevaTemperatures_hourly):
def build_hourly_dependent_model(
month,
intervals_open=((7.5, 8.5),),
intervals_presence_infected=((0., 4.), (5., 7.5)),
artificial_refinement=False,
temperatures=data.GenevaTemperatures_hourly
):
if artificial_refinement:
# 5-fold increase of number of times, WITHOUT interpolation
# (hence transparent for the results)
refine_factor = 2
times_refined = tuple(np.linspace(0.,24,
refine_factor*len(temperatures[month].values)+1))
temperatures_refined = tuple(np.hstack([[v]*refine_factor
for v in temperatures[month].values]))
outside_temp = models.PiecewiseConstant(times_refined,temperatures_refined)
times_refined = tuple(
float(t) for t in np.linspace(
0., 24, refine_factor * len(temperatures[month].values) + 1
)
)
temperatures_refined = tuple(np.hstack(
[[v] * refine_factor for v in temperatures[month].values]
))
outside_temp = models.PiecewiseConstant(times_refined, temperatures_refined)
else:
outside_temp = temperatures[month]
@ -212,7 +222,7 @@ def build_hourly_dependent_model(month, intervals_open=((7.5, 8.5),),
room=models.Room(volume=75),
ventilation=models.SlidingWindow(
active=models.SpecificInterval(intervals_open),
inside_temp=models.PiecewiseConstant((0,24),(293,)),
inside_temp=models.PiecewiseConstant((0., 24.), (293, )),
outside_temp=outside_temp,
window_height=1.6, opening_length=0.6,
),
@ -233,14 +243,14 @@ def build_constant_temp_model(outside_temp, intervals_open=((7.5, 8.5),)):
room=models.Room(volume=75),
ventilation=models.SlidingWindow(
active=models.SpecificInterval(intervals_open),
inside_temp=models.PiecewiseConstant((0,24),(293,)),
outside_temp=models.PiecewiseConstant((0,24),(outside_temp,)),
inside_temp=models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=models.PiecewiseConstant((0., 24.), (outside_temp,)),
window_height=1.6, opening_length=0.6,
),
infected=models.InfectedPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((0, 4), (5, 7.5))),
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'],
@ -253,21 +263,22 @@ def build_hourly_dependent_model_multipleventilation(month, intervals_open=((7.5
vent = models.MultipleVentilation((
models.SlidingWindow(
active=models.SpecificInterval(intervals_open),
inside_temp=models.PiecewiseConstant((0,24),(293,)),
inside_temp=models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=data.GenevaTemperatures[month],
window_height=1.6, opening_length=0.6,
),
models.HEPAFilter(
active=models.SpecificInterval(((0,24),)),
q_air_mech=500.,
)))
active=models.SpecificInterval(((0., 24.),)),
q_air_mech=500.,
),
))
model = models.ConcentrationModel(
room=models.Room(volume=75),
ventilation=vent,
infected=models.InfectedPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(((0, 4), (5, 7.5))),
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'],
@ -288,7 +299,7 @@ def test_concentrations_hourly_dep_temp_vs_constant(month, temperatures, time):
# The concentrations should be the same up to 8 AM (time when the
# temperature changes DURING the window opening).
m1 = build_hourly_dependent_model(month)
m2 = build_constant_temp_model(temperatures[7]+273.15)
m2 = build_constant_temp_model(temperatures[7] + 273.15)
npt.assert_allclose(m1.concentration(time), m2.concentration(time), rtol=1e-5)
@pytest.mark.parametrize(
@ -301,9 +312,12 @@ def test_concentrations_hourly_dep_temp_vs_constant(month, temperatures, time):
)
def test_concentrations_hourly_dep_temp_startup(month, temperatures, time):
# The concentrations should be the zero up to the first presence time
# of an infecter person.
m = build_hourly_dependent_model(month,((0.,0.5),(1,1.5),(4,4.5),(7.5,8)),
((8,12.),))
# of an infected person.
m = build_hourly_dependent_model(
month,
((0., 0.5), (1., 1.5), (4., 4.5), (7.5, 8), ),
((8., 12.), ),
)
assert m.concentration(time) == 0.
@ -323,19 +337,22 @@ def test_concentrations_hourly_dep_multipleventilation():
def test_concentrations_hourly_dep_adding_artificial_transitions(month_temp_item, time):
month, temperatures = month_temp_item
# Adding a second opening inside the first one should not change anything
m1 = build_hourly_dependent_model(month,intervals_open=((7.5, 8.5),))
m2 = build_hourly_dependent_model(month,intervals_open=((7.5, 8.5),(8.,8.1)))
m1 = build_hourly_dependent_model(month, intervals_open=((7.5, 8.5), ))
m2 = build_hourly_dependent_model(month, intervals_open=((7.5, 8.5), (8., 8.1), ))
npt.assert_allclose(m1.concentration(time), m2.concentration(time), rtol=1e-5)
@pytest.mark.parametrize(
"time",
list(np.random.random_sample(10)*24.)+list(np.arange(0,24.5,0.5)),
(
[float(t) for t in np.random.random_sample(10) * 24.] # type: ignore
+ [float(t) for t in np.arange(0, 24.5, 0.5)]
),
)
def test_concentrations_refine_times(time):
month = 'Jan'
m1 = build_hourly_dependent_model(month,intervals_open=((0, 24),))
m2 = build_hourly_dependent_model(month,intervals_open=((0, 24),),
m1 = build_hourly_dependent_model(month, intervals_open=((0., 24.),))
m2 = build_hourly_dependent_model(month, intervals_open=((0., 24.),),
artificial_refinement=True)
npt.assert_allclose(m1.concentration(time), m2.concentration(time), rtol=1e-8)
@ -350,7 +367,7 @@ def build_exposure_model(concentration_model):
activity=infected.activity,
mask=infected.mask,
),
fraction_deposited = 1.,
fraction_deposited=1.,
)
@ -367,8 +384,8 @@ def test_exposure_hourly_dep(month,expected_exposure):
m = build_exposure_model(
build_hourly_dependent_model(
month,
intervals_open=((0,24),),
intervals_presence_infected=((8, 12), (13, 17))
intervals_open=((0., 24.), ),
intervals_presence_infected=((8., 12.), (13., 17.))
)
)
exposure = m.exposure()
@ -388,8 +405,8 @@ def test_exposure_hourly_dep_refined(month,expected_exposure):
m = build_exposure_model(
build_hourly_dependent_model(
month,
intervals_open=((0, 24),),
intervals_presence_infected=((8, 12), (13, 17)),
intervals_open=((0., 24.),),
intervals_presence_infected=((8., 12.), (13., 17.)),
temperatures=data.GenevaTemperatures,
)
)

View file

@ -43,14 +43,14 @@ def baseline_mc_model() -> cara.monte_carlo.ConcentrationModel:
room=cara.monte_carlo.Room(volume=cara.monte_carlo.sampleable.Normal(75, 20)),
ventilation=cara.monte_carlo.SlidingWindow(
active=cara.models.PeriodicInterval(period=120, duration=120),
inside_temp=cara.models.PiecewiseConstant((0, 24), (293,)),
outside_temp=cara.models.PiecewiseConstant((0, 24), (283,)),
inside_temp=cara.models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=cara.models.PiecewiseConstant((0., 24.), (283,)),
window_height=1.6, opening_length=0.6,
),
infected=cara.models.InfectedPopulation(
number=1,
virus=cara.models.Virus.types['SARS_CoV_2'],
presence=cara.models.SpecificInterval(((0, 4), (5, 8))),
presence=cara.models.SpecificInterval(((0., 4.), (5., 8.))),
mask=cara.models.Mask.types['No mask'],
activity=cara.models.Activity.types['Light activity'],
expiration=cara.models.Expiration.types['Breathing'],
@ -75,8 +75,8 @@ def baseline_mc_exposure_model(baseline_mc_model) -> cara.monte_carlo.ExposureMo
def test_build_concentration_model(baseline_mc_model: cara.monte_carlo.ConcentrationModel):
model = baseline_mc_model.build_model(7)
assert isinstance(model, cara.models.ConcentrationModel)
assert isinstance(model.concentration(time=0), float)
conc = model.concentration(time=1)
assert isinstance(model.concentration(time=0.), float)
conc = model.concentration(time=1.)
assert isinstance(conc, np.ndarray)
assert conc.shape == (7, )

View file

@ -26,12 +26,12 @@ def shared_office_mc():
(
models.SlidingWindow(
active=models.PeriodicInterval(period=120, duration=10),
inside_temp=models.PiecewiseConstant((0, 24), (293,)),
outside_temp=models.PiecewiseConstant((0, 24), (283,)),
inside_temp=models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=models.PiecewiseConstant((0., 24.), (283,)),
window_height=1.6, opening_length=0.6,
),
models.AirChange(
active=models.SpecificInterval(((0,24),)),
active=models.SpecificInterval(((0., 24.), )),
air_exch=0.25,
),
),
@ -39,7 +39,7 @@ def shared_office_mc():
infected=mc.InfectedPopulation(
number=1,
virus=virus_distributions['SARS_CoV_2_B117'],
presence=mc.SpecificInterval(((0, 2), (2.1, 4), (5, 7), (7.1, 9))),
presence=mc.SpecificInterval(((0., 2.), (2.1, 4.), (5., 7.), (7.1, 9.))),
mask=models.Mask(η_inhale=0.3),
activity=activity_distributions['Seated'],
expiration=models.MultipleExpiration(
@ -70,12 +70,12 @@ def classroom_mc():
(
models.SlidingWindow(
active=models.PeriodicInterval(period=120, duration=10),
inside_temp=models.PiecewiseConstant((0, 24), (293,)),
outside_temp=models.PiecewiseConstant((0, 24), (283,)),
inside_temp=models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=models.PiecewiseConstant((0., 24.), (283,)),
window_height=1.6, opening_length=0.6,
),
models.AirChange(
active=models.SpecificInterval(((0,24),)),
active=models.SpecificInterval(((0., 24.),)),
air_exch=0.25,
),
),
@ -83,7 +83,7 @@ def classroom_mc():
infected=mc.InfectedPopulation(
number=1,
virus=virus_distributions['SARS_CoV_2_B117'],
presence=mc.SpecificInterval(((0, 2), (2.5, 4), (5, 7), (7.5, 9))),
presence=mc.SpecificInterval(((0., 2.), (2.5, 4.), (5., 7.), (7.5, 9.))),
mask=models.Mask.types['No mask'],
activity=activity_distributions['Light activity'],
expiration=models.Expiration.types['Talking'],
@ -108,13 +108,13 @@ def ski_cabin_mc():
concentration_mc = mc.ConcentrationModel(
room=models.Room(volume=10, humidity=0.5),
ventilation=models.AirChange(
active=models.SpecificInterval(((0,24),)),
active=models.SpecificInterval(((0., 24.),)),
air_exch=0,
),
infected=mc.InfectedPopulation(
number=1,
virus=virus_distributions['SARS_CoV_2_B117'],
presence=mc.SpecificInterval(((0, 1/3),)),
presence=mc.SpecificInterval(((0., 1/3),)),
mask=models.Mask(η_inhale=0.3),
activity=activity_distributions['Moderate activity'],
expiration=models.Expiration.types['Talking'],
@ -141,13 +141,13 @@ def gym_mc():
concentration_mc = mc.ConcentrationModel(
room=models.Room(volume=300, humidity=0.5),
ventilation=models.AirChange(
active=models.SpecificInterval(((0,24),)),
active=models.SpecificInterval(((0., 24.),)),
air_exch=6,
),
infected=mc.InfectedPopulation(
number=2,
virus=virus_distributions['SARS_CoV_2_B117'],
presence=mc.SpecificInterval(((0, 1),)),
presence=mc.SpecificInterval(((0., 1.),)),
mask=models.Mask.types["No mask"],
activity=activity_distributions['Heavy exercise'],
expiration=models.Expiration.types['Breathing'],
@ -173,13 +173,13 @@ def waiting_room_mc():
concentration_mc = mc.ConcentrationModel(
room=models.Room(volume=100, humidity=0.5),
ventilation=models.AirChange(
active=models.SpecificInterval(((0,24),)),
active=models.SpecificInterval(((0., 24.),)),
air_exch=0.25,
),
infected=mc.InfectedPopulation(
number=1,
virus=virus_distributions['SARS_CoV_2_B117'],
presence=mc.SpecificInterval(((0, 2),)),
presence=mc.SpecificInterval(((0., 2.),)),
mask=models.Mask.types["No mask"],
activity=activity_distributions['Seated'],
expiration=models.MultipleExpiration(
@ -215,7 +215,7 @@ def skagit_chorale_mc():
infected=mc.InfectedPopulation(
number=1,
virus=virus_distributions['SARS_CoV_2'],
presence=mc.SpecificInterval(((0, 2.5),)),
presence=mc.SpecificInterval(((0., 2.5),)),
mask=models.Mask.types["No mask"],
activity=activity_distributions['Light activity'],
expiration=models.Expiration((5., 5., 5.)),
@ -274,13 +274,13 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi,
ventilation=models.MultipleVentilation(
(
models.SlidingWindow(
active=models.SpecificInterval(((0,24),)),
inside_temp=models.PiecewiseConstant((0, 24), (293,)),
active=models.SpecificInterval(((0., 24.),)),
inside_temp=models.PiecewiseConstant((0., 24.), (293,)),
outside_temp=data.GenevaTemperatures[month],
window_height=1.5, opening_length=0.2,
),
models.AirChange(
active=models.SpecificInterval(((0,24),)),
active=models.SpecificInterval(((0., 24.),)),
air_exch=0.25,
),
),
@ -288,7 +288,7 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi,
infected=mc.InfectedPopulation(
number=1,
virus=virus_distributions['SARS_CoV_2_B117'],
presence=mc.SpecificInterval(((9, 10+2/3), (10+5/6, 12.5), (13.5, 15+2/3), (15+5/6, 18))),
presence=mc.SpecificInterval(((9., 10+2/3), (10+5/6, 12.5), (13.5, 15+2/3), (15+5/6, 18.))),
mask=models.Mask.types[mask_type],
activity=activity_distributions['Seated'],
expiration=models.MultipleExpiration(

27
cara/utils.py Normal file
View file

@ -0,0 +1,27 @@
import functools
def method_cache(fn):
"""
A decorator for instance based caching.
Unlike lru_cache / memoization, this allows us to not have to have the
instance itself be hashable - only the arguments must be so.
The cache is stored as a dictionary in a private attribute on the instance
with the name ``_cache_{func_name}``.
"""
cache_name = f'_cache_{fn.__name__}'
@functools.wraps(fn)
def cached_method(self, *args, **kwargs):
cache = getattr(self, cache_name, None)
if cache is None:
cache = {}
object.__setattr__(self, cache_name, cache)
cache_key = hash(args + tuple(kwargs.items()))
if cache_key not in cache:
cache[cache_key] = fn(self, *args, **kwargs)
return cache[cache_key]
return cached_method