Merge with short range integration, version 4.1.1

This commit is contained in:
Luis Aleixo 2022-04-13 17:14:53 +02:00
commit ddf74234f8
33 changed files with 1615770 additions and 572 deletions

View file

@ -27,21 +27,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
with:
lfs: true
- name: Create LFS file list
run: git lfs ls-files -l | cut -d' ' -f1 | sort > .lfs-assets-id
- name: Restore LFS cache
uses: actions/cache@v2
id: lfs-cache
with:
path: .git/lfs
key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }}-v1
- name: Git LFS Pull
run: git lfs pull
- name: Set up Python 3.9
uses: actions/setup-python@v2
@ -64,21 +49,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
with:
lfs: true
- name: Create LFS file list
run: git lfs ls-files -l | cut -d' ' -f1 | sort > .lfs-assets-id
- name: Restore LFS cache
uses: actions/cache@v2
id: lfs-cache
with:
path: .git/lfs
key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }}-v1
- name: Git LFS Pull
run: git lfs pull
- name: Set up Python 3.9
uses: actions/setup-python@v2

View file

@ -2,15 +2,15 @@
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.
CARA models the concentration profile of potential virions in enclosed spaces , both as background (room) concentration and during close-proximity interations, 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 risk assessment tool simulates the airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture and a two-stage exhaled jet model, and estimates the risk of COVID-19 infection therein.
The results DO NOT include the other known modes of SARS-CoV-2 transmission, such as fomite or blood-bound.
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as 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.
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2022.
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.
@ -44,11 +44,13 @@ CARA COVID Airborne Risk Assessment tool
**For use of the model**
Henriques A, Mounet N, Aleixo L, Elson P, Devine J, Azzopardi G, Andreini M, Rognlien M, Tarocco N, Tang J. (2022). Modelling airborne transmission of SARS-CoV-2 using CARA: risk assessment for enclosed spaces. _Interface Focus 20210076_. https://doi.org/10.1098/rsfs.2021.0076
_Note that the short-range component of the model has not yet been published._
## Applications
### COVID Calculator
A risk assessment tool which simulates the long range airborne spread of the SARS-CoV-2 virus for space managers.
A risk assessment tool which simulates the long-range airborne spread of the SARS-CoV-2 virus for space managers.
### CARA Expert App
@ -77,16 +79,11 @@ This will start a local version of CARA, which can be visited at http://localhos
## Development guide
The CARA repository makes use of Git's Large File Storage (LFS) feature.
You will need a working installation of git-lfs in order to run CARA in development mode.
See https://git-lfs.github.com/ for installation instructions.
CARA is also mirrored to Github if you wish to collaborate on development and can be found at: https://github.com/CERN/cara
### Installing CARA in editable mode
```
git lfs pull # Fetch the data from LFS
pip install -e . # At the root of the repository
```

2
cara/.gitattributes vendored
View file

@ -1,2 +0,0 @@
global_weather_set.json filter=lfs diff=lfs merge=lfs -text
hadisd_station_fullinfo_v311_202001p.txt filter=lfs diff=lfs merge=lfs -text

View file

