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.- Short-range interactions: {{ form.short_range_interactions|length }} -
Total number of occupants having short-range interactions: {{ form.short_range_occupants }}
Applications
About