From eacc9690b56bf8ec50a1222b55095d471e756f7d Mon Sep 17 00:00:00 2001 From: lrdossan Date: Thu, 20 Jun 2024 11:41:35 +0200 Subject: [PATCH] Initial step towards a backend separation: extract, isolate and package it in a completely independent Python module, versioned and in a way that allows releases on PyPI.org Fixed error in placeholder for secondary school (data registry defaults) added restriction in pytest version to install Expected number of new cases fix Data registry update (schema v2.1.1) Github update Deprecate ExpertApplication and CO2Application Changes to reflect schema update 2.0.2 version update Fixed error with f_inf (short-range) --- .github/workflows/tests.yml | 2 + .readthedocs.yaml | 18 +- README.md | 29 +- .../caimira-public-docker-image/Dockerfile | 2 - .../caimira-public-docker-image/nginx.conf | 12 - .../run_caimira.sh | 11 - app-config/calculator-app/app.sh | 7 +- app-config/docker-compose.yml | 16 - app-config/nginx/nginx.conf | 38 +- app-config/openshift/deploymentconfig.yaml | 115 +--- app-config/openshift/services.yaml | 34 -- caimira/LICENSE | 13 + .../store/__init__.py => README.md} | 0 caimira/calculator/docs/conf.py | 2 +- caimira/calculator/docs/requirements.txt | 6 + caimira/calculator/models/enums.py | 12 +- caimira/calculator/models/models.py | 171 +++--- caimira/calculator/models/monte_carlo/data.py | 234 ++++--- caimira/calculator/report/report_generator.py | 59 +- caimira/calculator/store/data_registry.py | 573 ++++++++++-------- caimira/calculator/store/data_service.py | 2 +- .../tests/apps/calculator/conftest.py | 9 + .../tests/models/test_short_range_model.py | 8 +- .../calculator/tests/test_full_algorithm.py | 138 ++++- .../tests/test_monte_carlo_full_models.py | 8 +- .../validators/co2/co2_validator.py | 2 +- caimira/calculator/validators/defaults.py | 1 + .../validators/virus/virus_validator.py | 24 +- caimira/core_requirements.txt | 0 caimira/dev_requirements.txt | 0 caimira/pyproject.toml | 3 + caimira/setup.cfg | 2 + requirements.txt | 11 - setup.py | 4 +- ui/apps/calculator/__init__.py | 11 +- ui/apps/calculator/report.py | 6 +- ui/apps/calculator/static/js/form.js | 22 +- .../calculator/tests/test_report_generator.py | 31 +- ui/apps/expert_apps/expert.py | 7 + ui/apps/expert_apps/expert_co2.py | 7 + ui/apps/templates/about.html.j2 | 2 +- .../templates/base/calculator.form.html.j2 | 10 + .../templates/base/calculator.report.html.j2 | 43 +- ui/apps/templates/base/index.html.j2 | 4 +- ui/apps/templates/base/layout.html.j2 | 4 +- .../templates/cern/calculator.report.html.j2 | 4 +- ui/apps/templates/expert-app.html.j2 | 18 + ui/apps/tests/test_expert_app.py | 2 + ui/apps/tests/test_report_generator.py | 29 +- 49 files changed, 974 insertions(+), 792 deletions(-) create mode 100644 caimira/LICENSE rename caimira/{calculator/store/__init__.py => README.md} (100%) create mode 100644 caimira/calculator/docs/requirements.txt create mode 100644 caimira/core_requirements.txt create mode 100644 caimira/dev_requirements.txt create mode 100644 caimira/pyproject.toml create mode 100644 caimira/setup.cfg create mode 100644 ui/apps/templates/expert-app.html.j2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f8fc5fd..e8626a3b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,7 @@ jobs: env: PROJECT_ROOT: ./ PROJECT_NAME: caimira + CAIMIRA_TESTS_CALCULATOR_TIMEOUT: 30 steps: - name: Checkout uses: actions/checkout@v2 @@ -46,6 +47,7 @@ jobs: env: PROJECT_ROOT: ./ PROJECT_NAME: caimira + CAIMIRA_TESTS_CALCULATOR_TIMEOUT: 30 steps: - name: Checkout uses: actions/checkout@v2 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 8a50de4c..c489761c 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -2,28 +2,16 @@ # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details -# Required version: 2 -# Set the version of Python and other tools you might need build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "3.9" - # You can also specify other tool versions: - # nodejs: "16" - # rust: "1.55" - # golang: "1.17" + python: "3.11" -# Build documentation in the docs/ directory with Sphinx sphinx: configuration: caimira/docs/conf.py -# If using Sphinx, optionally build your docs in additional formats such as PDF -# formats: -# - pdf - -# Optionally declare the Python requirements required to build your docs python: install: - - requirements: requirements.txt \ No newline at end of file + - requirements: caimira/docs/requirements.txt diff --git a/README.md b/README.md index 1f12117d..fa66b893 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The information also features a distribution diagram of licenses and a brief des A risk assessment tool which simulates the airborne spread of the SARS-CoV-2 virus for space managers. -### CAiMIRA Expert App +### CAiMIRA Expert App and CO₂ App A tool to interact with various parameters of the CAiMIRA model. @@ -103,7 +103,7 @@ pip install -e . # At the root of the repository ### Running the Calculator app in development mode ``` -python -m caimira.apps.calculator +python -m ui.apps.calculator ``` To run with a specific template theme created: @@ -139,13 +139,32 @@ If any of the `.rst` files under the `caimira/docs` folder is changed, this comm Then, right click on `caimira/docs/_build/html/index.html` and select `Open with` your preferred web browser. -### Running the CAiMIRA Expert-App app in development mode +### Running the CAiMIRA Expert-App or CO2-App apps in development mode + +#### Disclaimer + +The `ExpertApplication` and `CO2Application` are no longer actively maintained but will remain in the codebase for legacy purposes. +Please note that the functionality of these applications might be compromised due to deprecation issues. + +#### Running the Applications + +These applications only work within Jupyter notebooks. Attempting to run them outside of a Jupyter environment may result in errors or degraded functionality. + +##### Prerequisites + +Make sure you have the needed dependencies intalled: ``` -voila ui/apps/expert_apps/expert/caimira.ipynb --port=8080 +pip install notebook jupyterlab ``` -Then visit http://localhost:8080. +Running with Visual Studio Code (VSCode): + +1. Ensure you have the following extensions installed in VSCode: `Jupyter` and `Python`. + +2. Open VSCode and navigate to the directory containing the notebook. + +3. Open the notebook (e.g. `caimira/apps/expert/caimira.ipynb`) and run the cells by clicking the `run` button next to each cell. ### Running the tests diff --git a/app-config/caimira-public-docker-image/Dockerfile b/app-config/caimira-public-docker-image/Dockerfile index 6e2c6914..621f344e 100644 --- a/app-config/caimira-public-docker-image/Dockerfile +++ b/app-config/caimira-public-docker-image/Dockerfile @@ -14,8 +14,6 @@ COPY ./app-config/caimira-public-docker-image/run_caimira.sh /opt/caimira/start. # To ensure that we have installed the full requirements, re-run the pip install. # In the best case this will be a no-op. RUN cd /opt/caimira/src/ && /opt/caimira/app/bin/pip install -r /opt/caimira/src/requirements.txt -RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/expert/*.ipynb -RUN /opt/caimira/app/bin/jupyter trust /opt/caimira/src/caimira/apps/expert_co2/*.ipynb COPY ./app-config/caimira-public-docker-image/nginx.conf /opt/caimira/nginx.conf EXPOSE 8080 diff --git a/app-config/caimira-public-docker-image/nginx.conf b/app-config/caimira-public-docker-image/nginx.conf index dd9595d9..d24c4399 100644 --- a/app-config/caimira-public-docker-image/nginx.conf +++ b/app-config/caimira-public-docker-image/nginx.conf @@ -44,18 +44,6 @@ http { large_client_header_buffers 4 16k; - location /voila-server/ { - proxy_pass http://localhost:8082/voila-server/; - } - rewrite ^/expert-app$ /voila-server/ last; - rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; - - location /co2-voila-server/ { - proxy_pass http://localhost:8083/co2-voila-server/; - } - rewrite ^/co2-app$ /voila-server/ last; - rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; - location / { proxy_pass http://localhost:8081; } diff --git a/app-config/caimira-public-docker-image/run_caimira.sh b/app-config/caimira-public-docker-image/run_caimira.sh index 0e0c895e..86a994f9 100755 --- a/app-config/caimira-public-docker-image/run_caimira.sh +++ b/app-config/caimira-public-docker-image/run_caimira.sh @@ -5,17 +5,6 @@ echo 'Please see https://gitlab.cern.ch/caimira/caimira for terms of use.' # Run a proxy for the apps (listening on 8080). nginx -c /opt/caimira/nginx.conf -# Run the expert app in the background. cd /opt/caimira/src/caimira -/opt/caimira/app/bin/python -m voila /opt/caimira/src/ui/apps/expert_apps/expert/caimira.ipynb \ - --port=8082 --no-browser --base_url=/voila-server/ \ - --Voila.tornado_settings 'allow_origin=*' \ - >> /var/log/expert-app.log 2>&1 & - -/opt/caimira/app/bin/python -m voila /opt/caimira/src/ui/apps/expert_apps/expert_co2/caimira.ipynb \ - --port=8083 --no-browser --base_url=/co2-voila-server/ \ - --Voila.tornado_settings 'allow_origin=*' \ - >> /var/log/co2-app.log 2>&1 & - # Run the calculator in the foreground. /opt/caimira/app/bin/python -m ui.apps.calculator --port 8081 --no-debug diff --git a/app-config/calculator-app/app.sh b/app-config/calculator-app/app.sh index 940e29a0..ec7beb8f 100755 --- a/app-config/calculator-app/app.sh +++ b/app-config/calculator-app/app.sh @@ -28,12 +28,7 @@ if [[ "$APP_NAME" == "calculator-app" ]]; then echo "Starting the caimira webservice with: python -m ui.apps.calculator ${args[@]}" python -m ui.apps.calculator "${args[@]}" -elif [[ "$APP_NAME" == "caimira-voila" ]]; then - echo "Starting the voila service" - voila ui/apps/expert_apps/expert --port=8080 --no-browser --base_url=/voila-server/ --tornado_settings 'allow_origin=*' -elif [[ "$APP_NAME" == "caimira-co2-voila" ]]; then - echo "Starting the CO2 voila service" - voila ui/apps/expert_apps/expert_co2/ --port=8080 --no-browser --base_url=/co2-voila-server/ --tornado_settings 'allow_origin=*' + else echo "No APP_NAME specified" exit 1 diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index db9387f8..329e886c 100644 --- a/app-config/docker-compose.yml +++ b/app-config/docker-compose.yml @@ -1,17 +1,5 @@ version: "3.8" services: - expert-app: - image: calculator-app - environment: - - APP_NAME=caimira-voila - user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} - - expert-co2-app: - image: calculator-app - environment: - - APP_NAME=caimira-co2-voila - user: ${CURRENT_UID:?"Please run as follows 'CURRENT_UID=$(id -u):$(id -g) docker-compose up'"} - calculator-app: image: calculator-app environment: @@ -54,10 +42,6 @@ services: condition: service_started calculator-open-app: condition: service_started - expert-app: - condition: service_started - expert-co2-app: - condition: service_started auth-service: condition: service_started user: ${CURRENT_UID} diff --git a/app-config/nginx/nginx.conf b/app-config/nginx/nginx.conf index 5e0ed708..3b447fe9 100644 --- a/app-config/nginx/nginx.conf +++ b/app-config/nginx/nginx.conf @@ -73,44 +73,8 @@ http { proxy_pass http://calculator-app:8080/$request_uri; } - location /voila-server/ { - proxy_intercept_errors on; - - # Anything under voila-server or expert-app is authenticated. - auth_request /auth/probe; - error_page 401 = @error401; - error_page 404 = @proxy_404_error_handler; - - # expert-app is the name of the voila server in each of docker-compose, - # caimira-test.web.cern.ch and caimira.web.cern.ch. - proxy_pass http://expert-app:8080/voila-server/; - } - rewrite ^/expert-app$ /voila-server/voila/render/caimira.ipynb last; - rewrite ^/(files/static)/(.*)$ /voila-server/voila/$1/$2 last; - - # Before implementing the nginx router we could access /voila/render/caimira.ipynb. - # Redirect this (and all other) URLs to the new scheme. + # Redirect URLs to the new scheme. absolute_redirect off; - rewrite ^/voila/(.*)$ /voila-server/voila/$1 redirect; - - location /co2-voila-server/ { - proxy_intercept_errors on; - - # Anything under voila-server or co2-app is authenticated. - auth_request /auth/probe; - error_page 401 = @error401; - error_page 404 = @proxy_404_error_handler; - - # expert-co2-app is the name of the voila server in each of docker-compose, - # caimira-test.web.cern.ch and caimira.web.cern.ch. - proxy_pass http://expert-co2-app:8080/co2-voila-server/; - } - rewrite ^/co2-app$ /co2-voila-server/voila/render/caimira.ipynb last; - rewrite ^/(files/static)/(.*)$ /co2-voila-server/voila/$1/$2 last; - - # Before implementing the nginx router we could access /voila/render/caimira.ipynb. - # Redirect this (and all other) URLs to the new scheme. - rewrite ^/voila/(.*)$ /co2-voila-server/voila/$1 redirect; location / { # By default we have no authentication. diff --git a/app-config/openshift/deploymentconfig.yaml b/app-config/openshift/deploymentconfig.yaml index 82d8974b..61c50e72 100644 --- a/app-config/openshift/deploymentconfig.yaml +++ b/app-config/openshift/deploymentconfig.yaml @@ -68,120 +68,6 @@ kind: ImageStreamTag name: 'auth-service:latest' namespace: ${PROJECT_NAME} - - - apiVersion: apps.openshift.io/v1 - kind: DeploymentConfig - metadata: - name: expert-app - labels: {app: expert-app} - spec: - replicas: 1 - template: - metadata: - labels: - app: expert-app - spec: - containers: - - name: calculator-app - env: - - name: APP_NAME - value: caimira-voila - image: '${PROJECT_NAME}/calculator-app' - ports: - - containerPort: 8080 - protocol: TCP - imagePullPolicy: Always - resources: - limits: { cpu: '1', memory: 1Gi } - requests: { cpu: 1m, memory: 512Mi } - terminationMessagePath: /dev/termination-log - terminationMessagePolicy: File - dnsPolicy: ClusterFirst - restartPolicy: Always - schedulerName: default-scheduler - securityContext: { } - terminationGracePeriodSeconds: 30 - strategy: - activeDeadlineSeconds: 21600 - resources: { } - rollingParams: - intervalSeconds: 1 - maxSurge: 25% - maxUnavailable: 25% - timeoutSeconds: 600 - updatePeriodSeconds: 1 - type: Rolling - test: false - selector: - app: expert-app - triggers: - - type: ConfigChange - - type: ImageChange - imageChangeParams: - automatic: true - containerNames: - - calculator-app - from: - kind: ImageStreamTag - name: 'calculator-app:latest' - namespace: ${PROJECT_NAME} - - - apiVersion: apps.openshift.io/v1 - kind: DeploymentConfig - metadata: - name: expert-co2-app - labels: {app: expert-co2-app} - spec: - replicas: 1 - template: - metadata: - labels: - app: expert-co2-app - spec: - containers: - - name: calculator-app - env: - - name: APP_NAME - value: caimira-co2-voila - image: '${PROJECT_NAME}/calculator-app' - ports: - - containerPort: 8080 - protocol: TCP - imagePullPolicy: Always - resources: - limits: { cpu: '1', memory: 1Gi } - requests: { cpu: 1m, memory: 512Mi } - terminationMessagePath: /dev/termination-log - terminationMessagePolicy: File - dnsPolicy: ClusterFirst - restartPolicy: Always - schedulerName: default-scheduler - securityContext: { } - terminationGracePeriodSeconds: 30 - strategy: - activeDeadlineSeconds: 21600 - resources: { } - rollingParams: - intervalSeconds: 1 - maxSurge: 25% - maxUnavailable: 25% - timeoutSeconds: 600 - updatePeriodSeconds: 1 - type: Rolling - test: false - selector: - app: expert-co2-app - triggers: - - type: ConfigChange - - type: ImageChange - imageChangeParams: - automatic: true - containerNames: - - calculator-app - from: - kind: ImageStreamTag - name: 'calculator-app:latest' - namespace: ${PROJECT_NAME} - apiVersion: apps.openshift.io/v1 kind: DeploymentConfig @@ -417,3 +303,4 @@ - name: PROJECT_NAME description: The name of this project, e.g. caimira-test required: true + \ No newline at end of file diff --git a/app-config/openshift/services.yaml b/app-config/openshift/services.yaml index f32ba062..78a05dfe 100644 --- a/app-config/openshift/services.yaml +++ b/app-config/openshift/services.yaml @@ -27,40 +27,6 @@ deploymentconfig: auth-service sessionAffinity: 'None' type: 'ClusterIP' - - - apiVersion: v1 - kind: Service - metadata: - labels: - app: expert-app - name: expert-app - spec: - ports: - - name: 8080-tcp - port: 8080 - protocol: TCP - targetPort: 8080 - selector: - deploymentconfig: expert-app - sessionAffinity: 'None' - type: 'ClusterIP' - - - apiVersion: v1 - kind: Service - metadata: - labels: - app: expert-co2-app - name: expert-co2-app - spec: - ports: - - name: 8080-tcp - port: 8080 - protocol: TCP - targetPort: 8080 - selector: - deploymentconfig: expert-co2-app - sessionAffinity: 'None' - type: 'ClusterIP' - apiVersion: v1 kind: Service diff --git a/caimira/LICENSE b/caimira/LICENSE new file mode 100644 index 00000000..de49c2af --- /dev/null +++ b/caimira/LICENSE @@ -0,0 +1,13 @@ +Copyright 2020-2021 CERN. All rights not expressly granted are reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/caimira/calculator/store/__init__.py b/caimira/README.md similarity index 100% rename from caimira/calculator/store/__init__.py rename to caimira/README.md diff --git a/caimira/calculator/docs/conf.py b/caimira/calculator/docs/conf.py index 2b9f7aaf..3982967b 100644 --- a/caimira/calculator/docs/conf.py +++ b/caimira/calculator/docs/conf.py @@ -30,7 +30,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ['sphinx.ext.autodoc'] +extensions = ['sphinx.ext.autodoc', 'sphinx_rtd_theme'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/caimira/calculator/docs/requirements.txt b/caimira/calculator/docs/requirements.txt new file mode 100644 index 00000000..b1b732ea --- /dev/null +++ b/caimira/calculator/docs/requirements.txt @@ -0,0 +1,6 @@ +sphinx==6.2.1 +sphinx-rtd-theme==1.2.2 +pillow==5.4.1 +mock==1.0.1 +commonmark==0.9.1 +recommonmark==0.5.0 diff --git a/caimira/calculator/models/enums.py b/caimira/calculator/models/enums.py index 6a776e2c..d3bf68cd 100644 --- a/caimira/calculator/models/enums.py +++ b/caimira/calculator/models/enums.py @@ -1,13 +1,5 @@ from enum import Enum class ViralLoads(Enum): - COVID_OVERALL = "Ref: Viral load - covid_overal_vl_data" - SYMPTOMATIC_FREQUENCIES = "Ref: Viral load - symptomatic_vl_frequencies" - - -class InfectiousDoses(Enum): - DISTRIBUTION = "Ref: Infectious dose - infectious_dose_distribution" - - -class ViableToRNARatios(Enum): - DISTRIBUTION = "Ref: Viable to RNA ratio - viable_to_RNA_ratio_distribution" + COVID_OVERALL = "Ref: Viral load - covid overal viral load data" + SYMPTOMATIC_FREQUENCIES = "Ref: Viral load - symptomatic viral load frequencies" diff --git a/caimira/calculator/models/models.py b/caimira/calculator/models/models.py index 5c2791f3..b49f6692 100644 --- a/caimira/calculator/models/models.py +++ b/caimira/calculator/models/models.py @@ -687,13 +687,8 @@ def particle(self) -> Particle: def aerosols(self, mask: Mask): """ - Total volume of aerosols expired per volume of exhaled air (mL/cm^3). - """ - raise NotImplementedError("Subclass must implement") - - def jet_origin_concentration(self): - """ - Concentration of viruses at the jet origin (mL/m3). + Total volume of aerosols expired per volume of exhaled air + considering the outward mask efficiency (mL/cm^3). """ raise NotImplementedError("Subclass must implement") @@ -723,8 +718,9 @@ def particle(self) -> Particle: @cached() def aerosols(self, mask: Mask): """ - Total volume of aerosols expired per volume of exhaled air. - Result is in mL.cm^-3 + Total volume of aerosols expired per volume + of exhaled air considering the outward mask + efficiency. Result is in mL.cm^-3. """ def volume(d): return (np.pi * d**3) / 6. @@ -733,14 +729,6 @@ def volume(d): return self.cn * (volume(self.diameter) * (1 - mask.exhale_efficiency(self.diameter))) * 1e-12 - @cached() - def jet_origin_concentration(self): - def volume(d): - return (np.pi * d**3) / 6. - - # Final result converted from microns^3/cm3 to mL/m3 - return self.cn * volume(self.diameter) * 1e-6 - @dataclass(frozen=True) class MultipleExpiration(_ExpirationBase): @@ -879,7 +867,7 @@ def fraction_of_infectious_virus(self) -> _VectorisedFloat: The fraction of infectious virus. """ - return self.data_registry.population_with_virus['fraction_of_infectious_virus'] # type: ignore + return 1 def aerosols(self): """ @@ -889,9 +877,9 @@ def aerosols(self): def emission_rate_per_aerosol_per_person_when_present(self) -> _VectorisedFloat: """ - The emission rate of virions in the expired air per mL of respiratory fluid, - per person, if the infected population is present, in (virion.cm^3)/(mL.h). - This method includes only the diameter-independent variables within the emission rate. + The emission rate of infectious respiratory particles (IRP) in the expired air per + mL of respiratory fluid, if the infected population is present, in (virions.cm^3)/(mL.h). + This method returns only the diameter-independent variables within the emission rate. It should not be a function of time. """ raise NotImplementedError("Subclass must implement") @@ -900,12 +888,12 @@ def emission_rate_per_aerosol_per_person_when_present(self) -> _VectorisedFloat: def emission_rate_per_person_when_present(self) -> _VectorisedFloat: """ The emission rate if the infected population is present, per person - (in virions / h). + (in virions/h). """ return (self.emission_rate_per_aerosol_per_person_when_present() * self.aerosols()) - def emission_rate(self, time) -> _VectorisedFloat: + def emission_rate(self, time: float) -> _VectorisedFloat: """ The emission rate of the population vs time. """ @@ -945,13 +933,13 @@ def aerosols(self): @method_cache def emission_rate_per_aerosol_per_person_when_present(self) -> _VectorisedFloat: """ - The emission rate of virions in the expired air per mL of respiratory fluid, - per person, if the infected population is present, in (virion.cm^3)/(mL.h). - This method includes only the diameter-independent variables within the emission rate. + The emission rate of infectious respiratory particles (IRP) in the expired air per + mL of respiratory fluid, if the infected population is present, in (virions.cm^3)/(mL.h). + This method returns only the diameter-independent variables within the emission rate. It should not be a function of time. """ return self.known_individual_emission_rate - + @dataclass(frozen=True) class InfectedPopulation(_PopulationWithVirus): @@ -974,17 +962,19 @@ def aerosols(self): @method_cache def emission_rate_per_aerosol_per_person_when_present(self) -> _VectorisedFloat: """ - The emission rate of virions in the expired air per mL of respiratory fluid, - if the infected population is present, in (virion.cm^3)/(mL.h). - This method includes only the diameter-independent variables within the emission rate. + The emission rate of infectious respiratory particles (IRP) in the expired air per + mL of respiratory fluid, if the infected population is present, in (virions.cm^3)/(mL.h). + This method returns only the diameter-independent variables within the emission rate. It should not be a function of time. """ - # Note on units: exhalation rate is in m^3/h -> 1e6 conversion factor - # Returns the emission rate times the number of infected hosts in the room + # Conversion factor explanation: + # The exhalation rate is in m^3/h, therefore the 1e6 conversion factor + # is to convert m^3/h into cm^3/h to return (virions.cm^3)/(mL.h), + # so that we can then multiply by aerosols (mL/cm^3). ER = (self.virus.viral_load_in_sputum * self.activity.exhalation_rate * self.fraction_of_infectious_virus() * - 10 ** 6) + 10**6) return ER @property @@ -1052,7 +1042,7 @@ def min_background_concentration(self) -> _VectorisedFloat: (in the same unit as the concentration). Its the value towards which the concentration will decay to. """ - return self.data_registry.concentration_model['min_background_concentration'] # type: ignore + return self.data_registry.concentration_model['virus_concentration_model']['min_background_concentration'] # type: ignore def normalization_factor(self) -> _VectorisedFloat: """ @@ -1242,7 +1232,7 @@ class ConcentrationModel(_ConcentrationModelBase): def __post_init__(self): if self.evaporation_factor is None: - self.evaporation_factor = self.data_registry.particle['evaporation_factor'] + self.evaporation_factor = self.data_registry.expiration_particle['particle']['evaporation_factor'] @property def population(self) -> InfectedPopulation: @@ -1335,7 +1325,7 @@ def dilution_factor(self) -> _VectorisedFloat: ''' The dilution factor for the respective expiratory activity type. ''' - _dilution_factor = self.data_registry.short_range_model['dilution_factor'] + _dilution_factor = self.data_registry.short_range_model['dilution_factor'] # Average mouth opening diameter (m) mouth_diameter: float = _dilution_factor['mouth_diameter'] # type: ignore @@ -1355,11 +1345,14 @@ def dilution_factor(self) -> _VectorisedFloat: # Initial velocity of the exhalation airflow (m/s) u0 = np.array(Q_exh/Am) - # Duration of the expiration period(s), assuming a 4s breath-cycle - tstar: float = _dilution_factor['tstar'] # type: ignore + # Duration of one breathing cycle + breathing_cicle: float = _dilution_factor['breathing_cycle'] # type: ignore + + # Duration of the expiration period(s) + tstar: float = breathing_cicle / 2 # Streamwise and radial penetration coefficients - _df_pc = _dilution_factor['penetration_coefficients'] + _df_pc = _dilution_factor['penetration_coefficients'] # type: ignore 𝛽r1: float = _df_pc['𝛽r1'] # type: ignore 𝛽r2: float = _df_pc['𝛽r2'] # type: ignore 𝛽x1: float = _df_pc['𝛽x1'] # type: ignore @@ -1383,29 +1376,38 @@ def dilution_factor(self) -> _VectorisedFloat: xstar[distances >= xstar])/𝛽r1/(xstar[distances >= xstar] + x0))**3 return factors + + def _normed_jet_origin_concentration(self) -> _VectorisedFloat: + """ + The initial jet concentration at the source origin (mouth/nose), normalized by + normalization_factor in the ShortRange class (corresponding to the diameter-independent + variables). Results in mL.cm^-3. + """ + # The short range origin concentration does not consider the mask contribution. + return self.expiration.aerosols(mask=Mask.types['No mask']) def _long_range_normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat: """ - Virus long-range exposure concentration normalized by the - virus viral load, as function of time. + Virus long-range exposure concentration normalized by normalization_factor in the + ShortRange class, as function of time. Results in mL.cm^-3. """ - return (concentration_model.concentration(time) / - concentration_model.virus.viral_load_in_sputum) + return (concentration_model.concentration(time) / self.normalization_factor(concentration_model.infected)) def _normed_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat: """ Virus short-range exposure concentration, as a function of time. If the given time falls within a short-range interval it returns the - short-range concentration normalized by the virus viral load. Otherwise - it returns 0. + short-range concentration normalized by normalization_factor in the + Short-range class. Otherwise it returns 0. Results in mL.cm^-3. """ start, stop = self.presence.boundaries()[0] # Verifies if the given time falls within a short-range interaction if start <= time <= stop: dilution = self.dilution_factor() - jet_origin_concentration = self.expiration.jet_origin_concentration() - # Long-range concentration normalized by the virus viral load + # Jet origin concentration normalized by the emission rate (except the BR) + normed_jet_origin_concentration = self._normed_jet_origin_concentration() + # Long-range concentration normalized by the emission rate (except the BR) long_range_normed_concentration = self._long_range_normed_concentration(concentration_model, time) # The long-range concentration values are then approximated using interpolation: @@ -1417,15 +1419,32 @@ def _normed_concentration(self, concentration_model: ConcentrationModel, time: f # Short-range concentration formula. The long-range concentration is added in the concentration method (ExposureModel). # based on continuum model proposed by Jia et al (2022) - https://doi.org/10.1016/j.buildenv.2022.109166 - return ((1/dilution)*(jet_origin_concentration - long_range_normed_concentration_interpolated)) + return ((1/dilution)*(normed_jet_origin_concentration - long_range_normed_concentration_interpolated)) return 0. - + + def normalization_factor(self, infected: InfectedPopulation) -> _VectorisedFloat: + """ + The normalization factor applied to the short-range results. It refers to the emission + rate per aerosol without accounting for the exhalation rate (viral load and f_inf). + Result in (virions.cm^3)/(mL.m^3). + """ + # Re-use the emission rate method divided by the BR contribution. + return infected.emission_rate_per_aerosol_per_person_when_present() / infected.activity.exhalation_rate + + def jet_origin_concentration(self, infected: InfectedPopulation) -> _VectorisedFloat: + """ + The initial jet concentration at the source origin (mouth/nose). + Returns the full result with the diameter dependent and independent variables, in virions/m^3. + """ + return self._normed_jet_origin_concentration() * self.normalization_factor(infected) + def short_range_concentration(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat: """ Virus short-range exposure concentration, as a function of time. + Factor of normalization applied back here. Results in virions/m^3. """ - return (self._normed_concentration(concentration_model, time) * - concentration_model.virus.viral_load_in_sputum) + return (self._normed_concentration(concentration_model, time) * + self.normalization_factor(concentration_model.infected)) @method_cache def _normed_short_range_concentration_cached(self, concentration_model: ConcentrationModel, time: float) -> _VectorisedFloat: @@ -1459,16 +1478,16 @@ def extract_between_bounds(self, time1: float, time2: float) -> typing.Union[Non return start, stop def _normed_jet_exposure_between_bounds(self, - concentration_model: ConcentrationModel, time1: float, time2: float): """ Get the part of the integrated short-range concentration of viruses in the air, between the times start and stop, coming - from the jet concentration, normalized by the viral load, and - without dilution. + from the jet concentration, normalized by normalization_factor, + and without dilution. """ start, stop = self.extract_between_bounds(time1, time2) - jet_origin = self.expiration.jet_origin_concentration() + # Note the conversion factor mL.cm^-3 -> mL.m^-3 + jet_origin = self._normed_jet_origin_concentration() * 10**6 return jet_origin * (stop - start) def _normed_interpolated_longrange_exposure_between_bounds( @@ -1476,20 +1495,24 @@ def _normed_interpolated_longrange_exposure_between_bounds( time1: float, time2: float): """ Get the part of the integrated short-range concentration due - to the background concentration, normalized by the viral load - and the breathing rate, and without dilution. + to the background concentration, normalized by normalization_factor + together with breathing rate, and without dilution. One needs to interpolate the integrated long-range concentration for the particle diameters defined here. - TODO: make sure any potential extrapolation has a - negligible effect. """ start, stop = self.extract_between_bounds(time1, time2) if stop<=start: return 0. + # Note that for the correct integration one needs to isolate those parameters + # that are diameter-dependent from those that are diameter independent. + # Therefore, the diameter-independent parameters (viral load, f_ind and BR) + # are removed for the interpolation, and added back once the integration over + # the new aerosol diameters (done with the mean) is completed. normed_int_concentration = ( concentration_model.integrated_concentration(start, stop) /concentration_model.virus.viral_load_in_sputum + /concentration_model.infected.fraction_of_infectious_virus() /concentration_model.infected.activity.exhalation_rate ) normed_int_concentration_interpolated = np.interp( @@ -1563,7 +1586,8 @@ def fun(x): @dataclass(frozen=True) class ExposureModel: """ - Represents the exposure to a concentration of virions in the air. + Represents the exposure to a concentration of + infectious respiratory particles (IRP) in the air. """ data_registry: DataRegistry @@ -1579,10 +1603,13 @@ class ExposureModel: #: Geographical data geographical_data: Cases + #: Total people with short-range interactions + exposed_to_short_range: int = 0 + #: The number of times the exposure event is repeated (default 1). @property def repeats(self) -> int: - return self.data_registry.exposure_model['repeats'] # type: ignore + return 1 def __post_init__(self): """ @@ -1687,7 +1714,6 @@ def long_range_deposited_exposure_between_bounds(self, time1: float, time2: floa self.exposed.activity.inhalation_rate * (1 - self.exposed.mask.inhale_efficiency())) - # In the end we multiply the final results by the fraction of infectious virus of the vD equation. return deposited_exposure def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _VectorisedFloat: @@ -1703,8 +1729,7 @@ def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _Vect deposited_exposure: _VectorisedFloat = 0. for interaction in self.short_range: start, stop = interaction.extract_between_bounds(time1, time2) - short_range_jet_exposure = interaction._normed_jet_exposure_between_bounds( - self.concentration_model, start, stop) + short_range_jet_exposure = interaction._normed_jet_exposure_between_bounds(start, stop) short_range_lr_exposure = interaction._normed_interpolated_longrange_exposure_between_bounds( self.concentration_model, start, stop) dilution = interaction.dilution_factor() @@ -1735,11 +1760,12 @@ def deposited_exposure_between_bounds(self, time1: float, time2: float) -> _Vect interaction.activity.inhalation_rate /dilution) - # Then we multiply by diameter-independent quantities: viral load - # and fraction of infected virions + # Then we multiply by the emission rate without the BR contribution (and conversion factor), + # and parameters of the vD equation (i.e. n_in). deposited_exposure *= ( - self.concentration_model.virus.viral_load_in_sputum - * (1 - self.exposed.mask.inhale_efficiency())) + (self.concentration_model.infected.emission_rate_per_aerosol_per_person_when_present() / ( + self.concentration_model.infected.activity.exhalation_rate * 10**6)) * + (1 - self.exposed.mask.inhale_efficiency())) # Long-range concentration deposited_exposure += self.long_range_deposited_exposure_between_bounds(time1, time2) @@ -1814,10 +1840,15 @@ def expected_new_cases(self) -> _VectorisedFloat: "with dynamic occupancy") """ - The expect_new_cases should always take the long-range infection_probability and multiply by the occupants exposed to long-range. + The expected_new_cases may provide one or two different outputs: + 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. """ + if self.short_range != (): - return nested_replace(self, {'short_range': ()}).infection_probability() * self.exposed.number / 100 + new_cases_long_range = nested_replace(self, {'short_range': (),}).infection_probability() * (self.exposed.number - self.exposed_to_short_range) + return (new_cases_long_range + (self.infection_probability() * self.exposed_to_short_range)) / 100 return self.infection_probability() * self.exposed.number / 100 diff --git a/caimira/calculator/models/monte_carlo/data.py b/caimira/calculator/models/monte_carlo/data.py index f0c836bf..7b503cb0 100644 --- a/caimira/calculator/models/monte_carlo/data.py +++ b/caimira/calculator/models/monte_carlo/data.py @@ -7,84 +7,87 @@ from scipy import special as sp from scipy.stats import weibull_min -from ..enums import ViralLoads, InfectiousDoses, ViableToRNARatios +from ..enums import ViralLoads import caimira.calculator.models.monte_carlo.models as mc from caimira.calculator.models.monte_carlo.sampleable import LogCustom, LogNormal, Normal, LogCustomKernel, CustomKernel, Uniform, Custom from caimira.calculator.store.data_registry import DataRegistry -def evaluate_vl(value, data_registry: DataRegistry): - if value == ViralLoads.COVID_OVERALL.value: +def evaluate_vl(root: typing.Dict, value: str, data_registry: DataRegistry): + if root[value] == ViralLoads.COVID_OVERALL.value: return covid_overal_vl_data(data_registry) - elif value == ViralLoads.SYMPTOMATIC_FREQUENCIES.value: - return symptomatic_vl_frequencies + elif root[value] == ViralLoads.SYMPTOMATIC_FREQUENCIES.value: + return symptomatic_vl_frequencies(data_registry) + elif root[value] == 'Custom': + return param_evaluation(root, 'Viral load custom') else: raise ValueError(f"Invalid ViralLoads value {value}") -def evaluate_infectd(value, data_registry: DataRegistry): - if value == InfectiousDoses.DISTRIBUTION.value: - return infectious_dose_distribution(data_registry) - else: - raise ValueError(f"Invalid InfectiousDoses value {value}") - - -def evaluate_vtrr(value, data_registry: DataRegistry): - if value == ViableToRNARatios.DISTRIBUTION.value: - return viable_to_RNA_ratio_distribution(data_registry) - else: - raise ValueError(f"Invalid ViableToRNARatios value {value}") - - sqrt2pi = np.sqrt(2.*np.pi) sqrt2 = np.sqrt(2.) -def custom_distribution_lookup(dict: dict, key_part: str) -> typing.Any: +def custom_value_type_lookup(dict: dict, key_part: str) -> typing.Any: """ - Look up a custom distribution based on a partial key. + Look up a custom value type based on a partial key. Args: dict (dict): The root to search. - key_part (str): The distribution key to match. + key_part (str): The value type key to match. Returns: - str: The associated distribution. + str: The associated value. """ try: for key, value in dict.items(): if (key_part in key): - return value['associated_distribution'] + return value['associated_value'] except KeyError: return f"Key '{key_part}' not found." -def evaluate_custom_distribution(dist: str, params: typing.Dict) -> typing.Any: +def evaluate_custom_value_type(value_type: str, params: typing.Dict) -> typing.Any: """ - Evaluate a custom distribution. + Evaluate a custom value type. Args: - dist (str): The type of distribution. - params (Dict): The parameters for the distribution. + dist (str): The type of value. + params (Dict): The parameters for the value type. Returns: - Any: The generated distribution. + Any: The generated value. Raises: - ValueError: If the distribution type is not recognized. + ValueError: If the value type is not recognized. """ - if dist == 'Linear Space': - return np.linspace(params['start'], params['stop'], params['num']) - elif dist == 'Normal': - return Normal(params['normal_mean_gaussian'], params['normal_standard_deviation_gaussian']) - elif dist == 'Log-normal': - return LogNormal(params['lognormal_mean_gaussian'], params['lognormal_standard_deviation_gaussian']) - elif dist == 'Uniform': - return Uniform(params['low'], params['high']) + if value_type == 'Constant value': + return params + elif value_type == 'Normal distribution': + return Normal( + mean=params['normal_mean_gaussian'], + standard_deviation=params['normal_standard_deviation_gaussian'] + ) + elif value_type == 'Log-normal distribution': + return LogNormal( + mean_gaussian=params['lognormal_mean_gaussian'], + standard_deviation_gaussian=params['lognormal_standard_deviation_gaussian'] + ) + elif value_type == 'Uniform distribution': + return Uniform( + low=params['low'], + high=params['high'] + ) + elif value_type == 'Log Custom Kernel distribution': + return LogCustomKernel( + log_variable=np.array(params['log_variable']), + frequencies=np.array(params['frequencies']), + kernel_bandwidth=params['kernel_bandwidth'] + ) else: - raise ValueError('Bad request - distribution not found.') + raise ValueError('Bad request - value type not found.') def param_evaluation(root: typing.Dict, param: typing.Union[str, typing.Any]) -> typing.Any: @@ -104,17 +107,10 @@ def param_evaluation(root: typing.Dict, param: typing.Union[str, typing.Any]) -> """ value = root.get(param) - if isinstance(value, str): - if value == 'Custom': - custom_distribution: typing.Dict = custom_distribution_lookup( - root, 'custom distribution') - for d, p in custom_distribution.items(): - return evaluate_custom_distribution(d, p) - - elif isinstance(value, dict): - dist: str = root[param]['associated_distribution'] + if isinstance(value, dict): + value_type: str = root[param]['associated_value'] params: typing.Dict = root[param]['parameters'] - return evaluate_custom_distribution(dist, params) + return evaluate_custom_value_type(value_type, params) elif isinstance(value, float) or isinstance(value, int): return value @@ -148,21 +144,21 @@ class BLOmodel: # total concentration of aerosols for each mode. @property def cn(self) -> typing.Tuple[float, float, float]: - _cn = self.data_registry.BLOmodel['cn'] + _cn = self.data_registry.expiration_particle['BLOmodel']['cn'] # type: ignore return (_cn['B'],_cn['L'],_cn['O']) # Mean of the underlying normal distributions (represents the log of a # diameter in microns), for resp. the B, L and O modes. @property def mu(self) -> typing.Tuple[float, float, float]: - _mu = self.data_registry.BLOmodel['mu'] + _mu = self.data_registry.expiration_particle['BLOmodel']['mu'] # type: ignore return (_mu['B'], _mu['L'], _mu['O']) # Std deviation of the underlying normal distribution, for resp. # the B, L and O modes. @property def sigma(self) -> typing.Tuple[float, float, float]: - _sigma = self.data_registry.BLOmodel['sigma'] + _sigma = self.data_registry.expiration_particle['BLOmodel']['sigma'] # type: ignore return (_sigma['B'],_sigma['L'],_sigma['O']) def distribution(self, d): @@ -229,19 +225,8 @@ def activity_distributions(data_registry): # From https://doi.org/10.1101/2021.10.14.21264988 and references therein -symptomatic_vl_frequencies = LogCustomKernel( - np.array((2.46032, 2.67431, 2.85434, 3.06155, 3.25856, 3.47256, 3.66957, 3.85979, 4.09927, 4.27081, - 4.47631, 4.66653, 4.87204, 5.10302, 5.27456, 5.46478, 5.6533, 5.88428, 6.07281, 6.30549, - 6.48552, 6.64856, 6.85407, 7.10373, 7.30075, 7.47229, 7.66081, 7.85782, 8.05653, 8.27053, - 8.48453, 8.65607, 8.90573, 9.06878, 9.27429, 9.473, 9.66152, 9.87552)), - np.array((0.001206885, 0.007851618, 0.008078144, 0.01502491, 0.013258014, 0.018528495, 0.020053765, - 0.021896167, 0.022047184, 0.018604005, 0.01547796, 0.018075445, 0.021503523, 0.022349217, - 0.025097721, 0.032875078, 0.030594727, 0.032573045, 0.034717482, 0.034792991, - 0.033267721, 0.042887485, 0.036846816, 0.03876473, 0.045016819, 0.040063473, 0.04883754, - 0.043944602, 0.048142864, 0.041588741, 0.048762031, 0.027921732, 0.033871788, - 0.022122693, 0.016927718, 0.008833228, 0.00478598, 0.002807662)), - kernel_bandwidth=0.1 -) +def symptomatic_vl_frequencies(data_registry): + return param_evaluation(data_registry.virological_data, 'symptomatic_vl_frequencies') # Weibull distribution with a shape factor of 3.47 and a scale factor of 7.01. @@ -250,87 +235,93 @@ def activity_distributions(data_registry): def viral_load(data_registry): return np.linspace( weibull_min.ppf( - data_registry.covid_overal_vl_data['start'], - c=data_registry.covid_overal_vl_data['shape_factor'], - scale=data_registry.covid_overal_vl_data['scale_factor'] + data_registry.virological_data['covid_overal_vl_data']['parameters']['start'], + c=data_registry.virological_data['covid_overal_vl_data']['parameters']['shape_factor'], + scale=data_registry.virological_data['covid_overal_vl_data']['parameters']['scale_factor'] ), weibull_min.ppf( - data_registry.covid_overal_vl_data['stop'], - c=data_registry.covid_overal_vl_data['shape_factor'], - scale=data_registry.covid_overal_vl_data['scale_factor'] + data_registry.virological_data['covid_overal_vl_data']['parameters']['stop'], + c=data_registry.virological_data['covid_overal_vl_data']['parameters']['shape_factor'], + scale=data_registry.virological_data['covid_overal_vl_data']['parameters']['scale_factor'] ), - int(data_registry.covid_overal_vl_data['num']) + int(data_registry.virological_data['covid_overal_vl_data']['parameters']['num']) ) def frequencies_pdf(data_registry): return weibull_min.pdf( viral_load(data_registry), - c=data_registry.covid_overal_vl_data['shape_factor'], - scale=data_registry.covid_overal_vl_data['scale_factor'] + c=data_registry.virological_data['covid_overal_vl_data']['parameters']['shape_factor'], + scale=data_registry.virological_data['covid_overal_vl_data']['parameters']['scale_factor'] ) def covid_overal_vl_data(data_registry): return LogCustom( - bounds=(data_registry.covid_overal_vl_data['min_bound'], data_registry.covid_overal_vl_data['max_bound']), + bounds=(data_registry.virological_data['covid_overal_vl_data']['parameters']['min_bound'], data_registry.virological_data['covid_overal_vl_data']['parameters']['max_bound']), function=lambda d: np.interp( d, viral_load(data_registry), frequencies_pdf(data_registry), - data_registry.covid_overal_vl_data['interpolation_fp_left'], - data_registry.covid_overal_vl_data['interpolation_fp_right'] + data_registry.virological_data['covid_overal_vl_data']['parameters']['interpolation_fp_left'], + data_registry.virological_data['covid_overal_vl_data']['parameters']['interpolation_fp_right'] ), - max_function=data_registry.covid_overal_vl_data['max_function'] + max_function=data_registry.virological_data['covid_overal_vl_data']['parameters']['max_function'] ) # Derived from data in doi.org/10.1016/j.ijid.2020.09.025 and # https://iosh.com/media/8432/aerosol-infection-risk-hospital-patient-care-full-report.pdf (page 60) -def viable_to_RNA_ratio_distribution(data_registry): - return Uniform(data_registry.viable_to_RNA_ratio_distribution['low'], data_registry.viable_to_RNA_ratio_distribution['high']) +def viable_to_RNA_ratio_distribution(): + return Uniform(0.01, 0.6) # From discussion with virologists -def infectious_dose_distribution(data_registry): - return Uniform(data_registry.infectious_dose_distribution['low'], data_registry.infectious_dose_distribution['high']) +def infectious_dose_distribution(): + return Uniform(10., 100.) # From https://doi.org/10.1101/2021.10.14.21264988 and references therein def virus_distributions(data_registry): - vd = data_registry.virus_distributions + vd = data_registry.virological_data['virus_distributions'] return { 'SARS_CoV_2': mc.SARSCoV2( - viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2']['viral_load_in_sputum'], data_registry), - infectious_dose=evaluate_infectd(vd['SARS_CoV_2']['infectious_dose'], data_registry), - viable_to_RNA_ratio=evaluate_vtrr(vd['SARS_CoV_2']['viable_to_RNA_ratio'], data_registry), - transmissibility_factor=vd['SARS_CoV_2']['transmissibility_factor'], + viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2'], 'viral_load_in_sputum', data_registry), + infectious_dose=param_evaluation(vd['SARS_CoV_2'], 'infectious_dose'), + viable_to_RNA_ratio=param_evaluation(vd['SARS_CoV_2'], 'viable_to_RNA_ratio'), + transmissibility_factor=vd['SARS_CoV_2']['transmissibility_factor']['value'], + infectiousness_days=vd['SARS_CoV_2']['infectiousness_days']['value'], ), 'SARS_CoV_2_ALPHA': mc.SARSCoV2( - viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_ALPHA']['viral_load_in_sputum'], data_registry), - infectious_dose=evaluate_infectd(vd['SARS_CoV_2_ALPHA']['infectious_dose'], data_registry), - viable_to_RNA_ratio=evaluate_vtrr(vd['SARS_CoV_2_ALPHA']['viable_to_RNA_ratio'], data_registry), - transmissibility_factor=vd['SARS_CoV_2_ALPHA']['transmissibility_factor'], + viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_ALPHA'], 'viral_load_in_sputum', data_registry), + infectious_dose=param_evaluation(vd['SARS_CoV_2_ALPHA'], 'infectious_dose'), + viable_to_RNA_ratio=param_evaluation(vd['SARS_CoV_2_ALPHA'], 'viable_to_RNA_ratio'), + transmissibility_factor=vd['SARS_CoV_2_ALPHA']['transmissibility_factor']['value'], + infectiousness_days=vd['SARS_CoV_2_ALPHA']['infectiousness_days']['value'], ), 'SARS_CoV_2_BETA': mc.SARSCoV2( - viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_BETA']['viral_load_in_sputum'], data_registry), - infectious_dose=evaluate_infectd(vd['SARS_CoV_2_BETA']['infectious_dose'], data_registry), - viable_to_RNA_ratio=evaluate_vtrr(vd['SARS_CoV_2_BETA']['viable_to_RNA_ratio'], data_registry), - transmissibility_factor=vd['SARS_CoV_2_BETA']['transmissibility_factor'], + viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_BETA'], 'viral_load_in_sputum', data_registry), + infectious_dose=param_evaluation(vd['SARS_CoV_2_BETA'], 'infectious_dose'), + viable_to_RNA_ratio=param_evaluation(vd['SARS_CoV_2_BETA'], 'viable_to_RNA_ratio'), + transmissibility_factor=vd['SARS_CoV_2_BETA']['transmissibility_factor']['value'], + infectiousness_days=vd['SARS_CoV_2_BETA']['infectiousness_days']['value'], ), 'SARS_CoV_2_GAMMA': mc.SARSCoV2( - viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_GAMMA']['viral_load_in_sputum'], data_registry), - infectious_dose=evaluate_infectd(vd['SARS_CoV_2_GAMMA']['infectious_dose'], data_registry), - viable_to_RNA_ratio=evaluate_vtrr(vd['SARS_CoV_2_GAMMA']['viable_to_RNA_ratio'], data_registry), - transmissibility_factor=vd['SARS_CoV_2_GAMMA']['transmissibility_factor'], + viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_GAMMA'], 'viral_load_in_sputum', data_registry), + infectious_dose=param_evaluation(vd['SARS_CoV_2_GAMMA'], 'infectious_dose'), + viable_to_RNA_ratio=param_evaluation(vd['SARS_CoV_2_GAMMA'], 'viable_to_RNA_ratio'), + transmissibility_factor=vd['SARS_CoV_2_GAMMA']['transmissibility_factor']['value'], + infectiousness_days=vd['SARS_CoV_2_GAMMA']['infectiousness_days']['value'], ), 'SARS_CoV_2_DELTA': mc.SARSCoV2( - viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_DELTA']['viral_load_in_sputum'], data_registry), - infectious_dose=evaluate_infectd(vd['SARS_CoV_2_DELTA']['infectious_dose'], data_registry), - viable_to_RNA_ratio=evaluate_vtrr(vd['SARS_CoV_2_DELTA']['viable_to_RNA_ratio'], data_registry), - transmissibility_factor=vd['SARS_CoV_2_DELTA']['transmissibility_factor'], + viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_DELTA'], 'viral_load_in_sputum', data_registry), + infectious_dose=param_evaluation(vd['SARS_CoV_2_DELTA'], 'infectious_dose'), + viable_to_RNA_ratio=param_evaluation(vd['SARS_CoV_2_DELTA'], 'viable_to_RNA_ratio'), + transmissibility_factor=vd['SARS_CoV_2_DELTA']['transmissibility_factor']['value'], + infectiousness_days=vd['SARS_CoV_2_DELTA']['infectiousness_days']['value'], ), 'SARS_CoV_2_OMICRON': mc.SARSCoV2( - viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_OMICRON']['viral_load_in_sputum'], data_registry), - infectious_dose=evaluate_infectd(vd['SARS_CoV_2_OMICRON']['infectious_dose'], data_registry), - viable_to_RNA_ratio=evaluate_vtrr(vd['SARS_CoV_2_OMICRON']['viable_to_RNA_ratio'], data_registry), - transmissibility_factor=vd['SARS_CoV_2_OMICRON']['transmissibility_factor'], + viral_load_in_sputum=evaluate_vl(vd['SARS_CoV_2_OMICRON'], 'viral_load_in_sputum', data_registry), + infectious_dose=param_evaluation(vd['SARS_CoV_2_OMICRON'], 'infectious_dose'), + viable_to_RNA_ratio=param_evaluation(vd['SARS_CoV_2_OMICRON'], 'viable_to_RNA_ratio'), + transmissibility_factor=vd['SARS_CoV_2_OMICRON']['transmissibility_factor']['value'], + infectiousness_days=vd['SARS_CoV_2_OMICRON']['infectiousness_days']['value'], ), } @@ -347,21 +338,24 @@ def mask_distributions(data_registry): data_registry.mask_distributions['Type I'], 'η_inhale'), η_exhale=param_evaluation( data_registry.mask_distributions['Type I'], 'η_exhale') - if data_registry.mask_distributions['Type I']['Known filtration efficiency of masks when exhaling?'] == 'Yes' else None, + if data_registry.mask_distributions['Type I'].get('η_exhale') is not None else None, + factor_exhale=data_registry.mask_distributions['Type I']['factor_exhale']['value'] ), 'FFP2': mc.Mask( η_inhale=param_evaluation( data_registry.mask_distributions['FFP2'], 'η_inhale'), η_exhale=param_evaluation( data_registry.mask_distributions['FFP2'], 'η_exhale') - if data_registry.mask_distributions['FFP2']['Known filtration efficiency of masks when exhaling?'] == 'Yes' else None, + if data_registry.mask_distributions['FFP2'].get('η_exhale') is not None else None, + factor_exhale=data_registry.mask_distributions['FFP2']['factor_exhale']['value'] ), 'Cloth': mc.Mask( η_inhale=param_evaluation( data_registry.mask_distributions['Cloth'], 'η_inhale'), η_exhale=param_evaluation( data_registry.mask_distributions['Cloth'], 'η_exhale') - if data_registry.mask_distributions['Cloth']['Known filtration efficiency of masks when exhaling?'] == 'Yes' else None, + if data_registry.mask_distributions['Cloth'].get('η_exhale') is not None else None, + factor_exhale=data_registry.mask_distributions['Cloth']['factor_exhale']['value'] ), } @@ -392,10 +386,10 @@ def expiration_distribution( def expiration_BLO_factors(data_registry): - breathing = data_registry.expiration_BLO_factors['Breathing'] - speaking = data_registry.expiration_BLO_factors['Speaking'] - singing = data_registry.expiration_BLO_factors['Singing'] - shouting = data_registry.expiration_BLO_factors['Shouting'] + breathing = data_registry.expiration_particle['expiration_BLO_factors']['Breathing'] + speaking = data_registry.expiration_particle['expiration_BLO_factors']['Speaking'] + singing = data_registry.expiration_particle['expiration_BLO_factors']['Singing'] + shouting = data_registry.expiration_particle['expiration_BLO_factors']['Shouting'] return { 'Breathing': ( param_evaluation(breathing, 'B'), @@ -425,8 +419,8 @@ def expiration_distributions(data_registry): exp_type: expiration_distribution( data_registry=data_registry, BLO_factors=BLO_factors, - d_min=param_evaluation(data_registry.long_range_expiration_distributions, 'minimum_diameter'), - d_max=param_evaluation(data_registry.long_range_expiration_distributions, 'maximum_diameter') + d_min=param_evaluation(data_registry.expiration_particle['long_range_particle_diameter'], 'minimum_diameter'), + d_max=param_evaluation(data_registry.expiration_particle['long_range_particle_diameter'], 'maximum_diameter') ) for exp_type, BLO_factors in expiration_BLO_factors(data_registry).items() } @@ -437,8 +431,8 @@ def short_range_expiration_distributions(data_registry): exp_type: expiration_distribution( data_registry=data_registry, BLO_factors=BLO_factors, - d_min=param_evaluation(data_registry.short_range_expiration_distributions, 'minimum_diameter'), - d_max=param_evaluation(data_registry.short_range_expiration_distributions, 'maximum_diameter') + d_min=param_evaluation(data_registry.expiration_particle['short_range_particle_diameter'], 'minimum_diameter'), + d_max=param_evaluation(data_registry.expiration_particle['short_range_particle_diameter'], 'maximum_diameter') ) for exp_type, BLO_factors in expiration_BLO_factors(data_registry).items() } @@ -452,8 +446,8 @@ def short_range_expiration_distributions(data_registry): def short_range_distances(data_registry): return Custom( bounds=( - param_evaluation(data_registry.short_range_distances, 'minimum_distance'), - param_evaluation(data_registry.short_range_distances, 'maximum_distance') + param_evaluation(data_registry.short_range_model['conversational_distance'], 'minimum_distance'), + param_evaluation(data_registry.short_range_model['conversational_distance'], 'maximum_distance') ), function=lambda x: np.interp(x, distances, frequencies, left=0., right=0.), max_function=0.13 diff --git a/caimira/calculator/report/report_generator.py b/caimira/calculator/report/report_generator.py index ade26f6b..7c6f5cea 100644 --- a/caimira/calculator/report/report_generator.py +++ b/caimira/calculator/report/report_generator.py @@ -10,9 +10,9 @@ # from caimira.apps.calculator import markdown_tools # from caimira.profiler import profile from caimira.calculator.store.data_registry import DataRegistry -from caimira.calculator.models import monte_carlo as mc from caimira.calculator.validators.virus.virus_validator import VirusFormData from caimira.calculator.models import dataclass_utils +from caimira.calculator.models.enums import ViralLoads def model_start_end(model: models.ExposureModel): t_start = min(model.exposed.presence_interval().boundaries()[0][0], @@ -165,12 +165,27 @@ def calculate_report_data(form: VirusFormData, model: models.ExposureModel, exec prob_dist_count, prob_dist_bins = np.histogram(prob/100, bins=100, density=True) prob_probabilistic_exposure = np.array(model.total_probability_rule()).mean() expected_new_cases = np.array(model.expected_new_cases()).mean() - uncertainties_plot_src = img2base64(_figure2bytes(uncertainties_plot(model, prob))) if form.conditional_probability_plot else None exposed_presence_intervals = [list(interval) for interval in model.exposed.presence_interval().boundaries()] - conditional_probability_data = {key: value for key, value in - zip(('viral_loads', 'pi_means', 'lower_percentiles', 'upper_percentiles'), - manufacture_conditional_probability_data(model, prob))} + if (model.data_registry.virological_data['virus_distributions'][form.virus_type]['viral_load_in_sputum'] == ViralLoads.COVID_OVERALL.value # type: ignore + and form.conditional_probability_plot): # Only generate this data if covid_overall_vl_data is selected. + + viral_load_in_sputum: models._VectorisedFloat = model.concentration_model.infected.virus.viral_load_in_sputum + viral_loads, pi_means, lower_percentiles, upper_percentiles = manufacture_conditional_probability_data(model, prob) + + uncertainties_plot_src = img2base64(_figure2bytes(uncertainties_plot(prob, viral_load_in_sputum, viral_loads, + pi_means, lower_percentiles, upper_percentiles))) + conditional_probability_data = {key: value for key, value in + zip(('viral_loads', 'pi_means', 'lower_percentiles', 'upper_percentiles'), + (viral_loads, pi_means, lower_percentiles, upper_percentiles))} + vl_dist = list(np.log10(viral_load_in_sputum)) + + else: + uncertainties_plot_src = None + conditional_probability_data = None + vl = model.concentration_model.virus.viral_load_in_sputum + if isinstance(vl, np.ndarray): vl_dist = list(np.log10(model.concentration_model.virus.viral_load_in_sputum)) + else: vl_dist = np.log10(model.concentration_model.virus.viral_load_in_sputum) return { "model_repr": repr(model), @@ -191,7 +206,7 @@ def calculate_report_data(form: VirusFormData, model: models.ExposureModel, exec "expected_new_cases": expected_new_cases, "uncertainties_plot_src": uncertainties_plot_src, "CO2_concentrations": CO2_concentrations, - "vl_dist": list(np.log10(model.concentration_model.virus.viral_load_in_sputum)), + "vl_dist": vl_dist, "conditional_probability_data": conditional_probability_data, } @@ -211,8 +226,8 @@ def conditional_prob_inf_given_vl_dist( for vl_log in viral_loads: specific_prob = infection_probability[np.where((vl_log-step/2-specific_vl)*(vl_log+step/2-specific_vl)<0)[0]] #type: ignore pi_means.append(specific_prob.mean()) - lower_percentiles.append(np.quantile(specific_prob, data_registry.conditional_prob_inf_given_viral_load['lower_percentile'])) - upper_percentiles.append(np.quantile(specific_prob, data_registry.conditional_prob_inf_given_viral_load['upper_percentile'])) + lower_percentiles.append(np.quantile(specific_prob, 0.05)) + upper_percentiles.append(np.quantile(specific_prob, 0.95)) return pi_means, lower_percentiles, upper_percentiles @@ -223,8 +238,8 @@ def manufacture_conditional_probability_data( ): data_registry: DataRegistry = exposure_model.data_registry - min_vl = data_registry.conditional_prob_inf_given_viral_load['min_vl'] - max_vl = data_registry.conditional_prob_inf_given_viral_load['max_vl'] + min_vl = 2 + max_vl = 10 step = (max_vl - min_vl)/100 viral_loads = np.arange(min_vl, max_vl, step) specific_vl = np.log10(exposure_model.concentration_model.virus.viral_load_in_sputum) @@ -234,25 +249,29 @@ def manufacture_conditional_probability_data( return list(viral_loads), list(pi_means), list(lower_percentiles), list(upper_percentiles) -def uncertainties_plot(exposure_model: models.ExposureModel, prob: models._VectorisedFloat): - fig = plt.figure(figsize=(4, 7), dpi=110) - - infection_probability = prob / 100 - viral_loads, pi_means, lower_percentiles, upper_percentiles = manufacture_conditional_probability_data(exposure_model, infection_probability) +def uncertainties_plot(infection_probability: models._VectorisedFloat, + viral_load_in_sputum: models._VectorisedFloat, + viral_loads: models._VectorisedFloat, + pi_means: models._VectorisedFloat, + lower_percentiles: models._VectorisedFloat, + upper_percentiles: models._VectorisedFloat): - fig, axs = plt.subplots(2, 3, + fig, axes = plt.subplots(2, 3, gridspec_kw={'width_ratios': [5, 0.5] + [1], 'height_ratios': [3, 1], 'wspace': 0}, sharey='row', sharex='col') + + # Type hint for axs + axs: np.ndarray = np.array(axes) for y, x in [(0, 1)] + [(1, i + 1) for i in range(2)]: axs[y, x].axis('off') axs[0, 1].set_visible(False) - axs[0, 0].plot(viral_loads, pi_means, label='Predictive total probability') - axs[0, 0].fill_between(viral_loads, lower_percentiles, upper_percentiles, alpha=0.1, label='5ᵗʰ and 95ᵗʰ percentile') + axs[0, 0].plot(viral_loads, np.array(pi_means)/100, label='Predictive total probability') + axs[0, 0].fill_between(viral_loads, np.array(lower_percentiles)/100, np.array(upper_percentiles)/100, alpha=0.1, label='5ᵗʰ and 95ᵗʰ percentile') axs[0, 2].hist(infection_probability, bins=30, orientation='horizontal') axs[0, 2].set_xticks([]) @@ -263,8 +282,8 @@ def uncertainties_plot(exposure_model: models.ExposureModel, prob: models._Vecto axs[0, 2].set_xlim(0, highest_bar) axs[0, 2].text(highest_bar * 0.5, 0.5, - rf"$\bf{np.round(np.mean(infection_probability) * 100, 1)}$%", ha='center', va='center') - axs[1, 0].hist(np.log10(exposure_model.concentration_model.infected.virus.viral_load_in_sputum), + rf"$\bf{np.round(np.mean(infection_probability), 1)}$%", ha='center', va='center') + axs[1, 0].hist(np.log10(viral_load_in_sputum), bins=150, range=(2, 10), color='grey') axs[1, 0].set_facecolor("lightgrey") axs[1, 0].set_yticks([]) diff --git a/caimira/calculator/store/data_registry.py b/caimira/calculator/store/data_registry.py index b232940d..5b549858 100644 --- a/caimira/calculator/store/data_registry.py +++ b/caimira/calculator/store/data_registry.py @@ -1,4 +1,4 @@ -from ..models.enums import ViralLoads, InfectiousDoses, ViableToRNARatios +from ..models.enums import ViralLoads class DataRegistry: @@ -6,345 +6,425 @@ class DataRegistry: version = None - BLOmodel = { - "cn": { - "B": 0.06, - "L": 0.2, - "O": 0.0010008, - }, - "mu": { - "B": 0.989541, - "L": 1.38629, - "O": 4.97673, - }, - "sigma": { - "B": 0.262364, - "L": 0.506818, - "O": 0.585005, + expiration_particle = { + "long_range_particle_diameter": { + "minimum_diameter": 0.1, + "maximum_diameter": 30, + "references": "Morawska et al. (https://doi.org/10.1016/j.jaerosci.2008.11.002); Johnson et al. (https://doi.org/10.1016/j.jaerosci.2011.07.009).", + }, + "short_range_particle_diameter": { + "minimum_diameter": 0.1, + "maximum_diameter": 100, + "references": "Morawska et al. (https://doi.org/10.1016/j.jaerosci.2008.11.002); Johnson et al. (https://doi.org/10.1016/j.jaerosci.2011.07.009).", + }, + "BLOmodel": { + "cn": {"B": 0.06, "L": 0.2, "O": 0.0010008}, + "mu": {"B": 0.989541, "L": 1.38629, "O": 4.97673}, + "sigma": {"B": 0.262364, "L": 0.506818, "O": 0.585005}, + "references": "Morawska et al. (https://doi.org/10.1016/j.jaerosci.2008.11.002); Johnson et al. (https://doi.org/10.1016/j.jaerosci.2011.07.009).", + }, + "expiration_BLO_factors": { + "Breathing": {"B": 1., "L": 0., "O": 0., }, + "Speaking": {"B": 1., "L": 1., "O": 1., }, + "Singing": {"B": 1., "L": 5., "O": 5., }, + "Shouting": {"B": 1., "L": 5., "O": 5., }, + "references": "Morawska et al. (https://doi.org/10.1016/j.jaerosci.2008.11.002); Johnson et al. (https://doi.org/10.1016/j.jaerosci.2011.07.009).", + }, + "particle": { + "evaporation_factor": 0.3, + "references": "Marr et al. (https://doi.org/10.1098/rsif.2018.0298).", }, } + activity_distributions = { "Seated": { "inhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": -0.6872121723362303, "lognormal_standard_deviation_gaussian": 0.10498338229297108, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, "exhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": -0.6872121723362303, "lognormal_standard_deviation_gaussian": 0.10498338229297108, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, }, "Standing": { "inhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": -0.5742377578494785, "lognormal_standard_deviation_gaussian": 0.09373162411398223, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, "exhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": -0.5742377578494785, "lognormal_standard_deviation_gaussian": 0.09373162411398223, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, }, "Light activity": { "inhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": 0.21380242785625422, "lognormal_standard_deviation_gaussian": 0.09435378091059601, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, "exhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": 0.21380242785625422, "lognormal_standard_deviation_gaussian": 0.09435378091059601, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, }, "Moderate activity": { "inhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": 0.551771330362601, "lognormal_standard_deviation_gaussian": 0.1894616357138137, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, "exhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": 0.551771330362601, "lognormal_standard_deviation_gaussian": 0.1894616357138137, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, }, "Heavy exercise": { "inhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": 1.1644665696723049, "lognormal_standard_deviation_gaussian": 0.21744554768657565, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, "exhalation_rate": { - "associated_distribution": "Log-normal", + "associated_value": "Log-normal distribution", "parameters": { "lognormal_mean_gaussian": 1.1644665696723049, "lognormal_standard_deviation_gaussian": 0.21744554768657565, }, + "references": "Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", }, }, } - symptomatic_vl_frequencies = { - "log_variable": [ - 2.46032, - 2.67431, - 2.85434, - 3.06155, - 3.25856, - 3.47256, - 3.66957, - 3.85979, - 4.09927, - 4.27081, - 4.47631, - 4.66653, - 4.87204, - 5.10302, - 5.27456, - 5.46478, - 5.6533, - 5.88428, - 6.07281, - 6.30549, - 6.48552, - 6.64856, - 6.85407, - 7.10373, - 7.30075, - 7.47229, - 7.66081, - 7.85782, - 8.05653, - 8.27053, - 8.48453, - 8.65607, - 8.90573, - 9.06878, - 9.27429, - 9.473, - 9.66152, - 9.87552, - ], - "frequencies": [ - 0.001206885, - 0.007851618, - 0.008078144, - 0.01502491, - 0.013258014, - 0.018528495, - 0.020053765, - 0.021896167, - 0.022047184, - 0.018604005, - 0.01547796, - 0.018075445, - 0.021503523, - 0.022349217, - 0.025097721, - 0.032875078, - 0.030594727, - 0.032573045, - 0.034717482, - 0.034792991, - 0.033267721, - 0.042887485, - 0.036846816, - 0.03876473, - 0.045016819, - 0.040063473, - 0.04883754, - 0.043944602, - 0.048142864, - 0.041588741, - 0.048762031, - 0.027921732, - 0.033871788, - 0.022122693, - 0.016927718, - 0.008833228, - 0.00478598, - 0.002807662, - ], - "kernel_bandwidth": 0.1, - } - covid_overal_vl_data = { - "shape_factor": 3.47, - "scale_factor": 7.01, - "start": 0.01, - "stop": 0.99, - "num": 30.0, - "min_bound": 2, - "max_bound": 10, - "interpolation_fp_left": 0, - "interpolation_fp_right": 0, - "max_function": 0.2, - } - viable_to_RNA_ratio_distribution = { - "low": 0.01, - "high": 0.6, - } - infectious_dose_distribution = { - "low": 10, - "high": 100, - } - virus_distributions = { - "SARS_CoV_2": { - "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, - "infectious_dose": InfectiousDoses.DISTRIBUTION.value, - "viable_to_RNA_ratio": ViableToRNARatios.DISTRIBUTION.value, - "transmissibility_factor": 1, - "infectiousness_days": 14, - }, - "SARS_CoV_2_ALPHA": { - "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, - "infectious_dose": InfectiousDoses.DISTRIBUTION.value, - "viable_to_RNA_ratio": ViableToRNARatios.DISTRIBUTION.value, - "transmissibility_factor": 0.78, - "infectiousness_days": 14, - }, - "SARS_CoV_2_BETA": { - "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, - "infectious_dose": InfectiousDoses.DISTRIBUTION.value, - "viable_to_RNA_ratio": ViableToRNARatios.DISTRIBUTION.value, - "transmissibility_factor": 0.8, - "infectiousness_days": 14, - }, - "SARS_CoV_2_GAMMA": { - "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, - "infectious_dose": InfectiousDoses.DISTRIBUTION.value, - "viable_to_RNA_ratio": ViableToRNARatios.DISTRIBUTION.value, - "transmissibility_factor": 0.72, - "infectiousness_days": 14, - }, - "SARS_CoV_2_DELTA": { - "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, - "infectious_dose": InfectiousDoses.DISTRIBUTION.value, - "viable_to_RNA_ratio": ViableToRNARatios.DISTRIBUTION.value, - "transmissibility_factor": 0.51, - "infectiousness_days": 14, - }, - "SARS_CoV_2_OMICRON": { - "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, - "infectious_dose": InfectiousDoses.DISTRIBUTION.value, - "viable_to_RNA_ratio": ViableToRNARatios.DISTRIBUTION.value, - "transmissibility_factor": 0.2, - "infectiousness_days": 14, - }, - "SARS_CoV_2_Other": { - "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, - "infectious_dose": InfectiousDoses.DISTRIBUTION.value, - "viable_to_RNA_ratio": ViableToRNARatios.DISTRIBUTION.value, - "transmissibility_factor": 0.1, - "infectiousness_days": 14, + + virological_data = { + "symptomatic_vl_frequencies": { + "associated_value": "Log Custom Kernel distribution", + "parameters": { + "log_variable": [ + 2.46032, + 2.67431, + 2.85434, + 3.06155, + 3.25856, + 3.47256, + 3.66957, + 3.85979, + 4.09927, + 4.27081, + 4.47631, + 4.66653, + 4.87204, + 5.10302, + 5.27456, + 5.46478, + 5.6533, + 5.88428, + 6.07281, + 6.30549, + 6.48552, + 6.64856, + 6.85407, + 7.10373, + 7.30075, + 7.47229, + 7.66081, + 7.85782, + 8.05653, + 8.27053, + 8.48453, + 8.65607, + 8.90573, + 9.06878, + 9.27429, + 9.473, + 9.66152, + 9.87552, + ], + "frequencies": [ + 0.001206885, + 0.007851618, + 0.008078144, + 0.01502491, + 0.013258014, + 0.018528495, + 0.020053765, + 0.021896167, + 0.022047184, + 0.018604005, + 0.01547796, + 0.018075445, + 0.021503523, + 0.022349217, + 0.025097721, + 0.032875078, + 0.030594727, + 0.032573045, + 0.034717482, + 0.034792991, + 0.033267721, + 0.042887485, + 0.036846816, + 0.03876473, + 0.045016819, + 0.040063473, + 0.04883754, + 0.043944602, + 0.048142864, + 0.041588741, + 0.048762031, + 0.027921732, + 0.033871788, + 0.022122693, + 0.016927718, + 0.008833228, + 0.00478598, + 0.002807662, + ], + "kernel_bandwidth": 0.1, + }, + "references": "Henriques et al. (https://doi.org/10.1101/2021.10.14.21264988) and references therein.", + }, + "covid_overal_vl_data": { + "associated_value": "Weibull distribution", + "parameters": { + "shape_factor": 3.47, + "scale_factor": 7.01, + "start": 0.01, + "stop": 0.99, + "num": 30.0, + "min_bound": 2, + "max_bound": 10, + "interpolation_fp_left": 0, + "interpolation_fp_right": 0, + "max_function": 0.2, + }, + "references": "Chen et al. (https://elifesciences.org/articles/65774); First line of the figure in https://iiif.elifesciences.org/lax:65774%2Felife-65774-fig4-figsupp3-v2.tif/full/1500,/0/default.jpg.", + }, + "virus_distributions": { + "SARS_CoV_2": { + "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, + "infectious_dose": { + "associated_value": "Uniform distribution", + "parameters": {"low": 10, "high": 100}, + "references": "Lednicky et al. (https://doi.org/10.1016/j.ijid.2020.09.025); Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", + }, + "viable_to_RNA_ratio": { + "associated_value": "Uniform distribution", + "parameters": {"low": 0.01, "high": 0.6}, + "references": "", + }, + "transmissibility_factor": { + "value": 1, + "references": "Campbell et al. (https://doi.org/10.2807/1560-7917.ES.2021.26.24.2100509.)", + }, + "infectiousness_days": { + "value": 14, + "references": "", + }, + }, + "SARS_CoV_2_ALPHA": { + "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, + "infectious_dose": { + "associated_value": "Uniform distribution", + "parameters": {"low": 10, "high": 100}, + "references": "Lednicky et al. (https://doi.org/10.1016/j.ijid.2020.09.025); Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", + }, + "viable_to_RNA_ratio": { + "associated_value": "Uniform distribution", + "parameters": {"low": 0.01, "high": 0.6}, + "references": "", + }, + "transmissibility_factor": { + "value": 0.78, + "references": "Campbell et al. (https://doi.org/10.2807/1560-7917.ES.2021.26.24.2100509.)", + }, + "infectiousness_days": { + "value": 14, + "references": "", + }, + }, + "SARS_CoV_2_BETA": { + "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, + "infectious_dose": { + "associated_value": "Uniform distribution", + "parameters": {"low": 10, "high": 100}, + "references": "Lednicky et al. (https://doi.org/10.1016/j.ijid.2020.09.025); Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", + }, + "viable_to_RNA_ratio": { + "associated_value": "Uniform distribution", + "parameters": {"low": 0.01, "high": 0.6}, + "references": "", + }, + "transmissibility_factor": { + "value": 0.8, + "references": "Campbell et al. (https://doi.org/10.2807/1560-7917.ES.2021.26.24.2100509.)", + }, + "infectiousness_days": { + "value": 14, + "references": "", + }, + }, + "SARS_CoV_2_GAMMA": { + "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, + "infectious_dose": { + "associated_value": "Uniform distribution", + "parameters": {"low": 10, "high": 100}, + "references": "Lednicky et al. (https://doi.org/10.1016/j.ijid.2020.09.025); Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", + }, + "viable_to_RNA_ratio": { + "associated_value": "Uniform distribution", + "parameters": {"low": 0.01, "high": 0.6}, + "references": "", + }, + "transmissibility_factor": { + "value": 0.72, + "references": "Campbell et al. (https://doi.org/10.2807/1560-7917.ES.2021.26.24.2100509.)", + }, + "infectiousness_days": { + "value": 14, + "references": "", + }, + }, + "SARS_CoV_2_DELTA": { + "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, + "infectious_dose": { + "associated_value": "Uniform distribution", + "parameters": {"low": 10, "high": 100}, + "references": "Lednicky et al. (https://doi.org/10.1016/j.ijid.2020.09.025); Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", + }, + "viable_to_RNA_ratio": { + "associated_value": "Uniform distribution", + "parameters": {"low": 0.01, "high": 0.6}, + "references": "", + }, + "transmissibility_factor": { + "value": 0.51, + "references": "Campbell et al. (https://doi.org/10.2807/1560-7917.ES.2021.26.24.2100509.)", + }, + "infectiousness_days": { + "value": 14, + "references": "", + }, + }, + "SARS_CoV_2_OMICRON": { + "viral_load_in_sputum": ViralLoads.COVID_OVERALL.value, + "infectious_dose": { + "associated_value": "Uniform distribution", + "parameters": {"low": 10, "high": 100}, + "references": "Lednicky et al. (https://doi.org/10.1016/j.ijid.2020.09.025); Henriques et al. (https://doi.org/10.1098/rsfs.2021.0076) and references therein.", + }, + "viable_to_RNA_ratio": { + "associated_value": "Uniform distribution", + "parameters": {"low": 0.01, "high": 0.6}, + "references": "", + }, + "transmissibility_factor": { + "value": 0.2, + "references": "", + }, + "infectiousness_days": { + "value": 14, + "references": "", + }, + }, }, } + mask_distributions = { "Type I": { "η_inhale": { - "associated_distribution": "Uniform", + "associated_value": "Uniform distribution", "parameters": { "low": 0.25, "high": 0.80, }, + "references": "Pan et al. (https://doi.org/10.1080/02786826.2021.1890687); Booth et al. (https://doi.org/10.1016/j.jhin.2013.02.007); Monn et al. (https://doi.org/10.4209/aaqr.2020.08.0531).", + }, + "factor_exhale": { + "value": 1, + "references": "", }, - "Known filtration efficiency of masks when exhaling?": "No", - "factor_exhale": 1, }, "FFP2": { "η_inhale": { - "associated_distribution": "Uniform", + "associated_value": "Uniform distribution", "parameters": { "low": 0.83, "high": 0.91, }, + "references": "Pan et al. (https://doi.org/10.1080/02786826.2021.1890687); Booth et al. (https://doi.org/10.1016/j.jhin.2013.02.007); Monn et al. (https://doi.org/10.4209/aaqr.2020.08.0531).", + }, + "factor_exhale": { + "value": 1, + "references": "", }, - "Known filtration efficiency of masks when exhaling?": "No", - "factor_exhale": 1, }, "Cloth": { "η_inhale": { - "associated_distribution": "Uniform", + "associated_value": "Uniform distribution", "parameters": { "low": 0.05, "high": 0.40, }, + "references": "Pan et al. (https://doi.org/10.1080/02786826.2021.1890687); Booth et al. (https://doi.org/10.1016/j.jhin.2013.02.007); Monn et al. (https://doi.org/10.4209/aaqr.2020.08.0531).", }, - "Known filtration efficiency of masks when exhaling?": "Yes", "η_exhale": { - "associated_distribution": "Uniform", + "associated_value": "Uniform distribution", "parameters": { "low": 0.20, "high": 0.50, }, + "references": "Pan et al. (https://doi.org/10.1080/02786826.2021.1890687); Booth et al. (https://doi.org/10.1016/j.jhin.2013.02.007); Monn et al. (https://doi.org/10.4209/aaqr.2020.08.0531).", + }, + "factor_exhale": { + "value": 1, + "references": "", }, - "factor_exhale": 1, - }, - } - expiration_BLO_factors = { - "Breathing": { - "B": 1.0, - "L": 0.0, - "O": 0.0, - }, - "Speaking": { - "B": 1.0, - "L": 1.0, - "O": 1.0, - }, - "Singing": { - "B": 1.0, - "L": 5.0, - "O": 5.0, - }, - "Shouting": { - "B": 1.0, - "L": 5.0, - "O": 5.0, }, } - long_range_expiration_distributions = { - "minimum_diameter": 0.1, - "maximum_diameter": 30, - } - short_range_expiration_distributions = { - "minimum_diameter": 0.1, - "maximum_diameter": 100, - } - short_range_distances = { - "minimum_distance": 0.5, - "maximum_distance": 2.0, - } #################################### room = { - "defaults": { - "inside_temp": 293, - "humidity_with_heating": 0.3, - "humidity_without_heating": 0.5, - }, + "inside_temp": 293., + "humidity_with_heating": 0.3, + "humidity_without_heating": 0.5, + "references": "", } + ventilation = { "natural": { "discharge_factor": { @@ -352,42 +432,45 @@ class DataRegistry: }, }, "infiltration_ventilation": 0.25, + "references": "Henriques et al. (https://doi.org/10.1101/2021.10.14.21264988).", } - particle = { - "evaporation_factor": 0.3, - } - population_with_virus = { - "fraction_of_infectious_virus": 1, - } + concentration_model = { - "min_background_concentration": 0.0, + "virus_concentration_model": { + "min_background_concentration": 0.0, + "references": "", + }, "CO2_concentration_model": { "CO2_atmosphere_concentration": 440.44, "CO2_fraction_exhaled": 0.042, + "references": "", }, } + short_range_model = { "dilution_factor": { "mouth_diameter": 0.02, "exhalation_coefficient": 2, - "tstar": 2, + "breathing_cycle": 4, "penetration_coefficients": { "𝛽r1": 0.18, "𝛽r2": 0.2, "𝛽x1": 2.4, }, + "references": "Jia et al. (https://doi.org/10.1016/j.buildenv.2022.109166).", + }, + "conversational_distance": { + "minimum_distance": 0.5, + "maximum_distance": 2.0, + "references": "Derived from Fig. 8 a) 'stand-stand' in Zhang et al. (https://www.mdpi.com/1660-4601/17/4/1445).", }, } - exposure_model = { - "repeats": 1, - } - conditional_prob_inf_given_viral_load = { - "lower_percentile": 0.05, - "upper_percentile": 0.95, - "min_vl": 2, - "max_vl": 10, + + monte_carlo = { + "sample_size": 250000, + "references": "", } - monte_carlo_sample_size = 250000 + population_scenario_activity = { "office": {"placeholder": "Office", "activity": "Seated", "expiration": {"Speaking": 1, "Breathing": 2}}, "smallmeeting": { @@ -441,7 +524,7 @@ class DataRegistry: "expiration": {"Breathing": 5, "Speaking": 5}, }, "secondary-school": { - "placeholder": "Primary school", + "placeholder": "Secondary school", "activity": "Light activity", "expiration": {"Breathing": 7, "Speaking": 3}, }, diff --git a/caimira/calculator/store/data_service.py b/caimira/calculator/store/data_service.py index 79d25dba..7271e708 100644 --- a/caimira/calculator/store/data_service.py +++ b/caimira/calculator/store/data_service.py @@ -20,7 +20,7 @@ def __init__( self._host = host @classmethod - def create(cls, host: str = "https://caimira-data-api.app.cern.ch"): + def create(cls, host: str = "https://caimira-data-api-qa.app.cern.ch"): """Factory.""" return cls(host) diff --git a/caimira/calculator/tests/apps/calculator/conftest.py b/caimira/calculator/tests/apps/calculator/conftest.py index d1ac45d1..d1aa1065 100644 --- a/caimira/calculator/tests/apps/calculator/conftest.py +++ b/caimira/calculator/tests/apps/calculator/conftest.py @@ -11,3 +11,12 @@ def baseline_form_data(): @pytest.fixture def baseline_form(baseline_form_data, data_registry): return virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) + + +@pytest.fixture +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_occupants'] = 5 + return virus_validator.VirusFormData.from_dict(form_data_sr, data_registry) diff --git a/caimira/calculator/tests/models/test_short_range_model.py b/caimira/calculator/tests/models/test_short_range_model.py index f762a777..0382369a 100644 --- a/caimira/calculator/tests/models/test_short_range_model.py +++ b/caimira/calculator/tests/models/test_short_range_model.py @@ -49,7 +49,7 @@ def test_short_range_model_ndarray(concentration_model, short_range_model): model = short_range_model.build_model(SAMPLE_SIZE) assert isinstance(model._normed_concentration(concentration_model, 10.75), np.ndarray) assert isinstance(model.short_range_concentration(concentration_model, 10.75), np.ndarray) - assert isinstance(model._normed_jet_exposure_between_bounds(concentration_model, 10.75, 10.85), np.ndarray) + assert isinstance(model._normed_jet_exposure_between_bounds(10.75, 10.85), np.ndarray) assert isinstance(model._normed_interpolated_longrange_exposure_between_bounds(concentration_model, 10.75, 10.85), np.ndarray) assert isinstance(model.short_range_concentration(concentration_model, 14.0), float) @@ -106,9 +106,9 @@ def test_extract_between_bounds(short_range_model, time1, time2, @pytest.mark.parametrize( "time, expected_short_range_concentration", [ [8.5, 0.], - [10.5, 11.266605], - [10.6, 11.266605], - [11.0, 11.266605], + [10.5, 5.6333025], + [10.6, 5.6333025], + [11.0, 5.6333025], [12.0, 0.], ] ) diff --git a/caimira/calculator/tests/test_full_algorithm.py b/caimira/calculator/tests/test_full_algorithm.py index fb5f1c78..adb93df0 100644 --- a/caimira/calculator/tests/test_full_algorithm.py +++ b/caimira/calculator/tests/test_full_algorithm.py @@ -16,6 +16,7 @@ expiration_BLO_factors,short_range_expiration_distributions, short_range_distances,virus_distributions,activity_distributions) + SAMPLE_SIZE = 1_000_000 TOLERANCE = 0.04 @@ -55,7 +56,7 @@ class SimpleConcentrationModel: #: Number of infected people num_infected: int = 1 - #: Fraction of infected viruses (viable to RNA ratio) + #: Viable to RNA ratio viable_to_RNA: _VectorisedFloat = 0.5 #: Host immunity factor (0. for not immune) @@ -97,6 +98,12 @@ def removal_rate(self) -> _VectorisedFloat: return (self.lambda_ventilation + ln2/(np.where(hl_calc <= 0, 6.43, np.minimum(6.43, hl_calc)))) + def fraction_of_infectious_virus(self) -> _VectorisedFloat: + """ + The fraction of infectious virus. + """ + return self.viable_to_RNA * (1 - self.HI) + @method_cache def deposition_removal_coefficient(self) -> float: """ @@ -181,8 +188,8 @@ def concentration(self,t: float) -> _VectorisedFloat: return ( ( (0 if not self.infected_presence.triggered(t) else self.f(lambda_rate,0)) + result * np.exp(-lambda_rate*(t-ti)) ) - * self.num_infected * self.viable_to_RNA - * (1. - self.HI) / self.room_volume) + * self.num_infected * self.fraction_of_infectious_virus() + / self.room_volume) @dataclass(frozen=True) @@ -263,6 +270,7 @@ def jet_concentration(self,conc_model: SimpleConcentrationModel) -> _VectorisedF we perform the integral of Np(d)*V(d) over diameter analytically """ vl = conc_model.viral_load + viable_to_RNA = conc_model.viable_to_RNA dmin = self.diameter_min dmax = self.diameter_max result = 0. @@ -273,7 +281,7 @@ def jet_concentration(self,conc_model: SimpleConcentrationModel) -> _VectorisedF ymax = (np.log(dmax)-mu)/(sqrt2*sigma)-3.*sigma/sqrt2 result += ( (cn * famp * d0**3)/2. * np.exp(9*sigma**2/2.) * (erf(ymax) - erf(ymin)) ) - return vl * 1e-6 * result * np.pi/6. + return vl * viable_to_RNA * 1e-6 * result * np.pi/6. def concentration(self, conc_model: SimpleConcentrationModel, time: float) -> _VectorisedFloat: """ @@ -410,8 +418,8 @@ def primitive(time): else self.f_with_fdep(lambda_rate,0,evaporation)*(t2-t1)) + (primitive(t2) * np.exp(-lambda_rate*(t2-ti)) - primitive(t1) * np.exp(-lambda_rate*(t1-ti)) ) ) - * self.num_infected * self.viable_to_RNA - * (1. - self.HI) / self.room_volume) + * self.num_infected * self.fraction_of_infectious_virus() + / self.room_volume) @method_cache def integrated_shortrange_concentration(self) -> _VectorisedFloat: @@ -430,7 +438,7 @@ def integrated_shortrange_concentration(self) -> _VectorisedFloat: res = (quad(integrand, sr_model.diameter_min,sr_model.diameter_max, epsabs=0.,limit=500)[0] - * self.viral_load * 1e-6 * (t2-t1) ) + * self.viral_load * self.fraction_of_infectious_virus() * 1e-6 * (t2-t1) ) result += sr_model.breathing_rate * ( res-self.integrated_longrange_concentration(t1,t2,evaporation) )/sr_model.dilution_factor() @@ -842,3 +850,119 @@ def test_exposure_with_shortrange_and_distributions(expo_sr_model_distr, rtol=0.03 ) + +def exposure_model_from_parameter(data_registry, short_range_models, f_inf=0.5, viral_load=1e9, BR=1.25): + virus: models.SARSCoV2 = models.SARSCoV2( + viral_load_in_sputum=viral_load, + infectious_dose=50, + viable_to_RNA_ratio=f_inf, + transmissibility_factor=0.51, + ) + c_model = mc.ConcentrationModel( + data_registry=data_registry, + room=models.Room(volume=50, humidity=0.3), + ventilation=models.AirChange(active=models.PeriodicInterval(period=120, duration=120), + air_exch=10_000_000), + infected=mc.InfectedPopulation( + data_registry=data_registry, + number=1, + presence=models.SpecificInterval(present_times=((8.5, 12.5), (13.5, 17.5))), + virus=virus, + mask=models.Mask.types['No mask'], + activity=models.Activity.types['Seated'], + expiration=expiration_distributions(data_registry)['Breathing'], + host_immunity=0., + ), + evaporation_factor=0.3, + ) + return mc.ExposureModel( + data_registry=data_registry, + concentration_model=c_model, + short_range=short_range_models, + exposed=mc.Population( + number=1, + presence=models.SpecificInterval(present_times=((8.5, 12.5), (13.5, 17.5))), + mask=models.Mask.types['No mask'], + activity=models.Activity(inhalation_rate=BR, exhalation_rate=1.25), + host_immunity=0., + ), + geographical_data=models.Cases(), + ).build_model(SAMPLE_SIZE) + + +@retry(tries=10) +def test_exposure_scale_with_f_inf(data_registry, sr_models): + """ + Exposure scaling test for the fraction of infectious virus. + """ + e_model_1: models.ExposureModel = exposure_model_from_parameter(data_registry=data_registry, short_range_models=sr_models, f_inf=0.5) + e_model_2: models.ExposureModel = exposure_model_from_parameter(data_registry=data_registry, short_range_models=sr_models, f_inf=1) + np.testing.assert_allclose( + 2*e_model_1.deposited_exposure().mean(), + e_model_2.deposited_exposure().mean(), rtol=0.02 + ) + + +@retry(tries=10) +def test_exposure_scale_with_viral_load(data_registry, sr_models): + """ + Exposure scaling test for the viral load. + """ + e_model_1: models.ExposureModel = exposure_model_from_parameter(data_registry=data_registry, short_range_models=sr_models, viral_load=1e9) + e_model_2: models.ExposureModel = exposure_model_from_parameter(data_registry=data_registry, short_range_models=sr_models, viral_load=2e9) + np.testing.assert_allclose( + 2*e_model_1.deposited_exposure().mean(), + e_model_2.deposited_exposure().mean(), rtol=0.02 + ) + + +@retry(tries=10) +def test_lr_exposure_scale_with_breathing_rate(data_registry): + """ + Exposure scaling test for the breathing rate when there are only long-range + interactions defined. Only the inhalation rate of the infected takes place + at the deposited exposure level. + """ + e_model_1: models.ExposureModel = exposure_model_from_parameter(data_registry=data_registry, short_range_models=(), BR=1.25) + e_model_2: models.ExposureModel = exposure_model_from_parameter(data_registry=data_registry, short_range_models=(), BR=2.5) + np.testing.assert_allclose( + 2*e_model_1.deposited_exposure().mean(), + e_model_2.deposited_exposure().mean(), rtol=0.02 + ) + + +@retry(tries=10) +def test_exposure_scale_with_breathing_rate(data_registry, sr_models): + """ + Exposure scaling test for the breathing rate when long- and short-range + interactions are defined. We need to apply the multiplication factor + to the inhalation rate of the infected (long-range), but also for + each short-range interaction. + """ + e_model_1: models.ExposureModel = exposure_model_from_parameter(data_registry=data_registry, short_range_models=sr_models, BR=1.25) + + seated_act = models.Activity.types['Seated'] + heavy_exercise_act = models.Activity.types['Heavy exercise'] + sr_models_activity = ( + mc.ShortRangeModel( + data_registry = data_registry, + expiration = short_range_expiration_distributions(data_registry)['Speaking'], + activity = models.Activity(inhalation_rate=seated_act.inhalation_rate * 2, + exhalation_rate=seated_act.exhalation_rate), + presence = interaction_intervals[0], + distance = 0.854, + ), + mc.ShortRangeModel( + data_registry = data_registry, + expiration = short_range_expiration_distributions(data_registry)['Breathing'], + activity = models.Activity(inhalation_rate=heavy_exercise_act.inhalation_rate * 2, + exhalation_rate=heavy_exercise_act.inhalation_rate), + presence = interaction_intervals[1], + distance = 0.854, + ), + ) + e_model_2: models.ExposureModel = exposure_model_from_parameter(data_registry=data_registry, short_range_models=sr_models_activity, BR=2.5) + np.testing.assert_allclose( + 2*e_model_1.deposited_exposure().mean(), + e_model_2.deposited_exposure().mean(), rtol=0.02 + ) diff --git a/caimira/calculator/tests/test_monte_carlo_full_models.py b/caimira/calculator/tests/test_monte_carlo_full_models.py index a91f4a78..948ce45e 100644 --- a/caimira/calculator/tests/test_monte_carlo_full_models.py +++ b/caimira/calculator/tests/test_monte_carlo_full_models.py @@ -186,8 +186,8 @@ def skagit_chorale_mc(data_registry): presence=models.SpecificInterval(((0, 2.5), )), virus=mc.SARSCoV2( viral_load_in_sputum=10**9, - infectious_dose=infectious_dose_distribution(data_registry), - viable_to_RNA_ratio=viable_to_RNA_ratio_distribution(data_registry), + infectious_dose=infectious_dose_distribution(), + viable_to_RNA_ratio=viable_to_RNA_ratio_distribution(), transmissibility_factor=1., ), mask=models.Mask.types['No mask'], @@ -230,8 +230,8 @@ def bus_ride_mc(data_registry): presence=models.SpecificInterval(((0, 1.67), )), virus=mc.SARSCoV2( viral_load_in_sputum=5*10**8, - infectious_dose=infectious_dose_distribution(data_registry), - viable_to_RNA_ratio=viable_to_RNA_ratio_distribution(data_registry), + infectious_dose=infectious_dose_distribution(), + viable_to_RNA_ratio=viable_to_RNA_ratio_distribution(), transmissibility_factor=1., ), mask=models.Mask.types['No mask'], diff --git a/caimira/calculator/validators/co2/co2_validator.py b/caimira/calculator/validators/co2/co2_validator.py index 2c8bae6f..a630e774 100644 --- a/caimira/calculator/validators/co2/co2_validator.py +++ b/caimira/calculator/validators/co2/co2_validator.py @@ -159,7 +159,7 @@ def ventilation_transition_times(self) -> typing.Tuple[float, ...]: return tuple((self.CO2_data['times'][0], self.CO2_data['times'][-1])) def build_model(self, size=None) -> models.CO2DataModel: # type: ignore - size = size or self.data_registry.monte_carlo_sample_size + size = size or self.data_registry.monte_carlo['sample_size'] # Build a simple infected and exposed population for the case when presence # intervals and number of people are dynamic. Activity type is not needed. infected_presence = self.infected_present_interval() diff --git a/caimira/calculator/validators/defaults.py b/caimira/calculator/validators/defaults.py index 4976d266..e12664eb 100644 --- a/caimira/calculator/validators/defaults.py +++ b/caimira/calculator/validators/defaults.py @@ -73,6 +73,7 @@ 'sensor_in_use': '', 'short_range_option': 'short_range_no', 'short_range_interactions': '[]', + 'short_range_occupants': 0, } # ------------------ Activities ---------------------- diff --git a/caimira/calculator/validators/virus/virus_validator.py b/caimira/calculator/validators/virus/virus_validator.py index f77d1b20..74f10427 100644 --- a/caimira/calculator/validators/virus/virus_validator.py +++ b/caimira/calculator/validators/virus/virus_validator.py @@ -68,6 +68,7 @@ class VirusFormData(FormData): sensor_in_use: str short_range_option: str short_range_interactions: list + short_range_occupants: int _DEFAULTS: typing.ClassVar[typing.Dict[str, typing.Any]] = DEFAULTS @@ -199,6 +200,13 @@ def validate(self): if total_percentage != 100: raise ValueError( f'The sum of all respiratory activities should be 100. Got {total_percentage}.') + + # Validate number of people with short-range interactions + max_occupants_for_sr = self.total_people - self.infected_people + 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}).' + ) def initialize_room(self) -> models.Room: # Initializes room with volume either given directly or as product of area and height @@ -209,21 +217,20 @@ def initialize_room(self) -> models.Room: if self.arve_sensors_option == False: if self.room_heating_option: - humidity = self.data_registry.room['defaults']['humidity_with_heating'] + humidity = self.data_registry.room['humidity_with_heating'] else: - humidity = self.data_registry.room['defaults']['humidity_without_heating'] - inside_temp = self.data_registry.room['defaults']['inside_temp'] + humidity = self.data_registry.room['humidity_without_heating'] + inside_temp = self.data_registry.room['inside_temp'] else: humidity = float(self.humidity) inside_temp = self.inside_temp - return models.Room(volume=volume, inside_temp=models.PiecewiseConstant((0, 24), (inside_temp,)), humidity=humidity) + 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() ventilation: models._VentilationBase = self.ventilation() infected_population = self.infected_population() - short_range = [] if self.short_range_option == "short_range_yes": for interaction in self.short_range_interactions: @@ -252,14 +259,15 @@ def build_mc_model(self) -> mc.ExposureModel: geographic_cases=self.geographic_cases, ascertainment_bias=CONFIDENCE_LEVEL_OPTIONS[self.ascertainment_bias], ), + exposed_to_short_range=self.short_range_occupants, ) def build_model(self, sample_size=None) -> models.ExposureModel: - sample_size = sample_size or self.data_registry.monte_carlo_sample_size + sample_size = sample_size or self.data_registry.monte_carlo['sample_size'] return self.build_mc_model().build_model(size=sample_size) def build_CO2_model(self, sample_size=None) -> models.CO2ConcentrationModel: - sample_size = sample_size or self.data_registry.monte_carlo_sample_size + 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) @@ -557,7 +565,7 @@ def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]: 'mask_type': 'Type I', 'mask_wearing_option': 'mask_off', 'mechanical_ventilation_type': '', - 'calculator_version': '4.16.0', #TODO different version for API and calculator form? + 'calculator_version': '4.17.0', #TODO different version for API and calculator form? 'opening_distance': '0.2', 'event_month': 'January', 'room_heating_option': '0', diff --git a/caimira/core_requirements.txt b/caimira/core_requirements.txt new file mode 100644 index 00000000..e69de29b diff --git a/caimira/dev_requirements.txt b/caimira/dev_requirements.txt new file mode 100644 index 00000000..e69de29b diff --git a/caimira/pyproject.toml b/caimira/pyproject.toml new file mode 100644 index 00000000..1b68d94e --- /dev/null +++ b/caimira/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/caimira/setup.cfg b/caimira/setup.cfg new file mode 100644 index 00000000..5d55bc97 --- /dev/null +++ b/caimira/setup.cfg @@ -0,0 +1,2 @@ +[tool:pytest] +addopts = --mypy diff --git a/requirements.txt b/requirements.txt index 452741d2..6adbfedf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -49,16 +49,6 @@ json5==0.9.14 jsonpointer==2.4 jsonschema==4.21.1 jsonschema-specifications==2023.12.1 -jupyter_client==8.6.0 -jupyter_core==5.7.1 -jupyter-events==0.9.0 -jupyter-lsp==2.2.2 -jupyter_server==2.12.5 -jupyter_server_terminals==0.5.2 -jupyterlab==4.1.1 -jupyterlab_pygments==0.3.0 -jupyterlab_server==2.25.3 -jupyterlab-widgets==1.1.7 kiwisolver==1.4.5 loky==3.4.1 MarkupSafe==2.1.5 @@ -124,7 +114,6 @@ typing_extensions==4.9.0 tzdata==2024.1 uri-template==1.3.0 urllib3==2.2.0 -voila==0.5.5 wcwidth==0.2.13 webcolors==1.13 webencodings==0.5.1 diff --git a/setup.py b/setup.py index c0d05007..42954280 100644 --- a/setup.py +++ b/setup.py @@ -40,11 +40,10 @@ 'timezonefinder', 'tornado', 'types-retry', - 'voila', ], 'app': [], 'test': [ - 'pytest', + 'pytest < 8.2', 'pytest-mypy >= 0.10.3', 'mypy >= 1.0.0', 'pytest-tornasync', @@ -54,7 +53,6 @@ 'types-requests', ], 'dev': [ - 'jupyterlab', ], 'doc': [ 'sphinx', diff --git a/ui/apps/calculator/__init__.py b/ui/apps/calculator/__init__.py index 1806077c..b16b7c7a 100644 --- a/ui/apps/calculator/__init__.py +++ b/ui/apps/calculator/__init__.py @@ -45,7 +45,7 @@ # 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 CAiMIRA version (found at ``caimira.__version__``). -__version__ = "4.16.0" +__version__ = "4.17.0" LOG = logging.getLogger("Calculator") @@ -523,6 +523,9 @@ def make_app( (get_root_calculator_url(r'/user-guide'), GenericExtraPage, { 'active_page': 'calculator/user-guide', 'filename': 'userguide.html.j2'}), + (get_root_url(r'/expert-app'), GenericExtraPage, { + 'active_page': 'expert-app', + 'filename': 'expert-app.html.j2'}), ] profiler_enabled = int(os.environ.get('CAIMIRA_PROFILER_ENABLED', 0)) @@ -572,7 +575,11 @@ def make_app( data_registry = DataRegistry() data_service = None - data_service_enabled = os.environ.get('DATA_SERVICE_ENABLED', 0) + try: + data_service_enabled = int(os.environ.get('DATA_SERVICE_ENABLED', 0)) + except ValueError: + data_service_enabled = None + if data_service_enabled: data_service = DataService.create() return Application( diff --git a/ui/apps/calculator/report.py b/ui/apps/calculator/report.py index 4d33602d..0ab7ea3a 100644 --- a/ui/apps/calculator/report.py +++ b/ui/apps/calculator/report.py @@ -185,8 +185,8 @@ def manufacture_alternative_scenarios(form: VirusFormData) -> typing.Dict[str, m scenarios['Neither ventilation nor masks'] = without_mask_or_vent.build_mc_model() else: - no_short_range_alternative = dataclass_utils.replace( - form, short_range_interactions=[]) + no_short_range_alternative = dataclass_utils.replace(form, short_range_interactions=[], + total_people=form.total_people - form.short_range_occupants) scenarios['Base scenario without short-range interactions'] = no_short_range_alternative.build_mc_model() return scenarios @@ -198,7 +198,7 @@ def scenario_statistics( 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']) if (compute_prob_exposure): # It means we have data to calculate the total_probability_rule prob_probabilistic_exposure = model.total_probability_rule() diff --git a/ui/apps/calculator/static/js/form.js b/ui/apps/calculator/static/js/form.js index ed28293e..71023c2a 100644 --- a/ui/apps/calculator/static/js/form.js +++ b/ui/apps/calculator/static/js/form.js @@ -869,7 +869,7 @@ function validate_sr_parameter(obj, error_message) { if ($(obj).val() == "" || $(obj).val() == null) { if (!$(obj).hasClass("red_border") && !$(obj).prop("disabled")) { var parameter = document.getElementById($(obj).attr('id')); - insertErrorFor(parameter, error_message) + insertErrorFor(parameter, error_message); $(parameter).addClass("red_border"); } return false; @@ -880,6 +880,22 @@ function validate_sr_parameter(obj, error_message) { } } +function validate_sr_people(obj) { + let sr_total_people = document.getElementById($(obj).attr('id')); + let max = document.getElementById("total_people").valueAsNumber - document.getElementById("infected_people").valueAsNumber; + if ($(obj).val() == "" || $(obj).val() == null || sr_total_people.valueAsNumber > max) { + if (!$(obj).hasClass("red_border")) { + insertErrorFor(sr_total_people, "Value must be less or equal than the number of exposed people."); + $(sr_total_people).addClass("red_border"); + } + return false; + } else { + removeErrorFor($(obj)); + $(obj).removeClass("red_border"); + return true; + } +} + function parseValToNumber(val) { return parseInt(val.replace(':',''), 10); } @@ -1084,7 +1100,6 @@ $(document).ready(function () { validateMaxInfectedPeople(); $("#total_people").change(validateMaxInfectedPeople); $("#activity_type").change(validateMaxInfectedPeople); - $("#total_people").change(validateMaxInfectedPeople); $("#infected_people").change(validateMaxInfectedPeople); //Validate all non zero values @@ -1253,7 +1268,8 @@ $(document).ready(function () { let activity = validate_sr_parameter('#sr_expiration_no_' + String(index)[0], "Required input."); let start = validate_sr_parameter('#sr_start_no_' + String(index)[0], "Required input."); let duration = validate_sr_parameter('#sr_duration_no_' + String(index)[0], "Required input."); - if (activity && start && duration) { + let total_people = validate_sr_people('#short_range_occupants'); + if (activity && start && duration && total_people) { if (validate_sr_time('#sr_start_no_' + String(index)) && validate_sr_time('#sr_duration_no_' + String(index))) { document.getElementById('sr_expiration_no_' + String(index)).disabled = true; document.getElementById('sr_start_no_' + String(index)).disabled = true; diff --git a/ui/apps/calculator/tests/test_report_generator.py b/ui/apps/calculator/tests/test_report_generator.py index 08025ec5..ec4494b1 100644 --- a/ui/apps/calculator/tests/test_report_generator.py +++ b/ui/apps/calculator/tests/test_report_generator.py @@ -7,10 +7,11 @@ import pytest from ui.apps.calculator import make_app -from ui.apps.calculator import ReportGenerator -from ui.apps.calculator.report import readable_minutes -import caimira.calculator.report.report_generator as rep_gen from caimira.api.controller.report_controller import generate_model, generate_report_results +from caimira.apps.calculator.model_generator import VirusFormData +from caimira.apps.calculator.report_generator import (ReportGenerator, readable_minutes, calculate_report_data, + manufacture_alternative_scenarios, interesting_times, comparison_report) +import caimira.calculator.report.report_generator as rep_gen def test_generate_report(baseline_form) -> None: @@ -105,3 +106,27 @@ def test_interesting_times_w_temp(exposure_model_w_outside_temp_changes): 5., 5.4, 5.8, 6.2, 6.6, 7., 7.4, 7.8, 8. ] np.testing.assert_allclose(result, expected) + + +def test_expected_new_cases(baseline_form_with_sr: VirusFormData): + model = baseline_form_with_sr.build_model() + + executor_factory = partial( + concurrent.futures.ThreadPoolExecutor, 1, + ) + + # Short- and Long-range contributions + report_data = calculate_report_data(baseline_form_with_sr, model, executor_factory) + sr_lr_expected_new_cases = report_data['expected_new_cases'] + sr_lr_prob_inf = report_data['prob_inf']/100 + + # Long-range contributions alone + scenario_sample_times = interesting_times(model) + alternative_scenarios = manufacture_alternative_scenarios(baseline_form_with_sr) + alternative_statistics = comparison_report( + baseline_form_with_sr, report_data, alternative_scenarios, scenario_sample_times, executor_factory=executor_factory, + ) + + lr_expected_new_cases = alternative_statistics['stats']['Base scenario without short-range interactions']['expected_new_cases'] + 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) + \ No newline at end of file diff --git a/ui/apps/expert_apps/expert.py b/ui/apps/expert_apps/expert.py index 34bb115d..4e9cb315 100644 --- a/ui/apps/expert_apps/expert.py +++ b/ui/apps/expert_apps/expert.py @@ -12,11 +12,14 @@ import numpy as np import datetime import pandas as pd +import logging from . import state from caimira.calculator.models import data, models from caimira.calculator.store.data_registry import DataRegistry +LOG = logging.getLogger(__name__) + def collapsible(widgets_to_collapse: typing.List, title: str, start_collapsed=False): collapsed = widgets.Accordion([widgets.VBox(widgets_to_collapse)]) @@ -930,6 +933,10 @@ def build_type__VentilationBase(self, _: dataclasses.Field): class ExpertApplication(Controller): def __init__(self) -> None: + LOG.warning( + "ExpertApplication is currently deactivated and will no longer be maintained. It remains in the codebase for legacy purposes." + ) + self._data_registry = DataRegistry() #: A list of scenario name and ModelState instances. This is intended to be #: mutated. Any mutation should notify the appropriate Views for handling. diff --git a/ui/apps/expert_apps/expert_co2.py b/ui/apps/expert_apps/expert_co2.py index 2756f81a..cca1c075 100644 --- a/ui/apps/expert_apps/expert_co2.py +++ b/ui/apps/expert_apps/expert_co2.py @@ -2,6 +2,7 @@ import ipywidgets as widgets import typing import numpy as np +import logging from . import state from .expert import generate_presence_widget, collapsible, ipympl_canvas, WidgetGroup, CAIMIRAStateBuilder @@ -13,6 +14,8 @@ import matplotlib.lines as mlines import matplotlib.patches as patches +LOG = logging.getLogger(__name__) + def baseline_model(data_registry: DataRegistry): return models.CO2ConcentrationModel( @@ -190,6 +193,10 @@ def update_plot(self, CO2_models: typing.Tuple[models.CO2ConcentrationModel, ... class CO2Application(Controller): def __init__(self) -> None: + LOG.warning( + "CO2Application is currently deactivated and will no longer be maintained. It remains in the codebase for legacy purposes." + ) + self._data_registry = DataRegistry() # self._debug_output = widgets.Output() diff --git a/ui/apps/templates/about.html.j2 b/ui/apps/templates/about.html.j2 index 28140cc1..c503c1bb 100644 --- a/ui/apps/templates/about.html.j2 +++ b/ui/apps/templates/about.html.j2 @@ -15,7 +15,7 @@ CAiMIRA stands for CERN Airborne Model for Indoor Risk Assessment, previously kn Since then, the model has evolved and now is capable of simulating the short-range component. CAiMIRA comes with different applications that allow more or less flexibility in the input parameters: The mathematical and physical model simulate the airborne spread of SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture and a two-stage exhaled jet model, and estimates the risk of COVID-19 airborne transmission therein. The results DO NOT include other known modes of SARS-CoV-2 transmission. Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as good hand hygiene and other barrier measures.
diff --git a/ui/apps/templates/base/calculator.form.html.j2 b/ui/apps/templates/base/calculator.form.html.j2 index 6842ea63..745612e6 100644 --- a/ui/apps/templates/base/calculator.form.html.j2 +++ b/ui/apps/templates/base/calculator.form.html.j2 @@ -584,6 +584,16 @@
+
+
+
+ +
+
+ +
+
+

