diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 68874eb9..d41ad965 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -42,7 +42,7 @@ test_dev: - python ./config-generate.py ${CARA_INSTANCE} --output-directory ./${CARA_INSTANCE}/expected - python ./config-normalise.py ./${CARA_INSTANCE}/actual ./${CARA_INSTANCE}/actual-normed - python ./config-normalise.py ./${CARA_INSTANCE}/expected ./${CARA_INSTANCE}/expected-normed - - diff -u ./test-cara/actual-normed/ ./${CARA_INSTANCE}/expected-normed/ + - diff -u ./${CARA_INSTANCE}/actual-normed/ ./${CARA_INSTANCE}/expected-normed/ artifacts: paths: @@ -59,6 +59,15 @@ check_openshift_config_test-cara: OC_TOKEN: "${OPENSHIFT_CONFIG_CHECKER_TOKEN_TEST_CARA}" +check_openshift_config_prod: + extends: .test_openshift_config + variables: + CARA_INSTANCE: 'cara' + BRANCH: 'master' + OC_SERVER: openshift.cern.ch + OC_TOKEN: "${OPENSHIFT_CONFIG_CHECKER_TOKEN_PROD}" + + # A development installation of CARA tested with pytest. test_dev-39: variables: diff --git a/README.md b/README.md index e6c151b2..1be26e55 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions. -CARA models the concentration profile of potential infectious viruses in enclosed spaces with clear and intuitive graphs. +CARA models the concentration profile of potential virions in enclosed spaces with clear and intuitive graphs. The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation. The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs. @@ -26,7 +26,7 @@ Each event modelled is unique, and the results generated therein are only as acc ## Authors CARA was developed by following members of CERN - European Council for Nuclear Research (visit https://home.cern/): -Andre Henriques1, Marco Andreini1, Gabriella Azzopardi2, James Devine3, Philip Elson4, Nicolas Mounet2, Markus Kongstein Rognlien2,6, Nicola Tarocco5 +Andre Henriques1, Luis Aleixo1, Marco Andreini1, Gabriella Azzopardi2, James Devine3, Philip Elson4, Nicolas Mounet2, Markus Kongstein Rognlien2,6, Nicola Tarocco5 1HSE Unit, Occupational Health & Safety Group, CERN
2Beams Department, Accelerators and Beam Physics Group, CERN
@@ -148,7 +148,7 @@ export CLIENT_SECRET Run docker-compose: ``` cd app-config -docker-compose up +CURRENT_UID=$(id -u):$(id -g) docker-compose up ``` Then visit http://localhost:8080/. diff --git a/app-config/cara-webservice/Dockerfile b/app-config/cara-webservice/Dockerfile index e4d044ac..517b30c8 100644 --- a/app-config/cara-webservice/Dockerfile +++ b/app-config/cara-webservice/Dockerfile @@ -23,9 +23,14 @@ FROM debian COPY --from=conda /opt/app /opt/app ENV PATH=/opt/app/bin/:$PATH # Make a convenient location to the installed CARA package (i.e. a directory called cara in the CWD). +# It is important that this directory is also writable by a non-root user. +RUN mkdir -p /scratch \ + && chmod a+wx /scratch +# Set the HOME directory to something that anybody can write to (to support non root users, such as on openshift). +ENV HOME=/scratch +WORKDIR /scratch RUN CARA_INIT_FILE=$(/opt/app/bin/python -c "import cara; print(cara.__file__)") \ - && ln -s $(dirname $(dirname ${CARA_INIT_FILE})) /opt/site-packages \ - && ln -s /opt/site-packages/cara ./cara + && ln -s $(dirname ${CARA_INIT_FILE}) /scratch/cara CMD [ \ "cara-app.sh" \ ] diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index e322e394..18d9d0a0 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -4,6 +4,7 @@ services: image: cara-webservice environment: - APP_NAME=cara-voila + user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} cara-webservice: image: cara-webservice @@ -12,6 +13,7 @@ services: - APP_NAME=cara-webservice - CARA_CALCULATOR_PREFIX=/calculator-cern - CARA_THEME=cara/apps/calculator/themes/cern + user: ${CURRENT_UID} cara-calculator-open: image: cara-webservice @@ -19,6 +21,7 @@ services: - COOKIE_SECRET - APP_NAME=cara-webservice - CARA_CALCULATOR_PREFIX=/calculator-open + user: ${CURRENT_UID} auth-service: image: auth-service @@ -28,6 +31,7 @@ services: - OIDC_REALM - CLIENT_ID - CLIENT_SECRET + user: ${CURRENT_UID} cara-router: image: cara-nginx-app @@ -35,10 +39,11 @@ services: - "8080:8080" depends_on: cara-webservice: - condition: service_started + condition: service_started cara-calculator-open: - condition: service_started + condition: service_started cara-app: - condition: service_started + condition: service_started auth-service: - condition: service_started + condition: service_started + user: ${CURRENT_UID} diff --git a/app-config/openshift/buildconfig.yaml b/app-config/openshift/buildconfig.yaml index f1f6b8f0..52dd8380 100644 --- a/app-config/openshift/buildconfig.yaml +++ b/app-config/openshift/buildconfig.yaml @@ -41,6 +41,9 @@ namespace: openshift type: Source triggers: + - type: ImageChange + imageChange: {} + - type: ConfigChange - generic: secretReference: name: gitlab-cara-webhook-secret diff --git a/app-config/openshift/deploymentconfig.yaml b/app-config/openshift/deploymentconfig.yaml index a473f6a6..fcdd97e1 100644 --- a/app-config/openshift/deploymentconfig.yaml +++ b/app-config/openshift/deploymentconfig.yaml @@ -73,6 +73,7 @@ kind: DeploymentConfig metadata: name: cara-app + labels: {app: cara-app} spec: replicas: 1 template: @@ -81,16 +82,18 @@ app: cara-app spec: containers: - - name: cara-app + - name: cara-webservice env: - name: APP_NAME value: cara-voila - image: '${PROJECT_NAME}/cara-app' + image: '${PROJECT_NAME}/cara-webservice' ports: - containerPort: 8080 protocol: TCP imagePullPolicy: Always - resources: {} + resources: + limits: { cpu: '1', memory: 1Gi } + requests: { cpu: 1m, memory: 512Mi } terminationMessagePath: /dev/termination-log terminationMessagePolicy: File dnsPolicy: ClusterFirst @@ -117,10 +120,10 @@ imageChangeParams: automatic: true containerNames: - - cara-app + - cara-webservice from: kind: ImageStreamTag - name: 'cara-app:latest' + name: 'cara-webservice:latest' namespace: ${PROJECT_NAME} - apiVersion: v1 @@ -165,7 +168,6 @@ selector: app: cara-router triggers: - - type: ConfigChange - type: ImageChange imageChangeParams: automatic: true @@ -181,6 +183,9 @@ kind: DeploymentConfig metadata: name: cara-webservice + labels: + image: cara-webservice + app: cara-webservice spec: replicas: 1 template: @@ -196,6 +201,8 @@ secretKeyRef: key: COOKIE_SECRET name: auth-service-secrets + - name: REPORT_PARALLELISM + value: '3' - name: APP_NAME value: cara-webservice - name: CARA_CALCULATOR_PREFIX @@ -260,6 +267,9 @@ kind: DeploymentConfig metadata: name: cara-calculator-open + labels: + image: cara-webservice + app: cara-calculator-open spec: replicas: 1 template: diff --git a/cara/apps/calculator/model_generator.py b/cara/apps/calculator/model_generator.py index 852b21b1..a4b139e7 100644 --- a/cara/apps/calculator/model_generator.py +++ b/cara/apps/calculator/model_generator.py @@ -550,7 +550,6 @@ class FormData: if current_time < finish: LOG.debug("trailing interval") present_intervals.append((current_time / 60, finish / 60)) - return models.SpecificInterval(tuple(present_intervals)) def infected_present_interval(self) -> models.Interval: diff --git a/cara/apps/calculator/report_generator.py b/cara/apps/calculator/report_generator.py index e6f1b9ff..1c9f6f24 100644 --- a/cara/apps/calculator/report_generator.py +++ b/cara/apps/calculator/report_generator.py @@ -34,8 +34,10 @@ def calculate_report_data(model: models.ExposureModel): t_start, t_end = model_start_end(model) times = np.linspace(t_start, t_end, resolution) - concentrations = [np.array(model.concentration_model.concentration(time)).mean() - for time in times] + concentrations = [ + np.array(model.concentration_model.concentration(float(time))).mean() + for time in times + ] highest_const = max(concentrations) prob = np.array(model.infection_probability()).mean() er = np.array(model.concentration_model.infected.emission_rate_when_present()).mean() @@ -44,13 +46,13 @@ def calculate_report_data(model: models.ExposureModel): return { "times": list(times), + "exposed_presence_intervals": [list(interval) for interval in model.exposed.presence.boundaries()], "concentrations": concentrations, "highest_const": highest_const, "prob_inf": prob, "emission_rate": er, "exposed_occupants": exposed_occupants, "expected_new_cases": expected_new_cases, - "scenario_plot_src": img2base64(_figure2bytes(plot(times, concentrations, model))), } @@ -115,8 +117,8 @@ def plot(times, concentrations, model: models.ExposureModel): ax.spines['top'].set_visible(False) ax.set_xlabel('Time of day') - ax.set_ylabel('Mean concentration ($q/m^3$)') - ax.set_title('Mean concentration of infectious quanta') + 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 @@ -133,7 +135,7 @@ def plot(times, concentrations, model: models.ExposureModel): ax.set_ylim(0) return fig - + def minutes_to_time(minutes: int) -> str: minute_string = str(minutes % 60) @@ -236,8 +238,8 @@ def comparison_plot(scenarios: typing.Dict[str, dict], sample_times: np.ndarray) ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M")) ax.set_xlabel('Time of day') - ax.set_ylabel('Mean concentration ($q/m^3$)') - ax.set_title('Mean concentration of infectious quanta') + ax.set_ylabel('Mean concentration ($virions/m^{3}$)') + ax.set_title('Mean concentration of virions') return fig diff --git a/cara/apps/calculator/static/js/report.js b/cara/apps/calculator/static/js/report.js new file mode 100644 index 00000000..d1105bec --- /dev/null +++ b/cara/apps/calculator/static/js/report.js @@ -0,0 +1,184 @@ +/* Generate the concentration plot using d3 library. */ +function draw_concentration_plot(svg_id, times, concentrations, exposed_presence_intervals) { + var visBoundingBox = d3.select(svg_id) + .node() + .getBoundingClientRect(); + + var time_format = d3.timeFormat('%H:%M'); + + var data = [] + times.map((time, index) => data.push({ 'time': time, 'hour': new Date().setHours(Math.trunc(time), (time - Math.trunc(time)) * 60), 'concentration': concentrations[index] })) + + var vis = d3.select(svg_id), + width = visBoundingBox.width - 300, + height = visBoundingBox.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([data[0].hour, data[data.length - 1].hour]), + xTimeRange = d3.scaleLinear().range([margins.left, width - margins.right]).domain([data[0].time, data[data.length - 1].time]), + bisecHour = d3.bisector((d) => { return d.hour; }).left, + + yRange = d3.scaleLinear().range([height - margins.bottom, margins.top]).domain([0., Math.max(...concentrations)]), + + xAxis = d3.axisBottom(xRange).tickFormat(d => time_format(d)), + 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'); + + // 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); + + vis.append('svg:path') + .attr('d', lineFunc(data)) + .attr('stroke', '#1f77b4') + .attr('stroke-width', 2) + .attr('fill', 'none'); + + // 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³)'); + + // Area representing the presence of exposed person(s). + exposed_presence_intervals.forEach(b => { + var curveFunc = d3.area() + .x(d => xTimeRange(d.time)) + .y0(height - margins.bottom) + .y1(d => yRange(d.concentration)); + + vis.append('svg:path') + .attr('d', curveFunc(data.filter(d => { + return d.time >= b[0] && d.time <= b[1] + }))) + .attr('fill', '#1f77b4') + .attr('fill-opacity', '0.1'); + }) + + // Legend for the plot elements - line and area. + var size = 20 + vis.append('rect') + .attr('x', width + size) + .attr('y', margins.top + size) + .attr('width', 20) + .attr('height', 3) + .style('fill', '#1f77b4'); + + vis.append('rect') + .attr('x', width + size) + .attr('y', 3 * size) + .attr('width', 20) + .attr('height', 20) + .attr('fill', '#1f77b4') + .attr('fill-opacity', '0.1'); + + vis.append('text') + .attr('x', width + 3 * size) + .attr('y', margins.top + size) + .text('Mean concentration') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + vis.append('text') + .attr('x', width + 3 * size) + .attr('y', margins.top + 2 * size) + .text('Presence of exposed person(s)') + .style('font-size', '15px') + .attr('alignment-baseline', 'central'); + + // Legend bounding box. + vis.append('rect') + .attr('width', 275) + .attr('height', 50) + .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'); + + // Tooltip. + var focus = vis.append('svg:g') + .style('display', 'none'); + + focus.append('circle') + .attr('r', 3); + + focus.append('rect') + .attr('fill', 'white') + .attr('stroke', '#000') + .attr('width', 80) + .attr('height', 50) + .attr('x', 10) + .attr('y', -22) + .attr('rx', 4) + .attr('ry', 4); + + focus.append('text') + .attr('id', 'tooltip-time') + .attr('x', 18) + .attr('y', -2); + + focus.append('text') + .attr('id', 'tooltip-concentration') + .attr('x', 18) + .attr('y', 18); + + vis.append('rect') + .attr('fill', 'none') + .attr('pointer-events', 'all') + .attr('width', width - margins.right) + .attr('height', height) + .on('mouseover', () => { focus.style('display', null); }) + .on('mouseout', () => { focus.style('display', 'none'); }) + .on('mousemove', mousemove); + + function mousemove() { + var x0 = xRange.invert(d3.pointer(event, this)[0]), + i = bisecHour(data, x0, 1), + d0 = data[i - 1], + d1 = data[i], + d = x0 - d0.hour > d1.hour - x0 ? d1 : d0; + focus.attr('transform', 'translate(' + xRange(d.hour) + ',' + yRange(d.concentration) + ')'); + focus.select('#tooltip-time').text('x = ' + time_format(d.hour)); + focus.select('#tooltip-concentration').text('y = ' + d.concentration.toFixed(2)); + } +} \ 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 6582b419..84578d5d 100644 --- a/cara/apps/calculator/templates/base/calculator.report.html.j2 +++ b/cara/apps/calculator/templates/base/calculator.report.html.j2 @@ -8,7 +8,9 @@ - + + + @@ -82,9 +84,15 @@ {% block report_summary_footnote %} {% endblock report_summary_footnote %} -

