Merge branch 'feature/form-to-model' into 'master'

Generate a model from the form

See merge request cara/cara!20
This commit is contained in:
Philip James Elson 2020-11-05 10:12:44 +00:00
commit 260af15781
8 changed files with 296 additions and 63 deletions

View file

@ -1,19 +1,13 @@
import json
from pathlib import Path
import jinja2
from tornado.web import Application, RequestHandler, StaticFileHandler
import cara.models
from datetime import datetime
def build_model(request: dict) -> cara.models.Model:
return None
from . import model_generator
from .report_generator import build_report
def build_response(model: cara.models.Model):
return {'items': 'foobar'}
DEBUG = True
class ConcentrationModel(RequestHandler):
@ -21,47 +15,41 @@ class ConcentrationModel(RequestHandler):
requested_model_config = {
name: self.get_argument(name) for name in self.request.arguments
}
if DEBUG:
from pprint import pprint
pprint(requested_model_config)
try:
model = build_model(requested_model_config)
form = model_generator.FormData.from_dict(requested_model_config)
model = form.build_model(
# TODO: This argument to be removed.
tmp_raw_form_data=requested_model_config,
)
except (KeyboardInterrupt, SystemExit):
raise
except Exception as err:
if DEBUG:
import traceback
traceback.print_last()
response_json = {'code': 400, 'error': f'Your request was invalid {err}'}
self.set_status(400)
self.finish(json.dumps(response_json))
return
response_json = build_response(model)
response_json['room_name'] = requested_model_config.get('room_name', 'unknown')
self.write(response_json)
report = build_report(model, form)
self.finish(report)
class StaticModel(RequestHandler):
def get(self):
import cara.apps.expert
model = cara.apps.expert.baseline_model
now = datetime.now()
time = now.strftime("%d/%m/%Y %H:%M:%S")
request = {'the': 'form', 'request': 'data'}
context = {'model': model, 'request': request, 'creation_date': time, 'model_version': 'Beta v1.0.0',
'simulation_name': 'SAMPLE', 'room_number': '40/1-02A', 'room_volume': 30, 'mechanical_ventilation': 'Yes',
'air_supply': 1, 'air_changes': 2, 'windows_number': 5, 'window_height': 2, 'window_width': 1,
'opening_distance': 0.05, 'windows_open': '20 minutes every 2 hours', 'hepa_filtration': 'No', 'total_people': 8,
'infected_people': 7, 'activity_type': 'Office work typical scenario with all persons seated, talking',
'activity_start': '00:00', 'activity_finish': '01:15', 'exposure_start': '00:00', 'exposure_finish': '01:15',
'single_event_date': '5th November', 'lunch_option': 'Yes', 'lunch_start': '00:00', 'lunch_finish': '01:15',
'coffee_option': 'Yes', 'coffee_number': 4,'coffee_duration': 15, 'coffee_start1': '00:00', 'coffee_finish1': '00:00',
'coffee_start2': '00:00','coffee_finish2': '00:00', 'coffee_start3': '00:00', 'coffee_finish3': '00:00',
'coffee_start4': '00:00', 'coffee_finish4': '00:00', 'mask_wearing': 'Yes',
'infection_probability': round(model.infection_probability(), 2), 'reproduction_rate': 2}
p = Path(__file__).parent / 'templates'
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(Path(p)))
template = env.get_template('report.html.j2')
self.write(template.render(**context))
requested_model_config = model_generator.baseline_raw_form_data()
form = model_generator.FormData.from_dict(model_generator.baseline_raw_form_data())
model = form.build_model(
# TODO: This argument to be removed.
tmp_raw_form_data=requested_model_config,
)
report = build_report(model, form)
self.finish(report)
def make_app(debug=False, prefix='/calculator'):
@ -71,7 +59,7 @@ def make_app(debug=False, prefix='/calculator'):
prefix + r'()', StaticFileHandler, {'path': static_dir / 'form.html'}
),
(
prefix + r'/api/calculator', ConcentrationModel
prefix + r'/report', ConcentrationModel
),
(
prefix + r'/baseline-model/result', StaticModel

View file

@ -0,0 +1,190 @@
from cara.models import Model
from dataclasses import dataclass
import typing
from cara import models
@dataclass
class FormData:
ceiling_height: float
@classmethod
def from_dict(cls, form_data: typing.Dict) -> "FormData":
# TODO: This fixup is a problem with the form.html.
form_data['ceiling_height'] = 1
return cls(
ceiling_height=float(form_data['ceiling_height']),
)
# TODO: Remove the tmp_raw_form_data usage.
def build_model(self, tmp_raw_form_data) -> Model:
return model_from_form(self, tmp_raw_form_data)
def ventilation(self) -> models.Ventilation:
# TODO
pass
def present_interval(self) -> models.Interval:
# TODO
pass
def model_from_form(form: FormData, tmp_raw_form_data) -> models.Model:
d = tmp_raw_form_data
# TODO: This fixup is a problem with the form.html.
d['coffee_breaks'] = 1
d['activity_type'] = 'Training'
d['lunch_start'] = '12:00'
d['lunch_finish'] = '13:00'
# Initializes room with volume either given directly or as product of area and height
if d['volume_type'] == 'room_volume':
volume = int(d['room_volume'])
else:
volume = int(float(d['floor_area']) * form.ceiling_height)
room = models.Room(volume=volume)
# Initializes a ventilation instance as a window if 'natural' is selected, or as a HEPA-filter otherwise
if d['ventilation_type'] == 'natural':
if d['windows_open'] == 'always':
period, duration = 120, 120
else:
period, duration = 15, 120
# I multiply the opening width by the number of windows to simulate the correct window area
ventilation = models.WindowOpening(active=models.PeriodicInterval(period=period, duration=duration),
inside_temp=293, outside_temp=283, cd_b=0.6,
window_height=float(d['window_height']),
opening_length=float(d['opening_distance']) * int(d['windows_number']))
else:
q_air_mech = float(d['air_changes']) if d['air_type'] == 'air_changes' else float(d['air_supply'])
ventilation = models.HEPAFilter(active=models.PeriodicInterval(period=120, duration=120),
q_air_mech=q_air_mech)
# Initializes the virus as SARS_Cov_2
virus = models.Virus.types['SARS_CoV_2']
# Defines all of the parameters required to construct a list of intervals where the infected person is present in
# the room
activity_start = int(d['activity_start'][:2]) * 60 + int(d['activity_start'][3:])
activity_finish = int(d['activity_finish'][:2]) * 60 + int(d['activity_finish'][3:])
lunch_start = int(d['lunch_start'][:2]) * 60 + int(d['lunch_start'][3:])
lunch_finish = int(d['lunch_finish'][:2]) * 60 + int(d['lunch_finish'][3:])
coffee_duration = int(d['coffee_duration'])
coffee_breaks = int(d['coffee_breaks'])
coffee_period = (activity_finish - activity_start) // coffee_breaks + 1
leave_times = [lunch_start]
enter_times = [lunch_finish]
for minute in range(activity_start, activity_finish, coffee_period):
leave_times.append(minute)
enter_times.append(minute + coffee_duration)
# These lists represent the times where the infected person leaves or enters the room, respectively, sorted in
# reverse order. Note that these lists allows the person to "leave" when they should not even be present in the room
# The following loop handles this.
leave_times.sort(reverse=True)
enter_times.sort(reverse=True)
# This loop iterates through the lists above, populating present_intervals with (enter, leave) intervals
# representing the infected person entering and leaving the room. Note that if one of the evenly spaced coffee-
# breaks happens to coincide with the lunch-break, it is simply ignored.
is_present = True
present_intervals = []
time = activity_start
while time < activity_finish:
if is_present:
if not leave_times:
present_intervals.append((time / 60, activity_finish / 60))
break
if leave_times[-1] < time:
leave_times.pop()
else:
new_time = leave_times.pop()
present_intervals.append((time / 60, min(new_time, activity_finish) / 60))
is_present = False
time = new_time
else:
if not enter_times:
break
if enter_times[-1] < time:
enter_times.pop()
else:
is_present = True
time = enter_times.pop()
# Initializes a mask of type 1 if mask wearing is "continuous", otherwise instantiates the mask attribute as
# the "No mask"-mask
mask = models.Mask.types['Type I' if d['mask_wearing'] == "Continuous" else 'No mask']
# A dictionary containing the mapping of activities listed in the UI to the activity level and expiration level
# of the infected and exposed occupants respectively.
# I.e. (infected_activity, infected_expiration), (exposed_activity, exposed_expiration)
activity_dict = {'Office/Meeting': (('Seated', 'Talking'), ('Seated', 'Talking')),
'Training': (('Light exercise', 'Talking'), ('Seated', 'Whispering')),
'Workshop': (('Light exercise', 'Talking'), ('Light exercise', 'Talking'))}
(infected_activity, infected_expiration), (exposed_activity, exposed_expiration) = activity_dict[d['activity_type']]
# Converts these strings to Activity and Expiration instances
infected_activity, exposed_activity = models.Activity.types[infected_activity], models.Activity.types[exposed_activity]
infected_expiration, exposed_expiration = models.Expiration.types[infected_expiration], models.Expiration.types[exposed_expiration]
infected_occupants = int(d['infected_people'])
# Defines the number of exposed occupants as the total number of occupants minus the number of infected occupants
exposed_occupants = int(d['total_people']) - infected_occupants
# Initializes and returns a model with the attributes defined above
return models.Model(
room=room,
ventilation=ventilation,
infected=models.InfectedPerson(
virus=virus,
presence=models.SpecificInterval(tuple(present_intervals)),
mask=mask,
activity=infected_activity,
expiration=infected_expiration
),
infected_occupants=infected_occupants,
exposed_occupants=exposed_occupants,
exposed_activity=exposed_activity
)
def baseline_raw_form_data():
# Note: This isn't a special "baseline". It can be updated as required.
return {
'activity_finish': '17:00',
'activity_start': '09:00',
'activity_type': 'training',
'air_changes': '',
'air_supply': '',
'ceiling_height': '',
'coffee_breaks': '',
'coffee_duration': '1',
'coffee_option': '0',
'event_type': 'single_event',
'floor_area': '',
'infected_people': '1',
'lunch_finish': '13:30',
'lunch_option': '1',
'lunch_start': '12:30',
'mask_wearing': 'removed',
'opening_distance': '15',
'recurrent_event_month': 'January',
'room_number': 'baseline room',
'room_volume': '75',
'simulation_name': 'Baseline simulation',
'single_event_date': '11/02/2020',
'total_people': '10',
'ventilation_type': 'natural',
'volume_type': 'room_volume',
'window_height': '2',
'window_width': '2',
'windows_number': '1',
'windows_open': 'interval'
}

View file

@ -0,0 +1,30 @@
from datetime import datetime
from pathlib import Path
import jinja2
from cara import models
from .model_generator import FormData
def build_report(model: models.Model, form: FormData):
now = datetime.now()
time = now.strftime("%d/%m/%Y %H:%M:%S")
request = {'the': 'form', 'request': 'data'}
context = {'model': model, 'request': request, 'creation_date': time, 'model_version': 'Beta v1.0.0',
'simulation_name': 'SAMPLE', 'room_number': '40/1-02A', 'room_volume': 30, 'mechanical_ventilation': 'Yes',
'air_supply': 1, 'air_changes': 2, 'windows_number': 5, 'window_height': 2, 'window_width': 1,
'opening_distance': 0.05, 'windows_open': '20 minutes every 2 hours', 'hepa_filtration': 'No', 'total_people': 8,
'infected_people': 7, 'activity_type': 'Office work typical scenario with all persons seated, talking',
'activity_start': '00:00', 'activity_finish': '01:15', 'exposure_start': '00:00', 'exposure_finish': '01:15',
'single_event_date': '5th November', 'lunch_option': 'Yes', 'lunch_start': '00:00', 'lunch_finish': '01:15',
'coffee_option': 'Yes', 'coffee_number': 4,'coffee_duration': 15, 'coffee_start1': '00:00', 'coffee_finish1': '00:00',
'coffee_start2': '00:00','coffee_finish2': '00:00', 'coffee_start3': '00:00', 'coffee_finish3': '00:00',
'coffee_start4': '00:00', 'coffee_finish4': '00:00', 'mask_wearing': 'Yes',
'infection_probability': round(model.infection_probability(), 2), 'reproduction_rate': 2}
p = Path(__file__).parent / 'templates'
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(Path(p)))
template = env.get_template('report.html.j2')
return template.render(**context)