{% if form.short_range_option == "short_range_yes" %} @@ -118,18 +126,19 @@ {% endblock warning_animation %} +
Expected new cases: {{ expected_new_cases | float_format }}
{% endif %}
{% block report_summary %}
{% if form.short_range_option == "short_range_yes" %}
{% endif %} {% block probabilistic_exposure_probability %} @@ -206,10 +215,12 @@
-
- - -
+ {% if model.data_registry.virological_data['virus_distributions'][form.virus_type]['viral_load_in_sputum'] == 'Ref: Viral load - covid_overal_vl_data' %} +
+ + +
+ {% endif %} {% if form.conditional_probability_plot %}
@@ -601,14 +612,16 @@ {% endif %}

{% if form.short_range_option == "short_range_yes" %} -
  • - Short-range interactions: {{ form.short_range_interactions|length }} -

  • +
  • Total number of occupants having short-range interactions: {{ form.short_range_occupants }}

    • {% for interaction in form.short_range_interactions %} -
    • Expiratory activity {{ loop.index if form.short_range_interactions|length > 1 }}: {{ "Shouting/Singing" if interaction.expiration == "Shouting" else interaction.expiration }}
    • -
    • Start time {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.start_time }}
    • -
    • Duration {{ loop.index if form.short_range_interactions|length > 1 }}: {{ interaction.duration }} {{ "minutes" if interaction.duration|float > 1 else "minute" }}
    • +
    • Interaction no. {{ loop.index }}: +
        +
      • Expiratory activity: {{ "Shouting/Singing" if interaction.expiration == "Shouting" else interaction.expiration }}
      • +
      • Start time: {{ interaction.start_time }}
      • +
      • Duration: {{ interaction.duration }} {{ "minutes" if interaction.duration|float > 1 else "minute" }}
      • +
      +
    • {% endfor %}
    {% endif %} diff --git a/ui/apps/templates/base/index.html.j2 b/ui/apps/templates/base/index.html.j2 index 65f19037..d7c432a3 100644 --- a/ui/apps/templates/base/index.html.j2 +++ b/ui/apps/templates/base/index.html.j2 @@ -32,7 +32,7 @@
    @@ -46,7 +46,7 @@

    Applications

    -
    CAiMIRA is composed of two applications, the Calculator and the Expert App.
    +
    CAiMIRA is composed of two applications, the Calculator and the Expert App.

    About

    diff --git a/ui/apps/templates/base/layout.html.j2 b/ui/apps/templates/base/layout.html.j2 index 5ea37537..9e743f72 100644 --- a/ui/apps/templates/base/layout.html.j2 +++ b/ui/apps/templates/base/layout.html.j2 @@ -45,8 +45,8 @@
    diff --git a/ui/apps/templates/cern/calculator.report.html.j2 b/ui/apps/templates/cern/calculator.report.html.j2 index d482b1dc..701c2ab0 100644 --- a/ui/apps/templates/cern/calculator.report.html.j2 +++ b/ui/apps/templates/cern/calculator.report.html.j2 @@ -70,7 +70,7 @@ {% if form.short_range_option == "short_range_yes" %}
    @@ -84,7 +84,7 @@ {% endif %} diff --git a/ui/apps/templates/expert-app.html.j2 b/ui/apps/templates/expert-app.html.j2 new file mode 100644 index 00000000..93c81e47 --- /dev/null +++ b/ui/apps/templates/expert-app.html.j2 @@ -0,0 +1,18 @@ +{% extends "base/layout.html.j2" %} + +{% block main %} + +
    + +

    CAiMIRA Expert Apps currently deactivated


    + +With the latest feature implementations in the core CAiMIRA engine, the ExpertApplication and CO2Application apps are no longer actively maintained. +For legacy purposes, the source code is still available in the GitLab repository. +

    +For any query, please let us know by sending an email to CAiMIRA-dev@cern.ch. +

    + + +
    + +{% endblock main %} diff --git a/ui/apps/tests/test_expert_app.py b/ui/apps/tests/test_expert_app.py index 58cbd3e3..c79008ad 100644 --- a/ui/apps/tests/test_expert_app.py +++ b/ui/apps/tests/test_expert_app.py @@ -8,6 +8,7 @@ def expert_app(): return exp.ExpertApplication() +@pytest.mark.skip(reason="ExpertApplication is deactivated") def test_app(expert_app): # To start with, let's just test that the application runs. We don't try to # do anything fancy to verify how it looks etc., we leave that for manual @@ -15,6 +16,7 @@ def test_app(expert_app): assert expert_app._model_scenarios[0][0] == "Scenario 1" +@pytest.mark.skip(reason="ExpertApplication is deactivated") def test_new_scenario_changes_tab(expert_app): # Adding a new scenario should change the tab index of the multi-model view. assert expert_app.multi_model_view.widget.selected_index == 0 diff --git a/ui/apps/tests/test_report_generator.py b/ui/apps/tests/test_report_generator.py index 08025ec5..f9fd6d37 100644 --- a/ui/apps/tests/test_report_generator.py +++ b/ui/apps/tests/test_report_generator.py @@ -7,8 +7,9 @@ import pytest from ui.apps.calculator import make_app -from ui.apps.calculator import ReportGenerator -from ui.apps.calculator.report import readable_minutes +from caimira.apps.calculator.model_generator import VirusFormData +from caimira.apps.calculator.report_generator import (ReportGenerator, readable_minutes, calculate_report_data, + manufacture_alternative_scenarios, interesting_times, comparison_report) import caimira.calculator.report.report_generator as rep_gen from caimira.api.controller.report_controller import generate_model, generate_report_results @@ -105,3 +106,27 @@ def test_interesting_times_w_temp(exposure_model_w_outside_temp_changes): 5., 5.4, 5.8, 6.2, 6.6, 7., 7.4, 7.8, 8. ] np.testing.assert_allclose(result, expected) + + +def test_expected_new_cases(baseline_form_with_sr: VirusFormData): + model = baseline_form_with_sr.build_model() + + executor_factory = partial( + concurrent.futures.ThreadPoolExecutor, 1, + ) + + # Short- and Long-range contributions + report_data = calculate_report_data(baseline_form_with_sr, model, executor_factory) + sr_lr_expected_new_cases = report_data['expected_new_cases'] + sr_lr_prob_inf = report_data['prob_inf']/100 + + # Long-range contributions alone + scenario_sample_times = interesting_times(model) + alternative_scenarios = manufacture_alternative_scenarios(baseline_form_with_sr) + alternative_statistics = comparison_report( + baseline_form_with_sr, report_data, alternative_scenarios, scenario_sample_times, executor_factory=executor_factory, + ) + + lr_expected_new_cases = alternative_statistics['stats']['Base scenario without short-range interactions']['expected_new_cases'] + 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) + \ No newline at end of file