Merge branch 'feature/dynamic_groups' into 'master'

Dynamic groups in backend

See merge request caimira/caimira!510
This commit is contained in:
Andre Henriques 2025-07-24 19:31:09 +00:00
commit 219207320a
26 changed files with 1781 additions and 1017 deletions

View file

@ -1,3 +1,19 @@
# 4.18.0 (July 24, 2025)
## Feature Added
- Dynamic occupancy: groups of exposed + infected may now be defined.
Model and calculator updated, and tests added. Documentation still to
be updated. User interface mostly unchanged (the new feature is not
visible there).
## Bug Fixes
- Fix in profiler to adapt to a non back-compatible change in pyinstrument
package.
- Type ignored in the expert app.
## Other
- Update of mypy, pytest-mypy and pyinstrument dependencies (version).
# 4.17.8 (March 13, 2025)
## Bug Fixes

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "caimira"
version = "4.17.8"
version = "4.18.0"
description = "CAiMIRA - CERN Airborne Model for Indoor Risk Assessment"
readme = "README.md"
license = { text = "Apache-2.0" }
@ -23,7 +23,7 @@ dependencies = [
"mistune",
"numpy",
"pandas",
"pyinstrument",
"pyinstrument >= 5.0.3",
"python-dateutil",
"requests",
"retry",
@ -39,8 +39,8 @@ dependencies = [
dev = []
test = [
"pytest",
"pytest-mypy >= 0.10.3",
"mypy >= 1.0.0",
"pytest-mypy >= 1.0.1",
"mypy >= 1.17.0",
"pytest-tornasync",
"types-dataclasses",
"types-python-dateutil",

View file

@ -31,6 +31,9 @@ def submit_virus_form(form_data: typing.Dict, report_generation_parallelism: typ
report_data: typing.Dict = generate_report(form_obj=form_obj, report_generation_parallelism=report_generation_parallelism)
# Handle model representation
if report_data['model']: report_data['model'] = repr(report_data['model'])
if report_data['model']:
report_data['model'] = repr(report_data['model'])
for single_group_output in report_data['groups'].values():
del single_group_output['model'] # Model representation per group not needed
return report_data

View file

@ -666,7 +666,7 @@ class Particle:
# deposition fraction depends on aerosol particle diameter.
d = (self.diameter * evaporation_factor)
IFrac = 1 - 0.5 * (1 - (1 / (1 + (0.00076*(d**2.8)))))
fdep = IFrac * (0.0587
fdep = IFrac * (0.0587 # type: ignore
+ (0.911/(1 + np.exp(4.77 + 1.485 * np.log(d))))
+ (0.943/(1 + np.exp(0.508 - 2.58 * np.log(d))))) # type: ignore
return fdep
@ -711,6 +711,9 @@ class Expiration(_ExpirationBase):
# to c_n,i in Eq. (4) of https://doi.org/10.1101/2021.10.14.21264988)
cn: float = 1.
#: Expiration name
name: typing.Optional[str] = None
@property
def particle(self) -> Particle:
"""
@ -799,7 +802,7 @@ Activity.types = {
@dataclass(frozen=True)
class SimplePopulation:
"""
Represents a group of people all with exactly the same behaviour and
Represents a group of people all with exactly the same behavior and
situation.
"""
@ -844,7 +847,7 @@ class SimplePopulation:
@dataclass(frozen=True)
class Population(SimplePopulation):
"""
Represents a group of people all with exactly the same behaviour and
Represents a group of people all with exactly the same behavior and
situation, considering the usage of mask and a certain host immunity.
"""
@ -1313,7 +1316,7 @@ class ShortRangeModel:
data_registry: DataRegistry
#: Expiration type
expiration: _ExpirationBase
expiration: Expiration
#: Activity type
activity: Activity
@ -1639,6 +1642,9 @@ class ExposureModel:
#: Total people with short-range interactions
exposed_to_short_range: int = 0
#: Unique group identifier
identifier: str = 'group_1'
#: The number of times the exposure event is repeated (default 1).
@property
def repeats(self) -> int:
@ -1653,6 +1659,9 @@ class ExposureModel:
In other words, the air exchange rate from the
ventilation, and the virus decay constant, must
not be given as arrays.
It also checks that the number of exposed is
static during the simulation time.
"""
c_model = self.concentration_model
# Check if the diameter is vectorised.
@ -1663,6 +1672,11 @@ class ExposureModel:
c_model.ventilation.air_exchange(c_model.room, time)) for time in c_model.state_change_times()))):
raise ValueError("If the diameter is an array, none of the ventilation parameters "
"or virus decay constant can be arrays at the same time.")
# Check if exposed population is static
if not isinstance(self.exposed.number, int) or not isinstance(self.exposed.presence, Interval):
raise TypeError("The exposed number must be an int and presence an Interval. "
f"Got {type(self.exposed.number)} and {type(self.exposed.presence)}.")
@method_cache
def population_state_change_times(self) -> typing.List[float]:
@ -1809,11 +1823,9 @@ class ExposureModel:
The number of virus per m^3 deposited on the respiratory tract.
"""
population_change_times = self.population_state_change_times()
deposited_exposure = []
for start, stop in zip(population_change_times[:-1], population_change_times[1:]):
deposited_exposure.append(self.deposited_exposure_between_bounds(start, stop))
return deposited_exposure
def deposited_exposure(self) -> _VectorisedFloat:
@ -1838,8 +1850,7 @@ class ExposureModel:
return (1 - np.prod([1 - prob for prob in self._infection_probability_list()], axis = 0)) * 100
def total_probability_rule(self) -> _VectorisedFloat:
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
isinstance(self.exposed.number, IntPiecewiseConstant)):
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant)):
raise NotImplementedError("Cannot compute total probability "
"(including incidence rate) with dynamic occupancy")
@ -1847,9 +1858,9 @@ class ExposureModel:
sum_probability = 0.0
# Create an equivalent exposure model but changing the number of infected cases.
total_people = self.concentration_model.infected.number + self.exposed.number
total_people = self.concentration_model.infected.number + self.exposed.number # type: ignore
max_num_infected = (total_people if total_people < 10 else 10)
# The influence of a higher number of simultainious infected people (> 4 - 5) yields an almost negligible contirbution to the total probability.
# The influence of a higher number of simultaneous infected people (> 4 - 5) yields an almost negligible contribution to the total probability.
# To be on the safe side, a hard coded limit with a safety margin of 2x was set.
# Therefore we decided a hard limit of 10 infected people.
for num_infected in range(1, max_num_infected + 1):
@ -1872,43 +1883,81 @@ class ExposureModel:
1) Long-range exposure: take the infection_probability and multiply by the occupants exposed to long-range.
2) Short- and long-range exposure: take the infection_probability of long-range multiplied by the occupants exposed to long-range only,
plus the infection_probability of short- and long-range multiplied by the occupants exposed to short-range only.
Currently disabled when dynamic occupancy is defined for the exposed population.
"""
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
isinstance(self.exposed.number, IntPiecewiseConstant)):
raise NotImplementedError("Cannot compute expected new cases "
"with dynamic occupancy")
number = self.exposed.number
if self.short_range != ():
new_cases_long_range = nested_replace(self, {'short_range': [],}).infection_probability() * (self.exposed.number - self.exposed_to_short_range)
new_cases_long_range = nested_replace(self, {'short_range': [],}).infection_probability() * (number - self.exposed_to_short_range) # type: ignore
return (new_cases_long_range + (self.infection_probability() * self.exposed_to_short_range)) / 100
return self.infection_probability() * self.exposed.number / 100
return self.infection_probability() * number / 100
def reproduction_number(self) -> _VectorisedFloat:
"""
The reproduction number can be thought of as the expected number of
cases directly generated by one infected case in a population.
Currently disabled when dynamic occupancy is defined for both the infected and exposed population.
"""
if (isinstance(self.concentration_model.infected.number, IntPiecewiseConstant) or
isinstance(self.exposed.number, IntPiecewiseConstant)):
raise NotImplementedError("Cannot compute reproduction number "
"with dynamic occupancy")
if self.concentration_model.infected.number == 1:
infected_population: InfectedPopulation = self.concentration_model.infected
if isinstance(infected_population.number, int) and infected_population.number == 1:
return self.expected_new_cases()
# Create an equivalent exposure model but with precisely
# one infected case.
# one infected case, respecting the presence interval.
single_exposure_model = nested_replace(
self, {
'concentration_model.infected.number': 1}
'concentration_model.infected.number': 1,
'concentration_model.infected.presence': infected_population.presence_interval(),
}
)
return single_exposure_model.expected_new_cases()
@dataclass(frozen=True)
class ExposureModelGroup:
"""
Represents a group of exposure models. This is to handle the case
when different groups of people come and go in the room at different
times. These groups are then handled fully independently, with
exposure dose and probability of infection defined for each of them.
"""
data_registry: DataRegistry
#: The set of exposure models for each exposed population
exposure_models: typing.Tuple[ExposureModel, ...]
def __post_init__(self):
"""
Validate that all ExposureModels have the same ConcentrationModel.
"""
first_concentration_model = self.exposure_models[0].concentration_model
for model in self.exposure_models[1:]:
# Check that the number of infected people and their presence is the same
if (model.concentration_model.infected.number != first_concentration_model.infected.number or
model.concentration_model.infected.presence != first_concentration_model.infected.presence):
raise ValueError("All ExposureModels must have the same infected number and presence in the ConcentrationModel.")
@method_cache
def _deposited_exposure_list(self) -> typing.List[_VectorisedFloat]:
"""
List of doses absorbed by each member of the groups.
"""
return [model.deposited_exposure() for model in self.exposure_models]
@method_cache
def _infection_probability_list(self):
"""
List of the probability of infection for each group.
"""
return [model.infection_probability() for model in self.exposure_models] # type: ignore
def expected_new_cases(self) -> _VectorisedFloat:
"""
Final expected number of new cases considering the
contribution of each individual probability of infection.
"""
return np.sum([model.expected_new_cases() for model in self.exposure_models], axis=0) # type: ignore
def reproduction_number(self) -> _VectorisedFloat:
"""
Expected number of cases when there is only one infected case.
"""
return np.sum([model.reproduction_number() for model in self.exposure_models], axis=0) # type: ignore

View file