[*] The results are based on the parameters and assumptions published in the CERN Open Report CERN-OPEN-2021-004

-

+ + +

diff --git a/cara/apps/calculator/templates/calculator.form.html.j2 b/cara/apps/calculator/templates/calculator.form.html.j2 index 1e38f217..712432fb 100644 --- a/cara/apps/calculator/templates/calculator.form.html.j2 +++ b/cara/apps/calculator/templates/calculator.form.html.j2 @@ -380,7 +380,7 @@ v{{ calculator_version }} Please sen CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.

- CARA models the concentration profile of potential infectious viruses in enclosed spaces with clear and intuitive graphs. + CARA models the concentration profile of virions in enclosed spaces with clear and intuitive graphs. The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation. The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.

diff --git a/cara/apps/calculator/templates/userguide.html.j2 b/cara/apps/calculator/templates/userguide.html.j2 index 4ddf3827..ede8c8e3 100644 --- a/cara/apps/calculator/templates/userguide.html.j2 +++ b/cara/apps/calculator/templates/userguide.html.j2 @@ -15,7 +15,7 @@ If you are using the expert version of the tool, you should look at the expert CARA is a risk assessment tool developed to model the concentration of viruses in enclosed spaces, in order to inform space-management decisions.

- CARA models the concentration profile of potential infectious viruses in enclosed spaces with clear and intuitive graphs. + CARA models the concentration profile of potential virions in enclosed spaces with clear and intuitive graphs. The user can set a number of parameters, including room volume, exposure time, activity type, mask-wearing and ventilation. The report generated indicates how to avoid exceeding critical concentrations and chains of airborne transmission in spaces such as individual offices, meeting rooms and labs.

@@ -183,15 +183,15 @@ It is estimated based on the emission rate of virus into the simulated volume, a This probability is valid for the simulation duration - i.e. the start and end time. If you are using the natural ventilation option, the simulation is only valid for the selected month, because the following or preceding month will have a different average temperature profile. The expected number of new cases for the simulation is calculated based on the probability of infection, multiplied by the number of exposed occupants.

-

The graph shows the variation in the concentration of infectious viruses within the simulated volume. +

The graph shows the variation in the concentration of virions within the simulated volume. It is determined by: