diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index d41ad965..8e3ddd0b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index b3a5065f..00000000 --- a/Dockerfile +++ /dev/null @@ -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"] diff --git a/app-config/cara-public-docker-image/Dockerfile b/app-config/cara-public-docker-image/Dockerfile new file mode 100644 index 00000000..d61f49d7 --- /dev/null +++ b/app-config/cara-public-docker-image/Dockerfile @@ -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"] diff --git a/app-config/cara-public-docker-image/nginx.conf b/app-config/cara-public-docker-image/nginx.conf new file mode 100644 index 00000000..b873a7d2 --- /dev/null +++ b/app-config/cara-public-docker-image/nginx.conf @@ -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; + } + } +} diff --git a/app-config/cara-public-docker-image/run_cara.sh b/app-config/cara-public-docker-image/run_cara.sh new file mode 100755 index 00000000..43d963b2 --- /dev/null +++ b/app-config/cara-public-docker-image/run_cara.sh @@ -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 diff --git a/app-config/openshift/buildconfig.yaml b/app-config/openshift/buildconfig.yaml index 52dd8380..822050c0 100644 --- a/app-config/openshift/buildconfig.yaml +++ b/app-config/openshift/buildconfig.yaml @@ -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 diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py index 15572267..dbfa15ef 100644 --- a/cara/apps/calculator/__init__.py +++ b/cara/apps/calculator/__init__.py @@ -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): diff --git a/cara/apps/calculator/__main__.py b/cara/apps/calculator/__main__.py index 10298c84..7ec61206 100644 --- a/cara/apps/calculator/__main__.py +++ b/cara/apps/calculator/__main__.py @@ -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() diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 852b21b1..a4b139e7 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -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: diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index bf91281c..7acfe8c4 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -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 diff --git a/cara/apps/calculator/static/css/report.css b/cara/apps/calculator/static/css/report.css index f6efde67..0e0a7cf7 100644 --- a/cara/apps/calculator/static/css/report.css +++ b/cara/apps/calculator/static/css/report.css @@ -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; + } } \ No newline at end of file diff --git a/cara/apps/calculator/static/images/warning_scale/Level1.png b/cara/apps/calculator/static/images/warning_scale/green-1.png similarity index 100% rename from cara/apps/calculator/static/images/warning_scale/Level1.png rename to cara/apps/calculator/static/images/warning_scale/green-1.png diff --git a/cara/apps/calculator/static/images/warning_scale/Level3.png b/cara/apps/calculator/static/images/warning_scale/orange-3.png similarity index 100% rename from cara/apps/calculator/static/images/warning_scale/Level3.png rename to cara/apps/calculator/static/images/warning_scale/orange-3.png diff --git a/cara/apps/calculator/static/images/warning_scale/Level4.png b/cara/apps/calculator/static/images/warning_scale/red-4.png similarity index 100% rename from cara/apps/calculator/static/images/warning_scale/Level4.png rename to cara/apps/calculator/static/images/warning_scale/red-4.png diff --git a/cara/apps/calculator/static/images/warning_scale/Level2.png b/cara/apps/calculator/static/images/warning_scale/yellow-2.png similarity index 100% rename from cara/apps/calculator/static/images/warning_scale/Level2.png rename to cara/apps/calculator/static/images/warning_scale/yellow-2.png diff --git a/cara/apps/calculator/static/js/pdf.js b/cara/apps/calculator/static/js/pdf.js new file mode 100644 index 00000000..6afb4fea --- /dev/null +++ b/cara/apps/calculator/static/js/pdf.js @@ -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(); +}; \ No newline at end of file diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js index 99499e8d..d1105bec 100644 --- a/cara/apps/calculator/static/js/report.js +++ b/cara/apps/calculator/static/js/report.js @@ -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('Mean concentration of virions'); // 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) diff --git a/cara/apps/calculator/templates/base/calculator.report.html.j2 b/cara/apps/calculator/templates/base/calculator.report.html.j2 index d45a089a..8a36f52b 100644 --- a/cara/apps/calculator/templates/base/calculator.report.html.j2 +++ b/cara/apps/calculator/templates/base/calculator.report.html.j2 @@ -8,7 +8,6 @@ - @@ -18,309 +17,419 @@ {% block report_header %} -
-
+ Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}
+Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}
Simulation:
- -Simulation Name: {{ form.simulation_name }}
-Room Number: {{ form.room_number }}
- -Input data:
-Virus variant: - {% if form.virus_type == "SARS_CoV_2" %} - SARS-CoV-2 (nominal strain) - {% elif form.virus_type == "SARS_CoV_2_B117" %} - SARS-CoV-2 (Alpha VOC) - {% elif form.virus_type == "SARS_CoV_2_P1" %} - SARS-CoV-2 (Gamma VOC) - {% elif form.virus_type == "SARS_CoV_2_B16172" %} - SARS-CoV-2 (Delta VOC) - {% endif %} -
Room Volume: {{ model.concentration_model.room.volume }} m³
Room Central Heating: {{ "On" if form.room_heating_option else "Off" }}
Ventilation data:
-Mechanical ventilation: - {% if form.ventilation_type == "mechanical_ventilation" %} - Yes
- {% 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 %} -
Natural ventilation: - {% if form.ventilation_type == "natural_ventilation"%} - Yes
Number of windows: {{ form.windows_number }}
Height of window: {{ form.window_height }} m
Window type: - {% if form.window_type == "window_hinged" %} - Top- or Bottom-Hung
Width of window: {{ form.window_width }} m
Opening distance: {{ form.opening_distance }} m
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 %} -
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.
- {% else %} - No - {% endif %} -HEPA Filtration: {{ 'Yes' if form.hepa_option else 'No' }}
HEPA amount: {{ form.hepa_amount }} m³ / hour
+
* The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004.
+ + + +Event data:
-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.
- 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 %} -
Exposed occupant(s) activity time:
Start time: {{ form.exposed_start | minutes_to_time }}    End time: {{ form.exposed_finish | minutes_to_time }}
Infected occupant(s) activity time:
Start time: {{ form.infected_start | minutes_to_time }}    End time: {{ form.infected_finish | minutes_to_time }}
Event for the month of {{ form.event_month }}
| Scenario | +P(I) | +Expected new cases | +
|---|---|---|
| {{ scenario_name }} | +{{ scenario_stats.probability_of_infection | non_zero_percentage }} | +{{ scenario_stats.expected_new_cases | float_format }} | +
Notes for alternative scenarios:
+
Break data:
- {% if form.infected_dont_have_breaks_with_exposed %} -Exposed occupant(s):
- {% endif %} -Lunch break: - {% if form.exposed_lunch_option%} - Yes
Start time: {{ form.exposed_lunch_start | minutes_to_time }}    End time: {{ form.exposed_lunch_finish | minutes_to_time }}
Coffee breaks: {{ form.exposed_number_of_coffee_breaks() }} - {% if form.exposed_number_of_coffee_breaks() > 0 %} - each of {{ form.exposed_coffee_duration }} minutes duration -
Coffee break {{ loop.index }}: Start: {{ start_time | minutes_to_time }}    End: {{ end_time | minutes_to_time }}
Infected occupant(s):
-Lunch break: - {% if form.infected_lunch_option%} - Yes
Start time: {{ form.infected_lunch_start | minutes_to_time }}    End time: {{ form.infected_lunch_finish | minutes_to_time }}
Coffee breaks: {{ form.infected_number_of_coffee_breaks() }} - {% if form.infected_number_of_coffee_breaks() > 0 %} - each of {{ form.infected_coffee_duration }} minutes duration -
Coffee break {{ loop.index }}: Start: {{ start_time | minutes_to_time }}    End: {{ end_time | minutes_to_time }}
Mask wearing:
-Masks worn at workstations? {{ 'Yes' if form.mask_wearing_option == "mask_on" else 'No' }}
Mask type: {{ form.mask_type }}
Results:
-- {% block report_summary %} - Taking into account the uncertainties tied to the model variables, in this scenario, the probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}[*] and the expected number of new cases is {{ expected_new_cases | float_format }}. - {% endblock report_summary %} -
[*] The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004
- - - - - -Alternative scenarios:
-
-
-
- {% block report_scenarios_summary_table %}
-
| Scenario | -P(I) | -Expected new cases | -
|---|---|---|
| {{ scenario_name }} | -{{ scenario_stats.probability_of_infection | non_zero_percentage }} | -{{ scenario_stats.expected_new_cases | float_format }} | -
Notes for alternative scenarios:
-
- Click the QR code to regenerate the report and get a shareable link.
Alternatively, scan to regenerate the report.
Mobile-friendly app coming soon!
-
Simulation Name: {{ form.simulation_name }}
+Room Number: {{ form.room_number }}
+Virus variant: + {% if form.virus_type == "SARS_CoV_2" %} + SARS-CoV-2 (nominal strain) + {% elif form.virus_type == "SARS_CoV_2_B117" %} + SARS-CoV-2 (Alpha VOC) + {% elif form.virus_type == "SARS_CoV_2_P1" %} + SARS-CoV-2 (Gamma VOC) + {% elif form.virus_type == "SARS_CoV_2_B16172" %} + SARS-CoV-2 (Delta VOC) + {% endif %} +
Room Volume: {{ model.concentration_model.room.volume }} m³
Room Central Heating: {{ "On" if form.room_heating_option else "Off" }}
Mechanical ventilation: + {% if form.ventilation_type == "mechanical_ventilation" %} + Yes
+ {% 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 %} +
Natural ventilation: + {% if form.ventilation_type == "natural_ventilation"%} + Yes
Number of windows: {{ form.windows_number }}
Height of window: {{ form.window_height }} m
Window type: + {% if form.window_type == "window_hinged" %} + Top- or Bottom-Hung
Width of window: {{ form.window_width }} m
Opening distance: {{ form.opening_distance }} m
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 %} +
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.
+ {% else %} + No + {% endif %} +HEPA Filtration: {{ 'Yes' if form.hepa_option else 'No' }}
HEPA amount: {{ form.hepa_amount }} m³ / hour
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.
+ 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 %} +
Exposed occupant(s) activity time:
Start time: {{ form.exposed_start | minutes_to_time }}    End time: {{ form.exposed_finish | minutes_to_time }}
Infected occupant(s) activity time:
Start time: {{ form.infected_start | minutes_to_time }}    End time: {{ form.infected_finish | minutes_to_time }}
Event for the month of {{ form.event_month }}
Exposed occupant(s):
+ {% endif %} +Lunch break: + {% if form.exposed_lunch_option%} + Yes
Start time: {{ form.exposed_lunch_start | minutes_to_time }}    End time: {{ form.exposed_lunch_finish | minutes_to_time }}
Coffee breaks: {{ form.exposed_number_of_coffee_breaks() }} + {% if form.exposed_number_of_coffee_breaks() > 0 %} + each of {{ form.exposed_coffee_duration }} minutes duration +
Coffee break {{ loop.index }}: Start: {{ start_time | minutes_to_time }}    End: {{ end_time | minutes_to_time }}
Infected occupant(s):
+ +Lunch break: + {% if form.infected_lunch_option%} + Yes
Start time: {{ form.infected_lunch_start | minutes_to_time }}    End time: {{ form.infected_lunch_finish | minutes_to_time }}
Coffee breaks: {{ form.infected_number_of_coffee_breaks() }} + {% if form.infected_number_of_coffee_breaks() > 0 %} + each of {{ form.infected_coffee_duration }} minutes duration +
Coffee break {{ loop.index }}: Start: {{ start_time | minutes_to_time }}    End: {{ end_time | minutes_to_time }}
Masks worn at workstations? {{ 'Yes' if form.mask_wearing_option == "mask_on" else 'No' }}
Mask type: {{ form.mask_type }}
Disclaimer:
- CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions. -
-- 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. -
-- 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. -
-- 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. -
-- 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. -
-- 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. -
-- 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. -
++ CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions. +
++ 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. +
++ 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. +
++ 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. +
++ 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. +
++ 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. +
++ 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. +
- {% endblock disclaimer %} + {% endblock disclaimer %}