@ -365,6 +365,7 @@ def expiration_distribution(
BLO_factors,
d_min=0.1,
d_max=30.,
exp_type=None,
):
"""
Returns an Expiration with an aerosol diameter distribution, defined
@ -382,6 +383,7 @@ def expiration_distribution(
kernel_bandwidth=0.1,
),
cn=BLOmodel(data_registry, BLO_factors).integrate(d_min, d_max),
name=exp_type,
)
@ -432,7 +434,8 @@ def short_range_expiration_distributions(data_registry):
data_registry=data_registry,
BLO_factors=BLO_factors,
d_min=param_evaluation(data_registry.expiration_particle['particle_size_range']['short_range'], 'minimum_diameter'),
d_max=param_evaluation(data_registry.expiration_particle['particle_size_range']['short_range'], 'maximum_diameter')
d_max=param_evaluation(data_registry.expiration_particle['particle_size_range']['short_range'], 'maximum_diameter'),
exp_type=exp_type,
)
for exp_type, BLO_factors in expiration_BLO_factors(data_registry).items()
}

View file

@ -74,7 +74,6 @@ def _build_mc_model(model: dataclass_instance) -> typing.Type[MCModelBase[_Model
elif new_field.type == typing.Tuple[models.SpecificInterval, ...]:
SI = getattr(sys.modules[__name__], "SpecificInterval")
field_type = typing.Tuple[typing.Union[models.SpecificInterval, SI], ...]
elif new_field.type == typing.Union[int, models.IntPiecewiseConstant]:
IPC = getattr(sys.modules[__name__], "IntPiecewiseConstant")
field_type = typing.Union[int, models.IntPiecewiseConstant, IPC]

View file

@ -31,7 +31,7 @@ class Profilers(Enum):
class PyInstrumentWrapper:
profiler = PyInstrumentProfiler(async_mode=True)
profiler = PyInstrumentProfiler(async_mode='enabled')
@property
def is_running(self):

View file

@ -5,18 +5,32 @@ import io
import typing
import numpy as np
import matplotlib.pyplot as plt
from collections import defaultdict
from caimira.calculator.models import models, dataclass_utils, profiler, monte_carlo as mc
from caimira.calculator.models.enums import ViralLoads
from caimira.calculator.validators.virus.virus_validator import VirusFormData
def model_start_end(model: models.ExposureModel):
t_start = min(model.exposed.presence_interval().boundaries()[0][0],
def model_start_end(model: typing.Union[models.ExposureModelGroup, models.ExposureModel]):
"""
Calculates the boundary times for an ExposureModel or ExposureModelGroup.
For a single ExposureModel, determines its boundary times by comparing
the presence intervals of both the exposed and the infected people.
For an ExposureModelGroup, finds the earliest start time and the latest end time
across all models in the group.
"""
if isinstance(model, models.ExposureModelGroup):
t_start = min((model_start_end(nth_model)[0] for nth_model in model.exposure_models))
t_end = max((model_start_end(nth_model)[1] for nth_model in model.exposure_models))
return t_start, t_end
else:
t_start = min(model.exposed.presence_interval().boundaries()[0][0],
model.concentration_model.infected.presence_interval().boundaries()[0][0])
t_end = max(model.exposed.presence_interval().boundaries()[-1][1],
t_end = max(model.exposed.presence_interval().boundaries()[-1][1],
model.concentration_model.infected.presence_interval().boundaries()[-1][1])
return t_start, t_end
return t_start, t_end
def fill_big_gaps(array, gap_size):
@ -42,7 +56,7 @@ def fill_big_gaps(array, gap_size):
return result
def non_temp_transition_times(model: models.ExposureModel):
def non_temp_transition_times(model: typing.Union[models.ExposureModelGroup, models.ExposureModel]):
"""
Return the non-temperature (and PiecewiseConstant) based transition times.
@ -63,7 +77,7 @@ def non_temp_transition_times(model: models.ExposureModel):
t_start, t_end = model_start_end(model)
change_times = {t_start, t_end}
for name, obj in walk_model(model, name="exposure"):
for _, obj in walk_model(model, name="exposure"):
if isinstance(obj, models.Interval):
change_times |= obj.transition_times()
@ -72,7 +86,8 @@ def non_temp_transition_times(model: models.ExposureModel):
return sorted(time for time in change_times if (t_start <= time <= t_end))
def interesting_times(model: models.ExposureModel, approx_n_pts: typing.Optional[int] = None) -> typing.List[float]:
def interesting_times(model: typing.Union[models.ExposureModelGroup, models.ExposureModel],
approx_n_pts: typing.Optional[int] = None) -> typing.List[float]:
"""
Pick approximately ``approx_n_pts`` time points which are interesting for the
given model. If not provided by argument, ``approx_n_pts`` is set to be 15 times
@ -94,126 +109,220 @@ def interesting_times(model: models.ExposureModel, approx_n_pts: typing.Optional
return nice_times
def concentrations_with_sr_breathing(form: VirusFormData, 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 _concentrations_with_sr_breathing(form: VirusFormData, model: models.ExposureModel,
time: float, fn_name: typing.Optional[str] = None):
"""
Returns the zoomed viral concentrations.
"""
for index, (start, stop) in enumerate([interaction.presence.boundaries()[0] for interaction in model.short_range]):
if start <= time <= stop and form.short_range_interactions[model.identifier][index]['expiration'] == 'Breathing':
return np.array(model.concentration(float(time))).mean(), fn_name
return np.array(model.concentration_model.concentration(float(time))).mean(), fn_name
def _calculate_deposited_exposure(model, time1, time2, fn_name=None):
def _calculate_deposited_exposure(model: models.ExposureModel,
time1: float, time2: float, fn_name: typing.Optional[str] = None):
return np.array(model.deposited_exposure_between_bounds(float(time1), float(time2))).mean(), fn_name
def _calculate_long_range_deposited_exposure(model, time1, time2, fn_name=None):
def _calculate_long_range_deposited_exposure(model: models.ExposureModel,
time1: float, time2: float, fn_name: typing.Optional[str] = None):
return np.array(model.long_range_deposited_exposure_between_bounds(float(time1), float(time2))).mean(), fn_name
def _calculate_co2_concentration(CO2_model, time, fn_name=None):
def _calculate_concentration(model: models.ExposureModel,
time: float, fn_name: typing.Optional[str] = None):
"""
Returns the concentration of viruses emitted by
the infected population. Short- and long-range included.
"""
return np.array(model.concentration(float(time))).mean(), fn_name
def _calculate_co2_concentration(CO2_model: models.CO2ConcentrationModel, time: float, fn_name: typing.Optional[str] = None):
"""
Returns the CO2 concentration emitted by all
the present population.
"""
return np.array(CO2_model.concentration(float(time))).mean(), fn_name
@profiler.profile
def calculate_report_data(form: VirusFormData, executor_factory: typing.Callable[[], concurrent.futures.Executor]) -> typing.Dict[str, typing.Any]:
model: models.ExposureModel = form.build_model()
def merge_intervals(intervals: typing.List[typing.List[float]]) -> typing.List[typing.List[float]]:
"""
Merges overlapping intervals from a list of intervals.
Assumes intervals are sorted based on start times.
"""
if not intervals:
return []
merged = [list(intervals[0])]
for start, end in intervals[1:]:
if merged[-1][1] < start:
merged.append([start, end])
else:
merged[-1][1] = max(merged[-1][1], end)
return merged
def merge_short_range_interactions(all_exposed_groups: typing.Dict[str, typing.Any]) -> typing.List[typing.Dict[str, typing.Any]]:
"""
Expands the short range interactions per exposed group to a single data structure.
"""
merged_interactions = defaultdict(list)
for group in all_exposed_groups.values():
for interaction in group["short_range_interactions"]:
merged_interactions[interaction["expiration"]].extend(interaction["presence_interval"])
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(float(time))).mean()
for time in times
# Merge and sort intervals
return [
{"expiration": exp, "presence_interval": merge_intervals(sorted(intervals, key=lambda x: x[0]))}
for exp, intervals in merged_interactions.items()
]
lower_concentrations = concentrations_with_sr_breathing(
form, model, times, short_range_intervals)
def group_results(form: VirusFormData, model_group: models.ExposureModelGroup) -> typing.Dict[str, typing.Any]:
"""
Generates the output per group of exposure models.
"""
groups: dict = defaultdict(dict)
for single_group in model_group.exposure_models:
# Probability of infection
prob = single_group.infection_probability()
prob_dist_count, prob_dist_bins = np.histogram(prob/100, bins=100, density=True)
# Expected new cases
expected_new_cases = np.array(single_group.expected_new_cases())
groups[single_group.identifier] = {
"model": single_group,
"prob_inf": prob.mean(),
"prob_inf_sd": prob.std(),
"prob_dist": list(prob),
"prob_hist_count": list(prob_dist_count),
"prob_hist_bins": list(prob_dist_bins),
"expected_new_cases": expected_new_cases.mean(),
"exposed_presence_intervals": list(single_group.exposed.presence_interval().boundaries()),
}
# In case of conditional probability plot
if (form.conditional_probability_viral_loads and
single_group.data_registry.virological_data['virus_distributions'][form.virus_type]['viral_load_in_sputum'] == ViralLoads.COVID_OVERALL.value): # type: ignore
conditional_probability_data = manufacture_conditional_probability_data(single_group, prob)
groups[single_group.identifier].update({
"conditional_probability_data": conditional_probability_data,
"uncertainties_plot_src": img2base64(_figure2bytes(uncertainties_plot(prob, conditional_probability_data)))
})
# Probabilistic exposure
if form.exposure_option == "p_probabilistic_exposure":
groups[single_group.identifier].update({
"prob_probabilistic_exposure": np.array(single_group.total_probability_rule()).mean()
})
# In case of short-range interactions
if single_group.short_range != ():
# Short range outputs
short_range_interactions: dict = defaultdict(list)
for short_range_model in single_group.short_range:
short_range_interactions[short_range_model.expiration.name].extend(
short_range_model.presence.boundaries()
)
long_range_single_group = dataclass_utils.nested_replace(
single_group, {'short_range': ()}
)
groups[single_group.identifier].update({
"long_range_prob": long_range_single_group.infection_probability().mean(),
"long_range_expected_new_cases": long_range_single_group.expected_new_cases().mean(),
"short_range_interactions": [
{"expiration": expiration, "presence_interval": intervals}
for expiration, intervals in short_range_interactions.items()
],
})
return groups
@profiler.profile
def calculate_report_data(form: VirusFormData,
executor_factory: typing.Callable[[], concurrent.futures.Executor]) -> typing.Dict[str, typing.Any]:
"""
Generates the simulation output data.
"""
model_group: models.ExposureModelGroup = form.build_model()
results_per_group: typing.Dict[str, typing.Any] = group_results(form, model_group)
times = interesting_times(model_group)
# CO2 concentration
CO2_model: models.CO2ConcentrationModel = form.build_CO2_model()
# compute deposited exposures and CO2 concentrations in parallel to increase performance
deposited_exposures = []
long_range_deposited_exposures = []
# Compute deposited exposures and virus/CO2 concentrations in parallel to increase performance
deposited_exposures = defaultdict(list)
long_range_deposited_exposures = defaultdict(list)
concentrations = defaultdict(list)
concentrations_zoomed = defaultdict(list)
CO2_concentrations = []
tasks = []
with executor_factory() as executor:
for time1, time2 in zip(times[:-1], times[1:]):
tasks.append(executor.submit(
_calculate_deposited_exposure, model, time1, time2, fn_name="de"))
tasks.append(executor.submit(
_calculate_long_range_deposited_exposure, model, time1, time2, fn_name="lr"))
# co2 concentration: takes each time as param, not the interval
for single_group in model_group.exposure_models:
tasks.append(executor.submit(
_calculate_deposited_exposure, single_group, time1, time2, fn_name=f"{single_group.identifier}:de"))
# virus and co2 concentration: takes each time as param, not the interval
tasks.append(executor.submit(
_calculate_concentration, single_group, time1, fn_name=f"{single_group.identifier}:cn"))
if single_group.short_range != ():
tasks.append(executor.submit(
_calculate_long_range_deposited_exposure, single_group, time1, time2, fn_name=f"{single_group.identifier}:de_lr"))
tasks.append(executor.submit(
_concentrations_with_sr_breathing, form, single_group, time1, fn_name=f"{single_group.identifier}:cn_zoomed"))
tasks.append(executor.submit(
_calculate_co2_concentration, CO2_model, time1, fn_name="co2"))
# co2 concentration: calculate the last time too
# virus and co2 concentration: calculate the last time too
for single_model in model_group.exposure_models:
tasks.append(executor.submit(_calculate_concentration,
single_model, times[-1], fn_name=f"{single_model.identifier}:cn"))
if single_group.short_range != ():
tasks.append(executor.submit(_concentrations_with_sr_breathing,
form, single_model, times[-1], fn_name=f"{single_model.identifier}:cn_zoomed"))
tasks.append(executor.submit(_calculate_co2_concentration,
CO2_model, times[-1], fn_name="co2"))
CO2_model, times[-1], fn_name="co2"))
for task in tasks:
result, fn_name = task.result()
if fn_name == "de":
deposited_exposures.append(result)
elif fn_name == "lr":
long_range_deposited_exposures.append(result)
elif fn_name == "co2":
CO2_concentrations.append(result)
if ":" in fn_name:
if fn_name.split(":")[1] == "de":
deposited_exposures[fn_name.split(':')[0]].append(result)
elif fn_name.split(":")[1] == "de_lr":
long_range_deposited_exposures[fn_name.split(':')[0]].append(result)
elif fn_name.split(":")[1] == "cn":
concentrations[fn_name.split(':')[0]].append(result)
elif fn_name.split(":")[1] == "cn_zoomed":
concentrations_zoomed[fn_name.split(':')[0]].append(result)
else:
if fn_name == "co2":
CO2_concentrations.append(result)
cumulative_doses = np.cumsum(deposited_exposures)
long_range_cumulative_doses = np.cumsum(long_range_deposited_exposures)
prob = np.array(model.infection_probability())
prob_dist_count, prob_dist_bins = np.histogram(prob/100, bins=100, density=True)
# Update results per group
for single_group in model_group.exposure_models:
results_per_group[single_group.identifier]["concentrations"] = concentrations[single_group.identifier]
results_per_group[single_group.identifier]["cumulative_doses"] = list(np.cumsum(deposited_exposures[single_group.identifier]))
# Calculate long_range results when short-range interactions are defined
if single_group.short_range != ():
results_per_group[single_group.identifier]["concentrations_zoomed"] = concentrations_zoomed[single_group.identifier]
results_per_group[single_group.identifier]["long_range_cumulative_doses"] = list(np.cumsum(long_range_deposited_exposures[single_group.identifier]))
# Probabilistic exposure and expected new cases (only for static occupancy)
prob_probabilistic_exposure = None
expected_new_cases = None
if form.occupancy_format == "static":
if form.exposure_option == "p_probabilistic_exposure":
prob_probabilistic_exposure = np.array(model.total_probability_rule()).mean()
expected_new_cases = np.array(model.expected_new_cases()).mean()
exposed_presence_intervals = [list(interval) for interval in model.exposed.presence_interval().boundaries()]
conditional_probability_data = None
uncertainties_plot_src = None
if (form.conditional_probability_viral_loads and
model.data_registry.virological_data['virus_distributions'][form.virus_type]['viral_load_in_sputum'] == ViralLoads.COVID_OVERALL.value): # type: ignore
# Generate all the required data for the conditional probability plot
conditional_probability_data = manufacture_conditional_probability_data(
model, prob)
# Generate the matplotlib image based on the received data
uncertainties_plot_src = img2base64(_figure2bytes(
uncertainties_plot(prob, conditional_probability_data)))
return {
"model": model,
# General results across all groups
"model": model_group.exposure_models[0],
"times": list(times),
"exposed_presence_intervals": exposed_presence_intervals,
"short_range_intervals": short_range_intervals,
"short_range_expirations": short_range_expirations,
"concentrations": concentrations,
"concentrations_zoomed": lower_concentrations,
"cumulative_doses": list(cumulative_doses),
"long_range_cumulative_doses": list(long_range_cumulative_doses),
"prob_inf": prob.mean(),
"prob_inf_sd": prob.std(),
"prob_dist": list(prob),
"prob_hist_count": list(prob_dist_count),
"prob_hist_bins": list(prob_dist_bins),
"prob_probabilistic_exposure": prob_probabilistic_exposure,
"expected_new_cases": expected_new_cases,
"uncertainties_plot_src": uncertainties_plot_src,
"CO2_concentrations": CO2_concentrations,
"conditional_probability_data": conditional_probability_data,
# Group specific results
"groups": results_per_group,
}
@ -339,9 +448,7 @@ def calculate_vl_scenarios_percentiles(model: mc.ExposureModel) -> typing.Dict[s
scenarios = {}
for percentil in (0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99):
vl = np.quantile(viral_load, percentil)
specific_vl_scenario = dataclass_utils.nested_replace(model,
{'concentration_model.infected.virus.viral_load_in_sputum': vl}
)
specific_vl_scenario = dataclass_utils.nested_replace(model, {'concentration_model.infected.virus.viral_load_in_sputum': vl})
scenarios[str(vl)] = np.mean(
specific_vl_scenario.infection_probability())
return {
@ -350,7 +457,12 @@ def calculate_vl_scenarios_percentiles(model: mc.ExposureModel) -> typing.Dict[s
def manufacture_alternative_scenarios(form: VirusFormData) -> typing.Dict[str, mc.ExposureModel]:
scenarios = {}
"""
Generates the data structure containing all the alternative scenarios.
It is only compatible with single group occupancy models, therefore
it returns an ExposureModel object and not an ExposureModelGroup.
"""
scenarios: typing.Dict[str, models.ExposureModelGroup] = {}
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 ==
@ -401,33 +513,39 @@ def manufacture_alternative_scenarios(form: VirusFormData) -> typing.Dict[str, m
scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model()
if not (form.mask_wearing_option == 'mask_off' and form.ventilation_type == 'no_ventilation'):
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model()
else:
# When dynamic occupancy is defined, the replace of total people is useless - the expected number of new cases is not calculated.
if form.occupancy_format == 'static':
no_short_range_alternative = dataclass_utils.replace(form, short_range_interactions=[], total_people=form.total_people - form.short_range_occupants)
elif form.occupancy_format == 'dynamic':
for occ in form.dynamic_exposed_occupancy: # Update the number of exposed people with long-range exposure
if occ['total_people'] > form.short_range_occupants: occ['total_people'] = max(0, occ['total_people'] - form.short_range_occupants)
no_short_range_alternative = dataclass_utils.replace(form, short_range_interactions=[], dynamic_exposed_occupancy=form.dynamic_exposed_occupancy)
# Adjust the number of exposed people with long-range exposure based on short-range interactions
if not form.occupancy:
no_short_range_alternative = dataclass_utils.replace(form, short_range_interactions={}, total_people=form.total_people - form.short_range_occupants)
else:
for group_id, group in form.occupancy.items():
# Check if the group exists in short-range interactions
if group_id in form.short_range_interactions:
short_range_count = form.short_range_occupants
total_people = group['total_people']
if total_people > short_range_count > 0:
# Update the total_people with the adjusted value
group['total_people'] = max(0, total_people - short_range_count)
no_short_range_alternative = dataclass_utils.replace(form, short_range_interactions={}, occupancy=form.occupancy)
scenarios['Base scenario without short-range interactions'] = no_short_range_alternative.build_mc_model()
for scenario_name, scenario in scenarios.items():
scenarios[scenario_name] = scenario.exposure_models[0] # type: ignore
return scenarios
def scenario_statistics(
mc_model: mc.ExposureModel,
sample_times: typing.List[float],
static_occupancy: bool,
compute_prob_exposure: bool,
):
model = mc_model.build_model(
size=mc_model.data_registry.monte_carlo['sample_size'])
size=mc_model.data_registry.monte_carlo['sample_size']
)
return {
'probability_of_infection': np.mean(model.infection_probability()),
'expected_new_cases': np.mean(model.expected_new_cases()) if static_occupancy else None,
'expected_new_cases': np.mean(model.expected_new_cases()),
'concentrations': [
np.mean(model.concentration(time))
for time in sample_times
@ -441,32 +559,30 @@ def comparison_report(
report_data: typing.Dict[str, typing.Any],
scenarios: typing.Dict[str, mc.ExposureModel],
executor_factory: typing.Callable[[], concurrent.futures.Executor],
):
):
if (form.short_range_option == "short_range_no"):
statistics = {
'Current scenario': {
'probability_of_infection': report_data['prob_inf'],
'expected_new_cases': report_data['expected_new_cases'],
'concentrations': report_data['concentrations'],
'probability_of_infection': report_data['groups']['group_1']['prob_inf'],
'expected_new_cases': report_data['groups']['group_1']['expected_new_cases'],
'concentrations': report_data['groups']['group_1']['concentrations'],
}
}
else:
statistics = {}
static_occupancy = form.occupancy_format == "static"
compute_prob_exposure = form.short_range_option == "short_range_yes" and form.exposure_option == "p_probabilistic_exposure" and static_occupancy
compute_prob_exposure = form.short_range_option == "short_range_yes" and form.exposure_option == "p_probabilistic_exposure" and not form.occupancy
with executor_factory() as executor:
results = executor.map(
scenario_statistics,
scenarios.values(),
[report_data['times']] * len(scenarios),
[static_occupancy] * len(scenarios),
[compute_prob_exposure] * len(scenarios),
timeout=60,
)
for (name, model), model_stats in zip(scenarios.items(), results):
for (name, _), model_stats in zip(scenarios.items(), results):
statistics[name] = model_stats
return {
@ -474,7 +590,9 @@ def comparison_report(
}
def alternative_scenarios_data(form: VirusFormData, report_data: typing.Dict[str, typing.Any], executor_factory: typing.Callable[[], concurrent.futures.Executor]) -> typing.Dict[str, typing.Any]:
def alternative_scenarios_data(form: VirusFormData,
report_data: typing.Dict[str, typing.Any],
executor_factory: typing.Callable[[], concurrent.futures.Executor]) -> typing.Dict[str, typing.Any]:
alternative_scenarios: typing.Dict[str, typing.Any] = manufacture_alternative_scenarios(form=form)
return {
'alternative_scenarios': comparison_report(form=form, report_data=report_data, scenarios=alternative_scenarios, executor_factory=executor_factory)

View file

@ -28,8 +28,6 @@ class CO2FormData(FormData):
# and the defaults in any html form must not be contradictory.
_DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = {
'CO2_data': '{}',
'dynamic_infected_occupancy': '[]',
'dynamic_exposed_occupancy': '[]',
'exposed_coffee_break_option': 'coffee_break_0',
'exposed_coffee_duration': 5,
'exposed_finish': '17:30',
@ -47,7 +45,7 @@ class CO2FormData(FormData):
'infected_lunch_start': '12:30',
'infected_people': 1,
'infected_start': '08:30',
'occupancy_format': 'static',
'occupancy': '{}',
'room_capacity': None,
'room_volume': NO_DEFAULT,
'specific_breaks': '{}',
@ -74,7 +72,7 @@ class CO2FormData(FormData):
raise TypeError(f'The room capacity should be a valid integer (> 0). Got {self.room_capacity}.')
# Validate specific inputs - breaks (exposed and infected)
if self.specific_breaks != {}:
if self.specific_breaks != {} and not self.occupancy:
if type(self.specific_breaks) is not dict:
raise TypeError('The specific breaks should be in a dictionary.')
@ -188,11 +186,6 @@ class CO2FormData(FormData):
return img2base64(_figure2bytes(fig)), vent_plot_data
def population_present_changes(self, infected_presence: models.Interval, exposed_presence: models.Interval) -> typing.List[float]:
state_change_times = set(infected_presence.transition_times())
state_change_times.update(exposed_presence.transition_times())
return sorted(state_change_times)
def ventilation_transition_times(self) -> typing.Tuple[float]:
'''
Check if the last time from the input data is
@ -207,45 +200,16 @@ class CO2FormData(FormData):
return tuple(vent_states)
def build_model(self, sample_size = None) -> models.CO2DataModel:
# Build a simple infected and exposed population for the case when presence
# intervals and number of people are dynamic. Activity type is not needed.
if self.occupancy_format == 'dynamic':
if isinstance(self.dynamic_infected_occupancy, typing.List) and len(self.dynamic_infected_occupancy) > 0:
infected_people = self.generate_dynamic_occupancy(self.dynamic_infected_occupancy)
infected_presence = None
else:
raise TypeError(f'If dynamic occupancy is selected, a populated list of occupancy intervals is expected. Got "{self.dynamic_infected_occupancy}".')
if isinstance(self.dynamic_exposed_occupancy, typing.List) and len(self.dynamic_exposed_occupancy) > 0:
exposed_people = self.generate_dynamic_occupancy(self.dynamic_exposed_occupancy)
exposed_presence = None
else:
raise TypeError(f'If dynamic occupancy is selected, a populated list of occupancy intervals is expected. Got "{self.dynamic_exposed_occupancy}".')
else:
infected_people = self.infected_people
exposed_people = self.total_people - self.infected_people
infected_presence = self.infected_present_interval()
exposed_presence = self.exposed_present_interval()
infected_population = models.SimplePopulation(
number=infected_people,
presence=infected_presence,
activity=None, # type: ignore
)
exposed_population=models.SimplePopulation(
number=exposed_people,
presence=exposed_presence,
activity=None, # type: ignore
)
all_state_changes=self.population_present_changes(infected_population.presence_interval(),
exposed_population.presence_interval())
total_people = [infected_population.people_present(stop) + exposed_population.people_present(stop)
for _, stop in zip(all_state_changes[:-1], all_state_changes[1:])]
"""
Builds a CO2 data model that considers data
from the defined population groups.
"""
occupancy = self.build_CO2_piecewise()
return models.CO2DataModel(
data_registry=self.data_registry,
room=models.Room(volume=self.room_volume, capacity=self.room_capacity),
occupancy=models.IntPiecewiseConstant(transition_times=tuple(all_state_changes), values=tuple(total_people)),
occupancy=occupancy,
ventilation_transition_times=self.ventilation_transition_times(),
times=self.CO2_data['times'],
CO2_concentrations=self.CO2_data['CO2'],

View file

@ -16,8 +16,6 @@ DEFAULTS = {
'calculator_version': NO_DEFAULT,
'ceiling_height': 0.,
'conditional_probability_viral_loads': False,
'dynamic_exposed_occupancy': '[]',
'dynamic_infected_occupancy': '[]',
'event_month': 'January',
'exposed_coffee_break_option': 'coffee_break_0',
'exposed_coffee_duration': 5,
@ -49,14 +47,14 @@ DEFAULTS = {
'mask_type': 'Type I',
'mask_wearing_option': 'mask_off',
'mechanical_ventilation_type': 'not-applicable',
'occupancy_format': 'static',
'occupancy': '{}',
'opening_distance': 0.,
'precise_activity': '{}',
'room_heating_option': False,
'room_number': NO_DEFAULT,
'room_volume': 0.,
'sensor_in_use': '',
'short_range_interactions': '[]',
'short_range_interactions': '{}',
'short_range_occupants': 0,
'short_range_option': 'short_range_no',
'simulation_name': NO_DEFAULT,
@ -82,7 +80,8 @@ DEFAULTS = {
# ------------------ Validation ----------------------
COFFEE_OPTIONS_INT = {'coffee_break_0': 0, 'coffee_break_1': 1,
'coffee_break_2': 2, 'coffee_break_4': 4}
'coffee_break_2': 2, 'coffee_break_3': 3,
'coffee_break_4': 4}
CONFIDENCE_LEVEL_OPTIONS = {'confidence_low': 10,
'confidence_medium': 5, 'confidence_high': 2}
MECHANICAL_VENTILATION_TYPES = {

View file

@ -36,15 +36,11 @@ class FormData:
infected_lunch_start: minutes_since_midnight #Used if infected_dont_have_breaks_with_exposed
infected_start: minutes_since_midnight
infected_people: int
occupancy_format: str
occupancy: dict
room_volume: float
specific_breaks: dict
total_people: int
# Dynamic occupancy inputs
dynamic_exposed_occupancy: list
dynamic_infected_occupancy: list
data_registry: DataRegistry
_DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = DEFAULTS
@ -97,117 +93,372 @@ class FormData:
form_dict.pop(attr)
return form_dict
def validate_population_parameters(self):
# Static occupancy is defined.
if self.occupancy_format == 'static':
# Validate number of infected <= number of total people
if self.infected_people >= self.total_people:
raise ValueError(
'Number of infected people cannot be greater or equal to the number of total people.')
def validate_group_presence_input(self, group_id: str, group_presence: typing.List):
"""
When occupancy is defined, this method validates the
presence times within an occupancy group.
"""
# Checks if the presence input is a valid list
if not isinstance(group_presence, list):
raise TypeError(f'The "presence" parameter in occupancy group "{group_id}" should be a valid list. Got {type(group_presence)}.')
# Checks if the presence input is populated
if len(group_presence) == 0:
raise TypeError(f'The "presence" parameter in occupancy group "{group_id}" should be a valid, non-empty list. Got {group_presence}.')
# Already processed presence intervals for overlap checking
existing_occupancy_presence_interval: typing.List = []
for presence_interval in group_presence:
# Checks if each presence entry is a valid dict
if not isinstance(presence_interval, typing.Dict):
raise TypeError(f'Each presence interval should be a valid dictionary. Got {type(presence_interval)} in occupancy group "{group_id}".')
# Validate time intervals selected by user
time_intervals = [
['exposed_start', 'exposed_finish'],
['infected_start', 'infected_finish'],
]
if self.exposed_lunch_option:
time_intervals.append(
['exposed_lunch_start', 'exposed_lunch_finish'])
if self.infected_dont_have_breaks_with_exposed and self.infected_lunch_option:
time_intervals.append(
['infected_lunch_start', 'infected_lunch_finish'])
# Parameters in each presence entry
presence_params = presence_interval.keys()
for start_name, end_name in time_intervals:
start = getattr(self, start_name)
end = getattr(self, end_name)
if start > end:
# Checks for the "start_time" and "finish_time" params
for time_param in ["start_time", "finish_time"]:
if time_param not in presence_params:
raise TypeError(f'Missing "{time_param}" key in "presence" parameter of occupancy group "{group_id}".'
f' Got keys: {", ".join(presence_params)}.')
time_value = presence_interval[time_param]
if not re.compile("^(2[0-3]|[01]?[0-9]):([0-5]?[0-9])$").match(time_value):
raise ValueError(f'Invalid time format found in "presence" parameter of occupancy group "{group_id}". '
f'Expected HH:MM, got {time_value}.')
if presence_interval["finish_time"] <= presence_interval["start_time"]:
raise ValueError(f'Inconsistent times found in "presence" parameter of occupancy group "{group_id}".'
f'The "{presence_interval}" entry has a start time ("{presence_interval["start_time"]}") '
f'after the finish time ("{presence_interval["finish_time"]}").')
# Checks for the occupancy group uniqueness of intervals
self.check_overlap(presence_interval, existing_occupancy_presence_interval)
existing_occupancy_presence_interval.append(presence_interval)
def validate_short_range_interaction_input(self, group_id: str, sr_interactions: typing.List):
"""
Validates the short-range interactions within an occupancy group.
"""
# Within a group, checks if the short-range input is a valid list
if not isinstance(sr_interactions, list):
raise TypeError(f'The short-range interactions in occupancy group "{group_id}" should be defined in a valid list. Got {type(sr_interactions)}.')
# Within a group, checks if the list is populated
if len(sr_interactions) == 0:
raise TypeError(f'The short-range interactions in occupancy group "{group_id}" should be a non-empty list. Got {type(sr_interactions)}.')
# Already processed interactions for overlap checking
existing_sr_interaction_interval: typing.List = []
for interaction in sr_interactions:
# Checks if each interaction is a valid dict
if not isinstance(interaction, typing.Dict):
raise TypeError(f'Each short-range interaction should be a dictionary. Got {type(interaction)} in occupancy group "{group_id}".')
# Parameters in each short-range interaction
interaction_params = interaction.keys()
# Checks for the expiration key and its constraints
if "expiration" not in interaction_params:
raise TypeError(f'Missing "expiration" key in short-range interaction for occupancy group "{group_id}". Got keys: {", ".join(interaction_params)}.')
else:
expiration = interaction["expiration"]
if expiration not in list(self.data_registry.expiration_particle['expiration_BLO_factors'].keys()): # type: ignore
raise ValueError(f'Invalid expiration value in short-range interaction for occupancy group "{group_id}". Got "{expiration}".')
# Checks for start_time key and its format
if "start_time" not in interaction_params:
raise TypeError(f'Missing "start_time" key in short-range interaction for occupancy group "{group_id}". Got keys: {", ".join(interaction_params)}.')
else:
start_time = interaction["start_time"]
if not re.compile("^(2[0-3]|[01]?[0-9]):([0-5]?[0-9])$").match(start_time):
raise ValueError(f'Invalid time format for start_time in short-range interaction for occupancy group "{group_id}". Expected HH:MM, got {start_time}.')
# Checks for "duration" and its format
if "duration" not in interaction_params:
raise TypeError(f'Missing "duration" key in short-range interaction for occupancy group "{group_id}". Got keys: {", ".join(interaction_params)}.')
else:
duration = interaction["duration"]
if duration < 0:
raise ValueError(f'The duration value in short-range interaction for occupancy group "{group_id}" should be a non-negative integer. Got {duration}.')
# Legacy usage - occupancy input is not defined (default empty dict)
if not self.occupancy:
# It means that we have a single exposure model
lr_start = min(self.infected_start, self.exposed_start)/60
lr_stop = max(self.infected_finish, self.exposed_finish)/60
if not self.check_interaction_is_within_long_range(interaction, existing_sr_interaction_interval, lr_start, lr_stop):
raise ValueError(
f"{start_name} must be less than {end_name}. Got {start} and {end}.")
f'Short-range interactions must occur during simulation time. Got {interaction} in occupancy group "{group_id}".'
)
# Add interaction to the list of already processed interactions
existing_sr_interaction_interval.append(interaction)
else:
# Find corresponding exposure group
occupancy_group_obj = next(
(occupancy_value for occupancy_key, occupancy_value in self.occupancy.items()
if occupancy_key == group_id),
None
)
def validate_lunch(start, finish):
lunch_start = getattr(self, f'{population}_lunch_start')
lunch_finish = getattr(self, f'{population}_lunch_finish')
return (start <= lunch_start <= finish and
start <= lunch_finish <= finish)
def get_lunch_mins(population):
lunch_mins = 0
if getattr(self, f'{population}_lunch_option'):
lunch_mins = getattr(
self, f'{population}_lunch_finish') - getattr(self, f'{population}_lunch_start')
return lunch_mins
def get_coffee_mins(population):
coffee_mins = 0
if getattr(self, f'{population}_coffee_break_option') != 'coffee_break_0':
coffee_mins = COFFEE_OPTIONS_INT[getattr(
self, f'{population}_coffee_break_option')] * getattr(self, f'{population}_coffee_duration')
return coffee_mins
def get_activity_mins(population):
return getattr(self, f'{population}_finish') - getattr(self, f'{population}_start')
populations = [
'exposed', 'infected'] if self.infected_dont_have_breaks_with_exposed else ['exposed']
for population in populations:
# Validate lunch time within the activity times.
if (getattr(self, f'{population}_lunch_option') and
not validate_lunch(getattr(self, f'{population}_start'), getattr(
self, f'{population}_finish'))
):
if occupancy_group_obj is None:
raise ValueError(
f"{population} lunch break must be within presence times."
f'Occupancy group "{group_id}" referenced in short-range interactions was not found in the occupancy input.'
)
# Length of breaks < length of activity
if (get_lunch_mins(population) + get_coffee_mins(population)) >= get_activity_mins(population):
is_within_any_lr = False
for presence in occupancy_group_obj['presence']:
# Check for correct timing within long-range exposure and overlaps with existing interactions
lr_start = time_string_to_minutes(presence['start_time'])/60
lr_stop = time_string_to_minutes(presence['finish_time'])/60
# Flag to check if interaction falls within any long-range exposure interval
if self.check_interaction_is_within_long_range(interaction, existing_sr_interaction_interval, lr_start, lr_stop):
is_within_any_lr = True
# Add interaction to the list of processed interactions if within long-range
existing_sr_interaction_interval.append(interaction)
# If the interaction does not fall within any presence interval of the occupancy group, raise an error
if not is_within_any_lr:
raise ValueError(
f"Length of breaks >= Length of {population} presence."
f'Short-range interaction {interaction} does not fall within any presence interval in occupancy group "{group_id}".'
)
def validate_dynamic_exposed_format(self, group_id: str, group: typing.Dict):
"""
Validates the expected keywords for the occupancy input.
"""
# Parameters in each presence entry
group_params = group.keys()
for attr_name, valid_set in [('exposed_coffee_break_option', COFFEE_OPTIONS_INT),
('infected_coffee_break_option', COFFEE_OPTIONS_INT)]:
if getattr(self, attr_name) not in valid_set:
raise ValueError(
f"{getattr(self, attr_name)} is not a valid value for {attr_name}")
# Dynamic occupancy is defined.
elif self.occupancy_format == 'dynamic':
for dynamic_format in (self.dynamic_infected_occupancy, self.dynamic_exposed_occupancy):
for occupancy in dynamic_format:
# Check if each occupancy entry is a dictionary
if not isinstance(occupancy, typing.Dict):
raise TypeError(f'Each occupancy entry should be in a dictionary format. Got "{type(occupancy)}".')
# Check for required keys in each occupancy entry
dict_keys = list(occupancy.keys())
if "total_people" not in dict_keys:
raise TypeError(f'Unable to fetch "total_people" key. Got "{dict_keys}".')
else:
value = occupancy["total_people"]
# Check if the value is a non-negative integer
if not isinstance(value, int):
raise ValueError(f'Total number of people should be integer. Got "{type(value)}".')
elif not value >= 0:
raise ValueError(f'Total number of people should be non-negative. Got "{value}".')
if "start_time" not in dict_keys:
raise TypeError(f'Unable to fetch "start_time" key. Got "{dict_keys}".')
if "finish_time" not in dict_keys:
raise TypeError(f'Unable to fetch "finish_time" key. Got "{dict_keys}".')
# Validate time format for start_time and finish_time
for time_key in ["start_time", "finish_time"]:
time = occupancy[time_key]
if not re.compile("^(2[0-3]|[01]?[0-9]):([0-5]?[0-9])$").match(time):
raise TypeError(f'Wrong time format - "HH:MM". Got "{time}".')
# Total people input
if 'total_people' not in group_params:
raise TypeError(f'Missing "total_people" key in occupancy group "{group_id}". Got keys: {", ".join(group_params)}.')
else:
raise ValueError(f"'{self.occupancy_format}' is not a valid value for 'self.occupancy_format'. Accepted values are 'static' or 'dynamic'.")
total_people = group['total_people']
if not isinstance(total_people, int) or total_people < 0:
raise ValueError(f'The "total_people" input in occupancy group "{group_id}" should be a non-negative integer. Got {total_people}.')
# Infected people input
if 'infected' not in group_params:
raise TypeError(f'Missing "infected" key in occupancy group "{group_id}". Got keys: {", ".join(group_params)}.')
else:
infected = group['infected']
if not isinstance(infected, int) or infected < 0:
raise ValueError(f'The infected input in occupancy group "{group_id}" should be a non-negative integer. Got {infected}.')
elif infected > total_people: # Validate number of infected <= number of total people
raise ValueError(f'The number of infected people ({infected}) cannot be greater than the total people ({total_people}).')
# Presence input
if 'presence' not in group_params:
raise TypeError(f'Missing "presence" key in occupancy group "{group_id}". Got keys: {", ".join(group_params)}.')
def get_start_and_finish_time(self, entry: typing.Dict) -> typing.Tuple:
entry_start = time_string_to_minutes(entry["start_time"])/60
if "finish_time" in entry:
entry_finish = time_string_to_minutes(entry["finish_time"])/60
else:
entry_finish = entry_start + entry['duration']/60
return entry_start, entry_finish
def check_interaction_is_within_long_range(self, interaction: typing.Dict, existing_interactions: typing.List,
lr_start: float, lr_stop: float) -> bool:
"""
Check if the short-range interaction falls within the long-range exposure time.
Check if the short-range interaction given as input overlaps with any already
existing interactions for the same occupancy group.
"""
interaction_start, interaction_finish = self.get_start_and_finish_time(interaction)
# Check if the SR interaction is within the LR exposure time
if lr_start <= interaction_start <= lr_stop and lr_start <= interaction_finish <= lr_stop:
# Check the overlap with already existing interactions
self.check_overlap(interaction, existing_interactions)
return True
return False
def check_overlap(self, entry: typing.Dict, existing_entries: typing.List):
"""
Check if an entry overlaps with an already existing entry
by comparing the start and finish times of all entries.
"""
entry_start, entry_finish = self.get_start_and_finish_time(entry)
for existing_entry in existing_entries:
existing_entry_start, existing_entry_finish = self.get_start_and_finish_time(existing_entry)
# Check for overlap
if (entry_start < existing_entry_finish and existing_entry_start < entry_finish):
raise ValueError(
f'Overlap detected: The entry {entry} overlaps with '
f'an already existing entry ({existing_entry}).'
)
# In case no exception is raised, simply returns
return
def validate_population_parameters(self):
"""
Validate required parameters for dynamic inputs.
"""
if isinstance(self.occupancy, typing.Dict):
# Legacy usage - occupancy input is not defined (default empty dict)
if not self.occupancy:
# Validate number of infected <= number of total people
if self.infected_people >= self.total_people:
raise ValueError(
'Number of infected people cannot be greater or equal to the number of total people.')
# Validate time intervals selected by user
time_intervals = [
['exposed_start', 'exposed_finish'],
['infected_start', 'infected_finish'],
]
if self.exposed_lunch_option:
time_intervals.append(
['exposed_lunch_start', 'exposed_lunch_finish'])
if self.infected_dont_have_breaks_with_exposed and self.infected_lunch_option:
time_intervals.append(
['infected_lunch_start', 'infected_lunch_finish'])
for start_name, end_name in time_intervals:
start = getattr(self, start_name)
end = getattr(self, end_name)
if start > end:
raise ValueError(
f"{start_name} must be less than {end_name}. Got {start} and {end}.")
def validate_lunch(start, finish):
lunch_start = getattr(self, f'{population}_lunch_start')
lunch_finish = getattr(self, f'{population}_lunch_finish')
return (start <= lunch_start <= finish and
start <= lunch_finish <= finish)
def get_lunch_mins(population):
lunch_mins = 0
if getattr(self, f'{population}_lunch_option'):
lunch_mins = getattr(
self, f'{population}_lunch_finish') - getattr(self, f'{population}_lunch_start')
return lunch_mins
def get_coffee_mins(population):
coffee_mins = 0
if getattr(self, f'{population}_coffee_break_option') != 'coffee_break_0':
coffee_mins = COFFEE_OPTIONS_INT[getattr(
self, f'{population}_coffee_break_option')] * getattr(self, f'{population}_coffee_duration')
return coffee_mins
def get_activity_mins(population):
return getattr(self, f'{population}_finish') - getattr(self, f'{population}_start')
populations = [
'exposed', 'infected'] if self.infected_dont_have_breaks_with_exposed else ['exposed']
for population in populations:
# Validate lunch time within the activity times.
if (getattr(self, f'{population}_lunch_option') and
not validate_lunch(getattr(self, f'{population}_start'), getattr(
self, f'{population}_finish'))
):
raise ValueError(
f"{population} lunch break must be within presence times."
)
# Length of breaks < length of activity
if (get_lunch_mins(population) + get_coffee_mins(population)) >= get_activity_mins(population):
raise ValueError(
f"Length of breaks >= Length of {population} presence."
)
for attr_name, valid_set in [('exposed_coffee_break_option', COFFEE_OPTIONS_INT),
('infected_coffee_break_option', COFFEE_OPTIONS_INT)]:
if getattr(self, attr_name) not in valid_set:
raise ValueError(
f"{getattr(self, attr_name)} is not a valid value for {attr_name}")
# Occupancy input is defined
else:
# Checks if occupancy input is a valid dict
if self.occupancy and isinstance(self.occupancy, typing.Dict):
# The key is the actual identifier
for group_id, group in self.occupancy.items():
# For each group, validate input format
self.validate_dynamic_exposed_format(group_id, group)
# ...as well as the respective presence input
self.validate_group_presence_input(group_id, group['presence'])
else:
raise TypeError(f'The "occupancy" input should be a valid dictionary. Got {self.occupancy}.')
def validate(self):
raise NotImplementedError("Subclass must implement")
def build_model(self, sample_size: typing.Optional[int] = None):
raise NotImplementedError("Subclass must implement")
def population_present_changes(self, transition_times_list: typing.Tuple[float, ...]) -> typing.List[float]:
"""
Returns a sorted list of unique state changes on
a population list.
"""
return sorted(set(transition_times_list))
def convert_interval_to_piecewise(self, interval: models.SpecificInterval, value: int):
"""
Converts an Interval and a single value to an IntPiecewiseConstant.
"""
transition_times = []
values = []
for start, end in interval.present_times:
transition_times.extend([start, end])
values.extend([value, 0])
# Drop the last value (0) to match number of intervals
if values:
values.pop()
return models.IntPiecewiseConstant(
transition_times=tuple(transition_times),
values=tuple(values),
)
def build_CO2_piecewise(self):
"""
Builds a simple IntPiecewiseConstant for the different
population groups that are defined.
"""
# Legacy usage - occupancy input is not defined (default empty dict)
if not self.occupancy:
infected_occupancy = self.convert_interval_to_piecewise(
interval=self.infected_present_interval(),
value=self.infected_people,
)
exposed_occupancy = self.convert_interval_to_piecewise(
interval=self.exposed_present_interval(),
value=self.total_people - self.infected_people,
)
total_models = [infected_occupancy, exposed_occupancy]
else:
infected_occupancy = self.generate_infected_occupancy(self.occupancy)
total_models = [infected_occupancy]
# For all state changes
for group in self.occupancy.values():
model_piecewise = self.convert_interval_to_piecewise(
interval=self.generate_exposed_presence(group['presence']),
value=group['total_people'] - group['infected']
)
total_models.append(model_piecewise)
# Get all state change times from combined populations
all_state_changes = self.population_present_changes([t for model in total_models for t in model.transition_times])
# Compute total people at each state change
total_people = []
for _, stop in zip(all_state_changes[:-1], all_state_changes[1:]):
total_people_in_group = sum(model.value(stop) for model in total_models)
total_people.append(total_people_in_group)
return models.IntPiecewiseConstant(
transition_times=tuple(all_state_changes),
values=tuple(total_people)
)
def _compute_breaks_in_interval(self, start, finish, n_breaks, duration) -> models.BoundarySequence_t:
break_delay = ((finish - start) -
@ -417,26 +668,65 @@ class FormData:
self.exposed_start, self.exposed_finish,
breaks=breaks,
)
def generate_exposed_presence(self, presence: typing.List) -> models.SpecificInterval:
"""
Creates a model to represent exposed occupancy over time.
"""
exposed_intervals = []
# Sort occupancy entries by start_time to ensure proper ordering
presence_sorted = sorted(
presence, key=lambda x: time_string_to_minutes(x['start_time'])
)
for period in presence_sorted:
start_time = time_string_to_minutes(period['start_time']) / 60
finish_time = time_string_to_minutes(period['finish_time']) / 60
exposed_intervals.append((start_time, finish_time))
return models.SpecificInterval(tuple(exposed_intervals))
def generate_dynamic_occupancy(self, dynamic_occupancy: typing.List[typing.Dict[str, typing.Any]]):
transition_times = []
values = []
for occupancy in dynamic_occupancy:
start_time = time_string_to_minutes(occupancy['start_time'])/60
finish_time = time_string_to_minutes(occupancy['finish_time'])/60
transition_times.extend([start_time, finish_time])
values.append(occupancy['total_people'])
def generate_infected_occupancy(self, occupancy: typing.Dict) -> models.IntPiecewiseConstant:
"""
Creates a model to represent infected occupancy over time.
"""
transition_times = set()
infected_intervals = []
unique_transition_times_sorted = np.array(sorted(set(transition_times)))
if len(values) != len(unique_transition_times_sorted) - 1:
raise ValueError("Cannot compute dynamic occupancy with the provided inputs.")
# Extract presence data
for group in occupancy.values():
infected = group["infected"]
for period in group["presence"]:
start_time = time_string_to_minutes(period['start_time']) / 60
finish_time = time_string_to_minutes(period['finish_time']) / 60
transition_times.add(start_time) # unique time points
transition_times.add(finish_time) # unique time points
infected_intervals.append((start_time, finish_time, infected))
population_occupancy: models.IntPiecewiseConstant = models.IntPiecewiseConstant(
transition_times=tuple(unique_transition_times_sorted),
values=tuple(values)
# Sort transition times
sorted_transition_times = list(sorted(transition_times))
# Values for each time segment
raw_values = [
sum(people for start, end, people in infected_intervals if start <= t1 < end)
for t1 in sorted_transition_times[:-1]
]
# Merge consecutive intervals with the same infected count
opt_times = [sorted_transition_times[0]]
opt_values = [raw_values[0]]
for i in range(1, len(raw_values)):
if raw_values[i] != opt_values[-1]:
opt_times.append(sorted_transition_times[i])
opt_values.append(raw_values[i])
# Ensure the last time is included
opt_times.append(sorted_transition_times[-1])
return models.IntPiecewiseConstant(
transition_times=tuple(opt_times),
values=tuple(opt_values)
)
return population_occupancy
def _hours2timestring(hours: float):
@ -450,6 +740,8 @@ def time_string_to_minutes(time: str) -> minutes_since_midnight:
:param time: A string of the form "HH:MM" representing a time of day
:return: The number of minutes between 'time' and 00:00
"""
if not (0 <= int(time[:2]) <= 23) or not (0 <= int(time[3:]) <= 59):
raise ValueError(f"Wrong time format. Got {time}")
return minutes_since_midnight(60 * int(time[:2]) + int(time[3:]))

