Merge branch 'make-calculator-customizable' into 'master'

calculator: make URL path customizable

See merge request cara/cara!199
This commit is contained in:
Andre Henriques 2021-06-15 16:15:35 +00:00
commit d36203f703
15 changed files with 179 additions and 64 deletions

View file

@ -36,6 +36,7 @@ deploy_to_test:
script:
- curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/cara-router/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/cara-webservice/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/cara-calculator-open/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic
- curl -X POST -k https://openshift-dev.cern.ch:443/apis/build.openshift.io/v1/namespaces/test-cara/buildconfigs/auth-service/webhooks/${OPENSHIFT_TEST_BUILD_WEBHOOK_SECRET}/generic

View file

@ -1,6 +1,6 @@
# CARA - COVID Airborne Risk Assessment
CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.
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.
@ -23,7 +23,7 @@ The objective is to facilitate targeted decision-making and investment through c
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.
## Authors
## Authors
CARA was developed by following members of CERN - European Council for Nuclear Research (visit https://home.cern/):
Andre Henriques<sup>1</sup>, Marco Andreini<sup>1</sup>, Gabriella Azzopardi<sup>2</sup>, James Devine<sup>3</sup>, Philip Elson<sup>4</sup>, Nicolas Mounet<sup>2</sup>, Markus Kongstein Rognlien<sup>2,6</sup>, Nicola Tarocco<sup>5</sup>
@ -70,7 +70,7 @@ Once you have used the scripts, the hourly temperature data for your location sh
'Feb': [0.9, 0.3, 0.0, -0.5, -0.7, -1.1, -1.2, -1.1, -0.7, 0.8, 2.5,
4.2, 5.4, 6.2, 6.3, 6.2, 6.1, 5.5, 4.5, 4.1, 3.5, 2.8, 2.5, 2.0],...`
CARA currently supports **only one geographic location for weather data per instance**.
CARA currently supports **only one geographic location for weather data per instance**.
## Running CARA locally
@ -80,7 +80,7 @@ In order to run cara locally with docker, run the following:
$ docker run -it -p 8080:8080 gitlab-registry.cern.ch/cara/cara/calculator
This will start a local version of CARA, which can be visited at http://localhost:8080/.
This will start a local version of CARA, which can be visited at http://localhost:8080/.
## Development guide
@ -98,6 +98,12 @@ To run with the CERN theme:
python -m cara.apps.calculator --theme=cara/apps/calculator/themes/cern
```
To run the calculator on a different URL path:
```
python -m cara.apps.calculator --prefix=/mycalc
```
### Running the CARA Expert-App app in development mode
```

View file

@ -99,13 +99,22 @@ http {
}
location /calculator {
# Anything under calculator is authenticated.
return 302 /calculator-cern;
}
location /calculator-cern {
# CERN calculator is authenticated.
auth_request /auth/probe;
error_page 401 = @error401;
# cara-webservice is the name of the tornado server (for the calculator)
# in each of docker-compose, test-cara.web.cern.ch and cara.web.cern.ch.
proxy_pass http://cara-webservice:8080/calculator;
proxy_pass http://cara-webservice:8080/calculator-cern;
}
location /calculator-open {
# Public open calculator
proxy_pass http://cara-calculator-open:8080/calculator-open;
}
}
}

View file

@ -198,7 +198,7 @@
env:
- name: APP_NAME
value: cara-voila
image: '${PROJECT_NAME}/cara-webservice'
image: '${PROJECT_NAME}/cara-app'
ports:
- containerPort: 8080
protocol: TCP
@ -211,7 +211,7 @@
- cara-app
from:
kind: ImageStreamTag
name: 'cara-webservice:latest'
name: 'cara-app:latest'
namespace: ${PROJECT_NAME}
-
apiVersion: v1
@ -267,8 +267,10 @@
name: auth-service-secrets
- name: APP_NAME
value: cara-webservice
- name: CARA_CALCULATOR_PREFIX
value: /calculator-cern
- name: CARA_THEME
value: cara/apps/calculator/themes/cern
value: cara/apps/calculator/themes/cern
image: '${PROJECT_NAME}/cara-webservice'
ports:
- containerPort: 8080
@ -285,6 +287,41 @@
name: 'cara-webservice:latest'
namespace: ${PROJECT_NAME}
- type: ConfigChange
-
apiVersion: v1
kind: DeploymentConfig
metadata:
name: cara-calculator-open
spec:
replicas;: 1
template:
metadata:
labels:
app: cara-calculator-open
spec:
containers:
- name: cara-calculator-open
env:
- name: APP_NAME
value: cara-webservice
- name: CARA_CALCULATOR_PREFIX
value: /calculator-open
image: '${PROJECT_NAME}/cara-webservice'
ports:
- containerPort: 8080
protocol: TCP
triggers:
- type: ConfigChange
- type: ImageChange
imageChangeParams:
automatic: true
containerNames:
- cara-calculator-open
from:
kind: ImageStreamTag
name: 'cara-webservice:latest'
namespace: ${PROJECT_NAME}
- type: ConfigChange
parameters:
- name: PROJECT_NAME

View file

@ -10,6 +10,21 @@
labels:
template: "cara-services"
objects:
-
apiVersion: v1
kind: Service
metadata:
labels:
app: auth-service
name: auth-service
spec:
ports:
- name: 8080-tcp
port: 8080
protocol: TCP
targetPort: 8080
selector:
deploymentconfig: auth-service
-
apiVersion: v1
kind: Service
@ -24,7 +39,7 @@
protocol: TCP
targetPort: 8080
selector:
app: cara-app
deploymentconfig: cara-app
-
apiVersion: v1
kind: Service
@ -43,7 +58,7 @@
protocol: TCP
targetPort: 8443
selector:
app: cara-router
deploymentconfig: cara-router
-
apiVersion: v1
kind: Service
@ -58,4 +73,19 @@
protocol: TCP
targetPort: 8080
selector:
app: cara-webservice
deploymentconfig: cara-webservice
-
apiVersion: v1
kind: Service
metadata:
labels:
app: cara-calculator-open
name: cara-calculator-open
spec:
ports:
- name: 8080-tcp
port: 8080
protocol: TCP
targetPort: 8080
selector:
deploymentconfig: cara-calculator-open

4
app.sh
View file

@ -10,6 +10,10 @@ if [[ "$APP_NAME" == "cara-webservice" ]]; then
args+=("--theme=${CARA_THEME}")
fi
if [ ! -z "$CARA_CALCULATOR_PREFIX" ]; then
args+=("--prefix=${CARA_CALCULATOR_PREFIX}")
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

View file

@ -66,6 +66,7 @@ class BaseRequestHandler(RequestHandler):
print(traceback.format_exc())
self.finish(template.render(
user=self.current_user,
calculator_prefix=self.settings["calculator_prefix"],
active_page='Error',
contents=contents
))
@ -79,6 +80,7 @@ class Missing404Handler(BaseRequestHandler):
"page.html.j2")
self.finish(template.render(
user=self.current_user,
calculator_prefix=self.settings["calculator_prefix"],
active_page='Error',
contents='Unfortunately the page you were looking for does not exist.<br><br><br><br>'
))
@ -123,7 +125,10 @@ class LandingPage(BaseRequestHandler):
def get(self):
template = self.settings["template_environment"].get_template(
"index.html.j2")
report = template.render(user=self.current_user)
report = template.render(
user=self.current_user,
calculator_prefix=self.settings["calculator_prefix"],
)
self.finish(report)
@ -133,6 +138,7 @@ class AboutPage(BaseRequestHandler):
template = template_environment.get_template("about.html.j2")
report = template.render(
user=self.current_user,
calculator_prefix=self.settings["calculator_prefix"],
active_page="about",
text_blocks=template_environment.globals['common_text']
)
@ -146,6 +152,7 @@ class CalculatorForm(BaseRequestHandler):
report = template.render(
user=self.current_user,
xsrf_form_html=self.xsrf_form_html(),
calculator_prefix=self.settings["calculator_prefix"],
calculator_version=__version__,
)
self.finish(report)
@ -160,7 +167,7 @@ class CompressedCalculatorFormInputs(BaseRequestHandler):
except Exception as err: # noqa
self.set_status(400)
return self.finish("Invalid calculator data: it seems incomplete. Was there an error copying & pasting the URL?")
self.redirect(f'/calculator?{args}')
self.redirect(f'{self.settings["calculator_prefix"]}?{args}')
class ReadmeHandler(BaseRequestHandler):
@ -168,14 +175,15 @@ class ReadmeHandler(BaseRequestHandler):
template = self.settings['template_environment'].get_template("userguide.html.j2")
readme = template.render(
active_page="calculator/user-guide",
user=self.current_user
user=self.current_user,
calculator_prefix=self.settings["calculator_prefix"],
)
self.finish(readme)
def make_app(
debug: bool = False,
prefix: str = '/calculator',
calculator_prefix: str = '/calculator',
theme_dir: typing.Optional[Path] = None,
) -> Application:
static_dir = Path(__file__).absolute().parent.parent / 'static'
@ -185,11 +193,11 @@ def make_app(
(r'/_c/(.*)', CompressedCalculatorFormInputs),
(r'/about', AboutPage),
(r'/static/(.*)', StaticFileHandler, {'path': static_dir}),
(prefix + r'/?', CalculatorForm),
(prefix + r'/report', ConcentrationModel),
(prefix + r'/baseline-model/result', StaticModel),
(prefix + r'/user-guide', ReadmeHandler),
(prefix + r'/static/(.*)', StaticFileHandler, {'path': calculator_static_dir}),
(calculator_prefix + r'/?', CalculatorForm),
(calculator_prefix + r'/report', ConcentrationModel),
(calculator_prefix + r'/baseline-model/result', StaticModel),
(calculator_prefix + r'/user-guide', ReadmeHandler),
(calculator_prefix + r'/static/(.*)', StaticFileHandler, {'path': calculator_static_dir}),
]
cara_templates = Path(__file__).parent.parent / "templates"
@ -210,9 +218,10 @@ def make_app(
return Application(
urls,
debug=debug,
calculator_prefix=calculator_prefix,
template_environment=template_environment,
default_handler_class=Missing404Handler,
report_generator=ReportGenerator(loader),
report_generator=ReportGenerator(loader, calculator_prefix),
xsrf_cookies=True,
# COOKIE_SECRET being undefined will result in no login information being
# presented to the user.

View file

@ -16,6 +16,11 @@ def configure_parser(parser) -> argparse.ArgumentParser:
help="A directory containing extensions for templates and static data",
default=None,
)
parser.add_argument(
"--prefix",
help="Change the URL path prefix to the calculator app",
default="/calculator"
)
return parser
@ -27,7 +32,7 @@ def main():
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 = make_app(debug=args.no_debug, calculator_prefix=args.prefix, theme_dir=theme_dir)
app.listen(8080)
IOLoop.instance().start()

View file

@ -71,7 +71,7 @@ def calculate_report_data(model: models.ExposureModel):
}
def generate_qr_code(prefix, form: FormData):
def generate_qr_code(base_url, calculator_prefix, form: FormData):
form_dict = FormData.to_dict(form, strip_defaults=True)
# Generate the calculator URL arguments that would be needed to re-create this
@ -80,10 +80,9 @@ def generate_qr_code(prefix, form: FormData):
# Then zlib compress + base64 encode the string. To be inverted by the
# /_c/ endpoint.
qr_url = prefix + "/_c/" + base64.b64encode(
zlib.compress(args.encode())
).decode()
url = prefix + "/calculator?" + args
compressed_args = base64.b64encode(zlib.compress(args.encode())).decode()
qr_url = f"{base_url}/_c/{compressed_args}"
url = f"{base_url}{calculator_prefix}?{args}"
qr = qrcode.QRCode(
version=1,
@ -281,6 +280,7 @@ def comparison_report(scenarios: typing.Dict[str, models.ExposureModel]):
@dataclasses.dataclass
class ReportGenerator:
jinja_loader: jinja2.BaseLoader
calculator_prefix: str
def build_report(self, base_url: str, form: FormData) -> str:
model = form.build_model()
@ -300,7 +300,8 @@ class ReportGenerator:
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)
context['qr_code'] = generate_qr_code(base_url, self.calculator_prefix, form)
context['calculator_prefix'] = self.calculator_prefix
return context
def _template_environment(self) -> jinja2.Environment:

View file

@ -6,7 +6,7 @@
<title>Report | CARA (COVID Airborne Risk Assessment)</title>
<link rel="stylesheet" type="text/css" href="/calculator/static/css/report.css">
<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">
</head>
@ -270,7 +270,7 @@
<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="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.

View file

@ -5,12 +5,12 @@
{% block extra_headers %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.css" integrity="sha512-aOG0c6nPNzGk+5zjwyJaoRUgCdOrfSDhmMID2u4+OIslr0GjpLKo7Xm0Ao3xmpM4T8AmIouRkqwj1nrdVsLKEQ==" crossorigin="anonymous">
<link rel="stylesheet" href="/calculator/static/css/form.css">
<link rel="stylesheet" href="{{ calculator_prefix }}/static/css/form.css">
{% endblock extra_headers %}
{% block body_scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js" integrity="sha512-uto9mlQzrs59VwILcLiRYeLKPPbS/bT71da/OEBYEwcdNUk8jYIy+D176RYoop1Da+f9mvkYrmj5MCLZWEtQuA==" crossorigin="anonymous"></script>
<script src="/calculator/static/js/form.js"></script>
<script src="{{ calculator_prefix }}/static/js/form.js"></script>
{% endblock body_scripts %}
@ -28,7 +28,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
{% if DEBUG %}
<form id="covid_calculator" name="covid_calculator" onsubmit="return debug_submit(this)" class="form-inline">
{% else %}
<form id="covid_calculator" name="covid_calculator" action="/calculator/report" onsubmit="return validate_form(this)" method="POST">
<form id="covid_calculator" name="covid_calculator" action="{{ calculator_prefix }}/report" onsubmit="return validate_form(this)" method="POST">
{% endif %}
{{ xsrf_form_html }}
@ -129,23 +129,23 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<div id="DIVnatural_ventilation" class="tabbed" style="display:none">
Number of windows: <input type="number" id="windows_number" class="non_zero" name="windows_number" min="1"><br>
Height of window: <input type="number" step="any" id="window_height" class="non_zero" name="window_height" placeholder="meters" min="0"><br>
Window type:
Window type:
<input type="radio" id="window_sliding" name="window_type" value="window_sliding" onclick="require_fields(this)" checked="checked">
<label for="window_sliding">Sliding / Side-Hung</label>&nbsp;&nbsp;
<input type="radio" id="window_hinged" name="window_type" value="window_hinged" onclick="require_fields(this)">
<label for="window_hinged">Top- or Bottom-Hung</label>&nbsp;&nbsp;<br>
Width of window: <input type="number" step="any" id="window_width" class="non_zero disabled" name="window_width" placeholder="meters" min="0" data-has-radio="#window_hinged"><br>
Opening distance: <input type="number" step="any" id="opening_distance" class="non_zero" name="opening_distance" placeholder="meters" min="0"><br>
Windows open:</span><br>
Windows open:</span><br>
<span class="tabbed"><input type="radio" id="windows_open_permanently" name="window_opening_regime" value="windows_open_permanently" onclick="require_fields(this)" checked="checked"></span>
<label for="windows_open_permanently">Permanently</label><br>
<span class="tabbed"><input type="radio" id="windows_open_periodically" name="window_opening_regime" value="windows_open_periodically" onclick="require_fields(this)"></span>
<label for="windows_open_periodically">Periodically:</label>&nbsp;&nbsp;
<input type="number" step="any" id="windows_duration" class="non_zero disabled" name="windows_duration" placeholder="Duration (min)" min="1" data-has-radio="#windows_open_periodically"> /
<input type="number" step="any" id="windows_duration" class="non_zero disabled" name="windows_duration" placeholder="Duration (min)" min="1" data-has-radio="#windows_open_periodically"> /
<input type="number" step="any" id="windows_frequency" class="non_zero disabled" name="windows_frequency" placeholder="Frequency (min)" min="1" data-has-radio="#windows_open_periodically">
<br>
</div>
HEPA filtration:
<input type="radio" id="hepa_no" name="hepa_option" value=0 onclick="require_fields(this)" checked="checked">
<label for="hepa_no">No</label>&nbsp;&nbsp;
@ -192,7 +192,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<span id="training_limit_error" class="red_text" hidden>Training activities limited to 1 infected<br></span>
<hr width="80%">
Activity type:
Activity type:
<select id="activity_type" name="activity_type">
<option value="office">Office</option>
<option value="meeting">Meeting</option>
@ -205,7 +205,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<option value="training">Training</option>
<option value="gym">Gym</option>
</select><br>
Exposed person(s) presence: <br>
Exposed person(s) presence: <br>
<span class="tabbed">Start: </span><input type="time" id="exposed_start" class="start_time" data-time-group="exposed" data-lunch-break="exposed_lunch" name="exposed_start" value="08:30" required> &nbsp;&nbsp;
Finish: <input type="time" id="exposed_finish" class="finish_time" data-time-group="exposed" data-lunch-break="exposed_lunch" name="exposed_finish" value="17:30" required><br>
Infected person(s) presence: <br>
@ -236,7 +236,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<span class="tooltip_text">?</span>
</div>
</span><br>
<!-- Lunch Options -->
<input type="checkbox" id="infected_dont_have_breaks_with_exposed" name="infected_dont_have_breaks_with_exposed" value='1' onclick="toggle_split_breaks()">
<label for="infected_dont_have_breaks_with_exposed">Input separate breaks for infected and exposed person(s)</label><br>
@ -248,7 +248,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<label for="exposed_lunch_option_no">No</label>&nbsp;&nbsp;
<input type="radio" id="exposed_lunch_option_yes" data-lunch-select="exposed" name="exposed_lunch_option" value=1 checked="checked" onclick="require_fields(this)">
<label for="exposed_lunch_option_yes">Yes</label><br>
Start: <input type="time" id="exposed_lunch_start" class="start_time" data-time-group="exposed_lunch" data-lunch-for="exposed" data-has-radio="#exposed_lunch_option_yes" name="exposed_lunch_start" value="12:30" required> &nbsp;&nbsp;
Finish: <input type="time" id="exposed_lunch_finish" class="finish_time" data-time-group="exposed_lunch" data-lunch-for="exposed" data-has-radio="#exposed_lunch_option_yes" name="exposed_lunch_finish" value="13:30" required><br>
@ -261,7 +261,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<input type="radio" id="exposed_coffee_break_4" name="exposed_coffee_break_option" value="coffee_break_4">
<label for="exposed_coffee_break_4">4</label><br>
Duration (minutes):
Duration (minutes):
<select id="exposed_coffee_duration" name="exposed_coffee_duration">
<option value="5">5</option>
<option value="10">10</option>
@ -282,7 +282,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<label for="infected_lunch_option_no">No</label>&nbsp;&nbsp;
<input type="radio" id="infected_lunch_option_yes" data-lunch-select="infected" name="infected_lunch_option" value=1 checked="checked" onclick="require_fields(this)">
<label for="infected_lunch_option_yes">Yes</label><br>
Start: <input type="time" id="infected_lunch_start" class="start_time" data-time-group="infected_lunch" data-lunch-for="infected" data-has-radio="#infected_lunch_option_yes" name="infected_lunch_start" value="12:30"> &nbsp;&nbsp;
Finish: <input type="time" id="infected_lunch_finish" class="finish_time" data-time-group="infected_lunch" data-lunch-for="infected" data-has-radio="#infected_lunch_option_yes" name="infected_lunch_finish" value="13:30"><br>
@ -295,7 +295,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<input type="radio" id="infected_coffee_break_4" name="infected_coffee_break_option" value="coffee_break_4">
<label for="infected_coffee_break_4">4</label><br>
Duration (minutes):
Duration (minutes):
<select id="infected_coffee_duration" name="infected_coffee_duration">
<option value="5">5</option>
<option value="10">10</option>
@ -307,7 +307,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
</div>
<br>
</div>
<br style="clear:both;">
<i>Coffee breaks are spread evenly throughout the day.</i><br>
<hr width="80%">
@ -363,7 +363,7 @@ v{{ calculator_version }} <span style="float:right; font-weight:bold">Please sen
<li>If coffee breaks are included, they are spread out evenly throughout the day,
in addition to any lunch break (if applicable).</li>
</ul>
Refer to <a href="/calculator/user-guide"> COVID Calculator App user guide </a>
Refer to <a href="{{ calculator_prefix }}/user-guide"> COVID Calculator App user guide </a>
for more detailed explanations on how to use this tool. <br>
</div>

View file

@ -3,7 +3,7 @@
{% block contents %}
<h1>Airborne Transmission of SARS-CoV-2</h1><br>
Currently, the existing public health measures point to the importance of proper building and environmental engineering control measures, such as proper Indoor Air Quality (IAQ).
Currently, the existing public health measures point to the importance of proper building and environmental engineering control measures, such as proper Indoor Air Quality (IAQ).
This pandemic clearly raised increased awareness on airborne transmission of respiratory viruses in indoor settings.
Out of the main modes of viral transmission, the airborne route of SARS-CoV-2 seems to have a significant importance to the spread of COVID-19 infections world-wide, hence proper guidance to building engineers or facility managers, on how to prevent on-site transmission, is essential.<br>
For information on the Airborne Transmission of SARS-CoV-2, feel free to check out the HSE Seminar: <a href=https://cds.cern.ch/record/2743403>https://cds.cern.ch/record/2743403</a>.<br>
@ -12,14 +12,14 @@ Slides available in <a href=https://indico.cern.ch/event/968258>https://indico.c
<h1>What is CARA?</h1><br>
CARA stands for COVID Airborne Risk Assessment and was developed in the spring of 2020 to better understand and quantify the risk of long-range airborne spread of SARS-CoV-2 virus in workplaces. CARA comes with different applications that allow more or less flexibility in the input parameters:
<ul>
<li><a href='/calculator'>CARA calculator app</a></li>
<li><a href='{{ calculator_prefix }}'>CARA calculator app</a></li>
<li><a href='/expert-app'>CARA expert app</a></li>
</ul>
The mathematical and physical model simulate the long-range airborne spread of 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>
<p>The methodology, mathematical equations and parameters of the model are described here in the CERN Report: <a href="https://cds.cern.ch/record/2756083"> CERN-OPEN-2021-004</a>.</p>
The mathematical and physical model simulate the long-range airborne spread of 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>
<p>The methodology, mathematical equations and parameters of the model are described here in the CERN Report: <a href="https://cds.cern.ch/record/2756083"> CERN-OPEN-2021-004</a>.</p>
The model used is based on scientific publications relating to airborne transmission of infectious diseases, virology, epidemiology and aerosol science. It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
The model used is based on scientific publications relating to airborne transmission of infectious diseases, virology, epidemiology and aerosol science. It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
The tool helps assess the potential dose of infectious airborne viruses in indoor gatherings, with people seated, standing, moving around, while breathing, speaking or shouting/singing. The model is based on the Wells-Riley model of aerosol disease transmission, which assumes a fixed value for the average infectious dose. The dose-response models for respiratory diseases is more accurate, although since this parameter for SARS-CoV-2 is not known so far, the Wells-Riley method is recommended in the health science community (see <a href="#references_block">References</a>).
The methodology of the model is divided into three parts:
@ -31,7 +31,7 @@ The methodology of the model is divided into three parts:
Parts #1 and #3 are mainly based on methods published in scientific papers (see <a href="#references_block">References</a>), and cover the medical aspects, which is not the core competencies of the authors. The heart and soul of CARA lies within the Part #2 and the concentration modelling, which is based on a mass-balance differential equation solved for a constant emission rate and time-dependent exchange rate (e.g. natural ventilation flow rate). Other aspects, e.g., the biological decay of the virus in the air, gravitational settlement of the aerosols, mechanical supply of fresh air, effect of HEPA filtration, among others, are also included.<br>
<h1>What is the aim of CARA?</h1><br>
Although the user is able to calculate the infection probability of a stand-alone event with a pre-defined set of protection measures, the main utility of CARA is to compare the relative impact of different measures and/or combination of measure. For example:
Although the user is able to calculate the infection probability of a stand-alone event with a pre-defined set of protection measures, the main utility of CARA is to compare the relative impact of different measures and/or combination of measure. For example:
<ul>
<li>Compare keeping a window slightly open vs one or two windows open entirely</li>
<li>Compare opening one entire window every 2h for 10 min vs keeping half a window open all day</li>

View file

@ -35,7 +35,7 @@
<div class="text-component-text cern_full_html" >
<p>
CARA has been developed by CERN with the intention of allowing members of personnel with roles related to supervision, health & safety or space management to simulate the concerned workplaces on CERN sites.
A hosted <a href="/calculator">CERN version of the CARA Covid Calculator</a> is available on this site to members of the CERN personnel.
A hosted <a href="{{ calculator_prefix }}">CERN version of the CARA Covid Calculator</a> is available on this site to members of the CERN personnel.
</p>
</div>

View file

@ -128,18 +128,18 @@
</li>
<li class="dropdown">
<a href="/calculator" class="dropdown-toggle {{ "is-active" if "calculator/" in active_page else "" }}">
<a href="{{ calculator_prefix }}" class="dropdown-toggle {{ "is-active" if "calculator" in active_page else "" }}">
COVID Calculator
<span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li>
<a href="/calculator" class="{{ "is-active" if "calculator/" == active_page else "" }}">
<a href="{{ calculator_prefix }}" class="{{ "is-active" if "calculator" == active_page else "" }}">
Calculator
</a>
</li>
<li>
<a href="/calculator/user-guide" class="{{ "is-active" if "calculator/user-guide" == active_page else "" }}">
<a href="{{ calculator_prefix }}/user-guide" class="{{ "is-active" if "user-guide" in active_page else "" }}">
User guide
</a>
</li>

View file

@ -66,27 +66,40 @@ class TestCernApp(tornado.testing.AsyncHTTPTestCase):
assert 'CERN HSE' in response.body.decode()
assert 'expected number of new cases is' in response.body.decode()
class TestOpenApp(tornado.testing.AsyncHTTPTestCase):
def get_app(self):
return cara.apps.calculator.make_app(calculator_prefix="/mycalc")
@tornado.testing.gen_test(timeout=_TIMEOUT)
def test_report(self):
response = yield self.http_client.fetch(self.get_url('/mycalc/baseline-model/result'))
self.assertEqual(response.code, 200)
def test_calculator_404(self):
response = self.fetch('/calculator')
assert response.code == 404
async def test_qrcode_urls(http_server_client, baseline_form):
prefix = 'proto://hostname/prefix'
qr_data = generate_qr_code(prefix, baseline_form)
expected = f'{prefix}/calculator?exposed_coffee_break_option={baseline_form.exposed_coffee_break_option}&'
base_url = 'proto://hostname/prefix'
qr_data = generate_qr_code(base_url, "/calculator", baseline_form)
expected = f'{base_url}/calculator?exposed_coffee_break_option={baseline_form.exposed_coffee_break_option}&'
assert qr_data['link'].startswith(expected)
# We should get a 200 for the link.
response = await http_server_client.fetch(qr_data['link'].replace(prefix, ''))
response = await http_server_client.fetch(qr_data['link'].replace(base_url, ''))
assert response.code == 200
# And a 302 for the QR url itself. The redirected URL should be the same as
# in the link.
assert qr_data['qr_url'].startswith(prefix)
assert qr_data['qr_url'].startswith(base_url)
response = await http_server_client.fetch(
qr_data['qr_url'].replace(prefix, ''),
qr_data['qr_url'].replace(base_url, ''),
max_redirects=0,
raise_error=False,
)
assert response.code == 302
assert response.headers['Location'] == qr_data['link'].replace(prefix, '')
assert response.headers['Location'] == qr_data['link'].replace(base_url, '')
async def test_invalid_compressed_url(http_server_client, baseline_form):