Merge branch 'feature/parallel_scenarios' into 'master'

Distribute the calculation of alternative scenarios

See merge request cara/cara!209
This commit is contained in:
Philip James Elson 2021-07-12 08:06:55 +00:00
commit db9af9cf04
3 changed files with 77 additions and 79 deletions

View file

@ -96,6 +96,7 @@ class ConcentrationModel(BaseRequestHandler):
if self.settings.get("debug", False): if self.settings.get("debug", False):
from pprint import pprint from pprint import pprint
pprint(requested_model_config) pprint(requested_model_config)
start = datetime.datetime.now()
try: try:
form = model_generator.FormData.from_dict(requested_model_config) form = model_generator.FormData.from_dict(requested_model_config)
@ -114,6 +115,9 @@ class ConcentrationModel(BaseRequestHandler):
report_generator.build_report, base_url, form, report_generator.build_report, base_url, form,
) )
report: str = await asyncio.wrap_future(report_task) report: str = await asyncio.wrap_future(report_task)
if self.settings.get("debug", False):
dt = (datetime.datetime.now() - start)
print(f'Report response time {dt.seconds}.{dt.microseconds}s')
self.finish(report) self.finish(report)

View file

@ -22,7 +22,8 @@ minutes_since_midnight = typing.NewType('minutes_since_midnight', int)
# Used to declare when an attribute of a class must have a value provided, and # Used to declare when an attribute of a class must have a value provided, and
# there should be no default value used. # there should be no default value used.
_NO_DEFAULT = object() _NO_DEFAULT = object()
_SAMPLE_SIZE = 50000 _DEFAULT_MC_SAMPLE_SIZE = 50000
@dataclass @dataclass
class FormData: class FormData:
@ -218,8 +219,30 @@ class FormData:
raise ValueError("mechanical_ventilation_type cannot be 'not-applicable' if " raise ValueError("mechanical_ventilation_type cannot be 'not-applicable' if "
"ventilation_type is 'mechanical_ventilation'") "ventilation_type is 'mechanical_ventilation'")
def build_model(self) -> models.ExposureModel: def build_mc_model(self) -> mc.ExposureModel:
return model_from_form(self) # Initializes room with volume either given directly or as product of area and height
if self.volume_type == 'room_volume_explicit':
volume = self.room_volume
else:
volume = self.floor_area * self.ceiling_height
if self.room_heating_option:
humidity = 0.3
else:
humidity = 0.5
room = models.Room(volume=volume, humidity=humidity)
# Initializes and returns a model with the attributes defined above
return mc.ExposureModel(
concentration_model=mc.ConcentrationModel(
room=room,
ventilation=self.ventilation(),
infected=self.infected_population(),
),
exposed=self.exposed_population()
)
def build_model(self, sample_size=_DEFAULT_MC_SAMPLE_SIZE) -> models.ExposureModel:
return self.build_mc_model().build_model(size=sample_size)
def ventilation(self) -> models._VentilationBase: def ventilation(self) -> models._VentilationBase:
always_on = models.PeriodicInterval(period=120, duration=120) always_on = models.PeriodicInterval(period=120, duration=120)
@ -553,29 +576,6 @@ def build_expiration(expiration_definition) -> models._ExpirationBase:
) )
def model_from_form(form: FormData) -> models.ExposureModel:
# Initializes room with volume either given directly or as product of area and height
if form.volume_type == 'room_volume_explicit':
volume = form.room_volume
else:
volume = form.floor_area * form.ceiling_height
if form.room_heating_option:
humidity = 0.3
else:
humidity = 0.5
room = models.Room(volume=volume, humidity=humidity)
# Initializes and returns a model with the attributes defined above
return mc.ExposureModel(
concentration_model=mc.ConcentrationModel(
room=room,
ventilation=form.ventilation(),
infected=form.infected_population(),
),
exposed=form.exposed_population()
).build_model(size=_SAMPLE_SIZE)
def baseline_raw_form_data(): def baseline_raw_form_data():
# Note: This isn't a special "baseline". It can be updated as required. # Note: This isn't a special "baseline". It can be updated as required.
return { return {

View file

@ -1,3 +1,4 @@
import concurrent.futures
import base64 import base64
import dataclasses import dataclasses
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -14,17 +15,11 @@ import matplotlib.pyplot as plt
import numpy as np import numpy as np
from cara import models from cara import models
from .model_generator import FormData from ... import monte_carlo as mc
from .model_generator import FormData, _DEFAULT_MC_SAMPLE_SIZE
from ... import dataclass_utils from ... import dataclass_utils
@dataclasses.dataclass(frozen=True)
class RepeatEvents:
repeats: int
probability_of_infection: float
expected_new_cases: float
def model_start_end(model: models.ExposureModel): def model_start_end(model: models.ExposureModel):
t_start = min(model.exposed.presence.boundaries()[0][0], t_start = min(model.exposed.presence.boundaries()[0][0],
model.concentration_model.infected.presence.boundaries()[0][0]) model.concentration_model.infected.presence.boundaries()[0][0])
@ -46,18 +41,6 @@ def calculate_report_data(model: models.ExposureModel):
exposed_occupants = model.exposed.number exposed_occupants = model.exposed.number
expected_new_cases = np.mean(model.expected_new_cases()) expected_new_cases = np.mean(model.expected_new_cases())
repeated_events = []
for n in [1, 2, 3, 4, 5]:
repeat_model = dataclass_utils.replace(model, repeats=n)
repeated_events.append(
RepeatEvents(
repeats=n,
probability_of_infection=np.mean(repeat_model.infection_probability()),
expected_new_cases=np.mean(repeat_model.expected_new_cases()),
)
)
return { return {
"times": times, "times": times,
"concentrations": concentrations, "concentrations": concentrations,
@ -67,7 +50,6 @@ def calculate_report_data(model: models.ExposureModel):
"exposed_occupants": exposed_occupants, "exposed_occupants": exposed_occupants,
"expected_new_cases": expected_new_cases, "expected_new_cases": expected_new_cases,
"scenario_plot_src": img2base64(_figure2bytes(plot(times, concentrations, model))), "scenario_plot_src": img2base64(_figure2bytes(plot(times, concentrations, model))),
"repeated_events": repeated_events,
} }
@ -187,17 +169,17 @@ def non_zero_percentage(percentage: int) -> str:
return "{:0.0f}%".format(percentage) return "{:0.0f}%".format(percentage)
def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, models.ExposureModel]: def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.ExposureModel]:
scenarios = {} scenarios = {}
# Two special option cases - HEPA and/or FFP2 masks. # Two special option cases - HEPA and/or FFP2 masks.
FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2') FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2')
if FFP2_being_worn and form.hepa_option: if FFP2_being_worn and form.hepa_option:
scenarios['Base scenario with HEPA and FFP2 masks'] = form.build_model() scenarios['Base scenario with HEPA and FFP2 masks'] = form.build_mc_model()
elif FFP2_being_worn: elif FFP2_being_worn:
scenarios['Base scenario with FFP2 masks'] = form.build_model() scenarios['Base scenario with FFP2 masks'] = form.build_mc_model()
elif form.hepa_option: elif form.hepa_option:
scenarios['Base scenario with HEPA filter'] = form.build_model() scenarios['Base scenario with HEPA filter'] = form.build_mc_model()
# The remaining scenarios are based on Type I masks (possibly not worn) # The remaining scenarios are based on Type I masks (possibly not worn)
# and no HEPA filtration. # and no HEPA filtration.
@ -209,47 +191,40 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, models
without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off') without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off')
if form.ventilation_type == 'mechanical_ventilation': if form.ventilation_type == 'mechanical_ventilation':
scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_model() scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model()
scenarios['Mechanical ventilation without masks'] = without_mask.build_model() scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model()
elif form.ventilation_type == 'natural_ventilation': elif form.ventilation_type == 'natural_ventilation':
scenarios['Windows open with Type I masks'] = with_mask.build_model() scenarios['Windows open with Type I masks'] = with_mask.build_mc_model()
scenarios['Windows open without masks'] = without_mask.build_model() scenarios['Windows open without masks'] = without_mask.build_mc_model()
# No matter the ventilation scheme, we include scenarios which don't have any ventilation. # No matter the ventilation scheme, we include scenarios which don't have any ventilation.
with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation') with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation')
without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation') without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation')
scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_model() scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model()
scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_model() scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model()
return scenarios return scenarios
def comparison_plot(scenarios: typing.Dict[str, models.ExposureModel]): def comparison_plot(scenarios: typing.Dict[str, dict], sample_times: np.ndarray):
fig = plt.figure() fig = plt.figure()
ax = fig.add_subplot(1, 1, 1) ax = fig.add_subplot(1, 1, 1)
resolution = 350
times = None
dash_styled_scenarios = [ dash_styled_scenarios = [
'Base scenario with FFP2 masks', 'Base scenario with FFP2 masks',
'Base scenario with HEPA filter', 'Base scenario with HEPA filter',
'Base scenario with HEPA and FFP2 masks', 'Base scenario with HEPA and FFP2 masks',
] ]
for name, model in scenarios.items(): sample_dts = [datetime(1970, 1, 1) + timedelta(hours=time) for time in sample_times]
if times is None: for name, statistics in scenarios.items():
t_start, t_end = model_start_end(model) concentrations = statistics['concentrations']
times = np.linspace(t_start, t_end, resolution)
datetimes = [datetime(1970, 1, 1) + timedelta(hours=time) for time in times]
concentrations = [np.mean(model.concentration_model.concentration(time))
for time in times]
if name in dash_styled_scenarios: if name in dash_styled_scenarios:
ax.plot(datetimes, concentrations, label=name, linestyle='--') ax.plot(sample_dts, concentrations, label=name, linestyle='--')
else: else:
ax.plot(datetimes, concentrations, label=name, linestyle='-', alpha=0.5) ax.plot(sample_dts, concentrations, label=name, linestyle='-', alpha=0.5)
# Place a legend outside of the axes itself. # Place a legend outside of the axes itself.
ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left') ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
@ -264,15 +239,31 @@ def comparison_plot(scenarios: typing.Dict[str, models.ExposureModel]):
return fig return fig
def comparison_report(scenarios: typing.Dict[str, models.ExposureModel]): def scenario_statistics(mc_model: mc.ExposureModel, sample_times: np.ndarray):
statistics = {} model = mc_model.build_model(size=_DEFAULT_MC_SAMPLE_SIZE)
for name, model in scenarios.items():
statistics[name] = {
'probability_of_infection': np.mean(model.infection_probability()),
'expected_new_cases': np.mean(model.expected_new_cases()),
}
return { return {
'plot': img2base64(_figure2bytes(comparison_plot(scenarios))), 'probability_of_infection': np.mean(model.infection_probability()),
'expected_new_cases': np.mean(model.expected_new_cases()),
'concentrations': [
np.mean(model.concentration_model.concentration(time))
for time in sample_times
],
}
def comparison_report(scenarios: typing.Dict[str, mc.ExposureModel], sample_times: np.ndarray):
statistics = {}
with concurrent.futures.ProcessPoolExecutor() as executor:
results = executor.map(
scenario_statistics,
scenarios.values(),
[sample_times] * len(scenarios),
timeout=60,
)
for (name, model), model_stats in zip(scenarios.items(), results):
statistics[name] = model_stats
return {
'plot': img2base64(_figure2bytes(comparison_plot(statistics, sample_times))),
'stats': statistics, 'stats': statistics,
} }
@ -297,9 +288,12 @@ class ReportGenerator:
'creation_date': time, 'creation_date': time,
} }
t_start, t_end = model_start_end(model)
scenario_sample_times = np.linspace(t_start, t_end, 350)
context.update(calculate_report_data(model)) context.update(calculate_report_data(model))
alternative_scenarios = manufacture_alternative_scenarios(form) alternative_scenarios = manufacture_alternative_scenarios(form)
context['alternative_scenarios'] = comparison_report(alternative_scenarios) context['alternative_scenarios'] = comparison_report(alternative_scenarios, scenario_sample_times)
context['qr_code'] = generate_qr_code(base_url, self.calculator_prefix, form) context['qr_code'] = generate_qr_code(base_url, self.calculator_prefix, form)
context['calculator_prefix'] = self.calculator_prefix context['calculator_prefix'] = self.calculator_prefix
return context return context