View file

@ -4,6 +4,7 @@ import logging
import typing
import re
from collections import defaultdict
import numpy as np
from caimira import __version__ as calculator_version
@ -11,7 +12,7 @@ from ..form_validator import FormData, cast_class_fields, time_string_to_minutes
from ..defaults import (DEFAULTS, CONFIDENCE_LEVEL_OPTIONS,
MECHANICAL_VENTILATION_TYPES, MASK_WEARING_OPTIONS, MONTH_NAMES, VACCINE_BOOSTER_TYPE, VACCINE_TYPE,
VENTILATION_TYPES, VOLUME_TYPES, WINDOWS_OPENING_REGIMES, WINDOWS_TYPES)
from ...models import models, data, monte_carlo as mc
from ...models import models, data, dataclass_utils, monte_carlo as mc
from ...models.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions, short_range_distances
from ...models.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions, short_range_expiration_distributions
@ -51,7 +52,7 @@ class VirusFormData(FormData):
room_heating_option: bool
room_number: str
sensor_in_use: str
short_range_interactions: list
short_range_interactions: dict
short_range_occupants: int
short_range_option: str
simulation_name: str
@ -73,7 +74,7 @@ class VirusFormData(FormData):
_DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = DEFAULTS
def validate(self):
# Validate population parameters
# Validate population parameters
self.validate_population_parameters()
validation_tuples = [('activity_type', self.data_registry.population_scenario_activity.keys()),
@ -200,30 +201,37 @@ class VirusFormData(FormData):
f'The sum of all respiratory activities should be 100. Got {total_percentage}.')
# Validate number of people with short-range interactions
if self.occupancy_format == "static": max_occupants_for_sr = self.total_people - self.infected_people
else: max_occupants_for_sr = np.max(np.array([entry["total_people"] for entry in self.dynamic_exposed_occupancy]))
if not self.occupancy:
# Legacy usage - occupancy input is not defined (default empty dict)
max_occupants_for_sr = self.total_people - self.infected_people
else:
max_occupants_for_sr = 0
for group_id, group in self.occupancy.items():
exposed_occupants_in_group = group['total_people'] - group['infected']
max_occupants_for_sr = max(max_occupants_for_sr, exposed_occupants_in_group)
if self.short_range_occupants > max_occupants_for_sr:
raise ValueError(
f'The total number of occupants having short-range interactions ({self.short_range_occupants}) should be lower than the exposed population ({max_occupants_for_sr}).'
)
# Validate short-range interactions interval
if self.short_range_option == "short_range_yes":
for interaction in self.short_range_interactions:
# Check if presence is within long-range exposure
presence = self.short_range_interval(interaction)
if (self.occupancy_format == 'dynamic'):
long_range_start = min(time_string_to_minutes(self.dynamic_infected_occupancy[0]['start_time']),
time_string_to_minutes(self.dynamic_exposed_occupancy[0]['start_time']))
long_range_stop = max(time_string_to_minutes(self.dynamic_infected_occupancy[-1]['finish_time']),
time_string_to_minutes(self.dynamic_exposed_occupancy[-1]['finish_time']))
if isinstance(self.short_range_interactions, dict):
# Checks if short_range_interactions input is not empty
if len(self.short_range_interactions) == 0:
raise ValueError(f'When short_range_option input is set to "{self.short_range_option}", the short_range_interactions input should not be empty. Got {self.short_range_interactions}.')
# Checks that the number of groups in the short_range_interactions input is less or equal than those defined in the occupancy
elif not self.occupancy:
# Legacy usage - occupancy input is not defined (default empty dict)
if len(self.short_range_interactions) > 1:
raise ValueError(f'Incompatible number of occupancy groups in the short_range_interactions input. Got {len(self.short_range_interactions)} groups when the maximum is 1.')
else:
long_range_start = min(self.infected_start, self.exposed_start)
long_range_stop = max(self.infected_finish, self.exposed_finish)
if not (long_range_start/60 <= presence.present_times[0][0] <= long_range_stop/60 and
long_range_start/60 <= presence.present_times[0][-1] <= long_range_stop/60):
raise ValueError(f"Short-range interactions should be defined during simulation time. Got {interaction}")
if len(self.short_range_interactions) > len(self.occupancy):
raise ValueError(f'Incompatible number of occupancy groups in the short_range_interactions input. Got {len(self.short_range_interactions)} groups when the maximum is {len(self.occupancy)} (from the occupancy input).')
for group_id, interactions in self.short_range_interactions.items():
self.validate_short_range_interaction_input(group_id, interactions)
def initialize_room(self) -> models.Room:
# Initializes room with volume either given directly or as product of area and height
if self.volume_type == 'room_volume_explicit':
@ -243,69 +251,98 @@ class VirusFormData(FormData):
return models.Room(volume=volume, inside_temp=models.PiecewiseConstant((0, 24), (inside_temp,)), humidity=humidity) # type: ignore
def build_mc_model(self) -> mc.ExposureModel:
room = self.initialize_room()
def build_mc_model(self) -> mc.ExposureModelGroup:
room: models.Room = self.initialize_room()
ventilation: models._VentilationBase = self.ventilation()
infected_population: models.InfectedPopulation = 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(
data_registry=self.data_registry,
expiration=short_range_expiration_distributions(
self.data_registry)[interaction['expiration']],
activity=infected_population.activity,
presence=self.short_range_interval(interaction),
distance=short_range_distances(self.data_registry),
))
return mc.ExposureModel(
short_range = defaultdict(list)
if self.short_range_option == "short_range_yes":
sr_expiration_distributions = short_range_expiration_distributions(self.data_registry)
for key, group in self.short_range_interactions.items():
for interaction in group:
expiration = sr_expiration_distributions[interaction['expiration']]
presence = self.short_range_interval(interaction)
distances = short_range_distances(self.data_registry)
short_range[key].append(mc.ShortRangeModel(
data_registry=self.data_registry,
expiration=expiration,
activity=infected_population.activity,
presence=presence,
distance=distances,
))
concentration_model: models.ConcentrationModel = mc.ConcentrationModel(
data_registry=self.data_registry,
concentration_model=mc.ConcentrationModel(
data_registry=self.data_registry,
room=room,
ventilation=ventilation,
infected=infected_population,
evaporation_factor=0.3,
),
short_range=tuple(short_range),
exposed=self.exposed_population(),
geographical_data=mc.Cases(
geographic_population=self.geographic_population,
geographic_cases=self.geographic_cases,
ascertainment_bias=CONFIDENCE_LEVEL_OPTIONS[self.ascertainment_bias],
),
exposed_to_short_range=self.short_range_occupants,
room=room,
ventilation=ventilation,
infected=infected_population,
evaporation_factor=0.3,
)
def build_model(self, sample_size=None) -> models.ExposureModel:
geographical_data: models.Cases = mc.Cases(
geographic_population=self.geographic_population,
geographic_cases=self.geographic_cases,
ascertainment_bias=CONFIDENCE_LEVEL_OPTIONS[self.ascertainment_bias],
)
if not self.occupancy:
# Legacy usage - occupancy input is not defined (default empty dict)
exposed_population = self.exposed_population()
short_range_tuple = tuple(item for sublist in short_range.values() for item in sublist)
return mc.ExposureModelGroup(
data_registry=self.data_registry,
exposure_models = (mc.ExposureModel(
data_registry=self.data_registry,
concentration_model=concentration_model,
short_range=short_range_tuple,
exposed=exposed_population,
geographical_data=geographical_data,
exposed_to_short_range=self.short_range_occupants,
),)
)
else:
exposure_model_set = []
for exposure_group in self.occupancy.keys():
sr_models: typing.Tuple[models.ShortRangeModel, ...] = tuple(short_range[exposure_group])
exposed_population = self.exposed_population(exposure_group)
exposure_model = mc.ExposureModel(
data_registry=self.data_registry,
concentration_model=concentration_model,
short_range=sr_models,
exposed=exposed_population,
geographical_data=geographical_data,
exposed_to_short_range=self.short_range_occupants,
identifier=exposure_group,
)
exposure_model_set.append(exposure_model)
return mc.ExposureModelGroup(
data_registry=self.data_registry,
exposure_models=tuple(exposure_model_set)
)
def build_model(self, sample_size=None) -> models.ExposureModelGroup:
sample_size = sample_size or self.data_registry.monte_carlo['sample_size']
return self.build_mc_model().build_model(size=sample_size)
return self.build_mc_model().build_model(sample_size)
def build_CO2_model(self, sample_size=None) -> models.CO2ConcentrationModel:
"""
Builds a CO2 model that considers the type of
activity and data from the defined population groups.
"""
sample_size = sample_size or self.data_registry.monte_carlo['sample_size']
infected_population: models.InfectedPopulation = self.infected_population(
).build_model(sample_size)
exposed_population: models.Population = self.exposed_population().build_model(sample_size)
state_change_times = set(
infected_population.presence_interval().transition_times())
state_change_times.update(
exposed_population.presence_interval().transition_times())
transition_times = sorted(state_change_times)
total_people = [infected_population.people_present(stop) + exposed_population.people_present(stop)
for _, stop in zip(transition_times[:-1], transition_times[1:])]
if (self.activity_type == 'precise'):
activity_defn, _ = self.generate_precise_activity_expiration()
else:
activity_defn = self.data_registry.population_scenario_activity[
self.activity_type]['activity']
occupancy = self.build_CO2_piecewise()
population = mc.SimplePopulation(
number=models.IntPiecewiseConstant(transition_times=tuple(
transition_times), values=tuple(total_people)),
number=occupancy,
presence=None,
activity=activity_distributions(self.data_registry)[activity_defn],
)
@ -420,6 +457,7 @@ class VirusFormData(FormData):
# This is a minimal, always present source of ventilation, due
# to the air infiltration from the outside.
# See CERN-OPEN-2021-004, p. 12.
# type: ignore
residual_vent: float = self.data_registry.ventilation['infiltration_ventilation'] # type: ignore
infiltration_ventilation = models.AirChange(
active=always_on, air_exch=residual_vent)
@ -447,7 +485,7 @@ class VirusFormData(FormData):
def generate_precise_activity_expiration(self) -> typing.Tuple[typing.Any, ...]:
# It means the precise activity is not defined by a specific input.
if self.precise_activity == {}:
if not self.precise_activity:
return ()
respiratory_dict = {}
for respiratory_activity in self.precise_activity['respiratory_activity']:
@ -457,30 +495,30 @@ class VirusFormData(FormData):
return (self.precise_activity['physical_activity'], respiratory_dict)
def infected_population(self) -> mc.InfectedPopulation:
"""
Generates an InfectedPopulation class, for both static and
dynamic occupancy.
"""
# Initializes the virus
virus = virus_distributions(self.data_registry)[self.virus_type]
# Occupancy
if self.occupancy_format == 'dynamic':
if isinstance(self.dynamic_infected_occupancy, typing.List) and len(self.dynamic_infected_occupancy) > 0:
# If dynamic occupancy is defined, the generator will parse and validate the
# respective input to a format readable by the model - `IntPiecewiseConstant`.
infected_occupancy = self.generate_dynamic_occupancy(self.dynamic_infected_occupancy)
infected_presence = None
else:
raise TypeError(f'If dynamic occupancy is selected, a populated list of occupancy intervals is expected. Got "{self.dynamic_infected_occupancy}".')
else:
# The number of exposed occupants is the total number of occupants
# minus the number of infected occupants.
infected_occupancy = self.infected_people
if not self.occupancy:
# Legacy usage - occupancy input is not defined (default empty dict)
infected_occupancy: typing.Union[int, models.IntPiecewiseConstant] = self.infected_people
infected_presence = self.infected_present_interval()
else:
infected_occupancy = self.generate_infected_occupancy(self.occupancy)
infected_presence = None
# Activity and expiration
activity_defn = self.data_registry.population_scenario_activity[self.activity_type]['activity']
expiration_defn = self.data_registry.population_scenario_activity[self.activity_type]['expiration']
activity_defn = self.data_registry.population_scenario_activity[
self.activity_type]['activity']
expiration_defn = self.data_registry.population_scenario_activity[
self.activity_type]['expiration']
if (self.activity_type == 'smallmeeting'):
# Conversation of N people is approximately 1/N% of the time speaking.
total_people: int = max(infected_occupancy.values) if self.occupancy_format == 'dynamic' else self.total_people
total_people: int = self.total_people if not self.occupancy else max(infected_occupancy.values) # type: ignore
expiration_defn = {'Speaking': 1, 'Breathing': total_people - 1}
elif (self.activity_type == 'precise'):
activity_defn, expiration_defn = self.generate_precise_activity_expiration()
@ -496,31 +534,35 @@ class VirusFormData(FormData):
mask=self.mask(),
activity=activity,
expiration=expiration,
# Vaccination status does not affect the infected population (for now)
# Vaccination status does not affect the infected population (for the time being)
host_immunity=0.,
)
return infected
def exposed_population(self) -> mc.Population:
def exposed_population(self, exposure_group: typing.Optional[str] = None) -> mc.Population:
"""
Generates an exposed Population class, for both static and
dynamic occupancy. The number of people is constant for a
single group of exposed population, except when breaks are defined.
"""
# Occupancy
if not exposure_group and not self.occupancy:
# The number of exposed occupants is the total number of occupants
# minus the number of infected occupants.
exposed_occupancy = self.total_people - self.infected_people
exposed_presence = self.exposed_present_interval()
elif exposure_group:
dynamic_group = self.occupancy[exposure_group]
exposed_occupancy = dynamic_group['total_people'] - dynamic_group['infected']
exposed_presence = self.generate_exposed_presence(dynamic_group['presence'])
# Activity
activity_defn = (self.precise_activity['physical_activity']
if self.activity_type == 'precise'
else str(self.data_registry.population_scenario_activity[self.activity_type]['activity']))
activity = activity_distributions(self.data_registry)[activity_defn]
if self.occupancy_format == 'dynamic':
if isinstance(self.dynamic_exposed_occupancy, typing.List) and len(self.dynamic_exposed_occupancy) > 0:
# If dynamic occupancy is defined, the generator will parse and validate the
# respective input to a format readable by the model - IntPiecewiseConstant.
exposed_occupancy = self.generate_dynamic_occupancy(self.dynamic_exposed_occupancy)
exposed_presence = None
else:
raise TypeError(f'If dynamic occupancy is selected, a populated list of occupancy intervals is expected. Got "{self.dynamic_exposed_occupancy}".')
else:
# The number of exposed occupants is the total number of occupants
# minus the number of infected occupants.
exposed_occupancy = self.total_people - self.infected_people
exposed_presence = self.exposed_present_interval()
# Vaccination
if (self.vaccine_option):
if (self.vaccine_booster_option and self.vaccine_booster_type != 'Other'):
host_immunity = [vaccine['VE'] for vaccine in data.vaccine_booster_host_immunity if
@ -569,8 +611,6 @@ def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]:
'calculator_version': calculator_version,
'ceiling_height': '',
'conditional_probability_viral_loads': '0',
'dynamic_exposed_occupancy': '[]',
'dynamic_infected_occupancy': '[]',
'event_month': 'January',
'exposed_coffee_break_option': 'coffee_break_4',
'exposed_coffee_duration': '10',
@ -601,12 +641,12 @@ def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]:
'mask_type': 'Type I',
'mask_wearing_option': 'mask_off',
'mechanical_ventilation_type': '',
'occupancy_format': 'static',
'occupancy': '{}',
'opening_distance': '0.2',
'room_heating_option': '0',
'room_number': '123',
'room_volume': '75',
'short_range_interactions': '[]',
'short_range_interactions': '{}',
'short_range_option': 'short_range_no',
'simulation_name': 'Test',
'total_people': '10',

