Merge branch 'feature/configurable_reports' into 'master'
Allow theming of CARA (initially in the report only, to be extended later on) Closes #157 See merge request cara/cara!160
This commit is contained in:
commit
da5cbc6891
12 changed files with 271 additions and 102 deletions
|
|
@ -38,6 +38,12 @@ pip install -e . # At the root of the repository
|
|||
python -m cara.apps.calculator
|
||||
```
|
||||
|
||||
To run with the CERN theme:
|
||||
|
||||
```
|
||||
python -m cara.apps.calculator --theme=cara/apps/calculator/themes/cern
|
||||
```
|
||||
|
||||
### Running the CARA Expert-App app locally
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -260,8 +260,15 @@
|
|||
containers:
|
||||
- name: cara-webservice
|
||||
env:
|
||||
- name: COOKIE_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: COOKIE_SECRET
|
||||
name: auth-service-secrets
|
||||
- name: APP_NAME
|
||||
value: cara-webservice
|
||||
- name: CARA_THEME
|
||||
value: cara/apps/calculator/themes/cern
|
||||
image: '${PROJECT_NAME}/cara-webservice'
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
|
|
|
|||
15
app.sh
15
app.sh
|
|
@ -1,6 +1,17 @@
|
|||
#!/bin/bash
|
||||
|
||||
if [[ "$APP_NAME" == "cara-webservice" ]]; then
|
||||
echo "Starting the cara webservice"
|
||||
python -m cara.apps.calculator --no-debug
|
||||
args=("$@")
|
||||
if [ "$DEBUG" != "true" ] && [[ ! "${args[@]}" =~ "--no-debug" ]]; then
|
||||
args+=("--no-debug")
|
||||
fi
|
||||
|
||||
if [ ! -z "$CARA_THEME" ]; then
|
||||
args+=("--theme=${CARA_THEME}")
|
||||
fi
|
||||
|
||||
echo "Starting the cara webservice with: python -m cara.apps.calculator ${args[@]}"
|
||||
python -m cara.apps.calculator "${args[@]}"
|
||||
elif [[ "$APP_NAME" == "cara-voila" ]]; then
|
||||
echo "Starting the voila service"
|
||||
voila app/ --port=8080 --no-browser --base_url=/voila-server/ --Voila.tornado_settings="{'allow_origin': '*'}"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import json
|
|||
import os
|
||||
from pathlib import Path
|
||||
import traceback
|
||||
import typing
|
||||
import uuid
|
||||
import zlib
|
||||
|
||||
|
|
@ -12,13 +13,13 @@ import jinja2
|
|||
from tornado.web import Application, RequestHandler, StaticFileHandler
|
||||
|
||||
from . import model_generator
|
||||
from .report_generator import build_report
|
||||
from .report_generator import ReportGenerator
|
||||
from .user import AuthenticatedUser, AnonymousUser
|
||||
|
||||
|
||||
# The calculator version is based on a combination of the model version and the
|
||||
# semantic version of the calculator itself. The version uses the terms
|
||||
# "{MAJOR}.{MINOR}.{PATCH}" to describe the 3 distinct numbers constituing a version.
|
||||
# "{MAJOR}.{MINOR}.{PATCH}" to describe the 3 distinct numbers constituting a version.
|
||||
# Effectively, if the model increases its MAJOR version then so too should this
|
||||
# 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
|
||||
|
|
@ -98,16 +99,17 @@ class ConcentrationModel(BaseRequestHandler):
|
|||
return
|
||||
|
||||
base_url = self.request.protocol + "://" + self.request.host
|
||||
report = build_report(base_url, form.build_model(), form)
|
||||
report_generator = self.settings['report_generator']
|
||||
report = report_generator.build_report(base_url, form)
|
||||
self.finish(report)
|
||||
|
||||
|
||||
class StaticModel(BaseRequestHandler):
|
||||
def get(self):
|
||||
form = model_generator.FormData.from_dict(model_generator.baseline_raw_form_data())
|
||||
model = form.build_model()
|
||||
base_url = self.request.protocol + "://" + self.request.host
|
||||
report = build_report(base_url, model, form)
|
||||
report_generator = self.settings['report_generator']
|
||||
report = report_generator.build_report(base_url, form)
|
||||
self.finish(report)
|
||||
|
||||
|
||||
|
|
@ -153,10 +155,14 @@ class ReadmeHandler(BaseRequestHandler):
|
|||
self.finish(readme)
|
||||
|
||||
|
||||
def make_app(debug=False, prefix='/calculator'):
|
||||
def make_app(
|
||||
debug: bool = False,
|
||||
prefix: str = '/calculator',
|
||||
theme_dir: typing.Optional[Path] = None,
|
||||
) -> Application:
|
||||
static_dir = Path(__file__).absolute().parent.parent / 'static'
|
||||
calculator_static_dir = Path(__file__).absolute().parent / 'static'
|
||||
urls = [
|
||||
urls: typing.Any = [
|
||||
(r'/?', LandingPage),
|
||||
(r'/_c/(.*)', CompressedCalculatorFormInputs),
|
||||
(r'/static/(.*)', StaticFileHandler, {'path': static_dir}),
|
||||
|
|
@ -169,8 +175,12 @@ def make_app(debug=False, prefix='/calculator'):
|
|||
|
||||
cara_templates = Path(__file__).parent.parent / "templates"
|
||||
calculator_templates = Path(__file__).parent / "templates"
|
||||
templates_directories = [cara_templates, calculator_templates]
|
||||
if theme_dir:
|
||||
templates_directories.insert(0, theme_dir / 'templates')
|
||||
loader = jinja2.FileSystemLoader([str(path) for path in templates_directories])
|
||||
template_environment = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader([cara_templates, calculator_templates]),
|
||||
loader=loader,
|
||||
)
|
||||
|
||||
return Application(
|
||||
|
|
@ -178,6 +188,7 @@ def make_app(debug=False, prefix='/calculator'):
|
|||
debug=debug,
|
||||
template_environment=template_environment,
|
||||
default_handler_class=Missing404Handler,
|
||||
report_generator=ReportGenerator(loader),
|
||||
xsrf_cookies=True,
|
||||
# COOKIE_SECRET being undefined will result in no login information being
|
||||
# presented to the user.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
|
|
@ -10,6 +11,11 @@ def configure_parser(parser):
|
|||
"--no-debug", help="Don't enable debug mode",
|
||||
action="store_false",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--theme",
|
||||
help="A directory containing extensions for templates and static data",
|
||||
default=None,
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
|
|
@ -17,7 +23,12 @@ def main():
|
|||
parser = argparse.ArgumentParser()
|
||||
configure_parser(parser)
|
||||
args = parser.parse_args()
|
||||
app = make_app(debug=args.no_debug)
|
||||
theme_dir = args.theme
|
||||
if theme_dir is not None:
|
||||
theme_dir = Path(theme_dir).absolute()
|
||||
assert theme_dir.exists()
|
||||
assert (theme_dir / 'templates').exists()
|
||||
app = make_app(debug=args.no_debug, theme_dir=theme_dir)
|
||||
app.listen(8080)
|
||||
IOLoop.instance().start()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import base64
|
|||
import dataclasses
|
||||
from datetime import datetime
|
||||
import io
|
||||
from pathlib import Path
|
||||
import typing
|
||||
import zlib
|
||||
|
||||
|
|
@ -137,7 +136,7 @@ def plot(times, concentrations, model: models.ExposureModel):
|
|||
for i, (presence_start, presence_finish) in enumerate(model.exposed.presence.boundaries()):
|
||||
plt.fill_between(
|
||||
times, concentrations, 0,
|
||||
where=(np.array(times)>presence_start) & (np.array(times)<presence_finish),
|
||||
where=(np.array(times) > presence_start) & (np.array(times) < presence_finish),
|
||||
color="#1f77b4", alpha=0.1,
|
||||
label="Presence of exposed person(s)" if i == 0 else ""
|
||||
)
|
||||
|
|
@ -271,31 +270,43 @@ def comparison_report(scenarios: typing.Dict[str, models.ExposureModel]):
|
|||
}
|
||||
|
||||
|
||||
def build_report(base_url: str, model: models.ExposureModel, form: FormData):
|
||||
now = datetime.utcnow().astimezone()
|
||||
time = now.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
@dataclasses.dataclass
|
||||
class ReportGenerator:
|
||||
jinja_loader: jinja2.BaseLoader
|
||||
|
||||
context = {
|
||||
'model': model,
|
||||
'form': form,
|
||||
'creation_date': time,
|
||||
}
|
||||
def build_report(self, base_url: str, form: FormData) -> str:
|
||||
model = form.build_model()
|
||||
context = self.prepare_context(base_url, model, form)
|
||||
return self.render(context)
|
||||
|
||||
context.update(calculate_report_data(model))
|
||||
alternative_scenarios = manufacture_alternative_scenarios(form)
|
||||
context['alternative_scenarios'] = comparison_report(alternative_scenarios)
|
||||
context['qr_code'] = generate_qr_code(base_url, form)
|
||||
def prepare_context(self, base_url: str, model: models.ExposureModel, form: FormData) -> dict:
|
||||
now = datetime.utcnow().astimezone()
|
||||
time = now.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
cara_templates = Path(__file__).parent.parent / "templates"
|
||||
calculator_templates = Path(__file__).parent / "templates"
|
||||
env = jinja2.Environment(
|
||||
loader=jinja2.FileSystemLoader([str(cara_templates), str(calculator_templates)]),
|
||||
undefined=jinja2.StrictUndefined,
|
||||
)
|
||||
env.filters['non_zero_percentage'] = non_zero_percentage
|
||||
env.filters['readable_minutes'] = readable_minutes
|
||||
env.filters['minutes_to_time'] = minutes_to_time
|
||||
env.filters['float_format'] = "{0:.2f}".format
|
||||
env.filters['int_format'] = "{:0.0f}".format
|
||||
template = env.get_template("report.html.j2")
|
||||
return template.render(**context)
|
||||
context = {
|
||||
'model': model,
|
||||
'form': form,
|
||||
'creation_date': time,
|
||||
}
|
||||
|
||||
context.update(calculate_report_data(model))
|
||||
alternative_scenarios = manufacture_alternative_scenarios(form)
|
||||
context['alternative_scenarios'] = comparison_report(alternative_scenarios)
|
||||
context['qr_code'] = generate_qr_code(base_url, form)
|
||||
return context
|
||||
|
||||
def _template_environment(self) -> jinja2.Environment:
|
||||
env = jinja2.Environment(
|
||||
loader=self.jinja_loader,
|
||||
undefined=jinja2.StrictUndefined,
|
||||
)
|
||||
env.filters['non_zero_percentage'] = non_zero_percentage
|
||||
env.filters['readable_minutes'] = readable_minutes
|
||||
env.filters['minutes_to_time'] = minutes_to_time
|
||||
env.filters['float_format'] = "{0:.2f}".format
|
||||
env.filters['int_format'] = "{:0.0f}".format
|
||||
return env
|
||||
|
||||
def render(self, context: dict) -> str:
|
||||
template = self._template_environment().get_template("calculator.report.html.j2")
|
||||
return template.render(**context)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
|
||||
<body id="body">
|
||||
|
||||
{% 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>
|
||||
|
|
@ -19,20 +21,19 @@
|
|||
|
||||
<p class=subtitle> Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}</p><br>
|
||||
|
||||
<p><strong>Applicable rules: <br>
|
||||
Please ensure that this scenario conforms to current <a href="https://hse.cern/covid-19-information"> CERN HSE rules</a> (minimum ventilation requirements, mask wearing and the maximum number of people permitted in a space).</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_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:
|
||||
|
|
@ -78,9 +79,9 @@
|
|||
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:
|
||||
<li><p class="data_subtext">Windows open:
|
||||
{% if form.window_opening_regime == "windows_open_periodically" %}
|
||||
Periodically for {{ form.windows_duration | readable_minutes}}
|
||||
Periodically for {{ form.windows_duration | readable_minutes}}
|
||||
every {{ form.windows_frequency | readable_minutes}}
|
||||
{% elif form.window_opening_regime == "windows_open_permanently" %}
|
||||
Permanently
|
||||
|
|
@ -148,7 +149,7 @@
|
|||
{% if form.exposed_lunch_option%}
|
||||
Yes</li>
|
||||
<ul>
|
||||
<li><p class="data_subtext">Start time: {{ form.exposed_lunch_start | minutes_to_time }}    End time: {{ form.exposed_lunch_finish | minutes_to_time }}
|
||||
<li><p class="data_subtext">Start time: {{ form.exposed_lunch_start | minutes_to_time }}    End time: {{ form.exposed_lunch_finish | minutes_to_time }}</p></li>
|
||||
</ul>
|
||||
{% else%}
|
||||
No
|
||||
|
|
@ -173,7 +174,7 @@
|
|||
{% if form.infected_lunch_option%}
|
||||
Yes</li>
|
||||
<ul>
|
||||
<li><p class="data_subtext">Start time: {{ form.infected_lunch_start | minutes_to_time }}    End time: {{ form.infected_lunch_finish | minutes_to_time }}
|
||||
<li><p class="data_subtext">Start time: {{ form.infected_lunch_start | minutes_to_time }}    End time: {{ form.infected_lunch_finish | minutes_to_time }}</p></li>
|
||||
</ul>
|
||||
{% else%}
|
||||
No
|
||||
|
|
@ -192,7 +193,7 @@
|
|||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<p class="data_title">Mask wearing:</p>
|
||||
<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" %}
|
||||
|
|
@ -201,29 +202,15 @@
|
|||
|
||||
</ul>
|
||||
|
||||
{% endblock simulation_overview %}
|
||||
|
||||
{% block report_results %}
|
||||
<p class="result_title">Results:</p>
|
||||
<p class="data_text">
|
||||
<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>
|
||||
|
||||
In this scenario, the estimated probability of one exposed occupant getting infected P(i) is {{ prob_inf | non_zero_percentage }} and the expected number of new cases is {{ expected_new_cases | float_format }}.
|
||||
|
||||
{% 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 %}</p>
|
||||
{% block report_summary %}
|
||||
In this scenario, the estimated probability of one exposed occupant getting infected P(i) is {{ prob_inf | non_zero_percentage }} and the expected number of new cases is {{ expected_new_cases | float_format }}.
|
||||
{% endblock report_summary %}
|
||||
</p>
|
||||
<p class="data_title">Exposure graph:</p>
|
||||
<img id="scenario_concentration_plot" src="{{ scenario_plot_src }}">
|
||||
|
||||
|
|
@ -254,6 +241,8 @@ In this scenario, the estimated probability of one exposed occupant getting infe
|
|||
<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>
|
||||
|
|
@ -264,13 +253,7 @@ In this scenario, the estimated probability of one exposed occupant getting infe
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
|
||||
{%if (( scenario_stats.probability_of_infection > 15) or (scenario_stats.expected_new_cases >= 1)) %}
|
||||
<tr class="red_bkg">
|
||||
{% elif (5 <= scenario_stats.probability_of_infection <= 15) %}
|
||||
<tr class="yellow_bkg">
|
||||
{% elif (scenario_stats.probability_of_infection < 5) %}
|
||||
<tr class="green_bkg">
|
||||
{% endif%}
|
||||
<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>
|
||||
|
|
@ -278,6 +261,7 @@ In this scenario, the estimated probability of one exposed occupant getting infe
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock report_scenarios_summary_table %}
|
||||
</p>
|
||||
<div style="clear: both;">
|
||||
|
||||
|
|
@ -291,31 +275,32 @@ In this scenario, the estimated probability of one exposed occupant getting infe
|
|||
<br>
|
||||
</p>
|
||||
|
||||
<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>
|
||||
{% endblock report_results %}
|
||||
|
||||
{% block report_footer %}
|
||||
<div>
|
||||
<a href="{{ qr_code.link }}"><img style="width:250pt;" id="qr_code" src="{{ qr_code.image }}"/></a>
|
||||
<i style="position:absolute; margin-top:3.5cm">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!</i>
|
||||
<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 style="clear: both;"></div>
|
||||
|
||||
{% block disclaimer_container %}
|
||||
<br><br><br>
|
||||
<div style="border: 2px solid black; padding: 15px;">
|
||||
{% block disclaimer %}
|
||||
<p class="image"> <img align="middle" src="/calculator/static/images/disclaimer.jpg" width="40" height="40"><b>Disclaimer:</b><br><br></p>
|
||||
<p class="discalimer">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 plays a 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.<br><br>
|
||||
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of December 2020 . It can be used to compare the effectiveness of different airborne-related risk mitigation measures.<br><br>
|
||||
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.<br><br>
|
||||
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 a '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.<br><br>
|
||||
CARA is made available for internal CERN use only. It is intended for Members of Personnel with roles related to Supervision, Health & Safety or Space Management, in order to simulate the concerned workplaces on CERN sites. For use outside of this scope, it has to be performed under a pre-defined framework and licence agreement issued by CERN Knowledge Transfer (<a href="mailto:kt@cern.ch">kt@cern.ch</a>).</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 a '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>
|
||||
{% endblock disclaimer %}
|
||||
</div>
|
||||
{% endblock disclaimer_container %}
|
||||
{% endblock report_footer %}
|
||||
</body>
|
||||
</html>
|
||||
2
cara/apps/calculator/templates/calculator.report.html.j2
Normal file
2
cara/apps/calculator/templates/calculator.report.html.j2
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{# The main calculator report, this template is intended to be implemented by themes #}
|
||||
{% extends "base/calculator.report.html.j2" %}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
{% extends "base/calculator.report.html.j2" %}
|
||||
|
||||
|
||||
{% block report_preamble %}
|
||||
<p><strong>Applicable rules: <br>
|
||||
Please ensure that this scenario conforms to current <a href="https://hse.cern/covid-19-information"> CERN HSE rules</a> (minimum ventilation requirements, mask wearing and the maximum number of people permitted in a space).</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 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>
|
||||
|
||||
{{ super() }}
|
||||
|
||||
{% 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_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() %}
|
||||
{%if (( scenario_stats.probability_of_infection > 15) or (scenario_stats.expected_new_cases >= 1)) %}
|
||||
<tr class="red_bkg">
|
||||
{% elif (5 <= scenario_stats.probability_of_infection <= 15) %}
|
||||
<tr class="yellow_bkg">
|
||||
{% elif (scenario_stats.probability_of_infection < 5) %}
|
||||
<tr class="green_bkg">
|
||||
{% endif%}
|
||||
<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 %}
|
||||
|
||||
|
||||
{% 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 %}
|
||||
{{ super() }}
|
||||
<p>
|
||||
CARA is made available for internal CERN use only.
|
||||
It is intended for Members of Personnel with roles related to Supervision, Health & Safety or Space Management, in order to simulate the concerned workplaces on CERN sites.
|
||||
For use outside of this scope, it has to be performed under a pre-defined framework and licence agreement issued by CERN Knowledge Transfer (<a href="mailto:kt@cern.ch">kt@cern.ch</a>).
|
||||
</p>
|
||||
{% endblock disclaimer %}
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,6 +3,7 @@ import time
|
|||
import pytest
|
||||
|
||||
from cara.apps.calculator import report_generator
|
||||
from cara.apps.calculator import make_app
|
||||
|
||||
|
||||
def test_generate_report(baseline_form):
|
||||
|
|
@ -13,8 +14,9 @@ def test_generate_report(baseline_form):
|
|||
time_limit: float = 5.0 # seconds
|
||||
|
||||
start = time.perf_counter()
|
||||
model = baseline_form.build_model()
|
||||
report = report_generator.build_report("", model, baseline_form)
|
||||
|
||||
generator: report_generator.ReportGenerator = make_app().settings['report_generator']
|
||||
report = generator.build_report("", baseline_form)
|
||||
end = time.perf_counter()
|
||||
assert report != ""
|
||||
assert end - start < time_limit
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from cara.apps.calculator import make_app
|
||||
import pytest
|
||||
import tornado.testing
|
||||
|
||||
import cara.apps.calculator
|
||||
from cara.apps.calculator.report_generator import generate_qr_code
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
return make_app()
|
||||
return cara.apps.calculator.make_app()
|
||||
|
||||
|
||||
async def test_homepage(http_server_client):
|
||||
|
|
@ -14,7 +17,7 @@ async def test_homepage(http_server_client):
|
|||
assert response.code == 200
|
||||
|
||||
|
||||
async def test_calculator(http_server_client):
|
||||
async def test_calculator_form(http_server_client):
|
||||
# Both with and without a trailing slash.
|
||||
response = await http_server_client.fetch('/calculator')
|
||||
assert response.code == 200
|
||||
|
|
@ -23,6 +26,29 @@ async def test_calculator(http_server_client):
|
|||
assert response.code == 200
|
||||
|
||||
|
||||
class TestBasicApp(tornado.testing.AsyncHTTPTestCase):
|
||||
def get_app(self):
|
||||
return cara.apps.calculator.make_app()
|
||||
|
||||
def test_report(self):
|
||||
response = self.fetch('/calculator/baseline-model/result')
|
||||
self.assertEqual(response.code, 200)
|
||||
assert 'CERN HSE rules' not in response.body.decode()
|
||||
assert 'the expected number of new cases is' in response.body.decode()
|
||||
|
||||
|
||||
class TestCernApp(tornado.testing.AsyncHTTPTestCase):
|
||||
def get_app(self):
|
||||
cern_theme = Path(cara.apps.calculator.__file__).parent / 'themes' / 'cern'
|
||||
return cara.apps.calculator.make_app(theme_dir=cern_theme)
|
||||
|
||||
def test_report(self):
|
||||
response = self.fetch('/calculator/baseline-model/result')
|
||||
self.assertEqual(response.code, 200)
|
||||
assert 'CERN HSE rules' in response.body.decode()
|
||||
assert 'the expected number of new cases is' in response.body.decode()
|
||||
|
||||
|
||||
async def test_qrcode_urls(http_server_client, baseline_form):
|
||||
prefix = 'proto://hostname/prefix'
|
||||
qr_data = generate_qr_code(prefix, baseline_form)
|
||||
|
|
|
|||
3
setup.py
3
setup.py
|
|
@ -76,6 +76,7 @@ setup(
|
|||
'apps/calculator/templates/*.j2',
|
||||
'apps/calculator/*',
|
||||
'apps/calculator/*/*',
|
||||
'apps/calculator/*/*/*'
|
||||
'apps/calculator/*/*/*',
|
||||
'apps/calculator/*/*/*/*',
|
||||
]},
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue