diff --git a/README.md b/README.md
index 1be26e55..7853027d 100644
--- a/README.md
+++ b/README.md
@@ -58,19 +58,6 @@ CARA has not undergone review, approval or certification by competent authoritie
The software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and non-infringement.
In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software.
-## Adapting CARA to your location
-
-The default weather data (average hourly outdoor temperature in Celcius for each month of the year) used in CARA is for Geneva, Switzerland.
-In order for the natural ventilation option to work correctly for other geographic locations, the outdoor temperatures must be updated.
-There are some scripts to help download and process the temperature data from your nearest weather station in the https://gitlab.cern.ch/cara/climatology-data repository.
-Once you have used the scripts, the hourly temperature data for your location should be added to the file `data.py` in place of the default values for Geneva. The temperature values for your locations should be pasted into the `Geneva_hourly_temperatures_celsius_per_hour` variable, **without changing the variable name** in the following format:
-
- `'Jan': [0.2, -0.3, -0.5, -0.9, -1.1, -1.4, -1.5, -1.5, -1.1, 0.1, 1.5,
- 2.8, 3.8, 4.4, 4.5, 4.4, 4.4, 3.9, 3.1, 2.7, 2.2, 1.7, 1.5, 1.1],
- 'Feb': [0.9, 0.3, 0.0, -0.5, -0.7, -1.1, -1.2, -1.1, -0.7, 0.8, 2.5,
- 4.2, 5.4, 6.2, 6.3, 6.2, 6.1, 5.5, 4.5, 4.1, 3.5, 2.8, 2.5, 2.0],...`
-
-CARA currently supports **only one geographic location for weather data per instance**.
## Running CARA locally
@@ -85,10 +72,20 @@ This will start a local version of CARA, which can be visited at http://localhos
## Development guide
+The CARA repository makes use of Git's Large File Storage (LFS) feature.
+You will need a working installation of git-lfs in order to run CARA in development mode.
+See https://git-lfs.github.com/ for installation instructions.
+
+### Installing CARA in editable mode
+
+```
+git lfs pull # Fetch the data from LFS
+pip install -e . # At the root of the repository
+```
+
### Running the COVID calculator app in development mode
```
-pip install -e . # At the root of the repository
python -m cara.apps.calculator
```
@@ -107,7 +104,6 @@ python -m cara.apps.calculator --prefix=/mycalc
### Running the CARA Expert-App app in development mode
```
-pip install -e . # At the root of the repository
voila cara/apps/expert/cara.ipynb --port=8080
```
diff --git a/cara/.gitattributes b/cara/.gitattributes
new file mode 100644
index 00000000..c1609315
--- /dev/null
+++ b/cara/.gitattributes
@@ -0,0 +1,2 @@
+global_weather_set.json filter=lfs diff=lfs merge=lfs -text
+hadisd_station_fullinfo_v311_202001p.txt filter=lfs diff=lfs merge=lfs -text
diff --git a/cara/apps/calculator/__init__.py b/cara/apps/calculator/__init__.py
index dbfa15ef..1688d494 100644
--- a/cara/apps/calculator/__init__.py
+++ b/cara/apps/calculator/__init__.py
@@ -33,7 +33,7 @@ from .user import AuthenticatedUser, AnonymousUser
# calculator version. If the calculator needs to make breaking changes (e.g. change
# form attributes) then it can also increase its MAJOR version without needing to
# increase the overall CARA version (found at ``cara.__version__``).
-__version__ = "2.1.0"
+__version__ = "3.0.0"
class BaseRequestHandler(RequestHandler):
diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py
index a4b139e7..06c29229 100644
--- a/cara/apps/calculator/model_generator.py
+++ b/cara/apps/calculator/model_generator.py
@@ -1,5 +1,5 @@
import dataclasses
-from dataclasses import dataclass
+import datetime
import html
import logging
import typing
@@ -8,9 +8,10 @@ import numpy as np
from cara import models
from cara import data
+import cara.data.weather
import cara.monte_carlo as mc
from .. import calculator
-from cara.monte_carlo.data import activity_distributions, virus_distributions
+from cara.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions
LOG = logging.getLogger(__name__)
@@ -25,7 +26,7 @@ _NO_DEFAULT = object()
_DEFAULT_MC_SAMPLE_SIZE = 50000
-@dataclass
+@dataclasses.dataclass
class FormData:
activity_type: str
air_changes: float
@@ -50,6 +51,9 @@ class FormData:
infected_lunch_start: minutes_since_midnight #Used if infected_dont_have_breaks_with_exposed
infected_people: int
infected_start: minutes_since_midnight
+ location_name: str
+ location_latitude: float
+ location_longitude: float
mask_type: str
mask_wearing_option: str
mechanical_ventilation_type: str
@@ -100,6 +104,9 @@ class FormData:
'infected_lunch_start': '12:30',
'infected_people': _NO_DEFAULT,
'infected_start': '08:30',
+ 'location_latitude': _NO_DEFAULT,
+ 'location_longitude': _NO_DEFAULT,
+ 'location_name': _NO_DEFAULT,
'mask_type': 'Type I',
'mask_wearing_option': 'mask_off',
'mechanical_ventilation_type': 'not-applicable',
@@ -197,7 +204,8 @@ class FormData:
('virus_type', VIRUS_TYPES),
('volume_type', VOLUME_TYPES),
('window_opening_regime', WINDOWS_OPENING_REGIMES),
- ('window_type', WINDOWS_TYPES)]
+ ('window_type', WINDOWS_TYPES),
+ ('event_month', MONTH_NAMES)]
for attr_name, valid_set in validation_tuples:
if getattr(self, attr_name) not in valid_set:
raise ValueError(f"{getattr(self, attr_name)} is not a valid value for {attr_name}")
@@ -244,6 +252,52 @@ class FormData:
def build_model(self, sample_size=_DEFAULT_MC_SAMPLE_SIZE) -> models.ExposureModel:
return self.build_mc_model().build_model(size=sample_size)
+ def tz_name_and_utc_offset(self) -> typing.Tuple[str, float]:
+ """
+ Return the timezone name (e.g. CET), and offset, in hours, that need to
+ be *added* to UTC to convert to the form location's timezone.
+
+ """
+ month = MONTH_NAMES.index(self.event_month) + 1
+ timezone = cara.data.weather.timezone_at(
+ latitude=self.location_latitude, longitude=self.location_longitude,
+ )
+ # We choose the first of the month for the current year.
+ date = datetime.datetime(datetime.datetime.now().year, month, 1)
+ name = timezone.tzname(date)
+ assert isinstance(name, str)
+ utc_offset_td = timezone.utcoffset(date)
+ assert isinstance(utc_offset_td, datetime.timedelta)
+ utc_offset_hours = utc_offset_td.total_seconds() / 60 / 60
+ return name, utc_offset_hours
+
+ def outside_temp(self) -> models.PiecewiseConstant:
+ """
+ Return the outside temperature as a PiecewiseConstant in the destination
+ timezone.
+
+ """
+ month = MONTH_NAMES.index(self.event_month) + 1
+
+ wx_station = self.nearest_weather_station()
+ temp_profile = cara.data.weather.mean_hourly_temperatures(wx_station[0], month)
+
+ _, utc_offset = self.tz_name_and_utc_offset()
+
+ # Offset the source times according to the difference from UTC (as a
+ # result the first data value may no longer be a midnight, and the hours
+ # no longer ordered modulo 24).
+ source_times = np.arange(24) + utc_offset
+ times, temp_profile = cara.data.weather.refine_hourly_data(
+ source_times,
+ temp_profile,
+ npts=24*10, # 10 steps per hour => 6 min steps
+ )
+ outside_temp = models.PiecewiseConstant(
+ tuple(float(t) for t in times), tuple(float(t) for t in temp_profile),
+ )
+ return outside_temp
+
def ventilation(self) -> models._VentilationBase:
always_on = models.PeriodicInterval(period=120, duration=120)
# Initializes a ventilation instance as a window if 'natural_ventilation' is selected, or as a HEPA-filter otherwise
@@ -253,10 +307,8 @@ class FormData:
else:
window_interval = always_on
- month = self.event_month[:3]
-
+ outside_temp = self.outside_temp()
inside_temp = models.PiecewiseConstant((0, 24), (293,))
- outside_temp = data.GenevaTemperatures[month]
ventilation: models.Ventilation
if self.window_type == 'window_sliding':
@@ -298,10 +350,19 @@ class FormData:
else:
return models.MultipleVentilation((ventilation, infiltration_ventilation))
+ def nearest_weather_station(self) -> cara.data.weather.WxStationRecordType:
+ """Return the nearest weather station (which has valid data) for this form"""
+ return cara.data.weather.nearest_wx_station(
+ longitude=self.location_longitude, latitude=self.location_latitude
+ )
+
def mask(self) -> models.Mask:
# Initializes the mask type if mask wearing is "continuous", otherwise instantiates the mask attribute as
# the "No mask"-mask
- mask = models.Mask.types[self.mask_type if self.mask_wearing_option == "mask_on" else 'No mask']
+ if self.mask_wearing_option == 'mask_on':
+ mask = mask_distributions[self.mask_type]
+ else:
+ mask = models.Mask.types['No mask']
return mask
def infected_population(self) -> mc.InfectedPopulation:
@@ -601,6 +662,9 @@ def baseline_raw_form_data():
'infected_lunch_start': '12:30',
'infected_people': '1',
'infected_start': '09:00',
+ 'location_latitude': 46.20833,
+ 'location_longitude': 6.14275,
+ 'location_name': 'Geneva',
'mask_type': 'Type I',
'mask_wearing_option': 'mask_off',
'mechanical_ventilation_type': '',
@@ -637,6 +701,11 @@ WINDOWS_TYPES = {'window_sliding', 'window_hinged', 'not-applicable'}
COFFEE_OPTIONS_INT = {'coffee_break_0': 0, 'coffee_break_1': 1, 'coffee_break_2': 2, 'coffee_break_4': 4}
+MONTH_NAMES = [
+ 'January', 'February', 'March', 'April', 'May', 'June', 'July',
+ 'August', 'September', 'October', 'November', 'December',
+]
+
def _hours2timestring(hours: float):
# Convert times like 14.5 to strings, like "14:30"
@@ -692,4 +761,3 @@ for _field in dataclasses.fields(FormData):
elif _field.type is bool:
_CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = lambda v: v == '1'
_CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = int
-
diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py
index 7acfe8c4..0a444c4e 100644
--- a/cara/apps/calculator/report_generator.py
+++ b/cara/apps/calculator/report_generator.py
@@ -9,11 +9,9 @@ import zlib
import loky
import jinja2
-import matplotlib
-matplotlib.use('agg')
-import matplotlib.pyplot as plt
import numpy as np
import qrcode
+import json
from cara import models
from ... import monte_carlo as mc
@@ -162,50 +160,13 @@ def _img2bytes(figure):
return img_data
-def _figure2bytes(figure):
- # Draw the image
- img_data = io.BytesIO()
- figure.savefig(img_data, format='png', bbox_inches="tight", transparent=True)
- return img_data
-
-
def img2base64(img_data) -> str:
- plt.close()
img_data.seek(0)
pic_hash = base64.b64encode(img_data.read()).decode('ascii')
# A src suitable for a tag such as f'.
return f'data:image/png;base64,{pic_hash}'
-def plot(times, concentrations, model: models.ExposureModel):
- fig = plt.figure()
- ax = fig.add_subplot(1, 1, 1)
- datetimes = [datetime(1970, 1, 1) + timedelta(hours=time) for time in times]
- ax.plot(datetimes, concentrations, lw=2, color='#1f77b4', label='Mean concentration')
- ax.spines['right'].set_visible(False)
- ax.spines['top'].set_visible(False)
-
- ax.set_xlabel('Time of day')
- ax.set_ylabel('Mean concentration ($virions/m^{3}$)')
- ax.set_title('Mean concentration of virions')
- ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M"))
-
- # Plot presence of exposed person
- for i, (presence_start, presence_finish) in enumerate(model.exposed.presence.boundaries()):
- plt.fill_between(
- datetimes, concentrations, 0,
- where=(np.array(times) > presence_start) & (np.array(times) < presence_finish),
- color="#1f77b4", alpha=0.1,
- label="Presence of exposed person(s)" if i == 0 else ""
- )
-
- # Place a legend outside of the axes itself.
- ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
- ax.set_ylim(0)
-
- return fig
-
-
def minutes_to_time(minutes: int) -> str:
minute_string = str(minutes % 60)
minute_string = "0" * (2 - len(minute_string)) + minute_string
@@ -281,39 +242,7 @@ def manufacture_alternative_scenarios(form: FormData) -> typing.Dict[str, mc.Exp
return scenarios
-def comparison_plot(scenarios: typing.Dict[str, dict], sample_times: typing.List[float]):
- fig = plt.figure()
- ax = fig.add_subplot(1, 1, 1)
-
- dash_styled_scenarios = [
- 'Base scenario with FFP2 masks',
- 'Base scenario with HEPA filter',
- 'Base scenario with HEPA and FFP2 masks',
- ]
-
- sample_dts = [datetime(1970, 1, 1) + timedelta(hours=time) for time in sample_times]
- for name, statistics in scenarios.items():
- concentrations = statistics['concentrations']
-
- if name in dash_styled_scenarios:
- ax.plot(sample_dts, concentrations, label=name, linestyle='--')
- else:
- ax.plot(sample_dts, concentrations, label=name, linestyle='-', alpha=0.5)
-
- # Place a legend outside of the axes itself.
- ax.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
- ax.spines['right'].set_visible(False)
- ax.spines['top'].set_visible(False)
- ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M"))
-
- ax.set_xlabel('Time of day')
- ax.set_ylabel('Mean concentration ($virions/m^{3}$)')
- ax.set_title('Mean concentration of virions')
-
- return fig
-
-
-def scenario_statistics(mc_model: mc.ExposureModel, sample_times: typing.List[float]):
+def scenario_statistics(mc_model: mc.ExposureModel, sample_times: np.ndarray):
model = mc_model.build_model(size=_DEFAULT_MC_SAMPLE_SIZE)
return {
'probability_of_infection': np.mean(model.infection_probability()),
@@ -342,7 +271,6 @@ def comparison_report(
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,
}
@@ -405,6 +333,7 @@ class ReportGenerator:
env.filters['minutes_to_time'] = minutes_to_time
env.filters['float_format'] = "{0:.2f}".format
env.filters['int_format'] = "{:0.0f}".format
+ env.filters['JSONify'] = json.dumps
return env
def render(self, context: dict) -> str:
diff --git a/cara/apps/calculator/static/js/form.js b/cara/apps/calculator/static/js/form.js
index b15f2fc6..abb888c5 100644
--- a/cara/apps/calculator/static/js/form.js
+++ b/cara/apps/calculator/static/js/form.js
@@ -311,7 +311,7 @@ function validate_form(form) {
var lunch_finish = document.getElementById(activity+"_lunch_finish");
lunch_mins = parseTimeToMins(lunch_finish.value) - parseTimeToMins(lunch_start.value);
}
-
+
var coffee_breaks = parseInt(document.querySelector('input[name="'+activity+'_coffee_break_option"]:checked').value);
var coffee_duration = parseInt(document.getElementById(activity+"_coffee_duration").value);
var coffee_mins = coffee_breaks * coffee_duration;
@@ -328,6 +328,24 @@ function validate_form(form) {
});
}
+ // Validate location input.
+ if (submit) {
+ // We make the non-visible location inputs mandatory, without marking them as "required" inputs.
+ // See https://stackoverflow.com/q/22148080/741316 for motivation.
+ var locationSelectObj= document.getElementById("location_select");
+ removeErrorFor(locationSelectObj);
+ $("input[name*='location']").each(function() {
+ el = $(this);
+ if ($.trim(el.val()) == ''){
+ submit = false;
+ }
+ });
+
+ if (!submit) {
+ insertErrorFor(locationSelectObj, "Please select a location");
+ }
+ }
+
//Validate all non zero values
$("input[required].non_zero").each(function() {
if (!validateValue(this)) {
@@ -335,7 +353,6 @@ function validate_form(form) {
}
});
-
//Validate window venting duration < venting frequency
if (!$("#windows_duration").hasClass("disabled")) {
var windowsDurationObj = document.getElementById("windows_duration");
@@ -464,9 +481,9 @@ function parseTimeToMins(cTime) {
/* -------On Load------- */
$(document).ready(function () {
-
+ var url = new URL(decodeURIComponent(window.location.href));
//Pre-fill form with known values
- (new URL(decodeURIComponent(window.location.href))).searchParams.forEach((value, name) => {
+ url.searchParams.forEach((value, name) => {
//If element exists
if(document.getElementsByName(name).length > 0) {
@@ -484,6 +501,7 @@ $(document).ready(function () {
else if (elemObj.type === 'checkbox') {
elemObj.checked = (value==1);
}
+
//Ignore 0 (default) values from server side
else if (!(elemObj.classList.contains("non_zero") || elemObj.classList.contains("remove_zero")) || (value != "0.0" && value != "0")) {
elemObj.value = value;
@@ -492,6 +510,21 @@ $(document).ready(function () {
}
});
+ // Handle default URL values if they are not explicitly defined.
+ if (Array.from(url.searchParams).length > 0) {
+ if (!url.searchParams.has('location_name')) {
+ $('[name="location_name"]').val('Geneva')
+ $('[name="location_select"]').val('Geneva')
+ }
+ if (!url.searchParams.has('location_latitude')) {
+ $('[name="location_latitude"]').val('46.20833')
+ }
+ if (!url.searchParams.has('location_longitude')) {
+ $('[name="location_longitude"]').val('6.14275')
+ }
+ }
+
+
// When the document is ready, deal with the fact that we may be here
// as a result of a forward/back browser action. If that is the case, update
// the visibility of some of our inputs.
@@ -531,8 +564,102 @@ $(document).ready(function () {
$(".start_time[data-lunch-for]").each(function() {validateLunchBreak($(this).data('time-group'))});
$("[data-lunch-for]").change(function() {validateLunchBreak($(this).data('time-group'))});
$("[data-lunch-break]").change(function() {validateLunchBreak($(this).data('lunch-break'))});
+
+ $("#location_select").select2({
+ ajax: {
+ // Docs for the geocoding service at:
+ // https://developers.arcgis.com/rest/geocode/api-reference/geocoding-service-output.htm
+ url: "https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest",
+ dataType: 'json',
+ delay: 250,
+ data: function(params) {
+ return {
+ text: params.term, // search term
+ f: 'json',
+ page: params.page,
+ maxSuggestions: 20,
+ };
+ },
+ processResults: function(data, params) {
+ // Enable infinite scrolling
+ params.page = params.page || 1;
+ return {
+ results: data.suggestions.map(function(suggestion) {
+ return {
+ id: suggestion.magicKey, // The unique reference to this result.
+ text: suggestion.text,
+ magicKey: suggestion.magicKey
+ }
+ }),
+ pagination: {
+ more: (params.page * 10) < data.suggestions.length
+ }
+ };
+ },
+ cache: true
+ },
+ placeholder: 'Search for a location',
+ minimumInputLength: 1,
+ templateResult: formatlocation,
+ templateSelection: formatLocationSelection
+ });
+
+ function formatlocation(suggestedLocation) {
+ // Function is called for each location from the geocoding API.
+
+ if (suggestedLocation.loading) {
+ // Update the first message in the search results to show the
+ // "Searching..." message.
+ return suggestedLocation.text;
+ }
+
+ // Create a container for this location (to be added to the DOM by the select2
+ // library when returned).
+ // This will become one of many search results in the dropdown.
+ var $container = $(
+ "
" +
+ "
" +
+ "
" + suggestedLocation.text + "
" +
+ "
" +
+ "
"
+ );
+ return $container;
+ }
+
+ function formatLocationSelection(selectedSuggestion) {
+ // Function is called when a selection is made in the search result dropdown.
+
+ // ID may be empty, for example when the page is refreshed or back button pressed.
+ if (selectedSuggestion.id != "") {
+
+ // Turn the suggestion into a proper location (so that we can get its latitude & longitude).
+ $.ajax({
+ dataType: "json",
+ url: 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/findAddressCandidates',
+ data: {
+ magicKey: selectedSuggestion.magicKey,
+ outFields: 'country, location',
+ f: "json"
+ },
+ success: function (locations) {
+ // If there isn't precisely one result something is very wrong.
+ geocoded_loc = locations.candidates[0];
+ $('input[name="location_name"]').val(selectedSuggestion.text);
+ $('input[name="location_latitude"]').val(geocoded_loc.location.y.toPrecision(7));
+ $('input[name="location_longitude"]').val(geocoded_loc.location.x.toPrecision(7));
+ }
+ });
+
+ } else if ($('input[name="location_name"]').val() != "") {
+ // If we have no selection AND the location_name is available, use that in the search bar.
+ // This means that we preserve the location through refresh/back button.
+ return $('input[name="location_name"]').val();
+ }
+ return selectedSuggestion.text;
+ }
});
+
/* -------Debugging------- */
function debug_submit(form) {
diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js
index d1105bec..b815da2f 100644
--- a/cara/apps/calculator/static/js/report.js
+++ b/cara/apps/calculator/static/js/report.js
@@ -25,56 +25,16 @@ function draw_concentration_plot(svg_id, times, concentrations, exposed_presence
yAxis = d3.axisLeft(yRange);
// Plot tittle.
- vis.append('svg:foreignObject')
- .attr("background-color", "transparent")
- .attr('width', width)
- .attr('height', margins.top)
- .style('text-align', 'center')
- .html('Mean concentration of virions');
+ plot_title(vis, width, margins.top, 'Mean concentration of virions');
// Line representing the mean concentration.
- var lineFunc = d3.line()
- .defined(d => !isNaN(d.concentration))
- .x(d => xTimeRange(d.time))
- .y(d => yRange(d.concentration))
- .curve(d3.curveBasis);
+ plot_scenario_data(vis, data, xTimeRange, yRange, '#1f77b4');
- vis.append('svg:path')
- .attr('d', lineFunc(data))
- .attr('stroke', '#1f77b4')
- .attr('stroke-width', 2)
- .attr('fill', 'none');
+ // X axis.
+ plot_x_axis(vis, height, width, margins, xAxis, 'Time of day');
- // X axis declaration.
- vis.append('svg:g')
- .attr('class', 'x axis')
- .attr('transform', 'translate(0,' + (height - margins.bottom) + ')')
- .call(xAxis);
-
- // X axis label.
- vis.append('text')
- .attr('class', 'x label')
- .attr('fill', 'black')
- .attr('text-anchor', 'middle')
- .attr('x', (width + margins.right) / 2)
- .attr('y', height * 0.97)
- .text('Time of day')
-
- // Y axis declaration.
- vis.append('svg:g')
- .attr('class', 'y axis')
- .attr('transform', 'translate(' + margins.left + ',0)')
- .call(yAxis);
-
- // Y axis label.
- vis.append('svg:text')
- .attr('class', 'y label')
- .attr('fill', 'black')
- .attr('transform', 'rotate(-90, 0,' + height + ')')
- .attr('text-anchor', 'middle')
- .attr('x', (height + margins.bottom) / 2)
- .attr('y', (height + margins.left) * 0.92)
- .text('Mean concentration (virions/m³)');
+ // Y axis
+ plot_y_axis(vis, height, width, margins, yAxis, 'Mean concentration (virions/m³)')
// Area representing the presence of exposed person(s).
exposed_presence_intervals.forEach(b => {
@@ -181,4 +141,178 @@ function draw_concentration_plot(svg_id, times, concentrations, exposed_presence
focus.select('#tooltip-time').text('x = ' + time_format(d.hour));
focus.select('#tooltip-concentration').text('y = ' + d.concentration.toFixed(2));
}
+}
+
+// Generate the alternative scenarios plot using d3 library.
+// 'alternative_scenarios' is a dictionary with all the alternative scenarios
+// 'times' is a list of times for all the scenarios
+function draw_alternative_scenarios_plot(svg_id, width, height, alternative_scenarios, times) {
+ // H:M format
+ var time_format = d3.timeFormat('%H:%M');
+ // D3 array of ten categorical colors represented as RGB hexadecimal strings.
+ var colors = d3.schemeAccent;
+
+ // Variable for the highest concentration for all the scenarios
+ var highest_concentration = 0.
+
+ var data_for_scenarios = {}
+ for (scenario in alternative_scenarios) {
+ scenario_concentrations = alternative_scenarios[scenario].concentrations
+
+ highest_concentration = Math.max(highest_concentration, Math.max(...scenario_concentrations))
+
+ var data = []
+ times.map((time, index) => data.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': scenario_concentrations[index] }))
+
+ // Add data into lines dictionary
+ data_for_scenarios[scenario] = data
+ }
+
+ // We need one scenario to get the time range
+ var first_scenario = Object.values(data_for_scenarios)[0]
+
+ var vis = d3.select(svg_id),
+ width = width,
+ height = height,
+ margins = { top: 30, right: 20, bottom: 50, left: 50 },
+
+ // H:M time format for x axis.
+ xRange = d3.scaleTime().range([margins.left, width - margins.right]).domain([first_scenario[0].hour, first_scenario[first_scenario.length - 1].hour]),
+ xTimeRange = d3.scaleLinear().range([margins.left, width - margins.right]).domain([times[0], times[times.length - 1]]),
+
+ yRange = d3.scaleLinear().range([height - margins.bottom, margins.top]).domain([0., highest_concentration]),
+
+ xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)),
+ yAxis = d3.axisLeft(yRange);
+
+ // Plot title.
+ plot_title(vis, width, margins.top, 'Mean concentration of virions');
+
+ // Line representing the mean concentration for each scenario.
+ for (const [scenario_name, data] of Object.entries(data_for_scenarios)) {
+ var scenario_index = Object.keys(data_for_scenarios).indexOf(scenario_name)
+
+ // Line representing the mean concentration.
+ plot_scenario_data(vis, data, xTimeRange, yRange, colors[scenario_index])
+
+ // Legend for the plot elements - lines.
+ var size = 20 * (scenario_index + 1)
+ vis.append('rect')
+ .attr('x', width + 20)
+ .attr('y', margins.top + size)
+ .attr('width', 20)
+ .attr('height', 3)
+ .style('fill', colors[scenario_index]);
+
+ vis.append('text')
+ .attr('x', width + 3 * 20)
+ .attr('y', margins.top + size)
+ .text(scenario_name)
+ .style('font-size', '15px')
+ .attr('alignment-baseline', 'central');
+
+ }
+
+ // X axis.
+ plot_x_axis(vis, height, width, margins, xAxis, "Time of day");
+
+ // Y axis declaration.
+ vis.append('svg:g')
+ .attr('class', 'y axis')
+ .attr('transform', 'translate(' + margins.left + ',0)')
+ .call(yAxis);
+
+ // Y axis label.
+ vis.append('svg:text')
+ .attr('class', 'y label')
+ .attr('fill', 'black')
+ .attr('transform', 'rotate(-90, 0,' + height + ')')
+ .attr('text-anchor', 'middle')
+ .attr('x', (height + margins.bottom) / 2)
+ .attr('y', (height + margins.left) * 0.92)
+ .text('Mean concentration (virions/m³)');
+
+ // Legend bounding box.
+ vis.append('rect')
+ .attr('width', 275)
+ .attr('height', 25 * (Object.keys(data_for_scenarios).length))
+ .attr('x', width * 1.005)
+ .attr('y', margins.top + 5)
+ .attr('stroke', 'lightgrey')
+ .attr('stroke-width', '2')
+ .attr('rx', '5px')
+ .attr('ry', '5px')
+ .attr('stroke-linejoin', 'round')
+ .attr('fill', 'none');
+}
+
+
+// Functions used to build the plots' components
+
+function plot_title(vis, width, margin_top, title) {
+ vis.append('svg:foreignObject')
+ .attr('width', width)
+ .attr('height', margin_top)
+ .attr('fill', 'none')
+ .append('xhtml:div')
+ .style('text-align', 'center')
+ .html(title);
+
+ return vis;
+}
+
+function plot_x_axis(vis, height, width, margins, xAxis, label) {
+ // X axis declaration
+ vis.append('svg:g')
+ .attr('class', 'x axis')
+ .attr('transform', 'translate(0,' + (height - margins.bottom) + ')')
+ .call(xAxis);
+
+ // X axis label.
+ vis.append('text')
+ .attr('class', 'x label')
+ .attr('fill', 'black')
+ .attr('text-anchor', 'middle')
+ .attr('x', (width + margins.right) / 2)
+ .attr('y', height * 0.97)
+ .text(label);
+
+ return vis;
+}
+
+function plot_y_axis(vis, height, width, margins, yAxis, label) {
+ // Y axis declaration.
+ vis.append('svg:g')
+ .attr('class', 'y axis')
+ .attr('transform', 'translate(' + margins.left + ',0)')
+ .call(yAxis);
+
+ // Y axis label.
+ vis.append('svg:text')
+ .attr('class', 'y label')
+ .attr('fill', 'black')
+ .attr('transform', 'rotate(-90, 0,' + height + ')')
+ .attr('text-anchor', 'middle')
+ .attr('x', (height + margins.bottom) / 2)
+ .attr('y', (height + margins.left) * 0.92)
+ .text(label);
+
+ return vis;
+
+}
+
+function plot_scenario_data(vis, data, xTimeRange, yRange, line_color) {
+ var lineFunc = d3.line()
+ .defined(d => !isNaN(d.concentration))
+ .x(d => xTimeRange(d.time))
+ .y(d => yRange(d.concentration))
+ .curve(d3.curveBasis);
+
+ vis.append('svg:path')
+ .attr('d', lineFunc(data))
+ .attr("stroke", line_color)
+ .attr('stroke-width', 2)
+ .attr('fill', 'none');
+
+ return vis;
}
\ No newline at end of file
diff --git a/cara/apps/calculator/templates/base/calculator.report.html.j2 b/cara/apps/calculator/templates/base/calculator.report.html.j2
index 8a36f52b..146478dc 100644
--- a/cara/apps/calculator/templates/base/calculator.report.html.j2
+++ b/cara/apps/calculator/templates/base/calculator.report.html.j2
@@ -88,9 +88,9 @@
* The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004.
When using the natural ventilation option, air flows are calculated using averaged hourly temperatures for the Geneva region, based on historical data for the month selected.
+
When using the natural ventilation option, air flows are calculated using averaged hourly temperatures for the region {{ form.location_name }}, based on historical data for the month selected.