├── .github └── workflows │ ├── conda.yml │ ├── draft-pdf.yml │ ├── gh-pages.yml │ ├── nightly.yml │ ├── notebooks.yml │ ├── pypi.yml │ └── tests.yml ├── .gitignore ├── CITATION.cff ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── ecm.png ├── install_autoeis.sh ├── install_julia.sh ├── paper.bib ├── paper.md ├── test_data.csv ├── test_data.txt ├── workflow-mmd.png └── workflow.png ├── doc ├── Makefile ├── _basic_usage.md ├── _static │ ├── custom.css │ ├── logo-dark-mode.png │ └── logo-light-mode.png ├── _templates │ └── autosummary │ │ ├── base.rst │ │ └── module.rst ├── circuit.md ├── conf.py ├── contributing.md ├── examples.rst ├── index.rst ├── installation.md ├── make.bat └── modules.rst ├── examples ├── basic_workflow.ipynb ├── circuit_basics.ipynb ├── circuit_generation.ipynb └── parallel_inference.ipynb ├── pyproject.toml ├── ruff.toml ├── src └── autoeis │ ├── __init__.py │ ├── __main__.py │ ├── assets │ ├── battery_data.npy │ ├── circuits_filtered.csv │ ├── circuits_unfiltered.csv │ └── test_data.txt │ ├── cli.py │ ├── cli_pyjulia.py │ ├── core.py │ ├── io.py │ ├── julia_helpers.py │ ├── julia_helpers_pyjulia.py │ ├── juliapkg.json │ ├── legacy.py │ ├── metrics.py │ ├── models.py │ ├── parser.py │ ├── utils.py │ ├── version.py │ └── visualization.py └── tests ├── test_backend.py ├── test_core.py ├── test_io.py ├── test_metrics.py ├── test_parser.py └── test_utils.py /.github/workflows/conda.yml: -------------------------------------------------------------------------------- 1 | name: Conda 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | defaults: 9 | run: 10 | shell: bash 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v3 17 | with: 18 | enable-cache: true 19 | cache-dependency-glob: "**/pyproject.toml" 20 | 21 | - name: Set up Python 22 | uses: conda-incubator/setup-miniconda@v3 23 | with: 24 | python-version: "3.10" 25 | miniforge-version: latest 26 | 27 | - name: Install AutoEIS 28 | run: | 29 | uv sync --dev 30 | # Install Julia dependencies at first import 31 | uv run python -c "import autoeis" 32 | 33 | - name: Run tests 34 | run: | 35 | uv run pytest -v tests/ 36 | -------------------------------------------------------------------------------- /.github/workflows/draft-pdf.yml: -------------------------------------------------------------------------------- 1 | name: JOSS Paper Draft 2 | 3 | on: [push] 4 | 5 | jobs: 6 | paper: 7 | runs-on: ubuntu-latest 8 | name: Paper Draft 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Build draft PDF 13 | uses: openjournals/openjournals-draft-action@master 14 | with: 15 | journal: joss 16 | # This should be the path to the paper within your repo. 17 | paper-path: assets/paper.md 18 | - name: Upload 19 | uses: actions/upload-artifact@v4 20 | with: 21 | name: paper 22 | # This is the output path where Pandoc will write the compiled 23 | # PDF. Note, this should be the same directory as the input 24 | # paper.md 25 | path: assets/paper.pdf 26 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | jobs: 10 | deploy-docs: 11 | runs-on: macos-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Homebrew 17 | uses: Homebrew/actions/setup-homebrew@master 18 | 19 | - name: Set up Julia 20 | uses: julia-actions/setup-julia@v2 21 | with: 22 | version: "1.10" 23 | 24 | - name: Cache Julia 25 | uses: julia-actions/cache@v2 26 | with: 27 | cache-name: "macos-latest-test-1.10-3.10" 28 | cache-packages: true 29 | 30 | - name: Install uv 31 | uses: astral-sh/setup-uv@v6 32 | with: 33 | enable-cache: true 34 | cache-dependency-glob: "**/pyproject.toml" 35 | 36 | - name: Set up Python 3.10 37 | run: uv python install 3.10 38 | 39 | - name: Install Pandoc and TeXLive 40 | run: | 41 | brew install pandoc # Required by nbsphinx 42 | brew install mactex # Required by lcapy 43 | eval "$(/usr/libexec/path_helper)" 44 | echo "$(dirname "$(which pdflatex)")" >> $GITHUB_PATH 45 | 46 | - name: Install AutoEIS 47 | run: | 48 | uv sync --dev --all-extras 49 | # Install Julia dependencies at first import 50 | uv run python -c "import autoeis" 51 | 52 | - name: Build the documentation 53 | run: | 54 | uv run jupyter nbconvert \ 55 | --ExecutePreprocessor.timeout=3600 \ 56 | --to notebook --execute --inplace examples/*.ipynb 57 | cd doc 58 | uv run make html 59 | 60 | - name: GitHub Pages action 61 | uses: peaceiris/actions-gh-pages@v4 62 | with: 63 | github_token: ${{ secrets.GITHUB_TOKEN }} 64 | publish_dir: ./doc/_build/html 65 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly 2 | 3 | on: 4 | schedule: 5 | # Run (on default branch only) at 05:00 (hr:mm) UTC -> 12am EST 6 | - cron: "0 5 * * *" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ${{ matrix.os }} 12 | defaults: 13 | run: 14 | shell: bash 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | julia-version: ["1.10"] 19 | python-version: ["3.10", "3.11", "3.12"] 20 | os: [ubuntu-latest, macos-latest, windows-latest] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Set up Julia 26 | uses: julia-actions/setup-julia@v2 27 | with: 28 | version: ${{ matrix.julia-version }} 29 | 30 | - name: Cache Julia 31 | uses: julia-actions/cache@v2 32 | with: 33 | cache-name: ${{ matrix.os }}-test-${{ matrix.julia-version }}-${{ matrix.python-version }} 34 | cache-packages: true 35 | 36 | - name: Install uv 37 | uses: astral-sh/setup-uv@v3 38 | with: 39 | enable-cache: true 40 | cache-dependency-glob: "**/pyproject.toml" 41 | 42 | - name: Set up Python ${{ matrix.python-version }} 43 | run: uv python install ${{ matrix.python-version }} 44 | 45 | - name: Install AutoEIS 46 | run: | 47 | uv sync --dev 48 | # Install Julia dependencies at first import 49 | uv run python -c "import autoeis" 50 | 51 | - name: Run tests 52 | run: | 53 | uv run pytest -v tests/ 54 | -------------------------------------------------------------------------------- /.github/workflows/notebooks.yml: -------------------------------------------------------------------------------- 1 | name: Notebooks 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | test: 9 | runs-on: ${{ matrix.os }} 10 | defaults: 11 | run: 12 | shell: bash 13 | strategy: 14 | matrix: 15 | julia-version: ["1.10"] 16 | python-version: ["3.10"] 17 | os: [macos-latest] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Julia 23 | uses: julia-actions/setup-julia@v2 24 | with: 25 | version: ${{ matrix.julia-version }} 26 | 27 | - name: Cache Julia 28 | uses: julia-actions/cache@v2 29 | with: 30 | cache-name: ${{ matrix.os }}-test-${{ matrix.julia-version }}-${{ matrix.python-version }} 31 | cache-packages: true 32 | 33 | - name: Install uv 34 | uses: astral-sh/setup-uv@v3 35 | with: 36 | enable-cache: true 37 | cache-dependency-glob: "**/pyproject.toml" 38 | 39 | - name: Set up Python ${{ matrix.python-version }} 40 | run: uv python install ${{ matrix.python-version }} 41 | 42 | - name: Install Pandoc and TeXLive 43 | run: | 44 | brew install pandoc # Required by nbsphinx 45 | brew install mactex # Required by lcapy 46 | eval "$(/usr/libexec/path_helper)" 47 | echo "$(dirname "$(which pdflatex)")" >> $GITHUB_PATH 48 | 49 | - name: Install AutoEIS 50 | run: | 51 | uv sync --dev --all-extras 52 | # Install Julia dependencies at first import 53 | uv run python -c "import autoeis" 54 | 55 | - name: Run tests 56 | run: | 57 | uv run pytest -v --nbmake --nbmake-timeout=3600 examples/*.ipynb 58 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version (major/minor/patch)' 8 | required: true 9 | default: 'patch' 10 | 11 | jobs: 12 | deploy: 13 | name: Publish 🐍 📦 to PyPI 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | ref: main 20 | fetch-depth: 0 21 | fetch-tags: true 22 | token: ${{ secrets.PAT }} 23 | 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v3 26 | with: 27 | enable-cache: true 28 | cache-dependency-glob: "**/pyproject.toml" 29 | 30 | - name: Set up Python 3.10 31 | run: uv python install 3.10 32 | 33 | - name: Install AutoEIS 34 | run: | 35 | uv sync --dev 36 | 37 | - name: Ensure no mismatch between recent tag and new version 38 | run: | 39 | export TAG=$(git describe --tags --abbrev=0) 40 | export VERSION=v$(uv run hatch version) 41 | if [ "$TAG" != "$VERSION" ]; then 42 | echo "Tag $TAG does not match version $VERSION" 43 | exit 1 44 | fi 45 | 46 | - name: Bump up version 47 | run: | 48 | uv run hatch version ${{ github.event.inputs.version }} 49 | echo "VERSION=v$(uv run hatch version)" >> $GITHUB_ENV 50 | 51 | - name: Commit version bump + push tag 52 | uses: stefanzweifel/git-auto-commit-action@v5 53 | with: 54 | commit_message: Bump version to ${{ env.VERSION }} 55 | commit_author: GitHub Actions 56 | tagging_message: ${{ env.VERSION }} 57 | 58 | - name: Build and publish to PyPI 📦 59 | run: | 60 | export HATCH_INDEX_USER="__token__" 61 | export HATCH_INDEX_AUTH="${{ secrets.HATCH_INDEX_AUTH }}" 62 | uv run hatch build 63 | uv run hatch publish 64 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | defaults: 9 | run: 10 | shell: bash 11 | strategy: 12 | matrix: 13 | julia-version: ["1.10"] 14 | python-version: ["3.10"] 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Julia 21 | uses: julia-actions/setup-julia@v2 22 | with: 23 | version: ${{ matrix.julia-version }} 24 | 25 | - name: Cache Julia 26 | uses: julia-actions/cache@v2 27 | with: 28 | cache-name: ${{ matrix.os }}-test-${{ matrix.julia-version }}-${{ matrix.python-version }} 29 | cache-packages: true 30 | 31 | - name: Install uv 32 | uses: astral-sh/setup-uv@v3 33 | with: 34 | enable-cache: true 35 | cache-dependency-glob: "**/pyproject.toml" 36 | 37 | - name: Set up Python ${{ matrix.python-version }} 38 | run: uv python install ${{ matrix.python-version }} 39 | 40 | - name: Install AutoEIS 41 | run: | 42 | uv sync --dev 43 | # Install Julia dependencies at first import 44 | uv run python -c "import autoeis" 45 | 46 | - name: Run tests 47 | run: | 48 | uv run pytest -v tests/ 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | doc/_build/ 74 | doc/generated/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | # AutoEIS-generated files 165 | examples/**/*.png 166 | examples/**/*.csv 167 | examples/**/*.txt 168 | doc/examples/** 169 | results/ 170 | *.pkl 171 | 172 | # Local development 173 | tmp/* 174 | *.lock 175 | 176 | # macOS 177 | .DS_Store 178 | 179 | # Ignore CSV files in the root directory 180 | /*.csv 181 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: "1.2.0" 2 | authors: 3 | - family-names: Sadeghi 4 | given-names: Mohammad Amin 5 | orcid: "https://orcid.org/0000-0002-6756-9117" 6 | - family-names: Zhang 7 | given-names: Runze 8 | orcid: "https://orcid.org/0009-0004-9088-7924" 9 | - family-names: Hattrick-Simpers 10 | given-names: Jason 11 | orcid: "https://orcid.org/0000-0003-2937-3188" 12 | contact: 13 | - family-names: Hattrick-Simpers 14 | given-names: Jason 15 | orcid: "https://orcid.org/0000-0003-2937-3188" 16 | doi: 10.5281/zenodo.15066846 17 | message: If you use this software, please cite our article in the 18 | Journal of Open Source Software. 19 | preferred-citation: 20 | authors: 21 | - family-names: Sadeghi 22 | given-names: Mohammad Amin 23 | orcid: "https://orcid.org/0000-0002-6756-9117" 24 | - family-names: Zhang 25 | given-names: Runze 26 | orcid: "https://orcid.org/0009-0004-9088-7924" 27 | - family-names: Hattrick-Simpers 28 | given-names: Jason 29 | orcid: "https://orcid.org/0000-0003-2937-3188" 30 | date-published: 2025-05-16 31 | doi: 10.21105/joss.06256 32 | issn: 2475-9066 33 | issue: 109 34 | journal: Journal of Open Source Software 35 | publisher: 36 | name: Open Journals 37 | start: 6256 38 | title: "AutoEIS: Automated equivalent circuit modeling from 39 | electrochemical impedance spectroscopy data using statistical 40 | machine learning" 41 | type: article 42 | url: "https://joss.theoj.org/papers/10.21105/joss.06256" 43 | volume: 10 44 | title: "AutoEIS: Automated equivalent circuit modeling from 45 | electrochemical impedance spectroscopy data using statistical machine 46 | learning" 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:18.04 2 | 3 | ARG PYTHON_VERSION=3.8.5 4 | ARG JULIA_VERSION=1.7.1 5 | 6 | ENV container docker 7 | ENV DEBIAN_FRONTEND noninteractive 8 | ENV LANG en_US.utf8 9 | ENV MAKEFLAGS -j4 10 | 11 | RUN mkdir /app 12 | WORKDIR /app 13 | 14 | RUN cp /etc/apt/sources.list /etc/apt/sources.list~ 15 | RUN sed -Ei 's/^# deb-src /deb-src /' /etc/apt/sources.list 16 | 17 | # DEPENDENCIES 18 | #=========================================== 19 | RUN apt-get update -y && \ 20 | apt-get build-dep -y python3 && \ 21 | apt-get install -y gcc make wget zlib1g-dev openssh-server \ 22 | build-essential gdb lcov pkg-config \ 23 | libbz2-dev libffi-dev libgdbm-dev libgdbm-compat-dev liblzma-dev \ 24 | libncurses5-dev libreadline6-dev libsqlite3-dev libssl-dev \ 25 | lzma lzma-dev tk-dev uuid-dev zlib1g-dev vim 26 | 27 | # INSTALL PYTHON 28 | #=========================================== 29 | RUN wget https://www.python.org/ftp/python/$PYTHON_VERSION/Python-$PYTHON_VERSION.tgz && \ 30 | tar -zxf Python-$PYTHON_VERSION.tgz && \ 31 | cd Python-$PYTHON_VERSION && \ 32 | ./configure --with-ensurepip=install --enable-shared && make && make install && \ 33 | ldconfig && \ 34 | ln -sf python3 /usr/local/bin/python 35 | RUN python -m pip install --upgrade pip setuptools wheel && \ 36 | python -m pip install julia 37 | 38 | # INSTALL JULIA 39 | #==================================== 40 | RUN wget https://raw.githubusercontent.com/abelsiqueira/jill/main/jill.sh && \ 41 | bash /app/jill.sh -y -v $JULIA_VERSION && \ 42 | export PYTHON="python" && \ 43 | julia -e 'using Pkg; Pkg.add("PyCall")' && \ 44 | python -c 'import julia; julia.install()' 45 | 46 | # CLEAN UP 47 | #=========================================== 48 | RUN rm -rf /app/jill.sh \ 49 | /opt/julias/*.tar.gz \ 50 | /app/Python-3.8.5.tgz 51 | 52 | WORKDIR /app/ 53 | 54 | COPY ./requirements.txt /app/ 55 | 56 | RUN pip install --no-cache-dir -r requirements.txt 57 | 58 | COPY . /app/ 59 | 60 | RUN julia -e 'using Pkg; Pkg.add([Pkg.PackageSpec(;name="EquivalentCircuits"), \ 61 | Pkg.PackageSpec(;name="CSV", version="0.10.2"), \ 62 | Pkg.PackageSpec(;name="DataFrames", version="1.3.2"), \ 63 | Pkg.PackageSpec(;name="JSON3", version="1.9.2"), \ 64 | Pkg.PackageSpec(;name="StringEncodings", version="0.3.5"), \ 65 | Pkg.PackageSpec(;name="PyCall", version="1.93.0") \ 66 | ])' 67 | 68 | CMD ["/bin/bash"] 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 AUTODIAL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![DOI](https://joss.theoj.org/papers/10.21105/joss.06256/status.svg)](https://doi.org/10.21105/joss.06256) 2 | ![example workflow](https://github.com/AUTODIAL/AutoEIS/actions/workflows/nightly.yml/badge.svg) 3 | 4 | > [!NOTE] 5 | > AutoEIS is now published in the Journal of Open Source Software (JOSS). You can find the paper [here](https://doi.org/10.21105/joss.06256). If you find AutoEIS useful, please consider citing it in your work. 6 | > 7 | > > Sadeghi et al., (2025). AutoEIS: Automated equivalent circuit modeling from electrochemical impedance spectroscopy data using statistical machine learning. _Journal of Open Source Software_, 10(109), 6256, https://doi.org/10.21105/joss.06256 8 | > 9 | > > Zhang, Runze, et al. "Editors’ choice—AutoEIS: automated bayesian model selection and analysis for electrochemical impedance spectroscopy." _Journal of The Electrochemical Society_ 170.8 (2023): 086502. https://doi.org/10.1149/1945-7111/aceab2 10 | 11 | > [!TIP] 12 | > _Want to get notified about major announcements/new features?_ Please click on "Watch" -> "Custom" -> Check "Releases". Starring the repository alone won't notify you when we make a new release. This is particularly useful since we're actively working on adding new features/improvements to AutoEIS. Currently, we might issue a new release every month, so rest assured that you won't be spammed. 13 | 14 | # AutoEIS 15 | 16 | ## What is AutoEIS? 17 | 18 | AutoEIS (Auto ee-eye-ess) is a Python package that automatically proposes statistically plausible equivalent circuit models (ECMs) for electrochemical impedance spectroscopy (EIS) analysis. The package is designed for researchers and practitioners in the fields of electrochemical analysis, including but not limited to explorations of electrocatalysis, battery design, and investigations of material degradation. 19 | 20 | ## Contributing 21 | 22 | AutoEIS is still under development and the API might change. If you find any bugs or have any suggestions, please file an [issue](https://github.com/AUTODIAL/AutoEIS/issues) or directly submit a [pull request](https://github.com/AUTODIAL/AutoEIS/pulls). We would greatly appreciate any contributions from the community. Please refer to the [contributing guide](https://github.com/AUTODIAL/AutoEIS/doc/contributing.md). 23 | 24 | ## Installation 25 | 26 | ### Pip 27 | 28 | Open a terminal (or command prompt on Windows) and run the following command: 29 | 30 | ```bash 31 | pip install -U autoeis 32 | ``` 33 | 34 | Julia dependencies will be automatically installed at first import. It's recommended that you have your own Julia installation, but if you don't, Julia itself will also be installed automatically. 35 | 36 | > **How to install Julia?** If you decided to have your own Julia installation (recommended), the official way to install Julia is via [juliaup](https://github.com/JuliaLang/juliaup). [Juliaup](https://github.com/JuliaLang/juliaup) provides a command line interface to automatically install Julia (optionally multiple versions side by side). Working with [juliaup](https://github.com/JuliaLang/juliaup) is straightforward; Please follow the instructions on its GitHub [page](https://github.com/JuliaLang/juliaup). 37 | 38 | ## Usage 39 | 40 | Visit our [example notebooks](https://autodial.github.io/AutoEIS/examples.html) page to learn how to use AutoEIS. 41 | 42 | > [!WARNING] 43 | > The examples are designed to be run interactively, so you should use a Jupyter notebook-like environment like Jupyter Lab, IPython Notebook, or VSCode. The examples may not work as expected if you run them in a non-interactive environment like a Python REPL. For a smooth experience, please use a supported environment. 44 | 45 | ## Workflow 46 | 47 | The schematic workflow of AutoEIS is shown below: 48 | 49 | ![AutoEIS workflow](https://raw.githubusercontent.com/AUTODIAL/AutoEIS/develop/assets/workflow.png) 50 | 51 | It includes: data pre-processing, ECM generation, circuit post-filtering, Bayesian inference, and the model evaluation process. Through this workflow, AutoEis can prioritize the statistically optimal ECM and also retain suboptimal models with lower priority for subsequent expert inspection. A detailed workflow can be found in the [paper](https://iopscience.iop.org/article/10.1149/1945-7111/aceab2/meta). 52 | 53 | # Acknowledgement 54 | 55 | Thanks to Prof. Jason Hattrick-Simpers, Dr. Robert Black, Dr. Debashish Sur, Dr. Parisa Karimi, Dr. Brian DeCost, Dr. Kangming Li, and Prof. John R. Scully for their guidance and support. Also, thanks to Dr. Shijing Sun, Prof. Keryn Lian, Dr. Alvin Virya, Dr. Austin McDannald, Dr. Fuzhan Rahmanian, and Prof. Helge Stein for their feedback and discussions. Special shoutout to Prof. John R. Scully and Dr. Debashish Sur for letting us use their corrosion data to showcase the functionality of AutoEIS—your help has been invaluable! 56 | -------------------------------------------------------------------------------- /assets/ecm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AUTODIAL/AutoEIS/38ba0e5c26204a47a48b03ea24c16341f44189c5/assets/ecm.png -------------------------------------------------------------------------------- /assets/install_autoeis.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # The following lines are not needed if your .bashrc has them 4 | eval "$(pyenv init -)" 5 | eval "$(pyenv virtualenv-init -)" 6 | echo 7 | 8 | # Name of the virtual environment 9 | VENV_NAME="autoeis" 10 | PYTHON_VERSION="3.10.13" 11 | PYTHON_DEPS="requirements.txt" 12 | # Use single quotes to tell Bash to not interpret any characters (e.g., !) 13 | DONE_MSG='\033[0;32mSUCCESS!\033[0m' 14 | FAILED_MSG='\033[31mFAIL!\033[0m' 15 | 16 | # Remove the existing virtual environment if it exists 17 | if pyenv virtualenvs | grep -q "$VENV_NAME"; then 18 | echo -n "> Deleting existing virtual environment: $VENV_NAME ... " 19 | pyenv uninstall -f "$VENV_NAME" 20 | echo -e "$DONE_MSG" 21 | fi 22 | 23 | # Create a new virtual environment 24 | echo -n "> Creating new virtual environment: $VENV_NAME ... " 25 | pyenv virtualenv -q "$PYTHON_VERSION" "$VENV_NAME" 26 | echo -e "$DONE_MSG" 27 | 28 | # Activate the virtual environment 29 | echo -n "> Activating virtual environment: $VENV_NAME ... " 30 | # source $(pyenv root)/versions/$VENV_NAME/bin/activate 31 | pyenv activate -q "$VENV_NAME" 32 | echo -e "$DONE_MSG" 33 | 34 | # Install Python packages 35 | echo -n "> Installing Python packages ... " 36 | pip install --upgrade pip -q 37 | pip install -r "$PYTHON_DEPS" -q 38 | # Optional requirements 39 | pip install ipython ipykernel -q 40 | echo -e "$DONE_MSG" 41 | # Install AutoEIS 42 | echo -n "> Installing AutoEIS ... " 43 | pip install -e . -q 44 | echo -e "$DONE_MSG" 45 | 46 | # Install Julia and packages 47 | echo -n "> Installing Julia packages ... " 48 | python -c "import autoeis.julia_helpers as jh; jh.install(precompile=True, quiet=True)" 2>error.txt 49 | if [ $? -eq 0 ]; then 50 | echo -e "$DONE_MSG" 51 | else 52 | echo -e "$FAILED_MSG" "See error.txt for details" 53 | fi 54 | 55 | # Deactivate virtual environment 56 | echo -n "> Deactivating virtual environment: $VENV_NAME ... " 57 | pyenv deactivate -q "$VENV_NAME" 58 | echo -e "$DONE_MSG" 59 | -------------------------------------------------------------------------------- /assets/install_julia.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -i 2 | 3 | DONE_MSG='\033[0;32mSUCCESS!\033[0m' 4 | FAILED_MSG='\033[31mFAIL!\033[0m' 5 | 6 | function check_julia_installed { 7 | source ~/.bashrc 8 | if julia -e 'println("Hello world")' &> /dev/null; then 9 | return 0 10 | else 11 | return 1 12 | fi 13 | } 14 | 15 | function is_juliaup_functional { 16 | source ~/.bashrc 17 | juliaup status &> /dev/null 18 | return $? 19 | } 20 | 21 | function check_juliaup_exists { 22 | if is_juliaup_functional; then 23 | return 0 24 | else 25 | return 1 26 | fi 27 | } 28 | 29 | function install_julia_via_juliaup { 30 | if juliaup add release &> /dev/null; then 31 | return 0 32 | else 33 | return 1 34 | fi 35 | } 36 | 37 | function uninstall_juliaup { 38 | expect -c 'spawn juliaup self uninstall; expect "Do you really want to uninstall Julia?" {send "y\r"}; expect eof' &> /dev/null 39 | if [ $? -eq 0 ]; then 40 | source ~/.bashrc 41 | return 0 42 | else 43 | return 1 44 | fi 45 | } 46 | 47 | function install_juliaup { 48 | curl -fsSL https://install.julialang.org | sh -s -- --default-channel release -y &> /dev/null 49 | is_juliaup_functional 50 | if [ $? -eq 0 ]; then 51 | return 0 52 | else 53 | return 1 54 | fi 55 | } 56 | 57 | function report_status { 58 | if [ $1 -eq 0 ]; then 59 | echo -e " ... $DONE_MSG" 60 | else 61 | echo -e " ... $FAILED_MSG" 62 | fi 63 | } 64 | 65 | echo -n "Checking if Julia is already installed" 66 | if check_julia_installed; then 67 | report_status 0 68 | exit 0 69 | else 70 | report_status 1 71 | fi 72 | 73 | echo -n "Checking if juliaup is available and functional" 74 | if check_juliaup_exists; then 75 | report_status 0 76 | echo -n "Attempting to install Julia using juliaup" 77 | if install_julia_via_juliaup; then 78 | report_status 0 79 | echo -n "Checking if Julia is now installed" 80 | if check_julia_installed; then 81 | report_status 0 82 | exit 0 83 | else 84 | report_status 1 85 | fi 86 | else 87 | report_status 1 88 | echo -n "Uninstalling problematic juliaup" 89 | if uninstall_juliaup; then 90 | report_status 0 91 | else 92 | report_status 1 93 | echo "Manual intervention required. Please uninstall juliaup manually." 94 | exit 1 95 | fi 96 | fi 97 | else 98 | report_status 1 99 | fi 100 | 101 | echo -n "Attempting to install juliaup" 102 | if install_juliaup; then 103 | report_status 0 104 | echo -n "Installing Julia using the newly installed juliaup" 105 | if install_julia_via_juliaup; then 106 | report_status 0 107 | else 108 | report_status 1 109 | echo "Failed to install Julia using juliaup." 110 | exit 1 111 | fi 112 | else 113 | report_status 1 114 | echo "Failed to install juliaup." 115 | exit 1 116 | fi 117 | 118 | echo -n "Final check to confirm if Julia is installed" 119 | if check_julia_installed; then 120 | report_status 0 121 | else 122 | report_status 1 123 | echo "Installation process completed, but Julia doesn't seem to be functional. Manual intervention may be required." 124 | exit 1 125 | fi 126 | -------------------------------------------------------------------------------- /assets/paper.bib: -------------------------------------------------------------------------------- 1 | @article{zhang2023, 2 | doi = {10.1149/1945-7111/aceab2}, 3 | url = {https://dx.doi.org/10.1149/1945-7111/aceab2}, 4 | year = {2023}, 5 | month = {aug}, 6 | publisher = {IOP Publishing}, 7 | volume = {170}, 8 | number = {8}, 9 | pages = {086502}, 10 | author = {Runze Zhang and Robert Black and Debashish Sur and Parisa Karimi and Kangming Li and Brian DeCost and John R. Scully and Jason Hattrick-Simpers}, 11 | title = {AutoEIS: Automated Bayesian Model Selection and Analysis for Electrochemical Impedance Spectroscopy}, 12 | journal = {Journal of The Electrochemical Society} 13 | } 14 | @software{pyimpspec, 15 | author = {Ville Yrj\"{a}n\"{a}}, 16 | title = {pyimpspec - Python package for electrochemical impedance spectroscopy}, 17 | month = dec, 18 | year = 2022, 19 | publisher = {Zenodo}, 20 | version = {3.2.4}, 21 | doi = {10.5281/zenodo.7436137}, 22 | url = {https://github.com/vyrjana/pyimpspec} 23 | } 24 | @article{yrjana2022deareis, 25 | doi = {10.21105/joss.04808}, 26 | title = {DearEIS-A GUI program for analyzing impedance spectra}, 27 | author = {Yrj{\"a}n{\"a}, Ville}, 28 | journal = {Journal of Open Source Software}, 29 | volume = {7}, 30 | number = {80}, 31 | pages = {4808}, 32 | year = {2022} 33 | } 34 | @misc{elchemea, 35 | author = {Koch, S\o{} and Graves, Christopher and Vels Hansen, Karin and {DTU Energy}}, 36 | title = {{Elchemea Analytical}}, 37 | url = {https://www.elchemea.com/}, 38 | year = {2021} 39 | } 40 | @article{van2021practical, 41 | doi = {10.1109/TIM.2021.3113116}, 42 | title = {Practical equivalent electrical circuit identification for electrochemical impedance spectroscopy analysis with gene expression programming}, 43 | author = {Van Haeverbeke, Maxime and Stock, Michiel and De Baets, Bernard}, 44 | journal = {IEEE Transactions on Instrumentation and Measurement}, 45 | volume = {70}, 46 | pages = {1--12}, 47 | year = {2021}, 48 | publisher = {IEEE} 49 | } 50 | @article{wang2021electrochemical, 51 | doi = {10.1038/s43586-021-00039-w}, 52 | title = {Electrochemical impedance spectroscopy}, 53 | author = {Wang, Shangshang and Zhang, Jianbo and Gharbi, Ouma{\"\i}ma and Vivier, Vincent and Gao, Ming and Orazem, Mark E}, 54 | journal = {Nature Reviews Methods Primers}, 55 | volume = {1}, 56 | number = {1}, 57 | pages = {41}, 58 | year = {2021}, 59 | publisher = {Nature Publishing Group UK London} 60 | } 61 | @article{murbach2020impedance, 62 | author = {Murbach, Matthew D and Gerwe, Brian and Dawson-Elli, Neal and Tsui, Lok-kun}, 63 | doi = {10.21105/joss.02349}, 64 | journal = {Journal of Open Source Software}, 65 | number = {52}, 66 | pages = {2349}, 67 | publisher = {The Open Journal}, 68 | title = {{impedance.py}: A {P}ython package for electrochemical impedance analysis}, 69 | url = {https://doi.org/10.21105/joss.02349}, 70 | volume = {5}, 71 | year = {2020} 72 | } 73 | @misc{knudsen2019pyeis, 74 | author = {Knudsen, Kristian B}, 75 | doi = {10.5281/zenodo.2535951}, 76 | publisher = {Zenodo}, 77 | title = {{kbknudsen/PyEIS}: {PyEIS}: A {P}ython-based Electrochemical Impedance Spectroscopy simulator and analyzer}, 78 | url = {https://doi.org/10.5281/zenodo.2535951}, 79 | year = {2019} 80 | } 81 | @article{boukamp1995linear, 82 | author = {Boukamp, Bernard A}, 83 | doi = {10.1149/1.2044210}, 84 | journal = {Journal of the Electrochemical Society}, 85 | number = {6}, 86 | pages = {1885--1894}, 87 | title = {A linear {K}ronig-{K}ramers transform test for immittance data validation}, 88 | volume = {142}, 89 | year = {1995} 90 | } 91 | -------------------------------------------------------------------------------- /assets/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "AutoEIS: Automated equivalent circuit modeling from electrochemical impedance spectroscopy data using statistical machine learning" 3 | tags: 4 | - python 5 | - julia 6 | - electrochemistry 7 | - materials science 8 | - electrochemical impedance spectroscopy 9 | - equivalent circuit model 10 | - statistical machine learning 11 | - bayesian inference 12 | - evolutionary search 13 | authors: 14 | - name: Mohammad Amin Sadeghi 15 | orcid: 0000-0002-6756-9117 16 | equal-contrib: true 17 | affiliation: 1 # (Multiple affiliations must be quoted) 18 | - name: Runze Zhang 19 | equal-contrib: true # (This is how you can denote equal contributions between multiple authors) 20 | orcid: 0009-0004-9088-7924 21 | affiliation: 1 22 | - name: Jason Hattrick-Simpers 23 | corresponding: true # (This is how to denote the corresponding author) 24 | orcid: 0000-0003-2937-3188 25 | affiliation: 1 26 | affiliations: 27 | - name: University of Toronto, Canada 28 | index: 1 29 | date: 10 April 2025 30 | bibliography: paper.bib 31 | --- 32 | # Summary 33 | 34 | AutoEIS is an innovative Python software tool designed to automate the analysis of Electrochemical Impedance Spectroscopy (EIS) data, a key technique in electrochemical materials research. By integrating evolutionary algorithms and Bayesian inference, AutoEIS automates the construction and evaluation of equivalent circuit models (ECM), providing more objective, efficient, and accurate analysis compared to traditional manual methods. 35 | 36 | EIS data interpretation is fundamental for understanding electrochemical processes and generating mechanistic insights. However, selecting an appropriate ECM has historically been complex, time-consuming, and subjective [@wang2021electrochemical]. AutoEIS resolves this challenge through a systematic approach: it generates multiple candidate ECMs, evaluates their fit against experimental data, and ranks them using comprehensive statistical metrics. This methodology not only streamlines analysis but also introduces reproducibility and objectivity that manual analysis cannot consistently achieve. 37 | 38 | The effectiveness of AutoEIS has been validated through diverse case studies, including oxygen evolution reaction electrocatalysis, corrosion of multi-principal element alloys, and CO~2 reduction in electrolyzer devices [@zhang2023]. These applications demonstrate the software's versatility across different electrochemical systems and its ability to identify physically meaningful ECMs that accurately capture the underlying electrochemical phenomena. 39 | 40 | # Statement of need 41 | 42 | EIS is widely used in electrochemistry for applications spanning battery research, fuel cell development, and corrosion studies. Accurate interpretation of EIS data is essential for understanding electrochemical reaction mechanisms and material behaviors. Traditional EIS analysis faces three significant challenges: it requires substantial expert knowledge, consumes significant time, and introduces potential researcher bias in model selection and interpretation. 43 | 44 | AutoEIS addresses these limitations through an automated platform that reduces the expertise barrier for rigorous EIS analysis. By systematically evaluating numerous potential circuit models, the software minimizes human bias and dramatically reduces analysis time. This automation is particularly valuable for complex systems where manual trial-and-error approaches become impractical. Furthermore, this automation capability enables the application of rigorous EIS analysis within high-throughput experimental workflows, where manual approaches become intractable. 45 | 46 | Current EIS analysis tools—including open-source options like DearEIS [@yrjana2022deareis], Elchemea Analytical [@elchemea], impedance.py [@murbach2020impedance], PyEIS [@knudsen2019pyeis], and pyimpspec [@pyimpspec], as well as commercial software such as ZView, RelaxIS, and Echem Analyst—all require users to manually propose ECMs and iteratively refine them. This approach becomes increasingly unreliable as system complexity grows, as researchers may not explore the full model space or may unconsciously favor familiar circuit elements. 47 | 48 | AutoEIS distinguishes itself by comprehensively exploring the model space through evolutionary algorithms, ensuring that potentially valuable circuit configurations are not overlooked. This capability aligns with the growing trend toward self-driving laboratories and autonomous research workflows in materials science and electrochemistry. 49 | 50 | # Software description 51 | 52 | AutoEIS implements a four-stage workflow to analyze EIS data as shown in \autoref{fig:workflow}: 53 | 54 | ![AutoEIS workflow. The four-stage process includes data validation via Kramers-Kronig checks, ECM generation using evolutionary algorithms, filtering based on electrochemical theory, and Bayesian parameter estimation for uncertainty-aware model ranking. \label{fig:workflow}](./workflow-mmd.png){ width="100%" } 55 | 56 | ## Data preprocessing and validation 57 | 58 | Before model fitting, AutoEIS applies Kramers-Kronig transformations [@boukamp1995linear] to validate experimental data quality. This critical step identifies measurement artifacts and ensures that only reliable data proceeds to model fitting. Poor-quality data that violates Kramers-Kronig relations is flagged, allowing researchers to address experimental issues before interpretation. 59 | 60 | ## ECM generation via evolutionary algorithms 61 | 62 | AutoEIS employs evolutionary algorithms through the Julia package EquivalentCircuits.jl [@van2021practical] to generate diverse candidate ECMs. This approach efficiently explores the vast space of possible circuit configurations, including models that might not be intuitively chosen by researchers. 63 | 64 | ## Physics-based model filtering 65 | 66 | The software then applies electrochemical theory-based filters [@zhang2023] to eliminate physically implausible models. For example, models lacking an Ohmic resistor are automatically rejected as physically unrealistic, despite potentially good mathematical fits. This step ensures that analysis results remain consistent with established electrochemical principles. 67 | 68 | ## Bayesian parameter estimation 69 | 70 | For physically plausible models, AutoEIS employs Bayesian inference to estimate circuit component values and their uncertainty distributions. Unlike point estimates from traditional least-squares fitting, this approach quantifies parameter uncertainty, providing crucial information about model reliability. The Bayesian framework also enables model comparison through metrics like the Bayesian Information Criterion, helping identify the most statistically justified model complexity. 71 | 72 | # Authorship contributions 73 | 74 | The original AutoEIS software was developed by RZ. MS conducted a comprehensive refactoring of the codebase that improved algorithmic efficiency. MS also implemented unit testing, expanded documentation, and established automated CI/CD workflows to ensure software reliability. JHS provided project supervision and domain expertise in electrochemical theory. All authors—RZ, MS, and JHS—contributed substantively to the writing and editing of this manuscript. 75 | 76 | # Acknowledgements 77 | 78 | We extend our thanks to Dr. Robert Black, Dr. Debashish Sur, Dr. Parisa Karimi, Dr. Brian DeCost, Dr. Kangming Li, and Prof. John R. Scully for their insightful guidance and support during the development of AutoEIS. Our gratitude also goes to Dr. Shijing Sun, Prof. Keryn Lian, Dr. Alvin Virya, Dr. Austin McDannald, Dr. Fuzhan Rahmanian, and Prof. Helge Stein for their valuable feedback and engaging technical discussions. We particularly acknowledge Prof. John R. Scully and Dr. Debashish Sur for allowing the use of their corrosion data as a key example in our work, significantly aiding in the demonstration and improvement of AutoEIS. 79 | 80 | # References 81 | -------------------------------------------------------------------------------- /assets/test_data.csv: -------------------------------------------------------------------------------- 1 | freq/Hz,Re(Z)/Ohm,-Im(Z)/Ohm,|Z|/Ohm,Phase(Z)/deg,time/s,/V,/mA 2 | 200019.48,130.4171,34.680012,134.94934,-14.891262,10602.35877037526,-0.040435545,-0.00099461863 3 | 149716.77,134.15704,27.274202,136.9014,-11.491652,10602.73376095307,-0.04046046,-0.00074689457 4 | 112070.29,136.40355,21.577759,138.0997,-8.9891653,10603.23275020346,-0.040456451,-0.00072788144 5 | 83886.695,137.78223,17.275707,138.86105,-7.1466866,10603.60777374124,-0.040450379,-0.00078529207 6 | 62792.953,138.55463,14.341043,139.29483,-5.9093351,10603.98176706571,-0.040451832,-0.00081735285 7 | 47011.707,139.11638,12.488245,139.67578,-5.1295972,10604.35675903014,-0.040456861,-0.00080188201 8 | 35185.535,139.57651,11.539886,140.05273,-4.7263427,10604.73176272796,-0.040454011,-0.00079927233 9 | 26337.883,139.9438,11.390005,140.40656,-4.6530385,10605.10676431912,-0.040449407,-0.00081884407 10 | 19716.791,140.40123,12.181064,140.92865,-4.9585056,10605.48177159025,-0.04045105,-0.00081828487 11 | 14755.859,140.87042,13.87962,141.55254,-5.6270518,10605.85676907474,-0.040452633,-0.00082536775 12 | 11044.919,141.45625,16.460274,142.41072,-6.637259,10606.35576128511,-0.040457122,-0.00084754941 13 | 8412.8223,142.14565,19.882139,143.52939,-7.9623947,10606.78597871889,-0.04059194,-0.00082648644 14 | 6299.2124,142.97421,24.846706,145.11714,-9.8586588,10607.3309341314,-0.040589053,-0.00080467958 15 | 4633.5156,143.9037,31.965317,147.41118,-12.523768,10608.20247777287,-0.040590171,-0.00085575104 16 | 3468.886,144.97437,40.939404,150.64395,-15.769184,10608.85548737721,-0.040599115,-0.00086656213 17 | 2597.4958,146.033,53.045052,155.36865,-19.963053,10609.50521255471,-0.040591531,-0.00091502559 18 | 1944.3779,147.32561,69.020302,162.69185,-25.102533,10610.15847202577,-0.040590692,-0.00088221929 19 | 1455.4634,148.9675,90.31414,174.20665,-31.227068,10611.02733404037,-0.040597569,-0.000888184 20 | 1088.9545,150.868,118.55865,191.87836,-38.161808,10611.6806124447,-0.040591978,-0.000867308 21 | 815.25739,153.38699,156.07516,218.83104,-45.497692,10612.33235639569,-0.040591065,-0.00084885472 22 | 610.19507,156.75284,205.60303,258.54218,-52.67791,10612.98435479999,-0.040588327,-0.00090421538 23 | 456.90088,160.92087,271.35919,315.48587,-59.331299,10614.09658026614,-0.040594678,-7.3313553e-05 24 | 342.07999,166.47746,357.70621,394.54843,-65.042603,10614.74419693684,-0.040597253,-7.1317132e-05 25 | 256.06528,173.77625,471.94934,502.92581,-69.785812,10615.39457998113,-0.040596843,-7.5683842e-05 26 | 191.65575,183.60539,622.90924,649.40503,-73.576874,10616.04270089191,-0.040599115,-7.4825941e-05 27 | 143.48476,196.32477,822.00745,845.12701,-76.56736,10616.70253430991,-0.040590581,-7.4882046e-05 28 | 107.37106,213.2697,1085.1769,1105.9353,-78.881371,10617.3410159669,-0.040605396,-7.4415708e-05 29 | 80.385803,236.03098,1433.6805,1452.9799,-80.651093,10617.99143573114,-0.040612921,-7.6505785e-05 30 | 60.173271,265.3732,1892.153,1910.6715,-82.01638,10618.641833762,-0.040605005,-7.9360791e-05 31 | 45.043228,305.79886,2499.6538,2518.2896,-83.025291,10619.54023572465,-0.040599562,1.6005855e-05 32 | 33.71611,360.31354,3301.0442,3320.6504,-83.770744,10620.21501359669,-0.040602617,1.2039787e-05 33 | 25.234146,432.33145,4354.8379,4376.2456,-84.330475,10620.8625540275,-0.04060493,1.0398163e-05 34 | 18.893555,532.7395,5755.6865,5780.2886,-84.711838,10621.55015331344,-0.040607322,1.1430669e-05 35 | 14.135986,669.84698,7596.9028,7626.377,-84.961052,10622.23865001285,-0.040596414,1.2125528e-05 36 | 10.586008,849.92682,10017.949,10053.938,-85.15062,10622.85835401565,-0.040598333,1.2588146e-05 37 | 7.9233942,1103.5782,13215.479,13261.478,-85.226509,10623.96486513485,-0.040596135,1.9567002e-05 38 | 5.9320397,1464.2717,17422.945,17484.367,-85.195999,10624.52397185471,-0.040600419,1.8486064e-05 39 | 4.4389215,1954.4601,22996.092,23078.998,-85.142052,10625.25171506265,-0.040596377,1.9493671e-05 40 | 3.324471,2610.5586,30248.844,30361.283,-85.067436,10626.20330732119,-0.040591262,1.9928868e-05 41 | 2.4872644,3620.1074,39883.594,40047.551,-84.813652,10627.45759652741,-0.040592488,1.9572613e-05 42 | 1.8623395,4931.8779,52506.844,52737.957,-84.634048,10629.69853210053,-0.040601045,1.9966785e-05 43 | 1.3933479,6875.2324,69083.32,69424.594,-84.316589,10631.89746095164,-0.040614765,2.0064368e-05 44 | 1.0433345,9789.4717,90776.766,91303.094,-83.84494,10634.81936210376,-0.040598035,2.0129652e-05 45 | 0.78156585,14103.699,119175.55,120007.19,-83.250786,10638.70443962162,-0.040596291,2.015875e-05 46 | 0.58476776,20709.209,156059.33,157427.39,-82.440964,10643.88251285424,-0.040591829,1.9872046e-05 47 | 0.43787137,30738.281,204079.8,206381.7,-81.434555,10650.78148627526,-0.040589634,2.0033691e-05 48 | 0.32773316,45855.754,266872.66,270783.63,-80.250282,10659.98510196566,-0.040598374,2.0084613e-05 49 | 0.24519041,69076.125,347539.72,354337.94,-78.758537,10672.27384862938,-0.040597517,2.006397e-05 50 | 0.18356889,104691.99,449390.25,461423.91,-76.886017,10688.67494503508,-0.040595997,2.0051477e-05 51 | 0.13737231,159232.22,576487.56,598074.25,-74.559258,10710.57860860878,-0.040595096,2.0007825e-05 52 | 0.10286123,241254.55,730398.63,769211.25,-71.721336,10739.81817005092,-0.040587906,1.9945475e-05 53 | 0.077023357,362957.91,911170.75,980801.0,-68.28051,10778.85330233169,-0.040593203,1.9853838e-05 54 | 0.057640672,538028.13,1112502.4,1235773.4,-64.190674,10831.00201414188,-0.040597111,1.9719819e-05 55 | 0.043156743,777120.5,1317741.5,1529823.1,-59.470589,10900.63974414286,-0.040596895,1.9543148e-05 56 | 0.032296106,1086405.9,1506690.8,1857523.8,-54.206348,10993.68277797551,-0.040601477,1.93124e-05 57 | 0.024171919,1446482.3,1656020.5,2198798.5,-48.8638,11117.98482526618,-0.040594768,1.9025645e-05 58 | 0.018095449,1841747.9,1745598.6,2537548.0,-43.464706,11284.01506408554,-0.040576864,1.864451e-05 59 | 0.013546422,2237393.3,1783892.9,2861503.5,-38.565655,11505.78577837913,-0.040591758,1.8159395e-05 60 | 0.010138676,2615759.8,1774237.9,3160715.0,-34.148567,11802.08381933247,-0.040594667,1.7505585e-05 61 | 0.0075937808,2958592.8,1755594.4,3440259.0,-30.684399,12197.66871406842,-0.040597528,1.6762518e-05 62 | 0.0056843013,3287941.3,1752618.3,3725886.3,-28.059591,12726.12766799558,-0.04059742,1.5911364e-05 63 | 0.0042551756,3577237.3,1777850.0,3994668.5,-26.426903,13432.06003496551,-0.040598158,1.4855485e-05 64 | 0.0031851381,3891231.0,1812847.3,4292795.5,-24.979877,14375.13482034585,-0.040596075,1.3775185e-05 65 | 0.0023847669,4124007.8,1907836.8,4543928.0,-24.826071,15634.70947531908,-0.040595856,1.2261165e-05 66 | 0.0017846748,4443237.0,2000512.5,4872823.0,-24.239067,17317.80379976856,-0.04059843,1.0719642e-05 67 | 0.0013360009,4779632.5,2148919.5,5240490.5,-24.208651,19566.11522341089,-0.040601213,9.2869222e-06 68 | 0.00099990517,5170240.5,2345775.5,5677504.0,-24.404139,22570.14876454812,-0.040601943,8.0679802e-06 69 | -------------------------------------------------------------------------------- /assets/test_data.txt: -------------------------------------------------------------------------------- 1 | freq/Hz Re(Z)/Ohm -Im(Z)/Ohm |Z|/Ohm Phase(Z)/deg time/s /V /mA 2 | 2.0001948E+005 1.3041710E+002 3.4680012E+001 1.3494934E+002 -1.4891262E+001 1.060235877037526E+004 -4.0435545E-002 -9.9461863E-004 3 | 1.4971677E+005 1.3415704E+002 2.7274202E+001 1.3690140E+002 -1.1491652E+001 1.060273376095307E+004 -4.0460460E-002 -7.4689457E-004 4 | 1.1207029E+005 1.3640355E+002 2.1577759E+001 1.3809970E+002 -8.9891653E+000 1.060323275020346E+004 -4.0456451E-002 -7.2788144E-004 5 | 8.3886695E+004 1.3778223E+002 1.7275707E+001 1.3886105E+002 -7.1466866E+000 1.060360777374124E+004 -4.0450379E-002 -7.8529207E-004 6 | 6.2792953E+004 1.3855463E+002 1.4341043E+001 1.3929483E+002 -5.9093351E+000 1.060398176706571E+004 -4.0451832E-002 -8.1735285E-004 7 | 4.7011707E+004 1.3911638E+002 1.2488245E+001 1.3967578E+002 -5.1295972E+000 1.060435675903014E+004 -4.0456861E-002 -8.0188201E-004 8 | 3.5185535E+004 1.3957651E+002 1.1539886E+001 1.4005273E+002 -4.7263427E+000 1.060473176272796E+004 -4.0454011E-002 -7.9927233E-004 9 | 2.6337883E+004 1.3994380E+002 1.1390005E+001 1.4040656E+002 -4.6530385E+000 1.060510676431912E+004 -4.0449407E-002 -8.1884407E-004 10 | 1.9716791E+004 1.4040123E+002 1.2181064E+001 1.4092865E+002 -4.9585056E+000 1.060548177159025E+004 -4.0451050E-002 -8.1828487E-004 11 | 1.4755859E+004 1.4087042E+002 1.3879620E+001 1.4155254E+002 -5.6270518E+000 1.060585676907474E+004 -4.0452633E-002 -8.2536775E-004 12 | 1.1044919E+004 1.4145625E+002 1.6460274E+001 1.4241072E+002 -6.6372590E+000 1.060635576128511E+004 -4.0457122E-002 -8.4754941E-004 13 | 8.4128223E+003 1.4214565E+002 1.9882139E+001 1.4352939E+002 -7.9623947E+000 1.060678597871889E+004 -4.0591940E-002 -8.2648644E-004 14 | 6.2992124E+003 1.4297421E+002 2.4846706E+001 1.4511714E+002 -9.8586588E+000 1.060733093413140E+004 -4.0589053E-002 -8.0467958E-004 15 | 4.6335156E+003 1.4390370E+002 3.1965317E+001 1.4741118E+002 -1.2523768E+001 1.060820247777287E+004 -4.0590171E-002 -8.5575104E-004 16 | 3.4688860E+003 1.4497437E+002 4.0939404E+001 1.5064395E+002 -1.5769184E+001 1.060885548737721E+004 -4.0599115E-002 -8.6656213E-004 17 | 2.5974958E+003 1.4603300E+002 5.3045052E+001 1.5536865E+002 -1.9963053E+001 1.060950521255471E+004 -4.0591531E-002 -9.1502559E-004 18 | 1.9443779E+003 1.4732561E+002 6.9020302E+001 1.6269185E+002 -2.5102533E+001 1.061015847202577E+004 -4.0590692E-002 -8.8221929E-004 19 | 1.4554634E+003 1.4896750E+002 9.0314140E+001 1.7420665E+002 -3.1227068E+001 1.061102733404037E+004 -4.0597569E-002 -8.8818400E-004 20 | 1.0889545E+003 1.5086800E+002 1.1855865E+002 1.9187836E+002 -3.8161808E+001 1.061168061244470E+004 -4.0591978E-002 -8.6730800E-004 21 | 8.1525739E+002 1.5338699E+002 1.5607516E+002 2.1883104E+002 -4.5497692E+001 1.061233235639569E+004 -4.0591065E-002 -8.4885472E-004 22 | 6.1019507E+002 1.5675284E+002 2.0560303E+002 2.5854218E+002 -5.2677910E+001 1.061298435479999E+004 -4.0588327E-002 -9.0421538E-004 23 | 4.5690088E+002 1.6092087E+002 2.7135919E+002 3.1548587E+002 -5.9331299E+001 1.061409658026614E+004 -4.0594678E-002 -7.3313553E-005 24 | 3.4207999E+002 1.6647746E+002 3.5770621E+002 3.9454843E+002 -6.5042603E+001 1.061474419693684E+004 -4.0597253E-002 -7.1317132E-005 25 | 2.5606528E+002 1.7377625E+002 4.7194934E+002 5.0292581E+002 -6.9785812E+001 1.061539457998113E+004 -4.0596843E-002 -7.5683842E-005 26 | 1.9165575E+002 1.8360539E+002 6.2290924E+002 6.4940503E+002 -7.3576874E+001 1.061604270089191E+004 -4.0599115E-002 -7.4825941E-005 27 | 1.4348476E+002 1.9632477E+002 8.2200745E+002 8.4512701E+002 -7.6567360E+001 1.061670253430991E+004 -4.0590581E-002 -7.4882046E-005 28 | 1.0737106E+002 2.1326970E+002 1.0851769E+003 1.1059353E+003 -7.8881371E+001 1.061734101596690E+004 -4.0605396E-002 -7.4415708E-005 29 | 8.0385803E+001 2.3603098E+002 1.4336805E+003 1.4529799E+003 -8.0651093E+001 1.061799143573114E+004 -4.0612921E-002 -7.6505785E-005 30 | 6.0173271E+001 2.6537320E+002 1.8921530E+003 1.9106715E+003 -8.2016380E+001 1.061864183376200E+004 -4.0605005E-002 -7.9360791E-005 31 | 4.5043228E+001 3.0579886E+002 2.4996538E+003 2.5182896E+003 -8.3025291E+001 1.061954023572465E+004 -4.0599562E-002 1.6005855E-005 32 | 3.3716110E+001 3.6031354E+002 3.3010442E+003 3.3206504E+003 -8.3770744E+001 1.062021501359669E+004 -4.0602617E-002 1.2039787E-005 33 | 2.5234146E+001 4.3233145E+002 4.3548379E+003 4.3762456E+003 -8.4330475E+001 1.062086255402750E+004 -4.0604930E-002 1.0398163E-005 34 | 1.8893555E+001 5.3273950E+002 5.7556865E+003 5.7802886E+003 -8.4711838E+001 1.062155015331344E+004 -4.0607322E-002 1.1430669E-005 35 | 1.4135986E+001 6.6984698E+002 7.5969028E+003 7.6263770E+003 -8.4961052E+001 1.062223865001285E+004 -4.0596414E-002 1.2125528E-005 36 | 1.0586008E+001 8.4992682E+002 1.0017949E+004 1.0053938E+004 -8.5150620E+001 1.062285835401565E+004 -4.0598333E-002 1.2588146E-005 37 | 7.9233942E+000 1.1035782E+003 1.3215479E+004 1.3261478E+004 -8.5226509E+001 1.062396486513485E+004 -4.0596135E-002 1.9567002E-005 38 | 5.9320397E+000 1.4642717E+003 1.7422945E+004 1.7484367E+004 -8.5195999E+001 1.062452397185471E+004 -4.0600419E-002 1.8486064E-005 39 | 4.4389215E+000 1.9544601E+003 2.2996092E+004 2.3078998E+004 -8.5142052E+001 1.062525171506265E+004 -4.0596377E-002 1.9493671E-005 40 | 3.3244710E+000 2.6105586E+003 3.0248844E+004 3.0361283E+004 -8.5067436E+001 1.062620330732119E+004 -4.0591262E-002 1.9928868E-005 41 | 2.4872644E+000 3.6201074E+003 3.9883594E+004 4.0047551E+004 -8.4813652E+001 1.062745759652741E+004 -4.0592488E-002 1.9572613E-005 42 | 1.8623395E+000 4.9318779E+003 5.2506844E+004 5.2737957E+004 -8.4634048E+001 1.062969853210053E+004 -4.0601045E-002 1.9966785E-005 43 | 1.3933479E+000 6.8752324E+003 6.9083320E+004 6.9424594E+004 -8.4316589E+001 1.063189746095164E+004 -4.0614765E-002 2.0064368E-005 44 | 1.0433345E+000 9.7894717E+003 9.0776766E+004 9.1303094E+004 -8.3844940E+001 1.063481936210376E+004 -4.0598035E-002 2.0129652E-005 45 | 7.8156585E-001 1.4103699E+004 1.1917555E+005 1.2000719E+005 -8.3250786E+001 1.063870443962162E+004 -4.0596291E-002 2.0158750E-005 46 | 5.8476776E-001 2.0709209E+004 1.5605933E+005 1.5742739E+005 -8.2440964E+001 1.064388251285424E+004 -4.0591829E-002 1.9872046E-005 47 | 4.3787137E-001 3.0738281E+004 2.0407980E+005 2.0638170E+005 -8.1434555E+001 1.065078148627526E+004 -4.0589634E-002 2.0033691E-005 48 | 3.2773316E-001 4.5855754E+004 2.6687266E+005 2.7078363E+005 -8.0250282E+001 1.065998510196566E+004 -4.0598374E-002 2.0084613E-005 49 | 2.4519041E-001 6.9076125E+004 3.4753972E+005 3.5433794E+005 -7.8758537E+001 1.067227384862938E+004 -4.0597517E-002 2.0063970E-005 50 | 1.8356889E-001 1.0469199E+005 4.4939025E+005 4.6142391E+005 -7.6886017E+001 1.068867494503508E+004 -4.0595997E-002 2.0051477E-005 51 | 1.3737231E-001 1.5923222E+005 5.7648756E+005 5.9807425E+005 -7.4559258E+001 1.071057860860878E+004 -4.0595096E-002 2.0007825E-005 52 | 1.0286123E-001 2.4125455E+005 7.3039863E+005 7.6921125E+005 -7.1721336E+001 1.073981817005092E+004 -4.0587906E-002 1.9945475E-005 53 | 7.7023357E-002 3.6295791E+005 9.1117075E+005 9.8080100E+005 -6.8280510E+001 1.077885330233169E+004 -4.0593203E-002 1.9853838E-005 54 | 5.7640672E-002 5.3802813E+005 1.1125024E+006 1.2357734E+006 -6.4190674E+001 1.083100201414188E+004 -4.0597111E-002 1.9719819E-005 55 | 4.3156743E-002 7.7712050E+005 1.3177415E+006 1.5298231E+006 -5.9470589E+001 1.090063974414286E+004 -4.0596895E-002 1.9543148E-005 56 | 3.2296106E-002 1.0864059E+006 1.5066908E+006 1.8575238E+006 -5.4206348E+001 1.099368277797551E+004 -4.0601477E-002 1.9312400E-005 57 | 2.4171919E-002 1.4464823E+006 1.6560205E+006 2.1987985E+006 -4.8863800E+001 1.111798482526618E+004 -4.0594768E-002 1.9025645E-005 58 | 1.8095449E-002 1.8417479E+006 1.7455986E+006 2.5375480E+006 -4.3464706E+001 1.128401506408554E+004 -4.0576864E-002 1.8644510E-005 59 | 1.3546422E-002 2.2373933E+006 1.7838929E+006 2.8615035E+006 -3.8565655E+001 1.150578577837913E+004 -4.0591758E-002 1.8159395E-005 60 | 1.0138676E-002 2.6157598E+006 1.7742379E+006 3.1607150E+006 -3.4148567E+001 1.180208381933247E+004 -4.0594667E-002 1.7505585E-005 61 | 7.5937808E-003 2.9585928E+006 1.7555944E+006 3.4402590E+006 -3.0684399E+001 1.219766871406842E+004 -4.0597528E-002 1.6762518E-005 62 | 5.6843013E-003 3.2879413E+006 1.7526183E+006 3.7258863E+006 -2.8059591E+001 1.272612766799558E+004 -4.0597420E-002 1.5911364E-005 63 | 4.2551756E-003 3.5772373E+006 1.7778500E+006 3.9946685E+006 -2.6426903E+001 1.343206003496551E+004 -4.0598158E-002 1.4855485E-005 64 | 3.1851381E-003 3.8912310E+006 1.8128473E+006 4.2927955E+006 -2.4979877E+001 1.437513482034585E+004 -4.0596075E-002 1.3775185E-005 65 | 2.3847669E-003 4.1240078E+006 1.9078368E+006 4.5439280E+006 -2.4826071E+001 1.563470947531908E+004 -4.0595856E-002 1.2261165E-005 66 | 1.7846748E-003 4.4432370E+006 2.0005125E+006 4.8728230E+006 -2.4239067E+001 1.731780379976856E+004 -4.0598430E-002 1.0719642E-005 67 | 1.3360009E-003 4.7796325E+006 2.1489195E+006 5.2404905E+006 -2.4208651E+001 1.956611522341089E+004 -4.0601213E-002 9.2869222E-006 68 | 9.9990517E-004 5.1702405E+006 2.3457755E+006 5.6775040E+006 -2.4404139E+001 2.257014876454812E+004 -4.0601943E-002 8.0679802E-006 69 | -------------------------------------------------------------------------------- /assets/workflow-mmd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AUTODIAL/AutoEIS/38ba0e5c26204a47a48b03ea24c16341f44189c5/assets/workflow-mmd.png -------------------------------------------------------------------------------- /assets/workflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AUTODIAL/AutoEIS/38ba0e5c26204a47a48b03ea24c16341f44189c5/assets/workflow.png -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | wipe: 23 | make clean 24 | find . -name "generated" | xargs rm -rf 25 | -------------------------------------------------------------------------------- /doc/_basic_usage.md: -------------------------------------------------------------------------------- 1 | # Basic usage 2 | 3 | :::{warning} 4 | The envelope function, `perform_full_analysis` has some issues since it was doing too much all at once. For now, we've deprecated the function until it's made robust. We recommend using the step-by-step approach since it gives more control. That said, since a one-stop-shop function is what many users, especially experimentlists, would like, we're working on making it robust. We'll update this page once the function is ready. 5 | ::: 6 | 7 | To use AutoEIS, you can either perform the circuit generation and Bayesian inference step by step or use the `perform_full_analysis` function to perform the whole process automatically. The following is a minimal example of how to use the `perform_full_analysis` function. 8 | 9 | ```python 10 | import autoeis as ae 11 | 12 | # Load and visualize the test dataset 13 | freq, Z = ae.io.load_test_dataset() 14 | ae.visualization.plot_impedance_combo(freq, Z) 15 | 16 | # Perform automated EIS analysis 17 | circuits = ae.perform_full_analysis(freq, Z, iters=24, parallel=True) 18 | 19 | # Print summary of the inference for each circuit model 20 | for i, row in circuits.iterrows(): 21 | circuit = row["circuit"] 22 | mcmc = row["InferenceResult"].mcmc 23 | if row["converged"]: 24 | ae.visualization.print_summary_statistics(mcmc, circuit) 25 | 26 | # Print summary of all circuit models 27 | ae.visualization.print_inference_results(circuits) 28 | ``` 29 | 30 | :::{seealso} 31 | While the above example should work out of the box, it is recommended to use AutoEIS in a step by step fashion to have more control over the analysis process. Furthermore, you'll learn more about the inner workings of AutoEIS this way. An example notebook that demonstrates how to use AutoEIS in more details can be found [here](https://github.com/AUTODIAL/AutoEIS/blob/develop/examples/autoeis_demo.ipynb). 32 | ::: 33 | 34 | :::{note} 35 | Apart from the functions used in the example notebook, there are more functionalities in AutoEIS that are not yet documented. Until we add more examples on how to use these features, you can find the full list of functions in the [API reference](modules) section. 36 | ::: 37 | -------------------------------------------------------------------------------- /doc/_static/custom.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,400;0,500;0,600;1,400;1,500;1,600&display=swap'); 2 | 3 | body { 4 | font-family: 'Noto Sans', sans-serif !important; 5 | } 6 | 7 | img.sidebar-logo { 8 | width: 80%; 9 | } 10 | 11 | p.admonition-title, 12 | .admonition>p { 13 | font-size: 0.9rem !important; 14 | } 15 | 16 | p.admonition-title { 17 | font-weight: bold !important; 18 | } 19 | 20 | .admonition { 21 | margin-top: 25px; 22 | margin-bottom: 25px; 23 | } 24 | 25 | a.reference.internal { 26 | /* truncate if more than one line */ 27 | display: block; 28 | overflow: hidden; 29 | text-overflow: ellipsis; 30 | white-space: nowrap; 31 | } 32 | 33 | img { 34 | height: auto !important; 35 | } 36 | 37 | img.fig-ecm { 38 | width: 600px; 39 | padding: 25px; 40 | background-color: white; 41 | border-radius: 10px; 42 | } 43 | 44 | div.input_area { 45 | border-radius: 5px !important; 46 | } 47 | 48 | pre { 49 | margin: 10px !important; 50 | font-size: var(--code-font-size); 51 | } 52 | 53 | .widget-dropdown>select { 54 | border-radius: 5px !important; 55 | } 56 | 57 | div.input_area { 58 | margin-bottom: 10px !important; 59 | } 60 | 61 | @media (max-width: 800px) { 62 | img#banner { 63 | display: none; 64 | } 65 | } 66 | 67 | img#banner { 68 | width: 100%; 69 | padding: 25px 25px 0; 70 | } 71 | -------------------------------------------------------------------------------- /doc/_static/logo-dark-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AUTODIAL/AutoEIS/38ba0e5c26204a47a48b03ea24c16341f44189c5/doc/_static/logo-dark-mode.png -------------------------------------------------------------------------------- /doc/_static/logo-light-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AUTODIAL/AutoEIS/38ba0e5c26204a47a48b03ea24c16341f44189c5/doc/_static/logo-light-mode.png -------------------------------------------------------------------------------- /doc/_templates/autosummary/base.rst: -------------------------------------------------------------------------------- 1 | {{ name | escape | underline }} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. auto{{ objtype }}:: {{ objname }} 6 | -------------------------------------------------------------------------------- /doc/_templates/autosummary/module.rst: -------------------------------------------------------------------------------- 1 | :noindex: 2 | 3 | {{ name | escape | underline }} 4 | 5 | .. automodule:: {{ fullname }} 6 | -------------------------------------------------------------------------------- /doc/circuit.md: -------------------------------------------------------------------------------- 1 | # Circuit notation 2 | 3 | AutoEIS uses a custom notation to represent circuit models based on the following two rules: 4 | 5 | 1. Elements in series are separate by a `-` symbol. 6 | 2. Elements in parallel are enclosed in square brackets `[ ]`, and are separated by a `,` symbol. 7 | 8 | For example, the following circuit: 9 | 10 | ```{image} ../assets/ecm.png 11 | :class: fig-ecm 12 | :align: center 13 | ``` 14 | 15 | is represented as `R1-C2-[R3,P4]-[R5,[R6,C7]]`. 16 | 17 | As for the component names, AutoEIS uses the following convention: 18 | 19 | - `R` for resistors 20 | - `C` for capacitors 21 | - `L` for inductors 22 | - `P` for constant phase elements (CPEs) 23 | 24 | Resistors, capacitors, and inductors are each parameterized by a single value, which is the component value in Ohms, Farads, and Henrys, respectively. CPEs are parameterized by two values, the magnitude $Q$ (represented by `w`) and the exponent $\alpha$ (represented by `n`) based on the following equation: 25 | 26 | $$ 27 | Z_{CPE} = \frac{1}{Q \times (j\omega)^{\alpha}} 28 | $$ 29 | 30 | For example, consider component `P4` in the above circuit. The corresponding CPE is parameterized by `P4w` and `P4n`. The other components are parameterized by `R1`, `C2`, `R3`, `R5`, `R6`, and `C7` (basically, the parameter name is the same as the component name). 31 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from datetime import date 3 | 4 | from autoeis.version import __version__ 5 | 6 | # Copy notebooks to the root of the documentation 7 | shutil.rmtree("examples", ignore_errors=True) 8 | shutil.copytree("../examples", "examples", dirs_exist_ok=True) 9 | 10 | # -- Project information ----------------------------------------------------- 11 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 12 | 13 | project = "AutoEIS" 14 | copyright = f"{date.today().year}, AutoEIS developers" 15 | author = "Runze Zhang, Amin Sadeghi, Jason Hattrick-Simpers" 16 | version = __version__ 17 | release = __version__ 18 | 19 | # -- General configuration --------------------------------------------------- 20 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 21 | 22 | # For Sphinx not to complain about missing heading levels 23 | suppress_warnings = ["myst.header"] 24 | 25 | extensions = [ 26 | "sphinx.ext.autodoc", 27 | "sphinx.ext.napoleon", 28 | "sphinx.ext.autosummary", 29 | "myst_parser", # already activated by myst_nb 30 | # "myst_nb", # overrides nbsphinx 31 | "sphinx_copybutton", 32 | "nbsphinx", 33 | # 'autodoc2', 34 | # 'numpydoc', 35 | ] 36 | 37 | myst_enable_extensions = [ 38 | "amsmath", 39 | "attrs_inline", 40 | "colon_fence", 41 | "deflist", 42 | "dollarmath", 43 | "fieldlist", 44 | "html_admonition", 45 | "html_image", 46 | "linkify", 47 | "replacements", 48 | "smartquotes", 49 | "strikethrough", 50 | "substitution", 51 | "tasklist", 52 | ] 53 | 54 | templates_path = ["_templates"] 55 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 56 | 57 | autodoc2_packages = [ 58 | "../autoeis", 59 | ] 60 | 61 | # myst_nb config 62 | # nb_execution_timeout = 600 63 | # nb_execution_mode = "cache" 64 | 65 | # nbsphinx config 66 | nbsphinx_execute_arguments = [ 67 | "--InlineBackend.figure_formats={'svg', 'pdf'}", 68 | "--InlineBackend.rc=figure.dpi=96", 69 | ] 70 | nbsphinx_execute = "auto" 71 | nbsphinx_prompt_width = "0" 72 | nbsphinx_allow_errors = False 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 76 | 77 | html_theme = "furo" 78 | # html_title = '' 79 | html_static_path = ["_static"] 80 | html_css_files = ["custom.css"] 81 | html_theme_options = { 82 | "sidebar_hide_name": True, 83 | "light_logo": "logo-light-mode.png", 84 | "dark_logo": "logo-dark-mode.png", 85 | "footer_icons": [ 86 | { 87 | "name": "GitHub", 88 | "url": "https://github.com/AUTODIAL/AutoEIS", 89 | "html": """ 90 | 91 | 92 | 93 | """, 94 | "class": "", 95 | }, 96 | ], 97 | } 98 | -------------------------------------------------------------------------------- /doc/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to AutoEIS 2 | 3 | AutoEIS is a Python package for equivalent circuit modeling of electrochemical impedance spectroscopy (EIS) data using statistical machine learning. It is designed to be fast and user-friendly, making it accessible to researchers and practitioners in the field of electrochemistry. The package is open-source, and we welcome contributions from the community. This document describes how to get involved. 4 | 5 | Before you start you'll need to set up a free GitHub account and sign in. Here are some [instructions][link_signupinstructions] to get started. 6 | 7 | ## Ways to Contribute 8 | 9 | ### Open a New Issue 10 | 11 | We use Github to track [issues][link_issues]. Issues can take the form of: 12 | 13 | (a) bug reports such as a function producing an error or odd result in some circumstances. 14 | 15 | (b) feature requests such a suggesting a new function be added to the package, presumably based on some literature report that describes it, or enhancements to an existing function. 16 | 17 | (c) general usage questions where the documentation is not clear and you need help getting a function to work as desired. This is actually a bug report in disguise since it means there is a problem with the documentation. 18 | 19 | ### Addressing Open Issues 20 | 21 | Help fixing open [issues][link_issues] is always welcome; however, the learning curve for submitting new code to any repo on Github is a bit intimidating. The process is as follows: 22 | 23 | a) [Fork][link_fork] AutoEIS to your own Github account. This lets you work on the code since you are the owner of that forked copy. 24 | 25 | b) Pull the code to your local machine using some Git client. We suggest [GitKraken][link_gitkraken]. For help using the Git version control system, see [these resources][link_using_git]. 26 | 27 | c) Create a new branch, with a useful name like "fix_issue_41" or "add_feature_X", then checkout that branch. 28 | 29 | d) Edit the code as desired, either fixing or adding something. You'll need to know Python and the various packages in the [SciPy][link_scipy] stack for this part. 30 | 31 | e) Push the changes back to Github, to your own repo. 32 | 33 | f) Navigate to the [pull requests area][link_pull_requests] on the AutoEIS repo, then click the "new pull request" button. As the name suggests, you are [requesting us to pull][link_pullrequest] your code in to our repo. You'll want to select the correct branch on your repo (e.g. "add_awesome_new_feature") and the "main" branch on AutoEIS. 34 | 35 | g) This will trigger several things on our repo, including most importantly a conversation between you and the AutoEIS team about your code. After any fine-tuning is done, we will merge your code into AutoEIS, and your contribution will be immortalized in AutoEIS. 36 | 37 | [link_issues]: https://github.com/AUTODIAL/AutoEIS/issues 38 | [link_gitkraken]: https://www.gitkraken.com/ 39 | [link_pull_requests]: https://github.com/AUTODIAL/AutoEIS/pulls 40 | [link_fork]: https://help.github.com/articles/fork-a-repo/ 41 | [link_signupinstructions]: https://help.github.com/articles/signing-up-for-a-new-github-account 42 | [link_pullrequest]: https://help.github.com/articles/creating-a-pull-request/ 43 | [link_using_git]: http://try.github.io/ 44 | [link_scipy]: https://www.scipy.org/ 45 | 46 | !!! note 47 | 48 | Adapted from PoreSpy's [contributing guide](https://github.com/PMEAL/porespy/blob/dev/CONTRIBUTING.md). 49 | -------------------------------------------------------------------------------- /doc/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | The following examples demonstrate how to use AutoEIS to perform EIS analysis. You can download the examples from the main `AutoEIS repository `__. To run the examples, you need to install `jupyter notebook `__. If you have any questions or issues running the examples, please open an `issue `__ or directly submit a `pull request `__. We would greatly appreciate any contributions from the community. 5 | 6 | AutoEIS is still under development, and we will add more examples as we make progress. In the meantime, feel free to explore the functionalities via the `API reference `__. If you have any suggestions for new examples, please open an `issue `__ or directly submit a `pull request `__. 7 | 8 | 9 | .. warning:: 10 | 11 | The examples are designed to be run interactively, so you should use 12 | a Jupyter notebook-like environment like Jupyter Lab, IPython Notebook, 13 | or VSCode. The examples may not work as expected if you run them in a 14 | non-interactive environment like a Python REPL. For a smooth experience, 15 | please use a supported environment. 16 | 17 | 18 | .. nbgallery:: 19 | :name: examples-gallery 20 | :maxdepth: 1 21 | 22 | examples/circuit_basics 23 | examples/circuit_generation 24 | examples/basic_workflow 25 | examples/parallel_inference 26 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. AutoEIS documentation master file, created by 2 | sphinx-quickstart on Tue Oct 10 07:17:41 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. _front_page: 7 | 8 | .. module:: autoeis 9 | :noindex: 10 | 11 | ############################### 12 | AutoEIS: Automated EIS Analysis 13 | ############################### 14 | 15 | What is AutoEIS? 16 | ################ 17 | 18 | AutoEIS is a Python package that automatically proposes statistically plausible equivalent circuit models (ECMs) for electrochemical impedance spectroscopy (EIS) analysis. The package is designed for researchers and practitioners in the fields of electrochemical analysis, including but not limited to explorations of electrocatalysis, battery design, and investigations of material degradation. 19 | 20 | AutoEIS is still under development and therefore, the API is still not stable. If you find any bugs or have any suggestions, please file an `issue `_ or directly submit a `pull request `_. We would greatly appreciate any contributions from the community. 21 | 22 | .. figure:: ../assets/workflow.png 23 | :name: banner 24 | :align: center 25 | 26 | Typical workflow of AutoEIS 27 | 28 | .. Contents 29 | .. ######## 30 | 31 | .. toctree:: 32 | :hidden: 33 | :maxdepth: 1 34 | 35 | installation.md 36 | circuit.md 37 | examples 38 | modules.rst 39 | -------------------------------------------------------------------------------- /doc/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Open a terminal (or command prompt on Windows) and run the following command: 4 | 5 | ```bash 6 | pip install -U autoeis 7 | ``` 8 | 9 | Julia dependencies will be automatically installed at first import. It's recommended that you have your own Julia installation, but if you don't, Julia itself will also be installed automatically. 10 | 11 | :::{admonition} How to install Julia 12 | :class: note 13 | If you decided to have your own Julia installation (recommended), the official way to install Julia is via [juliaup](https://github.com/JuliaLang/juliaup). [Juliaup](https://github.com/JuliaLang/juliaup) provides a command line interface to automatically install Julia (optionally multiple versions side by side). Working with [juliaup](https://github.com/JuliaLang/juliaup) is straightforward; Please follow the instructions on its GitHub [page](https://github.com/JuliaLang/juliaup). 14 | ::: 15 | 16 | :::{admonition} Minimum Julia version 17 | :class: warning 18 | AutoEIS requires Julia version 1.9 or higher. This strict requirement is due to many optimizations introduced in Julia 1.9 that significantly reduce the startup time of `EquivalentCircuits.jl`, the backend of AutoEIS. 19 | ::: 20 | 21 | :::{admonition} About shared environments 22 | :class: note 23 | AutoEIS doesn't pollute your global Julia environment. Instead, it creates a new environment with the same name as your Python virtual environment (if you're in on!) and installs the required packages there. This way, you can safely use AutoEIS without worrying about breaking your global Julia environment. The Julia environment is stored in the same folder as your Python virtual environment. For instance, if you're using the Anaconda Python distribution and the name of your Python virtual environment is `myenv`, the path to the Julia environment is `~/anaconda3/envs/myenv/julia_env` on Unix-based systems and `%USERPROFILE%\anaconda3/envs/myenv/julia_env` on Windows. 24 | ::: 25 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /doc/modules.rst: -------------------------------------------------------------------------------- 1 | .. _modules_index: 2 | 3 | API Reference 4 | ############# 5 | 6 | AutoEIS contains five modules: ``io`` for reading and writing data, ``core`` for finding the best fit and performing the analysis, ``visualization`` for plotting, ``utils`` for helper functions used in ``core``, and ``parser`` for parsing circuit strings. 7 | 8 | .. automodule:: autoeis 9 | 10 | .. autosummary:: 11 | :toctree: generated 12 | :recursive: 13 | 14 | autoeis.io 15 | autoeis.core 16 | autoeis.visualization 17 | autoeis.utils 18 | autoeis.parser 19 | -------------------------------------------------------------------------------- /examples/circuit_basics.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Circuit Models 101\n", 8 | "\n", 9 | "In this notebook, we will explore the basics of circuit models in AutoEIS. We will start by importing the necessary libraries." 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": null, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import numpy as np\n", 19 | "import autoeis as ae\n", 20 | "ae.visualization.set_plot_style()" 21 | ] 22 | }, 23 | { 24 | "cell_type": "markdown", 25 | "metadata": {}, 26 | "source": [ 27 | "## Circuit representation\n", 28 | "\n", 29 | "In AutoEIS, circuits are represented as strings. Please refer to [circuit notation](../circuit.md) for the syntax of the circuit string. Now, let's create a sample circuit string:" 30 | ] 31 | }, 32 | { 33 | "cell_type": "code", 34 | "execution_count": null, 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "circuit = \"R1-[P2,R3]-C4-[[R5,C6],[L7,R8]]-R2\"" 39 | ] 40 | }, 41 | { 42 | "cell_type": "markdown", 43 | "metadata": {}, 44 | "source": [ 45 | "We can visualize the circuit model using the `draw_circuit` function, which requires the `lcapy` package to be installed, and a working LaTeX installation. See [here](https://lcapy.readthedocs.io/en/latest/install.html) for more details." 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "metadata": {}, 52 | "outputs": [], 53 | "source": [ 54 | "x = ae.visualization.draw_circuit(circuit)" 55 | ] 56 | }, 57 | { 58 | "cell_type": "markdown", 59 | "metadata": {}, 60 | "source": [ 61 | "## Querying circuit strings\n", 62 | "\n", 63 | "Once you have the circuit string, you can run different queries on it. We're not going to explore all of available queries here, but we'll show you a few of the most common ones. To see the full list of available queries, check out the [API reference](../modules.rst).\n", 64 | "\n", 65 | "To get the list of components in the circuit, you can use the `get_component_labels` function:" 66 | ] 67 | }, 68 | { 69 | "cell_type": "code", 70 | "execution_count": null, 71 | "metadata": {}, 72 | "outputs": [], 73 | "source": [ 74 | "ae.parser.get_component_labels(circuit)" 75 | ] 76 | }, 77 | { 78 | "cell_type": "markdown", 79 | "metadata": {}, 80 | "source": [ 81 | "(The impedance of) Each component is represented by one or more parameters. To get the list of parameters that fully describe the circuit, use the `get_parameter_labels` function:" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": null, 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "ae.parser.get_parameter_labels(circuit)" 91 | ] 92 | }, 93 | { 94 | "cell_type": "markdown", 95 | "metadata": {}, 96 | "source": [ 97 | "Note that components and parameters are not the same, despite the fact that for single-parameter components they're represented by the same string. For instance, `R1` is both a parameter and a component, but `P2` is a component, which is described by the parameters `P2w` and `P2n`." 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "metadata": {}, 103 | "source": [ 104 | "You can also validate the circuit using the `validate_circuit` function in case you're not sure if the circuit is valid:" 105 | ] 106 | }, 107 | { 108 | "cell_type": "code", 109 | "execution_count": null, 110 | "metadata": {}, 111 | "outputs": [], 112 | "source": [ 113 | "ae.parser.validate_circuit(circuit)" 114 | ] 115 | }, 116 | { 117 | "cell_type": "markdown", 118 | "metadata": {}, 119 | "source": [ 120 | "Let's try to validate an invalid circuit string:" 121 | ] 122 | }, 123 | { 124 | "cell_type": "code", 125 | "execution_count": null, 126 | "metadata": { 127 | "tags": [ 128 | "raises-exception" 129 | ] 130 | }, 131 | "outputs": [], 132 | "source": [ 133 | "ae.parser.validate_circuit(\"R1-[R2,P3]-R1\")" 134 | ] 135 | }, 136 | { 137 | "cell_type": "markdown", 138 | "metadata": {}, 139 | "source": [ 140 | "Another useful query is to compare two circuits to see if they are structurally equivalent. For instance, one would expect that `R1-R2` and `R2-R1` and `R5-R0` are equivalent, i.e., neither the order of appearance of the components nor the labels matter. This is useful for filtering out duplicate circuits, which may (and will) arise during circuit generation using evolutionary algorithms. You can do this using the `are_circuits_equivalent` function:" 141 | ] 142 | }, 143 | { 144 | "cell_type": "code", 145 | "execution_count": null, 146 | "metadata": {}, 147 | "outputs": [], 148 | "source": [ 149 | "circuit1 = \"R1-[P2,R3]-C4\"\n", 150 | "circuit2 = \"C4-R1-[R3,P2]\"\n", 151 | "circuit3 = \"C0-R5-[R9,P0]\"\n", 152 | "\n", 153 | "assert ae.utils.are_circuits_equivalent(circuit1, circuit2)\n", 154 | "assert ae.utils.are_circuits_equivalent(circuit1, circuit3)" 155 | ] 156 | }, 157 | { 158 | "cell_type": "markdown", 159 | "metadata": {}, 160 | "source": [ 161 | "## Evaluating circuit strings\n", 162 | "\n", 163 | "Once you have a valid circuit string, you can calculate the EIS spectra of the circuit model by evaluating it at the frequency range of interest. To do this, you need to convert the circuit string to a function using the `generate_circuit_fn` function:" 164 | ] 165 | }, 166 | { 167 | "cell_type": "code", 168 | "execution_count": null, 169 | "metadata": {}, 170 | "outputs": [], 171 | "source": [ 172 | "circuit_fn = ae.utils.generate_circuit_fn(circuit)" 173 | ] 174 | }, 175 | { 176 | "cell_type": "markdown", 177 | "metadata": {}, 178 | "source": [ 179 | "Now, let's calculate the EIS spectra for a few frequencies. For this, we also need to pass the parameters of the circuit model, for which we can use random values:" 180 | ] 181 | }, 182 | { 183 | "cell_type": "code", 184 | "execution_count": null, 185 | "metadata": {}, 186 | "outputs": [], 187 | "source": [ 188 | "freq = np.logspace(-3, 3, 10)\n", 189 | "num_params = ae.parser.count_parameters(circuit)\n", 190 | "p = np.random.rand(num_params)\n", 191 | "Z = circuit_fn(freq, p)\n", 192 | "Z" 193 | ] 194 | } 195 | ], 196 | "metadata": { 197 | "kernelspec": { 198 | "display_name": ".venv", 199 | "language": "python", 200 | "name": "python3" 201 | }, 202 | "language_info": { 203 | "codemirror_mode": { 204 | "name": "ipython", 205 | "version": 3 206 | }, 207 | "file_extension": ".py", 208 | "mimetype": "text/x-python", 209 | "name": "python", 210 | "nbconvert_exporter": "python", 211 | "pygments_lexer": "ipython3" 212 | } 213 | }, 214 | "nbformat": 4, 215 | "nbformat_minor": 2 216 | } 217 | -------------------------------------------------------------------------------- /examples/circuit_generation.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Circuit Generation" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "In this notebook, we demonstrate how to generate a pool of equivalent circuit models from electrochemical impedance spectroscopy (EIS) measurements that best fit the data." 15 | ] 16 | }, 17 | { 18 | "cell_type": "markdown", 19 | "metadata": {}, 20 | "source": [ 21 | "## Set up the environment" 22 | ] 23 | }, 24 | { 25 | "cell_type": "markdown", 26 | "metadata": {}, 27 | "source": [ 28 | "AutoEIS relies on `EquivalentCircuits.jl` package to perform the EIS analysis. The package is not written in Python, so we need to install it first. AutoEIS ships with `julia_helpers` module that helps to install and manage Julia dependencies with minimal user interaction. For convenience, installing Julia and the required packages is done automatically when you import `autoeis` for the first time. If you have Julia installed already (discoverable in system PATH), it'll get detected and used, otherwise, it'll be installed automatically." 29 | ] 30 | }, 31 | { 32 | "cell_type": "markdown", 33 | "metadata": {}, 34 | "source": [ 35 | "
\n", 36 | "\n", 37 | "Note\n", 38 | "\n", 39 | "If this is the first time you're importing AutoEIS, executing the next cell will take a while, outputting a lot of logs. Re-run the cell to get rid of the logs.\n", 40 | "\n", 41 | "
" 42 | ] 43 | }, 44 | { 45 | "cell_type": "code", 46 | "execution_count": null, 47 | "metadata": {}, 48 | "outputs": [], 49 | "source": [ 50 | "import matplotlib.pyplot as plt\n", 51 | "\n", 52 | "import autoeis as ae\n", 53 | "\n", 54 | "ae.visualization.set_plot_style()" 55 | ] 56 | }, 57 | { 58 | "cell_type": "markdown", 59 | "metadata": {}, 60 | "source": [ 61 | "## Load EIS data" 62 | ] 63 | }, 64 | { 65 | "cell_type": "markdown", 66 | "metadata": {}, 67 | "source": [ 68 | "Once the environment is set up, we can load the EIS data. You can use [`pyimpspec`](https://vyrjana.github.io/pyimpspec/guide_data.html) to load EIS data from a variety of popular formats. Eventually, AutoEIS requires two arrays: `Z` and `freq`. `Z` is a complex impedance array, and `freq` is a frequency array. Both arrays must be 1D and have the same length. The impedance array must be in Ohms, and the frequency array must be in Hz.\n", 69 | "\n", 70 | "For convenience, we provide a function `load_test_dataset()` in `autoeis.io` to load a test dataset. The function returns a tuple of `freq` and `Z`." 71 | ] 72 | }, 73 | { 74 | "cell_type": "code", 75 | "execution_count": null, 76 | "metadata": {}, 77 | "outputs": [], 78 | "source": [ 79 | "freq, Z = ae.io.load_test_dataset(preprocess=True)" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "metadata": {}, 85 | "source": [ 86 | "
\n", 87 | "\n", 88 | "Note\n", 89 | "\n", 90 | "If your EIS data is stored as text, you can easily load them using `numpy.loadtxt`. See NumPy's documentation for more details.\n", 91 | "\n", 92 | "
" 93 | ] 94 | }, 95 | { 96 | "cell_type": "markdown", 97 | "metadata": {}, 98 | "source": [ 99 | "Let's take a look at the test dataset before we proceed:" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": null, 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "ae.visualization.plot_impedance_combo(freq, Z);" 109 | ] 110 | }, 111 | { 112 | "cell_type": "markdown", 113 | "metadata": {}, 114 | "source": [ 115 | "## Generate equivalent circuits\n", 116 | "\n", 117 | "Now that we have loaded the EIS data, we can generate a pool of candidate equivalent circuits using the `generate_equivalent_circuits` function. The function takes the impedance data and frequency as input and returns a list of equivalent circuits. It also takes many optional arguments to control the circuit generation process. The most important ones are:\n", 118 | "\n", 119 | "- `iters`: Number of circuits to generate.\n", 120 | "- `complexity`: Maximum number of elements in a circuit.\n", 121 | "- `terminals`: Type of circuit components to use (e.g., R, C, L, or P).\n", 122 | "- `parallel`: Whether to run the circuit generation in parallel.\n", 123 | "- `tol`: Tolerance for accepting a circuit as a good fit.\n", 124 | "- `seed`: Random seed for reproducibility.\n", 125 | "\n", 126 | "The function uses a gene expression programming (GEP) algorithm to generate the circuits. The GEP algorithm is a genetic algorithm that evolves circuits by combining and mutating genes. The algorithm starts with a population of random circuits and evolves them over many generations to find the best circuits that fit the data. The following parameters control the GEP algorithm:\n", 127 | "\n", 128 | "- `generations`: Number of generations to run the genetic algorithm.\n", 129 | "- `population_size`: Number of circuits in the population for\n", 130 | "\n", 131 | "The default values for these parameters are usually good enough for most cases. However, you can adjust them to get better results (e.g., increase both arguments if you're not satisfied with the generated circuits, or decrease them if you want to speed up the process)." 132 | ] 133 | }, 134 | { 135 | "cell_type": "markdown", 136 | "metadata": {}, 137 | "source": [ 138 | "
\n", 139 | "\n", 140 | "Reproducibility\n", 141 | "\n", 142 | "Since the circuit generation process is stochastic, you may get different results each time you run the function. To get reproducible results, you can set the random seed using the `seed` argument. That said, a successful circuit generation process should yield similar results after enough iterations, even with different seeds.\n", 143 | "\n", 144 | "
" 145 | ] 146 | }, 147 | { 148 | "cell_type": "markdown", 149 | "metadata": {}, 150 | "source": [ 151 | "
\n", 152 | "\n", 153 | "Runtime\n", 154 | "\n", 155 | "Circuit generation is a lengthy process. It may take one minute per iteration on a modern CPU. We recommend generating at least 50 circuits to get a good pool of candidate circuits, which may take about an hour.\n", 156 | "\n", 157 | "
" 158 | ] 159 | }, 160 | { 161 | "cell_type": "markdown", 162 | "metadata": {}, 163 | "source": [ 164 | "
\n", 165 | "\n", 166 | "Note\n", 167 | "\n", 168 | "By default, `terminals` is set to `\"RLP\"`, i.e., the algorithm searches for circuits with resistors, inductors, and constant-phase elements. You can add capacitors by setting `terminals=\"RCLP\"`, but since a capacitor is a special case of a constant-phase element, it's not necessary, and it makes the search less efficient.\n", 169 | "\n", 170 | "
" 171 | ] 172 | }, 173 | { 174 | "cell_type": "code", 175 | "execution_count": null, 176 | "metadata": {}, 177 | "outputs": [], 178 | "source": [ 179 | "kwargs = {\n", 180 | " \"iters\": 24,\n", 181 | " \"complexity\": 12,\n", 182 | " \"population_size\": 100,\n", 183 | " \"generations\": 30,\n", 184 | " \"terminals\": \"RLP\",\n", 185 | " \"tol\": 1e-2,\n", 186 | " \"parallel\": True,\n", 187 | "}\n", 188 | "circuits_unfiltered = ae.core.generate_equivalent_circuits(freq, Z, **kwargs)\n", 189 | "circuits_unfiltered" 190 | ] 191 | }, 192 | { 193 | "cell_type": "markdown", 194 | "metadata": {}, 195 | "source": [ 196 | "
\n", 197 | "\n", 198 | "Convergence\n", 199 | "\n", 200 | "The Circuit generation algorithm is sensitive to the `tol` parameter, meaning that the order of magnitude of the `tol` needs to be proportional to the order of magnitude of the impedance data. There's no one-size-fits-all value for `tol`, and we're trying to make the algorithm `tol`-agnostic in future releases. For now, we've hacked a heuristic that internally scales the `tol` based on the impedance data. Nevertheless, you may still need to adjust the `tol` if you end up with no circuits (increase `tol`) or too many circuits (decrease `tol`). The default value is `1e-2`. When increasing or decreasing `tol`, try doubling or halving the value to see if it helps.\n", 201 | "\n", 202 | "
" 203 | ] 204 | }, 205 | { 206 | "cell_type": "markdown", 207 | "metadata": {}, 208 | "source": [ 209 | "## Filter candidate equivalent circuits\n", 210 | "\n", 211 | "Note that all these circuits generated by the GEP process probably fit the data well, but they may not be physically meaningful. Therefore, we need to filter them to find the ones that are most plausible. AutoEIS uses \"statistical plausibility\" as a proxy for gauging \"physical plausibility\". To this end, AutoEIS provides a function to filter the candidate circuits based on some heuristics (read our [paper](https://doi.org/10.1149/1945-7111/aceab2) for the exact steps and the supporting rationale)." 212 | ] 213 | }, 214 | { 215 | "cell_type": "code", 216 | "execution_count": null, 217 | "metadata": {}, 218 | "outputs": [], 219 | "source": [ 220 | "circuits = ae.core.filter_implausible_circuits(circuits_unfiltered)\n", 221 | "circuits" 222 | ] 223 | }, 224 | { 225 | "cell_type": "markdown", 226 | "metadata": {}, 227 | "source": [ 228 | "Let's see how well the generated circuits fit the data. You can either use the parameters' values at the end of the GEP process (stored in the `circuits` dataframe), or use `fit_circuit_parameters` to further refine the parameters (recommended)." 229 | ] 230 | }, 231 | { 232 | "cell_type": "markdown", 233 | "metadata": {}, 234 | "source": [ 235 | "
\n", 236 | "\n", 237 | "Note\n", 238 | "\n", 239 | "Normally, the found circuits are good enough, but since we didn't run the algorithm for long enough (to not timeout our CI on GitHub), we will use a custom circuit for evaluation. If you're running this notebook on your own data, try using `iters >= 200` together with a more stringet `tol` to get a good pool of circuits. We're currently working on changing the evolutionary algorithm backend to speed up the process, so you no longer need to wait for hours to get a good pool of circuits.\n", 240 | "\n", 241 | "
" 242 | ] 243 | }, 244 | { 245 | "cell_type": "code", 246 | "execution_count": null, 247 | "metadata": {}, 248 | "outputs": [], 249 | "source": [ 250 | "use_custom_circuit = False\n", 251 | "\n", 252 | "if not use_custom_circuit:\n", 253 | " circuit = circuits.iloc[0][\"circuitstring\"]\n", 254 | " p = circuits.iloc[0][\"Parameters\"]\n", 255 | " # Refine the circuit parameters\n", 256 | " p = ae.utils.fit_circuit_parameters(circuit, freq, Z, p0=p)\n", 257 | "else:\n", 258 | " circuit = \"R4-[P1,R3-P2]\"\n", 259 | " p = ae.utils.fit_circuit_parameters(circuit, freq, Z)\n", 260 | "\n", 261 | "# Simulate Z using the circuit and the fitted parameters\n", 262 | "circuit_fn = ae.utils.generate_circuit_fn(circuit)\n", 263 | "Z_sim = circuit_fn(freq, list(p.values()))\n", 264 | "\n", 265 | "# Plot against ground truth\n", 266 | "fig, ax = plt.subplots(figsize=(5.5, 4))\n", 267 | "ae.visualization.plot_nyquist(Z_sim, fmt=\"-\", ax=ax, label=\"simulated\")\n", 268 | "ae.visualization.plot_nyquist(Z, fmt=\".\", ax=ax, label=\"data\");\n", 269 | "ax.set_title(circuit)" 270 | ] 271 | } 272 | ], 273 | "metadata": { 274 | "language_info": { 275 | "codemirror_mode": { 276 | "name": "ipython" 277 | }, 278 | "file_extension": ".py", 279 | "mimetype": "text/x-python", 280 | "name": "python", 281 | "nbconvert_exporter": "python" 282 | } 283 | }, 284 | "nbformat": 4, 285 | "nbformat_minor": 4 286 | } 287 | -------------------------------------------------------------------------------- /examples/parallel_inference.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Batch Analysis" 8 | ] 9 | }, 10 | { 11 | "cell_type": "markdown", 12 | "metadata": {}, 13 | "source": [ 14 | "In this notebook, we'll learn how to analyze EIS data in batch mode. Normally, you have a single set of EIS data, i.e., set of impedance measurements at various frequencies, plus a common circuit model that you want to fit to the data. This is what we call single circuit, single dataset or SCSD in short. However, there are two other modes of analysis that you might encounter in practice:\n", 15 | "\n", 16 | "- Single circuit, multiple datasets (SCMD): You have multiple datasets, each with its own impedance measurements, but you want to fit the same circuit model to all of them. A good example of this is when you have EIS data for multiple samples which you want to compare, or a single sample under different conditions, e.g., EIS data at different cycles during battery cycling.\n", 17 | "\n", 18 | "- Multiple circuits, single dataset (MCSD): You have a single dataset, but you want to fit different circuit models to it. This is useful when you want to compare different models to see which one fits the data best, which is by the way the classic use case of AutoEIS itself!" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "metadata": {}, 25 | "outputs": [], 26 | "source": [ 27 | "import random\n", 28 | "\n", 29 | "import autoeis as ae\n", 30 | "import matplotlib.pyplot as plt\n", 31 | "\n", 32 | "ae.visualization.set_plot_style()" 33 | ] 34 | }, 35 | { 36 | "cell_type": "markdown", 37 | "metadata": {}, 38 | "source": [ 39 | "## Single circuit, multiple datsets (SCMD)" 40 | ] 41 | }, 42 | { 43 | "cell_type": "markdown", 44 | "metadata": {}, 45 | "source": [ 46 | "To test this, we can use a toy dataset that ships with the package. This dataset contains EIS data for a coin cell battery measured at discharged state at various cycles. Let's load the dataset and see what it looks like." 47 | ] 48 | }, 49 | { 50 | "cell_type": "code", 51 | "execution_count": null, 52 | "metadata": {}, 53 | "outputs": [], 54 | "source": [ 55 | "datasets = ae.io.load_battery_dataset()\n", 56 | "print(f\"Number of cycles: {len(datasets)}\")" 57 | ] 58 | }, 59 | { 60 | "cell_type": "markdown", 61 | "metadata": {}, 62 | "source": [ 63 | "To save time searching for the optimal circuit by calling the `generate_equivalent_circuits` function, we will use the circuit that we know fits the data well." 64 | ] 65 | }, 66 | { 67 | "cell_type": "code", 68 | "execution_count": null, 69 | "metadata": {}, 70 | "outputs": [], 71 | "source": [ 72 | "circuit = \"R1-P2-[R3,P4]-[R5,P6]\"" 73 | ] 74 | }, 75 | { 76 | "cell_type": "markdown", 77 | "metadata": {}, 78 | "source": [ 79 | "Now, let's run Bayesian inference on the entire dataset using the given circuit. For convenience, the API for SCSD, SCMD, and MCSD is the same, so we just need to call `perform_bayesian_inference` with the appropriate arguments: the circuit string, list of frequencies, and list of impedance measurements. Since the loaded dataset is in the form of a list of tuples (frequency, impedance), we can easily extract the frequencies and impedances:" 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": null, 85 | "metadata": {}, 86 | "outputs": [], 87 | "source": [ 88 | "freq, Z = zip(*datasets)\n", 89 | "# If you don't understand the above syntax, you can use the following code instead\n", 90 | "# freq, Z = [], []\n", 91 | "# for dataset in datasets:\n", 92 | "# freq.append(dataset[0])\n", 93 | "# Z.append(dataset[1])" 94 | ] 95 | }, 96 | { 97 | "cell_type": "markdown", 98 | "metadata": {}, 99 | "source": [ 100 | "
\n", 101 | "\n", 102 | "Note\n", 103 | "\n", 104 | "`perform_bayesian_inference` can handle all three modes of analysis: SCSD, MCSD, and SCMD. You only need to pass the appropriate arguments. The main three arguments are: `circuit`, `freq`, and `Z`. If any of these arguments is a list, then the function will automatically switch to the corresponding mode of analysis. Of course, you need to make sure the arguments are consistent, e.g., for SCMD, the length of `freq` and `Z` must be the same, etc.\n", 105 | "\n", 106 | "
" 107 | ] 108 | }, 109 | { 110 | "cell_type": "markdown", 111 | "metadata": {}, 112 | "source": [ 113 | "Now, `freq` and `Z` are lists of frequencies and impedances, respectively, each associated with a different cycle. We can now call `perform_bayesian_inference` with these lists to get the posterior distributions for the circuit parameters for each cycle." 114 | ] 115 | }, 116 | { 117 | "cell_type": "code", 118 | "execution_count": null, 119 | "metadata": {}, 120 | "outputs": [], 121 | "source": [ 122 | "results = ae.perform_bayesian_inference(circuit, freq, Z)" 123 | ] 124 | }, 125 | { 126 | "cell_type": "markdown", 127 | "metadata": {}, 128 | "source": [ 129 | "`results` is a list of `InferenceResult` objects. Each object contains the posterior distributions for the circuit parameters for a single cycle with other useful information. Let's take a look, e.g., let's see how many of the inferences converged:" 130 | ] 131 | }, 132 | { 133 | "cell_type": "code", 134 | "execution_count": null, 135 | "metadata": {}, 136 | "outputs": [], 137 | "source": [ 138 | "for i, result in enumerate(results):\n", 139 | " if not result.converged:\n", 140 | " print(f\"Inference for cycle {i+1:3}/{len(results)} did not converge\")" 141 | ] 142 | }, 143 | { 144 | "cell_type": "markdown", 145 | "metadata": {}, 146 | "source": [ 147 | "Now, let's inspect a sample inference result randomly picked from the list:" 148 | ] 149 | }, 150 | { 151 | "cell_type": "code", 152 | "execution_count": null, 153 | "metadata": {}, 154 | "outputs": [], 155 | "source": [ 156 | "while True:\n", 157 | " result = random.choice(results)\n", 158 | " if result.converged:\n", 159 | " break\n", 160 | "\n", 161 | "# Randomly select a parameter and plot its posterior distribution\n", 162 | "param = random.choice(result.variables)\n", 163 | "fig, ax = plt.subplots(figsize=(5.5, 4))\n", 164 | "ax.hist(result.samples[param])\n", 165 | "ax.set_title(f\"posterior distribution of {param}\")\n", 166 | "\n", 167 | "# Let's list InferenceResult attributes/methods\n", 168 | "print(\n", 169 | " \"List of InferenceResult attributes/methods:\\n >>\",\n", 170 | " \"\\n >> \".join(attr for attr in dir(result) if not attr.startswith(\"_\")),\n", 171 | ")" 172 | ] 173 | }, 174 | { 175 | "cell_type": "markdown", 176 | "metadata": {}, 177 | "source": [ 178 | "This was just a quick overview, but you can do all sorts of analyses with the results, e.g., plotting the evolution of posterior distributions as a function of cycle number in form of violin plots, etc." 179 | ] 180 | }, 181 | { 182 | "cell_type": "markdown", 183 | "metadata": {}, 184 | "source": [ 185 | "## Single circuit, single dataset (SCSD)" 186 | ] 187 | }, 188 | { 189 | "cell_type": "markdown", 190 | "metadata": {}, 191 | "source": [ 192 | "We've already covered how to use `perform_bayesian_inference` for SCMD in the previous section. For SCSD, you just need to pass a single impedance dataset to the function, i.e., a NumPy array instead of a list of arrays. The rest of the process is the same!" 193 | ] 194 | }, 195 | { 196 | "cell_type": "markdown", 197 | "metadata": {}, 198 | "source": [ 199 | "## Multiple circuits, single dataset (MCSD)" 200 | ] 201 | }, 202 | { 203 | "cell_type": "markdown", 204 | "metadata": {}, 205 | "source": [ 206 | "Similarly, you can use `perform_bayesian_inference` for MCSD by passing a list of circuit strings instead of a single string. Alternatively, you can pass a dataframe, but it needs to be formatted with columns named `circuitstring`, and `Parameters` with the circuit strings and initial guesses for the parameters, respectively. This unusual format is for legacy reasons and might be changed in the future." 207 | ] 208 | }, 209 | { 210 | "cell_type": "markdown", 211 | "metadata": {}, 212 | "source": [ 213 | "## Multiple circuits, multiple datasets (MCMD)" 214 | ] 215 | }, 216 | { 217 | "cell_type": "markdown", 218 | "metadata": {}, 219 | "source": [ 220 | "You might ask, what about MCMD? Well, we can easily extend the API to support this mode of analysis, but we couldn't find an actual use case for it, so it's not implemented to keep the codebase sane! If you really need this feature, you can easily implement it yourself by calling `perform_bayesian_inference` in a loop over the datasets!" 221 | ] 222 | } 223 | ], 224 | "metadata": { 225 | "language_info": { 226 | "codemirror_mode": { 227 | "name": "ipython" 228 | }, 229 | "file_extension": ".py", 230 | "mimetype": "text/x-python", 231 | "name": "python", 232 | "nbconvert_exporter": "python" 233 | } 234 | }, 235 | "nbformat": 4, 236 | "nbformat_minor": 4 237 | } 238 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "autoeis" 3 | dynamic = ["version"] 4 | description = "A tool for automated EIS analysis by proposing statistically plausible ECMs." 5 | readme = "README.md" 6 | requires-python = ">=3.10, <3.13" 7 | license = "MIT" 8 | authors = [ 9 | { name = "Runze Zhang", email = "runzee.zhang@mail.utoronto.ca" }, 10 | { name = "Amin Sadeghi", email = "amin.sadeghi@live.com" }, 11 | { name = "Robert Black", email = "robert.black@nrc-cnrc.gc.ca" }, 12 | { name = "Jason Hattrick-Simpers", email = "jason.hattrick.simpers@utoronto.ca" }, 13 | ] 14 | maintainers = [{ name = "Amin Sadeghi", email = "amin.sadeghi@live.com" }] 15 | keywords = [ 16 | "bayesian inference", 17 | "electrochemical impedance spectroscopy", 18 | "equivalent circuit model", 19 | "evolutionary algorithm", 20 | ] 21 | classifiers = [ 22 | "License :: OSI Approved :: MIT License", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python :: 3", 25 | ] 26 | dependencies = [ 27 | "arviz", 28 | "click", 29 | "deprecated", 30 | "dill", 31 | "impedance", 32 | # "impedance @ git+https://github.com/ma-sadeghi/impedance.py@fix-numpy2", 33 | "ipython", 34 | "ipykernel", 35 | "ipywidgets", 36 | "jax", 37 | "jinja2>=3.1.2", 38 | "juliacall", 39 | "juliapkg", 40 | "matplotlib", 41 | "mpire[dill]", 42 | "numpy<2", 43 | "numpyro", 44 | "pandas", 45 | "python-box", 46 | "psutil", 47 | "pyparsing>=3", 48 | "rich", 49 | "scikit-learn>=1.4", 50 | "seaborn", 51 | "tqdm", 52 | ] 53 | [project.optional-dependencies] 54 | lcapy = ["lcapy"] 55 | [project.urls] 56 | "Homepage" = "https://github.com/AUTODIAL/AutoEIS" 57 | Repository = "https://github.com/AUTODIAL/AutoEIS" 58 | "Bug Tracker" = "https://github.com/AUTODIAL/AutoEIS/issues" 59 | Documentation = "https://autodial.github.io/AutoEIS" 60 | 61 | [build-system] 62 | requires = ["hatchling"] 63 | build-backend = "hatchling.build" 64 | 65 | [tool.hatch.version] 66 | path = "src/autoeis/version.py" 67 | 68 | [tool.hatch.build.targets.sdist] 69 | include = ["src/autoeis"] 70 | 71 | [tool.hatch.metadata] 72 | allow-direct-references = true 73 | 74 | [tool.pytest.ini_options] 75 | minversion = "6.0" 76 | addopts = "-ra -vv --durations=5 --assert=plain" 77 | python_files = "*.py" 78 | python_classes = "*Test" 79 | python_functions = "test_*" 80 | testpaths = ["tests", "integration"] 81 | norecursedirs = [".git", ".github", ".ipynb_checkpoints", "build", "dist"] 82 | 83 | [tool.ruff] 84 | exclude = [".git", ".github", ".venv", "build"] 85 | line-length = 92 86 | [tool.ruff.lint.per-file-ignores] 87 | "__init__.py" = ["E402", "F401", "F403"] 88 | 89 | [tool.uv] 90 | dev-dependencies = [ 91 | "hatch>=1.12.0", 92 | "pytest>=8.3.2", 93 | "pytest-sugar>=1.0.0", 94 | "furo>=2024.8.6", 95 | "sphinx>=8.0.2", 96 | "sphinx-autobuild>=2024.4.16", 97 | "sphinx-autodoc2>=0.5.0", 98 | "sphinx-copybutton>=0.5.2", 99 | "linkify-it-py>=2.0.3", 100 | "myst-nb>=1.1.1", 101 | "myst-parser>=4.0.0", 102 | "jupyterlab>=4.2.5", 103 | "nbmake>=1.5.4", 104 | "nbsphinx>=0.9.5", 105 | "nbconvert>=7.16.4", 106 | "pre-commit>=4.2.0", 107 | ] 108 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Exclude a variety of commonly ignored directories. 2 | exclude = [ 3 | ".bzr", 4 | ".direnv", 5 | ".eggs", 6 | ".git", 7 | ".git-rewrite", 8 | ".hg", 9 | ".ipynb_checkpoints", 10 | ".mypy_cache", 11 | ".nox", 12 | ".pants.d", 13 | ".pyenv", 14 | ".pytest_cache", 15 | ".pytype", 16 | ".ruff_cache", 17 | ".svn", 18 | ".tox", 19 | ".venv", 20 | ".vscode", 21 | "__pypackages__", 22 | "_build", 23 | "buck-out", 24 | "build", 25 | "dist", 26 | "node_modules", 27 | "site-packages", 28 | "venv", 29 | ] 30 | 31 | # Same as Black. 32 | line-length = 95 33 | indent-width = 4 34 | 35 | # Assume Python 3.10 36 | target-version = "py310" 37 | 38 | [lint] 39 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 40 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 41 | # McCabe complexity (`C901`) by default. 42 | select = ["E4", "E7", "E9", "F", "W"] 43 | ignore = ["E731"] 44 | 45 | # Allow fix for all enabled rules (when `--fix`) is provided. 46 | fixable = ["ALL"] 47 | unfixable = [] 48 | 49 | # Allow unused variables when underscore-prefixed. 50 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 51 | 52 | [format] 53 | # Like Black, use double quotes for strings. 54 | quote-style = "double" 55 | 56 | # Like Black, indent with spaces, rather than tabs. 57 | indent-style = "space" 58 | 59 | # Like Black, respect magic trailing commas. 60 | skip-magic-trailing-comma = false 61 | 62 | # Like Black, automatically detect the appropriate line ending. 63 | line-ending = "auto" 64 | 65 | # Enable auto-formatting of code examples in docstrings. Markdown, 66 | # reStructuredText code/literal blocks and doctests are all supported. 67 | # 68 | # This is currently disabled by default, but it is planned for this 69 | # to be opt-out in the future. 70 | docstring-code-format = true 71 | 72 | # Set the line length limit used when formatting code snippets in 73 | # docstrings. 74 | # 75 | # This only has an effect when the `docstring-code-format` setting is 76 | # enabled. 77 | docstring-code-line-length = "dynamic" 78 | -------------------------------------------------------------------------------- /src/autoeis/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rich.console import Console 4 | from rich.logging import RichHandler 5 | 6 | from .utils import Settings as _Settings 7 | 8 | 9 | def _setup_logger(): 10 | """Sets up logging using ``rich``.""" 11 | log = logging.getLogger("autoeis") 12 | 13 | if log.hasHandlers(): 14 | log.critical("Logging already set up.") 15 | return 16 | 17 | log.setLevel(logging.WARNING) 18 | console = Console(force_jupyter=False) 19 | handler = RichHandler( 20 | rich_tracebacks=True, console=console, show_path=not config.notebook 21 | ) 22 | handler.setFormatter(logging.Formatter("%(message)s", datefmt="[%X]")) 23 | log.addHandler(handler) 24 | 25 | 26 | config = _Settings() 27 | _setup_logger() 28 | 29 | from . import core, io, metrics, parser, utils, visualization # noqa: F401, E402 30 | from .core import * # noqa: E402 31 | from .version import __equivalent_circuits_jl_version__, __version__ # noqa: F401, E402 32 | from .visualization import rich_print # noqa: F401, E402 33 | -------------------------------------------------------------------------------- /src/autoeis/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import autoeis_installer 2 | 3 | if __name__ == "__main__": 4 | autoeis_installer(prog_name="autoeis") 5 | -------------------------------------------------------------------------------- /src/autoeis/assets/battery_data.npy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AUTODIAL/AutoEIS/38ba0e5c26204a47a48b03ea24c16341f44189c5/src/autoeis/assets/battery_data.npy -------------------------------------------------------------------------------- /src/autoeis/assets/circuits_filtered.csv: -------------------------------------------------------------------------------- 1 | circuitstring,Parameters 2 | "[P1,R2]-R3","{'P1w': 1.986654419964975e-06, 'P1n': 0.9373079958623047, 'R2': 4629850.953391226, 'R3': 139.14713061653163}" 3 | "R1-P2-[P3,R4]","{'R1': 130.21245496329337, 'P2w': 2.726518813253607e-06, 'P2n': 0.8681492329761771, 'P3w': 898179842.3135369, 'P3n': 0.8671955810406204, 'R4': 109781191.8272061}" 4 | "P1-R2-[P3,R4]-[L5,R6]","{'P1w': 3.060047763952672e-05, 'P1n': 0.8656671998064308, 'R2': 139.68410290307114, 'P3w': 2.0355768962488757e-06, 'P3n': 0.9521824823343752, 'R4': 3756228.5668118354, 'L5': 3.992901235637166e-20, 'R6': 686733089.0863287}" 5 | "R1-[R2-L3,P4]","{'R1': 139.14713070316256, 'R2': 4629850.951461096, 'L3': 4.16089662136513e-09, 'P4w': 1.9866544220375024e-06, 'P4n': 0.9373079958415903}" 6 | "R1-[R2-[R3,P4],[L5,R6]-P7]","{'R1': 141.23906880866465, 'R2': 2757765.1015891605, 'R3': 1499999987.9777336, 'P4w': 2.2882416023531487e-06, 'P4n': 0.4017278612838408, 'L5': 1.0269190149187605e-16, 'R6': 834372380.7853769, 'P7w': 1.817407466396406e-06, 'P7n': 0.954905888771501}" 7 | "P1-[P2,R3]-L4-R5","{'P1w': 3.060047791227673e-05, 'P1n': 0.8656672001651722, 'P2w': 2.0355768949833594e-06, 'P2n': 0.9521824822597936, 'R3': 3756228.5791262547, 'L4': 6.94937958919136e-21, 'R5': 139.68410298946637}" 8 | "[P1,P2]-[P3,R4]-R5","{'P1w': 1.4706292096441107e-06, 'P1n': 0.34376367445689265, 'P2w': 4.654516669629682e-06, 'P2n': 0.999999985635303, 'P3w': 2.8272405794492478e-06, 'P3n': 0.9398241617577261, 'R4': 2206492.89891646, 'R5': 141.32371538045382}" 9 | "R1-[R2,P3-R4]","{'R1': 35.07220572671824, 'R2': 4629955.027337872, 'P3w': 1.986565107637467e-06, 'P3n': 0.9373079957841405, 'R4': 104.07726417290927}" 10 | "P1-R2-[P3,R4]-R5","{'P1w': 2.7265188153219155e-06, 'P1n': 0.868149232948759, 'R2': 77.43663917559485, 'P3w': 452987714.04031914, 'P3n': 0.8651039170000835, 'R4': 390587055.2351061, 'R5': 52.77581578101956}" 11 | "L1-[P2,R3]-[R4,L5]-P6-R7","{'L1': 7.336357952082608e-20, 'P2w': 2.035576896057408e-06, 'P2n': 0.9521824825733203, 'R3': 3756228.5744548813, 'R4': 862734206.1976002, 'L5': 4.4767342105739886e-20, 'P6w': 3.0600477686348286e-05, 'P6n': 0.8656671995057185, 'R7': 139.68410278904608}" 12 | "[P1,P2-R3]-R4","{'P1w': 1.8172983928967454e-06, 'P1n': 0.9549161213807512, 'P2w': 2.275503938969737e-06, 'P2n': 0.4002444280286115, 'R3': 2753990.312617316, 'R4': 141.2402327553605}" 13 | "R1-[P2-R3,[L4,R5]-P6]","{'R1': 141.24023286281513, 'P2w': 2.27550393232863e-06, 'P2n': 0.4002444276409079, 'R3': 2753990.3122879867, 'L4': 9.778577835323835e-21, 'R5': 701831455.1908306, 'P6w': 1.8172983936370922e-06, 'P6n': 0.9549161213477311}" 14 | "R1-[R2,P3-L4]","{'R1': 139.14713051318796, 'R2': 4629850.9504917, 'P3w': 1.986654421152103e-06, 'P3n': 0.9373079958117277, 'L4': 4.579783278040225e-21}" 15 | "[P1,P2-[R3,L4]]-R5","{'P1w': 9.503614832162238, 'P1n': 1.0, 'P2w': 932102500.0769538, 'P2n': 0.99999999985139, 'R3': 999999997.5006131, 'L4': 4.561727426090469, 'R5': 3224022.358994354}" 16 | "R1-[P2,[R3,P4]]","{'R1': 142.06322312672248, 'P2w': 1.7374203222790718e-06, 'P2n': 0.9624345575203931, 'R3': 999999999.9999995, 'P4w': 4.0628439493973436e-07, 'P4n': 0.1667769437233244}" 17 | -------------------------------------------------------------------------------- /src/autoeis/assets/test_data.txt: -------------------------------------------------------------------------------- 1 | freq/Hz Re(Z)/Ohm -Im(Z)/Ohm |Z|/Ohm Phase(Z)/deg time/s /V /mA 2 | 2.0001948E+005 1.3041710E+002 3.4680012E+001 1.3494934E+002 -1.4891262E+001 1.060235877037526E+004 -4.0435545E-002 -9.9461863E-004 3 | 1.4971677E+005 1.3415704E+002 2.7274202E+001 1.3690140E+002 -1.1491652E+001 1.060273376095307E+004 -4.0460460E-002 -7.4689457E-004 4 | 1.1207029E+005 1.3640355E+002 2.1577759E+001 1.3809970E+002 -8.9891653E+000 1.060323275020346E+004 -4.0456451E-002 -7.2788144E-004 5 | 8.3886695E+004 1.3778223E+002 1.7275707E+001 1.3886105E+002 -7.1466866E+000 1.060360777374124E+004 -4.0450379E-002 -7.8529207E-004 6 | 6.2792953E+004 1.3855463E+002 1.4341043E+001 1.3929483E+002 -5.9093351E+000 1.060398176706571E+004 -4.0451832E-002 -8.1735285E-004 7 | 4.7011707E+004 1.3911638E+002 1.2488245E+001 1.3967578E+002 -5.1295972E+000 1.060435675903014E+004 -4.0456861E-002 -8.0188201E-004 8 | 3.5185535E+004 1.3957651E+002 1.1539886E+001 1.4005273E+002 -4.7263427E+000 1.060473176272796E+004 -4.0454011E-002 -7.9927233E-004 9 | 2.6337883E+004 1.3994380E+002 1.1390005E+001 1.4040656E+002 -4.6530385E+000 1.060510676431912E+004 -4.0449407E-002 -8.1884407E-004 10 | 1.9716791E+004 1.4040123E+002 1.2181064E+001 1.4092865E+002 -4.9585056E+000 1.060548177159025E+004 -4.0451050E-002 -8.1828487E-004 11 | 1.4755859E+004 1.4087042E+002 1.3879620E+001 1.4155254E+002 -5.6270518E+000 1.060585676907474E+004 -4.0452633E-002 -8.2536775E-004 12 | 1.1044919E+004 1.4145625E+002 1.6460274E+001 1.4241072E+002 -6.6372590E+000 1.060635576128511E+004 -4.0457122E-002 -8.4754941E-004 13 | 8.4128223E+003 1.4214565E+002 1.9882139E+001 1.4352939E+002 -7.9623947E+000 1.060678597871889E+004 -4.0591940E-002 -8.2648644E-004 14 | 6.2992124E+003 1.4297421E+002 2.4846706E+001 1.4511714E+002 -9.8586588E+000 1.060733093413140E+004 -4.0589053E-002 -8.0467958E-004 15 | 4.6335156E+003 1.4390370E+002 3.1965317E+001 1.4741118E+002 -1.2523768E+001 1.060820247777287E+004 -4.0590171E-002 -8.5575104E-004 16 | 3.4688860E+003 1.4497437E+002 4.0939404E+001 1.5064395E+002 -1.5769184E+001 1.060885548737721E+004 -4.0599115E-002 -8.6656213E-004 17 | 2.5974958E+003 1.4603300E+002 5.3045052E+001 1.5536865E+002 -1.9963053E+001 1.060950521255471E+004 -4.0591531E-002 -9.1502559E-004 18 | 1.9443779E+003 1.4732561E+002 6.9020302E+001 1.6269185E+002 -2.5102533E+001 1.061015847202577E+004 -4.0590692E-002 -8.8221929E-004 19 | 1.4554634E+003 1.4896750E+002 9.0314140E+001 1.7420665E+002 -3.1227068E+001 1.061102733404037E+004 -4.0597569E-002 -8.8818400E-004 20 | 1.0889545E+003 1.5086800E+002 1.1855865E+002 1.9187836E+002 -3.8161808E+001 1.061168061244470E+004 -4.0591978E-002 -8.6730800E-004 21 | 8.1525739E+002 1.5338699E+002 1.5607516E+002 2.1883104E+002 -4.5497692E+001 1.061233235639569E+004 -4.0591065E-002 -8.4885472E-004 22 | 6.1019507E+002 1.5675284E+002 2.0560303E+002 2.5854218E+002 -5.2677910E+001 1.061298435479999E+004 -4.0588327E-002 -9.0421538E-004 23 | 4.5690088E+002 1.6092087E+002 2.7135919E+002 3.1548587E+002 -5.9331299E+001 1.061409658026614E+004 -4.0594678E-002 -7.3313553E-005 24 | 3.4207999E+002 1.6647746E+002 3.5770621E+002 3.9454843E+002 -6.5042603E+001 1.061474419693684E+004 -4.0597253E-002 -7.1317132E-005 25 | 2.5606528E+002 1.7377625E+002 4.7194934E+002 5.0292581E+002 -6.9785812E+001 1.061539457998113E+004 -4.0596843E-002 -7.5683842E-005 26 | 1.9165575E+002 1.8360539E+002 6.2290924E+002 6.4940503E+002 -7.3576874E+001 1.061604270089191E+004 -4.0599115E-002 -7.4825941E-005 27 | 1.4348476E+002 1.9632477E+002 8.2200745E+002 8.4512701E+002 -7.6567360E+001 1.061670253430991E+004 -4.0590581E-002 -7.4882046E-005 28 | 1.0737106E+002 2.1326970E+002 1.0851769E+003 1.1059353E+003 -7.8881371E+001 1.061734101596690E+004 -4.0605396E-002 -7.4415708E-005 29 | 8.0385803E+001 2.3603098E+002 1.4336805E+003 1.4529799E+003 -8.0651093E+001 1.061799143573114E+004 -4.0612921E-002 -7.6505785E-005 30 | 6.0173271E+001 2.6537320E+002 1.8921530E+003 1.9106715E+003 -8.2016380E+001 1.061864183376200E+004 -4.0605005E-002 -7.9360791E-005 31 | 4.5043228E+001 3.0579886E+002 2.4996538E+003 2.5182896E+003 -8.3025291E+001 1.061954023572465E+004 -4.0599562E-002 1.6005855E-005 32 | 3.3716110E+001 3.6031354E+002 3.3010442E+003 3.3206504E+003 -8.3770744E+001 1.062021501359669E+004 -4.0602617E-002 1.2039787E-005 33 | 2.5234146E+001 4.3233145E+002 4.3548379E+003 4.3762456E+003 -8.4330475E+001 1.062086255402750E+004 -4.0604930E-002 1.0398163E-005 34 | 1.8893555E+001 5.3273950E+002 5.7556865E+003 5.7802886E+003 -8.4711838E+001 1.062155015331344E+004 -4.0607322E-002 1.1430669E-005 35 | 1.4135986E+001 6.6984698E+002 7.5969028E+003 7.6263770E+003 -8.4961052E+001 1.062223865001285E+004 -4.0596414E-002 1.2125528E-005 36 | 1.0586008E+001 8.4992682E+002 1.0017949E+004 1.0053938E+004 -8.5150620E+001 1.062285835401565E+004 -4.0598333E-002 1.2588146E-005 37 | 7.9233942E+000 1.1035782E+003 1.3215479E+004 1.3261478E+004 -8.5226509E+001 1.062396486513485E+004 -4.0596135E-002 1.9567002E-005 38 | 5.9320397E+000 1.4642717E+003 1.7422945E+004 1.7484367E+004 -8.5195999E+001 1.062452397185471E+004 -4.0600419E-002 1.8486064E-005 39 | 4.4389215E+000 1.9544601E+003 2.2996092E+004 2.3078998E+004 -8.5142052E+001 1.062525171506265E+004 -4.0596377E-002 1.9493671E-005 40 | 3.3244710E+000 2.6105586E+003 3.0248844E+004 3.0361283E+004 -8.5067436E+001 1.062620330732119E+004 -4.0591262E-002 1.9928868E-005 41 | 2.4872644E+000 3.6201074E+003 3.9883594E+004 4.0047551E+004 -8.4813652E+001 1.062745759652741E+004 -4.0592488E-002 1.9572613E-005 42 | 1.8623395E+000 4.9318779E+003 5.2506844E+004 5.2737957E+004 -8.4634048E+001 1.062969853210053E+004 -4.0601045E-002 1.9966785E-005 43 | 1.3933479E+000 6.8752324E+003 6.9083320E+004 6.9424594E+004 -8.4316589E+001 1.063189746095164E+004 -4.0614765E-002 2.0064368E-005 44 | 1.0433345E+000 9.7894717E+003 9.0776766E+004 9.1303094E+004 -8.3844940E+001 1.063481936210376E+004 -4.0598035E-002 2.0129652E-005 45 | 7.8156585E-001 1.4103699E+004 1.1917555E+005 1.2000719E+005 -8.3250786E+001 1.063870443962162E+004 -4.0596291E-002 2.0158750E-005 46 | 5.8476776E-001 2.0709209E+004 1.5605933E+005 1.5742739E+005 -8.2440964E+001 1.064388251285424E+004 -4.0591829E-002 1.9872046E-005 47 | 4.3787137E-001 3.0738281E+004 2.0407980E+005 2.0638170E+005 -8.1434555E+001 1.065078148627526E+004 -4.0589634E-002 2.0033691E-005 48 | 3.2773316E-001 4.5855754E+004 2.6687266E+005 2.7078363E+005 -8.0250282E+001 1.065998510196566E+004 -4.0598374E-002 2.0084613E-005 49 | 2.4519041E-001 6.9076125E+004 3.4753972E+005 3.5433794E+005 -7.8758537E+001 1.067227384862938E+004 -4.0597517E-002 2.0063970E-005 50 | 1.8356889E-001 1.0469199E+005 4.4939025E+005 4.6142391E+005 -7.6886017E+001 1.068867494503508E+004 -4.0595997E-002 2.0051477E-005 51 | 1.3737231E-001 1.5923222E+005 5.7648756E+005 5.9807425E+005 -7.4559258E+001 1.071057860860878E+004 -4.0595096E-002 2.0007825E-005 52 | 1.0286123E-001 2.4125455E+005 7.3039863E+005 7.6921125E+005 -7.1721336E+001 1.073981817005092E+004 -4.0587906E-002 1.9945475E-005 53 | 7.7023357E-002 3.6295791E+005 9.1117075E+005 9.8080100E+005 -6.8280510E+001 1.077885330233169E+004 -4.0593203E-002 1.9853838E-005 54 | 5.7640672E-002 5.3802813E+005 1.1125024E+006 1.2357734E+006 -6.4190674E+001 1.083100201414188E+004 -4.0597111E-002 1.9719819E-005 55 | 4.3156743E-002 7.7712050E+005 1.3177415E+006 1.5298231E+006 -5.9470589E+001 1.090063974414286E+004 -4.0596895E-002 1.9543148E-005 56 | 3.2296106E-002 1.0864059E+006 1.5066908E+006 1.8575238E+006 -5.4206348E+001 1.099368277797551E+004 -4.0601477E-002 1.9312400E-005 57 | 2.4171919E-002 1.4464823E+006 1.6560205E+006 2.1987985E+006 -4.8863800E+001 1.111798482526618E+004 -4.0594768E-002 1.9025645E-005 58 | 1.8095449E-002 1.8417479E+006 1.7455986E+006 2.5375480E+006 -4.3464706E+001 1.128401506408554E+004 -4.0576864E-002 1.8644510E-005 59 | 1.3546422E-002 2.2373933E+006 1.7838929E+006 2.8615035E+006 -3.8565655E+001 1.150578577837913E+004 -4.0591758E-002 1.8159395E-005 60 | 1.0138676E-002 2.6157598E+006 1.7742379E+006 3.1607150E+006 -3.4148567E+001 1.180208381933247E+004 -4.0594667E-002 1.7505585E-005 61 | 7.5937808E-003 2.9585928E+006 1.7555944E+006 3.4402590E+006 -3.0684399E+001 1.219766871406842E+004 -4.0597528E-002 1.6762518E-005 62 | 5.6843013E-003 3.2879413E+006 1.7526183E+006 3.7258863E+006 -2.8059591E+001 1.272612766799558E+004 -4.0597420E-002 1.5911364E-005 63 | 4.2551756E-003 3.5772373E+006 1.7778500E+006 3.9946685E+006 -2.6426903E+001 1.343206003496551E+004 -4.0598158E-002 1.4855485E-005 64 | 3.1851381E-003 3.8912310E+006 1.8128473E+006 4.2927955E+006 -2.4979877E+001 1.437513482034585E+004 -4.0596075E-002 1.3775185E-005 65 | 2.3847669E-003 4.1240078E+006 1.9078368E+006 4.5439280E+006 -2.4826071E+001 1.563470947531908E+004 -4.0595856E-002 1.2261165E-005 66 | 1.7846748E-003 4.4432370E+006 2.0005125E+006 4.8728230E+006 -2.4239067E+001 1.731780379976856E+004 -4.0598430E-002 1.0719642E-005 67 | 1.3360009E-003 4.7796325E+006 2.1489195E+006 5.2404905E+006 -2.4208651E+001 1.956611522341089E+004 -4.0601213E-002 9.2869222E-006 68 | 9.9990517E-004 5.1702405E+006 2.3457755E+006 5.6775040E+006 -2.4404139E+001 2.257014876454812E+004 -4.0601943E-002 8.0679802E-006 69 | -------------------------------------------------------------------------------- /src/autoeis/cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import click 4 | 5 | from .julia_helpers import install_backend, install_julia 6 | 7 | 8 | @click.group("autoeis") 9 | @click.pass_context 10 | def autoeis_installer(context): 11 | ctx = context 12 | 13 | 14 | @click.option( 15 | "--ec-path", 16 | default=None, 17 | type=str, 18 | help="Installs a local copy of EquivalentCircuits instead of the remote version.", 19 | ) 20 | @click.option( 21 | "--verbose", 22 | is_flag=True, 23 | help="Prints the installation process to the console.", 24 | ) 25 | @autoeis_installer.command("install", help="Install Julia dependencies for AutoEIS.") 26 | def install_cli(ec_path, verbose): 27 | if ec_path is not None: 28 | # Expand ~ in path if present 29 | ec_path = Path(ec_path).expanduser() 30 | install_julia(quiet=not verbose) 31 | install_backend(ec_path=ec_path, quiet=not verbose) 32 | -------------------------------------------------------------------------------- /src/autoeis/cli_pyjulia.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from autoeis.julia_helpers import install 4 | 5 | 6 | @click.group("autoeis") 7 | @click.pass_context 8 | def autoeis_installer(context): 9 | ctx = context 10 | 11 | 12 | @autoeis_installer.command("install", help="Install Julia dependencies for AutoEIS.") 13 | @click.option( 14 | "-p", 15 | "julia_project", 16 | "--project", 17 | default=None, 18 | type=str, 19 | help="Install in a specific Julia project (e.g., a local copy of EquivalentProject.jl).", 20 | metavar="PROJECT_DIRECTORY", 21 | ) 22 | @click.option("-q", "--quiet", is_flag=True, default=False, help="Disable logging.") 23 | @click.option( 24 | "--precompile", 25 | "precompile", 26 | flag_value=True, 27 | default=None, 28 | help="Force precompilation of Julia libraries.", 29 | ) 30 | @click.option( 31 | "--no-precompile", 32 | "precompile", 33 | flag_value=False, 34 | default=None, 35 | help="Disable precompilation.", 36 | ) 37 | def install_cli(julia_project, quiet, precompile): 38 | install(julia_project=julia_project, quiet=quiet, precompile=precompile) 39 | -------------------------------------------------------------------------------- /src/autoeis/io.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of functions for importing and exporting EIS data/results. 3 | 4 | .. currentmodule:: autoeis.io 5 | 6 | .. autosummary:: 7 | :toctree: generated/ 8 | 9 | get_assets_path 10 | load_battery_dataset 11 | load_test_dataset 12 | load_test_circuits 13 | parse_ec_output 14 | 15 | """ 16 | 17 | import logging 18 | import os 19 | from collections.abc import Iterable 20 | from pathlib import Path 21 | 22 | import numpy as np 23 | import pandas as pd 24 | 25 | import autoeis as ae 26 | 27 | log = logging.getLogger(__name__) 28 | 29 | 30 | def get_assets_path() -> Path: 31 | """Returns the path to the assets folder.""" 32 | PATH = Path(ae.__file__).parent / "assets" 33 | return PATH 34 | 35 | 36 | def load_battery_dataset( 37 | preprocess: bool = False, 38 | noise: float = 0, 39 | ) -> list[tuple[np.ndarray[float], np.ndarray[complex]]]: 40 | """Loads EIS data of a battery cell during cycling (at discharged state). 41 | 42 | Parameters 43 | ---------- 44 | preprocess: bool, optional 45 | If True, the impedance data is preprocessed using 46 | :func:`autoeis.core.preprocess_impedance_data`. Default is False. 47 | noise: float, optional 48 | If greater than zero, uniform noise with prescribed amplitude is added 49 | to the impedance data. Default is 0. 50 | 51 | Returns 52 | ------- 53 | list[tuple[np.ndarray[float], np.ndarray[complex]]] 54 | List of tuples of frequency and impedance arrays for each cycle. 55 | """ 56 | PATH = get_assets_path() 57 | fpath = os.path.join(PATH, "battery_data.npy") 58 | data = np.load(fpath) 59 | # Data are stored as complex, convert frequency to float 60 | data = [(freq.real, Z) for freq, Z in data] 61 | if preprocess: 62 | data = [ae.utils.preprocess_impedance_data(freq, Z) for freq, Z in data] 63 | if noise: 64 | # Only add noise to impedance data (opinionated!) 65 | noise_real = [np.random.rand(len(Z)) * Z.real * noise for freq, Z in data] 66 | noise_imag = [np.random.rand(len(Z)) * Z.imag * noise for freq, Z in data] 67 | data = [ 68 | (freq, Z + noise_real[i] + noise_imag[i] * 1j) for i, (freq, Z) in enumerate(data) 69 | ] 70 | return data 71 | 72 | 73 | def load_test_dataset( 74 | preprocess: bool = False, noise: float = 0 75 | ) -> tuple[np.ndarray[float], np.ndarray[complex]]: 76 | """Returns a test dataset as a tuple of frequency and impedance arrays. 77 | 78 | Parameters 79 | ---------- 80 | preprocess: bool, optional 81 | If True, the impedance data is preprocessed using 82 | :func:`autoeis.core.preprocess_impedance_data`. Default is False. 83 | noise: float, optional 84 | If greater than zero, uniform noise with prescribed amplitude is added 85 | to the impedance data. Default is 0. 86 | 87 | Returns 88 | ------- 89 | tuple[np.ndarray[float], np.ndarray[complex]] 90 | Tuple of frequency and impedance data. 91 | """ 92 | PATH = get_assets_path() 93 | fpath = os.path.join(PATH, "test_data.txt") 94 | freq, Zreal, Zimag = np.loadtxt(fpath, skiprows=1, unpack=True, usecols=(0, 1, 2)) 95 | # Convert to complex impedance (the file contains -Im(Z) hence the minus sign) 96 | Z = Zreal - 1j * Zimag 97 | if preprocess: 98 | freq, Z = ae.utils.preprocess_impedance_data(freq, Z) 99 | if noise: 100 | # Only add noise to impedance data (opinionated!) 101 | noise_real = np.random.rand(len(Z)) * Z.real * noise 102 | noise_imag = np.random.rand(len(Z)) * Z.imag * noise 103 | Z += noise_real + noise_imag * 1j 104 | return freq, Z 105 | 106 | 107 | def load_test_circuits(filtered: bool = False) -> pd.DataFrame: 108 | """Returns candidate ECMs fitted to test dataset for testing. 109 | 110 | Parameters 111 | ---------- 112 | filtered: bool, optional 113 | If True, only physically plausible circuits are returned. Default is False. 114 | 115 | Returns 116 | ------- 117 | pd.DataFrame 118 | Dataframe containing the ECMs. 119 | 120 | """ 121 | PATH = get_assets_path() 122 | fname = "circuits_filtered.csv" if filtered else "circuits_unfiltered.csv" 123 | fpath = os.path.join(PATH, fname) 124 | circuits = pd.read_csv(fpath) 125 | # Convert stringified list to proper Python objects 126 | circuits["Parameters"] = circuits["Parameters"].apply(eval) 127 | return circuits 128 | 129 | 130 | def parse_ec_output( 131 | circuits: Iterable[str] | str, ignore_invalid_inputs: bool = True 132 | ) -> pd.DataFrame: 133 | """Parses the output of EquivalentCircuits.jl's ``circuit_evolution``. 134 | 135 | Parameters 136 | ---------- 137 | circuits: Iterable[str] | str 138 | List of stringified output of EquivalentCircuits.jl's 139 | ``circuit_evolution``. A valid input should be in the following format: 140 | 'EquivalentCircuit("R1", (R1 = 1.0,))', or a list of such strings. 141 | 142 | Returns 143 | ------- 144 | pd.DataFrame 145 | Dataframe containing ECMs (cols: "circuitstring" and "Parameters") 146 | """ 147 | 148 | def _validate_input(ec_output: str, raise_error: bool = True): 149 | """Ensures the input string can be parsed into circuit and parameters.""" 150 | ec_output = ec_output.replace(" ", "") 151 | try: 152 | assert len(ec_output.split('",(')) == 2 153 | assert "EquivalentCircuit" in ec_output 154 | except AssertionError: 155 | if raise_error: 156 | raise ValueError(f"Invalid EC output format: {ec_output}.") 157 | return False 158 | return True 159 | 160 | def _split_labels_and_values(ec_output: str) -> tuple[str, dict[str, float]]: 161 | """Splits the circuit string and parameters from the input string.""" 162 | ec_output = ec_output.removeprefix("EquivalentCircuit(").removesuffix(")") 163 | ec_output = ec_output.replace(" ", "") 164 | cstr, pstr = ec_output.split('",(') 165 | cstr = cstr.replace('"', "") 166 | # NOTE: Trailing comma in one-element tuples needs to also be removed 167 | pstr = pstr.replace(")", "").replace("(", "").replace('"', "").rstrip(",").split(",") 168 | # Convert parameters substring to a dict[str, float] 169 | pdict = dict(pair.split("=") for pair in pstr) # dict[str, str] 170 | pdict = {p.split("=")[0]: float(p.split("=")[1]) for p in pstr} # dict[str, float] 171 | return cstr, pdict 172 | 173 | circuits = [circuits] if isinstance(circuits, str) else circuits 174 | parsed = [] 175 | 176 | header = "circuitstring,Parameters" 177 | 178 | for circuit in circuits: 179 | # Validate input format 180 | circuit = circuit.replace(" ", "") 181 | # Skip header if present 182 | if circuit == header: 183 | continue 184 | # Skip invalid inputs if flag is set 185 | if not _validate_input(circuit, raise_error=not ignore_invalid_inputs): 186 | continue 187 | # Finally, parse the circuit and parameters 188 | cstr, pdict = _split_labels_and_values(circuit) 189 | parsed.append([cstr, pdict]) 190 | 191 | return pd.DataFrame(parsed, columns=["circuitstring", "Parameters"]) 192 | -------------------------------------------------------------------------------- /src/autoeis/julia_helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import shutil 4 | from pathlib import Path 5 | 6 | import juliapkg 7 | from juliapkg.deps import can_skip_resolve 8 | from juliapkg.find_julia import find_julia 9 | 10 | from .utils import suppress_output 11 | from .version import __equivalent_circuits_jl_version__ 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | def install_julia(quiet=True): 17 | """Installs Julia using juliapkg.""" 18 | # Importing juliacall automatically installs Julia using juliapkg 19 | if quiet: 20 | with suppress_output(): 21 | import juliacall 22 | else: 23 | import juliacall 24 | 25 | 26 | def install_backend(ec_path: Path = None, quiet=True): 27 | """Installs Julia dependencies for AutoEIS. 28 | 29 | Parameters 30 | ---------- 31 | ec_path : Path, optional 32 | Path to the local copy of EquivalentCircuits. Default is None. If None, 33 | the remote version will be used. 34 | """ 35 | is_julia_installed(error=True) 36 | 37 | # TODO: No longer needed since dependencies are specified in juliapkg.json 38 | # kwargs = {"name": "EquivalentCircuits", "uuid": "da5bd070-f609-4e16-a30d-de86b3faa756"} 39 | # if ec_path is not None: 40 | # kwargs["path"] = str(ec_path) 41 | # kwargs["dev"] = True 42 | # else: 43 | # if __equivalent_circuits_jl_version__.startswith("v"): 44 | # kwargs["version"] = __equivalent_circuits_jl_version__ 45 | # else: 46 | # kwargs["rev"] = __equivalent_circuits_jl_version__ 47 | # kwargs["url"] = "https://github.com/ma-sadeghi/EquivalentCircuits.jl" 48 | # pkg_spec = juliapkg.PkgSpec(**kwargs) 49 | # juliapkg.add(pkg_spec) 50 | 51 | if quiet: 52 | with suppress_output(): 53 | juliapkg.resolve() 54 | else: 55 | juliapkg.resolve() 56 | 57 | 58 | def init_julia(quiet=False): 59 | """Initializes Julia and returns the Main module. 60 | 61 | Raises 62 | ------ 63 | ImportError 64 | If Julia is not installed. 65 | """ 66 | is_julia_installed(error=True) 67 | if not can_skip_resolve(): 68 | log.warning("Julia is installed, but needs to be resolved...") 69 | if quiet: 70 | with suppress_output(): 71 | from juliacall import Main 72 | else: 73 | from juliacall import Main 74 | 75 | return Main 76 | 77 | 78 | def import_package(package_name, Main, error=False): 79 | """Imports a package in Julia and returns the module. 80 | 81 | Parameters 82 | ---------- 83 | package_name : str 84 | Name of the Julia package to import. 85 | Main : juliacall.Main 86 | Julia Main module. 87 | error : bool, optional 88 | If True, raises an error if the package is not found. Default is False. 89 | 90 | Returns 91 | ------- 92 | module 93 | The imported Julia module. 94 | 95 | Raises 96 | ------ 97 | ImportError 98 | If the package is not found and error is True. 99 | """ 100 | from juliacall import JuliaError 101 | 102 | try: 103 | Main.seval(f"using {package_name}") 104 | return eval(f"Main.{package_name}") 105 | except JuliaError as e: 106 | if error: 107 | raise e 108 | return None 109 | 110 | 111 | def import_backend(Main=None): 112 | """Imports EquivalentCircuits package from Julia. 113 | 114 | Parameters 115 | ---------- 116 | Main : juliacall.Main, optional 117 | Julia Main module. Default is None. 118 | 119 | Returns 120 | ------- 121 | module 122 | The imported Julia module. 123 | 124 | Raises 125 | ------ 126 | ImportError 127 | If Julia is not installed or the package is not found. 128 | """ 129 | Main = init_julia() if Main is None else Main 130 | is_backend_installed(Main=Main, error=True) 131 | return import_package("EquivalentCircuits", Main) 132 | 133 | 134 | def is_julia_installed(error=False): 135 | """Asserts that Julia is installed.""" 136 | # Look for system-wide Julia executable 137 | try: 138 | find_julia() 139 | return True 140 | except Exception: 141 | pass 142 | # Look for local Julia executable (e.g., installed by juliapkg) 143 | if can_skip_resolve(): 144 | return True 145 | msg = "Julia not found. Visit https://github.com/JuliaLang/juliaup and install Julia." 146 | if error: 147 | raise ImportError(msg) 148 | return False 149 | 150 | 151 | def is_backend_installed(Main=None, error=False): 152 | """Asserts that EquivalentCircuits.jl is installed. 153 | 154 | Parameters 155 | ---------- 156 | Main : juliacall.Main, optional 157 | Julia Main module. Default is None. If None, the Main module will be 158 | initialized using `init_julia()`. 159 | error : bool, optional 160 | If True, raises an error if the package is not found. Default is False. 161 | install : bool, optional 162 | If True, installs the package if it is not found. Default is False. 163 | 164 | Returns 165 | ------- 166 | bool 167 | True if the package is installed, False otherwise. 168 | 169 | Raises 170 | ------ 171 | ImportError 172 | If Julia is not installed or the package is not found and error is True. 173 | """ 174 | Main = init_julia() if Main is None else Main 175 | if import_package("EquivalentCircuits", Main, error=False) is not None: 176 | return True 177 | msg = "EquivalentCircuits.jl not found, run 'python -m autoeis install'" 178 | if error: 179 | raise ImportError(msg) 180 | return False 181 | 182 | 183 | def ensure_julia_deps_ready(quiet=True, retry=True): 184 | """Ensures Julia and EquivalentCircuits.jl are installed.""" 185 | 186 | def _ensure_julia_deps_ready(quiet): 187 | if not is_julia_installed(error=False): 188 | log.warning("Julia not found, installing Julia...") 189 | install_julia(quiet=quiet) 190 | Main = init_julia(quiet=quiet) 191 | if not is_backend_installed(Main=Main, error=False): 192 | log.warning("Julia dependencies not found, installing EquivalentCircuits.jl...") 193 | install_backend(quiet=quiet) 194 | 195 | def _reset_julia_env(quiet): 196 | remove_julia_env() 197 | if quiet: 198 | with suppress_output(): 199 | juliapkg.resolve(force=True) 200 | else: 201 | juliapkg.resolve(force=True) 202 | 203 | try: 204 | _ensure_julia_deps_ready(quiet) 205 | except Exception: 206 | if retry: 207 | _reset_julia_env(quiet) 208 | _ensure_julia_deps_ready(quiet) 209 | return 210 | raise 211 | 212 | 213 | def remove_julia_env(): 214 | """Removes the active Julia environment directory. 215 | 216 | Notes 217 | ----- 218 | When Julia or its dependencies are corrupted, this is a possible fix. 219 | """ 220 | path_julia_env = Path(juliapkg.project()) 221 | 222 | if path_julia_env.exists(): 223 | log.warning(f"Removing Julia environment directory: {path_julia_env}") 224 | shutil.rmtree(path_julia_env) 225 | else: 226 | log.warning("Julia environment directory not found.") 227 | 228 | 229 | # Lazy loading for jl and ec 230 | class _LazyJuliaRuntime: 231 | def __init__(self): 232 | self._initialized = False 233 | self._jl = None 234 | self._ec = None 235 | 236 | def _init(self): 237 | if not self._initialized: 238 | log.info("Initializing Julia runtime...") 239 | os.environ["PYTHON_JULIACALL_AUTOLOAD_IPYTHON_EXTENSION"] = "no" 240 | ensure_julia_deps_ready(quiet=True) 241 | self._jl = init_julia(quiet=True) 242 | self._ec = import_backend(self._jl) 243 | self._initialized = True 244 | 245 | def get_jl(self): 246 | self._init() 247 | return self._jl 248 | 249 | def get_ec(self): 250 | self._init() 251 | return self._ec 252 | 253 | 254 | class _LazyProxy: 255 | def __init__(self, getter): 256 | self._getter = getter 257 | 258 | def __getattr__(self, name): 259 | return getattr(self._getter(), name) 260 | 261 | def __call__(self, *args, **kwargs): 262 | return self._getter()(*args, **kwargs) 263 | 264 | def __repr__(self): 265 | return repr(self._getter()) 266 | 267 | 268 | _lazy_rt = _LazyJuliaRuntime() 269 | jl = _LazyProxy(_lazy_rt.get_jl) 270 | ec = _LazyProxy(_lazy_rt.get_ec) 271 | -------------------------------------------------------------------------------- /src/autoeis/julia_helpers_pyjulia.py: -------------------------------------------------------------------------------- 1 | # This code is based on the file julia_helpers.py from the PySR project. 2 | # It has been adapted to suit the requirements of AutoEIS. 3 | # The original file is under the Apache 2.0 license. 4 | # Acknowledge the original authors and license when utilizing this code. 5 | # Original repository: https://github.com/MilesCranmer/PySR 6 | # Commit reference: 976f8d8 dated 2023-09-16. 7 | 8 | import importlib 9 | import logging 10 | import os 11 | import subprocess 12 | import sys 13 | from pathlib import Path 14 | 15 | from julia.api import JuliaError 16 | 17 | from .version import __equivalent_circuits_jl_version__, __version__ 18 | 19 | MAX_RETRIES = 2 20 | juliainfo = None 21 | julia_initialized = False 22 | julia_kwargs_at_initialization = None 23 | julia_activated_env = None 24 | 25 | log = logging.getLogger(__name__) 26 | 27 | 28 | # TODO: For virtualenvs see https://github.com/JuliaPy/PyCall.jl?tab=readme-ov-file#python-virtual-environments 29 | def install( 30 | julia_project=None, quiet=False, precompile=None, offline=False 31 | ): # pragma: no cover 32 | """Install all required dependencies for EquivalentCircuits.jl.""" 33 | import julia 34 | 35 | _julia_version_assertion() 36 | # Set JULIA_PROJECT so that we install in the autoeis environment 37 | processed_julia_project, is_shared = _process_julia_project(julia_project) 38 | _set_julia_project_env(processed_julia_project, is_shared) 39 | 40 | if precompile is False: 41 | os.environ["JULIA_PKG_PRECOMPILE_AUTO"] = "0" 42 | 43 | if offline: 44 | os.environ["JULIA_PKG_OFFLINE"] = "true" 45 | 46 | try: 47 | julia.install(quiet=quiet) 48 | except julia.tools.PyCallInstallError: 49 | # Attempt to reset PyCall.jl's build: 50 | subprocess.run( 51 | [ 52 | "julia", 53 | "-e", 54 | f'ENV["PYTHON"] = "{sys.executable}"; import Pkg; Pkg.build("PyCall")', 55 | ], 56 | ) 57 | # Try installing again: 58 | julia.install(quiet=quiet) 59 | 60 | Main, init_log = init_julia(julia_project, quiet=quiet, return_aux=True) 61 | io_arg = _get_io_arg(quiet) 62 | 63 | if precompile is None: 64 | precompile = init_log["compiled_modules"] 65 | 66 | if not precompile: 67 | Main.eval('ENV["JULIA_PKG_PRECOMPILE_AUTO"] = 0') 68 | 69 | if is_shared: 70 | # Install EquivalentCircuits.jl: 71 | _add_ec_to_julia_project(Main, io_arg) 72 | 73 | Main.eval("using Pkg") 74 | Main.eval(f"Pkg.instantiate({io_arg})") 75 | 76 | if precompile: 77 | Main.eval(f"Pkg.precompile({io_arg})") 78 | 79 | if not quiet: 80 | log.warning( 81 | "It is recommended to restart Python after installing AutoEIS " 82 | "dependencies, so that the Julia environment is properly initialized." 83 | ) 84 | 85 | 86 | def init_julia(julia_project=None, quiet=False, julia_kwargs=None, return_aux=False): 87 | """Initialize julia binary, turning off compiled modules if needed.""" 88 | global julia_initialized 89 | global julia_kwargs_at_initialization 90 | global julia_activated_env 91 | 92 | if julia_kwargs is None: 93 | julia_kwargs = {"optimize": 3} 94 | 95 | from julia.core import JuliaInfo, UnsupportedPythonError 96 | 97 | _julia_version_assertion() 98 | processed_julia_project, is_shared = _process_julia_project(julia_project) 99 | _set_julia_project_env(processed_julia_project, is_shared) 100 | 101 | try: 102 | info = JuliaInfo.load(julia="julia") 103 | except FileNotFoundError: 104 | _raise_julia_not_found() 105 | 106 | if not info.is_pycall_built(): 107 | _raise_import_error() 108 | 109 | from julia.core import Julia 110 | 111 | try: 112 | Julia(**julia_kwargs) 113 | except UnsupportedPythonError: 114 | # HACK: When switching virtualenvs, we also get UnsupportedPythonError. 115 | # Check if it resolves by recompiling PyCall, if not turn off compiled_modules 116 | _recompile_pycall() 117 | try: 118 | Julia(**julia_kwargs) 119 | except UnsupportedPythonError: 120 | # Static python binary, so we turn off pre-compiled modules. 121 | julia_kwargs = {**julia_kwargs, "compiled_modules": False} 122 | Julia(**julia_kwargs) 123 | log.warning( 124 | "Your system's Python library is static (e.g., conda), " 125 | "so precompilation will be turned off. For a dynamic library, " 126 | "try using `pyenv` and installing with `--enable-shared`: " 127 | "https://github.com/pyenv/pyenv/blob/master/plugins/python-build/README.md" 128 | ) 129 | 130 | using_compiled_modules = ("compiled_modules" not in julia_kwargs) or julia_kwargs[ 131 | "compiled_modules" 132 | ] 133 | 134 | from julia import Main as _Main 135 | 136 | Main = _Main 137 | 138 | if julia_activated_env is None: 139 | julia_activated_env = processed_julia_project 140 | 141 | if julia_initialized and julia_kwargs_at_initialization is not None: 142 | # Check if the kwargs are the same as the previous initialization 143 | init_set = set(julia_kwargs_at_initialization.items()) 144 | new_set = set(julia_kwargs.items()) 145 | set_diff = new_set - init_set 146 | # Remove the `compiled_modules` key, since it is not a user-specified kwarg: 147 | set_diff = {k: v for k, v in set_diff if k != "compiled_modules"} 148 | if len(set_diff) > 0: 149 | log.warning( 150 | f"Julia has already started. The new Julia options {set_diff} " 151 | "will be ignored." 152 | ) 153 | 154 | if julia_initialized and julia_activated_env != processed_julia_project: 155 | Main.eval("using Pkg") 156 | 157 | io_arg = _get_io_arg(quiet) 158 | # Can't pass IO to Julia call as it evaluates to PyObject, so just directly 159 | # use Main.eval: 160 | Main.eval( 161 | f'Pkg.activate("{_escape_filename(processed_julia_project)}",' 162 | f"shared = Bool({int(is_shared)}), " 163 | f"{io_arg})" 164 | ) 165 | 166 | julia_activated_env = processed_julia_project 167 | 168 | if not julia_initialized: 169 | julia_kwargs_at_initialization = julia_kwargs 170 | 171 | julia_initialized = True 172 | if return_aux: 173 | return Main, {"compiled_modules": using_compiled_modules} 174 | return Main 175 | 176 | 177 | def import_backend(Main): 178 | """Load EquivalentCircuits.jl, verify version and return a reference.""" 179 | ec = import_package("EquivalentCircuits", Main=Main) 180 | # FIXME: Currently don't know how to assert branch name if installed from GitHub 181 | if __equivalent_circuits_jl_version__.startswith("v"): 182 | _backend_version_assertion(Main) 183 | return ec 184 | 185 | 186 | def import_package(pkg_name, Main): 187 | """Load a Julia package and return a reference to it.""" 188 | # HACK: On Windows, for some reason, the first two imports fail! 189 | for _ in range(MAX_RETRIES): 190 | try: 191 | Main.eval(f"using {pkg_name}") 192 | break 193 | except Exception: 194 | pass 195 | else: 196 | # Import failed, raise proper error 197 | try: 198 | Main.eval(f"using {pkg_name}") 199 | except (JuliaError, RuntimeError) as e: 200 | _raise_import_error(root=e) 201 | return importlib.import_module(f"julia.{pkg_name}") 202 | 203 | 204 | def add_to_path(path): 205 | if not os.path.exists(path): 206 | raise ValueError(f"The provided path '{path}' does not exist.") 207 | 208 | if path not in os.environ["PATH"].split(os.pathsep): 209 | os.environ["PATH"] += os.pathsep + path 210 | 211 | 212 | def _recompile_pycall(): 213 | import julia 214 | 215 | try: 216 | os.environ["PYCALL_JL_RUNTIME_PYTHON"] = sys.executable 217 | julia.install(quiet=True) 218 | except julia.tools.PyCallInstallError: 219 | pass 220 | 221 | 222 | def _raise_import_error(root: Exception = None): 223 | """Raise ImportError if Julia dependencies are not installed.""" 224 | raise ImportError( 225 | "Required dependencies are not installed or built. Run the " 226 | "following command: import autoeis; autoeis.julia_helpers.install()." 227 | ) from root 228 | 229 | 230 | def _raise_julia_not_found(root: Exception = None): 231 | """Raise FileNotFoundError if Julia is not installed.""" 232 | raise FileNotFoundError( 233 | "Julia is not installed in your PATH. Please install Julia " 234 | "and add it to your PATH." 235 | ) from root 236 | 237 | 238 | def _load_juliainfo(): 239 | """Execute julia.core.JuliaInfo.load(), and store as juliainfo.""" 240 | global juliainfo 241 | 242 | if juliainfo is None: 243 | from julia.core import JuliaInfo 244 | 245 | try: 246 | juliainfo = JuliaInfo.load(julia="julia") 247 | except FileNotFoundError: 248 | _raise_julia_not_found() 249 | 250 | return juliainfo 251 | 252 | 253 | def _get_julia_env_dir(): 254 | """Find the Julia environments' directory.""" 255 | try: 256 | julia_env_dir_str = subprocess.run( 257 | ["julia", "-e using Pkg; print(Pkg.envdir())"], 258 | capture_output=True, 259 | env=os.environ, 260 | ).stdout.decode() 261 | except FileNotFoundError: 262 | env_path = os.environ["PATH"] 263 | _raise_julia_not_found() 264 | return Path(julia_env_dir_str) 265 | 266 | 267 | def _set_julia_project_env(julia_project, is_shared): 268 | """Set JULIA_PROJECT environment variable.""" 269 | if is_shared: 270 | if _is_julia_version_greater_eq(version=(1, 7, 0)): 271 | os.environ["JULIA_PROJECT"] = "@" + str(julia_project) 272 | else: 273 | julia_env_dir = _get_julia_env_dir() 274 | os.environ["JULIA_PROJECT"] = str(julia_env_dir / julia_project) 275 | else: 276 | os.environ["JULIA_PROJECT"] = str(julia_project) 277 | 278 | 279 | def _get_io_arg(quiet): 280 | """Return Julia-compatible IO arg that suppresses output if quiet=True.""" 281 | io = "devnull" if quiet else "stderr" 282 | io_arg = f"io={io}" if _is_julia_version_greater_eq(version=(1, 6, 0)) else "" 283 | return io_arg 284 | 285 | 286 | def _process_julia_project(julia_project): 287 | if julia_project is None: 288 | is_shared = True 289 | processed_julia_project = f"autoeis-{__version__}" 290 | elif julia_project[0] == "@": 291 | is_shared = True 292 | processed_julia_project = julia_project[1:] 293 | else: 294 | is_shared = False 295 | processed_julia_project = Path(julia_project) 296 | return processed_julia_project, is_shared 297 | 298 | 299 | def _is_julia_version_greater_eq(juliainfo=None, version=(1, 6, 0)): 300 | """Check if Julia version is greater than specified version.""" 301 | if juliainfo is None: 302 | juliainfo = _load_juliainfo() 303 | current_version = ( 304 | juliainfo.version_major, 305 | juliainfo.version_minor, 306 | juliainfo.version_patch, 307 | ) 308 | return current_version >= version 309 | 310 | 311 | def _add_ec_to_julia_project(Main, io_arg): 312 | """Install EquivalentCircuits.jl and dependencies to the Julia project.""" 313 | Main.eval("using Pkg") 314 | Main.eval(f"Pkg.Registry.update({io_arg})") 315 | kwargs = {"name": "EquivalentCircuits"} 316 | if __equivalent_circuits_jl_version__.startswith("v"): 317 | kwargs["version"] = __equivalent_circuits_jl_version__ 318 | else: 319 | kwargs["rev"] = __equivalent_circuits_jl_version__ 320 | Main.ec_spec = Main.PackageSpec(**kwargs) 321 | Main.eval(f"Pkg.add([ec_spec], {io_arg})") 322 | 323 | 324 | def _escape_filename(filename): 325 | """Turn a path into a string with correctly escaped backslashes.""" 326 | str_repr = str(filename) 327 | str_repr = str_repr.replace("\\", "\\\\") 328 | return str_repr 329 | 330 | 331 | def _julia_version_assertion(): 332 | """Check if Julia version is greater than 1.9""" 333 | if not _is_julia_version_greater_eq(version=(1, 9, 0)): 334 | raise NotImplementedError( 335 | "AutoEIS requires Julia 1.9.0 or greater. " 336 | "Please update your Julia installation." 337 | ) 338 | 339 | 340 | def _backend_version_assertion(Main): 341 | """Check if EquivalentCircuits.jl version is correct.""" 342 | try: 343 | backend_version = Main.eval("string(pkgversion(EquivalentCircuits))") 344 | expected_backend_version = __equivalent_circuits_jl_version__ 345 | if backend_version != expected_backend_version: # pragma: no cover 346 | log.warning( 347 | f"AutoEIS backend (EquivalentCircuits.jl) version {backend_version} " 348 | f"does not match expected version {expected_backend_version}. " 349 | "Things may break. Please update your AutoEIS installation with " 350 | "`import autoeis; autoeis.julia_helpers.install()`." 351 | ) 352 | except JuliaError: # pragma: no cover 353 | log.warning( 354 | "You seem to have an outdated version of EquivalentCircuits.jl. " 355 | "Things may break. Please update your AutoEIS installation with " 356 | "`import autoeis; autoeis.julia_helpers.install()`." 357 | ) 358 | 359 | 360 | def _update_julia_project(Main, is_shared, io_arg): 361 | try: 362 | if is_shared: 363 | _add_ec_to_julia_project(Main, io_arg) 364 | Main.eval("using Pkg") 365 | Main.eval(f"Pkg.resolve({io_arg})") 366 | except (JuliaError, RuntimeError) as e: 367 | _raise_import_error(root=e) 368 | -------------------------------------------------------------------------------- /src/autoeis/juliapkg.json: -------------------------------------------------------------------------------- 1 | { 2 | "julia": "=1.10.0", 3 | "packages": { 4 | "EquivalentCircuits": { 5 | "uuid": "da5bd070-f609-4e16-a30d-de86b3faa756", 6 | "url": "https://github.com/ma-sadeghi/EquivalentCircuits.jl", 7 | "rev": "master" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/autoeis/metrics.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of functions for calculating metrics. 3 | 4 | .. currentmodule:: autoeis.metrics 5 | 6 | .. autosummary:: 7 | :toctree: generated/ 8 | 9 | mape_score 10 | mse_score 11 | rmse_score 12 | r2_score 13 | 14 | """ 15 | 16 | import numpy as np 17 | 18 | 19 | def _assert_compatible_shapes(y_true: np.ndarray, y_pred: np.ndarray, axis: int): 20 | # y_true must be 1D, but y_pred can be ND (ND y_pred is complicated) 21 | assert y_true.squeeze().ndim == 1, "y_true must be 1D." 22 | # This is not necessary (numpy catches it), but it's a good sanity check 23 | msg = ( 24 | f"y_pred {y_pred.shape} is incompatibe with y_true {y_true.shape} " 25 | f"given axis = {axis}" 26 | ) 27 | assert y_true.shape[axis] == y_pred.shape[axis], msg 28 | 29 | 30 | def _reshape_given_axis(y_true: np.ndarray, y_pred: np.ndarray, axis: int): 31 | y_true = y_true.squeeze() 32 | # For broadcasting to work correctly, ensure y_true is expanded in the correct axis 33 | other_axes = [i for i in range(y_pred.ndim) if i != axis] 34 | y_true = np.expand_dims(y_true, axis=other_axes) 35 | return y_true 36 | 37 | 38 | def mape_score(y_true: np.ndarray, y_pred: np.ndarray, axis=0): 39 | """ 40 | Calculates the generalized MAPE (Mean Absolute Percentage Error) score. 41 | 42 | Parameters 43 | ---------- 44 | y_true : np.ndarray 45 | Ground truth (true) values. 46 | y_pred : np.ndarray 47 | Predicted values. 48 | axis : int, optional 49 | Axis along which to calculate the MAPE score. Default is 0. 50 | 51 | Returns 52 | ------- 53 | float 54 | The MAPE score as a percentage. 55 | 56 | Notes 57 | ----- 58 | This function handles complex numbers in the input arrays. 59 | """ 60 | y_true = _reshape_given_axis(y_true, y_pred, axis) 61 | _assert_compatible_shapes(y_true, y_pred, axis) 62 | # NOTE: abs is needed to handle complex numbers 63 | return np.mean(np.abs((y_true - y_pred) / y_true), axis=axis) * 100 64 | 65 | 66 | def mse_score(y_true: np.ndarray, y_pred: np.ndarray, axis=0): 67 | """ 68 | Calculates the generalized MSE (Mean Squared Error) score. 69 | 70 | Parameters 71 | ---------- 72 | y_true : np.ndarray 73 | Ground truth (true) values. 74 | y_pred : np.ndarray 75 | Predicted values. 76 | axis : int, optional 77 | Axis along which to calculate the MSE score. Default is 0. 78 | 79 | Returns 80 | ------- 81 | float 82 | The MSE score. 83 | 84 | Notes 85 | ----- 86 | This function handles complex numbers in the input arrays. 87 | """ 88 | y_true = _reshape_given_axis(y_true, y_pred, axis) 89 | _assert_compatible_shapes(y_true, y_pred, axis) 90 | # NOTE: abs is needed to handle complex numbers 91 | return np.mean(np.abs(y_true - y_pred) ** 2, axis=axis) 92 | 93 | 94 | def rmse_score(y_true: np.ndarray, y_pred: np.ndarray, axis=0): 95 | """ 96 | Calculates the generalized RMSE (Root Mean Squared Error) score. 97 | 98 | Parameters 99 | ---------- 100 | y_true : np.ndarray 101 | Ground truth (true) values. 102 | y_pred : np.ndarray 103 | Predicted values. 104 | axis : int, optional 105 | Axis along which to calculate the RMSE score. Default is 0. 106 | 107 | Returns 108 | ------- 109 | float 110 | The RMSE score. 111 | 112 | Notes 113 | ----- 114 | This function handles complex numbers in the input arrays. 115 | """ 116 | y_true = _reshape_given_axis(y_true, y_pred, axis) 117 | _assert_compatible_shapes(y_true, y_pred, axis) 118 | return np.sqrt(mse_score(y_true, y_pred, axis=axis)) 119 | 120 | 121 | def r2_score(y_true: np.ndarray, y_pred: np.ndarray, axis=0): 122 | """ 123 | Calculates the generalized R2 score. 124 | 125 | Parameters 126 | ---------- 127 | y_true : np.ndarray 128 | Ground truth (true) values. 129 | y_pred : np.ndarray 130 | Predicted values. 131 | axis : int, optional 132 | Axis along which to calculate the R2 score. Default is 0. 133 | 134 | Returns 135 | ------- 136 | float 137 | The R2 score. 138 | 139 | Notes 140 | ----- 141 | This function handles complex numbers in the input arrays. 142 | """ 143 | y_true = _reshape_given_axis(y_true, y_pred, axis) 144 | _assert_compatible_shapes(y_true, y_pred, axis) 145 | # NOTE: abs is needed to handle complex numbers 146 | ssr = np.sum(np.abs(y_true - y_pred) ** 2, axis=axis) 147 | sst = np.sum(np.abs(y_true - np.mean(y_true)) ** 2) 148 | return 1 - ssr / sst 149 | -------------------------------------------------------------------------------- /src/autoeis/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of functions to be used as models for Bayesian inference. 3 | 4 | .. currentmodule:: autoeis.models 5 | 6 | .. autosummary:: 7 | :toctree: generated/ 8 | 9 | circuit_regression_magnitude 10 | circuit_regression_nyquist 11 | circuit_regression_bode 12 | 13 | Notes 14 | ----- 15 | 16 | 1. For positive variable `y`, there are multiple options to model the observation 17 | to ensure that the predicted value is also positive: 18 | 19 | - Sample ``y`` with a shifted ``HalfNormal`` (see pyro-ppl/numpyro/issues/932) 20 | - Sample ``y`` with ``TruncatedNormal`` with 0 as the lower bound 21 | - Sample ``y - y_gt`` with ``Normal(0, sigma)`` 22 | 23 | 2. Using ``Normal`` distribution for positive variables may lead to negative 24 | samples 25 | 26 | """ 27 | 28 | import copy 29 | from collections import namedtuple 30 | from typing import Callable, Mapping 31 | 32 | import jax.numpy as jnp 33 | import numpy as np 34 | import numpyro 35 | import numpyro.distributions as dist 36 | from numpyro.distributions import Distribution 37 | 38 | Impedance = namedtuple("Impedance", ["real", "imag"]) 39 | 40 | 41 | def circuit_regression_magnitude( 42 | freq: np.ndarray[float], 43 | priors: Mapping[str, Distribution], 44 | circuit_fn: Callable[[np.ndarray | float, np.ndarray], np.ndarray[complex]], 45 | Z: np.ndarray[complex] = None, 46 | ): 47 | """Inference model for ECM parameters based on |Z|. 48 | 49 | This model infers the circuit parameters by matching the magnitude of the 50 | impedance measurements. To do this, the MCMC sampling is performed on the 51 | magnitude of the impedance. 52 | 53 | Parameters 54 | ---------- 55 | freq : np.ndarray[float] 56 | Frequencies to evaluate the posterior predictive distribution at. 57 | priors : Mapping[str, Distribution] 58 | Priors for the circuit parameters as a dictionary of parameter labels 59 | and distributions. 60 | circuit_fn : Callable[[np.ndarray | float, np.ndarray], np.ndarray[complex]] 61 | Function to compute the circuit impedance, parameterized by frequency 62 | and circuit parameters, fn(freq, p). 63 | Z : np.ndarray[complex], optional 64 | Observed impedance data. If ``None``, the model will be used for 65 | posterior prediction, otherwise it will be used for Bayesian 66 | inference of circuit parameters. 67 | """ 68 | # Make a deep copy of the priors to avoid side effects (pyro-ppl/numpyro/issues/1651) 69 | priors = copy.deepcopy(priors) 70 | # Sample each element of X separately 71 | p = jnp.array([numpyro.sample(k, v) for k, v in priors.items()]) 72 | # Predict Z using the model 73 | Z_pred = circuit_fn(freq, p) 74 | 75 | # Short-circuit if posterior prediction is requested 76 | if Z is None: 77 | _posterior_predictive(Z_pred) 78 | return 79 | 80 | # Observation model based on the magnitude of the impedance 81 | error_model = "abs(diff)" 82 | assert error_model in ["diff(abs)", "abs(diff)"] 83 | sigma = numpyro.sample("sigma", dist.Exponential(rate=1.0)) 84 | if error_model == "diff(abs)": # |Z| - |Z_gt| 85 | error = jnp.abs(Z) - jnp.abs(Z_pred) 86 | numpyro.sample("obs", dist.Normal(0.0, sigma), obs=error) 87 | if error_model == "abs(diff)": # |Z - Z_gt| 88 | error = jnp.abs(Z - Z_pred) 89 | numpyro.sample("obs", dist.HalfNormal(sigma), obs=error) 90 | 91 | 92 | def circuit_regression_nyquist( 93 | freq: np.ndarray[float], 94 | priors: Mapping[str, Distribution], 95 | circuit_fn: Callable[[np.ndarray | float, np.ndarray], np.ndarray[complex]], 96 | Z: np.ndarray[complex] = None, 97 | ): 98 | """Inference model for ECM parameters based on Nyquist plot. 99 | 100 | This model infers the circuit parameters by matching the impedance 101 | measurements as plotted on the Nyquist plot. To do this, the MCMC sampling 102 | is performed on the real and imaginary parts of the impedance. 103 | 104 | Parameters 105 | ---------- 106 | freq : np.ndarray[float] 107 | Frequencies to evaluate the posterior predictive distribution at. 108 | priors : Mapping[str, Distribution] 109 | Priors for the circuit parameters as a dictionary of parameter labels 110 | and distributions. 111 | circuit_fn : Callable[[np.ndarray | float, np.ndarray], np.ndarray[complex]] 112 | Function to compute the circuit impedance, parameterized by frequency 113 | and circuit parameters, fn(freq, p). 114 | Z : np.ndarray[complex], optional 115 | Observed impedance data. If ``None``, the model will be used for 116 | posterior prediction, otherwise it will be used for Bayesian 117 | inference of circuit parameters. 118 | """ 119 | # Make a deep copy of the priors to avoid side effects (pyro-ppl/numpyro/issues/1651) 120 | priors = copy.deepcopy(priors) 121 | # Sample each element of X separately 122 | p = jnp.array([numpyro.sample(k, v) for k, v in priors.items()]) 123 | # Predict Z using the model 124 | Z_pred = circuit_fn(freq, p) 125 | 126 | # Short-circuit if posterior prediction is requested 127 | if Z is None: 128 | _posterior_predictive(Z_pred) 129 | return 130 | 131 | # Observation model based on the Nyquist plot 132 | sigma = { 133 | "real": numpyro.sample("sigma.real", dist.Exponential(rate=1.0)), 134 | "imag": numpyro.sample("sigma.imag", dist.Exponential(rate=1.0)), 135 | } 136 | numpyro.sample("obs.real", dist.Normal(Z_pred.real, sigma["real"]), obs=Z.real) 137 | numpyro.sample("obs.imag", dist.Normal(Z_pred.imag, sigma["imag"]), obs=Z.imag) 138 | 139 | 140 | def circuit_regression_bode( 141 | freq: np.ndarray[float], 142 | priors: Mapping[str, Distribution], 143 | circuit_fn: Callable[[np.ndarray | float, np.ndarray], np.ndarray[complex]], 144 | Z: np.ndarray[complex] = None, 145 | ): 146 | """Inference model for ECM parameters based on Bode plot. 147 | 148 | This model infers the circuit parameters by matching the impedance 149 | measurements as plotted on the Bode plot. To do this, the MCMC sampling is 150 | performed on the magnitude and phase of the impedance. 151 | 152 | Parameters 153 | ---------- 154 | freq : np.ndarray[float] 155 | Frequencies to evaluate the posterior predictive distribution at. 156 | priors : Mapping[str, Distribution] 157 | Priors for the circuit parameters as a dictionary of parameter labels 158 | and distributions. 159 | circuit_fn : Callable[[np.ndarray | float, np.ndarray], np.ndarray[complex]] 160 | Function to compute the circuit impedance, parameterized by frequency 161 | and circuit parameters, fn(freq, p). 162 | Z : np.ndarray[complex], optional 163 | Observed impedance data. If ``None``, the model will be used for 164 | posterior prediction, otherwise it will be used for Bayesian 165 | inference of circuit parameters. 166 | """ 167 | # Make a deep copy of the priors to avoid side effects (pyro-ppl/numpyro/issues/1651) 168 | priors = copy.deepcopy(priors) 169 | # Sample each element of X separately 170 | p = jnp.array([numpyro.sample(k, v) for k, v in priors.items()]) 171 | # Predict Z using the model 172 | Z_pred = circuit_fn(freq, p) 173 | mag, phase = jnp.abs(Z_pred), jnp.angle(Z_pred) 174 | 175 | # Custom observation model based on the Bode plot 176 | if Z is None: 177 | mag_gt = phase_gt = None 178 | else: 179 | mag_gt, phase_gt = jnp.abs(Z), jnp.angle(Z) 180 | # Log-transform the magnitude, otherwise low-frequency values dominate 181 | mag, mag_gt = jnp.log10(mag), jnp.log10(mag_gt) 182 | sigma_mag = numpyro.sample("sigma.mag", dist.Exponential(rate=1.0)) 183 | sigma_phase = numpyro.sample("sigma.phase", dist.Exponential(rate=1.0)) 184 | dist_mag = dist.TruncatedDistribution(dist.Normal(mag, sigma_mag), low=mag) 185 | dist_phase = dist.Normal(phase, sigma_phase) 186 | numpyro.sample("obs.mag", dist_mag, obs=mag_gt) 187 | numpyro.sample("obs.phase", dist_phase, obs=phase_gt) 188 | 189 | # NOTE: The following code is an alternative to the above code 190 | # error_mag = jnp.abs(jnp.log10(mag) - jnp.log10(mag_gt)) 191 | # sigma_mag = numpyro.sample("sigma.mag", dist.HalfNormal()) 192 | # numpyro.sample("obs.error.mag", dist.HalfNormal(sigma_mag), obs=error_mag) 193 | # error_phase = jnp.abs(phase - phase_gt) 194 | # sigma_phase = numpyro.sample("sigma.phase", dist.HalfNormal()) 195 | # numpyro.sample("obs.error.phase", dist.HalfNormal(sigma_phase), obs=error_phase) 196 | 197 | 198 | def _posterior_predictive(Z: np.ndarray[complex]): 199 | """Private helper to compute the posterior predictive distribution.""" 200 | # NOTE: Z is predicted impedance, not observed impedance 201 | sigma_real = numpyro.sample("sigma.real", dist.Exponential(rate=1.0)) 202 | sigma_imag = numpyro.sample("sigma.imag", dist.Exponential(rate=1.0)) 203 | numpyro.sample("obs.real", dist.Normal(Z.real, sigma_real)) 204 | numpyro.sample("obs.imag", dist.Normal(Z.imag, sigma_imag)) 205 | -------------------------------------------------------------------------------- /src/autoeis/parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of functions for parsing circuit strings. 3 | 4 | .. currentmodule:: autoeis.parser 5 | 6 | .. autosummary:: 7 | :toctree: generated/ 8 | 9 | validate_circuit 10 | validate_parameter 11 | parse_component 12 | parse_parameter 13 | get_component_labels 14 | get_component_types 15 | get_parameter_types 16 | get_parameter_labels 17 | group_parameters_by_type 18 | group_parameters_by_component 19 | count_parameters 20 | convert_to_impedance_format 21 | find_ohmic_resistors 22 | 23 | """ 24 | 25 | import re 26 | 27 | from numpy import pi # noqa: F401 28 | from pyparsing import nested_expr 29 | 30 | 31 | def validate_circuit(circuit: str) -> bool: 32 | """Checks if a circuit string is valid. 33 | 34 | This function ensures that the circuit string: 35 | - is not empty, 36 | - contains valid element names (``R``, ``C``, ``L``, ``P``), 37 | - and, doesn't contain duplicate elements, e.g., ``R1-[P2,P2]``. 38 | 39 | Parameters 40 | ---------- 41 | circuit : str 42 | CDC string representation of the input circuit. See 43 | `here `_ for details. 44 | 45 | Returns 46 | ------- 47 | bool 48 | True if the circuit string is valid, False otherwise. 49 | """ 50 | # TODO: Check for parallel elements with < 2 elements 51 | # TODO: Check for duplicate "-" or "," symbols 52 | # TODO: Check for disconnected elements, eg R1R2 or R1[R2,R3] 53 | # Check for duplicate elements 54 | components = get_component_labels(circuit) 55 | duplicates = [e for e in components if components.count(e) > 1] 56 | assert not duplicates, f"Duplicate elements found: {set(duplicates)}" 57 | # Test circuit is not empty 58 | assert len(circuit) > 0, "Circuit string is empty." 59 | # Check for valid element names 60 | valid_types = ["R", "C", "L", "P"] 61 | types = get_component_types(circuit) 62 | for t in types: 63 | assert t in valid_types, f"Invalid element type: {t}" 64 | return True # If all checks pass, the circuit is considered valid 65 | 66 | 67 | def validate_parameter(p: str, raises: bool = True) -> bool: 68 | """Checks if a parameter label is valid. 69 | 70 | Valid parameter labels: {R,C,L,Pw,Pn}{N} where N is a number, e.g., P1n. 71 | 72 | Parameters 73 | ---------- 74 | p : str 75 | String representation of the parameter label. 76 | raises : bool, optional 77 | If True, raises an AssertionError on invalid parameter labels. 78 | Default is True. 79 | 80 | Returns 81 | ------- 82 | bool 83 | True if the parameter label is valid, False otherwise. 84 | """ 85 | # Check if parameter label is a string 86 | if not isinstance(p, str): 87 | if raises: 88 | raise AssertionError("Parameter label must be a string.") 89 | return False 90 | # Check if parameter label is not empty 91 | if not p: 92 | if raises: 93 | raise AssertionError("Parameter label is empty.") 94 | return False 95 | # Check if parameter label is valid 96 | pattern = r"(?:R\d+|C\d+|L\d+|P\d+[wn])" 97 | if not re.fullmatch(pattern, p): 98 | if raises: 99 | raise AssertionError(f"Invalid parameter label: {p}") 100 | return False 101 | return True 102 | 103 | 104 | def parse_component(c: str) -> str: 105 | """Returns the component type of a component/parameter label. 106 | 107 | Parameters 108 | ---------- 109 | c : str 110 | String representation of a component/parameter label. 111 | 112 | Returns 113 | ------- 114 | str 115 | The type of the component label from the set {R,C,L,P}. 116 | 117 | Examples 118 | -------- 119 | >>> parse_component("R1") 120 | 'R' 121 | >>> parse_component("P2n") 122 | 'P' 123 | """ 124 | return re.match(r"[A-Za-z]+", c).group() 125 | 126 | 127 | def parse_parameter(p: str) -> str: 128 | """Returns the type of a parameter label. 129 | 130 | Parameters 131 | ---------- 132 | p : str 133 | String representation of the parameter label. 134 | 135 | Returns 136 | ------- 137 | str 138 | The type of the parameter label from the set {R,C,L,Pn,Pw}. 139 | 140 | Examples 141 | -------- 142 | >>> parse_parameter("R1") 143 | 'R' 144 | >>> parse_parameter("P2n") 145 | 'Pn' 146 | """ 147 | validate_parameter(p) 148 | if p.startswith(("R", "C", "L")): 149 | ptype = p[0] 150 | elif p.startswith("P") and p.endswith("w"): 151 | ptype = "Pw" 152 | elif p.startswith("P") and p.endswith("n"): 153 | ptype = "Pn" 154 | return ptype 155 | 156 | 157 | def get_component_labels(circuit: str, types: list[str] = None) -> list[str]: 158 | """Returns a list of labels for all components in a circuit string. 159 | 160 | Parameters 161 | ---------- 162 | circuit : str 163 | CDC string representation of the input circuit. See 164 | `here `_ for details. 165 | types : list[str], optional 166 | List of component types to filter by. Default is None. 167 | 168 | Returns 169 | ------- 170 | list[str] 171 | A list of component labels. 172 | 173 | Examples 174 | -------- 175 | >>> get_component_labels("R1-[R2,P4]") 176 | ['R1', 'R2', 'P4'] 177 | >>> get_component_labels("R1-[R2,P4]", types=["R"]) 178 | ['R1', 'R2'] 179 | """ 180 | types = [types] if isinstance(types, str) else types 181 | types = ["R", "C", "L", "P"] if types is None else types 182 | assert isinstance(types, list), "types must be a list of strings." 183 | pattern = rf'\b(?:{"|".join(types)})\d+\b' 184 | return re.findall(pattern, circuit) 185 | 186 | 187 | def get_component_types(circuit: str, unique: bool = False) -> list[str]: 188 | """Returns a list of component types in a circuit string. 189 | 190 | Parameters 191 | ---------- 192 | circuit : str 193 | CDC string representation of the input circuit. See 194 | `here `_ for details. 195 | unique : bool, optional 196 | If True, returns a list of unique component types. Default is False. 197 | 198 | Returns 199 | ------- 200 | list[str] 201 | A list of component types. 202 | 203 | Examples 204 | -------- 205 | >>> get_component_types("R1-[R2,P4]") 206 | ['R', 'R', 'P'] 207 | >>> get_component_types("R1-[R2,P4]", unique=True) 208 | ['P', 'R'] 209 | """ 210 | types = re.findall(r"[A-Za-z]+", circuit) 211 | return list(set(types)) if unique else types 212 | 213 | 214 | def get_parameter_labels(circuit: str, types: list[str] = None) -> list[str]: 215 | """Returns a list of labels for all parameters in a circuit string. 216 | 217 | Parameters 218 | ---------- 219 | circuit : str 220 | CDC string representation of the input circuit. See 221 | `here `_ for details. 222 | types : list[str], optional 223 | List of parameter types to filter by. Default is None. 224 | 225 | Returns 226 | ------- 227 | list[str] 228 | A list of parameter labels. 229 | 230 | Examples 231 | -------- 232 | >>> get_parameter_labels("R1-[R2,P4]") 233 | ['R1', 'R2', 'P4w', 'P4n'] 234 | >>> get_parameter_labels("R1-[R2,P4]", types=["R"]) 235 | ['R1', 'R2'] 236 | """ 237 | types = [types] if isinstance(types, str) else types 238 | types = ["R", "C", "L", "P"] if types is None else types 239 | assert isinstance(types, list), "types must be a list of strings." 240 | components = get_component_labels(circuit, types=types) 241 | parameters = [] 242 | for component in components: 243 | # CPE elements have two parameters P{i}w and P{i}n 244 | if component.startswith("P"): 245 | parameters.extend([f"{component}w", f"{component}n"]) 246 | else: 247 | parameters.append(component) 248 | return parameters 249 | 250 | 251 | def get_parameter_types(circuit: str, unique: bool = False) -> list[str]: 252 | """Returns a list of parameter types in a circuit string. 253 | 254 | Parameters 255 | ---------- 256 | circuit : str 257 | CDC string representation of the input circuit. See 258 | `here `_ for details. 259 | unique : bool, optional 260 | If True, returns a list of unique parameter types. Default is False. 261 | 262 | Returns 263 | ------- 264 | list[str] 265 | A list of parameter types. 266 | 267 | Examples 268 | -------- 269 | >>> get_parameter_types("R1-[R2,P4]") 270 | ['R', 'R', 'Pw', 'Pn'] 271 | >>> get_parameter_types("R1-[R2,P4]", unique=True) 272 | ['Pn', 'Pw', 'R'] 273 | """ 274 | ptypes = [parse_parameter(p) for p in get_parameter_labels(circuit)] 275 | return list(set(ptypes)) if unique else ptypes 276 | 277 | 278 | def group_parameters_by_type(circuit: str) -> dict[str, list[str]]: 279 | """Groups parameter labels by component type. 280 | 281 | Parameters 282 | ---------- 283 | circuit : str 284 | CDC string representation of the input circuit. See 285 | `here `_ for details. 286 | 287 | Returns 288 | ------- 289 | dict[str, list[str]] 290 | A dictionary of parameter labels grouped by component type. 291 | 292 | Examples 293 | -------- 294 | >>> group_parameters_by_type("R1-[R2,P4]") 295 | {'Pn': ['P4n'], 'Pw': ['P4w'], 'R': ['R1', 'R2']} 296 | """ 297 | params = get_parameter_labels(circuit) 298 | ptypes = get_parameter_types(circuit) 299 | groups = {ptype: [] for ptype in set(ptypes)} 300 | for param, ptype in zip(params, ptypes): 301 | groups[ptype].append(param) 302 | return groups 303 | 304 | 305 | def group_parameters_by_component(circuit: str) -> dict[str, list[str]]: 306 | """Groups parameter labels by component label. 307 | 308 | Parameters 309 | ---------- 310 | circuit : str 311 | CDC string representation of the input circuit. See 312 | `here `_ for details. 313 | 314 | Returns 315 | ------- 316 | dict[str, list[str]] 317 | A dictionary of parameter labels grouped by component label. 318 | 319 | Examples 320 | -------- 321 | >>> group_parameters_by_component("R1-[R2,P4]") 322 | {'R': ['R1', 'R2'], 'P': ['P4w', 'P4n']} 323 | """ 324 | ctypes = get_component_types(circuit) 325 | params_by_component = {ctype: [] for ctype in ctypes} 326 | params = get_parameter_labels(circuit) 327 | for param in params: 328 | ctype = parse_component(param) 329 | params_by_component[ctype].append(param) 330 | return params_by_component 331 | 332 | 333 | def count_parameters(circuit: str) -> int: 334 | """Returns the number of parameters present in a circuit string. 335 | 336 | Parameters 337 | ---------- 338 | circuit : str 339 | CDC string representation of the input circuit. See 340 | `here `_ for details. 341 | 342 | Returns 343 | ------- 344 | int 345 | The number of parameters present in the circuit. 346 | 347 | Examples 348 | -------- 349 | >>> count_parameters("R1-[R2,P4]") 350 | 4 351 | """ 352 | return len(get_parameter_labels(circuit)) 353 | 354 | 355 | def convert_to_impedance_format(circuit: str) -> str: 356 | """Converts a circuit string the format used by impedance.py. 357 | 358 | Parameters 359 | ---------- 360 | circuit : str 361 | CDC string representation of the input circuit. See 362 | `here `_ for details. 363 | 364 | Returns 365 | ------- 366 | str 367 | The circuit string in the format used by impedance.py. 368 | 369 | Examples 370 | -------- 371 | >>> convert_to_impedance_format("R1-[R2,P4]") 372 | 'R1-p(R2,CPE4)' 373 | """ 374 | circuit = circuit.replace("P", "CPE") 375 | circuit = circuit.replace("[", "p(") 376 | circuit = circuit.replace("]", ")") 377 | return circuit 378 | 379 | 380 | def circuit_to_nested_expr(circuit: str) -> list: 381 | """Parses a circuit string to a nested list[str]. 382 | 383 | Parameters 384 | ---------- 385 | circuit : str 386 | CDC string representation of the input circuit. See 387 | `here `_ for details. 388 | 389 | Returns 390 | ------- 391 | list 392 | A nested list of component labels. 393 | 394 | Examples 395 | -------- 396 | >>> circuit_to_nested_expr("R1-[R2,P4]") 397 | ['R1', ['R2,P4']] 398 | """ 399 | 400 | def cleanup(lst: list, chars: list[str]): 401 | """Removes leading/trailing chars from a nested list[str].""" 402 | chars = "".join(chars) 403 | result = [] 404 | for el in lst: 405 | if isinstance(el, list): 406 | result.append(cleanup(el, chars)) 407 | else: 408 | # Don't add empty strings 409 | if el.strip(chars): 410 | result.append(el.strip(chars)) 411 | return result 412 | 413 | def parse(circuit: str): 414 | # Enclose circuit with brackets to make it a valid nested expression 415 | circuit = f"[{circuit}]" 416 | parser = nested_expr(opener="[", closer="]") 417 | parsed = parser.parse_string(circuit, parse_all=True).as_list() 418 | return parsed 419 | 420 | expr = parse(circuit) 421 | expr = cleanup(expr, chars=[",", "-"]) 422 | return expr[0] 423 | 424 | 425 | def find_series_elements(circuit: str) -> list[str]: 426 | """Extracts the series componenets from a circuit (in the main chain). 427 | 428 | Parameters 429 | ---------- 430 | circuit : str 431 | CDC string representation of the input circuit. See 432 | `here `_ for details. 433 | 434 | Returns 435 | ------- 436 | list[str] 437 | A list of series component labels. 438 | 439 | Examples 440 | -------- 441 | >>> find_series_elements("R1-[R2,P4]-P5") 442 | ['R1', 'P5] 443 | """ 444 | parsed = circuit_to_nested_expr(circuit) 445 | series_elements = [el for el in parsed if isinstance(el, str)] 446 | series_elements = re.findall(r"[A-Z]+\d+", str(series_elements)) 447 | return series_elements 448 | 449 | 450 | def find_ohmic_resistors(circuit: str) -> list[str]: 451 | """Finds all ohmic resistors in a circuit (only in the main chain). 452 | 453 | Parameters 454 | ---------- 455 | circuit : str 456 | CDC string representation of the input circuit. See 457 | `here `_ for details. 458 | 459 | Returns 460 | ------- 461 | list[str] 462 | A list of ohmic resistor labels. 463 | 464 | Examples 465 | -------- 466 | >>> find_ohmic_resistors("R1-[R2,P4]-R5") 467 | ['R1', 'R5'] 468 | """ 469 | series_elements = find_series_elements(circuit) 470 | return re.findall(r"R\d+", str(series_elements)) 471 | 472 | 473 | def generate_mathematical_expression(circuit: str) -> str: 474 | """Converts a circuit string to a mathematical expression, parameterized 475 | by frequency and the circuit parameters, i.e., func(freq, p). 476 | 477 | The returned string can be evaluated assuming 'p' is an array of 478 | parameter values and 'freq' is the frequency (scalar/array). 479 | 480 | Parameters 481 | ---------- 482 | circuit : str 483 | CDC string representation of the input circuit. See 484 | `here `_ for details. 485 | 486 | Returns 487 | ------- 488 | str 489 | The mathematical expression for impedance. 490 | 491 | Examples 492 | -------- 493 | >>> generate_mathematical_expr("R1-R2") 494 | 'p[0]+(1/(p[1]*(2*1j*pi*freq)**p[2]))' 495 | """ 496 | # Apply series-parallel conversion, e.g., [R1,R2] -> (1/R1+1/R2)**(-1) 497 | replacements = { 498 | "-": "+", 499 | "[": "((", 500 | ",": ")**(-1)+(", 501 | "]": ")**(-1))**(-1)" 502 | } # fmt: off 503 | expr = circuit 504 | for j, k in replacements.items(): 505 | expr = expr.replace(j, k) 506 | 507 | # Embed impedance expressions, e.g., C1 -> (1/(2*1j*pi*f*C1)) 508 | expr = replace_components_with_impedance(expr) 509 | 510 | # Replace parameters with array indexing, e.g., R1, P2w, P2n -> x[0], x[1], x[2] 511 | parameters = get_parameter_labels(circuit) 512 | for i, var in enumerate(parameters): 513 | # Negative look-ahead to avoid replacing R10 when dealing with R1 514 | expr = re.sub(rf"{var}(?!\d)", f"p[{i}]", expr) 515 | 516 | return expr 517 | 518 | 519 | def replace_components_with_impedance(expr: str) -> str: 520 | """Expands the circuit expression with the impedance of the components. 521 | 522 | The circuit expression describes describes the impedance of a circuit, 523 | parameterized by component impedance values, e.g., '[R1,P2]' -> 524 | '1 / (1/R1 + 1/P2)'. This function parameterizes the impedence terms using 525 | the actual component values, e.g., R1 -> R1, P2 -> 1/(P2w*(2*1j*pi*freq)**P2n) 526 | 527 | Parameters 528 | ---------- 529 | expr : str 530 | The circuit expression to be expanded. 531 | 532 | Returns 533 | ------- 534 | str 535 | The expanded circuit expression. 536 | 537 | Examples 538 | -------- 539 | >>> replace_components_with_impedance("R1") 540 | 'R1' 541 | >>> replace_components_with_impedance("P1") 542 | '(1/(P1w*(2*1j*pi*freq)**P1n))' 543 | >>> replace_components_with_impedance("R1+P2") 544 | 'R1+(1/(P2w*(2*1j*pi*freq)**P2n))' 545 | """ 546 | 547 | def replacement(var): 548 | eltype = get_component_types(var)[0] 549 | return { 550 | "R": f"{var}", 551 | "C": f"(1/(2*1j*pi*freq*{var}))", 552 | "L": f"(2*1j*pi*freq*{var})", 553 | "P": f"(1/({var}w*(2*1j*pi*freq)**{var}n))", 554 | }[eltype] 555 | 556 | # Get component lables 557 | components = get_component_labels(expr) 558 | # Replace components with impedance expression, e.g., C1 -> (1/(2*1j*pi*freq*C1)) 559 | for c in components: 560 | # Negative look-ahead to avoid replacing R10 when dealing with R1 561 | expr = re.sub(rf"{c}(?!\d)", replacement(c), expr) 562 | 563 | return expr 564 | -------------------------------------------------------------------------------- /src/autoeis/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.38" 2 | # Either use a branch name or a tag name for EquivalentCircuits.jl 3 | __equivalent_circuits_jl_version__ = "master" # "0.3.1" 4 | -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from autoeis import julia_helpers 4 | 5 | 6 | def test_init_julia(): 7 | Main = julia_helpers.init_julia() 8 | assert Main.seval("1+1") == 2 9 | 10 | 11 | def test_import_julia_modules(): 12 | Main = julia_helpers.init_julia() 13 | 14 | # Ensure installed modules can be imported 15 | ec = julia_helpers.import_package("EquivalentCircuits", Main) 16 | assert hasattr(ec, "circuit_evolution") 17 | 18 | # Throw error for non-existent module if error=True 19 | with pytest.raises(Exception): 20 | julia_helpers.import_package("NonExistentModule", Main, error=True) 21 | # Otherwise, return None 22 | ref = julia_helpers.import_package("NonExistentModule", Main, error=False) 23 | assert ref is None 24 | 25 | 26 | def test_import_backend(): 27 | # Import backend with Julia runtime as argument 28 | Main = julia_helpers.init_julia() 29 | ec = julia_helpers.import_backend(Main) 30 | assert hasattr(ec, "circuit_evolution") 31 | # Import backend without Julia runtime as argument 32 | ec = julia_helpers.import_backend() 33 | assert hasattr(ec, "circuit_evolution") 34 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | import autoeis as ae 2 | import numpy as np 3 | import pandas as pd 4 | import pytest 5 | 6 | 7 | def test_compute_ohmic_resistance(): 8 | circuit_string = "R1-[P2,P3-R4]" 9 | circuit_fn = ae.utils.generate_circuit_fn_impedance_backend(circuit_string) 10 | R1 = 250 11 | parameters = np.array([R1, 1e-3, 0.1, 5e-5, 0.8, 10]) 12 | freq = np.logspace(-3, 3, 1000) 13 | Z = circuit_fn(freq, parameters) 14 | R = ae.core.compute_ohmic_resistance(freq, Z) 15 | np.testing.assert_allclose(R, R1, rtol=0.15) 16 | 17 | 18 | def test_compute_ohmic_resistance_missing_high_freq(): 19 | circuit_string = "R1-[P2,P3-R4]" 20 | circuit_fn = ae.utils.generate_circuit_fn_impedance_backend(circuit_string) 21 | R1 = 250 22 | parameters = np.array([R1, 1e-3, 0.1, 5e-5, 0.8, 10]) 23 | freq = np.logspace(-3, 0, 1000) 24 | Z = circuit_fn(freq, parameters) 25 | R = ae.core.compute_ohmic_resistance(freq, Z) 26 | # When high frequency measurements are missing, Re(Z) @ max(freq) is good approximation 27 | Zreal_at_high_freq = Z.real[np.argmax(freq)] 28 | np.testing.assert_allclose(R, Zreal_at_high_freq) 29 | 30 | 31 | def test_gep(): 32 | def test_gep_serial(): 33 | freq, Z = ae.io.load_test_dataset() 34 | freq, Z = ae.utils.preprocess_impedance_data(freq, Z, tol_linKK=5e-2) 35 | kwargs = { 36 | "iters": 2, 37 | "complexity": 2, 38 | "population_size": 5, 39 | "generations": 2, 40 | "tol": 1e10, 41 | "parallel": False, 42 | } 43 | circuits = ae.core.generate_equivalent_circuits(freq, Z, **kwargs) 44 | assert len(circuits) == kwargs["iters"] 45 | assert isinstance(circuits, pd.DataFrame) 46 | 47 | def test_gep_parallel(): 48 | freq, Z = ae.io.load_test_dataset(preprocess=True) 49 | kwargs = { 50 | "iters": 2, 51 | "complexity": 2, 52 | "population_size": 5, 53 | "generations": 2, 54 | "tol": 1e10, 55 | "parallel": True, 56 | } 57 | circuits = ae.core.generate_equivalent_circuits(freq, Z, **kwargs) 58 | assert len(circuits) == kwargs["iters"] 59 | assert isinstance(circuits, pd.DataFrame) 60 | 61 | test_gep_serial() 62 | test_gep_parallel() 63 | 64 | 65 | def test_filter_implausible_circuits(): 66 | circuits_unfiltered = ae.io.load_test_circuits() 67 | N1 = len(circuits_unfiltered) 68 | circuits = ae.core.filter_implausible_circuits(circuits_unfiltered) 69 | N2 = len(circuits) 70 | assert N2 < N1 71 | 72 | 73 | def test_bayesian_inference_single_circuit_single_data(): 74 | freq, Z = ae.io.load_test_dataset(preprocess=True) 75 | circuits = ae.io.load_test_circuits(filtered=True) 76 | circuit = circuits.iloc[0].circuitstring 77 | p0 = circuits.iloc[0].Parameters 78 | kwargs_mcmc = {"num_warmup": 25, "num_samples": 10, "progress_bar": False} 79 | result = ae.core.perform_bayesian_inference(circuit, freq, Z, p0, **kwargs_mcmc)[0] 80 | assert isinstance(result, ae.utils.InferenceResult) 81 | assert result.converged 82 | 83 | 84 | def test_bayesian_inference_single_circuit_multiple_data(): 85 | # Load test dataset N times with noise to simulate multiple datasets 86 | n_datasets = 2 87 | freq, Z = zip( 88 | *[ae.io.load_test_dataset(preprocess=True, noise=0.1) for _ in range(n_datasets)] 89 | ) 90 | circuits = ae.io.load_test_circuits(filtered=True) 91 | circuit = circuits.iloc[0].circuitstring 92 | p0 = circuits.iloc[0].Parameters 93 | kwargs_mcmc = {"num_warmup": 25, "num_samples": 10, "progress_bar": False} 94 | results = ae.core.perform_bayesian_inference(circuit, freq, Z, p0, **kwargs_mcmc) 95 | assert len(results) == n_datasets 96 | for result in results: 97 | assert isinstance(result, ae.utils.InferenceResult) 98 | assert result.converged 99 | 100 | 101 | def test_bayesian_inference_multiple_circuits_single_data(): 102 | freq, Z = ae.io.load_test_dataset(preprocess=True) 103 | circuits = ae.io.load_test_circuits(filtered=True) 104 | circuits = circuits.iloc[:2] # Test the first two circuits to save CI time 105 | kwargs_mcmc = {"num_warmup": 25, "num_samples": 10, "progress_bar": False} 106 | results = ae.core.perform_bayesian_inference(circuits, freq, Z, **kwargs_mcmc) 107 | assert len(results) == len(circuits) 108 | for result in results: 109 | assert isinstance(result, ae.utils.InferenceResult) 110 | assert result.converged 111 | 112 | 113 | # TODO: Use a simple circuit to generate test data so this test doesn't take too long 114 | @pytest.mark.skip(reason="This test is too slow!") 115 | def test_perform_full_analysis(): 116 | freq, Z = ae.io.load_test_dataset() 117 | results = ae.core.perform_full_analysis(freq, Z) 118 | required_columns = ["circuitstring", "Parameters", "MCMC", "success", "divergences"] 119 | assert all(col in results.columns for col in required_columns) 120 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import autoeis as ae 4 | import numpy as np 5 | 6 | 7 | def test_get_assets_path(): 8 | path = ae.io.get_assets_path() 9 | assert os.path.exists(path) 10 | 11 | 12 | def test_load_test_dataset(): 13 | freq, Z = ae.io.load_test_dataset() 14 | assert Z.shape == freq.shape 15 | assert len(Z) == len(freq) > 0 16 | 17 | 18 | def test_load_dataset_preprocess(): 19 | freq0, Z0 = ae.io.load_test_dataset() 20 | freq, Z = ae.io.load_test_dataset(preprocess=True) 21 | assert Z.shape == freq.shape 22 | assert len(Z) == len(freq) > 0 23 | assert len(Z) < len(Z0) 24 | 25 | 26 | def test_load_dataset_noise(): 27 | freq0, Z0 = ae.io.load_test_dataset() 28 | freq, Z = ae.io.load_test_dataset(noise=0.1) 29 | assert Z.shape == freq.shape 30 | assert len(Z) == len(freq) > 0 31 | assert np.allclose(freq, freq0) # No noised added to frequency 32 | assert not np.allclose(Z, Z0) # Noise added to impedance 33 | 34 | 35 | def test_load_test_circuits_no_filter(): 36 | circuits = ae.io.load_test_circuits() 37 | assert len(circuits) == 118 38 | assert "circuitstring" in circuits.columns.tolist() 39 | assert "Parameters" in circuits.columns.tolist() 40 | 41 | 42 | def test_load_test_circuits_filter(): 43 | circuits = ae.io.load_test_circuits(filtered=True) 44 | assert len(circuits) == 15 45 | assert "circuitstring" in circuits.columns.tolist() 46 | assert "Parameters" in circuits.columns.tolist() 47 | 48 | 49 | def test_parse_ec_output_single(): 50 | circuits = 'EquivalentCircuit("R1", (R1 = 1.0,))' 51 | circuits = ae.io.parse_ec_output(circuits) 52 | assert len(circuits) == 1 53 | assert circuits.columns.tolist() == ["circuitstring", "Parameters"] 54 | assert circuits["circuitstring"][0] == "R1" 55 | assert circuits["Parameters"][0] == {"R1": 1.0} 56 | 57 | 58 | def test_parse_ec_output_multiple(): 59 | circuits = [ 60 | 'EquivalentCircuit("R1", (R1 = 1.0,))', 61 | 'EquivalentCircuit("R1-[C2,P3]", (R1 = 1.0, C2 = 1.5, P3w = 1.25e3, P3n = 0.75))', 62 | ] 63 | circuits = ae.io.parse_ec_output(circuits) 64 | assert len(circuits) == 2 65 | assert circuits.columns.tolist() == ["circuitstring", "Parameters"] 66 | assert circuits["circuitstring"][0] == "R1" 67 | assert circuits["Parameters"][0] == {"R1": 1.0} 68 | assert circuits["circuitstring"][1] == "R1-[C2,P3]" 69 | assert circuits["Parameters"][1] == {"R1": 1.0, "C2": 1.5, "P3w": 1.25e3, "P3n": 0.75} 70 | -------------------------------------------------------------------------------- /tests/test_metrics.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import sklearn.metrics as skmetrics 3 | from autoeis import metrics 4 | 5 | # Real numbers 6 | x1 = np.random.rand(10) 7 | x2 = np.random.rand(10) 8 | 9 | # Complex numbers 10 | y1 = x1 + np.zeros(10) * 1j 11 | y2 = x2 + np.zeros(10) * 1j 12 | 13 | # 2D arrays 14 | y3 = np.random.rand(5) 15 | y4 = np.random.rand(10) 16 | y5 = np.random.rand(5, 10) 17 | 18 | 19 | def test_mse_score_real(): 20 | mse = metrics.mse_score(x1, x2) 21 | mse_gt = skmetrics.mean_squared_error(x1, x2) 22 | assert np.isclose(mse, mse_gt) 23 | 24 | 25 | def test_mse_score_complex(): 26 | mse = metrics.mse_score(y1, y2) 27 | mse_gt = skmetrics.mean_squared_error(x1, x2) 28 | assert np.isclose(mse, mse_gt) 29 | 30 | 31 | def test_rmse_score_real(): 32 | rmse = metrics.rmse_score(x1, x2) 33 | rmse_gt = skmetrics.root_mean_squared_error(x1, x2) 34 | assert np.isclose(rmse, rmse_gt) 35 | 36 | 37 | def test_rmse_score_complex(): 38 | rmse = metrics.rmse_score(y1, y2) 39 | rmse_gt = skmetrics.root_mean_squared_error(x1, x2) 40 | assert np.isclose(rmse, rmse_gt) 41 | 42 | 43 | def test_mape_score_real(): 44 | mape = metrics.mape_score(x1, x2) 45 | mape_gt = skmetrics.mean_absolute_percentage_error(x1, x2) * 100 46 | assert np.isclose(mape, mape_gt) 47 | 48 | 49 | def test_mape_score_complex(): 50 | mape = metrics.mape_score(y1, y2) 51 | mape_gt = skmetrics.mean_absolute_percentage_error(x1, x2) * 100 52 | assert np.isclose(mape, mape_gt) 53 | 54 | 55 | def test_r2_score_real(): 56 | r2 = metrics.r2_score(x1, x2) 57 | r2_gt = skmetrics.r2_score(x1, x2) 58 | assert np.isclose(r2, r2_gt) 59 | 60 | 61 | def test_r2_score_complex(): 62 | r2 = metrics.r2_score(y1, y2) 63 | r2_gt = skmetrics.r2_score(x1, x2) 64 | assert np.isclose(r2, r2_gt) 65 | 66 | 67 | def test_r2_score_axis(): 68 | m, n = y5.shape 69 | # axis = 0 70 | r2 = metrics.r2_score(y3, y5, axis=0) 71 | r2_gt = [skmetrics.r2_score(y3, y5[:, i]) for i in range(n)] 72 | np.testing.assert_allclose(r2, r2_gt) 73 | # axis = 1 74 | r2 = metrics.r2_score(y4, y5, axis=1) 75 | r2_gt = [skmetrics.r2_score(y4, y5[i, :]) for i in range(m)] 76 | np.testing.assert_allclose(r2, r2_gt) 77 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from autoeis import parser 3 | 4 | 5 | def test_get_component_labels(): 6 | circuit = "[R1,R2-P12]-L2-R22-[R6,C7-[L8,R5],L9]-R3" 7 | # Pass default types = None (all components) 8 | components = parser.get_component_labels(circuit) 9 | components_gt = ["R1", "R2", "P12", "L2", "R22", "R6", "C7", "L8", "R5", "L9", "R3"] 10 | assert components == components_gt 11 | # Pass string as types 12 | components = parser.get_component_labels(circuit, types="P") 13 | components_gt = ["P12"] 14 | assert components == components_gt 15 | # Pass list as types 16 | components = parser.get_component_labels(circuit, types=["L", "P"]) 17 | components_gt = ["P12", "L2", "L8", "L9"] 18 | assert components == components_gt 19 | 20 | 21 | def test_get_parameter_labels(): 22 | circuit = "[R1,R2-P12]-L2-R22-[R6,C7-[L8,R5],L9]-R3" 23 | # Use default types = None (all parameters) 24 | variables = parser.get_parameter_labels(circuit) 25 | variables_gt = ["R1", "R2", "P12w", "P12n", "L2", "R22", "R6", "C7", "L8", "R5", "L9", "R3"] 26 | assert variables == variables_gt 27 | # Pass string as types 28 | variables = parser.get_parameter_labels(circuit, types="R") 29 | variables_gt = ["R1", "R2", "R22", "R6", "R5", "R3"] 30 | assert variables == variables_gt 31 | # Pass list as types 32 | variables = parser.get_parameter_labels(circuit, types=["R", "P"]) 33 | variables_gt = ["R1", "R2", "P12w", "P12n", "R22", "R6", "R5", "R3"] 34 | assert variables == variables_gt 35 | 36 | 37 | def test_get_component_types(): 38 | circuit = "[R1,R2-P12]-L2-R22-[R6,C7-[L8,R5],L9]-R3" 39 | types_gt = ["R", "R", "P", "L", "R", "R", "C", "L", "R", "L", "R"] 40 | types = parser.get_component_types(circuit) 41 | assert types == types_gt 42 | 43 | 44 | def test_get_parameter_types(): 45 | circuit = "[R1,R2-P12]-L2-[R6,C7-[L8,R5],L9]-P3" 46 | types_gt = ["R", "R", "Pw", "Pn", "L", "R", "C", "L", "R", "L", "Pw", "Pn"] 47 | types_gt_unique = list(set(types_gt)) 48 | types = parser.get_parameter_types(circuit, unique=False) 49 | types_unique = parser.get_parameter_types(circuit, unique=True) 50 | assert types == types_gt 51 | assert types_unique == types_gt_unique 52 | 53 | 54 | def test_group_parameters_by_component(): 55 | circuit = "[R1,R2-P3]-L4-L5-[P6,R7]" 56 | g = { 57 | "R": ["R1", "R2", "R7"], 58 | "P": ["P3w", "P3n", "P6w", "P6n"], 59 | "L": ["L4", "L5"], 60 | } 61 | assert parser.group_parameters_by_component(circuit) == g 62 | 63 | 64 | def test_group_parameters_by_type(): 65 | circuit = "[R1,R2-P3]-L4-L5-[P6,R7]" 66 | g = { 67 | "R": ["R1", "R2", "R7"], 68 | "Pw": ["P3w", "P6w"], 69 | "Pn": ["P3n", "P6n"], 70 | "L": ["L4", "L5"], 71 | } 72 | assert parser.group_parameters_by_type(circuit) == g 73 | 74 | 75 | def test_count_parameters(): 76 | d = { 77 | "[R1,R2-P12]-L2-R22-[R6,C7-[L8,R5],L9]-R3": 12, 78 | "": 0, 79 | "[P1,P2]-P3-R4": 7, 80 | } 81 | for circuit, num_params_gt in d.items(): 82 | assert parser.count_parameters(circuit) == num_params_gt 83 | 84 | 85 | def test_validate_circuit(): 86 | # Valid circuits 87 | circuits_valid = [ 88 | "[R1,R2-P12]-L2-R22-[R6,C7-[L8,R5],L9]-R3", 89 | ] 90 | for circuit in circuits_valid: 91 | parser.validate_circuit(circuit) 92 | # Invalid circuits 93 | circuits_invalid = [ 94 | "", 95 | "[R1,R2]-R1", 96 | ] 97 | for circuit in circuits_invalid: 98 | with pytest.raises(AssertionError): 99 | parser.validate_circuit(circuit) 100 | 101 | 102 | def test_validate_parameter(): 103 | # Valid parameters 104 | parameters_valid = ["R1", "C5", "L12", "P1n", "P22w"] 105 | for p in parameters_valid: 106 | parser.validate_parameter(p) 107 | # Invalid parameters 108 | parameter_invalid = ["R1w", "C", "L2r", "Pn", "P2s"] 109 | for p in parameter_invalid: 110 | with pytest.raises(AssertionError): 111 | parser.validate_parameter(p) 112 | 113 | 114 | def test_parse_component(): 115 | d = { 116 | "R2": "R", 117 | "C5": "C", 118 | "L12": "L", 119 | "P1n": "P", 120 | "P22w": "P", 121 | "P4": "P", 122 | } 123 | for k, v in d.items(): 124 | assert parser.parse_component(k) == v 125 | 126 | 127 | def test_parse_parameter(): 128 | d = { 129 | "R2": "R", 130 | "C5": "C", 131 | "L12": "L", 132 | "P1n": "Pn", 133 | "P22w": "Pw", 134 | } 135 | for k, v in d.items(): 136 | assert parser.parse_parameter(k) == v 137 | 138 | 139 | def test_find_series_elements(): 140 | d = { 141 | "[R1,R2-P12]-L2-[R6,C7-[L8,R5],L9]-P3": ["L2", "P3"], 142 | "[R1,R2-P12]-L2-[R6,C7-[L8,R5],L9]-P3-P4": ["L2", "P3", "P4"], 143 | "": [], 144 | "[P1,P2]-[P3,P4]": [], 145 | } 146 | for k, v in d.items(): 147 | assert parser.find_series_elements(k) == v 148 | 149 | 150 | def test_find_ohmic_resistors(): 151 | # No ohmic resistors 152 | circuit = "[R1,R2-P12]-L2-[R6,C7-[L8,R5],L9]-P3" 153 | ohmic_gt = [] 154 | ohmic = parser.find_ohmic_resistors(circuit) 155 | assert ohmic == ohmic_gt 156 | # Single ohmic resistor 157 | circuit = "[R1,R2-P12]-L2-[R6,C7-[L8,R5],L9]-R3" 158 | ohmic_gt = ["R3"] 159 | ohmic = parser.find_ohmic_resistors(circuit) 160 | assert ohmic == ohmic_gt 161 | # Multiple ohmic resistors 162 | circuit = "[R1,R2-P12]-L2-R9-[R6,C7-[L8,R5],L9]-R8" 163 | ohmic_gt = ["R9", "R8"] 164 | ohmic = parser.find_ohmic_resistors(circuit) 165 | assert ohmic == ohmic_gt 166 | 167 | 168 | def test_convert_to_impedance_format(): 169 | circuit = "R1-[R2,P1-[R5,L8]]-P5" 170 | circuit_impy = parser.convert_to_impedance_format(circuit) 171 | circuit_gt = "R1-p(R2,CPE1-p(R5,L8))-CPE5" 172 | assert circuit_impy == circuit_gt 173 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | import autoeis as ae 5 | 6 | # Real numbers 7 | x1 = np.random.rand(10) 8 | x2 = np.random.rand(10) 9 | # Complex numbers 10 | y1 = x1 + np.zeros(10) * 1j 11 | y2 = x2 + np.zeros(10) * 1j 12 | 13 | # Simulated EIS data 14 | circuit_string = "R1-[P2,R3]" 15 | p0_dict = {"R1": 250, "P2w": 1e-3, "P2n": 0.5, "R3": 10.0} 16 | p0_vals = list(p0_dict.values()) 17 | circuit_fn_gt = ae.utils.generate_circuit_fn_impedance_backend(circuit_string) 18 | freq = np.logspace(-3, 3, 1000) 19 | Z = circuit_fn_gt(freq, p0_vals) 20 | 21 | 22 | def test_preprocess_impedance_data(): 23 | freq, Z = ae.io.load_test_dataset() 24 | # Test various tolerances for linKK validation 25 | freq_prep, Z_prep = ae.utils.preprocess_impedance_data(freq, Z, tol_linKK=5e-2) 26 | assert len(Z_prep) == len(freq_prep) 27 | assert len(Z_prep) == 60 28 | freq_prep, Z_prep = ae.utils.preprocess_impedance_data(freq, Z, tol_linKK=5e-3) 29 | assert len(Z_prep) == len(freq_prep) 30 | assert len(Z_prep) == 50 31 | # Test return_aux=True 32 | _, _, aux = ae.utils.preprocess_impedance_data(freq, Z, return_aux=True) 33 | assert set(aux.keys()) == {"res", "rmse", "freq"} 34 | assert set(aux["res"].keys()) == {"real", "imag"} 35 | 36 | 37 | def test_preprocess_impedance_data_no_high_freq(): 38 | # This is to ensure AUTODIAL/AutoEIS/#122 is fixed 39 | freq, Z = ae.io.load_test_dataset() 40 | # Pass high_freq_threshold=1e10 to simulate missing high frequency data 41 | ae.utils.preprocess_impedance_data(freq, Z, high_freq_threshold=1e10) 42 | 43 | 44 | def test_fit_circuit_parameters_without_x0(): 45 | p_dict = ae.utils.fit_circuit_parameters(circuit_string, freq, Z, max_iters=100) 46 | p_fit = list(p_dict.values()) 47 | assert np.allclose(p_fit, p0_vals, rtol=0.01) 48 | 49 | 50 | def test_fit_circuit_parameters_with_x0(): 51 | # Add some noise to the initial guess to test robustness 52 | p0 = p0_vals + np.random.rand(len(p0_vals)) * p0_vals * 0.5 53 | p_dict = ae.utils.fit_circuit_parameters(circuit_string, freq, Z, p0) 54 | p_fit = list(p_dict.values()) 55 | assert np.allclose(p_fit, p0_vals, rtol=0.01) 56 | 57 | 58 | @pytest.mark.skip(reason="We're catching Exceptions in the function") 59 | def test_fit_circuit_parameters_with_bounds(): 60 | # Pass incorrect bounds to ensure bounds are being used (Exception should be raised) 61 | bounds = [(0, 0, 0, 0), (1e-6, 1e-6, 1e-6, 1e-6)] 62 | with pytest.raises(Exception): 63 | ae.utils.fit_circuit_parameters(circuit_string, freq, Z, max_iters=25, bounds=bounds) 64 | 65 | 66 | def test_generate_circuit_fn(): 67 | circuit = "R0-C1-[P2,R3]-[P4-[P5,C6],[L7,R8]]" 68 | circuit_fn = ae.utils.generate_circuit_fn(circuit) 69 | num_params = ae.parser.count_parameters(circuit) 70 | p = np.random.rand(num_params) 71 | freq = np.array([1, 10, 100]) 72 | Z_py = circuit_fn(freq, p) 73 | Main = ae.julia_helpers.init_julia() 74 | ec = ae.julia_helpers.import_backend(Main) 75 | Z_jl = np.array([ec.get_target_impedance(circuit, p, f) for f in freq]) 76 | np.testing.assert_allclose(Z_py, Z_jl) 77 | 78 | 79 | def test_generate_circuit_fn_frequency_independent_ecm(): 80 | """Frequency-independent ECMs used to return a scalar, rather than an 81 | array of size len(freq). This unit test checks for this behavior.""" 82 | circuit = "R1-R2" 83 | circuit_fn = ae.utils.generate_circuit_fn(circuit) 84 | num_params = ae.parser.count_parameters(circuit) 85 | p = np.random.rand(num_params) 86 | # Input: frequency array, output: array of size len(freq) 87 | freq = np.array([1, 10, 100]) 88 | assert len(circuit_fn(freq, p)) == len(freq) 89 | # Input: scalar frequency, output: still array, but of size 1! 90 | freq = 10 91 | assert len(circuit_fn(freq, p)) == 1 92 | 93 | 94 | def test_circuit_complexity(): 95 | circuit_complexity_dict = { 96 | "R1": [0], 97 | "[R1,R2]": [1, 1], 98 | "R1-C2": [0, 0], 99 | "R1-[C2,L3]": [0, 1, 1], 100 | "[R1,R2-R3]-[C4,L5]-P6": [1, 1, 1, 1, 1, 0], 101 | "R1-[R2,R3]-[[C4,L5]-P6]-[R7,[R8,[C9,L10]]]": [0, 1, 1, 2, 2, 1, 1, 2, 3, 3], 102 | } 103 | for cstr, cc in circuit_complexity_dict.items(): 104 | assert ae.utils.circuit_complexity(cstr) == cc 105 | 106 | 107 | def test_are_circuits_equivalent(): 108 | testset = [ 109 | ["R1-C2-L3-R4", "C1-R2-R5-L8", True], 110 | ["R1-[C2,R3-L4]-P5", "[L1-R2,C5]-P10-R20", True], 111 | ["R1-[[[C2,P5],R3],P2]", "[P1,[R1,[P5,C5]]]-R4", True], 112 | ["R1-C2-L3", "R1-C2-P3", False], 113 | ["[R1,C2]-[P1,C3]", "[R1,C3]-[P1,R2]", False], 114 | ] 115 | for row in testset: 116 | c1, c2, eq = row 117 | assert ae.utils.are_circuits_equivalent(c1, c2) == eq 118 | 119 | 120 | def test_eval_posterior_predictive(): 121 | # Load test dataset 122 | freq, Z = ae.io.load_test_dataset() 123 | circuits = ae.io.load_test_circuits(filtered=True) 124 | circuit = circuits.iloc[0].circuitstring 125 | p0 = circuits.iloc[0].Parameters 126 | 127 | # Perform Bayesian inference on a single ECM 128 | kwargs_mcmc = {"num_warmup": 2500, "num_samples": 1000, "progress_bar": False} 129 | result = ae.core.perform_bayesian_inference(circuit, freq, Z, p0, **kwargs_mcmc)[0] 130 | 131 | # Evaluate the posterior predictive distribution with priors 132 | priors = ae.utils.initialize_priors(p0) 133 | Z_pred = ae.utils.eval_posterior_predictive(result.samples, circuit, freq, priors) 134 | assert Z_pred.shape == (1000, len(freq)) 135 | 136 | # Evaluate the posterior predictive distribution without priors 137 | Z_pred = ae.utils.eval_posterior_predictive(result.samples, circuit, freq) 138 | assert Z_pred.shape == (1000, len(freq)) 139 | --------------------------------------------------------------------------------