├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── build-n-publish.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── archABM ├── __init__.py ├── actions.py ├── aerosol_model.py ├── aerosol_model_colorado.py ├── aerosol_model_maxplanck.py ├── aerosol_model_mit.py ├── config.json ├── config.py ├── creator.py ├── database.py ├── engine.py ├── event.py ├── event_generator.py ├── event_model.py ├── options.py ├── parameters.py ├── person.py ├── place.py ├── results.py ├── schema.json ├── snapshot.py ├── snapshot_person.py └── snapshot_place.py ├── data ├── config.json ├── config_basic.json └── config_toy.json ├── designer ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── favicon.png │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── config.json │ ├── config_basic.js │ ├── config_basic.json │ ├── config_office.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── reportWebVitals.js │ └── setupTests.js ├── docs ├── Makefile ├── index.html ├── make.bat └── source │ ├── _static │ ├── command.png │ ├── favicon.png │ ├── figures │ │ ├── architecture.png │ │ ├── boxplot_place_CO2.png │ │ ├── boxplot_place_quanta.png │ │ ├── distribution_person_quanta.png │ │ ├── floorplan.png │ │ ├── floorplan_CO2.png │ │ ├── floorplan_quanta.png │ │ ├── performance.png │ │ ├── table_1.png │ │ ├── table_2.png │ │ ├── table_3.png │ │ ├── table_4.png │ │ ├── timeline_activity_density.png │ │ ├── timeline_activity_person.png │ │ ├── timeline_person_quanta.png │ │ ├── timeline_place_CO2.png │ │ └── timeline_place_quanta.png │ ├── index.html │ ├── logo_1.png │ ├── logo_2.png │ ├── logo_3.png │ ├── logo_4.png │ ├── main.1f09b65e.css │ ├── main.83d82daa.js │ ├── manifest.json │ ├── my_theme.css │ ├── schedule.png │ ├── vicomtech_logo.png │ └── vicomtech_logo.svg │ ├── api.rst │ ├── archABM │ ├── actions.rst │ ├── aerosol_model.rst │ ├── creator.rst │ ├── database.rst │ ├── engine.rst │ ├── event.rst │ ├── options.rst │ ├── parameters.rst │ ├── person.rst │ ├── place.rst │ ├── results.rst │ ├── schema.rst │ └── snapshot.rst │ ├── authors.rst │ ├── changelog.rst │ ├── citing.rst │ ├── conf.py │ ├── contents.rst │ ├── designer.rst │ ├── example.rst │ ├── framework.rst │ ├── index.rst │ ├── license.rst │ └── references.bib ├── experiments ├── config_0.json ├── config_1.json ├── config_2.json ├── config_3.json ├── config_4.json ├── config_5.json ├── config_6.json ├── config_7.json ├── config_8.json ├── config_performance.json ├── config_validation.json ├── performance.csv └── performance.py ├── main.py ├── main_manual.py ├── models ├── 2020_COVID-19_Aerosol_Transmission_Estimator.xlsx ├── 2021.04.21.21255898v2.full.pdf ├── BEST.pdf ├── COVID-19_Indoor_Safety_Guideline_PNAS_with_CO2_final.xlsx ├── acs.estlett.1c00183.pdf ├── ijerph-17-08114-s001.xlsx ├── ijerph-17-08114-v3.pdf ├── ina.12751.pdf └── thermal_model.ods ├── pyproject.toml ├── requirements.txt ├── requirements_dev.txt ├── requirements_docs.txt ├── run.py ├── setup.cfg ├── setup.py ├── tests.py └── visualization ├── analysis.py ├── analysis_comparison_v1.R ├── analysis_comparison_v2.R ├── analysis_single_v1.R ├── analysis_single_v2.R ├── floorplan ├── floorplan.pdf ├── floorplan.svg ├── floorplan_CO2.pdf ├── floorplan_CO2.svg ├── floorplan_backup.svg ├── floorplan_example_1.svg ├── floorplan_example_2.svg ├── floorplan_example_3.svg ├── floorplan_example_4.svg ├── floorplan_heatmap.svg ├── floorplan_legend_CO2.pdf ├── floorplan_legend_quanta.pdf ├── floorplan_quanta.pdf └── floorplan_quanta.svg ├── performance.R ├── schedule ├── legend.pdf ├── legend.png └── legend.svg └── validation ├── Occupancy-detection-data-master ├── Correlation_Plot.png ├── Model Development.R ├── README.md ├── VarImp_modelRF_All.png ├── datatest.txt ├── datatest2.txt ├── datatraining.txt ├── pairs_plot_green_blue_time.png └── varimportance_no_light.png ├── validation.R └── validation.pdf /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build-n-publish.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Build & publish to Pypi 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on pushing a tag to the main branch 8 | push: 9 | tags: 10 | - 'v*' 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | jobs: 16 | prepare: 17 | runs-on: ubuntu-latest 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - uses: actions/checkout@v2 21 | with: 22 | fetch-depth: 0 # Fetch all history to be able to obtain branch of tag 23 | 24 | - id: branch 25 | name: Get branch of tag 26 | run: echo "::set-output name=BRANCH::$(git branch -a --contains ${{ github.ref }} | grep -v HEAD | cut -d '/' -f3)" 27 | 28 | outputs: 29 | branch_name: ${{ steps.branch.outputs.BRANCH }} 30 | 31 | build-n-publish: 32 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 33 | runs-on: ubuntu-latest 34 | needs: prepare 35 | if: ${{ needs.prepare.outputs.branch_name }} == 'main' 36 | 37 | # strategy: 38 | # fail-fast: false 39 | # matrix: 40 | # python-version: [3.7, 3.8, 3.9] 41 | 42 | steps: 43 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 44 | - uses: actions/checkout@v2 45 | 46 | - name: Set up Python 47 | uses: actions/setup-python@v2 48 | with: 49 | python-version: 3.7 50 | 51 | - name: Install dependencies 52 | run: | 53 | # Upgrade pip 54 | python -m pip install --upgrade pip 55 | # Install build deps 56 | python -m pip install flake8 pytest build 57 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 58 | 59 | # - name: Lint with flake8 60 | # run: | 61 | # pip install flake8 62 | # # stop the build if there are Python syntax errors or undefined names 63 | # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 64 | # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 65 | # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 66 | 67 | # - name: Test with pytest 68 | # run: | 69 | # pip install pytest pytest-cov 70 | # pytest tests.py --doctest-modules --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov=com --cov-report=xml --cov-report=html 71 | 72 | # - name: Upload pytest test results 73 | # uses: actions/upload-artifact@v2 74 | # with: 75 | # name: pytest-results-${{ matrix.python-version }} 76 | # path: junit/test-results-${{ matrix.python-version }}.xml 77 | # # Use always() to always run this step to publish test results when there are test failures 78 | # if: ${{ always() }} 79 | 80 | - name: Build a binary wheel and a source tarball 81 | run: python -m build --sdist --wheel --outdir dist/ . 82 | 83 | # - name: Create a Release 84 | # uses: softprops/action-gh-release@v1 85 | # with: 86 | # name: ArchABM-${{ github.ref }} 87 | # env: 88 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | - name: Publish distribution 📦 to PyPI 91 | uses: pypa/gh-action-pypi-publish@release/v1 92 | with: 93 | user: __token__ 94 | password: ${{ secrets.PYPI_API_TOKEN }} 95 | 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .Rproj.user 3 | results 4 | docs/build 5 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Vicomtech - Data Intelligence for Energy & Industrial Processes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include archABM/schema.json 2 | include archABM/config.json -------------------------------------------------------------------------------- /archABM/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vicomtech/ArchABM/a5cbe99ab81a88113c06124f95f15a50a1aba899/archABM/__init__.py -------------------------------------------------------------------------------- /archABM/actions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | from typing import List 4 | 5 | from simpy import Environment 6 | 7 | from .database import Database 8 | from .event import Event 9 | from .event_model import EventModel 10 | from .person import Person 11 | from .place import Place 12 | 13 | 14 | class Actions: 15 | """Actions applied to any asset in :class:`~archABM.database.Database`""" 16 | 17 | env: Environment 18 | db: Database 19 | 20 | def __init__(self, env: Environment, db: Database) -> None: 21 | self.env = env 22 | self.db = db 23 | 24 | def find_place(self, model: EventModel, person: Person) -> None: 25 | """Find a place to carry out a certain :class:`~archABM.event_model.EventModel`. 26 | 27 | The selected place must share the same activity as the indicated :class:`~archABM.event_model.EventModel`. 28 | Places that are open (``allow = True``) and not full (``num_people < capacity``) are only considered as valid. 29 | 30 | .. note:: 31 | Movement restrictions (between buildings and between departments) are also considered. 32 | 33 | .. attention:: 34 | The list of places is shuffled after each search procedure. 35 | 36 | Args: 37 | model (EventModel): activity model to which find a place 38 | person (Person): person invoking the place search 39 | 40 | Returns: 41 | Place: selected place 42 | """ 43 | places = self.db.places 44 | random.shuffle(places) 45 | for place in places: 46 | if model.params.activity in place.params.activity: 47 | # check if place is allowed and not full 48 | if place.params.allow and not place.full(): 49 | # check if movement between buildings is not allowed 50 | if not self.db.options.params.movement_buildings: 51 | # check if person has a place (initial condition) 52 | if person.place is not None: 53 | # check if person has building defined 54 | if person.params.building is not None: 55 | # check if place has building defined 56 | if place.params.building is not None: 57 | if person.params.building != place.params.building: 58 | continue 59 | 60 | if not self.db.options.params.movement_department: 61 | # check if person has a place (initial condition) 62 | if person.place is not None: 63 | # check if person has department defined 64 | if person.params.department is not None: 65 | # check if place has department defined 66 | if place.params.department is not None: 67 | if person.params.department not in place.params.department: 68 | continue 69 | 70 | return place 71 | return None 72 | 73 | @staticmethod 74 | def create_event(model: EventModel, place: Place, duration: int) -> Event: 75 | """Wrapper to create an :class:`~archABM.event.Event` 76 | 77 | Args: 78 | model (EventModel): type of event or activity 79 | place (Place): physical location of the event 80 | duration (int): time duration in minutes 81 | 82 | Returns: 83 | Event: created :class:`~archABM.event.Event` instance 84 | """ 85 | return Event(model, place, duration) 86 | 87 | @staticmethod 88 | def assign_event(event: Event, people: List[Person]) -> None: 89 | """Assign an event to certain people 90 | 91 | Args: 92 | event (Event): :class:`~archABM.event.Event` instance to be assigned 93 | people (List[Person]): list of people to be interrupted from their current events 94 | """ 95 | for person in people: 96 | person.assign_event(event) 97 | 98 | def create_collective_event(self, model: EventModel, place: Place, duration: int, person: Person) -> None: 99 | """Creates a collective event of certain type :class:`~archABM.event_model.EventModel` at a physical location :class:`~archABM.place.Place` and for a time duration in minutes. 100 | 101 | .. important:: 102 | The number of people called into the collective event is a random integer between 103 | the current number of people at that place and the total place capacity. 104 | 105 | .. note:: 106 | Movement restrictions (between buildings and between departments) are also considered to select the people called into the collective event. 107 | 108 | Args: 109 | model (EventModel): type of event or activity 110 | place (Place): physical location of the event 111 | duration (int): time duration in minutes 112 | person (Person): person invoking the collective event 113 | 114 | Returns: 115 | Event: generated collective event 116 | """ 117 | # create event 118 | event = self.create_event(model, place, duration) 119 | 120 | # select people 121 | people = self.db.people 122 | # from the same building if option applied 123 | if not self.db.options.params.movement_buildings: 124 | building = event.place.params.building 125 | if building is not None: 126 | people_filter = [] 127 | for p in people: 128 | if p.place is not None: 129 | if p.place.params.building is None or p.place.params.building == building: 130 | people_filter.append(p) 131 | people = people_filter 132 | 133 | # from the same department if option applied 134 | if not self.db.options.params.movement_department: 135 | department = event.place.params.department 136 | if department is not None: 137 | people_filter = [] 138 | for p in people: 139 | if p.params.department is None or p.params.department in department: 140 | people_filter.append(p) 141 | people = people_filter 142 | 143 | if len(people) > 1: 144 | num_people = place.people_attending() 145 | num_people = min(len(people), num_people) 146 | people = random.sample(people, k=num_people) 147 | people = [p for p in people if p.generator.valid_activity(model) and p.model != model] 148 | 149 | logging.info( 150 | "[%.2f] Person %d invoked collective event %s at place %s for %d minutes for %d people" 151 | % (self.env.now, person.id, model.params.activity, place.params.name, duration, len(people),) 152 | ) 153 | 154 | self.assign_event(event, people) 155 | return event 156 | -------------------------------------------------------------------------------- /archABM/aerosol_model.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | from .parameters import Parameters 3 | 4 | 5 | class AerosolModel: 6 | """Aerosol transmission estimator""" 7 | 8 | name: str 9 | params: Parameters 10 | 11 | def __init__(self, params: Parameters): 12 | self.params = params 13 | 14 | def get_risk(self, inputs: Parameters) -> Tuple[float, float]: 15 | """Calculate the infection risk of an individual in a room 16 | and the CO\ :sub:`2` thrown into the air. 17 | 18 | Args: 19 | inputs (Parameters): model parameters 20 | 21 | Returns: 22 | Tuple[float, float]: CO\ :sub:`2` concentration (ppm), and infection risk probability 23 | """ 24 | return None, None 25 | -------------------------------------------------------------------------------- /archABM/aerosol_model_colorado.py: -------------------------------------------------------------------------------- 1 | import math 2 | from typing import Tuple 3 | 4 | from .aerosol_model import AerosolModel 5 | from .parameters import Parameters 6 | 7 | 8 | class AerosolModelColorado(AerosolModel): 9 | """Aerosol transmission estimator 10 | 11 | COVID-19 Airborne Transmission Estimator :cite:`doi:10.1021/acs.estlett.1c00183,https://doi.org/10.1111/ina.12751,Peng2021.04.21.21255898` 12 | 13 | The model combines two submodels: 14 | 15 | #. A standard atmospheric box model, which assumes that the emissions are completely mixed across a control volume quickly \ 16 | (such as an indoor room or other space). \ 17 | See for example Chapter 3 of the Jacob Atmos. Chem. textbook :cite:`10.2307/j.ctt7t8hg`, and Chapter 21 of the Cooper and \ 18 | Alley Air Pollution Control Engineering Textbook :cite:`cooper2010air` for indoor applications. \ 19 | This is an approximation that allows easy calculation, is approximately correct as long as near-field effects \ 20 | are avoided by social distancing, and is commonly used in air quality modeling. 21 | 22 | #. A standard aerosol infection model (Wells-Riley model), as formulated in Miller et al. 2020 :cite:`https://doi.org/10.1111/ina.12751`,\ 23 | and references therein :cite:`10.1093/oxfordjournals.aje.a112560,BUONANNO2020105794,BUONANNO2020106112`. 24 | 25 | .. important:: 26 | The propagation of COVID-19 is only by aerosol transmission. 27 | 28 | The model is based on a standard model of aerosol disease transmission, the Wells-Riley model. 29 | It is calibrated to COVID-19 per recent literature on quanta emission rate. 30 | 31 | This is not an epidemiological model, and does not include droplet or contact / fomite transmission, and assumes that 6 ft / 2 m social distancing is respected. Otherwise higher transmission will result. 32 | 33 | """ 34 | 35 | name: str = "Colorado" 36 | 37 | def __init__(self, params): 38 | super().__init__(params) 39 | self.params = params 40 | 41 | def get_risk(self, inputs: Parameters) -> Tuple[float, float]: 42 | """Calculate the infection risk of an individual in a room 43 | and the CO\ :sub:`2` thrown into the air. 44 | 45 | Args: 46 | inputs (Parameters): model parameters 47 | 48 | Returns: 49 | Tuple[float, float]: CO\ :sub:`2` concentration (ppm), and infection risk probability 50 | """ 51 | 52 | params = self.params 53 | 54 | # length = 8 55 | # width = 6 56 | height = inputs.room_height 57 | area = inputs.room_area # width * length 58 | volume = area * height 59 | event_duration = inputs.event_duration # 50 / 60 # hours 60 | 61 | # PRESSURE 62 | pressure = params.pressure # 0.95 63 | 64 | # TEMPERATURE 65 | temperature_ext = params.temperature 66 | temperature_int = inputs.temperature 67 | area_transfer = 2*math.sqrt(area)*height # suppose half of the room is exposed 68 | 69 | heat_transfer_coefficient = 1.2 # W / m2 K # https://www.researchgate.net/publication/325614742_A_Dynamic_Model_for_Indoor_Temperature_Prediction_in_Buildings 70 | heat_transfer = heat_transfer_coefficient*area_transfer # W/K 71 | heat_capacity_coefficient = 144e3 # J/(m2 K) # https://www.researchgate.net/publication/325614742_A_Dynamic_Model_for_Indoor_Temperature_Prediction_in_Buildings 72 | heat_capacity = heat_capacity_coefficient*area_transfer # J/K 73 | heat_per_person = 150 # W # https://www.researchgate.net/publication/271444362_Predicting_Energy_Requirement_for_Cooling_the_Building_Using_Artificial_Neural_Network/figures?lo=1 74 | P = heat_per_person*inputs.num_people # W 75 | Q = inputs.room_ventilation_rate * volume / 3600 # m3 / s # https://www.ncbi.nlm.nih.gov/books/NBK143289/ 76 | delta_time = event_duration * 3600 # seconds 77 | temperature = (temperature_int + delta_time*(P/heat_capacity + temperature_ext*heat_transfer/heat_capacity + temperature_ext*Q/volume))/(1 + delta_time*heat_transfer/heat_capacity + delta_time*Q/volume) 78 | 79 | 80 | relative_humidity = params.relative_humidity # 50 # TODO: include formula 81 | 82 | CO2_background = params.CO2_background # 415 83 | ventilation = inputs.room_ventilation_rate # 3 84 | decay_rate = params.decay_rate # 0.62 85 | deposition_rate = params.deposition_rate # 0.3 86 | 87 | hepa_flow_rate = params.hepa_flow_rate 88 | hepa_removal = hepa_flow_rate * volume 89 | 90 | recirculated_flow_rate = inputs.recirculated_flow_rate 91 | filter_efficiency = params.filter_efficiency 92 | ducts_removal = params.ducts_removal 93 | other_removal = params.other_removal 94 | 95 | ach_additional = recirculated_flow_rate / volume * min(1, filter_efficiency + ducts_removal + other_removal) 96 | additional_measures = hepa_removal + ach_additional 97 | 98 | loss_rate = ventilation + decay_rate + deposition_rate + additional_measures 99 | 100 | # ventilation_person = volume * (ventilation + additional_measures) * 1000 / 3600 / num_people 101 | 102 | num_people = inputs.num_people 103 | infective_people = inputs.infective_people # 1 104 | fraction_immune = params.fraction_immune # 0 105 | susceptible_people = (num_people - infective_people) * (1 - fraction_immune) 106 | 107 | # density_area_person = area / num_people 108 | # density_people_area = num_people / area 109 | # density_volume_person = volume / num_people 110 | 111 | breathing_rate = params.breathing_rate # 0.52 112 | breathing_rate_relative = breathing_rate / (0.0048 * 60) 113 | CO2_emission_person = params.CO2_emission_person # 0.005 114 | CO2_emission = CO2_emission_person * num_people / pressure * (273.15 + temperature_int) / 273.15 115 | 116 | quanta_exhalation = params.quanta_exhalation # 25 117 | quanta_enhancement = params.quanta_enhancement # 1 118 | quanta_exhalation_relative = quanta_exhalation / 2 119 | 120 | mask_efficiency_exhalation = inputs.mask_efficiency # 50 / 100 121 | mask_efficiency_inhalation = inputs.mask_efficiency # 30 / 100 122 | people_with_masks = params.people_with_masks # 100 / 100 123 | 124 | # probability_infective = 0.20 / 100 125 | # hospitalization_rate = 20 / 100 126 | # death_rate = 1 / 100 127 | 128 | net_emission_rate = quanta_exhalation * (1 - mask_efficiency_exhalation * people_with_masks) * infective_people * quanta_enhancement 129 | quanta_concentration = net_emission_rate / loss_rate / volume * (1 - (1 / loss_rate / event_duration) * (1 - math.exp(-loss_rate * event_duration))) 130 | # TODO: NEW FORMULA 131 | # TODO: infection risk dynamic 132 | quanta_concentration = ( 133 | net_emission_rate / loss_rate / volume * (1 - (1 / loss_rate / event_duration) * (1 - math.exp(-loss_rate * event_duration))) 134 | + math.exp(-loss_rate * event_duration) * (inputs.quanta_level - 0) 135 | + 0 136 | ) 137 | quanta_inhaled_per_person = quanta_concentration * breathing_rate * event_duration * (1 - mask_efficiency_inhalation * people_with_masks) 138 | 139 | # probability_infection = 1 - math.exp(-quanta_inhaled_per_person) 140 | # probability_infection = probability_infection * susceptible_people 141 | # probability_hospitalization = probability_infection * hospitalization_rate 142 | # probability_death = probability_infection * death_rate 143 | 144 | # if susceptible_people == 0 or infective_people == 0: 145 | # infection_risk = 0.0 146 | # infection_risk_relative = 0.0 147 | # else: 148 | # infection_risk = ( 149 | # breathing_rate_relative 150 | # * quanta_exhalation_relative 151 | # * (1 - mask_efficiency_exhalation * people_with_masks) 152 | # * (1 - mask_efficiency_inhalation * people_with_masks) 153 | # * event_duration 154 | # * susceptible_people 155 | # / (loss_rate * volume) 156 | # * (1 - (1 - math.exp(-loss_rate * event_duration)) / (loss_rate * event_duration)) 157 | # + math.exp(-loss_rate * event_duration) * (inputs.infection_risk - 0) 158 | # + 0 159 | # ) 160 | 161 | # infection_risk_relative = infection_risk / susceptible_people 162 | # infection_risk = (1 - math.exp(-infection_risk_relative))*susceptible_people # TODO: review Taylor approximation 163 | 164 | CO2_mixing_ratio = ( 165 | (CO2_emission * 3.6 / ventilation / volume * (1 - (1 / ventilation / event_duration) * (1 - math.exp(-ventilation * event_duration)))) * 1e6 166 | + math.exp(-ventilation * event_duration) * (inputs.CO2_level - CO2_background) 167 | + CO2_background 168 | ) 169 | CO2_mixing_ratio_delta = CO2_mixing_ratio - inputs.CO2_level 170 | CO2_concentration = CO2_mixing_ratio_delta * 40.9 / 1e6 * 44 * 298 / (273.15 + temperature) * pressure 171 | CO2_reinhaled_grams = CO2_concentration * breathing_rate * event_duration 172 | CO2_reinhaled_ppm = CO2_mixing_ratio_delta * event_duration 173 | # CO2_probability_infection_= CO2_reinhaled_ppm / 1e4 / probability_infection 174 | # CO2_inhale_ppm = CO2_mixing_ratio_delta * event_duration * 0.01 / probability_infection + CO2_background 175 | 176 | # return CO2_mixing_ratio, quanta_inhaled_per_person, quanta_concentration 177 | return CO2_mixing_ratio, quanta_inhaled_per_person, quanta_concentration, temperature, relative_humidity 178 | -------------------------------------------------------------------------------- /archABM/aerosol_model_maxplanck.py: -------------------------------------------------------------------------------- 1 | import math 2 | from typing import Tuple 3 | 4 | from .parameters import Parameters 5 | from .aerosol_model import AerosolModel 6 | 7 | 8 | class AerosolModelMaxPlanck(AerosolModel): 9 | """Aerosol transmission estimator 10 | 11 | Model Calculations of Aerosol Transmission and Infection Risk of COVID-19 in Indoor Environments :cite:`ijerph17218114` 12 | 13 | An adjustable algorithm to estimate the infection risk for different indoor environments, 14 | constrained by published data of human aerosol emissions, SARS-CoV-2 viral loads, infective dose and other parameters. 15 | Evaluates typical indoor settings such as an office, a classroom, choir practice, and a reception/party. 16 | 17 | The model includes a number of modifiable environmental factors that represent relevant physiological parameters and environmental conditions. 18 | For simplicity, all subjects are assumed to be equal in terms of breathing, speaking and susceptibility to infection. 19 | The model parameters can be easily adjusted to account for different environmental conditions and activities. 20 | 21 | """ 22 | 23 | name: str = "MaxPlanck" 24 | 25 | def __init__(self, params): 26 | super().__init__(params) 27 | self.params = params 28 | 29 | def get_risk(self, inputs: Parameters) -> Tuple[float, float]: 30 | """Calculate the infection risk of an individual in a room 31 | and the CO\ :sub:`2` thrown into the air. 32 | 33 | Args: 34 | inputs (Parameters): model parameters 35 | 36 | Returns: 37 | Tuple[float, float]: CO\ :sub:`2` concentration (ppm), and infection risk probability 38 | """ 39 | params = self.params 40 | # inputs: room_area, room_height, room_ventilation_rate, mask_efficiency, time_in_room_h, susceptible_people 41 | 42 | infection_probability = 1 - 10 ** (math.log10(0.5) / params.RNA_D50) 43 | RNA_content = params.RNA_concentration * math.pi / 6 * (params.aerosol_diameter / 10000) ** 3 44 | aerosol_emission = (params.emission_breathing * (1 - params.speaking_breathing_ratio) + params.emission_speaking * params.speaking_breathing_ratio) * 1000 * params.respiratory_rate * 60 45 | aerosol_concentration = aerosol_emission / (inputs.room_area * inputs.room_height * 1000) 46 | RNA_concentration = aerosol_concentration * RNA_content 47 | RNA_dosis = params.respiratory_rate * 60 * RNA_concentration * params.deposition_rate 48 | 49 | dosis_infectious = RNA_dosis / (inputs.room_ventilation_rate + 1 / params.virus_lifetime) * (1 - inputs.mask_efficiency) * inputs.time_in_room_h 50 | risk_one_person = (1 - ((1 - infection_probability) ** dosis_infectious) ** inputs.num_people) * 100 51 | 52 | # Return results 53 | dosis_min, dosis_max = 0, 1 54 | co2_min, co2_max = 0, 80 55 | co2_dosis = (dosis_infectious - dosis_min) / (dosis_max - dosis_min) * (co2_max - co2_min) + co2_min 56 | 57 | air_contamination = co2_dosis 58 | infection_risk = risk_one_person 59 | 60 | return air_contamination, infection_risk 61 | -------------------------------------------------------------------------------- /archABM/aerosol_model_mit.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | from .parameters import Parameters 3 | from .aerosol_model import AerosolModel 4 | 5 | 6 | class AerosolModelMIT(AerosolModel): 7 | """Aerosol transmission estimator 8 | 9 | MIT COVID-19 Indoor Safety Guideline :cite:`Bazante2018995118,Bazant2021.04.04.21254903,Risbeck2021.06.21.21259287` 10 | 11 | Theoretical model that quantifies the extent to which transmission risk is reduced in large rooms 12 | with high air exchange rates, increased for more vigorous respiratory activities, and dramatically reduced by the use of face masks. 13 | Consideration of a number of outbreaks yields self-consistent estimates for the infectiousness of the new coronavirus. 14 | 15 | """ 16 | 17 | name: str = "MIT" 18 | 19 | def __init__(self, params): 20 | super().__init__(params) 21 | self.params = params 22 | 23 | def get_risk(self, inputs: Parameters) -> Tuple[float, float]: 24 | """Calculate the infection risk of an individual in a room 25 | and the CO\ :sub:`2` thrown into the air. 26 | 27 | Args: 28 | inputs (Parameters): model parameters 29 | 30 | Returns: 31 | Tuple[float, float]: CO\ :sub:`2` concentration (ppm), and infection risk probability 32 | """ 33 | params = self.params 34 | 35 | area = inputs.room_area 36 | height = inputs.room_height 37 | volume = area * height 38 | 39 | ventilation = inputs.room_ventilation_rate 40 | ventilation_flow = ventilation * volume 41 | 42 | recirculation = 1 43 | recirculation_flow = recirculation * volume 44 | 45 | air_flow = ventilation_flow + recirculation_flow 46 | air_outdoor_fraction = ventilation_flow / air_flow 47 | 48 | filtration_efficiency = params.filtration_efficiency # 0.01 49 | filtration_rate = filtration_efficiency * recirculation_flow / volume 50 | relative_humidity = params.relative_humidity / 100 # 60 / 100 51 | 52 | breathing_rate = params.breathing_rate # 0.49 53 | aerosol_radius = params.aerosol_radius # 2 54 | aerosol_radius_humidity = aerosol_radius * (0.4 / (1 - relative_humidity)) ** (1 / 3) 55 | 56 | infectiousness = params.infectiousness # 72 57 | deactivation_rate = params.deactivation_rate # 0.3 58 | deactivation_rate_humidity = deactivation_rate * relative_humidity / 50 59 | transmissibility = params.transmissibility # 1 60 | 61 | settling_speed = (2 / 9) * 1100 / (1.86 * 10 ** (-5)) * 9.8 / 1e9 * aerosol_radius_humidity ** 2 # * 60 * 60 / 1000 62 | relaxation_rate = ventilation + deactivation_rate_humidity + filtration_rate + settling_speed * 60 * 60 / 1000 / ventilation 63 | dillution_factor = breathing_rate / (relaxation_rate * volume) 64 | infectiousness_room = infectiousness * dillution_factor 65 | 66 | mask_passage_probability = 1 - inputs.mask_efficiency # 0.145 67 | transmission_rate = (breathing_rate * mask_passage_probability) ** 2 * infectiousness * transmissibility / (volume * relaxation_rate) 68 | 69 | # if risk tolerance is fixed 70 | # risk_tolerance = 0.1 71 | # exposure_time = inputs.time_in_room_h # 10 72 | # maximum_occupancy_transient = 1 + risk_tolerance * (1 + 1/(relaxation_rate*exposure_time)) / (transmission_rate * exposure_time) 73 | # maximum_occupancy_steady = 1 + risk_tolerance / (transmission_rate * exposure_time) 74 | 75 | # occupancy = inputs.num_people 76 | # maximum_exposure_time_steady = risk_tolerance / (occupancy - 1) / transmission_rate 77 | # maximum_exposure_time_transient = maximum_exposure_time_steady * (1 + math.sqrt(1 + 4/(relaxation_rate*maximum_exposure_time_steady) )) / 2 78 | 79 | # background_co2 = params.background_co2 # 410 80 | # average_co2 = params.average_co2 # 700 81 | # maximum_exposure_time_co2 = risk_tolerance * 38000 * relaxation_rate / \ 82 | # ((average_co2 - background_co2) * breathing_rate * infectiousness * \ 83 | # transmissibility * mask_passage_probability * mask_passage_probability * ventilation ) 84 | 85 | # co2_concentration = background_co2 + risk_tolerance * 38000 * relaxation_rate / (exposure_time * breathing_rate * infectiousness * transmissibility * mask_passage_probability * mask_passage_probability * ventilation) 86 | 87 | # if exposure time and occupancy is fixed 88 | exposure_time = inputs.time_in_room_h # 10 89 | occupancy = inputs.num_people 90 | risk_tolerance_steady = (occupancy - 1) * transmission_rate * exposure_time 91 | risk_tolerance_transient = risk_tolerance_steady / (1 + 1 / (relaxation_rate * exposure_time)) 92 | 93 | background_co2 = params.background_co2 # 410 94 | co2_concentration = background_co2 + risk_tolerance_steady * 38000 * relaxation_rate / ( 95 | exposure_time * breathing_rate * infectiousness * transmissibility * mask_passage_probability * mask_passage_probability * ventilation 96 | ) 97 | 98 | # Return results 99 | air_contamination = co2_concentration - background_co2 100 | infection_risk = risk_tolerance_steady 101 | 102 | print("hey", risk_tolerance_steady, relaxation_rate, exposure_time, breathing_rate, infectiousness, transmissibility, mask_passage_probability, ventilation) 103 | 104 | return air_contamination, infection_risk 105 | -------------------------------------------------------------------------------- /archABM/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | here = os.path.dirname(__file__) 5 | with open(os.path.join(here, 'config.json'), "r") as f: 6 | config = json.load(f) -------------------------------------------------------------------------------- /archABM/creator.py: -------------------------------------------------------------------------------- 1 | from random import sample 2 | from typing import List 3 | 4 | from simpy import Environment 5 | 6 | from .actions import Actions 7 | from .database import Database 8 | from .event_model import EventModel 9 | from .options import Options 10 | from .parameters import Parameters 11 | from .person import Person 12 | from .place import Place 13 | 14 | 15 | class Creator: 16 | """Initializes the required assets to run a simulation: 17 | :class:`~archABM.options.Options`, 18 | :class:`~archABM.aerosol_model.AerosolModel`, 19 | :class:`~archABM.event.Event`, 20 | :class:`~archABM.place.Place`, 21 | :class:`~archABM.actions.Actions`, 22 | :class:`~archABM.person.Person`. 23 | """ 24 | 25 | env: Environment 26 | config: dict 27 | db: Database 28 | 29 | def __init__(self, env: Environment, config: dict, db: Database) -> None: 30 | self.env = env 31 | self.config = config 32 | self.db = db 33 | 34 | Person.reset() 35 | Place.reset() 36 | EventModel.reset() 37 | 38 | def create_options(self) -> Options: 39 | """Initializes general :class:`~archABM.options.Options` for the simulation. 40 | 41 | Returns: 42 | Options: general :class:`~archABM.options.Options` for the simulation 43 | """ 44 | p = self.config["options"] 45 | params = Parameters(p) 46 | options = Options(self.env, self.db, params) 47 | 48 | return options 49 | 50 | def create_events(self) -> List[EventModel]: 51 | """Initializes list of :class:`~archABM.event_model.EventModel` and their respective :class:`~archABM.parameters.Parameters`. 52 | 53 | Returns: 54 | List[EventModel]: types of events or activities 55 | """ 56 | events = [] 57 | for e in self.config["events"]: 58 | params = Parameters(e) 59 | event = EventModel(params) 60 | events.append(event) 61 | 62 | return events 63 | 64 | def create_places(self) -> List[Place]: 65 | """Initializes list of :class:`~archABM.place.Place` and their respective :class:`~archABM.parameters.Parameters`. 66 | 67 | Returns: 68 | List[Place]: list of available places 69 | """ 70 | places = [] 71 | for p in self.config["places"]: 72 | params = Parameters(p) 73 | place = Place(self.env, self.db, params) 74 | places.append(place) 75 | 76 | return places 77 | 78 | def create_actions(self) -> Actions: 79 | """Initializes instance of class :class:`~archABM.actions.Actions`. 80 | 81 | Returns: 82 | Actions: instance of class :class:`~archABM.actions.Actions` 83 | """ 84 | return Actions(self.env, self.db) 85 | 86 | def create_people(self) -> List[Person]: 87 | """Initializes list of :class:`~archABM.person.Person` and their respective :class:`~archABM.parameters.Parameters`. 88 | 89 | It also sets the status of certain :class:`~archABM.person.Person` from ``susceptible`` to ``infected``. 90 | This is controlled by the ``ratio_infected`` configuration parameter. 91 | 92 | Returns: 93 | List[Person]: simulation people 94 | """ 95 | people = [] 96 | for p in self.config["people"]: 97 | params = Parameters(p) 98 | person = Person(self.env, self.db, params) 99 | person.start() 100 | people.append(person) 101 | 102 | num_people = len(people) 103 | num_infected = int(max(1, self.config["options"]["ratio_infected"] * num_people)) 104 | for p in sample(people, num_infected): 105 | p.status = 1 106 | 107 | return people 108 | 109 | def create_model(self): 110 | """Initializes the selected COVID19 aerosol model. 111 | 112 | At the moment, there are three models available: 113 | 114 | #. :class:`~archABM.aerosol_model_colorado.AerosolModelColorado`: COVID-19 Airborne Transmission Estimator \ 115 | :cite:`doi:10.1021/acs.estlett.1c00183,https://doi.org/10.1111/ina.12751,Peng2021.04.21.21255898` 116 | 117 | #. :class:`~archABM.aerosol_model_mit.AerosolModelMIT`: MIT COVID-19 Indoor Safety Guideline \ 118 | :cite:`Bazante2018995118,Bazant2021.04.04.21254903,Risbeck2021.06.21.21259287` 119 | 120 | #. :class:`~archABM.aerosol_model_maxplanck.AerosolModelMaxPlanck`: Model Calculations of Aerosol Transmission and \ 121 | Infection Risk of COVID-19 in Indoor Environments :cite:`ijerph17218114` 122 | 123 | Returns: 124 | AerosolModel: selected aerosol model 125 | """ 126 | options = self.config["options"] 127 | selection = options["model"] 128 | params = Parameters(options["model_parameters"][selection]) 129 | model = None 130 | if selection == "MaxPlanck": 131 | from .aerosol_model_maxplanck import AerosolModelMaxPlanck 132 | 133 | model = AerosolModelMaxPlanck(params) 134 | elif selection == "MIT": 135 | from .aerosol_model_mit import AerosolModelMIT 136 | 137 | model = AerosolModelMIT(params) 138 | elif selection == "Colorado": 139 | from .aerosol_model_colorado import AerosolModelColorado 140 | 141 | model = AerosolModelColorado(params) 142 | 143 | return model 144 | -------------------------------------------------------------------------------- /archABM/database.py: -------------------------------------------------------------------------------- 1 | class Database: 2 | """In-memory database of the simulation components 3 | 4 | It registers: 5 | :class:`~archABM.options.Options`, 6 | :class:`~archABM.aerosol_model.AerosolModel`, 7 | :class:`~archABM.actions.Actions`, 8 | :class:`~archABM.event.Event` list, 9 | :class:`~archABM.place.Place` list, 10 | :class:`~archABM.person.Person` list, 11 | simulation run ID. 12 | """ 13 | 14 | model: None 15 | actions: None 16 | options: None 17 | events: list 18 | places: list 19 | people: list 20 | run: int 21 | 22 | def __init__(self) -> None: 23 | self.options = None 24 | self.model = None 25 | self.actions = None 26 | self.events = [] 27 | self.places = [] 28 | self.people = [] 29 | 30 | self.run = -1 31 | 32 | def next(self) -> None: 33 | """Increments one unit the simulation run ID""" 34 | self.run += 1 35 | -------------------------------------------------------------------------------- /archABM/engine.py: -------------------------------------------------------------------------------- 1 | from jsonschema import validate 2 | from simpy import Environment 3 | from tqdm import tqdm 4 | 5 | from .creator import Creator 6 | from .database import Database 7 | from .results import Results 8 | 9 | import json 10 | import os 11 | import copy 12 | 13 | 14 | class Engine: 15 | """Core class of the archABM package 16 | 17 | Launches the agent-based simulation with the specified configuration. 18 | """ 19 | 20 | config: dict 21 | db: Database 22 | env: Environment 23 | 24 | def __init__(self, config: dict) -> None: 25 | schema = self.retrieve_schema() 26 | validate(instance=config, schema=schema) 27 | 28 | self.config = self.preprocess(config) 29 | 30 | def retrieve_schema(self): 31 | """Get configuration file JSON schema 32 | 33 | Returns: 34 | dict: json-schema 35 | """ 36 | dir_path = os.path.dirname(os.path.realpath(__file__)) 37 | with open(dir_path + "/schema.json", "r") as f: 38 | schema = json.load(f) 39 | return schema 40 | 41 | def preprocess(self, config) -> None: 42 | """Processes the configuration dictionary to generate people. 43 | 44 | Based on the specified configuration of number of people per group, 45 | this method generates an array of people, and assignes a incremental name to each person. 46 | """ 47 | config = copy.deepcopy(config) 48 | 49 | num_people = 0 50 | for person in config["people"]: 51 | num_people += person["num_people"] 52 | 53 | for place in config["places"]: 54 | if place["capacity"] is None: 55 | place["capacity"] = num_people + 1 56 | 57 | people = [] 58 | cont = 0 59 | for person in config["people"]: 60 | num_people = person.pop("num_people") 61 | for i in range(num_people): 62 | person["name"] = "person" + str(cont) 63 | people.append(person.copy()) 64 | cont += 1 65 | config["people"] = people 66 | return config 67 | 68 | def setup(self) -> None: 69 | """Setup for a simulation run. 70 | 71 | Creates the environment and the required assets to run a simulation: 72 | :class:`~archABM.options.Options`, 73 | :class:`~archABM.aerosol_model.AerosolModel`, 74 | :class:`~archABM.event.Event`, 75 | :class:`~archABM.place.Place`, 76 | :class:`~archABM.actions.Actions`, 77 | :class:`~archABM.person.Person`. 78 | """ 79 | self.env = Environment() 80 | self.db.next() 81 | 82 | god = Creator(self.env, self.config, self.db) 83 | self.db.options = god.create_options() 84 | self.db.model = god.create_model() 85 | self.db.events = god.create_events() 86 | self.db.places = god.create_places() 87 | self.db.actions = god.create_actions() 88 | self.db.people = god.create_people() 89 | 90 | def run(self, until: int = None, number_runs: int = None) -> dict: 91 | """Launches a batch of simulations 92 | 93 | Args: 94 | until (int, optional): duration of each simulation, in minutes. Defaults to None. 95 | number_runs (int, optional): number of simulation runs. Defaults to None. 96 | 97 | Returns: 98 | dict: simulation history and configuration 99 | """ 100 | self.db = Database() 101 | self.db.results = Results(self.config) 102 | 103 | if until is None: 104 | until = 1440 105 | if number_runs is None: 106 | number_runs = self.config["options"]["number_runs"] 107 | 108 | with tqdm(total=number_runs) as pbar: 109 | for i in range(number_runs): 110 | self.setup() 111 | self.env.run(until) 112 | pbar.update(1) 113 | return self.db.results.done() 114 | -------------------------------------------------------------------------------- /archABM/event.py: -------------------------------------------------------------------------------- 1 | from .event_model import EventModel 2 | from .place import Place 3 | 4 | 5 | class Event: 6 | """Event primitive 7 | 8 | An event is defined by an activity :class:`~archABM.event_model.EventModel`, that happens at some physical location 9 | :class:`~archABM.place.Place``, for a finite period of time, in minutes (duration). 10 | """ 11 | 12 | model: EventModel 13 | place: Place 14 | duration: int 15 | 16 | def __init__(self, model: EventModel, place: Place, duration: int) -> None: 17 | self.model = model 18 | self.place = place 19 | self.duration = duration 20 | -------------------------------------------------------------------------------- /archABM/event_generator.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from simpy import Environment 4 | 5 | from .database import Database 6 | from .event_model import EventModel 7 | 8 | 9 | class EventGenerator: 10 | """Generates events 11 | 12 | An event is defined by an activity :class:`~archABM.event_model.EventModel`, that happens at a given physical location 13 | :class:`~archABM.place.Place`, for a finite period of time, in minutes (duration). 14 | 15 | A event generator has certain event models to choose from, 16 | each one related to an activity. 17 | 18 | """ 19 | 20 | env: Environment 21 | db: Database 22 | models: list 23 | activities: list 24 | 25 | def __init__(self, env: Environment, db: Database): 26 | self.env = env 27 | self.db = db 28 | 29 | # generate new for allowed events 30 | self.models = [m.new() for m in self.db.events if m.params.allow] 31 | self.activities = [m.params.activity for m in self.models] 32 | 33 | def generate(self, now: int, person): 34 | """Generates events 35 | 36 | First, it computes the probabilities and the validity 37 | of each :class:`~archABM.event_model.EventModel` at the current timestamp. 38 | Then, the activity is selected based on these probabilities as follows: 39 | 40 | * If there exists any probability among the list of :class:`~archABM.event_model.EventModel`, the activity is selected randomly according to the relative probabilities. 41 | * If all :class:`~archABM.event_model.EventModel` have ``0`` probability, then the activity is selected randomly among the valid ones. 42 | * Otherwise a random activity is returned. 43 | 44 | Once the activity type :class:`~archABM.event_model.EventModel` has been selected, 45 | the event duration can computed and the physical location :class:`~archABM.place.Place`` can also be chosen. 46 | 47 | The selected activity is counted (consumed) from the list of :class:`~archABM.event_model.EventModel` of the invoking person. 48 | 49 | .. note:: 50 | Collective activities are consumed individually after the current event interruption. 51 | 52 | Args: 53 | now (int): current timestamp in minutes 54 | person (Person): person that invokes the event generation 55 | 56 | Returns: 57 | Event: generated :class:`~archABM.event.Event`, which is a set of 58 | a) activity :class:`~archABM.event_model.EventModel`, 59 | b) physical location :class:`~archABM.place.Place`` and 60 | c) time duration. 61 | """ 62 | # Get probabilities for each model event 63 | p = [m.probability(now) for m in self.models] 64 | v = [m.valid() for m in self.models] 65 | 66 | # Select event model 67 | if sum(p) > 0: 68 | model = random.choices(self.models, weights=p)[0] 69 | elif sum(v) > 0: 70 | model = random.choices(self.models, weights=v)[0] 71 | else: 72 | model = random.choice(self.models) 73 | 74 | # Create event based on selected model 75 | activity = model.params.activity 76 | duration = model.duration(now) 77 | # duration += 0.001 78 | place = self.db.actions.find_place(model, person) 79 | if place is None: 80 | return None 81 | if model.params.collective: 82 | return self.db.actions.create_collective_event(model, place, duration, person) 83 | else: 84 | model.consume() 85 | return self.db.actions.create_event(model, place, duration) 86 | 87 | def consume_activity(self, model: EventModel): 88 | """Consumes a unit from a given :class:`~archABM.event_model.EventModel`. 89 | 90 | Args: 91 | model (EventModel): event model to consume from 92 | """ 93 | for m in self.models: 94 | if m.params.activity == model.params.activity: 95 | m.consume() 96 | 97 | def valid_activity(self, model: EventModel): 98 | """Checks whether a given :class:`~archABM.event_model.EventModel` is valid. 99 | 100 | Args: 101 | model (EventModel): event model to check validity from 102 | 103 | Returns: 104 | [bool]: whether the event model is valid 105 | """ 106 | for m in self.models: 107 | if m.params.activity == model.params.activity: 108 | return m.valid() 109 | -------------------------------------------------------------------------------- /archABM/event_model.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import random 3 | from .parameters import Parameters 4 | 5 | 6 | class EventModel: 7 | """Defines an event model, also called "activity" 8 | 9 | An event model is defined by these parameters: 10 | 11 | * Activity name: :obj:`str` 12 | * Schedule: :obj:`list` of :obj:`tuple` (in minutes :obj:`int`) 13 | * Repetitions range: minimum (:obj:`int`) and maximum (:obj:`int`) 14 | * Duration range: minimum (:obj:`int`) and maximum (:obj:`int`) in minutes 15 | * Other parameters: 16 | 17 | * mask efficiency ratio: :obj:`float` 18 | * collective event: :obj:`bool` 19 | * shared event: :obj:`bool` 20 | 21 | The schedule defines the allowed periods of time in which an activity can happen. 22 | For example, ``schedule=[(120,180),(240,300)]`` allows people to carry out this activity from 23 | the time ``120`` to ``180`` and also from time ``240`` until ``300``. 24 | Notice that the schedule units are in minutes. 25 | 26 | Each activity is limited to a certain duration, and its priority follows 27 | a piecewise linear function, parametrized by: 28 | 29 | * ``r``: repeat\ :sub:`min` 30 | * ``R``: repeat\ :sub:`max` 31 | * ``e``: event count 32 | 33 | .. math:: 34 | Priority(e) = 35 | \\left\{\\begin{matrix} 36 | 1-(1-\\alpha)\\cfrac{e}{r}\,,\quad 0 \leq e < r \\\\ 37 | \\alpha\\cfrac{R-e}{R-r}\,,\quad r \leq e < R \\ 38 | \end{matrix}\\right. 39 | 40 | 41 | 42 | .. tikz:: Priority piecewise linear function 43 | \pgfmathsetmacro{\\N}{10}; 44 | \pgfmathsetmacro{\\M}{6}; 45 | \pgfmathsetmacro{\\NN}{\\N-1}; 46 | \pgfmathsetmacro{\\MM}{\\M-1}; 47 | \pgfmathsetmacro{\\repmin}{2.25}; 48 | \pgfmathsetmacro{\\repmax}{8.5}; 49 | \pgfmathsetmacro{\\a}{2}; 50 | \coordinate (A) at (0,\\MM); 51 | \coordinate (B) at (\\NN,0); 52 | \coordinate (C) at (\\repmin, \\a); 53 | \coordinate (D) at (\\repmax, 0); 54 | \coordinate (E) at (\\repmin, 0); 55 | \coordinate (F) at (0, \\a); 56 | \draw[stepx=1,thin, black!20] (0,0) grid (\\N,\\M); 57 | \draw[->, very thick] (0,0) to (\\N,0) node[right] {Event count}; 58 | \draw[->, very thick] (0,0) to (0,\\M) node[above] {Priority}; 59 | \draw (0.1,0) -- (-0.1, 0) node[anchor=east] {0}; 60 | \draw (0, 0.1) -- (0, -0.1); 61 | \draw (\\repmin,0.1) -- (\\repmin,-0.1) node[anchor=north] {$repeat_{min}$}; 62 | \draw (\\repmax,0.1) -- (\\repmax,-0.1) node[anchor=north] {$repeat_{max}$}; 63 | \draw[ultra thick] (0.1, \\MM) -- (-0.1, \\MM) node[left] {1}; 64 | \draw[very thick, black!50, dashed] (C) -- (F) node[left] {$\\alpha$}; 65 | \draw[very thick, black!50, dashed] (C) -- (E); 66 | \draw[ultra thick, red] (A) -- (C); 67 | \draw[ultra thick, red] (C) -- (D); 68 | :xscale: 80 69 | :align: left 70 | 71 | """ 72 | 73 | id: int = -1 74 | params: Parameters 75 | count: int 76 | noise: int 77 | 78 | def __init__(self, params: Parameters) -> None: 79 | self.next() 80 | self.id = EventModel.id 81 | 82 | self.params = params 83 | self.count = 0 84 | self.noise = None 85 | 86 | @classmethod 87 | def reset(cls) -> None: 88 | """Resets :class:`~archABM.event_model.EventModel` ID.""" 89 | EventModel.id = -1 90 | 91 | @staticmethod 92 | def next() -> None: 93 | """Increments one unit the :class:`~archABM.event_model.EventModel` ID.""" 94 | EventModel.id += 1 95 | 96 | def get_noise(self) -> int: 97 | """Generates random noise 98 | 99 | Returns: 100 | int: noise amount in minutes 101 | """ 102 | if self.noise is None: 103 | m = 15 # minutes # TODO: review hardcoded value 104 | if m == 0: 105 | self.noise = 0 106 | else: 107 | self.noise = random.randrange(m) # minutes 108 | return self.noise 109 | 110 | def new(self): 111 | """Generates a :class:`~archABM.event_model.EventModel` copy, with reset count and noise 112 | 113 | Returns: 114 | EventModel: cloned instance 115 | """ 116 | self.count = 0 117 | self.noise = None 118 | return copy.copy(self) 119 | 120 | def duration(self, now) -> int: 121 | """Generates a random duration between :attr:`duration_min` and :attr:`duration_max`. 122 | 123 | .. note:: 124 | If the generated duration, together with the current timestamp, 125 | exceeds the allowed schedule, the duration is limited to finish 126 | at the scheduled time interval. 127 | 128 | 129 | The :attr:`noise` attribute is used to model the schedule's time tolerance. 130 | 131 | Args: 132 | now (int): current timestamp in minutes 133 | 134 | Returns: 135 | int: event duration in minutes 136 | """ 137 | duration = random.randint(self.params.duration_min, self.params.duration_max) 138 | estimated = now + duration 139 | noise = self.get_noise() # minutes 140 | for interval in self.params.schedule: 141 | a, b = interval 142 | if a - noise <= now <= b + noise < estimated: 143 | duration = b + noise - now + 1 144 | break 145 | return duration 146 | 147 | def priority(self) -> float: 148 | """Computes the priority of a certain event. 149 | 150 | The priority function follows a piecewise linear function, parametrized by: 151 | 152 | * ``r``: repeat\ :sub:`min` 153 | * ``R``: repeat\ :sub:`max` 154 | * ``e``: event count 155 | 156 | .. math:: 157 | Priority(e) = 158 | \\left\{\\begin{matrix} 159 | 1-(1-\\alpha)\\cfrac{e}{r}\,,\quad 0 \leq e < r \\\\ 160 | \\alpha\\cfrac{R-e}{R-r}\,,\quad r \leq e < R \\ 161 | \end{matrix}\\right. 162 | 163 | Returns: 164 | float: priority value [0-1] 165 | """ 166 | alpha = 0.5 # TODO: review hardcoded value 167 | if self.params.repeat_max is None: 168 | return random.uniform(0.0, 1.0) 169 | if self.count == self.params.repeat_max: 170 | return 0.0 171 | if self.count < self.params.repeat_min: 172 | return 1 - (1 - alpha) * self.count / self.params.repeat_min 173 | if self.params.repeat_min == self.params.repeat_max: 174 | return alpha 175 | return alpha * (self.params.repeat_max - self.count) / (self.params.repeat_max - self.params.repeat_min) 176 | 177 | def probability(self, now: int) -> float: 178 | """Wrapper to call the priority function 179 | 180 | If the event :attr:`count` is equal to the :attr:`repeat_max` parameters, 181 | it yields a ``0`` probability. Otherwise, it computes the :meth:`priority` function 182 | described above. 183 | 184 | Args: 185 | now (int): current timestamp in minutes 186 | 187 | Returns: 188 | float: event probability [0-1] 189 | """ 190 | p = 0.0 191 | if self.count == self.params.repeat_max: 192 | return p 193 | 194 | noise = self.get_noise() # minutes 195 | for interval in self.params.schedule: 196 | a, b = interval 197 | if a - noise <= now <= b + noise: 198 | p = self.priority() 199 | break 200 | 201 | return p 202 | 203 | def valid(self) -> bool: 204 | """Computes whether the event count has reached the :attr:`repeat_max` limit. 205 | 206 | It yields ``True`` 207 | if :attr:`repeat_max` is ``undefined`` or 208 | if the event :attr:`count` is less than :attr:`repeat_max`. 209 | Otherwise, it yields ``False``. 210 | 211 | Returns: 212 | bool: valid event 213 | """ 214 | if self.params.repeat_max is None: 215 | return True 216 | return self.count < self.params.repeat_max 217 | 218 | def consume(self) -> None: 219 | """Increments one unit the event count""" 220 | self.count += 1 221 | # logging.info("Event %s repeated %d out of %d" % (self.name, self.count, self.target)) 222 | 223 | def supply(self) -> None: 224 | """Decrements one unit the event count""" 225 | self.count -= 1 226 | -------------------------------------------------------------------------------- /archABM/options.py: -------------------------------------------------------------------------------- 1 | from simpy import Environment 2 | 3 | from .database import Database 4 | from .parameters import Parameters 5 | 6 | 7 | class Options: 8 | """Stores general options for the simulation""" 9 | 10 | env: Environment 11 | db: Database 12 | params: Parameters 13 | 14 | def __init__(self, env: Environment, db: Database, params: Parameters) -> None: 15 | self.env = env 16 | self.db = db 17 | self.params = params 18 | -------------------------------------------------------------------------------- /archABM/parameters.py: -------------------------------------------------------------------------------- 1 | from typing import Generator 2 | 3 | 4 | class Parameters: 5 | """Helper to access dictionary elements as class attributes 6 | """ 7 | 8 | __dict__: dict 9 | 10 | def __init__(self, __dict__: dict = None) -> None: 11 | if __dict__ is not None: 12 | self.__dict__ = __dict__ 13 | 14 | def __repr__(self) -> str: 15 | return str(self.__dict__) 16 | 17 | def __iter__(self) -> Generator[str, None, None]: 18 | for key, value in self.__dict__.items(): 19 | yield key, value 20 | 21 | def __copy__(self): 22 | newone = type(self)() 23 | newone.__dict__.update(self.__dict__) 24 | return newone 25 | 26 | def copy(self): 27 | """Return a shallow copy of the instance. 28 | 29 | Returns: 30 | Parameters: cloned object 31 | """ 32 | newone = type(self)() 33 | newone.__dict__.update(self.__dict__) 34 | return newone 35 | -------------------------------------------------------------------------------- /archABM/person.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from simpy import Environment, Interrupt, Process, Timeout 4 | 5 | from .database import Database 6 | from .place import Place 7 | from .event import Event 8 | from .event_generator import EventGenerator 9 | from .event_model import EventModel 10 | from .parameters import Parameters 11 | from .snapshot_person import SnapshotPerson 12 | 13 | 14 | class Person: 15 | """Person primitive""" 16 | 17 | id = -1 18 | 19 | def __init__(self, env: Environment, db: Database, params: Parameters) -> None: 20 | self.next() 21 | self.id = Person.id 22 | 23 | self.env = env 24 | self.db = db 25 | self.params = params 26 | 27 | self.generator = EventGenerator(env, db) 28 | self.current_process = None 29 | self.place = None 30 | self.event = None 31 | self.model = None 32 | self.duration = None 33 | 34 | self.status = 0 # 0: susceptible, 1: infective 35 | 36 | self.CO2_level = self.db.model.params.CO2_background 37 | self.quanta_inhaled = 0.0 38 | 39 | self.elapsed = 0.0 40 | self.last_updated = 0 41 | 42 | self.snapshot = SnapshotPerson() 43 | 44 | @classmethod 45 | def reset(cls) -> None: 46 | """Resets :class:`~archABM.person.Person` ID.""" 47 | Person.id = -1 48 | 49 | @staticmethod 50 | def next() -> None: 51 | """Increments one unit the :class:`~archABM.person.Person` ID.""" 52 | Person.id += 1 53 | 54 | def start(self) -> None: 55 | """Initiates the event queue processing""" 56 | logging.info("[%.2f] Person %d starting up" % (self.env.now, self.id)) 57 | self.env.process(self.process()) 58 | 59 | def process(self) -> None: 60 | """Processes the chain of discrete events 61 | 62 | The person is moved from the current :class:`~archABM.place.Place` to the new one based on the generated :class:`~archABM.event.Event`, 63 | and stays there for a defined duration (in minutes). 64 | 65 | Once an event or task get fulfilled, the :class:`~archABM.event_generator.EventGenerator` produces a new :class:`~archABM.event.Event`. 66 | If, after a limited number of trials, the :class:`~archABM.event_generator.EventGenerator` is not able to correctly generate an event, 67 | a random one is created. 68 | 69 | If a person gets interrupted while carrying out (waiting) its current task, the assigned event happens to be the new one. 70 | 71 | .. note:: 72 | State snapshots are taken after each event if fulfilled. 73 | 74 | Yields: 75 | Process: an event yielding generator 76 | """ 77 | cont_event = 0 78 | cont_event_max = 1000 # TODO: review maximum allowed number of events per person 79 | while True: 80 | # generate event 81 | cont_generator = 0 82 | cont_generator_max = 3 83 | while self.event is None: # TODO: review maximum allowed number of events per person 84 | self.event = self.generator.generate(self.env.now, self) 85 | cont_generator += 1 86 | # if exceeded, generate random event 87 | if cont_generator > cont_generator_max: 88 | duration = self.model.duration(self.env.now) 89 | self.event = Event(self.model, self.place, duration) 90 | break 91 | self.model = self.event.model 92 | self.duration = self.event.duration 93 | activity = self.model.params.activity 94 | 95 | # TODO: review if we want to save only new places or all of them => self.place != self.event.place and not self.event.place.full() 96 | # move from current place to new one 97 | if self.event is not None and self.event.place is not None: 98 | 99 | if self.place != self.event.place and not self.event.place.full(): 100 | # remove from current place 101 | if self.place is not None: 102 | self.place.remove_person(self) 103 | 104 | # add to new place 105 | self.place = self.event.place 106 | self.place.add_person(self, self.event) 107 | else: 108 | self.place.update_place() 109 | 110 | # save snapshot (if first event or elapsed time > 0) 111 | elapsed = self.env.now - self.last_updated 112 | if elapsed > 0 or cont_event == 0: 113 | self.save_snapshot() 114 | pass 115 | 116 | logging.info("[%.2f] Person %d event %s at place %s for %d minutes" % ( 117 | self.env.now, self.id, self.model.params.activity, self.place.params.name, self.duration,)) 118 | 119 | self.event = None 120 | self.last_updated = self.env.now 121 | self.current_process = self.env.process(self.wait()) 122 | yield self.current_process 123 | 124 | cont_event += 1 125 | if cont_event > cont_event_max: 126 | break 127 | 128 | def wait(self) -> None: 129 | """Wait for a certain amount of time 130 | 131 | Yields: 132 | Timeout: event triggered after a delay has passed 133 | """ 134 | try: 135 | yield self.env.timeout(self.duration) 136 | except Interrupt: 137 | # print("interrupted") 138 | pass 139 | 140 | def assign_event(self, event: Event) -> None: 141 | """Interrupt current task and assign new event 142 | 143 | Args: 144 | event (Event): new assigned event 145 | """ 146 | if self.current_process is not None and not self.current_process.triggered: 147 | self.current_process.interrupt("Need to go!") 148 | logging.info("[%.2f] Person %d interrupted current event" % (self.env.now, self.id)) 149 | self.event = event 150 | self.generator.consume_activity(event.model) 151 | 152 | # TODO: review if we need to update the risk of infected people as well 153 | # TODO: review infection risk metric: average vs cumulative 154 | def update(self, elapsed: float, quanta_inhaled: float, CO2_level: float) -> None: 155 | """Update the infection risk probability and the CO\ :sub:`2` concentration (ppm). 156 | 157 | Args: 158 | elapsed (float): event elapsed time (in minutes) 159 | infection_risk (float): infection risk probability 160 | CO2_level (float): CO\ :sub:`2` concentration (ppm) 161 | """ 162 | # self.elapsed += elapsed 163 | # self.infection_risk_avg += elapsed * (infection_risk - self.infection_risk_avg) / self.elapsed 164 | # self.infection_risk_cum += infection_risk 165 | # self.CO2_level += elapsed * (CO2_level - self.CO2_level) / self.elapsed 166 | 167 | self.CO2_level = CO2_level 168 | self.quanta_inhaled += quanta_inhaled 169 | 170 | def save_snapshot(self) -> None: 171 | """Saves state snapshot on :class:`~archABM.snapshot_person.SnapshotPerson`""" 172 | self.snapshot.set("run", self.db.run) 173 | self.snapshot.set("time", self.env.now, 0) 174 | self.snapshot.set("person", self.id) 175 | self.snapshot.set("status", self.status) 176 | self.snapshot.set("place", self.place.id) 177 | self.snapshot.set("event", self.model.id) 178 | self.snapshot.set("CO2_level", self.CO2_level, 2) 179 | self.snapshot.set("quanta_inhaled", self.quanta_inhaled, 6) 180 | self.db.results.write_person(self.snapshot) 181 | -------------------------------------------------------------------------------- /archABM/place.py: -------------------------------------------------------------------------------- 1 | from random import randrange 2 | from simpy import Environment 3 | 4 | from .database import Database 5 | from .parameters import Parameters 6 | from .snapshot_place import SnapshotPlace 7 | 8 | 9 | class Place: 10 | """Place primitive""" 11 | 12 | id: int = -1 13 | 14 | def __init__(self, env: Environment, db: Database, params: Parameters) -> None: 15 | self.next() 16 | self.id = Place.id 17 | 18 | self.env = env 19 | self.db = db 20 | self.params = params 21 | 22 | self.people = [] 23 | self.num_people = 0 24 | self.infective_people = 0 25 | 26 | self.CO2_baseline = self.db.model.params.CO2_background 27 | self.CO2_level = self.CO2_baseline 28 | self.quanta_level = 0.0 29 | self.temperature = self.db.model.params.temperature 30 | self.relative_humidity = self.db.model.params.relative_humidity 31 | 32 | self.elapsed = 0.0 33 | self.last_updated = 0.0 34 | 35 | self.events = self.get_events() 36 | self.event = None 37 | self.snapshot = SnapshotPlace() 38 | 39 | @classmethod 40 | def reset(cls) -> None: 41 | """Resets :class:`~archABM.place.Place` ID.""" 42 | Place.id = -1 43 | 44 | @staticmethod 45 | def next() -> None: 46 | """Increments one unit the :class:`~archABM.place.Place` ID.""" 47 | Place.id += 1 48 | 49 | def get_events(self) -> None: 50 | """Yields the corresponding :class:`~archABM.event_model.EventModel` 51 | 52 | Returns: 53 | EventModel: place's type of activity 54 | """ 55 | events = [] 56 | for e in self.db.events: 57 | if e.params.activity in self.params.activity: 58 | events.append(e) 59 | return events 60 | 61 | def add_person(self, person, event): 62 | """Add person to place 63 | 64 | Prior to the inclusion of the person, the ``air quality`` of the place is updated. 65 | Then, the number of people in the place is incremented by one unit, 66 | and the number of infective people is updated in case the entering person's status is ``infective``. 67 | Finally, a ``snapshot`` is taken and saved into the simulation history. 68 | 69 | Args: 70 | person (Person): person to be added 71 | """ 72 | # update air quality 73 | self.update_air(event) 74 | 75 | # add to list 76 | self.people.append(person) 77 | self.num_people += 1 78 | self.infective_people += person.status 79 | 80 | # save snapshot 81 | self.save_snapshot() 82 | 83 | def remove_person(self, person) -> None: 84 | """Remove person from place 85 | 86 | Prior to the exclusion of the person, the ``air quality`` of the place is updated. 87 | Then, the number of people in the place is decremented by one unit, 88 | and the number of infective people is updated in case the leaving person's status is ``infective``. 89 | Finally, a ``snapshot`` is taken and saved into the simulation history. 90 | 91 | Args: 92 | person (Person): person to be removed 93 | """ 94 | # update air quality 95 | self.update_air() 96 | 97 | # remove from list 98 | self.people.remove(person) 99 | self.num_people -= 1 100 | self.infective_people -= person.status 101 | 102 | # save snapshot 103 | self.save_snapshot() 104 | 105 | def update_place(self): 106 | """Update place air quality 107 | 108 | Updates the ``air quality`` of the place and saves a ``snapshot`` into the simulation history. 109 | """ 110 | # update air quality 111 | self.update_air() 112 | 113 | # save snapshot 114 | self.save_snapshot() 115 | 116 | def update_air(self, event=None) -> None: 117 | """Air quality update 118 | 119 | This method updates the air quality based on the selected :class:`~archABM.aerosol_model.AerosolModel`. 120 | 121 | The infection risk is also computed by the aerosol model, 122 | and is transferred to every person in the room. 123 | 124 | """ 125 | elapsed = self.env.now - self.last_updated 126 | if event is None: 127 | event = self.event 128 | good = any(event.model.params.activity == e.params.activity for e in self.events) 129 | if not good: 130 | for e in self.events: 131 | print(f, event.model.params.activity, e.params.activity, good) 132 | raise BaseException 133 | if event.model.params.shared and elapsed > 0: 134 | inputs = Parameters( 135 | { 136 | "room_area": self.params.area, 137 | "room_height": self.params.height, 138 | "room_ventilation_rate": self.params.ventilation, 139 | "recirculated_flow_rate": self.params.recirculated_flow_rate, 140 | "mask_efficiency": event.model.params.mask_efficiency, 141 | "event_duration": elapsed / 60, 142 | "num_people": self.num_people, 143 | "infective_people": self.infective_people, 144 | "CO2_level": self.CO2_level, 145 | "quanta_level": self.quanta_level, 146 | "temperature": self.temperature, 147 | "relative_humidity": self.relative_humidity 148 | } 149 | ) 150 | CO2_level, quanta_inhaled, quanta_level, temperature, relative_humidity = self.db.model.get_risk(inputs) 151 | 152 | # update place 153 | self.CO2_level = CO2_level 154 | self.quanta_level = quanta_level 155 | self.temperature = temperature 156 | self.relative_humidity = relative_humidity 157 | 158 | # self.elapsed += elapsed 159 | # self.infection_risk_avg += elapsed * (infection_risk - self.infection_risk_avg) / self.elapsed 160 | # self.infection_risk_cum += infection_risk 161 | 162 | # update people # TODO: review if we need to update the risk of infected people as well 163 | for p in self.people: 164 | p.update(elapsed, quanta_inhaled, CO2_level) 165 | self.last_updated = self.env.now 166 | self.event = event 167 | 168 | def people_attending(self) -> int: 169 | """Number of people attending a collective event 170 | 171 | .. note:: 172 | If the place is full, this method yields ``0`` people. 173 | 174 | Returns: 175 | int: number of people 176 | """ 177 | if self.full(): 178 | return 0 179 | return randrange(int(self.params.capacity - self.num_people)) 180 | 181 | def full(self) -> bool: 182 | """Checks whether the place is full ``num_people < capacity`` 183 | 184 | Returns: 185 | bool: place is full 186 | """ 187 | return self.params.capacity == self.num_people 188 | 189 | def save_snapshot(self) -> None: 190 | """Saves state snapshot on :class:`~archABM.snapshot_place.SnapshotPlace`""" 191 | self.snapshot.set("run", self.db.run) 192 | self.snapshot.set("time", self.env.now, 0) 193 | self.snapshot.set("place", self.id) 194 | self.snapshot.set("activity", self.event.model.params.activity) 195 | self.snapshot.set("num_people", self.num_people) 196 | self.snapshot.set("infective_people", self.infective_people) 197 | self.snapshot.set("CO2_level", self.CO2_level, 2) 198 | self.snapshot.set("quanta_level", self.quanta_level, 6) 199 | self.snapshot.set("temperature", self.temperature, 2) 200 | self.snapshot.set("relative_humidity", self.relative_humidity, 2) 201 | self.db.results.write_place(self.snapshot) 202 | -------------------------------------------------------------------------------- /archABM/results.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | import os 5 | 6 | from .person import Person 7 | from .place import Place 8 | from .snapshot_person import SnapshotPerson 9 | from .snapshot_place import SnapshotPlace 10 | 11 | 12 | class Results: 13 | """Simulation history processing and export""" 14 | 15 | output: dict 16 | 17 | def __init__(self, config: dict) -> None: 18 | # TODO: review hardcoded names 19 | self.people_name = "people" 20 | self.places_name = "places" 21 | self.results_name = "results" 22 | self.config_name = "config" 23 | self.output_name = "output" 24 | self.log_name = "app.log" 25 | 26 | self.config = config 27 | 28 | self.log = False 29 | self.save_log = self.config["options"]["save_log"] 30 | self.save_config = self.config["options"]["save_config"] 31 | self.save_csv = self.config["options"]["save_csv"] 32 | self.save_json = self.config["options"]["save_json"] 33 | self.return_output = self.config["options"]["return_output"] 34 | 35 | self.output = None 36 | 37 | if self.save_log or self.save_config or self.save_csv or self.save_json: 38 | self.mkpath() 39 | self.mkdir() 40 | 41 | self.setup_log() 42 | if self.save_config: 43 | self.write_config() 44 | if self.save_csv: 45 | self.open_people_csv() 46 | self.open_places_csv() 47 | if self.save_json: 48 | self.open_json() 49 | if self.return_output or self.save_json: 50 | self.init_results() 51 | 52 | def mkpath(self) -> None: 53 | """Creates the path where the simulation results should be saved. 54 | 55 | If the ``directory`` option is specified, another folder level is added to the path. 56 | """ 57 | cwd = os.getcwd() 58 | now = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S-%f") 59 | folder = "results" 60 | self.path = os.path.join(cwd, folder, now) 61 | if "directory" in self.config["options"]: 62 | directory = self.config["options"]["directory"] 63 | if directory is not None: 64 | self.path = os.path.join(cwd, folder, directory, now) 65 | 66 | def mkdir(self) -> None: 67 | """Creates the directory where the simulation results should be saved.""" 68 | os.makedirs(self.path) 69 | 70 | def setup_log(self) -> None: 71 | """Logging setup""" 72 | if self.save_log: 73 | logging.basicConfig( 74 | filename=os.path.join(self.path, self.log_name), filemode="w", format="%(message)s", level=logging.INFO, 75 | ) 76 | elif self.log: 77 | logging.basicConfig(format="%(message)s", level=logging.INFO) 78 | else: 79 | logging.disable(logging.INFO) 80 | 81 | def open_people_csv(self) -> None: 82 | """Creates and opens the *csv* file to save people state history""" 83 | self.people_csv = open(os.path.join(self.path, self.people_name + ".csv"), "a") 84 | self.people_csv.write(SnapshotPerson.get_header()) 85 | 86 | def close_people_csv(self) -> None: 87 | """Closes the people *csv* file""" 88 | self.people_csv.close() 89 | 90 | def open_places_csv(self) -> None: 91 | """Creates and opens the *csv* file to save places state history""" 92 | self.places_csv = open(os.path.join(self.path, self.places_name + ".csv"), "a") 93 | self.places_csv.write(SnapshotPlace.get_header()) 94 | 95 | def close_places_csv(self) -> None: 96 | """Closes the places *csv* file""" 97 | self.places_csv.close() 98 | 99 | def open_json(self) -> None: 100 | """Creates and opens the *json* file to save all results""" 101 | self.output_json = open(os.path.join(self.path, self.output_name + ".json"), "w") 102 | 103 | def write_json(self) -> None: 104 | """Writes the :attr:`output` dictionary to the *json* file""" 105 | json.dump(self.output, self.output_json) 106 | 107 | def close_json(self) -> None: 108 | """Closes the *json* file""" 109 | self.output_json.close() 110 | 111 | def init_results(self) -> None: 112 | """Initializes the results dictionary""" 113 | self.output = {} 114 | self.results = dict.fromkeys([self.people_name, self.places_name], {}) 115 | self.results[self.people_name] = dict.fromkeys(SnapshotPerson.header) 116 | self.results[self.places_name] = dict.fromkeys(SnapshotPlace.header) 117 | 118 | self.output[self.config_name] = self.config 119 | self.output[self.results_name] = self.results 120 | 121 | for key in SnapshotPerson.header: 122 | self.results[self.people_name][key] = [] 123 | for key in SnapshotPlace.header: 124 | self.results[self.places_name][key] = [] 125 | 126 | def write_person(self, person: Person) -> None: 127 | """Appends a new row to the person state history. 128 | 129 | Args: 130 | person (Person): person state to be saved 131 | """ 132 | if self.save_csv: 133 | self.people_csv.write(person.get_data()) 134 | if self.save_json or self.return_output: 135 | for key, value in person.store.items(): 136 | self.results[self.people_name][key].append(value) 137 | 138 | def write_place(self, place: Place) -> None: 139 | """Appends a new row to the place state history. 140 | 141 | Args: 142 | place (Place): place state to be saved 143 | """ 144 | if self.save_csv: 145 | self.places_csv.write(place.get_data()) 146 | if self.save_json or self.return_output: 147 | for key, value in place.store.items(): 148 | self.results[self.places_name][key].append(value) 149 | 150 | def write_config(self) -> None: 151 | """Writes the configuration dictionary into a *json* file.""" 152 | with open(os.path.join(self.path, self.config_name + ".json"), "w") as f: 153 | json.dump(self.config, f) 154 | 155 | def done(self) -> None: 156 | """Closes all file connections and returns a :obj:`dict` with the complete simulation history. 157 | 158 | Returns: 159 | dict: complete simulation history 160 | """ 161 | if self.save_csv: 162 | self.close_people_csv() 163 | self.close_places_csv() 164 | if self.save_json: 165 | self.write_json() 166 | self.close_json() 167 | if self.return_output: 168 | return self.output 169 | return None 170 | -------------------------------------------------------------------------------- /archABM/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Configuration Schema", 3 | "type": "object", 4 | "properties": { 5 | "events": { 6 | "title": "Events", 7 | "type": "array", 8 | "minItems": 1, 9 | "uniqueItems": true, 10 | "items": { 11 | "type": "object", 12 | "properties": { 13 | "activity": { 14 | "type": "string" 15 | }, 16 | "schedule": { 17 | "type": "array", 18 | "minItems": 1, 19 | "uniqueItems": true, 20 | "items": { 21 | "type": "array", 22 | "minItems": 2, 23 | "maxItems": 2, 24 | "items": { 25 | "type": "integer" 26 | } 27 | } 28 | }, 29 | "repeat_min": { 30 | "type": ["integer", "null"] 31 | }, 32 | "repeat_max": { 33 | "type": ["integer", "null"] 34 | }, 35 | "duration_min": { 36 | "type": "integer", 37 | "minimum": 0 38 | }, 39 | "duration_max": { 40 | "type": "integer", 41 | "minimum": 0 42 | }, 43 | "mask_efficiency": { 44 | "type": ["number", "null"], 45 | "minimum": 0, 46 | "maximum": 1 47 | }, 48 | "collective": { 49 | "type": "boolean" 50 | }, 51 | "shared": { 52 | "type": "boolean" 53 | }, 54 | "allow": { 55 | "type": "boolean" 56 | } 57 | 58 | }, 59 | "required": ["activity", "schedule", "repeat_min", "repeat_max", "duration_min", "duration_max", "mask_efficiency", "collective", "shared", "allow"] 60 | } 61 | }, 62 | "places": { 63 | "title": "Places", 64 | "type": "array", 65 | "minItems": 1, 66 | "uniqueItems": true, 67 | "items": { 68 | "type": "object", 69 | "properties": { 70 | "name": { 71 | "type": "string" 72 | }, 73 | "activity": { 74 | "type": ["array", "null"], 75 | "items": { 76 | "type": "string" 77 | }, 78 | "minItems": 1 79 | }, 80 | "building": { 81 | "type": ["string", "null"] 82 | }, 83 | "department": { 84 | "type": ["array", "null"], 85 | "items": { 86 | "type": "string" 87 | }, 88 | "minItems": 1 89 | }, 90 | "area": { 91 | "type": ["number", "null"] 92 | }, 93 | "height": { 94 | "type": ["number", "null"] 95 | }, 96 | "capacity": { 97 | "type": ["integer", "null"] 98 | }, 99 | "ventilation": { 100 | "type": ["number", "null"] 101 | }, 102 | "recirculated_flow_rate": { 103 | "type": ["number", "null"] 104 | }, 105 | "allow": { 106 | "type": "boolean" 107 | } 108 | }, 109 | "required": ["name", "activity", "building", "department", "area", "height", "capacity", "ventilation", "recirculated_flow_rate", "allow"] 110 | } 111 | }, 112 | "people": { 113 | "title": "People", 114 | "type": "array", 115 | "minItems": 1, 116 | "uniqueItems": true, 117 | "items": { 118 | "type": "object", 119 | "properties": { 120 | "department": { 121 | "type": "string" 122 | }, 123 | "building": { 124 | "type": "string" 125 | }, 126 | "num_people": { 127 | "type": "integer", 128 | "minimum": 0 129 | } 130 | }, 131 | "required": ["department", "building", "num_people"] 132 | } 133 | }, 134 | "options": { 135 | "title": "Options", 136 | "type": "object", 137 | "properties": { 138 | "movement_buildings": { 139 | "type": "boolean" 140 | }, 141 | "movement_department": { 142 | "type": "boolean" 143 | }, 144 | "number_runs": { 145 | "type": "integer", 146 | "minimum": 1 147 | }, 148 | "save_log": { 149 | "type": "boolean" 150 | }, 151 | "save_config": { 152 | "type": "boolean" 153 | }, 154 | "save_csv": { 155 | "type": "boolean" 156 | }, 157 | "save_json": { 158 | "type": "boolean" 159 | }, 160 | "return_output": { 161 | "type": "boolean" 162 | }, 163 | "directory": { 164 | "type": ["string", "null"] 165 | }, 166 | "ratio_infected": { 167 | "type": "number", 168 | "minimum": 0, 169 | "maximum": 1 170 | }, 171 | "model": { 172 | "type": "string" 173 | }, 174 | "model_parameters": { 175 | "type": "object" 176 | } 177 | 178 | }, 179 | "required": ["movement_buildings", "movement_department", "number_runs", "save_log", "save_config", "save_csv", "save_json", "return_output", "directory", "ratio_infected", "model", "model_parameters"] 180 | } 181 | }, 182 | "required": ["events", "places", "people", "options"] 183 | } -------------------------------------------------------------------------------- /archABM/snapshot.py: -------------------------------------------------------------------------------- 1 | class Snapshot: 2 | """Stores the state of an agent at a given time 3 | """ 4 | 5 | header: list 6 | store: dict 7 | 8 | def __init__(self, header) -> None: 9 | self.header = header 10 | self.store = {} 11 | self.reset() 12 | 13 | def reset(self) -> None: 14 | """Initializes dictionary :attr:`store` with :attr:`header` as keys.""" 15 | self.store = dict.fromkeys(self.header, "") 16 | 17 | @classmethod 18 | def get_header(cls, sep=",") -> str: 19 | """Takes all items in :attr:`header` and joins them into one string. 20 | 21 | A string must be specified as the separator, by default "," 22 | 23 | Args: 24 | sep (str, optional): separator. Defaults to ",". 25 | 26 | Returns: 27 | str: a string created by joining the elements of :attr:`header` by string separator 28 | """ 29 | return sep.join(cls.header) + "\n" 30 | 31 | def get_data(self, sep=",") -> str: 32 | """Takes all items in :attr:`store` and joins them into one string. 33 | 34 | A string must be specified as the separator, by default "," 35 | 36 | Args: 37 | sep (str, optional): separator. Defaults to ",". 38 | 39 | Returns: 40 | str: a string created by joining the elements of :attr:`store` by string separator 41 | """ 42 | return sep.join(map(str, self.store.values())) + "\n" 43 | 44 | def set(self, key: str, value, digits: int = 2) -> None: 45 | """Add a key:value pair to the :attr:`store` dictionary 46 | 47 | * If :attr:`value` is :obj:`bool`, it is casted to :obj:`int`. 48 | * If :attr:`value` is :obj:`list`, the elements are joined into one :obj:`str`, separated by ";". 49 | * If :attr:`value` is :obj:`float`, it is rounded to the specified number :attr:`digits`. 50 | * If :attr:`digits` is :const:`0`, :attr:`value` is casted to :obj:`int`. 51 | 52 | Args: 53 | key (str): item key 54 | value (Union[bool, int, float, list]): item value 55 | digits (int, optional): number of digits to store. Defaults to 2. 56 | 57 | Raises: 58 | BaseException: raises if the specified :attr:`key` is not a key in :attr:`store` 59 | """ 60 | if key in self.header: 61 | typ = type(value) 62 | if typ == bool: 63 | value = int(value) 64 | elif typ == int: 65 | pass 66 | elif typ == float: 67 | if digits == 0: 68 | value = int(value) 69 | else: 70 | value = round(value, digits) 71 | elif typ == list: 72 | value = ";".join(map(str, value)) 73 | self.store[key] = value 74 | else: 75 | raise BaseException 76 | -------------------------------------------------------------------------------- /archABM/snapshot_person.py: -------------------------------------------------------------------------------- 1 | from .snapshot import Snapshot 2 | 3 | 4 | class SnapshotPerson(Snapshot): 5 | """Stores the state of a person at a given time 6 | 7 | It saves the following attributes: 8 | 9 | .. list-table:: 10 | :header-rows: 1 11 | 12 | * - Attribute 13 | - Description 14 | - Type 15 | * - *run* 16 | - Simulation run 17 | - :obj:`int` 18 | * - *time* 19 | - Simulation time (minutes) 20 | - :obj:`int` 21 | * - *person* 22 | - Person ID 23 | - :obj:`int` 24 | * - *status* 25 | - Person status (0: susceptible, 1: infective) 26 | - :obj:`bool` 27 | * - *place* 28 | - Place ID 29 | - :obj:`int` 30 | * - *event* 31 | - Event ID 32 | - :obj:`int` 33 | * - *CO2_level* 34 | - Average CO\ :sub:`2` level (ppm) 35 | - :obj:`float` 36 | * - *quanta_inhaled* 37 | - Quanta inhaled (quanta) 38 | - :obj:`float` 39 | """ 40 | 41 | header = ["run", "time", "person", "status", "place", "event", "CO2_level", "quanta_inhaled"] 42 | 43 | def __init__(self) -> None: 44 | super(SnapshotPerson, self).__init__(SnapshotPerson.header) 45 | -------------------------------------------------------------------------------- /archABM/snapshot_place.py: -------------------------------------------------------------------------------- 1 | from .snapshot import Snapshot 2 | 3 | 4 | class SnapshotPlace(Snapshot): 5 | """Stores the state of a place at a given time 6 | 7 | It saves the following attributes: 8 | 9 | .. list-table:: 10 | :header-rows: 1 11 | 12 | * - Attribute 13 | - Description 14 | - Type 15 | * - *run* 16 | - Simulation run 17 | - :obj:`int` 18 | * - *time* 19 | - Simulation time (minutes) 20 | - :obj:`int` 21 | * - *place* 22 | - Place ID 23 | - :obj:`int` 24 | * - *activity* 25 | - Activity 26 | - :obj:`str` 27 | * - *num_people* 28 | - Number of people 29 | - :obj:`int` 30 | * - *infective_people* 31 | - Number of infective people 32 | - :obj:`int` 33 | * - *CO2_level* 34 | - CO\ :sub:`2` level (ppm) 35 | - :obj:`float` 36 | * - *quanta_level* 37 | - quanta level (ppm) 38 | - :obj:`float` 39 | * - *temperature* 40 | - Room temperature 41 | - :obj:`float` 42 | * - *relative_humidity* 43 | - Room relative humidity 44 | - :obj:`float` 45 | """ 46 | 47 | header = ["run", "time", "place", "activity", "num_people", "infective_people", "CO2_level", "quanta_level", "temperature", "relative_humidity"] 48 | 49 | def __init__(self) -> None: 50 | super(SnapshotPlace, self).__init__(SnapshotPlace.header) 51 | -------------------------------------------------------------------------------- /data/config_basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "events": [{ 3 | "activity": "home", 4 | "schedule": [ 5 | [0, 480], 6 | [1020, 1440] 7 | ], 8 | "repeat_min": 0, 9 | "repeat_max": null, 10 | "duration_min": 300, 11 | "duration_max": 360, 12 | "mask_efficiency": null, 13 | "collective": false, 14 | "shared": false, 15 | "allow": true 16 | }, 17 | { 18 | "activity": "work", 19 | "schedule": [ 20 | [480, 1020] 21 | ], 22 | "repeat_min": 0, 23 | "repeat_max": null, 24 | "duration_min": 30, 25 | "duration_max": 60, 26 | "mask_efficiency": 0.8, 27 | "collective": false, 28 | "shared": true, 29 | "allow": true 30 | } 31 | ], 32 | "places": [{ 33 | "name": "home", 34 | "activity": ["home"], 35 | "building": null, 36 | "department": null, 37 | "area": null, 38 | "height": null, 39 | "capacity": null, 40 | "ventilation": null, 41 | "recirculated_flow_rate": null, 42 | "allow": true 43 | }, 44 | { 45 | "name": "office1", 46 | "activity": ["work"], 47 | "building": "building1", 48 | "department": ["department1"], 49 | "area": 200.0, 50 | "height": 3.0, 51 | "capacity": 50, 52 | "ventilation": 3, 53 | "recirculated_flow_rate": 0, 54 | "allow": true 55 | }, 56 | { 57 | "name": "office2", 58 | "activity": ["work"], 59 | "building": "building1", 60 | "department": ["department2"], 61 | "area": 200.0, 62 | "height": 3.0, 63 | "capacity": 50, 64 | "ventilation": 3, 65 | "recirculated_flow_rate": 0, 66 | "allow": true 67 | } 68 | ], 69 | "people": [{ 70 | "department": "department1", 71 | "building": "building1", 72 | "num_people": 20 73 | }, 74 | { 75 | "department": "department2", 76 | "building": "building1", 77 | "num_people": 20 78 | } 79 | ], 80 | "options": { 81 | "movement_buildings": true, 82 | "movement_department": true, 83 | "number_runs": 1, 84 | "save_log": true, 85 | "save_config": true, 86 | "save_csv": false, 87 | "save_json": false, 88 | "return_output": false, 89 | "directory": null, 90 | "ratio_infected": 0.05, 91 | "model": "Colorado", 92 | "model_parameters": { 93 | "Colorado": { 94 | "pressure": 0.95, 95 | "temperature": 20, 96 | "relative_humidity": 50, 97 | "CO2_background": 415, 98 | "decay_rate": 0.62, 99 | "deposition_rate": 0.3, 100 | "hepa_flow_rate": 0.0, 101 | "filter_efficiency": 0.20, 102 | "ducts_removal": 0.10, 103 | "other_removal": 0.00, 104 | "fraction_immune": 0, 105 | "breathing_rate": 0.52, 106 | "CO2_emission_person": 0.005, 107 | "quanta_exhalation": 25, 108 | "quanta_enhancement": 1, 109 | "people_with_masks": 1.00 110 | } 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /data/config_toy.json: -------------------------------------------------------------------------------- 1 | { 2 | "events": [{ 3 | "activity": "home", 4 | "schedule": [ 5 | [0, 480], 6 | [1020, 1440] 7 | ], 8 | "repeat_min": 0, 9 | "repeat_max": null, 10 | "duration_min": 300, 11 | "duration_max": 360, 12 | "mask_efficiency": null, 13 | "collective": false, 14 | "shared": false, 15 | "allow": true 16 | }, 17 | { 18 | "activity": "work", 19 | "schedule": [ 20 | [480, 1020] 21 | ], 22 | "repeat_min": 0, 23 | "repeat_max": null, 24 | "duration_min": 30, 25 | "duration_max": 60, 26 | "mask_efficiency": 0.8, 27 | "collective": false, 28 | "shared": true, 29 | "allow": true 30 | }, 31 | { 32 | "activity": "meeting", 33 | "schedule": [ 34 | [540, 960] 35 | ], 36 | "repeat_min": 0, 37 | "repeat_max": 5, 38 | "duration_min": 15, 39 | "duration_max": 60, 40 | "mask_efficiency": 0.8, 41 | "collective": false, 42 | "shared": true, 43 | "allow": true 44 | }, 45 | { 46 | "activity": "lunch", 47 | "schedule": [ 48 | [780, 900] 49 | ], 50 | "repeat_min": 1, 51 | "repeat_max": 1, 52 | "duration_min": 20, 53 | "duration_max": 45, 54 | "mask_efficiency": 0.0, 55 | "collective": true, 56 | "shared": true, 57 | "allow": true 58 | }, 59 | { 60 | "activity": "coffee", 61 | "schedule": [ 62 | [600, 660], 63 | [900, 960] 64 | ], 65 | "repeat_min": 0, 66 | "repeat_max": 2, 67 | "duration_min": 5, 68 | "duration_max": 15, 69 | "mask_efficiency": 0.5, 70 | "collective": false, 71 | "shared": true, 72 | "allow": true 73 | }, 74 | { 75 | "activity": "restroom", 76 | "schedule": [ 77 | [480, 1020] 78 | ], 79 | "repeat_min": 0, 80 | "repeat_max": 4, 81 | "duration_min": 2, 82 | "duration_max": 5, 83 | "mask_efficiency": 0.5, 84 | "collective": false, 85 | "shared": true, 86 | "allow": true 87 | } 88 | ], 89 | "places": [{ 90 | "name": "home", 91 | "activity": ["home"], 92 | "building": null, 93 | "department": null, 94 | "area": null, 95 | "height": null, 96 | "capacity": null, 97 | "ventilation": null, 98 | "recirculated_flow_rate": null, 99 | "allow": true 100 | }, 101 | { 102 | "name": "office1", 103 | "activity": ["work"], 104 | "building": "building1", 105 | "department": ["department1"], 106 | "area": 200.0, 107 | "height": 3.0, 108 | "capacity": 50, 109 | "ventilation": 3, 110 | "recirculated_flow_rate": 0, 111 | "allow": true 112 | }, 113 | { 114 | "name": "office2", 115 | "activity": ["work"], 116 | "building": "building1", 117 | "department": ["department2"], 118 | "area": 200.0, 119 | "height": 3.0, 120 | "capacity": 50, 121 | "ventilation": 3, 122 | "recirculated_flow_rate": 0, 123 | "allow": true 124 | }, 125 | { 126 | "name": "meeting1", 127 | "activity": ["meeting"], 128 | "building": "building1", 129 | "department": ["department1"], 130 | "area": 40.0, 131 | "height": 3.0, 132 | "capacity": 10, 133 | "ventilation": 3, 134 | "recirculated_flow_rate": 0, 135 | "allow": true 136 | }, 137 | { 138 | "name": "meeting2", 139 | "activity": ["meeting"], 140 | "building": "building1", 141 | "department": null, 142 | "area": 40.0, 143 | "height": 3.0, 144 | "capacity": 10, 145 | "ventilation": 3, 146 | "recirculated_flow_rate": 0, 147 | "allow": true 148 | }, 149 | { 150 | "name": "lunch1", 151 | "activity": ["lunch"], 152 | "building": "building1", 153 | "department": ["department1", "department2"], 154 | "area": 200.0, 155 | "height": 3.0, 156 | "capacity": 50, 157 | "ventilation": 3, 158 | "recirculated_flow_rate": 0, 159 | "allow": true 160 | }, 161 | { 162 | "name": "coffee1", 163 | "activity": ["coffee"], 164 | "building": "building1", 165 | "department": ["department1", "department2"], 166 | "area": 25.0, 167 | "height": 3.0, 168 | "capacity": 10, 169 | "ventilation": 3, 170 | "recirculated_flow_rate": 0, 171 | "allow": true 172 | }, 173 | { 174 | "name": "restroom1", 175 | "activity": ["restroom"], 176 | "building": "building1", 177 | "department": null, 178 | "area": 20.0, 179 | "height": 3.0, 180 | "capacity": 4, 181 | "ventilation": 3, 182 | "recirculated_flow_rate": 0, 183 | "allow": true 184 | }, 185 | { 186 | "name": "restroom2", 187 | "activity": ["restroom"], 188 | "building": "building2", 189 | "department": null, 190 | "area": 15.0, 191 | "height": 3.0, 192 | "capacity": 4, 193 | "ventilation": 3, 194 | "recirculated_flow_rate": 0, 195 | "allow": true 196 | } 197 | ], 198 | "people": [{ 199 | "department": "department1", 200 | "building": "building1", 201 | "num_people": 20 202 | }, 203 | { 204 | "department": "department2", 205 | "building": "building1", 206 | "num_people": 20 207 | } 208 | ], 209 | "options": { 210 | "movement_buildings": true, 211 | "movement_department": true, 212 | "number_runs": 1, 213 | "save_log": true, 214 | "save_config": true, 215 | "save_csv": false, 216 | "save_json": false, 217 | "return_output": false, 218 | "directory": null, 219 | "ratio_infected": 0.05, 220 | "model": "Colorado", 221 | "model_parameters": { 222 | "MaxPlanck": { 223 | "RNA_D50": 316, 224 | "deposition_rate": 0.5, 225 | "emission_breathing": 0.06, 226 | "emission_speaking": 0.6, 227 | "speaking_breathing_ratio": 0.1, 228 | "respiratory_rate": 10, 229 | "RNA_concentration": 5e8, 230 | "aerosol_diameter": 5, 231 | "virus_lifetime": 1.7, 232 | "CO2_background": 410 233 | }, 234 | "MIT": { 235 | "filtration_efficiency": 0.01, 236 | "relative_humidity": 60, 237 | "breathing_rate": 0.49, 238 | "aerosol_radius": 2, 239 | "infectiousness": 72, 240 | "deactivation_rate": 0.3, 241 | "transmissibility": 1, 242 | "CO2_background": 410 243 | }, 244 | "Colorado": { 245 | "pressure": 0.95, 246 | "temperature": 20, 247 | "relative_humidity": 50, 248 | "CO2_background": 415, 249 | "decay_rate": 0.62, 250 | "deposition_rate": 0.3, 251 | "hepa_flow_rate": 0.0, 252 | "recirculated_flow_rate": 300, 253 | "filter_efficiency": 0.20, 254 | "ducts_removal": 0.10, 255 | "other_removal": 0.00, 256 | "fraction_immune": 0, 257 | "breathing_rate": 0.52, 258 | "CO2_emission_person": 0.005, 259 | "quanta_exhalation": 25, 260 | "quanta_enhancement": 1, 261 | "people_with_masks": 1.00 262 | } 263 | } 264 | } 265 | } -------------------------------------------------------------------------------- /designer/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /designer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "builder", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.1", 7 | "@testing-library/react": "^12.1.2", 8 | "@testing-library/user-event": "^13.5.0", 9 | "antd": "^4.17.3", 10 | "react": "^17.0.2", 11 | "react-dom": "^17.0.2", 12 | "react-scripts": "5.0.0", 13 | "web-vitals": "^2.1.2" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": [ 23 | "react-app", 24 | "react-app/jest" 25 | ] 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /designer/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vicomtech/ArchABM/a5cbe99ab81a88113c06124f95f15a50a1aba899/designer/public/favicon.ico -------------------------------------------------------------------------------- /designer/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Vicomtech/ArchABM/a5cbe99ab81a88113c06124f95f15a50a1aba899/designer/public/favicon.png -------------------------------------------------------------------------------- /designer/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 |