├── .circleci └── config.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── TODO.md ├── ci ├── requirements-py310.yml ├── requirements-py311.yml ├── requirements-py38.yml └── requirements-py39.yml ├── docs ├── Makefile ├── build_doc.sh ├── conf.py ├── environment.yml ├── examples │ ├── Makefile │ ├── README.md │ ├── activate.ipynb │ ├── activate.rst │ ├── activate_files │ │ └── activate_13_0.png │ ├── basic_run.ipynb │ ├── basic_run.rst │ └── basic_run_files │ │ ├── basic_run_13_0.png │ │ └── basic_run_9_1.png ├── figs │ ├── model_example.png │ └── sample_script.png ├── index.rst ├── install.rst ├── parcel.rst ├── reference.rst ├── requirements.txt ├── sci_descr.rst └── templates │ └── class.rst ├── examples ├── simple.yml ├── simple_mono.yml └── simple_supersat.yml ├── pixi.lock ├── pyproject.toml ├── pyrcel ├── __init__.py ├── _parcel_aux_numba.py ├── activation.py ├── aerosol.py ├── constants.py ├── data │ └── std_atm.csv ├── distributions.py ├── driver.py ├── integrator.py ├── output.py ├── parcel.py ├── postprocess.py ├── scripts │ ├── __init__.py │ └── run_parcel.py ├── test │ ├── __init__.py │ ├── generate_data.py │ ├── pm_test.py │ ├── results.dict │ └── test_thermo.py ├── thermo.py ├── util.py └── vis.py └── requirements.txt /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build-python311: 4 | docker: 5 | - image: continuumio/miniconda3 6 | resource_class: large 7 | working_directory: ~/circleci-pyrcel311 8 | steps: 9 | - checkout 10 | # Download and cache dependencies 11 | - restore_cache: 12 | keys: 13 | - v2-dependencies 14 | - run: echo "Building Python 3.11 version..." 15 | - run: 16 | name: Create Conda Environment 17 | command: | 18 | conda env create -f ci/requirements-py311.yml 19 | - run: 20 | name: Install and Test Pyrcel Simulation 21 | command: | 22 | source activate pyrcel 23 | pip install -e . 24 | run_parcel examples/simple.yml 25 | 26 | build-python310: 27 | docker: 28 | - image: continuumio/miniconda3 29 | resource_class: large 30 | working_directory: ~/circleci-pyrcel310 31 | steps: 32 | - checkout 33 | # Download and cache dependencies 34 | - restore_cache: 35 | keys: 36 | - v2-dependencies 37 | - run: echo "Building Python 3.10 version..." 38 | - run: 39 | name: Create Conda Environment 40 | command: | 41 | conda env create -f ci/requirements-py310.yml 42 | - run: 43 | name: Install and Test Pyrcel Simulation 44 | command: | 45 | source activate pyrcel 46 | pip install -e . 47 | run_parcel examples/simple.yml 48 | 49 | build-python39: # required for runs that don't use workflows 50 | docker: 51 | - image: continuumio/miniconda3 52 | resource_class: large 53 | working_directory: ~/circleci-pyrcel39 54 | steps: 55 | - checkout 56 | # Download and cache dependencies 57 | - restore_cache: 58 | keys: 59 | - v2-dependencies 60 | - run: echo "Building Python 3.9 version..." 61 | - run: 62 | name: Create Conda Environment 63 | command: | 64 | conda env create -f ci/requirements-py39.yml 65 | - run: 66 | name: Install and Test Pyrcel Simulation 67 | command: | 68 | source activate pyrcel 69 | pip install -e . 70 | run_parcel examples/simple.yml 71 | 72 | build-python38: # required for runs that don't use workflows 73 | docker: 74 | - image: continuumio/miniconda3 75 | working_directory: ~/circleci-pyrcel38 76 | resource_class: large 77 | steps: 78 | - checkout 79 | # Download and cache dependencies 80 | - restore_cache: 81 | keys: 82 | - v2-dependencies 83 | - run: echo "Building Python 3.8 version..." 84 | - run: 85 | name: Create Conda Environment 86 | command: | 87 | conda env create -f ci/requirements-py38.yml 88 | - run: 89 | name: Install and Test Pyrcel Simulation 90 | command: | 91 | source activate pyrcel 92 | pip install -e . 93 | run_parcel examples/simple.yml 94 | 95 | workflows: 96 | version: 2 97 | build: 98 | jobs: 99 | - build-python311 100 | - build-python310 101 | - build-python39 102 | - build-python38 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled files 2 | ################ 3 | *.pyc 4 | *.so 5 | *.c 6 | *.h 7 | *.exe 8 | *.o 9 | *.mod 10 | build/ 11 | dist/ 12 | 13 | # IPython Notebook files 14 | ############################ 15 | .ipynb_checkpoints/ 16 | 17 | # Previously generated files 18 | ############################ 19 | *.pdf 20 | *.csv 21 | *.nc 22 | *.html 23 | pyrcel.egg-info 24 | 25 | # Documentation 26 | ############################ 27 | docs/_build/ 28 | docs/generated/ 29 | docs/examples/*.rst 30 | docs/examples/*files/ 31 | 32 | # Other 33 | ####### 34 | *~ 35 | *# 36 | parcel_model.egg-info 37 | .idea/* 38 | *.dat 39 | *.DS_Store 40 | # pixi environments 41 | .pixi 42 | *.egg-info 43 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "mambaforge-22.9" 12 | 13 | # Build documentation in the docs/ directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | conda: 18 | environment: docs/environment.yml -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Daniel Rothenberg, 2012-2019 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.md 2 | recursive-include doc * 3 | prune doc/_build 4 | global-exclude .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | pyrcel: cloud parcel model 2 | ========================== 3 | 4 | ![sample parcel model run](docs/figs/model_example.png) 5 | 6 | [![DOI](https://zenodo.org/badge/12927551.svg)](https://zenodo.org/badge/latestdoi/12927551)[![PyPI Version](https://badge.fury.io/py/pyrcel.svg)](https://badge.fury.io/py/pyrcel)[![CircleCI Build Status](https://circleci.com/gh/darothen/pyrcel/tree/master.svg?style=svg)](https://circleci.com/gh/darothen/pyrcel/tree/master)[![Documentation Status](https://readthedocs.org/projects/pyrcel/badge/?version=stable)](http://pyrcel.readthedocs.io/en/latest/index.html) 7 | 8 | 9 | This is an implementation of a simple, adiabatic cloud parcel model for use in 10 | aerosol-cloud interaction studies. [Rothenberg and Wang (2016)](http://journals.ametsoc.org/doi/full/10.1175/JAS-D-15-0223.1) discuss the model in detail and its improvements 11 | and changes over [Nenes et al (2001)][nenes2001]: 12 | 13 | * Implementation of κ-Köhler theory for condensation physics ([Petters and 14 | Kreidenweis, 2007)][pk2007] 15 | * Extension of model to handle arbitrary sectional representations of aerosol 16 | populations, based on user-controlled empirical or parameterized size distributions 17 | * Improved, modular numerical framework for integrating the model, including bindings 18 | to several different stiff integrators: 19 | - ~~`lsoda` - [scipy ODEINT wrapper](http://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.odeint.html)~~ 20 | - ~~`vode, lsode*, lsoda*` - ODEPACK via [odespy][hplgit]~~ 21 | - `cvode` - SUNDIALS via [Assimulo](http://www.jmodelica.org/assimulo_home/index.html#) 22 | 23 | among other details. It also includes a library of droplet activation routines and scripts/notebooks for evaluating those schemes against equivalent calculations done with the parcel model. 24 | 25 | > [!WARNING] 26 | > As of version 1.3, we no longer support any ODE solver backends other than `cvode`. 27 | > All publications using this model have used this backend, so users shouldn't expect 28 | > any inconsistencies with historical results. A future version is planned to add a new 29 | > suite of ODE solvers from the [diffrax][diffrax] toolkit. 30 | 31 | Updated code can be found the project [github repository](https://github.com/darothen/pyrcel). If you'd like to use this code or have any questions about it, please [contact the author][author_email]. In particular, if you use this code for research purposes, be sure to carefully read through the model and ensure that you have tweaked/configured it for your purposes (i.e., modifying the accomodation coefficient); other derived quantities). 32 | 33 | [Detailed documentation is available](http://pyrcel.readthedocs.org/en/latest/index.html), including a [scientific description](http://pyrcel.readthedocs.org/en/latest/sci_descr.html), [installation details](http://pyrcel.readthedocs.org/en/latest/install.html), and a [basic example](http://pyrcel.readthedocs.org/en/latest/examples/basic_run.html) which produces a figure like the plot at the top of this page. 34 | 35 | Quick Start 36 | ----------- 37 | 38 | As of February, 2025, we provide an ultra simple way to run `pyrcel` without any installation 39 | or setup using [`pixi`](https://pixi.sh/latest/). 40 | `pixi` is an all-in-one package management tool that makes handling complex environment 41 | setup and dependencies extremely easy. 42 | 43 | Clone or download this repo, then **cd** into the top-level folder from a terminal. 44 | From there, execute: 45 | 46 | ``` shell 47 | $ pixi run run_parcel examples/simple.yml 48 | ``` 49 | 50 | This will automatically prepare an environment with all of `pyrcel`'s dependencies installed, 51 | and then run an example model setup. 52 | The first time the model runs, it may take a few second after invoking the script; this is 53 | normal, and is just a side-effect of `numba` caching and pre-compiling some of the functions 54 | used to drive the parcel model simulation. 55 | 56 | > [!NOTE] 57 | > We provide `pixi` environments for Linux, MacOS (both Intel and Apple Silicon) and 58 | > Windows, but we have never tried to run the model on a Windows computer so your mileage 59 | > may vary. Contact the authors if you have any questions and we can try to support your 60 | > use case. 61 | 62 | Installation 63 | ------------ 64 | 65 | To get started with using `pyrcel`, complete the following steps: 66 | 67 | 1. Set up a new Python environment; we recommend using [mambaforge](https://conda-forge.org/miniforge/): 68 | 69 | ``` shell 70 | $ mamba create -n pyrcel_quick_start python=3.11 71 | ``` 72 | 73 | 2. Activate the new Python environment and install the model and its dependencies. If you install the published version from PyPi (_recommended_), then you also need to install [Assimulo](http://www.jmodelica.org/assimulo) using the Mamba package manager - but no other manual dependency installation is necessary: 74 | 75 | ``` shell 76 | $ mamba activate pyrcel_quick_start 77 | $ pip install pyrcel 78 | $ mamba install -c conda-forge assimulo 79 | ``` 80 | 81 | 3. Run a test simulation using the CLI tool and a sample YAML file from **pyrcel/examples/\*.yml** (you may want to clone the repository or download them locally): 82 | 83 | ``` shell 84 | $ run_parcel simple.yml 85 | ``` 86 | 87 | * Visualize the output NetCDF (should be in the directory you ran the CLI tool, at **output/simple.nc**) 88 | 89 | That's it! You should be able to import `pyrcel` into any script or program running in the 90 | environment you created. 91 | 92 | 93 | Requirements 94 | ------------ 95 | 96 | **Required** 97 | 98 | * Python >= 3.8 99 | * [numba](http://numba.pydata.org) 100 | * [NumPy](http://www.numpy.org) 101 | * [SciPy](http://www.scipy.org) 102 | * [pandas](http://pandas.pydata.org) 103 | * [xarray](http://xarray.pydata.org/en/stable/) 104 | * [PyYAML](http://pyyaml.org/) 105 | 106 | Additionally, the following packages are used for better numerics (ODE solving) 107 | 108 | * [Assimulo](http://www.jmodelica.org/assimulo) 109 | 110 | The easiest way to satisfy the basic requirements for building and running the 111 | model is to use the [Anaconda](http://continuum.io/downloads) scientific Python 112 | distribution. Alternatively, a 113 | [miniconda environment](http://conda.pydata.org/docs/using/envs.html) is 114 | provided to quickly set-up and get running the model. Assimulo's dependency on 115 | the SUNDIALS library makes it a little bit tougher to install in an automated 116 | fashion, so it has not been included in the automatic setup provided here; you 117 | should refer to [Assimulo's documentation](http://www.jmodelica.org/assimulo_home/installation.html) 118 | for more information on its installation process. Note that many components of 119 | the model and package can be used without Assimulo. 120 | 121 | Development 122 | ----------- 123 | 124 | [http://github.com/darothen/pyrcel]() 125 | 126 | Please fork this repository if you intend to develop the model further so that the 127 | code's provenance can be maintained. 128 | 129 | License / Usage 130 | --------------- 131 | 132 | [All scientific code should be licensed](http://www.astrobetter.com/the-whys-and-hows-of-licensing-scientific-code/). This code is released under the New BSD (3-clause) [license](LICENSE.md). 133 | 134 | You are free to use this code however you would like. 135 | If you use this for any scientific work resulting in a publication or citation, please 136 | cite our original publication detailing the model, and let the authors know: 137 | 138 | ``` 139 | @article { 140 | author = "Daniel Rothenberg and Chien Wang", 141 | title = "Metamodeling of Droplet Activation for Global Climate Models", 142 | journal = "Journal of the Atmospheric Sciences", 143 | year = "2016", 144 | publisher = "American Meteorological Society", 145 | address = "Boston MA, USA", 146 | volume = "73", 147 | number = "3", 148 | doi = "10.1175/JAS-D-15-0223.1", 149 | pages= "1255 - 1272", 150 | url = "https://journals.ametsoc.org/view/journals/atsc/73/3/jas-d-15-0223.1.xml" 151 | } 152 | ``` 153 | 154 | 155 | [author_email]: mailto:daniel@danielrothenberg.com 156 | [nenes2001]: http://nenes.eas.gatech.edu/Preprints/KinLimitations_TellusPP.pdf 157 | [pk2007]: http://www.atmos-chem-phys.net/7/1961/2007/acp-7-1961-2007.html 158 | [hplgit]: https://github.com/hplgit/odespy 159 | [diffrax]: https://docs.kidger.site/diffrax/ 160 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Master TODO list 2 | 3 | 1. Currently uses virtual temperature ($T_v = T(1 + 0.61w)$) when approximating density; should rather use the density temperature for consistency. 4 | - updates in thermo.py, parcel_aux.pyx, and fortran code 5 | 6 | 2. Flesh out basic plotting routines for quick visualization. 7 | 8 | 3. Add a simple IO package 9 | - save state for an initial simulation, and its end point 10 | - read in that state to initialize a new model 11 | - better flexibility between pandas / xray as basic storage options 12 | 13 | 4. Re-structure integration logic 14 | - model should accept two timesteps - one of the numerical integration, one for the output 15 | + Actually not a problem for the variable-step size solvers; e.g. for CVODE, dt will be the output points 16 | - the integration should proceed piecewise between output timesteps 17 | + Already does for Assimulo; 1-minute chunks reporting at the desired timestep 18 | 19 | 5. Add activation diagnostics to integration loop 20 | 21 | -------------------------------------------------------------------------------- /ci/requirements-py310.yml: -------------------------------------------------------------------------------- 1 | name: pyrcel 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.10 6 | - gcc_linux-64 7 | - gxx_linux-64 8 | - assimulo 9 | - numba 10 | - numpy 11 | - pandas 12 | - pyyaml 13 | - scipy 14 | - xarray -------------------------------------------------------------------------------- /ci/requirements-py311.yml: -------------------------------------------------------------------------------- 1 | name: pyrcel 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.11 6 | - gcc_linux-64 7 | - gxx_linux-64 8 | - assimulo 9 | - numba 10 | - numpy 11 | - pandas 12 | - pyyaml 13 | - scipy 14 | - xarray -------------------------------------------------------------------------------- /ci/requirements-py38.yml: -------------------------------------------------------------------------------- 1 | name: pyrcel 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.8 6 | - gcc_linux-64 7 | - gxx_linux-64 8 | - assimulo 9 | - numba 10 | - numpy 11 | - pandas 12 | - pyyaml 13 | - scipy 14 | - xarray -------------------------------------------------------------------------------- /ci/requirements-py39.yml: -------------------------------------------------------------------------------- 1 | name: pyrcel 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.9 6 | - gcc_linux-64 7 | - gxx_linux-64 8 | - assimulo 9 | - numba 10 | - numpy 11 | - pandas 12 | - pyyaml 13 | - scipy 14 | - xarray -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html notebooks gen dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* generated/ 51 | rm -rf examples/*_files/ 52 | rm -rf examples/*.rst 53 | 54 | notebooks: 55 | 56 | make -C examples notebooks 57 | 58 | html: 59 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 62 | 63 | gen: 64 | sphinx-autogen -t templates -o generated *.rst 65 | @echo 66 | @echo "auto-generated content finished building in $(BUILDDIR)/generated" 67 | 68 | dirhtml: 69 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 70 | @echo 71 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 72 | 73 | singlehtml: 74 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 75 | @echo 76 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 77 | 78 | pickle: 79 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 80 | @echo 81 | @echo "Build finished; now you can process the pickle files." 82 | 83 | json: 84 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 85 | @echo 86 | @echo "Build finished; now you can process the JSON files." 87 | 88 | htmlhelp: 89 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 90 | @echo 91 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 92 | ".hhp project file in $(BUILDDIR)/htmlhelp." 93 | 94 | qthelp: 95 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 96 | @echo 97 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 98 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 99 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pyrcel.qhcp" 100 | @echo "To view the help file:" 101 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pyrcel.qhc" 102 | 103 | devhelp: 104 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 105 | @echo 106 | @echo "Build finished." 107 | @echo "To view the help file:" 108 | @echo "# mkdir -p $$HOME/.local/share/devhelp/pyrcel" 109 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pyrcel" 110 | @echo "# devhelp" 111 | 112 | epub: 113 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 114 | @echo 115 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 116 | 117 | latex: 118 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 119 | @echo 120 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 121 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 122 | "(use \`make latexpdf' here to do that automatically)." 123 | 124 | latexpdf: 125 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 126 | @echo "Running LaTeX files through pdflatex..." 127 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 128 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 129 | 130 | latexpdfja: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo "Running LaTeX files through platex and dvipdfmx..." 133 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 134 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 135 | 136 | text: 137 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 138 | @echo 139 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 140 | 141 | man: 142 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 143 | @echo 144 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 145 | 146 | texinfo: 147 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 148 | @echo 149 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 150 | @echo "Run \`make' in that directory to run these through makeinfo" \ 151 | "(use \`make info' here to do that automatically)." 152 | 153 | info: 154 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 155 | @echo "Running Texinfo files through makeinfo..." 156 | make -C $(BUILDDIR)/texinfo info 157 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 158 | 159 | gettext: 160 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 161 | @echo 162 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 163 | 164 | changes: 165 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 166 | @echo 167 | @echo "The overview file is in $(BUILDDIR)/changes." 168 | 169 | linkcheck: 170 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 171 | @echo 172 | @echo "Link check complete; look for any errors in the above output " \ 173 | "or in $(BUILDDIR)/linkcheck/output.txt." 174 | 175 | doctest: 176 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 177 | @echo "Testing of doctests in the sources finished, look at the " \ 178 | "results in $(BUILDDIR)/doctest/output.txt." 179 | 180 | xml: 181 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 182 | @echo 183 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 184 | 185 | pseudoxml: 186 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 187 | @echo 188 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 189 | -------------------------------------------------------------------------------- /docs/build_doc.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Quick script to build the documentation 3 | 4 | # Generate .rst files from the notebooks containing pre-rendered examples 5 | cd examples 6 | make 7 | 8 | # Go back and generate the stub automethod templates in the API reference 9 | cd ../ 10 | make gen 11 | 12 | # Now make the html documents 13 | make html 14 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pyrcel documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Jul 2 15:49:19 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import os 16 | import sys 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.abspath("../")) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | # needs_sphinx = '1.0' 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | "sphinx.ext.autodoc", 33 | "sphinx.ext.autosummary", 34 | "sphinx.ext.doctest", 35 | "sphinx.ext.todo", 36 | "sphinx.ext.coverage", 37 | "sphinx.ext.mathjax", 38 | "numpydoc", 39 | "IPython.sphinxext.ipython_directive", 40 | "IPython.sphinxext.ipython_console_highlighting", 41 | ] 42 | 43 | # Fix toctree bug http://stackoverflow.com/questions/12206334/sphinx-autosummary-toctree-contains-reference-to-nonexisting-document-warnings 44 | numpydoc_show_class_members = False 45 | 46 | autosummary_generate = True 47 | 48 | numpydoc_class_members_toctree = True 49 | numpydoc_show_class_members = False 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ["templates"] 53 | 54 | # The suffix of source filenames. 55 | source_suffix = ".rst" 56 | 57 | # The encoding of source files. 58 | # source_encoding = 'utf-8-sig' 59 | 60 | # The master toctree document. 61 | master_doc = "index" 62 | 63 | # General information about the project. 64 | project = "pyrcel" 65 | copyright = "2025, Daniel Rothenberg" 66 | 67 | # The version info for the project you're documenting, acts as replacement for 68 | # |version| and |release|, also used in various other places throughout the 69 | # built documents. 70 | # 71 | # The short X.Y version. 72 | from pyrcel import __version__ as version 73 | 74 | # The full version, including alpha/beta/rc tags. 75 | release = version 76 | 77 | # The language for content autogenerated by Sphinx. Refer to documentation 78 | # for a list of supported languages. 79 | # language = None 80 | 81 | # There are two options for replacing |today|: either, you set today to some 82 | # non-false value, then it is used: 83 | # today = '' 84 | # Else, today_fmt is used as the format for a strftime call. 85 | # today_fmt = '%B %d, %Y' 86 | 87 | # List of patterns, relative to source directory, that match files and 88 | # directories to ignore when looking for source files. 89 | exclude_patterns = ["_build", "templates"] 90 | 91 | # The reST default role (used for this markup: `text`) to use for all 92 | # documents. 93 | # default_role = None 94 | 95 | # If true, '()' will be appended to :func: etc. cross-reference text. 96 | # add_function_parentheses = True 97 | 98 | # If true, the current module name will be prepended to all description 99 | # unit titles (such as .. function::). 100 | # add_module_names = True 101 | 102 | # If true, sectionauthor and moduleauthor directives will be shown in the 103 | # output. They are ignored by default. 104 | # show_authors = False 105 | 106 | # The name of the Pygments (syntax highlighting) style to use. 107 | pygments_style = "sphinx" 108 | 109 | # A list of ignored prefixes for module index sorting. 110 | # modindex_common_prefix = [] 111 | 112 | # If true, keep warnings as "system message" paragraphs in the built documents. 113 | # keep_warnings = False 114 | 115 | 116 | # -- Options for HTML output ---------------------------------------------- 117 | 118 | # The theme to use for HTML and HTML Help pages. See the documentation for 119 | # a list of builtin themes. 120 | 121 | # on_rtd is whether we are on readthedocs.org, this line of code grabbed from 122 | # docs.readthedocs.org 123 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 124 | 125 | if not on_rtd: # only import and set the theme if we're building docs locally 126 | import sphinx_rtd_theme 127 | 128 | html_theme = "sphinx_rtd_theme" 129 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 130 | 131 | # Theme options are theme-specific and customize the look and feel of a theme 132 | # further. For a list of options available for each theme, see the 133 | # documentation. 134 | html_theme_options = { 135 | # 'bootstrap_version': "3", 136 | # 'bootswatch_theme': "cosmo", #"yeti" 137 | } 138 | 139 | # Add any paths that contain custom themes here, relative to this directory. 140 | # html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() 141 | # html_theme_path = [sphinx_rtd_theme.get_html_theme_path(), ] 142 | # html_theme_path = [] 143 | 144 | 145 | # The name for this set of Sphinx documents. If None, it defaults to 146 | # " v documentation". 147 | # html_title = None 148 | 149 | # A shorter title for the navigation bar. Default is the same as html_title. 150 | # html_short_title = None 151 | 152 | # The name of an image file (relative to this directory) to place at the top 153 | # of the sidebar. 154 | # html_logo = None 155 | 156 | # The name of an image file (within the static path) to use as favicon of the 157 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 158 | # pixels large. 159 | # html_favicon = None 160 | 161 | # Add any paths that contain custom static files (such as style sheets) here, 162 | # relative to this directory. They are copied after the builtin static files, 163 | # so a file named "default.css" will overwrite the builtin "default.css". 164 | html_static_path = ["_static"] 165 | 166 | # Add any extra paths that contain custom files (such as robots.txt or 167 | # .htaccess) here, relative to this directory. These files are copied 168 | # directly to the root of the documentation. 169 | # html_extra_path = [] 170 | 171 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 172 | # using the given strftime format. 173 | # html_last_updated_fmt = '%b %d, %Y' 174 | 175 | # If true, SmartyPants will be used to convert quotes and dashes to 176 | # typographically correct entities. 177 | # html_use_smartypants = True 178 | 179 | # Custom sidebar templates, maps document names to template names. 180 | # html_sidebars = {} 181 | 182 | # Additional templates that should be rendered to pages, maps page names to 183 | # template names. 184 | # html_additional_pages = {} 185 | 186 | # If false, no module index is generated. 187 | # html_domain_indices = True 188 | 189 | # If false, no index is generated. 190 | # html_use_index = True 191 | 192 | # If true, the index is split into individual pages for each letter. 193 | # html_split_index = False 194 | 195 | # If true, links to the reST sources are added to the pages. 196 | # html_show_sourcelink = True 197 | 198 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 199 | # html_show_sphinx = True 200 | 201 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 202 | # html_show_copyright = True 203 | 204 | # If true, an OpenSearch description file will be output, and all pages will 205 | # contain a tag referring to it. The value of this option must be the 206 | # base URL from which the finished HTML is served. 207 | # html_use_opensearch = '' 208 | 209 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 210 | # html_file_suffix = None 211 | 212 | # Output file base name for HTML help builder. 213 | htmlhelp_basename = "pyrceldoc" 214 | 215 | 216 | # -- Options for LaTeX output --------------------------------------------- 217 | 218 | latex_elements = { 219 | # The paper size ('letterpaper' or 'a4paper'). 220 | #'papersize': 'letterpaper', 221 | # The font size ('10pt', '11pt' or '12pt'). 222 | #'pointsize': '10pt', 223 | # Additional stuff for the LaTeX preamble. 224 | #'preamble': '', 225 | } 226 | 227 | # Grouping the document tree into LaTeX files. List of tuples 228 | # (source start file, target name, title, 229 | # author, documentclass [howto, manual, or own class]). 230 | latex_documents = [ 231 | ( 232 | "index", 233 | "pyrcel.tex", 234 | "pyrcel documentation", 235 | "Daniel Rothenberg", 236 | "manual", 237 | ) 238 | ] 239 | 240 | # The name of an image file (relative to this directory) to place at the top of 241 | # the title page. 242 | # latex_logo = None 243 | 244 | # For "manual" documents, if this is true, then toplevel headings are parts, 245 | # not chapters. 246 | # latex_use_parts = False 247 | 248 | # If true, show page references after internal links. 249 | # latex_show_pagerefs = False 250 | 251 | # If true, show URL addresses after external links. 252 | # latex_show_urls = False 253 | 254 | # Documents to append as an appendix to all manuals. 255 | # latex_appendices = [] 256 | 257 | # If false, no module index is generated. 258 | # latex_domain_indices = True 259 | 260 | 261 | # -- Options for manual page output --------------------------------------- 262 | 263 | # One entry per manual page. List of tuples 264 | # (source start file, name, description, authors, manual section). 265 | man_pages = [("index", "pyrcel", "pyrcel Documentation", ["Daniel Rothenberg"], 1)] 266 | 267 | # If true, show URL addresses after external links. 268 | # man_show_urls = False 269 | 270 | 271 | # -- Options for Texinfo output ------------------------------------------- 272 | 273 | # Grouping the document tree into Texinfo files. List of tuples 274 | # (source start file, target name, title, author, 275 | # dir menu entry, description, category) 276 | texinfo_documents = [ 277 | ( 278 | "index", 279 | "pyrcel", 280 | "pyrcel Documentation", 281 | "Daniel Rothenberg", 282 | "pyrcel", 283 | "Adiabatic cloud parcel model for aerosol activation studies", 284 | "Miscellaneous", 285 | ) 286 | ] 287 | 288 | # Documents to append as an appendix to all manuals. 289 | # texinfo_appendices = [] 290 | 291 | # If false, no module index is generated. 292 | # texinfo_domain_indices = True 293 | 294 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 295 | # texinfo_show_urls = 'footnote' 296 | 297 | # If true, do not generate a @detailmenu in the "Top" node's menu. 298 | # texinfo_no_detailmenu = False 299 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | name: pyrcel 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - gcc_linux-64 6 | - gxx_linux-64 7 | - python==3.11 8 | - assimulo==3.6 9 | - pandas 10 | - pyyaml 11 | - scipy 12 | - numba 13 | - numpy<2.2 14 | - xarray 15 | - numpydoc 16 | - ipython 17 | - sphinx 18 | - sphinx_rtd_theme -------------------------------------------------------------------------------- /docs/examples/Makefile: -------------------------------------------------------------------------------- 1 | 2 | NOTEBOOKS := $(wildcard *.ipynb) 3 | RSTS := $(NOTEBOOKS:.ipynb=.rst) 4 | 5 | .PHONY: all 6 | all: $(RSTS) 7 | 8 | %.rst: %.ipynb 9 | jupyter-nbconvert $^ --to rst 10 | 11 | clean: 12 | rm *.rst 13 | rm -rf *_files/ 14 | rm -rf _build/ -------------------------------------------------------------------------------- /docs/examples/README.md: -------------------------------------------------------------------------------- 1 | Because the Assimulo dependency is difficult to build on third-party content providers (travis, RTD, etc), I've opted to manually control the examples in this directory. Here are the steps to keep the examples up to date: 2 | 3 | 1. Run each notebook independently, so that the results (figures, etc) it includes are pre-rendered and self-contained 4 | 2. Execute the supplied Makefile to convert ipynb -> rst and generate hte rendered figures 5 | 3. Commit the newly generated *_files/ folders and all new resources 6 | 7 | In the future, this can be simplified by packaging the entire parcel model install. However, that will require either (a) re-opening the ability to use third-party ODE solvers from SciPy, or (b) packaging Assimulo and Sundials into conda for easy access on a third-party server. -------------------------------------------------------------------------------- /docs/examples/activate.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _example_activate: 3 | 4 | .. currentmodule:: parcel_model 5 | 6 | Example: Activation 7 | =================== 8 | 9 | In this example, we will study the effect of updraft speed on the 10 | activation of a lognormal ammonium sulfate accumulation mode aerosol. 11 | 12 | .. code:: python 13 | 14 | # Suppress warnings 15 | import warnings 16 | warnings.simplefilter('ignore') 17 | 18 | import pyrcel as pm 19 | import numpy as np 20 | 21 | %matplotlib inline 22 | import matplotlib.pyplot as plt 23 | import seaborn as sns 24 | 25 | First, we indicate the parcel's initial thermodynamic conditions. 26 | 27 | .. code:: python 28 | 29 | P0 = 100000. # Pressure, Pa 30 | T0 = 279. # Temperature, K 31 | S0 = -0.1 # Supersaturation, 1-RH 32 | 33 | We next define the aerosol distribution to follow the reference 34 | simulation from `Ghan et al, 35 | 2011 `__ 36 | 37 | .. code:: python 38 | 39 | aer = pm.AerosolSpecies('ammonium sulfate', 40 | pm.Lognorm(mu=0.05, sigma=2.0, N=1000.), 41 | kappa=0.7, bins=100) 42 | 43 | Loop over updraft several velocities in the range 0.1 - 10.0 m/s. We 44 | will peform a detailed parcel model calculation, as well as calculations 45 | with two activation parameterizations. We will also use an accommodation 46 | coefficient of :math:`\alpha_c = 0.1`, following the recommendations of 47 | `Raatikainen et al (2013) `__. 48 | 49 | First, the parcel model calculations: 50 | 51 | .. code:: python 52 | 53 | from pyrcel import binned_activation 54 | 55 | Vs = np.logspace(-1, np.log10(10,), 11.)[::-1] # 0.1 - 5.0 m/s 56 | accom = 0.1 57 | 58 | smaxes, act_fracs = [], [] 59 | for V in Vs: 60 | # Initialize the model 61 | model = pm.ParcelModel([aer,], V, T0, S0, P0, accom=accom, console=False) 62 | par_out, aer_out = model.run(t_end=2500., dt=1.0, solver='cvode', 63 | output='dataframes', terminate=True) 64 | print(V, par_out.S.max()) 65 | 66 | # Extract the supersaturation/activation details from the model 67 | # output 68 | S_max = par_out['S'].max() 69 | time_at_Smax = par_out['S'].argmax() 70 | wet_sizes_at_Smax = aer_out['ammonium sulfate'].ix[time_at_Smax].iloc[0] 71 | wet_sizes_at_Smax = np.array(wet_sizes_at_Smax.tolist()) 72 | 73 | frac_eq, _, _, _ = binned_activation(S_max, T0, wet_sizes_at_Smax, aer) 74 | 75 | # Save the output 76 | smaxes.append(S_max) 77 | act_fracs.append(frac_eq) 78 | 79 | 80 | .. parsed-literal:: 81 | 82 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 83 | 10.0 0.0156189147154 84 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 85 | 6.3095734448 0.0116683910368 86 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 87 | 3.98107170553 0.00878287310116 88 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 89 | 2.51188643151 0.00664901290831 90 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 91 | 1.58489319246 0.00505644091867 92 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 93 | 1.0 0.00385393398982 94 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 95 | 0.63095734448 0.00293957320198 96 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 97 | 0.398107170553 0.00224028774582 98 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 99 | 0.251188643151 0.00170480101361 100 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 101 | 0.158489319246 0.0012955732509 102 | [CVode Warning] b'At the end of the first step, there are still some root functions identically 0. This warning will not be issued again.' 103 | 0.1 0.000984803827635 104 | 105 | 106 | Now the activation parameterizations: 107 | 108 | .. code:: python 109 | 110 | smaxes_arg, act_fracs_arg = [], [] 111 | smaxes_mbn, act_fracs_mbn = [], [] 112 | 113 | for V in Vs: 114 | smax_arg, _, afs_arg = pm.arg2000(V, T0, P0, [aer], accom=accom) 115 | smax_mbn, _, afs_mbn = pm.mbn2014(V, T0, P0, [aer], accom=accom) 116 | 117 | smaxes_arg.append(smax_arg) 118 | act_fracs_arg.append(afs_arg[0]) 119 | smaxes_mbn.append(smax_mbn) 120 | act_fracs_mbn.append(afs_mbn[0]) 121 | 122 | Finally, we compile our results into a nice plot for visualization. 123 | 124 | .. code:: python 125 | 126 | sns.set(context="notebook", style='ticks') 127 | sns.set_palette("husl", 3) 128 | fig, [ax_s, ax_a] = plt.subplots(1, 2, sharex=True, figsize=(10,4)) 129 | 130 | ax_s.plot(Vs, np.array(smaxes)*100., color='k', lw=2, label="Parcel Model") 131 | ax_s.plot(Vs, np.array(smaxes_mbn)*100., linestyle='None', 132 | marker="o", ms=10, label="MBN2014" ) 133 | ax_s.plot(Vs, np.array(smaxes_arg)*100., linestyle='None', 134 | marker="o", ms=10, label="ARG2000" ) 135 | ax_s.semilogx() 136 | ax_s.set_ylabel("Superaturation Max, %") 137 | ax_s.set_ylim(0, 2.) 138 | 139 | ax_a.plot(Vs, act_fracs, color='k', lw=2, label="Parcel Model") 140 | ax_a.plot(Vs, act_fracs_mbn, linestyle='None', 141 | marker="o", ms=10, label="MBN2014" ) 142 | ax_a.plot(Vs, act_fracs_arg, linestyle='None', 143 | marker="o", ms=10, label="ARG2000" ) 144 | ax_a.semilogx() 145 | ax_a.set_ylabel("Activated Fraction") 146 | ax_a.set_ylim(0, 1.) 147 | 148 | plt.tight_layout() 149 | sns.despine() 150 | 151 | for ax in [ax_s, ax_a]: 152 | ax.legend(loc='upper left') 153 | ax.xaxis.set_ticks([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0]) 154 | ax.xaxis.set_ticklabels([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0]) 155 | ax.set_xlabel("Updraft speed, m/s") 156 | 157 | 158 | 159 | .. image:: activate_files/activate_13_0.png 160 | 161 | -------------------------------------------------------------------------------- /docs/examples/activate_files/activate_13_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darothen/pyrcel/ad790b4b074b183f32f8d98a1a820747b9918988/docs/examples/activate_files/activate_13_0.png -------------------------------------------------------------------------------- /docs/examples/basic_run.rst: -------------------------------------------------------------------------------- 1 | 2 | .. _example_basic: 3 | 4 | .. currentmodule:: parcel_model 5 | 6 | Example: Basic Run 7 | ================== 8 | 9 | In this example, we will setup a simple parcel model simulation 10 | containing two aerosol modes. We will then run the model with a 1 m/s 11 | updraft, and observe how the aerosol population bifurcates into swelled 12 | aerosol and cloud droplets. 13 | 14 | .. code:: python 15 | 16 | # Suppress warnings 17 | import warnings 18 | warnings.simplefilter('ignore') 19 | 20 | import pyrcel as pm 21 | import numpy as np 22 | 23 | %matplotlib inline 24 | import matplotlib.pyplot as plt 25 | 26 | 27 | .. parsed-literal:: 28 | 29 | Could not find GLIMDA 30 | 31 | 32 | First, we indicate the parcel's initial thermodynamic conditions. 33 | 34 | .. code:: python 35 | 36 | P0 = 77500. # Pressure, Pa 37 | T0 = 274. # Temperature, K 38 | S0 = -0.02 # Supersaturation, 1-RH (98% here) 39 | 40 | Next, we define the aerosols present in the parcel. The model itself is 41 | agnostic to how the aerosol are specified; it simply expects lists of 42 | the radii of wetted aerosol radii, their number concentration, and their 43 | hygroscopicity. We can make container objects 44 | (:class:``AerosolSpecies``) that wrap all of this information so that we 45 | never need to worry about it. 46 | 47 | Here, let's construct two aerosol modes: 48 | 49 | +----------+-----------------------+-----------------+---------+--------------------+ 50 | | Mode | :math:`\kappa` | Mean size | Std dev | Number Conc | 51 | | | (hygroscopicity) | (micron) | | (cm\*\*-3) | 52 | +==========+=======================+=================+=========+====================+ 53 | | sulfate | 0.54 | 0.015 | 1.6 | 850 | 54 | +----------+-----------------------+-----------------+---------+--------------------+ 55 | | sea salt | 1.2 | 0.85 | 1.2 | 10 | 56 | +----------+-----------------------+-----------------+---------+--------------------+ 57 | 58 | We'll define each mode using the :class:``Lognorm`` distribution 59 | packaged with the model. 60 | 61 | .. code:: python 62 | 63 | sulfate = pm.AerosolSpecies('sulfate', 64 | pm.Lognorm(mu=0.015, sigma=1.6, N=850.), 65 | kappa=0.54, bins=200) 66 | sea_salt = pm.AerosolSpecies('sea salt', 67 | pm.Lognorm(mu=0.85, sigma=1.2, N=10.), 68 | kappa=1.2, bins=40) 69 | 70 | The :class:``AerosolSpecies`` class automatically computes 71 | gridded/binned representations of the size distributions. Let's double 72 | check that the aerosol distribution in the model will make sense by 73 | plotting the number concentration in each bin. 74 | 75 | .. code:: python 76 | 77 | fig = plt.figure(figsize=(10,5)) 78 | ax = fig.add_subplot(111) 79 | ax.grid(False, "minor") 80 | 81 | sul_c = "#CC0066" 82 | ax.bar(sulfate.rs[:-1], sulfate.Nis*1e-6, np.diff(sulfate.rs), 83 | color=sul_c, label="sulfate", edgecolor="#CC0066") 84 | sea_c = "#0099FF" 85 | ax.bar(sea_salt.rs[:-1], sea_salt.Nis*1e-6, np.diff(sea_salt.rs), 86 | color=sea_c, label="sea salt", edgecolor="#0099FF") 87 | ax.semilogx() 88 | 89 | ax.set_xlabel("Aerosol dry radius, micron") 90 | ax.set_ylabel("Aerosl number conc., cm$^{-3}$") 91 | ax.legend(loc='upper right') 92 | 93 | 94 | 95 | 96 | .. parsed-literal:: 97 | 98 | 99 | 100 | 101 | 102 | 103 | .. image:: basic_run_files/basic_run_9_1.png 104 | 105 | 106 | Actually running the model is very straightforward, and involves just 107 | two steps: 108 | 109 | 1. Instantiate the model by creating a :class:``ParcelModel`` object. 110 | 2. Call the model's :method:``run`` method. 111 | 112 | For convenience this process is encoded into several routines in the 113 | ``driver`` file, including both a single-strategy routine and an 114 | iterating routine which adjusts the the timestep and numerical 115 | tolerances if the model crashes. However, we can illustrate the simple 116 | model running process here in case you wish to develop your own scheme 117 | for running the model. 118 | 119 | .. code:: python 120 | 121 | initial_aerosols = [sulfate, sea_salt] 122 | V = 1.0 # updraft speed, m/s 123 | 124 | dt = 1.0 # timestep, seconds 125 | t_end = 250./V # end time, seconds... 250 meter simulation 126 | 127 | model = pm.ParcelModel(initial_aerosols, V, T0, S0, P0, console=False, accom=0.3) 128 | parcel_trace, aerosol_traces = model.run(t_end, dt, solver='cvode') 129 | 130 | If ``console`` is set to ``True``, then some basic debugging output will 131 | be written to the terminal, including the initial equilibrium droplet 132 | size distribution and some numerical solver diagnostics. The model 133 | output can be customized; by default, we get a DataFrame and a Panel of 134 | the parcel state vector and aerosol bin sizes as a function of time (and 135 | height). We can use this to visualize the simulation results, like in 136 | the package's 137 | `README `__. 138 | 139 | .. code:: python 140 | 141 | fig, [axS, axA] = plt.subplots(1, 2, figsize=(10, 4), sharey=True) 142 | 143 | axS.plot(parcel_trace['S']*100., parcel_trace['z'], color='k', lw=2) 144 | axT = axS.twiny() 145 | axT.plot(parcel_trace['T'], parcel_trace['z'], color='r', lw=1.5) 146 | 147 | Smax = parcel_trace['S'].max()*100 148 | z_at_smax = parcel_trace['z'].ix[parcel_trace['S'].argmax()] 149 | axS.annotate("max S = %0.2f%%" % Smax, 150 | xy=(Smax, z_at_smax), 151 | xytext=(Smax-0.3, z_at_smax+50.), 152 | arrowprops=dict(arrowstyle="->", color='k', 153 | connectionstyle='angle3,angleA=0,angleB=90'), 154 | zorder=10) 155 | 156 | axS.set_xlim(0, 0.7) 157 | axS.set_ylim(0, 250) 158 | 159 | axT.set_xticks([270, 271, 272, 273, 274]) 160 | axT.xaxis.label.set_color('red') 161 | axT.tick_params(axis='x', colors='red') 162 | 163 | axS.set_xlabel("Supersaturation, %") 164 | axT.set_xlabel("Temperature, K") 165 | axS.set_ylabel("Height, m") 166 | 167 | sulf_array = aerosol_traces['sulfate'].values 168 | sea_array = aerosol_traces['sea salt'].values 169 | 170 | ss = axA.plot(sulf_array[:, ::10]*1e6, parcel_trace['z'], color=sul_c, 171 | label="sulfate") 172 | sa = axA.plot(sea_array*1e6, parcel_trace['z'], color=sea_c, label="sea salt") 173 | axA.semilogx() 174 | axA.set_xlim(1e-2, 10.) 175 | axA.set_xticks([1e-2, 1e-1, 1e0, 1e1], [0.01, 0.1, 1.0, 10.0]) 176 | axA.legend([ss[0], sa[0]], ['sulfate', 'sea salt'], loc='upper right') 177 | axA.set_xlabel("Droplet radius, micron") 178 | 179 | for ax in [axS, axA, axT]: 180 | ax.grid(False, 'both', 'both') 181 | 182 | 183 | 184 | .. image:: basic_run_files/basic_run_13_0.png 185 | 186 | 187 | In this simple example, the sulfate aerosol population bifurcated into 188 | interstitial aerosol and cloud droplets, while the entire sea salt 189 | population activated. A peak supersaturation of about 0.63% was reached 190 | a few meters above cloud base, where the ambient relative humidity hit 191 | 100%. 192 | 193 | How many CDNC does this translate into? We can call upon helper methods 194 | from the ``activation`` package to perform these calculations for us: 195 | 196 | .. code:: python 197 | 198 | from pyrcel import binned_activation 199 | 200 | sulf_trace = aerosol_traces['sulfate'] 201 | sea_trace = aerosol_traces['sea salt'] 202 | 203 | ind_final = int(t_end/dt) - 1 204 | 205 | T = parcel_trace['T'].iloc[ind_final] 206 | eq_sulf, kn_sulf, alpha_sulf, phi_sulf = \ 207 | binned_activation(Smax/100, T, sulf_trace.iloc[ind_final], sulfate) 208 | eq_sulf *= sulfate.total_N 209 | 210 | eq_sea, kn_sea, alpha_sea, phi_sea = \ 211 | binned_activation(Smax/100, T, sea_trace.iloc[ind_final], sea_salt) 212 | eq_sea *= sea_salt.total_N 213 | 214 | print(" CDNC(sulfate) = {:3.1f}".format(eq_sulf)) 215 | print(" CDNC(sea salt) = {:3.1f}".format(eq_sea)) 216 | print("------------------------") 217 | print(" total = {:3.1f} / {:3.0f} ~ act frac = {:1.2f}".format( 218 | eq_sulf+eq_sea, 219 | sea_salt.total_N+sulfate.total_N, 220 | (eq_sulf+eq_sea)/(sea_salt.total_N+sulfate.total_N) 221 | )) 222 | 223 | 224 | .. parsed-literal:: 225 | 226 | CDNC(sulfate) = 146.9 227 | CDNC(sea salt) = 10.0 228 | ------------------------ 229 | total = 156.9 / 860 ~ act frac = 0.18 230 | 231 | -------------------------------------------------------------------------------- /docs/examples/basic_run_files/basic_run_13_0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darothen/pyrcel/ad790b4b074b183f32f8d98a1a820747b9918988/docs/examples/basic_run_files/basic_run_13_0.png -------------------------------------------------------------------------------- /docs/examples/basic_run_files/basic_run_9_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darothen/pyrcel/ad790b4b074b183f32f8d98a1a820747b9918988/docs/examples/basic_run_files/basic_run_9_1.png -------------------------------------------------------------------------------- /docs/figs/model_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darothen/pyrcel/ad790b4b074b183f32f8d98a1a820747b9918988/docs/figs/model_example.png -------------------------------------------------------------------------------- /docs/figs/sample_script.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darothen/pyrcel/ad790b4b074b183f32f8d98a1a820747b9918988/docs/figs/sample_script.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | 2 | pyrcel: cloud parcel model 3 | ========================== 4 | 5 | |DOI|\ |PyPI version|\ |Build Status|\ |Documentation Status|\ |Code Style| 6 | 7 | .. |DOI| image:: https://zenodo.org/badge/12927551.svg 8 | :target: https://zenodo.org/badge/latestdoi/12927551 9 | .. |PyPI version| image:: https://badge.fury.io/py/pyrcel.svg 10 | :target: https://badge.fury.io/py/pyrcel 11 | .. |Build Status| image:: https://circleci.com/gh/darothen/pyrcel/tree/master.svg?style=svg 12 | :target: https://circleci.com/gh/darothen/pyrcel/tree/master 13 | .. |Documentation Status| image:: https://readthedocs.org/projects/pyrcel/badge/?version=stable 14 | :target: http://pyrcel.readthedocs.org/en/stable/?badge=stable 15 | .. |Code Style| image:: https://img.shields.io/badge/code%20style-black-000000.svg 16 | :target: https://github.com/python/black 17 | 18 | This is an implementation of a simple, 0D adiabatic cloud parcel model tool (following `Nenes et al, 2001`_ and `Pruppacher and Klett, 1997`_). It allows flexible descriptions of an initial aerosol population, and simulates the evolution of a proto-cloud droplet population as the parcel ascends adiabatically at either a constant or time/height-dependent updraft speed. Droplet growth within the parcel is tracked on a Lagrangian grid. 19 | 20 | .. _Pruppacher and Klett, 1997: http://books.google.com/books?hl=en&lr=&id=1mXN_qZ5sNUC&oi=fnd&pg=PR15&ots=KhdkC6uhB3&sig=PSlNsCeLSB2FvR93Vzo0ptCAnYA#v=onepage&q&f=false 21 | .. _Nenes et al, 2001: http://onlinelibrary.wiley.com/doi/10.1034/j.1600-0889.2001.d01-12.x/abstract 22 | 23 | .. image:: figs/model_example.png 24 | 25 | You are invited to use the model (in accordance with the `licensing `_), but please get in 27 | touch with the author via `e-mail `_ or on 28 | `twitter `_. p-to-date versions can be obtained 29 | through the model's `github repository `_ 30 | or directly from the author. If you use the model for research, please cite 31 | `this journal article `_ 32 | which details the original model formulation: 33 | 34 | | Daniel Rothenberg and Chien Wang, 2016: Metamodeling of Droplet Activation for Global Climate Models. *J. Atmos. Sci.*, **73**, 1255–1272. doi: http://dx.doi.org/10.1175/JAS-D-15-0223.1 35 | 36 | 37 | Documentation Outline 38 | --------------------- 39 | 40 | .. toctree:: 41 | :maxdepth: 2 42 | :glob: 43 | 44 | sci_descr 45 | install 46 | examples/* 47 | parcel 48 | reference 49 | 50 | 51 | Current version: |version| 52 | 53 | Documentation last compiled: |today| 54 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | .. _install: 2 | 3 | Installation 4 | ------------ 5 | 6 | Quick Start 7 | =========== 8 | 9 | Install `pixi `_ on your machine. Clone this repository, then 10 | invoke from a terminal: 11 | 12 | .. code-block:: shell 13 | 14 | $ pixi run run_parcel examples/simple.yml 15 | 16 | 17 | An environment should automatically be set up and the model run from it. 18 | 19 | Normal Installation 20 | =================== 21 | 22 | To quickly get started with running pyrcel, complete the following steps: 23 | 24 | - Set up a new Python environment; we recommend using `mambaforge `_: 25 | 26 | .. code-block:: shell 27 | 28 | $ mamba create -n pyrcel_quick_start python=3.11 29 | 30 | - Activate the new Python environment and install the model and its dependencies. If you install the published version from PyPi (recommended), then you also need to install `Assimulo `_ using the Mamba package manager - but no other manual dependency installation is necessary: 31 | 32 | .. code-block:: shell 33 | 34 | $ mamba activate pyrcel_quick_start 35 | $ pip install pyrcel 36 | $ mamba install -c conda-forge assimulo 37 | 38 | - Run a test simulation using the CLI tool and a sample YAML file from **pyrcel/examples/*.yml** (you may want to clone the repository or download them locally): 39 | 40 | .. code-block:: shell 41 | 42 | $ run_parcel simple.yml 43 | 44 | 45 | Detailed Installation Notes 46 | =========================== 47 | 48 | 49 | From PyPI 50 | +++++++++ 51 | 52 | This package and most of its dependencies can automatically be installed by using 53 | ``pip``: 54 | 55 | .. code-block:: bash 56 | 57 | $ pip install pyrcel 58 | 59 | However, note that this will not install **Assimulo**; you will separately need 60 | to install that, using the conda/mamba package manager. See the example in the previous 61 | section for more details. 62 | 63 | 64 | From source code 65 | ++++++++++++++++ 66 | 67 | To grab and build the latest bleeding-edge version of the model, you should use 68 | ``pip`` and point it to the source code `repository`_ on github: 69 | 70 | 71 | .. code-block:: bash 72 | 73 | $ pip install git+git://github.com/darothen/pyrcel.git 74 | 75 | The same caveats as in the previous section regarding installing **Assimulo** will 76 | still apply. 77 | 78 | You can also install the code from the cloned source directory by invoking 79 | ``pip install`` from within it; this is useful if you're updating or 80 | modifying the model, since you can install an "editable" package which 81 | points directly to the git-monitored code: 82 | 83 | 84 | .. code-block:: bash 85 | 86 | $ cd path/to/pyrcel/ 87 | $ pip install -e . 88 | 89 | 90 | Dependencies 91 | ++++++++++++ 92 | 93 | This code was originally written for Python 2.7, and then 94 | `futurized `_ to Python 3.3+ with hooks for 95 | backwards compatibility. It should work on modern Python versions, and we recommend 96 | using Python 3.11+ for the greatest compatibility with required dependencies. 97 | 98 | The easiest way to manage dependencies is to use a tool like `Mambaforge ` 99 | to set up an environment. Suitable environment files can be found in the ``pyrcel/ci`` 100 | directory. 101 | 102 | Necessary dependencies 103 | ^^^^^^^^^^^^^^^^^^^^^^ 104 | 105 | All of these (except for Assimulo; see the note below) can be installed via `pip`: 106 | 107 | - `Assimulo `_ 108 | 109 | - `numba `_ 110 | 111 | - `numpy `_ 112 | 113 | - `scipy `_ 114 | 115 | - `pandas `_ 116 | 117 | .. note:: 118 | 119 | As of version 1.2.0, the model integration components are being re-written 120 | and only the CVODE interface is exposed. As such, Assimulo is 121 | a core and required dependency; in the future the other solvers will 122 | be re-enabled. You should first try to install Assimulo via conda 123 | 124 | .. code-block:: bash 125 | 126 | $ mamba install -c conda-forge assimulo 127 | 128 | since this will automatically take care of obtaining necessary compiled 129 | dependencies like sundials. However, for best results you may want to 130 | `manually install Assimulo `_, 131 | since the conda-forge recipe may default to a sundials/OpenBLAS combination 132 | which could degare the performance of the model. 133 | 134 | Numerical solver dependencies 135 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 136 | 137 | - **LSODA** - `scipy `_ or 138 | `odespy `_ 139 | 140 | - **VODE**, **LSODE** - `odespy `_ 141 | 142 | - **CVODE** - `Assimulo `_ 143 | 144 | Recommended additional packages 145 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 146 | 147 | .. note:: 148 | 149 | These are not required for the model to run, but are useful for 150 | post-processing and visualization of the model output. They should be installed 151 | automatically if you install the model from PyPI or the source code repository. 152 | 153 | - `matplotlib `_ 154 | 155 | - `seaborn `_ 156 | 157 | - `PyYAML `_ 158 | 159 | - `xarray `_ 160 | 161 | Testing 162 | +++++++ 163 | 164 | A nose test-suite is under construction. To check that your model is configured 165 | and running correctly, you copy and run the notebook corresponding to the 166 | :ref:`basic run example `, or run the command-line interface 167 | version of the model with the pre-packed simple run case: 168 | 169 | .. code-block:: bash 170 | 171 | $ cd path/to/pyrcel/ 172 | $ ./run_parcel examples/simple.yml 173 | 174 | 175 | Bugs / Suggestions 176 | ++++++++++++++++++ 177 | 178 | The code has an 179 | `issue tracker on github `_ 180 | and I strongly encourage you to note any problems with the model there, such 181 | as typos or weird behavior and results. Furthermore, I'm looking for ways to 182 | expand and extend the model, so if there is something you might wish to see 183 | added, please note it there or `send me an e-mail `_. 184 | The code was written in such a way that it should be trivial to add physics in a modular fashion. 185 | 186 | .. _repository: http://github.com/darothen/pyrcel 187 | -------------------------------------------------------------------------------- /docs/parcel.rst: -------------------------------------------------------------------------------- 1 | .. _parcel: 2 | 3 | .. currentmodule:: pyrcel 4 | 5 | Parcel Model Details 6 | ==================== 7 | 8 | Below is the documentation for the parcel model, which is useful for debugging 9 | and development. For a higher-level overview, see the :ref:`scientific description 10 | `. 11 | 12 | Implementation 13 | -------------- 14 | 15 | .. autoclass:: ParcelModel 16 | :members: set_initial_conditions, run 17 | 18 | Derivative Equation 19 | ------------------- 20 | 21 | .. automethod:: parcel.parcel_ode_sys -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | .. _reference: 2 | 3 | .. currentmodule:: pyrcel 4 | 5 | Reference 6 | ========= 7 | 8 | Main Parcel Model 9 | ----------------- 10 | 11 | The core of the model has its own documentation page, which you can access :ref:`here `. 12 | 13 | .. autosummary:: 14 | :toctree: generated/ 15 | 16 | ParcelModel 17 | 18 | Driver Tools 19 | ------------ 20 | 21 | .. automodule:: pyrcel.driver 22 | 23 | .. autosummary:: 24 | :toctree: generated/ 25 | 26 | run_model 27 | iterate_runs 28 | 29 | Thermodynamics/Kohler Theory 30 | ---------------------------- 31 | 32 | .. automodule:: pyrcel.thermo 33 | 34 | .. autosummary:: 35 | :toctree: generated/ 36 | 37 | dv 38 | ka 39 | rho_air 40 | es 41 | sigma_w 42 | Seq 43 | Seq_approx 44 | kohler_crit 45 | critical_curve 46 | 47 | Aerosols 48 | -------- 49 | 50 | .. automodule:: pyrcel.aerosol 51 | 52 | .. autosummary:: 53 | :toctree: generated/ 54 | :template: class.rst 55 | 56 | AerosolSpecies 57 | 58 | The following are utility functions which might be useful in studying 59 | and manipulating aerosol distributions for use in the :ref:`model ` 60 | or activation routines. 61 | 62 | .. autosummary:: 63 | :toctree: generated/ 64 | 65 | dist_to_conc 66 | 67 | Distributions 68 | ------------- 69 | 70 | .. automodule:: pyrcel.distributions 71 | 72 | .. autosummary:: 73 | :toctree: generated/ 74 | :template: class.rst 75 | 76 | BaseDistribution 77 | Gamma 78 | Lognorm 79 | MultiModeLognorm 80 | 81 | The following dictionaries containing (multi) Lognormal aerosol size distributions have also been saved for convenience: 82 | 83 | 1. ``FN2005_single_modes``: Fountoukis, C., and A. Nenes (2005), Continued development of a cloud droplet formation parameterization for global climate models, J. Geophys. Res., 110, D11212, doi:10.1029/2004JD005591 84 | 2. ``NS2003_single_modes``: Nenes, A., and J. H. Seinfeld (2003), Parameterization of cloud droplet formation in global climate models, J. Geophys. Res., 108, 4415, doi:10.1029/2002JD002911, D14. 85 | 3. ``whitby_distributions``: Whitby, K. T. (1978), The physical characteristics of sulfur aerosols, Atmos. Environ., 12(1-3), 135–159, doi:10.1016/0004-6981(78)90196-8. 86 | 4. ``jaenicke_distributions``: Jaenicke, R. (1993), Tropospheric Aerosols, in *Aerosol-Cloud-Climate Interactions*, P. V. Hobbs, ed., Academic Press, San Diego, CA, pp. 1-31. 87 | 88 | 89 | Activation 90 | ---------- 91 | 92 | .. automodule:: pyrcel.activation 93 | 94 | .. autosummary:: 95 | :toctree: generated/ 96 | 97 | lognormal_activation 98 | binned_activation 99 | multi_mode_activation 100 | arg2000 101 | mbn2014 102 | shipwayabel2010 103 | ming2006 104 | 105 | .. _constants: 106 | 107 | Constants 108 | --------- 109 | 110 | .. automodule:: pyrcel.constants -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | ipython 2 | numba<0.57 3 | numpy<1.25 4 | numpydoc 5 | pandas 6 | pyyaml 7 | scipy<1.11 8 | setuptools 9 | sphinx 10 | sphinx_rtd_theme 11 | xarray 12 | -------------------------------------------------------------------------------- /docs/sci_descr.rst: -------------------------------------------------------------------------------- 1 | .. _sci_descr: 2 | 3 | Scientific Description 4 | ====================== 5 | 6 | The simplest tools available for describing the growth and evolution of 7 | a cloud droplet spectrum from a given population of aerosols are based 8 | on zero-dimensional, adiabatic cloud parcel models. By employing a 9 | detailed description of the condensation of ambient water vapor onto the 10 | growing droplets, these models can accurately describe the activation of 11 | a subset of the aerosol population by predicting how the presence of the 12 | aerosols in the updraft modify the maximum supersaturation achieved as 13 | the parcel rises. Furthermore, these models serve as the theoretical 14 | basis or reference for parameterizations of droplet activation which are 15 | included in modern general circulation models ([Ghan2011]_) . 16 | 17 | The complexity of these models varies with the range of physical processes 18 | one wishes to study. At the most complex end of the spectrum, one might wish 19 | to accurately resolve chemical transfer between the gas and aqueous phase in 20 | addition to physical transformations such as collision/coalescence. One could 21 | also add ice-phase processes to such a model. 22 | 23 | Model Formulation 24 | ----------------- 25 | 26 | The adiabatic cloud parcel model implemented here is based on 27 | models described in the literature ([Nenes2001]_, [SP2006]_,) with some modifications and improvements. For a full description of the parcel model, please see ([Rothenberg2016]_) 28 | The conservation of heat in a parcel of air rising at constant 29 | velocity :math:`V` without entrainment can be written as 30 | 31 | .. math:: \frac{dT}{dt} = -\frac{gV}{c_p} - \frac{L}{c_p}\frac{d w_v}{dt} 32 | :label: dTdt 33 | 34 | where :math:`T` is the parcel air temperature. Assuming adiabaticity 35 | and neglecting entrainment is suitable for studying cloud droplet 36 | formation near the cloud base, where the majority of droplet activation 37 | occurs. Because the mass of water must be conserved as it changes from 38 | the vapor to liquid phase, the relationship 39 | 40 | .. math:: \frac{d w_v}{dt} = - \frac{dw_c}{dt} 41 | :label: dw_vdt 42 | 43 | must hold, where :math:`w_v` and :math:`w_c` are the mass mixing ratios 44 | of water vapor and liquid water (condensed in droplets) in the parcel. 45 | The rate of change of water in the liquid phase in the parcel is 46 | governed solely by condensation onto the existing droplet population. 47 | For a population of :math:`N_i` droplets of radius :math:`r_i`, where 48 | :math:`i=1,\dots,n`, the total condensation rate is given by 49 | 50 | .. math:: \frac{dw_c}{dt} = \frac{4\pi \rho_w}{\rho_a}\sum\limits_{i=1}^nN_ir_i^2\frac{dr_i}{dt} 51 | :label: dw_cdt 52 | 53 | Here, the particle growth rate, :math:`\frac{dr_i}{dt}` is calculated as 54 | 55 | .. math:: \frac{dr_i}{dt} = \frac{G}{r_i}(S-S_{eq}) 56 | :label: dr_idt 57 | 58 | where :math:`G` is a growth coefficient which is a function of the 59 | physical and chemical properties of the particle receiving condensate, 60 | given by 61 | 62 | .. math:: G = \left(\frac{\rho_w R T}{e_s D'_v M_w} + \frac{L\rho_w[(LM_w/RT) - 1]}{k'_a T}\right)^{-1} 63 | :label: G 64 | 65 | Droplet growth via condensation is modulated by the difference between 66 | the environmental supersaturation, :math:`S`, and the droplet 67 | equilibrium supersaturation, :math:`S_{eq}`, predicted from Kohler 68 | theory. To account for differences in aerosol chemical properties which 69 | could affect the ability for particles to uptake water, the 70 | :math:`\kappa`-Köhler theory parameterization ([PK2007]_) is employed in the 71 | model. :math:`\kappa`-Kohler theory utilizes a single parameter to 72 | describe aerosol hygroscopicity, and is widely employed in modeling of 73 | aerosol processes. The hygroscopicity parameter :math:`\kappa` is 74 | related to the water activity of an aqueous aerosol solution by 75 | 76 | .. math:: \frac{1}{a_w} = 1 + \kappa\frac{V_s}{V_w} 77 | 78 | where :math:`V_s` and :math:`V_w` are the volumes of dy particulate 79 | matter and water in the aerosol solution. With this parameter, the full 80 | :math:`\kappa`-Kohler theory may be expressed as 81 | 82 | .. math:: S_{eq} = \frac{r_i^3 - r_{d,i}^3}{r_i^3 - r_{d,i}^3(1-\kappa_i)}\exp\left( \frac{2M_w\sigma_w}{RT\rho_w r_i} \right) - 1 83 | :label: S_eq 84 | 85 | where :math:`r_d` and :math:`r` are the dry aerosol particle size and 86 | the total radius of the wetted aerosol. The surface tension of water, 87 | :math:`\sigma_w`, is dependent on the temperature of the parcel such 88 | that :math:`\sigma_w = 0.0761 - 1.55\times 10^{-4}(T-273.15)` 89 | J/m\ :math:`^2` . Both the diffusivity and thermal conductivity of air 90 | have been modified in the growth coefficient equation to account for 91 | non-continuum effects as droplets grow, and are given by the expressions 92 | 93 | .. math:: D'_v = D_v\bigg/\left(1 + \frac{D_v}{a_c r}\sqrt{\frac{2\pi M_w}{RT}}\right) 94 | 95 | and 96 | 97 | .. math:: k'_a = k_a\bigg/\left(1 + \frac{k_a}{a_T r \rho_a c_p}\sqrt{\frac{2\pi M_a}{RT}} \right) 98 | 99 | In these expressions, the thermal accommodation coefficient, 100 | :math:`a_T`, is assumed to be :math:`0.96` and the condensation 101 | coefficient, :math:`a_c` is taken as unity (see :ref:`Constants `). 102 | Under the adiabatic assumption, the evolution of the parcel’s 103 | supersaturation is governed by the balance between condensational 104 | heating as water vapor condenses onto droplets and cooling induced by 105 | the parcel’s vertical motion, 106 | 107 | .. math:: \frac{dS}{dt} = \alpha V - \gamma\frac{w_c}{dt} 108 | :label: dSdt 109 | 110 | where :math:`\alpha` and :math:`\gamma` are functions which are weakly 111 | dependent on temperature and pressure : 112 | 113 | .. math:: \alpha = \frac{gM_wL}{c_pRT^2} - \frac{gM_a}{RT} 114 | 115 | .. math:: \gamma = \frac{PM_a}{e_sM_w} + \frac{M_wL^2}{c_pRT^2} 116 | 117 | The parcel’s pressure is predicted using the hydrostatic relationship, 118 | accounting for moisture by using virtual temperature (which can always 119 | be diagnosed as the model tracks the specific humidity through the mass 120 | mixing ratio of water vapor), 121 | 122 | .. math:: \frac{dP}{dt} = \frac{-g P V}{R_d T_v} 123 | :label: dPdt 124 | 125 | The equations :eq:`dPdt`, :eq:`dSdt`, :eq:`dw_cdt`, :eq:`dw_vdt`, 126 | and :eq:`dTdt` provide a simple, closed system of ordinary 127 | differential equations which can be numerically integrated forward in 128 | time. Furthermore, this model formulation allows the simulation of an 129 | arbitrary configuration of initial aerosols, in terms of size, number 130 | concentration, and hygroscopicity. Adding additional aerosol size bins 131 | is simply accomplished by tracking one additional size bin in the system 132 | of ODE’s. The important application of this feature is that the model 133 | can be configured to simulate both internal or external mixtures of 134 | aerosols, or some combination thereof. 135 | 136 | Model Implementation and Procedure 137 | ---------------------------------- 138 | 139 | The parcel model described in the previous section was implemented using 140 | a modern modular and object-oriented software engineering framework. 141 | This framework allows the model to be simply configured with myriad 142 | initial conditions and aerosol populations. It also enables model 143 | components - such as the numerical solver or condensation 144 | parameterization - to be swapped and replaced. Most importantly, the use 145 | of object-oriented techniques allows the model to be incorporated into 146 | frameworks which grossly accelerate the speed at which the model can be 147 | evaluated. For instance, although models like the one developed here are 148 | relatively cheap to execute, large ensembles of model runs have been 149 | limited in scope to several hundred or a thousand runs. However, the 150 | framework of this particular parcel model implementation was designed 151 | such that it could be run as a black box as part of a massively-parallel 152 | ensemble driver. 153 | 154 | To run the model, a set of initial conditions needs to be specified, 155 | which includes the updraft speed, the parcel’s initial temperature, 156 | pressure, and supersaturation, and the aerosol population. Given these 157 | parameters, the model calculates an initial equilibrium droplet spectrum 158 | by computing the equilibrium wet radii of each aerosol. This is calculated 159 | numerically from the Kohler equation for each aerosol/proto-droplet, or 160 | numerically by employing the typical Kohler theory approximation 161 | 162 | .. math:: S \approx \frac{A}{r} - \kappa\frac{r_d^3}{r^3} 163 | 164 | These wet radii are used as the initial droplet radii in the simulation. 165 | 166 | Once the initial conditions have been configured, the model is 167 | integrated forward in time with a numerical solver (see :func:`ParcelModel.run` 168 | for more details). The available solvers wrapped here are: 169 | 170 | - LSODA(R) 171 | - LSODE 172 | - (C)VODE 173 | 174 | During the model integration, the size representing each aerosol bin is 175 | allowed to grow via condensation, producing something akin to a moving 176 | grid. In the future, a fixed Eulerian 177 | grid will likely be implemented in the model for comparison. 178 | 179 | 180 | Aerosol Population Specification 181 | -------------------------------- 182 | 183 | The model may be supplied with any arbitrary population of aerosols, 184 | providing the population can be approximated with a sectional 185 | representation. Most commonly, aerosol size distributions are 186 | represented with a continuous lognormal distribution, 187 | 188 | .. math:: n_N(r) = \frac{dN}{d \ln r} = \frac{N_t}{\sqrt{2\pi}\ln \sigma_g}\exp\left(-\frac{ \ln^2(r/\mu_g)}{2\ln^2\sigma_g}\right) 189 | :label: lognormal 190 | 191 | which can be summarized with the set of three parameters, 192 | :math:`(N_t, \mu_g, \sigma_g)` and correspond, respectively, to the 193 | total aerosol number concentration, the geometric mean or number mode 194 | radius, and the geometric standard deviation. Complicated multi-modal 195 | aerosol distributions can often be represented as the sum of several 196 | lognormal distributions. Since the parcel model describes the evolution 197 | of a discrete aerosol size spectrum, can be broken into :math:`n` bins, 198 | and the continuous aerosol size distribution approximated by taking the 199 | number concentration and size at the geometric mean value in each bin, 200 | such that the discrete approximation to the aerosol size distribution 201 | becomes 202 | 203 | .. math:: n_{N,i}(r_i) = \sum\limits_{i=1}^n\frac{N_i}{\sqrt{2\pi}\ln\sigma_g}\exp\left(-\frac{\ln^2(r_i/\mu_g)}{2\ln^2\sigma_g}\right) 204 | 205 | If no bounds on the size range of :math:`r_i` is specified, then the 206 | model pre-computes :math:`n` equally-spaced bins over the logarithm of 207 | :math:`r`, and covers the size range :math:`\mu_g/10\sigma_g` to 208 | :math:`10\sigma_g\mu_g`. It is typical to run the model with :math:`200` 209 | size bins per aerosol mode. Neither this model nor similar ones exhibit 210 | much sensitivity towards the density of the sectional discretization . 211 | 212 | Typically, a single value for hygroscopicity, :math:`\kappa` is 213 | prescribed for each aerosol mode. However, the model tracks a 214 | hygroscopicity parameter for each individual size bin, so size-dependent 215 | aerosol composition can be incorporated into the aerosol population. 216 | This representation of the aerosol population is similar to the external 217 | mixing state assumption. An advantage to using this representation is 218 | that complex mixing states can be represented by adding various size 219 | bins, each with their own number concentration and hygroscopicity. 220 | 221 | .. topic:: Reference 222 | 223 | References 224 | ---------- 225 | 226 | .. [Nenes2001] Nenes, A., Ghan, S., Abdul-Razzak, H., Chuang, P. Y. & Seinfeld, J. H. Kinetic limitations on cloud droplet formation and impact on cloud albedo. Tellus 53, 133–149 (2001). 227 | 228 | .. [SP2006] Seinfeld, J. H. & Pandis, S. N. Atmospheric Chemistry and Physics: From Air Pollution to Climate Change. Atmos. Chem. Phys. 2nd, 1203 (Wiley, 2006). 229 | 230 | .. [Rothenberg2016] Daniel Rothenberg and Chien Wang, 2016: Metamodeling of Droplet Activation for Global Climate Models. *J. Atmos. Sci.*, **73**, 1255–1272. doi: http://dx.doi.org/10.1175/JAS-D-15-0223.1 231 | 232 | .. [PK2007] Petters, M. D. & Kreidenweis, S. M. A single parameter representation of hygroscopic growth and cloud condensation nucleus activity. Atmos. Chem. Phys. 7, 1961–1971 (2007). 233 | 234 | .. [Ghan2011] Ghan, S. J. et al. Droplet nucleation: Physically-based parameterizations and comparative evaluation. J. Adv. Model. Earth Syst. 3, M10001 (2011). -------------------------------------------------------------------------------- /docs/templates/class.rst: -------------------------------------------------------------------------------- 1 | 2 | :mod:`{{module}}`.{{objname}} 3 | {{ underline }}============== 4 | 5 | .. currentmodule:: {{ module }} 6 | 7 | .. autoclass:: {{ objname }} 8 | :members: 9 | :undoc-members: 10 | 11 | {% block methods %} 12 | .. automethod:: __init__ 13 | {% endblock %} 14 | 15 | -------------------------------------------------------------------------------- /examples/simple.yml: -------------------------------------------------------------------------------- 1 | # Simple, example configuration script for a parcel model simulation 2 | 3 | # Save the simulation output in a folder "output/" with the name "simple" 4 | experiment_control: 5 | name: "simple" 6 | output_dir: "output/" 7 | 8 | # Here, we set the model to run in 10 second chunks and save output interpolated to every 9 | # 1 second. It will end after 9999 simulation seconds, unless termination criteria 10 | # (a supersaturation max) is reached, after which it will stop once the simulated parcel has 11 | # ascended 10 more meters. 12 | model_control: 13 | output_dt: 1.0 14 | solver_dt: 10.0 15 | t_end: 9999.0 16 | terminate: true 17 | terminate_depth: 10.0 18 | 19 | # Initialize the model with two aerosol species: 20 | initial_aerosol: 21 | # 1) a sulfate mode, with 250 bins, and 22 | - name: sulfate 23 | distribution: lognormal 24 | distribution_args: { mu: 0.15, N: 1000, sigma: 1.2 } 25 | kappa: 0.54 26 | bins: 250 27 | 28 | # 2) a mixed mode of small particles, with 50 bins 29 | - name: mos 30 | distribution: lognormal 31 | distribution_args: { mu: 0.02, N: 50000, sigma: 1.05 } 32 | kappa: 0.12 33 | bins: 50 34 | 35 | # Set the model initial conditions 36 | initial_conditions: 37 | temperature: 283.15 # K 38 | relative_humidity: 0.95 # %, as a fraction 39 | pressure: 85000.0 # Pa 40 | updraft_speed: 0.44 # m/s 41 | -------------------------------------------------------------------------------- /examples/simple_mono.yml: -------------------------------------------------------------------------------- 1 | # Simple, example configuration script for a parcel model simulation 2 | 3 | # Save the simulation output in a folder "output/" with the name "simple" 4 | experiment_control: 5 | name: "simple" 6 | output_dir: "output/" 7 | 8 | # Here, we set the model to run in 10 second chunks and save output interpolated to every 9 | # 1 second. It will end after 9999 simulation seconds, unless termination criteria 10 | # (a supersaturation max) is reached, after which it will stop once the simulated parcel has 11 | # ascended 10 more meters. 12 | model_control: 13 | output_dt: 0.5 14 | solver_dt: 1.0 15 | t_end: 9999.0 16 | terminate: true 17 | terminate_depth: 25.0 18 | 19 | # Initialize the model with two aerosol species: 20 | initial_aerosol: 21 | # 1) a sulfate mode, with 250 bins, and 22 | - name: sulfate 23 | distribution: lognormal 24 | distribution_args: { mu: 0.15, N: 1000, sigma: 1.2 } 25 | kappa: 0.54 26 | bins: 250 27 | 28 | # Set the model initial conditions 29 | initial_conditions: 30 | temperature: 283.15 # K 31 | relative_humidity: 0.95 # %, as a fraction 32 | pressure: 85000.0 # Pa 33 | updraft_speed: 0.44 # m/s 34 | -------------------------------------------------------------------------------- /examples/simple_supersat.yml: -------------------------------------------------------------------------------- 1 | # Simple, example configuration script for a parcel model simulation 2 | 3 | # Save the simulation output in a folder "output/" with the name "simple" 4 | experiment_control: 5 | name: "simple_supersat" 6 | output_dir: "output/" 7 | 8 | # Here, we set the model to run in 10 second chunks and save output interpolated to every 9 | # 1 second. It will end after 9999 simulation seconds, unless termination criteria 10 | # (a supersaturation max) is reached, after which it will stop once the simulated parcel has 11 | # ascended 10 more meters. 12 | model_control: 13 | output_dt: 1.0 14 | solver_dt: 10.0 15 | t_end: 9999.0 16 | terminate: true 17 | terminate_depth: 10.0 18 | 19 | # Initialize the model with two aerosol species: 20 | initial_aerosol: 21 | # 1) a sulfate mode, with 250 bins, and 22 | - name: sulfate 23 | distribution: lognormal 24 | distribution_args: { mu: 0.15, N: 1000, sigma: 1.2 } 25 | kappa: 0.54 26 | bins: 250 27 | 28 | # 2) a mixed mode of small particles, with 50 bins 29 | - name: mos 30 | distribution: lognormal 31 | distribution_args: { mu: 0.02, N: 50000, sigma: 1.05 } 32 | kappa: 0.12 33 | bins: 50 34 | 35 | # Set the model initial conditions 36 | initial_conditions: 37 | temperature: 283.15 # K 38 | relative_humidity: 1.005 # %, as a fraction 39 | pressure: 85000.0 # Pa 40 | updraft_speed: 0.44 # m/s 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "setuptools-scm>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | description = "pyrcel: 0D adiabatic cloud parcel model" 7 | name = "pyrcel" 8 | authors = [ 9 | { name = "Daniel Rothenberg", email = "daniel@danielrothenberg.com" }, 10 | ] 11 | readme = "README.md" 12 | requires-python = ">=3.8,<3.13" 13 | license = { file = "LICENSE.md" } 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Environment :: Console", 17 | "Intended Audience :: Science/Research", 18 | "License :: OSI Approved :: BSD License", 19 | "Natural Language :: English", 20 | "Operating System :: Unix", 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | "Programming Language :: Python :: 3.11", 25 | "Programming Language :: Python :: 3.12", 26 | "Topic :: Scientific/Engineering :: Atmospheric Science", 27 | ] 28 | dependencies = [ 29 | "numba", 30 | "numpy", 31 | "pandas", 32 | "pyyaml", 33 | "scipy", 34 | "setuptools", 35 | "setuptools-scm", 36 | "xarray", 37 | ] 38 | dynamic = ["version"] 39 | 40 | [project.urls] 41 | Documentation = "https://pyrcel.readthedocs.io/en/latest/" 42 | Repository = "https://github.com/darothen/pyrcel" 43 | 44 | [tools.setuptools] 45 | packages = ["pyrcel"] 46 | 47 | [tool.setuptools.packages] 48 | find = {namespaces = false} 49 | 50 | [project.scripts] 51 | run_parcel = "pyrcel.scripts.run_parcel:run_parcel" 52 | 53 | [tool.setuptools_scm] 54 | version_file = "pyrcel/version.py" 55 | 56 | [tool.pixi.project] 57 | channels = ["conda-forge"] 58 | platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"] 59 | 60 | [tool.pixi.dependencies] 61 | numpy = "<2.2" 62 | assimulo = "==3.6" 63 | 64 | [tool.pixi.pypi-dependencies] 65 | pyrcel = { path = ".", editable = true } 66 | 67 | [tool.pixi.tasks] 68 | run_parcel = "run_parcel" -------------------------------------------------------------------------------- /pyrcel/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Adiabatic Cloud Parcel Model 3 | ---------------------------- 4 | 5 | This module implements a zero-dimensional, constant updraft 6 | adiabatic cloud parcel model, suitable for studying aerosol effects 7 | on droplet activation. 8 | 9 | """ 10 | 11 | from importlib.metadata import version as _version 12 | 13 | try: 14 | __version__ = _version("pyrcel") 15 | except Exception: 16 | # This is a local copy, or a copy that was not installed via setuptools 17 | __version__ = "local" 18 | 19 | __author__ = "Daniel Rothenberg " 20 | 21 | # TODO: Re-factor module-wide implicit imports 22 | from .activation import * 23 | from .aerosol import * 24 | from .distributions import * 25 | from .driver import * 26 | from .parcel import * 27 | from .thermo import * 28 | -------------------------------------------------------------------------------- /pyrcel/_parcel_aux_numba.py: -------------------------------------------------------------------------------- 1 | import numba as nb 2 | import numpy as np 3 | from numba.pycc import CC 4 | 5 | import pyrcel.constants as c 6 | 7 | ## Define double DTYPE 8 | DTYPE = np.float64 9 | 10 | PI = 3.14159265358979323846264338328 11 | N_STATE_VARS = c.N_STATE_VARS 12 | 13 | # AOT/numba stuff 14 | auxcc = CC("parcel_aux_numba") 15 | auxcc.verbose = True 16 | 17 | 18 | ## Auxiliary, single-value calculations with GIL released for derivative 19 | ## calculations 20 | @nb.njit() 21 | @auxcc.export("sigma_w", "f8(f8)") 22 | def sigma_w(T): 23 | """See :func:`pyrcel.thermo.sigma_w` for full documentation""" 24 | return 0.0761 - (1.55e-4) * (T - 273.15) 25 | 26 | 27 | @nb.njit() 28 | @auxcc.export("ka", "f8(f8, f8, f8)") 29 | def ka(T, r, rho): 30 | """See :func:`pyrcel.thermo.ka` for full documentation""" 31 | ka_cont = 1e-3 * (4.39 + 0.071 * T) 32 | denom = 1.0 + (ka_cont / (c.at * r * rho * c.Cp)) * np.sqrt( 33 | (2 * PI * c.Ma) / (c.R * T) 34 | ) 35 | return ka_cont / denom 36 | 37 | 38 | @nb.njit() 39 | @auxcc.export("dv", "f8(f8, f8, f8, f8)") 40 | def dv(T, r, P, accom): 41 | """See :func:`pyrcel.thermo.dv` for full documentation""" 42 | P_atm = P * 1.01325e-5 # Pa -> atm 43 | dv_cont = 1e-4 * (0.211 / P_atm) * ((T / 273.0) ** 1.94) 44 | denom = 1.0 + (dv_cont / (accom * r)) * np.sqrt((2 * PI * c.Mw) / (c.R * T)) 45 | return dv_cont / denom 46 | 47 | 48 | @nb.njit() 49 | @auxcc.export("es", "f8(f8)") 50 | def es(T): 51 | """See :func:`pyrcel.thermo.es` for full documentation""" 52 | return 611.2 * np.exp(17.67 * T / (T + 243.5)) 53 | 54 | 55 | @nb.njit() 56 | @auxcc.export("Seq", "f8(f8, f8, f8)") 57 | def Seq(r, r_dry, T, kappa): 58 | """See :func:`pyrcel.thermo.Seq` for full documentation.""" 59 | A = (2.0 * c.Mw * sigma_w(T)) / (c.R * T * c.rho_w * r) 60 | B = 1.0 61 | if kappa > 0.0: 62 | B = (r**3 - (r_dry**3)) / (r**3 - (r_dry**3) * (1.0 - kappa)) 63 | return np.exp(A) * B - 1.0 64 | 65 | 66 | ## RHS Derivative callback function 67 | @nb.njit(parallel=True) 68 | @auxcc.export("parcel_ode_sys", "f8[:](f8[:], f8, i4, f8[:], f8[:], f8, f8[:], f8)") 69 | def parcel_ode_sys(y, t, nr, r_drys, Nis, V, kappas, accom): 70 | """Calculates the instantaneous time-derivative of the parcel model system. 71 | 72 | Given a current state vector `y` of the parcel model, computes the tendency 73 | of each term including thermodynamic (pressure, temperature, etc) and aerosol 74 | terms. The basic aerosol properties used in the model must be passed along 75 | with the state vector (i.e. if being used as the callback function in an ODE 76 | solver). 77 | 78 | Parameters 79 | ---------- 80 | y : array_like 81 | Current state of the parcel model system, 82 | * y[0] = altitude, m 83 | * y[1] = Pressure, Pa 84 | * y[2] = temperature, K 85 | * y[3] = water vapor mass mixing ratio, kg/kg 86 | * y[4] = cloud liquid water mass mixing ratio, kg/kg 87 | * y[5] = cloud ice water mass mixing ratio, kg/kg 88 | * y[6] = parcel supersaturation 89 | * y[7:] = aerosol bin sizes (radii), m 90 | t : float 91 | Current simulation time, in seconds. 92 | nr : Integer 93 | Number of aerosol radii being tracked. 94 | r_drys : array_like 95 | Array recording original aerosol dry radii, m. 96 | Nis : array_like 97 | Array recording aerosol number concentrations, 1/(m**3). 98 | V : float 99 | Updraft velocity, m/s. 100 | kappas : array_like 101 | Array recording aerosol hygroscopicities. 102 | accom : float, optional (default=:const:`constants.ac`) 103 | Condensation coefficient. 104 | 105 | Returns 106 | ------- 107 | x : array_like 108 | Array of shape (``nr``+7, ) containing the evaluated parcel model 109 | instaneous derivative. 110 | 111 | Notes 112 | ----- 113 | This function is implemented using numba; it does not need to be just-in- 114 | time compiled in order ot function correctly, but it is set up ahead of time 115 | so that the internal loop over each bin growth term is parallelized. 116 | 117 | """ 118 | z = y[0] 119 | P = y[1] 120 | T = y[2] 121 | wv = y[3] 122 | wc = y[4] 123 | wi = y[5] 124 | S = y[6] 125 | rs = y[N_STATE_VARS:] 126 | 127 | T_c = T - 273.15 # convert temperature to Celsius 128 | pv_sat = es(T_c) # saturation vapor pressure 129 | wv_sat = wv / (S + 1.0) # saturation mixing ratio 130 | Tv = (1.0 + 0.61 * wv) * T 131 | e = (1.0 + S) * pv_sat # water vapor pressure 132 | 133 | ## Compute air densities from current state 134 | rho_air = P / c.Rd / Tv 135 | #: TODO - port to parcel.py 136 | rho_air_dry = (P - e) / c.Rd / T 137 | 138 | ## Begin computing tendencies 139 | dP_dt = -1.0 * rho_air * c.g * V 140 | dwc_dt = 0.0 141 | # drs_dt = np.empty(shape=(nr), dtype=DTYPE) 142 | drs_dt = np.empty_like(rs) 143 | 144 | for i in nb.prange(nr): 145 | r = rs[i] 146 | r_dry = r_drys[i] 147 | kappa = kappas[i] 148 | Ni = Nis[i] 149 | 150 | ## Non-continuum diffusivity/thermal conductivity of air near 151 | ## near particle 152 | dv_r = dv(T, r, P, accom) 153 | ka_r = ka(T, r, rho_air) 154 | 155 | ## Condensation coefficient 156 | G_a = (c.rho_w * c.R * T) / (pv_sat * dv_r * c.Mw) 157 | G_b = (c.L * c.rho_w * ((c.L * c.Mw / (c.R * T)) - 1.0)) / (ka_r * T) 158 | G = 1.0 / (G_a + G_b) 159 | 160 | ## Difference between ambient and particle equilibrium supersaturation 161 | Seq_r = Seq(r, r_dry, T, kappa) 162 | delta_S = S - Seq_r 163 | 164 | ## Size and liquid water tendencies 165 | dr_dt = (G / r) * delta_S 166 | dwc_dt += ( 167 | Ni * r * r * dr_dt 168 | ) # Contribution to liq. water tendency due to growth 169 | drs_dt[i] = dr_dt 170 | 171 | dwc_dt *= 4.0 * PI * c.rho_w / rho_air_dry # Hydrated aerosol size -> water mass 172 | # use rho_air_dry for mixing ratio definition consistency 173 | # No freezing implemented yet 174 | dwi_dt = 0.0 175 | 176 | ## MASS BALANCE CONSTRAINT 177 | dwv_dt = -1.0 * (dwc_dt + dwi_dt) 178 | 179 | ## ADIABATIC COOLING 180 | dT_dt = -c.g * V / c.Cp - c.L * dwv_dt / c.Cp 181 | 182 | dz_dt = V 183 | 184 | """ Alternative methods for calculation supersaturation tendency 185 | # Used eq 12.28 from Pruppacher and Klett in stead of (9) from Nenes et al, 2001 186 | #cdef double S_a, S_b, S_c, dS_dt 187 | #cdef double S_b_old, S_c_old, dS_dt_old 188 | #S_a = (S+1.0) 189 | 190 | ## NENES (2001) 191 | #S_b_old = dT_dt*wv_sat*(17.67*243.5)/((243.5+(Tv-273.15))**2.) 192 | #S_c_old = (rho_air*g*V)*(wv_sat/P)*((0.622*L)/(Cp*Tv) - 1.0) 193 | #dS_dt_old = (1./wv_sat)*(dwv_dt - S_a*(S_b_old-S_c_old)) 194 | 195 | ## PRUPPACHER (PK 1997) 196 | #S_b = dT_dt*0.622*L/(Rd*T**2.) 197 | #S_c = g*V/(Rd*T) 198 | #dS_dt = P*dwv_dt/(0.622*es(T-273.15)) - S_a*(S_b + S_c) 199 | 200 | ## SEINFELD (SP 1998) 201 | #S_b = L*Mw*dT_dt/(R*T**2.) 202 | #S_c = V*g*Ma/(R*T) 203 | #dS_dt = dwv_dt*(Ma*P)/(Mw*es(T-273.15)) - S_a*(S_b + S_c) 204 | """ 205 | 206 | ## GHAN (2011) 207 | alpha = (c.g * c.Mw * c.L) / (c.Cp * c.R * (T**2)) 208 | alpha -= (c.g * c.Ma) / (c.R * T) 209 | gamma = (P * c.Ma) / (c.Mw * pv_sat) 210 | gamma += (c.Mw * c.L * c.L) / (c.Cp * c.R * T * T) 211 | dS_dt = alpha * V - gamma * dwc_dt 212 | 213 | # x = np.empty(shape=(nr+N_STATE_VARS), dtype='d') 214 | x = np.empty_like(y) 215 | x[0] = dz_dt 216 | x[1] = dP_dt 217 | x[2] = dT_dt 218 | x[3] = dwv_dt 219 | x[4] = dwc_dt 220 | x[5] = dwi_dt 221 | x[6] = dS_dt 222 | x[N_STATE_VARS:] = drs_dt[:] 223 | 224 | return x 225 | -------------------------------------------------------------------------------- /pyrcel/activation.py: -------------------------------------------------------------------------------- 1 | """ Collection of activation parameterizations. 2 | 3 | """ 4 | import numpy as np 5 | from scipy.special import erfc 6 | 7 | from . import constants as c 8 | from .thermo import dv, dv_cont, es, ka_cont, kohler_crit, sigma_w 9 | 10 | 11 | def _unpack_aerosols(aerosols): 12 | """Convert a list of :class:`AerosolSpecies` into lists of aerosol properties. 13 | 14 | Parameters 15 | ---------- 16 | aerosols : list of :class:`AerosolSpecies` 17 | 18 | Returns 19 | ------- 20 | dictionary of lists of aerosol properties 21 | 22 | """ 23 | 24 | species, mus, sigmas, kappas, Ns = [], [], [], [], [] 25 | for a in aerosols: 26 | species.append(a.species) 27 | mus.append(a.distribution.mu) 28 | sigmas.append(a.distribution.sigma) 29 | Ns.append(a.distribution.N) 30 | kappas.append(a.kappa) 31 | 32 | species = np.asarray(species) 33 | mus = np.asarray(mus) 34 | sigmas = np.asarray(sigmas) 35 | kappas = np.asarray(kappas) 36 | Ns = np.asarray(Ns) 37 | 38 | return dict(species=species, mus=mus, sigmas=sigmas, Ns=Ns, kappas=kappas) 39 | 40 | 41 | def lognormal_activation(smax, mu, sigma, N, kappa, sgi=None, T=None, approx=True): 42 | """Compute the activated number/fraction from a lognormal mode 43 | 44 | Parameters 45 | ---------- 46 | smax : float 47 | Maximum parcel supersaturation 48 | mu, sigma, N : floats 49 | Lognormal mode parameters; ``mu`` should be in meters 50 | kappa : float 51 | Hygroscopicity of material in aerosol mode 52 | sgi :float, optional 53 | Modal critical supersaturation; if not provided, this method will 54 | go ahead and compute them, but a temperature ``T`` must also be passed 55 | T : float, optional 56 | Parcel temperature; only necessary if no ``sgi`` was passed 57 | approx : boolean, optional (default=False) 58 | If computing modal critical supersaturations, use the approximated 59 | Kohler theory 60 | 61 | Returns 62 | ------- 63 | N_act, act_frac : floats 64 | Activated number concentration and fraction for the given mode 65 | 66 | """ 67 | 68 | if not sgi: 69 | assert T 70 | _, sgi = kohler_crit(T, mu, kappa, approx) 71 | 72 | ui = 2.0 * np.log(sgi / smax) / (3.0 * np.sqrt(2.0) * np.log(sigma)) 73 | N_act = 0.5 * N * erfc(ui) 74 | act_frac = N_act / N 75 | 76 | return N_act, act_frac 77 | 78 | 79 | def binned_activation(Smax, T, rs, aerosol, approx=False): 80 | """Compute the activation statistics of a given aerosol, its transient 81 | size distribution, and updraft characteristics. Following Nenes et al, 2001 82 | also compute the kinetic limitation statistics for the aerosol. 83 | 84 | Parameters 85 | ---------- 86 | Smax : float 87 | Environmental maximum supersaturation. 88 | T : float 89 | Environmental temperature. 90 | rs : array of floats 91 | Wet radii of aerosol/droplet population. 92 | aerosol : :class:`AerosolSpecies` 93 | The characterization of the dry aerosol. 94 | approx : boolean 95 | Approximate Kohler theory rather than include detailed calculation (default False) 96 | 97 | Returns 98 | ------- 99 | eq, kn: floats 100 | Activated fractions 101 | alpha : float 102 | N_kn / N_eq 103 | phi : float 104 | N_unact / N_kn 105 | 106 | """ 107 | kappa = aerosol.kappa 108 | r_drys = aerosol.r_drys 109 | Nis = aerosol.Nis 110 | N_tot = np.sum(Nis) 111 | 112 | # Coerce the reference wet droplet sizes (rs) to an array if they were passed as 113 | # a Series 114 | if hasattr(rs, "values"): 115 | rs = rs.values 116 | 117 | r_crits, s_crits = list( 118 | zip(*[kohler_crit(T, r_dry, kappa, approx) for r_dry in r_drys]) 119 | ) 120 | s_crits = np.array(s_crits) 121 | r_crits = np.array(r_crits) 122 | 123 | # Equilibrium calculation - all aerosol whose critical supersaturation is 124 | # less than the environmental supersaturation 125 | activated_eq = Smax >= s_crits 126 | N_eq = np.sum(Nis[activated_eq]) 127 | eq_frac = N_eq / N_tot 128 | 129 | # Kinetic calculation - find the aerosol with the smallest dry radius which has 130 | # grown past its critical radius, and count this aerosol and 131 | # all larger ones as activated. This will include some large 132 | # particles which haven't grown to critical size yet. 133 | is_r_large = rs >= r_crits 134 | if not np.any(is_r_large): 135 | N_kn, kn_frac = 0.0, 0.0 136 | phi = 1.0 137 | else: 138 | smallest_ind = np.where(is_r_large)[0][0] 139 | N_kn = np.sum(Nis[smallest_ind:]) 140 | kn_frac = N_kn / N_tot 141 | 142 | # Unactivated - all droplets smaller than their critical size 143 | droplets = list(range(smallest_ind, len(Nis))) 144 | Nis_drops = Nis[droplets] 145 | r_crits_drops = r_crits[droplets] 146 | rs_drops = rs[droplets] 147 | too_small = rs_drops < r_crits_drops 148 | 149 | N_unact = np.sum(Nis_drops[too_small]) 150 | 151 | phi = N_unact / N_kn 152 | 153 | alpha = N_kn / N_eq 154 | 155 | return eq_frac, kn_frac, alpha, phi 156 | 157 | 158 | # noinspection PyUnresolvedReferences 159 | def multi_mode_activation(Smax, T, aerosols, rss): 160 | """Compute the activation statistics of a multi-mode, binned_activation 161 | aerosol population. 162 | 163 | Parameters 164 | ---------- 165 | Smax : float 166 | Environmental maximum supersaturation. 167 | T : float 168 | Environmental temperature. 169 | aerosol : array of :class:`AerosolSpecies` 170 | The characterizations of the dry aerosols. 171 | rss : array of arrays of floats 172 | Wet radii corresponding to each aerosol/droplet population. 173 | 174 | Returns 175 | ------- 176 | eqs, kns : lists of floats 177 | The activated fractions of each aerosol population. 178 | 179 | """ 180 | act_fracs = [] 181 | for rs, aerosol in zip(rss, aerosols): 182 | eq, kn, _, _ = binned_activation(Smax, T, rs, aerosol) 183 | act_fracs.append([eq, kn]) 184 | return list(zip(*act_fracs)) 185 | 186 | 187 | ###################################################################### 188 | # Code implementing the Nenes and Seinfeld (2003) parameterization, 189 | # with improvements from Fountoukis and Nenes (2005), Barahona 190 | # et al (2010), and Morales Betancourt and Nenes (2014) 191 | ###################################################################### 192 | 193 | 194 | def _vpres(T): 195 | """Polynomial approximation of saturated water vapour pressure as 196 | a function of temperature. 197 | 198 | Parameters 199 | ---------- 200 | T : float 201 | Ambient temperature, in Kelvin 202 | 203 | Returns 204 | ------- 205 | float 206 | Saturated water vapor pressure expressed in mb 207 | 208 | See Also 209 | -------- 210 | es 211 | 212 | """ 213 | # Coefficients for vapor pressure approximation 214 | A = [ 215 | 6.107799610e0, 216 | 4.436518521e-1, 217 | 1.428945805e-2, 218 | 2.650648471e-4, 219 | 3.031240396e-6, 220 | 2.034080948e-8, 221 | 6.136820929e-11, 222 | ] 223 | T -= 273 # Convert from Kelvin to C 224 | vp = A[-1] * T 225 | for ai in reversed(A[1:-1]): 226 | vp = (vp + ai) * T 227 | vp += A[0] 228 | return vp 229 | 230 | 231 | def _erfp(x): 232 | """Polynomial approximation to error function""" 233 | AA = [0.278393, 0.230389, 0.000972, 0.078108] 234 | y = np.abs(1.0 * x) 235 | axx = 1.0 + y * (AA[0] + y * (AA[1] + y * (AA[2] + y * AA[3]))) 236 | axx = axx * axx 237 | axx = axx * axx 238 | axx = 1.0 - (1.0 / axx) 239 | 240 | if x <= 0.0: 241 | axx = -1.0 * axx 242 | 243 | return axx 244 | 245 | 246 | def mbn2014( 247 | V, 248 | T, 249 | P, 250 | aerosols=[], 251 | accom=c.ac, 252 | mus=[], 253 | sigmas=[], 254 | Ns=[], 255 | kappas=[], 256 | xmin=1e-5, 257 | xmax=0.1, 258 | tol=1e-6, 259 | max_iters=100, 260 | ): 261 | """Computes droplet activation using an iterative scheme. 262 | 263 | This method implements the iterative activation scheme under development by 264 | the Nenes' group at Georgia Tech. It encompasses modifications made over a 265 | sequence of several papers in the literature, culminating in [MBN2014]. The 266 | implementation here overrides some of the default physical constants and 267 | thermodynamic calculations to ensure consistency with a reference implementation. 268 | 269 | Parameters 270 | ---------- 271 | V, T, P : floats 272 | Updraft speed (m/s), parcel temperature (K) and pressure (Pa) 273 | aerosols : list of :class:`AerosolSpecies` 274 | List of the aerosol population in the parcel; can be omitted if ``mus``, 275 | ``sigmas``, ``Ns``, and ``kappas`` are present. If both supplied, will 276 | use ``aerosols``. 277 | accom : float, optional (default=:const:`constants.ac`) 278 | Condensation/uptake accomodation coefficient 279 | mus, sigmas, Ns, kappas : lists of floats 280 | Lists of aerosol population parameters; must be present if ``aerosols`` 281 | is not passed, but ``aerosols`` overrides if both are present 282 | xmin, xmax : floats, opional 283 | Minimum and maximum supersaturation for bisection 284 | tol : float, optional 285 | Convergence tolerance threshold for supersaturation, in decimal units 286 | max_iters : int, optional 287 | Maximum number of bisections before exiting convergence 288 | 289 | Returns 290 | ------- 291 | smax, N_acts, act_fracs : lists of floats 292 | Maximum parcel supersaturation and the number concentration/activated 293 | fractions for each mode 294 | 295 | .. [MBN2014] Morales Betancourt, R. and Nenes, A.: Droplet activation 296 | parameterization: the population splitting concept revisited, Geosci. 297 | Model Dev. Discuss., 7, 2903-2932, doi:10.5194/gmdd-7-2903-2014, 2014. 298 | 299 | """ 300 | 301 | # TODO: Convert mutable function arguments to None 302 | 303 | if aerosols: 304 | d = _unpack_aerosols(aerosols) 305 | mus = d["mus"] 306 | sigmas = d["sigmas"] 307 | kappas = d["kappas"] 308 | Ns = d["Ns"] 309 | else: 310 | # Assert that the aerosol was already decomposed into component vars 311 | assert mus is not None 312 | assert sigmas is not None 313 | assert Ns is not None 314 | assert kappas is not None 315 | 316 | # Convert sizes/number concentrations to diameters + SI units 317 | mus = np.asarray(mus) 318 | Ns = np.asarray(Ns) 319 | 320 | dpgs = 2 * (mus * 1e-6) 321 | Ns = Ns * 1e6 322 | 323 | nmodes = len(Ns) 324 | 325 | # Overriding using Nenes' physical constants, for numerical accuracy comparisons 326 | Ma = c.Ma # 29e-3 327 | g = c.g # 9.81 328 | Mw = c.Mw # 18e-3 329 | R = c.R # 8.31 330 | Rho_w = c.rho_w # 1e3 331 | L = c.L # 2.25e6 332 | Cp = c.Cp # 1.0061e3 333 | 334 | # Thermodynamic environmental values to be set 335 | # TODO: Revert to functions in 'thermo' module 336 | # rho_a = rho_air(T, P, RH=0.0) # MBN2014: could set RH=1.0, account for moisture 337 | rho_a = P * Ma / R / T 338 | aka = ka_cont(T) # MBN2014: could use thermo.ka(), include air density 339 | # surt = sigma_w(T) 340 | surt = 0.0761 - 1.55e-4 * (T - 273.0) 341 | 342 | # Compute modal critical supersaturation (Sg) for each mode, corresponding 343 | # to the critical supersaturation at the median diameter of each mode 344 | A = 4.0 * Mw * surt / R / T / Rho_w 345 | # There are three different ways to do this: 346 | # 1) original formula from MBN2014 347 | f = lambda T, dpg, kappa: np.sqrt((A**3.0) * 4.0 / 27.0 / kappa / (dpg**3.0)) 348 | # 2) detailed kohler calculation 349 | # f = lambda T, dpg, kappa: kohler_crit(T, dpg/2., kappa) 350 | # 3) approximate kohler calculation 351 | # f = lambda T, dpg, kappa: kohler_crit(T, dpg/2., kappa, approx=True) 352 | # and the possibility of a correction factor: 353 | f2 = lambda T, dpg, kappa: np.exp(f(T, dpg, kappa)) - 1.0 354 | sgis = [f2(T, dpg, kappa) for dpg, kappa in zip(dpgs, kappas)] 355 | 356 | # Calculate the correction factor for the water vapor diffusivity. 357 | # Note that the Nenes' form is the exact same as the continuum form in this package 358 | # dv_orig = dv_cont(T, P) 359 | dv_orig = 1e-4 * (0.211 / (P / 1.013e5) * (T / 273.0) ** 1.94) 360 | dv_big = 5.0e-6 361 | dv_low = 1e-6 * 0.207683 * (accom ** (-0.33048)) 362 | 363 | coef = (2.0 * np.pi * Mw / (R * T)) ** 0.5 364 | # TODO: Formatting on this average dv equation 365 | dv_ave = (dv_orig / (dv_big - dv_low)) * ( 366 | (dv_big - dv_low) 367 | - (2 * dv_orig / accom) 368 | * coef 369 | * ( 370 | np.log( 371 | (dv_big + (2 * dv_orig / accom) * coef) 372 | / (dv_low + (2 * dv_orig / accom) * coef) 373 | ) 374 | ) 375 | ) 376 | 377 | # Setup constants used in supersaturation equation 378 | wv_pres_sat = _vpres(T) * (1e5 / 1e3) # MBN2014: could also use thermo.es() 379 | alpha = g * Mw * L / Cp / R / T / T - g * Ma / R / T 380 | beta1 = P * Ma / wv_pres_sat / Mw + Mw * L * L / Cp / R / T / T 381 | beta2 = ( 382 | R * T * Rho_w / wv_pres_sat / dv_ave / Mw / 4.0 383 | + L * Rho_w / 4.0 / aka / T * (L * Mw / R / T - 1.0) 384 | ) # this is 1/G 385 | beta = 0.5 * np.pi * beta1 * Rho_w / beta2 / alpha / V / rho_a 386 | 387 | cf1 = 0.5 * np.sqrt((1 / beta2) / (alpha * V)) 388 | cf2 = A / 3.0 389 | 390 | def _sintegral(smax): 391 | """Integrate the activation equation, using ``spar`` as the population 392 | splitting threshold. 393 | 394 | Inherits the workspace thermodynamic/constant variables from one level 395 | of scope higher. 396 | """ 397 | 398 | zeta_c = ((16.0 / 9.0) * alpha * V * beta2 * (A**2)) ** 0.25 399 | delta = 1.0 - (zeta_c / smax) ** 4.0 # spar -> smax 400 | critical = delta <= 0.0 401 | 402 | if critical: 403 | ratio = ( 404 | (2e7 / 3.0) * A * (smax ** (-0.3824) - zeta_c ** (-0.3824)) 405 | ) # Computing sp1 and sp2 (sp1 = sp2) 406 | ratio = 1.0 / np.sqrt(2.0) + ratio 407 | 408 | if ratio > 1.0: 409 | ratio = 1.0 # cap maximum value 410 | ssplt2 = smax * ratio 411 | 412 | else: 413 | ssplt1 = 0.5 * (1.0 - np.sqrt(delta)) # min root --> sp1 414 | ssplt2 = 0.5 * (1.0 + np.sqrt(delta)) # max root --> sp2 415 | ssplt1 = np.sqrt(ssplt1) * smax 416 | ssplt2 = np.sqrt(ssplt2) * smax 417 | 418 | ssplt = ssplt2 # secondary partitioning supersaturation 419 | 420 | # Computing the condensation integrals I1 and I2 421 | sum_integ1 = 0.0 422 | sum_integ2 = 0.0 423 | 424 | integ1 = np.empty(nmodes) 425 | integ2 = np.empty(nmodes) 426 | 427 | sqrtwo = np.sqrt(2.0) 428 | for i in range(nmodes): 429 | log_sigma = np.log(sigmas[i]) # ln(sigma_i) 430 | log_sgi_smax = np.log(sgis[i] / smax) # ln(sg_i/smax) 431 | log_sgi_sp2 = np.log(sgis[i] / ssplt2) # ln(sg_i/sp2 432 | 433 | u_sp2 = 2.0 * log_sgi_sp2 / (3.0 * sqrtwo * log_sigma) 434 | u_smax = 2.0 * log_sgi_smax / (3.0 * sqrtwo * log_sigma) 435 | # Subtract off the integrating factor 436 | log_factor = 3.0 * log_sigma / (2.0 * sqrtwo) 437 | 438 | d_eq = ( 439 | A * 2.0 / sgis[i] / 3.0 / np.sqrt(3.0) 440 | ) # Dpc/sqrt(3) - equilibrium diameter 441 | 442 | erf_u_sp2 = _erfp(u_sp2 - log_factor) # ERF2 443 | erf_u_smax = _erfp(u_smax - log_factor) # ERF3 444 | 445 | integ2[i] = ( 446 | np.exp(9.0 / 8.0 * log_sigma * log_sigma) * Ns[i] / sgis[i] 447 | ) * (erf_u_sp2 - erf_u_smax) 448 | 449 | if critical: 450 | u_sp_plus = sqrtwo * log_sgi_sp2 / 3.0 / log_sigma 451 | erf_u_sp_plus = _erfp(u_sp_plus - log_factor) 452 | 453 | integ1[i] = 0.0 454 | I_extra_term = ( 455 | Ns[i] 456 | * d_eq 457 | * np.exp((9.0 / 8.0) * log_sigma * log_sigma) 458 | * (1.0 - erf_u_sp_plus) 459 | * ((beta2 * alpha * V) ** 0.5) 460 | ) # 'inertially limited' particles 461 | 462 | else: 463 | g_i = np.exp((9.0 / 2.0) * log_sigma * log_sigma) 464 | log_sgi_sp1 = np.log(sgis[i] / ssplt1) # ln(sg_i/sp1) 465 | 466 | int1_partial2 = Ns[i] * smax # building I1(0, sp2), eq (B4) 467 | int1_partial2 *= (1.0 - _erfp(u_sp2)) - 0.5 * ( 468 | (sgis[i] / smax) ** 2.0 469 | ) * g_i * (1.0 - _erfp(u_sp2 + 3.0 * log_sigma / sqrtwo)) 470 | 471 | u_sp1 = 2.0 * log_sgi_sp1 / (3.0 * sqrtwo * log_sigma) 472 | int1_partial1 = Ns[i] * smax # building I1(0, sp1), eq (B4) 473 | int1_partial1 *= (1.0 - _erfp(u_sp1)) - 0.5 * ( 474 | (sgis[i] / smax) ** 2.0 475 | ) * g_i * (1.0 - _erfp(u_sp1 + 3.0 * log_sigma / sqrtwo)) 476 | 477 | integ1[i] = int1_partial2 - int1_partial1 # I1(sp1, sp2) 478 | 479 | u_sp1_inertial = sqrtwo * log_sgi_sp1 / 3.0 / log_sigma 480 | erf_u_sp1 = _erfp(u_sp1_inertial - log_factor) 481 | I_extra_term = ( 482 | Ns[i] 483 | * d_eq 484 | * np.exp((9.0 / 8.0) * log_sigma * log_sigma) 485 | * (1.0 - erf_u_sp1) 486 | * ((beta2 * alpha * V) ** 0.5) 487 | ) # 'inertially limited' particles 488 | 489 | # Compute total integral values 490 | sum_integ1 += integ1[i] + I_extra_term 491 | sum_integ2 += integ2[i] 492 | 493 | return sum_integ1, sum_integ2 494 | 495 | # Bisection routine 496 | # Initial calculation 497 | x1 = xmin # min cloud super sat -> 0. 498 | integ1, integ2 = _sintegral(x1) 499 | # Note that we ignore the contribution from the FHH integral term in this 500 | # implementation 501 | y1 = (integ1 * cf1 + integ2 * cf2) * beta * x1 - 1.0 502 | 503 | x2 = xmax # max cloud super sat 504 | integ1, integ2 = _sintegral(x2) 505 | y2 = (integ1 * cf1 + integ2 * cf2) * beta * x2 - 1.0 506 | 507 | # Iteration of bisection routine to convergence 508 | iter_count = 0 509 | for i in range(max_iters): 510 | iter_count += 1 511 | 512 | x3 = 0.5 * (x1 + x2) 513 | integ1, integ2 = _sintegral(x3) 514 | y3 = (integ1 * cf1 + integ2 * cf2) * beta * x3 - 1.0 515 | 516 | if (y1 * y3) <= 0.0: # different signs 517 | y2 = y3 518 | x2 = x3 519 | else: 520 | y1 = y3 521 | x1 = x3 522 | if np.abs(x2 - x1 <= tol * x1): 523 | break 524 | 525 | # Finalize bisection with one more intersection 526 | x3 = 0.5 * (x1 + x2) 527 | integ1, integ2 = _sintegral(x3) 528 | y3 = (integ1 * cf1 + integ2 * cf2) * beta * x3 - 1.0 529 | 530 | smax = x3 531 | 532 | n_acts, act_fracs = [], [] 533 | for mu, sigma, N, kappa, sgi in zip(mus, sigmas, Ns, kappas, sgis): 534 | N_act, act_frac = lognormal_activation( 535 | smax, mu * 1e-6, sigma, N * 1e-6, kappa, sgi 536 | ) 537 | n_acts.append(N_act) 538 | act_fracs.append(act_frac) 539 | 540 | return smax, n_acts, act_fracs 541 | 542 | 543 | def arg2000( 544 | V, 545 | T, 546 | P, 547 | aerosols=[], 548 | accom=c.ac, 549 | mus=[], 550 | sigmas=[], 551 | Ns=[], 552 | kappas=[], 553 | min_smax=False, 554 | ): 555 | """Computes droplet activation using a psuedo-analytical scheme. 556 | 557 | This method implements the psuedo-analytical scheme of [ARG2000] to 558 | calculate droplet activation an an adiabatically ascending parcel. It 559 | includes the extension to multiple lognormal modes, and the correction 560 | for non-unity condensation coefficient [GHAN2011]. 561 | 562 | To deal with multiple aerosol modes, the scheme includes an expression 563 | trained on the mode std deviations, :math:`\sigma_i` 564 | 565 | .. math:: 566 | 567 | S_\\text{max} = 1 \\bigg/ \sqrt{\sum \\frac{1}{S^2_\text{mi}}\left[H(f_i, g_i)\right]} 568 | 569 | This effectively combines the supersaturation maximum for each mode into 570 | a single value representing competition between modes. An alternative approach, 571 | which assumes the mode which produces the smallest predict Smax sets a 572 | first-order control on the activation, is also available 573 | 574 | Parameters 575 | ---------- 576 | V, T, P : floats 577 | Updraft speed (m/s), parcel temperature (K) and pressure (Pa) 578 | aerosols : list of :class:`AerosolSpecies` 579 | List of the aerosol population in the parcel; can be omitted if ``mus``, 580 | ``sigmas``, ``Ns``, and ``kappas`` are present. If both supplied, will 581 | use ``aerosols``. 582 | accom : float, optional (default=:const:`constants.ac`) 583 | Condensation/uptake accomodation coefficient 584 | mus, sigmas, Ns, kappas : lists of floats 585 | Lists of aerosol population parameters; must be present if ``aerosols`` 586 | is not passed, but ``aerosols`` overrides if both are present. 587 | min_smax : boolean, optional 588 | If `True`, will use alternative formulation for parameterizing competition 589 | described above. 590 | 591 | Returns 592 | ------- 593 | smax, N_acts, act_fracs : lists of floats 594 | Maximum parcel supersaturation and the number concentration/activated 595 | fractions for each mode 596 | 597 | .. [ARG2000] Abdul-Razzak, H., and S. J. Ghan (2000), A parameterization of 598 | aerosol activation: 2. Multiple aerosol types, J. Geophys. Res., 105(D5), 599 | 6837-6844, doi:10.1029/1999JD901161. 600 | 601 | .. [GHAN2011] Ghan, S. J. et al (2011) Droplet Nucleation: Physically-based 602 | Parameterization and Comparative Evaluation, J. Adv. Model. Earth Syst., 603 | 3, doi:10.1029/2011MS000074 604 | 605 | """ 606 | 607 | if aerosols: 608 | d = _unpack_aerosols(aerosols) 609 | mus = d["mus"] 610 | sigmas = d["sigmas"] 611 | kappas = d["kappas"] 612 | Ns = d["Ns"] 613 | else: 614 | # Assert that the aerosol was already decomposed into component vars 615 | assert mus is not None 616 | assert sigmas is not None 617 | assert Ns is not None 618 | assert kappas is not None 619 | 620 | # Originally from Abdul-Razzak 1998 w/ Ma. Need kappa formulation 621 | wv_sat = es(T - 273.15) 622 | alpha = (c.g * c.Mw * c.L) / (c.Cp * c.R * (T**2)) - (c.g * c.Ma) / (c.R * T) 623 | gamma = (c.R * T) / (wv_sat * c.Mw) + (c.Mw * (c.L**2)) / (c.Cp * c.Ma * T * P) 624 | 625 | # Condensation effects - base calculation 626 | G_a = (c.rho_w * c.R * T) / (wv_sat * dv_cont(T, P) * c.Mw) 627 | G_b = (c.L * c.rho_w * ((c.L * c.Mw / (c.R * T)) - 1)) / (ka_cont(T) * T) 628 | G_0 = 1.0 / (G_a + G_b) # reference, no kinetic effects 629 | 630 | Smis = [] 631 | Sparts = [] 632 | for mu, sigma, N, kappa in zip(mus, sigmas, Ns, kappas): 633 | am = mu * 1e-6 634 | N = N * 1e6 635 | 636 | fi = 0.5 * np.exp(2.5 * (np.log(sigma) ** 2)) 637 | gi = 1.0 + 0.25 * np.log(sigma) 638 | 639 | A = (2.0 * sigma_w(T) * c.Mw) / (c.rho_w * c.R * T) 640 | rc_mode, Smi2 = kohler_crit(T, am, kappa, approx=True) 641 | 642 | # Scale ``G`` to account for differences in condensation coefficient 643 | if accom == 1.0: 644 | G = G_0 645 | else: 646 | # Scale using the formula from [GHAN2011] 647 | # G_ac - estimate using critical radius of number mode radius, 648 | # and new value for condensation coefficient 649 | G_a = (c.rho_w * c.R * T) / (wv_sat * dv(T, rc_mode, P, accom) * c.Mw) 650 | G_b = (c.L * c.rho_w * ((c.L * c.Mw / (c.R * T)) - 1)) / (ka_cont(T) * T) 651 | G_ac = 1.0 / (G_a + G_b) 652 | 653 | # G_ac1 - estimate using critical radius of number mode radius, 654 | # unity condensation coefficient; re_use G_b (no change) 655 | G_a = (c.rho_w * c.R * T) / (wv_sat * dv(T, rc_mode, P, accom=1.0) * c.Mw) 656 | G_ac1 = 1.0 / (G_a + G_b) 657 | 658 | # Combine using scaling formula (40) from [GHAN2011] 659 | G = G_0 * G_ac / G_ac1 660 | 661 | # Parameterization integral solutions 662 | zeta = (2.0 / 3.0) * A * (np.sqrt(alpha * V / G)) 663 | etai = ((alpha * V / G) ** (3.0 / 2.0)) / (N * gamma * c.rho_w * 2.0 * np.pi) 664 | 665 | # Contributions to maximum supersaturation 666 | Spa = fi * ((zeta / etai) ** (1.5)) 667 | Spb = gi * (((Smi2**2) / (etai + 3.0 * zeta)) ** (0.75)) 668 | S_part = (1.0 / (Smi2**2)) * (Spa + Spb) 669 | 670 | Smis.append(Smi2) 671 | Sparts.append(S_part) 672 | 673 | if min_smax: 674 | smax = 1e20 675 | for i in range(len(mus)): 676 | mode_smax = 1.0 / np.sqrt(Sparts[i]) 677 | if mode_smax < smax: 678 | smax = mode_smax 679 | else: # Use default competition parameterization 680 | smax = 1.0 / np.sqrt(np.sum(Sparts)) 681 | 682 | n_acts, act_fracs = [], [] 683 | for mu, sigma, N, kappa, sgi in zip(mus, sigmas, Ns, kappas, Smis): 684 | N_act, act_frac = lognormal_activation(smax, mu * 1e-6, sigma, N, kappa, sgi) 685 | n_acts.append(N_act) 686 | act_fracs.append(act_frac) 687 | 688 | return smax, n_acts, act_fracs 689 | 690 | 691 | def shipwayabel2010(V, T, P, aerosol): 692 | """Activation scheme following Shipway and Abel, 2010 693 | (doi:10.1016/j.atmosres.2009.10.005). 694 | 695 | """ 696 | raise NotImplementedError 697 | 698 | # rho_a = rho_air(T, P) 699 | # 700 | # # The following calculation for Dv_mean is identical to the Fountoukis and Nenes (2005) 701 | # # implementation, as referenced in Shipway and Abel, 2010 702 | # Dp_big = 5e-6 703 | # Dp_low = np.min([0.207683*(ac**-0.33048), 5.0])*1e-5 704 | # Dp_B = 2.*dv_cont(T, P)*np.sqrt(2*np.pi*Mw/R/T)/ac 705 | # Dp_diff = Dp_big - Dp_low 706 | # Dv_mean = (dv_cont(T, P)/Dp_diff)*(Dp_diff - Dp_B*np.log((Dp_big + Dp_B)/(Dp_low+Dp_B))) 707 | # 708 | # G = 1./rho_w/(Rv*T/es(T-273.15)/Dv_mean + (L/Ka/T)*(L/Rv/T - 1)) 709 | # 710 | # # FROM APPENDIX B 711 | # psi1 = (g/T/Rd)*(Lv/Cp/T - 1.) 712 | # psi2 = (2.*np.pi*rho_w/rho_a) \ 713 | # * ((2.*G)**(3./2.)) \ 714 | # * (P/epsilon/es(T-273.15) + epsilon*(L**2)/Rd/(T**2)/Cp) 715 | # 716 | # Smax = 0 717 | # 718 | # act_fracs = [] 719 | # #for Smi, aerosol in zip(Smis, aerosols): 720 | # # ui = 2.*np.log(Smi/Smax)/(3.*np.sqrt(2.)*np.log(aerosol.distribution.sigma)) 721 | # # N_act = 0.5*aerosol.distribution.N*erfc(ui) 722 | # # act_fracs.append(N_act/aerosol.distribution.N) 723 | # 724 | # return Smax, act_fracs 725 | 726 | 727 | def ming2006(V, T, P, aerosol): 728 | """Ming activation scheme. 729 | 730 | NOTE - right now, the variable names correspond to the FORTRAN implementation of the routine. Will change in the future. 731 | 732 | """ 733 | 734 | # TODO: rename variables 735 | # TODO: docstring 736 | # TODO: extend for multiple modes. 737 | 738 | raise NotImplementedError 739 | # Num = aerosol.Nis*1e-6 740 | # 741 | # RpDry = aerosol.distribution.mu*1e-6 742 | # kappa = aerosol.kappa 743 | # 744 | # # pre-algorithm 745 | # # subroutine Kohler()... calculate things from Kohler theory, particularly critical 746 | # # radii and supersaturations for each bin 747 | # r_crits, s_crits = list(zip(*[kohler_crit(T, r_dry, kappa) for r_dry in aerosol.r_drys])) 748 | # 749 | # # subroutine CalcAlphaGamma 750 | # alpha = (c.g*c.Mw*c.L)/(c.Cp*c.R*(T**2)) - (c.g*c.Ma)/(c.R*T) 751 | # gamma = (c.R*T)/(es(T-273.15)*c.Mw) + (c.Mw*(c.L**2))/(c.Cp*c.Ma*T*P) 752 | # 753 | # # re-name variables as in Ming scheme 754 | # Dpc = 2.*np.array(r_crits)*1e6 755 | # Dp0 = r_crits/np.sqrt(3.) 756 | # Sc = np.array(s_crits)+1.0 757 | # DryDp = aerosol.r_drys*2. 758 | # 759 | # # Begin algorithm 760 | # Smax1 = 1.0 761 | # Smax2 = 1.1 762 | # 763 | # iter_count = 1 764 | # while (Smax2 - Smax1) > 1e-7: 765 | # #print "\t", iter_count, Smax1, Smax2 766 | # Smax = 0.5*(Smax2 + Smax1) 767 | # #print "---", Smax-1.0 768 | # 769 | # ## subroutine Grow() 770 | # 771 | # ## subroutine CalcG() 772 | # # TODO: implement size-dependent effects on Dv, ka, using Dpc 773 | # #G_a = (rho_w*R*T)/(es(T-273.15)*Dv_T(T)*Mw) 774 | # G_a = (c.rho_w*c.R*T)/(es(T-273.15)*dv(T, (Dpc*1e-6)/2.)*c.Mw) 775 | # #G_b = (L*rho_w*((L*Mw/(R*T))-1))/(ka_T(T)*T) 776 | # G_b = (c.L*c.rho_w*((c.L*c.Mw/(c.R*T))-1))/(ka(T, 1.007e3, (Dpc*1e-6)/2.)*T) 777 | # G = 1./(G_a + G_b) # multiply by four since we're doing diameter this time 778 | # 779 | # Smax_large = (Smax > Sc) # if(Smax>Sc(count1,count2)) 780 | # WetDp = np.zeros_like(Dpc) 781 | # #WetDp[Smax_large] = np.sqrt(Dpc[Smax_large]**2. + \ 782 | # # 1e12*(G[Smax_large]/(alpha*V))*((Smax-.0)**2.4 - (Sc[Smax_large]-.0)**2.4)) 783 | # WetDp[Smax_large] = 1e6*np.sqrt((Dpc[Smax_large]*1e-6)**2. + \ 784 | # (G[Smax_large]/(alpha*V))*((Smax-.0)**2.4 - (Sc[Smax_large]-.0)**2.4)) 785 | # 786 | # #print Dpc 787 | # #print WetDp/DryDp 788 | # #print WetDp 789 | # 790 | # # subroutine Activity() 791 | # def Activity(dry, wet, dens, molar_weight): 792 | # temp1 = (dry**3)*dens/molar_weight 793 | # temp2 = ((wet**3) - (dry**3))*1e3/0.018 794 | # act = temp2/(temp1+temp2)*np.exp(0.66/T/wet) 795 | # #print dry[0], wet[0], dens, molar_weight, act[0] 796 | # return act 797 | # # Is this just the Kohler curve? 798 | # Act = np.ones_like(WetDp) 799 | # WetDp_large = (WetDp > 1e-5) # if(WetDp(i,j)>1e-5) 800 | # Act[WetDp_large] = Seq(WetDp[WetDp_large]*1e-6, DryDp[WetDp_large], T, kappa) + 1.0 801 | # #Act[WetDp_large] = Activity(DryDp[WetDp_large]*1e6, WetDp[WetDp_large], 1.7418e3, 0.132) 802 | # 803 | # #print Act 804 | # 805 | # # subroutine Conden() 806 | # 807 | # # subroutine CalcG() 808 | # # TODO: implement size-dependent effects on Dv, ka, using WetDp 809 | # #G_a = (rho_w*R*T)/(es(T-273.15)*Dv_T(T)*Mw) 810 | # G_a = (c.rho_w*c.R*T)/(es(T-273.15)*dv(T, (WetDp*1e-6)/2.)*c.Mw) 811 | # #G_b = (L*rho_w*((L*Mw/(R*T))-1))/(ka_T(T)*T) 812 | # G_b = (c.L*c.rho_w*((c.L*c.Mw/(c.R*T))-1))/(ka(T, 1.3e3, (WetDp*1e-6)/2.)*T) 813 | # G = 1./(G_a + G_b) # multiply by four since we're doing diameter this time 814 | # 815 | # WetDp_large = (WetDp > Dpc) # (WetDp(count1,count2)>Dpc(count1,count2)) 816 | # #WetDp_large = (WetDp > 0) 817 | # f_stre = lambda x: "%12.12e" % x 818 | # f_strf = lambda x: "%1.12f" % x 819 | # #for i, a in enumerate(Act): 820 | # # if WetDp[i] > Dpc[i]: 821 | # # print " ",i+1, Act[i], f_stre(Smax-Act[i]) 822 | # CondenRate = np.sum((np.pi/2.)*1e3*G[WetDp_large]*(WetDp[WetDp_large]*1e-6)*Num[WetDp_large]*1e6* 823 | # (Smax-Act[WetDp_large])) 824 | # 825 | # #print iter_count, "%r %r %r" % (Smax, CondenRate, alpha*V/gamma) 826 | # DropletNum = np.sum(Num[WetDp_large]) 827 | # ActDp = 0.0 828 | # for i in range(1, len(WetDp)): 829 | # if (WetDp[i] > Dpc[i]) and (WetDp[i-1] < Dpc[i]): 830 | # ActDp = DryDp[i] 831 | # 832 | # # Iteration logic 833 | # if CondenRate < (alpha*V/gamma): 834 | # Smax1 = Smax*1.0 835 | # else: 836 | # Smax2 = Smax*1.0 837 | # 838 | # iter_count += 1 839 | # 840 | # Smax = Smax-1.0 841 | # 842 | # return Smax, None 843 | -------------------------------------------------------------------------------- /pyrcel/aerosol.py: -------------------------------------------------------------------------------- 1 | """ Container class for encapsulating data about aerosol size distributions. 2 | 3 | """ 4 | import numpy as np 5 | 6 | from .distributions import BaseDistribution, Lognorm, MultiModeLognorm 7 | 8 | 9 | def dist_to_conc(dist, r_min, r_max, rule="trapezoid"): 10 | """Converts a swath of a size distribution function to an actual number 11 | concentration. 12 | 13 | Aerosol size distributions are typically reported by normalizing the 14 | number density by the size of the aerosol. However, it's sometimes more 15 | convenient to simply have a histogram of representing several aerosol 16 | size ranges (bins) and the actual number concentration one should expect 17 | in those bins. To accomplish this, one only needs to integrate the size 18 | distribution function over the range spanned by the bin. 19 | 20 | Parameters 21 | ---------- 22 | dist : object implementing a ``pdf()`` method 23 | the representation of the size distribution 24 | r_min, r_max : float 25 | the lower and upper bounds of the size bin, in the native units of ``dist`` 26 | rule : {'trapezoid', 'simpson', 'other'} (default='trapezoid') 27 | rule used to integrate the size distribution 28 | 29 | Returns 30 | ------- 31 | float 32 | The number concentration of aerosol particles the given bin. 33 | 34 | Examples 35 | -------- 36 | 37 | >>> dist = Lognorm(mu=0.015, sigma=1.6, N=850.0) 38 | >>> r_min, r_max = 0.00326456461236 0.00335634401598 39 | >>> dist_to_conc(dist, r_min, r_max) 40 | 0.114256210943 41 | 42 | """ 43 | pdf = dist.pdf 44 | width = r_max - r_min 45 | if rule == "trapezoid": 46 | return width * 0.5 * (pdf(r_max) + pdf(r_min)) 47 | elif rule == "simpson": 48 | return (width / 6.0) * ( 49 | pdf(r_max) + pdf(r_min) + 4.0 * pdf(0.5 * (r_max + r_min)) 50 | ) 51 | else: 52 | return width * pdf(0.5 * (r_max + r_min)) 53 | 54 | 55 | class AerosolSpecies(object): 56 | """Container class for organizing aerosol metadata. 57 | 58 | To allow flexibility with how aerosols are defined in the model, this class is 59 | meant to act as a wrapper to contain metadata about aerosols (their species 60 | name, etc), their chemical composition (particle mass, hygroscopicity, etc), 61 | and the particular size distribution chosen for the initial dry aerosol. 62 | Because the latter could be very diverse - for instance, it might be desired 63 | to have a monodisperse aerosol population, or a bin representation of a 64 | canonical size distribution - the core of this class is designed to take 65 | those representations and homogenize them for use in the model. 66 | 67 | To construct an :class:`AerosolSpecies`, only the metadata (``species`` and 68 | ``kappa``) and the size distribution needs to be specified. The size distribution 69 | (``distribution``) can be an instance of :class:`Lognorm`, as 70 | long as an extra parameter ``bins``, which is an integer representing how many 71 | bins into which the distribution should be divided, is also passed to the 72 | constructor. In this case, the constructor will figure out how to slice the 73 | size distribution to calculate all the aerosol dry radii and their number 74 | concentrations. If ``r_min`` and ``r_max`` are supplied, then the size range of 75 | the aerosols will be bracketed; else, the supplied ``distribution`` will contain 76 | a shape parameter or other bounds to use. 77 | 78 | Alternatively, a :class:`dict` can be passed as ``distribution`` where that 79 | slicing has already occurred. In this case, `distribution` must have 2 keys: 80 | ``r_drys`` and ``Nis``. Each of the values stored to those keys should fit the 81 | attribute descriptors above (although they don't need to be arrays - they can 82 | be any iterable.) 83 | 84 | Parameters 85 | ---------- 86 | species : string 87 | Name of aerosol species. 88 | distribution : { LogNorm, MultiLogNorm, dict } 89 | Representation of aerosol size distribution. 90 | kappa : float 91 | Hygroscopicity of species. 92 | rho : float, optional 93 | Density of dry aerosol material, kg m**-3. 94 | mw : float, optional 95 | Molecular weight of dry aerosol material, kg/mol. 96 | bins : int 97 | Number of bins in discretized size distribution. 98 | 99 | Attributes 100 | ---------- 101 | nr : float 102 | Number of sizes tracked for this aerosol. 103 | r_drys : array of floats of length ``nr`` 104 | Dry radii of each representative size tracked for this aerosol, m. 105 | rs : array of floats of length ``nr + 1`` 106 | Edges of bins in discretized aerosol distribution representation, m. 107 | Nis : array of floats of length ``nr`` 108 | Number concentration of aerosol of each representative size, m**-3. 109 | total_N : float 110 | Total number concentration of aerosol in this species, cm**-3. 111 | 112 | 113 | Examples 114 | -------- 115 | 116 | Constructing sulfate aerosol with a specified lognormal distribution - 117 | 118 | >>> aerosol1 = AerosolSpecies('(NH4)2SO4', Lognorm(mu=0.05, sigma=2.0, N=300.), 119 | ... bins=200, kappa=0.6) 120 | 121 | Constructing a monodisperse sodium chloride distribution - 122 | 123 | >>> aerosol2 = AerosolSpecies('NaCl', {'r_drys': [0.25, ], 'Nis': [1000.0, ]}, 124 | ... kappa=0.2) 125 | 126 | .. warning :: 127 | 128 | Throws a :class:`ValueError` if an unknown type of ``distribution`` is passed 129 | to the constructor, or if `bins` isn't present when ``distribution`` is 130 | an instance of :class:`Lognorm`. 131 | 132 | """ 133 | 134 | def __init__( 135 | self, 136 | species, 137 | distribution, 138 | kappa, 139 | rho=None, 140 | mw=None, 141 | bins=None, 142 | r_min=None, 143 | r_max=None, 144 | ): 145 | self.species = species # Species molecular formula 146 | self.kappa = kappa # Kappa hygroscopicity parameter 147 | self.rho = rho # aerosol density kg/m^3 148 | self.mw = mw 149 | self.bins = bins # Number of bins for discretizing the size distribution 150 | 151 | # Handle the size distribution passed to the constructor 152 | self.distribution = distribution 153 | if isinstance(distribution, dict): 154 | self.r_drys = np.array(distribution["r_drys"]) * 1e-6 155 | 156 | # Compute boundaries for bins. To do this, assume the right 157 | # edge of the first bin is the geometric mean of the two smallest 158 | # dry radii. Then, always assume that r_dry is the geometric mean 159 | # of a bin and use that to back out all other edges in sequence 160 | if len(self.r_drys) > 1: 161 | mid1 = np.sqrt(self.r_drys[0] * self.r_drys[1]) 162 | lr = (self.r_drys[0] ** 2.0) / mid1 163 | rs = [lr, mid1] 164 | for r_dry in self.r_drys[1:]: 165 | rs.append(r_dry**2.0 / rs[-1]) 166 | self.rs = np.array(rs) * 1e6 167 | else: 168 | # Truly mono-disperse, so no boundaries (we don't actually need 169 | # them in this case anyways) 170 | self.rs = None 171 | self.Nis = np.array(distribution["Nis"]) 172 | self.N = np.sum(self.Nis) 173 | 174 | elif isinstance(distribution, Lognorm): 175 | # Check for missing keyword argument 176 | if bins is None: 177 | raise ValueError( 178 | "Need to specify `bins` argument if passing a Lognorm " 179 | "distribution" 180 | ) 181 | 182 | if isinstance(bins, (list, np.ndarray)): 183 | self.rs = bins[:] 184 | else: 185 | if not r_min: 186 | lr = np.log10(distribution.mu / (10.0 * distribution.sigma)) 187 | else: 188 | lr = np.log10(r_min) 189 | if not r_max: 190 | rr = np.log10(distribution.mu * 10.0 * distribution.sigma) 191 | else: 192 | rr = np.log10(r_max) 193 | self.rs = np.logspace(lr, rr, num=bins + 1)[:] 194 | 195 | nbins = len(self.rs) 196 | mids = np.array( 197 | [np.sqrt(a * b) for a, b in zip(self.rs[:-1], self.rs[1:])] 198 | )[0:nbins] 199 | self.Nis = np.array( 200 | [ 201 | 0.5 * (b - a) * (distribution.pdf(a) + distribution.pdf(b)) 202 | for a, b in zip(self.rs[:-1], self.rs[1:]) 203 | ] 204 | )[0:nbins] 205 | self.r_drys = mids * 1e-6 206 | 207 | elif isinstance(distribution, MultiModeLognorm): 208 | if bins is None: 209 | raise ValueError( 210 | "Need to specify `bins` argument if passing a " 211 | "MultiModeLognorm distribution" 212 | ) 213 | 214 | small_mu = distribution.mus[0] 215 | small_sigma = distribution.sigmas[0] 216 | big_mu = distribution.mus[-1] 217 | big_sigma = distribution.sigmas[-1] 218 | 219 | if isinstance(bins, (list, np.ndarray)): 220 | self.rs = bins[:] 221 | else: 222 | if not r_min: 223 | lr = np.log10(small_mu / (10.0 * small_sigma)) 224 | else: 225 | lr = np.log10(r_min) 226 | if not r_max: 227 | rr = np.log10(big_mu * 10.0 * big_sigma) 228 | else: 229 | rr = np.log10(r_max) 230 | 231 | self.rs = np.logspace(lr, rr, num=bins + 1)[:] 232 | nbins = len(self.rs) 233 | mids = np.array( 234 | [np.sqrt(a * b) for a, b in zip(self.rs[:-1], self.rs[1:])] 235 | )[0:nbins] 236 | self.Nis = np.array( 237 | [ 238 | 0.5 * (b - a) * (distribution.pdf(a) + distribution.pdf(b)) 239 | for a, b in zip(self.rs[:-1], self.rs[1:]) 240 | ] 241 | )[0:nbins] 242 | self.r_drys = mids * 1e-6 243 | 244 | else: 245 | raise ValueError( 246 | "Could not work with size distribution of type %r" % type(distribution) 247 | ) 248 | 249 | # Correct to SI units 250 | # Nis: cm**-3 -> m**-3 251 | self.total_N = np.sum(self.Nis) 252 | self.Nis *= 1e6 253 | self.nr = len(self.r_drys) 254 | 255 | def stats(self): 256 | """Compute useful statistics about this aerosol's size distribution. 257 | 258 | Returns 259 | ------- 260 | dict 261 | Inherits the values from the ``distribution``, and if ``rho`` 262 | was provided, adds some statistics about the mass and 263 | mass-weighted properties. 264 | 265 | Raises 266 | ------ 267 | ValueError 268 | If the stored ``distribution`` does not implement a ``stats()`` 269 | function. 270 | """ 271 | 272 | if isinstance(self.distribution, BaseDistribution): 273 | stats_dict = self.distribution.stats() 274 | 275 | if self.rho: 276 | stats_dict["total_mass"] = stats_dict["total_volume"] * self.rho 277 | stats_dict["mean_mass"] = stats_dict["mean_volume"] * self.rho 278 | stats_dict["specific_surface_area"] = ( 279 | stats_dict["total_surface_area"] / stats_dict["total_mass"] 280 | ) 281 | 282 | return self.rho 283 | 284 | else: 285 | raise ValueError( 286 | "Could not work with size distribution of type %r" 287 | % type(self.distribution) 288 | ) 289 | 290 | def __repr__(self): 291 | return "%s - %r" % (self.species, self.distribution) 292 | -------------------------------------------------------------------------------- /pyrcel/constants.py: -------------------------------------------------------------------------------- 1 | """ Commonly used constants in microphysics and aerosol thermodynamics equations as 2 | well as important model parameters. 3 | 4 | ================= ============= ========== ========== ====================== 5 | Symbol Variable Value Units Description 6 | ================= ============= ========== ========== ====================== 7 | :math:`g` ``g`` 9.8 m s**-2 gravitational constant 8 | :math:`C_p` ``Cp`` 1004.0 J/kg specific heat of dry air 9 | at constant pressure 10 | :math:`\\rho_w` ``rho_w`` 1000.0 kg m**-3 density of water at STP 11 | :math:`R_d` ``Rd`` 287.0 J/kg/K gas constant for dry air 12 | :math:`R_v` ``Rv`` 461.5 J/kg/K gas constant for water vapor 13 | :math:`R` ``R`` 8.314 J/mol/K universal gas constant 14 | :math:`M_w` ``Mw`` 0.018 kg/mol molecular weight of water 15 | :math:`M_a` ``Ma`` 0.0289 kg/mol molecular weight of dry air 16 | :math:`D_v` ``Dv`` 3e-5 m**2/s diffusivity of water vapor 17 | in air 18 | :math:`L_v` ``L`` 2.25e6 J/kg/K latent heat of vaporization 19 | of water 20 | :math:`\\alpha_c` ``ac`` 1.0 unitless condensation coefficient 21 | :math:`K_a` ``Ka`` 0.02 J/m/s/K thermal conductivity of air 22 | :math:`a_T` ``at`` 0.96 unitless thermal accommodation 23 | coefficient 24 | :math:`\epsilon` ``epsilon`` 0.622 unitless ratio of :math:`M_w/M_a` 25 | ================= ============= ========== ========== ====================== 26 | 27 | Additionally, a reference table containing the 28 | `1976 US Standard Atmosphere `_ is implemented in the 29 | constant ``std_atm``, which is a pandas DataFrame with the fields 30 | 31 | - ``alt``, altitude in km 32 | - ``sigma``, ratio of density to sea-level density 33 | - ``delta``, ratio of pressure to sea-level pressure 34 | - ``theta``, ratio of temperature to sea-level temperature 35 | - ``temp``, temperature in K 36 | - ``press``, pressure in Pa 37 | - ``dens``, air density in kg/m**3 38 | - ``k.visc``, air kinematic viscosity 39 | - ``ratio``, ratio of speed of sound to kinematic viscosity in m**-1 40 | 41 | Using default pandas functons, you can interpolate to any reference pressure or 42 | height level. 43 | 44 | """ 45 | 46 | import pandas as pd 47 | import pkg_resources 48 | 49 | g = 9.81 #: Gravitational constant, m/s^2 50 | Cp = 1004.0 #: Specific heat of dry air at constant pressure, J/kg 51 | L = 2.25e6 #: Latent heat of condensation, J/kg 52 | rho_w = 1e3 #: Density of water, kg/m^3 53 | R = 8.314 #: Universal gas constant, J/(mol K) 54 | Mw = 18.0 / 1e3 #: Molecular weight of water, kg/mol 55 | Ma = 28.9 / 1e3 #: Molecular weight of dry air, kg/mol 56 | Rd = R / Ma #: Gas constant for dry air, J/(kg K) 57 | Rv = R / Mw #: Gas constant for water vapor, J/(kg K) 58 | Dv = 3.0e-5 #: Diffusivity of water vapor in air, m^2/s 59 | ac = 1.0 #: condensation constant 60 | Ka = 2.0e-2 #: Thermal conductivity of air, J/m/s/K 61 | at = 0.96 #: thermal accomodation coefficient 62 | epsilon = 0.622 #: molecular weight of water / molecular weight of dry air 63 | 64 | # Additional fixed model parameters 65 | N_STATE_VARS = 7 66 | STATE_VARS = ["z", "P", "T", "wv", "wc", "wi", "S"] 67 | STATE_VAR_MAP = {var: i for i, var in enumerate(STATE_VARS)} 68 | 69 | # Read the standard atmosphere CSV file 70 | _std_atm_fn = pkg_resources.resource_filename("pyrcel", "data/std_atm.csv") 71 | std_atm = pd.read_csv(_std_atm_fn, sep="\s+") 72 | -------------------------------------------------------------------------------- /pyrcel/data/std_atm.csv: -------------------------------------------------------------------------------- 1 | alt sigma delta theta temp press dens a visc k.visc ratio 2 | -0.5 1.0489 1.0607 1.0113 291.4 107477 1.285 342.2 18.05 1.40E-5 24.36 3 | 0.0 1.0000 1.0000 1.0000 288.1 101325 1.225 340.3 17.89 1.46E-5 23.30 4 | 0.5 0.9529 0.9421 0.9887 284.9 95461 1.167 338.4 17.74 1.52E-5 22.27 5 | 1.0 0.9075 0.8870 0.9774 281.7 89876 1.112 336.4 17.58 1.58E-5 21.28 6 | 1.5 0.8638 0.8345 0.9662 278.4 84559 1.058 334.5 17.42 1.65E-5 20.32 7 | 2.0 0.8217 0.7846 0.9549 275.2 79501 1.007 332.5 17.26 1.71E-5 19.39 8 | 2.5 0.7812 0.7372 0.9436 271.9 74691 0.957 330.6 17.10 1.79E-5 18.50 9 | 3.0 0.7422 0.6920 0.9324 268.7 70121 0.909 328.6 16.94 1.86E-5 17.64 10 | 3.5 0.7048 0.6492 0.9211 265.4 65780 0.863 326.6 16.78 1.94E-5 16.81 11 | 4.0 0.6689 0.6085 0.9098 262.2 61660 0.819 324.6 16.61 2.03E-5 16.01 12 | 4.5 0.6343 0.5700 0.8986 258.9 57752 0.777 322.6 16.45 2.12E-5 15.24 13 | 5.0 0.6012 0.5334 0.8873 255.7 54048 0.736 320.5 16.28 2.21E-5 14.50 14 | 5.5 0.5694 0.4988 0.8760 252.4 50539 0.697 318.5 16.12 2.31E-5 13.78 15 | 6.0 0.5389 0.4660 0.8648 249.2 47217 0.660 316.5 15.95 2.42E-5 13.10 16 | 6.5 0.5096 0.4350 0.8535 245.9 44075 0.624 314.4 15.78 2.53E-5 12.44 17 | 7.0 0.4816 0.4057 0.8423 242.7 41105 0.590 312.3 15.61 2.65E-5 11.80 18 | 7.5 0.4548 0.3780 0.8310 239.5 38299 0.557 310.2 15.44 2.77E-5 11.19 19 | 8.0 0.4292 0.3519 0.8198 236.2 35651 0.526 308.1 15.27 2.90E-5 10.61 20 | 8.5 0.4047 0.3272 0.8085 233.0 33154 0.496 306.0 15.10 3.05E-5 10.05 21 | 9.0 0.3813 0.3040 0.7973 229.7 30800 0.467 303.8 14.93 3.20E-5 9.51 22 | 9.5 0.3589 0.2821 0.7860 226.5 28584 0.440 301.7 14.75 3.36E-5 8.99 23 | 10.0 0.3376 0.2615 0.7748 223.3 26499 0.414 299.5 14.58 3.53E-5 8.50 24 | 10.5 0.3172 0.2422 0.7635 220.0 24540 0.389 297.4 14.40 3.71E-5 8.02 25 | 11.0 0.2978 0.2240 0.7523 216.8 22699 0.365 295.2 14.22 3.90E-5 7.57 26 | 11.5 0.2755 0.2071 0.7519 216.6 20984 0.337 295.1 14.22 4.21E-5 7.00 27 | 12.0 0.2546 0.1915 0.7519 216.6 19399 0.312 295.1 14.22 4.56E-5 6.47 28 | 12.5 0.2354 0.1770 0.7519 216.6 17933 0.288 295.1 14.22 4.93E-5 5.99 29 | 13.0 0.2176 0.1636 0.7519 216.6 16579 0.267 295.1 14.22 5.33E-5 5.53 30 | 13.5 0.2012 0.1513 0.7519 216.6 15327 0.246 295.1 14.22 5.77E-5 5.12 31 | 14.0 0.1860 0.1398 0.7519 216.6 14170 0.228 295.1 14.22 6.24E-5 4.73 32 | 14.5 0.1720 0.1293 0.7519 216.6 13100 0.211 295.1 14.22 6.75E-5 4.37 33 | 15.0 0.1590 0.1195 0.7519 216.6 12111 0.195 295.1 14.22 7.30E-5 4.04 34 | 15.5 0.1470 0.1105 0.7519 216.6 11197 0.180 295.1 14.22 7.90E-5 3.74 35 | 16.0 0.1359 0.1022 0.7519 216.6 10352 0.166 295.1 14.22 8.54E-5 3.46 36 | 16.5 0.1256 0.0945 0.7519 216.6 9571 0.154 295.1 14.22 9.24E-5 3.19 37 | 17.0 0.1162 0.0873 0.7519 216.6 8849 0.142 295.1 14.22 9.99E-5 2.95 38 | 17.5 0.1074 0.0808 0.7519 216.6 8182 0.132 295.1 14.22 1.08E-4 2.73 39 | 18.0 0.0993 0.0747 0.7519 216.6 7565 0.122 295.1 14.22 1.17E-4 2.52 40 | 18.5 0.0918 0.0690 0.7519 216.6 6994 0.112 295.1 14.22 1.26E-4 2.33 41 | 19.0 0.0849 0.0638 0.7519 216.6 6467 0.104 295.1 14.22 1.37E-4 2.16 42 | 19.5 0.0785 0.0590 0.7519 216.6 5979 0.096 295.1 14.22 1.48E-4 2.00 43 | 20.0 0.0726 0.0546 0.7519 216.6 5529 0.089 295.1 14.22 1.60E-4 1.85 -------------------------------------------------------------------------------- /pyrcel/distributions.py: -------------------------------------------------------------------------------- 1 | """ Collection of classes for representing aerosol size distributions. 2 | 3 | Most commonly, one would use the :class:`Lognorm` distribution. However, 4 | for the sake of completeness, other canonical distributions will be 5 | included here, with the notion that this package could be extended to 6 | describe droplet size distributions or other collections of objects. 7 | 8 | """ 9 | from abc import ABCMeta, abstractmethod 10 | 11 | import numpy as np 12 | from scipy.special import erf, erfinv 13 | 14 | 15 | class BaseDistribution(metaclass=ABCMeta): 16 | """Interface for distributions, to ensure that they contain a pdf method.""" 17 | 18 | @abstractmethod 19 | def cdf(self, x): 20 | """Cumulative density function""" 21 | 22 | @abstractmethod 23 | def pdf(self, x): 24 | """Probability density function.""" 25 | 26 | @property 27 | @abstractmethod 28 | def stats(self): 29 | pass 30 | 31 | @abstractmethod 32 | def __repr__(self): 33 | """Representation function.""" 34 | 35 | 36 | class Gamma(BaseDistribution): 37 | """Gamma size distribution""" 38 | 39 | # TODO: Implement Gamma size distribution 40 | pass 41 | 42 | 43 | class Lognorm(BaseDistribution): 44 | """Lognormal size distribution. 45 | 46 | An instance of :class:`Lognorm` contains a construction of a lognormal distribution 47 | and the utilities necessary for computing statistical functions associated 48 | with that distribution. The parameters of the constructor are invariant with respect 49 | to what length and concentration unit you choose; that is, if you use meters for 50 | ``mu`` and cm**-3 for ``N``, then you should keep these in mind when evaluating 51 | the :func:`pdf` and :func:`cdf` functions and when interpreting moments. 52 | 53 | Parameters 54 | ---------- 55 | mu : float 56 | Median/geometric mean radius, length unit. 57 | sigma : float 58 | Geometric standard deviation, unitless. 59 | N : float, optional (default=1.0) 60 | Total number concentration, concentration unit. 61 | base : float, optional (default=np.e) 62 | Base of logarithm in lognormal distribution. 63 | 64 | Attributes 65 | ---------- 66 | median, mean : float 67 | Pre-computed statistical quantities 68 | 69 | Methods 70 | ------- 71 | pdf(x) 72 | Evaluate distribution at a particular value 73 | cdf(x) 74 | Evaluate cumulative distribution at a particular value. 75 | moment(k) 76 | Compute the *k*-th moment of the lognormal distribution. 77 | 78 | """ 79 | 80 | def __init__(self, mu, sigma, N=1.0, base=np.e): 81 | self.mu = mu 82 | self.sigma = sigma 83 | self.N = N 84 | 85 | self.base = base 86 | if self.base == np.e: 87 | self.log = np.log 88 | elif self.base == 10.0: 89 | self.log = np.log10 90 | else: 91 | self.log_base = np.log(self.base) 92 | self.log = lambda x: np.log(x) / self.log_base 93 | 94 | # Compute moments 95 | self.median = self.mu 96 | self.mean = self.mu * np.exp(0.5 * self.sigma**2) 97 | 98 | def invcdf(self, y): 99 | """Inverse of cumulative density function. 100 | 101 | Parameters 102 | ---------- 103 | y : float 104 | CDF value, between (0, 1) 105 | 106 | Returns 107 | ------- 108 | value of ordinate corresponding to given CDF evaluation 109 | 110 | """ 111 | 112 | if (np.any(y) < 0) or (np.any(y) > 1): 113 | raise ValueError("y must be between (0, 1)") 114 | 115 | erfinv_arg = 2.0 * y / self.N - 1.0 116 | return self.mu * np.exp(np.log(self.sigma) * np.sqrt(2.0) * erfinv(erfinv_arg)) 117 | 118 | def cdf(self, x): 119 | """Cumulative density function 120 | 121 | .. math:: 122 | \\text{CDF} = \\frac{N}{2}\\left(1.0 + \\text{erf}(\\frac{\log{x/\mu}}{\sqrt{2}\log{\sigma}}) \\right) 123 | 124 | Parameters 125 | ---------- 126 | x : float 127 | Ordinate value to evaluate CDF at 128 | 129 | Returns 130 | ------- 131 | value of CDF at ordinate 132 | 133 | """ 134 | erf_arg = (self.log(x / self.mu)) / (np.sqrt(2.0) * self.log(self.sigma)) 135 | return (self.N / 2.0) * (1.0 + erf(erf_arg)) 136 | 137 | def pdf(self, x): 138 | """Probability density function 139 | 140 | .. math:: 141 | \\text{PDF} = \\frac{N}{\sqrt{2\pi}\log\sigma x}\exp\\left( -\\frac{\log{x/\mu}^2}{2\log^2\sigma} \\right) 142 | 143 | Parameters 144 | ---------- 145 | x : float 146 | Ordinate value to evaluate CDF at 147 | 148 | Returns 149 | ------- 150 | value of CDF at ordinate 151 | 152 | """ 153 | scaling = self.N / (np.sqrt(2.0 * np.pi) * self.log(self.sigma)) 154 | exponent = ((self.log(x / self.mu)) ** 2) / (2.0 * (self.log(self.sigma)) ** 2) 155 | return (scaling / x) * np.exp(-exponent) 156 | 157 | def moment(self, k): 158 | """Compute the k-th moment of the lognormal distribution 159 | 160 | .. math:: 161 | F(k) = N\mu^k\exp\\left( \\frac{k^2}{2} \ln^2 \sigma \\right) 162 | 163 | Parameters 164 | ---------- 165 | k : int 166 | Moment to evaluate 167 | 168 | Returns 169 | ------- 170 | moment of distribution 171 | 172 | """ 173 | scaling = (self.mu**k) * self.N 174 | exponent = ((k**2) / 2.0) * (self.log(self.sigma)) ** 2 175 | return scaling * np.exp(exponent) 176 | 177 | def stats(self): 178 | """Compute useful statistics for a lognormal distribution 179 | 180 | Returns 181 | ------- 182 | dict 183 | Dictionary containing the stats ``mean_radius``, ``total_diameter``, 184 | ``total_surface_area``, ``total_volume``, ``mean_surface_area``, 185 | ``mean_volume``, and ``effective_radius`` 186 | 187 | """ 188 | stats_dict = dict() 189 | stats_dict["mean_radius"] = self.mu * np.exp(0.5 * self.sigma**2) 190 | 191 | stats_dict["total_diameter"] = self.N * stats_dict["mean_radius"] 192 | stats_dict["total_surface_area"] = 4.0 * np.pi * self.moment(2.0) 193 | stats_dict["total_volume"] = (4.0 * np.pi / 3.0) * self.moment(3.0) 194 | 195 | stats_dict["mean_surface_area"] = stats_dict["total_surface_area"] / self.N 196 | stats_dict["mean_volume"] = stats_dict["total_volume"] / self.N 197 | 198 | stats_dict["effective_radius"] = ( 199 | stats_dict["total_volume"] / stats_dict["total_surface_area"] 200 | ) 201 | 202 | return stats_dict 203 | 204 | def __repr__(self): 205 | return "Lognorm | mu = {:2.2e}, sigma = {:2.2e}, Total = {:2.2e} |".format( 206 | self.mu, self.sigma, self.N 207 | ) 208 | 209 | 210 | class MultiModeLognorm(BaseDistribution): 211 | """Multimode lognormal distribution class. 212 | 213 | Container for multiple Lognorm classes representing a full aerosol size 214 | distribution. 215 | """ 216 | 217 | def __init__(self, mus, sigmas, Ns, base=np.e): 218 | dist_params = list(zip(mus, sigmas, Ns)) 219 | from operator import itemgetter 220 | 221 | dist_params = sorted(dist_params, key=itemgetter(0)) 222 | 223 | self.mus, self.sigmas, self.Ns = list(zip(*dist_params)) 224 | 225 | self.base = base 226 | 227 | self.lognorms = [] 228 | for mu, sigma, N in zip(self.mus, self.sigmas, self.Ns): 229 | mode_dist = Lognorm(mu, sigma, N, base) 230 | self.lognorms.append(mode_dist) 231 | 232 | def cdf(self, x): 233 | return np.sum([d.cdf(x) for d in self.lognorms], axis=0) 234 | 235 | def pdf(self, x): 236 | return np.sum([d.pdf(x) for d in self.lognorms], axis=0) 237 | 238 | def stats(self): 239 | """Compute useful statistics for a multi-mode lognormal distribution 240 | 241 | TODO: Implement multi-mode lognorm stats 242 | """ 243 | raise NotImplementedError() 244 | 245 | def __repr__(self): 246 | mus_str = "(" + ", ".join("%2.2e" % mu for mu in self.mus) + ")" 247 | sigmas_str = "(" + ", ".join("%2.2e" % sigma for sigma in self.sigmas) + ")" 248 | Ns_str = "(" + ", ".join("%2.2e" % N for N in self.Ns) + ")" 249 | return "MultiModeLognorm| mus = {}, sigmas = {}, Totals = {} |".format( 250 | mus_str, sigmas_str, Ns_str 251 | ) 252 | 253 | 254 | #: Single mode aerosols 255 | # TODO: Re-factor saved size distributions into a resource like 'constants' 256 | 257 | FN2005_single_modes = { 258 | "SM1": Lognorm(0.025 / 2, 1.3, 100.0), 259 | "SM2": Lognorm(0.025 / 2, 1.3, 500.0), 260 | "SM3": Lognorm(0.05 / 2, 1.8, 500.0), 261 | "SM4": Lognorm(0.25 / 2, 1.8, 100.0), 262 | "SM5": Lognorm(0.75 / 2, 1.8, 1000.0), 263 | } 264 | 265 | NS2003_single_modes = { 266 | "SM1": Lognorm(0.02 / 2, 2.5, 200.0), 267 | "SM2": Lognorm(0.02 / 2, 2.5, 1000.0), 268 | "SM3": Lognorm(0.02 / 2, 1.5, 1000.0), 269 | "SM4": Lognorm(0.2 / 2, 2.5, 200.0), 270 | "SM5": Lognorm(0.02 / 2, 2.5, 10000.0), 271 | } 272 | 273 | whitby_distributions = { 274 | # name: [nucleation, accumulation, coarse] 275 | # mu = micron, N = cm**-3 276 | "marine": [ 277 | Lognorm(0.01 / 2.0, 1.6, 340.0), 278 | Lognorm(0.07 / 2.0, 2.0, 6.0), 279 | Lognorm(0.62 / 2.0, 2.7, 3.1), 280 | ], 281 | "continental": [ 282 | Lognorm(0.016 / 2.0, 1.6, 1000.0), 283 | Lognorm(0.068 / 2.0, 2.1, 800.0), 284 | Lognorm(0.92 / 2.0, 2.2, 0.72), 285 | ], 286 | "background": [ 287 | Lognorm(0.01 / 2.0, 1.7, 6400.0), 288 | Lognorm(0.076 / 2.0, 2.0, 2300.0), 289 | Lognorm(1.02 / 2.0, 2.16, 3.2), 290 | ], 291 | "urban": [ 292 | Lognorm(0.014 / 2.0, 1.8, 10600.0), 293 | Lognorm(0.054 / 2.0, 2.16, 32000.0), 294 | Lognorm(0.86 / 2.0, 2.21, 5.4), 295 | ], 296 | } 297 | 298 | # Source = Aerosol-Cloud-Climate Interactions by Peter V. Hobbs, pg. 14 299 | jaenicke_distributions = { 300 | "Polar": MultiModeLognorm( 301 | mus=(0.0689, 0.375, 4.29), 302 | sigmas=(10**0.245, 10**0.300, 10**0.291), 303 | Ns=(21.7, 0.186, 3.04e-4), 304 | base=10.0, 305 | ), 306 | "Urban": MultiModeLognorm( 307 | mus=(0.00651, 0.00714, 0.0248), 308 | sigmas=(10.0**0.245, 10.0**0.666, 10.0**0.337), 309 | Ns=(9.93e4, 1.11e3, 3.64e4), 310 | base=10.0, 311 | ), 312 | "Background": MultiModeLognorm( 313 | mus=(0.0036, 0.127, 0.259), 314 | sigmas=(10.0**0.645, 10.0**0.253, 10.0**0.425), 315 | Ns=(129.0, 59.7, 63.5), 316 | base=10.0, 317 | ), 318 | "Maritime": MultiModeLognorm( 319 | mus=(0.0039, 0.133, 0.29), 320 | sigmas=(10.0**0.657, 10.0**0.210, 10.0**0.396), 321 | Ns=(133.0, 66.6, 3.06), 322 | base=10.0, 323 | ), 324 | "Remote Continental": MultiModeLognorm( 325 | mus=(0.01, 0.058, 0.9), 326 | sigmas=(10.0**0.161, 10.0**0.217, 10.0**0.38), 327 | Ns=(3.2e3, 2.9e3, 0.3), 328 | base=10.0, 329 | ), 330 | "Rural": MultiModeLognorm( 331 | mus=(0.00739, 0.0269, 0.0149), 332 | sigmas=(10.0**0.225, 10.0**0.557, 10.0**0.266), 333 | Ns=(6.65e3, 147.0, 1990.0), 334 | base=10.0, 335 | ), 336 | } 337 | -------------------------------------------------------------------------------- /pyrcel/driver.py: -------------------------------------------------------------------------------- 1 | """ Utilities for driving sets of parcel model integration strategies. 2 | 3 | Occasionally, a pathological set of input parameters to the parcel model 4 | will really muck up the ODE solver's ability to integrate the model. 5 | In that case, it would be nice to quietly adjust some of the numerical 6 | parameters for the ODE solver and re-submit the job. This module includes a 7 | workhorse function :func:`iterate_runs` which can serve this purpose and can 8 | serve as an example for more complex integration strategies. Alternatively, 9 | :func:`run_model`is a useful shortcut for building/running a model and snagging 10 | its output. 11 | 12 | """ 13 | from numpy import empty, nan 14 | from pandas import DataFrame 15 | 16 | from pyrcel.util import ParcelModelError 17 | 18 | from .activation import arg2000, mbn2014 19 | from .parcel import ParcelModel 20 | 21 | 22 | def run_model( 23 | V, 24 | initial_aerosols, 25 | T, 26 | P, 27 | dt, 28 | S0=-0.0, 29 | max_steps=1000, 30 | t_end=500.0, 31 | solver="lsoda", 32 | output_fmt="smax", 33 | terminate=False, 34 | solver_kws=None, 35 | model_kws=None, 36 | ): 37 | """Setup and run the parcel model with given solver configuration. 38 | 39 | Parameters 40 | ---------- 41 | V, T, P : float 42 | Updraft speed and parcel initial temperature and pressure. 43 | S0 : float, optional, default 0.0 44 | Initial supersaturation, as a percent. Defaults to 100% relative humidity. 45 | initial_aerosols : array_like of :class:`AerosolSpecies` 46 | Set of aerosol populations contained in the parcel. 47 | dt : float 48 | Solver timestep, in seconds. 49 | max_steps : int, optional, default 1000 50 | Maximum number of steps per solver iteration. Defaults to 1000; setting 51 | excessively high could produce extremely long computation times. 52 | t_end : float, optional, default 500.0 53 | Model time in seconds after which the integration will stop. 54 | solver : string, optional, default 'lsoda' 55 | Alias of which solver to use; see :class:`Integrator` for all options. 56 | output_fmt : string, optional, default 'smax' 57 | Alias indicating which output format to use; see :class:`ParcelModel` for 58 | all options. 59 | solver_kws, model_kws : dicts, optional 60 | Additional arguments/configuration to pass to the numerical integrator or model. 61 | 62 | Returns 63 | ------- 64 | Smax : (user-defined) 65 | Output from parcel model simulation based on user-specified `output_fmt` argument. See 66 | :class:`ParcelModel` for details. 67 | 68 | Raises 69 | ------ 70 | ParcelModelError 71 | If the model fails to initialize or breaks during runtime. 72 | 73 | """ 74 | # Setup kw dicts 75 | if model_kws is None: 76 | model_kws = {} 77 | if solver_kws is None: 78 | solver_kws = {} 79 | 80 | if V <= 0: 81 | return 0.0 82 | 83 | try: 84 | model = ParcelModel(initial_aerosols, V, T, S0, P, **model_kws) 85 | Smax = model.run( 86 | t_end, 87 | dt, 88 | max_steps, 89 | solver=solver, 90 | output_fmt=output_fmt, 91 | terminate=terminate, 92 | **solver_kws 93 | ) 94 | except ParcelModelError: 95 | return None 96 | return Smax 97 | 98 | 99 | def iterate_runs( 100 | V, 101 | initial_aerosols, 102 | T, 103 | P, 104 | S0=-0.0, 105 | dt=0.01, 106 | dt_iters=2, 107 | t_end=500.0, 108 | max_steps=500, 109 | output_fmt="smax", 110 | fail_easy=True, 111 | ): 112 | """Iterate through several different strategies for integrating the parcel model. 113 | 114 | As long as `fail_easy` is set to `False`, the strategies this method implements are: 115 | 116 | 1. **CVODE** with a 10 second time limit and 2000 step limit. 117 | 2. **LSODA** with up to `dt_iters` iterations, where the timestep `dt` is 118 | halved each time. 119 | 3. **LSODE** with coarse tolerance and the original timestep. 120 | 121 | If these strategies all fail, the model will print a statement indicating such 122 | and return either -9999 if `output_fmt` was 'smax', or an empty array or DataFrame 123 | accordingly. 124 | 125 | Parameters 126 | ---------- 127 | V, T, P : float 128 | Updraft speed and parcel initial temperature and pressure. 129 | S0 : float, optional, default 0.0 130 | Initial supersaturation, as a percent. Defaults to 100% relative humidity. 131 | initial_aerosols : array_like of :class:`AerosolSpecies` 132 | Set of aerosol populations contained in the parcel. 133 | dt : float 134 | Solver timestep, in seconds. 135 | dt_iters : int, optional, default 2 136 | Number of times to halve `dt` when attempting **LSODA** solver. 137 | max_steps : int, optional, default 1000 138 | Maximum number of steps per solver iteration. Defaults to 1000; setting 139 | excessively high could produce extremely long computation times. 140 | t_end : float, optional, default 500.0 141 | Model time in seconds after which the integration will stop. 142 | output : string, optional, default 'smax' 143 | Alias indicating which output format to use; see :class:`ParcelModel` for 144 | all options. 145 | fail_easy : boolean, optional, default `True` 146 | If `True`, then stop after the first strategy (**CVODE**) 147 | 148 | Returns 149 | ------- 150 | Smax : (user-defined) 151 | Output from parcel model simulation based on user-specified `output` argument. See 152 | :class:`ParcelModel` for details. 153 | 154 | """ 155 | aerosols = initial_aerosols 156 | if V <= 0: 157 | return 0.0, 0.0, 0.0 158 | 159 | # Check that there are actually aerosols to deal with 160 | aerosol_N = [a.distribution.N for a in initial_aerosols] 161 | if len(aerosol_N) == 1: 162 | if aerosol_N[0] < 0.01: 163 | return -9999.0, -9999.0, -9999.0 164 | else: 165 | new_aerosols = [] 166 | for i in range(len(aerosol_N)): 167 | if aerosol_N[i] > 0.01: 168 | new_aerosols.append(initial_aerosols[i]) 169 | aerosols = new_aerosols[:] 170 | 171 | S_max_arg, _, _ = arg2000(V, T, P, aerosols) 172 | S_max_fn, _, _ = mbn2014(V, T, P, aerosols) 173 | 174 | dt_orig = dt * 1.0 175 | finished = False 176 | S_max = None 177 | 178 | # Strategy 1: Try CVODE with modest tolerances. 179 | print(" Trying CVODE with default tolerance") 180 | S_max = run_model( 181 | V, 182 | aerosols, 183 | T, 184 | P, 185 | dt, 186 | S0=S0, 187 | max_steps=2000, 188 | solver="cvode", 189 | t_end=t_end, 190 | output_fmt=output_fmt, 191 | solver_kws={ 192 | "iter": "Newton", 193 | "time_limit": 10.0, 194 | "linear_solver": "DENSE", 195 | }, 196 | ) 197 | 198 | # Strategy 2: Iterate over some increasingly relaxed tolerances for LSODA. 199 | if (S_max is None) and not fail_easy: 200 | while dt > dt_orig / (2**dt_iters): 201 | print(" Trying LSODA, dt = %1.3e, max_steps = %d" % (dt, max_steps)) 202 | S_max = run_model( 203 | V, 204 | aerosols, 205 | T, 206 | P, 207 | dt, 208 | S0, 209 | max_steps, 210 | solver="lsoda", 211 | t_end=t_end, 212 | ) 213 | if not S_max: 214 | dt /= 2.0 215 | print(" Retrying...") 216 | else: 217 | finished = True 218 | break 219 | 220 | # Strategy 3: Last ditch numerical integration with LSODE. This will likely take a 221 | # a very long time. 222 | if (not finished) and (S_max is None) and (not fail_easy): 223 | print(" Trying LSODE") 224 | S_max = run_model( 225 | V, 226 | aerosols, 227 | T, 228 | P, 229 | dt_orig, 230 | max_steps=1000, 231 | solver="lsode", 232 | t_end=t_end, 233 | S0=S0, 234 | ) 235 | 236 | # Strategy 4: If all else fails return -9999. 237 | if S_max is None: 238 | if output_fmt == "smax": 239 | S_max = -9999.0 240 | elif output_fmt == "arrays": 241 | S_max = empty([0]), empty([0]) 242 | elif output_fmt == "dataframes": 243 | S_max = ( 244 | DataFrame(data={"S": [nan]}), 245 | DataFrame(data={"aerosol1": [nan]}), 246 | ) 247 | else: 248 | S_max = nan 249 | print(" failed", V, dt) 250 | 251 | return S_max, S_max_arg, S_max_fn 252 | return S_max, S_max_arg, S_max_fn 253 | return S_max, S_max_arg, S_max_fn 254 | -------------------------------------------------------------------------------- /pyrcel/integrator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ Interface to numerical ODE solvers. 3 | """ 4 | import sys 5 | 6 | # Compatibility - timer functions 7 | # In Python 3, the more accurate `time.process_time()` method is available. But 8 | # for legacy support, can default instead to `time.clock()` 9 | import time 10 | import warnings 11 | from abc import ABCMeta, abstractmethod 12 | 13 | import numpy as np 14 | 15 | if sys.version_info[0] < 3: 16 | timer = time.clock 17 | else: 18 | timer = time.process_time 19 | 20 | from . import constants as c 21 | 22 | available_integrators = ["odeint"] 23 | 24 | try: 25 | from odespy import Vode 26 | from odespy.odepack import Lsoda, Lsode 27 | 28 | available_integrators.extend(["lsode", "lsoda", "vode"]) 29 | except ImportError: 30 | warnings.warn( 31 | "Could not import odespy package; " 32 | "invoking the 'lsoda' or 'lsode' options will fail!" 33 | ) 34 | 35 | 36 | try: 37 | # from assimulo.solvers.odepack import LSODAR 38 | from assimulo.exception import TimeLimitExceeded 39 | from assimulo.problem import Explicit_Problem 40 | from assimulo.solvers.sundials import CVode, CVodeError 41 | 42 | available_integrators.extend(["cvode", "lsodar"]) 43 | except ImportError: 44 | warnings.warn("Could not import Assimulo; " "invoking the CVode solver will fail!") 45 | 46 | 47 | __all__ = ["Integrator"] 48 | 49 | state_atol = [1e-4, 1e-4, 1e-4, 1e-10, 1e-10, 1e-4, 1e-8] 50 | state_rtol = 1e-7 51 | 52 | 53 | class Integrator(metaclass=ABCMeta): 54 | """ 55 | Container class for the various integrators to use in the parcel model. 56 | 57 | All defined integrators should return a tuple whose first value ``x`` is either 58 | ``None`` or vector containing the parcel state at all requested timestamps, and 59 | whose second value is a boolean indicating whether the model run was successful. 60 | 61 | """ 62 | 63 | def __init__(self, rhs, output_dt, solver_dt, y0, args, t0=0.0, console=False): 64 | self.output_dt = output_dt 65 | self.solver_dt = solver_dt 66 | self.y0 = y0 67 | self.t0 = t0 68 | self.console = console 69 | 70 | self.args = args 71 | 72 | def _user_rhs(t, y): 73 | dode_dt = rhs(y, t, *self.args) 74 | return dode_dt 75 | 76 | self.rhs = _user_rhs 77 | 78 | @abstractmethod 79 | def integrate(self, t_end, **kwargs): 80 | pass 81 | 82 | @abstractmethod 83 | def __repr__(self): 84 | pass 85 | 86 | @staticmethod 87 | def solver(method): 88 | """Maps a solver name to a function.""" 89 | solvers = { 90 | # # SciPy interfaces 91 | # 'odeint': Integrator._solve_odeint, 92 | # # ODESPY interfaces 93 | # 'lsoda': partial(Integrator._solve_with_odespy, method='lsoda'), 94 | # 'lsode': partial(Integrator._solve_with_odespy, method='lsode'), 95 | # 'vode': partial(Integrator._solve_with_odespy, method='vode'), 96 | # # Assimulo interfaces 97 | # 'cvode': partial(Integrator._solve_with_assimulo, method='cvode'), 98 | # 'lsodar': partial(Integrator._solve_with_assimulo, method='lsodar'), 99 | "cvode": CVODEIntegrator 100 | } 101 | 102 | if method in available_integrators: 103 | return solvers[method] 104 | else: 105 | # solver name is not defined, or the module containing 106 | # it is unavailable 107 | raise ValueError("integrator for %s is not available" % method) 108 | 109 | 110 | class ExtendedProblem(Explicit_Problem): 111 | """This extension of the Assimulo 'Explicit_Problem' class 112 | encodes some of the logic particular to the parcel model simulation, 113 | specifically rules for terminating the simulation and detecting 114 | events such as the maximum supersaturation occurring""" 115 | 116 | name = "Parcel model ODEs" 117 | sw0 = [True, False] # Normal integration switch # Past cut-off switch 118 | t_cutoff = 1e5 119 | dS_dt = 1.0 120 | 121 | def __init__(self, rhs_fcn, rhs_args, terminate_depth, *args, **kwargs): 122 | self.rhs_fcn = rhs_fcn 123 | self.rhs_args = rhs_args 124 | self.V = rhs_args[3] 125 | self.terminate_time = terminate_depth / self.V 126 | super(Explicit_Problem, self).__init__(*args, **kwargs) 127 | 128 | def rhs(self, t, y, sw): 129 | if not sw[1]: # Normal integration before cutoff 130 | dode_dt = self.rhs_fcn(t, y) # FROM THE CVODEINTEGRATOR 131 | self.dS_dt = dode_dt[c.N_STATE_VARS - 1] 132 | else: 133 | # There may be a bug here. I can't recall when this branch is ever run; it 134 | # seems to zero out the state derivative, but to construct that array it 135 | # should be looking at self.rhs_args, not self.args (which isn't saved). 136 | # I'm going to comment out this line which I think is broken and replace it 137 | # with the correct one for now, but leave a record of this change 138 | # Daniel Rothenberg - 2/15/2016 139 | # dode_dt = np.zeros(c.N_STATE_VARS + self.args[0]) # FROM INIT ARGS 140 | dode_dt = np.zeros(c.N_STATE_VARS + self.rhs_args[0]) 141 | return dode_dt 142 | 143 | # The event function 144 | def state_events(self, t, y, sw): 145 | """Check whether an 'event' has occurred. We want to see if the 146 | supersaturation is decreasing or not.""" 147 | if sw[0]: 148 | smax_event = self.dS_dt 149 | else: 150 | smax_event = -1.0 151 | 152 | t_cutoff_event = t - self.t_cutoff 153 | 154 | return np.array([smax_event > 0, t_cutoff_event < 0]) 155 | 156 | # Event handling function 157 | def handle_event(self, solver, event_info): 158 | """Event handling. This function is called when Assimulo finds 159 | an event as specified by the event function.""" 160 | event_info = event_info[0] # Only state events, event_info[1] is time events 161 | if event_info[0] != 0: 162 | solver.sw[0] = False 163 | self.t_cutoff = solver.t + self.terminate_time 164 | 165 | def handle_result(self, solver, t, y): 166 | if t < self.t_cutoff: 167 | Explicit_Problem.handle_result(self, solver, t, y) 168 | 169 | 170 | class CVODEIntegrator(Integrator): 171 | kwargs = None # Save the kwargs used for setting up the interface to CVODE! 172 | 173 | def __init__( 174 | self, 175 | rhs, 176 | output_dt, 177 | solver_dt, 178 | y0, 179 | args, 180 | t0=0.0, 181 | console=False, 182 | terminate=False, 183 | terminate_depth=100.0, 184 | **kwargs 185 | ): 186 | self.terminate = terminate 187 | super(CVODEIntegrator, self).__init__( 188 | rhs, output_dt, solver_dt, y0, args, t0, console 189 | ) 190 | 191 | # Setup solver 192 | if terminate: 193 | self.prob = ExtendedProblem( 194 | self.rhs, self.args, terminate_depth, y0=self.y0 195 | ) 196 | else: 197 | self.prob = Explicit_Problem(self.rhs, self.y0) 198 | 199 | self.sim = self._setup_sim(**kwargs) 200 | 201 | self.kwargs = kwargs 202 | 203 | def _setup_sim(self, **kwargs): 204 | """Create a simulation interface to Assimulo using CVODE, given 205 | a problem definition 206 | 207 | """ 208 | 209 | # Create Assimulo interface 210 | sim = CVode(self.prob) 211 | sim.discr = "BDF" 212 | sim.maxord = 5 213 | 214 | # Setup some default arguments for the ODE solver, or override 215 | # if available. This is very hackish, but it's fine for now while 216 | # the number of anticipated tuning knobs is small. 217 | if "maxh" in kwargs: 218 | sim.maxh = kwargs["maxh"] 219 | else: 220 | sim.maxh = np.min([0.1, self.output_dt]) 221 | 222 | if "minh" in kwargs: 223 | sim.minh = kwargs["minh"] 224 | # else: sim.minh = 0.001 225 | 226 | if "iter" in kwargs: 227 | sim.iter = kwargs["iter"] 228 | else: 229 | sim.iter = "Newton" 230 | 231 | if "linear_solver" in kwargs: 232 | sim.linear_solver = kwargs["linear_solver"] 233 | 234 | if "max_steps" in kwargs: # DIFFERENT NAME!!!! 235 | sim.maxsteps = kwargs["max_steps"] 236 | else: 237 | sim.maxsteps = 1000 238 | 239 | if "time_limit" in kwargs: 240 | sim.time_limit = kwargs["time_limit"] 241 | sim.report_continuously = True 242 | else: 243 | sim.time_limit = 0.0 244 | 245 | # Don't save the [t_-, t_+] around events 246 | sim.store_event_points = False 247 | 248 | # Setup tolerances 249 | nr = self.args[0] 250 | sim.rtol = state_rtol 251 | sim.atol = state_atol + [1e-12] * nr 252 | 253 | if not self.console: 254 | sim.verbosity = 50 255 | else: 256 | sim.verbosity = 40 257 | # sim.report_continuously = False 258 | 259 | # Save the Assimulo interface 260 | return sim 261 | 262 | def integrate(self, t_end, **kwargs): 263 | # Compute integration logic. We need to know: 264 | # 1) How are we iterating the solver loop? 265 | t_increment = self.solver_dt 266 | # 2) How many points do we want to interpolate for output? 267 | n_out = int(self.solver_dt / self.output_dt) 268 | t_current = self.t0 269 | 270 | if self.console: 271 | print() 272 | print("Integration Loop") 273 | print() 274 | print(" step time walltime Δwalltime | z T S") 275 | print(" " "------------------------------------|----------------------") 276 | step_fmt = ( 277 | " {:5d} {:7.2f}s {:7.2f}s {:8.2f}s |" " {:5.1f} {:7.2f} {:6.2f}%" 278 | ) 279 | 280 | txs, xxs = [], [] 281 | n_steps = 1 282 | total_walltime = 0.0 283 | now = timer() 284 | while t_current < t_end: 285 | if self.console: 286 | # Update timing estimates 287 | delta_walltime = timer() - now 288 | total_walltime += delta_walltime 289 | 290 | # Grab state vars 291 | state = self.y0 if n_steps == 1 else xxs[-1][-1] 292 | _z = state[c.STATE_VAR_MAP["z"]] 293 | _T = state[c.STATE_VAR_MAP["T"]] 294 | _S = state[c.STATE_VAR_MAP["S"]] * 100 295 | print( 296 | step_fmt.format( 297 | n_steps, 298 | t_current, 299 | total_walltime, 300 | delta_walltime, 301 | _z, 302 | _T, 303 | _S, 304 | ) 305 | ) 306 | try: 307 | now = timer() 308 | out_list = np.linspace(t_current, t_current + t_increment, n_out + 1) 309 | tx, xx = self.sim.simulate(t_current + t_increment, 0, out_list) 310 | except CVodeError as e: 311 | raise ValueError("Something broke in CVode: %r" % e) 312 | except TimeLimitExceeded: 313 | raise ValueError("CVode took too long to complete") 314 | 315 | if n_out == 1: 316 | txs.append(tx[-1]) 317 | xxs.append(xx[-1]) 318 | else: 319 | txs.extend(tx[:-1]) 320 | xxs.append(xx[:-1]) 321 | t_current = tx[-1] 322 | 323 | # Has the max been found and can we terminate? 324 | if self.terminate: 325 | if not self.sim.sw[0]: 326 | if self.console: 327 | print("---- termination condition reached ----") 328 | break 329 | 330 | n_steps += 1 331 | if self.console: 332 | print("---- end of integration loop ----") 333 | 334 | # Determine output information 335 | t = np.array(txs) 336 | if n_out == 1: # can just merge the outputs 337 | x = np.array(xxs) 338 | else: # Need to concatenate lists of outputs 339 | x = np.concatenate(xxs) 340 | 341 | return x, t, True 342 | 343 | def __repr__(self): 344 | return "CVODE integrator - direct Assimulo interface" 345 | -------------------------------------------------------------------------------- /pyrcel/output.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import pickle 3 | from datetime import datetime as ddt 4 | 5 | import numpy as np 6 | import pandas as pd 7 | import xarray as xr 8 | 9 | from . import __version__ as ver 10 | from . import constants as c 11 | from .thermo import rho_air 12 | from .util import ParcelModelError 13 | 14 | #: Acceptable output formats 15 | OUTPUT_FORMATS = ["nc", "obj", "csv"] 16 | 17 | 18 | def get_timestamp(fmt="%m%d%y_%H%M%S"): 19 | """Get current timestamp in MMDDYY_hhmmss format.""" 20 | 21 | current_time = ddt.now() 22 | timestamp = current_time.strftime("%m%d%y_%H%M%S") 23 | 24 | return timestamp 25 | 26 | 27 | def write_parcel_output( 28 | filename=None, 29 | format=None, 30 | parcel=None, 31 | parcel_df=None, 32 | aerosol_dfs=None, 33 | other_dfs=None, 34 | ): 35 | """Write model output to disk. 36 | 37 | Wrapper for methods to write parcel model output to disk. 38 | 39 | Parameters 40 | ---------- 41 | filename : str 42 | Full filename to write output; if not supplied, will default 43 | to the current timestamp 44 | format : str 45 | Format to use from ``OUTPUT_FORMATS``; must be supplied if no 46 | filename is provided 47 | parcel : ParcelModel 48 | A ParcelModel which has already been integrated at least once 49 | parcel_df : DataFrame 50 | Model thermodynamic history 51 | aerosol_dfs : Panel 52 | Aerosol size history 53 | other_dfs : list of DataFrames 54 | Additional DataFrames to include in output; must have the same index 55 | as the parcel's results when transformed to a DataFrame! 56 | 57 | """ 58 | 59 | if not filename: 60 | if not format: 61 | raise ParcelModelError("Must supply either a filename or format.") 62 | if format not in OUTPUT_FORMATS: 63 | raise ParcelModelError("Please supply a format from %r" % OUTPUT_FORMATS) 64 | basename = get_timestamp() 65 | extension = format 66 | else: 67 | basename, extension = os.path.splitext(filename) 68 | extension = extension[1:] # strip '.' 69 | if extension not in OUTPUT_FORMATS: 70 | extension = format = "obj" 71 | else: 72 | format = extension 73 | 74 | if parcel.console: 75 | print() 76 | print( 77 | "Saving output to %s format with base filename %s" % (extension, basename) 78 | ) 79 | print() 80 | 81 | # filename = "%s.%s" % (basename, extension) 82 | 83 | # Sanity - check, either we need the dataframes themselves or 84 | # we need the model 85 | if (parcel_df is None) and (aerosol_dfs is None): 86 | if parcel is None: 87 | raise ValueError("Need to supply either dataframes or model") 88 | else: 89 | parcel_df, aerosol_dfs = parcel_to_dataframes(parcel) 90 | # Concatenate on the additional dataframes supplied by the user 91 | if other_dfs is not None: 92 | for df in other_dfs: 93 | parcel_df = pd.concat([parcel_df, df], axis=1) 94 | 95 | # 1) csv 96 | if format == "csv": 97 | # Write parcel data 98 | parcel_df.to_csv("%s_%s.%s" % (basename, "parcel", extension)) 99 | 100 | # Write aerosol data 101 | for species, data in list(aerosol_dfs.items()): 102 | data.to_csv("%s_%s.%s" % (basename, species, extension)) 103 | 104 | # 2) nc 105 | elif format == "nc": 106 | ## Construct xarray datastructure to write to netCDF 107 | ds = xr.Dataset(attrs={"Conventions": "CF-1.0", "source": "pyrcel v%s" % ver}) 108 | 109 | ds.coords["time"] = ( 110 | "time", 111 | parcel.time, 112 | {"units": "seconds", "long_name": "simulation time"}, 113 | ) 114 | 115 | ## Aerosol coordinates and basic data 116 | for aerosol in parcel.aerosols: 117 | if parcel.console: 118 | print(aerosol) 119 | 120 | nr = aerosol.nr 121 | r_drys = aerosol.r_drys * 1e6 122 | kappas = [aerosol.kappa] * nr 123 | Nis = aerosol.Nis * 1e-6 124 | species = aerosol.species 125 | 126 | aer_coord = "%s_bins" % species 127 | 128 | ds.coords[aer_coord] = ( 129 | aer_coord, 130 | np.array(list(range(1, aerosol.nr + 1)), dtype=np.int32), 131 | {"long_name": "%s size bin number" % species}, 132 | ) 133 | ds["%s_rdry" % species] = ( 134 | (aer_coord,), 135 | r_drys, 136 | {"units": "micron", "long_name": "%s bin dry radii" % species}, 137 | ) 138 | ds["%s_kappas" % species] = ( 139 | (aer_coord,), 140 | kappas, 141 | {"long_name": "%s bin kappa-kohler hygroscopicity" % species}, 142 | ) 143 | ds["%s_Nis" % species] = ( 144 | (aer_coord,), 145 | Nis, 146 | { 147 | "units": "cm-3", 148 | "long_name": "%s bin number concentration" % species, 149 | }, 150 | ) 151 | 152 | size_data = aerosol_dfs[species].values * 1e6 153 | ds["%s_size" % species] = ( 154 | ("time", aer_coord), 155 | size_data, 156 | {"units": "micron", "long_name": "%s bin wet radii" % species}, 157 | ) 158 | 159 | ## Parcel data 160 | ds["S"] = ( 161 | ("time",), 162 | parcel_df["S"] * 100.0, 163 | {"units": "%", "long_name": "Supersaturation"}, 164 | ) 165 | ds["T"] = ( 166 | ("time",), 167 | parcel_df["T"], 168 | {"units": "K", "long_name": "Temperature"}, 169 | ) 170 | ds["P"] = ( 171 | ("time",), 172 | parcel_df["P"], 173 | {"units": "Pa", "long_name": "Pressure"}, 174 | ) 175 | ds["wv"] = ( 176 | ("time",), 177 | parcel_df["wv"], 178 | {"units": "kg/kg", "long_name": "Water vapor mixing ratio"}, 179 | ) 180 | ds["wc"] = ( 181 | ("time",), 182 | parcel_df["wc"], 183 | {"units": "kg/kg", "long_name": "Liquid water mixing ratio"}, 184 | ) 185 | ds["wi"] = ( 186 | ("time",), 187 | parcel_df["wi"], 188 | {"units": "kg/kg", "long_name": "Ice water mixing ratio"}, 189 | ) 190 | ds["height"] = ( 191 | ("time",), 192 | parcel_df["z"], 193 | {"units": "meters", "long_name": "Parcel height above start"}, 194 | ) 195 | ds["rho"] = ( 196 | ("time",), 197 | parcel_df["rho"], 198 | {"units": "kg/m3", "long_name": "Air density"}, 199 | ) 200 | 201 | ds["wtot"] = ( 202 | ("time",), 203 | parcel_df["wv"] + parcel_df["wc"], 204 | {"units": "kg/kg", "long_name": "Total water mixing ratio"}, 205 | ) 206 | 207 | if "alpha" in parcel_df: 208 | ds["alpha"] = ( 209 | ("time",), 210 | parcel_df["alpha"], 211 | {"long_name": "ratio of Nkn/Neq"}, 212 | ) 213 | if "phi" in parcel_df: 214 | ds["phi"] = ( 215 | ("time",), 216 | parcel_df["phi"], 217 | {"long_name": "fraction of not-strictly activated drops in Nkn"}, 218 | ) 219 | if "eq" in parcel_df: 220 | ds["eq"] = ( 221 | ("time",), 222 | parcel_df["eq"], 223 | {"long_name": "Equilibrium Kohler-activated fraction"}, 224 | ) 225 | if "kn" in parcel_df: 226 | ds["kn"] = ( 227 | ("time",), 228 | parcel_df["kn"], 229 | {"long_name": "Kinetic activated fraction"}, 230 | ) 231 | 232 | ## Save to disk 233 | ds.to_netcdf(basename + ".nc") 234 | 235 | # 3) obj (pickle) 236 | else: 237 | assert parcel 238 | 239 | with open(basename + ".obj", "w") as f: 240 | pickle.dump(parcel, f) 241 | 242 | 243 | def parcel_to_dataframes(parcel): 244 | """Convert model simulation to dataframe. 245 | 246 | Parameters 247 | ---------- 248 | parcel : ParcelModel 249 | 250 | Returns 251 | ------- 252 | DataFrame and list of DataFrames 253 | The first returned DataFrame will be the parcel model thermo/dynamical 254 | output with keys ``T``, ``wv``, ``wc``, ``S``, ``z``, and indexed by 255 | ``time``. The list of DataFrames shares the same ``time`` index, but 256 | is divided up so that the radii of each aerosol species are tracked in 257 | different containers. 258 | 259 | Raises 260 | ------ 261 | ParcelModelError 262 | If the argument does not have any output saved 263 | 264 | Notes 265 | ----- 266 | Will double-check that the parcel model passed into the function actually 267 | has output by seeing if it as the attribute ``x``, which is where such output 268 | would be saved. 269 | 270 | """ 271 | 272 | x = parcel.x 273 | heights = parcel.heights 274 | time = parcel.time 275 | 276 | parcel_out = pd.DataFrame( 277 | {var: x[:, i] for i, var in enumerate(c.STATE_VARS)}, index=time 278 | ) 279 | 280 | ## Add some thermodynamic output to the parcel model dataframe 281 | parcel_out["rho"] = rho_air(parcel_out["T"], parcel_out["P"], parcel_out["S"] + 1.0) 282 | 283 | aerosol_dfs = {} 284 | species_shift = 0 # increment by nr to select the next aerosol's radii 285 | for aerosol in parcel.aerosols: 286 | nr = aerosol.nr 287 | species = aerosol.species 288 | 289 | labels = ["r%03d" % i for i in range(nr)] 290 | radii_dict = dict() 291 | for i, label in enumerate(labels): 292 | radii_dict[label] = x[:, c.N_STATE_VARS + species_shift + i] 293 | 294 | aerosol_dfs[species] = pd.DataFrame(radii_dict, index=time) 295 | species_shift += nr 296 | 297 | return parcel_out, aerosol_dfs 298 | -------------------------------------------------------------------------------- /pyrcel/postprocess.py: -------------------------------------------------------------------------------- 1 | """ Collection of output post-processing routines. 2 | """ 3 | import numpy as np 4 | import pandas as pd 5 | 6 | from .activation import binned_activation 7 | 8 | 9 | def simulation_activation(model, parcel_df, aerosols_panel): 10 | """Given the DataFrame output from a parcel model simulation, compute 11 | activation kinetic limitation diagnostics. 12 | 13 | Parameters 14 | ---------- 15 | model : ParcelModel 16 | The ParcelModel 17 | parcel_df : DataFrame used to generate the results to be analyzed 18 | The DataFrame containing the parcel's thermodynamic trajectory 19 | aerosols_panel : Panel 20 | A Panel collection of DataFrames containing the aerosol size evolution 21 | 22 | Returns 23 | ------- 24 | act_stats : DataFrame 25 | A DataFrame containing the activation statistics 26 | 27 | """ 28 | 29 | initial_row = parcel_df.iloc[0] 30 | Smax_i, T_i = initial_row["S"], initial_row["T"] 31 | 32 | acts = {"eq": [], "kn": [], "alpha": [], "phi": []} 33 | 34 | initial_aerosols = model.aerosols 35 | N_all_modes = np.sum([aer.total_N for aer in initial_aerosols]) 36 | N_fracs = {aer.species: aer.total_N / N_all_modes for aer in initial_aerosols} 37 | for i in range(len(parcel_df)): 38 | row_par = parcel_df.iloc[i] 39 | rows_aer = {key: aerosols_panel[key].iloc[i] for key in aerosols_panel} 40 | 41 | # Update thermo 42 | T_i = row_par["T"] 43 | if row_par["S"] > Smax_i: 44 | Smax_i = row_par["S"] 45 | 46 | eq_tot, kn_tot, alpha_tot, phi_tot = 0.0, 0.0, 0.0, 0.0 47 | for aerosol in initial_aerosols: 48 | N_frac = N_fracs[aerosol.species] 49 | rs = rows_aer[aerosol.species] 50 | 51 | eq, kn, alpha, phi = binned_activation(Smax_i, T_i, rs, aerosol) 52 | eq_tot += eq * N_frac 53 | kn_tot += kn * N_frac 54 | alpha_tot += alpha * N_frac 55 | phi_tot += phi * N_frac 56 | 57 | acts["kn"].append(kn_tot) 58 | acts["eq"].append(eq_tot) 59 | acts["alpha"].append(alpha_tot) 60 | acts["phi"].append(phi_tot) 61 | acts_total = pd.DataFrame(acts, index=parcel_df.index) 62 | 63 | return acts_total 64 | -------------------------------------------------------------------------------- /pyrcel/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darothen/pyrcel/ad790b4b074b183f32f8d98a1a820747b9918988/pyrcel/scripts/__init__.py -------------------------------------------------------------------------------- /pyrcel/scripts/run_parcel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | CLI interface to run parcel model simulation. 4 | 5 | """ 6 | import os 7 | import sys 8 | from argparse import ArgumentParser, RawDescriptionHelpFormatter 9 | 10 | import yaml 11 | 12 | import pyrcel as pm 13 | import pyrcel.util 14 | 15 | parser = ArgumentParser( 16 | description=__doc__, formatter_class=RawDescriptionHelpFormatter 17 | ) 18 | parser.add_argument( 19 | "namelist", 20 | type=str, 21 | metavar="config.yml", 22 | help="YAML namelist controlling simulation configuration", 23 | ) 24 | 25 | DIST_MAP = { 26 | "lognormal": pm.Lognorm, 27 | } 28 | 29 | 30 | def run_parcel(): 31 | # Read command-line arguments 32 | args = parser.parse_args() 33 | 34 | # Convert the namelist file into an in-memory dictionary 35 | try: 36 | print("Attempting to read simulation namelist {}".format(args.namelist)) 37 | with open(args.namelist, "rb") as f: 38 | y = yaml.safe_load(f) 39 | except IOError: 40 | print("Couldn't read file {}".format(args.namelist)) 41 | sys.exit(0) 42 | 43 | # Create the aerosol 44 | aerosol_modes = [] 45 | print("Constructing aerosol modes") 46 | for i, aerosol_params in enumerate(y["initial_aerosol"], start=1): 47 | ap = aerosol_params 48 | 49 | dist = DIST_MAP[ap["distribution"]](**ap["distribution_args"]) 50 | 51 | aer = pm.AerosolSpecies(ap["name"], dist, kappa=ap["kappa"], bins=ap["bins"]) 52 | print(" {:2d})".format(i), aer) 53 | 54 | aerosol_modes.append(aer) 55 | 56 | # Set up the model 57 | ic = y["initial_conditions"] 58 | print("Initializing model") 59 | try: 60 | model = pm.ParcelModel( 61 | aerosol_modes, 62 | V=ic["updraft_speed"], 63 | T0=ic["temperature"], 64 | S0=-1.0 * (1.0 - ic["relative_humidity"]), 65 | P0=ic["pressure"], 66 | console=True, 67 | truncate_aerosols=True, 68 | ) 69 | except pyrcel.util.ParcelModelError: 70 | print("Something went wrong setting up the model") 71 | sys.exit(0) 72 | 73 | # Run the model 74 | mc = y["model_control"] 75 | print("Beginning simulation") 76 | try: 77 | par_out, aer_out = model.run( 78 | max_steps=2000, 79 | solver="cvode", 80 | output_fmt="dataframes", 81 | # terminate=True, 82 | # terminate_depth=10., 83 | **mc, 84 | ) 85 | except pyrcel.util.ParcelModelError: 86 | print("Something went wrong during model run") 87 | sys.exit(0) 88 | 89 | # Output 90 | ec = y["experiment_control"] 91 | 92 | Smax = par_out["S"].max() 93 | T_fin = par_out["T"].iloc[-1] 94 | 95 | # Make output directory if it doesn't exist 96 | if not os.path.exists(ec["output_dir"]): 97 | os.makedirs(ec["output_dir"]) 98 | 99 | out_file = os.path.join(ec["output_dir"], ec["name"]) + ".nc" 100 | try: 101 | print("Trying to save output to {}".format(out_file)) 102 | pm.output.write_parcel_output(out_file, parcel=model) 103 | except (IOError, RuntimeError): 104 | print("Something went wrong saving to {}".format(out_file)) 105 | sys.exit(0) 106 | 107 | # Succesful completion 108 | print("Done!") 109 | 110 | 111 | if __name__ == "__main__": 112 | run_parcel() 113 | -------------------------------------------------------------------------------- /pyrcel/test/__init__.py: -------------------------------------------------------------------------------- 1 | from itertools import product 2 | 3 | import numpy as np 4 | 5 | ## Import unit testing librarys 6 | try: 7 | import unittest2 as unittest 8 | except ImportError: 9 | import unittest 10 | 11 | 12 | def prod_to_array(*iterables): 13 | return np.array(list(product(*iterables))) 14 | -------------------------------------------------------------------------------- /pyrcel/test/generate_data.py: -------------------------------------------------------------------------------- 1 | """ Generate test case data for future reference. 2 | """ 3 | import os 4 | import pickle 5 | from itertools import product 6 | 7 | import numpy as np 8 | 9 | from pyrcel import thermo 10 | 11 | REFERENCE_FN = "results.dict" 12 | 13 | 14 | def generate_reference(overwrite=False): 15 | 16 | results = dict() 17 | results["temperatures"] = np.linspace(233, 333, 10) # K 18 | results["pressures"] = np.linspace(100, 1050, 10) # hPa 19 | results["radii"] = np.logspace(-3, 1, 10) # microns 20 | results["densities"] = np.linspace(0.5, 1.5, 10) 21 | results["dry_radii"] = np.logspace(-8, -6, 5) 22 | results["kappas"] = np.logspace(-2, 0, 5) 23 | results["r_over_r_dry"] = np.logspace(0.1, 3, 5) 24 | 25 | print("dv_cont", end=", ") 26 | results["dv_cont"] = [ 27 | thermo.dv_cont(T, P * 100) 28 | for T, P in product(results["temperatures"], results["pressures"]) 29 | ] 30 | print("(%d cases)" % len(results["dv_cont"])) 31 | 32 | print("dv", end=", ") 33 | results["dv"] = [ 34 | thermo.dv(T, r * 1e-6, P * 100) 35 | for T, r, P in product( 36 | results["temperatures"], results["radii"], results["pressures"] 37 | ) 38 | ] 39 | print("(%d cases)" % len(results["dv"])) 40 | 41 | print("rho_air", end=", ") 42 | results["rho_air"] = [ 43 | thermo.rho_air(T, P * 100) 44 | for T, P in product(results["temperatures"], results["pressures"]) 45 | ] 46 | print("(%d cases)" % len(results["rho_air"])) 47 | 48 | print("es", end=", ") 49 | results["es"] = [thermo.es(T - 273.15) for T in results["temperatures"]] 50 | print("(%d cases)" % len(results["es"])) 51 | 52 | print("ka_cont", end=", ") 53 | results["ka_cont"] = [thermo.ka_cont(T) for T in results["temperatures"]] 54 | print("(%d cases)" % len(results["ka_cont"])) 55 | 56 | print("ka", end=", ") 57 | results["ka"] = [ 58 | thermo.ka(T, rho, r * 1e-6) 59 | for T, rho, r in product( 60 | results["temperatures"], results["densities"], results["radii"] 61 | ) 62 | ] 63 | print("(%d cases)" % len(results["ka"])) 64 | 65 | print("sigma_w", end=", ") 66 | results["sigma_w"] = [thermo.sigma_w(T) for T in results["temperatures"]] 67 | print("(%d cases)" % len(results["sigma_w"])) 68 | 69 | print("Seq", end=", ") 70 | results["Seq_approx"] = [ 71 | thermo.Seq(f * r_dry * 1e-6, r_dry * 1e-6, T, kappa) 72 | for f, r_dry, T, kappa in product( 73 | results["r_over_r_dry"], 74 | results["dry_radii"], 75 | results["temperatures"], 76 | results["kappas"], 77 | ) 78 | ] 79 | results["Seq_exact"] = [ 80 | thermo.Seq_approx(f * r_dry * 1e-6, r_dry * 1e-6, T, kappa) 81 | for f, r_dry, T, kappa in product( 82 | results["r_over_r_dry"], 83 | results["dry_radii"], 84 | results["temperatures"], 85 | results["kappas"], 86 | ) 87 | ] 88 | print("(%d cases)" % (2 * len(results["Seq_exact"]),)) 89 | 90 | if (not os.path.exists(REFERENCE_FN)) or overwrite: 91 | with open(REFERENCE_FN, "wb") as f: 92 | pickle.dump(results, f) 93 | 94 | 95 | if __name__ == "__main__": 96 | 97 | generate_reference(True) 98 | -------------------------------------------------------------------------------- /pyrcel/test/pm_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/env python 2 | 3 | import time 4 | 5 | import matplotlib.pyplot as plt 6 | 7 | import pyrcel as pm 8 | from pyrcel.postprocess import simulation_activation 9 | 10 | P0 = 95000.0 # Pressure, Pa 11 | T0 = 290.15 # Temperature, K 12 | S0 = -0.01 # Supersaturation, 1-RH 13 | V = 1.0 14 | accom = 0.1 15 | 16 | mu = 0.025 17 | sigma = 1.82 18 | kappa = 0.54 19 | N = 1500.0 20 | 21 | aerosol_distribution = pm.Lognorm(mu=mu, sigma=sigma, N=N) 22 | aerosol = pm.AerosolSpecies( 23 | "test", aerosol_distribution, kappa=kappa, bins=200 24 | ) 25 | 26 | output_dt = 1.0 # simulation seconds; how frequently should output be saved? 27 | solver_dt = ( 28 | 10.0 29 | ) # simulation seconds; how frequently should the solver be re-set? 30 | # Why this change? (1) Adding ice is going to introduce a 31 | # time-splitting operation, so the integration logic will need 32 | # to change accordingly and be more sequential, step-by-step. 33 | # (2) The solver works *much* better in terms of adapting to the 34 | # stiffness of the ODE when it doesn't have to try to predict very 35 | # far in advance where the solution will shoot. 36 | # 37 | # In general, use solver_dt = 10.*output_dt 38 | dZ = 1000.0 39 | t_end = dZ / V 40 | 41 | results = {} 42 | initial_aerosols = [aerosol] 43 | 44 | ## Vanilla parcel model run 45 | model = pm.ParcelModel( 46 | initial_aerosols, V, T0, S0, P0, console=True, accom=accom 47 | ) 48 | # raw_input("Continue? ") 49 | start = time.time() 50 | parcel, aerosols = model.run( 51 | t_end, 52 | output_dt, 53 | solver_dt, # note new argument order! 54 | terminate=True, 55 | terminate_depth=50.0, 56 | max_steps=2000, 57 | solver="cvode", 58 | output="dataframes", 59 | ) 60 | end = time.time() 61 | 62 | Smax = parcel.S.max() 63 | tmax = parcel.S.argmax() 64 | 65 | print("Elapsed time:", end - start) 66 | print(" Smax", Smax) 67 | print(" tmax", tmax) 68 | print("") 69 | print("Computing activation") 70 | acts_total = simulation_activation(model, parcel, aerosols) 71 | 72 | fig = plt.figure(2) 73 | ax = fig.add_subplot(111) 74 | 75 | 76 | def quick_plot(ax): 77 | plt_meta = parcel.plot("z", "S", zorder=3, ax=ax) 78 | ylims = ax.get_ylim() 79 | 80 | c = plt_meta.lines[-1].get_color() 81 | plt.vlines(parcel.z.ix[tmax], ylims[0], Smax, color=c, linestyle="dashed") 82 | plt.hlines(Smax, 0, parcel.z.iloc[tmax], color=c, linestyle="dashed") 83 | plt.ylim(S0) 84 | 85 | 86 | quick_plot(ax) 87 | 88 | model.save("test.nc", other_dfs=[acts_total]) 89 | -------------------------------------------------------------------------------- /pyrcel/test/test_thermo.py: -------------------------------------------------------------------------------- 1 | """ Test cases for thermodynamics module. 2 | 3 | Most of these test cases just compare the current version of the code's results 4 | for parameter sets versus a reference to serve as basic regression testing. 5 | 6 | """ 7 | import pickle 8 | import unittest 9 | from itertools import product 10 | 11 | from numpy.testing import assert_allclose 12 | 13 | from .generate_data import REFERENCE_FN 14 | from ..thermo import * 15 | 16 | 17 | class TestThermoTestCases(unittest.TestCase): 18 | def setUp(self): 19 | 20 | with open(REFERENCE_FN, "rb") as f: 21 | self.reference = pickle.load(f) 22 | 23 | self.temperatures = self.reference["temperatures"] 24 | self.pressures = self.reference["pressures"] 25 | self.radii = self.reference["radii"] 26 | self.densities = self.reference["densities"] 27 | 28 | def test_dv_cont(self): 29 | result = [ 30 | dv_cont(T, P * 100) 31 | for T, P in product(self.temperatures, self.pressures) 32 | ] 33 | 34 | assert_allclose(self.reference["dv_cont"], result) 35 | 36 | def test_dv(self): 37 | result = [ 38 | dv(T, r * 1e-6, P * 100) 39 | for T, r, P in product( 40 | self.temperatures, self.radii, self.pressures 41 | ) 42 | ] 43 | 44 | assert_allclose(self.reference["dv"], result) 45 | 46 | def test_rho_air(self): 47 | result = [ 48 | rho_air(T, P * 100) 49 | for T, P in product(self.temperatures, self.pressures) 50 | ] 51 | 52 | assert_allclose(self.reference["rho_air"], result) 53 | 54 | def test_es(self): 55 | result = [es(T - 273.15) for T in self.temperatures] 56 | 57 | assert_allclose(self.reference["es"], result) 58 | 59 | def test_ka_cont(self): 60 | result = [ka_cont(T) for T in self.temperatures] 61 | 62 | assert_allclose(self.reference["ka_cont"], result) 63 | 64 | def test_ka(self): 65 | result = [ 66 | ka(T, rho, r * 1e-6) 67 | for T, rho, r in product( 68 | self.temperatures, self.densities, self.radii 69 | ) 70 | ] 71 | 72 | assert_allclose(self.reference["ka"], result) 73 | 74 | def sigma_w(self): 75 | result = [sigma_w(T) for T in self.temperatures] 76 | 77 | assert_allclose(self.reference["sigma_w"], result) 78 | -------------------------------------------------------------------------------- /pyrcel/thermo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ Aerosol/atmospheric thermodynamics functions. 3 | 4 | The following sets of functions calculate useful thermodynamic quantities 5 | that arise in aerosol-cloud studies. Where possible, the source of the 6 | parameterization for each function is documented. 7 | 8 | """ 9 | import numpy as np 10 | from scipy.optimize import fminbound 11 | 12 | from .constants import * 13 | 14 | # THERMODYNAMIC FUNCTIONS 15 | 16 | 17 | def dv_cont(T, P): 18 | """Diffusivity of water vapor in air, neglecting non-continuum effects. 19 | 20 | See :func:`dv` for details. 21 | 22 | Parameters 23 | ---------- 24 | T : float 25 | ambient temperature of air surrounding droplets, K 26 | P : float 27 | ambient pressure of surrounding air, Pa 28 | 29 | Returns 30 | ------- 31 | float 32 | :math:`D_v(T, P)` in m^2/s 33 | 34 | See Also 35 | -------- 36 | dv : includes correction for non-continuum effects 37 | 38 | """ 39 | P_atm = P * 1.01325e-5 # Pa -> atm 40 | return 1e-4 * (0.211 / P_atm) * ((T / 273.0) ** 1.94) 41 | 42 | 43 | def dv(T, r, P, accom=ac): 44 | """Diffusivity of water vapor in air, modified for non-continuum effects. 45 | 46 | The diffusivity of water vapor in air as a function of temperature and pressure 47 | is given by 48 | 49 | .. math:: 50 | \\begin{equation} 51 | D_v = 10^{-4}\\frac{0.211}{P}\left(\\frac{T}{273}\\right)^{1.94} 52 | \\tag{SP2006, 17.61} 53 | \end{equation} 54 | 55 | where :math:`P` is in atm [SP2006]. Aerosols much smaller than the mean free path 56 | of the air surrounding them (:math:`K_n >> 1`) perturb the flow around them 57 | moreso than larger particles, which affects this value. We account for corrections 58 | to :math:`D_v` in the non-continuum regime via the parameterization 59 | 60 | .. math:: 61 | \\begin{equation} 62 | D'_v = \\frac{D_v}{1+ \\frac{D_v}{\\alpha_c r} 63 | \left(\\frac{2\pi M_w}{RT}\\right)^{1/2}} \\tag{SP2006, 17.62} 64 | \end{equation} 65 | 66 | where :math:`\\alpha_c` is the condensation coefficient (:const:`constants.ac`). 67 | 68 | Parameters 69 | ---------- 70 | T : float 71 | ambient temperature of air surrounding droplets, K 72 | r : float 73 | radius of aerosol/droplet, m 74 | P : float 75 | ambient pressure of surrounding air, Pa 76 | accom : float, optional (default=:const:`constants.ac`) 77 | condensation coefficient 78 | 79 | Returns 80 | ------- 81 | float 82 | :math:`D'_v(T, r, P)` in m^2/s 83 | 84 | References 85 | ---------- 86 | 87 | .. [SP2006] Seinfeld, John H, and Spyros N Pandis. Atmospheric Chemistry 88 | and Physics: From Air Pollution to Climate Change. Vol. 2nd. Wiley, 2006. 89 | 90 | See Also 91 | -------- 92 | dv_cont : neglecting correction for non-continuum effects 93 | 94 | """ 95 | dv_t = dv_cont(T, P) 96 | denom = 1.0 + (dv_t / (accom * r)) * np.sqrt((2.0 * np.pi * Mw) / (R * T)) 97 | return dv_t / denom 98 | 99 | 100 | def rho_air(T, P, RH=1.0): 101 | """Density of moist air with a given relative humidity, temperature, and pressure. 102 | 103 | Uses the traditional formula from the ideal gas law (3.41)[Petty2006]. 104 | 105 | .. math:: 106 | \\begin{equation} 107 | \\rho_a = \\frac{P}{R_d T_v} 108 | \end{equation} 109 | 110 | where :math:`T_v = T(1 + 0.61w)` and :math:`w` is the water vapor mixing ratio. 111 | 112 | Parameters 113 | ---------- 114 | T : float 115 | ambient air temperature, K 116 | P : float 117 | ambient air pressure, Pa 118 | RH : float, optional (default=1.0) 119 | relative humidity, decimal 120 | 121 | Returns 122 | ------- 123 | float 124 | :math:`\\rho_{a}` in kg m**-3 125 | 126 | References 127 | ---------- 128 | 129 | .. [Petty2006] Petty, Grant Williams. A First Course in Atmospheric Radiation. 130 | Sundog Publishing, 2006. Print. 131 | 132 | """ 133 | qsat = RH * 0.622 * (es(T - 273.15) / P) 134 | Tv = T * (1.0 + 0.61 * qsat) 135 | rho_a = P / Rd / Tv # air density 136 | 137 | return rho_a 138 | 139 | 140 | def es(T_c): 141 | """Calculates the saturation vapor pressure over water for a given temperature. 142 | 143 | Uses an empirical fit [Bolton1980], which is accurate to :math:`0.1\%` over the 144 | temperature range :math:`-30^oC \leq T \leq 35^oC`, 145 | 146 | .. math:: 147 | \\begin{equation} 148 | e_s(T) = 611.2 \exp\left(\\frac{17.67T}{T + 243.5}\\right) \\tag{RY1989, 2.17} 149 | \end{equation} 150 | 151 | where :math:`e_s` is in Pa and :math:`T` is in degrees C. 152 | 153 | Parameters 154 | ---------- 155 | T_c : float 156 | ambient air temperature, degrees C 157 | 158 | Returns 159 | ------- 160 | float 161 | :math:`e_s(T)` in Pa 162 | 163 | References 164 | ---------- 165 | 166 | .. [Bolton1980] Bolton, David. "The Computation of Equivalent Potential 167 | Temperature". Monthly Weather Review 108.8 (1980): 1046-1053 168 | 169 | .. [RY1989] Rogers, R. R., and M. K. Yau. A Short Course in Cloud Physics. 170 | Burlington, MA: Butterworth Heinemann, 1989. 171 | 172 | """ 173 | return 611.2 * np.exp(17.67 * T_c / (T_c + 243.5)) 174 | 175 | 176 | def ka_cont(T): 177 | """Thermal conductivity of air, neglecting non-continuum effects. 178 | 179 | See :func:`ka` for details. 180 | 181 | Parameters 182 | ---------- 183 | T : 184 | ambient air temperature surrounding droplet, K 185 | 186 | Returns 187 | ------- 188 | float 189 | :math:`k_a(T)` in J/m/s/K 190 | 191 | See Also 192 | -------- 193 | ka : includes correction for non-continuum effects. 194 | 195 | """ 196 | return 1e-3 * (4.39 + 0.071 * T) 197 | 198 | 199 | def ka(T, rho, r): 200 | """Thermal conductivity of air, modified for non-continuum effects. 201 | 202 | The thermal conductivity of air is given by 203 | 204 | .. math:: 205 | \\begin{equation} 206 | k_a = 10^{-3}(4.39 + 0.071T) \\tag{SP2006, 17.71} 207 | \end{equation} 208 | 209 | Modification to account for non-continuum effects (small aerosol/droplet 210 | size) yields the equation 211 | 212 | .. math:: 213 | \\begin{equation} 214 | k'_a = \\frac{k_a}{1 + \\frac{k_a}{\\alpha_t r_p \\rho C_p} 215 | \\frac{2\pi M_a}{RT}^{1/2}} \\tag{SP2006, 17.72} 216 | \end{equation} 217 | 218 | where :math:`\\alpha_t` is a thermal accommodation coefficient 219 | (:const:`constants.at`). 220 | 221 | Parameters 222 | ---------- 223 | T : float 224 | ambient air temperature, K 225 | rho : float 226 | ambient air density, kg/m^3 227 | r : float 228 | droplet radius, m 229 | 230 | Returns 231 | ------- 232 | float 233 | :math:`k'_a(T, \\rho, r)` in J/m/s/K 234 | 235 | References 236 | ---------- 237 | 238 | .. [SP2006] Seinfeld, John H, and Spyros N Pandis. Atmospheric Chemistry 239 | and Physics: From Air Pollution to Climate Change. Vol. 2nd. Wiley, 2006. 240 | 241 | See Also 242 | -------- 243 | ka_cont : neglecting correction for non-continuum effects 244 | 245 | """ 246 | ka_t = ka_cont(T) 247 | denom = 1.0 + (ka_t / (at * r * rho * Cp)) * np.sqrt((2.0 * np.pi * Ma) / (R * T)) 248 | return ka_t / denom 249 | 250 | 251 | def sigma_w(T): 252 | """Surface tension of water for a given temperature. 253 | 254 | .. math:: 255 | \\begin{equation} 256 | \sigma_w = 0.0761 - 1.55\\times 10^{-4}(T - 273.15) 257 | \end{equation} 258 | 259 | Parameters 260 | ---------- 261 | T : float 262 | ambient air temperature, degrees K 263 | 264 | Returns 265 | ------- 266 | float 267 | :math:`\sigma_w(T)` in J/m^2 268 | 269 | """ 270 | return 0.0761 - 1.55e-4 * (T - 273.15) 271 | 272 | 273 | # KOHLER THEORY FUNCTIONS 274 | 275 | 276 | def Seq(r, r_dry, T, kappa): 277 | """ κ-Kohler theory equilibrium saturation over aerosol. 278 | 279 | Calculates the equilibrium supersaturation (relative to 100% RH) over an 280 | aerosol particle of given dry/wet radius and of specified hygroscopicity 281 | bathed in gas at a particular temperature 282 | 283 | Following the technique of [PK2007], classical 284 | Kohler theory can be modified to account for the hygroscopicity of an aerosol 285 | particle using a single parameter, :math:`\kappa`. The modified theory predicts 286 | that the supersaturation with respect to a given aerosol particle is, 287 | 288 | .. math:: 289 | S_\\text{eq} &= a_w \exp \\left( \\frac{2\sigma_{w} M_w}{RT\\rho_w r} \\right)\\\\ 290 | a_w &= \\left(1 + \kappa\\left(\\frac{r_d}{r}^3\\right) \\right)^{-1} 291 | 292 | with the relevant thermodynamic properties of water defined elsewhere in this 293 | module, :math:`r_d` is the particle dry radius (``r_dry``), :math:`r` is the 294 | radius of the droplet containing the particle (``r``), :math:`T` is the temperature 295 | of the environment (``T``), and :math:`\kappa` is the hygroscopicity parameter 296 | of the particle (``kappa``). 297 | 298 | 299 | Parameters 300 | ---------- 301 | r : float 302 | droplet radius, m 303 | r_dry : float 304 | dry particle radius, m 305 | T : float 306 | ambient air temperature, K 307 | kappa: float 308 | particle hygroscopicity parameter 309 | 310 | Returns 311 | ------- 312 | float 313 | :math:`S_\\text{eq}` for the given aerosol/droplet system 314 | 315 | References 316 | ---------- 317 | 318 | .. [PK2007] Petters, M. D., and S. M. Kreidenweis. "A Single Parameter 319 | Representation of Hygroscopic Growth and Cloud Condensation Nucleus 320 | Activity." Atmospheric Chemistry and Physics 7.8 (2007): 1961-1971 321 | 322 | See Also 323 | -------- 324 | Seq_approx : compute equilibrium supersaturation using an approximation 325 | kohler_crit : compute critical radius and equilibrium supersaturation 326 | 327 | """ 328 | A = (2.0 * Mw * sigma_w(T)) / (R * T * rho_w * r) 329 | B = (r**3 - (r_dry**3)) / (r**3 - (r_dry**3) * (1.0 - kappa)) 330 | s = np.exp(A) * B - 1.0 331 | return s 332 | 333 | 334 | def Seq_approx(r, r_dry, T, kappa): 335 | """Approximate κ-Kohler theory equilibrium saturation over aerosol. 336 | 337 | Calculates the equilibrium supersaturation (relative to 100% RH) over an 338 | aerosol particle of given dry/wet radius and of specified hygroscopicity 339 | bathed in gas at a particular temperature, using a simplified expression 340 | derived by Taylor-expanding the original equation, 341 | 342 | .. math:: 343 | S_\\text{eq} = \\frac{2\sigma_{w} M_w}{RT\\rho_w r} - \kappa\\frac{r_d^3}{r^3} 344 | 345 | which is valid when the equilibrium supersaturation is small, i.e. in 346 | most terrestrial atmosphere applications. 347 | 348 | Parameters 349 | ---------- 350 | r : float 351 | droplet radius, m 352 | r_dry : float 353 | dry particle radius, m 354 | T : float 355 | ambient air temperature, K 356 | kappa: float 357 | particle hygroscopicity parameter 358 | 359 | Returns 360 | ------- 361 | float 362 | :math:`S_\\text{eq}` for the given aerosol/droplet system 363 | 364 | References 365 | ---------- 366 | 367 | See Also 368 | -------- 369 | Seq : compute equilibrium supersaturation using full theory 370 | kohler_crit : compute critical radius and equilibrium supersaturation 371 | 372 | """ 373 | A = (2.0 * Mw * sigma_w(T)) / (R * T * rho_w * r) 374 | return A - kappa * (r_dry**3) / ( 375 | r**3 376 | ) # the minus 1.0 is built into this expression 377 | 378 | 379 | def kohler_crit(T, r_dry, kappa, approx=False): 380 | """Critical radius and supersaturation of an aerosol particle. 381 | 382 | The critical size of an aerosol particle corresponds to the maximum equilibrium 383 | supersaturation achieved on its Kohler curve. If a particle grows beyond this 384 | size, then it is said to "activate", and will continue to freely grow even 385 | if the environmental supersaturation decreases. 386 | 387 | This function computes the critical size and and corresponding supersaturation 388 | for a given aerosol particle. Typically, it will analyze :func:`Seq` for the 389 | given particle and numerically compute its inflection point. However, if the 390 | ``approx`` flag is passed, then it will compute the analytical critical point 391 | for the approximated kappa-Kohler equation. 392 | 393 | Parameters 394 | ---------- 395 | T : float 396 | ambient air temperature, K 397 | r_dry : float 398 | dry particle radius, m 399 | kappa : float 400 | particle hygroscopicity parameter 401 | approx : boolean, optional (default=False) 402 | use the approximate kappa-kohler equation 403 | 404 | Returns 405 | ------- 406 | (r_crit, s_crit) : tuple of floats 407 | Tuple of :math:`(r_\\text{crit},\, S_\\text{crit})`, the critical radius (m) 408 | and supersaturation of the aerosol droplet. 409 | 410 | See Also 411 | -------- 412 | Seq : equilibrium supersaturation calculation 413 | 414 | """ 415 | if approx: 416 | A = (2.0 * Mw * sigma_w(T)) / (R * T * rho_w) 417 | s_crit = np.sqrt((4.0 * (A**3)) / (27 * kappa * (r_dry**3))) 418 | r_crit = np.sqrt((3.0 * kappa * (r_dry**3)) / A) 419 | 420 | else: 421 | neg_Seq = lambda r: -1.0 * Seq(r, r_dry, T, kappa) 422 | out = fminbound( 423 | neg_Seq, r_dry, r_dry * 1e4, xtol=1e-10, full_output=True, disp=0 424 | ) 425 | r_crit, s_crit = out[:2] 426 | s_crit *= -1.0 # multiply by -1 to undo negative flag for Seq 427 | 428 | return r_crit, s_crit 429 | 430 | 431 | def critical_curve(T, r_a, r_b, kappa, approx=False): 432 | """Calculates curves of critical radii and supersaturations for aerosol. 433 | 434 | Calls :func:`kohler_crit` for values of ``r_dry`` between ``r_a`` and ``r_b`` 435 | to calculate how the critical supersaturation changes with the dry radius for a 436 | particle of specified ``kappa`` 437 | 438 | Parameters 439 | ---------- 440 | T : float 441 | ambient air temperature, K 442 | r_a, r_b : floats 443 | left/right bounds of parcel dry radii, m 444 | kappa : float 445 | particle hygroscopicity parameter 446 | 447 | Returns 448 | ------- 449 | rs, rcrits, scrits : np.ndarrays 450 | arrays containing particle dry radii (between ``r_a`` and ``r_b``) 451 | and their corresponding criticall wet radii and supersaturations 452 | 453 | See Also 454 | -------- 455 | kohler_crit : critical supersaturation calculation 456 | 457 | """ 458 | 459 | def crit_func(rd): 460 | kohler_crit(T, rd, kappa, approx) 461 | 462 | rs = np.logspace(np.log10(r_a), np.log10(r_b), 200) 463 | ss = np.array(list(map(crit_func, rs))) 464 | 465 | rcrits = ss[:, 0] 466 | scrits = ss[:, 1] 467 | 468 | return rs, rcrits, scrits 469 | 470 | 471 | # MICROPHYSICS 472 | 473 | 474 | def r_eff(rho, wc, Ni): 475 | """Calculates the cloud droplet effective radius given the parcel liquid 476 | water mixing ratio and the number of activated droplets, as well as the parcel 477 | air density 478 | 479 | Assuming the droplet population is monodisperse or close to it, the cloud droplet 480 | effective radius can be computed by 481 | 482 | .. math:: 483 | \\begin{equation} 484 | r_{\\text{eff}} = \left(\\frac{3 \\rho_a w_c}{4 \pi N_i \\rho_w}\\right)^{1/3} 485 | \end{equation} 486 | 487 | Parameters 488 | ---------- 489 | rho : float 490 | parcel air density, kg/m^3 491 | wc : float 492 | liquid water mixing ratio, kg/kg 493 | Ni : float 494 | droplet number concentration, m^-3 495 | 496 | Returns 497 | ------- 498 | Cloud droplet effective radius, m 499 | 500 | .. warning:: 501 | 502 | Not completely implemented yet. 503 | 504 | """ 505 | return (3.0 * rho * wc / (4.0 * np.pi * rho_w * Ni)) ** (1.0 / 3.0) 506 | -------------------------------------------------------------------------------- /pyrcel/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Software utilities 3 | 4 | """ 5 | 6 | 7 | class ParcelModelError(Exception): 8 | """Custom exception to throw during parcel model execution.""" 9 | 10 | def __init__(self, error_str): 11 | self.error_str = error_str 12 | 13 | def __str__(self): 14 | return repr(self.error_str) 15 | -------------------------------------------------------------------------------- /pyrcel/vis.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def plot_distribution(aer, aer_kwargs={}, ax=None, **kwargs): 5 | """Generate a comparison plot of a given aerosol or 6 | droplet distribution 7 | 8 | Parameters 9 | ---------- 10 | aer : `AerosolSpecies` 11 | The container class for the aerosol 12 | aer_kwargs : dict 13 | A dictionary of arguments to pass to the matplotlib 14 | function which plots the binned distribution 15 | ax : Axis 16 | The axes object to plot on 17 | 18 | """ 19 | 20 | if ax is None: 21 | raise ValueError("Must provide axes instance for plotting.") 22 | 23 | ## Add some basic aer_kwargs if not provided 24 | if not "color" in aer_kwargs: 25 | aer_kwargs["color"] = "g" 26 | if not "alpha" in aer_kwargs: 27 | aer_kwargs["alpha"] = 0.5 28 | 29 | rl, rr = aer.rs[0], aer.rs[-1] 30 | r_left = aer.rs[:-1] 31 | r_width = aer.rs[1:] - r_left 32 | r_mean = np.sqrt(aer.rs[1:] * r_left) 33 | bin_height = aer.Nis / 1e6 34 | bars = ax.bar(r_left, bin_height, width=r_width, **aer_kwargs) 35 | 36 | legend_objects = [(bars[0], "%s bins" % aer.species)] 37 | 38 | handles, labels = list(zip(*legend_objects)) 39 | ax.legend(handles, labels, loc="upper right") 40 | 41 | ax.semilogx() 42 | ax.set_xlim([rl, rr]) 43 | 44 | ax.set_xlabel("$r$ ($\mu$m)") 45 | ax.set_ylabel("Number Concentration (cm$^{-3}$)") 46 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numba 2 | numpy 3 | pandas 4 | pyyaml 5 | scipy 6 | setuptools 7 | xarray --------------------------------------------------------------------------------