View file

@ -17,7 +17,7 @@ from caimira.calculator.store.data_registry import DataRegistry
def test_model_from_dict(baseline_form_data, data_registry):
form = virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry)
assert isinstance(form.build_model(), models.ExposureModel)
assert isinstance(form.build_model(), models.ExposureModelGroup)
def test_model_from_dict_invalid(baseline_form_data, data_registry):
@ -590,51 +590,344 @@ def test_form_timezone(baseline_form_data, data_registry, longitude, latitude, m
assert offset == expected_offset
@pytest.mark.parametrize(
["occupancy_format_input", "error"],
[
['dynamc', "'dynamc' is not a valid value for 'self.occupancy_format'. Accepted values are 'static' or 'dynamic'.",],
['stact', "'stact' is not a valid value for 'self.occupancy_format'. Accepted values are 'static' or 'dynamic'.",],
['random', "'random' is not a valid value for 'self.occupancy_format'. Accepted values are 'static' or 'dynamic'.",]
]
)
def test_dynamic_format_input(occupancy_format_input, error, baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy_format = occupancy_format_input
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
@pytest.mark.parametrize(
["dynamic_occupancy_input", "error"],
[
[[["total_people", 10, "start_time", "10:00", "finish_time", "11:00"]], "Each occupancy entry should be in a dictionary format. Got \"<class 'list'>\"."],
[[{"tal_people": 10, "start_time": "10:00", "finish_time": "11:00"}], "Unable to fetch \"total_people\" key. Got \"['tal_people', 'start_time', 'finish_time']\"."],
[[{"total_people": 10, "art_time": "10:00", "finish_time": "11:00"}], "Unable to fetch \"start_time\" key. Got \"['total_people', 'art_time', 'finish_time']\"."],
[[{"total_people": 10, "start_time": "10:00", "ish_time": "11:00"}], "Unable to fetch \"finish_time\" key. Got \"['total_people', 'start_time', 'ish_time']\"."],
[[{"total_people": 10, "start_time": "10", "finish_time": "11:00"}], "Wrong time format - \"HH:MM\". Got \"10\"."],
[[{"total_people": 10, "start_time": "10:00", "finish_time": "11"}], "Wrong time format - \"HH:MM\". Got \"11\"."],
]
)
def test_dynamic_occupancy_structure(dynamic_occupancy_input, error, baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy_format = "dynamic"
baseline_form.dynamic_infected_occupancy = dynamic_occupancy_input
baseline_form.dynamic_exposed_occupancy = dynamic_occupancy_input
def test_occupancy_TypeError(baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy = [] # type: ignore
error = 'The "occupancy" input should be a valid dictionary. Got [].'
with pytest.raises(TypeError, match=re.escape(error)):
baseline_form.validate()
@pytest.mark.parametrize(
["dynamic_occupancy_input", "error"],
[
[[{"total_people": "10", "start_time": "10:00", "finish_time": "11:00"}], "Total number of people should be integer. Got \"<class 'str'>\"."],
[[{"total_people": 9.8, "start_time": "10:00", "finish_time": "11:00"}], "Total number of people should be integer. Got \"<class 'float'>\"."],
[[{"total_people": [10], "start_time": "10:00", "finish_time": "11:00"}], "Total number of people should be integer. Got \"<class 'list'>\"."],
[[{"total_people": -1, "start_time": "10:00", "finish_time": "11:00"}], "Total number of people should be non-negative. Got \"-1\"."],
["occupancy", "error"],
[
[
{"tal_people": 10, "infected": 5, "presence": [{"start_time": "10:00", "finish_time": "11:00"}],},
'Missing "total_people" key in occupancy group "group_A". Got keys: tal_people, infected, presence.'
],
[
{"total_people": 10, "infeted": 5, "presence": [{"start_time": "10:00", "finish_time": "11:00"}],},
'Missing "infected" key in occupancy group "group_A". Got keys: total_people, infeted, presence.'
],
[
{"total_people": 10, "infected": 5, "pesence": [{"start_time": "10:00", "finish_time": "11:00"}],},
'Missing "presence" key in occupancy group "group_A". Got keys: total_people, infected, pesence.'
],
]
)
def test_dynamic_occupancy_total_people(dynamic_occupancy_input, error, baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy_format = "dynamic"
baseline_form.dynamic_infected_occupancy = dynamic_occupancy_input
baseline_form.dynamic_exposed_occupancy = dynamic_occupancy_input
def test_occupancy_general_params_TypeError(occupancy, error, baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy = {"group_A": occupancy}
with pytest.raises(TypeError, match=re.escape(error)):
baseline_form.validate()
@pytest.mark.parametrize(
["occupancy_presence", "error"],
[
[{"start_time": "10:00", "finish_time": "11:00"}, 'The "presence" parameter in occupancy group "group_A" should be a valid list. Got <class \'dict\'>.'],
[[], 'The "presence" parameter in occupancy group "group_A" should be a valid, non-empty list. Got [].'],
[[["start_time", "10:00", "finish_time", "11:00"]], 'Each presence interval should be a valid dictionary. Got <class \'list\'> in occupancy group "group_A".'],
[[{"art_time": "10:00", "finish_time": "11:00"}], 'Missing "start_time" key in "presence" parameter of occupancy group "group_A". Got keys: art_time, finish_time.'],
[[{"start_time": "10:00", "ish_time": "11:00"}], 'Missing "finish_time" key in "presence" parameter of occupancy group "group_A". Got keys: start_time, ish_time.'],
]
)
def test_occupancy_presence_TypeError(occupancy_presence, error, baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy = {
"group_A": {
"total_people": 10,
"infected": 5,
"presence": occupancy_presence,
}
}
with pytest.raises(TypeError, match=re.escape(error)):
baseline_form.validate()
@pytest.mark.parametrize(
["occupancy_presence", "error"],
[
[[{"start_time": "10", "finish_time": "11:00"}], 'Invalid time format found in "presence" parameter of occupancy group "group_A". Expected HH:MM, got 10.'],
[[{"start_time": "10:00", "finish_time": "11"}], 'Invalid time format found in "presence" parameter of occupancy group "group_A". Expected HH:MM, got 11.'],
]
)
def test_occupancy_presence_ValueError(occupancy_presence, error, baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy = {
"group_A": {
"total_people": 10,
"infected": 5,
"presence": occupancy_presence
}
}
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
@pytest.mark.parametrize(
["total_people", "error"],
[
["10", 'The "total_people" input in occupancy group "group_A" should be a non-negative integer. Got 10.'],
[9.8, 'The "total_people" input in occupancy group "group_A" should be a non-negative integer. Got 9.8.'],
[[10], 'The "total_people" input in occupancy group "group_A" should be a non-negative integer. Got [10].'],
[-1, 'The "total_people" input in occupancy group "group_A" should be a non-negative integer. Got -1.'],
]
)
def test_occupancy_total_people_ValueError(total_people, error, baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy = {
"group_A": {
"total_people": total_people,
"infected": 10,
"presence": [{"start_time": "08:00", "finish_time": "18:00"},],
},
}
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
@pytest.mark.parametrize(
["infected", "error"],
[
["10", 'The infected input in occupancy group "group_A" should be a non-negative integer. Got 10.'],
[9.8, 'The infected input in occupancy group "group_A" should be a non-negative integer. Got 9.8.'],
[[10], 'The infected input in occupancy group "group_A" should be a non-negative integer. Got [10].'],
[-1, 'The infected input in occupancy group "group_A" should be a non-negative integer. Got -1.'],
[30, 'The number of infected people (30) cannot be greater than the total people (20).']
]
)
def test_occupancy_infected_ValueError(infected, error, baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy = {
"group_A": {
"total_people": 20,
"infected": infected,
"presence": [{"start_time": "08:00", "finish_time": "18:00"},],
},
}
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
def test_occupancy_presence_overlap(baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy = {
"group_A": {
"total_people": 10,
"infected": 5,
"presence": [
{"start_time": "08:00", "finish_time": "17:00"},
{"start_time": "13:00", "finish_time": "14:00"},
],
},
}
error = (
'Overlap detected: The entry '
'{\'start_time\': \'13:00\', \'finish_time\': \'14:00\'}'
' overlaps with an already existing entry '
'({\'start_time\': \'08:00\', \'finish_time\': \'17:00\'}).'
)
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
@pytest.mark.parametrize(
["short_range_input", "error"],
[
[[["expiration", "Shouting", "start_time", "09:00", "duration", 30]], 'Each short-range interaction should be a dictionary. Got <class \'list\'> in occupancy group "group_A".'],
[[{"expiratio": "Shouting", "start_time": "09:00", "duration": 30}], 'Missing "expiration" key in short-range interaction for occupancy group "group_A". Got keys: expiratio, start_time, duration.'],
[[{"expiration": "Shouting", "start_tim": "09:00", "duration": 30}], 'Missing "start_time" key in short-range interaction for occupancy group "group_A". Got keys: expiration, start_tim, duration.'],
[[{"expiration": "Shouting", "start_time": "09:00", "duratio": 30}], 'Missing "duration" key in short-range interaction for occupancy group "group_A". Got keys: expiration, start_time, duratio.'],
]
)
def test_short_range_TypeError(short_range_input, error, baseline_form: virus_validator.VirusFormData):
baseline_form.short_range_option = "short_range_yes"
baseline_form.short_range_interactions = {"group_A": short_range_input}
with pytest.raises(TypeError, match=re.escape(error)):
baseline_form.validate()
def test_short_range_exposure_group(baseline_form: virus_validator.VirusFormData):
baseline_form.occupancy = {
"group_A": {
"total_people": 20,
"infected": 10,
"presence": [
{"start_time": "10:00", "finish_time": "12:00"},
{"start_time": "13:00", "finish_time": "17:00"},
],
},
"group_B": {
"total_people": 20,
'infected': 10,
"presence": [
{"start_time": "10:00", "finish_time": "11:00"},
],
},
}
# Check for existence of the dictionary key
baseline_form.short_range_option = 'short_range_yes'
baseline_form.short_range_interactions = {
"group_C": [{"expiration": "Shouting", "start_time": "10:30", "duration": 30}],
}
error = 'Occupancy group "group_C" referenced in short-range interactions was not found in the occupancy input.'
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
# Check if interaction time is within simulation time
baseline_form.short_range_interactions = {
"group_A": [{"expiration": "Shouting", "start_time": "18:00", "duration": 30}],
}
error = (
'Short-range interaction {\'expiration\': \'Shouting\', \'start_time\': \'18:00\', \'duration\': 30}'
' does not fall within any presence interval in occupancy group "group_A".'
)
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
@pytest.mark.parametrize(
["short_range_input", "error"],
[
[[{"expiration": "Shouting", "start_time": "9", "duration": 30}], 'Invalid time format for start_time in short-range interaction for occupancy group "group_A". Expected HH:MM, got 9.'],
[[{"expiration": "Whisper", "start_time": "09:00", "duration": 30}], 'Invalid expiration value in short-range interaction for occupancy group "group_A". Got "Whisper".'],
[[{"expiration": "Shouting", "start_time": "09:00", "duration": -30}], 'The duration value in short-range interaction for occupancy group "group_A" should be a non-negative integer. Got -30.'],
]
)
def test_short_range_value_error(short_range_input, error, baseline_form: virus_validator.VirusFormData):
baseline_form.short_range_option = "short_range_yes"
baseline_form.short_range_interactions = {"group_A": short_range_input}
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
def test_short_range_with_occupancy_format(baseline_form: virus_validator.VirusFormData):
baseline_form.short_range_option = "short_range_yes"
baseline_form.short_range_interactions = {"group_A": [{"expiration": "Shouting", "start_time": "07:00", "duration": 30}]}
# Checks if interaction is defined during simulation time
error = (
'Short-range interactions must occur during simulation time. Got'
' {\'expiration\': \'Shouting\', \'start_time\': \'07:00\', \'duration\': 30}'
' in occupancy group "group_A".'
)
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
# Checks overlap of short-range interactions
baseline_form.short_range_interactions = {
"group_A": [{"expiration": "Shouting", "start_time": "10:00", "duration": 30},
{"expiration": "Shouting", "start_time": "10:10", "duration": 15}],
}
error = (
'Overlap detected: The entry '
'{\'expiration\': \'Shouting\', \'start_time\': \'10:10\', \'duration\': 15}'
' overlaps with an already existing entry '
'({\'expiration\': \'Shouting\', \'start_time\': \'10:00\', \'duration\': 30}).'
)
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
# Checks if short_range_option relates with the short_range-interactions input
baseline_form.short_range_option = "short_range_yes"
baseline_form.short_range_interactions = {}
error = (
'When short_range_option input is set to "short_range_yes", the short_range_interactions '
'input should not be empty. Got {}.'
)
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
# Checks if more than one group is defined (legacy)
baseline_form.short_range_interactions = {
"group_A": [{"expiration": "Shouting", "start_time": "10:00", "duration": 30}],
"group_B": [{"expiration": "Shouting", "start_time": "10:00", "duration": 30}]
}
error = (
'Incompatible number of occupancy groups in the short_range_interactions input. '
'Got 2 groups when the maximum is 1.'
)
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
# Checks if more than one group is defined
baseline_form.occupancy = {
"group_A": {"total_people": 20, "infected": 10, "presence": [
{"start_time": "10:00", "finish_time": "12:00"},
{"start_time": "13:00", "finish_time": "17:00"},
],
}
}
baseline_form.short_range_interactions = {
"group_A": [{"expiration": "Shouting", "start_time": "10:00", "duration": 30}],
"group_B": [{"expiration": "Shouting", "start_time": "10:00", "duration": 30}]
}
error = (
'Incompatible number of occupancy groups in the short_range_interactions input. '
'Got 2 groups when the maximum is 1 (from the occupancy input).'
)
with pytest.raises(ValueError, match=re.escape(error)):
baseline_form.validate()
def test_population_generation_from_occupancy(baseline_form: virus_validator.VirusFormData):
# Checks the correct translation of the occupancy data into the right exposure and infected models
baseline_form.occupancy = {
"group_A": {
"total_people": 5,
"infected": 2,
"presence": [
{"start_time": "09:00", "finish_time": "12:00"},
{"start_time": "13:00", "finish_time": "17:00"},
],
},
"group_B": {
"total_people": 3,
"infected": 1,
"presence": [
{"start_time": "09:00", "finish_time": "10:00"},
{"start_time": "11:00", "finish_time": "12:00"},
],
},
}
exposure_model_group: models.ExposureModelGroup = baseline_form.build_model()
# Assert that from this occupancy input, two ExposureModels are created
assert len(exposure_model_group.exposure_models) == 2
assert all(isinstance(model, models.ExposureModel) for model in exposure_model_group.exposure_models)
first_group = exposure_model_group.exposure_models[0]
second_group = exposure_model_group.exposure_models[1]
# Assert the exposed population generation (number and presence) from the occupancy input
# Type checks
assert isinstance(first_group.exposed, models.Population)
assert isinstance(first_group.exposed.number, int)
assert isinstance(first_group.exposed.presence, models.Interval)
assert isinstance(second_group.exposed, models.Population)
assert isinstance(second_group.exposed.number, int)
assert isinstance(second_group.exposed.presence, models.Interval)
# Value checks
assert first_group.exposed.number == 3
assert tuple(first_group.exposed.presence.transition_times()) == (9, 12, 13, 17)
assert first_group.exposed.presence.boundaries() == ((9, 12), (13, 17))
assert second_group.exposed.number == 2
assert tuple(second_group.exposed.presence.transition_times()) == (9, 10, 11, 12)
assert second_group.exposed.presence.boundaries() == ((9, 10), (11, 12))
# Assert that the infected population is the same for all the models
# Type checks
assert isinstance(first_group.concentration_model.infected, models.InfectedPopulation)
assert isinstance(second_group.concentration_model.infected, models.InfectedPopulation)
# Value checks
assert first_group.concentration_model.infected.number == second_group.concentration_model.infected.number
assert first_group.concentration_model.infected.presence == second_group.concentration_model.infected.presence
# Assert the infected population generation (number and presence) from the occupancy input
for infected_obj in [first_group.concentration_model.infected, second_group.concentration_model.infected]:
# Type checks
assert isinstance(infected_obj.number, models.IntPiecewiseConstant)
assert infected_obj.presence is None
# Value checks
assert infected_obj.number.interval().boundaries() == ((9, 10), (10, 11), (11, 12), (13, 17))
assert infected_obj.number.transition_times == (9, 10, 11, 12, 13, 17)
assert infected_obj.number.values == (3, 2, 3, 0, 2)

View file

@ -41,7 +41,7 @@ def full_exposure_model(data_registry):
@pytest.fixture
def baseline_infected_population_number(data_registry):
def baseline_infected_population(data_registry):
return models.InfectedPopulation(
data_registry=data_registry,
number=models.IntPiecewiseConstant(
@ -56,34 +56,15 @@ def baseline_infected_population_number(data_registry):
@pytest.fixture
def baseline_exposed_population_number():
return models.Population(
number=models.IntPiecewiseConstant(
(8, 12, 13, 17), (10, 0, 10)),
presence=None,
mask=models.Mask.types['No mask'],
activity=models.Activity.types['Seated'],
host_immunity=0.,
)
@pytest.fixture
def dynamic_infected_single_exposure_model(full_exposure_model, baseline_infected_population_number):
def dynamic_infected_single_exposure_model(full_exposure_model, baseline_infected_population):
return dc_utils.nested_replace(full_exposure_model,
{'concentration_model.infected': baseline_infected_population_number, })
{'concentration_model.infected': baseline_infected_population, })
@pytest.fixture
def dynamic_exposed_single_exposure_model(full_exposure_model, baseline_exposed_population_number):
return dc_utils.nested_replace(full_exposure_model,
{'exposed': baseline_exposed_population_number, })
@pytest.fixture
def dynamic_population_exposure_model(full_exposure_model, baseline_infected_population_number ,baseline_exposed_population_number):
def dynamic_population_exposure_model(full_exposure_model, baseline_infected_population):
return dc_utils.nested_replace(full_exposure_model, {
'concentration_model.infected': baseline_infected_population_number,
'exposed': baseline_exposed_population_number,
'concentration_model.infected': baseline_infected_population,
})
@ -92,10 +73,10 @@ def dynamic_population_exposure_model(full_exposure_model, baseline_infected_pop
[4., 8., 10., 12., 13., 14., 16., 20., 24.],
)
def test_population_number(full_exposure_model: models.ExposureModel,
baseline_infected_population_number: models.InfectedPopulation, time: float):
baseline_infected_population: models.InfectedPopulation, time: float):
int_population_number: models.InfectedPopulation = full_exposure_model.concentration_model.infected
piecewise_population_number: models.InfectedPopulation = baseline_infected_population_number
piecewise_population_number: models.InfectedPopulation = baseline_infected_population
with pytest.raises(
TypeError,
@ -206,58 +187,52 @@ def test_dynamic_dose(data_registry, full_exposure_model: models.ExposureModel,
def test_infection_probability(
full_exposure_model: models.ExposureModel,
dynamic_infected_single_exposure_model: models.ExposureModel,
dynamic_exposed_single_exposure_model: models.ExposureModel,
dynamic_population_exposure_model: models.ExposureModel):
base_infection_probability = full_exposure_model.infection_probability()
npt.assert_almost_equal(base_infection_probability, dynamic_infected_single_exposure_model.infection_probability())
npt.assert_almost_equal(base_infection_probability, dynamic_exposed_single_exposure_model.infection_probability())
npt.assert_almost_equal(base_infection_probability, dynamic_population_exposure_model.infection_probability())
def test_dynamic_total_probability_rule(
dynamic_infected_single_exposure_model: models.ExposureModel,
dynamic_exposed_single_exposure_model: models.ExposureModel,
dynamic_population_exposure_model: models.ExposureModel):
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute total probability "
"(including incidence rate) with dynamic occupancy")):
dynamic_infected_single_exposure_model.total_probability_rule()
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute total probability "
"(including incidence rate) with dynamic occupancy")):
dynamic_exposed_single_exposure_model.total_probability_rule()
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute total probability "
"(including incidence rate) with dynamic occupancy")):
dynamic_population_exposure_model.total_probability_rule()
def test_dynamic_expected_new_cases(
dynamic_infected_single_exposure_model: models.ExposureModel,
dynamic_exposed_single_exposure_model: models.ExposureModel,
dynamic_population_exposure_model: models.ExposureModel):
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute expected new cases "
"with dynamic occupancy")):
dynamic_infected_single_exposure_model.expected_new_cases()
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute expected new cases "
"with dynamic occupancy")):
dynamic_exposed_single_exposure_model.expected_new_cases()
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute expected new cases "
"with dynamic occupancy")):
dynamic_population_exposure_model.expected_new_cases()
def test_exposure_model_group_structure(data_registry, full_exposure_model: models.ExposureModel):
"""
ExposureModels must have the same ConcentrationModel.
In this test the number of infected occupants is different.
"""
another_full_exposure_model = dc_utils.nested_replace(full_exposure_model,
{'concentration_model.infected.number': 2, })
with pytest.raises(ValueError, match=re.escape("All ExposureModels must have the same infected number and presence in the ConcentrationModel.")):
models.ExposureModelGroup(data_registry, exposure_models=(full_exposure_model, another_full_exposure_model, ))
def test_dynamic_reproduction_number(
dynamic_infected_single_exposure_model: models.ExposureModel,
dynamic_exposed_single_exposure_model: models.ExposureModel,
dynamic_population_exposure_model: models.ExposureModel):
def test_exposure_model_group_expected_new_cases(data_registry, full_exposure_model: models.ExposureModel):
"""
ExposureModelGroup expected number of new cases must
be the sum of expected new cases of each ExposureModel.
In this case, the number of exposed people is changing
between the two ExposureModel groups.
"""
another_full_exposure_model = dc_utils.nested_replace(
full_exposure_model, {'exposed.number': 5, }
)
exposure_model_group = models.ExposureModelGroup(
data_registry=data_registry,
exposure_models=(full_exposure_model, another_full_exposure_model, ),
)
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute reproduction number "
"with dynamic occupancy")):
dynamic_infected_single_exposure_model.reproduction_number()
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute reproduction number "
"with dynamic occupancy")):
dynamic_exposed_single_exposure_model.reproduction_number()
with pytest.raises(NotImplementedError, match=re.escape("Cannot compute reproduction number "
"with dynamic occupancy")):
dynamic_population_exposure_model.reproduction_number()
assert exposure_model_group.expected_new_cases() == (
full_exposure_model.expected_new_cases() + another_full_exposure_model.expected_new_cases()
)

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cern-caimira"
version = "4.17.8"
version = "4.18.0"
description = "CAiMIRA - CERN Airborne Model for Indoor Risk Assessment"
license = { text = "Apache-2.0" }
authors = [
@ -27,7 +27,7 @@ dependencies = [
"mistune",
"numpy",
"pandas",
"pyinstrument",
"pyinstrument >= 5.0.3",
"retry",
"ruptures",
"scipy",
@ -40,8 +40,8 @@ dependencies = [
dev = []
test = [
"pytest",
"pytest-mypy >= 0.10.3",
"mypy >= 1.0.0",
"pytest-mypy >= 1.0.1",
"mypy >= 1.17.0",
"pytest-tornasync",
"types-dataclasses",
"types-requests"

View file

@ -122,9 +122,9 @@ class VirusReportGenerator:
model: models.ExposureModel = report_data['model']
data_registry_version: typing.Optional[str] = f"v{model.data_registry.version}" if model.data_registry.version else None
# Alternative scenarios data
alternative_scenarios: typing.Dict[str,typing.Any] = alternative_scenarios_data(form, report_data, executor_factory)
context.update(alternative_scenarios)
# Alternative scenarios data (only generated in the legacy version - when occupancy input is empty)
if not form.occupancy:
context.update(alternative_scenarios_data(form, report_data, executor_factory))
# Alternative viral load data
if form.conditional_probability_viral_loads:

View file

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

View file

@ -17,14 +17,12 @@ const CO2_data_form = [
"infected_lunch_option",
"infected_lunch_start",
"infected_people",
"dynamic_infected_occupancy",
"infected_start",
"occupancy",
"room_capacity",
"room_volume",
"specific_breaks",
"total_people",
"dynamic_exposed_occupancy",
"occupancy_format",
"total_people"
];
// Method to upload a valid data file (accepted formats: .xls and .xlsx)
@ -301,6 +299,7 @@ function displayFittingData(json_response) {
// Not needed for the form submission
delete json_response["CO2_plot_img"];
delete json_response["predictive_CO2"];
delete json_response["CO2_plot_data"];
// Convert nulls to empty strings in the JSON response
if (json_response["room_capacity"] === null) json_response["room_capacity"] = '';
if (json_response["ventilation_lsp_values"] === null) json_response["ventilation_lsp_values"] = '';

View file

@ -631,22 +631,38 @@ 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));
let short_range_interactions = {};
$(".form_field_outer_row").each(function (index, element) {
const $element = $(element);
let obj = {};
obj.expiration = $element.find("[name='short_range_expiration']").val();
obj.start_time = $element.find("[name='short_range_start_time']").val();
obj.duration = parseFloat($element.find("[name='short_range_duration']").val());
const exposure_group = $element.find("[name='short_range_exposure_group']").val();
// If the exposure_group key already exists, push the new obj into the array
if (short_range_interactions[exposure_group]) {
short_range_interactions[exposure_group].push(obj);
} else {
// Otherwise, create a new array with the current obj
short_range_interactions[exposure_group] = [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) {
// Sort each array within the short_range_interactions object by start_time
for (const key in short_range_interactions) {
short_range_interactions[key].sort(function (a, b) {
return a.start_time.localeCompare(b.start_time);
});
}
// Convert the entire object to a JSON string and assign it to the input field
$("input[type=text][name=short_range_interactions]").val(JSON.stringify(short_range_interactions));
// Check if there are no entries and update the radio button accordingly
if (Object.keys(short_range_interactions).length === 0) {
$("input[type=radio][id=short_range_no]").prop("checked", true);
on_short_range_option_change();
}
@ -907,18 +923,42 @@ $(document).ready(function () {
}
// 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++;
else if (name === 'short_range_interactions') {
// Parse the JSON value from the URL
let interactions = JSON.parse(value);
let index = 1; // Initialize interaction index
// Iterate over each group in the interactions
for (const group in interactions) {
if (interactions.hasOwnProperty(group)) {
// Iterate over each interaction within the group
for (const interaction of interactions[group]) {
// Append the interaction row to the dialog
$("#dialog_sr").append(inject_sr_interaction(index, interaction, "row_validated"));
// Set the values for each input field based on the interaction
$('#sr_expiration_no_' + index).val(interaction.expiration).change();
document.getElementById('sr_start_no_' + index).value = interaction.start_time; // Set start time
document.getElementById('sr_duration_no_' + index).value = interaction.duration; // Set duration
document.getElementById('sr_group_no_' + index).value = group; // Set exposure group
// Disable the input fields for editing
document.getElementById('sr_expiration_no_' + index).disabled = true;
document.getElementById('sr_start_no_' + index).disabled = true;
document.getElementById('sr_duration_no_' + index).disabled = true;
document.getElementById('sr_group_no_' + index).disabled = true;
// Update visibility of editing and validation rows
document.getElementById('edit_row_no_' + index).style.display = 'inline';
document.getElementById('validate_row_no_' + index).style.display = 'none';
// Increment the index for the next interaction
index++;
}
}
}
// Update the total count of interactions displayed
$("#sr_interactions").text(index - 1);
}
@ -1196,6 +1236,11 @@ $(document).ready(function () {
<div class="col-sm-6"><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 row d-none'>
<div class="col-sm-6"><label class="col-form-label col-form-label-sm"> Exposure group:</label></div>
<div class="col-sm-6"><input type="text" id="sr_group_no_${index}" value="${value.exposure_group}" class="form-control form-control-sm short_range_option" name="short_range_exposure_group" placeholder="group_1" 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>
@ -1213,11 +1258,11 @@ $(document).ready(function () {
// 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" }));
if (last_row.length == 0) $("#dialog_sr").append(inject_sr_interaction(1, value = { activity: "", start_time: "", duration: "15", exposure_group: "group_1" }));
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" }));
$("#dialog_sr").append(inject_sr_interaction(index, value = { activity: "", start_time: "", duration: "15", exposure_group: "group_1"}));
}
});

View file

@ -4,13 +4,18 @@ function on_report_load(conditional_probability_viral_loads) {
}
/* Generate the concentration plot using d3 library. */
function draw_plot(svg_id) {
function draw_plot(svg_id, group_id, times, concentrations_zoomed,
concentrations, cumulative_doses, long_range_cumulative_doses,
exposed_presence_intervals, short_range_interactions) {
// 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;
let button_full_exposure = document.getElementById(`button_full_exposure-group_${group_id}`);
let button_hide_high_concentration = document.getElementById(`button_hide_high_concentration-group_${group_id}`);
let long_range_checkbox = document.getElementById(`long_range_cumulative_checkbox-group_${group_id}`);
let show_sr_legend = short_range_interactions.length > 0;
let short_range_intervals = short_range_interactions.map((interaction) => interaction["presence_interval"]);
let short_range_expirations = short_range_interactions.map((interaction) => interaction["expiration"]);
var data_for_graphs = {
'concentrations': [],
@ -192,7 +197,7 @@ function draw_plot(svg_id) {
// Area representing the short-range interaction(s).
var shortRangeArea = {};
var drawShortRangeArea = {};
short_range_intervals.forEach((b, index) => {
short_range_intervals?.forEach((b, index) => {
shortRangeArea[index] = d3.area();
drawShortRangeArea[index] = draw_area.append('svg:path');
@ -285,7 +290,7 @@ function draw_plot(svg_id) {
});
// Short-Range Area.
short_range_intervals.forEach((b, index) => {
short_range_intervals.flat().forEach((b, index) => {
shortRangeArea[index].x(d => xTimeRange(d.time))
.y0(graph_height - 50)
.y1(d => yRange(d.concentration));
@ -521,12 +526,12 @@ function draw_plot(svg_id) {
}
// Draw for the first time to initialize.
redraw();
redraw(svg_id);
update_concentration_plot(concentrations, cumulative_doses);
// Redraw based on the new size whenever the browser window is resized.
window.addEventListener("resize", e => {
redraw();
redraw(svg_id);
if (button_full_exposure && button_full_exposure.disabled) update_concentration_plot(concentrations, cumulative_doses);
else update_concentration_plot(concentrations_zoomed, long_range_cumulative_doses)
});
@ -536,12 +541,13 @@ function draw_plot(svg_id) {
// 'list_of_scenarios' is a dictionary with all the scenarios
// 'times' is a list of times for all the scenarios
function draw_generic_concentration_plot(
plot_svg_id,
svg_id,
times,
y_axis_label,
h_lines,
) {
if (plot_svg_id === 'CO2_concentration_graph') {
if (svg_id === 'CO2_concentration_graph') {
list_of_scenarios = {'CO₂ concentration': {'concentrations': CO2_concentrations}};
min_y_axis_domain = 400;
}
@ -575,7 +581,7 @@ function draw_generic_concentration_plot(
var first_scenario = Object.values(data_for_scenarios)[0]
// Add main SVG element
var plot_div = document.getElementById(plot_svg_id);
var plot_div = document.getElementById(svg_id);
var vis = d3.select(plot_div).append('svg');
var xRange = d3.scaleTime().domain([first_scenario[0].hour, first_scenario[first_scenario.length - 1].hour]);
@ -706,7 +712,7 @@ function draw_generic_concentration_plot(
}
function update_concentration_plot(concentration_data) {
list_of_scenarios = (plot_svg_id === 'CO2_concentration_graph') ? {'CO₂ concentration': {'concentrations': CO2_concentrations}} : alternative_scenarios
list_of_scenarios = (svg_id === 'CO2_concentration_graph') ? {'CO₂ concentration': {'concentrations': CO2_concentrations}} : alternative_scenarios
var highest_concentration = 0.
for (scenario in list_of_scenarios) {
@ -739,11 +745,11 @@ function draw_generic_concentration_plot(
var graph_width;
var graph_height;
function redraw() {
function redraw(svg_id) {
// Define width and height according to the screen size. Always use an already defined
var window_width = document.getElementById('concentration_plot').clientWidth;
var window_width = document.getElementById(svg_id).clientWidth;
var div_width = window_width;
var div_height = document.getElementById('concentration_plot').clientHeight;
var div_height = document.getElementById(svg_id).clientHeight;
graph_width = div_width;
graph_height = div_height;
var margins = { top: 30, right: 20, bottom: 50, left: 60 };
@ -882,12 +888,12 @@ function draw_generic_concentration_plot(
}
// Draw for the first time to initialize.
redraw();
redraw(svg_id);
update_concentration_plot('concentrations');
// Redraw based on the new size whenever the browser window is resized.
window.addEventListener("resize", e => {
redraw();
redraw(svg_id);
update_concentration_plot('concentrations');
});
}

View file

@ -237,7 +237,7 @@ class DataclassInstanceState(DataclassState[Datamodel_T]):
# TODO: It is possible to cut observer connections by clearing like this.
self._data.clear()
for field in dataclasses.fields(instance_dataclass):
for field in dataclasses.fields(instance_dataclass): # type: ignore
if dataclasses.is_dataclass(field.type):
self._data[field.name] = self._state_builder.visit(field)
self._data[field.name].dcs_observe(self._fire_observers)

View file

@ -454,9 +454,7 @@
<span class="tooltip_text">?</span>
</div><br>
<input type="text" class="form-control d-none" name="occupancy_format" value="static" required> {# "static" vs. "dynamic" #}
<input type="text" class="form-control d-none" name="dynamic_exposed_occupancy" value="[]">
<input type="text" class="form-control d-none" name="dynamic_infected_occupancy" value="[]">
<input type="text" class="form-control d-none" name="occupancy" value="{}">
<div class="form-group row">
<div class="col-sm-4"><label class="col-form-label">Total number of occupants:</label></div>

View file

@ -54,362 +54,361 @@
<div class="tab-content" style="border-top: #dee2e6 1px solid; margin-top: -1px" >
<div class="tab-pane show active" id="results" role="tabpanel" aria-labelledby="results-tab" style="padding: 1%">
{% set long_range_prob_inf = prob_inf %}
{% set long_range_expected_cases = expected_new_cases %}
{# Update values if short range option is "short_range_yes" #}
{% if form.short_range_option == "short_range_yes" %}
{% set scenario = alternative_scenarios.stats.values() | first %}
{# Probability of infection values #}
{% set long_range_prob_inf = scenario.probability_of_infection %}
{# Expected new case values #}
{% set long_range_expected_cases = scenario.expected_new_cases %}
{% if form.exposure_option == 'p_probabilistic_exposure' %}
{% set long_range_prob_probabilistic_exposure = scenario.prob_probabilistic_exposure %}
{% for group_id, group_results in groups.items() %}
{% if group_results.get('long_range_prob_inf') %}{{group_results.long_range_prob_inf}}{% endif %}
{% set long_range_prob_inf = group_results.prob_inf %}
{% set long_range_expected_cases = group_results.expected_new_cases %}
{# Update values if short range option is "short_range_yes" #}
{% if group_results.get('long_range_prob') %}
{# Probability of infection values #}
{% set long_range_prob_inf = group_results.long_range_prob %}
{# Expected new case values #}
{% set long_range_expected_cases = group_results.long_range_expected_new_cases %}
{% if form.exposure_option == 'p_probabilistic_exposure' %}
{% set long_range_prob_probabilistic_exposure = scenario.prob_probabilistic_exposure %}
{% endif %}
{% endif %}
{% endif %}
{% block report_results %}
<div class="card bg-light mb-3" id="results-div">
<div class="card-header"><strong>Results </strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseResults" role="button" aria-expanded="true" aria-controls="collapseResults">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<div class="collapse show" id="collapseResults">
<div class="card-body">
<p class="card-text">
<div class="align-self-center">
<div class="split">
<div class="card card-body align-self-center pi-box" style="text-align:center; max-width: 300px">
<h6 class="card-title">
<b>Probability of infection (%)</b><br>
{% if form.short_range_option == "short_range_yes" %}
Without <b>short-range interactions</b>
{% endif %}
</h6>
<br>
<img src="{{ get_url('/static/images') }}/long_range_anim.png" class="align-middle mb-3 pi-image">
<div class="d-flex" style="min-height: 160px">
{% block long_range_warning_animation %}
<div class="intro-banner-vdo-play-btn animation-color m-auto d-flex align-items-center justify-content-center">
<b>{{long_range_prob_inf | non_zero_percentage}}</b>
<i class="glyphicon glyphicon-play whiteText" aria-hidden="true"></i>
<span class="ripple animation-color"></span>
<span class="ripple animation-color"></span>
<span class="ripple animation-color"></span>
</div>
{% endblock long_range_warning_animation %}
</div>
{% if form.occupancy_format == "static" %}<h6><b>Expected new cases:</b> {{ long_range_expected_cases | float_format }}</h6>{% endif %}
</div>
<br>
{% if form.short_range_option == "short_range_yes" %}
<div class="card card-body align-self-center pi-box" style="text-align:center; max-width: 300px">
<h6 class="card-title">
<b>Probability of infection (%)</b><br>
With <b>short-range interactions</b>
</h6>
<br>
<img src="{{ get_url('/static/images') }}/short_range_anim.png" class="align-middle mb-3 pi-image">
<div class="d-flex" style="min-height: 160px">
{% block warning_animation %}
<div class="intro-banner-vdo-play-btn animation-color m-auto d-flex align-items-center justify-content-center">
<b>{{prob_inf | non_zero_percentage}}</b>
<i class="glyphicon glyphicon-play whiteText" aria-hidden="true"></i>
<span class="ripple animation-color"></span>
<span class="ripple animation-color"></span>
<span class="ripple animation-color"></span>
</div>
{% endblock warning_animation %}
</div>
{% if form.occupancy_format == "static" %}
<h6><b>Expected new cases:</b> {{ expected_new_cases | float_format }}</h6>
{% endif %}
</div>
{% endif %}
<div class="d-flex">
{% block report_summary %}
<div class="flex-row align-self-center">
<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 and assuming all occupants are exposed equally (i.e. without short-range interactions), the <b>probability of one exposed occupant getting infected is {{ long_range_prob_inf | non_zero_percentage }}</b>
{% if form.occupancy_format == "static" %}
and the <b>expected number of new cases is {{ long_range_expected_cases | float_format }}</b>
{% endif %}*.
</div>
{% if form.short_range_option == "short_range_yes" %}
<br>
<div class="align-self-center alert alert-dark mb-0" role="alert">
In this scenario, the <b>probability the occupant(s) exposed to short-range interactions get infected can go as high as {{ prob_inf | non_zero_percentage }}</b>
{% if form.occupancy_format == "static" %}
and the <b>expected number of new cases increases to {{ expected_new_cases | float_format }}</b>
{% endif %}.
</div>
{% endif %}
{% block probabilistic_exposure_probability %}
{% if form.exposure_option == "p_probabilistic_exposure" %}
<br>
<div class="align-self-center alert alert-dark mb-0" role="alert">
{% if form.occupancy_format == "static" %}
The above {{ "result assumes" if form.short_range_option == "short_range_no" else "results assume" }} that <b>{{ form.infected_people }}
{{ "occupant is infected" if form.infected_people == 1 else "occupants are infected" }}
</b> in the room.
By taking into account the estimate of cases currently circulating in <b>{{ form.location_name }}</b>,
the probability of on-site transmission, having at least 1 new infection in an <b>event
with {{ form.total_people }} occupants</b>, is
{% if form.short_range_option == 'short_range_yes' %}:
<ul>
<li><b>{{ long_range_prob_probabilistic_exposure | non_zero_percentage }}</b>, assuming all occupants are exposed equally (i.e. without short-range interactions).</li>
<li><b>{{ prob_probabilistic_exposure | non_zero_percentage }}</b>, assuming short-range interactions occur with the infector(s).</li>
</ul>
{% else %}
<b>{{ prob_probabilistic_exposure | non_zero_percentage }}</b>.
{% endif %}
{% else %}
<p><strong>Warning: </strong>Since dynamic occupancy was defined, the results for probabilistic exposure with incidence rates have not been computed.</p>
{% endif %}
</div>
{% endif %}
{% endblock probabilistic_exposure_probability %}
</div>
{% endblock report_summary %}
</div>
</div>
<br>
{% block report_summary_footnote %}
{% endblock report_summary_footnote %}
</div>
<br><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">
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>
<i>IRP - Infectious Respiratory Particles.</i>
</p>
</div>
</div>
</div>
<div class="card bg-light mb-3" id="results-div">
<div class="card-header"><strong>Result uncertainties </strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseUncertainties" role="button" aria-expanded="true" aria-controls="collapseUncertainties">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<div class="collapse show" id="collapseUncertainties">
<div class="card-body">
<div class="align-self-center">
<div id="prob_inf_hist" style="height: 400px"></div>
<script type="application/javascript">
let prob_dist = {{ prob_dist | JSONify }};
let prob_hist_count = {{ prob_hist_count | JSONify }};
let prob_hist_bins = {{ prob_hist_bins | JSONify }};
draw_histogram("prob_inf_hist", {{ prob_inf }}, {{ prob_inf_sd }});
</script>
<br>
{% if model.data_registry.virological_data['virus_distributions'][form.virus_type]['viral_load_in_sputum'] == 'Ref: Viral load - covid overal viral load data' %}
<div class="form-check">
<input type="checkbox" id="conditional_probability_viral_loads" class="tabbed form-check-input" name="conditional_probability_viral_loads" value="1" onClick="conditional_probability_viral_loads(this.checked, {{ form.conditional_probability_viral_loads | int }});">
<label id="label_conditional_probability_viral_loads" for="conditional_probability_viral_loads" class="form-check-label col-sm-12">Generate full uncertainty data (as function of the viral load)</label>
</div>
{% endif %}
{% if form.conditional_probability_viral_loads %}
<div id="conditional_probability_div">
<img src= "{{ uncertainties_plot_src }}" />
<div class="ml-5">
<p>(i) &nbsp;&nbsp;Predictive probability of infection for a given value of the viral load</p>
<p>(ii) &nbsp;Histogram of the viral load data</p>
<p>(iii) Histogram of the conditional probability of infection (result of total predictive probability in the middle)</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header"><strong>Predictive CO₂ Concentration Profile</strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseCO₂Profile" role="button" aria-expanded="false" aria-controls="collapseCO₂Profile">
<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="collapseCO₂Profile">
<div class="card-body">
<div>
<div id="CO2_concentration_graph" style="height: 400px"></div>
<script type="application/javascript">
var CO2_concentrations = {{ CO2_concentrations | JSONify }}
draw_generic_concentration_plot(
"CO2_concentration_graph",
"Mean concentration (ppm)",
h_lines = [
{'label': 'Acceptable level',
'y': 800,
'color': 'forestgreen',
'style': 'dashed'
},
{'label': 'Insufficient level',
'y': 1500,
'color': 'firebrick',
'style': 'dashed'
},
]
);
</script>
</div>
</div>
</div>
</div>
{% 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">
{% block report_results scoped %}
<div class="card bg-light mb-3" id="results-div">
<div class="card-header"><strong>Results{% if form.occupancy != {} %} - {{ group_id }}{% endif %}</strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseResults-group_{{ group_id }}" role="button" aria-expanded="true" aria-controls="collapseResults">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-chevron-expand" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
</svg>
</button>
</div>
<div class="collapse" id="collapseAlternativeScenarios">
<div class="collapse show" id="collapseResults-group_{{ group_id }}">
<div class="card-body">
<div>
<div id="alternative_scenario_plot" style="height: 400px"></div>
<p class="card-text">
<div class="align-self-center">
<div class="split">
<div class="card card-body align-self-center pi-box" style="text-align:center; max-width: 300px">
<h6 class="card-title">
<b>Probability of infection (%)</b><br>
{% if group_results.get('long_range_prob') %}
Without <b>short-range interactions</b>
{% endif %}
</h6>
<br>
<img src="{{ get_url('/static/images') }}/long_range_anim.png" class="align-middle mb-3 pi-image">
<div class="d-flex" style="min-height: 160px">
{% block long_range_warning_animation scoped %}
<div class="intro-banner-vdo-play-btn animation-color m-auto d-flex align-items-center justify-content-center">
<b>{{ long_range_prob_inf | non_zero_percentage }}</b>
<i class="glyphicon glyphicon-play whiteText" aria-hidden="true"></i>
<span class="ripple animation-color"></span>
<span class="ripple animation-color"></span>
<span class="ripple animation-color"></span>
</div>
{% endblock long_range_warning_animation %}
</div>
<h6><b>Expected new cases:</b> {{ long_range_expected_cases | float_format }}</h6>
</div>
<br>
{% if group_results.get('long_range_prob') %}
<div class="card card-body align-self-center pi-box" style="text-align:center; max-width: 300px">
<h6 class="card-title">
<b>Probability of infection (%)</b><br>
With <b>short-range interactions</b>
</h6>
<br>
<img src="{{ get_url('/static/images') }}/short_range_anim.png" class="align-middle mb-3 pi-image">
<div class="d-flex" style="min-height: 160px">
{% block warning_animation scoped %}
<div class="intro-banner-vdo-play-btn animation-color m-auto d-flex align-items-center justify-content-center">
<b>{{ group_results.prob_inf | non_zero_percentage }}</b>
<i class="glyphicon glyphicon-play whiteText" aria-hidden="true"></i>
<span class="ripple animation-color"></span>
<span class="ripple animation-color"></span>
<span class="ripple animation-color"></span>
</div>
{% endblock warning_animation %}
</div>
<h6><b>Expected new cases:</b> {{ group_results.expected_new_cases | float_format }}</h6>
</div>
{% endif %}
<div class="d-flex">
{% block report_summary scoped %}
<div class="flex-row align-self-center">
<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 and assuming all occupants are exposed equally (i.e. without short-range interactions), the <b>probability of one exposed occupant getting infected is {{ long_range_prob_inf | non_zero_percentage }}</b>
and the <b>expected number of new cases is {{ long_range_expected_cases | float_format }}</b>*.
</div>
{% if group_results.get('long_range_prob') %}
<br>
<div class="align-self-center alert alert-dark mb-0" role="alert">
In this scenario, the <b>probability the occupant(s) exposed to short-range interactions get infected can go as high as {{ group_results.prob_inf | non_zero_percentage }}</b>
and the <b>expected number of new cases increases to {{ group_results.expected_new_cases | float_format }}</b>.
</div>
{% endif %}
{% block probabilistic_exposure_probability %}
{% if form.exposure_option == "p_probabilistic_exposure" %}
<br>
<div class="align-self-center alert alert-dark mb-0" role="alert">
{% if form.occupancy == {} %}
The above {{ "result assumes" if form.short_range_option == "short_range_no" else "results assume" }} that <b>{{ form.infected_people }}
{{ "occupant is infected" if form.infected_people == 1 else "occupants are infected" }}
</b> in the room.
By taking into account the estimate of cases currently circulating in <b>{{ form.location_name }}</b>,
the probability of on-site transmission, having at least 1 new infection in an <b>event
with {{ form.total_people }} occupants</b>, is
{% if form.short_range_option == 'short_range_yes' %}:
<ul>
<li><b>{{ long_range_prob_probabilistic_exposure | non_zero_percentage }}</b>, assuming all occupants are exposed equally (i.e. without short-range interactions).</li>
<li><b>{{ prob_probabilistic_exposure | non_zero_percentage }}</b>, assuming short-range interactions occur with the infector(s).</li>
</ul>
{% else %}
<b>{{ prob_probabilistic_exposure | non_zero_percentage }}</b>.
{% endif %}
{% else %}
<p><strong>Warning: </strong>Since dynamic occupancy was defined, the results for probabilistic exposure with incidence rates have not been computed.</p>
{% endif %}
</div>
{% endif %}
{% endblock probabilistic_exposure_probability %}
</div>
{% endblock report_summary %}
</div>
</div>
<br>
{% block report_summary_footnote %}
{% endblock report_summary_footnote %}
</div>
<br><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 group_results.get('long_range_prob') %}
{% if form.short_range_option == 'short_range_yes' and ('Speaking' in form.short_range_interactions[group_id]|string or 'Shouting' in form.short_range_interactions[group_id]|string) %}
<button class="btn btn-sm btn-primary" id="button_full_exposure-group_{{ group_id }}" disabled>Show full exposure</button>
<button class="btn btn-sm btn-primary ml-0" id="button_hide_high_concentration-group_{{ group_id }}">Hide high concentration</button>
{% endif %}
<input type="checkbox" id="long_range_cumulative_checkbox-group_{{ group_id }}">
<label class="form-check-label ml-1" for="long_range_cumulative_checkbox-group_{{ group_id }}" id="lr_cumulative_checkbox_label-group_{{ group_id }}">Show doses from long-range exposure alone</label>
{% endif %}
<div id="concentration_plot-group_{{ group_id }}" style="height: 400px"></div>
<script type="application/javascript">
var alternative_scenarios = {{ alternative_scenarios.stats | JSONify }}
draw_generic_concentration_plot(
"alternative_scenario_plot",
"Mean concentration (IRP/m³)",
);
draw_plot("concentration_plot-group_{{ group_id }}",
{{ group_id | JSONify }},
{{ times | JSONify }},
{{ group_results.get("concentrations_zoomed", []) | JSONify }},
{{ group_results.concentrations | JSONify }},
{{ group_results.cumulative_doses | JSONify }},
{{ group_results.get("long_range_cumulative_doses", []) | JSONify }},
{{ group_results.exposed_presence_intervals | JSONify }},
{{ group_results.get("short_range_interactions", []) | JSONify }}
)
</script>
<br>
{% block report_scenarios_summary_table %}
<table class="table w-auto">
<thead class="thead-light">
<tr>
<th>Scenario</th>
<th>P(I)</th>
{% if form.occupancy_format == "static" %}<th>Expected new cases</th>{% endif %}
</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>
{% if form.occupancy_format == "static" %}<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>{% endif %}
</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>
<i>IRP - Infectious Respiratory Particles.</i>
</p>
</div>
</div>
</div>
{% endif %}
{% endblock report_results %}
<!-- Export Data Concentration Modal -->
<div class="modal fade" id="modalCSV" tabindex="-1" role="dialog" aria-labelledby="modalCSV" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLongTitle">Export data (.csv)</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
<div class="card bg-light mb-3" id="results-div">
<div class="card-header"><strong>Result uncertainties{% if form.occupancy != {} %} - {{ group_id }}{% endif %}</strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseUncertainties-group_{{ group_id }}" role="button" aria-expanded="true" aria-controls="collapseUncertainties">
<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="modal-body">
<b>Select the data to export:</b>
<div class="form-check mt-3">
<input type="checkbox" class="form-check-input" name="checkedItems" id="Time" onclick="display_rename_column(this.checked, 'time-rename-div')" checked disabled>
<label class="form-check-label" for="Time">Time of day</label>
<div id="time-rename-div" class="d-flex align-items-center">
<label class="col-form-label" for="time-rename-div">Column name:</label>
<input type="text" class="form-control form-control-sm col-sm-4 ml-2" id="Time__rename" placeholder="Time" value="Time">
<div class="collapse show" id="collapseUncertainties-group_{{ group_id }}">
<div class="card-body">
<div class="align-self-center">
<div id="prob_inf_hist-group_{{ group_id }}" style="height: 400px"></div>
<script type="application/javascript">
prob_dist = {{ group_results.prob_dist | JSONify }};
prob_hist_count = {{ group_results.prob_hist_count | JSONify }};
prob_hist_bins = {{ group_results.prob_hist_bins | JSONify }};
draw_histogram("prob_inf_hist-group_{{ group_id }}", {{ group_results.prob_inf }}, {{ group_results.prob_inf_sd }});
</script>
<br>
{% if group_results.model.data_registry.virological_data['virus_distributions'][form.virus_type]['viral_load_in_sputum'] == 'Ref: Viral load - covid overal viral load data' %}
<div class="form-check">
<input type="checkbox" id="conditional_probability_viral_loads" class="tabbed form-check-input" name="conditional_probability_viral_loads" value="1" onClick="conditional_probability_viral_loads(this.checked, {{ form.conditional_probability_viral_loads | int }});">
<label id="label_conditional_probability_viral_loads" for="conditional_probability_viral_loads" class="form-check-label col-sm-12">Generate full uncertainty data (as function of the viral load)</label>
</div>
{% endif %}
{% if form.conditional_probability_viral_loads %}
<div id="conditional_probability_div">
<img src= "{{ group_results.uncertainties_plot_src }}" />
<div class="ml-5">
<p>(i) &nbsp;&nbsp;Predictive probability of infection for a given value of the viral load</p>
<p>(ii) &nbsp;Histogram of the viral load data</p>
<p>(iii) Histogram of the conditional probability of infection (result of total predictive probability in the middle)</p>
</div>
</div>
{% endif %}
</div>
</div>
{# If short-range interactions are set, we don't display Alternative Scenarios, and there is no need to arrange items in a list #}
{% if form.short_range_option == "short_range_no" %}
<ul style="padding-left: inherit">
<li>
Current Scenario
{% endif %}
<div class="form-check">
<input type="checkbox" class="form-check-input" name="checkedItems" id="Concentration" onclick="display_rename_column(this.checked, 'concentration-rename-div')">
<label class="form-check-label" for="Concentration">Concentration</label>
<div id="concentration-rename-div" class="align-items-center" style="display:none">
<label class="col-form-label" for="concentration-rename-div">Column name:</label>
<input type="text" class="form-control form-control-sm col-sm-4 ml-2" id="Concentration__rename" placeholder="Concentration" value="Concentration">
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="checkedItems" id="Cumulative Dose" onclick="display_rename_column(this.checked, 'cumulative-dose-rename-div')">
<label class="form-check-label" for="Cumulative Dose">Cumulative dose</label>
<div id="cumulative-dose-rename-div" class="align-items-center" style="display:none">
<label class="col-form-label" for="cumulative-dose-rename-div">Column name:</label>
<input type="text" class="form-control form-control-sm col-sm-4 ml-2" id="Cumulative Dose__rename" placeholder="Cumulative Dose" value="Cumulative Dose">
</div>
</div>
</li>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="checkedItems" id="CO2_Concentration" onclick="display_rename_column(this.checked, 'CO2-rename-div')">
<label class="form-check-label" for="CO2_Concentration">CO₂ Concentration</label>
<div id="CO2-rename-div" class="align-items-center" style="display:none">
<label class="col-form-label" for="CO2-rename-div">Column name:</label>
<input type="text" class="form-control form-control-sm col-sm-4 ml-2" id="CO2_Concentration__rename" placeholder="CO₂ Concentration" value="CO₂ Concentration">
</div>
</div>
{% if form.short_range_option == "short_range_no" %}
</li>
<li>
Alternative Scenarios
<div class="form-check">
<input type="checkbox" class="form-check-input" name="checkedItems" id="Alternative Scenarios" onclick="check_download_button(); display_column_name_warning(this.checked);">
<label class="form-check-label" for="Alternative Scenarios">Concentration</label>
<p id="alternative_scenario_warning" class="text-warning" style="display: none">The column name will be the scenario name.</p>
</div>
</li>
{% endif %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button id="downloadCSV" type="button" class="btn btn-primary" onclick="export_csv();" disabled>Download</button>
</div>
{% if form.short_range_option == "short_range_no" and form.occupancy == {} %}
<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 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 }}
draw_generic_concentration_plot(
"alternative_scenario_plot",
{{ times | JSONify }},
"Mean concentration (IRP/m³)",
);
</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>
</div>
</div>
{% endif %}
{% endblock report_results %}
<!-- Export Data Concentration Modal -->
<div class="modal fade" id="modalCSV" tabindex="-1" role="dialog" aria-labelledby="modalCSV" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLongTitle">Export data (.csv)</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<b>Select the data to export:</b>
<div class="form-check mt-3">
<input type="checkbox" class="form-check-input" name="checkedItems" id="Time" onclick="display_rename_column(this.checked, 'time-rename-div')" checked disabled>
<label class="form-check-label" for="Time">Time of day</label>
<div id="time-rename-div" class="d-flex align-items-center">
<label class="col-form-label" for="time-rename-div">Column name:</label>
<input type="text" class="form-control form-control-sm col-sm-4 ml-2" id="Time__rename" placeholder="Time" value="Time">
</div>
</div>
{# If short-range interactions are set, we don't display Alternative Scenarios, and there is no need to arrange items in a list #}
{% if form.short_range_option == "short_range_no" %}
<ul style="padding-left: inherit">
<li>
Current Scenario
{% endif %}
<div class="form-check">
<input type="checkbox" class="form-check-input" name="checkedItems" id="Concentration" onclick="display_rename_column(this.checked, 'concentration-rename-div')">
<label class="form-check-label" for="Concentration">Concentration</label>
<div id="concentration-rename-div" class="align-items-center" style="display:none">
<label class="col-form-label" for="concentration-rename-div">Column name:</label>
<input type="text" class="form-control form-control-sm col-sm-4 ml-2" id="Concentration__rename" placeholder="Concentration" value="Concentration">
</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="checkedItems" id="Cumulative Dose" onclick="display_rename_column(this.checked, 'cumulative-dose-rename-div')">
<label class="form-check-label" for="Cumulative Dose">Cumulative dose</label>
<div id="cumulative-dose-rename-div" class="align-items-center" style="display:none">
<label class="col-form-label" for="cumulative-dose-rename-div">Column name:</label>
<input type="text" class="form-control form-control-sm col-sm-4 ml-2" id="Cumulative Dose__rename" placeholder="Cumulative Dose" value="Cumulative Dose">
</div>
</div>
</li>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="checkedItems" id="CO2_Concentration" onclick="display_rename_column(this.checked, 'CO2-rename-div')">
<label class="form-check-label" for="CO2_Concentration">CO₂ Concentration</label>
<div id="CO2-rename-div" class="align-items-center" style="display:none">
<label class="col-form-label" for="CO2-rename-div">Column name:</label>
<input type="text" class="form-control form-control-sm col-sm-4 ml-2" id="CO2_Concentration__rename" placeholder="CO₂ Concentration" value="CO₂ Concentration">
</div>
</div>
{% if form.short_range_option == "short_range_no" and form.occupancy == {} %}
</li>
<li>
Alternative Scenarios
<div class="form-check">
<input type="checkbox" class="form-check-input" name="checkedItems" id="Alternative Scenarios" onclick="check_download_button(); display_column_name_warning(this.checked);">
<label class="form-check-label" for="Alternative Scenarios">Concentration</label>
<p id="alternative_scenario_warning" class="text-warning" style="display: none">The column name will be the scenario name.</p>
</div>
</li>
{% endif %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button id="downloadCSV" type="button" class="btn btn-primary" onclick="export_csv();" disabled>Download</button>
</div>
</div>
</div>
</div>
{% endfor %}
<div class="card bg-light mb-3">
<div class="card-header"><strong>Predictive CO₂ Concentration Profile</strong>
<button class="icon_button p-0 float-right" data-toggle="collapse" href="#collapseCO₂Profile" role="button" aria-expanded="true" aria-controls="collapseCO₂Profile">
<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 id="collapseCO₂Profile">
<div class="card-body">
<div>
<div id="CO2_concentration_graph" style="height: 400px"></div>
<script type="application/javascript">
var CO2_concentrations = {{ CO2_concentrations | JSONify }}
draw_generic_concentration_plot(
"CO2_concentration_graph",
{{ times | JSONify }},
"Mean concentration (ppm)",
h_lines = [
{'label': 'Acceptable level',
'y': 800,
'color': 'forestgreen',
'style': 'dashed'
},
{'label': 'Insufficient level',
'y': 1500,
'color': 'firebrick',
'style': 'dashed'
},
]
);
</script>
</div>
</div>
</div>
@ -443,7 +442,7 @@
</div>
<div class="tab-pane" id="data" role="tabpanel" aria-labelledby="data-tab" style="padding: 1%">
{% block simulation_overview %}
{% block simulation_overview scoped %}
<div class="card">
<div class="card-header"><strong>Simulation:</strong></div>
<div class="card-body">
@ -636,14 +635,19 @@
{% if form.short_range_option == "short_range_yes" %}
<li><p class="data_text">Total number of occupants having short-range interactions: {{ form.short_range_occupants }}</p></li>
<ul>
{% for interaction in form.short_range_interactions %}
<li>Interaction no. {{ loop.index }}:
<ul>
<li>Expiratory activity: {{ "Shouting/Singing" if interaction.expiration == "Shouting" else interaction.expiration }} </li>
<li>Start time: {{ interaction.start_time }} </li>
<li>Duration: {{ interaction.duration }} {{ "minutes" if interaction.duration|float > 1 else "minute" }}</li>
</ul>
</li>
{% for key, interactions in form.short_range_interactions.items() %}
<li>Interactions for group "{{ key }}":</li>
<ul>
{% for interaction in interactions %}
<li>Interaction no. {{ loop.index }}:
<ul>
<li>Expiratory activity: {{ "Shouting/Singing" if interaction.expiration == "Shouting" else interaction.expiration }} </li>
<li>Start time: {{ interaction.start_time }} </li>
<li>Duration: {{ interaction.duration }} {{ "minutes" if interaction.duration|float > 1 else "minute" }}</li>
</ul>
</li>
{% endfor %}
</ul>
{% endfor %}
</ul>
{% endif %}

View file

@ -4,26 +4,26 @@
{% set orange_prob_lim = 2 %}
{% set red_prob_lim = 10 %}
{% if form.short_range_option == "short_range_yes" %}
{% set scenario = alternative_scenarios.stats.values() | first %}
{% set long_range_prob_inf = scenario.probability_of_infection %}
{% set group_results = groups.values() | first %}
{% if group_results.get('long_range_prob') %}
{% set long_range_prob_inf = group_results.long_range_prob %}
{% else %}
{% set long_range_prob_inf = prob_inf %}
{% set long_range_prob_inf = group_results.prob_inf %}
{% endif %}
{% if ((long_range_prob_inf > red_prob_lim) or (form.occupancy_format == "static" and expected_new_cases >= 1)) %}
{% if ((long_range_prob_inf > red_prob_lim) or (form.occupancy == {} and group_results.expected_new_cases >= 1)) %}
{% set long_range_scale_warning = 'red' %}
{% set long_range_warning_color= 'bg-danger' %}
{% elif (orange_prob_lim <= long_range_prob_inf <= red_prob_lim) %}
{% set long_range_scale_warning = 'orange' %}
{% set long_range_warning_color = 'bg-warning' %}
{% set long_range_scale_warning = 'orange' %}
{% set long_range_warning_color = 'bg-warning' %}
{% else %}
{% set long_range_scale_warning = 'green' %}
{% set long_range_warning_color = 'bg-success' %}
{% endif %}
{% if ((prob_inf > red_prob_lim) or (form.occupancy_format == "static" and expected_new_cases >= 1)) %} {% set scale_warning = 'red' %}
{% elif (orange_prob_lim <= prob_inf <= red_prob_lim) %} {% set scale_warning = 'orange' %}
{% if ((group_results.prob_inf > red_prob_lim) or (form.occupancy == {} and group_results.expected_new_cases >= 1)) %} {% set scale_warning = 'red' %}
{% elif (orange_prob_lim <= group_results.prob_inf <= red_prob_lim) %} {% set scale_warning = 'orange' %}
{% else %} {% set scale_warning = 'green' %}
{% endif %}
@ -49,7 +49,7 @@
{% 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">
<b>{{prob_inf | non_zero_percentage}}</b>
<b>{{ group_results.prob_inf | non_zero_percentage}}</b>
<i class="glyphicon glyphicon-play whiteText" aria-hidden="true"></i>
<span class="ripple {{warning_color}}"></span>
<span class="ripple {{warning_color}}"></span>
@ -70,10 +70,8 @@
<div class="alert alert-success mb-0" role="alert">
<strong>Acceptable:</strong>
{% endif %}
Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally (i.e. without short-range interactions), the <b>probability of one exposed occupant getting infected is {{long_range_prob_inf | non_zero_percentage}}</b>
{% if form.occupancy_format == "static" %}
and the <b>expected number of new cases is {{ long_range_expected_cases | float_format }}</b>
{% endif %}*.
Taking into account the uncertainties tied to the model variables, in this scenario and assuming all occupants are exposed equally (i.e. without short-range interactions), the <b>probability of one exposed occupant getting infected is {{long_range_prob_inf | non_zero_percentage}}</b>
and the <b>expected number of new cases is {{ long_range_expected_cases | float_format }}</b>*.
</div>
{% if form.short_range_option == "short_range_yes" %}
<br>
@ -87,10 +85,8 @@
<div class="alert alert-success mb-0" role="alert">
<strong>Acceptable:</strong>
{% endif %}
In this scenario, the <b>probability the occupant(s) exposed to short-range interactions get infected can go as high as {{ prob_inf | non_zero_percentage }}</b>
{% if form.occupancy_format == "static" %}
and the <b>expected number of new cases increases to {{ expected_new_cases | float_format }}</b>
{% endif %}.
In this scenario, the <b>probability the occupant(s) exposed to short-range interactions get infected can go as high as {{ group_results.prob_inf | non_zero_percentage }}</b>
and the <b>expected number of new cases increases to {{ group_results.expected_new_cases | float_format }}</b>
</div>
{% endif %}
@ -98,7 +94,7 @@
{{ super() }}
{% endblock probabilistic_exposure_probability %}
{% if (prob_inf > 2) %}
{% if (group_results.prob_inf > 2) %}
<br>
{% if cern_level == "green-1" %}
<div class="alert alert-dark mb-0" role="alert" style="height:fit-content">
@ -143,12 +139,12 @@
<tr>
<th>Scenario</th>
<th>P(i)</th>
{% if form.occupancy_format == "static" %}<th>Expected new cases</th>{% endif %}
{% if form.occupancy == {} %}<th>Expected new cases</th>{% endif %}
</tr>
</thead>
<tbody>
{% for scenario_name, scenario_stats in alternative_scenarios.stats.items() %}
{%if (( scenario_stats.probability_of_infection > red_prob_lim) or (form.occupancy_format == "static" and scenario_stats.expected_new_cases >= 1)) %}
{%if (( scenario_stats.probability_of_infection > red_prob_lim) or (form.occupancy == {} and scenario_stats.expected_new_cases >= 1)) %}
<tr class="alert-danger">
{% elif (orange_prob_lim <= scenario_stats.probability_of_infection <= red_prob_lim) %}
<tr class="alert-warning">
@ -157,7 +153,7 @@
{% endif%}
<td> {{ scenario_name }}</td>
<td> {{ scenario_stats.probability_of_infection | non_zero_percentage }}</td>
{% if form.occupancy_format == "static" %}<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>{% endif %}
{% if form.occupancy == {} %}<td style="text-align:right">{{ scenario_stats.expected_new_cases | float_format }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>

View file

@ -90,6 +90,6 @@ def exposure_model_w_outside_temp_changes(data_registry, baseline_exposure_model
def baseline_form_with_sr(baseline_form_data, data_registry):
form_data_sr = baseline_form_data
form_data_sr['short_range_option'] = 'short_range_yes'
form_data_sr['short_range_interactions'] = '[{"expiration": "Shouting", "start_time": "10:30", "duration": "30"}]'
form_data_sr['short_range_interactions'] = '{"group_1": [{"expiration": "Shouting", "start_time": "10:30", "duration": 30}]}'
form_data_sr['short_range_occupants'] = 5
return virus_validator.VirusFormData.from_dict(form_data_sr, data_registry)

View file

@ -112,8 +112,8 @@ def test_expected_new_cases(baseline_form_with_sr: VirusFormData):
# Short- and Long-range contributions
report_data = rep_gen.calculate_report_data(baseline_form_with_sr, executor_factory)
sr_lr_expected_new_cases = report_data['expected_new_cases']
sr_lr_prob_inf = report_data['prob_inf']/100
sr_lr_expected_new_cases = report_data['groups']['group_1']['expected_new_cases']
sr_lr_prob_inf = report_data['groups']['group_1']['prob_inf']/100
# Long-range contributions alone
alternative_scenarios = rep_gen.manufacture_alternative_scenarios(baseline_form_with_sr)
@ -125,60 +125,25 @@ def test_expected_new_cases(baseline_form_with_sr: VirusFormData):
np.testing.assert_almost_equal(sr_lr_expected_new_cases, lr_expected_new_cases + sr_lr_prob_inf * baseline_form_with_sr.short_range_occupants, 2)
def test_static_vs_dynamic_occupancy_from_form(baseline_form_data, data_registry):
"""
Assert that the results between a static and dynamic occupancy model (from form inputs) are similar.
def test_alternative_scenarios(baseline_form):
"""
executor_factory = partial(
Tests if the alternative scenarios are only generated when
the occupancy input is empty ({}) - legacy usage.
"""
generator: VirusReportGenerator = make_app().settings['report_generator']
report_data = generator.prepare_context("", baseline_form, partial(
concurrent.futures.ThreadPoolExecutor, 1,
)
# By default the baseline form accepts static occupancy
static_occupancy_baseline_form: VirusFormData = VirusFormData.from_dict(baseline_form_data, data_registry)
static_occupancy_model = static_occupancy_baseline_form.build_model()
static_occupancy_report_data = rep_gen.calculate_report_data(static_occupancy_baseline_form, executor_factory)
))
assert "alternative_scenarios" in report_data.keys()
# Update the initial form data to include dynamic occupancy (please note the 4 coffee and 1 lunch breaks)
baseline_form_data['occupancy_format'] = 'dynamic'
baseline_form_data['dynamic_infected_occupancy'] = json.dumps([
{'total_people': 1, 'start_time': '09:00', 'finish_time': '10:03'},
{'total_people': 0, 'start_time': '10:03', 'finish_time': '10:13'},
{'total_people': 1, 'start_time': '10:13', 'finish_time': '11:16'},
{'total_people': 0, 'start_time': '11:16', 'finish_time': '11:26'},
{'total_people': 1, 'start_time': '11:26', 'finish_time': '12:30'},
{'total_people': 0, 'start_time': '12:30', 'finish_time': '13:30'},
{'total_people': 1, 'start_time': '13:30', 'finish_time': '14:53'},
{'total_people': 0, 'start_time': '14:53', 'finish_time': '15:03'},
{'total_people': 1, 'start_time': '15:03', 'finish_time': '16:26'},
{'total_people': 0, 'start_time': '16:26', 'finish_time': '16:36'},
{'total_people': 1, 'start_time': '16:36', 'finish_time': '18:00'},
])
baseline_form_data['dynamic_exposed_occupancy'] = json.dumps([
{'total_people': 9, 'start_time': '09:00', 'finish_time': '10:03'},
{'total_people': 0, 'start_time': '10:03', 'finish_time': '10:13'},
{'total_people': 9, 'start_time': '10:13', 'finish_time': '11:16'},
{'total_people': 0, 'start_time': '11:16', 'finish_time': '11:26'},
{'total_people': 9, 'start_time': '11:26', 'finish_time': '12:30'},
{'total_people': 0, 'start_time': '12:30', 'finish_time': '13:30'},
{'total_people': 9, 'start_time': '13:30', 'finish_time': '14:53'},
{'total_people': 0, 'start_time': '14:53', 'finish_time': '15:03'},
{'total_people': 9, 'start_time': '15:03', 'finish_time': '16:26'},
{'total_people': 0, 'start_time': '16:26', 'finish_time': '16:36'},
{'total_people': 9, 'start_time': '16:36', 'finish_time': '18:00'},
])
baseline_form_data['total_people'] = 0
baseline_form_data['infected_people'] = 0
dynamic_occupancy_baseline_form: VirusFormData = VirusFormData.from_dict(baseline_form_data, data_registry)
dynamic_occupancy_model = dynamic_occupancy_baseline_form.build_model()
dynamic_occupancy_report_data = rep_gen.calculate_report_data(dynamic_occupancy_baseline_form, executor_factory)
assert (list(sorted(static_occupancy_model.concentration_model.infected.presence.transition_times())) ==
list(dynamic_occupancy_model.concentration_model.infected.number.transition_times))
assert (list(sorted(static_occupancy_model.exposed.presence.transition_times())) ==
list(dynamic_occupancy_model.exposed.number.transition_times))
np.testing.assert_almost_equal(static_occupancy_report_data['prob_inf'], dynamic_occupancy_report_data['prob_inf'], 1)
assert dynamic_occupancy_report_data['expected_new_cases'] == None
assert dynamic_occupancy_report_data['prob_probabilistic_exposure'] == None
baseline_form.occupancy = {
"group_A": {
"total_people": 10,
"infected": 5,
"presence": [{"start_time": "10:00", "finish_time": "11:00"}]
}
}
report_data = generator.prepare_context("", baseline_form, partial(
concurrent.futures.ThreadPoolExecutor, 1,
))
assert "alternative_scenarios" not in report_data.keys()