View file

@ -12,11 +12,11 @@
</head>
<body onload="clear_form()">
<body>
<h1> <p><b>CARA</b> Covid Calculator </p></h1>
<form id="covid_calculator" name="covid_calculator" onsubmit='return on_submit(this)'>
<form id="covid_calculator" name="covid_calculator" action="/calculator/report" method="POST" onsubmit='return on_submit(this)'>
<div style="width: 33%; float:left;">
<!-- General Options -->
@ -190,7 +190,7 @@ var request;
function on_submit(form){
// Prevent default posting of form - put here to work in case of errors
event.preventDefault();
// event.preventDefault();
// Abort any pending request
if (request)
@ -199,32 +199,32 @@ function on_submit(form){
// Let's select and cache all the fields
var $inputs = $(form).find("input, select, button, textarea");
// Serialize the data in the form
var serializedData = objectifyForm($(form).serializeArray());
// // Serialize the data in the form
// var serializedData = objectifyForm($(form).serializeArray());
console.log(['Sending over', JSON.stringify(serializedData)])
// console.log(['Sending over', JSON.stringify(serializedData)])
// Fire off the request to the calculator.
request = $.ajax({
url: "/calculator/api/calculator",
type: "post",
data: serializedData,
dataType: "json",
});
// // Fire off the request to the calculator.
// request = $.ajax({
// url: "/calculator/report",
// type: "post",
// data: serializedData,
// dataType: "json",
// });
// Callback handler that will be called on success
request.done(function (response, textStatus, jqXHR){
build_report(response);
});
// // Callback handler that will be called on success
// request.done(function (response, textStatus, jqXHR){
// build_report(response);
// });
// Callback handler that will be called on failure
request.fail(function (jqXHR, textStatus, errorThrown){
// Log the error to the console
console.error(
"The following error occurred: "+
textStatus, errorThrown
);
});
// // Callback handler that will be called on failure
// request.fail(function (jqXHR, textStatus, errorThrown){
// // Log the error to the console
// console.error(
// "The following error occurred: "+
// textStatus, errorThrown
// );
// });
}
// Convert all type int in form

View file

View file

View file

@ -0,0 +1,25 @@
import pytest
from cara.apps.calculator import model_generator
@pytest.fixture
def baseline_form_data():
return model_generator.baseline_raw_form_data()
@pytest.fixture
def baseline_form(baseline_form_data):
return model_generator.FormData.from_dict(baseline_form_data)
def test_model_from_dict(baseline_form_data):
model = model_generator.FormData.from_dict(baseline_form_data)
# TODO:
# assert model.ventilation == cara.models.Ventilation()
def test_ventilation(baseline_form):
ventilation = baseline_form.ventilation()
# TODO:
# assert ventilation == cara.models.Ventilation()