@ -33,7 +33,7 @@ from .user import AuthenticatedUser, AnonymousUser
# calculator version. If the calculator needs to make breaking changes (e.g. change
# form attributes) then it can also increase its MAJOR version without needing to
# increase the overall CARA version (found at ``cara.__version__``).
__version__ = "4.0.0"
__version__ = "4.1.1"
class BaseRequestHandler(RequestHandler):
@ -162,7 +162,7 @@ class ConcentrationModelJsonResponse(BaseRequestHandler):
max_workers=self.settings['handler_worker_pool_size'],
timeout=300,
)
report_data_task = executor.submit(calculate_report_data, form.build_model())
report_data_task = executor.submit(calculate_report_data, form, form.build_model())
report_data: dict = await asyncio.wrap_future(report_data_task)
await self.finish(report_data)
@ -187,12 +187,12 @@ class StaticModel(BaseRequestHandler):
class LandingPage(BaseRequestHandler):
def get(self):
template_environment = self.settings["template_environment"]
template = self.settings["template_environment"].get_template(
template = template_environment.get_template(
"index.html.j2")
report = template.render(
user=self.current_user,
calculator_prefix=self.settings["calculator_prefix"],
text_blocks=template_environment.globals['common_text']
text_blocks=template_environment.globals['common_text'],
)
self.finish(report)
@ -212,13 +212,15 @@ class AboutPage(BaseRequestHandler):
class CalculatorForm(BaseRequestHandler):
def get(self):
template = self.settings["template_environment"].get_template(
template_environment = self.settings["template_environment"]
template = template_environment.get_template(
"calculator.form.html.j2")
report = template.render(
user=self.current_user,
xsrf_form_html=self.xsrf_form_html(),
calculator_prefix=self.settings["calculator_prefix"],
calculator_version=__version__,
text_blocks=template_environment.globals['common_text'],
)
self.finish(report)
@ -237,11 +239,13 @@ class CompressedCalculatorFormInputs(BaseRequestHandler):
class ReadmeHandler(BaseRequestHandler):
def get(self):
template = self.settings['template_environment'].get_template("userguide.html.j2")
template_environment = self.settings["template_environment"]
template = template_environment.get_template("userguide.html.j2")
readme = template.render(
active_page="calculator/user-guide",
user=self.current_user,
calculator_prefix=self.settings["calculator_prefix"],
text_blocks=template_environment.globals['common_text'],
)
self.finish(readme)

View file

@ -3,6 +3,8 @@ import datetime
import html
import logging
import typing
import ast
import json
import numpy as np
@ -11,8 +13,8 @@ from cara import data
import cara.data.weather
import cara.monte_carlo as mc
from .. import calculator
from cara.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions
from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions
from cara.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions, short_range_distances
from cara.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions, short_range_expiration_distributions
LOG = logging.getLogger(__name__)
@ -76,6 +78,8 @@ class FormData:
window_width: float
windows_number: int
window_opening_regime: str
short_range_option: str
short_range_interactions: list
#: The default values for undefined fields. Note that the defaults here
#: and the defaults in the html form must not be contradictory.
@ -127,6 +131,8 @@ class FormData:
'windows_frequency': 0.,
'windows_number': 0,
'window_opening_regime': 'windows_open_permanently',
'short_range_option': 'short_range_no',
'short_range_interactions': '[]',
}
@classmethod
@ -240,14 +246,27 @@ class FormData:
humidity = 0.5
room = models.Room(volume=volume, humidity=humidity)
infected_population = self.infected_population()
short_range = []
if self.short_range_option == "short_range_yes":
for interaction in self.short_range_interactions:
short_range.append(mc.ShortRangeModel(
expiration=short_range_expiration_distributions[interaction['expiration']],
activity=infected_population.activity,
presence=self.short_range_interval(interaction),
distance=short_range_distances,
))
# Initializes and returns a model with the attributes defined above
return mc.ExposureModel(
concentration_model=mc.ConcentrationModel(
room=room,
ventilation=self.ventilation(),
infected=self.infected_population(),
infected=infected_population,
evaporation_factor=0.3,
),
short_range = tuple(short_range),
exposed=self.exposed_population(),
)
@ -387,11 +406,16 @@ class FormData:
# Nightshift control room, 10% speaking.
{'Speaking': 1, 'Breathing': 9}
),
'meeting': (
'smallmeeting': (
'Seated',
# Conversation of N people is approximately 1/N% of the time speaking.
{'Speaking': 1, 'Breathing': self.total_people - 1}
),
'largemeeting': (
'Standing',
# each infected person spends 1/3 of time speaking.
{'Speaking': 1, 'Breathing': 2}
),
'callcentre': ('Seated', 'Speaking'),
'library': ('Seated', 'Breathing'),
'training': ('Standing', 'Speaking'),
@ -428,7 +452,8 @@ class FormData:
'office': 'Seated',
'controlroom-day': 'Seated',
'controlroom-night': 'Seated',
'meeting': 'Seated',
'smallmeeting': 'Seated',
'largemeeting': 'Seated',
'callcentre': 'Seated',
'library': 'Seated',
'training': 'Seated',
@ -623,6 +648,11 @@ class FormData:
breaks=self.infected_lunch_break_times() + self.infected_coffee_break_times(),
)
def short_range_interval(self, interaction) -> models.SpecificInterval:
start_time = time_string_to_minutes(interaction['start_time'])
duration = float(interaction['duration'])
return models.SpecificInterval(present_times=((start_time/60, (start_time + duration)/60),))
def exposed_present_interval(self) -> models.Interval:
return self.present_interval(
self.exposed_start, self.exposed_finish,
@ -630,7 +660,7 @@ class FormData:
)
def build_expiration(expiration_definition) -> models._ExpirationBase:
def build_expiration(expiration_definition) -> mc._ExpirationBase:
if isinstance(expiration_definition, str):
return expiration_distributions[expiration_definition]
elif isinstance(expiration_definition, dict):
@ -639,7 +669,7 @@ def build_expiration(expiration_definition) -> models._ExpirationBase:
np.array(expiration_BLO_factors[exp_type]) * weight/total_weight
for exp_type, weight in expiration_definition.items()
], axis=0)
return expiration_distribution(tuple(BLO_factors))
return expiration_distribution(BLO_factors=tuple(BLO_factors))
def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]:
@ -691,11 +721,13 @@ def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]:
'window_type': 'window_sliding',
'window_width': '2',
'windows_number': '1',
'window_opening_regime': 'windows_open_permanently'
'window_opening_regime': 'windows_open_permanently',
'short_range_option': 'short_range_no',
'short_range_interactions': '[]',
}
ACTIVITY_TYPES = {'office', 'meeting', 'training', 'callcentre', 'controlroom-day', 'controlroom-night', 'library', 'workshop', 'lab', 'gym'}
ACTIVITY_TYPES = {'office', 'smallmeeting', 'largemeeting', 'training', 'callcentre', 'controlroom-day', 'controlroom-night', 'library', 'workshop', 'lab', 'gym'}
MECHANICAL_VENTILATION_TYPES = {'mech_type_air_changes', 'mech_type_air_supply', 'not-applicable'}
MASK_TYPES = {'Type I', 'FFP2'}
MASK_WEARING_OPTIONS = {'mask_on', 'mask_off'}
@ -736,6 +768,14 @@ def time_minutes_to_string(time: int) -> str:
return "{0:0=2d}".format(int(time/60)) + ":" + "{0:0=2d}".format(time%60)
def string_to_list(l: str) -> list:
return list(ast.literal_eval(l.replace(""", "\"")))
def list_to_string(s: list) -> str:
return json.dumps(s)
def _safe_int_cast(value) -> int:
if isinstance(value, int):
return value
@ -767,3 +807,6 @@ for _field in dataclasses.fields(FormData):
elif _field.type is bool:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = lambda v: v == '1'
_CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = int
elif _field.type is list:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = string_to_list
_CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = list_to_string

View file

@ -12,6 +12,7 @@ import jinja2
import numpy as np
from cara import models
from cara.apps.calculator import markdown_tools
from ... import monte_carlo as mc
from .model_generator import FormData, _DEFAULT_MC_SAMPLE_SIZE
from ... import dataclass_utils
@ -96,29 +97,54 @@ def interesting_times(model: models.ExposureModel, approx_n_pts=100) -> typing.L
return nice_times
def calculate_report_data(model: models.ExposureModel) -> typing.Dict[str, typing.Any]:
times = interesting_times(model)
def concentrations_with_sr_breathing(form: FormData, model: models.ExposureModel, times: typing.List[float], short_range_intervals: typing.List) -> typing.List[float]:
lower_concentrations = []
for time in times:
for index, (start, stop) in enumerate(short_range_intervals):
# For visualization issues, add short-range breathing activity to the initial long-range concentrations
if start <= time <= stop and form.short_range_interactions[index]['expiration'] == 'Breathing':
lower_concentrations.append(np.array(model.concentration(float(time))).mean())
break
lower_concentrations.append(np.array(model.concentration_model.concentration(float(time))).mean())
return lower_concentrations
def calculate_report_data(form: FormData, model: models.ExposureModel) -> typing.Dict[str, typing.Any]:
times = interesting_times(model)
short_range_intervals = [interaction.presence.boundaries()[0] for interaction in model.short_range]
short_range_expirations = [interaction['expiration'] for interaction in form.short_range_interactions] if form.short_range_option == "short_range_yes" else []
concentrations = [
np.array(model.concentration_model.concentration(float(time))).mean()
np.array(model.concentration(float(time))).mean()
for time in times
]
]
lower_concentrations = concentrations_with_sr_breathing(form, model, times, short_range_intervals)
highest_const = max(concentrations)
prob = np.array(model.infection_probability()).mean()
er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean()
exposed_occupants = model.exposed.number
expected_new_cases = np.array(model.expected_new_cases()).mean()
cumulative_doses = np.cumsum([
np.array(model.deposited_exposure_between_bounds(float(time1), float(time2))).mean()
for time1, time2 in zip(times[:-1], times[1:])
])
long_range_cumulative_doses = np.cumsum([
np.array(model.long_range_deposited_exposure_between_bounds(float(time1), float(time2))).mean()
for time1, time2 in zip(times[:-1], times[1:])
])
prob = np.array(model.infection_probability()).mean()
er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean()
exposed_occupants = model.exposed.number
expected_new_cases = np.array(model.expected_new_cases()).mean()
return {
"times": list(times),
"exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()],
"cumulative_doses": list(cumulative_doses),
"short_range_intervals": short_range_intervals,
"short_range_expirations": short_range_expirations,
"concentrations": concentrations,
"concentrations_zoomed": lower_concentrations,
"highest_const": highest_const,
"cumulative_doses": list(cumulative_doses),
"long_range_cumulative_doses": list(long_range_cumulative_doses),
"prob_inf": prob,
"emission_rate": er,
"exposed_occupants": exposed_occupants,
@ -196,51 +222,52 @@ def non_zero_percentage(percentage: int) -> str:
def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.ExposureModel]:
scenarios = {}
if (form.short_range_option == "short_range_no"):
# Two special option cases - HEPA and/or FFP2 masks.
FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2')
if FFP2_being_worn and form.hepa_option:
FFP2andHEPAalternative = dataclass_utils.replace(form, mask_type='Type I')
scenarios['Base scenario with HEPA filter and Type I masks'] = FFP2andHEPAalternative.build_mc_model()
if not FFP2_being_worn and form.hepa_option:
noHEPAalternative = dataclass_utils.replace(form, mask_type = 'FFP2')
noHEPAalternative = dataclass_utils.replace(noHEPAalternative, mask_wearing_option = 'mask_on')
noHEPAalternative = dataclass_utils.replace(noHEPAalternative, hepa_option=False)
scenarios['Base scenario without HEPA filter, with FFP2 masks'] = noHEPAalternative.build_mc_model()
# Two special option cases - HEPA and/or FFP2 masks.
FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2')
if FFP2_being_worn and form.hepa_option:
FFP2andHEPAalternative = dataclass_utils.replace(form, mask_type='Type I')
scenarios['Base scenario with HEPA filter and Type I masks'] = FFP2andHEPAalternative.build_mc_model()
if not FFP2_being_worn and form.hepa_option:
noHEPAalternative = dataclass_utils.replace(form, mask_type = 'FFP2')
noHEPAalternative = dataclass_utils.replace(noHEPAalternative, mask_wearing_option = 'mask_on')
noHEPAalternative = dataclass_utils.replace(noHEPAalternative, hepa_option=False)
scenarios['Base scenario without HEPA filter, with FFP2 masks'] = noHEPAalternative.build_mc_model()
# The remaining scenarios are based on Type I masks (possibly not worn)
# and no HEPA filtration.
form = dataclass_utils.replace(form, mask_type='Type I')
if form.hepa_option:
form = dataclass_utils.replace(form, hepa_option=False)
# The remaining scenarios are based on Type I masks (possibly not worn)
# and no HEPA filtration.
form = dataclass_utils.replace(form, mask_type='Type I')
if form.hepa_option:
form = dataclass_utils.replace(form, hepa_option=False)
with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on')
without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off')
with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on')
without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off')
if form.ventilation_type == 'mechanical_ventilation':
#scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model()
scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model()
if form.ventilation_type == 'mechanical_ventilation':
#scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model()
scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model()
elif form.ventilation_type == 'natural_ventilation':
#scenarios['Windows open with Type I masks'] = with_mask.build_mc_model()
scenarios['Windows open without masks'] = without_mask.build_mc_model()
elif form.ventilation_type == 'natural_ventilation':
#scenarios['Windows open with Type I masks'] = with_mask.build_mc_model()
scenarios['Windows open without masks'] = without_mask.build_mc_model()
# No matter the ventilation scheme, we include scenarios which don't have any ventilation.
with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation')
without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation')
scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model()
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model()
# No matter the ventilation scheme, we include scenarios which don't have any ventilation.
with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation')
without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation')
scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model()
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model()
return scenarios
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()),
'expected_new_cases': np.mean(model.expected_new_cases()),
'concentrations': [
np.mean(model.concentration_model.concentration(time))
np.mean(model.concentration(time))
for time in sample_times
],
}
@ -300,7 +327,7 @@ class ReportGenerator:
scenario_sample_times = interesting_times(model)
context.update(calculate_report_data(model))
context.update(calculate_report_data(form, model))
alternative_scenarios = manufacture_alternative_scenarios(form)
context['alternative_scenarios'] = comparison_report(
alternative_scenarios, scenario_sample_times, executor_factory=executor_factory,
@ -308,13 +335,6 @@ class ReportGenerator:
context['permalink'] = generate_permalink(base_url, self.calculator_prefix, form)
context['calculator_prefix'] = self.calculator_prefix
# For further information about these values visit https://gitlab.cern.ch/cara/cara/-/merge_requests/321.
context['scale_warning'] = {
'level': 'red-4', # 'red-4' - 'orange-3' - 'yellow-2' - 'green-1'
'risk': 'strong', # 'strong' - 'medium' - 'reduced' - ''
'onsite_access': '4000', # '4000' - '5000' - '6500' - '8000'
'threshold': '2%' # '2%' - '' - '' - ''
}
return context
def _template_environment(self) -> jinja2.Environment:
@ -322,6 +342,9 @@ class ReportGenerator:
loader=self.jinja_loader,
undefined=jinja2.StrictUndefined,
)
env.globals['common_text'] = markdown_tools.extract_rendered_markdown_blocks(
env.get_template('common_text.md.j2')
)
env.filters['non_zero_percentage'] = non_zero_percentage
env.filters['readable_minutes'] = readable_minutes
env.filters['minutes_to_time'] = minutes_to_time
@ -332,4 +355,4 @@ class ReportGenerator:
def render(self, context: dict) -> str:
template = self._template_environment().get_template("calculator.report.html.j2")
return template.render(**context)
return template.render(**context, text_blocks=template.globals['common_text'])

View file

@ -144,6 +144,15 @@ p.notes {
padding: 15px;
page-break-inside: avoid;
}
#button_full_exposure, #button_hide_high_concentration {
display: none!important;
}
#long_range_cumulative_checkbox, #lr_cumulative_checkbox_label {
display: none!important;
}
#button_alternative_full_exposure, #button_alternative_hide_high_concentration {
display: none!important;
}
}

View file

@ -242,6 +242,16 @@ function on_wearing_mask_change() {
if (this.checked) {
getChildElement($(this)).show();
require_fields(this);
if (this.id == "mask_on") {
$('#short_range_no').click();
$('input[name="short_range_option"]').attr('disabled', true);
$("#short_range_warning").show();
}
else {
$('input[name="short_range_option"]').attr('disabled', false);
$("#short_range_warning").hide();
}
}
else {
getChildElement($(this)).hide();
@ -250,6 +260,20 @@ function on_wearing_mask_change() {
})
}
function on_short_range_option_change() {
short_range = $('input[type=radio][name=short_range_option]')
short_range.each(function (index){
if (this.checked) {
getChildElement($(this)).show();
require_fields(this);
}
else {
getChildElement($(this)).hide();
require_fields(this);
}
})
}
/* -------UI------- */
function show_disclaimer() {
@ -379,6 +403,27 @@ function validate_form(form) {
}
}
// Generate the short-range interactions list
var short_range_interactions = [];
$(".form_field_outer_row").each(function (index, element){
let obj = {};
const $element = $(element);
obj.expiration = $element.find("[name='short_range_expiration']").val();
obj.start_time = $element.find("[name='short_range_start_time']").val();
obj.duration = $element.find("[name='short_range_duration']").val();
short_range_interactions.push(JSON.stringify(obj));
});
// Sort list by time
short_range_interactions.sort(function (a, b) {
return JSON.parse(a).start_time.localeCompare(JSON.parse(b).start_time);
});
$("input[type=text][name=short_range_interactions]").val('[' + short_range_interactions + ']');
if (short_range_interactions.length == 0) {
$("input[type=radio][id=short_range_no]").prop("checked", true);
on_short_range_option_change();
}
if (submit) {
$("#generate_report").prop("disabled", true);
//Add spinner to button
@ -492,6 +537,84 @@ function validateLunchTime(obj) {
return true;
}
function overlapped_times(obj, start_time, finish_time) {
removeErrorFor($(obj));
$(obj).removeClass("red_border");
let parameter = document.getElementById($(obj).attr('id'));
if ($(obj).attr('name') == "short_range_duration" && parseFloat($(obj).val()) < 15.0) {
if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message.
insertErrorFor(parameter, "Must be ≥ 15 min.")
return false;
}
let simulation_start = parseTimeToMins($("#exposed_start").val())
let simulation_finish = parseTimeToMins($("#exposed_finish").val())
var simulation_lunch_start, simulation_lunch_finish;
if ($('input[name=exposed_lunch_option]:checked').val() == 1) {
simulation_lunch_start = parseTimeToMins($("#exposed_lunch_start").val())
simulation_lunch_finish = parseTimeToMins($("#exposed_lunch_finish").val())
} else {
simulation_lunch_start = 0
simulation_lunch_finish = 0
}
if (start_time < simulation_start || start_time > simulation_finish ||
finish_time < simulation_start || finish_time > simulation_finish ||
start_time >= simulation_lunch_start && start_time <= simulation_lunch_finish ||
finish_time >= simulation_lunch_start && finish_time <= simulation_lunch_finish ) {//If start and finish inputs are out of the simulation period
//Adds the red border and error message.
if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border");
insertErrorFor(parameter, "Out of event time.");
return false;
}
let current_interaction = $(obj).closest(".form_field_outer_row");
var toReturn = true;
$(".form_field_outer_row.row_validated").not(current_interaction).each(function(index, el) {
let current_start_el = $(el).find("input[name='short_range_start_time']");
let current_duration_el = $(el).find("input[name='short_range_duration']")
start_time_2 = parseTimeToMins(current_start_el.val())
finish_time_2 = parseTimeToMins(current_start_el.val()) + parseInt(current_duration_el.val());
if ((start_time > start_time_2 && start_time < finish_time_2) || ( //If hour input is within other time range
finish_time > start_time_2 && finish_time < finish_time_2) || //If finish time input is within other time range
(start_time <= start_time_2 && finish_time >= finish_time_2) || //If start and finish inputs encompass other time range
start_time == start_time_2) {
if (!$(obj).hasClass("red_border")) $(parameter).addClass("red_border"); //Adds the red border and error message.
insertErrorFor(parameter, "Time overlap.")
toReturn = false;
return false;
}
});
return toReturn;
}
function validate_sr_time(obj) {
let obj_id = $(obj).attr('id').split('_').slice(-1)[0];
var start_time, finish_time;
if ($(obj).val() != "") {
if ($('#sr_start_no_' + String(obj_id)).val()) start_time = parseTimeToMins($('#sr_start_no_' + String(obj_id)).val());
else start = 0.
finish_time = start_time + parseInt($('#sr_duration_no_' + String(obj_id)).val());
}
return overlapped_times(obj, start_time, finish_time);
};
// Check if short-range durations are filled, and if there is no repetitions
function validate_sr_parameter(obj, error_message) {
if ($(obj).val() == "" || $(obj).val() == null) {
if (!$(obj).hasClass("red_border") && !$(obj).prop("disabled")) {
var parameter = document.getElementById($(obj).attr('id'));
insertErrorFor(parameter, error_message)
$(parameter).addClass("red_border");
}
return false;
} else {
removeErrorFor($(obj));
$(obj).removeClass("red_border");
return true;
}
}
function parseValToNumber(val) {
return parseInt(val.replace(':',''), 10);
}
@ -529,6 +652,22 @@ $(document).ready(function () {
elemObj.checked = (value==1);
}
// Read short-range from URL
else if (name == 'short_range_interactions') {
let index = 1;
for (const interaction of JSON.parse(value)) {
$("#dialog_sr").append(inject_sr_interaction(index, value = interaction, is_validated="row_validated"))
$('#sr_expiration_no_' + String(index)).val(interaction.expiration).change();
document.getElementById('sr_expiration_no_' + String(index)).disabled = true;
document.getElementById('sr_start_no_' + String(index)).disabled = true;
document.getElementById('sr_duration_no_' + String(index)).disabled = true;
document.getElementById('edit_row_no_' + String(index)).style.cssText = 'display:inline !important';
document.getElementById('validate_row_no_' + String(index)).style.cssText = 'display: none !important';
index++;
}
$("#sr_interactions").text(index - 1);
}
//Ignore 0 (default) values from server side
else if (!(elemObj.classList.contains("non_zero") || elemObj.classList.contains("remove_zero")) || (value != "0.0" && value != "0")) {
elemObj.value = value;
@ -578,6 +717,13 @@ $(document).ready(function () {
// Call the function now to handle forward/back button presses in the browser.
on_wearing_mask_change();
// When the short_range_option changes we want to make its respective
// children show/hide.
$("input[type=radio][name=short_range_option]").change(on_short_range_option_change);
// Call the function now to handle forward/back button presses in the browser.
on_short_range_option_change();
// Setup the maximum number of people at page load (to handle back/forward),
// and update it when total people is changed.
setMaxInfectedPeople();
@ -690,6 +836,134 @@ $(document).ready(function () {
}
return selectedSuggestion.text;
}
function inject_sr_interaction(index, value, is_validated) {
return `<div class="col-md-12 form_field_outer p-0">
<div class="form_field_outer_row ${is_validated} split">
<div class='form-group row'>
<div class="col-sm-4"><label class="col-form-label col-form-label-sm"> Expiration: </label><br></div>
<div class="col-sm-8"><select id="sr_expiration_no_${index}" name="short_range_expiration" class="form-control form-control-sm" onchange="validate_sr_parameter(this)" form="not-submitted">
<option value="" selected disabled>Select type</option>
<option value="Breathing">Breathing</option>
<option value="Speaking">Speaking</option>
<option value="Shouting">Shouting</option>
</select><br>
</div>
</div>
<div class='form-group row'>
<div class="col-sm-4"><label class="col-form-label col-form-label-sm"> Start: </label></div>
<div class="col-sm-8"><input type="time" class="form-control form-control-sm short_range_option" name="short_range_start_time" id="sr_start_no_${index}" value="${value.start_time}" onchange="validate_sr_time(this)" form="not-submitted"><br></div>
</div>
<div class='form-group row'>
<div class="col-sm-4"><label class="col-form-label col-form-label-sm"> Duration:</label></div>
<div class="col-sm-8"><input type="number" id="sr_duration_no_${index}" value="${value.duration}" class="form-control form-control-sm short_range_option" name="short_range_duration" min=1 placeholder="Minutes" onchange="validate_sr_time(this)" form="not-submitted"><br></div>
</div>
<div class="form-group" style="max-width: 8rem">
<button type="button" id="edit_row_no_${index}" class="edit_node_btn_frm_field btn btn-success btn-sm d-none">Edit</button>
<button type="button" id="validate_row_no_${index}" class="validate_node_btn_frm_field btn btn-success btn-sm">Save</button>
<button type="button" class="remove_node_btn_frm_field btn btn-danger btn-sm">Delete</button>
</div>
</div>
</div>`
}
// Add one empty row if none.
$("#set_interactions_button").on("click", e => {
if ($(".form_field_outer").find(".form_field_outer_row").length == 0) $(".add_node_btn_frm_field").click();
});
// When short_range_yes option is selected, we want to inject rows for each expiractory activity, start_time and duration.
$("body").on("click", ".add_node_btn_frm_field", function(e) {
let last_row = $(".form_field_outer").find(".form_field_outer_row");
if (last_row.length == 0) $("#dialog_sr").append(inject_sr_interaction(1, value = { activity: "", start_time: "", duration: "15" }));
else {
last_index = last_row.last().find(".short_range_option").prop("id").split("_").slice(-1)[0];
index = parseInt(last_index) + 1;
$("#dialog_sr").append(inject_sr_interaction(index, value = { activity: "", start_time: "", duration: "15" }));
}
});
// Validate row button (Save button)
$("body").on("click", ".validate_node_btn_frm_field", function() {
var index = $(this).attr('id').split('_').slice(-1)[0];
let activity = validate_sr_parameter('#sr_expiration_no_' + String(index)[0], "Required input.");
let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "Required input.");
let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "Required input.");
if (activity && start && duration) {
if (validate_sr_time('#sr_start_no_' + String(index)) && validate_sr_time('#sr_duration_no_' + String(index))) {
document.getElementById('sr_expiration_no_' + String(index)).disabled = true;
document.getElementById('sr_start_no_' + String(index)).disabled = true;
document.getElementById('sr_duration_no_' + String(index)).disabled = true;
document.getElementById('edit_row_no_' + String(index)).style.cssText = 'display:inline !important';
$(this).closest(".form_field_outer_row").addClass("row_validated");
$(this).hide();
index = index + 1;
}
}
// On save, check open/unvalidated rows.
$(".validate_node_btn_frm_field").not(".row_validated").not(this).each(function( index ) {
index = $(this).attr('id').split('_').slice(-1)[0];
if ($('#sr_start_no_' + String(index)[0]).val() != "") {
validate_sr_parameter('#sr_start_no_' + String(index)[0], "Required input.")
validate_sr_time('#sr_start_no_' + String(index));
};
if ($('#sr_duration_no_' + String(index)[0]).val() != "") {
validate_sr_parameter('#sr_duration_no_' + String(index)[0], "Required input.");
validate_sr_time('#sr_duration_no_' + String(index));
}
});
});
//Edit short-range activity type
$("body").on("click", ".edit_node_btn_frm_field", function() {
$(this).closest(".form_field_outer_row").removeClass("row_validated");
$(this).hide();
let id = $(this).attr('id').split('_').slice(-1)[0];
document.getElementById('sr_expiration_no_' + String(id)).disabled = false;
document.getElementById('sr_start_no_' + String(id)).disabled = false;
document.getElementById('sr_duration_no_' + String(id)).disabled = false;
document.getElementById('validate_row_no_' + String(id)).style.cssText = 'display:inline !important';
})
//Remove short-range interaction (modal field row).
$("body").on("click", ".remove_node_btn_frm_field", function() {
$(this).closest(".form_field_outer_row").remove();
// On delete, check open/unvalidated rows.
$(".validate_node_btn_frm_field").not(".row_validated").not(this).each(function( index ) {
index = $(this).attr('id').split('_').slice(-1)[0];
if ($('#sr_start_no_' + String(index)[0]).val() != "") {
validate_sr_parameter('#sr_start_no_' + String(index)[0], "Required input.")
validate_sr_time('#sr_start_no_' + String(index));
};
if ($('#sr_duration_no_' + String(index)[0]).val() != "") {
validate_sr_parameter('#sr_duration_no_' + String(index)[0], "Required input.");
validate_sr_time('#sr_duration_no_' + String(index));
}
});
});
//Short-range modal - close and save button
$("body").on("click", ".close_btn_frm_field", function() {
$(".validate_node_btn_frm_field").click();
if ($(".form_field_outer").find(".form_field_outer_row.row_validated").length == $(".form_field_outer").find(".form_field_outer_row").length) {
$("#sr_interactions").text($(".form_field_outer").find(".form_field_outer_row.row_validated").length);
$(".form_field_outer_row").not(".row_validated").remove();
$('#short_range_dialog').modal('hide');
}
});
//Short-range modal - reset button
$("body").on("click", ".dismiss_btn_frm_field", function() {
$(".form_field_outer_row").remove();
$("#sr_interactions").text(0);
$('input[type=radio][id=short_range_no]').prop("checked", true);
on_short_range_option_change();
});
});

View file

@ -1,56 +1,38 @@
/* Generate the concentration plot using d3 library. */
function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses, exposed_presence_intervals) {
function draw_plot(svg_id) {
console.log(cumulative_doses)
var time_format = d3.timeFormat('%H:%M');
// Used for controlling the short-range interactions
let button_full_exposure = document.getElementById("button_full_exposure");
let button_hide_high_concentration = document.getElementById("button_hide_high_concentration");
let long_range_checkbox = document.getElementById('long_range_cumulative_checkbox')
let show_sr_legend = short_range_expirations.length > 0;
var data_for_graphs = {
'concentrations': [],
'cumulative_doses': [],
'long_range_cumulative_doses': [],
}
times.map((time, index) => data_for_graphs.concentrations.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': concentrations[index]}));
times.map((time, index) => data_for_graphs.cumulative_doses.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': cumulative_doses[index]}));
times.map((time, index) => data_for_graphs.long_range_cumulative_doses.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': long_range_cumulative_doses[index]}));
const tooltip_data_for_graphs = Object.fromEntries(Object.entries(data_for_graphs).filter(([key]) => !key.includes('long_range_cumulative_doses')));
// Add main SVG element
var plot_div = document.getElementById(svg_id);
var vis = d3.select(plot_div).append('svg');
var time_format = d3.timeFormat('%H:%M');
// H:M time format for x axis.
xRange = d3.scaleTime().domain([data_for_graphs.concentrations[0].hour, data_for_graphs.concentrations[data_for_graphs.concentrations.length - 1].hour]),
xTimeRange = d3.scaleLinear().domain([data_for_graphs.concentrations[0].time, data_for_graphs.concentrations[data_for_graphs.concentrations.length - 1].time]),
bisecHour = d3.bisector((d) => { return d.hour; }).left,
yRange = d3.scaleLinear().domain([0., Math.max(...concentrations)]),
yCumulativeRange = d3.scaleLinear().domain([0., Math.max(...cumulative_doses)*1.1]),
xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)),
yAxis = d3.axisLeft(yRange).ticks(4),
yCumulativeAxis = d3.axisRight(yCumulativeRange).ticks(4);
// Line representing the mean concentration.
var lineFunc = d3.line();
var draw_line = vis.append('svg:path')
.attr('stroke', '#1f77b4')
.attr('stroke-width', 2)
.attr('fill', 'none');
var lineCumulative = d3.line();
var draw_cumulative_line = vis.append('svg:path')
.attr('stroke', '#1f77b4')
.attr('stroke-width', 2)
.style("stroke-dasharray", "5 5")
.attr('fill', 'none');
// Area representing the presence of exposed person(s).
var exposedArea = {};
var drawArea = {};
exposed_presence_intervals.forEach((b, index) => {
exposedArea[index] = d3.area();
drawArea[index] = vis.append('svg:path')
.attr('fill', '#1f77b4')
.attr('fill-opacity', '0.1');
});
yRange = d3.scaleLinear(),
yCumulativeRange = d3.scaleLinear(),
yAxis = d3.axisLeft(),
yCumulativeAxis = d3.axisRight();
// X axis declaration.
var xAxisEl = vis.append('svg:g')
@ -77,7 +59,6 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
// Y cumulative concentration axis declaration.
var yAxisCumEl = vis.append('svg:g')
.attr('class', 'y axis')
.style('font-size', 14)
.style("stroke-dasharray", "5 5");
// Y cumulated concentration axis label.
@ -88,41 +69,76 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
.text('Mean cumulative dose (infectious virus)');
// Legend for the plot elements - line and area.
// Concentration line icon
var legendLineIcon = vis.append('rect')
.attr('width', 20)
.attr('height', 3)
.style('fill', '#1f77b4');
// Concentration line text
var legendLineText = vis.append('text')
.text('Mean concentration')
.style('font-size', '15px');
// Cumulative dose line icon
var legendCumulativeIcon = vis.append('line')
.style("stroke-dasharray", "5 5") //dashed array for line
.attr('stroke-width', '2')
.style("stroke", '#1f77b4');
var legendAreaIcon = vis.append('rect')
.attr('width', 20)
.attr('height', 20)
.attr('fill', '#1f77b4')
.attr('fill-opacity', '0.1');
var legendLineText = vis.append('text')
.text('Mean concentration')
.style('font-size', '15px')
.attr('alignment-baseline', 'central');
// Cumulative dose line text
var legendCumutiveText = vis.append('text')
.text('Cumulative dose')
.style('font-size', '15px')
.attr('alignment-baseline', 'central');
.style('font-size', '15px');
// Area line icon
var legendAreaIcon = vis.append('rect')
.attr('width', 20)
.attr('height', 15)
.attr('fill', '#1f77b4')
.attr('fill-opacity', '0.1');
// Area line text
var legendAreaText = vis.append('text')
.text('Presence of exposed person(s)')
.style('font-size', '15px')
.attr('alignment-baseline', 'central');
.style('font-size', '15px');
sr_unique_activities = [...new Set(short_range_expirations)]
if (show_sr_legend) {
// Long range cumulative dose line legend - line and area
var legendLongCumulativeIcon = vis.append('line')
.style("stroke-dasharray", "5 5") //dashed array for line
.attr('stroke-width', '2')
.style("stroke", 'purple')
.attr('opacity', 0);
var legendLongCumutiveText = vis.append('text')
.text('Long-range cumulative dose')
.style('font-size', '15px')
.attr('opacity', 0);
// Short range area icon
var legendShortRangeAreaIcon = {};
sr_unique_activities.forEach((b, index) => {
legendShortRangeAreaIcon[index] = vis.append('rect')
.attr('width', 20)
.attr('height', 15);
// Short range area icon colors
if (sr_unique_activities[index] == 'Breathing') legendShortRangeAreaIcon[index].attr('fill', 'red').attr('fill-opacity', '0.2');
else if (sr_unique_activities[index] == 'Speaking') legendShortRangeAreaIcon[index].attr('fill', 'green').attr('fill-opacity', '0.1');
else legendShortRangeAreaIcon[index].attr('fill', 'blue').attr('fill-opacity', '0.1');
});
// Short range area text
var legendShortRangeText = {};
sr_unique_activities.forEach((b, index) => {
legendShortRangeText[index] = vis.append('text')
.text('Short-range - ' + sr_unique_activities[index])
.style('font-size', '15px');
});
}
// Legend bounding
if (show_sr_legend) legendBBox_height = 68 + 20 * sr_unique_activities.length;
else legendBBox_height = 68;
var legendBBox = vis.append('rect')
.attr('width', 255)
.attr('height', 70)
.attr('height', legendBBox_height)
.attr('stroke', 'lightgrey')
.attr('stroke-width', '2')
.attr('rx', '5px')
@ -130,9 +146,64 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
.attr('stroke-linejoin', 'round')
.attr('fill', 'none');
var clip = vis.append("defs").append("svg:clipPath")
.attr("id", "clip")
.append("svg:rect");
var draw_area = vis.append('svg:g')
.attr('clip-path', 'url(#clip)');
// Line representing the mean concentration.
var lineFunc = d3.line();
draw_area.append('svg:path')
.attr('class', 'line')
.attr('stroke', '#1f77b4')
.attr('stroke-width', 2)
.attr('fill', 'none');
// Line representing the cumulative concentration.
var lineCumulative = d3.line();
var draw_cumulative_line = draw_area.append('svg:path')
.attr('stroke', '#1f77b4')
.attr('stroke-width', 2)
.style("stroke-dasharray", "5 5")
.attr('fill', 'none');
// Line representing the long-range cumulative concentration.
if (show_sr_legend) {
var longRangeCumulative = d3.line();
var draw_long_range_cumulative_line = draw_area.append('svg:path')
.attr('stroke', 'purple')
.attr('stroke-width', 2)
.style("stroke-dasharray", "5 5")
.attr('fill', 'none')
.attr('opacity', 0);
}
// Area representing the presence of exposed person(s).
var exposedArea = {};
var drawArea = {};
exposed_presence_intervals.forEach((b, index) => {
exposedArea[index] = d3.area();
drawArea[index] = draw_area.append('svg:path')
.attr('fill', '#1f77b4')
.attr('fill-opacity', '0.1');
});
// Area representing the short-range interaction(s).
var shortRangeArea = {};
var drawShortRangeArea = {};
short_range_intervals.forEach((b, index) => {
shortRangeArea[index] = d3.area();
drawShortRangeArea[index] = draw_area.append('svg:path');
if (short_range_expirations[index] == 'Breathing') drawShortRangeArea[index].attr('fill', 'red').attr('fill-opacity', '0.2');
else if (short_range_expirations[index] == 'Speaking') drawShortRangeArea[index].attr('fill', 'green').attr('fill-opacity', '0.1');
else drawShortRangeArea[index].attr('fill', 'blue').attr('fill-opacity', '0.1');
});
// Tooltip.
var focus = {}, tooltip_rect = {}, tooltip_time = {}, tooltip_concentration = {}, toolBox = {};
for (const [concentration, data] of Object.entries(data_for_graphs)) {
for (const [concentration, data] of Object.entries(tooltip_data_for_graphs)) {
focus[concentration] = vis.append('svg:g')
.style('display', 'none');
@ -145,7 +216,6 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
.attr('stroke', '#000')
.attr('width', 85)
.attr('height', 50)
.attr('x', 10)
.attr('y', -22)
.attr('rx', 4)
.attr('ry', 4);
@ -165,11 +235,66 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
.attr('pointer-events', 'all')
.on('mouseover', () => { for (const [concentration, data] of Object.entries(focus)) focus[concentration].style('display', null); })
.on('mouseout', () => { for (const [concentration, data] of Object.entries(focus)) focus[concentration].style('display', 'none'); })
.on('mousemove', mousemove);
.on('mousemove', mousemove);;
}
var graph_width;
var graph_height;
function update_concentration_plot(concentration_data, cumulative_data) {
yRange.domain([0., Math.max(...concentration_data)*1.1]);
yAxisEl.transition().duration(1000).call(yAxis);
yCumulativeRange.domain([0., Math.max(...cumulative_data)*1.1]);
yAxisCumEl.transition().duration(1000).call(yCumulativeAxis)
// Concentration line
lineFunc.defined(d => !isNaN(d.concentration))
.x(d => xTimeRange(d.time))
.y(d => yRange(d.concentration));
draw_area.select('.line')
.transition()
.duration(1000)
.attr("d", lineFunc(data_for_graphs.concentrations));
// Cumulative line.
lineCumulative.defined(d => !isNaN(d.concentration))
.x(d => xTimeRange(d.time))
.y(d => yCumulativeRange(d.concentration));
draw_cumulative_line.transition()
.duration(1000)
.attr("d", lineCumulative(data_for_graphs.cumulative_doses));
// Long-range cumulative line.
if (show_sr_legend) {
longRangeCumulative.defined(d => !isNaN(d.concentration))
.x(d => xTimeRange(d.time))
.y(d => yCumulativeRange(d.concentration));
draw_long_range_cumulative_line.transition()
.duration(1000)
.attr("d", lineCumulative(data_for_graphs.long_range_cumulative_doses));
}
// Area.
exposed_presence_intervals.forEach((b, index) => {
exposedArea[index].x(d => xTimeRange(d.time))
.y0(graph_height - 50)
.y1(d => yRange(d.concentration)
);
drawArea[index].transition().duration(1000).attr('d', exposedArea[index](data_for_graphs.concentrations.filter(d => {
return d.time >= b[0] && d.time <= b[1]
})));
});
// Short-Range Area.
short_range_intervals.forEach((b, index) => {
shortRangeArea[index].x(d => xTimeRange(d.time))
.y0(graph_height - 50)
.y1(d => yRange(d.concentration));
drawShortRangeArea[index].transition().duration(1000).attr('d', shortRangeArea[index](data_for_graphs.concentrations.filter(d => {
return d.time >= b[0] && d.time <= b[1]
})));
});
}
function redraw() {
@ -182,7 +307,7 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
var margins = { top: 30, right: 20, bottom: 50, left: 60 };
div_width = 900;
graph_width = div_width * (2/3);
const svg_margins = {'margin-left': '0rem', 'margin-top': '0rem'};
const svg_margins = {'margin-left': '0rem'};
Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val));
}
else {
@ -190,7 +315,7 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
div_width = div_width * 1.1
graph_width = div_width * .9;
graph_height = div_height * 0.65; // On mobile screen sizes we want the legend to be on the bottom of the graph.
const svg_margins = {'margin-left': '-1rem', 'margin-top': '3rem'};
const svg_margins = {'margin-left': '-1rem'};
Object.entries(svg_margins).forEach(([prop,val]) => vis.style(prop,val));
};
@ -200,45 +325,29 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
// SVG components according to the width and height.
// clipPath: everything out of this area won't be drawn.
clip.attr("x", margins.left)
.attr("y", margins.top)
.attr("width", graph_width - margins.right - margins.left)
.attr("height", graph_height - margins.top - margins.bottom);
// Axis ranges.
xRange.range([margins.left, graph_width - margins.right]);
xTimeRange.range([margins.left, graph_width - margins.right]);
yRange.range([graph_height - margins.bottom, margins.top]);
yCumulativeRange.range([graph_height - margins.bottom, margins.top]);
// Line.
lineFunc.defined(d => !isNaN(d.concentration))
.x(d => xTimeRange(d.time))
.y(d => yRange(d.concentration));
draw_line.attr("d", lineFunc(data_for_graphs.concentrations));
// Cumulative line
lineCumulative.defined(d => !isNaN(d.concentration))
.x(d => xTimeRange(d.time))
.y(d => yCumulativeRange(d.concentration));
draw_cumulative_line.attr("d", lineCumulative(data_for_graphs.cumulative_doses));
// Area.
exposed_presence_intervals.forEach((b, index) => {
exposedArea[index].x(d => xTimeRange(d.time))
.y0(graph_height - margins.bottom)
.y1(d => yRange(d.concentration));
drawArea[index].attr('d', exposedArea[index](data_for_graphs.concentrations.filter(d => {
return d.time >= b[0] && d.time <= b[1]
})));
});
// Axis.
var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d));
var yAxis = d3.axisLeft(yRange);
yAxis.scale(yRange);
yCumulativeAxis.scale(yCumulativeRange);
xAxisEl.attr('transform', 'translate(0,' + (graph_height - margins.bottom) + ')')
.call(xAxis);
xAxisLabelEl.attr('x', (graph_width + margins.right) / 2)
.attr('y', graph_height * 0.97);
yAxisEl.attr('transform', 'translate(' + margins.left + ',0)').call(yAxis);
yAxisEl.attr('transform', 'translate(' + margins.left + ',0)');
yAxisLabelEl.attr('x', (graph_height * 0.9 + margins.bottom) / 2)
.attr('y', (graph_height + margins.left) * 0.9)
.attr('transform', 'rotate(-90, 0,' + graph_height + ')');
@ -258,64 +367,129 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
.attr('y', graph_width + 290);
}
// Legend on right side.
const size = 20;
var legend_x_start = 50;
const space_between_text_icon = 30;
const text_height = 6;
// Legend on right side.
if (plot_div.clientWidth >= 900) {
legendLineIcon.attr('x', graph_width + size * 2.5)
.attr('y', margins.top + size);
legendLineText.attr('x', graph_width + 4 * size)
legendLineIcon.attr('x', graph_width + legend_x_start)
.attr('y', margins.top + size);
legendLineText.attr('x', graph_width + legend_x_start + space_between_text_icon)
.attr('y', margins.top + size + text_height);
legendCumulativeIcon.attr("x1", graph_width + legend_x_start)
.attr("x2", graph_width + legend_x_start + 20)
.attr("y1", margins.top + 2 * size)
.attr("y2", margins.top + 2 * size);
legendCumutiveText.attr('x', graph_width + legend_x_start + space_between_text_icon)
.attr('y', margins.top + 2 * size + text_height);
legendCumulativeIcon.attr("x1", graph_width + size + 30)
.attr("x2", graph_width + 2 * size + 32)
.attr("y1", 3.5 * size)
.attr("y2", 3.5 * size);
legendCumutiveText.attr('x', graph_width + 2.5 * size + 30)
.attr('y', margins.top + 2 * size);
legendAreaIcon.attr('x', graph_width + legend_x_start)
.attr('y', margins.top + (3 * size) - 15/2);
legendAreaText.attr('x', graph_width + legend_x_start + space_between_text_icon)
.attr('y', margins.top + 3 * size + text_height);
legendAreaIcon.attr('x', graph_width + size * 2.5)
.attr('y', margins.top + 2.5 * size);
legendAreaText.attr('x', graph_width + 4 * size)
.attr('y', margins.top + 3 * size);
if (show_sr_legend) {
sr_unique_activities.forEach((b, index) => {
legendShortRangeAreaIcon[index].attr('x', graph_width + legend_x_start)
.attr('y', margins.top + (4 + index) * size - 15/2);
legendShortRangeText[index].attr('x', graph_width + legend_x_start + space_between_text_icon)
.attr('y', margins.top + (4 + index) * size + text_height);
});
legendLongCumulativeIcon.attr("x1", graph_width + legend_x_start)
.attr("x2", graph_width + legend_x_start + 20)
.attr("y1", margins.top + (4 + sr_unique_activities.length) * size)
.attr("y2", margins.top + (4 + sr_unique_activities.length) * size);
legendLongCumutiveText.attr('x', graph_width + legend_x_start + space_between_text_icon)
.attr('y', margins.top + (4 + sr_unique_activities.length) * size + + text_height);
}
legendBBox.attr('x', graph_width * 1.07)
.attr('y', margins.top * 1.2);
}
// Legend on the bottom.
else {
legendLineIcon.attr('x', size * 0.5)
.attr('y', graph_height * 1.05);
legendLineText.attr('x', 2 * size)
.attr('y', graph_height * 1.05);
legend_x_start = 10;
legendCumulativeIcon.attr("x1", size * 0.5)
.attr("x2", size * 1.55)
.attr("y1", graph_height * 1.05 + size)
.attr("y2", graph_height * 1.05 + size);
legendCumutiveText.attr('x', 2 * size)
.attr('y', graph_height + 1.65 * size);
legendLineIcon.attr('x', legend_x_start)
.attr('y', graph_height + size);
legendLineText.attr('x', legend_x_start + space_between_text_icon)
.attr('y', graph_height + size + text_height);
legendAreaIcon.attr('x', size * 0.50)
.attr('y', graph_height * 1.09 + size);
legendAreaText.attr('x', 2 * size)
.attr('y', graph_height + 2.7 * size);
legendCumulativeIcon.attr("x1", legend_x_start)
.attr("x2", legend_x_start + 20)
.attr("y1", graph_height + 2 * size)
.attr("y2", graph_height + 2 * size);
legendCumutiveText.attr('x', legend_x_start + space_between_text_icon)
.attr('y', graph_height + 2 * size + text_height);
legendAreaIcon.attr('x', legend_x_start)
.attr('y', graph_height + 3 * size - 15/2);
legendAreaText.attr('x', legend_x_start + space_between_text_icon)
.attr('y', graph_height + 3 * size + text_height);
if (show_sr_legend) {
sr_unique_activities.forEach((b, index) => {
legendShortRangeAreaIcon[index].attr('x', legend_x_start)
.attr('y', graph_height + 4 * size - 15/2);
legendShortRangeText[index].attr('x', legend_x_start + space_between_text_icon)
.attr('y', graph_height + 4 * size + text_height);
});
legendLongCumulativeIcon.attr("x1", legend_x_start)
.attr("x2", legend_x_start + 20)
.attr("y1", graph_height + (4 + sr_unique_activities.length) * size)
.attr("y2", graph_height + (4 + sr_unique_activities.length) * size)
legendLongCumutiveText.attr('x', legend_x_start + space_between_text_icon)
.attr('y', graph_height + (4 + sr_unique_activities.length) * size + text_height);
}
legendBBox.attr('x', 1)
.attr('y', graph_height);
.attr('y', graph_height + 6);
}
// ToolBox.
for (const [concentration, data] of Object.entries(data_for_graphs)) {
for (const [concentration, data] of Object.entries(tooltip_data_for_graphs)) {
toolBox[concentration].attr('width', graph_width - margins.right)
.attr('height', graph_height);
}
}
// Draw for the first time to initialize.
redraw();
if (show_sr_legend) {
long_range_checkbox.addEventListener("click", () => {
if (long_range_checkbox.checked) {
draw_long_range_cumulative_line.transition().duration(1000).attr("opacity", 1);
legendBBox.transition().duration(1000).attr("height", legendBBox_height + 20);
legendLongCumulativeIcon.transition().duration(1000).attr("opacity", 1);
legendLongCumutiveText.transition().duration(1000).attr("opacity", 1);
}
else {
draw_long_range_cumulative_line.transition().duration(1000).attr("opacity", 0);
legendBBox.transition().duration(1000).attr("height", legendBBox_height);
legendLongCumulativeIcon.transition().duration(1000).attr("opacity", 0);
legendLongCumutiveText.transition().duration(1000).attr("opacity", 0);
}
});
};
if (button_full_exposure) {
button_full_exposure.addEventListener("click", () => {
update_concentration_plot(concentrations, cumulative_doses);
button_full_exposure.disabled = true;
button_hide_high_concentration.disabled = false;
});
}
if (button_hide_high_concentration) {
button_hide_high_concentration.addEventListener("click", () => {
update_concentration_plot(concentrations_zoomed, long_range_cumulative_doses);
button_full_exposure.disabled = false;
button_hide_high_concentration.disabled = true;
});
}
function mousemove() {
for (const [scenario, data] of Object.entries(data_for_graphs)) {
for (const [scenario, data] of Object.entries(tooltip_data_for_graphs)) {
if (d3.pointer(event)[0] < graph_width / 2) {
tooltip_rect[scenario].attr('x', 10)
tooltip_time[scenario].attr('x', 18)
@ -351,27 +525,39 @@ function draw_concentration_plot(svg_id, times, concentrations, cumulative_doses
}
}
// Redraw based on the new size whenever the browser window is resized.
window.addEventListener("resize", redraw);
// Draw for the first time to initialize.
redraw();
update_concentration_plot(concentrations, cumulative_doses);
// Redraw based on the new size whenever the browser window is resized.
window.addEventListener("resize", e => {
redraw();
if (button_full_exposure && button_full_exposure.disabled) update_concentration_plot(concentrations, cumulative_doses);
else update_concentration_plot(concentrations_zoomed, long_range_cumulative_doses)
});
}
// Generate the alternative scenarios plot using d3 library.
// 'alternative_scenarios' is a dictionary with all the alternative scenarios
// 'times' is a list of times for all the scenarios
function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_plot_svg_id, times, alternative_scenarios) {
// H:M format
// The method is prepared to consider short range interactions if needed.
function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_plot_svg_id) {
// H:M format
var time_format = d3.timeFormat('%H:%M');
// D3 array of ten categorical colors represented as RGB hexadecimal strings.
var colors = d3.schemeAccent;
// Used for controlling the short-range interactions
let button_full_exposure = document.getElementById("button_alternative_full_exposure");
let button_hide_high_concentration = document.getElementById("button_alternative_hide_high_concentration");
// Variable for the highest concentration for all the scenarios
var highest_concentration = 0.
var data_for_scenarios = {}
for (scenario in alternative_scenarios) {
scenario_concentrations = alternative_scenarios[scenario].concentrations
scenario_concentrations = alternative_scenarios[scenario].concentrations;
highest_concentration = Math.max(highest_concentration, Math.max(...scenario_concentrations))
@ -393,34 +579,9 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
var xTimeRange = d3.scaleLinear().domain([times[0], times[times.length - 1]]);
var bisecHour = d3.bisector((d) => { return d.hour; }).left;
var yRange = d3.scaleLinear().domain([0., highest_concentration]);
var yRange = d3.scaleLinear();
var yAxis = d3.axisLeft();
// Line representing the mean concentration for each scenario.
var lineFuncs = {}, draw_lines = {}, label_icons = {}, label_text = {};
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
// Line representing the mean concentration.
lineFuncs[scenario_name] = d3.line();
draw_lines[scenario_name] = vis.append('svg:path')
.attr("stroke", colors[scenario_index])
.attr('stroke-width', 2)
.attr('fill', 'none');
// Legend for the plot elements - lines.
label_icons[scenario_name] = vis.append('rect')
.attr('width', 20)
.attr('height', 3)
.style('fill', colors[scenario_index]);
label_text[scenario_name] = vis.append('text')
.text(scenario_name)
.style('font-size', '15px')
.attr('alignment-baseline', 'central');
}
// X axis.
var xAxisEl = vis.append('svg:g')
.attr('class', 'x axis');
@ -454,6 +615,38 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
.attr('stroke-linejoin', 'round')
.attr('fill', 'none');
var clip = vis.append("defs").append("svg:clipPath")
.attr("id", "clip")
.append("svg:rect");
var draw_area = vis.append('svg:g')
.attr('clip-path', 'url(#clip)');
// Line representing the mean concentration for each scenario.
var lineFuncs = {}, draw_lines = {}, label_icons = {}, label_text = {};
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
// Line representing the mean concentration.
lineFuncs[scenario_name] = d3.line();
draw_lines[scenario_name] = draw_area.append('svg:path')
.attr("stroke", colors[scenario_index])
.attr('stroke-width', 2)
.attr('fill', 'none');
// Legend for the plot elements - lines.
label_icons[scenario_name] = vis.append('rect')
.attr('width', 20)
.attr('height', 3)
.style('fill', colors[scenario_index]);
label_text[scenario_name] = vis.append('text')
.text(scenario_name)
.style('font-size', '15px');
}
// Tooltip.
var focus = {}, tooltip_rect = {}, tooltip_time = {}, tooltip_concentration = {}, toolBox = {};
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
@ -489,6 +682,26 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
.on('mousemove', mousemove);
}
function update_alternative_concentration_plot(concentration_data) {
var highest_concentration = 0.
for (scenario in alternative_scenarios) {
scenario_concentrations = alternative_scenarios[scenario][concentration_data];
highest_concentration = Math.max(highest_concentration, Math.max(...scenario_concentrations));
}
yRange.domain([0., highest_concentration]);
yAxisEl.transition().duration(1000).call(yAxis);
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
// Lines.
lineFuncs[scenario_name].defined(d => !isNaN(d.concentration))
.x(d => xTimeRange(d.time))
.y(d => yRange(d.concentration));
draw_lines[scenario_name].transition().duration(1000).attr("d", lineFuncs[scenario_name](data));
}
}
var graph_width;
var graph_height;
@ -519,41 +732,45 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
.attr('height', div_height);
// SVG components according to the width and height.
// clipPath: everything out of this area won't be drawn.
clip.attr("x", margins.left)
.attr("y", margins.top)
.attr("width", graph_width - margins.right - margins.left)
.attr("height", graph_height - margins.top - margins.bottom);
// Axis ranges.
xRange.range([margins.left, graph_width - margins.right]);
xTimeRange.range([margins.left, graph_width - margins.right]);
yRange.range([graph_height - margins.bottom, margins.top]);
var legend_x_start = 25;
const space_between_text_icon = 30;
const text_height = 6;
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
// Lines.
lineFuncs[scenario_name].defined(d => !isNaN(d.concentration))
.x(d => xTimeRange(d.time))
.y(d => yRange(d.concentration));
draw_lines[scenario_name].attr("d", lineFuncs[scenario_name](data));
// Legend on right side.
var size = 20 * (scenario_index + 1);
if (document.getElementById(concentration_plot_svg_id).clientWidth >= 900) {
label_icons[scenario_name].attr('x', graph_width + 20)
.attr('y', margins.top + size);
label_text[scenario_name].attr('x', graph_width + 3 * 20)
label_icons[scenario_name].attr('x', graph_width + legend_x_start)
.attr('y', margins.top + size);
label_text[scenario_name].attr('x', graph_width + legend_x_start + space_between_text_icon)
.attr('y', margins.top + size + text_height);
}
// Legend on the bottom.
else {
label_icons[scenario_name].attr('x', margins.left * 0.3)
.attr('y', graph_height + size);
label_text[scenario_name].attr('x', margins.left * 1.4)
legend_x_start = 10;
label_icons[scenario_name].attr('x', legend_x_start)
.attr('y', graph_height + size);
label_text[scenario_name].attr('x', legend_x_start + space_between_text_icon)
.attr('y', graph_height + size + text_height);
}
}
// Axis.
var xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d));
var yAxis = d3.axisLeft(yRange);
yAxis.scale(yRange);
xAxisEl.attr('transform', 'translate(0,' + (graph_height - margins.bottom) + ')')
.call(xAxis);
@ -585,8 +802,20 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
}
}
// Draw for the first time to initialize.
redraw();
if (button_full_exposure) {
button_full_exposure.addEventListener("click", () => {
update_alternative_concentration_plot('concentrations');
button_full_exposure.disabled = true;
button_hide_high_concentration.disabled = false;
});
}
if (button_hide_high_concentration) {
button_hide_high_concentration.addEventListener("click", () => {
update_alternative_concentration_plot('concentrations_zoomed');
button_full_exposure.disabled = false;
button_hide_high_concentration.disabled = true;
});
}
function mousemove() {
for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
@ -611,8 +840,16 @@ function draw_alternative_scenarios_plot(concentration_plot_svg_id, alternative_
}
}
// Draw for the first time to initialize.
redraw();
update_alternative_concentration_plot('concentrations');
// Redraw based on the new size whenever the browser window is resized.
window.addEventListener("resize", redraw);
window.addEventListener("resize", e => {
redraw();
if (button_full_exposure && button_full_exposure.disabled) update_alternative_concentration_plot('concentrations');
else update_alternative_concentration_plot('concentrations')
});
}
function copy_clipboard(shareable_link) {

View file

@ -503,6 +503,7 @@ baseline_model = models.ExposureModel(
),
evaporation_factor=0.3,
),
short_range=(),
exposed=models.Population(
number=10,
presence=models.SpecificInterval(((8., 12.), (13., 17.))),

View file

@ -299,7 +299,7 @@ footer img {
#download-pdf, #pdf_qrcode_aref {
display: none;
}
#cern_level{
#scale_warning{
height: 15em;
margin-top: 1em;
}

View file

@ -17,8 +17,9 @@ CARA stands for COVID Airborne Risk Assessment and was developed in the spring o
<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 airborne transmission therein. The results DO NOT include (for now) 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>
The mathematical and physical model simulate the airborne spread of SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture and a two-stage exhaled jet model, and estimates the risk of COVID-19 airborne transmission therein. The results DO NOT include 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 good hand hygiene and other barrier measures.<br>
<p>The methodology, mathematical equations and parameters of the model are published here in the CARA paper: <a href="https://doi.org/10.1098/rsfs.2021.0076"> Modelling airborne transmission of SARS-CoV-2 using CARA: risk assessment for enclosed spaces</a>.</p>
<p><i>Note that the short-range component of the model has not yet been published.</i></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.

View file

@ -317,7 +317,8 @@
<div class="col-sm-6">
<select id="activity_type" name="activity_type" class="form-control">
<option value="office">Office</option>
<option value="meeting">Meeting</option>
<option value="smallmeeting">Small meeting (<10 occ.)</option>
<option value="largemeeting">Large meeting (>=10 occ.)</option>
<option value="callcentre">Call Centre</option>
<option value="controlroom-day">Control Room - Day shift</option>
<option value="controlroom-night">Control Room - Night shift</option>
@ -365,6 +366,47 @@
<hr width="80%">
<div class="split">
<div style="min-width: 22em">Short-range interactions (without masks):</div>
<div>
<input class="ml-2" type="radio" id="short_range_no" name="short_range_option" value="short_range_no" checked="checked">
<label for="short_range_no">No</label>
<input class="ml-2" type="radio" id="short_range_yes" name="short_range_option" value="short_range_yes" data-enables="#DIVsr_interactions">
<label for="short_range_yes">Yes</label>
</div>
</div>
<p id="short_range_warning" class="red_text" style="margin-right: 2rem">The use of masks mitigates exposure at short-range. The analytical model with short-range interactions does not take mask wearing into account.</p>
<div id="DIVsr_interactions" class="none">
<div class="d-flex">
<button type="button" id="set_interactions_button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#short_range_dialog" data-keyboard="false" data-backdrop="static">Set interactions</button>
<p class="align-self-center pl-4"><b id="sr_interactions">0</b> short-range interactions.</p>
</div>
</div>
<div class="modal fade" id="short_range_dialog" tabindex="-1" role="dialog" aria-labelledby="short_range_dialogTitle" aria-hidden="true">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="short_range_dialogTitle">Short-range interactions</h5>
</div>
<div class="modal-body">
<div class="col-md-12 p-0 form-group" id="dialog_sr"></div>
<div class="text-center"><button type="button" class="add_node_btn_frm_field btn btn-primary btn-sm">Add row</button></div>
<input type="text" class="form-control d-none" name="short_range_interactions">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary dismiss_btn_frm_field" data-dismiss="modal">Clear all</button>
<button type="button" class="btn btn-primary close_btn_frm_field">Save all</button>
</div>
</div>
</div>
</div>
<hr width="80%">
<div class="form-group row">
<div class="col-sm-4"><label class="col-form-label">Which month is the event?</label></div>
<div class="col-sm-6 align-self-center">
@ -514,7 +556,7 @@
<div class="container container--padding">
<b>Quick Guide:</b><br>
This tool simulates the long range airborne spread SARS-CoV-2 virus in a finite volume and estimates the risk of COVID-19 infection. It is based on current scientific data and can be used to compare the effectiveness of different mitigation measures.<br>
This tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume and estimates the risk of COVID-19 infection. It is based on current scientific data and can be used to compare the effectiveness of different mitigation measures.<br>
<b>Virus data:</b> <br>
SARS-CoV-2 covers the original "wild type" strain of the virus and three variants of concern (VOC):<br>
<ul>
@ -542,7 +584,8 @@
The type of activity applies to both the infected and exposed persons:
<ul>
<li>Office = all seated, talking 33% of the time,</li>
<li>Meeting = all seated, talking time shared between all persons,</li>
<li>Small meeting (< 10 occ.) = all seated, talking time shared between all persons,</li>
<li>Large meeting (>= 10 occ.) = speaker is standing and speaking 33% of the time, other occupants are seated,</li>
<li>Call Centre = all seated, continuous talking,</li>
<li>Control Room (day shift) = all seated, talking 50% of the time,</li>
<li>Control Room (night shift) = all seated, talking 10% of the time,</li>
@ -572,40 +615,7 @@
</div>
<div class="collapse container container--padding" id="collapseDisclaimer">
<div class="card card-body">
<p>
CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.
</p>
<p>
CARA models the concentration profile of virions in enclosed spaces with clear and intuitive graphs.
The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation.
The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.
</p>
<p>
The risk assessment tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 airborne transmission therein.
The results DO NOT include short-range airborne exposure (where the physical distance is a significant factor) nor the other known modes of SARS-CoV-2 transmission.
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.
</p>
<p>
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021.
It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
</p>
<p>
Note that this model applies a deterministic approach, i.e., it is assumed at least one person is infected and shedding viruses into the simulated volume.
Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event.
The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and
the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings.
</p>
<p>
This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities.
The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk.
While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist.
Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions.
</p>
<p>
CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered
as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled.
</p> </div>
<div class="card card-body">{{ text_blocks['Disclaimer'] }}</div>
</div>
</form>

View file

@ -24,7 +24,7 @@
<h2 class="header_text mb-0">REPORT</h1>
<p class="mb-0" id="report_version"> Created {{ creation_date }} using CARA calculator version v{{ form.calculator_version }}</p>
</div>
<button type="button" class="btn btn-outline-dark align-self-center" id="download-pdf" style="margin-right: -100pt" onclick="print()">Print Report</button>
<button type="button" class="btn btn-outline-dark align-self-center" id="download-pdf" style="margin-right: -100pt" onclick="print();">Print Report</button>
<a href="{{ permalink.link }}" style="float: left;" id="pdf_qrcode_aref" class="align-self-center invisible mr-0"><div id="pdf_qrcode"></div></a>
</div>
@ -78,7 +78,7 @@
<div class="col-md-8 pr-0 pl-0 d-flex">
{% block report_summary %}
<div class="align-self-center alert alert-dark mb-0" role="alert">
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
</div>
{% endblock report_summary %}
</div>
@ -88,72 +88,90 @@
{% block report_summary_footnote %}
{% endblock report_summary_footnote %}
</div>
<p id="section1">* The results are based on the parameters and assumptions published in the CERN Open Report <a href="https://cds.cern.ch/record/2756083"> CERN-OPEN-2021-004</a>.</p>
<p id="section1">* The results are based on the parameters and assumptions published in the CARA publication: <a href="https://doi.org/10.1098/rsfs.2021.0076"> doi.org/10.1098/rsfs.2021.0076</a>.</p><br>
{% if form.short_range_option == "short_range_yes" %}
{% if 'Speaking' in form.short_range_interactions|string or 'Shouting' in form.short_range_interactions|string %}
<button class="btn btn-sm btn-primary" id="button_full_exposure" disabled>Show full exposure</button>
<button class="btn btn-sm btn-primary ml-0" id="button_hide_high_concentration">Hide high concentration</button>
{% endif %}
<input type="checkbox" id="long_range_cumulative_checkbox"><label class="form-check-label ml-1" for="long_range_cumulative_checkbox" id="lr_cumulative_checkbox_label">Show doses from long-range exposure alone</label>
{% endif %}
<div id="concentration_plot" style="height: 400px"></div>
<script type="application/javascript">
var times = {{ times | JSONify }}
var concentrations = {{ concentrations | JSONify }}
var cumulative_doses = {{ cumulative_doses | JSONify }}
var exposed_presence_intervals = {{ exposed_presence_intervals | JSONify }}
draw_concentration_plot("concentration_plot", times, concentrations, cumulative_doses, exposed_presence_intervals);
let times = {{ times | JSONify }}
let concentrations_zoomed = {{ concentrations_zoomed | JSONify }}
let concentrations = {{ concentrations | JSONify }}
let cumulative_doses = {{ cumulative_doses | JSONify }}
let long_range_cumulative_doses = {{ long_range_cumulative_doses | JSONify }}
let exposed_presence_intervals = {{ exposed_presence_intervals | JSONify }}
let short_range_intervals = {{ short_range_intervals | JSONify }}
let short_range_expirations = {{ short_range_expirations | JSONify }}
draw_plot("concentration_plot")
</script>
</p>
</div>
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header"><strong>Alternative scenarios</strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseAlternativeScenarios" role="button" aria-expanded="false" aria-controls="collapseAlternativeScenarios">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<div class="collapse" id="collapseAlternativeScenarios">
<div class="card-body">
<div>
<div id="alternative_scenario_plot" style="height: 400px"></div>
<script type="application/javascript">
var alternative_scenarios = {{ alternative_scenarios.stats | JSONify }}
var times = {{ times | JSONify }}
draw_alternative_scenarios_plot("concentration_plot", "alternative_scenario_plot", times, alternative_scenarios);
</script>
<br>
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<thead class="thead-light">
<tr>
<th>Scenario</th>
<th>P(I)</th>
<th>Expected new cases</th>
</tr>
</thead>
<tbody>
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
<tr>
<td> {{ scenario_name }}</td>
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock report_scenarios_summary_table %}
{% if form.short_range_option == "short_range_no" %}
<div class="card bg-light mb-3">
<div class="card-header"><strong>Alternative scenarios</strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseAlternativeScenarios" role="button" aria-expanded="false" aria-controls="collapseAlternativeScenarios">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<div class="collapse" id="collapseAlternativeScenarios">
<div class="card-body">
<div>
{% if form.short_range_option == "short_range_yes" %}
{% if 'Speaking' in form.short_range_interactions|string or 'Shouting' in form.short_range_interactions|string %}
<button class="btn btn-sm btn-primary" id="button_alternative_full_exposure" disabled>Show full exposure</button>
<button class="btn btn-sm btn-primary ml-0" id="button_alternative_hide_high_concentration">Hide high concentration</button>
{% endif %}
{% endif %}
<div id="alternative_scenario_plot" style="height: 400px"></div>
<script type="application/javascript">
let alternative_scenarios = {{ alternative_scenarios.stats | JSONify }}
draw_alternative_scenarios_plot("concentration_plot", "alternative_scenario_plot");
</script>
<br>
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<thead class="thead-light">
<tr>
<th>Scenario</th>
<th>P(I)</th>
<th>Expected new cases</th>
</tr>
</thead>
<tbody>
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
<tr>
<td> {{ scenario_name }}</td>
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock report_scenarios_summary_table %}
</div>
<br/>
<p class="data_text"> <strong> Notes for alternative scenarios: </strong><br>
<ol>
<li>This graph shows the concentration of infectious quanta in the air. The filtration of Type I and FFP2 masks, if worn, applies not only to the emission rate but also to the individual exposure (i.e. inhalation).
For this reason, scenarios with different types of mask will show the same concentration on the graph but have different absorbed doses and infection probabilities.</li>
<li>If you have selected more sophisticated options, such as HEPA filtration or FFP2 masks, this will be indicated in the plot as the "base scenario", representing the inputs inserted in the form.<br>
The other alternative scenarios shown for comparison will not include either HEPA filtration or FFP2 masks.</li>
</ol>
<br>
</p>
</div>
<br/>
<p class="data_text"> <strong> Notes for alternative scenarios: </strong><br>
<ol>
<li>This graph shows the concentration of infectious quanta in the air. The filtration of Type I and FFP2 masks, if worn, applies not only to the emission rate but also to the individual exposure (i.e. inhalation).
For this reason, scenarios with different types of mask will show the same concentration on the graph but have different absorbed doses and infection probabilities.</li>
<li>If you have selected more sophisticated options, such as HEPA filtration or FFP2 masks, this will be indicated in the plot as the "base scenario", representing the inputs inserted in the form.<br>
The other alternative scenarios shown for comparison will not include either HEPA filtration or FFP2 masks.</li>
</ol>
<br>
</p>
</div>
</div>
</div>
{% endif %}
{% endblock report_results %}
{% block report_footer %}
@ -289,8 +307,10 @@
Activity type:
{% if form.activity_type == "office" %}
Office typical scenario with all persons seated, speaking occasionally (speaking assumed for 1/3rd of the time).
{% elif form.activity_type == "meeting" %}
Meeting typical scenario with all persons seated, one person speaking at a time.
{% elif form.activity_type == "smallmeeting" %}
Small meeting typical scenario with all persons seated, one person speaking at a time.
{% elif form.activity_type == "largemeeting" %}
Large meeting infected occupant(s) is standing and speaking 1/3rd of the time, while the other occupants are seated.
{% elif form.activity_type == "callcentre" %}
Call Centre = typical office-like scenario with all persons seated, all speaking continuously.
{% elif form.activity_type == "controlroom-day" %}
@ -309,6 +329,18 @@
Gym = For comparison only, all persons doing heavy physical exercise, breathing and not speaking.
{% endif %}
</p></li>
{% if form.short_range_option == "short_range_yes" %}
<li><p class="data_text">
Short-range interactions: {{ form.short_range_interactions|length }}
</p></li>
<ul>
{% for interaction in form.short_range_interactions %}
<li>Expiratory activity {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.expiration }} </li>
<li>Start time {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.start_time }} </li>
<li>Duration {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.duration }} {{ "minutes" if interaction.duration|float > 1 else "minute" }}</li>
{% endfor %}
</ul>
{% endif %}
<li><p class="data_text">Exposed occupant(s) activity time:</p></li>
<ul>
<li><p class="data_subtext">Start time: {{ form.exposed_start | minutes_to_time }}</p></li>
@ -408,42 +440,8 @@
<br><br><br>
<div id="disclaimer" style="border: #dee2e6 1px solid; margin: 1%; padding: 20px" class="rounded">
{% block disclaimer %}
<p class="image"> <img align="middle" src="{{ calculator_prefix }}/static/images/disclaimer.jpg" width="40" height="40"><b>Disclaimer:</b><br><br></p>
<p>
CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.
</p>
<p>
CARA models the concentration profile of potential infectious viruses in enclosed spaces with clear and intuitive graphs.
The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation.
The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.
</p>
<p>
The risk assessment tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 airborne transmission therein.
The results DO NOT include short-range airborne exposure (where the physical distance is a significant factor) nor the other known modes of SARS-CoV-2 transmission.
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.
</p>
<p>
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021.
It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
</p>
<p>
Note that this model applies a deterministic approach, i.e., it is assumed at least one person is infected and shedding viruses into the simulated volume.
Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event.
The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and
the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings.
</p>
<p>
This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities.
The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk.
While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist.
Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions.
</p>
<p>
CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered
as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled.
</p>
<p class="image"> <img align="middle" src="{{ calculator_prefix }}/static/images/disclaimer.jpg" width="40" height="40"><b>Disclaimer:</b><br><br></p>
{{ text_blocks['Disclaimer'] }}
{% endblock disclaimer %}
</div>
{% endblock disclaimer_container %}

View file

@ -17,41 +17,7 @@
</div>
<div class="collapse container container--padding" id="collapseDisclaimer">
<div class="card card-body">
<p>
CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.
</p>
<p>
CARA models the concentration profile of potential virions in enclosed spaces with clear and intuitive graphs.
The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation.
The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.
</p>
<p>
The risk assessment tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 airborne transmission therein.
The results DO NOT include short-range airborne exposure (where the physical distance is a significant factor) nor the other known modes of SARS-CoV-2 transmission.
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.
</p>
<p>
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021.
It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
</p>
<p>
Note that this model applies a deterministic approach, i.e., it is assumed at least one person is infected and shedding viruses into the simulated volume.
Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event.
The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and
the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings.
</p>
<p>
This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities.
The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk.
While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist.
Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions.
</p>
<p>
CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered
as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled.
</p>
</div>
<div class="card card-body">{{ text_blocks['Disclaimer'] }}</div>
</div>
@ -154,7 +120,8 @@ Within the number of people occupying the space, you should specify how many are
<p>There are a few predefined activities in the tool at present.</p>
<ul>
<li><strong>Office </strong> = All persons seated, talking occasionally (1/3rd of the time, with normal breathing the other 2/3rds of the time). Everyone (exposed and infected occupants) is treated the same in this model.</li>
<li><strong>Meeting</strong> = All persons seated, having a conversation (approximately each occupant is 1/N % of the time talking, where N is the number of occupants). Everyone (exposed and infected occupants) is treated the same in this model.</li>
<li><strong>Small meeting</strong> = Less than 10 participants. All persons seated, having a conversation (approximately each occupant is 1/N % of the time talking, where N is the number of occupants). Everyone (exposed and infected occupants) is treated the same in this model.</li>
<li><strong>Large Meeting</strong> = 10 or more participants. Similar to a seminar with 'speakers and audience'. Infected occupant(s) is standing and speaking 1/3rd of the time, while the other occupants are seated.</li>
<li><strong>Library</strong> = All persons seated, breathing only (not talking), all the time.</li>
<li><strong>Call Centre</strong> = All persons seated, all talking simultaneously, all the time. This is a conservative profile, i.e. will show an increased <code>P(i)</code> compared to office/meeting activity. Everyone (exposed and infected occupants) is treated the same in this model.</li>
<li><strong>Control Room (day shift)</strong> = All persons seated, all talking 50% of the time. This is a conservative profile, i.e. will show an increased <code>P(i)</code> compared to office/meeting activity. Everyone (exposed and infected occupants) is treated the same in this model.</li>

View file

@ -1,8 +1,9 @@
{% extends "base/calculator.report.html.j2" %}
{% if ((prob_inf > 10) or (expected_new_cases >= 1)) %} {% set cern_level = 'red' %}
{% elif (2 <= prob_inf <= 10) %} {% set cern_level = 'orange' %}
{% else %} {% set cern_level = 'green' %}
{% set cern_level = 'green-1' %} <!-- green-1, yellow-2, orange-3, red-4 -->
{% if ((prob_inf > 10) or (expected_new_cases >= 1)) %} {% set scale_warning = 'red' %}
{% elif (2 <= prob_inf <= 10) %} {% set scale_warning = 'orange' %}
{% else %} {% set scale_warning = 'green' %}
{% endif %}
{% block report_preamble_navtab %}
@ -12,9 +13,9 @@
{% endblock report_preamble_navtab %}
{% block warning_animation %}
{% if cern_level == 'red' %} {% set warning_color= 'bg-danger' %}
{% elif cern_level == 'orange' %} {% set warning_color = 'bg-warning' %}
{% elif cern_level == 'green' %} {% set warning_color = 'bg-success' %}
{% if scale_warning == 'red' %} {% set warning_color= 'bg-danger' %}
{% elif scale_warning == 'orange' %} {% set warning_color = 'bg-warning' %}
{% elif scale_warning == 'green' %} {% set warning_color = 'bg-success' %}
{% endif %}
<div class="intro-banner-vdo-play-btn {{warning_color}} m-auto d-flex align-items-center justify-content-center">
@ -27,42 +28,42 @@
{% endblock warning_animation %}
{% block report_summary %}
{% set report_message = "Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally, the <b>probability of one exposed occupant getting infected is " + prob_inf | non_zero_percentage + "</b> and the <b>expected number of new cases is " + expected_new_cases | float_format + "</b>*." %}
<div class="flex-row align-self-center">
{% if cern_level == 'red' %}
{% if scale_warning == 'red' %}
<div class="alert alert-danger mb-0" role="alert">
<strong>Not Acceptable:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
{{ report_message }}
</div>
{% elif cern_level == 'orange' %}
{% elif scale_warning == 'orange' %}
<div class="alert alert-warning mb-0" role="alert">
<strong>Attention:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
{{ report_message }}
</div>
{% elif cern_level == 'green' %}
{% elif scale_warning == 'green' %}
<div class="alert alert-success mb-0" role="alert">
<strong>Acceptable:</strong>
Taking into account the uncertainties tied to the model variables, in this scenario, the <b>probability of one exposed occupant getting infected is {{ prob_inf | non_zero_percentage }}</b> and the <b>expected number of new cases is {{ expected_new_cases | float_format }}</b>*.
{{ report_message }}
</div>
{% endif %}
{% if (prob_inf > 2) %}
<br>
{% if scale_warning.level == "green-1" %}
{% if cern_level == "green-1" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
Note: the current CERN COVID Scale is <b>Green 1</b>. The risk of circulation of asymptomatic or pre-symptomatic infected individuals within the CERN site is considered negligible. There may be more than <b>{{scale_warning.onsite_access}} daily on-site accesses</b>. Align your risk assessment with the guidance and instructions provided by the HSE Unit.
Note: the current CERN COVID Scale is <b>Green 1</b>. The risk of circulation of asymptomatic or pre-symptomatic infected individuals within the CERN site is considered negligible. There may be more than <b>8'000 daily on-site accesses</b>. Align your risk assessment with the guidance and instructions provided by the HSE Unit.
</div>
{% elif scale_warning.level == "yellow-2" %}
{% elif cern_level == "yellow-2" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
Note: the current CERN COVID Scale is <b>Yellow - 2</b>. There is a {{scale_warning.risk}} risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>{{scale_warning.onsite_access}} daily on-site accesses</b> during this stage. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures can be applied (ALARA).
Note: the current CERN COVID Scale is <b>Yellow - 2</b>. There is a reduced risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>6'500 daily on-site accesses</b> during this stage. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures can be applied (ALARA).
</div>
{% elif scale_warning.level == "orange-3" %}
{% elif cern_level == "orange-3" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
Warning: the current CERN COVID Scale is <b>Orange - 3</b>. There is a {{scale_warning.risk}} risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>{{scale_warning.onsite_access}} daily on-site accesses</b> during this stage. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures can be applied (ALARA).
Warning: the current CERN COVID Scale is <b>Orange - 3</b>. There is a medium risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>5'000 daily on-site accesses</b> during this stage. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures can be applied (ALARA).
</div>
{% elif scale_warning.level == "red-4" %}
{% elif cern_level == "red-4" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
Warning: the current CERN COVID Scale is <b>Red - 4</b>. There is a {{scale_warning.risk}} risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>{{scale_warning.onsite_access}} daily on-site accesses</b> during this stage. Please reduce the value below the threshold of {{scale_warning.threshold}}. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures are required.
Warning: the current CERN COVID Scale is <b>Red - 4</b>. There is a strong risk that asymptomatic or pre-symptomatic infected individuals circulate within the CERN site. There may be around <b>4'000 daily on-site accesses</b> during this stage. Please reduce the value below the threshold of 2%. See with your supervisor, DSO/LEXGLIMOS and space manager if this scenario is acceptable and if any additional measures are required.
</div>
{% else %}
<p><b>Note:</b> The CERN COVID Level is not specified.</p>
@ -72,13 +73,13 @@
{% endblock report_summary %}
{% block report_summary_footnote %}
{% if cern_level == 'red' %}
{% if scale_warning == 'red' %}
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 cern_level == 'orange' %}
{% elif scale_warning == 'orange' %}
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 cern_level == 'green' %}
{% elif scale_warning == 'green' %}
This level of risk is within acceptable parameters, no further actions are required.
{% endif %}
{% endblock report_summary_footnote %}
@ -133,7 +134,7 @@
<div class="alert alert-warning" role="alert">Events with a <strong>P(i) between 2% and 10%</strong> shall be subject to ALARA principles (see footnote) to minimise the risk before proceeding.</div>
<div class="alert alert-danger mb-0" role="alert">Events with a <strong>P(i) exceeding 10% or a number of expected new cases that exceeds 1</strong> may not take place until additional measures are in place and a risk reduction has been performed.</div>
</div>
<div class="col-xl-3 align-self-center text-center"><img id="cern_level" class="rounded" src="{{ calculator_prefix }}/static/images/warning_scale/{{scale_warning.level}}.png"></div>
<div class="col-xl-3 align-self-center text-center"><img id="scale_warning" class="rounded" src="{{ calculator_prefix }}/static/images/warning_scale/{{ cern_level }}.png"></div>
</div>
</div>
<br>

View file

@ -22,6 +22,41 @@
We wish to thank CERNs HSE Unit, Beams Department, Experimental Physics Department, Information Technology Department, Industry, Procurement and Knowledge Transfer Department and International Relations Sector for their support to the study. Thanks to Doris Forkel-Wirth, Benoit Delille, Walid Fadel, Olga Beltramello, Letizia Di Giulio, Evelyne Dho, Wayne Salter, Benoit Salvant and colleagues from the COVID working group for providing expert advice and extensively testing the model. Finally, we wish to thank Fabienne Landua and the design service for preparing the illustrations and Alessandro Raimondo and Manuela Cirilli from the Knowledge Transfer Group for their continuous support. Our compliments towards the work and research performed by world leading scientists in this domain: Dr. Julian Tang, Prof. Manuel Gameiro, Dr. Linsey Marr, Prof. Lidia Morawska, Prof. Yuguo Li, and others their scientific contribution was indispensable for this project.
## Disclaimer
<p>
CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.
</p>
<p>
CARA models the concentration profile of virions in enclosed spaces with clear and intuitive graphs.
The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation.
The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.
</p>
<p>
The risk assessment tool simulates the long-range airborne spread SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture, and estimates the risk of COVID-19 airborne transmission therein.
The results DO NOT include short-range airborne exposure (where the physical distance is a significant factor) nor the other known modes of SARS-CoV-2 transmission.
Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as adequate physical distancing, good hand hygiene and other barrier measures.
</p>
<p>
The model used is based on scientific publications relating to airborne transmission of infectious diseases, dose-response exposures and aerosol science, as of February 2021.
It can be used to compare the effectiveness of different airborne-related risk mitigation measures.
</p>
<p>
Note that this model applies a deterministic approach, i.e., it is assumed at least one person is infected and shedding viruses into the simulated volume.
Nonetheless, it is also important to understand that the absolute risk of infection is uncertain, as it will depend on the probability that someone infected attends the event.
The model is most useful for comparing the impact and effectiveness of different mitigation measures such as ventilation, filtration, exposure time, physical activity and
the size of the room, only considering long-range airborne transmission of COVID-19 in indoor settings.
</p>
<p>
This tool is designed to be informative, allowing the user to adapt different settings and model the relative impact on the estimated infection probabilities.
The objective is to facilitate targeted decision-making and investment through comparisons, rather than a singular determination of absolute risk.
While the SARS-CoV-2 virus is in circulation among the population, the notion of 'zero risk' or 'completely safe scenario' does not exist.
Each event modelled is unique, and the results generated therein are only as accurate as the inputs and assumptions.
</p>
<p>
CARA has not undergone review, approval or certification by competent authorities, and as a result, it cannot be considered
as a fully endorsed and reliable tool, namely in the assessment of potential viral emissions from infected hosts to be modelled.
</p>
## References
Reference list can be found in the CARA paper: <a href="https://cds.cern.ch/record/2756083"> CERN-OPEN-2021-004</a>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,6 @@ import typing
import numpy as np
from scipy.interpolate import interp1d
import scipy.integrate
if not typing.TYPE_CHECKING:
from memoization import cached
@ -502,7 +501,7 @@ Virus.types = {
),
'SARS_CoV_2_OMICRON': SARSCoV2(
viral_load_in_sputum=1e9,
infectious_dose=20.,
infectious_dose=50.,
viable_to_RNA_ratio=0.5,
transmissibility_factor=0.2
),
@ -634,6 +633,12 @@ class _ExpirationBase:
"""
raise NotImplementedError("Subclass must implement")
def jet_origin_concentration(self):
"""
concentration of viruses at the jet origin (mL/m3).
"""
raise NotImplementedError("Subclass must implement")
@dataclass(frozen=True)
class Expiration(_ExpirationBase):
@ -667,6 +672,14 @@ class Expiration(_ExpirationBase):
return self.cn * (volume(self.diameter) *
(1 - mask.exhale_efficiency(self.diameter))) * 1e-12
@cached()
def jet_origin_concentration(self):
def volume(d):
return (np.pi * d**3) / 6.
# final result converted from microns^3/cm3 to mL/m3
return self.cn * volume(self.diameter) * 1e-6
@dataclass(frozen=True)
class MultipleExpiration(_ExpirationBase):
@ -886,7 +899,7 @@ class InfectedPopulation(_PopulationWithVirus):
class ConcentrationModel:
room: Room
ventilation: _VentilationBase
infected: _PopulationWithVirus
infected: InfectedPopulation
#: evaporation factor: the particles' diameter is multiplied by this
# factor as soon as they are in the air (but AFTER going out of the,
@ -987,7 +1000,7 @@ class ConcentrationModel:
def _normed_concentration(self, time: float) -> _VectorisedFloat:
"""
Virus exposure concentration, as a function of time, and
Virus long-range exposure concentration, as a function of time, and
normalized by the emission rate.
The formulas used here assume that all parameters (ventilation,
emission rate) are constant between two state changes - only
@ -1013,18 +1026,18 @@ class ConcentrationModel:
def concentration(self, time: float) -> _VectorisedFloat:
"""
Virus exposure concentration, as a function of time.
Virus long-range exposure concentration, as a function of time.
Note that time is not vectorised. You can only pass a single float
to this method.
"""
return (self._normed_concentration(time) *
return (self._normed_concentration_cached(time) *
self.infected.emission_rate_when_present())
@method_cache
def normed_integrated_concentration(self, start: float, stop: float) -> _VectorisedFloat:
"""
Get the integrated concentration of viruses in the air between the times start and stop,
Get the integrated long-range concentration of viruses in the air between the times start and stop,
normalized by the emission rate.
"""
if stop <= self._first_presence_time():
@ -1059,6 +1072,126 @@ class ConcentrationModel:
self.infected.emission_rate_when_present())
@dataclass(frozen=True)
class ShortRangeModel:
#: Expiration type
expiration: _ExpirationBase
#: Activity type
activity: Activity
#: Short-range expiration and respective presence
presence: SpecificInterval
#: Interpersonal distances
distance: _VectorisedFloat
def dilution_factor(self) -> _VectorisedFloat:
'''
The dilution factor for the respective expiratory activity type.
'''
# Average mouth diameter
D = 0.02
# Convert Breathing rate from m3/h to m3/s
BR = np.array(self.activity.exhalation_rate/3600.)
# Area of the mouth assuming a perfect circle
Am = np.pi*(D**2)/4
# Initial velocity from the division of the Breathing rate with the area
u0 = np.array(BR/Am)
tstar = 2.0
Cr1 = 0.18
Cr2 = 0.2
Cx1 = 2.4
# The expired flow rate during the expiration period, m^3/s
Q0 = u0 * np.pi/4*D**2
# Parameters in the jet-like stage
x01 = D/2/Cr1
# Time of virtual origin
t01 = (x01/Cx1)**2 * (Q0*u0)**(-0.5)
# The transition point, m
xstar = np.array(Cx1*(Q0*u0)**0.25*(tstar + t01)**0.5 - x01)
# Dilution factor at the transition point xstar
Sxstar = np.array(2*Cr1*(xstar+x01)/D)
distances = np.array(self.distance)
factors = np.empty(distances.shape, dtype=np.float64)
factors[distances < xstar] = 2*Cr1*(distances[distances < xstar]
+ x01)/D
factors[distances >= xstar] = Sxstar[distances >= xstar]*(1 +
Cr2*(distances[distances >= xstar] -
xstar[distances >= xstar])/Cr1/(xstar[distances >= xstar]
+ x01))**3
return factors
def _normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
"""
Virus short-range exposure concentration, as a function of time.
If the given time falls within a short-range interval it returns the
short-range concentration normalized by the virus viral load. Otherwise
it returns 0.
"""
start, stop = self.presence.boundaries()[0]
# Verifies if the given time falls within a short-range interaction
if start <= time <= stop:
dilution = self.dilution_factor()
jet_origin_concentration = self.expiration.jet_origin_concentration()
# Long-range concentration normalized by the virus viral load
long_range_normed_concentration = (concentration_model.concentration(time) /
concentration_model.virus.viral_load_in_sputum)
# The long-range concentration values are then approximated using interpolation:
# The set of points where we want the interpolated values are the short-range particle diameters (given the current expiration);
# The set of points with a known value are the long-range particle diameters (given the initial expiration);
# The set of known values are the long-range concentration values normalized by the viral load.
long_range_normed_concentration_interpolated=np.interp(self.expiration.particle.diameter,
concentration_model.infected.particle.diameter, long_range_normed_concentration)
# Short-range concentration formula. The long-range concentration is added in the concentration method (ExposureModel).
return ((1/dilution)*(jet_origin_concentration - long_range_normed_concentration_interpolated))
return 0.
def short_range_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
"""
Virus short-range exposure concentration, as a function of time.
"""
return (self._normed_concentration(concentration_model, time) *
concentration_model.virus.viral_load_in_sputum)
@method_cache
def _normed_short_range_concentration_cached(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat:
# A cached version of the _normed_concentration method. Use this
# method if you expect that there may be multiple short-range concentration
# calculations for the same time (e.g. at state change times).
return self._normed_concentration(concentration_model, time)
def normed_exposure_between_bounds(self, concentration_model: ConcentrationModel, time1: float, time2: float):
"""
Get the integrated short-range concentration of viruses in the air between the times start and stop,
normalized by the virus viral load.
"""
start_bound, stop_bound = self.presence.boundaries()[0]
jet_origin = self.expiration.jet_origin_concentration()
dilution = self.dilution_factor()
total_normed_concentration_diluted = (
concentration_model.integrated_concentration(start_bound,
stop_bound)/dilution/
concentration_model.virus.viral_load_in_sputum
)
total_normed_concentration_interpolated = np.interp(
self.expiration.particle.diameter,
concentration_model.infected.particle.diameter,
total_normed_concentration_diluted
)
return (jet_origin/dilution * (stop_bound - start_bound)
) - total_normed_concentration_interpolated
@dataclass(frozen=True)
class ExposureModel:
"""
@ -1071,13 +1204,16 @@ class ExposureModel:
#: The virus concentration model which this exposure model should consider.
concentration_model: ConcentrationModel
#: The list of short-range models which this exposure model should consider.
short_range: typing.Tuple[ShortRangeModel, ...]
#: The population of non-infected people to be used in the model.
exposed: Population
#: The number of times the exposure event is repeated (default 1).
repeats: int = 1
def fraction_deposited(self) -> _VectorisedFloat:
def long_range_fraction_deposited(self) -> _VectorisedFloat:
"""
The fraction of particles actually deposited in the respiratory
tract (over the total number of particles). It depends on the
@ -1086,7 +1222,7 @@ class ExposureModel:
return self.concentration_model.infected.particle.fraction_deposited(
self.concentration_model.evaporation_factor)
def _normed_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
def _long_range_normed_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
"""The number of virions per meter^3 between any two times, normalized
by the emission rate of the infected population"""
exposure = 0.
@ -1104,28 +1240,26 @@ class ExposureModel:
elif time1 <= start and stop < time2:
exposure += self.concentration_model.normed_integrated_concentration(start, stop)
return exposure
def _normed_exposure(self) -> _VectorisedFloat:
"""
The number of virions per meter^3, normalized by the emission rate
of the infected population.
"""
normed_exposure = 0.0
for start, stop in self.exposed.presence.boundaries():
normed_exposure += self.concentration_model.normed_integrated_concentration(start, stop)
return normed_exposure * self.repeats
def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
def concentration(self, time: float) -> _VectorisedFloat:
"""
The number of virus per m^3 deposited on the respiratory tract
between any two times.
Virus exposure concentration, as a function of time.
It considers the long-range concentration with the
contribution of the short-range concentration.
"""
concentration = self.concentration_model.concentration(time)
for interaction in self.short_range:
concentration += interaction.short_range_concentration(self.concentration_model, time)
return concentration
def long_range_deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
deposited_exposure = 0.
emission_rate_per_aerosol = self.concentration_model.infected.emission_rate_per_aerosol_when_present()
aerosols = self.concentration_model.infected.aerosols()
fdep = self.fraction_deposited()
f_inf = self.concentration_model.infected.fraction_of_infectious_virus()
fdep = self.long_range_fraction_deposited()
diameter = self.concentration_model.infected.particle.diameter
@ -1134,20 +1268,80 @@ class ExposureModel:
# to perform properly the Monte-Carlo integration over
# particle diameters (doing things in another order would
# lead to wrong results).
dep_exposure_integrated = np.array(self._normed_exposure_between_bounds(time1, time2) *
aerosols *
fdep).mean()
dep_exposure_integrated = np.array(self._long_range_normed_exposure_between_bounds(time1, time2) *
aerosols *
fdep).mean()
else:
# in the case of a single diameter or no diameter defined,
# one should not take any mean at this stage.
dep_exposure_integrated = self._normed_exposure_between_bounds(time1, time2)*aerosols*fdep
dep_exposure_integrated = self._long_range_normed_exposure_between_bounds(time1, time2)*aerosols*fdep
# then we multiply by the diameter-independent quantity emission_rate_per_aerosol,
# and parameters of the vD equation (i.e. f_inf, BR_k and n_in).
return (dep_exposure_integrated * emission_rate_per_aerosol *
f_inf * self.exposed.activity.inhalation_rate *
# and parameters of the vD equation (i.e. BR_k and n_in).
deposited_exposure += (dep_exposure_integrated * emission_rate_per_aerosol *
self.exposed.activity.inhalation_rate *
(1 - self.exposed.mask.inhale_efficiency()))
# In the end we multiply the final results by the fraction of infectious virus of the vD equation.
return deposited_exposure * f_inf
def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat:
"""
The number of virus per m^3 deposited on the respiratory tract
between any two times.
Considers a contribution between the short-range and long-range exposures:
It calculates the deposited exposure given a short-range interaction (if any).
Then, the deposited exposure given the long-range interactions is added to the
initial deposited exposure.
"""
deposited_exposure = 0.
for interaction in self.short_range:
start, stop = interaction.presence.boundaries()[0]
if stop < time1:
continue
elif start > time2:
break
elif start <= time1 and time2<= stop:
start_bound, stop_bound = time1, time2
elif start <= time1 and stop < time2:
start_bound, stop_bound = time1, stop
elif time1 < start and time2 <= stop:
start_bound, stop_bound = start, time2
elif time1 <= start and stop < time2:
start_bound, stop_bound = start, stop
short_range_exposure = interaction.normed_exposure_between_bounds(self.concentration_model, start_bound, stop_bound)
fdep = interaction.expiration.particle.fraction_deposited(evaporation_factor=1.0)
diameter = interaction.expiration.particle.diameter
# Aerosols not considered given the formula for the initial concentration at mouth/nose.
if diameter is not None and not np.isscalar(diameter):
# we compute first the mean of all diameter-dependent quantities
# to perform properly the Monte-Carlo integration over
# particle diameters (doing things in another order would
# lead to wrong results).
deposited_exposure += np.array(short_range_exposure *
fdep).mean()
else:
# in the case of a single diameter or no diameter defined,
# one should not take any mean at this stage.
deposited_exposure += short_range_exposure*fdep
# multiply by the (diameter-independent) inhalation rate
deposited_exposure *= interaction.activity.inhalation_rate
# then we multiply by diameter-independent quantities: viral load
# and fraction of infected virions
f_inf = self.concentration_model.infected.fraction_of_infectious_virus()
deposited_exposure *= (f_inf
* self.concentration_model.virus.viral_load_in_sputum
)
# long-range concentration
deposited_exposure += self.long_range_deposited_exposure_between_bounds(time1, time2)
return deposited_exposure
def deposited_exposure(self) -> _VectorisedFloat:
"""
The number of virus per m^3 deposited on the respiratory tract.
@ -1162,7 +1356,7 @@ class ExposureModel:
def infection_probability(self) -> _VectorisedFloat:
# viral dose (vD)
vD = self.deposited_exposure()
# oneoverln2 multiplied by ID_50 corresponds to ID_63.
infectious_dose = oneoverln2 * self.concentration_model.virus.infectious_dose

View file

@ -5,11 +5,13 @@ import numpy as np
from scipy import special as sp
import cara.monte_carlo as mc
from cara.monte_carlo.sampleable import Normal,LogNormal,LogCustomKernel,CustomKernel,Uniform
from cara.monte_carlo.sampleable import LogNormal,LogCustomKernel,CustomKernel,Uniform
sqrt2pi = np.sqrt(2.*np.pi)
sqrt2 = np.sqrt(2.)
@dataclass(frozen=True)
class BLOmodel:
"""
@ -65,7 +67,7 @@ class BLOmodel:
return result
# From https://doi.org/10.1101/2021.10.14.21264988 and refererences therein
# From https://doi.org/10.1101/2021.10.14.21264988 and references therein
activity_distributions = {
'Seated': mc.Activity(LogNormal(-0.6872121723362303, 0.10498338229297108),
LogNormal(-0.6872121723362303, 0.10498338229297108)),
@ -84,7 +86,7 @@ activity_distributions = {
}
# From https://doi.org/10.1101/2021.10.14.21264988 and refererences therein
# From https://doi.org/10.1101/2021.10.14.21264988 and references therein
symptomatic_vl_frequencies = LogCustomKernel(
np.array((2.46032, 2.67431, 2.85434, 3.06155, 3.25856, 3.47256, 3.66957, 3.85979, 4.09927, 4.27081,
4.47631, 4.66653, 4.87204, 5.10302, 5.27456, 5.46478, 5.6533, 5.88428, 6.07281, 6.30549,
@ -157,7 +159,10 @@ mask_distributions = {
}
def expiration_distribution(BLO_factors):
def expiration_distribution(
BLO_factors,
d_max=30.,
) -> mc.Expiration:
"""
Returns an Expiration with an aerosol diameter distribution, defined
by the BLO factors (a length-3 tuple).
@ -166,10 +171,15 @@ def expiration_distribution(BLO_factors):
an historical choice based on previous implementations of the model
(it limits the influence of the O-mode).
"""
dscan = np.linspace(0.1, 30. ,3000)
return mc.Expiration(CustomKernel(dscan,
BLOmodel(BLO_factors).distribution(dscan),kernel_bandwidth=0.1),
cn=BLOmodel(BLO_factors).integrate(0.1, 30.))
dscan = np.linspace(0.1, d_max, 3000)
return mc.Expiration(
CustomKernel(
dscan,
BLOmodel(BLO_factors).distribution(dscan),
kernel_bandwidth=0.1,
),
cn=BLOmodel(BLO_factors).integrate(0.1, d_max),
)
expiration_BLO_factors = {
@ -182,5 +192,15 @@ expiration_BLO_factors = {
expiration_distributions = {
exp_type: expiration_distribution(BLO_factors)
for exp_type,BLO_factors in expiration_BLO_factors.items()
for exp_type, BLO_factors in expiration_BLO_factors.items()
}
short_range_expiration_distributions = {
exp_type: expiration_distribution(BLO_factors, d_max=100)
for exp_type, BLO_factors in expiration_BLO_factors.items()
}
# Fit from Fig 8 a) "stand-stand" in https://www.mdpi.com/1660-4601/17/4/1445/htm
short_range_distances = LogNormal(-0.269359136417347, 0.4728300188814934)

View file

@ -37,7 +37,7 @@ class MCModelBase(typing.Generic[_ModelType]):
def build_model(self, size: int) -> _ModelType:
"""
Turn this MCModelBase subclass into a cara.models Model instance
Turn this MCModelBase subclass into a cara.model Model instance
from which you can then run the model.
"""
@ -72,6 +72,9 @@ def _build_mc_model(model: _ModelType) -> typing.Type[MCModelBase[_ModelType]]:
elif new_field.type == typing.Tuple[cara.models._ExpirationBase, ...]:
EB = getattr(sys.modules[__name__], "_ExpirationBase")
field_type = typing.Tuple[typing.Union[cara.models._ExpirationBase, EB], ...]
elif new_field.type == typing.Tuple[cara.models.SpecificInterval, ...]:
SI = getattr(sys.modules[__name__], "SpecificInterval")
field_type = typing.Tuple[typing.Union[cara.models.SpecificInterval, SI], ...]
else:
# Check that we don't need to do anything with this type.
for item in new_field.type.__args__:

View file

@ -1,8 +1,6 @@
cd Downloads
git clone https://gitlab.cern.ch/cara/cara.git
cd cara
git lfs install
git lfs pull
if [[ `uname -m` == 'arm64' ]]; then
pip3 install scipy --index-url=https://pypi.anaconda.org/scipy-wheels-nightly/simple
pip3 install Cython
@ -12,4 +10,5 @@ pip3 install -e .
echo "############################################"
echo "CARA is now running at http://localhost:8080"
echo "############################################"
python3 -m cara.apps.calculator
python3 -m cara.apps.calculator

View file

@ -1,9 +1,8 @@
git clone https://gitlab.cern.ch/cara/cara.git
cd cara
git lfs install
git lfs pull
pip install -e .
echo "############################################"
echo "CARA is now running at http://localhost:8080"
echo "############################################"
python -m cara.apps.calculator
python -m cara.apps.calculator

View file

@ -1,8 +1,6 @@
cd Downloads
git clone https://gitlab.cern.ch/cara/cara.git
cd cara
git lfs install
git lfs pull
if [[ `uname -m` == 'arm64' ]]; then
pip3 install scipy --index-url=https://pypi.anaconda.org/scipy-wheels-nightly/simple
pip3 install Cython
@ -12,4 +10,5 @@ pip3 install -e .
echo "############################################"
echo "CARA is now running at http://localhost:8080"
echo "############################################"
python3 -m cara.apps.calculator --theme=cara/apps/templates/cern
python3 -m cara.apps.calculator --theme=cara/apps/templates/cern

View file

@ -1,9 +1,8 @@
git clone https://gitlab.cern.ch/cara/cara.git
cd cara
git lfs install
git lfs pull
pip install -e .
echo "############################################"
echo "CARA is now running at http://localhost:8080"
echo "############################################"
python -m cara.apps.calculator --theme=cara/apps/templates/cern
python -m cara.apps.calculator --theme=cara/apps/templates/cern

View file

@ -6,7 +6,7 @@ import pytest
@pytest.fixture
def baseline_model():
def baseline_concentration_model():
model = models.ConcentrationModel(
room=models.Room(volume=75),
ventilation=models.AirChange(
@ -30,14 +30,20 @@ def baseline_model():
@pytest.fixture
def baseline_exposure_model(baseline_model):
def baseline_sr_model():
return ()
@pytest.fixture
def baseline_exposure_model(baseline_concentration_model, baseline_sr_model):
return models.ExposureModel(
baseline_model,
baseline_concentration_model,
baseline_sr_model,
exposed=models.Population(
number=1000,
presence=baseline_model.infected.presence,
activity=baseline_model.infected.activity,
mask=baseline_model.infected.mask,
presence=baseline_concentration_model.infected.presence,
activity=baseline_concentration_model.infected.activity,
mask=baseline_concentration_model.infected.mask,
host_immunity=0.,
),
)

View file

@ -90,8 +90,8 @@ def known_concentrations(func):
np.array([40.91708675, 91.46172332]), np.array([51.6749232285, 80.3196524031])],
])
def test_exposure_model_ndarray(population, cm,
expected_exposure, expected_probability):
model = ExposureModel(cm, population)
expected_exposure, expected_probability, sr_model):
model = ExposureModel(cm, sr_model, population)
np.testing.assert_almost_equal(
model.deposited_exposure(), expected_exposure
)
@ -110,10 +110,10 @@ def test_exposure_model_ndarray(population, cm,
[populations[1], np.array([2.13410688, 1.98167067])],
[populations[2], np.array([1.36390289, 1.52436206])],
])
def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exposure):
def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exposure, sr_model):
cm = known_concentrations(
lambda t: 0. if np.floor(t) % 2 else np.array([1.2, 1.2]))
model = ExposureModel(cm, population)
model = ExposureModel(cm, sr_model, population)
np.testing.assert_almost_equal(
model.deposited_exposure(), expected_deposited_exposure
@ -128,17 +128,17 @@ def test_exposure_model_ndarray_and_float_mix(population, expected_deposited_exp
[populations[1], np.array([2.13410688, 1.98167067])],
[populations[2], np.array([1.36390289, 1.52436206])],
])
def test_exposure_model_vector(population, expected_deposited_exposure):
def test_exposure_model_vector(population, expected_deposited_exposure, sr_model):
cm_array = known_concentrations(lambda t: np.array([1.2, 1.2]))
model_array = ExposureModel(cm_array, population)
model_array = ExposureModel(cm_array, sr_model, population)
np.testing.assert_almost_equal(
model_array.deposited_exposure(), np.array(expected_deposited_exposure)
)
def test_exposure_model_scalar():
def test_exposure_model_scalar(sr_model):
cm_scalar = known_concentrations(lambda t: 1.2)
model_scalar = ExposureModel(cm_scalar, populations[0])
model_scalar = ExposureModel(cm_scalar, sr_model, populations[0])
expected_deposited_exposure = 1.52436206
np.testing.assert_almost_equal(
model_scalar.deposited_exposure(), expected_deposited_exposure
@ -169,6 +169,11 @@ def conc_model():
)
@pytest.fixture
def sr_model():
return ()
# Expected deposited exposure were computed with a trapezoidal integration, using
# a mesh of 10'000 pts per exposed presence interval.
@pytest.mark.parametrize(
@ -183,17 +188,17 @@ def conc_model():
]
)
def test_exposure_model_integral_accuracy(exposed_time_interval,
expected_deposited_exposure, conc_model):
expected_deposited_exposure, conc_model, sr_model):
presence_interval = models.SpecificInterval((exposed_time_interval,))
population = models.Population(
10, presence_interval, models.Mask.types['Type I'],
models.Activity.types['Standing'], 0.,
)
model = ExposureModel(conc_model, population)
model = ExposureModel(conc_model, sr_model, population)
np.testing.assert_allclose(model.deposited_exposure(), expected_deposited_exposure)
def test_infectious_dose_vectorisation():
def test_infectious_dose_vectorisation(sr_model):
infected_population = models.InfectedPopulation(
number=1,
presence=halftime,
@ -216,7 +221,7 @@ def test_infectious_dose_vectorisation():
10, presence_interval, models.Mask.types['Type I'],
models.Activity.types['Standing'], 0.,
)
model = ExposureModel(cm, population)
model = ExposureModel(cm, sr_model, population)
inf_probability = model.infection_probability()
assert isinstance(inf_probability, np.ndarray)
assert inf_probability.shape == (3, )

View file

@ -0,0 +1,105 @@
import typing
import numpy as np
import pytest
from cara import models
import cara.monte_carlo as mc_models
from cara.apps.calculator.model_generator import build_expiration
from cara.monte_carlo.data import short_range_expiration_distributions, short_range_distances, activity_distributions
# TODO: seed better the random number generators
np.random.seed(2000)
@pytest.fixture
def concentration_model() -> mc_models.ConcentrationModel:
return mc_models.ConcentrationModel(
room=models.Room(volume=75),
ventilation=models.AirChange(
active=models.SpecificInterval(present_times=((8.5, 12.5), (13.5, 17.5))),
air_exch=10_000_000.,
),
infected=mc_models.InfectedPopulation(
number=1,
virus=models.Virus.types['SARS_CoV_2'],
presence=models.SpecificInterval(present_times=((8.5, 12.5), (13.5, 17.5))),
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Light activity'],
expiration=build_expiration({'Speaking': 0.33, 'Breathing': 0.67}),
host_immunity=0.,
),
evaporation_factor=0.3,
)
@pytest.fixture
def short_range_model():
return mc_models.ShortRangeModel(expiration=short_range_expiration_distributions['Breathing'],
activity=activity_distributions['Seated'],
presence=models.SpecificInterval(present_times=((10.5, 11.0),)),
distance=short_range_distances)
def test_short_range_model_ndarray(concentration_model, short_range_model):
concentration_model = concentration_model.build_model(250_000)
model = short_range_model.build_model(250_000)
assert isinstance(model._normed_concentration(concentration_model, 10.75), np.ndarray)
assert isinstance(model.short_range_concentration(concentration_model, 10.75), np.ndarray)
assert isinstance(model.normed_exposure_between_bounds(concentration_model, 10.75, 10.85), np.ndarray)
assert isinstance(model.short_range_concentration(concentration_model, 14.0), float)
@pytest.mark.parametrize(
"activity, expected_dilution", [
["Seated", 176.04075727780327],
["Standing", 157.12965288170005],
["Light activity", 69.06672998536413],
["Moderate activity", 47.165817446310115],
["Heavy exercise", 23.759992220217875],
]
)
def test_dilution_factor(activity, expected_dilution):
model = models.ShortRangeModel(expiration="Breathing",
activity=models.Activity.types[activity],
presence=models.SpecificInterval(present_times=((10.5, 11.0),)),
distance=0.854)
assert isinstance(model.dilution_factor(), np.ndarray)
np.testing.assert_almost_equal(
model.dilution_factor(), expected_dilution, decimal=10
)
@pytest.mark.parametrize(
"time, expected_short_range_concentration", [
[8.5, 0.],
[10.5, 15.24806213],
[10.6, 15.24806213],
[11.0, 15.24806213],
[12.0, 0.],
]
)
def test_short_range_concentration(time, expected_short_range_concentration, concentration_model, short_range_model):
concentration_model = concentration_model.build_model(250_000)
model = short_range_model.build_model(250_000)
np.testing.assert_allclose(
np.array(model.short_range_concentration(concentration_model, time)).mean(),
expected_short_range_concentration, rtol=0.01
)
@pytest.mark.parametrize(
"start, stop, expected_exposure", [
[8.5, 12.5, 7.875963317294013e-09],
[10.5, 11.0, 7.875963317294013e-09],
[10.4, 11.1, 7.875963317294013e-09],
[10.5, 11.1, 7.875963317294013e-09],
[10.6, 11.1, 7.66539809488759e-09],
[10.4, 10.9, 7.66539809488759e-09],
]
)
def test_normed_exposure_between_bounds(start, stop, expected_exposure, concentration_model, short_range_model):
concentration_model = concentration_model.build_model(250_000)
model = short_range_model.build_model(250_000)
np.testing.assert_almost_equal(
model.normed_exposure_between_bounds(concentration_model, start, stop).mean(), expected_exposure
)

View file

@ -0,0 +1,667 @@
from dataclasses import dataclass
import typing
import numpy as np
from scipy.integrate import quad
from scipy.special import erf
import numpy.testing as npt
import pytest
import cara.monte_carlo as mc
from cara import models,data
from cara.utils import method_cache
from cara.models import _VectorisedFloat,Interval,SpecificInterval
from cara.monte_carlo.sampleable import LogNormal
from cara.monte_carlo.data import (expiration_distributions,
expiration_BLO_factors,short_range_expiration_distributions,
short_range_distances,virus_distributions,activity_distributions)
# TODO: seed better the random number generators
np.random.seed(2000)
SAMPLE_SIZE = 1_000_000
TOLERANCE = 0.02
sqrt2pi = np.sqrt(2.*np.pi)
sqrt2 = np.sqrt(2.)
ln2 = np.log(2)
@dataclass(frozen=True)
class SimpleConcentrationModel:
"""
Simple model for the background (long-range) concentration, without
all the flexibility of cara.models.ConcentrationModel.
For independent, end-to-end testing purposes.
This assumes no mask wearing, and the same ventilation rate at all
times.
"""
#: infected people presence interval
infected_presence: Interval
#: viral load (RNA copies / mL)
viral_load: _VectorisedFloat
#: breathing rate (m^3/h)
breathing_rate: _VectorisedFloat
#: room volume (m^3)
room_volume: _VectorisedFloat
#: ventilation rate (air changes per hour) - including HEPA
lambda_ventilation: _VectorisedFloat
#: BLO factors
BLO_factors: typing.Tuple[float, float, float]
#: number of infected people
num_infected: int = 1
#: relative humidity RH
humidity: float = 0.3
#: minimum particle diameter considered (microns)
diameter_min: float = 0.1
#: maximum particle diameter considered (microns)
diameter_max: float = 30.
#: evaporation factor
evaporation: float = 0.3
#: cn (cm^-3) for resp. the B, L and O modes. Corresponds to the
# total concentration of aerosols for each mode.
cn: typing.Tuple[float, float, float] = (0.06, 0.2, 0.0010008)
# mean of the underlying normal distributions (represents the log of a
# diameter in microns), for resp. the B, L and O modes.
mu: typing.Tuple[float, float, float] = (0.989541, 1.38629, 4.97673)
# std deviation of the underlying normal distribution, for resp.
# the B, L and O modes.
sigma: typing.Tuple[float, float, float] = (0.262364, 0.506818, 0.585005)
def removal_rate(self) -> _VectorisedFloat:
"""
removal rate lambda in h^-1, excluding the deposition rate.
"""
return (self.lambda_ventilation
+ ln2/(6.43 if self.humidity<=0.4 else 1.1) )
@method_cache
def deposition_removal_coefficient(self) -> float:
"""
coefficient in front of gravitational deposition rate, in h^-1.microns^-2
Note: 0.4512 = 1.88e-4 * 3600 / 1.5
"""
return 0.4512*(self.evaporation/2.5)**2
@method_cache
def aerosol_volume(self,diameter: float) -> float:
"""
particle volume in microns^3
"""
return 4*np.pi/3. * (diameter/2.)**3
@method_cache
def Np(self,diameter: float,
BLO_factors: typing.Tuple[float, float, float]) -> float:
"""
number of emitted particles per unit volume (BLO model)
in cm^-3.ln(micron)^-1
"""
result = 0.
for cn,mu,sigma,famp in zip(self.cn,self.mu,self.sigma,
BLO_factors):
result += ( (cn * famp)/sigma *
np.exp(-(np.log(diameter)-mu)**2/(2*sigma**2)))
return result/(diameter*sqrt2pi)
def vR(self,diameter: float) -> float:
"""
emission rate per unit diameter, in RNA copies / h / micron
"""
return (self.Np(diameter, self.BLO_factors)
* self.aerosol_volume(diameter) * 1e-6)
@method_cache
def f(self, removal_rate: _VectorisedFloat, deltat: float) -> _VectorisedFloat:
"""
A general function to compute the main integral over diameters
"""
def integrand(diameter):
# function to return the integrand
a = self.deposition_removal_coefficient()
a_dsquare = a*diameter**2
return (self.vR(diameter)/(a_dsquare + removal_rate)
* np.exp(-a_dsquare*deltat))
return (quad(integrand,self.diameter_min,self.diameter_max,
epsabs=0.,limit=500)[0]
* self.viral_load * self.breathing_rate)
def concentration(self,t: float) -> _VectorisedFloat:
"""
concentration at a given time t
"""
trans_times = sorted(self.infected_presence.transition_times())
if t==trans_times[0]:
return 0.
lambda_rate = self.removal_rate()
# transition_times[i] < t <= transition_times[i]+1
i: int = np.searchsorted(trans_times,t) - 1 # type: ignore
ti = trans_times[i]
Pim1 = (False if i==0 else
self.infected_presence.triggered((trans_times[i-1]+ti)/2.))
Pi = self.infected_presence.triggered((ti+trans_times[i+1])/2.)
result = (0 if not Pim1 else self.f(lambda_rate,t-ti))
result -= (0 if not Pi else self.f(lambda_rate,t-ti))
for k,tk in enumerate(trans_times[:i]):
Pkm1 = (False if k==0 else
self.infected_presence.triggered((trans_times[k-1]+tk)/2.))
Pk = self.infected_presence.triggered((tk+trans_times[k+1])/2.)
s = np.sum([lambda_rate*(trans_times[l]-trans_times[l-1])
for l in range(k+1,i+1)])
result += ( (0 if not Pkm1 else self.f(lambda_rate,t-tk))
-(0 if not Pk else self.f(lambda_rate,t-tk))
) * np.exp(-s)
return ( ( (0 if not self.infected_presence.triggered(t)
else self.f(lambda_rate,0))
+ result * np.exp(-lambda_rate*(t-ti)) )
* self.num_infected/self.room_volume)
@dataclass(frozen=True)
class SimpleShortRangeModel:
"""
Simple model for the short-range concentration, without
all the flexibility of cara.models.ShortRangeModel.
For independent, end-to-end testing purposes.
This assumes no mask wearing.
"""
#: time intervals in which a short-range interaction occurs
interaction_interval: SpecificInterval
#: tuple with interpersonal distanced from infected person (m)
distance : _VectorisedFloat = 0.854
#: breathing rate (m^3/h)
breathing_rate: _VectorisedFloat = 0.51
#: tuple with BLO factors
BLO_factors: typing.Tuple[float, float, float] = (1,0,0)
#: minimum diameter for integration (short-range only) (microns)
diameter_min: float = 0.1
#: maximum diameter for integration (short-range only) (microns)
diameter_max: float = 100.
#: mouth opening diameter (m)
D: float = 0.02
#: duration of the expiration (s)
tstar: float = 2.
#: Streamwise and radial penetration coefficients
Cr1: float = 0.18
Cx1: float = 2.4
Cr2: float = 0.2
Cx2: float = 2.2
@method_cache
def dilution_factor(self) -> _VectorisedFloat:
"""
computes dilution factor at a certain distance x
based on Wei JIA matlab script.
"""
x = np.array(self.distance)
dilution = np.empty(x.shape, dtype=np.float64)
# expired flow rate during the expiration period, m^3/s
Q0 = np.array(self.breathing_rate/3600)
# the expired flow velocity at the noozle (mouth opening), m/s
u0 = np.array(Q0/(np.pi/4. * self.D**2))
# parameters in the jet-like stage
# position of virtual origin
x01 = self.D/2/self.Cr1
# time of virtual origin
t01 = (x01/self.Cx1)**2 * (Q0*u0)**(-0.5)
# transition point (in m)
xstar = np.array(self.Cx1*(Q0*u0)**0.25*(self.tstar + t01)**0.5
- x01)
# dilution factor at the transition point xstar
Sxstar = np.array(2.*self.Cr1*(xstar+x01)/self.D)
# calculate dilution factor at the short-range distance x
dilution[x <= xstar] = 2.*self.Cr1*(x[x <= xstar] + x01)/self.D
dilution[x > xstar] = Sxstar[x > xstar]*(1. + self.Cr2*(x[x > xstar]
- xstar[x > xstar])
/self.Cr1/(xstar[x > xstar] + x01))**3
return dilution
@method_cache
def jet_concentration(self,conc_model: SimpleConcentrationModel) -> _VectorisedFloat:
"""
virion concentration at the origin of the jet (close to
the mouth of the infected person), in m^-3
we perform the integral of Np(d)*V(d) over diameter analytically
"""
vl = conc_model.viral_load
dmin = self.diameter_min
dmax = self.diameter_max
result = 0.
for cn,mu,sigma,famp in zip(conc_model.cn,conc_model.mu,conc_model.sigma,
self.BLO_factors):
d0 = np.exp(mu)
ymin = (np.log(dmin)-mu)/(sqrt2*sigma)-3.*sigma/sqrt2
ymax = (np.log(dmax)-mu)/(sqrt2*sigma)-3.*sigma/sqrt2
result += ( (cn * famp * d0**3)/2. * np.exp(9*sigma**2/2.) *
(erf(ymax) - erf(ymin)) )
return vl * 1e-6 * result * np.pi/6.
def concentration(self, conc_model: SimpleConcentrationModel, time: float) -> _VectorisedFloat:
"""
compute the short-range part of the concentration, and add it
to the background concentration
"""
if self.interaction_interval.triggered(time):
background_concentration = conc_model.concentration(time)
S = self.dilution_factor()
return (self.jet_concentration(conc_model)
- background_concentration) / S
else:
return 0.
@dataclass(frozen=True)
class SimpleExposureModel(SimpleConcentrationModel):
"""
Simple model for the background (long-range) exposure, without
all the flexibility of cara.models.ExposureModel.
For independent, end-to-end testing purposes.
This assumes no mask wearing, identical inhalation and exhalation
breathing rate, indentical presence for the infected and the exposed
people, the same ventilation rate at all times, and all short-range
interaction intervals are within presence intervals of the infected.
"""
#: fraction of infected viruses
finf: _VectorisedFloat = 0.5
#: host immunity factor (0. for not immune)
HI: _VectorisedFloat = 0.
#: infectious dose (ID50)
ID50: _VectorisedFloat = 50.
#: transmissibility factor w.r.t. original strain
# (<1 means more transmissible)
transmissibility: _VectorisedFloat = 1.
#: list of short-range interaction models
sr_models: typing.Tuple[SimpleShortRangeModel, ...] = ()
def fdep(self, diameter: float, evaporation: float) -> float:
"""
fraction deposited
"""
d = diameter * evaporation
IFrac = 1 - 0.5 * (1 - (1 / (1 + (0.00076*(d**2.8)))))
fdep = IFrac * (0.0587
+ (0.911/(1 + np.exp(4.77 + 1.485 * np.log(d))))
+ (0.943/(1 + np.exp(0.508 - 2.58 * np.log(d)))))
return fdep
@method_cache
def F(self, removal_rate: _VectorisedFloat, deltat: float,
evaporation: float) -> _VectorisedFloat:
"""
A general function to compute the main integral over diameters
"""
def integrand(diameter):
# function to return the integrand
a = self.deposition_removal_coefficient()
a_dsquare = a*diameter**2
return -(self.vR(diameter)*self.fdep(diameter,evaporation)/(
(a_dsquare + removal_rate)**2)
* np.exp(-a_dsquare*deltat))
return (quad(integrand,self.diameter_min,self.diameter_max,
epsabs=0.,limit=500)[0]
* self.viral_load * self.breathing_rate)
@method_cache
def f_with_fdep(self, removal_rate: _VectorisedFloat, deltat: float,
evaporation: float) -> _VectorisedFloat:
"""
Same as f but with fdep included in the integral.
"""
def integrand(diameter):
# function to return the integrand
a = self.deposition_removal_coefficient()
a_dsquare = a*diameter**2
return (self.vR(diameter)*self.fdep(diameter,evaporation)
/(a_dsquare + removal_rate) * np.exp(-a_dsquare*deltat))
return (quad(integrand,self.diameter_min,self.diameter_max,
epsabs=0.,limit=500)[0]
* self.viral_load * self.breathing_rate)
@method_cache
def integrated_background_concentration(self,t1: float,t2: float,
evaporation: float) -> _VectorisedFloat:
"""
background (long-range) concentration integrated from t1 to t2
assuming both t1 and t2 are within a single presence interval.
This includes the deposition fraction (fdep).
"""
trans_times = sorted(self.infected_presence.transition_times())
if t2==trans_times[0]:
return 0.
lambda_rate = self.removal_rate()
# transition_times[i] < t2 <= transition_times[i]+1
i: int = np.searchsorted(trans_times,t2) - 1 # type: ignore
ti = trans_times[i]
if np.searchsorted(trans_times,t1,side='right')-1 != i:
raise ValueError("t1={}, t2={}, i1={}, i2={} "
"(i1 and i2 should be equal)".format(
t1,t2,i,np.searchsorted(trans_times,t1,side='right')-1))
Pim1 = (False if i==0 else
self.infected_presence.triggered((trans_times[i-1]+ti)/2.))
Pi = self.infected_presence.triggered((ti+trans_times[i+1])/2.)
def primitive(time):
result = (0 if not Pim1 else self.F(lambda_rate,time-ti,evaporation))
result -= (0 if not Pi else self.F(lambda_rate,time-ti,evaporation))
for k,tk in enumerate(trans_times[:i]):
Pkm1 = (False if k==0 else
self.infected_presence.triggered((trans_times[k-1]+tk)/2.))
Pk = self.infected_presence.triggered((tk+trans_times[k+1])/2.)
s = np.sum([lambda_rate*(trans_times[l]-trans_times[l-1])
for l in range(k+1,i+1)])
result += ( (0 if not Pkm1 else self.F(lambda_rate,time-tk,evaporation))
-(0 if not Pk else self.F(lambda_rate,time-tk,evaporation))
) * np.exp(-s)
return result
return ( ( (0 if not self.infected_presence.triggered((t1+t2)/2.)
else self.f_with_fdep(lambda_rate,0,evaporation)*(t2-t1))
+ (primitive(t2) * np.exp(-lambda_rate*(t2-ti)) -
primitive(t1) * np.exp(-lambda_rate*(t1-ti)) ) )
* self.num_infected/self.room_volume)
@method_cache
def integrated_shortrange_concentration(self) -> _VectorisedFloat:
"""
short-range concentration integrated over interaction times and
diameters. This includes the deposition fraction (fdep).
"""
result = 0.
# evaporation set to 1 (particles do not have time to evaporate)
evaporation = 1.
for sr_model in self.sr_models:
t1,t2 = sr_model.interaction_interval.boundaries()[0]
# function to return the integrand
integrand = lambda d: (self.aerosol_volume(d)
* self.Np(d,sr_model.BLO_factors)*self.fdep(d,evaporation))
res = (quad(integrand,
sr_model.diameter_min,sr_model.diameter_max,
epsabs=0.,limit=500)[0]
* self.viral_load * 1e-6 * (t2-t1) )
result += sr_model.breathing_rate * (
res-self.integrated_background_concentration(t1,t2,evaporation)
)/sr_model.dilution_factor()
return result
def dose(self) -> _VectorisedFloat:
"""
total deposited dose (integrated over time and over particle
diameters), including short and long range.
"""
result = 0.
for t1,t2 in self.infected_presence.boundaries():
result += (self.integrated_background_concentration(t1,t2,self.evaporation)
* self.breathing_rate)
result += self.integrated_shortrange_concentration()
return result * self.finf * (1. - self.HI)
def probability_infection(self):
"""
total probability of infection
"""
return (1. - np.exp(-self.dose() * ln2 * (1-self.HI)
/(self.ID50 * self.transmissibility) )) * 100.
presence = models.SpecificInterval(present_times=((8.5, 12), (13, 17.5)))
interaction_intervals = (models.SpecificInterval(present_times=((10.5, 11.0),)),
models.SpecificInterval(present_times=((14.5, 15.0),))
)
@pytest.fixture
def c_model() -> mc.ConcentrationModel:
return mc.ConcentrationModel(
room=models.Room(volume=50, humidity=0.3),
ventilation=models.AirChange(active=models.PeriodicInterval(period=120, duration=120), air_exch=1.),
infected=mc.InfectedPopulation(
number=1,
presence=presence,
virus=models.Virus.types['SARS_CoV_2_DELTA'],
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Seated'],
expiration=expiration_distributions['Breathing'],
host_immunity=0.,
),
evaporation_factor=0.3,
).build_model(SAMPLE_SIZE)
@pytest.fixture
def sr_models() -> typing.Tuple[mc.ShortRangeModel, ...]:
return (
mc.ShortRangeModel(
expiration = short_range_expiration_distributions['Breathing'],
activity = models.Activity.types['Seated'],
presence = interaction_intervals[0],
distance = 0.854,
).build_model(SAMPLE_SIZE),
mc.ShortRangeModel(
expiration = short_range_expiration_distributions['Speaking'],
activity = models.Activity.types['Seated'],
presence = interaction_intervals[1],
distance = 0.854,
).build_model(SAMPLE_SIZE),
)
@pytest.fixture
def simple_c_model() -> SimpleConcentrationModel:
return SimpleConcentrationModel(
infected_presence = presence,
viral_load = models.Virus.types['SARS_CoV_2_DELTA'].viral_load_in_sputum,
breathing_rate = models.Activity.types['Seated'].exhalation_rate,
room_volume = 50.,
lambda_ventilation= 1.,
BLO_factors = expiration_BLO_factors['Breathing'],
)
@pytest.fixture
def simple_sr_models() -> typing.Tuple[SimpleShortRangeModel, ...]:
return (
SimpleShortRangeModel(
interaction_interval = interaction_intervals[0],
distance = 0.854,
breathing_rate = models.Activity.types['Seated'].exhalation_rate,
BLO_factors = expiration_BLO_factors['Breathing'],
),
SimpleShortRangeModel(
interaction_interval = interaction_intervals[1],
distance = 0.854,
breathing_rate = models.Activity.types['Seated'].exhalation_rate,
BLO_factors = expiration_BLO_factors['Speaking'],
)
)
@pytest.mark.parametrize(
"time", np.linspace(8.5,17.5,12),
)
def test_background_concentration(time,c_model,simple_c_model):
npt.assert_allclose(
c_model.concentration(time).mean(),
simple_c_model.concentration(time), rtol=TOLERANCE
)
@pytest.mark.parametrize(
"time", [10, 10.7, 11., 12.5, 14.75, 14.9, 17]
)
def test_shortrange_concentration(time,c_model,simple_c_model,
sr_models,simple_sr_models):
result_sr_model = np.sum([np.array(
sr_mod.short_range_concentration(c_model,time)).mean()
for sr_mod in sr_models])
result_simple_sr_model = np.sum([np.array(
sr_mod.concentration(simple_c_model,time)).mean()
for sr_mod in simple_sr_models])
npt.assert_allclose(
result_sr_model,result_simple_sr_model,rtol=TOLERANCE
)
def test_background_exposure(c_model):
simple_expo_model = SimpleExposureModel(
infected_presence = presence,
viral_load = models.Virus.types['SARS_CoV_2_DELTA'].viral_load_in_sputum,
breathing_rate = models.Activity.types['Seated'].exhalation_rate,
room_volume = 50.,
lambda_ventilation= 1.,
BLO_factors = expiration_BLO_factors['Breathing'],
finf = models.Virus.types['SARS_CoV_2_DELTA'].viable_to_RNA_ratio,
HI = 0.,
ID50 = models.Virus.types['SARS_CoV_2_DELTA'].infectious_dose,
transmissibility = models.Virus.types['SARS_CoV_2_DELTA'].transmissibility_factor,
sr_models = (),
)
expo_model = mc.ExposureModel(
concentration_model=c_model,
short_range=(),
exposed=mc.Population(
number=1,
presence=presence,
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Seated'],
host_immunity=0.,
),
).build_model(SAMPLE_SIZE)
npt.assert_allclose(
expo_model.deposited_exposure().mean(),
simple_expo_model.dose().mean(), rtol=TOLERANCE
)
npt.assert_allclose(
expo_model.infection_probability().mean(),
simple_expo_model.probability_infection().mean(), rtol=TOLERANCE
)
def test_background_exposure_with_distributions():
simple_expo_model = SimpleExposureModel(
infected_presence = presence,
viral_load = virus_distributions['SARS_CoV_2_DELTA'
].build_model(SAMPLE_SIZE).viral_load_in_sputum,
breathing_rate = activity_distributions['Seated'].build_model(
SAMPLE_SIZE).exhalation_rate,
room_volume = 50.,
lambda_ventilation= 1.,
BLO_factors = expiration_BLO_factors['Breathing'],
finf = virus_distributions['SARS_CoV_2_DELTA'
].build_model(SAMPLE_SIZE).viable_to_RNA_ratio,
HI = 0.,
ID50 = virus_distributions['SARS_CoV_2_DELTA'
].build_model(SAMPLE_SIZE).infectious_dose,
transmissibility = virus_distributions['SARS_CoV_2_DELTA'
].transmissibility_factor,
sr_models = (),
)
expo_model = mc.ExposureModel(
concentration_model=mc.ConcentrationModel(
room=models.Room(volume=50, humidity=0.3),
ventilation=models.AirChange(active=models.PeriodicInterval(
period=120, duration=120), air_exch=1.),
infected=mc.InfectedPopulation(
number=1,
presence=presence,
virus=virus_distributions['SARS_CoV_2_DELTA'],
mask=models.Mask.types['No mask'],
activity=activity_distributions['Seated'],
expiration=expiration_distributions['Breathing'],
host_immunity=0.,
),
evaporation_factor=0.3,
),
short_range=(),
exposed=mc.Population(
number=1,
presence=presence,
mask=models.Mask.types['No mask'],
activity=activity_distributions['Seated'],
host_immunity=0.,
),
).build_model(SAMPLE_SIZE)
npt.assert_allclose(
expo_model.deposited_exposure().mean(),
simple_expo_model.dose().mean(), rtol=TOLERANCE
)
npt.assert_allclose(
expo_model.infection_probability().mean(),
simple_expo_model.probability_infection().mean(), rtol=TOLERANCE
)
def test_exposure_with_shortrange(c_model,sr_models,simple_sr_models):
simple_expo_sr_model = SimpleExposureModel(
infected_presence = presence,
viral_load = models.Virus.types['SARS_CoV_2_DELTA'].viral_load_in_sputum,
breathing_rate = models.Activity.types['Seated'].exhalation_rate,
room_volume = 50.,
lambda_ventilation= 1.,
BLO_factors = expiration_BLO_factors['Breathing'],
finf = models.Virus.types['SARS_CoV_2_DELTA'].viable_to_RNA_ratio,
HI = 0.,
ID50 = models.Virus.types['SARS_CoV_2_DELTA'].infectious_dose,
transmissibility = models.Virus.types['SARS_CoV_2_DELTA'].transmissibility_factor,
sr_models = simple_sr_models,
)
expo_sr_model = mc.ExposureModel(
concentration_model=c_model,
short_range=sr_models,
exposed=mc.Population(
number=1,
presence=presence,
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Seated'],
host_immunity=0.,
),
).build_model(SAMPLE_SIZE)
npt.assert_allclose(
expo_sr_model.deposited_exposure().mean(),
simple_expo_sr_model.dose().mean(), rtol=TOLERANCE
)
npt.assert_allclose(
expo_sr_model.infection_probability().mean(),
simple_expo_sr_model.probability_infection().mean(), rtol=TOLERANCE
)

View file

@ -6,10 +6,10 @@ import cara.models as models
import cara.data as data
def test_no_mask_superspeading_emission_rate(baseline_model):
def test_no_mask_superspeading_emission_rate(baseline_concentration_model):
expected_rate = 48500.
npt.assert_allclose(
[baseline_model.infected.emission_rate(float(t)) for t in [0, 1, 4, 4.5, 5, 8, 9]],
[baseline_concentration_model.infected.emission_rate(float(t)) for t in [0, 1, 4, 4.5, 5, 8, 9]],
[0, expected_rate, expected_rate, 0, 0, expected_rate, 0],
rtol=1e-12
)
@ -38,10 +38,10 @@ def baseline_periodic_hepa():
)
def test_concentrations(baseline_model):
def test_concentrations(baseline_concentration_model):
# expected concentrations were computed analytically
ts = [0, 4, 5, 7, 10]
concentrations = [baseline_model.concentration(float(t)) for t in ts]
concentrations = [baseline_concentration_model.concentration(float(t)) for t in ts]
npt.assert_allclose(
concentrations,
[0.000000e+00, 20.805628, 6.602814e-13, 20.805628, 2.09545e-26],
@ -49,13 +49,13 @@ def test_concentrations(baseline_model):
)
def test_smooth_concentrations(baseline_model):
def test_smooth_concentrations(baseline_concentration_model):
# We don't care about the actual concentrations in this test, but rather
# that the curve itself is smooth.
dx = 0.002
dy_limit = 0.2 # Anything more than this (in relative) is a bit steep.
ts = np.arange(0, 10, dx)
concentrations = [baseline_model.concentration(float(t)) for t in ts]
concentrations = [baseline_concentration_model.concentration(float(t)) for t in ts]
assert np.abs(np.diff(concentrations)).max()/np.mean(concentrations) < dy_limit
@ -367,10 +367,11 @@ def test_concentrations_refine_times(time):
npt.assert_allclose(m1.concentration(time), m2.concentration(time), rtol=1e-8)
def build_exposure_model(concentration_model):
def build_exposure_model(concentration_model, short_range_model):
infected = concentration_model.infected
return models.ExposureModel(
concentration_model=concentration_model,
short_range=short_range_model,
exposed=models.Population(
number=10,
presence=infected.presence,
@ -390,13 +391,13 @@ def build_exposure_model(concentration_model):
['Jun', 1721.03336729],
],
)
def test_exposure_hourly_dep(month,expected_deposited_exposure):
def test_exposure_hourly_dep(month,expected_deposited_exposure, baseline_sr_model):
m = build_exposure_model(
build_hourly_dependent_model(
month,
intervals_open=((0., 24.), ),
intervals_presence_infected=((8., 12.), (13., 17.))
)
), baseline_sr_model
)
deposited_exposure = m.deposited_exposure()
npt.assert_allclose(deposited_exposure, expected_deposited_exposure)
@ -411,14 +412,14 @@ def test_exposure_hourly_dep(month,expected_deposited_exposure):
['Jun', 1799.17597184],
],
)
def test_exposure_hourly_dep_refined(month,expected_deposited_exposure):
def test_exposure_hourly_dep_refined(month,expected_deposited_exposure, baseline_sr_model):
m = build_exposure_model(
build_hourly_dependent_model(
month,
intervals_open=((0., 24.),),
intervals_presence_infected=((8., 12.), (13., 17.)),
temperatures=data.GenevaTemperatures,
)
), baseline_sr_model
)
deposited_exposure = m.deposited_exposure()
npt.assert_allclose(deposited_exposure, expected_deposited_exposure, rtol=0.02)

View file

@ -38,7 +38,7 @@ def test_type_annotations():
@pytest.fixture
def baseline_mc_model() -> cara.monte_carlo.ConcentrationModel:
def baseline_mc_concentration_model() -> cara.monte_carlo.ConcentrationModel:
mc_model = cara.monte_carlo.ConcentrationModel(
room=cara.monte_carlo.Room(volume=cara.monte_carlo.sampleable.Normal(75, 20)),
ventilation=cara.monte_carlo.SlidingWindow(
@ -62,21 +62,27 @@ def baseline_mc_model() -> cara.monte_carlo.ConcentrationModel:
@pytest.fixture
def baseline_mc_exposure_model(baseline_mc_model) -> cara.monte_carlo.ExposureModel:
def baseline_mc_sr_model() -> cara.monte_carlo.ShortRangeModel:
return ()
@pytest.fixture
def baseline_mc_exposure_model(baseline_mc_concentration_model, baseline_mc_sr_model) -> cara.monte_carlo.ExposureModel:
return cara.monte_carlo.ExposureModel(
baseline_mc_model,
baseline_mc_concentration_model,
baseline_mc_sr_model,
exposed=cara.models.Population(
number=10,
presence=baseline_mc_model.infected.presence,
activity=baseline_mc_model.infected.activity,
mask=baseline_mc_model.infected.mask,
presence=baseline_mc_concentration_model.infected.presence,
activity=baseline_mc_concentration_model.infected.activity,
mask=baseline_mc_concentration_model.infected.mask,
host_immunity=0.,
)
)
def test_build_concentration_model(baseline_mc_model: cara.monte_carlo.ConcentrationModel):
model = baseline_mc_model.build_model(7)
def test_build_concentration_model(baseline_mc_concentration_model: cara.monte_carlo.ConcentrationModel):
model = baseline_mc_concentration_model.build_model(7)
assert isinstance(model, cara.models.ConcentrationModel)
assert isinstance(model.concentration(time=0.), float)
conc = model.concentration(time=1.)

View file

@ -9,8 +9,8 @@ from cara.apps.calculator.model_generator import build_expiration
# TODO: seed better the random number generators
np.random.seed(2000)
SAMPLE_SIZE = 250000
TOLERANCE = 0.05
SAMPLE_SIZE = 600_000
TOLERANCE = 0.06
# Load the weather data (temperature in kelvin) for Toronto.
toronto_coordinates = (43.667, 79.400)
@ -39,7 +39,6 @@ TorontoTemperatures = {
# references values for infection_probability and expected new cases
# in the following tests, were obtained from the feature/mc branch
@pytest.fixture
def shared_office_mc():
"""
@ -72,6 +71,7 @@ def shared_office_mc():
)
return mc.ExposureModel(
concentration_model=concentration_mc,
short_range=(),
exposed=mc.Population(
number=3,
presence=mc.SpecificInterval(present_times=((0, 3.5), (4.5, 9))),
@ -114,6 +114,7 @@ def classroom_mc():
)
return mc.ExposureModel(
concentration_model=concentration_mc,
short_range=(),
exposed=mc.Population(
number=19,
presence=models.SpecificInterval(((0, 2), (2.5, 4), (5, 7), (7.5, 9))),
@ -147,6 +148,7 @@ def ski_cabin_mc():
)
return mc.ExposureModel(
concentration_model=concentration_mc,
short_range=(),
exposed=mc.Population(
number=3,
presence=models.SpecificInterval(((0, 20/60),)),
@ -186,6 +188,7 @@ def skagit_chorale_mc():
)
return mc.ExposureModel(
concentration_model=concentration_mc,
short_range=(),
exposed=mc.Population(
number=60,
presence=models.SpecificInterval(((0, 2.5), )),
@ -225,6 +228,7 @@ def bus_ride_mc():
)
return mc.ExposureModel(
concentration_model=concentration_mc,
short_range=(),
exposed=mc.Population(
number=67,
presence=models.SpecificInterval(((0, 1.67), )),
@ -259,6 +263,7 @@ def gym_mc():
)
return mc.ExposureModel(
concentration_model=concentration_mc,
short_range=(),
exposed=mc.Population(
number=28,
presence=concentration_mc.infected.presence,
@ -293,6 +298,7 @@ def waiting_room_mc():
)
return mc.ExposureModel(
concentration_model=concentration_mc,
short_range=(),
exposed=mc.Population(
number=14,
presence=concentration_mc.infected.presence,
@ -370,6 +376,7 @@ def test_small_shared_office_Geneva(mask_type, month, expected_pi,
)
exposure_mc = mc.ExposureModel(
concentration_model=concentration_mc,
short_range=(),
exposed=mc.Population(
number=1,
presence=concentration_mc.infected.presence,