├── tests ├── __init__.py ├── test_pythermalcomfort.py ├── test_net.py ├── test_e_pmv.py ├── test_athb.py ├── test_cooling_effect.py ├── test_two_nodes_gagge.py ├── test_discomfort_index.py ├── test_use_fans_heatwaves.py ├── test_clo_tout.py ├── test_vertical_tmp_grad_ppd.py ├── test_utci.py ├── test_at.py ├── test_wbgt.py ├── test_a_pmv.py ├── test_solar_gain.py ├── test_set.py ├── test_wind_chill_index.py ├── test_pet_steady.py ├── test_humidex.py ├── test_esi.py ├── test_thi.py ├── test_work_capacity_dunne.py ├── test_adaptive_en.py ├── test_heat_index_lu.py ├── test_heat_index_rothfusz.py ├── test_ankle_draft.py ├── test_classes_return.py ├── test_pmv_ppd_optimised.py ├── test_wind_chill_temperature.py ├── test_adaptive_ashrae.py ├── test_work_capacity_niosh.py ├── test_work_capacity_iso.py ├── test_work_capacity_hothaps.py ├── test_two_nodes_gagge_sleep.py ├── test_pmv_ppd_iso.py ├── test_psychrometrics.py └── test_scale_wind_speed_log.py ├── pythermalcomfort ├── jos3_functions │ └── __init__.py ├── utils │ └── __init__.py ├── __init__.py ├── __main__.py ├── cli.py ├── models │ ├── thi.py │ ├── esi.py │ ├── wind_chill_temperature.py │ ├── work_capacity_iso.py │ ├── work_capacity_niosh.py │ ├── __init__.py │ ├── _pmv_ppd_optimized.py │ ├── work_capacity_dunne.py │ ├── heat_index_rothfusz.py │ ├── clo_tout.py │ ├── wci.py │ ├── at.py │ ├── discomfort_index.py │ ├── net.py │ ├── wbgt.py │ ├── work_capacity_hothaps.py │ ├── humidex.py │ ├── pmv_a.py │ ├── pmv_athb.py │ ├── ankle_draft.py │ ├── set_tmp.py │ └── vertical_tmp_grad_ppd.py └── shared_functions.py ├── docs ├── authors.rst ├── readme.rst ├── contributing.rst ├── documentation │ ├── changelog.rst │ ├── images │ │ └── clothing_and_activity.png │ ├── met.rst │ ├── index.rst │ ├── examples.rst │ └── utilities_functions.rst ├── requirements.txt ├── images │ └── pythermalcomfort-3-short.png ├── spelling_wordlist.txt ├── index.rst └── conf.py ├── examples ├── atcs_output_example │ └── atcs_example_local_sensations.png ├── jos3_output_example │ ├── human_subject_experiment_dataset.xlsx │ ├── jos3_example2_skin_temperatures.png │ ├── jos3_validation_with_Stolwijk1966.png │ ├── jos3_example1_mean_skin_temperature.png │ └── jos3_example3_mean_skin_temperature.png ├── template-SI.csv ├── calc_set_tmp.py ├── calc_adaptive_ASHRAE.py ├── calc_adaptive_EN.py ├── calc_phs.py ├── calc_utci.py └── calc_pmv_ppd.py ├── .coveragerc ├── .editorconfig ├── MANIFEST.in ├── CITATION.bib ├── Pipfile ├── .bumpversion.toml ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── pull-request.yml │ ├── build-test-publish.yml │ └── build-test-publish-testPyPI.yml └── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── setup.cfg ├── .readthedocs.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── tox.ini ├── ruff.toml ├── .cookiecutterrc ├── .coderabbit.yaml ├── setup.py └── CODE_OF_CONDUCT.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_pythermalcomfort.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pythermalcomfort/jos3_functions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/documentation/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | docutils<0.18 4 | urllib3<2.0 5 | pydata-sphinx-theme 6 | -------------------------------------------------------------------------------- /docs/images/pythermalcomfort-3-short.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CenterForTheBuiltEnvironment/pythermalcomfort/HEAD/docs/images/pythermalcomfort-3-short.png -------------------------------------------------------------------------------- /docs/documentation/images/clothing_and_activity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CenterForTheBuiltEnvironment/pythermalcomfort/HEAD/docs/documentation/images/clothing_and_activity.png -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | builtins 3 | classmethod 4 | staticmethod 5 | classmethods 6 | staticmethods 7 | args 8 | kwargs 9 | callstack 10 | Changelog 11 | Indices 12 | -------------------------------------------------------------------------------- /pythermalcomfort/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .scale_wind_speed_log import scale_wind_speed_log 4 | 5 | __all__ = [ 6 | "scale_wind_speed_log", 7 | ] 8 | -------------------------------------------------------------------------------- /examples/atcs_output_example/atcs_example_local_sensations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CenterForTheBuiltEnvironment/pythermalcomfort/HEAD/examples/atcs_output_example/atcs_example_local_sensations.png -------------------------------------------------------------------------------- /examples/jos3_output_example/human_subject_experiment_dataset.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CenterForTheBuiltEnvironment/pythermalcomfort/HEAD/examples/jos3_output_example/human_subject_experiment_dataset.xlsx -------------------------------------------------------------------------------- /examples/jos3_output_example/jos3_example2_skin_temperatures.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CenterForTheBuiltEnvironment/pythermalcomfort/HEAD/examples/jos3_output_example/jos3_example2_skin_temperatures.png -------------------------------------------------------------------------------- /examples/jos3_output_example/jos3_validation_with_Stolwijk1966.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CenterForTheBuiltEnvironment/pythermalcomfort/HEAD/examples/jos3_output_example/jos3_validation_with_Stolwijk1966.png -------------------------------------------------------------------------------- /examples/jos3_output_example/jos3_example1_mean_skin_temperature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CenterForTheBuiltEnvironment/pythermalcomfort/HEAD/examples/jos3_output_example/jos3_example1_mean_skin_temperature.png -------------------------------------------------------------------------------- /examples/jos3_output_example/jos3_example3_mean_skin_temperature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CenterForTheBuiltEnvironment/pythermalcomfort/HEAD/examples/jos3_output_example/jos3_example3_mean_skin_temperature.png -------------------------------------------------------------------------------- /examples/template-SI.csv: -------------------------------------------------------------------------------- 1 | tdb,tr,v,rh,met,clo 2 | 25,25,0.15,50,1,1 3 | 26,25,0.15,50,1.3,1 4 | 27,25,0.15,50,1.6,1 5 | 28,25,0.15,50,1.9,1 6 | 29,25,0.15,50,2.2,1 7 | 20,25,0.15,50,2.5,1 8 | 21,25,0.15,50,2.8,1 9 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = 3 | pythermalcomfort 4 | */site-packages 5 | 6 | [run] 7 | branch = true 8 | source = 9 | pythermalcomfort 10 | tests 11 | parallel = true 12 | 13 | [report] 14 | show_missing = true 15 | precision = 2 16 | omit = *migrations* 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # see https://editorconfig.org/ 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | charset = utf-8 11 | 12 | [*.{bat,cmd,ps1}] 13 | end_of_line = crlf 14 | -------------------------------------------------------------------------------- /pythermalcomfort/__init__.py: -------------------------------------------------------------------------------- 1 | """pythermalcomfort: A Python package for thermal comfort calculations. 2 | 3 | This package provides comprehensive tools for calculating thermal comfort indices, 4 | heat/cold stress metrics, and thermophysiological responses using multiple models. 5 | """ 6 | 7 | __version__ = "3.8.0" 8 | -------------------------------------------------------------------------------- /docs/documentation/met.rst: -------------------------------------------------------------------------------- 1 | Metabolic Rate 2 | ============== 3 | 4 | Met typical tasks, [met] 5 | ------------------------ 6 | 7 | .. autodata:: pythermalcomfort.utilities.met_typical_tasks 8 | 9 | **Example** 10 | 11 | .. code-block:: python 12 | 13 | from pythermalcomfort.utilities import met_typical_tasks 14 | 15 | print(met_typical_tasks["Filing, standing"]) 16 | # 1.4 17 | -------------------------------------------------------------------------------- /examples/calc_set_tmp.py: -------------------------------------------------------------------------------- 1 | """In this example I am calculating the Standard Effective Temperature (SET) with the pythermalcomfort package.""" 2 | 3 | from pythermalcomfort.models import set_tmp 4 | 5 | set_value = set_tmp(tdb=25, tr=25, v=0.3, rh=60, met=1.2, clo=0.5) 6 | print(f"set_value= {set_value.set}") 7 | 8 | set_value = set_tmp(tdb=25, tr=25, v=0.5, rh=60, met=1.2, clo=0.5) 9 | print(f"set_value= {set_value.set}") 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft pythermalcomfort 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.toml 7 | include .coveragerc 8 | include .cookiecutterrc 9 | include .editorconfig 10 | 11 | include AUTHORS.rst 12 | include CHANGELOG.rst 13 | include CONTRIBUTING.rst 14 | include LICENSE 15 | include README.rst 16 | 17 | include tox.ini 18 | 19 | include *.yml 20 | recursive-include examples *.py 21 | 22 | global-exclude *.py[cod] __pycache__/* *.so *.dylib 23 | -------------------------------------------------------------------------------- /docs/documentation/index.rst: -------------------------------------------------------------------------------- 1 | Documentation 2 | ============= 3 | 4 | This section contains the user-facing reference documentation for the 5 | pythermalcomfort package: model reference pages, utility functions, examples, 6 | and background material. Use the links below to navigate by topic. 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | models 12 | utilities_functions 13 | clothing 14 | met 15 | surveys 16 | references 17 | examples 18 | changelog 19 | -------------------------------------------------------------------------------- /pythermalcomfort/__main__.py: -------------------------------------------------------------------------------- 1 | """Entrypoint module, in case you use `python -mpythermalcomfort`. 2 | 3 | Why does this file exist, and why __main__? For more info, read: 4 | 5 | - https://www.python.org/dev/peps/pep-0338/ 6 | - https://docs.python.org/2/using/cmdline.html#cmdoption-m 7 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m 8 | """ 9 | 10 | import sys 11 | 12 | from pythermalcomfort.cli import main 13 | 14 | if __name__ == "__main__": 15 | sys.exit(main()) 16 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ./readme.rst 2 | 3 | Getting Started 4 | =============== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | installation 10 | 11 | Usage 12 | ===== 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | 17 | documentation/index 18 | 19 | Contributing 20 | ============ 21 | 22 | .. toctree:: 23 | :maxdepth: 2 24 | 25 | contributing 26 | authors 27 | 28 | 29 | Indices and tables 30 | ================== 31 | 32 | * :ref:`genindex` 33 | * :ref:`search` 34 | -------------------------------------------------------------------------------- /CITATION.bib: -------------------------------------------------------------------------------- 1 | @article{Tartarini2020a, 2 | author = {Tartarini, Federico and Schiavon, Stefano}, 3 | doi = {10.1016/j.softx.2020.100578}, 4 | issn = {23527110}, 5 | journal = {SoftwareX}, 6 | month = {jul}, 7 | pages = {100578}, 8 | publisher = {Elsevier B.V.}, 9 | title = {{pythermalcomfort: A Python package for thermal comfort research}}, 10 | url = {https://doi.org/10.1016/j.softx.2020.100578 https://linkinghub.elsevier.com/retrieve/pii/S2352711020302910}, 11 | volume = {12}, 12 | year = {2020} 13 | } 14 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | scipy = "*" 8 | numba = "*" 9 | numpy = "*" 10 | setuptools = "*" 11 | tox = "*" 12 | 13 | [dev-packages] 14 | pytest = "*" 15 | requests = "*" 16 | pandas = "*" 17 | pydantic = "*" 18 | tabulate = "*" 19 | matplotlib = "*" 20 | seaborn = "*" 21 | ruff = "*" 22 | docformatter = "*" 23 | autopep8 = "*" 24 | bump-my-version = "*" 25 | twine = "*" 26 | readme-renderer = "*" 27 | pre-commit = "*" 28 | blacken-docs = "*" 29 | 30 | [requires] 31 | python_version = "3.12" 32 | -------------------------------------------------------------------------------- /.bumpversion.toml: -------------------------------------------------------------------------------- 1 | [tool.bumpversion] 2 | current_version = "3.8.0" 3 | commit = true 4 | parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)" 5 | serialize = ["{major}.{minor}.{patch}"] 6 | search = "{current_version}" 7 | replace = "{new_version}" 8 | tag_name = "v{new_version}" 9 | tag_message = "Bump version: {current_version} → {new_version}" 10 | allow_dirty = false 11 | commit_message = "Bump version: {current_version} → {new_version}" 12 | 13 | [[tool.bumpversion.files]] 14 | filename = "pythermalcomfort/__init__.py" 15 | 16 | [[tool.bumpversion.files]] 17 | filename = "setup.py" 18 | 19 | [[tool.bumpversion.files]] 20 | filename = "docs/conf.py" 21 | -------------------------------------------------------------------------------- /tests/test_net.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import net 2 | from tests.conftest import Urls, retrieve_reference_table, validate_result 3 | 4 | 5 | def test_net(get_test_url, retrieve_data) -> None: 6 | """Test the NET model with various inputs.""" 7 | reference_table = retrieve_reference_table( 8 | get_test_url, 9 | retrieve_data, 10 | Urls.NET.name, 11 | ) 12 | tolerance = reference_table["tolerance"] 13 | 14 | for entry in reference_table["data"]: 15 | inputs = entry["inputs"] 16 | outputs = entry["outputs"] 17 | result = net(**inputs) 18 | 19 | validate_result(result, outputs, tolerance) 20 | -------------------------------------------------------------------------------- /.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. 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 | -------------------------------------------------------------------------------- /examples/calc_adaptive_ASHRAE.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | 3 | from pythermalcomfort.models import adaptive_ashrae 4 | from pythermalcomfort.utilities import running_mean_outdoor_temperature 5 | 6 | result = adaptive_ashrae(tdb=25, tr=25, t_running_mean=23, v=0.3) 7 | 8 | pprint(result) 9 | 10 | print(result.acceptability_80) # or use result["acceptability_80"] 11 | 12 | result = adaptive_ashrae(tdb=77, tr=77, t_running_mean=73.5, v=1, units="IP") 13 | 14 | pprint(result) 15 | 16 | print(result["acceptability_80"]) 17 | 18 | rmt_value = running_mean_outdoor_temperature([29, 28, 30, 29, 28, 30, 27], alpha=0.9) 19 | 20 | result = adaptive_ashrae(tdb=25, tr=25, t_running_mean=rmt_value, v=0.3) 21 | pprint(result) 22 | -------------------------------------------------------------------------------- /examples/calc_adaptive_EN.py: -------------------------------------------------------------------------------- 1 | from pprint import pprint 2 | 3 | from pythermalcomfort.models import adaptive_en 4 | from pythermalcomfort.utilities import running_mean_outdoor_temperature 5 | 6 | result = adaptive_en(tdb=25, tr=25, t_running_mean=24, v=0.1) 7 | 8 | pprint(result) 9 | 10 | result = adaptive_en(tdb=22.5, tr=22.5, t_running_mean=24, v=0.1) 11 | 12 | pprint(result) 13 | 14 | comf_tmp = result["tmp_cmf"] 15 | 16 | result = adaptive_en(tdb=72.5, tr=72.5, t_running_mean=75, v=0.1, units="IP") 17 | pprint(result) 18 | 19 | rmt_value = running_mean_outdoor_temperature([29, 28, 30, 29, 28, 30, 27], alpha=0.9) 20 | 21 | result = adaptive_en(tdb=25, tr=25, t_running_mean=rmt_value, v=0.3) 22 | pprint(result) 23 | -------------------------------------------------------------------------------- /tests/test_e_pmv.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import pmv_e 2 | from tests.conftest import Urls, retrieve_reference_table, validate_result 3 | 4 | 5 | def test_e_pmv(get_test_url, retrieve_data) -> None: 6 | """Test that the function calculates the Effective Predicted Mean Vote (E-PMV) correctly for various inputs.""" 7 | reference_table = retrieve_reference_table( 8 | get_test_url, 9 | retrieve_data, 10 | Urls.E_PMV.name, 11 | ) 12 | tolerance = reference_table["tolerance"] 13 | 14 | for entry in reference_table["data"]: 15 | inputs = entry["inputs"] 16 | outputs = entry["outputs"] 17 | result = pmv_e(**inputs) 18 | 19 | validate_result(result, outputs, tolerance) 20 | -------------------------------------------------------------------------------- /tests/test_athb.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import pmv_athb 2 | from tests.conftest import Urls, retrieve_reference_table, validate_result 3 | 4 | 5 | def test_athb(get_test_url, retrieve_data) -> None: 6 | """Test that the function calculates the Adaptive Thermal Comfort Model (ASHRAE 55) correctly for various inputs.""" 7 | reference_table = retrieve_reference_table( 8 | get_test_url, 9 | retrieve_data, 10 | Urls.ATHB.name, 11 | ) 12 | tolerance = reference_table["tolerance"] 13 | 14 | for entry in reference_table["data"]: 15 | inputs = entry["inputs"] 16 | outputs = entry["outputs"] 17 | 18 | result = pmv_athb(**inputs) 19 | 20 | validate_result(result, outputs, tolerance) 21 | -------------------------------------------------------------------------------- /tests/test_cooling_effect.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import cooling_effect 2 | from tests.conftest import Urls, retrieve_reference_table, validate_result 3 | 4 | 5 | def test_cooling_effect(get_test_url, retrieve_data) -> None: 6 | """Test that the function calculates the cooling effect correctly for various inputs.""" 7 | reference_table = retrieve_reference_table( 8 | get_test_url, 9 | retrieve_data, 10 | Urls.COOLING_EFFECT.name, 11 | ) 12 | tolerance = reference_table["tolerance"] 13 | 14 | for entry in reference_table["data"]: 15 | inputs = entry["inputs"] 16 | outputs = entry["outputs"] 17 | result = cooling_effect(**inputs) 18 | 19 | validate_result(result, outputs, tolerance) 20 | -------------------------------------------------------------------------------- /tests/test_two_nodes_gagge.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import two_nodes_gagge 2 | from tests.conftest import Urls, retrieve_reference_table, validate_result 3 | 4 | 5 | def test_two_nodes(get_test_url, retrieve_data) -> None: 6 | """Test that the function calculates the two nodes Gagge model correctly for various inputs.""" 7 | reference_table = retrieve_reference_table( 8 | get_test_url, 9 | retrieve_data, 10 | Urls.TWO_NODES.name, 11 | ) 12 | tolerance = reference_table["tolerance"] 13 | 14 | for entry in reference_table["data"]: 15 | inputs = entry["inputs"] 16 | outputs = entry["outputs"] 17 | result = two_nodes_gagge(**inputs) 18 | 19 | validate_result(result, outputs, tolerance) 20 | -------------------------------------------------------------------------------- /tests/test_discomfort_index.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import discomfort_index 2 | from tests.conftest import Urls, retrieve_reference_table, validate_result 3 | 4 | 5 | def test_discomfort_index(get_test_url, retrieve_data) -> None: 6 | """Test that the function calculates the Discomfort Index correctly for various inputs.""" 7 | reference_table = retrieve_reference_table( 8 | get_test_url, 9 | retrieve_data, 10 | Urls.DISCOMFORT_INDEX.name, 11 | ) 12 | tolerance = reference_table["tolerance"] 13 | 14 | for entry in reference_table["data"]: 15 | inputs = entry["inputs"] 16 | outputs = entry["outputs"] 17 | result = discomfort_index(**inputs) 18 | 19 | validate_result(result, outputs, tolerance) 20 | -------------------------------------------------------------------------------- /examples/calc_phs.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import matplotlib.pyplot as plt 4 | import pandas as pd 5 | import seaborn as sns 6 | 7 | from pythermalcomfort.models import phs 8 | 9 | plt.close("all") 10 | 11 | t_array = range(30, 52, 1) 12 | rh_array = range(0, 100, 1) 13 | 14 | df = pd.DataFrame(list(itertools.product(t_array, rh_array)), columns=["tdb", "rh"]) 15 | 16 | results = phs( 17 | tdb=df.tdb.values, 18 | tr=df.tdb.values, 19 | rh=df.rh.values, 20 | v=0.3, 21 | met=2, 22 | clo=0.5, 23 | posture="sitting", 24 | ) 25 | 26 | df["t_re"] = results.t_re 27 | 28 | plt.figure() 29 | pivot = df.pivot(columns="tdb", index="rh", values="t_re") 30 | pivot = pivot.sort_index(ascending=False) 31 | ax = sns.heatmap(pivot) 32 | plt.tight_layout() 33 | plt.show() 34 | -------------------------------------------------------------------------------- /tests/test_use_fans_heatwaves.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import use_fans_heatwaves 2 | from tests.conftest import Urls, retrieve_reference_table, validate_result 3 | 4 | 5 | def test_use_fans_heatwaves(get_test_url, retrieve_data) -> None: 6 | """Test that the function calculates the use of fans during heatwaves correctly for various inputs.""" 7 | reference_table = retrieve_reference_table( 8 | get_test_url, 9 | retrieve_data, 10 | Urls.USE_FANS_HEATWAVES.name, 11 | ) 12 | tolerance = reference_table["tolerance"] 13 | 14 | for entry in reference_table["data"]: 15 | inputs = entry["inputs"] 16 | outputs = entry["outputs"] 17 | result = use_fans_heatwaves(**inputs) 18 | 19 | validate_result(result, outputs, tolerance) 20 | -------------------------------------------------------------------------------- /tests/test_clo_tout.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import clo_tout 2 | from pythermalcomfort.utilities import Units 3 | from tests.conftest import Urls, retrieve_reference_table, validate_result 4 | 5 | 6 | def test_clo_tout(get_test_url, retrieve_data) -> None: 7 | """Test that the function calculates the clothing thermal insulation (Clo) for various inputs.""" 8 | reference_table = retrieve_reference_table( 9 | get_test_url, 10 | retrieve_data, 11 | Urls.CLO_TOUT.name, 12 | ) 13 | tolerance = reference_table["tolerance"] 14 | 15 | for entry in reference_table["data"]: 16 | inputs = entry["inputs"] 17 | outputs = entry["outputs"] 18 | result = clo_tout( 19 | tout=inputs["tout"], 20 | units=inputs.get("units", Units.SI.value), 21 | ) 22 | 23 | validate_result(result, outputs, tolerance) 24 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 140 6 | exclude = */migrations/* 7 | 8 | [tool:pytest] 9 | # If a pytest section is found in one of the possible config files 10 | # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, 11 | # so if you add a pytest config section elsewhere, 12 | # you will need to delete this section from setup.cfg. 13 | norecursedirs = 14 | migrations 15 | 16 | python_files = 17 | test_*.py 18 | *_test.py 19 | tests.py 20 | addopts = 21 | -ra 22 | --strict 23 | --doctest-modules 24 | --doctest-glob=\*.rst 25 | --tb=short 26 | testpaths = 27 | tests 28 | 29 | [tool:isort] 30 | force_single_line = True 31 | line_length = 120 32 | known_first_party = pythermalcomfort 33 | default_section = THIRDPARTY 34 | forced_separate = test_pythermalcomfort 35 | not_skip = __init__.py 36 | skip = migrations 37 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Build documentation with MkDocs 13 | #mkdocs: 14 | # configuration: mkdocs.yml 15 | 16 | # Optionally build your docs in additional formats such as PDF and ePub 17 | formats: all 18 | 19 | # Optionally set the version of Python and requirements required to build your docs 20 | build: 21 | os: ubuntu-22.04 22 | tools: 23 | python: "3.11" 24 | 25 | # Optional but recommended, declare the Python requirements required 26 | # to build your documentation 27 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 28 | python: 29 | install: 30 | - requirements: docs/requirements.txt 31 | - method: pip 32 | path: . 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | .eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | wheelhouse 19 | develop-eggs 20 | .installed.cfg 21 | lib 22 | lib64 23 | venv*/ 24 | pyvenv*/ 25 | pip-wheel-metadata/ 26 | .venv/ 27 | 28 | # Installer logs 29 | pip-log.txt 30 | 31 | # Unit test / coverage reports 32 | .coverage 33 | .tox 34 | .coverage.* 35 | .pytest_cache/ 36 | nosetests.xml 37 | coverage.xml 38 | htmlcov 39 | 40 | # Translations 41 | *.mo 42 | 43 | # Mr Developer 44 | .mr.developer.cfg 45 | .project 46 | .pydevproject 47 | .idea 48 | *.iml 49 | *.komodoproject 50 | 51 | # Complexity 52 | output/*.html 53 | output/*/index.html 54 | 55 | # Sphinx 56 | docs/_build 57 | 58 | .DS_Store 59 | *~ 60 | .*.sw[po] 61 | .build 62 | .ve 63 | .env 64 | .cache 65 | .pytest 66 | .benchmarks 67 | .bootstrap 68 | *.bak 69 | 70 | # Mypy Cache 71 | .mypy_cache/ 72 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | cache: false 4 | env: 5 | global: 6 | - LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so 7 | - SEGFAULT_SIGNALS=all 8 | matrix: 9 | include: 10 | - env: 11 | - TOXENV=py310,codecov 12 | python: '3.10' 13 | - env: 14 | - TOXENV=py311,codecov 15 | python: '3.11' 16 | - env: 17 | - TOXENV=py312,codecov 18 | python: '3.12' 19 | - env: 20 | - TOXENV=py313,codecov 21 | python: '3.13' 22 | before_install: 23 | - python --version 24 | - uname -a 25 | - lsb_release -a || true 26 | install: 27 | - python -mpip install --progress-bar=off tox -rci/requirements.txt 28 | - virtualenv --version 29 | - easy_install --version 30 | - pip --version 31 | - tox --version 32 | script: 33 | - tox -v 34 | after_failure: 35 | - more .tox/log/* | cat 36 | - more .tox/*/log/* | cat 37 | notifications: 38 | email: 39 | on_success: never 40 | on_failure: always 41 | -------------------------------------------------------------------------------- /docs/documentation/examples.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Examples 3 | ======== 4 | 5 | Code snippets 6 | ============= 7 | 8 | Each function contains a few examples in the documentation. 9 | 10 | Example files 11 | ============= 12 | 13 | You can also check the `Examples`_ files on how to use some of the functions 14 | 15 | .. _Examples: https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort/tree/master/examples 16 | 17 | Video tutorials 18 | =============== 19 | 20 | If instead of reading you prefer watching, you can check the YouTube `tutorials`_ playlist 21 | 22 | .. _tutorials: https://youtube.com/playlist?list=PLY91jl6VVD7zMaJjRVrVkaBtI56U7ztQC 23 | 24 | .. raw:: html 25 | 26 |
27 | 28 |
29 | -------------------------------------------------------------------------------- /examples/calc_utci.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.models import utci 6 | 7 | utci_val = utci(tdb=29, tr=30, v=1, rh=60) 8 | print(utci_val) 9 | 10 | utci_val = utci(tdb=60, tr=30, v=1, rh=60) 11 | print(utci_val) 12 | print(utci(tdb=60, tr=30, v=1, rh=60, limit_inputs=False)) 13 | 14 | utci_val = utci(tdb=77, tr=77, v=6.56168, rh=60, units="IP") 15 | print(utci_val) 16 | 17 | # numpy examples 18 | utci_val = utci( 19 | tdb=[29, 29, 25], 20 | tr=[30, 30, 25], 21 | v=[1, 2, 1], 22 | rh=[60, 60, 50], 23 | ) 24 | print(utci_val.utci) 25 | 26 | utci_val = utci( 27 | tdb=[29, 29, 25], 28 | tr=[30, 30, 25], 29 | v=[1, 2, 1], 30 | rh=[60, 60, 50], 31 | ) 32 | print(utci_val) 33 | print(utci_val["utci"]) 34 | print(utci_val["stress_category"]) 35 | 36 | iterations = 100000 37 | tdb = np.empty(iterations) 38 | tdb.fill(25) 39 | start = time.time() 40 | utci_val = utci(tdb=tdb, tr=30, v=1, rh=60) 41 | end = time.time() 42 | print(end - start) 43 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /pythermalcomfort/cli.py: -------------------------------------------------------------------------------- 1 | """Module that contains the command line app. 2 | 3 | Why does this file exist, and why not put this in __main__? 4 | 5 | You might be tempted to import things from __main__ later, but that will cause 6 | problems: the code will get executed twice: 7 | 8 | - When you run `python -mpythermalcomfort` python will execute 9 | ``__main__.py`` as a script. That means there won't be any 10 | ``pythermalcomfort.__main__`` in ``sys.modules``. 11 | - When you import __main__ it will get executed again (as a module) because 12 | there's no ``pythermalcomfort.__main__`` in ``sys.modules``. 13 | 14 | Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration 15 | """ 16 | 17 | import sys 18 | 19 | 20 | def main(argv=sys.argv): 21 | """Print command line arguments and return an exit code. 22 | 23 | Args: 24 | argv (list): List of command line arguments. 25 | 26 | Returns: 27 | int: Exit code. 28 | """ 29 | print(argv) 30 | return 0 31 | -------------------------------------------------------------------------------- /tests/test_vertical_tmp_grad_ppd.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pythermalcomfort.models.vertical_tmp_grad_ppd import vertical_tmp_grad_ppd 4 | from tests.conftest import Urls, retrieve_reference_table, validate_result 5 | 6 | 7 | def test_vertical_tmp_grad_ppd(get_test_url, retrieve_data) -> None: 8 | """Test that the function calculates the output correctly for various inputs.""" 9 | reference_table = retrieve_reference_table( 10 | get_test_url, 11 | retrieve_data, 12 | Urls.VERTICAL_TMP_GRAD_PPD.name, 13 | ) 14 | tolerance = reference_table["tolerance"] 15 | 16 | for entry in reference_table["data"]: 17 | inputs = entry["inputs"] 18 | outputs = entry["outputs"] 19 | result = vertical_tmp_grad_ppd(**inputs) 20 | 21 | validate_result(result, outputs, tolerance) 22 | 23 | # Test for ValueError 24 | np.isclose( 25 | vertical_tmp_grad_ppd(25, 25, 0.3, 50, 1.2, 0.5, 7).ppd_vg, 26 | np.nan, 27 | equal_nan=True, 28 | ) 29 | -------------------------------------------------------------------------------- /tests/test_utci.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pythermalcomfort.models import utci 4 | from pythermalcomfort.models.utci import _utci_optimized 5 | from tests.conftest import Urls, retrieve_reference_table, validate_result 6 | 7 | 8 | def test_utci(get_test_url, retrieve_data) -> None: 9 | """Test that the UTCI function calculates correctly for various inputs.""" 10 | reference_table = retrieve_reference_table( 11 | get_test_url, 12 | retrieve_data, 13 | Urls.UTCI.name, 14 | ) 15 | tolerance = reference_table["tolerance"] 16 | 17 | for entry in reference_table["data"]: 18 | inputs = entry["inputs"] 19 | outputs = entry["outputs"] 20 | result = utci(**inputs) 21 | 22 | validate_result(result, outputs, tolerance) 23 | 24 | 25 | def test_utci_optimized() -> None: 26 | """Test that the optimized UTCI function calculates correctly for various inputs.""" 27 | np.testing.assert_equal( 28 | np.around(_utci_optimized([25, 27], 1, 1, 1.5), 2), 29 | [24.73, 26.57], 30 | ) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Federico Tartarini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice (including the next paragraph) shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /tests/test_at.py: -------------------------------------------------------------------------------- 1 | from pythermalcomfort.models import at 2 | from tests.conftest import Urls, is_equal, retrieve_reference_table, validate_result 3 | 4 | 5 | def test_at(get_test_url, retrieve_data) -> None: 6 | """Test that the function calculates the AT correctly for various inputs.""" 7 | reference_table = retrieve_reference_table( 8 | get_test_url, 9 | retrieve_data, 10 | Urls.AT.name, 11 | ) 12 | tolerance = reference_table["tolerance"] 13 | 14 | for entry in reference_table["data"]: 15 | inputs = entry["inputs"] 16 | outputs = entry["outputs"] 17 | result = at(**inputs) 18 | 19 | validate_result(result, outputs, tolerance) 20 | 21 | 22 | def test_at_list_input() -> None: 23 | """Test that the function calculates the AT correctly for list inputs.""" 24 | result = at([25, 25, 25], [30, 30, 30], [0.1, 0.1, 0.1]) 25 | is_equal(result, [24.1, 24.1, 24.1], 0.1) 26 | 27 | 28 | def test_at_q() -> None: 29 | """Test that the function calculates the AT correctly for given inputs.""" 30 | result = at(25, 30, 0.1, 100) 31 | is_equal(result, 25.3, 0.1) 32 | -------------------------------------------------------------------------------- /tests/test_wbgt.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pythermalcomfort.models import wbgt 4 | from tests.conftest import Urls, retrieve_reference_table, validate_result 5 | 6 | 7 | def test_wbgt_with_url_cases(get_test_url, retrieve_data) -> None: 8 | """Test that the function calculates the Wet Bulb Globe Temperature (WBGT) correctly for various inputs.""" 9 | reference_table = retrieve_reference_table( 10 | get_test_url, 11 | retrieve_data, 12 | Urls.WBGT.name, 13 | ) 14 | tolerance = reference_table["tolerance"] 15 | 16 | for entry in reference_table["data"]: 17 | inputs = entry["inputs"] 18 | outputs = entry["outputs"] 19 | result = wbgt(**inputs) 20 | 21 | validate_result(result, outputs, tolerance) 22 | 23 | 24 | # Test wbgt value error 25 | def test_wbgt() -> None: 26 | """Test that the function raises ValueError if twb is not provided.""" 27 | with pytest.raises(ValueError): 28 | wbgt(twb=25, tg=32, with_solar_load=True) 29 | 30 | 31 | # Calculate WBGT with twb and tg set to None 32 | def test_calculate_wbgt_with_twb_and_tg_set_to_none() -> None: 33 | """Test that the function raises Error with twb and tg set to None.""" 34 | with pytest.raises(TypeError): 35 | wbgt(None, None) 36 | -------------------------------------------------------------------------------- /tests/test_a_pmv.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pythermalcomfort.models import pmv_a 4 | from tests.conftest import Urls, retrieve_reference_table, validate_result 5 | 6 | 7 | def test_a_pmv(get_test_url, retrieve_data) -> None: 8 | """Test that the function calculates the aPMV correctly for various inputs.""" 9 | reference_table = retrieve_reference_table( 10 | get_test_url, 11 | retrieve_data, 12 | Urls.A_PMV.name, 13 | ) 14 | tolerance = reference_table["tolerance"] 15 | 16 | for entry in reference_table["data"]: 17 | inputs = entry["inputs"] 18 | outputs = entry["outputs"] 19 | result = pmv_a(**inputs) 20 | 21 | validate_result(result, outputs, tolerance) 22 | 23 | 24 | def test_a_pmv_wrong_input_type() -> None: 25 | """Test that the function raises a TypeError for wrong input types.""" 26 | with pytest.raises(TypeError): 27 | pmv_a("25", 25, 0.1, 50, 1.2, 0.5, 7) 28 | with pytest.raises(ValueError): 29 | pmv_a(25, 25, 0.1, 50, 1.2, 0.5, 7, units="celsius") 30 | 31 | 32 | def test_not_valid_units() -> None: 33 | """Test that the function raises a ValueError for invalid units.""" 34 | with pytest.raises(ValueError): 35 | pmv_a(25, 25, 0.1, 50, 1.2, 0.5, 7, units="wrong") 36 | -------------------------------------------------------------------------------- /tests/test_solar_gain.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pythermalcomfort.models import solar_gain 4 | from tests.conftest import Urls, retrieve_reference_table, validate_result 5 | 6 | 7 | def test_solar_gain(get_test_url, retrieve_data) -> None: 8 | """Test that the solar gain function calculates correctly for various inputs.""" 9 | reference_table = retrieve_reference_table( 10 | get_test_url, 11 | retrieve_data, 12 | Urls.SOLAR_GAIN.name, 13 | ) 14 | tolerance = reference_table["tolerance"] 15 | 16 | for entry in reference_table["data"]: 17 | inputs = entry["inputs"] 18 | outputs = entry["outputs"] 19 | result = solar_gain(**inputs) 20 | 21 | validate_result(result, outputs, tolerance) 22 | 23 | 24 | def test_solar_gain_array() -> None: 25 | """Test that the solar gain function works with arrays.""" 26 | np.allclose( 27 | solar_gain( 28 | sol_altitude=[0, 30], 29 | sharp=[120, 60], 30 | sol_radiation_dir=[800, 600], 31 | sol_transmittance=[0.5, 0.6], 32 | f_svv=[0.5, 0.4], 33 | f_bes=[0.5, 0.6], 34 | asw=0.7, 35 | posture="sitting", 36 | ).erf, 37 | np.asarray([46.4, 52.8]), 38 | atol=0.1, 39 | ) 40 | -------------------------------------------------------------------------------- /tests/test_set.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pythermalcomfort.models import ( 4 | set_tmp, 5 | ) 6 | from tests.conftest import Urls, retrieve_reference_table, validate_result 7 | 8 | tdb = [] 9 | tr = [] 10 | v = [] 11 | rh = [] 12 | met = [] 13 | clo = [] 14 | set_exp = [] 15 | 16 | 17 | def test_set_url(get_test_url, retrieve_data) -> None: 18 | """Test the SET model with various inputs.""" 19 | reference_table = retrieve_reference_table( 20 | get_test_url, 21 | retrieve_data, 22 | Urls.SET.name, 23 | ) 24 | tolerance = reference_table["tolerance"] 25 | for entry in reference_table["data"]: 26 | inputs = entry["inputs"] 27 | outputs = entry["outputs"] 28 | inputs["round_output"] = True 29 | inputs["limit_inputs"] = False 30 | result = set_tmp(**inputs) 31 | 32 | validate_result(result, outputs, tolerance) 33 | 34 | 35 | def test_set_npnan() -> None: 36 | """Test that the function returns np.nan when inputs are np.nan.""" 37 | np.testing.assert_equal( 38 | set_tmp( 39 | [41, 20, 20, 20, 20, 39], 40 | [20, 41, 20, 20, 20, 39], 41 | [0.1, 0.1, 2.1, 0.1, 0.1, 0.1], 42 | 50, 43 | [1.1, 1.1, 1.1, 0.7, 1.1, 3.9], 44 | [0.5, 0.5, 0.5, 0.5, 2.1, 1.9], 45 | ).set, 46 | [np.nan, np.nan, np.nan, np.nan, np.nan, np.nan], 47 | ) 48 | -------------------------------------------------------------------------------- /pythermalcomfort/models/thi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import THIInputs 6 | from pythermalcomfort.classes_return import THI 7 | 8 | 9 | def thi( 10 | tdb: float | list[float], 11 | rh: float | list[float], 12 | round_output: bool = True, 13 | ) -> THI: 14 | """Calculate the Temperature-Humidity Index (THI) defined in [Yan2025]_, equivalent 15 | to the definition in [Schlatter1987]_, but uses Celsius instead of Fahrenheit. 16 | 17 | Parameters 18 | ---------- 19 | tdb : float or list of floats 20 | Dry bulb air temperature, [°C]. 21 | rh: float or list of floats 22 | Relative humidity, [%]. 23 | round_output : bool, optional 24 | If True, rounds output value. If False, it does not round it. Defaults to True. 25 | 26 | Returns 27 | ------- 28 | THI 29 | A dataclass containing the Temperature-Humidity Index. 30 | See :py:class:`~pythermalcomfort.classes_return.THI` for more details. 31 | To access the `thi` value, use the `thi` attribute of the returned `THI` 32 | instance, e.g., `result.thi`. 33 | """ 34 | # Validate inputs using the THIInputs class 35 | THIInputs( 36 | tdb=tdb, 37 | rh=rh, 38 | round_output=round_output, 39 | ) 40 | 41 | tdb = np.asarray(tdb) 42 | rh = np.asarray(rh) 43 | 44 | _thi = 1.8 * tdb + 32 - 0.55 * (1 - 0.01 * rh) * (1.8 * tdb - 26) 45 | 46 | if round_output: 47 | _thi = np.round(_thi, 1) 48 | 49 | return THI(thi=_thi) 50 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - id: check-yaml 8 | - id: check-json 9 | - id: check-toml 10 | - id: check-added-large-files 11 | - id: check-case-conflict 12 | - id: check-docstring-first 13 | - id: check-illegal-windows-names 14 | - id: check-merge-conflict 15 | - id: detect-private-key 16 | 17 | - repo: https://github.com/astral-sh/ruff-pre-commit 18 | rev: v0.14.3 19 | hooks: 20 | - id: ruff-check 21 | args: [--fix] 22 | - id: ruff-format 23 | 24 | - repo: https://github.com/asottile/pyupgrade 25 | rev: v3.21.0 26 | hooks: 27 | - id: pyupgrade 28 | 29 | - repo: https://github.com/jorisroovers/gitlint 30 | rev: v0.19.1 31 | hooks: 32 | - id: gitlint 33 | 34 | # Optional hooks (uncomment to enable) 35 | # - repo: https://github.com/pre-commit/mirrors-mypy 36 | # rev: v1.18.2 37 | # hooks: 38 | # - id: mypy 39 | # args: [--exclude, tests/] 40 | # 41 | # - repo: https://github.com/streetsidesoftware/cspell-cli 42 | # rev: v9.2.1 43 | # hooks: 44 | # - id: cspell # Spell check changed files 45 | # - id: cspell # Spell check the commit message 46 | # name: check commit message spelling 47 | # args: 48 | # - --no-must-find-files 49 | # - --no-progress 50 | # - --no-summary 51 | # - --files 52 | # - .git/COMMIT_EDITMSG 53 | # stages: [commit-msg] 54 | # always_run: true 55 | -------------------------------------------------------------------------------- /tests/test_wind_chill_index.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pythermalcomfort.models import wci 4 | from tests.conftest import Urls, retrieve_reference_table, validate_result 5 | 6 | 7 | def test_calculates_wci(get_test_url, retrieve_data) -> None: 8 | """Test that the function calculates the wind chill index (WCI) correctly for various inputs.""" 9 | reference_table = retrieve_reference_table( 10 | get_test_url, 11 | retrieve_data, 12 | Urls.WIND_CHILL.name, 13 | ) 14 | tolerance = reference_table["tolerance"] 15 | 16 | for entry in reference_table["data"]: 17 | inputs = entry["inputs"] 18 | outputs = entry["outputs"] 19 | result = wci(**inputs) 20 | 21 | validate_result(result, outputs, tolerance) 22 | 23 | 24 | class TestWc: 25 | """Test cases for the wind chill index (WCI) model.""" 26 | 27 | # Raises TypeError if tdb parameter is not provided 28 | def test_raises_type_error_tdb_not_provided(self) -> None: 29 | """Test that the function raises TypeError if tdb is not provided.""" 30 | with pytest.raises(TypeError): 31 | wci(v=0.1) 32 | 33 | # Raises TypeError if v parameter is not provided 34 | def test_raises_type_error_v_not_provided(self) -> None: 35 | """Test that the function raises TypeError if v is not provided.""" 36 | with pytest.raises(TypeError): 37 | wci(tdb=0) 38 | 39 | # Raises TypeError if tdb parameter is not a float 40 | def test_raises_type_error_tdb_not_float(self) -> None: 41 | """Test that the function raises TypeError if tdb is not a float.""" 42 | with pytest.raises(TypeError): 43 | wci(tdb="0", v=0.1) 44 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | Main Authors 5 | ------------ 6 | 7 | * `Federico Tartarini`_ 8 | * `Stefano Schiavon`_ 9 | 10 | Contributors 11 | ------------ 12 | 13 | * `Akihisa Nomoto`_ 14 | * `Tyler Hoyt`_ 15 | * Chris Mackey 16 | * `Jonas Kittner`_ 17 | * `Connor Forbes`_ 18 | 19 | .. _Federico Tartarini: https://www.linkedin.com/in/federico-tartarini-3991995b/ 20 | .. _Stefano Schiavon: https://www.linkedin.com/in/stefanoschiavon/ 21 | .. _Tyler Hoyt: https://www.linkedin.com/in/tyler-hoyt1/ 22 | .. _Jonas Kittner: https://github.com/jkittner/ 23 | .. _Akihisa Nomoto: https://www.linkedin.com/in/akihisa-nomoto-3b872611b/ 24 | .. _Connor Forbes: https://www.linkedin.com/in/connor-forbes-1a490117b/ 25 | 26 | Acknowledgements 27 | ---------------- 28 | 29 | `pythermalcomfort` is a derivative work of the following software projects: 30 | 31 | * `CBE Comfort Tool`_ for indoor thermal comfort calculations. Available under GPL. 32 | * `ladybug-comfort`_. Available under GPL. 33 | * `UTCI`_ Fortran Code for outdoor thermal comfort calculations. Available under MIT. 34 | 35 | .. _pythermalcomfort: https://pypi.org/project/pythermalcomfort/ 36 | .. _CBE Comfort Tool: https://comfort.cbe.berkeley.edu 37 | .. _ladybug-comfort: https://pypi.org/project/ladybug-comfort/ 38 | .. _UTCI: https://www.utci.org/ 39 | 40 | Contact us 41 | ---------- 42 | 43 | If you need help, would like to ask a question about the tool, give us a feedback, or suggest a new feature you can now use `GitHub discussions `_. 44 | 45 | Bug reports 46 | ----------- 47 | 48 | You can report bugs or request new features using `GitHub issues `_. 49 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Workflow 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | branches: 7 | - development 8 | 9 | jobs: 10 | 11 | format: 12 | 13 | # check that the code is formatted correctly 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python 🐍 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.12' 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | python -m pip install setuptools 26 | python -m pip install ruff 27 | - name: Lint 28 | run: ruff check ./pythermalcomfort ./tests 29 | - name: Check formatting 30 | run: ruff format --check ./pythermalcomfort ./tests 31 | 32 | test: 33 | needs: format 34 | 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | python-version: [ '3.10', '3.13' ] 39 | platform: [ ubuntu-latest ] 40 | 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Setup Python ${{ matrix.python-version }} 44 | uses: actions/setup-python@v4 45 | with: 46 | python-version: ${{ matrix.python-version }} 47 | - name: Install Tox and any other packages 48 | run: | 49 | python -m pip install --upgrade pip 50 | python -m pip install setuptools 51 | python -m pip install "tox<4" tox-gh-actions 52 | python -m pip install ruff 53 | - name: Lint 54 | run: ruff check ./pythermalcomfort ./tests 55 | - name: Check formatting 56 | run: ruff format 57 | - name: Run Tox 58 | run: | 59 | tox 60 | env: 61 | PLATFORM: ${{ matrix.platform }} 62 | -------------------------------------------------------------------------------- /.github/workflows/build-test-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Test and publish pythermalcomfort 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [ '3.10', '3.11', '3.12', '3.13' ] 19 | platform: [ubuntu-latest, macos-latest, windows-latest] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Setup Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v4 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install Tox and any other packages 28 | run: | 29 | python -m pip install --upgrade pip 30 | python -m pip install setuptools 31 | python -m pip install "tox<4" tox-gh-actions 32 | - name: Run Tox 33 | run: | 34 | tox 35 | env: 36 | PLATFORM: ${{ matrix.platform }} 37 | 38 | deploy: 39 | 40 | needs: test 41 | 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - uses: actions/checkout@v3 46 | - name: Set up Python 🐍 47 | uses: actions/setup-python@v4 48 | with: 49 | python-version: '3.10' 50 | - name: Install dependencies 51 | run: | 52 | python -m pip install --upgrade pip 53 | pip install setuptools wheel twine 54 | - name: Publish pythermalcomfort 📦 to PyPI 55 | env: 56 | TWINE_USERNAME: "__token__" 57 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 58 | run: | 59 | python setup.py sdist bdist_wheel 60 | python -m twine upload dist/*.gz dist/*.whl 61 | -------------------------------------------------------------------------------- /tests/test_pet_steady.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.models import pet_steady 5 | from tests.conftest import Urls, retrieve_reference_table, validate_result 6 | 7 | 8 | def test_pet_steady(get_test_url, retrieve_data) -> None: 9 | """Test that the PET function calculates correctly for various inputs.""" 10 | reference_table = retrieve_reference_table( 11 | get_test_url, 12 | retrieve_data, 13 | Urls.PET_STEADY.name, 14 | ) 15 | tolerance = reference_table["tolerance"] 16 | 17 | for entry in reference_table["data"]: 18 | inputs = entry["inputs"] 19 | outputs = entry["outputs"] 20 | result = pet_steady(**inputs) 21 | 22 | validate_result(result, outputs, tolerance) 23 | 24 | 25 | PET_TEST_MATRIX = ( 26 | # 'tdb', 'tr', 'rh', 'v', 'met', 'clo', 'exp' 27 | (20, 20, 50, 0.15, 1.37, 0.5, 18.85), 28 | (30, 30, 50, 0.15, 1.37, 0.5, 30.6), 29 | (20, 20, 50, 0.5, 1.37, 0.5, 17.16), 30 | (21, 21, 50, 0.1, 1.37, 0.9, 21.08), 31 | (20, 20, 50, 0.1, 1.37, 0.9, 19.92), 32 | (-5, 40, 2, 0.5, 1.37, 0.9, 7.82), 33 | (-5, -5, 50, 5.0, 1.37, 0.9, -13.38), 34 | (30, 60, 80, 1.0, 1.37, 0.9, 44.63), 35 | (30, 30, 80, 1.0, 1.37, 0.9, 32.21), 36 | ) 37 | 38 | 39 | @pytest.mark.parametrize("shape", [(10, 10), 10, (3, 3, 3)]) 40 | @pytest.mark.parametrize(("tdb", "tr", "rh", "v", "met", "clo", "exp"), PET_TEST_MATRIX) 41 | def test_pet_array(shape, tdb, tr, rh, v, met, clo, exp) -> None: 42 | """Test that the PET function calculates correctly for various inputs.""" 43 | tdb_arr = list(np.full(shape, tdb)) 44 | tr_arr = list(np.full(shape, tr)) 45 | rh_arr = list(np.full(shape, rh)) 46 | v_arr = list(np.full(shape, v)) 47 | res = pet_steady(tdb=tdb_arr, tr=tr_arr, rh=rh_arr, v=v_arr, met=met, clo=clo).pet 48 | np.testing.assert_array_equal(actual=res, desired=np.full(shape, exp)) 49 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv:bootstrap] 2 | deps = 3 | jinja2 4 | matrix 5 | tox<4 6 | skip_install = true 7 | commands = 8 | python ci/bootstrap.py --no-env 9 | passenv = 10 | * 11 | ; a generative tox configuration, see: https://tox.readthedocs.io/en/latest/config.html#generative-envlist 12 | 13 | [tox] 14 | envlist = 15 | clean, 16 | docs, 17 | {py310,py311,py312,py313}, 18 | report 19 | ignore_basepython_conflict = true 20 | skip_missing_interpreters = True 21 | 22 | [gh-actions] 23 | python = 24 | 3.10: py310, mypy 25 | 3.11: py311 26 | 3.12: py312 27 | 3.13: py313 28 | 29 | [testenv] 30 | basepython = 31 | {py310,docs}: {env:TOXPYTHON:python3.10} 32 | py311: {env:TOXPYTHON:python3.11} 33 | py312: {env:TOXPYTHON:python3.12} 34 | py313: {env:TOXPYTHON:python3.13} 35 | {bootstrap,clean,check,report,codecov}: {env:TOXPYTHON:python3} 36 | setenv = 37 | PYTHONPATH={toxinidir}/tests 38 | PYTHONUNBUFFERED=yes 39 | passenv = 40 | * 41 | usedevelop = false 42 | deps = 43 | pytest<7 44 | requests 45 | pytest-travis-fold 46 | pytest-cov 47 | scipy 48 | numba 49 | pandas 50 | tabulate 51 | setuptools 52 | commands = 53 | {posargs:pytest --cov --cov-report=term-missing -vv tests} 54 | 55 | [testenv:docs_build] 56 | usedevelop = true 57 | deps = 58 | -rdocs/requirements.txt 59 | commands = 60 | sphinx-build {posargs:-E} -b html docs dist/docs 61 | 62 | [testenv:docs] 63 | usedevelop = true 64 | deps = 65 | -rdocs/requirements.txt 66 | commands = 67 | sphinx-build {posargs:-E} -b html docs dist/docs 68 | sphinx-build -b linkcheck docs dist/docs 69 | 70 | [testenv:codecov] 71 | deps = 72 | codecov 73 | skip_install = true 74 | commands = 75 | codecov [] 76 | 77 | [testenv:report] 78 | deps = coverage 79 | skip_install = true 80 | commands = 81 | coverage report 82 | coverage html 83 | 84 | [testenv:clean] 85 | commands = coverage erase 86 | skip_install = true 87 | deps = coverage 88 | -------------------------------------------------------------------------------- /tests/test_humidex.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import pytest 4 | 5 | from pythermalcomfort.models import humidex 6 | from tests.conftest import Urls, retrieve_reference_table, validate_result 7 | 8 | 9 | def test_humidex(get_test_url, retrieve_data) -> None: 10 | """Test that the function calculates the Humidex correctly for various inputs.""" 11 | reference_table = retrieve_reference_table( 12 | get_test_url, 13 | retrieve_data, 14 | Urls.HUMIDEX.name, 15 | ) 16 | tolerance = reference_table["tolerance"] 17 | 18 | for entry in reference_table["data"]: 19 | inputs = entry["inputs"] 20 | outputs = entry["outputs"] 21 | result = humidex(**inputs) 22 | 23 | validate_result(result, outputs, tolerance) 24 | 25 | with pytest.raises(TypeError): 26 | humidex("25", 50) 27 | 28 | with pytest.raises(TypeError): 29 | humidex(25, "50") 30 | 31 | with pytest.raises(ValueError): 32 | humidex(tdb=25, rh=110) 33 | 34 | with pytest.raises(ValueError): 35 | humidex(25, -10) 36 | 37 | 38 | def test_humidex_masterson() -> None: 39 | """Test the Masterson model for Humidex calculations.""" 40 | # TODO move this to shared test 41 | # I got these values from 42 | # https://publications.gc.ca/collections/collection_2018/eccc/En57-23-1-79-eng.pdf 43 | result = humidex(tdb=21, rh=100, model="masterson") 44 | assert math.isclose(result.humidex, 29.3, abs_tol=0.1) 45 | assert result.discomfort == "Little or no discomfort" 46 | 47 | result = humidex(tdb=34, rh=100, model="masterson") 48 | assert math.isclose(result.humidex, 58.9, abs_tol=0.01) 49 | 50 | result = humidex(tdb=43, rh=20, model="masterson") 51 | assert math.isclose(result.humidex, 47.1, abs_tol=0.01) 52 | 53 | result = humidex(tdb=30, rh=30, model="masterson") 54 | assert math.isclose(result.humidex, 31.5, abs_tol=0.01) 55 | 56 | result = humidex(tdb=31, rh=55, model="masterson") 57 | assert math.isclose(result.humidex, 39.3, abs_tol=0.01) 58 | -------------------------------------------------------------------------------- /tests/test_esi.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pythermalcomfort.models import esi 4 | from tests.conftest import is_equal 5 | 6 | 7 | def test_esi() -> None: 8 | """Test that the function calculates the ESI correctly for given inputs.""" 9 | result = esi(tdb=30.2, rh=42.2, sol_radiation_global=766) 10 | is_equal(result.esi, 26.2, 0.1) 11 | 12 | 13 | def test_esi_list_input() -> None: 14 | """Test that the function calculates the ESI correctly for list inputs.""" 15 | result = esi([30.2, 27.0], [42.2, 68.8], [766, 289]) 16 | is_equal(result.esi, [26.2, 25.6], 0.1) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | ("tdb", "rh", "sol"), 21 | [ 22 | (30, -5, 500), # negative relative humidity 23 | (30, 120, 500), # RH above 100% 24 | (30, 50, -10), # negative solar radiation 25 | ], 26 | ) 27 | def test_esi_invalid_numeric_ranges(tdb, rh, sol) -> None: 28 | """Test that the function raises ValueError for invalid input ranges.""" 29 | with pytest.raises(ValueError): 30 | esi(tdb=tdb, rh=rh, sol_radiation_global=sol) 31 | 32 | 33 | @pytest.mark.parametrize( 34 | ("tdb", "rh", "sol", "expected"), 35 | [ 36 | (30, 0, 500, 16.5), # minimum RH 37 | (30, 100, 500, 19.5), # maximum RH 38 | (30, 50, 0, 18.0), # minimum solar radiation 39 | ], 40 | ) 41 | def test_esi_boundary_conditions( 42 | tdb: float, 43 | rh: float, 44 | sol: float, 45 | expected: float, 46 | ) -> None: 47 | """Test that the function handles boundary conditions correctly.""" 48 | result = esi(tdb=tdb, rh=rh, sol_radiation_global=sol) 49 | is_equal(result.esi, expected, 0.1) # Replace expected values with actual results 50 | 51 | 52 | @pytest.mark.parametrize( 53 | ("tdb", "rh", "sol"), 54 | [("30.0", 45, 500), (30, "45", 500), (30, 45, "500.0")], 55 | ) 56 | def test_esi_invalid_type(tdb: float, rh: float, sol: float) -> None: 57 | """Test that the function raises TypeError for invalid input types.""" 58 | with pytest.raises(TypeError): 59 | esi(tdb=tdb, rh=rh, sol_radiation_global=sol) 60 | -------------------------------------------------------------------------------- /tests/test_thi.py: -------------------------------------------------------------------------------- 1 | """Test suite for Temperature-Humidity Index (THI) model. 2 | 3 | Tests cover scalar and list inputs, rounding behaviour, and input validation. 4 | """ 5 | 6 | import numpy as np 7 | import pytest 8 | 9 | from pythermalcomfort.classes_return import THI 10 | from pythermalcomfort.models import thi 11 | from tests.conftest import is_equal 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ("tdb", "rh", "expected"), 16 | [ 17 | (30.0, 70.0, 81.4), 18 | (20.0, 50.0, 65.2), 19 | ], 20 | ) 21 | def test_scalar_rounding_default(tdb: float, rh: float, expected: float) -> None: 22 | """Test that the function calculates THI correctly with default rounding.""" 23 | result = thi(tdb, rh) 24 | assert isinstance(result, THI) 25 | assert isinstance(result.thi, float) 26 | assert is_equal(result.thi, expected) 27 | 28 | 29 | def test_scalar_no_rounding() -> None: 30 | """Test that the function calculates THI correctly without rounding.""" 31 | tdb, rh = 30.0, 70.0 32 | expected = 1.8 * tdb + 32 - 0.55 * (1 - 0.01 * rh) * (1.8 * tdb - 26) 33 | result = thi(tdb, rh, round_output=False) 34 | assert is_equal(result.thi, expected) 35 | 36 | 37 | def test_list_input() -> None: 38 | """Test that the function calculates THI correctly for lists of tdb and rh values.""" 39 | tdb_list = [30, 20] 40 | rh_list = [70, 50] 41 | result = thi(tdb_list, rh_list) 42 | expected = [81.4, 65.2] 43 | 44 | assert isinstance(result.thi, np.ndarray) 45 | assert is_equal(result.thi, expected) 46 | 47 | 48 | @pytest.mark.parametrize( 49 | ("tdb", "rh", "expected_error"), 50 | [ 51 | (25.0, -5.0, ValueError), 52 | (25.0, 150.0, ValueError), # humidity should be between 0 and 100 53 | ("hot", "humid", TypeError), # invalid types 54 | ], 55 | ) 56 | def test_invalid_inputs_raise_specific( 57 | tdb: float, 58 | rh: float, 59 | expected_error: Exception, 60 | ) -> None: 61 | """Test that the function raises specific errors for invalid inputs.""" 62 | with pytest.raises(expected_error): 63 | thi(tdb, rh) 64 | -------------------------------------------------------------------------------- /tests/test_work_capacity_dunne.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.classes_return import WorkCapacity 5 | from pythermalcomfort.models import work_capacity_dunne 6 | 7 | 8 | def test_heavy_scalar_at_25() -> None: 9 | """Verify that a scalar WBGT of 25 with heavy intensity yields 100% capacity.""" 10 | wc = work_capacity_dunne(25, "heavy") 11 | assert isinstance(wc, WorkCapacity) 12 | assert wc.capacity == pytest.approx(100) 13 | 14 | 15 | def test_heavy_array_and_clipping() -> None: 16 | """Verify heavy intensity for an array of WBGT values and proper clipping at [0, 100].""" 17 | wbgt = [20, 25, 28, 30, 35] 18 | wc = work_capacity_dunne(wbgt, "heavy") 19 | expected_base = np.clip( 20 | 100 - 25 * np.maximum(0, np.asarray(wbgt) - 25) ** (2 / 3), 21 | 0, 22 | 100, 23 | ) 24 | assert isinstance(wc, WorkCapacity) 25 | assert np.allclose(wc.capacity, expected_base) 26 | 27 | 28 | def test_moderate_and_light_intensity() -> None: 29 | """Verify moderate and light intensities scale the base capacity correctly.""" 30 | wbgt = 30 31 | base = np.clip(100 - 25 * (5) ** (2 / 3), 0, 100) 32 | wc_med = work_capacity_dunne(wbgt, "moderate") 33 | wc_light = work_capacity_dunne(wbgt, "light") 34 | assert wc_med.capacity == pytest.approx(np.clip(base * 2, 0, 100)) 35 | assert wc_light.capacity == pytest.approx(np.clip(base * 4, 0, 100)) 36 | 37 | 38 | def test_lower_bound_clipping() -> None: 39 | """Verify that capacity is clipped to 0 when WBGT is extremely high.""" 40 | wc = work_capacity_dunne(100, "heavy") 41 | assert wc.capacity == pytest.approx(0) 42 | 43 | 44 | def test_upper_bound_clipping() -> None: 45 | """Verify that capacity is clipped to 100 when WBGT is low and intensity is light.""" 46 | wc = work_capacity_dunne(20, "light") 47 | assert wc.capacity == pytest.approx(100) 48 | 49 | 50 | def test_invalid_intensity_raises() -> None: 51 | """Verify that providing an invalid intensity string raises a ValueError.""" 52 | with pytest.raises(ValueError): 53 | work_capacity_dunne(25, "invalid") 54 | -------------------------------------------------------------------------------- /pythermalcomfort/models/esi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import ESIInputs 6 | from pythermalcomfort.classes_return import ESI 7 | 8 | 9 | def esi( 10 | tdb: float | list[float], 11 | rh: float | list[float], 12 | sol_radiation_global: float | list[float], 13 | round_output: bool = True, 14 | ) -> ESI: 15 | """Calculate the Environmental Stress Index (ESI) [Moran2001]_. 16 | 17 | Parameters 18 | ---------- 19 | tdb : float or list of floats 20 | Dry bulb air temperature, [°C]. 21 | rh: float or list of floats 22 | Relative humidity, [%]. 23 | sol_radiation_global: float or list of floats 24 | Global solar radiation, [W/m2]. 25 | round_output : bool, optional 26 | If True, rounds output value. If False, it does not round it. Defaults to True. 27 | 28 | Returns 29 | ------- 30 | ESI 31 | A dataclass containing the Environmental Stress Index. See :py:class:`~pythermalcomfort.classes_return.ESI` for more details. 32 | To access the `esi` value, use the `esi` attribute of the returned `ESI` instance, e.g., `result.esi`. 33 | 34 | Examples 35 | -------- 36 | .. code-block:: python 37 | 38 | from pythermalcomfort.models import esi 39 | 40 | result = esi(tdb=30.2, rh=42.2, sol_radiation_global=766) 41 | print(result.esi) # 26.2 42 | 43 | result = esi(tdb=[30.2, 27.0], rh=[42.2, 68.8], sol_radiation_global=[766, 289]) 44 | print(result.esi) # [26.2, 25.6] 45 | """ 46 | ESIInputs( 47 | tdb=tdb, 48 | rh=rh, 49 | sol_radiation_global=sol_radiation_global, 50 | round_output=round_output, 51 | ) 52 | 53 | tdb = np.asarray(tdb) 54 | rh = np.asarray(rh) 55 | sol_radiation_global = np.asarray(sol_radiation_global) 56 | 57 | _esi = ( 58 | 0.63 * tdb 59 | - 0.03 * rh 60 | + 0.002 * sol_radiation_global 61 | + 0.0054 * (tdb * rh) 62 | - 0.073 * (0.1 + sol_radiation_global) ** (-1) 63 | ) 64 | 65 | if round_output: 66 | _esi = np.round(_esi, 1) 67 | 68 | return ESI(esi=_esi) 69 | -------------------------------------------------------------------------------- /tests/test_adaptive_en.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pythermalcomfort.models import adaptive_en 4 | from tests.conftest import Urls, retrieve_reference_table, validate_result 5 | 6 | 7 | def test_adaptive_en(get_test_url, retrieve_data) -> None: 8 | """Test that the function calculates the Adaptive Thermal Comfort Model (ASHRAE 55) correctly for various inputs.""" 9 | reference_table = retrieve_reference_table( 10 | get_test_url, 11 | retrieve_data, 12 | Urls.ADAPTIVE_EN.name, 13 | ) 14 | tolerance = reference_table["tolerance"] 15 | 16 | for entry in reference_table["data"]: 17 | inputs = entry["inputs"] 18 | outputs = entry["outputs"] 19 | result = adaptive_en( 20 | inputs["tdb"], 21 | inputs["tr"], 22 | inputs["t_running_mean"], 23 | inputs["v"], 24 | ) 25 | 26 | validate_result(result, outputs, tolerance) 27 | 28 | 29 | def test_ashrae_inputs_invalid_units() -> None: 30 | """Test that the function raises a ValueError for invalid units.""" 31 | with pytest.raises(ValueError): 32 | adaptive_en(tdb=25, tr=25, t_running_mean=20, v=0.1, units="INVALID") 33 | 34 | 35 | def test_ashrae_inputs_invalid_tdb_type() -> None: 36 | """Test that the function raises a TypeError for invalid tdb type.""" 37 | with pytest.raises(TypeError): 38 | adaptive_en(tdb="invalid", tr=25, t_running_mean=20, v=0.1) 39 | 40 | 41 | def test_ashrae_inputs_invalid_tr_type() -> None: 42 | """Test that the function raises a TypeError for invalid tr type.""" 43 | with pytest.raises(TypeError): 44 | adaptive_en(tdb=25, tr="invalid", t_running_mean=20, v=0.1) 45 | 46 | 47 | def test_ashrae_inputs_invalid_t_running_mean_type() -> None: 48 | """Test that the function raises a TypeError for invalid t_running_mean type.""" 49 | with pytest.raises(TypeError): 50 | adaptive_en(tdb=25, tr=25, t_running_mean="invalid", v=0.1) 51 | 52 | 53 | def test_ashrae_inputs_invalid_v_type() -> None: 54 | """Test that the function raises a TypeError for invalid v type.""" 55 | with pytest.raises(TypeError): 56 | adaptive_en(tdb=25, tr=25, t_running_mean=20, v="invalid") 57 | -------------------------------------------------------------------------------- /pythermalcomfort/models/wind_chill_temperature.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import WCTInputs 6 | from pythermalcomfort.classes_return import WCT 7 | 8 | 9 | def wind_chill_temperature( 10 | tdb: float | list[float], 11 | v: float | list[float], 12 | round_output: bool = True, 13 | ) -> WCT: 14 | """Calculate the Wind Chill Temperature (`WCT`_). 15 | 16 | We validated the implementation of this model by comparing the results with the Wind Chill 17 | Temperature Calculator on `Calculator.net`_ 18 | 19 | .. _WCT: https://en.wikipedia.org/wiki/Wind_chill#North_American_and_United_Kingdom_wind_chill_index 20 | .. _Calculator.net: https://www.calculator.net/wind-chill-calculator.html 21 | 22 | Parameters 23 | ---------- 24 | tdb : float or list of floats 25 | Dry bulb air temperature, [°C]. 26 | v : float or list of floats 27 | Wind speed 10m above ground level, [km/h]. 28 | round_output : bool, optional 29 | If True, rounds output value. If False, it does not round it. Defaults to True. 30 | 31 | Returns 32 | ------- 33 | WCT 34 | A dataclass containing the Wind Chill Temperature. See :py:class:`~pythermalcomfort.classes_return.WCT` for more details. 35 | To access the `wct` value, use the `wct` attribute of the returned `WCT` instance, e.g., `result.wct`. 36 | 37 | Examples 38 | -------- 39 | .. code-block:: python 40 | 41 | from pythermalcomfort.models import wind_chill_temperature 42 | 43 | result = wind_chill_temperature(tdb=-5, v=5.5) 44 | print(result.wct) # -7.5 45 | 46 | result = wind_chill_temperature(tdb=[-5, -10], v=[5.5, 10], round_output=True) 47 | print(result.wct) # [-7.5, -15.3] 48 | """ 49 | # Validate inputs using the WCYInputs class 50 | WCTInputs( 51 | tdb=tdb, 52 | v=v, 53 | round_output=round_output, 54 | ) 55 | 56 | tdb = np.asarray(tdb) 57 | v = np.asarray(v) 58 | 59 | _wct = 13.12 + 0.6215 * tdb - 11.37 * v**0.16 + 0.3965 * tdb * v**0.16 60 | 61 | if round_output: 62 | _wct = np.around(_wct, 1) 63 | 64 | return WCT(wct=_wct) 65 | -------------------------------------------------------------------------------- /tests/test_heat_index_lu.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pythermalcomfort.models import heat_index_lu 4 | 5 | 6 | def test_extended_heat_index() -> None: 7 | """Test the heat index function with extended inputs.""" 8 | index = 0 9 | hi_test_values = [ 10 | 199.9994020652, 11 | 199.9997010342, 12 | 200.0000000021, 13 | 209.9975943902, 14 | 209.9987971085, 15 | 209.9999998068, 16 | 219.9915822029, 17 | 219.9957912306, 18 | 219.9999999912, 19 | 229.9739691979, 20 | 229.9869861009, 21 | 230.0000001850, 22 | 239.9253828022, 23 | 239.9626700074, 24 | 240.0000000003, 25 | 249.7676757244, 26 | 249.8837049107, 27 | 250.0000000037, 28 | 259.3735990024, 29 | 259.6864068902, 30 | 259.9999999944, 31 | 268.5453870455, 32 | 269.2745889562, 33 | 270.0000002224, 34 | 277.2234200026, 35 | 278.6369451963, 36 | 280.0000000091, 37 | 285.7510545370, 38 | 288.2813660100, 39 | 290.7860610129, 40 | 297.5737503539, 41 | 300.2922595865, 42 | 305.3947127590, 43 | 305.5549530893, 44 | 318.6225524695, 45 | 359.9063248191, 46 | 313.0298872791, 47 | 359.0538750602, 48 | 407.5345212438, 49 | 320.5088548469, 50 | 398.5759733823, 51 | 464.9949352940, 52 | 328.0358006469, 53 | 445.8599463105, 54 | 530.5524786708, 55 | 333.2806160592, 56 | 500.0421800191, 57 | 601.9518435268, 58 | 343.6312984164, 59 | 559.6640227151, 60 | 677.2462089759, 61 | 354.1825692377, 62 | 623.1960299857, 63 | 755.0832658147, 64 | ] 65 | for t in range(200, 380, 10): 66 | for rh in [0, 0.5, 1]: 67 | hi = heat_index_lu(t - 273.15, rh * 100).hi 68 | assert np.isclose(hi + 273.15, hi_test_values[index], atol=1) 69 | index += 1 70 | 71 | 72 | def test_extended_heat_index_array_input() -> None: 73 | """Test the heat index function with array inputs.""" 74 | hi = heat_index_lu([20, 40], 50).hi 75 | assert np.allclose(hi, [19.0, 63.4], atol=0.1) 76 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the changes and the related issue. 4 | Please also include relevant motivation and context. 5 | List any dependencies that are required for this change. 6 | Mention the person who is responsible for reviewing the pull request. 7 | 8 | Fixes # (issue) 9 | 10 | ## Type of change 11 | 12 | Please delete options that are not relevant. 13 | 14 | - [ ] Bug fix (non-breaking change which fixes an issue) 15 | - [ ] New feature (non-breaking change which adds functionality) 16 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 17 | - [ ] This change requires a documentation update 18 | 19 | ## Changes to Core Features: 20 | 21 | * [ ] Have you added an explanation of what your changes do and why you'd like us to include them? 22 | * [ ] Have you written new tests for your core changes, as applicable? 23 | * [ ] Have you successfully run tests with your changes locally? 24 | 25 | ## New Feature Submissions: 26 | 27 | 1. [ ] Does your submission pass tests? 28 | 2. [ ] Have you lint your code locally before submission? 29 | 30 | # How Has This Been Tested? 31 | 32 | Please describe the tests that you ran to verify your changes. 33 | Provide instructions so we can reproduce. 34 | Please also list any relevant details for your test configuration 35 | 36 | - [ ] Test A 37 | - [ ] Test B 38 | 39 | # Checklist: 40 | 41 | - [ ] My code follows the style guidelines of this project 42 | - [ ] I have performed a self-review of my code 43 | - [ ] I have commented my code, particularly in hard-to-understand areas 44 | - [ ] I have made corresponding changes to the documentation 45 | - [ ] My changes generate no new warnings 46 | - [ ] I have added tests that prove my fix is effective or that my feature works 47 | - [ ] New and existing unit tests pass locally with my changes 48 | - [ ] Any dependent changes have been merged and published in downstream modules 49 | 50 | # Reviewer's checklist 51 | 52 | - [ ] The code is well-documented 53 | - [ ] The code is well-tested 54 | - [ ] The code follows the style guidelines of this project 55 | - [ ] The code has been reviewed by a team member 56 | - [ ] The code has been tested by a team member 57 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Same as Black. 2 | line-length = 88 3 | indent-width = 4 4 | 5 | # Support Python 3.10+. 6 | target-version = "py310" 7 | 8 | [lint] 9 | select = [ 10 | "A", # prevent using keywords that clobber python builtins 11 | "B", # bugbear: security warnings 12 | "E", # pycodestyle 13 | "F", # pyflakes 14 | "E4", 15 | "E7", 16 | "E9", 17 | "UP", # alert you when better syntax is available in your python version 18 | "I", # import order 19 | "D401", # pydocstyle 20 | "RUF002", 21 | "SIM108", 22 | "FA100", 23 | "EM102", 24 | "D415", 25 | # "ANN202", 26 | # "TRY003", 27 | # "EM101", 28 | # "ALL", # all rules 29 | ] 30 | ignore = [ 31 | "E712", # Allow using if x == False, as it's not always equivalent to if x. 32 | "E501", # Supress line-too-long warnings: trust black's judgement on this one. 33 | "FIX", 34 | "S101", 35 | "PLR2004", 36 | "TD", 37 | "D100", 38 | "PT011", 39 | "ANN001", 40 | "PLR0913", 41 | "SLF001", 42 | "D205", 43 | "D104", 44 | "FBT002", 45 | "FBT001", 46 | "PLC0206", 47 | # "PLR1714", 48 | ] 49 | 50 | [format] 51 | # Like Black, use double quotes for strings. 52 | quote-style = "double" 53 | # Like Black, indent with spaces, rather than tabs. 54 | indent-style = "space" 55 | # Like Black, respect magic trailing commas. 56 | skip-magic-trailing-comma = false 57 | # Like Black, automatically detect the appropriate line ending. 58 | line-ending = "auto" 59 | docstring-code-format = true 60 | # Set the line length limit used when formatting code snippets in 61 | # docstrings. 62 | docstring-code-line-length = "dynamic" 63 | 64 | # Exclude a variety of commonly ignored directories. 65 | exclude = [ 66 | ".bzr", 67 | ".direnv", 68 | ".eggs", 69 | ".git", 70 | ".git-rewrite", 71 | ".hg", 72 | ".ipynb_checkpoints", 73 | ".mypy_cache", 74 | ".nox", 75 | ".pants.d", 76 | ".pyenv", 77 | ".pytest_cache", 78 | ".pytype", 79 | ".ruff_cache", 80 | ".svn", 81 | ".tox", 82 | ".venv", 83 | ".vscode", 84 | "__pypackages__", 85 | "_build", 86 | "buck-out", 87 | "build", 88 | "dist", 89 | "node_modules", 90 | "site-packages", 91 | "venv", 92 | ] 93 | -------------------------------------------------------------------------------- /pythermalcomfort/models/work_capacity_iso.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import WorkCapacityStandardsInputs 6 | from pythermalcomfort.classes_return import WorkCapacity 7 | 8 | 9 | def work_capacity_iso( 10 | wbgt: float | list[float], 11 | met: float | list[float], 12 | ) -> WorkCapacity: 13 | """Estimate work capacity due to heat based on ISO standards as described by Brode 14 | et al. 15 | 16 | Estimates the amount of work that will be done at a given WBGT and 17 | intensity of work as a percent. 100% means work is unaffected by heat. 0% 18 | means no work is done. 19 | 20 | The function definitions / parameters can be found in: 1. Bröde P, Fiala D, 21 | Lemke B, Kjellstrom T. Estimated work ability in warm outdoor environments 22 | depends on the chosen heat stress assessment metric. International Journal 23 | of Biometeorology. 2018 Mar;62(3):331 45. 24 | 25 | For a comparison of different functions see Fig 1 of Day E, Fankhauser S, Kingsmill 26 | N, Costa H, Mavrogianni A. Upholding labour productivity under climate 27 | change: an assessment of adaptation options. Climate Policy. 2019 28 | Mar;19(3):367 85. 29 | 30 | Parameters 31 | ---------- 32 | wbgt : float or list of floats 33 | Wet bulb globe temperature, [°C]. 34 | met : float or list of floats 35 | Metabolic heat production in Watts 36 | 37 | Returns 38 | ------- 39 | WorkCapacity 40 | A dataclass containing the work capacity. See 41 | :py:class:`~pythermalcomfort.classes_return.WorkCapacity` for more details. To access the 42 | `capacity` value, use the `capacity` attribute of the returned `WorkCapacity` instance, e.g., 43 | `result.capacity`. 44 | """ 45 | # Validate inputs 46 | WorkCapacityStandardsInputs(wbgt=wbgt, met=met) 47 | 48 | wbgt = np.asarray(wbgt) 49 | met = np.asarray(met) 50 | 51 | met_rest = 117 # assumed resting metabolic rate 52 | 53 | wbgt_lim = 34.9 - met / 46 54 | wbgt_lim_rest = 34.9 - met_rest / 46 55 | capacity = ((wbgt_lim_rest - wbgt) / (wbgt_lim_rest - wbgt_lim)) * 100 56 | capacity = np.clip(capacity, 0, 100) 57 | 58 | return WorkCapacity(capacity=capacity) 59 | -------------------------------------------------------------------------------- /pythermalcomfort/models/work_capacity_niosh.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import WorkCapacityStandardsInputs 6 | from pythermalcomfort.classes_return import WorkCapacity 7 | 8 | 9 | def work_capacity_niosh( 10 | wbgt: float | list[float], 11 | met: float | list[float], 12 | ) -> WorkCapacity: 13 | """Estimate work capacity due to heat based on NIOSH standards as described by Brode 14 | et al. 15 | 16 | Estimates the amount of work that will be done at a given WBGT and 17 | intensity of work as a percent. 100% means work is unaffected by heat. 0% 18 | means no work is done. 19 | 20 | The function definitions / parameters can be found in: 1. Bröde P, Fiala D, 21 | Lemke B, Kjellstrom T. Estimated work ability in warm outdoor environments 22 | depends on the chosen heat stress assessment metric. International Journal 23 | of Biometeorology. 2018 Mar;62(3):331 45. 24 | 25 | For a comparison of different functions see Fig 1 of Day E, Fankhauser S, Kingsmill 26 | N, Costa H, Mavrogianni A. Upholding labour productivity under climate 27 | change: an assessment of adaptation options. Climate Policy. 2019 28 | Mar;19(3):367 85. 29 | 30 | Parameters 31 | ---------- 32 | wbgt : float or list of floats 33 | Wet bulb globe temperature, [°C]. 34 | met : float or list of floats 35 | Metabolic heat production in Watts 36 | 37 | Returns 38 | ------- 39 | WorkCapacity 40 | A dataclass containing the work capacity. See 41 | :py:class:`~pythermalcomfort.classes_return.WorkCapacity` for more details. To access the 42 | `capacity` value, use the `capacity` attribute of the returned `WorkCapacity` instance, e.g., 43 | `result.capacity`. 44 | """ 45 | # Validate inputs 46 | WorkCapacityStandardsInputs(wbgt=wbgt, met=met) 47 | 48 | wbgt = np.asarray(wbgt) 49 | met = np.asarray(met) 50 | 51 | met_rest = 117 # assumed resting metabolic rate 52 | 53 | wbgt_lim = 56.7 - 11.5 * np.log10(met) 54 | wbgt_lim_rest = 56.7 - 11.5 * np.log10(met_rest) 55 | capacity = ((wbgt_lim_rest - wbgt) / (wbgt_lim_rest - wbgt_lim)) * 100 56 | capacity = np.clip(capacity, 0, 100) 57 | 58 | return WorkCapacity(capacity=capacity) 59 | -------------------------------------------------------------------------------- /docs/documentation/utilities_functions.rst: -------------------------------------------------------------------------------- 1 | Utilities functions 2 | =================== 3 | 4 | Body Surface Area 5 | ----------------- 6 | 7 | The body surface area (BSA) is the measured or calculated surface of a human body. It is used as a reference for the estimation of the metabolic rate of an individual. The BSA is usually measured in square meters. The most common formula to calculate the BSA is the Du Bois formula, which is based on the height and weight of the individual. All the equations implemented in pythermalcomfort are contained in pythermalcomfort.utilities.BodySurfaceAreaEquations 8 | 9 | .. autofunction:: pythermalcomfort.utilities.body_surface_area 10 | 11 | Dew point temperature 12 | --------------------- 13 | 14 | .. autofunction:: pythermalcomfort.utilities.dew_point_tmp 15 | 16 | Enthalpy 17 | -------- 18 | 19 | .. autofunction:: pythermalcomfort.utilities.enthalpy_air 20 | 21 | Mean radiant temperature 22 | ------------------------ 23 | 24 | .. autofunction:: pythermalcomfort.utilities.mean_radiant_tmp 25 | 26 | Operative temperature 27 | --------------------- 28 | 29 | .. autofunction:: pythermalcomfort.utilities.operative_tmp 30 | 31 | Psychrometric properties of air from dry-bulb temperature and relative humidity 32 | ------------------------------------------------------------------------------- 33 | 34 | .. autofunction:: pythermalcomfort.utilities.psy_ta_rh 35 | 36 | Relative air speed 37 | ------------------ 38 | 39 | .. autofunction:: pythermalcomfort.utilities.v_relative 40 | 41 | Running mean outdoor temperature 42 | -------------------------------- 43 | 44 | .. autofunction:: pythermalcomfort.utilities.running_mean_outdoor_temperature 45 | 46 | Saturation vapor pressure 47 | ------------------------- 48 | 49 | .. autofunction:: pythermalcomfort.utilities.p_sat 50 | 51 | Sky-vault view fraction 52 | ----------------------- 53 | 54 | .. autofunction:: pythermalcomfort.utilities.f_svv 55 | 56 | Units converter 57 | --------------- 58 | 59 | .. autofunction:: pythermalcomfort.utilities.units_converter 60 | 61 | Wet bulb temperature 62 | -------------------- 63 | 64 | .. autofunction:: pythermalcomfort.utilities.wet_bulb_tmp 65 | 66 | Utils functions 67 | =================== 68 | 69 | Scale wind speed 70 | ---------------- 71 | 72 | .. autofunction:: pythermalcomfort.utils.scale_wind_speed_log 73 | -------------------------------------------------------------------------------- /tests/test_heat_index_rothfusz.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.models import heat_index_rothfusz 5 | from tests.conftest import Urls, retrieve_reference_table, validate_result 6 | 7 | 8 | def test_heat_index(get_test_url, retrieve_data) -> None: 9 | """Test the heat index function with various inputs.""" 10 | reference_table = retrieve_reference_table( 11 | get_test_url, 12 | retrieve_data, 13 | Urls.HEAT_INDEX.name, 14 | ) 15 | tolerance = reference_table["tolerance"] 16 | 17 | for entry in reference_table["data"]: 18 | inputs = entry["inputs"] 19 | outputs = entry["outputs"] 20 | result = heat_index_rothfusz(**inputs, limit_inputs=False) 21 | 22 | validate_result(result, outputs, tolerance) 23 | 24 | 25 | def test_single_input_caution() -> None: 26 | """Test that the function returns a single value with caution category.""" 27 | result = heat_index_rothfusz(tdb=29, rh=50, round_output=True) 28 | assert result.hi.shape == () # zero-dim ndarray 29 | assert result.hi.item() == pytest.approx(29.7, rel=1e-3) 30 | assert result.stress_category == "caution" 31 | 32 | 33 | def test_below_threshold_produces_nan() -> None: 34 | """Test that the function returns NaN for heat index below threshold.""" 35 | result = heat_index_rothfusz(tdb=25, rh=80) 36 | assert np.isnan(result.hi).item() 37 | assert np.isnan(result.stress_category) 38 | 39 | 40 | def test_vector_input_no_rounding() -> None: 41 | """Test that the function handles vector inputs correctly without rounding.""" 42 | tdb = [30.0, 20.0, 28.5] 43 | rh = [70.0, 90.0, 50.0] 44 | result = heat_index_rothfusz(tdb=tdb, rh=rh, round_output=False) 45 | hi = result.hi 46 | # Shape matches inputs 47 | assert hi.shape == (3,) 48 | # First element has multiple decimals and matches expected formula 49 | assert hi[0] == pytest.approx(35.33, rel=1e-2) 50 | # Second element below threshold ⇒ NaN 51 | assert np.isnan(hi[1]) 52 | # Third element above threshold ⇒ finite number 53 | assert np.isfinite(hi[2]) 54 | # Stress categories array matches hi length 55 | assert isinstance(result.stress_category, np.ndarray) 56 | assert result.stress_category.shape == (3,) 57 | assert result.stress_category[0] == "extreme caution" 58 | -------------------------------------------------------------------------------- /pythermalcomfort/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .adaptive_ashrae import adaptive_ashrae 2 | from .adaptive_en import adaptive_en 3 | from .ankle_draft import ankle_draft 4 | from .at import at 5 | from .clo_tout import clo_tout 6 | from .cooling_effect import cooling_effect 7 | from .discomfort_index import discomfort_index 8 | from .esi import esi 9 | from .heat_index_lu import heat_index_lu 10 | from .heat_index_rothfusz import heat_index_rothfusz 11 | from .humidex import humidex 12 | from .jos3 import JOS3 13 | from .net import net 14 | from .pet_steady import pet_steady 15 | from .phs import phs 16 | from .pmv_a import pmv_a 17 | from .pmv_athb import pmv_athb 18 | from .pmv_e import pmv_e 19 | from .pmv_ppd_ashrae import pmv_ppd_ashrae 20 | from .pmv_ppd_iso import pmv_ppd_iso 21 | from .ridge_regression_predict_t_re_t_sk import ridge_regression_predict_t_re_t_sk 22 | from .set_tmp import set_tmp 23 | from .solar_gain import solar_gain 24 | from .thi import thi 25 | from .two_nodes_gagge import two_nodes_gagge 26 | from .two_nodes_gagge_ji import two_nodes_gagge_ji 27 | from .two_nodes_gagge_sleep import two_nodes_gagge_sleep 28 | from .use_fans_heatwaves import use_fans_heatwaves 29 | from .utci import utci 30 | from .vertical_tmp_grad_ppd import vertical_tmp_grad_ppd 31 | from .wbgt import wbgt 32 | from .wci import wci 33 | from .wind_chill_temperature import wind_chill_temperature 34 | from .work_capacity_dunne import work_capacity_dunne 35 | from .work_capacity_hothaps import work_capacity_hothaps 36 | from .work_capacity_iso import work_capacity_iso 37 | from .work_capacity_niosh import work_capacity_niosh 38 | 39 | __all__ = [ 40 | "JOS3", 41 | "adaptive_ashrae", 42 | "adaptive_en", 43 | "ankle_draft", 44 | "at", 45 | "clo_tout", 46 | "cooling_effect", 47 | "discomfort_index", 48 | "esi", 49 | "heat_index_lu", 50 | "heat_index_rothfusz", 51 | "humidex", 52 | "net", 53 | "pet_steady", 54 | "phs", 55 | "pmv_a", 56 | "pmv_athb", 57 | "pmv_e", 58 | "pmv_ppd_ashrae", 59 | "pmv_ppd_iso", 60 | "ridge_regression_predict_t_re_t_sk", 61 | "set_tmp", 62 | "solar_gain", 63 | "thi", 64 | "two_nodes_gagge", 65 | "two_nodes_gagge_ji", 66 | "two_nodes_gagge_sleep", 67 | "use_fans_heatwaves", 68 | "utci", 69 | "vertical_tmp_grad_ppd", 70 | "wbgt", 71 | "wci", 72 | "wind_chill_temperature", 73 | "work_capacity_dunne", 74 | "work_capacity_hothaps", 75 | "work_capacity_iso", 76 | "work_capacity_niosh", 77 | ] 78 | -------------------------------------------------------------------------------- /pythermalcomfort/models/_pmv_ppd_optimized.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from numba import float64, vectorize 3 | 4 | from pythermalcomfort.utilities import met_to_w_m2 5 | 6 | 7 | @vectorize( 8 | [ 9 | float64( 10 | float64, 11 | float64, 12 | float64, 13 | float64, 14 | float64, 15 | float64, 16 | float64, 17 | ), 18 | ], 19 | cache=True, 20 | ) 21 | def _pmv_ppd_optimized(tdb, tr, vr, rh, met, clo, wme): 22 | pa = rh * 10 * np.exp(16.6536 - 4030.183 / (tdb + 235)) 23 | 24 | icl = 0.155 * clo # thermal insulation of the clothing in M2K/W 25 | m = met * met_to_w_m2 # metabolic rate in W/M2 26 | w = wme * met_to_w_m2 # external work in W/M2 27 | mw = m - w # internal heat production in the human body 28 | # calculation of the clothing area factor 29 | f_cl = ( 30 | 1 + 1.29 * icl if icl <= 0.078 else 1.05 + 0.645 * icl 31 | ) # ratio of surface clothed body over nude body 32 | 33 | # heat transfer coefficient by forced convection 34 | hcf = 12.1 * np.sqrt(vr) 35 | hc = hcf # initialize variable 36 | taa = tdb + 273 37 | tra = tr + 273 38 | t_cla = taa + (35.5 - tdb) / (3.5 * icl + 0.1) 39 | 40 | p1 = icl * f_cl 41 | p2 = p1 * 3.96 42 | p3 = p1 * 100 43 | p4 = p1 * taa 44 | p5 = (308.7 - 0.028 * mw) + (p2 * (tra / 100.0) ** 4) 45 | xn = t_cla / 100 46 | xf = t_cla / 50 47 | eps = 0.00015 48 | 49 | n = 0 50 | while np.abs(xn - xf) > eps: 51 | xf = (xf + xn) / 2 52 | hcn = 2.38 * np.abs(100.0 * xf - taa) ** 0.25 53 | hc = max(hcn, hcf) 54 | xn = (p5 + p4 * hc - p2 * xf**4) / (100 + p3 * hc) 55 | n += 1 56 | if n > 150: 57 | raise StopIteration("Max iterations exceeded") 58 | 59 | tcl = 100 * xn - 273 60 | 61 | # heat loss diff. through skin 62 | hl1 = 3.05 * 0.001 * (5733 - (6.99 * mw) - pa) 63 | # heat loss by sweating 64 | hl2 = 0.42 * (mw - met_to_w_m2) if mw > met_to_w_m2 else 0 65 | # latent respiration heat loss 66 | hl3 = 1.7 * 0.00001 * m * (5867 - pa) 67 | # dry respiration heat loss 68 | hl4 = 0.0014 * m * (34 - tdb) 69 | # heat loss by radiation 70 | hl5 = 3.96 * f_cl * (xn**4 - (tra / 100.0) ** 4) 71 | # heat loss by convection 72 | hl6 = f_cl * hc * (tcl - tdb) 73 | 74 | ts = 0.303 * np.exp(-0.036 * m) + 0.028 75 | _pmv = ts * (mw - hl1 - hl2 - hl3 - hl4 - hl5 - hl6) 76 | 77 | return _pmv 78 | -------------------------------------------------------------------------------- /pythermalcomfort/models/work_capacity_dunne.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import WorkCapacityHothapsInputs, WorkIntensity 6 | from pythermalcomfort.classes_return import WorkCapacity 7 | 8 | 9 | def work_capacity_dunne( 10 | wbgt: float | list[float], 11 | work_intensity: str = WorkIntensity.HEAVY.value, 12 | ) -> WorkCapacity: 13 | """Estimate work capacity due to heat based on Dunne et al [Dunne2013]_. 14 | 15 | Estimates the amount of work that will be done at a given WBGT and 16 | intensity of work as a percent. 100% means work is unaffected by heat. 0% 17 | means no work is done. 18 | 19 | This is based upon NIOSH safety standards. See: 20 | Dunne JP, Stouffer RJ, John JG. Reductions in labour capacity from heat 21 | stress under climate warming. Nature Climate Change. 2013 Jun;3(6):563 6. 22 | 23 | Heavy intensity work is sometimes labelled as 400 W, but this is only 24 | nominal. Moderate work is assumed to be half as much as heavy intensity, and 25 | light half as much as moderate. 26 | 27 | Parameters 28 | ---------- 29 | wbgt : float or list of floats 30 | Wet bulb globe temperature, [°C]. 31 | work_intensity : str 32 | Which work intensity to use for the calculation, choice of "heavy", 33 | "moderate" or "light". Default is "heavy". 34 | 35 | .. note:: 36 | Dunne et al [Dunne2013]_ suggests that heavy intensity work is 350-500 kcal/h, moderate 37 | is 200-350 kcal/h, and light is less than 100-200 kcal/h. 38 | 39 | Returns 40 | ------- 41 | WorkCapacity 42 | A dataclass containing the work capacity. See 43 | :py:class:`~pythermalcomfort.classes_return.WorkCapacity` for more details. To access the 44 | `capacity` value, use the `capacity` attribute of the returned `WorkCapacity` instance, e.g., 45 | `result.capacity`. 46 | """ 47 | # validate inputs 48 | WorkCapacityHothapsInputs(wbgt=wbgt, work_intensity=work_intensity) 49 | 50 | # convert str to enum 51 | work_intensity = WorkIntensity(work_intensity.lower()) 52 | wbgt = np.asarray(wbgt) 53 | 54 | capacity = np.clip((100 - (25 * (np.maximum(0, wbgt - 25)) ** (2 / 3))), 0, 100) 55 | 56 | factor_map = { 57 | WorkIntensity.HEAVY: 1, 58 | WorkIntensity.MODERATE: 2, 59 | WorkIntensity.LIGHT: 4, 60 | } 61 | 62 | capacity = np.clip(capacity * factor_map[work_intensity], 0, 100) 63 | 64 | return WorkCapacity(capacity=capacity) 65 | -------------------------------------------------------------------------------- /tests/test_ankle_draft.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.models.ankle_draft import AnkleDraft, ankle_draft 5 | from pythermalcomfort.utilities import Units 6 | from tests.conftest import Urls, retrieve_reference_table, validate_result 7 | 8 | 9 | def test_ankle_draft(get_test_url, retrieve_data) -> None: 10 | """Test that the function calculates the ankle draft correctly for various inputs.""" 11 | reference_table = retrieve_reference_table( 12 | get_test_url, 13 | retrieve_data, 14 | Urls.ANKLE_DRAFT.name, 15 | ) 16 | tolerance = reference_table["tolerance"] 17 | 18 | for entry in reference_table["data"]: 19 | inputs = entry["inputs"] 20 | outputs = entry["outputs"] 21 | result = ankle_draft(**inputs) 22 | 23 | validate_result(result, outputs, tolerance) 24 | 25 | 26 | def test_ankle_draft_invalid_input_range() -> None: 27 | """Test for ankle_draft with invalid input ranges.""" 28 | # Test for ValueError 29 | with pytest.raises(ValueError): 30 | ankle_draft(25, 25, 0.3, 50, 1.2, 0.5, 7) 31 | # Test for ValueError 32 | with pytest.raises(ValueError): 33 | ankle_draft(25, 25, 0.3, 50, 1.2, 0.5, [0.1, 0.2, 0.3, 0.4]) 34 | 35 | 36 | def test_ankle_draft_outside_ashrae_range() -> None: 37 | """Test for ankle_draft with inputs outside the ASHRAE range.""" 38 | r = ankle_draft(50, 25, 0.1, 50, 1.2, 0.5, 0.1) 39 | assert np.isnan(r.ppd_ad) 40 | 41 | r = ankle_draft([50, 45], 25, 0.1, 50, 1.2, 0.5, 0.1) 42 | assert np.all(np.isnan(r.ppd_ad)) 43 | 44 | 45 | def test_ankle_draft_list_inputs() -> None: 46 | """Test for ankle_draft with list inputs.""" 47 | results = ankle_draft( 48 | tdb=[25, 26, 27], 49 | tr=[25, 26, 27], 50 | vr=[0.1, 0.1, 0.1], 51 | rh=[50, 50, 50], 52 | met=[1.2, 1.2, 1.2], 53 | clo=[0.5, 0.5, 0.5], 54 | v_ankle=[0.1, 0.1, 0.1], 55 | units=Units.SI.value, 56 | ) 57 | assert isinstance(results, AnkleDraft) 58 | assert len(results.ppd_ad) == 3 59 | assert len(results.acceptability) == 3 60 | 61 | 62 | def test_ankle_draft_invalid_list_inputs() -> None: 63 | """Test for TypeError with invalid types in list inputs.""" 64 | with pytest.raises(TypeError): 65 | ankle_draft( 66 | tdb=[25, "invalid", 27], 67 | tr=[25, 26, 27], 68 | vr=[0.1, 0.1, 0.1], 69 | rh=[50, 50, 50], 70 | met=[1.2, 1.2, 1.2], 71 | clo=[0.5, 0.5, 0.5], 72 | v_ankle=[0.1, 0.1, 0.1], 73 | units=Units.SI.value, 74 | ) 75 | -------------------------------------------------------------------------------- /pythermalcomfort/shared_functions.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from typing import Any 3 | 4 | import numpy as np 5 | 6 | 7 | def valid_range(x, valid) -> np.ndarray: 8 | """Filter values based on a valid range.""" 9 | return np.where((x >= valid[0]) & (x <= valid[1]), x, np.nan) 10 | 11 | 12 | def _finalize_scalar_or_array(arr: Any) -> Any: 13 | """Convert 0d arrays to Python scalars, preserve np.nan, return arrays as-is. 14 | 15 | Args: 16 | arr: np.ndarray, scalar, or array-like. 17 | 18 | Returns: 19 | Python scalar (with np.nan preserved) if input is scalar, else array. 20 | 21 | Examples 22 | -------- 23 | >>> _finalize_scalar_or_array(np.array(True, dtype=object)) 24 | True 25 | >>> _finalize_scalar_or_array(np.array(np.nan, dtype=object)) 26 | nan 27 | >>> _finalize_scalar_or_array(np.array([True, False, np.nan], dtype=object)) 28 | array([True, False, nan], dtype=object) 29 | """ 30 | arr = np.asarray(arr, dtype=object) 31 | if arr.shape == (): 32 | val = arr.item() 33 | if isinstance(val, float) and np.isnan(val): 34 | return np.nan 35 | # Convert np.bool_ to Python bool 36 | if isinstance(val, (np.bool_ | bool)): 37 | return bool(val) 38 | return val 39 | return arr 40 | 41 | 42 | def mapping( 43 | value: float | np.ndarray, map_dictionary: Mapping[float, Any], right: bool = True 44 | ) -> np.ndarray: 45 | """Map a temperature array to stress categories. 46 | 47 | Parameters 48 | ---------- 49 | value : float or array-like 50 | Temperature(s) to map. 51 | map_dictionary : dict 52 | Dictionary mapping bin edges to categories. 53 | right : bool, optional 54 | If True, intervals include the right bin edge. 55 | 56 | Returns 57 | ------- 58 | np.ndarray 59 | Stress category for each input temperature. np.nan for unmapped. 60 | 61 | Raises 62 | ------ 63 | TypeError 64 | If input types are invalid. 65 | 66 | Examples 67 | -------- 68 | >>> mapping([20, 25, 30], {15: "low", 25: "medium", 35: "high"}) 69 | array(['low', 'medium', 'high'], dtype=object) 70 | """ 71 | if not isinstance(map_dictionary, dict): 72 | raise TypeError("map_dictionary must be a dict") 73 | value_arr = np.asarray(value) 74 | bins = np.asarray(list(map_dictionary.keys())) 75 | categories = np.array(list(map_dictionary.values()), dtype=object) 76 | # Append np.nan for out-of-range values 77 | categories = np.append(categories, np.nan) 78 | idx = np.digitize(value_arr, bins, right=right) 79 | return categories[idx] 80 | -------------------------------------------------------------------------------- /pythermalcomfort/models/heat_index_rothfusz.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import HIInputs 6 | from pythermalcomfort.classes_return import HI 7 | from pythermalcomfort.shared_functions import mapping 8 | 9 | 10 | def heat_index_rothfusz( 11 | tdb: float | list[float], 12 | rh: float | list[float], 13 | round_output: bool = True, 14 | limit_inputs: bool = True, 15 | ) -> HI: 16 | """Calculate the Heat Index (HI) in accordance with the Rothfusz (1990) model 17 | [Rothfusz1990]_. 18 | 19 | Parameters 20 | ---------- 21 | tdb : float or list of floats 22 | Dry bulb air temperature, [°C]. 23 | rh : float or list of floats 24 | Relative humidity, [%]. 25 | round_output : bool, optional 26 | If True, rounds output value. If False, it does not round it. Defaults to True. 27 | 28 | Returns 29 | ------- 30 | HI 31 | A dataclass containing the Heat Index. See :py:class:`~pythermalcomfort.classes_return.HI` for more details. 32 | To access the `hi` value, use the `hi` attribute of the returned `HI` instance, e.g., `result.hi`. 33 | 34 | Examples 35 | -------- 36 | .. code-block:: python 37 | 38 | from pythermalcomfort.models import heat_index_rothfusz 39 | 40 | result = heat_index_rothfusz(tdb=29, rh=50) 41 | print(result.hi) # 29.7 42 | print(result.stress_category) # "caution" 43 | """ 44 | # Validate inputs using the HeatIndexInputs class 45 | HIInputs( 46 | tdb=tdb, 47 | rh=rh, 48 | round_output=round_output, 49 | limit_inputs=limit_inputs, 50 | ) 51 | 52 | tdb = np.asarray(tdb) 53 | rh = np.asarray(rh) 54 | 55 | hi = -8.784695 + 1.61139411 * tdb + 2.338549 * rh - 0.14611605 * tdb * rh 56 | hi += -1.2308094 * 10**-2 * tdb**2 - 1.6424828 * 10**-2 * rh**2 57 | hi += 2.211732 * 10**-3 * tdb**2 * rh + 7.2546 * 10**-4 * tdb * rh**2 58 | hi += -3.582 * 10**-6 * tdb**2 * rh**2 59 | 60 | # heat index should only be calculated for temperatures above 27 °C 61 | if limit_inputs: 62 | tdb_valid = np.where((tdb >= 27.0), tdb, np.nan) 63 | all_valid = ~(np.isnan(tdb_valid)) 64 | hi_valid = np.where(all_valid, hi, np.nan) 65 | else: 66 | hi_valid = hi 67 | 68 | heat_index_categories = { 69 | 27.0: "no risk", 70 | 32.0: "caution", 71 | 41.0: "extreme caution", 72 | 54.0: "danger", 73 | 1000.0: "extreme danger", 74 | } 75 | 76 | if round_output: 77 | hi_valid = np.around(hi_valid, 1) 78 | 79 | return HI(hi=hi_valid, stress_category=mapping(hi_valid, heat_index_categories)) 80 | -------------------------------------------------------------------------------- /tests/test_classes_return.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import pytest 4 | 5 | # Import the AutoStrMixin class 6 | from pythermalcomfort.classes_return import AutoStrMixin 7 | 8 | 9 | @dataclass(repr=False) 10 | class TestDataClass(AutoStrMixin): 11 | """A test dataclass to demonstrate the AutoStrMixin functionality.""" 12 | 13 | field1: int 14 | field2: str 15 | field3: list 16 | 17 | 18 | def test_autostr_with_dataclass() -> None: 19 | """Test that the AutoStrMixin generates a string representation for a dataclass.""" 20 | obj = TestDataClass(field1=42, field2="test", field3=[1, 2, 3]) 21 | expected_output = ( 22 | "------------------\n TestDataClass \n------------------\n" 23 | "field1 : 42\nfield2 : test\nfield3 : [1, 2, 3]" 24 | ) 25 | 26 | assert str(obj) == expected_output 27 | 28 | 29 | def test_autostr_with_multiline_field() -> None: 30 | """Test that the AutoStrMixin handles multiline fields correctly.""" 31 | obj = TestDataClass(field1=42, field2="test", field3=["line1", "line2"]) 32 | expected_output = ( 33 | "-----------------------\n TestDataClass \n" 34 | "-----------------------\nfield1 : 42\nfield2 : test\nfield3 : [line1, line2]" 35 | ) 36 | 37 | assert str(obj) == expected_output 38 | 39 | 40 | def test_autostr_empty_dataclass() -> None: 41 | """Test that the AutoStrMixin handles an empty dataclass correctly.""" 42 | 43 | @dataclass 44 | class EmptyDataClass(AutoStrMixin): 45 | pass 46 | 47 | obj = EmptyDataClass() 48 | expected_output = "--------------\nEmptyDataClass\n--------------" 49 | assert str(obj) == expected_output 50 | 51 | 52 | def test_autostr_repr_method() -> None: 53 | """Test that the __repr__ method returns the same as __str__.""" 54 | obj = TestDataClass(field1=42, field2="test", field3=[1, 2, 3]) 55 | expected_output = ( 56 | "------------------\n TestDataClass \n------------------\n" 57 | "field1 : 42\nfield2 : test\nfield3 : [1, 2, 3]" 58 | ) 59 | assert repr(obj) == expected_output 60 | # Verify that __repr__ returns the same as __str__ 61 | assert repr(obj) == str(obj) 62 | 63 | 64 | def test_autostr_getitem_method() -> None: 65 | """Test that the __getitem__ method works correctly.""" 66 | obj = TestDataClass(field1=42, field2="test", field3=[1, 2, 3]) 67 | assert obj["field1"] == 42 68 | assert obj["field2"] == "test" 69 | assert obj["field3"] == [1, 2, 3] 70 | 71 | 72 | def test_autostr_getitem_method_key_error() -> None: 73 | """Test that __getitem__ raises AttributeError for non-existent keys.""" 74 | obj = TestDataClass(field1=42, field2="test", field3=[1, 2, 3]) 75 | with pytest.raises(KeyError): 76 | _ = obj["non_existent"] 77 | -------------------------------------------------------------------------------- /examples/calc_pmv_ppd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import numpy as np 5 | import pandas as pd 6 | 7 | from pythermalcomfort.models import pmv_ppd_ashrae, pmv_ppd_iso 8 | from pythermalcomfort.utilities import ( 9 | clo_dynamic_ashrae, 10 | clo_individual_garments, 11 | met_typical_tasks, 12 | v_relative, 13 | ) 14 | 15 | # input variables 16 | tdb = 27 # dry bulb air temperature, [$^{\circ}$C] 17 | tr = 25 # mean radiant temperature, [$^{\circ}$C] 18 | v = 0.3 # average air speed, [m/s] 19 | rh = 50 # relative humidity, [%] 20 | activity = "Typing" # participant's activity description 21 | garments = ["Sweatpants", "T-shirt", "Shoes or sandals"] 22 | 23 | met = met_typical_tasks[activity] # activity met, [met] 24 | icl = sum( 25 | [clo_individual_garments[item] for item in garments], 26 | ) # calculate total clothing insulation 27 | 28 | # calculate the relative air velocity 29 | vr = v_relative(v=v, met=met) 30 | # calculate the dynamic clothing insulation 31 | clo = clo_dynamic_ashrae(clo=icl, met=met) 32 | 33 | # calculate PMV in accordance with the ASHRAE 55 2020 34 | results = pmv_ppd_iso(tdb=tdb, tr=tr, vr=vr, rh=rh, met=met, clo=clo) 35 | 36 | # print the results 37 | print(results) 38 | 39 | # print PMV value 40 | print(f"pmv={results['pmv']}, ppd={results['ppd']}%") 41 | 42 | # for users who want to use the IP system 43 | results_ip = pmv_ppd_iso(tdb=77, tr=77, vr=0.6, rh=50, met=1.1, clo=0.5, units="IP") 44 | print(results_ip) 45 | 46 | # If you want you can also pass pandas series or arrays as inputs 47 | df = pd.read_csv(os.getcwd() + "/examples/template-SI.csv") 48 | 49 | v_rel = v_relative(df["v"], df["met"]) 50 | clo_d = clo_dynamic_ashrae(df["clo"], df["met"]) 51 | results = pmv_ppd_ashrae(df["tdb"], df["tr"], v_rel, df["rh"], df["met"], clo_d, 0) 52 | print(results) 53 | 54 | df["vr"] = v_rel 55 | df["clo_d"] = clo_d 56 | df["pmv"] = results.pmv # you can also use results["pmv"] 57 | df["ppd"] = results["ppd"] 58 | 59 | print(df.head()) 60 | 61 | # uncomment the following line if you want to save the data to .csv file 62 | # df.to_csv('results.csv') 63 | 64 | # This method is extremely fast and can perform a lot of calculations in very little time 65 | iterations = 10000 66 | tdb = np.empty(iterations) 67 | tdb.fill(25) 68 | tdb = tdb.tolist() 69 | met = np.empty(iterations) 70 | met.fill(1.5) 71 | met = met.tolist() 72 | 73 | v_rel = v_relative(0.1, met) 74 | clo_d = clo_dynamic_ashrae(1, met) 75 | 76 | # ASHRAE PMV 77 | start = time.time() 78 | pmv_ppd_ashrae( 79 | tdb=tdb, 80 | tr=23, 81 | vr=v_rel, 82 | rh=40, 83 | met=1.2, 84 | clo=clo_d, 85 | ) 86 | end = time.time() 87 | print(end - start) 88 | 89 | # ISO PMV 90 | start = time.time() 91 | pmv_ppd_iso(tdb=tdb, tr=23, vr=v_rel, rh=40, met=1.2, clo=clo_d) 92 | end = time.time() 93 | print(end - start) 94 | -------------------------------------------------------------------------------- /pythermalcomfort/models/clo_tout.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal 4 | 5 | import numpy as np 6 | 7 | from pythermalcomfort.classes_input import CloTOutInputs 8 | from pythermalcomfort.classes_return import CloTOut 9 | from pythermalcomfort.utilities import Units, units_converter 10 | 11 | 12 | def clo_tout( 13 | tout: float | list[float], 14 | units: Literal["SI", "IP"] = Units.SI.value, 15 | ) -> CloTOut: 16 | """Calculate representative clothing insulation Icl based on outdoor air temperature 17 | at 06:00 a.m. [Schiavon2013]_. 18 | 19 | Parameters 20 | ---------- 21 | tout : float or list of floats 22 | Outdoor air temperature at 06:00 a.m., default in [°C] or [°F] if `units` = 'IP'. 23 | units : str, optional 24 | Select the SI (International System of Units) or the IP (Imperial Units) system. 25 | Supported values are 'SI' and 'IP'. Defaults to 'SI'. 26 | 27 | Returns 28 | ------- 29 | CloTOut 30 | A dataclass containing the representative clothing insulation Icl. See :py:class:`~pythermalcomfort.classes_return.CloTOut` for more details. 31 | To access the `clo_tout` value, use the `clo_tout` attribute of the returned `CloTOut` instance, e.g., `result.clo_tout`. 32 | 33 | Raises 34 | ------ 35 | TypeError 36 | If `tout` is not a float, int, NumPy array, or a list of floats or integers. 37 | ValueError 38 | If an invalid unit is provided or non-numeric elements are found in `tout`. 39 | 40 | Notes 41 | ----- 42 | .. note:: 43 | The ASHRAE 55 2020 states that it is acceptable to determine the clothing 44 | insulation Icl using this equation in mechanically conditioned buildings [55ASHRAE2023]_. 45 | 46 | .. note:: 47 | Limitations: 48 | - This equation may not be accurate for extreme temperature ranges. 49 | 50 | Examples 51 | -------- 52 | .. code-block:: python 53 | 54 | from pythermalcomfort.models import clo_tout 55 | 56 | result = clo_tout(tout=27) 57 | print(result.clo_tout) # 0.46 58 | 59 | result = clo_tout(tout=[27, 25]) 60 | print(result.clo_tout) # array([0.46, 0.47]) 61 | """ 62 | # Validate inputs using the CloTOutInputs class 63 | CloTOutInputs( 64 | tout=tout, 65 | units=units, 66 | ) 67 | 68 | # Convert tout to NumPy array for vectorized operations 69 | tout = np.asarray(tout) 70 | 71 | # Convert units if necessary 72 | if units.upper() == Units.IP.value: 73 | tout = units_converter(tmp=tout)[0] 74 | 75 | clo = np.where(tout < 26, np.power(10, -0.1635 - 0.0066 * tout), 0.46) 76 | clo = np.where(tout < 5, 0.818 - 0.0364 * tout, clo) 77 | clo = np.where(tout < -5, 1, clo) 78 | 79 | return CloTOut(clo_tout=np.around(clo, 2)) 80 | -------------------------------------------------------------------------------- /pythermalcomfort/models/wci.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import WCIInputs 6 | from pythermalcomfort.classes_return import WCI 7 | 8 | 9 | def wci( 10 | tdb: float | list[float], 11 | v: float | list[float], 12 | round_output: bool = True, 13 | ) -> WCI: 14 | """Calculate the Wind Chill Index (WCI) in accordance with the ASHRAE 2017 Handbook Fundamentals - Chapter 9 [ashrae2017]_. 15 | 16 | The wind chill index (WCI) is an empirical index based on cooling measurements 17 | taken on a cylindrical flask partially filled with water in Antarctica 18 | (Siple and Passel 1945). For a surface temperature of 33°C, the index describes 19 | the rate of heat loss from the cylinder via radiation and convection as a function 20 | of ambient temperature and wind velocity. 21 | 22 | This formulation has been met with some valid criticism. WCI is unlikely to be an 23 | accurate measure of heat loss from exposed flesh, which differs from plastic in terms 24 | of curvature, roughness, and radiation exchange qualities, and is always below 33°C 25 | in a cold environment. Furthermore, the equation's values peak at 90 km/h and then 26 | decline as velocity increases. Nonetheless, this score reliably represents the 27 | combined effects of temperature and wind on subjective discomfort for velocities 28 | below 80 km/h [ashrae2017]_. 29 | 30 | Parameters 31 | ---------- 32 | tdb : float or list of floats 33 | Dry bulb air temperature, [°C]. 34 | v : float or list of floats 35 | Wind speed 10m above ground level, [m/s]. 36 | round_output : bool, optional 37 | If True, rounds output value. If False, it does not round it. Defaults to True. 38 | 39 | Returns 40 | ------- 41 | WCI 42 | A dataclass containing the Wind Chill Index. See :py:class:`~pythermalcomfort.classes_return.WCI` for more details. 43 | To access the `wci` value, use the `wci` attribute of the returned `WCI` instance, e.g., `result.wci`. 44 | 45 | Examples 46 | -------- 47 | .. code-block:: python 48 | 49 | from pythermalcomfort.models import wci 50 | 51 | result = wci(tdb=-5, v=5.5) 52 | print(result.wci) # 1255.2 53 | 54 | result = wci(tdb=[-5, -10], v=[5.5, 10], round_output=True) 55 | print(result.wci) # [1255.2 1603.9] 56 | 57 | """ 58 | # Validate inputs using the WCYInputs class 59 | WCIInputs( 60 | tdb=tdb, 61 | v=v, 62 | round_output=round_output, 63 | ) 64 | 65 | tdb = np.asarray(tdb) 66 | v = np.asarray(v) 67 | 68 | _wci = (10.45 + 10 * v**0.5 - v) * (33 - tdb) 69 | 70 | # the factor 1.163 is used to convert to W/m^2 71 | _wci = _wci * 1.163 72 | 73 | if round_output: 74 | _wci = np.around(_wci, 1) 75 | 76 | return WCI(wci=_wci) 77 | -------------------------------------------------------------------------------- /pythermalcomfort/models/at.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import ATInputs 6 | from pythermalcomfort.classes_return import AT 7 | from pythermalcomfort.utilities import psy_ta_rh 8 | 9 | 10 | def at( 11 | tdb: float | list[float], 12 | rh: float | list[float], 13 | v: float | list[float], 14 | q: float | list[float] = None, 15 | round_output: bool = True, 16 | ) -> AT: 17 | """Calculate the Apparent Temperature (AT). The AT is defined as the temperature at 18 | the reference humidity level producing the same amount of discomfort as that 19 | experienced under the current ambient temperature, humidity, and solar radiation 20 | [Steadman1984]_. In other words, the AT is an adjustment to the dry bulb temperature 21 | based on the relative humidity value. Absolute humidity with a dew point of 14°C is 22 | chosen as a reference. 23 | 24 | It includes the chilling effect of the wind at lower temperatures. [Blazejczyk2012]_ 25 | 26 | .. note:: 27 | Two formulas for AT are in use by the Australian Bureau of Meteorology: one includes 28 | solar radiation and the other one does not (http://www.bom.gov.au/info/thermal_stress/ 29 | , 29 Sep 2021). Please specify q if you want to estimate AT with solar load. 30 | 31 | Parameters 32 | ---------- 33 | tdb : float or list of floats 34 | Dry bulb air temperature, [°C] 35 | rh : float or list of floats 36 | Relative humidity, [%] 37 | v : float or list of floats 38 | Wind speed 10m above ground level, [m/s] 39 | q : float or list of floats, optional 40 | Net radiation absorbed per unit area of body surface [W/m2] 41 | round_output : bool, default True 42 | If True, rounds the output value; if False, does not round it. 43 | 44 | Returns 45 | ------- 46 | AT 47 | Dataclass containing the apparent temperature, [°C]. See :py:class:`~pythermalcomfort.classes_return.AT` for more details. 48 | 49 | Examples 50 | -------- 51 | .. code-block:: python 52 | 53 | from pythermalcomfort.models import at 54 | 55 | at(tdb=25, rh=30, v=0.1) 56 | # AT(at=24.1) 57 | """ 58 | # Validate inputs 59 | ATInputs(tdb=tdb, rh=rh, v=v, q=q, round_output=round_output) 60 | 61 | # Convert lists to numpy arrays if necessary 62 | tdb = np.asarray(tdb) 63 | rh = np.asarray(rh) 64 | v = np.asarray(v) 65 | 66 | # Calculate vapor pressure 67 | p_vap = psy_ta_rh(tdb, rh).p_vap / 100 68 | 69 | # Calculate apparent temperature 70 | if q is not None: 71 | q = np.asarray(q) 72 | t_at = tdb + 0.348 * p_vap - 0.7 * v + 0.7 * q / (v + 10) - 4.25 73 | else: 74 | t_at = tdb + 0.33 * p_vap - 0.7 * v - 4.00 75 | 76 | if round_output: 77 | t_at = np.around(t_at, 1) 78 | 79 | return AT(at=t_at) 80 | -------------------------------------------------------------------------------- /.github/workflows/build-test-publish-testPyPI.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Test development branch 5 | 6 | on: 7 | push: 8 | branches: 9 | - development 10 | 11 | jobs: 12 | 13 | format: 14 | 15 | # check that the code is formatted correctly 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Set up Python 🐍 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: '3.12' 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install setuptools 28 | python -m pip install ruff 29 | - name: Lint 30 | run: ruff check ./pythermalcomfort ./tests 31 | - name: Check formatting 32 | run: ruff format --check ./pythermalcomfort ./tests 33 | 34 | test: 35 | 36 | needs: format 37 | 38 | # I am only testing with py310 but travis is Testing the other versions of Python 39 | runs-on: ubuntu-latest 40 | strategy: 41 | matrix: 42 | python-version: [ '3.10', '3.13' ] 43 | platform: [ubuntu-latest] 44 | 45 | steps: 46 | - uses: actions/checkout@v3 47 | - name: Setup Python ${{ matrix.python-version }} 48 | uses: actions/setup-python@v4 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | - name: Install Tox and any other packages 52 | run: | 53 | python -m pip install --upgrade pip 54 | python -m pip install setuptools 55 | python -m pip install "tox<4" tox-gh-actions 56 | python -m pip install ruff 57 | - name: Lint 58 | run: ruff check ./pythermalcomfort ./tests 59 | - name: Check formatting 60 | run: ruff format ./pythermalcomfort ./tests 61 | - name: Run Tox 62 | run: | 63 | tox 64 | env: 65 | PLATFORM: ${{ matrix.platform }} 66 | 67 | # deploy: 68 | # 69 | # needs: test 70 | # 71 | # runs-on: ubuntu-latest 72 | # 73 | # steps: 74 | # - uses: actions/checkout@v3 75 | # - name: Set up Python 🐍 76 | # uses: actions/setup-python@v4 77 | # with: 78 | # python-version: '3.12' 79 | # - name: Install dependencies 80 | # run: | 81 | # python -m pip install --upgrade pip 82 | # pip install setuptools wheel twine 83 | # - name: Publish pythermalcomfort 📦 to Test PyPI 84 | # env: 85 | # TWINE_USERNAME: "__token__" 86 | # TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} 87 | # run: | 88 | # python setup.py sdist bdist_wheel 89 | # python -m twine upload --skip-existing --repository-url https://test.pypi.org/legacy/ dist/*.gz dist/*.whl 90 | -------------------------------------------------------------------------------- /pythermalcomfort/models/discomfort_index.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import DIInputs 6 | from pythermalcomfort.classes_return import DI 7 | from pythermalcomfort.shared_functions import mapping 8 | 9 | 10 | def discomfort_index( 11 | tdb: float | list[float], 12 | rh: float | list[float], 13 | ) -> DI: 14 | """Calculate the Discomfort Index (DI). 15 | 16 | The index is essentially an 17 | effective temperature based on air temperature and humidity. The discomfort 18 | index is usually divided into 6 discomfort categories and it only applies to 19 | warm environments: [Polydoros2015]_ 20 | 21 | * class 1 - DI < 21 °C - No discomfort 22 | * class 2 - 21 <= DI < 24 °C - Less than 50% feels discomfort 23 | * class 3 - 24 <= DI < 27 °C - More than 50% feels discomfort 24 | * class 4 - 27 <= DI < 29 °C - Most of the population feels discomfort 25 | * class 5 - 29 <= DI < 32 °C - Everyone feels severe stress 26 | * class 6 - DI >= 32 °C - State of medical emergency 27 | 28 | Parameters 29 | ---------- 30 | tdb : float or list of floats 31 | Dry bulb air temperature, [°C]. 32 | rh : float or list of floats 33 | Relative humidity, [%]. 34 | 35 | Returns 36 | ------- 37 | DI 38 | A dataclass containing the Discomfort Index and its classification. See :py:class:`~pythermalcomfort.classes_return.DI` for more details. 39 | To access the `di` and `discomfort_condition` values, use the respective attributes of the returned `DI` instance, e.g., `result.di`. 40 | 41 | Examples 42 | -------- 43 | .. code-block:: python 44 | 45 | from pythermalcomfort.models import discomfort_index 46 | 47 | result = discomfort_index(tdb=25, rh=50) 48 | print(result.di) # 22.1 49 | print(result.discomfort_condition) # Less than 50% feels discomfort 50 | 51 | result = discomfort_index(tdb=[25, 30], rh=[50, 60]) 52 | print(result.di) # [22.1, 27.3] 53 | print( 54 | result.discomfort_condition 55 | ) # ['Less than 50% feels discomfort', 'Most of the population feels discomfort'] 56 | """ 57 | # Validate inputs using the DiscomfortIndexInputs class 58 | DIInputs( 59 | tdb=tdb, 60 | rh=rh, 61 | ) 62 | 63 | tdb = np.asarray(tdb) 64 | rh = np.asarray(rh) 65 | 66 | di = tdb - 0.55 * (1 - 0.01 * rh) * (tdb - 14.5) 67 | 68 | di_categories = { 69 | 21: "No discomfort", 70 | 24: "Less than 50% feels discomfort", 71 | 27: "More than 50% feels discomfort", 72 | 29: "Most of the population feels discomfort", 73 | 32: "Everyone feels severe stress", 74 | 99: "State of medical emergency", 75 | } 76 | 77 | return DI( 78 | di=np.around(di, 1), 79 | discomfort_condition=mapping(di, di_categories, right=False), 80 | ) 81 | -------------------------------------------------------------------------------- /tests/test_pmv_ppd_optimised.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.models._pmv_ppd_optimized import _pmv_ppd_optimized 6 | 7 | 8 | class TestPmvPpdOptimized: 9 | """Test cases for the PMV and PPD optimized model.""" 10 | 11 | # Returns NaN for invalid input values 12 | 13 | def test_pmv_ppd_optimized(self) -> None: 14 | """Test the optimized PMV and PPD function with various inputs.""" 15 | assert math.isclose( 16 | _pmv_ppd_optimized(25, 25, 0.3, 50, 1.5, 0.7, 0), 17 | 0.55, 18 | abs_tol=0.01, 19 | ) 20 | 21 | np.testing.assert_equal( 22 | np.around(_pmv_ppd_optimized([25, 25], 25, 0.3, 50, 1.5, 0.7, 0), 2), 23 | [0.55, 0.55], 24 | ) 25 | 26 | # The function returns the correct PMV value for typical input values. 27 | def test_pmv_typical_input(self) -> None: 28 | """Test the optimized PMV and PPD function with typical input values.""" 29 | # Typical input values 30 | tdb = 25 31 | tr = 23 32 | vr = 0.1 33 | rh = 50 34 | met = 1.2 35 | clo = 0.5 36 | wme = 0 37 | 38 | expected_pmv = -0.197 39 | 40 | assert math.isclose( 41 | _pmv_ppd_optimized(tdb, tr, vr, rh, met, clo, wme), 42 | expected_pmv, 43 | abs_tol=0.01, 44 | ) 45 | 46 | # The function returns the correct PMV value for extreme input values. 47 | def test_pmv_extreme_input(self) -> None: 48 | """Test the optimized PMV and PPD function with extreme input values.""" 49 | # Extreme input values 50 | tdb = 35 51 | tr = 45 52 | vr = 2 53 | rh = 10 54 | met = 2.5 55 | clo = 1.5 56 | wme = 1 57 | 58 | expected_pmv = 1.86 59 | 60 | assert math.isclose( 61 | _pmv_ppd_optimized(tdb, tr, vr, rh, met, clo, wme), 62 | expected_pmv, 63 | abs_tol=0.01, 64 | ) 65 | 66 | # The function returns NaN if any of the input values are NaN. 67 | def test_nan_input_values(self) -> None: 68 | """Test the optimized PMV and PPD function with NaN input values.""" 69 | tdb = float("nan") 70 | tr = 23 71 | vr = 0.1 72 | rh = 50 73 | met = 1.2 74 | clo = 0.5 75 | wme = 0 76 | 77 | assert math.isnan(_pmv_ppd_optimized(tdb, tr, vr, rh, met, clo, wme)) 78 | 79 | # The function returns NaN if any of the input values are infinite. 80 | def test_infinite_input_values(self) -> None: 81 | """Test the optimized PMV and PPD function with infinite input values.""" 82 | # Input values with infinity 83 | tdb = float("inf") 84 | tr = 23 85 | vr = 0.1 86 | rh = 50 87 | met = 1.2 88 | clo = 0.5 89 | wme = 0 90 | 91 | assert math.isnan(_pmv_ppd_optimized(tdb, tr, vr, rh, met, clo, wme)) 92 | -------------------------------------------------------------------------------- /tests/test_wind_chill_temperature.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.models.wind_chill_temperature import wind_chill_temperature 5 | 6 | 7 | class TestWct: 8 | """Test cases for the wind chill temperature (WCT) model.""" 9 | 10 | # TODO this tests are not synced with comf R 11 | 12 | def test_wct_results(self) -> None: 13 | """Test that the function calculates WCT correctly for various inputs.""" 14 | assert np.isclose(wind_chill_temperature(tdb=-20, v=5).wct, -24.3, atol=0.1) 15 | assert np.isclose(wind_chill_temperature(tdb=-20, v=15).wct, -29.1, atol=0.1) 16 | assert np.isclose(wind_chill_temperature(tdb=-20, v=60).wct, -36.5, atol=0.1) 17 | 18 | # Calculate WCT correctly for single float inputs of temperature and wind speed 19 | def test_wct_single_float_inputs(self) -> None: 20 | """Test that the function calculates WCT correctly for single float values of tdb and v.""" 21 | # Test with single float values 22 | result = wind_chill_temperature(tdb=-5.0, v=5.5) 23 | 24 | # Expected value calculated using the formula: 25 | # 13.12 + 0.6215 * tdb - 11.37 * v**0.16 + 0.3965 * tdb * v**0.16 26 | expected = -7.5 27 | 28 | assert isinstance(result.wct, float) 29 | assert round(result.wct, 1) == expected 30 | 31 | # Handle empty lists for temperature and wind speed inputs 32 | def test_wct_empty_lists(self) -> None: 33 | """Test that the function handles empty lists for tdb and v inputs.""" 34 | # Test with empty lists 35 | assert np.allclose( 36 | wind_chill_temperature(tdb=[], v=[]).wct, np.asarray([]), equal_nan=True 37 | ) 38 | 39 | # Calculate WCT correctly for lists of temperature and wind speed values 40 | def test_wct_list_inputs(self) -> None: 41 | """Test that the function calculates WCT correctly for lists of tdb and v values.""" 42 | # Test with list of values 43 | result = wind_chill_temperature( 44 | tdb=[-5.0, -10.0], v=[5.5, 10.0], round_output=True 45 | ) 46 | 47 | # Expected values calculated using the formula: 48 | # 13.12 + 0.6215 * tdb - 11.37 * v**0.16 + 0.3965 * tdb * v**0.16 49 | expected = [-7.5, -15.3] 50 | 51 | assert isinstance(result.wct, np.ndarray) 52 | assert np.allclose(result.wct, expected, atol=0.1) 53 | 54 | # Handle non-numeric input values 55 | def test_wct_non_numeric_inputs(self) -> None: 56 | """Function raises a TypeError if non-numeric values are inputs.""" 57 | # Test with non-numeric values for tdb and v 58 | with pytest.raises(TypeError): 59 | wind_chill_temperature(tdb="invalid", v=5.5) 60 | 61 | with pytest.raises(TypeError): 62 | wind_chill_temperature(tdb=-5.0, v="invalid") 63 | 64 | with pytest.raises(TypeError): 65 | wind_chill_temperature(tdb="invalid", v="invalid") 66 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.abspath("..")) 5 | 6 | extensions = [ 7 | "sphinx.ext.autodoc", 8 | "sphinx.ext.autosummary", 9 | "sphinx.ext.coverage", 10 | "sphinx.ext.doctest", 11 | "sphinx.ext.extlinks", 12 | "sphinx.ext.ifconfig", 13 | "sphinx.ext.napoleon", 14 | "sphinx.ext.todo", 15 | "sphinx.ext.viewcode", 16 | "sphinx_rtd_theme", 17 | ] 18 | source_suffix = ".rst" 19 | master_doc = "index" 20 | project = "pythermalcomfort" 21 | year = "2025" 22 | author = "Federico Tartarini" 23 | project_copyright = f"{year}, {author}" 24 | version = release = "3.8.0" 25 | 26 | autodoc_typehints = "none" 27 | pygments_style = "trac" 28 | templates_path = ["."] 29 | extlinks = { 30 | "issue": ( 31 | "https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort/issues/%s", 32 | "issue %s", 33 | ), 34 | "pr": ( 35 | "https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort/pull/%s", 36 | "PR %s", 37 | ), 38 | } 39 | # on_rtd is whether we are on readthedocs.org 40 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 41 | 42 | html_theme = "pydata_sphinx_theme" 43 | 44 | napoleon_use_ivar = True 45 | napoleon_use_rtype = False 46 | napoleon_use_param = False 47 | 48 | html_title = f"pythermalcomfort {version}" 49 | html_short_title = f"{project}-{version}" 50 | 51 | html_use_smartypants = True 52 | html_last_updated_fmt = "%b %d, %Y" 53 | html_split_index = False 54 | 55 | html_sidebars = { 56 | "**": ["sidebar-nav-bs"], 57 | "installation": [], 58 | "contributing": [], 59 | "authors": [], 60 | } 61 | 62 | html_theme_options = { 63 | "icon_links": [ 64 | { 65 | # Label for this link 66 | "name": "GitHub", 67 | # URL where the link will redirect 68 | "url": "https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort", # required 69 | # Icon class (if "type": "fontawesome"), or path to local image (if "type": "local") 70 | "icon": "fa-brands fa-square-github", 71 | # The type of image to be used (see below for details) 72 | "type": "fontawesome", 73 | }, 74 | { 75 | "name": "LinkedIn", 76 | "url": "https://www.linkedin.com/in/federico-tartarini", # required 77 | "icon": "fa-brands fa-linkedin", 78 | "type": "fontawesome", 79 | }, 80 | { 81 | "name": "Google Scholar", 82 | "url": "https://scholar.google.com/citations?view_op=list_works&hl=en&hl=en&user=QcamSPwAAAAJ", 83 | "icon": "fa-brands fa-google-scholar", 84 | }, 85 | { 86 | "name": "PyPI", 87 | "url": "https://pypi.org/project/pythermalcomfort/", 88 | "icon": "fa-brands fa-python", 89 | }, 90 | ], 91 | "secondary_sidebar_items": ["page-toc", "edit-this-page"], 92 | "content_footer_items": ["last-updated"], 93 | } 94 | -------------------------------------------------------------------------------- /tests/test_adaptive_ashrae.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.models import adaptive_ashrae 5 | from pythermalcomfort.utilities import Units 6 | from tests.conftest import Urls, retrieve_reference_table, validate_result 7 | 8 | 9 | def test_adaptive_ashrae(get_test_url, retrieve_data) -> None: 10 | """Test that the Adaptive ASHRAE function calculates correctly for various inputs.""" 11 | reference_table = retrieve_reference_table( 12 | get_test_url, 13 | retrieve_data, 14 | Urls.ADAPTIVE_ASHRAE.name, 15 | ) 16 | tolerance = reference_table["tolerance"] 17 | 18 | for entry in reference_table["data"]: 19 | inputs = entry["inputs"] 20 | outputs = entry["outputs"] 21 | units = inputs.get("units", Units.SI.value) 22 | result = adaptive_ashrae( 23 | inputs["tdb"], 24 | inputs["tr"], 25 | inputs["t_running_mean"], 26 | inputs["v"], 27 | units, 28 | ) 29 | 30 | validate_result(result, outputs, tolerance) 31 | 32 | 33 | def test_ashrae_inputs_invalid_units() -> None: 34 | """Test that the function raises a ValueError for invalid units.""" 35 | with pytest.raises(ValueError): 36 | adaptive_ashrae(tdb=25, tr=25, t_running_mean=20, v=0.1, units="INVALID") 37 | 38 | 39 | def test_ashrae_inputs_invalid_tdb_type() -> None: 40 | """Test that the function raises a TypeError for invalid tdb type.""" 41 | with pytest.raises(TypeError): 42 | adaptive_ashrae(tdb="invalid", tr=25, t_running_mean=20, v=0.1) 43 | 44 | 45 | def test_ashrae_inputs_invalid_tr_type() -> None: 46 | """Test that the function raises a TypeError for invalid tr type.""" 47 | with pytest.raises(TypeError): 48 | adaptive_ashrae(tdb=25, tr="invalid", t_running_mean=20, v=0.1) 49 | 50 | 51 | def test_ashrae_inputs_invalid_t_running_mean_type() -> None: 52 | """Test that the function raises a TypeError for invalid t_running_mean type.""" 53 | with pytest.raises(TypeError): 54 | adaptive_ashrae(tdb=25, tr=25, t_running_mean="invalid", v=0.1) 55 | 56 | 57 | def test_ashrae_inputs_invalid_v_type() -> None: 58 | """Test that the function raises a TypeError for invalid v type.""" 59 | with pytest.raises(TypeError): 60 | adaptive_ashrae(tdb=25, tr=25, t_running_mean=20, v="invalid") 61 | 62 | # Return nan values when limit_inputs=True and inputs are invalid 63 | 64 | 65 | def test_nan_values_for_invalid_inputs() -> None: 66 | """Test that the function returns nan values for invalid inputs when limit_inputs=True.""" 67 | # Test with invalid inputs where limit_inputs=True 68 | result = adaptive_ashrae( 69 | tdb=5.0, 70 | tr=5.0, 71 | t_running_mean=5.0, 72 | v=3.0, 73 | limit_inputs=True, 74 | ) 75 | 76 | # Check that the comfort temperature is nan 77 | assert np.isnan(result.tmp_cmf) 78 | 79 | # Check that the acceptability flags are False 80 | assert result.acceptability_80 == False 81 | assert result.acceptability_90 == False 82 | -------------------------------------------------------------------------------- /tests/test_work_capacity_niosh.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.classes_return import WorkCapacity 5 | from pythermalcomfort.models.work_capacity_niosh import work_capacity_niosh 6 | 7 | 8 | def _expected_capacity(wbgt, met) -> np.ndarray: 9 | """Calculate the expected work capacity based on WBGT and metabolic rate.""" 10 | wbgt_arr = np.asarray(wbgt) 11 | met_arr = np.asarray(met) 12 | met_rest = 117.0 13 | wbgt_lim = 56.7 - 11.5 * np.log10(met_arr) 14 | wbgt_lim_rest = 56.7 - 11.5 * np.log10(met_rest) 15 | cap = ((wbgt_lim_rest - wbgt_arr) / (wbgt_lim_rest - wbgt_lim)) * 100 16 | return np.clip(cap, 0, 100) 17 | 18 | 19 | def test_scalar_typical() -> None: 20 | """Test that the function returns a WorkCapacity object with correct capacity.""" 21 | wbgt = 25.0 22 | met = 200.0 23 | result = work_capacity_niosh(wbgt, met) 24 | assert isinstance(result, WorkCapacity) 25 | assert isinstance(result.capacity, float) 26 | expected = _expected_capacity(wbgt, met) 27 | assert pytest.approx(expected, rel=1e-3) == result.capacity 28 | 29 | 30 | def test_list_input_pairwise() -> None: 31 | """Test that the function handles list inputs correctly.""" 32 | wbgts = [20.0, 30.0, 40.0] 33 | mets = [150.0, 250.0, 350.0] 34 | result = work_capacity_niosh(wbgts, mets) 35 | assert isinstance(result.capacity, np.ndarray) 36 | expected = _expected_capacity(wbgts, mets) 37 | for got, exp in zip(result.capacity, expected, strict=False): 38 | assert pytest.approx(exp, rel=1e-3) == got 39 | 40 | 41 | def test_exact_wbgt_lim_full_capacity() -> None: 42 | """Test that the function returns full capacity when WBGT limit is met.""" 43 | met = 300.0 44 | wbgt_lim = 56.7 - 11.5 * np.log10(met) 45 | result = work_capacity_niosh(wbgt_lim, met) 46 | assert result.capacity == pytest.approx(100.0, abs=1e-6) 47 | 48 | 49 | def test_resting_limit_zero_capacity() -> None: 50 | """Test that the function returns zero capacity when WBGT limit for resting is met.""" 51 | met = 200.0 52 | wbgt_lim_rest = 56.7 - 11.5 * np.log10(117.0) 53 | result = work_capacity_niosh(wbgt_lim_rest, met) 54 | assert result.capacity == pytest.approx(0.0, abs=1e-6) 55 | 56 | 57 | def test_low_wbgt_clamped_to_100() -> None: 58 | """Test that the function clamps low WBGT values to 100% capacity.""" 59 | result = work_capacity_niosh(0.0, 250.0) 60 | assert result.capacity == pytest.approx(100.0, abs=1e-6) 61 | 62 | 63 | def test_high_wbgt_clamped_to_0() -> None: 64 | """Test that the function clamps high WBGT values to 0% capacity.""" 65 | result = work_capacity_niosh(100.0, 250.0) 66 | assert result.capacity == pytest.approx(0.0, abs=1e-6) 67 | 68 | 69 | def test_negative_met_raises() -> None: 70 | """Test that the function raises ValueError for negative metabolic rate.""" 71 | with pytest.raises(ValueError): 72 | work_capacity_niosh(25.0, -10.0) 73 | 74 | 75 | def test_met_above_max_raises() -> None: 76 | """Test that the function raises ValueError for metabolic rate above maximum.""" 77 | with pytest.raises(ValueError): 78 | work_capacity_niosh(25.0, 3000.0) 79 | -------------------------------------------------------------------------------- /pythermalcomfort/models/net.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import NETInputs 6 | from pythermalcomfort.classes_return import NET 7 | 8 | 9 | def net( 10 | tdb: float | list[float], 11 | rh: float | list[float], 12 | v: float | list[float], 13 | round_output: bool = True, 14 | ) -> NET: 15 | """Calculate the Normal Effective Temperature (NET). Missenard (1933) 16 | devised a formula for calculating effective temperature. The index 17 | establishes a link between the same condition of the organism's 18 | thermoregulatory capability (warm and cold perception) and the surrounding 19 | environment's temperature and humidity. The index is calculated as a 20 | function of three meteorological factors: air temperature, relative 21 | humidity of air, and wind speed. This index allows to calculate the 22 | effective temperature felt by a person. Missenard original equation was 23 | then used to calculate the Normal Effective Temperature (NET), by 24 | considering normal atmospheric pressure and a normal human body temperature 25 | (37°C). The NET is still in use in Germany, where medical check-ups for 26 | subjects working in the heat are decided on by prevailing levels of ET, 27 | depending on metabolic rates. The NET is also constantly monitored by the 28 | Hong Kong Observatory [Blazejczyk2012]_. In central Europe the following thresholds are 29 | in use: <1°C = very cold; 1-9 = cold; 9-17 = cool; 17-21 = fresh; 21-23 = comfortable; 30 | 23-27 = warm; >27°C = hot [Blazejczyk2012]_. 31 | 32 | Parameters 33 | ---------- 34 | tdb : float or list of floats 35 | Dry bulb air temperature, [°C]. 36 | rh : float or list of floats 37 | Relative humidity, [%]. 38 | v : float or list of floats 39 | Wind speed [m/s] at 1.2 m above the ground. 40 | round_output : bool, optional 41 | If True, rounds output value. If False, it does not round it. Defaults to True. 42 | 43 | Returns 44 | ------- 45 | NET 46 | A dataclass containing the Normal Effective Temperature. See :py:class:`~pythermalcomfort.classes_return.Net` for more details. 47 | To access the `net` value, use the `net` attribute of the returned `Net` instance, e.g., `result.net`. 48 | 49 | Examples 50 | -------- 51 | .. code-block:: python 52 | 53 | from pythermalcomfort.models import net 54 | 55 | result = net(tdb=37, rh=100, v=0.1) 56 | print(result.net) # 37.0 57 | 58 | result = net(tdb=[37, 30], rh=[100, 60], v=[0.1, 0.5], round_output=False) 59 | print(result.net) # [37.0, 26.38977535] 60 | 61 | """ 62 | # Validate inputs using the NetInputs class 63 | NETInputs( 64 | tdb=tdb, 65 | rh=rh, 66 | v=v, 67 | round_output=round_output, 68 | ) 69 | 70 | tdb = np.asarray(tdb) 71 | rh = np.asarray(rh) 72 | v = np.asarray(v) 73 | 74 | frac = 1.0 / (1.76 + 1.4 * v**0.75) 75 | et = 37 - (37 - tdb) / (0.68 - 0.0014 * rh + frac) - 0.29 * tdb * (1 - 0.01 * rh) 76 | 77 | if round_output: 78 | et = np.around(et, 1) 79 | 80 | return NET(net=et) 81 | -------------------------------------------------------------------------------- /tests/test_work_capacity_iso.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from pythermalcomfort.classes_return import WorkCapacity 7 | from pythermalcomfort.models.work_capacity_iso import work_capacity_iso 8 | 9 | 10 | def _expected_capacity( 11 | wbgt: float | list[float], 12 | met: float | list[float], 13 | ) -> np.ndarray: 14 | """Calculate the expected work capacity based on WBGT and metabolic rate.""" 15 | wbgt_arr = np.asarray(wbgt) 16 | met_arr = np.asarray(met) 17 | met_rest = 117.0 18 | wbgt_lim = 34.9 - met_arr / 46.0 19 | wbgt_lim_rest = 34.9 - met_rest / 46.0 20 | cap = ((wbgt_lim_rest - wbgt_arr) / (wbgt_lim_rest - wbgt_lim)) * 100.0 21 | return np.clip(cap, 0, 100) 22 | 23 | 24 | def test_scalar_typical() -> None: 25 | """Test that the function returns a WorkCapacity object with correct capacity.""" 26 | wbgt = 30.0 27 | met = 200.0 28 | result = work_capacity_iso(wbgt, met) 29 | assert isinstance(result, WorkCapacity) 30 | assert isinstance(result.capacity, float) 31 | expected = _expected_capacity(wbgt, met) 32 | assert pytest.approx(expected, rel=1e-3) == result.capacity 33 | 34 | 35 | def test_list_input_pairwise() -> None: 36 | """Test that the function handles list inputs correctly.""" 37 | wbgts = [20.0, 30.0, 40.0] 38 | mets = [100.0, 200.0, 300.0] 39 | result = work_capacity_iso(wbgts, mets) 40 | assert isinstance(result.capacity, np.ndarray) 41 | expected = _expected_capacity(wbgts, mets) 42 | for got, exp in zip(result.capacity, expected, strict=False): 43 | assert pytest.approx(exp, rel=1e-3) == got 44 | 45 | 46 | def test_exact_wbgt_lim_full_capacity() -> None: 47 | """Test that the function returns full capacity when WBGT limit is met.""" 48 | met = 250.0 49 | wbgt_lim = 34.9 - met / 46.0 50 | result = work_capacity_iso(wbgt_lim, met) 51 | assert result.capacity == pytest.approx(100.0, abs=1e-6) 52 | 53 | 54 | def test_resting_limit_zero_capacity() -> None: 55 | """Test that the function returns zero capacity when WBGT limit for resting is met.""" 56 | met = 250.0 57 | wbgt_lim_rest = 34.9 - 117.0 / 46.0 58 | result = work_capacity_iso(wbgt_lim_rest, met) 59 | assert result.capacity == pytest.approx(0.0, abs=1e-6) 60 | 61 | 62 | def test_low_wbgt_clamped_to_100() -> None: 63 | """Test that the function clamps low WBGT values to 100% capacity.""" 64 | result = work_capacity_iso(0.0, 500.0) 65 | assert result.capacity == pytest.approx(100.0, abs=1e-6) 66 | 67 | 68 | def test_high_wbgt_clamped_to_0() -> None: 69 | """Test that the function clamps high WBGT values to 0% capacity.""" 70 | result = work_capacity_iso(100.0, 500.0) 71 | assert result.capacity == pytest.approx(0.0, abs=1e-6) 72 | 73 | 74 | def test_negative_met_raises() -> None: 75 | """Test that the function raises ValueError for negative met.""" 76 | with pytest.raises(ValueError): 77 | work_capacity_iso(25.0, -10.0) 78 | 79 | 80 | def test_met_above_max_raises() -> None: 81 | """Test that the function raises ValueError for met above maximum.""" 82 | with pytest.raises(ValueError): 83 | work_capacity_iso(25.0, 3000.0) 84 | -------------------------------------------------------------------------------- /.cookiecutterrc: -------------------------------------------------------------------------------- 1 | # This file exists so you can easily regenerate your project. 2 | # 3 | # `cookiepatcher` is a convenient shim around `cookiecutter` 4 | # for regenerating projects (it will generate a .cookiecutterrc 5 | # automatically for any template). To use it: 6 | # 7 | # pip install cookiepatcher 8 | # cookiepatcher gh:ionelmc/cookiecutter-pylibrary project-path 9 | # 10 | # See: 11 | # https://pypi.org/project/cookiepatcher 12 | # 13 | # Alternatively, you can run: 14 | # 15 | # cookiecutter --overwrite-if-exists --config-file=project-path/.cookiecutterrc gh:ionelmc/cookiecutter-pylibrary 16 | 17 | default_context: 18 | 19 | _extensions: ['jinja2_time.TimeExtension'] 20 | _template: 'gh:ionelmc/cookiecutter-pylibrary' 21 | allow_tests_inside_package: 'no' 22 | appveyor: 'yes' 23 | c_extension_function: 'longest' 24 | c_extension_module: '_pythermalcomfort' 25 | c_extension_optional: 'no' 26 | c_extension_support: 'no' 27 | c_extension_test_pypi: 'no' 28 | c_extension_test_pypi_username: 'CenterForTheBuiltEnvironment' 29 | codacy: 'no' 30 | codacy_projectid: '[Get ID from https://app.codacy.com/app/CenterForTheBuiltEnvironment/pythermalcomfort/settings]' 31 | codeclimate: 'no' 32 | codecov: 'yes' 33 | command_line_interface: 'plain' 34 | command_line_interface_bin_name: 'pythermalcomfort' 35 | coveralls: 'no' 36 | coveralls_token: '[Required for Appveyor, take it from https://coveralls.io/github/CenterForTheBuiltEnvironment/pythermalcomfort]' 37 | distribution_name: 'pythermalcomfort' 38 | email: 'cbecomforttool@gmail.com' 39 | full_name: 'Federico Tartarini' 40 | landscape: 'no' 41 | license: 'MIT license' 42 | linter: 'flake8' 43 | package_name: 'pythermalcomfort' 44 | project_name: 'pythermalcomfort' 45 | project_short_description: 'Package to calculate sevral thermal comfort indeces (e.g. PMV, PPD, SET, adaptive) and convert physical variables.' 46 | pypi_badge: 'yes' 47 | pypi_disable_upload: 'no' 48 | release_date: 'today' 49 | repo_hosting: 'github.com' 50 | repo_hosting_domain: 'github.com' 51 | repo_name: 'pythermalcomfort' 52 | repo_username: 'CenterForTheBuiltEnvironment' 53 | requiresio: 'yes' 54 | scrutinizer: 'no' 55 | setup_py_uses_setuptools_scm: 'no' 56 | setup_py_uses_test_runner: 'no' 57 | sphinx_docs: 'yes' 58 | sphinx_docs_hosting: 'https://pythermalcomfort.readthedocs.io/' 59 | sphinx_doctest: 'no' 60 | sphinx_theme: 'sphinx-rtd-theme' 61 | test_matrix_configurator: 'no' 62 | test_matrix_separate_coverage: 'no' 63 | test_runner: 'pytest' 64 | travis: 'yes' 65 | travis_osx: 'no' 66 | version: '0.0.0' 67 | website: 'https://comfort.cbe.berkeley.edu' 68 | year_from: '2019' 69 | year_to: '2020' 70 | -------------------------------------------------------------------------------- /pythermalcomfort/models/wbgt.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | import numpy.typing as npt 5 | 6 | from pythermalcomfort.classes_input import WBGTInputs 7 | from pythermalcomfort.classes_return import WBGT 8 | 9 | 10 | def wbgt( 11 | twb: float | npt.ArrayLike, 12 | tg: float | npt.ArrayLike, 13 | tdb: float | npt.ArrayLike = None, 14 | with_solar_load: bool = False, 15 | round_output: bool = True, 16 | ) -> WBGT: 17 | """Calculate the Wet Bulb Globe Temperature (WBGT) index in compliance with the ISO 18 | 7243 Standard [7243ISO2017]_. 19 | 20 | The WBGT is a heat stress index that 21 | measures the thermal environment to which a person is exposed. In most 22 | situations, this index is simple to calculate. It should be used as a 23 | screening tool to determine whether heat stress is present. The PHS model 24 | allows a more accurate estimation of stress. PHS can be calculated using 25 | the function :py:meth:`pythermalcomfort.models.phs`. 26 | 27 | The WBGT determines the impact of heat on a person throughout the course of a working 28 | day (up to 8 hours). It does not apply to very brief heat exposures. It pertains to 29 | the evaluation of male and female people who are fit for work in both indoor and 30 | outdoor occupational environments, as well as other sorts of surroundings [7243ISO2017]_. 31 | 32 | The WBGT is defined as a function of only twb and tg if the person is not exposed to 33 | direct radiant heat from the sun. When a person is exposed to direct radiant heat, 34 | tdb must also be specified. 35 | 36 | Parameters 37 | ---------- 38 | twb : float or list of floats 39 | Natural (no forced air flow) wet bulb temperature, [°C]. 40 | tg : float or list of floats 41 | Globe temperature, [°C]. 42 | tdb : float or list of floats, optional 43 | Dry bulb air temperature, [°C]. This value is needed as input if the person is 44 | exposed to direct solar radiation. 45 | with_solar_load : bool, optional 46 | True if the globe sensor is exposed to direct solar radiation. Defaults to False. 47 | round_output : bool, optional 48 | If True, rounds output value. If False, it does not round it. Defaults to True. 49 | 50 | Returns 51 | ------- 52 | WBGT 53 | A dataclass containing the Wet Bulb Globe Temperature Index. See 54 | :py:class:`~pythermalcomfort.classes_return.WBGT` for more details. To access the 55 | `wbgt` value, use the `wbgt` attribute of the returned `wbgt` instance, e.g., 56 | `result.wbgt`. 57 | 58 | Examples 59 | -------- 60 | .. code-block:: python 61 | 62 | from pythermalcomfort.models import wbgt 63 | 64 | result = wbgt(twb=25, tg=32) 65 | print(result.wbgt) # 27.1 66 | 67 | result = wbgt(twb=25, tg=32, tdb=20, with_solar_load=True) 68 | print(result.wbgt) # 25.9 69 | """ 70 | # Validate inputs using the WBGTInputs class 71 | WBGTInputs( 72 | twb=twb, 73 | tg=tg, 74 | tdb=tdb, 75 | with_solar_load=with_solar_load, 76 | round_output=round_output, 77 | ) 78 | 79 | twb = np.asarray(twb) 80 | tg = np.asarray(tg) 81 | tdb = np.asarray(tdb) if tdb is not None else None 82 | 83 | if with_solar_load and tdb is None: 84 | raise ValueError("Please enter the dry bulb air temperature") 85 | 86 | if with_solar_load: 87 | t_wbg = 0.7 * twb + 0.2 * tg + 0.1 * tdb 88 | else: 89 | t_wbg = 0.7 * twb + 0.3 * tg 90 | 91 | if round_output: 92 | t_wbg = np.round(t_wbg, 1) 93 | 94 | return WBGT(wbgt=t_wbg) 95 | -------------------------------------------------------------------------------- /tests/test_work_capacity_hothaps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from pythermalcomfort.classes_input import WorkIntensity 7 | from pythermalcomfort.classes_return import WorkCapacity 8 | from pythermalcomfort.models import work_capacity_hothaps 9 | 10 | 11 | def _expected_capacity( 12 | wbgt: float | list[float], 13 | divisor: float, 14 | exponent: float, 15 | ) -> np.ndarray: 16 | """Compute the expected work capacity based on WBGT, divisor, and exponent. 17 | 18 | Parameters 19 | ---------- 20 | wbgt: float 21 | Wet Bulb Globe Temperature value(s). 22 | divisor: 23 | Divisor parameter for the capacity formula. 24 | exponent: 25 | Exponent parameter for the capacity formula. 26 | 27 | Returns 28 | ------- 29 | Numpy array of capacity values, clipped between 0 and 100. 30 | 31 | """ 32 | wbgt_array = np.asarray(wbgt) 33 | cap = 100 * (0.1 + 0.9 / (1 + (wbgt_array / divisor) ** exponent)) 34 | return cap.clip(0, 100) 35 | 36 | 37 | def test_scalar_heavy() -> None: 38 | """Test that a scalar heavy work intensity produces the expected capacity.""" 39 | result = work_capacity_hothaps(30.0, work_intensity=WorkIntensity.HEAVY.value) 40 | assert isinstance(result, WorkCapacity) 41 | assert isinstance(result.capacity, float) 42 | expected = _expected_capacity(30.0, divisor=30.94, exponent=16.64) 43 | assert pytest.approx(expected, rel=1e-3) == result.capacity 44 | 45 | 46 | def test_scalar_moderate() -> None: 47 | """Test that a scalar moderate work intensity produces the expected capacity.""" 48 | result = work_capacity_hothaps(30.0, work_intensity=WorkIntensity.MODERATE.value) 49 | assert isinstance(result, WorkCapacity) 50 | assert isinstance(result.capacity, float) 51 | expected = _expected_capacity(30.0, divisor=32.93, exponent=17.81) 52 | assert pytest.approx(expected, rel=1e-3) == result.capacity 53 | 54 | 55 | def test_scalar_light() -> None: 56 | """Test that a scalar light work intensity produces the expected capacity.""" 57 | result = work_capacity_hothaps(30.0, work_intensity=WorkIntensity.LIGHT.value) 58 | assert isinstance(result, WorkCapacity) 59 | assert isinstance(result.capacity, float) 60 | expected = _expected_capacity(30.0, divisor=34.64, exponent=22.72) 61 | assert pytest.approx(expected, rel=1e-3) == result.capacity 62 | 63 | 64 | def test_list_input() -> None: 65 | """Test that the function handles list inputs correctly.""" 66 | wbgts = [20.0, 40.0] 67 | result = work_capacity_hothaps(wbgts, work_intensity="heavy") 68 | assert isinstance(result.capacity, np.ndarray) 69 | exp_list = _expected_capacity(wbgts, divisor=30.94, exponent=16.64).tolist() 70 | assert all( 71 | pytest.approx(e, rel=1e-3) == r 72 | for e, r in zip(exp_list, result.capacity, strict=False) 73 | ) 74 | 75 | 76 | def test_low_wbgt_clamped_to_100() -> None: 77 | """Test that the function clamps low WBGT values to 100% capacity.""" 78 | result = work_capacity_hothaps(0.0, work_intensity="light") 79 | assert result.capacity == pytest.approx(100.0, rel=1e-6) 80 | 81 | 82 | def test_high_wbgt_approaches_10_percent() -> None: 83 | """Test that the function returns approximately 10% capacity for high WBGT.""" 84 | result = work_capacity_hothaps(100.0, work_intensity="moderate") 85 | assert result.capacity == pytest.approx(10.0, rel=1e-3) 86 | 87 | 88 | def test_invalid_intensity_raises() -> None: 89 | """Test that the function raises ValueError for invalid work intensity.""" 90 | with pytest.raises(ValueError): 91 | work_capacity_hothaps(30.0, work_intensity="invalid") 92 | -------------------------------------------------------------------------------- /pythermalcomfort/models/work_capacity_hothaps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import WorkCapacityHothapsInputs, WorkIntensity 6 | from pythermalcomfort.classes_return import WorkCapacity 7 | 8 | 9 | def work_capacity_hothaps( 10 | wbgt: float | list[float], 11 | work_intensity: str = WorkIntensity.HEAVY.value, 12 | ) -> WorkCapacity: 13 | """Estimate work capacity due to heat based on Kjellstrom et al. [Kjellstrom2018]_. 14 | 15 | Estimates the amount of work that will be done at a given WBGT and 16 | intensity of work as a percent. 100% means work is unaffected by heat. 0% 17 | means no work is done. Note that in this version the functions do not reach 18 | 0% as it is assumed that it is always possible to work in short bursts for 19 | 10% of the time. 20 | 21 | Note that for this function "the empirical evidence is from studies in 22 | heavyly distinct locations, including a gold mine (Wyndham, 1969), 124 rice 23 | harvesters in West Bengal in India (Sahu et al., 2013), and six women 24 | observed in a climatic chamber (Nag and Nag, 1992)." 25 | (https://adaptecca.es/sites/default/files/documentos/2018_jrc_pesetaiii_impact_labour_productivity.pdf). 26 | The shape of the function is just an assumption, and the fit of the 27 | sigmoid to the data it is analysing is not especially good. 28 | 29 | Heavy intensity work is sometimes labelled as 400 W, moderate 300 W, light 200 30 | W, but this is only nominal. 31 | 32 | The correction citation is: Bröde P, Fiala D, Lemke B, Kjellstrom T. 33 | Estimated work ability in warm outdoor environments depends on the chosen 34 | heat stress assessment metric. International Journal of Biometeorology. 35 | 2018 Mar;62(3):331 45. 36 | 37 | 38 | The relevant definitions of the functions can be found most clearly in: 39 | Orlov A, Sillmann J, Aunan K, Kjellstrom T, Aaheim A. Economic costs of 40 | heat-induced reductions in worker productivity due to global warming. Global 41 | Environmental Change [Internet]. 2020 Jul;63. Available from: 42 | https://doi.org/10.1016/j.gloenvcha.2020.102087 43 | 44 | For a comparison of different functions see Fig 1 of Day E, Fankhauser S, Kingsmill 45 | N, Costa H, Mavrogianni A. Upholding labour productivity under climate 46 | change: an assessment of adaptation options. Climate Policy. 2019 47 | Mar;19(3):367 85. 48 | 49 | Parameters 50 | ---------- 51 | wbgt : float or list of floats 52 | Wet bulb globe temperature, [°C]. 53 | work_intensity : str 54 | Which work intensity to use for the calculation, choice of "heavy", 55 | "moderate" or "light". 56 | 57 | Returns 58 | ------- 59 | WorkCapacity 60 | A dataclass containing the work capacity. See 61 | :py:class:`~pythermalcomfort.classes_return.WorkCapacity` for more details. To access the 62 | `capacity` value, use the `capacity` attribute of the returned `WorkCapacity` instance, e.g., 63 | `result.capacity`. 64 | """ 65 | # validate inputs 66 | WorkCapacityHothapsInputs(wbgt=wbgt, work_intensity=work_intensity) 67 | 68 | # convert str to enum 69 | work_intensity = WorkIntensity(work_intensity.lower()) 70 | wbgt = np.asarray(wbgt) 71 | 72 | params = { 73 | WorkIntensity.HEAVY: {"divisor": 30.94, "exponent": 16.64}, 74 | WorkIntensity.MODERATE: {"divisor": 32.93, "exponent": 17.81}, 75 | WorkIntensity.LIGHT: {"divisor": 34.64, "exponent": 22.72}, 76 | } 77 | divisor = params[work_intensity]["divisor"] 78 | exponent = params[work_intensity]["exponent"] 79 | capacity = np.clip(100 * (0.1 + (0.9 / (1 + (wbgt / divisor) ** exponent))), 0, 100) 80 | 81 | return WorkCapacity(capacity=capacity) 82 | -------------------------------------------------------------------------------- /pythermalcomfort/models/humidex.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import HumidexInputs, HumidexModels 6 | from pythermalcomfort.classes_return import Humidex 7 | from pythermalcomfort.utilities import dew_point_tmp 8 | 9 | 10 | def humidex( 11 | tdb: float | list[float], 12 | rh: float | list[float], 13 | model: str = "rana", 14 | round_output: bool = True, 15 | ) -> Humidex: 16 | """Calculate the humidex (short for "humidity index"). It has been developed by the 17 | Canadian Meteorological service. It was introduced in 1965 and then it was revised 18 | by Masterson and Richardson (1979) [Masterson1979]_. It aims to describe how hot, 19 | humid weather is felt by the average person. The Humidex differs from the heat index 20 | in being related to the dew point rather than relative humidity [Havenith2016]_. 21 | 22 | Parameters 23 | ---------- 24 | tdb : float or list of floats 25 | Dry bulb air temperature, [°C]. 26 | rh : float or list of floats 27 | Relative humidity, [%]. 28 | model : str, optional 29 | The model to be used for the calculation. Options are 'rana' and 'masterson'. Defaults to 'rana'. 30 | 31 | .. note:: 32 | The 'rana' model is the Humidex model proposed by `Rana et al. (2013)`_. 33 | The 'masterson' model is the Humidex model proposed by Masterson and Richardson (1979) [Masterson1979]_. 34 | 35 | .. _Rana et al. (2013): https://doi.org/10.1016/j.enbuild.2013.04.019 36 | round_output : bool, optional 37 | If True, rounds output value. If False, it does not round it. Defaults to True. 38 | 39 | Returns 40 | ------- 41 | Humidex 42 | A dataclass containing the Humidex value and its discomfort category. See :py:class:`~pythermalcomfort.classes_return.Humidex` for more details. 43 | To access the `humidex` and `discomfort` values, use the respective attributes of the returned `Humidex` instance, e.g., `result.humidex`. 44 | 45 | Examples 46 | -------- 47 | .. code-block:: python 48 | 49 | from pythermalcomfort.models import humidex 50 | 51 | result = humidex(tdb=25, rh=50) 52 | print(result.humidex) # 28.2 53 | print(result.discomfort) # Little or no discomfort 54 | 55 | result = humidex(tdb=[25, 30], rh=[50, 60], round_output=False) 56 | print(result.humidex) # [28.2, 39.1] 57 | print(result.discomfort) 58 | # ['Little or no discomfort', 'Evident discomfort'] 59 | """ 60 | # Validate inputs using the HumidexInputs class 61 | HumidexInputs( 62 | tdb=tdb, 63 | rh=rh, 64 | round_output=round_output, 65 | ) 66 | 67 | tdb = np.asarray(tdb) 68 | rh = np.asarray(rh) 69 | 70 | if np.any(rh > 100) or np.any(rh < 0): 71 | raise ValueError("Relative humidity must be between 0 and 100%") 72 | 73 | if model not in HumidexModels._value2member_map_: 74 | raise ValueError( 75 | "Invalid model. The model must be either 'rana' or 'masterson'", 76 | ) 77 | 78 | hi = tdb + 5 / 9 * ((6.112 * 10 ** (7.5 * tdb / (237.7 + tdb)) * rh / 100) - 10) 79 | if model == HumidexModels.masterson.value: 80 | hi = tdb + 5 / 9 * ( 81 | 6.11 82 | * np.exp( 83 | 5417.753 * (1 / 273.15 - 1 / (dew_point_tmp(tdb=tdb, rh=rh) + 273.15)), 84 | ) 85 | - 10 86 | ) 87 | 88 | if round_output: 89 | hi = np.around(hi, 1) 90 | 91 | stress_category = np.full_like(hi, "Heat stroke probable", dtype=object) 92 | stress_category[hi <= 30] = "Little or no discomfort" 93 | stress_category[(hi > 30) & (hi <= 35)] = "Noticeable discomfort" 94 | stress_category[(hi > 35) & (hi <= 40)] = "Evident discomfort" 95 | stress_category[(hi > 40) & (hi <= 45)] = "Intense discomfort; avoid exertion" 96 | stress_category[(hi > 45) & (hi <= 54)] = "Dangerous discomfort" 97 | 98 | return Humidex(humidex=hi, discomfort=stress_category) 99 | -------------------------------------------------------------------------------- /.coderabbit.yaml: -------------------------------------------------------------------------------- 1 | language: en-AU 2 | tone_instructions: 'You must talk like a professional software engineer. Use technical terms and jargon where appropriate, but avoid overly complex language. Be concise and to the point, while still being friendly and approachable.' 3 | early_access: false 4 | enable_free_tier: true 5 | reviews: 6 | profile: assertive 7 | request_changes_workflow: true 8 | high_level_summary: true 9 | high_level_summary_placeholder: '@coderabbitai summary' 10 | high_level_summary_in_walkthrough: false 11 | auto_title_placeholder: '@coderabbitai' 12 | auto_title_instructions: '' 13 | review_status: true 14 | commit_status: true 15 | fail_commit_status: false 16 | collapse_walkthrough: false 17 | changed_files_summary: true 18 | sequence_diagrams: true 19 | assess_linked_issues: true 20 | related_issues: true 21 | related_prs: true 22 | suggested_labels: true 23 | auto_apply_labels: false 24 | suggested_reviewers: true 25 | auto_assign_reviewers: false 26 | poem: false 27 | labeling_instructions: [] 28 | path_filters: [] 29 | path_instructions: [] 30 | abort_on_close: true 31 | disable_cache: false 32 | auto_review: 33 | enabled: true 34 | auto_incremental_review: true 35 | ignore_title_keywords: [] 36 | labels: [] 37 | drafts: false 38 | base_branches: ['development'] 39 | finishing_touches: 40 | docstrings: 41 | enabled: true 42 | unit_tests: 43 | enabled: true 44 | tools: 45 | ast-grep: 46 | rule_dirs: [] 47 | util_dirs: [] 48 | essential_rules: true 49 | packages: [] 50 | shellcheck: 51 | enabled: true 52 | ruff: 53 | enabled: true 54 | markdownlint: 55 | enabled: true 56 | github-checks: 57 | enabled: true 58 | timeout_ms: 90000 59 | languagetool: 60 | enabled: true 61 | enabled_rules: [] 62 | disabled_rules: [] 63 | enabled_categories: [] 64 | disabled_categories: [] 65 | enabled_only: false 66 | level: default 67 | biome: 68 | enabled: true 69 | hadolint: 70 | enabled: true 71 | swiftlint: 72 | enabled: true 73 | phpstan: 74 | enabled: true 75 | level: default 76 | golangci-lint: 77 | enabled: true 78 | yamllint: 79 | enabled: true 80 | gitleaks: 81 | enabled: true 82 | checkov: 83 | enabled: true 84 | detekt: 85 | enabled: true 86 | eslint: 87 | enabled: true 88 | rubocop: 89 | enabled: true 90 | buf: 91 | enabled: true 92 | regal: 93 | enabled: true 94 | actionlint: 95 | enabled: true 96 | pmd: 97 | enabled: true 98 | cppcheck: 99 | enabled: true 100 | semgrep: 101 | enabled: true 102 | circleci: 103 | enabled: true 104 | sqlfluff: 105 | enabled: true 106 | prismaLint: 107 | enabled: true 108 | oxc: 109 | enabled: true 110 | shopifyThemeCheck: 111 | enabled: true 112 | luacheck: 113 | enabled: true 114 | chat: 115 | auto_reply: true 116 | integrations: 117 | jira: 118 | usage: auto 119 | linear: 120 | usage: auto 121 | knowledge_base: 122 | opt_out: false 123 | web_search: 124 | enabled: true 125 | learnings: 126 | scope: auto 127 | issues: 128 | scope: auto 129 | jira: 130 | usage: auto 131 | project_keys: [] 132 | linear: 133 | usage: auto 134 | team_keys: [] 135 | pull_requests: 136 | scope: auto 137 | code_generation: 138 | docstrings: 139 | language: en-US 140 | path_instructions: [] 141 | unit_tests: 142 | path_instructions: [] 143 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from pathlib import Path 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | def read(*names: str, encoding: str = "utf8") -> str: 10 | """Read and return the contents of a text file located by path segments 11 | relative to this file's directory. 12 | 13 | Parameters 14 | ---------- 15 | *names : str 16 | Sequence of path components under the project root. 17 | encoding : str, default "utf8" 18 | File encoding to use when reading. 19 | 20 | Returns 21 | ------- 22 | str 23 | The full text content of the file. 24 | 25 | """ 26 | base_dir = Path(__file__).parent 27 | file_path = base_dir.joinpath(*names) 28 | return file_path.read_text(encoding=encoding) 29 | 30 | 31 | setup( 32 | name="pythermalcomfort", 33 | version="3.8.0", 34 | license="MIT", 35 | description=( 36 | "pythermalcomfort is a comprehensive toolkit for calculating " 37 | "thermal comfort indices, heat/cold stress metrics, and thermophysiological responses. " 38 | "It supports multiple models, including PMV, PPD, adaptive comfort, SET, " 39 | "UTCI, Heat Index, Wind Chill Index, and Humidex. " 40 | "The package also includes thermophysiological models like the two-node (Gagge) and multinode (JOS-3) models " 41 | "to estimate physiological responses such as core temperature, skin temperature, and skin wettedness. " 42 | ), 43 | long_description="{}\n{}".format( 44 | re.compile("^.. start-badges.*^.. end-badges", re.MULTILINE | re.DOTALL).sub( 45 | "", 46 | read("README.rst"), 47 | ), 48 | re.sub(":[a-z]+:`~?(.*?)`", r"``\1``", read("CHANGELOG.rst")), 49 | ), 50 | long_description_content_type="text/x-rst", 51 | author="Federico Tartarini", 52 | author_email="cbecomforttool@gmail.com", 53 | url="https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort", 54 | packages=find_packages(), 55 | include_package_data=True, 56 | zip_safe=False, 57 | classifiers=[ 58 | "Development Status :: 5 - Production/Stable", 59 | "Intended Audience :: Developers", 60 | "Intended Audience :: Education", 61 | "Intended Audience :: Science/Research", 62 | "License :: OSI Approved :: MIT License", 63 | "Operating System :: Unix", 64 | "Operating System :: POSIX", 65 | "Operating System :: Microsoft :: Windows", 66 | "Programming Language :: Python", 67 | "Programming Language :: Python :: 3", 68 | "Programming Language :: Python :: 3.10", 69 | "Programming Language :: Python :: 3.11", 70 | "Programming Language :: Python :: 3.12", 71 | "Programming Language :: Python :: 3.13", 72 | "Programming Language :: Python :: Implementation :: CPython", 73 | "Programming Language :: Python :: Implementation :: PyPy", 74 | "Topic :: Education", 75 | "Topic :: Scientific/Engineering", 76 | "Topic :: Scientific/Engineering :: Atmospheric Science", 77 | "Topic :: Utilities", 78 | ], 79 | project_urls={ 80 | "Documentation": "https://pythermalcomfort.readthedocs.io/", 81 | "Changelog": "https://pythermalcomfort.readthedocs.io/en/latest/changelog.html", 82 | "Issue Tracker": ( 83 | "https://github.com/CenterForTheBuiltEnvironment/pythermalcomfort/issues" 84 | ), 85 | }, 86 | keywords=[ 87 | "thermal comfort", 88 | "pmv", 89 | "heat stress", 90 | "cold stress", 91 | "thermal sensation", 92 | "thermal physiology", 93 | "meteorology", 94 | "climate analysis", 95 | "discomfort", 96 | "comfort", 97 | "thermal environment", 98 | "built environment", 99 | ], 100 | python_requires=">=3.10.0", 101 | install_requires=[ 102 | "scipy", 103 | "numba", 104 | "numpy", 105 | "setuptools", 106 | ], 107 | extras_require={ 108 | "dev": ["pytest", "sphinx"], 109 | }, 110 | entry_points={ 111 | "console_scripts": [ 112 | "pythermalcomfort = pythermalcomfort.cli:main", 113 | ], 114 | }, 115 | ) 116 | -------------------------------------------------------------------------------- /pythermalcomfort/models/pmv_a.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal 4 | 5 | import numpy as np 6 | 7 | from pythermalcomfort.classes_input import APMVInputs 8 | from pythermalcomfort.classes_return import APMV 9 | from pythermalcomfort.models.pmv_ppd_iso import pmv_ppd_iso 10 | from pythermalcomfort.utilities import Models, Units 11 | 12 | 13 | def pmv_a( 14 | tdb: float | list[float], 15 | tr: float | list[float], 16 | vr: float | list[float], 17 | rh: float | list[float], 18 | met: float | list[float], 19 | clo: float | list[float], 20 | a_coefficient: float, 21 | wme: float | list[float] = 0, 22 | units: Literal["SI", "IP"] = Units.SI.value, 23 | limit_inputs: bool = True, 24 | ) -> APMV: 25 | """Return Adaptive Predicted Mean Vote (aPMV) [Yao2009]_. This index was developed 26 | by Yao, R. et al. (2009). The model takes into account factors such as culture, 27 | climate, social, psychological, and behavioral adaptations, which have an impact on 28 | the senses used to detect thermal comfort. This model uses an adaptive coefficient 29 | (λ) representing the adaptive factors that affect the sense of thermal comfort. 30 | 31 | Parameters 32 | ---------- 33 | tdb : float or list of floats 34 | Dry bulb air temperature, default in [°C] or [°F] if `units` = 'IP'. 35 | tr : float or list of floats 36 | Mean radiant temperature, default in [°C] or [°F] if `units` = 'IP'. 37 | vr : float or list of floats 38 | Relative air speed, default in [m/s] or [fps] if `units` = 'IP'. 39 | 40 | .. note:: 41 | vr is the sum of the average air speed measured by the sensor and the activity-generated air speed (Vag). 42 | Calculate vr using :py:meth:`pythermalcomfort.utilities.v_relative`. 43 | 44 | rh : float or list of floats 45 | Relative humidity, [%]. 46 | met : float or list of floats 47 | Metabolic rate, [met]. 48 | clo : float or list of floats 49 | Clothing insulation, [clo]. 50 | 51 | .. note:: 52 | Correct for body movement effects using :py:meth:`pythermalcomfort.utilities.clo_dynamic_iso`. 53 | 54 | a_coefficient : float 55 | Adaptive coefficient. 56 | wme : float or list of floats, optional 57 | External work, [met], default is 0. 58 | units : str, optional 59 | Units system, 'SI' or 'IP'. Defaults to 'SI'. 60 | limit_inputs : bool, optional 61 | If True, returns nan for inputs outside standard limits. Defaults to True. 62 | 63 | .. note:: 64 | ISO 7730 2005 limits: 10 < tdb [°C] < 30, 10 < tr [°C] < 40, 0 < vr [m/s] < 1, 0.8 < met [met] < 4, 65 | 0 < clo [clo] < 2, -2 < PMV < 2. 66 | 67 | Returns 68 | ------- 69 | APMV 70 | A dataclass containing the Predicted Mean Vote (a_pmv). See 71 | :py:class:`~pythermalcomfort.classes_return.AdaptivePMV` for more details. 72 | To access the `a_pmv` value, use the `a_pmv` attribute of the returned `AdaptivePMV` instance, 73 | e.g., `result.a_pmv`. 74 | 75 | Examples 76 | -------- 77 | .. code-block:: python 78 | :emphasize-lines: 9,12,14 79 | 80 | from pythermalcomfort.models import pmv_a 81 | from pythermalcomfort.utilities import v_relative, clo_dynamic_iso 82 | 83 | v = 0.1 84 | met = 1.4 85 | clo = 0.5 86 | 87 | # Calculate relative air speed 88 | v_r = v_relative(v=v, met=met) 89 | 90 | # Calculate dynamic clothing 91 | clo_d = clo_dynamic_iso(clo=clo, met=met, v=v) 92 | 93 | results = pmv_a( 94 | tdb=28, 95 | tr=28, 96 | vr=v_r, 97 | rh=50, 98 | met=met, 99 | clo=clo_d, 100 | a_coefficient=0.293, 101 | ) 102 | print(results) # AdaptivePMV(a_pmv=0.74) 103 | print(results.a_pmv) # 0.71 104 | """ 105 | # Validate inputs using the APMVInputs class 106 | APMVInputs( 107 | tdb=tdb, 108 | tr=tr, 109 | vr=vr, 110 | rh=rh, 111 | met=met, 112 | clo=clo, 113 | a_coefficient=a_coefficient, 114 | wme=wme, 115 | units=units, 116 | ) 117 | 118 | _pmv = pmv_ppd_iso( 119 | tdb, 120 | tr, 121 | vr, 122 | rh, 123 | met, 124 | clo, 125 | wme, 126 | model=Models.iso_7730_2005.value, 127 | units=units, 128 | limit_inputs=limit_inputs, 129 | ).pmv 130 | 131 | pmv_value = np.around(_pmv / (1 + a_coefficient * _pmv), 2) 132 | 133 | return APMV(a_pmv=pmv_value) 134 | -------------------------------------------------------------------------------- /tests/test_two_nodes_gagge_sleep.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.classes_return import GaggeTwoNodesSleep 5 | from pythermalcomfort.models import two_nodes_gagge_sleep 6 | 7 | 8 | def test_two_nodes_gagge_sleep_single_input() -> None: 9 | """Test the two_nodes_gagge_sleep function with scalar inputs.""" 10 | result = two_nodes_gagge_sleep(18, 18, 0.05, 50, 1.4, thickness_quilt=1.76) 11 | 12 | # expected outputs 13 | expected = { 14 | "set": np.asarray([24.28]), 15 | "t_core": np.asarray([37.03]), 16 | "t_skin": np.asarray([33.67]), 17 | "wet": np.asarray([0.27]), 18 | "t_sens": np.asarray([1.12]), 19 | "disc": np.asarray([1.47]), 20 | "e_skin": np.asarray([28.75]), 21 | "met_shivering": np.asarray([0.0]), 22 | "alfa": np.asarray([0.13]), 23 | "skin_blood_flow": np.asarray([7.11]), 24 | } 25 | 26 | # compare each field with a reasonable tolerance 27 | for field, exp in expected.items(): 28 | actual = getattr(result, field) 29 | np.testing.assert_allclose(actual, exp, atol=0.01, rtol=0.005) 30 | 31 | 32 | def test_two_nodes_gagge_sleep_long_duration() -> None: 33 | """Test the two_nodes_gagge_sleep function with a longer duration input.""" 34 | duration = 481 35 | ta = np.repeat(18, duration) 36 | tr = np.repeat(18, duration) 37 | vel = np.repeat(0.05, duration) 38 | rh = np.repeat(50, duration) 39 | clo_a = np.repeat(1.4, duration) 40 | thickness1 = np.repeat(1.76, duration) 41 | 42 | result = two_nodes_gagge_sleep(ta, tr, vel, rh, clo_a, thickness1) 43 | 44 | # Assert return type and shape 45 | assert isinstance(result, GaggeTwoNodesSleep) 46 | assert result.set.shape == (duration,) 47 | assert result.t_core.shape == (duration,) 48 | assert result.t_skin.shape == (duration,) 49 | 50 | # for field in fields: 51 | # print(f"{getattr(result, field)[-1]:.2f},") 52 | 53 | first_row_expected = [ 54 | 24.29, 55 | 37.03, 56 | 33.67, 57 | 0.27, 58 | 1.13, 59 | 1.48, 60 | 28.76, 61 | 0.00, 62 | 0.14, 63 | 7.11, 64 | ] 65 | last_row_expected = [ 66 | 22.23, 67 | 36.23, 68 | 31.06, 69 | 0.06, 70 | -0.73, 71 | -0.73, 72 | 5.21, 73 | 0.00, 74 | 0.25, 75 | 2.98, 76 | ] 77 | 78 | fields = [ 79 | "set", 80 | "t_core", 81 | "t_skin", 82 | "wet", 83 | "t_sens", 84 | "disc", 85 | "e_skin", 86 | "met_shivering", 87 | "alfa", 88 | "skin_blood_flow", 89 | ] 90 | 91 | # check first row 92 | for field, exp in zip(fields, first_row_expected, strict=False): 93 | np.testing.assert_allclose( 94 | getattr(result, field)[0], 95 | exp, 96 | atol=0.01, 97 | rtol=0.005, 98 | err_msg=f"first {field} mismatch", 99 | ) 100 | 101 | # check last row 102 | for field, exp in zip(fields, last_row_expected, strict=False): 103 | np.testing.assert_allclose( 104 | getattr(result, field)[-1], 105 | exp, 106 | atol=0.01, 107 | rtol=0.005, 108 | err_msg=f"last {field} mismatch", 109 | ) 110 | 111 | 112 | def test_length_mismatch_raises_value_error() -> None: 113 | """Test that length mismatch in input lists raises ValueError.""" 114 | with pytest.raises(ValueError) as exc: 115 | two_nodes_gagge_sleep( 116 | [18, 18], 117 | [18], # length mismatch 118 | [0.05, 0.05], 119 | [50, 50], 120 | [1.4, 1.4], 121 | [1.76, 1.76], 122 | ) 123 | 124 | assert "must have the same length" in str(exc.value) 125 | 126 | 127 | def test_unexpected_kwargs_raises_type_error() -> None: 128 | """Test that unexpected keyword arguments raise TypeError.""" 129 | with pytest.raises(TypeError) as exc: 130 | two_nodes_gagge_sleep(18, 18, 0.05, 50, 1.4, 1.76, foo=123) 131 | assert "Unexpected keyword arguments" in str(exc.value) 132 | 133 | 134 | def test_invalid_kwarg_type_raises_type_error() -> None: 135 | """Test that a non-numeric type for tdb raises TypeError.""" 136 | with pytest.raises(TypeError) as exc: 137 | two_nodes_gagge_sleep("string", 18, 0.05, 50, 1.4, 1.76) 138 | msg = str(exc.value) 139 | assert "tdb" in msg 140 | 141 | 142 | def test_tickness_quilt_negative() -> None: 143 | """Test that a negative thickness_quilt raises ValueError.""" 144 | with pytest.raises(ValueError): 145 | two_nodes_gagge_sleep(18, 18, 0.05, 50, 1.4, thickness_quilt=-1.76) 146 | -------------------------------------------------------------------------------- /tests/test_pmv_ppd_iso.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from pythermalcomfort.classes_return import PMVPPD 7 | from pythermalcomfort.models.pmv_ppd_iso import pmv_ppd_iso 8 | from pythermalcomfort.utilities import Models, Units 9 | from tests.conftest import Urls, retrieve_reference_table, validate_result 10 | 11 | 12 | def test_pmv_ppd(get_test_url, retrieve_data) -> None: 13 | """Test that the function calculates the PMV and PPD model correctly for various inputs.""" 14 | reference_table = retrieve_reference_table( 15 | get_test_url, 16 | retrieve_data, 17 | Urls.PMV_PPD.name, 18 | ) 19 | tolerance = reference_table["tolerance"] 20 | 21 | for entry in reference_table["data"]: 22 | inputs = entry["inputs"] 23 | outputs = entry["outputs"] 24 | # TODO change the validation table code and removed the following 25 | if "standard" not in inputs: 26 | inputs["standard"] = "iso" 27 | if inputs["standard"] == "iso": 28 | inputs["model"] = Models.iso_7730_2005.value 29 | del inputs["standard"] 30 | result = pmv_ppd_iso(**inputs) 31 | 32 | validate_result(result, outputs, tolerance) 33 | 34 | 35 | class TestPmvPpd: 36 | """Test cases for the PMV and PPD model.""" 37 | 38 | def test_returns_nan_for_invalid_input_values(self) -> None: 39 | """Test that the function returns NaN for invalid input values.""" 40 | # Arrange 41 | tdb = [25, 50] 42 | tr = [23, 45] 43 | vr = [0.5, 3] 44 | rh = [50, 80] 45 | met = [1.2, 2.5] 46 | clo = [0.5, 1.8] 47 | 48 | # Act 49 | result = pmv_ppd_iso( 50 | tdb, 51 | tr, 52 | vr, 53 | rh, 54 | met, 55 | clo, 56 | model=Models.iso_7730_2005.value, 57 | ) 58 | 59 | # Assert 60 | assert math.isnan(result.pmv[1]) 61 | assert math.isnan(result.ppd[1]) 62 | 63 | assert ( 64 | round( 65 | pmv_ppd_iso( 66 | 67.28, 67 | 67.28, 68 | 0.328084, 69 | 86, 70 | 1.1, 71 | 1, 72 | units=Units.IP.value, 73 | model=Models.iso_7730_2005.value, 74 | ).pmv, 75 | 1, 76 | ) 77 | ) == -0.5 78 | 79 | np.testing.assert_equal( 80 | np.around( 81 | pmv_ppd_iso( 82 | [70, 70], 83 | 67.28, 84 | 0.328084, 85 | 86, 86 | 1.1, 87 | 1, 88 | units=Units.IP.value, 89 | model=Models.iso_7730_2005.value, 90 | ).pmv, 91 | 1, 92 | ), 93 | [-0.3, -0.3], 94 | ) 95 | 96 | # checking that returns np.nan when outside standard applicability limits 97 | np.testing.assert_equal( 98 | pmv_ppd_iso( 99 | [31, 20, 20, 20, 20, 30], 100 | [20, 41, 20, 20, 20, 20], 101 | [0.1, 0.1, 2, 0.1, 0.1, 0.1], 102 | 50, 103 | [1.1, 1.1, 1.1, 0.7, 1.1, 4.1], 104 | [0.5, 0.5, 0.5, 0.5, 2.1, 0.1], 105 | model=Models.iso_7730_2005.value, 106 | ).pmv, 107 | np.asarray([np.nan, np.nan, np.nan, np.nan, np.nan, np.nan]), 108 | ) 109 | 110 | # check results with limit_inputs disabled 111 | np.testing.assert_equal( 112 | pmv_ppd_iso( 113 | 31, 114 | 41, 115 | 2, 116 | 50, 117 | 0.7, 118 | 2.1, 119 | model=Models.iso_7730_2005.value, 120 | limit_inputs=False, 121 | ), 122 | PMVPPD(pmv=np.float64(2.4), ppd=np.float64(91.0), tsv="Warm"), 123 | ) 124 | 125 | def test_wrong_standard(self) -> None: 126 | """Test that the function raises ValueError for an unsupported standard.""" 127 | with pytest.raises(ValueError): 128 | pmv_ppd_iso(25, 25, 0.1, 50, 1.1, 0.5, model="random") 129 | 130 | def test_no_rounding(self) -> None: 131 | """Test that the function calculates PMV and PPD without rounding.""" 132 | np.isclose( 133 | pmv_ppd_iso( 134 | 25, 135 | 25, 136 | 0.1, 137 | 50, 138 | 1.1, 139 | 0.5, 140 | round_output=False, 141 | model=Models.iso_7730_2005.value, 142 | ).pmv, 143 | -0.13201636, 144 | atol=0.01, 145 | ) 146 | -------------------------------------------------------------------------------- /tests/test_psychrometrics.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.utilities import ( 5 | dew_point_tmp, 6 | enthalpy_air, 7 | mean_radiant_tmp, 8 | operative_tmp, 9 | p_sat, 10 | psy_ta_rh, 11 | wet_bulb_tmp, 12 | ) 13 | from tests.conftest import is_equal 14 | 15 | 16 | def test_t_dp() -> None: 17 | """Test the dew point temperature function with various inputs.""" 18 | # Scalar inputs 19 | assert dew_point_tmp(31.6, 59.6) == pytest.approx(22.778, abs=1e-1) 20 | assert dew_point_tmp(29.3, 75.4) == pytest.approx(24.497, abs=1e-1) 21 | assert dew_point_tmp(27.1, 66.4) == pytest.approx(20.302, abs=1e-1) 22 | 23 | # Edge cases 24 | # rh = 100%: dew point should equal tdb 25 | assert dew_point_tmp(25.0, 100.0) == pytest.approx(25.0, abs=1e-1) 26 | # rh = 0%: dew point should be very low (approaching -inf, but np.nan due to log(0)) 27 | result_rh0 = dew_point_tmp(25.0, 0.0) 28 | assert np.isnan(result_rh0) # Should be NaN due to log(0) 29 | 30 | # Array inputs 31 | tdb_array = [31.6, 29.3, 27.1] 32 | rh_array = [59.6, 75.4, 66.4] 33 | expected = [22.778, 24.497, 20.302] 34 | result_array = dew_point_tmp(tdb_array, rh_array) 35 | np.testing.assert_allclose(result_array, expected, atol=1e-1) 36 | 37 | # Broadcasting: tdb as array, rh as scalar 38 | tdb_array2 = [31.6, 29.3] 39 | rh_scalar = 59.6 40 | expected_broadcast = [22.778, 20.625] # Same rh for both 41 | result_broadcast = dew_point_tmp(tdb_array2, rh_scalar) 42 | np.testing.assert_allclose(result_broadcast, expected_broadcast, atol=1e-1) 43 | 44 | # Numpy array inputs 45 | tdb_np = np.array([31.6, 29.3]) 46 | rh_np = np.array([59.6, 75.4]) 47 | expected_np = np.array([22.778, 24.497]) 48 | result_np = dew_point_tmp(tdb_np, rh_np) 49 | np.testing.assert_allclose(result_np, expected_np, atol=1e-1) 50 | 51 | # More temperature ranges 52 | assert dew_point_tmp(10.0, 50.0) == pytest.approx(0.064, abs=1e-1) # Low temp 53 | assert dew_point_tmp(40.0, 50.0) == pytest.approx(27.587, abs=1e-1) # High temp 54 | 55 | 56 | def test_t_dp_invalid_rh() -> None: 57 | """Test that dew_point_tmp raises ValueError for invalid relative humidity.""" 58 | with pytest.raises(ValueError): 59 | dew_point_tmp(25, 110) 60 | with pytest.raises(ValueError): 61 | dew_point_tmp(25, -10) 62 | 63 | 64 | def test_t_wb() -> None: 65 | """Test the wet bulb temperature function with various inputs.""" 66 | assert wet_bulb_tmp(27.1, 66.4) == pytest.approx(22.4, abs=1e-1) 67 | assert wet_bulb_tmp(25, 50) == pytest.approx(18.0, abs=1e-1) 68 | 69 | 70 | def test_enthalpy() -> None: 71 | """Test the enthalpy function with various inputs.""" 72 | assert is_equal(enthalpy_air(25, 0.01), 50561.25, 0.1) 73 | assert is_equal(enthalpy_air(27.1, 0.01), 52707.56, 0.1) 74 | 75 | 76 | def test_psy_ta_rh() -> None: 77 | """Test the psychrometric function with various inputs.""" 78 | results = psy_ta_rh(25, 50, p_atm=101325) 79 | assert results.p_sat == pytest.approx(3169.2, abs=1e-1) 80 | assert results.p_vap == pytest.approx(1584.6, abs=1e-1) 81 | assert results.hr == pytest.approx(0.00988, abs=1e-3) 82 | assert results.wet_bulb_tmp == pytest.approx(18.0, abs=1e-1) 83 | assert results.dew_point_tmp == pytest.approx(13.8, abs=1e-1) 84 | assert results.h == pytest.approx(50259.79, abs=1e-1) 85 | 86 | 87 | def test_t_o() -> None: 88 | """Test the operative temperature function with various inputs.""" 89 | assert operative_tmp(25, 25, 0.1) == 25 90 | np.allclose( 91 | operative_tmp([25, 20], 30, 0.3), 92 | [26.83, 23.66], 93 | atol=1e-2, 94 | ) 95 | assert operative_tmp(25, 25, 0.1, standard="ASHRAE") == 25 96 | assert operative_tmp(20, 30, 0.1, standard="ASHRAE") == 25 97 | assert operative_tmp(20, 30, 0.3, standard="ASHRAE") == 24 98 | assert operative_tmp(20, 30, 0.7, standard="ASHRAE") == 23 99 | 100 | 101 | def test_p_sat() -> None: 102 | """Test the saturation pressure function with various inputs.""" 103 | assert pytest.approx(p_sat(tdb=25), abs=1e-1) == 3169.2 104 | assert pytest.approx(p_sat(tdb=50), abs=1e-1) == 12349.9 105 | 106 | 107 | def test_t_mrt() -> None: 108 | """Test the mean radiant temperature function with various inputs.""" 109 | np.allclose( 110 | mean_radiant_tmp( 111 | tg=[53.2, 55, 55], 112 | tdb=30, 113 | v=[0.3, 0.3, 0.1], 114 | d=0.1, 115 | standard="ISO", 116 | ), 117 | [74.8, 77.8, 71.9], 118 | atol=1e-1, 119 | ) 120 | np.allclose( 121 | mean_radiant_tmp( 122 | tg=[25.42, 26.42, 26.42, 26.42], 123 | tdb=26.10, 124 | v=0.1931, 125 | d=[0.1, 0.1, 0.5, 0.03], 126 | standard="Mixed Convection", 127 | ), 128 | [24.2, 27.0, np.nan, np.nan], 129 | atol=1e-1, 130 | ) 131 | -------------------------------------------------------------------------------- /pythermalcomfort/models/pmv_athb.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import ATHBInputs 6 | from pythermalcomfort.classes_return import ATHB 7 | from pythermalcomfort.models._pmv_ppd_optimized import _pmv_ppd_optimized 8 | from pythermalcomfort.utilities import met_to_w_m2 9 | 10 | 11 | def pmv_athb( 12 | tdb: float | list[float], 13 | tr: float | list[float], 14 | vr: float | list[float], 15 | rh: float | list[float], 16 | met: float | list[float], 17 | t_running_mean: float | list[float], 18 | clo: bool | float | list[float] = False, 19 | ) -> ATHB: 20 | """Return the PMV value calculated with the Adaptive Thermal Heat Balance Framework 21 | [Schweiker2022]_. The adaptive thermal heat balance (ATHB) framework introduced a 22 | method to account for the three adaptive principals, namely physiological, 23 | behavioral, and psychological adaptation, individually within existing heat balance 24 | models. The objective is a predictive model of thermal sensation applicable during 25 | the design stage or in international standards without knowing characteristics of 26 | future occupants. 27 | 28 | Parameters 29 | ---------- 30 | tdb : float or list of floats 31 | Dry bulb air temperature, in [°C]. 32 | tr : float or list of floats 33 | Mean radiant temperature, in [°C]. 34 | vr : float or list of floats 35 | Relative air speed, in [m/s]. 36 | 37 | .. note:: 38 | vr is the relative air speed caused by body movement and not the air 39 | speed measured by the air speed sensor. The relative air speed is the sum of the 40 | average air speed measured by the sensor plus the activity-generated air speed 41 | (Vag). Where Vag is the activity-generated air speed caused by motion of 42 | individual body parts. vr can be calculated using the function 43 | :py:meth:`pythermalcomfort.utilities.v_relative`. 44 | 45 | rh : float or list of floats 46 | Relative humidity, [%]. 47 | met : float or list of floats 48 | Metabolic rate, [met]. 49 | clo : bool, float or list of floats, optional 50 | Clothing insulation, in [clo]. If `clo` is set to False, the clothing insulation 51 | value will be calculated using the equation provided in the paper. Defaults to False. 52 | t_running_mean: float or list of floats 53 | Running mean temperature, in [°C]. 54 | 55 | .. note:: 56 | The running mean temperature can be calculated using the function 57 | :py:meth:`pythermalcomfort.utilities.running_mean_outdoor_temperature`. 58 | 59 | Returns 60 | ------- 61 | ATHB 62 | Dataclass containing the results of the ATHB calculation. See :py:class:`~pythermalcomfort.classes_return.ATHB` for more details. 63 | 64 | Examples 65 | -------- 66 | .. code-block:: python 67 | 68 | from pythermalcomfort.models import pmv_athb 69 | 70 | # calculate the predicted mean vote (PMV) using the Adaptive Thermal Heat Balance model 71 | results = pmv_athb(tdb=25, tr=25, vr=0.1, rh=50, met=1.2, t_running_mean=20) 72 | print(results.athb_pmv) # returns the PMV value 73 | 74 | # for multiple points 75 | results = pmv_athb( 76 | tdb=[25, 25, 25], 77 | tr=[25, 25, 25], 78 | vr=[0.1, 0.1, 0.1], 79 | rh=[50, 50, 50], 80 | met=[1.2, 1.2, 1.2], 81 | t_running_mean=[20, 20, 20], 82 | ) 83 | print(results.athb_pmv) 84 | """ 85 | # Validate inputs using the ATHBInputs class 86 | ATHBInputs(tdb=tdb, tr=tr, vr=vr, rh=rh, met=met, t_running_mean=t_running_mean) 87 | 88 | tdb = np.asarray(tdb) 89 | tr = np.asarray(tr) 90 | vr = np.asarray(vr) 91 | met = np.asarray(met) 92 | rh = np.asarray(rh) 93 | t_running_mean = np.asarray(t_running_mean) 94 | 95 | met_adapted = met - (0.234 * t_running_mean) / 58.2 96 | 97 | clo_adapted = clo 98 | if clo is False: 99 | # Adapted clothing insulation level through behavioral adaptation 100 | clo_adapted = np.power( 101 | 10, 102 | ( 103 | -0.17168 104 | - 0.000485 * t_running_mean 105 | + 0.08176 * met_adapted 106 | - 0.00527 * t_running_mean * met_adapted 107 | ), 108 | ) 109 | 110 | pmv_res = _pmv_ppd_optimized(tdb, tr, vr, rh, met_adapted, clo_adapted, 0) 111 | ts = 0.303 * np.exp(-0.036 * met_adapted * met_to_w_m2) + 0.028 112 | l_adapted = pmv_res / ts 113 | 114 | # Predicted thermal sensation vote 115 | athb_pmv = np.around( 116 | 1.484 117 | + 0.0276 * l_adapted 118 | - 0.9602 * met_adapted 119 | - 0.0342 * t_running_mean 120 | + 0.0002264 * l_adapted * t_running_mean 121 | + 0.018696 * met_adapted * t_running_mean 122 | - 0.0002909 * l_adapted * met_adapted * t_running_mean, 123 | 3, 124 | ) 125 | 126 | return ATHB(athb_pmv=athb_pmv) 127 | -------------------------------------------------------------------------------- /pythermalcomfort/models/ankle_draft.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import AnkleDraftInputs 6 | from pythermalcomfort.classes_return import AnkleDraft 7 | from pythermalcomfort.models.pmv_ppd_ashrae import pmv_ppd_ashrae 8 | from pythermalcomfort.utilities import ( 9 | Models, 10 | Units, 11 | _check_standard_compliance_array, 12 | units_converter, 13 | ) 14 | 15 | 16 | def ankle_draft( 17 | tdb: float | list[float], 18 | tr: float | list[float], 19 | vr: float | list[float], 20 | rh: float | list[float], 21 | met: float | list[float], 22 | clo: float | list[float], 23 | v_ankle: float | list[float], 24 | units: str = Units.SI.value, 25 | ) -> AnkleDraft: 26 | """Calculate the percentage of thermally dissatisfied people with the ankle draft 27 | (0.1 m) above floor level [Liu2017]_. 28 | 29 | This equation is only applicable for vr < 0.2 m/s (40 fps). 30 | 31 | Parameters 32 | ---------- 33 | tdb : float or list of floats 34 | Dry bulb air temperature, default in [°C] or [°F] if `units` = 'IP'. 35 | 36 | .. note:: 37 | The air temperature is the average value over two heights: 0.6 m (24 in.) 38 | and 1.1 m (43 in.) for seated occupants, and 1.1 m (43 in.) and 1.7 m (67 in.) for standing occupants. 39 | 40 | tr : float or list of floats 41 | Mean radiant temperature, default in [°C] or [°F] if `units` = 'IP'. 42 | 43 | vr : float or list of floats 44 | Relative air speed, default in [m/s] or [fps] if `units` = 'IP'. 45 | 46 | .. note:: 47 | `vr` is the relative air speed caused by body movement and not the air speed measured by the air speed sensor. 48 | The relative air speed is the sum of the average air speed measured by the sensor plus the activity-generated air speed (Vag). 49 | Vag is the activity-generated air speed caused by motion of individual body parts. 50 | `vr` can be calculated using the function :py:meth:`pythermalcomfort.utilities.v_relative`. 51 | 52 | rh : float or list of floats 53 | Relative humidity, [%]. 54 | 55 | met : float or list of floats 56 | Metabolic rate, [met]. 57 | 58 | clo : float or list of floats 59 | Clothing insulation, [clo]. 60 | 61 | .. note:: 62 | this is the basic insulation also known as the intrinsic clothing insulation value of the 63 | clothing ensemble (`I`:sub:`cl,r`), this is the thermal insulation from the skin 64 | surface to the outer clothing surface, including enclosed air layers, under actual 65 | environmental conditions. This value is not the total insulation (`I`:sub:`T,r`). 66 | The dynamic clothing insulation, clo, can be calculated using the function 67 | :py:meth:`pythermalcomfort.utilities.clo_dynamic_ashrae`. 68 | 69 | v_ankle : float or list of floats 70 | Air speed at 0.1 m (4 in.) above the floor, default in [m/s] or [fps] if `units` = 'IP'. 71 | 72 | units : {'SI', 'IP'} 73 | Select the SI (International System of Units) or the IP (Imperial Units) system. 74 | 75 | Returns 76 | ------- 77 | AnkleDraft 78 | Dataclass containing the results of the ankle draft calculation. See :py:class:`~pythermalcomfort.classes_return.AnkleDraft` for more details. 79 | 80 | Examples 81 | -------- 82 | .. code-block:: python 83 | 84 | from pythermalcomfort.models import ankle_draft 85 | 86 | results = ankle_draft(25, 25, 0.2, 50, 1.2, 0.5, 0.3, units="SI") 87 | print(results) 88 | # AnkleDraft(ppd_ad=18.5, acceptability=True) 89 | """ 90 | # Validate inputs using the AnkleDraftInputs class 91 | AnkleDraftInputs( 92 | tdb=tdb, 93 | tr=tr, 94 | vr=vr, 95 | rh=rh, 96 | met=met, 97 | clo=clo, 98 | v_ankle=v_ankle, 99 | units=units, 100 | ) 101 | 102 | # Convert lists to numpy arrays 103 | tdb = np.asarray(tdb) 104 | tr = np.asarray(tr) 105 | vr = np.asarray(vr) 106 | rh = np.asarray(rh) 107 | met = np.asarray(met) 108 | clo = np.asarray(clo) 109 | v_ankle = np.asarray(v_ankle) 110 | 111 | if units.upper() == Units.IP.value: 112 | tdb, tr, vr, v_ankle = units_converter(tdb=tdb, tr=tr, vr=vr, vel=v_ankle) 113 | 114 | tdb_valid, tr_valid, v_valid, v_limited = _check_standard_compliance_array( 115 | standard=Models.ashrae_55_2023.value, 116 | tdb=tdb, 117 | tr=tr, 118 | v_limited=vr, 119 | v=vr, 120 | ) 121 | 122 | if np.all(np.isnan(v_limited)): 123 | raise ValueError( 124 | "This equation is only applicable for air speed lower than 0.2 m/s", 125 | ) 126 | 127 | tsv = pmv_ppd_ashrae( 128 | tdb, 129 | tr, 130 | vr, 131 | rh, 132 | met, 133 | clo, 134 | model=Models.ashrae_55_2023.value, 135 | ).pmv 136 | ppd_val = np.around( 137 | np.exp(-2.58 + 3.05 * v_ankle - 1.06 * tsv) 138 | / (1 + np.exp(-2.58 + 3.05 * v_ankle - 1.06 * tsv)) 139 | * 100, 140 | 1, 141 | ) 142 | acceptability = ppd_val <= 20 143 | return AnkleDraft(ppd_ad=ppd_val, acceptability=acceptability) 144 | -------------------------------------------------------------------------------- /tests/test_scale_wind_speed_log.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from pythermalcomfort.utils import scale_wind_speed_log 5 | 6 | 7 | def test_compare_results_wind_profile_calculator() -> None: 8 | """Compare results with Wind Profile Calculator online tool. 9 | 10 | Reference: 11 | https://wind-data.ch/tools/profile.php?h=2&v=10&z0=0.01&abfrage=Refresh 12 | """ 13 | v10 = 6.52 # m/s 14 | z1 = 10 # m 15 | z2 = 2.0 # m 16 | z0 = 0.01 # m 17 | expected = 5 # m/s from Wind Profile Calculator 18 | result = scale_wind_speed_log(v_z1=v10, z2=z2, z1=z1, z0=z0, round_output=False) 19 | print(result) 20 | assert np.allclose(result.v_z2, expected, rtol=1e-2) 21 | 22 | v10 = 7.69 # m/s 23 | z2 = 2.0 # m 24 | z0 = 0.1 # m 25 | expected = 5 # m/s from Wind Profile Calculator 26 | result = scale_wind_speed_log(v_z1=v10, z2=z2, z1=10, z0=z0, round_output=False) 27 | print(result) 28 | assert np.allclose(result.v_z2, expected, rtol=1e-2) 29 | 30 | v_z1 = 5.0 # m/s 31 | z2 = 90.0 # m 32 | z0 = 0.1 # m 33 | expected = 7.39 # m/s from Wind Profile Calculator 34 | result = scale_wind_speed_log(v_z1=v_z1, z2=z2, z1=10, z0=z0, round_output=False) 35 | print(result) 36 | assert np.allclose(result.v_z2, expected, rtol=1e-2) 37 | 38 | 39 | def test_scale_winds_speed_scalar() -> None: 40 | """Test scaling wind speed from 10m to 2m (scalar inputs).""" 41 | v10 = 5.0 42 | z2 = 2.0 43 | expected = v10 * np.log((z2 - 0.0) / 0.01) / np.log((10.0 - 0.0) / 0.01) 44 | result = scale_wind_speed_log(v10, z2, round_output=False) 45 | assert np.allclose(result.v_z2, expected, rtol=1e-5) 46 | 47 | 48 | def test_scale_winds_speed_array() -> None: 49 | """Test scaling wind speed for array inputs.""" 50 | v10 = np.asarray([3.0, 5.0]) 51 | z2 = np.asarray([1.5, 2.5]) 52 | expected = v10 * np.log((z2 - 0.0) / 0.01) / np.log((10.0 - 0.0) / 0.01) 53 | result = scale_wind_speed_log(v10, z2, round_output=False) 54 | assert np.allclose(result.v_z2, expected, rtol=1e-5) 55 | 56 | 57 | def test_scale_wind_speed_broadcasting() -> None: 58 | """Test broadcasting with different z0 for each measurement.""" 59 | v10 = [3.0, 5.0] 60 | z2 = [1.5, 2.5] 61 | z0 = [0.01, 0.1] 62 | expected = np.asarray( 63 | [ 64 | 3.0 * np.log((1.5 - 0.0) / 0.01) / np.log((10.0 - 0.0) / 0.01), 65 | 5.0 * np.log((2.5 - 0.0) / 0.1) / np.log((10.0 - 0.0) / 0.1), 66 | ] 67 | ) 68 | result = scale_wind_speed_log(v10, z2, z0=z0, round_output=False) 69 | assert np.allclose(result.v_z2, expected, rtol=1e-5) 70 | 71 | 72 | def test_scale_winds_speed_with_displacement() -> None: 73 | """Test with nonzero displacement height d.""" 74 | v10 = 5.0 75 | z2 = 2.0 76 | z1 = 10.0 77 | z0 = 0.1 78 | d = 0.5 79 | expected = v10 * np.log((z2 - d) / z0) / np.log((z1 - d) / z0) 80 | result = scale_wind_speed_log(v10, z2, z1=z1, z0=z0, d=d, round_output=False) 81 | assert np.allclose(result.v_z2, expected, rtol=1e-5) 82 | 83 | 84 | def test_invalid_types() -> None: 85 | """Test that invalid types raise TypeError.""" 86 | with pytest.raises(TypeError): 87 | scale_wind_speed_log("bad", 2.0) 88 | with pytest.raises(TypeError): 89 | scale_wind_speed_log(5.0, "bad") 90 | with pytest.raises(TypeError): 91 | scale_wind_speed_log(5.0, 2.0, z0="bad") 92 | 93 | 94 | def test_negative_and_zero_values() -> None: 95 | """Test that negative and zero values raise ValueError.""" 96 | # Negative wind speed 97 | with pytest.raises(ValueError): 98 | scale_wind_speed_log(-1.0, 2.0, round_output=False) 99 | # Negative z2 100 | with pytest.raises(ValueError): 101 | scale_wind_speed_log(5.0, -2.0, round_output=False) 102 | # z0 <= 0 103 | with pytest.raises(ValueError): 104 | scale_wind_speed_log(5.0, 2.0, z0=0.0, round_output=False) 105 | with pytest.raises(ValueError): 106 | scale_wind_speed_log(5.0, 2.0, z0=-0.1, round_output=False) 107 | # z2 <= d 108 | with pytest.raises(ValueError): 109 | scale_wind_speed_log(5.0, 2.0, d=2.0, round_output=False) 110 | # z1 <= d 111 | with pytest.raises(ValueError): 112 | scale_wind_speed_log(5.0, 2.0, z1=1.0, d=1.0, round_output=False) 113 | # z2 <= z0 114 | with pytest.raises(ValueError): 115 | scale_wind_speed_log(5.0, 0.01, z0=0.01, round_output=False) 116 | # z1 <= z0 117 | with pytest.raises(ValueError): 118 | scale_wind_speed_log(5.0, 2.0, z1=0.01, z0=0.01, round_output=False) 119 | 120 | 121 | def test_edge_case_z2_less_than_z1() -> None: 122 | """Test scaling when z2 < z1 (should still work if all constraints are met).""" 123 | v10 = 5.0 124 | z2 = 2.0 125 | z1 = 10.0 126 | result = scale_wind_speed_log(v10, z2, z1=z1, round_output=False) 127 | assert result.v_z2 < v10 128 | 129 | 130 | def test_large_and_small_z0() -> None: 131 | """Test with very large and very small roughness lengths.""" 132 | v10 = 5.0 133 | z2 = 2.0 134 | # Very small z0 135 | result_small = scale_wind_speed_log(v10, z2, z0=1e-6, round_output=False) 136 | assert result_small.v_z2 > 0 137 | # Very large z0 (should be close to zero wind speed) 138 | result_large = scale_wind_speed_log(v10, z2, z0=1.0, round_output=False) 139 | assert result_large.v_z2 > 0 140 | -------------------------------------------------------------------------------- /pythermalcomfort/models/set_tmp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import SETInputs 6 | from pythermalcomfort.classes_return import SET 7 | from pythermalcomfort.models.two_nodes_gagge import two_nodes_gagge 8 | from pythermalcomfort.utilities import ( 9 | Models, 10 | Postures, 11 | _check_standard_compliance_array, 12 | ) 13 | 14 | 15 | def set_tmp( 16 | tdb: float | list[float], 17 | tr: float | list[float], 18 | v: float | list[float], 19 | rh: float | list[float], 20 | met: float | list[float], 21 | clo: float | list[float], 22 | wme: float | list[float] = 0.0, 23 | body_surface_area: float | list[float] = 1.8258, 24 | p_atm: float | list[float] = 101325, 25 | position: str = Postures.standing.value, 26 | limit_inputs: bool = True, 27 | round_output: bool = True, 28 | calculate_ce: bool = False, 29 | ) -> SET: 30 | """Calculate the Standard Effective Temperature (SET). 31 | 32 | The SET is the 33 | temperature of a hypothetical isothermal environment at 50% (rh), <0.1 m/s 34 | (20 fpm) average air speed (v), and tr = tdb, in which the total heat loss 35 | from the skin of an imaginary occupant wearing clothing, standardized for 36 | the activity concerned is the same as that from a person in the actual 37 | environment with actual clothing and activity level. [Gagge1986]_ 38 | 39 | Parameters 40 | ---------- 41 | tdb : float or list of floats 42 | Dry bulb air temperature, [°C]. 43 | tr : float or list of floats 44 | Mean radiant temperature, [°C]. 45 | v : float or list of floats 46 | Air speed, [m/s]. 47 | rh : float or list of floats 48 | Relative humidity, [%]. 49 | met : float or list of floats 50 | Metabolic rate, [met]. 51 | clo : float or list of floats 52 | Clothing insulation, [clo]. 53 | wme : float or list of floats, optional 54 | External work, [met]. Defaults to 0. 55 | body_surface_area : float or list of floats, optional 56 | Body surface area, default value 1.8258 [m2] 57 | 58 | .. note:: 59 | The body surface area can be calculated using the function 60 | :py:meth:`pythermalcomfort.utilities.body_surface_area`. 61 | 62 | p_atm : float or list of floats, optional 63 | Atmospheric pressure, default value 101325 [Pa] 64 | position : str, optional 65 | Select either "sitting" or "standing". Defaults to "standing". 66 | limit_inputs : bool, optional 67 | If True, limits the inputs to the standard applicability limits. Defaults to True. 68 | 69 | .. note:: 70 | By default, if the inputs are outside the standard applicability limits the 71 | function returns nan. If False returns values regardless of the input values. 72 | The limits are 10 < tdb [°C] < 40, 10 < tr [°C] < 40, 73 | 0 < v [m/s] < 2, 1 < met [met] < 4, and 0 < clo [clo] < 1.5. 74 | round_output : bool, optional 75 | If True, rounds output value. If False, it does not round it. Defaults to True. 76 | 77 | Returns 78 | ------- 79 | SET 80 | A dataclass containing the Standard Effective Temperature. See :py:class:`~pythermalcomfort.classes_return.SetTmp` for more details. 81 | To access the `set` value, use the corresponding attribute of the returned `SetTmp` instance, e.g., `result.set`. 82 | 83 | Examples 84 | -------- 85 | .. code-block:: python 86 | 87 | from pythermalcomfort.models import set_tmp 88 | 89 | result = set_tmp(tdb=25, tr=25, v=0.1, rh=50, met=1.2, clo=0.5) 90 | print(result.set) # 24.3 91 | 92 | result = set_tmp(tdb=[25, 25], tr=25, v=0.1, rh=50, met=1.2, clo=0.5) 93 | print(result.set) # [24.3, 24.3] 94 | """ 95 | tdb = np.asarray(tdb) 96 | tr = np.asarray(tr) 97 | v = np.asarray(v) 98 | rh = np.asarray(rh) 99 | met = np.asarray(met) 100 | clo = np.asarray(clo) 101 | wme = np.asarray(wme) 102 | 103 | # Validate inputs using the SetTmpInputs class 104 | SETInputs( 105 | tdb=tdb, 106 | tr=tr, 107 | v=v, 108 | rh=rh, 109 | met=met, 110 | clo=clo, 111 | wme=wme, 112 | body_surface_area=body_surface_area, 113 | p_atm=p_atm, 114 | position=position, 115 | limit_inputs=limit_inputs, 116 | ) 117 | 118 | set_array = two_nodes_gagge( 119 | tdb=tdb, 120 | tr=tr, 121 | v=v, 122 | rh=rh, 123 | met=met, 124 | clo=clo, 125 | wme=wme, 126 | body_surface_area=body_surface_area, 127 | p_atm=p_atm, 128 | position=position, 129 | calculate_ce=calculate_ce, 130 | round_output=False, 131 | ).set 132 | 133 | if limit_inputs: 134 | ( 135 | tdb_valid, 136 | tr_valid, 137 | v_valid, 138 | met_valid, 139 | clo_valid, 140 | ) = _check_standard_compliance_array( 141 | standard=Models.ashrae_55_2023.value, 142 | tdb=tdb, 143 | tr=tr, 144 | v=v, 145 | met=met, 146 | clo=clo, 147 | ) 148 | all_valid = ~( 149 | np.isnan(tdb_valid) 150 | | np.isnan(tr_valid) 151 | | np.isnan(v_valid) 152 | | np.isnan(met_valid) 153 | | np.isnan(clo_valid) 154 | ) 155 | set_array = np.where(all_valid, set_array, np.nan) 156 | 157 | if round_output: 158 | set_array = np.around(set_array, 1) 159 | 160 | return SET(set=set_array) 161 | -------------------------------------------------------------------------------- /pythermalcomfort/models/vertical_tmp_grad_ppd.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from pythermalcomfort.classes_input import VerticalTGradPPDInputs 6 | from pythermalcomfort.classes_return import VerticalTGradPPD 7 | from pythermalcomfort.models import pmv_ppd_ashrae 8 | from pythermalcomfort.utilities import Models, _check_standard_compliance_array 9 | 10 | 11 | def vertical_tmp_grad_ppd( 12 | tdb: float | list[float], 13 | tr: float | list[float], 14 | vr: float | list[float], 15 | rh: float | list[float], 16 | met: float | list[float], 17 | clo: float | list[float], 18 | vertical_tmp_grad: float | list[float], 19 | round_output: bool = True, 20 | ) -> VerticalTGradPPD: 21 | """Calculate the percentage of thermally dissatisfied people with a vertical 22 | temperature gradient between feet and head [55ASHRAE2023]_. This equation is only 23 | applicable for vr < 0.2 m/s (40 fps). 24 | 25 | Parameters 26 | ---------- 27 | tdb : float or list of floats 28 | Dry bulb air temperature, [°C]. 29 | 30 | .. note:: 31 | The air temperature is the average value over two heights: 0.6 m (24 in.) 32 | and 1.1 m (43 in.) for seated occupants 33 | and 1.1 m (43 in.) and 1.7 m (67 in.) for standing occupants. 34 | 35 | tr : float or list of floats 36 | Mean radiant temperature, [°C]. 37 | vr : float or list of floats 38 | Relative air speed, [m/s]. 39 | 40 | .. note:: 41 | vr is the relative air speed caused by body movement and not the air 42 | speed measured by the air speed sensor. The relative air speed is the sum of the 43 | average air speed measured by the sensor plus the activity-generated air speed 44 | (Vag). Where Vag is the activity-generated air speed caused by motion of 45 | individual body parts. vr can be calculated using the function 46 | :py:meth:`pythermalcomfort.utilities.v_relative`. 47 | 48 | rh : float or list of floats 49 | Relative humidity, [%]. 50 | met : float or list of floats 51 | Metabolic rate, [met]. 52 | clo : float or list of floats 53 | Clothing insulation, [clo]. 54 | 55 | .. note:: 56 | this is the basic insulation also known as the intrinsic clothing insulation value of the 57 | clothing ensemble (`I`:sub:`cl,r`), this is the thermal insulation from the skin 58 | surface to the outer clothing surface, including enclosed air layers, under actual 59 | environmental conditions. This value is not the total insulation (`I`:sub:`T,r`). 60 | The dynamic clothing insulation, clo, can be calculated using the function 61 | :py:meth:`pythermalcomfort.utilities.clo_dynamic_ashrae`. 62 | 63 | vertical_tmp_grad : float or list of floats 64 | Vertical temperature gradient between the feet and the head, [°C/m]. 65 | round_output : bool, optional 66 | If True, rounds output value. If False, it does not round it. Defaults to True. 67 | 68 | Returns 69 | ------- 70 | VerticalTGradPPD 71 | A dataclass containing the Predicted Percentage of Dissatisfied occupants with vertical temperature gradient and acceptability. 72 | See :py:class:`~pythermalcomfort.classes_return.VerticalTmpGradPPD` for more details. 73 | To access the `ppd_vg` and `acceptability` values, use the corresponding attributes of the returned `VerticalTmpGradPPD` instance, e.g., `result.ppd_vg`. 74 | 75 | Examples 76 | -------- 77 | .. code-block:: python 78 | 79 | from pythermalcomfort.models import vertical_tmp_grad_ppd 80 | 81 | result = vertical_tmp_grad_ppd( 82 | tdb=25, tr=25, vr=0.1, rh=50, met=1.2, clo=0.5, vertical_tmp_grad=7 83 | ) 84 | print(result.ppd_vg) # 12.6 85 | print(result.acceptability) # False 86 | """ 87 | # Validate inputs using the VerticalTmpGradPPDInputs class 88 | VerticalTGradPPDInputs( 89 | tdb=tdb, 90 | tr=tr, 91 | vr=vr, 92 | rh=rh, 93 | met=met, 94 | clo=clo, 95 | vertical_tmp_grad=vertical_tmp_grad, 96 | ) 97 | 98 | tdb = np.asarray(tdb) 99 | tr = np.asarray(tr) 100 | vr = np.asarray(vr) 101 | met = np.asarray(met) 102 | clo = np.asarray(clo) 103 | vertical_tmp_grad = np.asarray(vertical_tmp_grad) 104 | 105 | ( 106 | tdb_valid, 107 | tr_valid, 108 | v_valid, 109 | met_valid, 110 | clo_valid, 111 | ) = _check_standard_compliance_array( 112 | standard=Models.ashrae_55_2023.value, 113 | tdb=tdb, 114 | tr=tr, 115 | v_limited=vr, 116 | met=met, 117 | clo=clo, 118 | ) 119 | 120 | tsv = pmv_ppd_ashrae( 121 | tdb=tdb, 122 | tr=tr, 123 | vr=vr, 124 | rh=rh, 125 | met=met, 126 | clo=clo, 127 | model=Models.ashrae_55_2023.value, 128 | ).pmv 129 | numerator = np.exp(0.13 * (tsv - 1.91) ** 2 + 0.15 * vertical_tmp_grad - 1.6) 130 | ppd_val = (numerator / (1 + numerator) - 0.345) * 100 131 | acceptability = ppd_val <= 5 132 | 133 | if round_output: 134 | ppd_val = np.round(ppd_val, 1) 135 | 136 | all_valid = ~( 137 | np.isnan(tdb_valid) 138 | | np.isnan(tr_valid) 139 | | np.isnan(v_valid) 140 | | np.isnan(met_valid) 141 | | np.isnan(clo_valid) 142 | ) 143 | 144 | ppd_val = np.where(all_valid, ppd_val, np.nan) 145 | acceptability = np.where(all_valid, acceptability, np.nan) 146 | 147 | return VerticalTGradPPD(ppd_vg=ppd_val, acceptability=acceptability) 148 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | cbecomforttool@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | --------------------------------------------------------------------------------