From e5c8ce722c6a2b70895b96d429253934ce038c5d Mon Sep 17 00:00:00 2001 From: lrdossan Date: Thu, 20 Jun 2024 10:14:38 +0200 Subject: [PATCH] Backend separation - extract, isolate and package it in a completely independent Python module, versioned and in a way that allows releases on PyPI.org - fixed error in placeholder for secondary school (data registry defaults) - added restriction in pytest version to install - expected number of new cases fix - data registry update (schema v2.1.1) - github update - deprecate ExpertApplication and CO2Application - changes to reflect schema update 2.0.2 - version update - Fixed error with f_inf (short-range) --- .github/workflows/tests.yml | 2 + .readthedocs.yaml | 18 +- README.md | 35 +- .../caimira-public-docker-image/Dockerfile | 2 - .../caimira-public-docker-image/nginx.conf | 12 - .../run_caimira.sh | 13 +- app-config/calculator-app/app.sh | 11 +- app-config/docker-compose.yml | 18 +- app-config/nginx/nginx.conf | 38 +- app-config/openshift/deploymentconfig.yaml | 115 +--- app-config/openshift/services.yaml | 34 -- caimira/LICENSE | 13 + caimira/{store/__init__.py => README.md} | 0 caimira/{tests => api}/__init__.py | 0 caimira/api/app.py | 31 + .../apps => api/controller}/__init__.py | 0 caimira/api/controller/report_controller.py | 30 + .../calculator => api/routes}/__init__.py | 0 caimira/api/routes/report_routes.py | 28 + caimira/apps/__init__.py | 4 - .../{tests/data => calculator}/__init__.py | 0 caimira/{ => calculator}/docs/Makefile | 0 caimira/{ => calculator}/docs/UML-CAiMIRA.png | Bin .../docs/caimira.apps.calculator.rst | 0 .../{ => calculator}/docs/caimira.apps.rst | 0 .../{ => calculator}/docs/caimira.data.rst | 0 .../docs/caimira.monte_carlo.rst | 0 caimira/{ => calculator}/docs/caimira.rst | 0 .../docs/caimira.tests.apps.calculator.rst | 0 .../docs/caimira.tests.apps.rst | 0 .../docs/caimira.tests.data.rst | 0 .../docs/caimira.tests.models.rst | 0 .../{ => calculator}/docs/caimira.tests.rst | 0 caimira/{ => calculator}/docs/conf.py | 2 +- .../docs/full_diameter_dependence.rst | 0 caimira/{ => calculator}/docs/index.rst | 0 caimira/{ => calculator}/docs/make.bat | 0 caimira/calculator/docs/requirements.txt | 6 + caimira/calculator/models/__init__.py | 8 + .../{ => calculator/models}/data/__init__.py | 4 +- .../models}/data/global_weather_set.json | 0 .../hadisd_station_fullinfo_v311_202001p.txt | 0 .../{ => calculator/models}/data/weather.py | 0 .../models}/dataclass_utils.py | 0 caimira/calculator/models/enums.py | 5 + caimira/{ => calculator/models}/models.py | 173 +++--- .../models}/monte_carlo/__init__.py | 0 .../models}/monte_carlo/__init__.pyi | 0 .../models}/monte_carlo/data.py | 240 ++++---- .../models}/monte_carlo/models.py | 30 +- .../models}/monte_carlo/sampleable.py | 4 +- caimira/{ => calculator/models}/profiler.py | 0 caimira/{ => calculator/models}/utils.py | 0 .../models => calculator/report}/__init__.py | 0 caimira/calculator/report/report_generator.py | 316 ++++++++++ caimira/calculator/store/data_registry.py | 559 ++++++++++++++++++ .../{ => calculator}/store/data_service.py | 4 +- caimira/calculator/tests/__init__.py | 0 caimira/calculator/tests/apps/__init__.py | 0 .../tests/apps/calculator/__init__.py | 0 .../tests/apps/calculator/conftest.py | 22 + .../apps/calculator/test_model_generator.py | 88 +-- .../test_specific_model_generator.py | 14 +- caimira/{ => calculator}/tests/conftest.py | 12 +- caimira/calculator/tests/data/__init__.py | 0 .../tests/data/test_weather.py | 2 +- caimira/calculator/tests/models/__init__.py | 0 .../models/test_co2_concentration_model.py | 2 +- .../tests/models/test_concentration_model.py | 4 +- .../tests/models/test_dynamic_population.py | 4 +- .../tests/models/test_exposure_model.py | 10 +- .../tests/models/test_fitting_algorithm.py | 2 +- .../tests/models/test_mask.py | 2 +- .../tests/models/test_piecewiseconstant.py | 4 +- .../tests/models/test_short_range_model.py | 16 +- caimira/calculator/tests/test_caimira.py | 10 + .../tests/test_conditional_probability.py | 10 +- .../tests/test_data_service.py | 2 +- .../tests/test_dataclass_utils.py | 2 +- .../{ => calculator}/tests/test_expiration.py | 4 +- .../tests/test_full_algorithm.py | 148 ++++- .../tests/test_infected_population.py | 14 +- .../tests/test_known_quantities.py | 4 +- caimira/{ => calculator}/tests/test_model.py | 3 +- caimira/calculator/tests/test_monte_carlo.py | 102 ++++ .../tests/test_monte_carlo_full_models.py | 16 +- .../tests/test_predefined_distributions.py | 3 +- .../tests/test_sampleable_distribution.py | 2 +- .../tests/test_ventilation.py | 2 +- caimira/calculator/validators/__init__.py | 0 caimira/calculator/validators/co2/__init__.py | 0 .../validators/co2/co2_validator.py} | 12 +- .../validators}/defaults.py | 1 + .../validators/form_validator.py} | 110 ++-- .../calculator/validators/virus/__init__.py | 0 .../validators/virus/virus_validator.py} | 185 +++--- caimira/core_requirements.txt | 0 caimira/dev_requirements.txt | 0 caimira/enums.py | 13 - caimira/pyproject.toml | 3 + caimira/setup.cfg | 2 + caimira/store/data_registry.py | 476 --------------- caimira/tests/apps/calculator/conftest.py | 13 - .../apps/calculator/test_report_generator.py | 92 --- .../tests/apps/calculator/test_report_json.py | 32 - caimira/tests/models/test_virus.py | 25 - caimira/tests/test_caimira.py | 10 - caimira/tests/test_monte_carlo.py | 102 ---- requirements.txt | 11 - setup.py | 4 +- ui/__init__.py | 0 ui/apps/__.init__.py | 0 {caimira => ui}/apps/calculator/__init__.py | 64 +- {caimira => ui}/apps/calculator/__main__.py | 0 .../apps/calculator/markdown_tools.py | 0 .../apps/calculator/report.py | 428 ++++---------- .../apps/calculator/static/css/form.css | 0 .../apps/calculator/static/css/report.css | 0 .../apps/calculator/static/icons/favicon.ico | Bin .../calculator/static/images/disclaimer.jpg | Bin .../static/images/warning_scale/green-1.png | Bin .../static/images/warning_scale/orange-3.png | Bin .../static/images/warning_scale/red-4.png | Bin .../static/images/warning_scale/yellow-2.png | Bin .../static/images/window_opening.png | Bin .../calculator/static/images/window_type.PNG | Bin .../apps/calculator/static/js/co2_form.js | 10 + .../apps/calculator/static/js/form.js | 22 +- .../apps/calculator/static/js/report.js | 0 ui/apps/calculator/tests/__init__.py | 0 ui/apps/calculator/tests/conftest.py | 87 +++ .../calculator/tests}/test_markdown_tools.py | 2 +- .../calculator/tests/test_report_generator.py | 132 +++++ .../apps/calculator/tests}/test_webapp.py | 26 +- {caimira => ui}/apps/calculator/user.py | 0 ui/apps/expert_apps/__init__.py | 0 .../apps => ui/apps/expert_apps}/expert.py | 12 +- .../apps/expert_apps}/expert/caimira.ipynb | 0 .../expert/static/images/header_image.png | Bin .../apps/expert_apps}/expert_co2.py | 15 +- .../expert_apps}/expert_co2/caimira.ipynb | 0 .../expert_co2/static/images/header_image.png | Bin {caimira => ui/apps/expert_apps}/state.py | 0 {caimira => ui}/apps/static/css/style.css | 0 .../apps/static/icons/calculator.svg | 0 {caimira => ui}/apps/static/icons/expert.svg | 0 {caimira => ui}/apps/static/icons/favicon.ico | Bin .../static/images/CAiMIRA_1_Vs3_Colour.jpg | Bin .../apps/static/images/caimira_full_logo.png | Bin .../apps/static/images/caimira_full_text.png | Bin .../static/images/caimira_logo.200x200.png | Bin .../static/images/caimira_logo_white_text.png | Bin .../apps/static/images/long_range_anim.png | Bin .../apps/static/images/masks/cloth.png | Bin .../apps/static/images/masks/ffp2.png | Bin .../apps/static/images/masks/t1.png | Bin .../static/images/nat_vent_dimensions.png | Bin .../apps/static/images/short_range_anim.png | Bin .../apps/static/js/ScrollMagic.min.js | 0 .../apps/static/js/jquery.colorbox-min.js | 0 .../apps/static/js/js_packaged_for_theme.js | 0 .../apps/static/js/usage-tracking.js | 0 {caimira => ui}/apps/templates/about.html.j2 | 2 +- .../templates/base/calculator.form.html.j2 | 10 + .../templates/base/calculator.report.html.j2 | 43 +- .../apps/templates/base/index.html.j2 | 4 +- .../apps/templates/base/layout.html.j2 | 4 +- .../apps/templates/base/userguide.html.j2 | 0 .../apps/templates/calculator.form.html.j2 | 0 .../apps/templates/calculator.report.html.j2 | 0 .../templates/cern/calculator.form.html.j2 | 0 .../templates/cern/calculator.report.html.j2 | 4 +- .../apps/templates/cern/index.html.j2 | 0 .../apps/templates/cern/layout.html.j2 | 0 .../apps/templates/cern/userguide.html.j2 | 0 .../apps/templates/common_text.md.j2 | 0 {caimira => ui}/apps/templates/error.html.j2 | 0 ui/apps/templates/expert-app.html.j2 | 18 + {caimira => ui}/apps/templates/index.html.j2 | 0 {caimira => ui}/apps/templates/layout.html.j2 | 0 {caimira => ui}/apps/templates/page.html.j2 | 0 .../apps/templates/profiler.html.j2 | 0 .../apps/templates/userguide.html.j2 | 0 .../apps => ui/apps/tests}/test_expert_app.py | 6 +- ui/apps/tests/test_markdown_tools.py | 30 + ui/apps/tests/test_report_generator.py | 132 +++++ {caimira => ui/apps}/tests/test_state.py | 2 +- ui/apps/tests/test_webapp.py | 162 +++++ 188 files changed, 2683 insertions(+), 1882 deletions(-) create mode 100644 caimira/LICENSE rename caimira/{store/__init__.py => README.md} (100%) rename caimira/{tests => api}/__init__.py (100%) create mode 100644 caimira/api/app.py rename caimira/{tests/apps => api/controller}/__init__.py (100%) create mode 100644 caimira/api/controller/report_controller.py rename caimira/{tests/apps/calculator => api/routes}/__init__.py (100%) create mode 100644 caimira/api/routes/report_routes.py delete mode 100644 caimira/apps/__init__.py rename caimira/{tests/data => calculator}/__init__.py (100%) rename caimira/{ => calculator}/docs/Makefile (100%) rename caimira/{ => calculator}/docs/UML-CAiMIRA.png (100%) rename caimira/{ => calculator}/docs/caimira.apps.calculator.rst (100%) rename caimira/{ => calculator}/docs/caimira.apps.rst (100%) rename caimira/{ => calculator}/docs/caimira.data.rst (100%) rename caimira/{ => calculator}/docs/caimira.monte_carlo.rst (100%) rename caimira/{ => calculator}/docs/caimira.rst (100%) rename caimira/{ => calculator}/docs/caimira.tests.apps.calculator.rst (100%) rename caimira/{ => calculator}/docs/caimira.tests.apps.rst (100%) rename caimira/{ => calculator}/docs/caimira.tests.data.rst (100%) rename caimira/{ => calculator}/docs/caimira.tests.models.rst (100%) rename caimira/{ => calculator}/docs/caimira.tests.rst (100%) rename caimira/{ => calculator}/docs/conf.py (97%) rename caimira/{ => calculator}/docs/full_diameter_dependence.rst (100%) rename caimira/{ => calculator}/docs/index.rst (100%) rename caimira/{ => calculator}/docs/make.bat (100%) create mode 100644 caimira/calculator/docs/requirements.txt create mode 100644 caimira/calculator/models/__init__.py rename caimira/{ => calculator/models}/data/__init__.py (98%) rename caimira/{ => calculator/models}/data/global_weather_set.json (100%) rename caimira/{ => calculator/models}/data/hadisd_station_fullinfo_v311_202001p.txt (100%) rename caimira/{ => calculator/models}/data/weather.py (100%) rename caimira/{ => calculator/models}/dataclass_utils.py (100%) create mode 100644 caimira/calculator/models/enums.py rename caimira/{ => calculator/models}/models.py (91%) rename caimira/{ => calculator/models}/monte_carlo/__init__.py (100%) rename caimira/{ => calculator/models}/monte_carlo/__init__.pyi (100%) rename caimira/{ => calculator/models}/monte_carlo/data.py (58%) rename caimira/{ => calculator/models}/monte_carlo/models.py (78%) rename caimira/{ => calculator/models}/monte_carlo/sampleable.py (98%) rename caimira/{ => calculator/models}/profiler.py (100%) rename caimira/{ => calculator/models}/utils.py (100%) rename caimira/{tests/models => calculator/report}/__init__.py (100%) create mode 100644 caimira/calculator/report/report_generator.py create mode 100644 caimira/calculator/store/data_registry.py rename caimira/{ => calculator}/store/data_service.py (91%) create mode 100644 caimira/calculator/tests/__init__.py create mode 100644 caimira/calculator/tests/apps/__init__.py create mode 100644 caimira/calculator/tests/apps/calculator/__init__.py create mode 100644 caimira/calculator/tests/apps/calculator/conftest.py rename caimira/{ => calculator}/tests/apps/calculator/test_model_generator.py (89%) rename caimira/{ => calculator}/tests/apps/calculator/test_specific_model_generator.py (93%) rename caimira/{ => calculator}/tests/conftest.py (86%) create mode 100644 caimira/calculator/tests/data/__init__.py rename caimira/{ => calculator}/tests/data/test_weather.py (98%) create mode 100644 caimira/calculator/tests/models/__init__.py rename caimira/{ => calculator}/tests/models/test_co2_concentration_model.py (96%) rename caimira/{ => calculator}/tests/models/test_concentration_model.py (98%) rename caimira/{ => calculator}/tests/models/test_dynamic_population.py (99%) rename caimira/{ => calculator}/tests/models/test_exposure_model.py (98%) rename caimira/{ => calculator}/tests/models/test_fitting_algorithm.py (97%) rename caimira/{ => calculator}/tests/models/test_mask.py (95%) rename caimira/{ => calculator}/tests/models/test_piecewiseconstant.py (96%) rename caimira/{ => calculator}/tests/models/test_short_range_model.py (94%) create mode 100644 caimira/calculator/tests/test_caimira.py rename caimira/{ => calculator}/tests/test_conditional_probability.py (89%) rename caimira/{ => calculator}/tests/test_data_service.py (95%) rename caimira/{ => calculator}/tests/test_dataclass_utils.py (90%) rename caimira/{ => calculator}/tests/test_expiration.py (93%) rename caimira/{ => calculator}/tests/test_full_algorithm.py (84%) rename caimira/{ => calculator}/tests/test_infected_population.py (69%) rename caimira/{ => calculator}/tests/test_known_quantities.py (99%) rename caimira/{ => calculator}/tests/test_model.py (87%) create mode 100644 caimira/calculator/tests/test_monte_carlo.py rename caimira/{ => calculator}/tests/test_monte_carlo_full_models.py (97%) rename caimira/{ => calculator}/tests/test_predefined_distributions.py (92%) rename caimira/{ => calculator}/tests/test_sampleable_distribution.py (98%) rename caimira/{ => calculator}/tests/test_ventilation.py (99%) create mode 100644 caimira/calculator/validators/__init__.py create mode 100644 caimira/calculator/validators/co2/__init__.py rename caimira/{apps/calculator/co2_model_generator.py => calculator/validators/co2/co2_validator.py} (96%) rename caimira/{apps/calculator => calculator/validators}/defaults.py (99%) rename caimira/{apps/calculator/form_data.py => calculator/validators/form_validator.py} (79%) create mode 100644 caimira/calculator/validators/virus/__init__.py rename caimira/{apps/calculator/model_generator.py => calculator/validators/virus/virus_validator.py} (75%) create mode 100644 caimira/core_requirements.txt create mode 100644 caimira/dev_requirements.txt delete mode 100644 caimira/enums.py create mode 100644 caimira/pyproject.toml create mode 100644 caimira/setup.cfg delete mode 100644 caimira/store/data_registry.py delete mode 100644 caimira/tests/apps/calculator/conftest.py delete mode 100644 caimira/tests/apps/calculator/test_report_generator.py delete mode 100644 caimira/tests/apps/calculator/test_report_json.py delete mode 100644 caimira/tests/models/test_virus.py delete mode 100644 caimira/tests/test_caimira.py delete mode 100644 caimira/tests/test_monte_carlo.py create mode 100644 ui/__init__.py create mode 100644 ui/apps/__.init__.py rename {caimira => ui}/apps/calculator/__init__.py (92%) rename {caimira => ui}/apps/calculator/__main__.py (100%) rename {caimira => ui}/apps/calculator/markdown_tools.py (100%) rename caimira/apps/calculator/report_generator.py => ui/apps/calculator/report.py (51%) rename {caimira => ui}/apps/calculator/static/css/form.css (100%) rename {caimira => ui}/apps/calculator/static/css/report.css (100%) rename {caimira => ui}/apps/calculator/static/icons/favicon.ico (100%) rename {caimira => ui}/apps/calculator/static/images/disclaimer.jpg (100%) rename {caimira => ui}/apps/calculator/static/images/warning_scale/green-1.png (100%) rename {caimira => ui}/apps/calculator/static/images/warning_scale/orange-3.png (100%) rename {caimira => ui}/apps/calculator/static/images/warning_scale/red-4.png (100%) rename {caimira => ui}/apps/calculator/static/images/warning_scale/yellow-2.png (100%) rename {caimira => ui}/apps/calculator/static/images/window_opening.png (100%) rename {caimira => ui}/apps/calculator/static/images/window_type.PNG (100%) rename {caimira => ui}/apps/calculator/static/js/co2_form.js (97%) rename {caimira => ui}/apps/calculator/static/js/form.js (98%) rename {caimira => ui}/apps/calculator/static/js/report.js (100%) create mode 100644 ui/apps/calculator/tests/__init__.py create mode 100644 ui/apps/calculator/tests/conftest.py rename {caimira/tests/apps/calculator => ui/apps/calculator/tests}/test_markdown_tools.py (91%) create mode 100644 ui/apps/calculator/tests/test_report_generator.py rename {caimira/tests/apps/calculator => ui/apps/calculator/tests}/test_webapp.py (83%) rename {caimira => ui}/apps/calculator/user.py (100%) create mode 100644 ui/apps/expert_apps/__init__.py rename {caimira/apps => ui/apps/expert_apps}/expert.py (99%) rename {caimira/apps => ui/apps/expert_apps}/expert/caimira.ipynb (100%) rename {caimira/apps => ui/apps/expert_apps}/expert/static/images/header_image.png (100%) rename {caimira/apps => ui/apps/expert_apps}/expert_co2.py (98%) rename {caimira/apps => ui/apps/expert_apps}/expert_co2/caimira.ipynb (100%) rename {caimira/apps => ui/apps/expert_apps}/expert_co2/static/images/header_image.png (100%) rename {caimira => ui/apps/expert_apps}/state.py (100%) rename {caimira => ui}/apps/static/css/style.css (100%) rename {caimira => ui}/apps/static/icons/calculator.svg (100%) rename {caimira => ui}/apps/static/icons/expert.svg (100%) rename {caimira => ui}/apps/static/icons/favicon.ico (100%) rename {caimira => ui}/apps/static/images/CAiMIRA_1_Vs3_Colour.jpg (100%) rename {caimira => ui}/apps/static/images/caimira_full_logo.png (100%) rename {caimira => ui}/apps/static/images/caimira_full_text.png (100%) rename {caimira => ui}/apps/static/images/caimira_logo.200x200.png (100%) rename {caimira => ui}/apps/static/images/caimira_logo_white_text.png (100%) rename {caimira => ui}/apps/static/images/long_range_anim.png (100%) rename {caimira => ui}/apps/static/images/masks/cloth.png (100%) rename {caimira => ui}/apps/static/images/masks/ffp2.png (100%) rename {caimira => ui}/apps/static/images/masks/t1.png (100%) rename {caimira => ui}/apps/static/images/nat_vent_dimensions.png (100%) rename {caimira => ui}/apps/static/images/short_range_anim.png (100%) rename {caimira => ui}/apps/static/js/ScrollMagic.min.js (100%) rename {caimira => ui}/apps/static/js/jquery.colorbox-min.js (100%) rename {caimira => ui}/apps/static/js/js_packaged_for_theme.js (100%) rename {caimira => ui}/apps/static/js/usage-tracking.js (100%) rename {caimira => ui}/apps/templates/about.html.j2 (98%) rename {caimira => ui}/apps/templates/base/calculator.form.html.j2 (98%) rename {caimira => ui}/apps/templates/base/calculator.report.html.j2 (94%) rename {caimira => ui}/apps/templates/base/index.html.j2 (95%) rename {caimira => ui}/apps/templates/base/layout.html.j2 (94%) rename {caimira => ui}/apps/templates/base/userguide.html.j2 (100%) rename {caimira => ui}/apps/templates/calculator.form.html.j2 (100%) rename {caimira => ui}/apps/templates/calculator.report.html.j2 (100%) rename {caimira => ui}/apps/templates/cern/calculator.form.html.j2 (100%) rename {caimira => ui}/apps/templates/cern/calculator.report.html.j2 (97%) rename {caimira => ui}/apps/templates/cern/index.html.j2 (100%) rename {caimira => ui}/apps/templates/cern/layout.html.j2 (100%) rename {caimira => ui}/apps/templates/cern/userguide.html.j2 (100%) rename {caimira => ui}/apps/templates/common_text.md.j2 (100%) rename {caimira => ui}/apps/templates/error.html.j2 (100%) create mode 100644 ui/apps/templates/expert-app.html.j2 rename {caimira => ui}/apps/templates/index.html.j2 (100%) rename {caimira => ui}/apps/templates/layout.html.j2 (100%) rename {caimira => ui}/apps/templates/page.html.j2 (100%) rename {caimira => ui}/apps/templates/profiler.html.j2 (100%) rename {caimira => ui}/apps/templates/userguide.html.j2 (100%) rename {caimira/tests/apps => ui/apps/tests}/test_expert_app.py (75%) create mode 100644 ui/apps/tests/test_markdown_tools.py create mode 100644 ui/apps/tests/test_report_generator.py rename {caimira => ui/apps}/tests/test_state.py (99%) create mode 100644 ui/apps/tests/test_webapp.py 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 e6f01033..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,25 +103,25 @@ 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: ``` -python -m caimira.apps.calculator --theme=caimira/apps/templates/{theme} +python -m ui.apps.calculator --theme=ui/apps/templates/{theme} ``` To run the entire app in a different `APPLICATION_ROOT` path: ``` -python -m caimira.apps.calculator --app_root=/myroot +python -m ui.apps.calculator --app_root=/myroot ``` To run the calculator on a different URL path: ``` -python -m caimira.apps.calculator --prefix=/mycalc +python -m ui.apps.calculator --prefix=/mycalc ``` Each of these commands will start a local version of CAiMIRA, which can be visited at http://localhost:8080/. @@ -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 caimira/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 5f12ca46..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/caimira/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/caimira/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 caimira.apps.calculator --port 8081 --no-debug +/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 505067b7..ec7beb8f 100755 --- a/app-config/calculator-app/app.sh +++ b/app-config/calculator-app/app.sh @@ -26,14 +26,9 @@ if [[ "$APP_NAME" == "calculator-app" ]]; then export "DATA_SERVICE_ENABLED"="${DATA_SERVICE_ENABLED:=0}" export "CAIMIRA_PROFILER_ENABLED"="${CAIMIRA_PROFILER_ENABLED:=0}" - echo "Starting the caimira webservice with: python -m caimira.apps.calculator ${args[@]}" - python -m caimira.apps.calculator "${args[@]}" -elif [[ "$APP_NAME" == "caimira-voila" ]]; then - echo "Starting the voila service" - voila caimira/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 caimira/apps/expert_co2/ --port=8080 --no-browser --base_url=/co2-voila-server/ --tornado_settings 'allow_origin=*' + echo "Starting the caimira webservice with: python -m ui.apps.calculator ${args[@]}" + python -m ui.apps.calculator "${args[@]}" + else echo "No APP_NAME specified" exit 1 diff --git a/app-config/docker-compose.yml b/app-config/docker-compose.yml index 2d41dcc1..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: @@ -19,7 +7,7 @@ services: - APP_NAME=calculator-app - APPLICATION_ROOT=/ - CAIMIRA_CALCULATOR_PREFIX=/calculator-cern - - CAIMIRA_THEME=caimira/apps/templates/cern + - CAIMIRA_THEME=ui/apps/templates/cern - DATA_SERVICE_ENABLED=0 - CAIMIRA_PROFILER_ENABLED=0 user: ${CURRENT_UID} @@ -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/store/__init__.py b/caimira/README.md similarity index 100% rename from caimira/store/__init__.py rename to caimira/README.md diff --git a/caimira/tests/__init__.py b/caimira/api/__init__.py similarity index 100% rename from caimira/tests/__init__.py rename to caimira/api/__init__.py diff --git a/caimira/api/app.py b/caimira/api/app.py new file mode 100644 index 00000000..db26c9d4 --- /dev/null +++ b/caimira/api/app.py @@ -0,0 +1,31 @@ +# """ +# Entry point for the CAiMIRA application +# """ + +import tornado.ioloop +import tornado.web +import tornado.log +from tornado.options import define, options +import logging + +from caimira.api.routes.report_routes import ReportHandler + +define("port", default=8088, help="Port to listen on", type=int) + +logging.basicConfig(format="%(message)s", level=logging.INFO) + +class Application(tornado.web.Application): + def __init__(self): + handlers = [ + (r"/report", ReportHandler), + ] + settings = dict( + debug=True, + ) + super(Application, self).__init__(handlers, **settings) + +if __name__ == "__main__": + app = Application() + app.listen(options.port) + logging.info(f"Tornado server is running on port {options.port}") + tornado.ioloop.IOLoop.current().start() diff --git a/caimira/tests/apps/__init__.py b/caimira/api/controller/__init__.py similarity index 100% rename from caimira/tests/apps/__init__.py rename to caimira/api/controller/__init__.py diff --git a/caimira/api/controller/report_controller.py b/caimira/api/controller/report_controller.py new file mode 100644 index 00000000..be151555 --- /dev/null +++ b/caimira/api/controller/report_controller.py @@ -0,0 +1,30 @@ +import concurrent.futures +import functools + +from caimira.calculator.validators.virus.virus_validator import VirusFormData +from caimira.calculator.store.data_registry import DataRegistry +import caimira.calculator.report.report_generator as rg + + +def generate_form_obj(form_data, data_registry): + return VirusFormData.from_dict(form_data, data_registry) + + +def generate_model(form_obj): + return form_obj.build_model(250_000) + + +def generate_report_results(form_obj, model): + return rg.calculate_report_data(form=form_obj, model=model, executor_factory=functools.partial( + concurrent.futures.ThreadPoolExecutor, None, # TODO define report_parallelism + ),) + + +def submit_virus_form(form_data): + data_registry = DataRegistry + + form_obj = generate_form_obj(form_data, data_registry) + model = generate_model(form_obj) + report_data = generate_report_results(form_obj, model=model) + + return report_data diff --git a/caimira/tests/apps/calculator/__init__.py b/caimira/api/routes/__init__.py similarity index 100% rename from caimira/tests/apps/calculator/__init__.py rename to caimira/api/routes/__init__.py diff --git a/caimira/api/routes/report_routes.py b/caimira/api/routes/report_routes.py new file mode 100644 index 00000000..7b6a51cb --- /dev/null +++ b/caimira/api/routes/report_routes.py @@ -0,0 +1,28 @@ +import json +import traceback +import tornado.web + +from caimira.api.controller.report_controller import submit_virus_form + +class ReportHandler(tornado.web.RequestHandler): + def set_default_headers(self): + self.set_header("Access-Control-Allow-Origin", "*") + self.set_header("Access-Control-Allow-Headers", "x-requested-with") + self.set_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + + def post(self): + try: + form_data = json.loads(self.request.body) + report_data = submit_virus_form(form_data) + + response_data = { + "status": "success", + "message": "Results generated successfully", + "report_data": report_data, + } + + self.write(response_data) + except Exception as e: + traceback.print_exc() + self.set_status(400) + self.write({"message": str(e)}) diff --git a/caimira/apps/__init__.py b/caimira/apps/__init__.py deleted file mode 100644 index 26b7f5d3..00000000 --- a/caimira/apps/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .expert import ExpertApplication -from .expert_co2 import CO2Application - -__all__ = ['ExpertApplication', 'CO2Application'] diff --git a/caimira/tests/data/__init__.py b/caimira/calculator/__init__.py similarity index 100% rename from caimira/tests/data/__init__.py rename to caimira/calculator/__init__.py diff --git a/caimira/docs/Makefile b/caimira/calculator/docs/Makefile similarity index 100% rename from caimira/docs/Makefile rename to caimira/calculator/docs/Makefile diff --git a/caimira/docs/UML-CAiMIRA.png b/caimira/calculator/docs/UML-CAiMIRA.png similarity index 100% rename from caimira/docs/UML-CAiMIRA.png rename to caimira/calculator/docs/UML-CAiMIRA.png diff --git a/caimira/docs/caimira.apps.calculator.rst b/caimira/calculator/docs/caimira.apps.calculator.rst similarity index 100% rename from caimira/docs/caimira.apps.calculator.rst rename to caimira/calculator/docs/caimira.apps.calculator.rst diff --git a/caimira/docs/caimira.apps.rst b/caimira/calculator/docs/caimira.apps.rst similarity index 100% rename from caimira/docs/caimira.apps.rst rename to caimira/calculator/docs/caimira.apps.rst diff --git a/caimira/docs/caimira.data.rst b/caimira/calculator/docs/caimira.data.rst similarity index 100% rename from caimira/docs/caimira.data.rst rename to caimira/calculator/docs/caimira.data.rst diff --git a/caimira/docs/caimira.monte_carlo.rst b/caimira/calculator/docs/caimira.monte_carlo.rst similarity index 100% rename from caimira/docs/caimira.monte_carlo.rst rename to caimira/calculator/docs/caimira.monte_carlo.rst diff --git a/caimira/docs/caimira.rst b/caimira/calculator/docs/caimira.rst similarity index 100% rename from caimira/docs/caimira.rst rename to caimira/calculator/docs/caimira.rst diff --git a/caimira/docs/caimira.tests.apps.calculator.rst b/caimira/calculator/docs/caimira.tests.apps.calculator.rst similarity index 100% rename from caimira/docs/caimira.tests.apps.calculator.rst rename to caimira/calculator/docs/caimira.tests.apps.calculator.rst diff --git a/caimira/docs/caimira.tests.apps.rst b/caimira/calculator/docs/caimira.tests.apps.rst similarity index 100% rename from caimira/docs/caimira.tests.apps.rst rename to caimira/calculator/docs/caimira.tests.apps.rst diff --git a/caimira/docs/caimira.tests.data.rst b/caimira/calculator/docs/caimira.tests.data.rst similarity index 100% rename from caimira/docs/caimira.tests.data.rst rename to caimira/calculator/docs/caimira.tests.data.rst diff --git a/caimira/docs/caimira.tests.models.rst b/caimira/calculator/docs/caimira.tests.models.rst similarity index 100% rename from caimira/docs/caimira.tests.models.rst rename to caimira/calculator/docs/caimira.tests.models.rst diff --git a/caimira/docs/caimira.tests.rst b/caimira/calculator/docs/caimira.tests.rst similarity index 100% rename from caimira/docs/caimira.tests.rst rename to caimira/calculator/docs/caimira.tests.rst diff --git a/caimira/docs/conf.py b/caimira/calculator/docs/conf.py similarity index 97% rename from caimira/docs/conf.py rename to caimira/calculator/docs/conf.py index 2b9f7aaf..3982967b 100644 --- a/caimira/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/docs/full_diameter_dependence.rst b/caimira/calculator/docs/full_diameter_dependence.rst similarity index 100% rename from caimira/docs/full_diameter_dependence.rst rename to caimira/calculator/docs/full_diameter_dependence.rst diff --git a/caimira/docs/index.rst b/caimira/calculator/docs/index.rst similarity index 100% rename from caimira/docs/index.rst rename to caimira/calculator/docs/index.rst diff --git a/caimira/docs/make.bat b/caimira/calculator/docs/make.bat similarity index 100% rename from caimira/docs/make.bat rename to caimira/calculator/docs/make.bat 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/__init__.py b/caimira/calculator/models/__init__.py new file mode 100644 index 00000000..fd40e4db --- /dev/null +++ b/caimira/calculator/models/__init__.py @@ -0,0 +1,8 @@ +# This module is part of CAiMIRA. Please see the repository at +# https://gitlab.cern.ch/caimira/caimira for details of the license and terms of use. +""" +Documentation for the CAiMIRA package + +""" + +__version__ = "1.0.0" diff --git a/caimira/data/__init__.py b/caimira/calculator/models/data/__init__.py similarity index 98% rename from caimira/data/__init__.py rename to caimira/calculator/models/data/__init__.py index 8c0a1390..3d539df4 100644 --- a/caimira/data/__init__.py +++ b/caimira/calculator/models/data/__init__.py @@ -1,6 +1,6 @@ import numpy as np -from caimira import models -from caimira.data.weather import wx_data, nearest_wx_station +from caimira.calculator.models import models +from .weather import wx_data, nearest_wx_station MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', diff --git a/caimira/data/global_weather_set.json b/caimira/calculator/models/data/global_weather_set.json similarity index 100% rename from caimira/data/global_weather_set.json rename to caimira/calculator/models/data/global_weather_set.json diff --git a/caimira/data/hadisd_station_fullinfo_v311_202001p.txt b/caimira/calculator/models/data/hadisd_station_fullinfo_v311_202001p.txt similarity index 100% rename from caimira/data/hadisd_station_fullinfo_v311_202001p.txt rename to caimira/calculator/models/data/hadisd_station_fullinfo_v311_202001p.txt diff --git a/caimira/data/weather.py b/caimira/calculator/models/data/weather.py similarity index 100% rename from caimira/data/weather.py rename to caimira/calculator/models/data/weather.py diff --git a/caimira/dataclass_utils.py b/caimira/calculator/models/dataclass_utils.py similarity index 100% rename from caimira/dataclass_utils.py rename to caimira/calculator/models/dataclass_utils.py diff --git a/caimira/calculator/models/enums.py b/caimira/calculator/models/enums.py new file mode 100644 index 00000000..d3bf68cd --- /dev/null +++ b/caimira/calculator/models/enums.py @@ -0,0 +1,5 @@ +from enum import Enum + +class ViralLoads(Enum): + COVID_OVERALL = "Ref: Viral load - covid overal viral load data" + SYMPTOMATIC_FREQUENCIES = "Ref: Viral load - symptomatic viral load frequencies" diff --git a/caimira/models.py b/caimira/calculator/models/models.py similarity index 91% rename from caimira/models.py rename to caimira/calculator/models/models.py index 192a6afc..b49f6692 100644 --- a/caimira/models.py +++ b/caimira/calculator/models/models.py @@ -40,7 +40,7 @@ import scipy.stats as sct from scipy.optimize import minimize -from caimira.store.data_registry import DataRegistry +from caimira.calculator.store.data_registry import DataRegistry if not typing.TYPE_CHECKING: from memoization import cached @@ -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/monte_carlo/__init__.py b/caimira/calculator/models/monte_carlo/__init__.py similarity index 100% rename from caimira/monte_carlo/__init__.py rename to caimira/calculator/models/monte_carlo/__init__.py diff --git a/caimira/monte_carlo/__init__.pyi b/caimira/calculator/models/monte_carlo/__init__.pyi similarity index 100% rename from caimira/monte_carlo/__init__.pyi rename to caimira/calculator/models/monte_carlo/__init__.pyi diff --git a/caimira/monte_carlo/data.py b/caimira/calculator/models/monte_carlo/data.py similarity index 58% rename from caimira/monte_carlo/data.py rename to caimira/calculator/models/monte_carlo/data.py index d82970aa..7b503cb0 100644 --- a/caimira/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 caimira.enums import ViralLoads, InfectiousDoses, ViableToRNARatios +from ..enums import ViralLoads -import caimira.monte_carlo.models as mc -from caimira.monte_carlo.sampleable import LogCustom, LogNormal, Normal, LogCustomKernel, CustomKernel, Uniform, Custom -from caimira.store.data_registry import DataRegistry +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/monte_carlo/models.py b/caimira/calculator/models/monte_carlo/models.py similarity index 78% rename from caimira/monte_carlo/models.py rename to caimira/calculator/models/monte_carlo/models.py index 7215db16..f4ad09e2 100644 --- a/caimira/monte_carlo/models.py +++ b/caimira/calculator/models/monte_carlo/models.py @@ -3,7 +3,7 @@ import sys import typing -import caimira.models +from caimira.calculator.models import models from .sampleable import SampleableDistribution, _VectorisedFloatOrSampleable @@ -57,7 +57,7 @@ def _build_mc_model(model: dataclass_instance) -> typing.Type[MCModelBase[_Model # Note: deepcopy not needed here as we aren't mutating entities beyond # the top level. new_field = copy.copy(field) - if field.type is caimira.models._VectorisedFloat: # noqa + if field.type is models._VectorisedFloat: # noqa new_field.type = _VectorisedFloatOrSampleable # type: ignore field_type: typing.Any = new_field.type @@ -65,30 +65,30 @@ def _build_mc_model(model: dataclass_instance) -> typing.Type[MCModelBase[_Model if getattr(field_type, '__origin__', None) in [typing.Union, typing.Tuple]: # It is challenging to generalise this code, so we provide specific transformations, # and raise for unforseen cases. - if new_field.type == typing.Tuple[caimira.models._VentilationBase, ...]: + if new_field.type == typing.Tuple[models._VentilationBase, ...]: VB = getattr(sys.modules[__name__], "_VentilationBase") - field_type = typing.Tuple[typing.Union[caimira.models._VentilationBase, VB], ...] - elif new_field.type == typing.Tuple[caimira.models._ExpirationBase, ...]: + field_type = typing.Tuple[typing.Union[models._VentilationBase, VB], ...] + elif new_field.type == typing.Tuple[models._ExpirationBase, ...]: EB = getattr(sys.modules[__name__], "_ExpirationBase") - field_type = typing.Tuple[typing.Union[caimira.models._ExpirationBase, EB], ...] - elif new_field.type == typing.Tuple[caimira.models.SpecificInterval, ...]: + field_type = typing.Tuple[typing.Union[models._ExpirationBase, EB], ...] + elif new_field.type == typing.Tuple[models.SpecificInterval, ...]: SI = getattr(sys.modules[__name__], "SpecificInterval") - field_type = typing.Tuple[typing.Union[caimira.models.SpecificInterval, SI], ...] + field_type = typing.Tuple[typing.Union[models.SpecificInterval, SI], ...] - elif new_field.type == typing.Union[int, caimira.models.IntPiecewiseConstant]: + elif new_field.type == typing.Union[int, models.IntPiecewiseConstant]: IPC = getattr(sys.modules[__name__], "IntPiecewiseConstant") - field_type = typing.Union[int, caimira.models.IntPiecewiseConstant, IPC] - elif new_field.type == typing.Union[caimira.models.Interval, None]: + field_type = typing.Union[int, models.IntPiecewiseConstant, IPC] + elif new_field.type == typing.Union[models.Interval, None]: I = getattr(sys.modules[__name__], "Interval") - field_type = typing.Union[None, caimira.models.Interval, I] + field_type = typing.Union[None, models.Interval, I] else: # Check that we don't need to do anything with this type. for item in new_field.type.__args__: - if getattr(item, '__module__', None) == 'caimira.models': + if getattr(item, '__module__', None) == 'source.models.models': raise ValueError( f"unsupported type annotation transformation required for {new_field.type}") - elif field_type.__module__ == 'caimira.models': + elif field_type.__module__ == 'source.models.models': mc_model = getattr(sys.modules[__name__], new_field.type.__name__) field_type = typing.Union[new_field.type, mc_model] @@ -119,7 +119,7 @@ def _build_mc_model(model: dataclass_instance) -> typing.Type[MCModelBase[_Model _MODEL_CLASSES = [ - cls for cls in vars(caimira.models).values() + cls for cls in vars(models).values() if dataclasses.is_dataclass(cls) ] diff --git a/caimira/monte_carlo/sampleable.py b/caimira/calculator/models/monte_carlo/sampleable.py similarity index 98% rename from caimira/monte_carlo/sampleable.py rename to caimira/calculator/models/monte_carlo/sampleable.py index 4bbc4c35..eddb4d24 100644 --- a/caimira/monte_carlo/sampleable.py +++ b/caimira/calculator/models/monte_carlo/sampleable.py @@ -3,7 +3,7 @@ import numpy as np from sklearn.neighbors import KernelDensity # type: ignore -import caimira.models +from caimira.calculator.models import models # Declare a float array type of a given size. # There is no better way to declare this currently, unfortunately. @@ -158,5 +158,5 @@ def generate_samples(self, size: int) -> float_array_size_n: _VectorisedFloatOrSampleable = typing.Union[ - SampleableDistribution, caimira.models._VectorisedFloat, + SampleableDistribution, models._VectorisedFloat, ] diff --git a/caimira/profiler.py b/caimira/calculator/models/profiler.py similarity index 100% rename from caimira/profiler.py rename to caimira/calculator/models/profiler.py diff --git a/caimira/utils.py b/caimira/calculator/models/utils.py similarity index 100% rename from caimira/utils.py rename to caimira/calculator/models/utils.py diff --git a/caimira/tests/models/__init__.py b/caimira/calculator/report/__init__.py similarity index 100% rename from caimira/tests/models/__init__.py rename to caimira/calculator/report/__init__.py diff --git a/caimira/calculator/report/report_generator.py b/caimira/calculator/report/report_generator.py new file mode 100644 index 00000000..7c6f5cea --- /dev/null +++ b/caimira/calculator/report/report_generator.py @@ -0,0 +1,316 @@ +import concurrent.futures +import base64 +import dataclasses +import io +import typing +import numpy as np +import matplotlib.pyplot as plt + +from caimira.calculator.models import models +# from caimira.apps.calculator import markdown_tools +# from caimira.profiler import profile +from caimira.calculator.store.data_registry import DataRegistry +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], + model.concentration_model.infected.presence_interval().boundaries()[0][0]) + t_end = max(model.exposed.presence_interval().boundaries()[-1][1], + model.concentration_model.infected.presence_interval().boundaries()[-1][1]) + return t_start, t_end + + +def fill_big_gaps(array, gap_size): + """ + Insert values into the given sorted list if there is a gap of more than ``gap_size``. + All values in the given array are preserved, even if they are within the ``gap_size`` of one another. + + >>> fill_big_gaps([1, 2, 4], gap_size=0.75) + [1, 1.75, 2, 2.75, 3.5, 4] + + """ + result = [] + if len(array) == 0: + raise ValueError("Input array must be len > 0") + + last_value = array[0] + for value in array: + while value - last_value > gap_size + 1e-15: + last_value = last_value + gap_size + result.append(last_value) + result.append(value) + last_value = value + return result + + +def non_temp_transition_times(model: models.ExposureModel): + """ + Return the non-temperature (and PiecewiseConstant) based transition times. + + """ + def walk_model(model, name=""): + # Extend walk_dataclass to handle lists of dataclasses + # (e.g. in MultipleVentilation). + for name, obj in dataclass_utils.walk_dataclass(model, name=name): + if name.endswith('.ventilations') and isinstance(obj, (list, tuple)): + for i, item in enumerate(obj): + fq_name_i = f'{name}[{i}]' + yield fq_name_i, item + if dataclasses.is_dataclass(item): + yield from dataclass_utils.walk_dataclass(item, name=fq_name_i) + else: + yield name, obj + + t_start, t_end = model_start_end(model) + + change_times = {t_start, t_end} + for name, obj in walk_model(model, name="exposure"): + if isinstance(obj, models.Interval): + change_times |= obj.transition_times() + + # Only choose times that are in the range of the model (removes things + # such as PeriodicIntervals, which extend beyond the model itself). + return sorted(time for time in change_times if (t_start <= time <= t_end)) + +def interesting_times(model: models.ExposureModel, approx_n_pts: typing.Optional[int] = None) -> typing.List[float]: + """ + Pick approximately ``approx_n_pts`` time points which are interesting for the + given model. If not provided by argument, ``approx_n_pts`` is set to be 15 times + the number of hours of the simulation. + + Initially the times are seeded by important state change times (excluding + outside temperature), and the times are then subsequently expanded to ensure + that the step size is at most ``(t_end - t_start) / approx_n_pts``. + + """ + times = non_temp_transition_times(model) + sim_duration = max(times) - min(times) + if not approx_n_pts: approx_n_pts = sim_duration * 15 + + # Expand the times list to ensure that we have a maximum gap size between + # the key times. + nice_times = fill_big_gaps(times, gap_size=(sim_duration) / approx_n_pts) + return nice_times + + + +def concentrations_with_sr_breathing(form: VirusFormData, model: models.ExposureModel, times: typing.List[float], short_range_intervals: typing.List) -> typing.List[float]: + lower_concentrations = [] + for time in times: + for index, (start, stop) in enumerate(short_range_intervals): + # For visualization issues, add short-range breathing activity to the initial long-range concentrations + if start <= time <= stop and form.short_range_interactions[index]['expiration'] == 'Breathing': + lower_concentrations.append(np.array(model.concentration(float(time))).mean()) + break + lower_concentrations.append(np.array(model.concentration_model.concentration(float(time))).mean()) + return lower_concentrations + + +def _calculate_deposited_exposure(model, time1, time2, fn_name=None): + return np.array(model.deposited_exposure_between_bounds(float(time1), float(time2))).mean(),fn_name + + +def _calculate_long_range_deposited_exposure(model, time1, time2, fn_name=None): + return np.array(model.long_range_deposited_exposure_between_bounds(float(time1), float(time2))).mean(), fn_name + + +def _calculate_co2_concentration(CO2_model, time, fn_name=None): + return np.array(CO2_model.concentration(float(time))).mean(), fn_name + + +# @profile +def calculate_report_data(form: VirusFormData, model: models.ExposureModel, executor_factory: typing.Callable[[], concurrent.futures.Executor]) -> typing.Dict[str, typing.Any]: + times = interesting_times(model) + short_range_intervals = [interaction.presence.boundaries()[0] for interaction in model.short_range] + short_range_expirations = [interaction['expiration'] for interaction in form.short_range_interactions] if form.short_range_option == "short_range_yes" else [] + + concentrations = [ + np.array(model.concentration(float(time))).mean() + for time in times + ] + lower_concentrations = concentrations_with_sr_breathing(form, model, times, short_range_intervals) + + CO2_model: models.CO2ConcentrationModel = form.build_CO2_model() + + # compute deposited exposures and CO2 concentrations in parallel to increase performance + deposited_exposures = [] + long_range_deposited_exposures = [] + CO2_concentrations = [] + + tasks = [] + with executor_factory() as executor: + for time1, time2 in zip(times[:-1], times[1:]): + tasks.append(executor.submit(_calculate_deposited_exposure, model, time1, time2, fn_name="de")) + tasks.append(executor.submit(_calculate_long_range_deposited_exposure, model, time1, time2, fn_name="lr")) + # co2 concentration: takes each time as param, not the interval + tasks.append(executor.submit(_calculate_co2_concentration, CO2_model, time1, fn_name="co2")) + # co2 concentration: calculate the last time too + tasks.append(executor.submit(_calculate_co2_concentration, CO2_model, times[-1], fn_name="co2")) + + for task in tasks: + result, fn_name = task.result() + if fn_name == "de": + deposited_exposures.append(result) + elif fn_name == "lr": + long_range_deposited_exposures.append(result) + elif fn_name == "co2": + CO2_concentrations.append(result) + + cumulative_doses = np.cumsum(deposited_exposures) + long_range_cumulative_doses = np.cumsum(long_range_deposited_exposures) + + prob = np.array(model.infection_probability()) + 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() + exposed_presence_intervals = [list(interval) for interval in model.exposed.presence_interval().boundaries()] + + 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), + "times": list(times), + "exposed_presence_intervals": exposed_presence_intervals, + "short_range_intervals": short_range_intervals, + "short_range_expirations": short_range_expirations, + "concentrations": concentrations, + "concentrations_zoomed": lower_concentrations, + "cumulative_doses": list(cumulative_doses), + "long_range_cumulative_doses": list(long_range_cumulative_doses), + "prob_inf": prob.mean(), + "prob_inf_sd": prob.std(), + "prob_dist": list(prob), + "prob_hist_count": list(prob_dist_count), + "prob_hist_bins": list(prob_dist_bins), + "prob_probabilistic_exposure": prob_probabilistic_exposure, + "expected_new_cases": expected_new_cases, + "uncertainties_plot_src": uncertainties_plot_src, + "CO2_concentrations": CO2_concentrations, + "vl_dist": vl_dist, + "conditional_probability_data": conditional_probability_data, + } + + +def conditional_prob_inf_given_vl_dist( + data_registry: DataRegistry, + infection_probability: models._VectorisedFloat, + viral_loads: np.ndarray, + specific_vl: float, + step: models._VectorisedFloat + ): + + pi_means = [] + lower_percentiles = [] + upper_percentiles = [] + + 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, 0.05)) + upper_percentiles.append(np.quantile(specific_prob, 0.95)) + + return pi_means, lower_percentiles, upper_percentiles + + +def manufacture_conditional_probability_data( + exposure_model: models.ExposureModel, + infection_probability: models._VectorisedFloat +): + data_registry: DataRegistry = exposure_model.data_registry + + 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) + pi_means, lower_percentiles, upper_percentiles = conditional_prob_inf_given_vl_dist(data_registry, infection_probability, viral_loads, + specific_vl, step) + + return list(viral_loads), list(pi_means), list(lower_percentiles), list(upper_percentiles) + + +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, 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, 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([]) + axs[0, 2].set_xticklabels([]) + axs[0, 2].set_facecolor("lightgrey") + + highest_bar = axs[0, 2].get_xlim()[1] + 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), 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([]) + axs[1, 0].set_yticklabels([]) + axs[1, 0].set_xticks([i for i in range(2, 13, 2)]) + axs[1, 0].set_xticklabels(['$10^{' + str(i) + '}$' for i in range(2, 13, 2)]) + axs[1, 0].set_xlim(2, 10) + axs[1, 0].set_xlabel('Viral load\n(RNA copies)', fontsize=12) + axs[0, 0].set_ylabel('Conditional Probability\nof Infection', fontsize=12) + + axs[0, 0].text(9.5, -0.01, '$(i)$') + axs[1, 0].text(9.5, axs[1, 0].get_ylim()[1] * 0.8, '$(ii)$') + axs[0, 2].set_title('$(iii)$', fontsize=10) + + axs[0, 0].legend() + return fig + + +def _figure2bytes(figure): + # Draw the image + img_data = io.BytesIO() + figure.savefig(img_data, format='png', bbox_inches="tight", transparent=True, dpi=110) + return img_data + + +def img2base64(img_data) -> str: + img_data.seek(0) + pic_hash = base64.b64encode(img_data.read()).decode('ascii') + # A src suitable for a tag such as f'. + return f'data:image/png;base64,{pic_hash}' diff --git a/caimira/calculator/store/data_registry.py b/caimira/calculator/store/data_registry.py new file mode 100644 index 00000000..5b549858 --- /dev/null +++ b/caimira/calculator/store/data_registry.py @@ -0,0 +1,559 @@ +from ..models.enums import ViralLoads + + +class DataRegistry: + """Registry to hold data values.""" + + version = None + + 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_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_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_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_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_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_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_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_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_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_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.", + }, + }, + } + + 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_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": "", + }, + }, + "FFP2": { + "η_inhale": { + "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": "", + }, + }, + "Cloth": { + "η_inhale": { + "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).", + }, + "η_exhale": { + "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": "", + }, + }, + } + + #################################### + + room = { + "inside_temp": 293., + "humidity_with_heating": 0.3, + "humidity_without_heating": 0.5, + "references": "", + } + + ventilation = { + "natural": { + "discharge_factor": { + "sliding": 0.6, + }, + }, + "infiltration_ventilation": 0.25, + "references": "Henriques et al. (https://doi.org/10.1101/2021.10.14.21264988).", + } + + concentration_model = { + "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, + "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).", + }, + } + + monte_carlo = { + "sample_size": 250000, + "references": "", + } + + population_scenario_activity = { + "office": {"placeholder": "Office", "activity": "Seated", "expiration": {"Speaking": 1, "Breathing": 2}}, + "smallmeeting": { + "placeholder": "Small meeting (<10 occ.)", + "activity": "Seated", + "expiration": {"Speaking": 1}, + }, + "largemeeting": { + "placeholder": "Large meeting (>= 10 occ.)", + "activity": "Standing", + "expiration": {"Speaking": 1, "Breathing": 2}, + }, + "callcenter": {"placeholder": "Call Center", "activity": "Seated", "expiration": {"Speaking": 1}}, + "controlroom-day": { + "placeholder": "Control Room - Day shift", + "activity": "Seated", + "expiration": {"Speaking": 1, "Breathing": 1}, + }, + "controlroom-night": { + "placeholder": "Control Room - Night shift", + "activity": "Seated", + "expiration": {"Speaking": 1, "Breathing": 9}, + }, + "library": {"placeholder": "Library", "activity": "Seated", "expiration": {"Breathing": 1}}, + "lab": { + "placeholder": "Lab", + "activity": "Light activity", + "expiration": {"Speaking": 1, "Breathing": 1}, + }, + "workshop": { + "placeholder": "Workshop", + "activity": "Moderate activity", + "expiration": {"Speaking": 1, "Breathing": 1}, + }, + "training": {"placeholder": "Conference/Training (speaker infected)", "activity": "Standing", "expiration": {"Speaking": 1}}, + "training_attendee": {"placeholder": "Conference/Training (attendee infected)", "activity": "Seated", "expiration": {"Breathing": 1}}, + "gym": {"placeholder": "Gym", "activity": "Heavy exercise", "expiration": {"Breathing": 1}}, + "household-day": { + "placeholder": "Household (day time)", + "activity": "Light activity", + "expiration": {"Breathing": 5, "Speaking": 5}, + }, + "household-night": { + "placeholder": "Household (evening and night time)", + "activity": "Seated", + "expiration": {"Breathing": 7, "Speaking": 3}, + }, + "primary-school": { + "placeholder": "Primary school", + "activity": "Light activity", + "expiration": {"Breathing": 5, "Speaking": 5}, + }, + "secondary-school": { + "placeholder": "Secondary school", + "activity": "Light activity", + "expiration": {"Breathing": 7, "Speaking": 3}, + }, + "university": { + "placeholder": "University", + "activity": "Seated", + "expiration": {"Breathing": 9, "Speaking": 1}, + }, + "restaurant": { + "placeholder": "Restaurant", + "activity": "Seated", + "expiration": {"Breathing": 1, "Speaking": 9}, + }, + "precise": {"placeholder": "Precise", "activity": "", "expiration": {}}, + } + + def to_dict(self): + # Filter out methods, special attributes, and non-serializable objects + data_dict = { + key: value + for key, value in self.__class__.__dict__.items() + if not key.startswith("__") and not callable(value) and not isinstance(value, (type, classmethod, staticmethod)) + } + return data_dict + + def update(self, data, version=None): + """Update local cache with data provided as argument.""" + for attr_name, value in data.items(): + setattr(self, attr_name, value) + + if version: + self.version = version diff --git a/caimira/store/data_service.py b/caimira/calculator/store/data_service.py similarity index 91% rename from caimira/store/data_service.py rename to caimira/calculator/store/data_service.py index 80463f42..7271e708 100644 --- a/caimira/store/data_service.py +++ b/caimira/calculator/store/data_service.py @@ -2,7 +2,7 @@ import typing import requests -from caimira.store.data_registry import DataRegistry +from ..store.data_registry import DataRegistry logger = logging.getLogger("DATA") @@ -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/__init__.py b/caimira/calculator/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/calculator/tests/apps/__init__.py b/caimira/calculator/tests/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/calculator/tests/apps/calculator/__init__.py b/caimira/calculator/tests/apps/calculator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/calculator/tests/apps/calculator/conftest.py b/caimira/calculator/tests/apps/calculator/conftest.py new file mode 100644 index 00000000..d1aa1065 --- /dev/null +++ b/caimira/calculator/tests/apps/calculator/conftest.py @@ -0,0 +1,22 @@ +import pytest + +from caimira.calculator.validators.virus import virus_validator + + +@pytest.fixture +def baseline_form_data(): + return virus_validator.baseline_raw_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/tests/apps/calculator/test_model_generator.py b/caimira/calculator/tests/apps/calculator/test_model_generator.py similarity index 89% rename from caimira/tests/apps/calculator/test_model_generator.py rename to caimira/calculator/tests/apps/calculator/test_model_generator.py index bcd9e064..435ecd39 100644 --- a/caimira/tests/apps/calculator/test_model_generator.py +++ b/caimira/calculator/tests/apps/calculator/test_model_generator.py @@ -6,24 +6,24 @@ import pytest from retry import retry -from caimira.apps.calculator import model_generator -from caimira.apps.calculator.form_data import (_hours2timestring, minutes_since_midnight, +from caimira.calculator.validators.virus import virus_validator +from caimira.calculator.validators.form_validator import (_hours2timestring, minutes_since_midnight, _CAST_RULES_FORM_ARG_TO_NATIVE, _CAST_RULES_NATIVE_TO_FORM_ARG) -from caimira import models -from caimira.monte_carlo.data import expiration_distributions -from caimira.apps.calculator.defaults import NO_DEFAULT -from caimira.store.data_registry import DataRegistry +from caimira.calculator.models import models +from caimira.calculator.models.monte_carlo.data import expiration_distributions +from caimira.calculator.validators.defaults import NO_DEFAULT +from caimira.calculator.store.data_registry import DataRegistry def test_model_from_dict(baseline_form_data, data_registry): - form = model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + form = virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) assert isinstance(form.build_model(), models.ExposureModel) def test_model_from_dict_invalid(baseline_form_data, data_registry): baseline_form_data['invalid_item'] = 'foobar' with pytest.raises(ValueError, match='Invalid argument "invalid_item" given'): - model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) @retry(tries=10) @@ -39,14 +39,14 @@ def test_blend_expiration(data_registry, mask_type): SAMPLE_SIZE = 250000 TOLERANCE = 0.02 blend = {'Breathing': 2, 'Speaking': 1} - r = model_generator.build_expiration(data_registry, blend).build_model(SAMPLE_SIZE) + r = virus_validator.build_expiration(data_registry, blend).build_model(SAMPLE_SIZE) mask = models.Mask.types[mask_type] expected = (expiration_distributions(data_registry)['Breathing'].build_model(SAMPLE_SIZE).aerosols(mask).mean()*2/3. + expiration_distributions(data_registry)['Speaking'].build_model(SAMPLE_SIZE).aerosols(mask).mean()/3.) npt.assert_allclose(r.aerosols(mask).mean(), expected, rtol=TOLERANCE) -def test_ventilation_slidingwindow(data_registry: DataRegistry, baseline_form: model_generator.VirusFormData): +def test_ventilation_slidingwindow(data_registry: DataRegistry, baseline_form: virus_validator.VirusFormData): baseline_form.ventilation_type = 'natural_ventilation' baseline_form.windows_duration = 10 baseline_form.windows_frequency = 120 @@ -77,7 +77,7 @@ def test_ventilation_slidingwindow(data_registry: DataRegistry, baseline_form: m assert ventilation == baseline_vent -def test_ventilation_hingedwindow(baseline_form: model_generator.VirusFormData): +def test_ventilation_hingedwindow(baseline_form: virus_validator.VirusFormData): baseline_form.ventilation_type = 'natural_ventilation' baseline_form.windows_duration = 10 baseline_form.windows_frequency = 120 @@ -107,7 +107,7 @@ def test_ventilation_hingedwindow(baseline_form: model_generator.VirusFormData): assert ventilation == baseline_vent -def test_ventilation_mechanical(baseline_form: model_generator.VirusFormData): +def test_ventilation_mechanical(baseline_form: virus_validator.VirusFormData): room = models.Room(volume=75, inside_temp=models.PiecewiseConstant((0, 24), (293,))) mech = models.HVACMechanical( active=models.PeriodicInterval(period=120, duration=120), @@ -122,7 +122,7 @@ def test_ventilation_mechanical(baseline_form: model_generator.VirusFormData): np.array([baseline_form.ventilation().air_exchange(room, t) for t in ts])) -def test_ventilation_airchanges(baseline_form: model_generator.VirusFormData): +def test_ventilation_airchanges(baseline_form: virus_validator.VirusFormData): room = models.Room(75, inside_temp=models.PiecewiseConstant((0, 24), (293,))) airchange = models.AirChange( active=models.PeriodicInterval(period=120, duration=120), @@ -137,7 +137,7 @@ def test_ventilation_airchanges(baseline_form: model_generator.VirusFormData): np.array([baseline_form.ventilation().air_exchange(room, t) for t in ts])) -def test_ventilation_window_hepa(data_registry: DataRegistry, baseline_form: model_generator.VirusFormData): +def test_ventilation_window_hepa(data_registry: DataRegistry, baseline_form: virus_validator.VirusFormData): baseline_form.ventilation_type = 'natural_ventilation' baseline_form.windows_duration = 10 baseline_form.windows_frequency = 120 @@ -181,7 +181,7 @@ def test_ventilation_window_hepa(data_registry: DataRegistry, baseline_form: mod ] ) def test_infected_less_than_total_people(activity, total_people, infected_people, error, - baseline_form: model_generator.VirusFormData, + baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): baseline_form.activity_type = activity baseline_form.total_people = total_people @@ -195,7 +195,7 @@ def present_times(interval: models.Interval) -> models.BoundarySequence_t: return interval.present_times -def test_infected_present_intervals(baseline_form: model_generator.VirusFormData): +def test_infected_present_intervals(baseline_form: virus_validator.VirusFormData): baseline_form.infected_dont_have_breaks_with_exposed = False baseline_form.exposed_coffee_duration = 15 baseline_form.exposed_coffee_break_option = 'coffee_break_2' @@ -209,7 +209,7 @@ def test_infected_present_intervals(baseline_form: model_generator.VirusFormData assert present_times(baseline_form.infected_present_interval()) == correct -def test_exposed_present_intervals(baseline_form: model_generator.VirusFormData): +def test_exposed_present_intervals(baseline_form: virus_validator.VirusFormData): baseline_form.exposed_coffee_duration = 15 baseline_form.exposed_coffee_break_option = 'coffee_break_2' baseline_form.exposed_start = minutes_since_midnight(9 * 60) @@ -220,7 +220,7 @@ def test_exposed_present_intervals(baseline_form: model_generator.VirusFormData) assert present_times(baseline_form.exposed_present_interval()) == correct -def test_present_intervals_common_breaks(baseline_form: model_generator.VirusFormData): +def test_present_intervals_common_breaks(baseline_form: virus_validator.VirusFormData): baseline_form.infected_dont_have_breaks_with_exposed = False baseline_form.infected_coffee_duration = baseline_form.exposed_coffee_duration = 15 baseline_form.infected_coffee_break_option = baseline_form.exposed_coffee_break_option = 'coffee_break_2' @@ -236,7 +236,7 @@ def test_present_intervals_common_breaks(baseline_form: model_generator.VirusFor assert present_times(baseline_form.infected_present_interval()) == correct_infected -def test_present_intervals_split_breaks(baseline_form: model_generator.VirusFormData): +def test_present_intervals_split_breaks(baseline_form: virus_validator.VirusFormData): baseline_form.infected_dont_have_breaks_with_exposed = True baseline_form.infected_coffee_duration = baseline_form.exposed_coffee_duration = 15 baseline_form.infected_coffee_break_option = baseline_form.exposed_coffee_break_option = 'coffee_break_2' @@ -252,7 +252,7 @@ def test_present_intervals_split_breaks(baseline_form: model_generator.VirusForm assert present_times(baseline_form.infected_present_interval()) == correct_infected -def test_exposed_present_intervals_starting_with_lunch(baseline_form: model_generator.VirusFormData): +def test_exposed_present_intervals_starting_with_lunch(baseline_form: virus_validator.VirusFormData): baseline_form.exposed_coffee_break_option = 'coffee_break_0' baseline_form.exposed_start = baseline_form.exposed_lunch_start = minutes_since_midnight(13 * 60) baseline_form.exposed_finish = minutes_since_midnight(18 * 60) @@ -261,7 +261,7 @@ def test_exposed_present_intervals_starting_with_lunch(baseline_form: model_gene assert present_times(baseline_form.exposed_present_interval()) == correct -def test_exposed_present_intervals_ending_with_lunch(baseline_form: model_generator.VirusFormData): +def test_exposed_present_intervals_ending_with_lunch(baseline_form: virus_validator.VirusFormData): baseline_form.exposed_coffee_break_option = 'coffee_break_0' baseline_form.exposed_start = minutes_since_midnight(11 * 60) baseline_form.exposed_finish = baseline_form.exposed_lunch_start = minutes_since_midnight(13 * 60) @@ -270,7 +270,7 @@ def test_exposed_present_intervals_ending_with_lunch(baseline_form: model_genera assert present_times(baseline_form.exposed_present_interval()) == correct -def test_exposed_present_lunch_end_before_beginning(baseline_form: model_generator.VirusFormData, data_registry: DataRegistry): +def test_exposed_present_lunch_end_before_beginning(baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): baseline_form.exposed_coffee_break_option = 'coffee_break_0' baseline_form.exposed_lunch_start = minutes_since_midnight(14 * 60) baseline_form.exposed_lunch_finish = minutes_since_midnight(13 * 60) @@ -287,7 +287,7 @@ def test_exposed_present_lunch_end_before_beginning(baseline_form: model_generat [9, 20], # lunch_finish after the presence finishing ], ) -def test_exposed_presence_lunch_break(baseline_form: model_generator.VirusFormData, data_registry: DataRegistry, exposed_lunch_start, exposed_lunch_finish): +def test_exposed_presence_lunch_break(baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry, exposed_lunch_start, exposed_lunch_finish): baseline_form.exposed_lunch_start = minutes_since_midnight(exposed_lunch_start * 60) baseline_form.exposed_lunch_finish = minutes_since_midnight(exposed_lunch_finish * 60) with pytest.raises(ValueError, match='exposed lunch break must be within presence times.'): @@ -303,14 +303,14 @@ def test_exposed_presence_lunch_break(baseline_form: model_generator.VirusFormDa [9, 20], # lunch_finish after the presence finishing ], ) -def test_infected_presence_lunch_break(baseline_form: model_generator.VirusFormData, data_registry: DataRegistry, infected_lunch_start, infected_lunch_finish): +def test_infected_presence_lunch_break(baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry, infected_lunch_start, infected_lunch_finish): baseline_form.infected_lunch_start = minutes_since_midnight(infected_lunch_start * 60) baseline_form.infected_lunch_finish = minutes_since_midnight(infected_lunch_finish * 60) with pytest.raises(ValueError, match='infected lunch break must be within presence times.'): baseline_form.validate() -def test_exposed_breaks_length(baseline_form: model_generator.VirusFormData, data_registry: DataRegistry): +def test_exposed_breaks_length(baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): baseline_form.exposed_coffee_break_option = 'coffee_break_4' baseline_form.exposed_coffee_duration = 30 baseline_form.exposed_start = minutes_since_midnight(10 * 60) @@ -320,7 +320,7 @@ def test_exposed_breaks_length(baseline_form: model_generator.VirusFormData, dat baseline_form.validate() -def test_infected_breaks_length(baseline_form: model_generator.VirusFormData, data_registry: DataRegistry): +def test_infected_breaks_length(baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): baseline_form.infected_start = minutes_since_midnight(9 * 60) baseline_form.infected_finish = minutes_since_midnight(12 * 60) baseline_form.infected_lunch_start = minutes_since_midnight(10 * 60) @@ -332,7 +332,7 @@ def test_infected_breaks_length(baseline_form: model_generator.VirusFormData, da @pytest.fixture -def coffee_break_between_1045_and_1115(baseline_form: model_generator.VirusFormData): +def coffee_break_between_1045_and_1115(baseline_form: virus_validator.VirusFormData): baseline_form.exposed_coffee_break_option = 'coffee_break_1' baseline_form.exposed_coffee_duration = 30 baseline_form.exposed_start = minutes_since_midnight(10 * 60) @@ -390,7 +390,7 @@ def assert_boundaries(interval, boundaries_in_time_string_form): @pytest.fixture -def breaks_every_25_mins_for_20_mins(baseline_form: model_generator.VirusFormData): +def breaks_every_25_mins_for_20_mins(baseline_form: virus_validator.VirusFormData): baseline_form.exposed_coffee_break_option = 'coffee_break_4' baseline_form.exposed_coffee_duration = 20 baseline_form.exposed_start = time2mins("10:00") @@ -435,7 +435,7 @@ def test_present_only_during_second_break(breaks_every_25_mins_for_20_mins): assert_boundaries(interval, []) -def test_valid_no_lunch(baseline_form: model_generator.VirusFormData, data_registry: DataRegistry): +def test_valid_no_lunch(baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): # Check that it is valid to have a 0 length lunch if no lunch is selected. baseline_form.exposed_lunch_option = False baseline_form.exposed_lunch_start = minutes_since_midnight(0) @@ -443,7 +443,7 @@ def test_valid_no_lunch(baseline_form: model_generator.VirusFormData, data_regis assert baseline_form.validate() is None -def test_no_breaks(baseline_form: model_generator.VirusFormData): +def test_no_breaks(baseline_form: virus_validator.VirusFormData): # Check that the times are correct in the absence of breaks. baseline_form.infected_dont_have_breaks_with_exposed = False baseline_form.exposed_lunch_option = False @@ -458,7 +458,7 @@ def test_no_breaks(baseline_form: model_generator.VirusFormData): assert present_times(baseline_form.infected_present_interval()) == infected_correct -def test_coffee_lunch_breaks(baseline_form: model_generator.VirusFormData): +def test_coffee_lunch_breaks(baseline_form: virus_validator.VirusFormData): baseline_form.exposed_coffee_duration = 30 baseline_form.exposed_coffee_break_option = 'coffee_break_4' baseline_form.exposed_start = minutes_since_midnight(9 * 60) @@ -470,7 +470,7 @@ def test_coffee_lunch_breaks(baseline_form: model_generator.VirusFormData): np.testing.assert_allclose(present_times(baseline_form.exposed_present_interval()), correct, rtol=1e-14) -def test_coffee_lunch_breaks_unbalance(baseline_form: model_generator.VirusFormData): +def test_coffee_lunch_breaks_unbalance(baseline_form: virus_validator.VirusFormData): baseline_form.exposed_coffee_duration = 30 baseline_form.exposed_coffee_break_option = 'coffee_break_2' baseline_form.exposed_start = minutes_since_midnight(9 * 60) @@ -481,7 +481,7 @@ def test_coffee_lunch_breaks_unbalance(baseline_form: model_generator.VirusFormD np.testing.assert_allclose(present_times(baseline_form.exposed_present_interval()), correct, rtol=1e-14) -def test_coffee_breaks(baseline_form: model_generator.VirusFormData): +def test_coffee_breaks(baseline_form: virus_validator.VirusFormData): baseline_form.exposed_coffee_duration = 10 baseline_form.exposed_coffee_break_option = 'coffee_break_4' baseline_form.exposed_start = minutes_since_midnight(9 * 60) @@ -494,24 +494,24 @@ def test_coffee_breaks(baseline_form: model_generator.VirusFormData): def test_key_validation(baseline_form_data, data_registry): baseline_form_data['activity_type'] = 'invalid key' with pytest.raises(ValueError): - model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) def test_key_validation_natural_ventilation_window_type_na(baseline_form_data, data_registry): baseline_form_data['ventilation_type'] = 'natural_ventilation' baseline_form_data['window_type'] = 'not-applicable' with pytest.raises(ValueError, match='window_type cannot be \'not-applicable\''): - model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) def test_key_validation_natural_ventilation_window_opening_regime_na(baseline_form_data, data_registry): baseline_form_data['ventilation_type'] = 'natural_ventilation' baseline_form_data['window_opening_regime'] = 'not-applicable' with pytest.raises(ValueError, match='window_opening_regime cannot be \'not-applicable\''): - model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) -def test_natural_ventilation_window_opening_periodically(baseline_form: model_generator.VirusFormData, data_registry: DataRegistry): +def test_natural_ventilation_window_opening_periodically(baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): baseline_form.window_opening_regime = 'windows_open_periodically' baseline_form.windows_duration = 20 baseline_form.windows_frequency = 10 @@ -523,20 +523,20 @@ def test_key_validation_mech_ventilation_type_na(baseline_form_data, data_regist baseline_form_data['ventilation_type'] = 'mechanical_ventilation' baseline_form_data['mechanical_ventilation_type'] = 'not-applicable' with pytest.raises(ValueError, match='mechanical_ventilation_type cannot be \'not-applicable\''): - model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) def test_key_validation_event_month(baseline_form_data, data_registry): baseline_form_data['event_month'] = 'invalid month' with pytest.raises(ValueError, match='invalid month is not a valid value for event_month'): - model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) def test_default_types(): # Validate that VirusFormData._DEFAULTS are complete and of the correct type. # Validate that we have the right types and matching attributes to the DEFAULTS. - fields = {field.name: field for field in dataclasses.fields(model_generator.VirusFormData)} - for field, value in model_generator.VirusFormData._DEFAULTS.items(): + fields = {field.name: field for field in dataclasses.fields(virus_validator.VirusFormData)} + for field, value in virus_validator.VirusFormData._DEFAULTS.items(): if field not in fields: raise ValueError(f"Unmatched default {field}") @@ -557,7 +557,7 @@ def test_default_types(): for field in fields.values(): if field.name == "data_registry": continue # Skip the assertion for the "data_registry" field - assert field.name in model_generator.VirusFormData._DEFAULTS, f"No default set for field name {field.name}" + assert field.name in virus_validator.VirusFormData._DEFAULTS, f"No default set for field name {field.name}" def test_form_to_dict(baseline_form): @@ -566,7 +566,7 @@ def test_form_to_dict(baseline_form): assert 1 < len(stripped) < len(full) assert 'exposed_coffee_break_option' in stripped # If we set the value to the default one, it should no longer turn up in the dictionary. - baseline_form.exposed_coffee_break_option = model_generator.VirusFormData._DEFAULTS['exposed_coffee_break_option'] + baseline_form.exposed_coffee_break_option = virus_validator.VirusFormData._DEFAULTS['exposed_coffee_break_option'] assert 'exposed_coffee_break_option' not in baseline_form.to_dict(baseline_form, strip_defaults=True) @@ -584,7 +584,7 @@ def test_form_timezone(baseline_form_data, data_registry, longitude, latitude, m baseline_form_data['location_latitude'] = latitude baseline_form_data['location_longitude'] = longitude baseline_form_data['event_month'] = month - form = model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) + form = virus_validator.VirusFormData.from_dict(baseline_form_data, data_registry) name, offset = form.tz_name_and_utc_offset() assert name == expected_tz_name assert offset == expected_offset diff --git a/caimira/tests/apps/calculator/test_specific_model_generator.py b/caimira/calculator/tests/apps/calculator/test_specific_model_generator.py similarity index 93% rename from caimira/tests/apps/calculator/test_specific_model_generator.py rename to caimira/calculator/tests/apps/calculator/test_specific_model_generator.py index e8a6b977..6945a2c1 100644 --- a/caimira/tests/apps/calculator/test_specific_model_generator.py +++ b/caimira/calculator/tests/apps/calculator/test_specific_model_generator.py @@ -2,8 +2,8 @@ import numpy as np import pytest -from caimira.apps.calculator import model_generator -from caimira.store.data_registry import DataRegistry +from caimira.calculator.validators.virus import virus_validator +from caimira.calculator.store.data_registry import DataRegistry @pytest.mark.parametrize( @@ -14,7 +14,7 @@ [{"exposed_breaks": [], "ifected_breaks": []}, 'Unable to fetch "infected_breaks" key. Got "ifected_breaks".'], ] ) -def test_specific_break_structure(break_input, error, baseline_form: model_generator.VirusFormData, data_registry: DataRegistry): +def test_specific_break_structure(break_input, error, baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): baseline_form.specific_breaks = break_input with pytest.raises(TypeError, match=error): baseline_form.validate() @@ -31,7 +31,7 @@ def test_specific_break_structure(break_input, error, baseline_form: model_gener [[{"start_time": "10:00", "finish_time": "11"}], 'Wrong time format - "HH:MM". Got "11".'], ] ) -def test_specific_population_break_data_structure(population_break_input, error, baseline_form: model_generator.VirusFormData, data_registry: DataRegistry): +def test_specific_population_break_data_structure(population_break_input, error, baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): baseline_form.specific_breaks = {'exposed_breaks': population_break_input, 'infected_breaks': population_break_input} with pytest.raises(TypeError, match=error): baseline_form.validate() @@ -46,7 +46,7 @@ def test_specific_population_break_data_structure(population_break_input, error, [{'exposed_breaks': [], 'infected_breaks': [{"start_time": "08:00", "finish_time": "11:00"}, {"start_time": "14:00", "finish_time": "15:00"}, ]}, "All breaks should be within the simulation time. Got 08:00."], ] ) -def test_specific_break_time(break_input, error, baseline_form: model_generator.VirusFormData): +def test_specific_break_time(break_input, error, baseline_form: virus_validator.VirusFormData): with pytest.raises(ValueError, match=error): baseline_form.generate_specific_break_times(breaks_dict=break_input, target='exposed') baseline_form.generate_specific_break_times(breaks_dict=break_input, target='infected') @@ -65,7 +65,7 @@ def test_specific_break_time(break_input, error, baseline_form: model_generator. [{"physical_activity": "Light activity", "respiratory_activity": [{"type": "Breathing", "percentag": 50}, {"type": "Speaking", "percentage": 50}]}, 'Unable to fetch "percentage" key. Got "percentag".'], ] ) -def test_precise_activity_structure(precise_activity_input, error, baseline_form: model_generator.VirusFormData, data_registry: DataRegistry): +def test_precise_activity_structure(precise_activity_input, error, baseline_form: virus_validator.VirusFormData, data_registry: DataRegistry): baseline_form.precise_activity = precise_activity_input with pytest.raises(TypeError, match=error): baseline_form.validate() @@ -80,7 +80,7 @@ def test_precise_activity_structure(precise_activity_input, error, baseline_form [{"physical_activity": "Light activity", "respiratory_activity": [{"type": "Breathing", "percentage": 50}]}, 'The sum of all respiratory activities should be 100. Got 50.'], ] ) -def test_sum_precise_activity(precise_activity_input, error, baseline_form: model_generator.VirusFormData): +def test_sum_precise_activity(precise_activity_input, error, baseline_form: virus_validator.VirusFormData): baseline_form.precise_activity = precise_activity_input with pytest.raises(ValueError, match=error): baseline_form.validate() diff --git a/caimira/tests/conftest.py b/caimira/calculator/tests/conftest.py similarity index 86% rename from caimira/tests/conftest.py rename to caimira/calculator/tests/conftest.py index 4142ff79..f9391901 100644 --- a/caimira/tests/conftest.py +++ b/caimira/calculator/tests/conftest.py @@ -1,10 +1,10 @@ -from caimira import models -import caimira.data -import caimira.dataclass_utils +from caimira.calculator.models import models +import caimira.calculator.models.data +import caimira.calculator.models.dataclass_utils import pytest -from caimira.store.data_registry import DataRegistry +from caimira.calculator.store.data_registry import DataRegistry @pytest.fixture @@ -61,12 +61,12 @@ def baseline_exposure_model(data_registry, baseline_concentration_model, baselin @pytest.fixture def exposure_model_w_outside_temp_changes(data_registry, baseline_exposure_model: models.ExposureModel): - exp_model = caimira.dataclass_utils.nested_replace( + exp_model = caimira.calculator.models.dataclass_utils.nested_replace( baseline_exposure_model, { 'concentration_model.ventilation': models.SlidingWindow( data_registry=data_registry, active=models.PeriodicInterval(2.2 * 60, 1.8 * 60), - outside_temp=caimira.data.GenevaTemperatures['Jan'], + outside_temp=caimira.calculator.models.data.GenevaTemperatures['Jan'], window_height=1.6, opening_length=0.6, ) diff --git a/caimira/calculator/tests/data/__init__.py b/caimira/calculator/tests/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/tests/data/test_weather.py b/caimira/calculator/tests/data/test_weather.py similarity index 98% rename from caimira/tests/data/test_weather.py rename to caimira/calculator/tests/data/test_weather.py index 03eb29c3..7cdab2a8 100644 --- a/caimira/tests/data/test_weather.py +++ b/caimira/calculator/tests/data/test_weather.py @@ -5,7 +5,7 @@ import numpy.testing import pytest -import caimira.data.weather as wx +import caimira.calculator.models.data.weather as wx def test_nearest_wx_station(): diff --git a/caimira/calculator/tests/models/__init__.py b/caimira/calculator/tests/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/tests/models/test_co2_concentration_model.py b/caimira/calculator/tests/models/test_co2_concentration_model.py similarity index 96% rename from caimira/tests/models/test_co2_concentration_model.py rename to caimira/calculator/tests/models/test_co2_concentration_model.py index ce57110b..d6010032 100644 --- a/caimira/tests/models/test_co2_concentration_model.py +++ b/caimira/calculator/tests/models/test_co2_concentration_model.py @@ -1,7 +1,7 @@ import numpy.testing as npt import pytest -from caimira import models +from caimira.calculator.models import models @pytest.fixture diff --git a/caimira/tests/models/test_concentration_model.py b/caimira/calculator/tests/models/test_concentration_model.py similarity index 98% rename from caimira/tests/models/test_concentration_model.py rename to caimira/calculator/tests/models/test_concentration_model.py index 88d796d7..d2d5020c 100644 --- a/caimira/tests/models/test_concentration_model.py +++ b/caimira/calculator/tests/models/test_concentration_model.py @@ -5,8 +5,8 @@ import pytest from dataclasses import dataclass -from caimira import models -from caimira.store.data_registry import DataRegistry +from caimira.calculator.models import models +from caimira.calculator.store.data_registry import DataRegistry @dataclass(frozen=True) class KnownConcentrationModelBase(models._ConcentrationModelBase): diff --git a/caimira/tests/models/test_dynamic_population.py b/caimira/calculator/tests/models/test_dynamic_population.py similarity index 99% rename from caimira/tests/models/test_dynamic_population.py rename to caimira/calculator/tests/models/test_dynamic_population.py index 79c89980..daffdd56 100644 --- a/caimira/tests/models/test_dynamic_population.py +++ b/caimira/calculator/tests/models/test_dynamic_population.py @@ -4,8 +4,8 @@ import numpy.testing as npt import pytest -from caimira import models -import caimira.dataclass_utils as dc_utils +from caimira.calculator.models import models +import caimira.calculator.models.dataclass_utils as dc_utils @pytest.fixture def full_exposure_model(data_registry): diff --git a/caimira/tests/models/test_exposure_model.py b/caimira/calculator/tests/models/test_exposure_model.py similarity index 98% rename from caimira/tests/models/test_exposure_model.py rename to caimira/calculator/tests/models/test_exposure_model.py index 99e7ca1e..37fc7450 100644 --- a/caimira/tests/models/test_exposure_model.py +++ b/caimira/calculator/tests/models/test_exposure_model.py @@ -5,11 +5,11 @@ import pytest from dataclasses import dataclass -from caimira import models -from caimira.models import ExposureModel -from caimira.dataclass_utils import replace -from caimira.monte_carlo.data import expiration_distributions -from caimira.store.data_registry import DataRegistry +from caimira.calculator.models import models +from caimira.calculator.models.models import ExposureModel +from caimira.calculator.models.dataclass_utils import replace +from caimira.calculator.models.monte_carlo.data import expiration_distributions +from caimira.calculator.store.data_registry import DataRegistry @dataclass(frozen=True) class KnownNormedconcentration(models.ConcentrationModel): diff --git a/caimira/tests/models/test_fitting_algorithm.py b/caimira/calculator/tests/models/test_fitting_algorithm.py similarity index 97% rename from caimira/tests/models/test_fitting_algorithm.py rename to caimira/calculator/tests/models/test_fitting_algorithm.py index 65b6f447..bb4886de 100644 --- a/caimira/tests/models/test_fitting_algorithm.py +++ b/caimira/calculator/tests/models/test_fitting_algorithm.py @@ -2,7 +2,7 @@ import numpy.testing as npt import pytest -from caimira import models +from caimira.calculator.models import models @pytest.mark.parametrize( diff --git a/caimira/tests/models/test_mask.py b/caimira/calculator/tests/models/test_mask.py similarity index 95% rename from caimira/tests/models/test_mask.py rename to caimira/calculator/tests/models/test_mask.py index 5d87ac61..3c32a8dc 100644 --- a/caimira/tests/models/test_mask.py +++ b/caimira/calculator/tests/models/test_mask.py @@ -2,7 +2,7 @@ import numpy.testing as npt import pytest -from caimira import models +from caimira.calculator.models import models @pytest.mark.parametrize( diff --git a/caimira/tests/models/test_piecewiseconstant.py b/caimira/calculator/tests/models/test_piecewiseconstant.py similarity index 96% rename from caimira/tests/models/test_piecewiseconstant.py rename to caimira/calculator/tests/models/test_piecewiseconstant.py index 74c8a056..93347bf4 100644 --- a/caimira/tests/models/test_piecewiseconstant.py +++ b/caimira/calculator/tests/models/test_piecewiseconstant.py @@ -1,8 +1,8 @@ import numpy as np import pytest -from caimira import models -from caimira import data +from caimira.calculator.models import models +from caimira.calculator.models import data def test_piecewiseconstantfunction_wrongarguments(): diff --git a/caimira/tests/models/test_short_range_model.py b/caimira/calculator/tests/models/test_short_range_model.py similarity index 94% rename from caimira/tests/models/test_short_range_model.py rename to caimira/calculator/tests/models/test_short_range_model.py index 5e20d03b..0382369a 100644 --- a/caimira/tests/models/test_short_range_model.py +++ b/caimira/calculator/tests/models/test_short_range_model.py @@ -3,10 +3,10 @@ import numpy as np import pytest -from caimira import models -import caimira.monte_carlo as mc_models -from caimira.apps.calculator.model_generator import build_expiration -from caimira.monte_carlo.data import short_range_expiration_distributions,\ +from caimira.calculator.models import models +import caimira.calculator.models.monte_carlo as mc_models +from caimira.calculator.validators.virus.virus_validator import build_expiration +from caimira.calculator.models.monte_carlo.data import short_range_expiration_distributions,\ expiration_distributions, short_range_distances, activity_distributions SAMPLE_SIZE = 250_000 @@ -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_caimira.py b/caimira/calculator/tests/test_caimira.py new file mode 100644 index 00000000..e9f43c36 --- /dev/null +++ b/caimira/calculator/tests/test_caimira.py @@ -0,0 +1,10 @@ +""" +High-level tests for the package. + +""" + +import caimira.calculator.models + + +def test_version(): + assert caimira.calculator.models.__version__ is not None diff --git a/caimira/tests/test_conditional_probability.py b/caimira/calculator/tests/test_conditional_probability.py similarity index 89% rename from caimira/tests/test_conditional_probability.py rename to caimira/calculator/tests/test_conditional_probability.py index 0e616ae7..447a8fe3 100644 --- a/caimira/tests/test_conditional_probability.py +++ b/caimira/calculator/tests/test_conditional_probability.py @@ -2,11 +2,11 @@ import pytest from retry import retry -import caimira.monte_carlo as mc -from caimira import models -from caimira.dataclass_utils import nested_replace -from caimira.apps.calculator import report_generator -from caimira.monte_carlo.data import activity_distributions, virus_distributions, expiration_distributions +import caimira.calculator.models.monte_carlo as mc +from caimira.calculator.models import models +from caimira.calculator.models.dataclass_utils import nested_replace +from caimira.calculator.report import report_generator +from caimira.calculator.models.monte_carlo.data import activity_distributions, virus_distributions, expiration_distributions @pytest.fixture diff --git a/caimira/tests/test_data_service.py b/caimira/calculator/tests/test_data_service.py similarity index 95% rename from caimira/tests/test_data_service.py rename to caimira/calculator/tests/test_data_service.py index c6c21343..d6c5025b 100644 --- a/caimira/tests/test_data_service.py +++ b/caimira/calculator/tests/test_data_service.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import Mock, patch -from caimira.store.data_service import DataService +from caimira.calculator.store.data_service import DataService class DataServiceTests(unittest.TestCase): diff --git a/caimira/tests/test_dataclass_utils.py b/caimira/calculator/tests/test_dataclass_utils.py similarity index 90% rename from caimira/tests/test_dataclass_utils.py rename to caimira/calculator/tests/test_dataclass_utils.py index ac575052..14088018 100644 --- a/caimira/tests/test_dataclass_utils.py +++ b/caimira/calculator/tests/test_dataclass_utils.py @@ -1,6 +1,6 @@ import dataclasses -from caimira.dataclass_utils import nested_replace, walk_dataclass +from caimira.calculator.models.dataclass_utils import nested_replace, walk_dataclass @dataclasses.dataclass(frozen=True) diff --git a/caimira/tests/test_expiration.py b/caimira/calculator/tests/test_expiration.py similarity index 93% rename from caimira/tests/test_expiration.py rename to caimira/calculator/tests/test_expiration.py index 0cda581b..03842149 100644 --- a/caimira/tests/test_expiration.py +++ b/caimira/calculator/tests/test_expiration.py @@ -5,8 +5,8 @@ import pytest from retry import retry -from caimira import models -from caimira.monte_carlo.data import expiration_distribution +from caimira.calculator.models import models +from caimira.calculator.models.monte_carlo.data import expiration_distribution def test_multiple_wrong_weight_size(): diff --git a/caimira/tests/test_full_algorithm.py b/caimira/calculator/tests/test_full_algorithm.py similarity index 84% rename from caimira/tests/test_full_algorithm.py rename to caimira/calculator/tests/test_full_algorithm.py index 3f6c7fa5..adb93df0 100644 --- a/caimira/tests/test_full_algorithm.py +++ b/caimira/calculator/tests/test_full_algorithm.py @@ -8,14 +8,15 @@ import pytest from retry import retry -import caimira.monte_carlo as mc -from caimira import models -from caimira.utils import method_cache -from caimira.models import _VectorisedFloat,Interval,SpecificInterval -from caimira.monte_carlo.data import (expiration_distributions, +import caimira.calculator.models.monte_carlo as mc +from caimira.calculator.models import models +from caimira.calculator.models.utils import method_cache +from caimira.calculator.models.models import _VectorisedFloat,Interval,SpecificInterval +from caimira.calculator.models.monte_carlo.data import (expiration_distributions, 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/tests/test_infected_population.py b/caimira/calculator/tests/test_infected_population.py similarity index 69% rename from caimira/tests/test_infected_population.py rename to caimira/calculator/tests/test_infected_population.py index 192441e4..2a48e8df 100644 --- a/caimira/tests/test_infected_population.py +++ b/caimira/calculator/tests/test_infected_population.py @@ -1,7 +1,7 @@ import numpy as np import pytest -import caimira.models +import caimira.calculator.models.models @pytest.mark.parametrize( @@ -17,26 +17,26 @@ def test_infected_population_vectorisation(override_params, data_registry): } defaults.update(override_params) - office_hours = caimira.models.SpecificInterval(present_times=[(8,17)]) - infected = caimira.models.InfectedPopulation( + office_hours = caimira.calculator.models.models.SpecificInterval(present_times=[(8,17)]) + infected = caimira.calculator.models.models.InfectedPopulation( data_registry=data_registry, number=1, presence=office_hours, - mask=caimira.models.Mask( + mask=caimira.calculator.models.models.Mask( factor_exhale=0.95, η_inhale=0.3, ), - activity=caimira.models.Activity( + activity=caimira.calculator.models.models.Activity( 0.51, defaults['exhalation_rate'], ), - virus=caimira.models.SARSCoV2( + virus=caimira.calculator.models.models.SARSCoV2( viral_load_in_sputum=defaults['viral_load_in_sputum'], infectious_dose=50., viable_to_RNA_ratio = 0.5, transmissibility_factor=1.0, ), - expiration=caimira.models._ExpirationBase.types['Breathing'], + expiration=caimira.calculator.models.models._ExpirationBase.types['Breathing'], host_immunity=0., ) emission_rate = infected.emission_rate(10) diff --git a/caimira/tests/test_known_quantities.py b/caimira/calculator/tests/test_known_quantities.py similarity index 99% rename from caimira/tests/test_known_quantities.py rename to caimira/calculator/tests/test_known_quantities.py index ba7e451a..c3943157 100644 --- a/caimira/tests/test_known_quantities.py +++ b/caimira/calculator/tests/test_known_quantities.py @@ -2,8 +2,8 @@ import numpy.testing as npt import pytest -import caimira.models as models -import caimira.data as data +import caimira.calculator.models.models as models +import caimira.calculator.models.data as data def test_no_mask_superspeading_emission_rate(baseline_concentration_model): diff --git a/caimira/tests/test_model.py b/caimira/calculator/tests/test_model.py similarity index 87% rename from caimira/tests/test_model.py rename to caimira/calculator/tests/test_model.py index eec01921..956d0c92 100644 --- a/caimira/tests/test_model.py +++ b/caimira/calculator/tests/test_model.py @@ -1,5 +1,4 @@ -import caimira.models -from caimira.dataclass_utils import nested_replace +from caimira.calculator.models.dataclass_utils import nested_replace def test_exposure_r0(baseline_exposure_model): diff --git a/caimira/calculator/tests/test_monte_carlo.py b/caimira/calculator/tests/test_monte_carlo.py new file mode 100644 index 00000000..ece38bd7 --- /dev/null +++ b/caimira/calculator/tests/test_monte_carlo.py @@ -0,0 +1,102 @@ +import dataclasses + +import numpy as np +import pytest + +import caimira.calculator.models +import caimira.calculator.models.models +import caimira.calculator.models.monte_carlo.sampleable + +MODEL_CLASSES = [ + cls for cls in vars(caimira.calculator.models).values() + if dataclasses.is_dataclass(cls) +] + + +def test_type_annotations(): + # Check that there are appropriate type annotations for all of the model + # classes in caimira.models. Note that these must be statically defined in + # caimira.monte_carlo, rather than being dynamically generated, in order to + # allow the type system to be able to see their definition without needing + # runtime execution. + missing = [] + for cls in MODEL_CLASSES: + if not hasattr(caimira.calculator.models.monte_carlo, cls.__name__): + missing.append(cls.__name__) + continue + mc_cls = getattr(caimira.calculator.models.monte_carlo, cls.__name__) + assert issubclass(mc_cls, caimira.calculator.models.monte_carlo.MCModelBase) + + if missing: + msg = ( + 'There are missing model implementations in caimira.monte_carlo. ' + 'The following definitions are needed:\n ' + + '\n '.join([f'{model} = build_mc_model(caimira.models.{model})' for model in missing]) + ) + pytest.fail(msg) + + +@pytest.fixture +def baseline_mc_concentration_model(data_registry) -> caimira.calculator.models.monte_carlo.ConcentrationModel: + mc_model = caimira.calculator.models.monte_carlo.ConcentrationModel( + data_registry=data_registry, + room=caimira.calculator.models.monte_carlo.Room(volume=caimira.calculator.models.monte_carlo.sampleable.Normal(75, 20), + inside_temp=caimira.calculator.models.models.PiecewiseConstant((0., 24.), (293,))), + ventilation=caimira.calculator.models.monte_carlo.SlidingWindow( + data_registry=data_registry, + active=caimira.calculator.models.models.PeriodicInterval(period=120, duration=120), + outside_temp=caimira.calculator.models.models.PiecewiseConstant((0., 24.), (283,)), + window_height=1.6, opening_length=0.6, + ), + infected=caimira.calculator.models.models.InfectedPopulation( + data_registry=data_registry, + number=1, + virus=caimira.calculator.models.models.Virus.types['SARS_CoV_2'], + presence=caimira.calculator.models.models.SpecificInterval(((0., 4.), (5., 8.))), + mask=caimira.calculator.models.models.Mask.types['No mask'], + activity=caimira.calculator.models.models.Activity.types['Light activity'], + expiration=caimira.calculator.models.models.Expiration.types['Breathing'], + host_immunity=0., + ), + evaporation_factor=0.3, + ) + return mc_model + + +@pytest.fixture +def baseline_mc_sr_model() -> caimira.calculator.models.monte_carlo.ShortRangeModel: + return () + + +@pytest.fixture +def baseline_mc_exposure_model(data_registry, baseline_mc_concentration_model, baseline_mc_sr_model) -> caimira.calculator.models.monte_carlo.ExposureModel: + return caimira.calculator.models.monte_carlo.ExposureModel( + data_registry, + baseline_mc_concentration_model, + baseline_mc_sr_model, + exposed=caimira.calculator.models.models.Population( + number=10, + presence=baseline_mc_concentration_model.infected.presence, + activity=baseline_mc_concentration_model.infected.activity, + mask=baseline_mc_concentration_model.infected.mask, + host_immunity=0., + ), + geographical_data=caimira.calculator.models.models.Cases(), + ) + + +def test_build_concentration_model(baseline_mc_concentration_model: caimira.calculator.models.monte_carlo.ConcentrationModel): + model = baseline_mc_concentration_model.build_model(7) + assert isinstance(model, caimira.calculator.models.models.ConcentrationModel) + assert isinstance(model.concentration(time=0.), float) + conc = model.concentration(time=1.) + assert isinstance(conc, np.ndarray) + assert conc.shape == (7, ) + + +def test_build_exposure_model(baseline_mc_exposure_model: caimira.calculator.models.monte_carlo.ExposureModel): + model = baseline_mc_exposure_model.build_model(7) + assert isinstance(model, caimira.calculator.models.models.ExposureModel) + prob = model.deposited_exposure() + assert isinstance(prob, np.ndarray) + assert prob.shape == (7, ) diff --git a/caimira/tests/test_monte_carlo_full_models.py b/caimira/calculator/tests/test_monte_carlo_full_models.py similarity index 97% rename from caimira/tests/test_monte_carlo_full_models.py rename to caimira/calculator/tests/test_monte_carlo_full_models.py index f5b6bc74..948ce45e 100644 --- a/caimira/tests/test_monte_carlo_full_models.py +++ b/caimira/calculator/tests/test_monte_carlo_full_models.py @@ -3,10 +3,10 @@ import pytest from retry import retry -import caimira.monte_carlo as mc -from caimira import models,data -from caimira.monte_carlo.data import activity_distributions, virus_distributions, expiration_distributions, infectious_dose_distribution, viable_to_RNA_ratio_distribution -from caimira.apps.calculator.model_generator import build_expiration +import caimira.calculator.models.monte_carlo as mc +from caimira.calculator.models import models, data +from caimira.calculator.models.monte_carlo.data import activity_distributions, virus_distributions, expiration_distributions, infectious_dose_distribution, viable_to_RNA_ratio_distribution +from caimira.calculator.validators.virus.virus_validator import build_expiration SAMPLE_SIZE = 500_000 TOLERANCE = 0.05 @@ -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/tests/test_predefined_distributions.py b/caimira/calculator/tests/test_predefined_distributions.py similarity index 92% rename from caimira/tests/test_predefined_distributions.py rename to caimira/calculator/tests/test_predefined_distributions.py index b75e4ce5..c0248b17 100644 --- a/caimira/tests/test_predefined_distributions.py +++ b/caimira/calculator/tests/test_predefined_distributions.py @@ -2,8 +2,7 @@ import numpy.testing as npt import pytest -from caimira.monte_carlo.data import activity_distributions, virus_distributions -from caimira.store import data_registry +from caimira.calculator.models.monte_carlo.data import activity_distributions, virus_distributions # Mean & std deviations from https://doi.org/10.1101/2021.10.14.21264988 (Table 3) diff --git a/caimira/tests/test_sampleable_distribution.py b/caimira/calculator/tests/test_sampleable_distribution.py similarity index 98% rename from caimira/tests/test_sampleable_distribution.py rename to caimira/calculator/tests/test_sampleable_distribution.py index 98f0a9ca..add9dba2 100644 --- a/caimira/tests/test_sampleable_distribution.py +++ b/caimira/calculator/tests/test_sampleable_distribution.py @@ -3,7 +3,7 @@ import pytest from retry import retry -from caimira.monte_carlo import sampleable +from caimira.calculator.models.monte_carlo import sampleable @retry(tries=10) diff --git a/caimira/tests/test_ventilation.py b/caimira/calculator/tests/test_ventilation.py similarity index 99% rename from caimira/tests/test_ventilation.py rename to caimira/calculator/tests/test_ventilation.py index 4d322cfa..82c9f682 100644 --- a/caimira/tests/test_ventilation.py +++ b/caimira/calculator/tests/test_ventilation.py @@ -4,7 +4,7 @@ import numpy.testing as npt import pytest -from caimira import models +from caimira.calculator.models import models @pytest.fixture diff --git a/caimira/calculator/validators/__init__.py b/caimira/calculator/validators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/calculator/validators/co2/__init__.py b/caimira/calculator/validators/co2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/apps/calculator/co2_model_generator.py b/caimira/calculator/validators/co2/co2_validator.py similarity index 96% rename from caimira/apps/calculator/co2_model_generator.py rename to caimira/calculator/validators/co2/co2_validator.py index e2584256..a630e774 100644 --- a/caimira/apps/calculator/co2_model_generator.py +++ b/caimira/calculator/validators/co2/co2_validator.py @@ -6,11 +6,11 @@ import matplotlib.pyplot as plt import re -from caimira import models -from caimira.store.data_registry import DataRegistry -from .form_data import FormData, cast_class_fields -from .defaults import NO_DEFAULT -from .report_generator import img2base64, _figure2bytes +from ..form_validator import FormData, cast_class_fields +from ..defaults import NO_DEFAULT +from ...store.data_registry import DataRegistry +from ...models import models +from ...report.report_generator import img2base64, _figure2bytes minutes_since_midnight = typing.NewType('minutes_since_midnight', int) @@ -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/apps/calculator/defaults.py b/caimira/calculator/validators/defaults.py similarity index 99% rename from caimira/apps/calculator/defaults.py rename to caimira/calculator/validators/defaults.py index 4976d266..e12664eb 100644 --- a/caimira/apps/calculator/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/apps/calculator/form_data.py b/caimira/calculator/validators/form_validator.py similarity index 79% rename from caimira/apps/calculator/form_data.py rename to caimira/calculator/validators/form_validator.py index a0a96b67..94cf32b4 100644 --- a/caimira/apps/calculator/form_data.py +++ b/caimira/calculator/validators/form_validator.py @@ -7,9 +7,9 @@ import numpy as np -from caimira import models -from caimira.store.data_registry import DataRegistry from .defaults import DEFAULTS, NO_DEFAULT, COFFEE_OPTIONS_INT +from ..models import models +from ..store.data_registry import DataRegistry LOG = logging.getLogger(__name__) @@ -26,13 +26,16 @@ class FormData: exposed_lunch_option: bool exposed_lunch_start: minutes_since_midnight exposed_start: minutes_since_midnight - infected_coffee_break_option: str #Used if infected_dont_have_breaks_with_exposed - infected_coffee_duration: int #Used if infected_dont_have_breaks_with_exposed + # Used if infected_dont_have_breaks_with_exposed + infected_coffee_break_option: str + infected_coffee_duration: int # Used if infected_dont_have_breaks_with_exposed infected_dont_have_breaks_with_exposed: bool infected_finish: minutes_since_midnight - infected_lunch_finish: minutes_since_midnight #Used if infected_dont_have_breaks_with_exposed - infected_lunch_option: bool #Used if infected_dont_have_breaks_with_exposed - infected_lunch_start: minutes_since_midnight #Used if infected_dont_have_breaks_with_exposed + # Used if infected_dont_have_breaks_with_exposed + infected_lunch_finish: minutes_since_midnight + infected_lunch_option: bool # Used if infected_dont_have_breaks_with_exposed + # Used if infected_dont_have_breaks_with_exposed + infected_lunch_start: minutes_since_midnight infected_people: int infected_start: minutes_since_midnight room_volume: float @@ -47,7 +50,6 @@ def from_dict(cls, form_data: typing.Dict, data_registry: DataRegistry): # Take a copy of the form data so that we can mutate it. form_data = form_data.copy() form_data.pop('_xsrf', None) - # Don't let arbitrary unescaped HTML through the net. for key, value in form_data.items(): if isinstance(value, str): @@ -64,7 +66,8 @@ def from_dict(cls, form_data: typing.Dict, data_registry: DataRegistry): form_data[key] = _CAST_RULES_FORM_ARG_TO_NATIVE[key](value) if key not in cls._DEFAULTS: - raise ValueError(f'Invalid argument "{html.escape(key)}" given') + raise ValueError( + f'Invalid argument "{html.escape(key)}" given') instance = cls(**form_data, data_registry=data_registry) instance.validate() @@ -93,7 +96,8 @@ def to_dict(cls, form: "FormData", strip_defaults: bool = False) -> dict: def validate_population_parameters(self): # Validate number of infected <= number of total people if self.infected_people >= self.total_people: - raise ValueError('Number of infected people cannot be greater or equal to the number of total people.') + raise ValueError( + 'Number of infected people cannot be greater or equal to the number of total people.') # Validate time intervals selected by user time_intervals = [ @@ -101,9 +105,11 @@ def validate_population_parameters(self): ['infected_start', 'infected_finish'], ] if self.exposed_lunch_option: - time_intervals.append(['exposed_lunch_start', 'exposed_lunch_finish']) + time_intervals.append( + ['exposed_lunch_start', 'exposed_lunch_finish']) if self.infected_dont_have_breaks_with_exposed and self.infected_lunch_option: - time_intervals.append(['infected_lunch_start', 'infected_lunch_finish']) + time_intervals.append( + ['infected_lunch_start', 'infected_lunch_finish']) for start_name, end_name in time_intervals: start = getattr(self, start_name) @@ -116,29 +122,33 @@ def validate_lunch(start, finish): lunch_start = getattr(self, f'{population}_lunch_start') lunch_finish = getattr(self, f'{population}_lunch_finish') return (start <= lunch_start <= finish and - start <= lunch_finish <= finish) + start <= lunch_finish <= finish) def get_lunch_mins(population): lunch_mins = 0 if getattr(self, f'{population}_lunch_option'): - lunch_mins = getattr(self, f'{population}_lunch_finish') - getattr(self, f'{population}_lunch_start') + lunch_mins = getattr( + self, f'{population}_lunch_finish') - getattr(self, f'{population}_lunch_start') return lunch_mins def get_coffee_mins(population): coffee_mins = 0 if getattr(self, f'{population}_coffee_break_option') != 'coffee_break_0': - coffee_mins = COFFEE_OPTIONS_INT[getattr(self, f'{population}_coffee_break_option')] * getattr(self, f'{population}_coffee_duration') + coffee_mins = COFFEE_OPTIONS_INT[getattr( + self, f'{population}_coffee_break_option')] * getattr(self, f'{population}_coffee_duration') return coffee_mins def get_activity_mins(population): return getattr(self, f'{population}_finish') - getattr(self, f'{population}_start') - populations = ['exposed', 'infected'] if self.infected_dont_have_breaks_with_exposed else ['exposed'] + populations = [ + 'exposed', 'infected'] if self.infected_dont_have_breaks_with_exposed else ['exposed'] for population in populations: # Validate lunch time within the activity times. if (getattr(self, f'{population}_lunch_option') and - not validate_lunch(getattr(self, f'{population}_start'), getattr(self, f'{population}_finish')) - ): + not validate_lunch(getattr(self, f'{population}_start'), getattr( + self, f'{population}_finish')) + ): raise ValueError( f"{population} lunch break must be within presence times." ) @@ -152,7 +162,8 @@ def get_activity_mins(population): for attr_name, valid_set in [('exposed_coffee_break_option', COFFEE_OPTIONS_INT), ('infected_coffee_break_option', COFFEE_OPTIONS_INT)]: if getattr(self, attr_name) not in valid_set: - raise ValueError(f"{getattr(self, attr_name)} is not a valid value for {attr_name}") + raise ValueError( + f"{getattr(self, attr_name)} is not a valid value for {attr_name}") def validate(self): raise NotImplementedError("Subclass must implement") @@ -161,7 +172,8 @@ def build_model(self, sample_size=None): raise NotImplementedError("Subclass must implement") def _compute_breaks_in_interval(self, start, finish, n_breaks, duration) -> models.BoundarySequence_t: - break_delay = ((finish - start) - (n_breaks * duration)) // (n_breaks+1) + break_delay = ((finish - start) - + (n_breaks * duration)) // (n_breaks+1) break_times = [] end = start for n in range(n_breaks): @@ -173,14 +185,16 @@ def _compute_breaks_in_interval(self, start, finish, n_breaks, duration) -> mode def exposed_lunch_break_times(self) -> models.BoundarySequence_t: result = [] if self.exposed_lunch_option: - result.append((self.exposed_lunch_start, self.exposed_lunch_finish)) + result.append((self.exposed_lunch_start, + self.exposed_lunch_finish)) return tuple(result) def infected_lunch_break_times(self) -> models.BoundarySequence_t: if self.infected_dont_have_breaks_with_exposed: result = [] if self.infected_lunch_option: - result.append((self.infected_lunch_start, self.infected_lunch_finish)) + result.append((self.infected_lunch_start, + self.infected_lunch_finish)) return tuple(result) else: return self.exposed_lunch_break_times() @@ -194,7 +208,8 @@ def infected_number_of_coffee_breaks(self) -> int: def _coffee_break_times(self, activity_start, activity_finish, coffee_breaks, coffee_duration, lunch_start, lunch_finish) -> models.BoundarySequence_t: time_before_lunch = lunch_start - activity_start time_after_lunch = activity_finish - lunch_finish - before_lunch_frac = time_before_lunch / (time_before_lunch + time_after_lunch) + before_lunch_frac = time_before_lunch / \ + (time_before_lunch + time_after_lunch) n_morning_breaks = round(coffee_breaks * before_lunch_frac) breaks = ( self._compute_breaks_in_interval( @@ -211,9 +226,11 @@ def exposed_coffee_break_times(self) -> models.BoundarySequence_t: if exposed_coffee_breaks == 0: return () if self.exposed_lunch_option: - breaks = self._coffee_break_times(self.exposed_start, self.exposed_finish, exposed_coffee_breaks, self.exposed_coffee_duration, self.exposed_lunch_start, self.exposed_lunch_finish) + breaks = self._coffee_break_times(self.exposed_start, self.exposed_finish, exposed_coffee_breaks, + self.exposed_coffee_duration, self.exposed_lunch_start, self.exposed_lunch_finish) else: - breaks = self._compute_breaks_in_interval(self.exposed_start, self.exposed_finish, exposed_coffee_breaks, self.exposed_coffee_duration) + breaks = self._compute_breaks_in_interval( + self.exposed_start, self.exposed_finish, exposed_coffee_breaks, self.exposed_coffee_duration) return breaks def infected_coffee_break_times(self) -> models.BoundarySequence_t: @@ -222,9 +239,11 @@ def infected_coffee_break_times(self) -> models.BoundarySequence_t: if infected_coffee_breaks == 0: return () if self.infected_lunch_option: - breaks = self._coffee_break_times(self.infected_start, self.infected_finish, infected_coffee_breaks, self.infected_coffee_duration, self.infected_lunch_start, self.infected_lunch_finish) + breaks = self._coffee_break_times(self.infected_start, self.infected_finish, infected_coffee_breaks, + self.infected_coffee_duration, self.infected_lunch_start, self.infected_lunch_finish) else: - breaks = self._compute_breaks_in_interval(self.infected_start, self.infected_finish, infected_coffee_breaks, self.infected_coffee_duration) + breaks = self._compute_breaks_in_interval( + self.infected_start, self.infected_finish, infected_coffee_breaks, self.infected_coffee_duration) return breaks else: return self.exposed_coffee_break_times() @@ -232,13 +251,14 @@ def infected_coffee_break_times(self) -> models.BoundarySequence_t: def generate_specific_break_times(self, breaks_dict: dict, target: str) -> models.BoundarySequence_t: break_times = [] for n in breaks_dict[f'{target}_breaks']: - # Parse break times. + # Parse break times. begin = time_string_to_minutes(n["start_time"]) end = time_string_to_minutes(n["finish_time"]) for time in [begin, end]: # For a specific break, the infected and exposed presence is the same. if not getattr(self, f'{target}_start') < time < getattr(self, f'{target}_finish'): - raise ValueError(f'All breaks should be within the simulation time. Got {time_minutes_to_string(time)}.') + raise ValueError( + f'All breaks should be within the simulation time. Got {time_minutes_to_string(time)}.') break_times.append((begin, end)) return tuple(break_times) @@ -260,7 +280,8 @@ def present_interval( # Order the breaks by their start-time, and ensure that they are monotonic # and that the start of one break happens after the end of another. - break_boundaries: models.BoundarySequence_t = tuple(sorted(breaks, key=lambda break_pair: break_pair[0])) + break_boundaries: models.BoundarySequence_t = tuple( + sorted(breaks, key=lambda break_pair: break_pair[0])) for break_start, break_end in break_boundaries: if break_start >= break_end: @@ -269,13 +290,15 @@ def present_interval( prev_break_end = break_boundaries[0][1] for break_start, break_end in break_boundaries[1:]: if prev_break_end >= break_start: - raise ValueError(f"A break starts before another ends ({break_start}, {break_end}, {prev_break_end}).") + raise ValueError( + f"A break starts before another ends ({break_start}, {break_end}, {prev_break_end}).") prev_break_end = break_end present_intervals = [] current_time = start - LOG.debug(f"starting time march at {_hours2timestring(current_time/60)} to {_hours2timestring(finish/60)}") + LOG.debug( + f"starting time march at {_hours2timestring(current_time/60)} to {_hours2timestring(finish/60)}") # As we step through the breaks. For each break there are 6 important cases # we must cover. Let S=start; E=end; Bs=Break start; Be=Break end: @@ -336,8 +359,9 @@ def present_interval( return models.SpecificInterval(tuple(present_intervals)) def infected_present_interval(self) -> models.Interval: - if self.specific_breaks != {}: # It means the breaks are specific and not predefined - breaks = self.generate_specific_break_times(breaks_dict=self.specific_breaks, target='exposed') + if self.specific_breaks != {}: # It means the breaks are specific and not predefined + breaks = self.generate_specific_break_times( + breaks_dict=self.specific_breaks, target='exposed') else: breaks = self.infected_lunch_break_times() + self.infected_coffee_break_times() return self.present_interval( @@ -346,14 +370,17 @@ def infected_present_interval(self) -> models.Interval: ) def population_present_interval(self) -> models.Interval: - state_change_times = set(self.infected_present_interval().transition_times()) - state_change_times.update(self.exposed_present_interval().transition_times()) + state_change_times = set( + self.infected_present_interval().transition_times()) + state_change_times.update( + self.exposed_present_interval().transition_times()) all_state_changes = sorted(state_change_times) return models.SpecificInterval(tuple(zip(all_state_changes[:-1], all_state_changes[1:]))) def exposed_present_interval(self) -> models.Interval: - if self.specific_breaks != {}: # It means the breaks are specific and not predefined - breaks = self.generate_specific_break_times(breaks_dict=self.specific_breaks, target='exposed') + if self.specific_breaks != {}: # It means the breaks are specific and not predefined + breaks = self.generate_specific_break_times( + breaks_dict=self.specific_breaks, target='exposed') else: breaks = self.exposed_lunch_break_times() + self.exposed_coffee_break_times() return self.present_interval( @@ -382,7 +409,7 @@ def time_minutes_to_string(time: int) -> str: :param time: The number of minutes between 'time' and 00:00 :return: A string of the form "HH:MM" representing a time of day """ - return "{0:0=2d}".format(int(time/60)) + ":" + "{0:0=2d}".format(time%60) + return "{0:0=2d}".format(int(time/60)) + ":" + "{0:0=2d}".format(time % 60) def string_to_list(s: str) -> list: @@ -409,7 +436,8 @@ def _safe_int_cast(value) -> int: elif isinstance(value, str) and value.isdecimal(): return int(value) else: - raise TypeError(f"Unable to safely cast {value} ({type(value)} type) to int") + raise TypeError( + f"Unable to safely cast {value} ({type(value)} type) to int") #: Mapping of field name to a callable which can convert values from form @@ -420,6 +448,7 @@ def _safe_int_cast(value) -> int: #: that can be encoded to URL arguments. _CAST_RULES_NATIVE_TO_FORM_ARG: typing.Dict[str, typing.Callable] = {} + def cast_class_fields(cls): for _field in dataclasses.fields(cls): if _field.type is minutes_since_midnight: @@ -439,4 +468,5 @@ def cast_class_fields(cls): _CAST_RULES_FORM_ARG_TO_NATIVE[_field.name] = string_to_dict _CAST_RULES_NATIVE_TO_FORM_ARG[_field.name] = dict_to_string + cast_class_fields(FormData) diff --git a/caimira/calculator/validators/virus/__init__.py b/caimira/calculator/validators/virus/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/apps/calculator/model_generator.py b/caimira/calculator/validators/virus/virus_validator.py similarity index 75% rename from caimira/apps/calculator/model_generator.py rename to caimira/calculator/validators/virus/virus_validator.py index 4a305418..74f10427 100644 --- a/caimira/apps/calculator/model_generator.py +++ b/caimira/calculator/validators/virus/virus_validator.py @@ -6,17 +6,13 @@ import numpy as np -from caimira import models -from caimira import data -import caimira.data.weather -import caimira.monte_carlo as mc -from .. import calculator -from .form_data import FormData, cast_class_fields, time_string_to_minutes -from caimira.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions, short_range_distances -from caimira.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions, short_range_expiration_distributions -from .defaults import (DEFAULTS, CONFIDENCE_LEVEL_OPTIONS, - MECHANICAL_VENTILATION_TYPES, MASK_WEARING_OPTIONS, MONTH_NAMES, VACCINE_BOOSTER_TYPE, VACCINE_TYPE, - VENTILATION_TYPES, VOLUME_TYPES, WINDOWS_OPENING_REGIMES, WINDOWS_TYPES) +from ..form_validator import FormData, cast_class_fields, time_string_to_minutes +from ..defaults import (DEFAULTS, CONFIDENCE_LEVEL_OPTIONS, + MECHANICAL_VENTILATION_TYPES, MASK_WEARING_OPTIONS, MONTH_NAMES, VACCINE_BOOSTER_TYPE, VACCINE_TYPE, + VENTILATION_TYPES, VOLUME_TYPES, WINDOWS_OPENING_REGIMES, WINDOWS_TYPES) +from ...models import models, data, monte_carlo as mc +from ...models.monte_carlo.data import activity_distributions, virus_distributions, mask_distributions, short_range_distances +from ...models.monte_carlo.data import expiration_distribution, expiration_BLO_factors, expiration_distributions, short_range_expiration_distributions LOG = logging.getLogger("MODEL") @@ -72,19 +68,22 @@ 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 def validate(self): - # Validate population parameters self.validate_population_parameters() validation_tuples = [('activity_type', self.data_registry.population_scenario_activity.keys()), - ('mechanical_ventilation_type', MECHANICAL_VENTILATION_TYPES), - ('mask_type', list(mask_distributions(self.data_registry).keys())), + ('mechanical_ventilation_type', + MECHANICAL_VENTILATION_TYPES), + ('mask_type', list(mask_distributions( + self.data_registry).keys())), ('mask_wearing_option', MASK_WEARING_OPTIONS), ('ventilation_type', VENTILATION_TYPES), - ('virus_type', list(virus_distributions(self.data_registry).keys())), + ('virus_type', list(virus_distributions( + self.data_registry).keys())), ('volume_type', VOLUME_TYPES), ('window_opening_regime', WINDOWS_OPENING_REGIMES), ('window_type', WINDOWS_TYPES), @@ -95,11 +94,13 @@ def validate(self): for attr_name, valid_set in validation_tuples: if getattr(self, attr_name) not in valid_set: - raise ValueError(f"{getattr(self, attr_name)} is not a valid value for {attr_name}") + raise ValueError( + f"{getattr(self, attr_name)} is not a valid value for {attr_name}") # Validate number of infected people == 1 when activity is Conference/Training. if self.activity_type == 'training' and self.infected_people > 1: - raise ValueError('Conference/Training activities are limited to 1 infected.') + raise ValueError( + 'Conference/Training activities are limited to 1 infected.') # Validate ventilation parameters if self.ventilation_type == 'natural_ventilation': @@ -114,7 +115,7 @@ def validate(self): "ventilation_type is 'natural_ventilation'" ) if (self.window_opening_regime == 'windows_open_periodically' and - self.windows_duration > self.windows_frequency): + self.windows_duration > self.windows_frequency): raise ValueError( 'Duration cannot be bigger than frequency.' ) @@ -127,61 +128,85 @@ def validate(self): # Validate specific inputs - breaks (exposed and infected) if self.specific_breaks != {}: if type(self.specific_breaks) is not dict: - raise TypeError('The specific breaks should be in a dictionary.') + raise TypeError( + 'The specific breaks should be in a dictionary.') dict_keys = list(self.specific_breaks.keys()) if "exposed_breaks" not in dict_keys: - raise TypeError(f'Unable to fetch "exposed_breaks" key. Got "{dict_keys[0]}".') + raise TypeError( + f'Unable to fetch "exposed_breaks" key. Got "{dict_keys[0]}".') if "infected_breaks" not in dict_keys: - raise TypeError(f'Unable to fetch "infected_breaks" key. Got "{dict_keys[1]}".') + raise TypeError( + f'Unable to fetch "infected_breaks" key. Got "{dict_keys[1]}".') for population_breaks in ['exposed_breaks', 'infected_breaks']: if self.specific_breaks[population_breaks] != []: if type(self.specific_breaks[population_breaks]) is not list: - raise TypeError(f'All breaks should be in a list. Got {type(self.specific_breaks[population_breaks])}.') + raise TypeError( + f'All breaks should be in a list. Got {type(self.specific_breaks[population_breaks])}.') for input_break in self.specific_breaks[population_breaks]: # Input validations. if type(input_break) is not dict: - raise TypeError(f'Each break should be a dictionary. Got {type(input_break)}.') + raise TypeError( + f'Each break should be a dictionary. Got {type(input_break)}.') dict_keys = list(input_break.keys()) if "start_time" not in input_break: - raise TypeError(f'Unable to fetch "start_time" key. Got "{dict_keys[0]}".') + raise TypeError( + f'Unable to fetch "start_time" key. Got "{dict_keys[0]}".') if "finish_time" not in input_break: - raise TypeError(f'Unable to fetch "finish_time" key. Got "{dict_keys[1]}".') + raise TypeError( + f'Unable to fetch "finish_time" key. Got "{dict_keys[1]}".') for time in input_break.values(): if not re.compile("^(2[0-3]|[01]?[0-9]):([0-5]?[0-9])$").match(time): - raise TypeError(f'Wrong time format - "HH:MM". Got "{time}".') + raise TypeError( + f'Wrong time format - "HH:MM". Got "{time}".') # Validate specific inputs - precise activity if self.precise_activity != {}: if type(self.precise_activity) is not dict: - raise TypeError('The precise activities should be in a dictionary.') + raise TypeError( + 'The precise activities should be in a dictionary.') dict_keys = list(self.precise_activity.keys()) if "physical_activity" not in dict_keys: - raise TypeError(f'Unable to fetch "physical_activity" key. Got "{dict_keys[0]}".') + raise TypeError( + f'Unable to fetch "physical_activity" key. Got "{dict_keys[0]}".') if "respiratory_activity" not in dict_keys: - raise TypeError(f'Unable to fetch "respiratory_activity" key. Got "{dict_keys[1]}".') + raise TypeError( + f'Unable to fetch "respiratory_activity" key. Got "{dict_keys[1]}".') if type(self.precise_activity['physical_activity']) is not str: - raise TypeError('The physical activities should be a single string.') + raise TypeError( + 'The physical activities should be a single string.') if type(self.precise_activity['respiratory_activity']) is not list: - raise TypeError('The respiratory activities should be in a list.') + raise TypeError( + 'The respiratory activities should be in a list.') total_percentage = 0 for respiratory_activity in self.precise_activity['respiratory_activity']: if type(respiratory_activity) is not dict: - raise TypeError('Each respiratory activity should be defined in a dictionary.') + raise TypeError( + 'Each respiratory activity should be defined in a dictionary.') dict_keys = list(respiratory_activity.keys()) if "type" not in dict_keys: - raise TypeError(f'Unable to fetch "type" key. Got "{dict_keys[0]}".') + raise TypeError( + f'Unable to fetch "type" key. Got "{dict_keys[0]}".') if "percentage" not in dict_keys: - raise TypeError(f'Unable to fetch "percentage" key. Got "{dict_keys[1]}".') + raise TypeError( + f'Unable to fetch "percentage" key. Got "{dict_keys[1]}".') total_percentage += respiratory_activity['percentage'] if total_percentage != 100: - raise ValueError(f'The sum of all respiratory activities should be 100. Got {total_percentage}.') + 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 @@ -192,27 +217,27 @@ 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: short_range.append(mc.ShortRangeModel( data_registry=self.data_registry, - expiration=short_range_expiration_distributions(self.data_registry)[interaction['expiration']], + expiration=short_range_expiration_distributions( + self.data_registry)[interaction['expiration']], activity=infected_population.activity, presence=self.short_range_interval(interaction), distance=short_range_distances(self.data_registry), @@ -227,26 +252,30 @@ def build_mc_model(self) -> mc.ExposureModel: infected=infected_population, evaporation_factor=0.3, ), - short_range = tuple(short_range), + short_range=tuple(short_range), exposed=self.exposed_population(), geographical_data=mc.Cases( geographic_population=self.geographic_population, 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 - infected_population: models.InfectedPopulation = self.infected_population().build_model(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) - state_change_times = set(infected_population.presence_interval().transition_times()) - state_change_times.update(exposed_population.presence_interval().transition_times()) + state_change_times = set( + infected_population.presence_interval().transition_times()) + state_change_times.update( + exposed_population.presence_interval().transition_times()) transition_times = sorted(state_change_times) total_people = [infected_population.people_present(stop) + exposed_population.people_present(stop) @@ -255,10 +284,12 @@ def build_CO2_model(self, sample_size=None) -> models.CO2ConcentrationModel: if (self.activity_type == 'precise'): activity_defn, _ = self.generate_precise_activity_expiration() else: - activity_defn = self.data_registry.population_scenario_activity[self.activity_type]['activity'] + activity_defn = self.data_registry.population_scenario_activity[ + self.activity_type]['activity'] population = mc.SimplePopulation( - number=models.IntPiecewiseConstant(transition_times=tuple(transition_times), values=tuple(total_people)), + number=models.IntPiecewiseConstant(transition_times=tuple( + transition_times), values=tuple(total_people)), presence=None, activity=activity_distributions(self.data_registry)[activity_defn], ) @@ -278,7 +309,7 @@ def tz_name_and_utc_offset(self) -> typing.Tuple[str, float]: """ month = MONTH_NAMES.index(self.event_month) + 1 - timezone = caimira.data.weather.timezone_at( + timezone = data.weather.timezone_at( latitude=self.location_latitude, longitude=self.location_longitude, ) # We choose the first of the month for the current year. @@ -299,7 +330,8 @@ def outside_temp(self) -> models.PiecewiseConstant: month = MONTH_NAMES.index(self.event_month) + 1 wx_station = self.nearest_weather_station() - temp_profile = caimira.data.weather.mean_hourly_temperatures(wx_station = wx_station[0], month = MONTH_NAMES.index(self.event_month) + 1) + temp_profile = data.weather.mean_hourly_temperatures( + wx_station=wx_station[0], month=MONTH_NAMES.index(self.event_month) + 1) _, utc_offset = self.tz_name_and_utc_offset() @@ -307,13 +339,14 @@ def outside_temp(self) -> models.PiecewiseConstant: # result the first data value may no longer be a midnight, and the hours # no longer ordered modulo 24). source_times = np.arange(24) + utc_offset - times, temp_profile = caimira.data.weather.refine_hourly_data( + times, temp_profile = data.weather.refine_hourly_data( source_times, temp_profile, npts=24*10, # 10 steps per hour => 6 min steps ) outside_temp = models.PiecewiseConstant( - tuple(float(t) for t in times), tuple(float(t) for t in temp_profile), + tuple(float(t) for t in times), tuple(float(t) + for t in temp_profile), ) return outside_temp @@ -329,7 +362,8 @@ def ventilation(self) -> models._VentilationBase: ventilations.append(models.AirChange(active=models.SpecificInterval(present_times=((start, stop), )), air_exch=self.CO2_fitting_result['ventilation_values'][index])) else: - ventilations.append(models.AirChange(active=always_on, air_exch=self.CO2_fitting_result['ventilation_values'][0])) + ventilations.append(models.AirChange( + active=always_on, air_exch=self.CO2_fitting_result['ventilation_values'][0])) return models.MultipleVentilation(tuple(ventilations)) # Initializes a ventilation instance as a window if 'natural_ventilation' is selected, or as a HEPA-filter otherwise @@ -365,7 +399,8 @@ def ventilation(self) -> models._VentilationBase: ventilation = models.AirChange(active=always_on, air_exch=0.) else: if self.mechanical_ventilation_type == 'mech_type_air_changes': - ventilation = models.AirChange(active=always_on, air_exch=self.air_changes) + ventilation = models.AirChange( + active=always_on, air_exch=self.air_changes) else: ventilation = models.HVACMechanical( active=always_on, q_air_mech=self.air_supply) @@ -374,16 +409,18 @@ def ventilation(self) -> models._VentilationBase: # to the air infiltration from the outside. # See CERN-OPEN-2021-004, p. 12. residual_vent: float = self.data_registry.ventilation['infiltration_ventilation'] # type: ignore - infiltration_ventilation = models.AirChange(active=always_on, air_exch=residual_vent) + infiltration_ventilation = models.AirChange( + active=always_on, air_exch=residual_vent) if self.hepa_option: - hepa = models.HEPAFilter(active=always_on, q_air_mech=self.hepa_amount) + hepa = models.HEPAFilter( + active=always_on, q_air_mech=self.hepa_amount) return models.MultipleVentilation((ventilation, hepa, infiltration_ventilation)) else: return models.MultipleVentilation((ventilation, infiltration_ventilation)) - def nearest_weather_station(self) -> caimira.data.weather.WxStationRecordType: + def nearest_weather_station(self) -> data.weather.WxStationRecordType: """Return the nearest weather station (which has valid data) for this form""" - return caimira.data.weather.nearest_wx_station( + return data.weather.nearest_wx_station( longitude=self.location_longitude, latitude=self.location_latitude ) @@ -397,11 +434,13 @@ def mask(self) -> models.Mask: return mask def generate_precise_activity_expiration(self) -> typing.Tuple[typing.Any, ...]: - if self.precise_activity == {}: # It means the precise activity is not defined by a specific input. + # It means the precise activity is not defined by a specific input. + if self.precise_activity == {}: return () respiratory_dict = {} for respiratory_activity in self.precise_activity['respiratory_activity']: - respiratory_dict[respiratory_activity['type']] = respiratory_activity['percentage'] + respiratory_dict[respiratory_activity['type'] + ] = respiratory_activity['percentage'] return (self.precise_activity['physical_activity'], respiratory_dict) @@ -409,11 +448,14 @@ def infected_population(self) -> mc.InfectedPopulation: # Initializes the virus virus = virus_distributions(self.data_registry)[self.virus_type] - activity_defn = self.data_registry.population_scenario_activity[self.activity_type]['activity'] - expiration_defn = self.data_registry.population_scenario_activity[self.activity_type]['expiration'] + activity_defn = self.data_registry.population_scenario_activity[ + self.activity_type]['activity'] + expiration_defn = self.data_registry.population_scenario_activity[ + self.activity_type]['expiration'] if (self.activity_type == 'smallmeeting'): # Conversation of N people is approximately 1/N% of the time speaking. - expiration_defn = {'Speaking': 1, 'Breathing': self.total_people - 1} + expiration_defn = {'Speaking': 1, + 'Breathing': self.total_people - 1} elif (self.activity_type == 'precise'): activity_defn, expiration_defn = self.generate_precise_activity_expiration() @@ -430,7 +472,8 @@ def infected_population(self) -> mc.InfectedPopulation: mask=self.mask(), activity=activity, expiration=expiration, - host_immunity=0., # Vaccination status does not affect the infected population (for now) + # Vaccination status does not affect the infected population (for now) + host_immunity=0., ) return infected @@ -448,8 +491,8 @@ def exposed_population(self) -> mc.Population: if (self.vaccine_option): if (self.vaccine_booster_option and self.vaccine_booster_type != 'Other'): host_immunity = [vaccine['VE'] for vaccine in data.vaccine_booster_host_immunity if - vaccine['primary series vaccine'] == self.vaccine_type and - vaccine['booster vaccine'] == self.vaccine_booster_type][0] + vaccine['primary series vaccine'] == self.vaccine_type and + vaccine['booster vaccine'] == self.vaccine_booster_type][0] else: host_immunity = data.vaccine_primary_host_immunity[self.vaccine_type] else: @@ -476,9 +519,10 @@ def build_expiration(data_registry, expiration_definition) -> mc._ExpirationBase elif isinstance(expiration_definition, dict): total_weight = sum(expiration_definition.values()) BLO_factors = np.sum([ - np.array(expiration_BLO_factors(data_registry)[exp_type]) * weight/total_weight + np.array(expiration_BLO_factors(data_registry) + [exp_type]) * weight/total_weight for exp_type, weight in expiration_definition.items() - ], axis=0) + ], axis=0) return expiration_distribution(data_registry=data_registry, BLO_factors=tuple(BLO_factors)) @@ -521,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': calculator.__version__, + 'calculator_version': '4.17.0', #TODO different version for API and calculator form? 'opening_distance': '0.2', 'event_month': 'January', 'room_heating_option': '0', @@ -547,4 +591,5 @@ def baseline_raw_form_data() -> typing.Dict[str, typing.Union[str, float]]: 'short_range_interactions': '[]', } + cast_class_fields(VirusFormData) 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/enums.py b/caimira/enums.py deleted file mode 100644 index 6a776e2c..00000000 --- a/caimira/enums.py +++ /dev/null @@ -1,13 +0,0 @@ -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" 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/caimira/store/data_registry.py b/caimira/store/data_registry.py deleted file mode 100644 index e1074587..00000000 --- a/caimira/store/data_registry.py +++ /dev/null @@ -1,476 +0,0 @@ -from caimira.enums import ViralLoads, InfectiousDoses, ViableToRNARatios - - -class DataRegistry: - """Registry to hold data values.""" - - 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, - }, - } - activity_distributions = { - "Seated": { - "inhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": -0.6872121723362303, - "lognormal_standard_deviation_gaussian": 0.10498338229297108, - }, - }, - "exhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": -0.6872121723362303, - "lognormal_standard_deviation_gaussian": 0.10498338229297108, - }, - }, - }, - "Standing": { - "inhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": -0.5742377578494785, - "lognormal_standard_deviation_gaussian": 0.09373162411398223, - }, - }, - "exhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": -0.5742377578494785, - "lognormal_standard_deviation_gaussian": 0.09373162411398223, - }, - }, - }, - "Light activity": { - "inhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": 0.21380242785625422, - "lognormal_standard_deviation_gaussian": 0.09435378091059601, - }, - }, - "exhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": 0.21380242785625422, - "lognormal_standard_deviation_gaussian": 0.09435378091059601, - }, - }, - }, - "Moderate activity": { - "inhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": 0.551771330362601, - "lognormal_standard_deviation_gaussian": 0.1894616357138137, - }, - }, - "exhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": 0.551771330362601, - "lognormal_standard_deviation_gaussian": 0.1894616357138137, - }, - }, - }, - "Heavy exercise": { - "inhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": 1.1644665696723049, - "lognormal_standard_deviation_gaussian": 0.21744554768657565, - }, - }, - "exhalation_rate": { - "associated_distribution": "Log-normal", - "parameters": { - "lognormal_mean_gaussian": 1.1644665696723049, - "lognormal_standard_deviation_gaussian": 0.21744554768657565, - }, - }, - }, - } - 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, - }, - } - mask_distributions = { - "Type I": { - "η_inhale": { - "associated_distribution": "Uniform", - "parameters": { - "low": 0.25, - "high": 0.80, - }, - }, - "Known filtration efficiency of masks when exhaling?": "No", - "factor_exhale": 1, - }, - "FFP2": { - "η_inhale": { - "associated_distribution": "Uniform", - "parameters": { - "low": 0.83, - "high": 0.91, - }, - }, - "Known filtration efficiency of masks when exhaling?": "No", - "factor_exhale": 1, - }, - "Cloth": { - "η_inhale": { - "associated_distribution": "Uniform", - "parameters": { - "low": 0.05, - "high": 0.40, - }, - }, - "Known filtration efficiency of masks when exhaling?": "Yes", - "η_exhale": { - "associated_distribution": "Uniform", - "parameters": { - "low": 0.20, - "high": 0.50, - }, - }, - "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, - }, - } - ventilation = { - "natural": { - "discharge_factor": { - "sliding": 0.6, - }, - }, - "infiltration_ventilation": 0.25, - } - particle = { - "evaporation_factor": 0.3, - } - population_with_virus = { - "fraction_of_infectious_virus": 1, - } - concentration_model = { - "min_background_concentration": 0.0, - "CO2_concentration_model": { - "CO2_atmosphere_concentration": 440.44, - "CO2_fraction_exhaled": 0.042, - }, - } - short_range_model = { - "dilution_factor": { - "mouth_diameter": 0.02, - "exhalation_coefficient": 2, - "tstar": 2, - "penetration_coefficients": { - "𝛽r1": 0.18, - "𝛽r2": 0.2, - "𝛽x1": 2.4, - }, - }, - } - 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 - population_scenario_activity = { - "office": {"placeholder": "Office", "activity": "Seated", "expiration": {"Speaking": 1, "Breathing": 2}}, - "smallmeeting": { - "placeholder": "Small meeting (<10 occ.)", - "activity": "Seated", - "expiration": {"Speaking": 1}, - }, - "largemeeting": { - "placeholder": "Large meeting (>= 10 occ.)", - "activity": "Standing", - "expiration": {"Speaking": 1, "Breathing": 2}, - }, - "callcenter": {"placeholder": "Call Center", "activity": "Seated", "expiration": {"Speaking": 1}}, - "controlroom-day": { - "placeholder": "Control Room - Day shift", - "activity": "Seated", - "expiration": {"Speaking": 1, "Breathing": 1}, - }, - "controlroom-night": { - "placeholder": "Control Room - Night shift", - "activity": "Seated", - "expiration": {"Speaking": 1, "Breathing": 9}, - }, - "library": {"placeholder": "Library", "activity": "Seated", "expiration": {"Breathing": 1}}, - "lab": { - "placeholder": "Lab", - "activity": "Light activity", - "expiration": {"Speaking": 1, "Breathing": 1}, - }, - "workshop": { - "placeholder": "Workshop", - "activity": "Moderate activity", - "expiration": {"Speaking": 1, "Breathing": 1}, - }, - "training": {"placeholder": "Conference/Training (speaker infected)", "activity": "Standing", "expiration": {"Speaking": 1}}, - "training_attendee": {"placeholder": "Conference/Training (attendee infected)", "activity": "Seated", "expiration": {"Breathing": 1}}, - "gym": {"placeholder": "Gym", "activity": "Heavy exercise", "expiration": {"Breathing": 1}}, - "household-day": { - "placeholder": "Household (day time)", - "activity": "Light activity", - "expiration": {"Breathing": 5, "Speaking": 5}, - }, - "household-night": { - "placeholder": "Household (evening and night time)", - "activity": "Seated", - "expiration": {"Breathing": 7, "Speaking": 3}, - }, - "primary-school": { - "placeholder": "Primary school", - "activity": "Light activity", - "expiration": {"Breathing": 5, "Speaking": 5}, - }, - "secondary-school": { - "placeholder": "Primary school", - "activity": "Light activity", - "expiration": {"Breathing": 7, "Speaking": 3}, - }, - "university": { - "placeholder": "University", - "activity": "Seated", - "expiration": {"Breathing": 9, "Speaking": 1}, - }, - "restaurant": { - "placeholder": "Restaurant", - "activity": "Seated", - "expiration": {"Breathing": 1, "Speaking": 9}, - }, - "precise": {"placeholder": "Precise", "activity": "", "expiration": {}}, - } - - def to_dict(self): - # Filter out methods, special attributes, and non-serializable objects - data_dict = { - key: value - for key, value in self.__class__.__dict__.items() - if not key.startswith("__") and not callable(value) and not isinstance(value, (type, classmethod, staticmethod)) - } - return data_dict - - def update(self, data, version=None): - """Update local cache with data provided as argument.""" - for attr_name, value in data.items(): - setattr(self, attr_name, value) - - if version: - self.version = version diff --git a/caimira/tests/apps/calculator/conftest.py b/caimira/tests/apps/calculator/conftest.py deleted file mode 100644 index 38c19e97..00000000 --- a/caimira/tests/apps/calculator/conftest.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - -from caimira.apps.calculator import model_generator - - -@pytest.fixture -def baseline_form_data(): - return model_generator.baseline_raw_form_data() - - -@pytest.fixture -def baseline_form(baseline_form_data, data_registry): - return model_generator.VirusFormData.from_dict(baseline_form_data, data_registry) diff --git a/caimira/tests/apps/calculator/test_report_generator.py b/caimira/tests/apps/calculator/test_report_generator.py deleted file mode 100644 index 22355c5d..00000000 --- a/caimira/tests/apps/calculator/test_report_generator.py +++ /dev/null @@ -1,92 +0,0 @@ -import concurrent.futures -from functools import partial -import os -import time - -import numpy as np -import pytest - -from caimira.apps.calculator import make_app -from caimira.apps.calculator.report_generator import ReportGenerator, readable_minutes -import caimira.apps.calculator.report_generator as rep_gen - - -def test_generate_report(baseline_form) -> None: - # This is a simple test that confirms that given a model, we can actually - # generate a report for it. Because this is what happens in the caimira - # calculator, we confirm that the generation happens within a reasonable - # time threshold. - time_limit: float = float(os.environ.get("CAIMIRA_TESTS_CALCULATOR_TIMEOUT", 10.)) - - start = time.perf_counter() - - generator: ReportGenerator = make_app().settings['report_generator'] - report = generator.build_report("", baseline_form, partial( - concurrent.futures.ThreadPoolExecutor, 1, - )) - end = time.perf_counter() - total = end-start - print(f"Time limit: {time_limit} | Time taken: {end} - {start} = {total} < {time_limit}") - assert report != "" - assert end - start < time_limit - - -@pytest.mark.parametrize( - ["test_input", "expected"], - [ - [1, '1 minute'], - [2, '2 minutes'], - [60, '1 hour'], - [120, '2 hours'], - [150, '150 minutes'], - ], -) -def test_readable_minutes(test_input, expected): - assert readable_minutes(test_input) == expected - - -def test_fill_big_gaps(): - expected = [1, 1.75, 2, 2.75, 3.5, 4] - assert rep_gen.fill_big_gaps([1, 2, 4], gap_size=0.75) == expected - - -def test_fill_big_gaps__float_tolerance(): - # Ensure that there is some float tolerance to the gap size check. - assert rep_gen.fill_big_gaps([0, 2 + 1e-15, 4], gap_size=2) == [0, 2 + 1e-15, 4] - assert rep_gen.fill_big_gaps([0, 2 + 1e-14, 4], gap_size=2) == [0, 2, 2 + 1e-14, 4] - - -def test_non_temp_transition_times(baseline_exposure_model): - expected = [0.0, 4.0, 5.0, 8.0] - result = rep_gen.non_temp_transition_times(baseline_exposure_model) - assert result == expected - - -def test_interesting_times_many(baseline_exposure_model): - result = rep_gen.interesting_times(baseline_exposure_model, approx_n_pts=100) - assert 100 <= len(result) <= 120 - assert np.abs(np.diff(result)).max() < 8.1/100. - - -def test_interesting_times_small(baseline_exposure_model): - expected = [0.0, 0.8, 1.6, 2.4, 3.2, 4.0, 4.8, 5.0, 5.8, 6.6, 7.4, 8.0] - # Ask for more data than there is in the transition times. - result = rep_gen.interesting_times(baseline_exposure_model, approx_n_pts=10) - - np.testing.assert_allclose(result, expected, atol=1e-04) - - -def test_interesting_times_w_temp(exposure_model_w_outside_temp_changes): - # Ensure that the state change times are returned (minus the temperature changes) by - # requesting n_points=1. - result = rep_gen.interesting_times(exposure_model_w_outside_temp_changes, approx_n_pts=1) - expected = [0., 1.8, 2.2, 4., 4.4, 5., 6.2, 6.6, 8.] - np.testing.assert_allclose(result, expected) - - # Now request more than the state-change times. - result = rep_gen.interesting_times(exposure_model_w_outside_temp_changes, approx_n_pts=20) - expected = [ - 0., 0.4, 0.8, 1.2, 1.6, 1.8, 2.2, 2.6, 3., 3.4, 3.8, 4., 4.4, 4.8, - 5., 5.4, 5.8, 6.2, 6.6, 7., 7.4, 7.8, 8. - ] - np.testing.assert_allclose(result, expected) diff --git a/caimira/tests/apps/calculator/test_report_json.py b/caimira/tests/apps/calculator/test_report_json.py deleted file mode 100644 index 95cb60a1..00000000 --- a/caimira/tests/apps/calculator/test_report_json.py +++ /dev/null @@ -1,32 +0,0 @@ -import json - -import tornado.testing - -import caimira.apps.calculator -from caimira.apps.calculator import model_generator - -_TIMEOUT = 40. - - -class TestCalculatorJsonResponse(tornado.testing.AsyncHTTPTestCase): - def setUp(self): - super().setUp() - self.http_client.defaults['request_timeout'] = _TIMEOUT - - def get_app(self): - return caimira.apps.calculator.make_app() - - @tornado.testing.gen_test(timeout=_TIMEOUT) - def test_json_response(self): - response = yield self.http_client.fetch( - request=self.get_url("/calculator/report-json"), - method="POST", - headers={'content-type': 'application/json'}, - body=json.dumps(model_generator.baseline_raw_form_data()) - ) - self.assertEqual(response.code, 200) - - data = json.loads(response.body) - self.assertIsInstance(data['prob_inf'], float) - self.assertIsInstance(data['expected_new_cases'], float) - diff --git a/caimira/tests/models/test_virus.py b/caimira/tests/models/test_virus.py deleted file mode 100644 index 060ae6af..00000000 --- a/caimira/tests/models/test_virus.py +++ /dev/null @@ -1,25 +0,0 @@ -import numpy as np -import numpy.testing as npt -import pytest - -from caimira import models - - -@pytest.mark.parametrize( - "inside_temp, humidity, expected_halflife, expected_decay_constant", - [ - [293.15, 0.5, 0.5947447349860315, 1.1654532436949188], - [272.15, 0.7, 1.6070844193207476, 0.4313072619127947], - [300.15, 1., 0.17367078830147223, 3.9911558376571805], - [300.15, 0., 6.43, 0.10779893943389507], - [np.array([272.15, 300.15]), np.array([0.7, 0.]), - np.array([1.60708442, 6.43]), np.array([0.43130726, 0.10779894])], - [np.array([293.15, 300.15]), np.array([0.5, 1.]), - np.array([0.59474473, 0.17367079]), np.array([1.16545324, 3.99115584])] - ], -) -def test_decay_constant(inside_temp, humidity, expected_halflife, expected_decay_constant): - npt.assert_almost_equal(models.Virus.types['SARS_CoV_2'].halflife(humidity, inside_temp), - expected_halflife) - npt.assert_almost_equal(models.Virus.types['SARS_CoV_2'].decay_constant(humidity, inside_temp), - expected_decay_constant) \ No newline at end of file diff --git a/caimira/tests/test_caimira.py b/caimira/tests/test_caimira.py deleted file mode 100644 index c99882be..00000000 --- a/caimira/tests/test_caimira.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -High-level tests for the package. - -""" - -import caimira - - -def test_version(): - assert caimira.__version__ is not None diff --git a/caimira/tests/test_monte_carlo.py b/caimira/tests/test_monte_carlo.py deleted file mode 100644 index 656450ea..00000000 --- a/caimira/tests/test_monte_carlo.py +++ /dev/null @@ -1,102 +0,0 @@ -import dataclasses - -import numpy as np -import pytest - -import caimira.models -import caimira.monte_carlo.models as mc_models -import caimira.monte_carlo.sampleable - -MODEL_CLASSES = [ - cls for cls in vars(caimira.models).values() - if dataclasses.is_dataclass(cls) -] - - -def test_type_annotations(): - # Check that there are appropriate type annotations for all of the model - # classes in caimira.models. Note that these must be statically defined in - # caimira.monte_carlo, rather than being dynamically generated, in order to - # allow the type system to be able to see their definition without needing - # runtime execution. - missing = [] - for cls in MODEL_CLASSES: - if not hasattr(caimira.monte_carlo, cls.__name__): - missing.append(cls.__name__) - continue - mc_cls = getattr(caimira.monte_carlo, cls.__name__) - assert issubclass(mc_cls, caimira.monte_carlo.MCModelBase) - - if missing: - msg = ( - 'There are missing model implementations in caimira.monte_carlo. ' - 'The following definitions are needed:\n ' + - '\n '.join([f'{model} = build_mc_model(caimira.models.{model})' for model in missing]) - ) - pytest.fail(msg) - - -@pytest.fixture -def baseline_mc_concentration_model(data_registry) -> caimira.monte_carlo.ConcentrationModel: - mc_model = caimira.monte_carlo.ConcentrationModel( - data_registry=data_registry, - room=caimira.monte_carlo.Room(volume=caimira.monte_carlo.sampleable.Normal(75, 20), - inside_temp=caimira.models.PiecewiseConstant((0., 24.), (293,))), - ventilation=caimira.monte_carlo.SlidingWindow( - data_registry=data_registry, - active=caimira.models.PeriodicInterval(period=120, duration=120), - outside_temp=caimira.models.PiecewiseConstant((0., 24.), (283,)), - window_height=1.6, opening_length=0.6, - ), - infected=caimira.models.InfectedPopulation( - data_registry=data_registry, - number=1, - virus=caimira.models.Virus.types['SARS_CoV_2'], - presence=caimira.models.SpecificInterval(((0., 4.), (5., 8.))), - mask=caimira.models.Mask.types['No mask'], - activity=caimira.models.Activity.types['Light activity'], - expiration=caimira.models.Expiration.types['Breathing'], - host_immunity=0., - ), - evaporation_factor=0.3, - ) - return mc_model - - -@pytest.fixture -def baseline_mc_sr_model() -> caimira.monte_carlo.ShortRangeModel: - return () - - -@pytest.fixture -def baseline_mc_exposure_model(data_registry, baseline_mc_concentration_model, baseline_mc_sr_model) -> caimira.monte_carlo.ExposureModel: - return caimira.monte_carlo.ExposureModel( - data_registry, - baseline_mc_concentration_model, - baseline_mc_sr_model, - exposed=caimira.models.Population( - number=10, - presence=baseline_mc_concentration_model.infected.presence, - activity=baseline_mc_concentration_model.infected.activity, - mask=baseline_mc_concentration_model.infected.mask, - host_immunity=0., - ), - geographical_data=caimira.models.Cases(), - ) - - -def test_build_concentration_model(baseline_mc_concentration_model: caimira.monte_carlo.ConcentrationModel): - model = baseline_mc_concentration_model.build_model(7) - assert isinstance(model, caimira.models.ConcentrationModel) - assert isinstance(model.concentration(time=0.), float) - conc = model.concentration(time=1.) - assert isinstance(conc, np.ndarray) - assert conc.shape == (7, ) - - -def test_build_exposure_model(baseline_mc_exposure_model: caimira.monte_carlo.ExposureModel): - model = baseline_mc_exposure_model.build_model(7) - assert isinstance(model, caimira.models.ExposureModel) - prob = model.deposited_exposure() - assert isinstance(prob, np.ndarray) - assert prob.shape == (7, ) 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/__init__.py b/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ui/apps/__.init__.py b/ui/apps/__.init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/apps/calculator/__init__.py b/ui/apps/calculator/__init__.py similarity index 92% rename from caimira/apps/calculator/__init__.py rename to ui/apps/calculator/__init__.py index be8de7ab..b16b7c7a 100644 --- a/caimira/apps/calculator/__init__.py +++ b/ui/apps/calculator/__init__.py @@ -25,14 +25,17 @@ from tornado.web import Application, RequestHandler, StaticFileHandler from tornado.httpclient import AsyncHTTPClient, HTTPRequest import tornado.log -from caimira.profiler import CaimiraProfiler, Profilers -from caimira.store.data_registry import DataRegistry +from caimira.calculator.models.profiler import CaimiraProfiler, Profilers +from caimira.calculator.store.data_registry import DataRegistry +from caimira.calculator.store.data_service import DataService -from caimira.store.data_service import DataService +from caimira.api.controller.report_controller import generate_form_obj, generate_model, generate_report_results +from caimira.calculator.report.report_generator import calculate_report_data +from caimira.calculator.validators.virus import virus_validator +from caimira.calculator.validators.co2 import co2_validator +from .report import ReportGenerator from . import markdown_tools -from . import model_generator, co2_model_generator -from .report_generator import ReportGenerator, calculate_report_data from .user import AuthenticatedUser, AnonymousUser # The calculator version is based on a combination of the model version and the @@ -42,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.15.1" +__version__ = "4.17.0" LOG = logging.getLogger("Calculator") @@ -177,27 +180,27 @@ async def post(self) -> None: LOG.debug(pformat(requested_model_config)) try: - form = model_generator.VirusFormData.from_dict(requested_model_config, data_registry) + form = generate_form_obj(requested_model_config, data_registry) + model = generate_model(form) + report_data = generate_report_results(form, model) + except Exception as err: LOG.exception(err) response_json = {'code': 400, 'error': f'Your request was invalid {html.escape(str(err))}'} self.set_status(400) self.finish(json.dumps(response_json)) return - + base_url = self.request.protocol + "://" + self.request.host report_generator: ReportGenerator = self.settings['report_generator'] executor = loky.get_reusable_executor( max_workers=self.settings['handler_worker_pool_size'], timeout=300, ) - # Re-generate the report with the conditional probability of infection plot - if self.get_cookie('conditional_plot'): - form.conditional_probability_plot = True if self.get_cookie('conditional_plot') == '1' else False - self.clear_cookie('conditional_plot') # Clears cookie after changing the form value. report_task = executor.submit( report_generator.build_report, base_url, form, + model, report_data, executor_factory=functools.partial( concurrent.futures.ThreadPoolExecutor, self.settings['report_generation_parallelism'], @@ -231,7 +234,7 @@ async def post(self) -> None: LOG.debug(pformat(requested_model_config)) try: - form = model_generator.VirusFormData.from_dict(requested_model_config, data_registry) + form = generate_form_obj(requested_model_config, data_registry) except Exception as err: LOG.exception(err) response_json = {'code': 400, 'error': f'Your request was invalid {html.escape(str(err))}'} @@ -243,7 +246,7 @@ async def post(self) -> None: max_workers=self.settings['handler_worker_pool_size'], timeout=300, ) - model = form.build_model() + model = generate_model(form) report_data_task = executor.submit(calculate_report_data, form, model, executor_factory=functools.partial( concurrent.futures.ThreadPoolExecutor, @@ -262,14 +265,17 @@ async def get(self) -> None: if data_service: data_service.update_registry(data_registry) - form = model_generator.VirusFormData.from_dict(model_generator.baseline_raw_form_data(), data_registry) + form = generate_form_obj(virus_validator.baseline_raw_form_data(), data_registry) + model = generate_model(form) + report_data = generate_report_results(form, model) + base_url = self.request.protocol + "://" + self.request.host report_generator: ReportGenerator = self.settings['report_generator'] executor = loky.get_reusable_executor(max_workers=self.settings['handler_worker_pool_size']) report_task = executor.submit( report_generator.build_report, base_url, form, - executor_factory=functools.partial( + model, report_data, executor_factory=functools.partial( concurrent.futures.ThreadPoolExecutor, self.settings['report_generation_parallelism'], ), @@ -434,13 +440,6 @@ def get(self): class CO2ModelResponse(BaseRequestHandler): - def check_xsrf_cookie(self): - """ - This request handler implements a stateless API that returns report data in JSON format. - Thus, XSRF cookies are disabled by overriding base class implementation of this method with a pass statement. - """ - pass - async def post(self, endpoint: str) -> None: data_registry: DataRegistry = self.settings["data_registry"] data_service: typing.Optional[DataService] = self.settings.get("data_service", None) @@ -449,7 +448,7 @@ async def post(self, endpoint: str) -> None: requested_model_config = tornado.escape.json_decode(self.request.body) try: - form = co2_model_generator.CO2FormData.from_dict(requested_model_config, data_registry) + form = co2_validator.CO2FormData.from_dict(requested_model_config, data_registry) except Exception as err: if self.settings.get("debug", False): import traceback @@ -460,8 +459,8 @@ async def post(self, endpoint: str) -> None: return if endpoint.rstrip('/') == 'plot': - transition_times = co2_model_generator.CO2FormData.find_change_points_with_pelt(form.CO2_data) - self.finish({'CO2_plot': co2_model_generator.CO2FormData.generate_ventilation_plot(form.CO2_data, transition_times), + transition_times = co2_validator.CO2FormData.find_change_points_with_pelt(form.CO2_data) + self.finish({'CO2_plot': co2_validator.CO2FormData.generate_ventilation_plot(form.CO2_data, transition_times), 'transition_times': [round(el, 2) for el in transition_times]}) else: executor = loky.get_reusable_executor( @@ -469,7 +468,7 @@ async def post(self, endpoint: str) -> None: timeout=300, ) report_task = executor.submit( - co2_model_generator.CO2FormData.build_model, form, + co2_validator.CO2FormData.build_model, form, ) report = await asyncio.wrap_future(report_task) @@ -478,7 +477,7 @@ async def post(self, endpoint: str) -> None: result['fitting_ventilation_type'] = form.fitting_ventilation_type result['transition_times'] = ventilation_transition_times - result['CO2_plot'] = co2_model_generator.CO2FormData.generate_ventilation_plot(CO2_data=form.CO2_data, + result['CO2_plot'] = co2_validator.CO2FormData.generate_ventilation_plot(CO2_data=form.CO2_data, transition_times=ventilation_transition_times[:-1], predictive_CO2=result['predictive_CO2']) self.finish(result) @@ -524,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)) @@ -573,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/caimira/apps/calculator/__main__.py b/ui/apps/calculator/__main__.py similarity index 100% rename from caimira/apps/calculator/__main__.py rename to ui/apps/calculator/__main__.py diff --git a/caimira/apps/calculator/markdown_tools.py b/ui/apps/calculator/markdown_tools.py similarity index 100% rename from caimira/apps/calculator/markdown_tools.py rename to ui/apps/calculator/markdown_tools.py diff --git a/caimira/apps/calculator/report_generator.py b/ui/apps/calculator/report.py similarity index 51% rename from caimira/apps/calculator/report_generator.py rename to ui/apps/calculator/report.py index fae89ef5..0ab7ea3a 100644 --- a/caimira/apps/calculator/report_generator.py +++ b/ui/apps/calculator/report.py @@ -1,24 +1,38 @@ -import concurrent.futures -import base64 -import dataclasses from datetime import datetime -import io +import dataclasses + +import concurrent.futures import json import typing +import jinja2 +import numpy as np import urllib +import base64 import zlib -import jinja2 -import numpy as np -import matplotlib.pyplot as plt +from . import markdown_tools + +from caimira.calculator.validators.virus.virus_validator import VirusFormData +from caimira.calculator.models import dataclass_utils, models, monte_carlo as mc + + +def generate_permalink(base_url, get_root_url, get_root_calculator_url, form: VirusFormData): + form_dict = VirusFormData.to_dict(form, strip_defaults=True) + + # Generate the calculator URL arguments that would be needed to re-create this + # form. + args = urllib.parse.urlencode(form_dict) + + # Then zlib compress + base64 encode the string. To be inverted by the + # /_c/ endpoint. + compressed_args = base64.b64encode(zlib.compress(args.encode())).decode() + qr_url = f"{base_url}{get_root_url()}/_c/{compressed_args}" + url = f"{base_url}{get_root_calculator_url()}?{args}" -from caimira import models -from caimira.apps.calculator import markdown_tools -from caimira.profiler import profile -from caimira.store.data_registry import DataRegistry -from ... import monte_carlo as mc -from .model_generator import VirusFormData -from ... import dataclass_utils + return { + 'link': url, + 'shortened': qr_url, + } def model_start_end(model: models.ExposureModel): @@ -95,7 +109,8 @@ def interesting_times(model: models.ExposureModel, approx_n_pts: typing.Optional """ times = non_temp_transition_times(model) sim_duration = max(times) - min(times) - if not approx_n_pts: approx_n_pts = sim_duration * 15 + if not approx_n_pts: + approx_n_pts = sim_duration * 15 # Expand the times list to ensure that we have a maximum gap size between # the key times. @@ -103,286 +118,16 @@ def interesting_times(model: models.ExposureModel, approx_n_pts: typing.Optional return nice_times -def concentrations_with_sr_breathing(form: VirusFormData, model: models.ExposureModel, times: typing.List[float], short_range_intervals: typing.List) -> typing.List[float]: - lower_concentrations = [] - for time in times: - for index, (start, stop) in enumerate(short_range_intervals): - # For visualization issues, add short-range breathing activity to the initial long-range concentrations - if start <= time <= stop and form.short_range_interactions[index]['expiration'] == 'Breathing': - lower_concentrations.append(np.array(model.concentration(float(time))).mean()) - break - lower_concentrations.append(np.array(model.concentration_model.concentration(float(time))).mean()) - return lower_concentrations - -def _calculate_deposited_exposure(model, time1, time2, fn_name=None): - return np.array(model.deposited_exposure_between_bounds(float(time1), float(time2))).mean(),fn_name - -def _calculate_long_range_deposited_exposure(model, time1, time2, fn_name=None): - return np.array(model.long_range_deposited_exposure_between_bounds(float(time1), float(time2))).mean(), fn_name - -def _calculate_co2_concentration(CO2_model, time, fn_name=None): - return np.array(CO2_model.concentration(float(time))).mean(), fn_name - -@profile -def calculate_report_data(form: VirusFormData, model: models.ExposureModel, executor_factory: typing.Callable[[], concurrent.futures.Executor]) -> typing.Dict[str, typing.Any]: - times = interesting_times(model) - short_range_intervals = [interaction.presence.boundaries()[0] for interaction in model.short_range] - short_range_expirations = [interaction['expiration'] for interaction in form.short_range_interactions] if form.short_range_option == "short_range_yes" else [] - - concentrations = [ - np.array(model.concentration(float(time))).mean() - for time in times - ] - lower_concentrations = concentrations_with_sr_breathing(form, model, times, short_range_intervals) - - CO2_model: models.CO2ConcentrationModel = form.build_CO2_model() - - # compute deposited exposures and CO2 concentrations in parallel to increase performance - deposited_exposures = [] - long_range_deposited_exposures = [] - CO2_concentrations = [] - - tasks = [] - with executor_factory() as executor: - for time1, time2 in zip(times[:-1], times[1:]): - tasks.append(executor.submit(_calculate_deposited_exposure, model, time1, time2, fn_name="de")) - tasks.append(executor.submit(_calculate_long_range_deposited_exposure, model, time1, time2, fn_name="lr")) - # co2 concentration: takes each time as param, not the interval - tasks.append(executor.submit(_calculate_co2_concentration, CO2_model, time1, fn_name="co2")) - # co2 concentration: calculate the last time too - tasks.append(executor.submit(_calculate_co2_concentration, CO2_model, times[-1], fn_name="co2")) - - for task in tasks: - result, fn_name = task.result() - if fn_name == "de": - deposited_exposures.append(result) - elif fn_name == "lr": - long_range_deposited_exposures.append(result) - elif fn_name == "co2": - CO2_concentrations.append(result) - - cumulative_doses = np.cumsum(deposited_exposures) - long_range_cumulative_doses = np.cumsum(long_range_deposited_exposures) - - prob = np.array(model.infection_probability()) - 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))} - - - return { - "model_repr": repr(model), - "times": list(times), - "exposed_presence_intervals": exposed_presence_intervals, - "short_range_intervals": short_range_intervals, - "short_range_expirations": short_range_expirations, - "concentrations": concentrations, - "concentrations_zoomed": lower_concentrations, - "cumulative_doses": list(cumulative_doses), - "long_range_cumulative_doses": list(long_range_cumulative_doses), - "prob_inf": prob.mean(), - "prob_inf_sd": prob.std(), - "prob_dist": list(prob), - "prob_hist_count": list(prob_dist_count), - "prob_hist_bins": list(prob_dist_bins), - "prob_probabilistic_exposure": prob_probabilistic_exposure, - "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)), - "conditional_probability_data": conditional_probability_data, - } - - -def generate_permalink(base_url, get_root_url, get_root_calculator_url, form: VirusFormData): - form_dict = VirusFormData.to_dict(form, strip_defaults=True) - - # Generate the calculator URL arguments that would be needed to re-create this - # form. - args = urllib.parse.urlencode(form_dict) - - # Then zlib compress + base64 encode the string. To be inverted by the - # /_c/ endpoint. - compressed_args = base64.b64encode(zlib.compress(args.encode())).decode() - qr_url = f"{base_url}{get_root_url()}/_c/{compressed_args}" - url = f"{base_url}{get_root_calculator_url()}?{args}" - - return { - 'link': url, - 'shortened': qr_url, - } - - -def conditional_prob_inf_given_vl_dist( - data_registry: DataRegistry, - infection_probability: models._VectorisedFloat, - viral_loads: np.ndarray, - specific_vl: float, - step: models._VectorisedFloat - ): - - pi_means = [] - lower_percentiles = [] - upper_percentiles = [] - - 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'])) - - return pi_means, lower_percentiles, upper_percentiles - - -def manufacture_conditional_probability_data( - exposure_model: models.ExposureModel, - infection_probability: models._VectorisedFloat -): - 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'] - 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) - pi_means, lower_percentiles, upper_percentiles = conditional_prob_inf_given_vl_dist(data_registry, infection_probability, viral_loads, - specific_vl, step) - - 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) - - fig, axs = plt.subplots(2, 3, - gridspec_kw={'width_ratios': [5, 0.5] + [1], - 'height_ratios': [3, 1], 'wspace': 0}, - sharey='row', - sharex='col') - - 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, 2].hist(infection_probability, bins=30, orientation='horizontal') - axs[0, 2].set_xticks([]) - axs[0, 2].set_xticklabels([]) - axs[0, 2].set_facecolor("lightgrey") - - highest_bar = axs[0, 2].get_xlim()[1] - 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), - bins=150, range=(2, 10), color='grey') - axs[1, 0].set_facecolor("lightgrey") - axs[1, 0].set_yticks([]) - axs[1, 0].set_yticklabels([]) - axs[1, 0].set_xticks([i for i in range(2, 13, 2)]) - axs[1, 0].set_xticklabels(['$10^{' + str(i) + '}$' for i in range(2, 13, 2)]) - axs[1, 0].set_xlim(2, 10) - axs[1, 0].set_xlabel('Viral load\n(RNA copies)', fontsize=12) - axs[0, 0].set_ylabel('Conditional Probability\nof Infection', fontsize=12) - - axs[0, 0].text(9.5, -0.01, '$(i)$') - axs[1, 0].text(9.5, axs[1, 0].get_ylim()[1] * 0.8, '$(ii)$') - axs[0, 2].set_title('$(iii)$', fontsize=10) - - axs[0, 0].legend() - return fig - - -def _img2bytes(figure): - # Draw the image - img_data = io.BytesIO() - figure.save(img_data, format='png', bbox_inches="tight") - return img_data - - -def _figure2bytes(figure): - # Draw the image - img_data = io.BytesIO() - figure.savefig(img_data, format='png', bbox_inches="tight", transparent=True, dpi=110) - return img_data - - -def img2base64(img_data) -> str: - img_data.seek(0) - pic_hash = base64.b64encode(img_data.read()).decode('ascii') - # A src suitable for a tag such as f'. - return f'data:image/png;base64,{pic_hash}' - - -def minutes_to_time(minutes: int) -> str: - minute_string = str(minutes % 60) - minute_string = "0" * (2 - len(minute_string)) + minute_string - hour_string = str(minutes // 60) - hour_string = "0" * (2 - len(hour_string)) + hour_string - - return f"{hour_string}:{minute_string}" - - -def readable_minutes(minutes: int) -> str: - time = float(minutes) - unit = " minute" - if time % 60 == 0: - time = minutes/60 - unit = " hour" - if time != 1: - unit += "s" - - if time.is_integer(): - time_str = "{:0.0f}".format(time) - else: - time_str = "{0:.2f}".format(time) - - return time_str + unit - - -def hour_format(hour: float) -> str: - # Convert float hour to HH:MM format - hours = int(hour) - minutes = int(hour % 1 * 60) - return f"{hours}:{minutes if minutes != 0 else '00'}" - - -def percentage(absolute: float) -> float: - return absolute * 100 - - -def non_zero_percentage(percentage: int) -> str: - if percentage < 0.01: - return "<0.01%" - elif percentage < 1: - return "{:0.2f}%".format(percentage) - elif percentage > 99.9 or np.isnan(percentage): - return ">99.9%" - else: - return "{:0.1f}%".format(percentage) - - def manufacture_viral_load_scenarios_percentiles(model: mc.ExposureModel) -> typing.Dict[str, mc.ExposureModel]: viral_load = model.concentration_model.infected.virus.viral_load_in_sputum scenarios = {} for percentil in (0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99): vl = np.quantile(viral_load, percentil) specific_vl_scenario = dataclass_utils.nested_replace(model, - {'concentration_model.infected.virus.viral_load_in_sputum': vl} - ) - scenarios[str(vl)] = np.mean(specific_vl_scenario.infection_probability()) + {'concentration_model.infected.virus.viral_load_in_sputum': vl} + ) + scenarios[str(vl)] = np.mean( + specific_vl_scenario.infection_probability()) return scenarios @@ -390,15 +135,19 @@ def manufacture_alternative_scenarios(form: VirusFormData) -> typing.Dict[str, m scenarios = {} if (form.short_range_option == "short_range_no"): # Two special option cases - HEPA and/or FFP2 masks. - FFP2_being_worn = bool(form.mask_wearing_option == 'mask_on' and form.mask_type == 'FFP2') + FFP2_being_worn = bool(form.mask_wearing_option == + 'mask_on' and form.mask_type == 'FFP2') if FFP2_being_worn and form.hepa_option: - FFP2andHEPAalternative = dataclass_utils.replace(form, mask_type='Type I') + FFP2andHEPAalternative = dataclass_utils.replace( + form, mask_type='Type I') if not (form.hepa_option and form.mask_wearing_option == 'mask_on' and form.mask_type == 'Type I'): scenarios['Base scenario with HEPA filter and Type I masks'] = FFP2andHEPAalternative.build_mc_model() if not FFP2_being_worn and form.hepa_option: - noHEPAalternative = dataclass_utils.replace(form, mask_type = 'FFP2') - noHEPAalternative = dataclass_utils.replace(noHEPAalternative, mask_wearing_option = 'mask_on') - noHEPAalternative = dataclass_utils.replace(noHEPAalternative, hepa_option=False) + noHEPAalternative = dataclass_utils.replace(form, mask_type='FFP2') + noHEPAalternative = dataclass_utils.replace( + noHEPAalternative, mask_wearing_option='mask_on') + noHEPAalternative = dataclass_utils.replace( + noHEPAalternative, hepa_option=False) if not (not form.hepa_option and FFP2_being_worn): scenarios['Base scenario without HEPA filter, with FFP2 masks'] = noHEPAalternative.build_mc_model() @@ -408,22 +157,27 @@ def manufacture_alternative_scenarios(form: VirusFormData) -> typing.Dict[str, m if form.hepa_option: form = dataclass_utils.replace(form, hepa_option=False) - with_mask = dataclass_utils.replace(form, mask_wearing_option='mask_on') - without_mask = dataclass_utils.replace(form, mask_wearing_option='mask_off') + with_mask = dataclass_utils.replace( + form, mask_wearing_option='mask_on') + without_mask = dataclass_utils.replace( + form, mask_wearing_option='mask_off') if form.ventilation_type == 'mechanical_ventilation': - #scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model() + # scenarios['Mechanical ventilation with Type I masks'] = with_mask.build_mc_model() if not (form.mask_wearing_option == 'mask_off'): - scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model() + scenarios['Mechanical ventilation without masks'] = without_mask.build_mc_model( + ) elif form.ventilation_type == 'natural_ventilation': - #scenarios['Windows open with Type I masks'] = with_mask.build_mc_model() + # scenarios['Windows open with Type I masks'] = with_mask.build_mc_model() if not (form.mask_wearing_option == 'mask_off'): scenarios['Windows open without masks'] = without_mask.build_mc_model() # No matter the ventilation scheme, we include scenarios which don't have any ventilation. - with_mask_no_vent = dataclass_utils.replace(with_mask, ventilation_type='no_ventilation') - without_mask_or_vent = dataclass_utils.replace(without_mask, ventilation_type='no_ventilation') + with_mask_no_vent = dataclass_utils.replace( + with_mask, ventilation_type='no_ventilation') + without_mask_or_vent = dataclass_utils.replace( + without_mask, ventilation_type='no_ventilation') if not (form.mask_wearing_option == 'mask_on' and form.mask_type == 'Type I' and form.ventilation_type == 'no_ventilation'): scenarios['No ventilation with Type I masks'] = with_mask_no_vent.build_mc_model() @@ -431,7 +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 @@ -442,7 +197,8 @@ def scenario_statistics( sample_times: typing.List[float], compute_prob_exposure: bool ): - model = mc_model.build_model(size=mc_model.data_registry.monte_carlo_sample_size) + model = mc_model.build_model( + 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() @@ -469,7 +225,7 @@ def comparison_report( ): if (form.short_range_option == "short_range_no"): statistics = { - 'Current scenario' : { + 'Current scenario': { 'probability_of_infection': report_data['prob_inf'], 'expected_new_cases': report_data['expected_new_cases'], 'concentrations': report_data['concentrations'], @@ -500,6 +256,54 @@ def comparison_report( } +def minutes_to_time(minutes: int) -> str: + minute_string = str(minutes % 60) + minute_string = "0" * (2 - len(minute_string)) + minute_string + hour_string = str(minutes // 60) + hour_string = "0" * (2 - len(hour_string)) + hour_string + + return f"{hour_string}:{minute_string}" + + +def readable_minutes(minutes: int) -> str: + time = float(minutes) + unit = " minute" + if time % 60 == 0: + time = minutes/60 + unit = " hour" + if time != 1: + unit += "s" + + if time.is_integer(): + time_str = "{:0.0f}".format(time) + else: + time_str = "{0:.2f}".format(time) + + return time_str + unit + + +def hour_format(hour: float) -> str: + # Convert float hour to HH:MM format + hours = int(hour) + minutes = int(hour % 1 * 60) + return f"{hours}:{minutes if minutes != 0 else '00'}" + + +def percentage(absolute: float) -> float: + return absolute * 100 + + +def non_zero_percentage(percentage: int) -> str: + if percentage < 0.01: + return "<0.01%" + elif percentage < 1: + return "{:0.2f}%".format(percentage) + elif percentage > 99.9 or np.isnan(percentage): + return ">99.9%" + else: + return "{:0.1f}%".format(percentage) + + @dataclasses.dataclass class ReportGenerator: jinja_loader: jinja2.BaseLoader @@ -510,17 +314,20 @@ def build_report( self, base_url: str, form: VirusFormData, + model: models.ExposureModel, + report_data: dict, executor_factory: typing.Callable[[], concurrent.futures.Executor], ) -> str: - model = form.build_model() - context = self.prepare_context(base_url, model, form, executor_factory=executor_factory) + context = self.prepare_context( + base_url, form, model, report_data, executor_factory=executor_factory) return self.render(context) def prepare_context( self, base_url: str, - model: models.ExposureModel, form: VirusFormData, + model: models.ExposureModel, + report_data: dict, executor_factory: typing.Callable[[], concurrent.futures.Executor], ) -> dict: now = datetime.utcnow().astimezone() @@ -535,15 +342,16 @@ def prepare_context( } scenario_sample_times = interesting_times(model) - report_data = calculate_report_data(form, model, executor_factory=executor_factory) context.update(report_data) alternative_scenarios = manufacture_alternative_scenarios(form) - context['alternative_viral_load'] = manufacture_viral_load_scenarios_percentiles(model) if form.conditional_probability_viral_loads else None + context['alternative_viral_load'] = manufacture_viral_load_scenarios_percentiles( + model) if form.conditional_probability_viral_loads else None context['alternative_scenarios'] = comparison_report( form, report_data, alternative_scenarios, scenario_sample_times, executor_factory=executor_factory, ) - context['permalink'] = generate_permalink(base_url, self.get_root_url, self.get_root_calculator_url, form) + context['permalink'] = generate_permalink( + base_url, self.get_root_url, self.get_root_calculator_url, form) context['get_url'] = self.get_root_url context['get_calculator_url'] = self.get_root_calculator_url diff --git a/caimira/apps/calculator/static/css/form.css b/ui/apps/calculator/static/css/form.css similarity index 100% rename from caimira/apps/calculator/static/css/form.css rename to ui/apps/calculator/static/css/form.css diff --git a/caimira/apps/calculator/static/css/report.css b/ui/apps/calculator/static/css/report.css similarity index 100% rename from caimira/apps/calculator/static/css/report.css rename to ui/apps/calculator/static/css/report.css diff --git a/caimira/apps/calculator/static/icons/favicon.ico b/ui/apps/calculator/static/icons/favicon.ico similarity index 100% rename from caimira/apps/calculator/static/icons/favicon.ico rename to ui/apps/calculator/static/icons/favicon.ico diff --git a/caimira/apps/calculator/static/images/disclaimer.jpg b/ui/apps/calculator/static/images/disclaimer.jpg similarity index 100% rename from caimira/apps/calculator/static/images/disclaimer.jpg rename to ui/apps/calculator/static/images/disclaimer.jpg diff --git a/caimira/apps/calculator/static/images/warning_scale/green-1.png b/ui/apps/calculator/static/images/warning_scale/green-1.png similarity index 100% rename from caimira/apps/calculator/static/images/warning_scale/green-1.png rename to ui/apps/calculator/static/images/warning_scale/green-1.png diff --git a/caimira/apps/calculator/static/images/warning_scale/orange-3.png b/ui/apps/calculator/static/images/warning_scale/orange-3.png similarity index 100% rename from caimira/apps/calculator/static/images/warning_scale/orange-3.png rename to ui/apps/calculator/static/images/warning_scale/orange-3.png diff --git a/caimira/apps/calculator/static/images/warning_scale/red-4.png b/ui/apps/calculator/static/images/warning_scale/red-4.png similarity index 100% rename from caimira/apps/calculator/static/images/warning_scale/red-4.png rename to ui/apps/calculator/static/images/warning_scale/red-4.png diff --git a/caimira/apps/calculator/static/images/warning_scale/yellow-2.png b/ui/apps/calculator/static/images/warning_scale/yellow-2.png similarity index 100% rename from caimira/apps/calculator/static/images/warning_scale/yellow-2.png rename to ui/apps/calculator/static/images/warning_scale/yellow-2.png diff --git a/caimira/apps/calculator/static/images/window_opening.png b/ui/apps/calculator/static/images/window_opening.png similarity index 100% rename from caimira/apps/calculator/static/images/window_opening.png rename to ui/apps/calculator/static/images/window_opening.png diff --git a/caimira/apps/calculator/static/images/window_type.PNG b/ui/apps/calculator/static/images/window_type.PNG similarity index 100% rename from caimira/apps/calculator/static/images/window_type.PNG rename to ui/apps/calculator/static/images/window_type.PNG diff --git a/caimira/apps/calculator/static/js/co2_form.js b/ui/apps/calculator/static/js/co2_form.js similarity index 97% rename from caimira/apps/calculator/static/js/co2_form.js rename to ui/apps/calculator/static/js/co2_form.js index ef21f010..e846bbcf 100644 --- a/caimira/apps/calculator/static/js/co2_form.js +++ b/ui/apps/calculator/static/js/co2_form.js @@ -312,6 +312,11 @@ function plotCO2Data(url) { fetch(url, { method: "POST", body: JSON.stringify(CO2_mapping), + headers: { + "Content-Type": "application/json", + "X-XSRFToken": document.getElementsByName('_xsrf')[0].value + }, + credentials: "include", }).then((response) => response .json() @@ -347,6 +352,11 @@ function submitFittingAlgorithm(url) { fetch(url, { method: "POST", body: JSON.stringify(CO2_mapping), + headers: { + "Content-Type": "application/json", + "X-XSRFToken": document.getElementsByName('_xsrf')[0].value + }, + credentials: "include", }) .then((response) => response.json()) .then((json_response) => { diff --git a/caimira/apps/calculator/static/js/form.js b/ui/apps/calculator/static/js/form.js similarity index 98% rename from caimira/apps/calculator/static/js/form.js rename to ui/apps/calculator/static/js/form.js index ed28293e..71023c2a 100644 --- a/caimira/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/caimira/apps/calculator/static/js/report.js b/ui/apps/calculator/static/js/report.js similarity index 100% rename from caimira/apps/calculator/static/js/report.js rename to ui/apps/calculator/static/js/report.js diff --git a/ui/apps/calculator/tests/__init__.py b/ui/apps/calculator/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ui/apps/calculator/tests/conftest.py b/ui/apps/calculator/tests/conftest.py new file mode 100644 index 00000000..a611f7bc --- /dev/null +++ b/ui/apps/calculator/tests/conftest.py @@ -0,0 +1,87 @@ +from caimira.calculator.models import models +import caimira.calculator.models.data +import caimira.calculator.models.dataclass_utils + +import pytest + +from caimira.calculator.validators.virus import virus_validator +from caimira.calculator.store.data_registry import DataRegistry + + +@pytest.fixture +def baseline_form_data(): + return virus_validator.baseline_raw_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 data_registry(): + return DataRegistry() + + +@pytest.fixture +def baseline_concentration_model(data_registry): + model = models.ConcentrationModel( + data_registry=data_registry, + room=models.Room( + volume=75, inside_temp=models.PiecewiseConstant((0., 24.), (293,))), + ventilation=models.AirChange( + active=models.SpecificInterval(((0., 24.), )), + air_exch=30., + ), + infected=models.EmittingPopulation( + data_registry=data_registry, + number=1, + virus=models.Virus.types['SARS_CoV_2'], + presence=models.SpecificInterval(((0., 4.), (5., 8.))), + mask=models.Mask.types['No mask'], + activity=models.Activity.types['Light activity'], + known_individual_emission_rate=970 * 50, + host_immunity=0., + # Superspreading event, where ejection factor is fixed based + # on Miller et al. (2020) - 50 represents the infectious dose. + ), + evaporation_factor=0.3, + ) + return model + + +@pytest.fixture +def baseline_sr_model(): + return () + + +@pytest.fixture +def baseline_exposure_model(data_registry, baseline_concentration_model, baseline_sr_model): + return models.ExposureModel( + data_registry=data_registry, + concentration_model=baseline_concentration_model, + short_range=baseline_sr_model, + exposed=models.Population( + number=1000, + presence=baseline_concentration_model.infected.presence, + activity=baseline_concentration_model.infected.activity, + mask=baseline_concentration_model.infected.mask, + host_immunity=0., + ), + geographical_data=models.Cases(), + ) + + +@pytest.fixture +def exposure_model_w_outside_temp_changes(data_registry, baseline_exposure_model: models.ExposureModel): + exp_model = caimira.calculator.models.dataclass_utils.nested_replace( + baseline_exposure_model, { + 'concentration_model.ventilation': models.SlidingWindow( + data_registry=data_registry, + active=models.PeriodicInterval(2.2 * 60, 1.8 * 60), + outside_temp=caimira.calculator.models.data.GenevaTemperatures['Jan'], + window_height=1.6, + opening_length=0.6, + ) + }) + return exp_model diff --git a/caimira/tests/apps/calculator/test_markdown_tools.py b/ui/apps/calculator/tests/test_markdown_tools.py similarity index 91% rename from caimira/tests/apps/calculator/test_markdown_tools.py rename to ui/apps/calculator/tests/test_markdown_tools.py index 9dfe0781..f05c1cfb 100644 --- a/caimira/tests/apps/calculator/test_markdown_tools.py +++ b/ui/apps/calculator/tests/test_markdown_tools.py @@ -3,7 +3,7 @@ import jinja2 import pytest -import caimira.apps.calculator.markdown_tools as md_tools +import ui.apps.calculator.markdown_tools as md_tools @pytest.fixture diff --git a/ui/apps/calculator/tests/test_report_generator.py b/ui/apps/calculator/tests/test_report_generator.py new file mode 100644 index 00000000..ec4494b1 --- /dev/null +++ b/ui/apps/calculator/tests/test_report_generator.py @@ -0,0 +1,132 @@ +import concurrent.futures +from functools import partial +import os +import time + +import numpy as np +import pytest + +from ui.apps.calculator import make_app +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: + # This is a simple test that confirms that given a model, we can actually + # generate a report for it. Because this is what happens in the caimira + # calculator, we confirm that the generation happens within a reasonable + # time threshold. + time_limit: float = float(os.environ.get( + "CAIMIRA_TESTS_CALCULATOR_TIMEOUT", 10.)) + + start = time.perf_counter() + + generator: ReportGenerator = make_app().settings['report_generator'] + + model = generate_model(baseline_form) + report_data = generate_report_results(baseline_form, model) + + report = generator.build_report("", baseline_form, model, report_data, partial( + concurrent.futures.ThreadPoolExecutor, 1, + )) + + end = time.perf_counter() + total = end-start + print( + f"Time limit: {time_limit} | Time taken: {end} - {start} = {total} < {time_limit}") + assert report != "" + assert end - start < time_limit + + +@pytest.mark.parametrize( + ["test_input", "expected"], + [ + [1, '1 minute'], + [2, '2 minutes'], + [60, '1 hour'], + [120, '2 hours'], + [150, '150 minutes'], + ], +) +def test_readable_minutes(test_input, expected): + assert readable_minutes(test_input) == expected + + +def test_fill_big_gaps(): + expected = [1, 1.75, 2, 2.75, 3.5, 4] + assert rep_gen.fill_big_gaps([1, 2, 4], gap_size=0.75) == expected + + +def test_fill_big_gaps__float_tolerance(): + # Ensure that there is some float tolerance to the gap size check. + assert rep_gen.fill_big_gaps( + [0, 2 + 1e-15, 4], gap_size=2) == [0, 2 + 1e-15, 4] + assert rep_gen.fill_big_gaps( + [0, 2 + 1e-14, 4], gap_size=2) == [0, 2, 2 + 1e-14, 4] + + +def test_non_temp_transition_times(baseline_exposure_model): + expected = [0.0, 4.0, 5.0, 8.0] + result = rep_gen.non_temp_transition_times(baseline_exposure_model) + assert result == expected + + +def test_interesting_times_many(baseline_exposure_model): + result = rep_gen.interesting_times( + baseline_exposure_model, approx_n_pts=100) + assert 100 <= len(result) <= 120 + assert np.abs(np.diff(result)).max() < 8.1/100. + + +def test_interesting_times_small(baseline_exposure_model): + expected = [0.0, 0.8, 1.6, 2.4, 3.2, 4.0, 4.8, 5.0, 5.8, 6.6, 7.4, 8.0] + # Ask for more data than there is in the transition times. + result = rep_gen.interesting_times( + baseline_exposure_model, approx_n_pts=10) + + np.testing.assert_allclose(result, expected, atol=1e-04) + + +def test_interesting_times_w_temp(exposure_model_w_outside_temp_changes): + # Ensure that the state change times are returned (minus the temperature changes) by + # requesting n_points=1. + result = rep_gen.interesting_times( + exposure_model_w_outside_temp_changes, approx_n_pts=1) + expected = [0., 1.8, 2.2, 4., 4.4, 5., 6.2, 6.6, 8.] + np.testing.assert_allclose(result, expected) + + # Now request more than the state-change times. + result = rep_gen.interesting_times( + exposure_model_w_outside_temp_changes, approx_n_pts=20) + expected = [ + 0., 0.4, 0.8, 1.2, 1.6, 1.8, 2.2, 2.6, 3., 3.4, 3.8, 4., 4.4, 4.8, + 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/caimira/tests/apps/calculator/test_webapp.py b/ui/apps/calculator/tests/test_webapp.py similarity index 83% rename from caimira/tests/apps/calculator/test_webapp.py rename to ui/apps/calculator/tests/test_webapp.py index a9fd52f3..ce78037e 100644 --- a/caimira/tests/apps/calculator/test_webapp.py +++ b/ui/apps/calculator/tests/test_webapp.py @@ -4,15 +4,15 @@ import pytest import tornado.testing -import caimira.apps.calculator -from caimira.apps.calculator.report_generator import generate_permalink +import ui.apps.calculator +from ui.apps.calculator.report import generate_permalink _TIMEOUT = float(os.environ.get("CAIMIRA_TESTS_CALCULATOR_TIMEOUT", 10.)) @pytest.fixture def app(): - return caimira.apps.calculator.make_app() + return ui.apps.calculator.make_app() async def test_homepage(http_server_client): @@ -36,7 +36,7 @@ async def test_404(http_server_client): class TestBasicApp(tornado.testing.AsyncHTTPTestCase): def get_app(self): - return caimira.apps.calculator.make_app() + return ui.app.make_app() @tornado.testing.gen_test(timeout=_TIMEOUT) def test_report(self): @@ -64,8 +64,8 @@ def end_time(resp): class TestCernApp(tornado.testing.AsyncHTTPTestCase): def get_app(self): - cern_theme = Path(caimira.apps.calculator.__file__).parent.parent / 'themes' / 'cern' - return caimira.apps.calculator.make_app(theme_dir=cern_theme) + cern_theme = Path(ui.app.__file__).parent.parent / 'themes' / 'cern' + return ui.app.make_app(theme_dir=cern_theme) @tornado.testing.gen_test(timeout=_TIMEOUT) def test_report(self): @@ -76,7 +76,7 @@ def test_report(self): class TestOpenApp(tornado.testing.AsyncHTTPTestCase): def get_app(self): - return caimira.apps.calculator.make_app(calculator_prefix="/mycalc") + return ui.app.make_app(calculator_prefix="/mycalc") @tornado.testing.gen_test(timeout=_TIMEOUT) def test_report(self): @@ -121,10 +121,10 @@ async def test_invalid_compressed_url(http_server_client, baseline_form): class TestError500(tornado.testing.AsyncHTTPTestCase): def get_app(self): - class ProcessingErrorPage(caimira.apps.calculator.BaseRequestHandler): + class ProcessingErrorPage(ui.app.BaseRequestHandler): def get(self): raise ValueError('some unexpected error') - app = caimira.apps.calculator.make_app() + app = ui.app.make_app() page = [ (r'/', ProcessingErrorPage), ] @@ -138,11 +138,11 @@ def test_500(self): class TestCERNGenericPage(tornado.testing.AsyncHTTPTestCase): def get_app(self): - cern_theme = Path(caimira.apps.calculator.__file__).parent.parent / 'themes' / 'cern' - app = caimira.apps.calculator.make_app(theme_dir=cern_theme) + cern_theme = Path(ui.app.__file__).parent.parent / 'themes' / 'cern' + app = ui.app.make_app(theme_dir=cern_theme) pages = [ - (r'/calculator/user-guide', caimira.apps.calculator.GenericExtraPage, {'active_page': 'calculator/user-guide', 'filename': 'userguide.html.j2'}), - (r'/about', caimira.apps.calculator.GenericExtraPage, {'active_page': 'about', 'filename': 'about.html.j2'}), + (r'/calculator/user-guide', ui.app.GenericExtraPage, {'active_page': 'calculator/user-guide', 'filename': 'userguide.html.j2'}), + (r'/about', ui.app.GenericExtraPage, {'active_page': 'about', 'filename': 'about.html.j2'}), ] return tornado.web.Application(pages, **app.settings) diff --git a/caimira/apps/calculator/user.py b/ui/apps/calculator/user.py similarity index 100% rename from caimira/apps/calculator/user.py rename to ui/apps/calculator/user.py diff --git a/ui/apps/expert_apps/__init__.py b/ui/apps/expert_apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/caimira/apps/expert.py b/ui/apps/expert_apps/expert.py similarity index 99% rename from caimira/apps/expert.py rename to ui/apps/expert_apps/expert.py index 111505d2..4e9cb315 100644 --- a/caimira/apps/expert.py +++ b/ui/apps/expert_apps/expert.py @@ -12,9 +12,13 @@ import numpy as np import datetime import pandas as pd +import logging -from caimira import data, models, state -from caimira.store.data_registry import DataRegistry +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): @@ -929,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/caimira/apps/expert/caimira.ipynb b/ui/apps/expert_apps/expert/caimira.ipynb similarity index 100% rename from caimira/apps/expert/caimira.ipynb rename to ui/apps/expert_apps/expert/caimira.ipynb diff --git a/caimira/apps/expert/static/images/header_image.png b/ui/apps/expert_apps/expert/static/images/header_image.png similarity index 100% rename from caimira/apps/expert/static/images/header_image.png rename to ui/apps/expert_apps/expert/static/images/header_image.png diff --git a/caimira/apps/expert_co2.py b/ui/apps/expert_apps/expert_co2.py similarity index 98% rename from caimira/apps/expert_co2.py rename to ui/apps/expert_apps/expert_co2.py index d083d20b..cca1c075 100644 --- a/caimira/apps/expert_co2.py +++ b/ui/apps/expert_apps/expert_co2.py @@ -2,14 +2,19 @@ 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 +from caimira.calculator.models import data, models +from caimira.calculator.store.data_registry import DataRegistry -from caimira import data, models, state -from caimira.store.data_registry import DataRegistry import matplotlib import matplotlib.figure import matplotlib.lines as mlines import matplotlib.patches as patches -from .expert import generate_presence_widget, collapsible, ipympl_canvas, WidgetGroup, CAIMIRAStateBuilder + +LOG = logging.getLogger(__name__) def baseline_model(data_registry: DataRegistry): @@ -188,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/caimira/apps/expert_co2/caimira.ipynb b/ui/apps/expert_apps/expert_co2/caimira.ipynb similarity index 100% rename from caimira/apps/expert_co2/caimira.ipynb rename to ui/apps/expert_apps/expert_co2/caimira.ipynb diff --git a/caimira/apps/expert_co2/static/images/header_image.png b/ui/apps/expert_apps/expert_co2/static/images/header_image.png similarity index 100% rename from caimira/apps/expert_co2/static/images/header_image.png rename to ui/apps/expert_apps/expert_co2/static/images/header_image.png diff --git a/caimira/state.py b/ui/apps/expert_apps/state.py similarity index 100% rename from caimira/state.py rename to ui/apps/expert_apps/state.py diff --git a/caimira/apps/static/css/style.css b/ui/apps/static/css/style.css similarity index 100% rename from caimira/apps/static/css/style.css rename to ui/apps/static/css/style.css diff --git a/caimira/apps/static/icons/calculator.svg b/ui/apps/static/icons/calculator.svg similarity index 100% rename from caimira/apps/static/icons/calculator.svg rename to ui/apps/static/icons/calculator.svg diff --git a/caimira/apps/static/icons/expert.svg b/ui/apps/static/icons/expert.svg similarity index 100% rename from caimira/apps/static/icons/expert.svg rename to ui/apps/static/icons/expert.svg diff --git a/caimira/apps/static/icons/favicon.ico b/ui/apps/static/icons/favicon.ico similarity index 100% rename from caimira/apps/static/icons/favicon.ico rename to ui/apps/static/icons/favicon.ico diff --git a/caimira/apps/static/images/CAiMIRA_1_Vs3_Colour.jpg b/ui/apps/static/images/CAiMIRA_1_Vs3_Colour.jpg similarity index 100% rename from caimira/apps/static/images/CAiMIRA_1_Vs3_Colour.jpg rename to ui/apps/static/images/CAiMIRA_1_Vs3_Colour.jpg diff --git a/caimira/apps/static/images/caimira_full_logo.png b/ui/apps/static/images/caimira_full_logo.png similarity index 100% rename from caimira/apps/static/images/caimira_full_logo.png rename to ui/apps/static/images/caimira_full_logo.png diff --git a/caimira/apps/static/images/caimira_full_text.png b/ui/apps/static/images/caimira_full_text.png similarity index 100% rename from caimira/apps/static/images/caimira_full_text.png rename to ui/apps/static/images/caimira_full_text.png diff --git a/caimira/apps/static/images/caimira_logo.200x200.png b/ui/apps/static/images/caimira_logo.200x200.png similarity index 100% rename from caimira/apps/static/images/caimira_logo.200x200.png rename to ui/apps/static/images/caimira_logo.200x200.png diff --git a/caimira/apps/static/images/caimira_logo_white_text.png b/ui/apps/static/images/caimira_logo_white_text.png similarity index 100% rename from caimira/apps/static/images/caimira_logo_white_text.png rename to ui/apps/static/images/caimira_logo_white_text.png diff --git a/caimira/apps/static/images/long_range_anim.png b/ui/apps/static/images/long_range_anim.png similarity index 100% rename from caimira/apps/static/images/long_range_anim.png rename to ui/apps/static/images/long_range_anim.png diff --git a/caimira/apps/static/images/masks/cloth.png b/ui/apps/static/images/masks/cloth.png similarity index 100% rename from caimira/apps/static/images/masks/cloth.png rename to ui/apps/static/images/masks/cloth.png diff --git a/caimira/apps/static/images/masks/ffp2.png b/ui/apps/static/images/masks/ffp2.png similarity index 100% rename from caimira/apps/static/images/masks/ffp2.png rename to ui/apps/static/images/masks/ffp2.png diff --git a/caimira/apps/static/images/masks/t1.png b/ui/apps/static/images/masks/t1.png similarity index 100% rename from caimira/apps/static/images/masks/t1.png rename to ui/apps/static/images/masks/t1.png diff --git a/caimira/apps/static/images/nat_vent_dimensions.png b/ui/apps/static/images/nat_vent_dimensions.png similarity index 100% rename from caimira/apps/static/images/nat_vent_dimensions.png rename to ui/apps/static/images/nat_vent_dimensions.png diff --git a/caimira/apps/static/images/short_range_anim.png b/ui/apps/static/images/short_range_anim.png similarity index 100% rename from caimira/apps/static/images/short_range_anim.png rename to ui/apps/static/images/short_range_anim.png diff --git a/caimira/apps/static/js/ScrollMagic.min.js b/ui/apps/static/js/ScrollMagic.min.js similarity index 100% rename from caimira/apps/static/js/ScrollMagic.min.js rename to ui/apps/static/js/ScrollMagic.min.js diff --git a/caimira/apps/static/js/jquery.colorbox-min.js b/ui/apps/static/js/jquery.colorbox-min.js similarity index 100% rename from caimira/apps/static/js/jquery.colorbox-min.js rename to ui/apps/static/js/jquery.colorbox-min.js diff --git a/caimira/apps/static/js/js_packaged_for_theme.js b/ui/apps/static/js/js_packaged_for_theme.js similarity index 100% rename from caimira/apps/static/js/js_packaged_for_theme.js rename to ui/apps/static/js/js_packaged_for_theme.js diff --git a/caimira/apps/static/js/usage-tracking.js b/ui/apps/static/js/usage-tracking.js similarity index 100% rename from caimira/apps/static/js/usage-tracking.js rename to ui/apps/static/js/usage-tracking.js diff --git a/caimira/apps/templates/about.html.j2 b/ui/apps/templates/about.html.j2 similarity index 98% rename from caimira/apps/templates/about.html.j2 rename to ui/apps/templates/about.html.j2 index 28140cc1..c503c1bb 100644 --- a/caimira/apps/templates/about.html.j2 +++ b/ui/apps/templates/about.html.j2 @@ -15,7 +15,7 @@ CAiMIRA stands for CERN Airborne Model for Indoor Risk Assessment, previously kn Since then, the model has evolved and now is capable of simulating the short-range component. CAiMIRA comes with different applications that allow more or less flexibility in the input parameters: The mathematical and physical model simulate the airborne spread of SARS-CoV-2 virus in a finite volume, assuming a homogenous mixture and a two-stage exhaled jet model, and estimates the risk of COVID-19 airborne transmission therein. The results DO NOT include other known modes of SARS-CoV-2 transmission. Hence, the output from this model is only valid when the other recommended public health & safety instructions are observed, such as good hand hygiene and other barrier measures.
diff --git a/caimira/apps/templates/base/calculator.form.html.j2 b/ui/apps/templates/base/calculator.form.html.j2 similarity index 98% rename from caimira/apps/templates/base/calculator.form.html.j2 rename to ui/apps/templates/base/calculator.form.html.j2 index 6842ea63..745612e6 100644 --- a/caimira/apps/templates/base/calculator.form.html.j2 +++ b/ui/apps/templates/base/calculator.form.html.j2 @@ -584,6 +584,16 @@
+
+
+
+ +
+
+ +
+
+

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

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

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

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

    Applications

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

    About

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

    CAiMIRA Expert Apps currently deactivated


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

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

    + + +
    + +{% endblock main %} diff --git a/caimira/apps/templates/index.html.j2 b/ui/apps/templates/index.html.j2 similarity index 100% rename from caimira/apps/templates/index.html.j2 rename to ui/apps/templates/index.html.j2 diff --git a/caimira/apps/templates/layout.html.j2 b/ui/apps/templates/layout.html.j2 similarity index 100% rename from caimira/apps/templates/layout.html.j2 rename to ui/apps/templates/layout.html.j2 diff --git a/caimira/apps/templates/page.html.j2 b/ui/apps/templates/page.html.j2 similarity index 100% rename from caimira/apps/templates/page.html.j2 rename to ui/apps/templates/page.html.j2 diff --git a/caimira/apps/templates/profiler.html.j2 b/ui/apps/templates/profiler.html.j2 similarity index 100% rename from caimira/apps/templates/profiler.html.j2 rename to ui/apps/templates/profiler.html.j2 diff --git a/caimira/apps/templates/userguide.html.j2 b/ui/apps/templates/userguide.html.j2 similarity index 100% rename from caimira/apps/templates/userguide.html.j2 rename to ui/apps/templates/userguide.html.j2 diff --git a/caimira/tests/apps/test_expert_app.py b/ui/apps/tests/test_expert_app.py similarity index 75% rename from caimira/tests/apps/test_expert_app.py rename to ui/apps/tests/test_expert_app.py index c94f8206..c79008ad 100644 --- a/caimira/tests/apps/test_expert_app.py +++ b/ui/apps/tests/test_expert_app.py @@ -1,13 +1,14 @@ import pytest -import caimira.apps +import ui.apps.expert_apps.expert as exp @pytest.fixture def expert_app(): - return caimira.apps.ExpertApplication() + return exp.ExpertApplication() +@pytest.mark.skip(reason="ExpertApplication is deactivated") def test_app(expert_app): # To start with, let's just test that the application runs. We don't try to # do anything fancy to verify how it looks etc., we leave that for manual @@ -15,6 +16,7 @@ def test_app(expert_app): assert expert_app._model_scenarios[0][0] == "Scenario 1" +@pytest.mark.skip(reason="ExpertApplication is deactivated") def test_new_scenario_changes_tab(expert_app): # Adding a new scenario should change the tab index of the multi-model view. assert expert_app.multi_model_view.widget.selected_index == 0 diff --git a/ui/apps/tests/test_markdown_tools.py b/ui/apps/tests/test_markdown_tools.py new file mode 100644 index 00000000..f05c1cfb --- /dev/null +++ b/ui/apps/tests/test_markdown_tools.py @@ -0,0 +1,30 @@ +import textwrap + +import jinja2 +import pytest + +import ui.apps.calculator.markdown_tools as md_tools + + +@pytest.fixture +def example_template(): + return jinja2.Environment().from_string(textwrap.dedent(""" + # A header + + Some *text* + + {% block using_jinja_blocks %} + # Another header + + Some more **text**. + {% endblock %} + + """)) + + +def test_extract_blocks(example_template): + blocks = md_tools.extract_rendered_markdown_blocks(example_template) + assert 'A header' in blocks + assert blocks['A header'] == '

    Some text

    \n' + assert 'Another header' in blocks + assert blocks['Another header'] == '

    Some more text.

    \n' diff --git a/ui/apps/tests/test_report_generator.py b/ui/apps/tests/test_report_generator.py new file mode 100644 index 00000000..f9fd6d37 --- /dev/null +++ b/ui/apps/tests/test_report_generator.py @@ -0,0 +1,132 @@ +import concurrent.futures +from functools import partial +import os +import time + +import numpy as np +import pytest + +from ui.apps.calculator import make_app +from caimira.apps.calculator.model_generator import VirusFormData +from caimira.apps.calculator.report_generator import (ReportGenerator, readable_minutes, calculate_report_data, + manufacture_alternative_scenarios, interesting_times, comparison_report) +import caimira.calculator.report.report_generator as rep_gen +from caimira.api.controller.report_controller import generate_model, generate_report_results + + +def test_generate_report(baseline_form) -> None: + # This is a simple test that confirms that given a model, we can actually + # generate a report for it. Because this is what happens in the caimira + # calculator, we confirm that the generation happens within a reasonable + # time threshold. + time_limit: float = float(os.environ.get( + "CAIMIRA_TESTS_CALCULATOR_TIMEOUT", 10.)) + + start = time.perf_counter() + + generator: ReportGenerator = make_app().settings['report_generator'] + + model = generate_model(baseline_form) + report_data = generate_report_results(baseline_form, model) + + report = generator.build_report("", baseline_form, model, report_data, partial( + concurrent.futures.ThreadPoolExecutor, 1, + )) + + end = time.perf_counter() + total = end-start + print( + f"Time limit: {time_limit} | Time taken: {end} - {start} = {total} < {time_limit}") + assert report != "" + assert end - start < time_limit + + +@pytest.mark.parametrize( + ["test_input", "expected"], + [ + [1, '1 minute'], + [2, '2 minutes'], + [60, '1 hour'], + [120, '2 hours'], + [150, '150 minutes'], + ], +) +def test_readable_minutes(test_input, expected): + assert readable_minutes(test_input) == expected + + +def test_fill_big_gaps(): + expected = [1, 1.75, 2, 2.75, 3.5, 4] + assert rep_gen.fill_big_gaps([1, 2, 4], gap_size=0.75) == expected + + +def test_fill_big_gaps__float_tolerance(): + # Ensure that there is some float tolerance to the gap size check. + assert rep_gen.fill_big_gaps( + [0, 2 + 1e-15, 4], gap_size=2) == [0, 2 + 1e-15, 4] + assert rep_gen.fill_big_gaps( + [0, 2 + 1e-14, 4], gap_size=2) == [0, 2, 2 + 1e-14, 4] + + +def test_non_temp_transition_times(baseline_exposure_model): + expected = [0.0, 4.0, 5.0, 8.0] + result = rep_gen.non_temp_transition_times(baseline_exposure_model) + assert result == expected + + +def test_interesting_times_many(baseline_exposure_model): + result = rep_gen.interesting_times( + baseline_exposure_model, approx_n_pts=100) + assert 100 <= len(result) <= 120 + assert np.abs(np.diff(result)).max() < 8.1/100. + + +def test_interesting_times_small(baseline_exposure_model): + expected = [0.0, 0.8, 1.6, 2.4, 3.2, 4.0, 4.8, 5.0, 5.8, 6.6, 7.4, 8.0] + # Ask for more data than there is in the transition times. + result = rep_gen.interesting_times( + baseline_exposure_model, approx_n_pts=10) + + np.testing.assert_allclose(result, expected, atol=1e-04) + + +def test_interesting_times_w_temp(exposure_model_w_outside_temp_changes): + # Ensure that the state change times are returned (minus the temperature changes) by + # requesting n_points=1. + result = rep_gen.interesting_times( + exposure_model_w_outside_temp_changes, approx_n_pts=1) + expected = [0., 1.8, 2.2, 4., 4.4, 5., 6.2, 6.6, 8.] + np.testing.assert_allclose(result, expected) + + # Now request more than the state-change times. + result = rep_gen.interesting_times( + exposure_model_w_outside_temp_changes, approx_n_pts=20) + expected = [ + 0., 0.4, 0.8, 1.2, 1.6, 1.8, 2.2, 2.6, 3., 3.4, 3.8, 4., 4.4, 4.8, + 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/caimira/tests/test_state.py b/ui/apps/tests/test_state.py similarity index 99% rename from caimira/tests/test_state.py rename to ui/apps/tests/test_state.py index ac0341fe..11eb08e6 100644 --- a/caimira/tests/test_state.py +++ b/ui/apps/tests/test_state.py @@ -4,7 +4,7 @@ import pytest -from caimira import state +from ui.apps.expert_apps.expert import state @dataclass diff --git a/ui/apps/tests/test_webapp.py b/ui/apps/tests/test_webapp.py new file mode 100644 index 00000000..4add88c8 --- /dev/null +++ b/ui/apps/tests/test_webapp.py @@ -0,0 +1,162 @@ +import os +from pathlib import Path + +import pytest +import tornado.testing + +import ui.apps.calculator +from ui.apps.calculator.report import generate_permalink + +_TIMEOUT = float(os.environ.get("CAIMIRA_TESTS_CALCULATOR_TIMEOUT", 10.)) + + +@pytest.fixture +def app(): + return ui.apps.calculator.make_app() + + +async def test_homepage(http_server_client): + response = await http_server_client.fetch('/') + assert response.code == 200 + + +async def test_calculator_form(http_server_client): + # Both with and without a trailing slash. + response = await http_server_client.fetch('/calculator') + assert response.code == 200 + + response = await http_server_client.fetch('/calculator/') + assert response.code == 200 + + +async def test_404(http_server_client): + resp = await http_server_client.fetch('/doesnt-exist', raise_error=False) + assert resp.code == 404 + + +class TestBasicApp(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + return ui.apps.calculator.make_app() + + @tornado.testing.gen_test(timeout=_TIMEOUT) + def test_report(self): + requests = [ + self.http_client.fetch(self.get_url('/calculator/baseline-model/result'), request_timeout=_TIMEOUT), + # At the same time, request a non-report page (to check whether the report is blocking). + self.http_client.fetch(self.get_url('/calculator/')), + ] + response = yield requests[0] + other_response = yield requests[1] + + def end_time(resp): + return resp.start_time + resp.request_time + + # The start time is before the other request, + # but the end time is after the other request (because it takes longer + # to process a report than a simple page). + assert response.start_time < other_response.start_time + assert end_time(response) > end_time(other_response) + + self.assertEqual(response.code, 200) + assert 'CERN HSE' not in response.body.decode() + assert 'expected number of new cases is' in response.body.decode() + + +class TestCernApp(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + cern_theme = Path(ui.apps.calculator.__file__).parent.parent / 'themes' / 'cern' + return ui.apps.calculator.make_app(theme_dir=cern_theme) + + @tornado.testing.gen_test(timeout=_TIMEOUT) + def test_report(self): + response = yield self.http_client.fetch(self.get_url('/calculator/baseline-model/result'), request_timeout=_TIMEOUT) + self.assertEqual(response.code, 200) + assert 'expected number of new cases is' in response.body.decode() + + +class TestOpenApp(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + return ui.apps.calculator.make_app(calculator_prefix="/mycalc") + + @tornado.testing.gen_test(timeout=_TIMEOUT) + def test_report(self): + response = yield self.http_client.fetch(self.get_url('/mycalc/baseline-model/result'), request_timeout=_TIMEOUT) + self.assertEqual(response.code, 200) + + def test_calculator_404(self): + response = self.fetch('/calculator') + assert response.code == 404 + + +async def test_permalink_urls(http_server_client, baseline_form): + base_url = 'proto://hostname/prefix' + permalink_data = generate_permalink(base_url, lambda: "", lambda: "/calculator", baseline_form) + expected = f'{base_url}/calculator?exposed_coffee_break_option={baseline_form.exposed_coffee_break_option}&' + assert permalink_data['link'].startswith(expected) + + # We should get a 200 for the link. + response = await http_server_client.fetch(permalink_data['link'].replace(base_url, '')) + assert response.code == 200 + + # And a 302 for the QR url itself. The redirected URL should be the same as + # in the link. + assert permalink_data['shortened'].startswith(base_url) + response = await http_server_client.fetch( + permalink_data['shortened'].replace(base_url, ''), + max_redirects=0, + raise_error=False, + ) + assert response.code == 302 + assert response.headers['Location'] == permalink_data['link'].replace(base_url, '') + + +async def test_invalid_compressed_url(http_server_client, baseline_form): + response = await http_server_client.fetch( + '/_c/invalid-data', + max_redirects=0, + raise_error=False, + ) + assert response.code == 400 + + +class TestError500(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + class ProcessingErrorPage(ui.apps.calculator.BaseRequestHandler): + def get(self): + raise ValueError('some unexpected error') + app = ui.apps.calculator.make_app() + page = [ + (r'/', ProcessingErrorPage), + ] + return tornado.web.Application(page, **app.settings) + + def test_500(self): + response = self.fetch('/') + assert response.code == 500 + assert 'Unfortunately an error occurred when processing your request' in response.body.decode() + + +class TestCERNGenericPage(tornado.testing.AsyncHTTPTestCase): + def get_app(self): + cern_theme = Path(ui.apps.calculator.__file__).parent.parent / 'themes' / 'cern' + app = ui.apps.calculator.make_app(theme_dir=cern_theme) + pages = [ + (r'/calculator/user-guide', ui.apps.calculator.GenericExtraPage, {'active_page': 'calculator/user-guide', 'filename': 'userguide.html.j2'}), + (r'/about', ui.apps.calculator.GenericExtraPage, {'active_page': 'about', 'filename': 'about.html.j2'}), + ] + + return tornado.web.Application(pages, **app.settings) + + @tornado.testing.gen_test(timeout=_TIMEOUT) + def test_user_guide(self): + response = yield self.http_client.fetch(self.get_url('/calculator/user-guide')) + self.assertEqual(response.code, 200) + + @tornado.testing.gen_test(timeout=_TIMEOUT) + def test_about(self): + response = yield self.http_client.fetch(self.get_url('/about')) + self.assertEqual(response.code, 200) + + def test_calculator_404(self): + response = self.fetch('/calculator') + assert response.code == 404