├── ARCHITECTURE.md ├── src └── softpotato │ ├── py.typed │ ├── _version.py │ ├── __init__.py │ ├── validation.py │ └── waveforms.py ├── mkdocs.yml ├── .pre-commit-config.yaml ├── main.py ├── LICENSE ├── .github └── workflows │ └── ci.yml ├── CLA.md ├── .gitignore ├── CHANGELOG.md ├── pyproject.toml ├── CONTRIBUTING.md ├── docs ├── waveforms.md └── index.md ├── README.md ├── ROADMAP.md └── tests ├── test_m0_skeleton.py └── test_m1_waveforms.py /ARCHITECTURE.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/softpotato/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SoftPotato 2 | site_description: SoftPotato v3.0 documentation (skeleton) 3 | theme: 4 | name: mkdocs 5 | nav: 6 | - Home: index.md 7 | - Waveforms: waveforms.md 8 | 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 24.10.0 4 | hooks: 5 | - id: black 6 | 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.6.9 9 | hooks: 10 | - id: ruff 11 | args: ["--fix"] 12 | 13 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | 3 | import softpotato as sp 4 | 5 | lsv = sp.lsv(-0.5, 0.5, 0.01, 1) 6 | cv = sp.cv(0, 0.5, 1, 0.01, -0.5, 2) 7 | 8 | plt.figure(1) 9 | plt.plot(lsv[:, 1], lsv[:, 0]) 10 | plt.plot(cv[:, 1], cv[:, 0]) 11 | plt.xlabel("t / s") 12 | plt.ylabel("E / V") 13 | plt.grid() 14 | plt.show() 15 | -------------------------------------------------------------------------------- /src/softpotato/_version.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from importlib.metadata import PackageNotFoundError, version 4 | 5 | try: 6 | __version__ = version("softpotato") 7 | except PackageNotFoundError: # pragma: no cover 8 | # Allows `pytest` from a fresh checkout before editable install. 9 | __version__ = "3.0.0-alpha2" 10 | -------------------------------------------------------------------------------- /src/softpotato/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | SoftPotato v3.0 3 | 4 | M1 public surface: 5 | - Time grids 6 | - Potential waveforms 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from importlib.metadata import PackageNotFoundError, version 12 | 13 | from .waveforms import cv, lsv, step 14 | 15 | # Version --------------------------------------------------------------------- 16 | 17 | try: 18 | __version__ = version("softpotato") 19 | except PackageNotFoundError: # pragma: no cover 20 | __version__ = "3.0.0-alpha2" 21 | 22 | __all__ = [ 23 | "cv", 24 | "lsv", 25 | "step", 26 | "uniform_time_grid", 27 | ] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Oliver Rodriguez 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 | 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, v3.0] 6 | pull_request: 7 | 8 | concurrency: 9 | group: ci-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | quality: 14 | name: Quality gates (ruff + black + mypy) 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.12" 22 | cache: "pip" 23 | 24 | - name: Install (dev) 25 | run: | 26 | python -m pip install --upgrade pip 27 | python -m pip install -e ".[dev]" 28 | 29 | - name: Ruff lint 30 | run: ruff check . 31 | 32 | - name: Black (check) 33 | run: black --check . 34 | 35 | - name: Mypy 36 | run: mypy src/softpotato 37 | 38 | tests: 39 | name: Tests (pytest) 40 | runs-on: ubuntu-latest 41 | strategy: 42 | fail-fast: false 43 | matrix: 44 | python-version: ["3.10", "3.11", "3.12"] 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - uses: actions/setup-python@v5 49 | with: 50 | python-version: ${{ matrix.python-version }} 51 | cache: "pip" 52 | 53 | - name: Install (dev) 54 | run: | 55 | python -m pip install --upgrade pip 56 | python -m pip install -e ".[dev]" 57 | 58 | - name: Pytest 59 | run: pytest 60 | 61 | -------------------------------------------------------------------------------- /CLA.md: -------------------------------------------------------------------------------- 1 | # Contributor License Agreement (CLA) 2 | ## SoftPotato Project 3 | 4 | Thank you for your interest in contributing to **SoftPotato**. 5 | 6 | This Contributor License Agreement (“Agreement”) clarifies the intellectual property 7 | rights granted with contributions to the SoftPotato project. 8 | 9 | ### 1. Definitions 10 | 11 | - **“You”** (or **“Contributor”**) means the individual or legal entity submitting a 12 | contribution to the project. 13 | - **“Contribution”** means any code, documentation, or other material submitted by You 14 | to the SoftPotato project, including via pull requests, patches, or commits. 15 | - **“Project”** means the SoftPotato software and related repositories. 16 | 17 | --- 18 | 19 | ### 2. Copyright Ownership 20 | 21 | You retain copyright ownership of your Contributions. 22 | 23 | --- 24 | 25 | ### 3. License Grant 26 | 27 | You hereby grant the Project Maintainer a **perpetual, worldwide, non-exclusive, 28 | royalty-free, irrevocable license** to: 29 | 30 | - use, reproduce, modify, adapt, publish, distribute, sublicense, and relicense 31 | Your Contributions, 32 | - as part of the Project or in derivative works, 33 | - under **any open-source or commercial license**, including but not limited to 34 | MIT, BSD, Apache-2.0, or GPL-compatible licenses. 35 | 36 | --- 37 | 38 | ### 4. Original Work 39 | 40 | You represent that: 41 | - Your Contributions are your original work, **or** 42 | - You have the right to submit them under this Agreement, and 43 | - They do not knowingly infringe the rights of any third party. 44 | 45 | --- 46 | 47 | ### 5. No Warranty 48 | 49 | Your Contributions are provided **“as is”**, without warranty of any kind. 50 | 51 | --- 52 | 53 | ### 6. Acceptance 54 | 55 | By submitting a Contribution to the SoftPotato project (for example, via a GitHub 56 | pull request or commit), You agree to the terms of this Agreement. 57 | 58 | --- 59 | 60 | **Project Maintainer:** 61 | Oliver Rodriguez 62 | 63 | **Project:** 64 | SoftPotato 65 | https://github.com/oliverrdz/softpotato 66 | 67 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 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 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /src/softpotato/validation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | _FLOAT_TOL = 1e-12 6 | 7 | 8 | def _is_finite_float(x: float, *, name: str) -> None: 9 | """Raise ValueError if x is not a finite float.""" 10 | if not np.isfinite(x): 11 | raise ValueError(f"{name} must be finite; got {x!r}") 12 | 13 | 14 | def validate_positive_float(x: float, *, name: str) -> None: 15 | """Raise ValueError if x is not finite or not strictly > 0.""" 16 | _is_finite_float(x, name=name) 17 | if x <= 0.0: 18 | raise ValueError(f"{name} must be > 0; got {x!r}") 19 | 20 | 21 | def validate_finite_float(x: float, *, name: str) -> None: 22 | """Raise ValueError if x is not finite.""" 23 | _is_finite_float(x, name=name) 24 | 25 | 26 | def validate_cycles(cycles: int) -> None: 27 | """ 28 | Validate cycles per M1 revised spec. 29 | 30 | - Must be int >= 1 31 | - TypeError for non-int, ValueError for int < 1 32 | """ 33 | if not isinstance(cycles, int): 34 | raise TypeError(f"cycles must be an int >= 1; got {type(cycles).__name__}") 35 | if cycles < 1: 36 | raise ValueError(f"cycles must be >= 1; got {cycles!r}") 37 | 38 | 39 | def build_potential_segment( 40 | E0: float, 41 | E1: float, 42 | dE: float, 43 | *, 44 | drop_first: bool = False, 45 | atol: float = _FLOAT_TOL, 46 | ) -> np.ndarray: 47 | """ 48 | Build a deterministic piecewise-linear potential segment E0 -> E1 with nominal step |dE|. 49 | 50 | Rules (per spec): 51 | - Samples are spaced by dE in the correct direction. 52 | - Ensure the final value is exactly E1 (append if needed). 53 | - Avoid duplicate endpoint (do not append if already equal within tolerance). 54 | - Optionally drop the first sample to avoid duplicating join points. 55 | 56 | Parameters 57 | ---------- 58 | E0, E1 59 | Segment start and end potentials [V]. 60 | dE 61 | Positive potential increment magnitude [V]. Direction is inferred from E0/E1. 62 | drop_first 63 | If True, drop the first sample (useful when concatenating segments). 64 | atol 65 | Absolute tolerance used to decide whether endpoints are already equal. 66 | 67 | Returns 68 | ------- 69 | np.ndarray 70 | 1D array of E samples for the segment. 71 | """ 72 | validate_finite_float(E0, name="E0") 73 | validate_finite_float(E1, name="E1") 74 | validate_positive_float(dE, name="dE") 75 | 76 | # Degenerate segment: still return a single point. 77 | if np.isclose(E0, E1, atol=atol, rtol=0.0): 78 | E = np.array([float(E1)], dtype=float) 79 | return E[1:] if drop_first else E 80 | 81 | sign = 1.0 if E1 >= E0 else -1.0 82 | step = sign * dE 83 | 84 | distance = abs(E1 - E0) 85 | n_full = int(np.floor(distance / dE)) 86 | # Always include E0. This yields [E0, E0+step, ..., E0+n_full*step] 87 | E = E0 + step * np.arange(n_full + 1, dtype=float) 88 | 89 | # Enforce exact endpoint, without duplicate. 90 | if not np.isclose(E[-1], E1, atol=atol, rtol=0.0): 91 | E = np.append(E, float(E1)) 92 | 93 | if drop_first: 94 | E = E[1:] 95 | 96 | return E 97 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v3.0.0-alpha2 — 2025-12-17 2 | 3 | ### Changed (breaking) 4 | - Simplified M1 waveform generation to be **resolution-driven** rather than requiring a user-provided time grid. 5 | - Updated public waveform APIs: 6 | - `lsv(E_start, E_end, dE, scan_rate)` 7 | - `cv(E_start, E_vertex, scan_rate, dE, E_end=None, cycles=1)` 8 | - `step(E_before, E_after, dt, t_end)` 9 | 10 | ### Behavior 11 | - Scan waveforms (`lsv`, `cv`) now deterministically derive the time array from `dE` and `scan_rate`, with exact endpoint enforcement. 12 | - Step waveforms now construct a uniform time grid using `dt` up to `t_end`. 13 | - All waveform generators return `np.ndarray (n, 2)` with columns `[E, t]` and strictly increasing time. 14 | 15 | ### Documentation 16 | - Updated waveform documentation and examples to reflect the new resolution-based API. 17 | - Clarified parameter meanings, units, and deterministic sampling rules. 18 | 19 | ### Tests 20 | - Updated M1 waveform tests to validate the new public signatures and derived time behavior. 21 | 22 | ### Notes 23 | - This release introduces **breaking changes** relative to `v3.0.0-alpha1`. 24 | - Users migrating from `alpha1` should replace time-grid-based inputs with `dE` (scan waveforms) or `dt` (step waveforms). 25 | 26 | 27 | ## [3.0.0-alpha1] — 2025-12-16 28 | 29 | ### Added 30 | - Time grid utilities providing a validated, reusable time base for simulated electrochemical experiments. 31 | - Potential waveform generators (M1) built on top of time grids (e.g. linear sweep–style waveforms). 32 | - Input validation layer to enforce argument consistency and array shapes early. 33 | 34 | ### Documentation 35 | - Expanded MkDocs documentation including quickstart, API references for time grids and waveforms, and runnable examples. 36 | - Cross-linked tests and documentation for traceability of acceptance criteria. 37 | 38 | ### Quality 39 | - Test coverage for M0 project skeleton and M1 time grids and waveforms. 40 | - CI and documentation builds expected to pass on the release tag. 41 | 42 | ### Notes 43 | - This is an **alpha** release. APIs may change as diffusion, kinetics, and reaction mechanism milestones are implemented. 44 | 45 | --- 46 | 47 | ## [3.0.0-alpha0] — 2025-12-15 48 | 49 | **Status:** Alpha (M0 – project skeleton) 50 | 51 | This is the first pre-release of SoftPotato v3.0. 52 | It validates repository structure, packaging, versioning, tests, and documentation workflows. 53 | There is **no stable scientific or numerical API** in this release. 54 | 55 | ### Added 56 | - Initial SoftPotato v3.0 repository skeleton using a `src/` layout. 57 | - Packaging and versioning scaffold with runtime `softpotato.__version__`. 58 | - Pytest-based M0 smoke tests (import and version exposure). 59 | - Documentation baseline: 60 | - `README.md` 61 | - `ARCHITECTURE.md` 62 | - `ROADMAP.md` 63 | - MkDocs configuration and `docs/` skeleton. 64 | - Project governance files: 65 | - `LICENSE` 66 | - `CLA.md` 67 | - `CONTRIBUTING.md` 68 | 69 | ### Changed 70 | - Tooling and CI foundations for editable installs and test execution. 71 | 72 | ### Notes 73 | - This release is intended **only** to establish a clean, testable, and documented starting point. 74 | - All electrochemical models, solvers, mechanisms, and user-facing APIs are deferred to later alpha milestones. 75 | 76 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.25"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "softpotato" 7 | version = "3.0.0-alpha2" 8 | description = "Electrochemistry simulator and toolbox (Soft Potato)." 9 | readme = "README.md" 10 | license = { file = "LICENSE" } 11 | requires-python = ">=3.10" 12 | authors = [ 13 | { name = "Oliver Rodriguez" } 14 | ] 15 | keywords = ["electrochemistry", "simulation", "voltammetry", "mass-transport", "butler-volmer"] 16 | classifiers = [ 17 | "Development Status :: 3 - Alpha", 18 | "Intended Audience :: Science/Research", 19 | "License :: OSI Approved :: MIT License", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3 :: Only", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Topic :: Scientific/Engineering :: Chemistry", 26 | "Topic :: Scientific/Engineering :: Physics", 27 | "Typing :: Typed" 28 | ] 29 | 30 | dependencies = [ 31 | "numpy>=1.24", 32 | "scipy>=1.10", 33 | "matplotlib>=3.7", 34 | ] 35 | 36 | [project.optional-dependencies] 37 | dev = [ 38 | "black>=24.0", 39 | "pytest>=8", 40 | "pytest-cov>=5", 41 | "ruff>=0.6", 42 | "mypy>=1.10", 43 | "pre-commit>=3.7", 44 | "mkdocs", 45 | "mkdocs-material", 46 | "tomli; python_version < '3.11'", 47 | ] 48 | docs = [ 49 | "mkdocs>=1.6", 50 | "mkdocs-material>=9.5", 51 | "mkdocstrings[python]>=0.25", 52 | ] 53 | num = [ 54 | "numba>=0.59", 55 | ] 56 | 57 | [project.urls] 58 | Homepage = "https://softpotato.xyz" 59 | Repository = "https://github.com/oliverrdz/softpotato" 60 | Issues = "https://github.com/oliverrdz/softpotato/issues" 61 | 62 | # If you expose CLIs later, add e.g.: 63 | # [project.scripts] 64 | # softpotato = "softpotato.cli:main" 65 | 66 | [tool.hatch.build.targets.wheel] 67 | packages = ["src/softpotato"] 68 | include = ["src/softpotato/py.typed"] 69 | 70 | [tool.hatch.build.targets.sdist] 71 | include = [ 72 | "src/softpotato", 73 | "README.md", 74 | "LICENSE", 75 | "pyproject.toml", 76 | ] 77 | 78 | [tool.pytest.ini_options] 79 | minversion = "8.0" 80 | addopts = "-ra -q" 81 | testpaths = ["tests"] 82 | 83 | [tool.ruff] 84 | line-length = 88 85 | target-version = "py310" 86 | src = ["src", "tests"] 87 | 88 | [tool.ruff.lint] 89 | select = [ 90 | # Core correctness 91 | "E", # pycodestyle errors 92 | "F", # pyflakes (undefined names, unused imports, etc.) 93 | 94 | # Imports & modern Python 95 | "I", # import sorting 96 | "UP", # pyupgrade 97 | 98 | # Bug-prone patterns (excellent for simulation code) 99 | "B", # bugbear (mutable defaults, loop gotchas, etc.) 100 | 101 | # NumPy-specific best practices (low-noise, high-value) 102 | "NPY", # numpy rules 103 | 104 | # Ruff “meta” hygiene (keeps lint directives honest) 105 | "RUF", # e.g., unused noqa, ambiguous patterns 106 | 107 | # Performance micro-pitfalls (kept minimal; generally not noisy) 108 | "PERF", # common avoidable slow patterns 109 | 110 | # Readability simplifications (kept; usually safe) 111 | "SIM", 112 | ] 113 | 114 | ignore = [ 115 | "E501", # line length (Black owns this) 116 | "NPY002", # allow np.random.* legacy calls (common in sims); switch later if you want Generator-only 117 | ] 118 | 119 | [tool.ruff.lint.per-file-ignores] 120 | "tests/**/*.py" = [ 121 | "B018", # “useless expression” (common with pytest patterns) 122 | ] 123 | 124 | [tool.mypy] 125 | python_version = "3.10" 126 | warn_return_any = true 127 | warn_unused_configs = true 128 | disallow_untyped_defs = true 129 | no_implicit_optional = true 130 | strict_optional = true 131 | mypy_path = "src" 132 | 133 | [tool.coverage.run] 134 | source = ["softpotato"] 135 | branch = true 136 | 137 | [tool.coverage.report] 138 | show_missing = true 139 | skip_covered = true 140 | 141 | [tool.black] 142 | line-length = 88 143 | target-version = ["py310"] 144 | 145 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SoftPotato 2 | 3 | Thank you for your interest in contributing to **SoftPotato** — an open-source 4 | electrochemistry simulation and numerical modeling toolkit. 5 | 6 | Contributions of all kinds are welcome: bug fixes, numerical improvements, 7 | documentation, examples, and tests. 8 | 9 | --- 10 | 11 | ## Code of Conduct 12 | 13 | Please be respectful and constructive. 14 | This project follows the spirit of the [Contributor Covenant](https://www.contributor-covenant.org/). 15 | 16 | --- 17 | 18 | ## Contributor License Agreement (CLA) 19 | 20 | By submitting a contribution (e.g. a GitHub pull request or commit), you agree to 21 | the terms of the **Contributor License Agreement**: 22 | 23 | 📄 [`CLA.md`](CLA.md) 24 | 25 | In short: 26 | - You keep copyright to your contributions 27 | - You grant the maintainer the right to use and relicense them as part of SoftPotato 28 | 29 | If you do not agree with the CLA, please do not submit contributions. 30 | 31 | --- 32 | 33 | ## How to Contribute 34 | 35 | ### 1. Fork and branch 36 | 37 | 1. Fork the repository on GitHub 38 | 2. Create a feature branch from `main`: 39 | 40 | ```bash 41 | git checkout -b feature/my-change 42 | ``` 43 | --- 44 | 45 | ### 2. Set up a development environment 46 | SoftPotato uses a standard PEP 517/518 setup. 47 | ```bash 48 | pip install -e .[dev] 49 | ``` 50 | This installs: 51 | * pytest 52 | * ruff 53 | * black 54 | * mypy 55 | * pre-commit (optional but recommended) 56 | 57 | --- 58 | 59 | ### 3. Coding standards 60 | Python style 61 | * Python ≥ 3.10 62 | * Follow PEP 8 63 | * Format code with Black 64 | * Lint with Ruff 65 | * Type-check with mypy (where applicable) 66 | 67 | Before submitting: 68 | ```bash 69 | ruff check . 70 | black . 71 | pytest 72 | ``` 73 | CI will enforce these checks. 74 | 75 | --- 76 | 77 | ### 4. Numerical & scientific guidelines 78 | 79 | Because SoftPotato is a numerical simulation library, please ensure: 80 | 81 | * Units and physical meaning are documented 82 | * Sign conventions (flux, current, charge) are explicit 83 | * New solvers or operators include: 84 | * stability considerations 85 | * references where appropriate 86 | * Avoid silent failures (NaNs, negative concentrations, divergence) 87 | 88 | If in doubt, add a test. 89 | 90 | --- 91 | 92 | ### 5. Tests 93 | 94 | All new features and bug fixes must include tests. 95 | 96 | * Tests use pytest 97 | * Place tests under tests/ 98 | * Prefer small, deterministic tests over long simulations 99 | * Numerical comparisons should use tolerances (np.allclose, etc.) 100 | 101 | Pull requests without tests may be rejected unless the change is trivial. 102 | 103 | --- 104 | 105 | ### 6. Documentation 106 | 107 | If your change affects: 108 | 109 | * public APIs 110 | * numerical behavior 111 | * assumptions or limitations 112 | 113 | please update: 114 | * docstrings 115 | * README or docs (if applicable) 116 | 117 | Clear documentation is as important as correct code. 118 | 119 | --- 120 | 121 | ## Commit messages 122 | Please use clear, descriptive commit messages: 123 | ``` 124 | Add Crank–Nicolson integrator for planar diffusion 125 | Fix sign error in Butler–Volmer flux 126 | Docs: clarify EC mechanism assumptions 127 | ``` 128 | 129 | --- 130 | 131 | ## Pull Request Checklist 132 | 133 | Before submitting a PR, please ensure: 134 | 135 | * [ ] Code builds and tests pass locally 136 | * [ ] New functionality includes tests 137 | * [ ] Linting and formatting are clean 138 | * [ ] Documentation is updated if needed 139 | * [ ] You agree to the CLA (CLA.md) 140 | 141 | --- 142 | 143 | ## Reporting Issues 144 | 145 | If you find a bug or want to propose a feature: 146 | 147 | * Open a GitHub Issue 148 | * Include: 149 | * expected vs actual behavior 150 | * minimal reproducible example 151 | * plots or equations if relevant 152 | 153 | --- 154 | 155 | ## Questions & Discussion 156 | 157 | For design questions or larger changes, please open an issue before 158 | starting major work. This helps align contributions with the project roadmap. 159 | 160 | --- 161 | 162 | Thank you for helping improve Soft Potato 163 | 164 | 165 | -------------------------------------------------------------------------------- /docs/waveforms.md: -------------------------------------------------------------------------------- 1 | # Waveforms (Potential Programs) — M1 2 | 3 | SoftPotato waveforms are **potential programs**, not simulations. 4 | 5 | A waveform is a NumPy array with: 6 | 7 | - Shape: `(n, 2)` 8 | - Column order: `[E, t]` 9 | - Units: potential `E` in volts (V), time `t` in seconds (s) 10 | 11 | Waveforms do **not**: 12 | - Solve diffusion equations 13 | - Compute currents 14 | - Apply kinetics 15 | 16 | --- 17 | 18 | ## Canonical waveform format 19 | 20 | Given a waveform `w`: 21 | 22 | - `w[:, 0]` is potential `E` in volts (V) 23 | - `w[:, 1]` is time `t` in seconds (s) 24 | 25 | Time must be strictly increasing. 26 | 27 | --- 28 | 29 | ## lsv(E_start, E_end, scan_rate, dt) 30 | 31 | Generate a **linear sweep voltammetry (LSV)** potential program: a linear ramp from `E_start` to `E_end`. 32 | 33 | ### Parameters 34 | 35 | - `E_start` (float, V) 36 | Start potential in volts. 37 | 38 | - `E_end` (float, V) 39 | End potential in volts. 40 | 41 | - `scan_rate` (float, V/s) 42 | Scan rate in volts per second. Must be positive. 43 | 44 | - `dt` (float, s) 45 | Time step in seconds. Must be positive. 46 | 47 | ### Returns 48 | 49 | - `w` (numpy.ndarray, shape `(n, 2)`) 50 | Waveform array with columns `[E, t]`. 51 | 52 | ### Example 53 | 54 | ```python 55 | import softpotato as sp 56 | w = sp.lsv(E_start=0.0, E_end=1.0, scan_rate=0.5, dt=0.1) 57 | print(w.shape) 58 | print(w[:5]) 59 | ``` 60 | 61 | --- 62 | 63 | ## cv(E_start, E_vertex, E_end, scan_rate, dt) 64 | 65 | Generate a **cyclic voltammetry (CV)** potential program: a linear ramp from `E_start` to `E_vertex`, then a reversal to `E_end`. 66 | 67 | ### Parameters 68 | 69 | - `E_start` (float, V) 70 | Start potential in volts. 71 | 72 | - `E_vertex` (float, V) 73 | Vertex (turning point) potential in volts. 74 | 75 | - `E_end` (float, V) 76 | End potential in volts. 77 | 78 | - `scan_rate` (float, V/s) 79 | Scan rate in volts per second. Must be positive. 80 | 81 | - `dt` (float, s) 82 | Time step in seconds. Must be positive. 83 | 84 | ### Returns 85 | 86 | - `w` (numpy.ndarray, shape `(n, 2)`) 87 | Waveform array with columns `[E, t]`. 88 | 89 | ### Notes 90 | 91 | - The **exact vertex index** depends on how `dt` aligns with the vertex time. 92 | - Small changes in `dt` can shift the turning index. 93 | 94 | ### Example 95 | 96 | ```python 97 | import softpotato as sp 98 | w = sp.cv(E_start=0.0, E_vertex=1.0, E_end=0.0, scan_rate=1.0, dt=0.1) 99 | print(w.shape) 100 | print(w[:5]); print(w[-5:]) 101 | ``` 102 | 103 | --- 104 | 105 | ## step(E_before, E_after, t_step, dt, n) 106 | 107 | Generate a **step potential** program: potential is `E_before` up to (but not including) `t_step`, then `E_after` from `t_step` onward. 108 | 109 | ### Parameters 110 | 111 | - `E_before` (float, V) 112 | Potential before the step, in volts. 113 | 114 | - `E_after` (float, V) 115 | Potential after the step, in volts. 116 | 117 | - `t_step` (float, s) 118 | Step time in seconds. Defines the boundary: 119 | - for `t < t_step` → `E_before` 120 | - for `t >= t_step` → `E_after` 121 | 122 | - `dt` (float, s) 123 | Time step in seconds. Must be positive. 124 | 125 | - `n` (int, dimensionless) 126 | Number of points in the waveform (length of the time grid). Must be an integer ≥ 1. 127 | 128 | ### Returns 129 | 130 | - `w` (numpy.ndarray, shape `(n, 2)`) 131 | Waveform array with columns `[E, t]`. 132 | 133 | ### Example 134 | 135 | ```python 136 | import softpotato as sp 137 | w = sp.step(E_before=0.0, E_after=1.0, t_step=0.5, dt=0.1, n=11) 138 | print(w.shape) 139 | print(w) 140 | ``` 141 | 142 | --- 143 | 144 | ## waveform_from_arrays(E, t) 145 | 146 | Create a waveform directly from explicit arrays. 147 | 148 | ### Parameters 149 | 150 | - `E` (array-like of float, V) 151 | Potential samples in volts. Must be finite and one-dimensional. 152 | 153 | - `t` (array-like of float, s) 154 | Time samples in seconds. Must be finite, one-dimensional, and strictly increasing. 155 | 156 | ### Returns 157 | 158 | - `w` (numpy.ndarray, shape `(n, 2)`) 159 | Waveform array with columns `[E, t]`. 160 | 161 | ### Example 162 | 163 | ```python 164 | import numpy as np 165 | import softpotato as sp 166 | t = np.array([0.0, 0.5, 1.0]) 167 | E = np.array([0.0, 0.2, 0.4]) 168 | w = sp.waveform_from_arrays(E=E, t=t) 169 | print(w.shape) 170 | print(w) 171 | ``` 172 | 173 | -------------------------------------------------------------------------------- /src/softpotato/waveforms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import numpy as np 4 | 5 | from .validation import ( 6 | build_potential_segment, 7 | validate_cycles, 8 | validate_finite_float, 9 | validate_positive_float, 10 | ) 11 | 12 | 13 | def lsv(E_start: float, E_end: float, dE: float, scan_rate: float) -> np.ndarray: 14 | """ 15 | Linear sweep voltammetry (LSV): single monotonic ramp E_start -> E_end. 16 | 17 | Sampling: 18 | - Potential samples use nominal increment dE (direction inferred). 19 | - Endpoint is enforced exactly (E_end). 20 | - Time is derived from scan_rate: dt_step = dE / scan_rate 21 | - t[i] = i * dt_step 22 | 23 | Returns 24 | ------- 25 | np.ndarray 26 | Array shape (n, 2) with columns [E, t]. 27 | """ 28 | validate_finite_float(E_start, name="E_start") 29 | validate_finite_float(E_end, name="E_end") 30 | validate_positive_float(dE, name="dE") 31 | validate_positive_float(scan_rate, name="scan_rate") 32 | 33 | E = build_potential_segment(E_start, E_end, dE, drop_first=False) 34 | dt_step = dE / scan_rate 35 | t = dt_step * np.arange(E.size, dtype=float) 36 | 37 | wave = np.column_stack([E, t]) 38 | return wave 39 | 40 | 41 | def cv( 42 | E_start: float, 43 | E_vertex: float, 44 | scan_rate: float, 45 | dE: float, 46 | E_end: float | None = None, 47 | cycles: int = 1, 48 | ) -> np.ndarray: 49 | """ 50 | Cyclic voltammetry (CV): repeat a cycle path with constant dE and derived time. 51 | 52 | One cycle: 53 | Segment A: E_start -> E_vertex 54 | Segment B: E_vertex -> E_end_cycle 55 | where E_end_cycle = E_end if provided else E_start 56 | 57 | Join-point de-duplication: 58 | - Do not repeat E_vertex at A/B boundary. 59 | - When repeating cycles, drop the first sample of the next cycle if it would 60 | duplicate the previous cycle's last sample (within tolerance). 61 | 62 | Time: 63 | - dt_step = dE / scan_rate 64 | - Each potential sample advances by dt_step 65 | - Time is strictly increasing across the full waveform. 66 | 67 | Returns 68 | ------- 69 | np.ndarray 70 | Array shape (n, 2) with columns [E, t]. 71 | """ 72 | validate_finite_float(E_start, name="E_start") 73 | validate_finite_float(E_vertex, name="E_vertex") 74 | if E_end is not None: 75 | validate_finite_float(E_end, name="E_end") 76 | validate_positive_float(dE, name="dE") 77 | validate_positive_float(scan_rate, name="scan_rate") 78 | validate_cycles(cycles) 79 | 80 | E_end_cycle = E_start if E_end is None else float(E_end) 81 | 82 | E_all: list[np.ndarray] = [] 83 | 84 | for k in range(cycles): 85 | seg_a = build_potential_segment(E_start, E_vertex, dE, drop_first=False) 86 | seg_b = build_potential_segment(E_vertex, E_end_cycle, dE, drop_first=True) 87 | E_cycle = np.concatenate([seg_a, seg_b]) 88 | 89 | # Avoid duplicating boundary sample between cycles if it matches. 90 | if k > 0 and E_all: 91 | prev_last = E_all[-1][-1] 92 | if np.isclose(E_cycle[0], prev_last, atol=1e-12, rtol=0.0): 93 | E_cycle = E_cycle[1:] 94 | 95 | E_all.append(E_cycle) 96 | 97 | E = np.concatenate(E_all) if E_all else np.array([float(E_start)], dtype=float) 98 | dt_step = dE / scan_rate 99 | t = dt_step * np.arange(E.size, dtype=float) 100 | 101 | wave = np.column_stack([E, t]) 102 | return wave 103 | 104 | 105 | def step(E_before: float, E_after: float, dt: float, t_end: float) -> np.ndarray: 106 | """ 107 | Potential step waveform. 108 | 109 | Time grid: 110 | - n = floor(t_end / dt) + 1 111 | - t = [0, dt, 2dt, ..., (n-1)dt] 112 | - No snapping: t[-1] <= t_end and t[-1] > t_end - dt 113 | 114 | Potential: 115 | - Instantaneous step at t = 0 (recommended spec): 116 | E[0] = E_after and E is constant E_after for all samples. 117 | 118 | Returns 119 | ------- 120 | np.ndarray 121 | Array shape (n, 2) with columns [E, t]. 122 | """ 123 | validate_finite_float(E_before, name="E_before") 124 | validate_finite_float(E_after, name="E_after") 125 | validate_positive_float(dt, name="dt") 126 | validate_positive_float(t_end, name="t_end") 127 | 128 | n = int(np.floor(t_end / dt)) + 1 129 | t = dt * np.arange(n, dtype=float) 130 | 131 | E = np.full(n, float(E_after), dtype=float) 132 | 133 | wave = np.column_stack([E, t]) 134 | return wave 135 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # SoftPotato v3.0 Documentation 2 | 3 | Welcome to the documentation for **SoftPotato v3.0**, a modular electrochemistry simulation toolkit focused on **deterministic, validated, and testable numerical experiments**. 4 | 5 | This documentation reflects the project state at **Milestones M0 and M1**, and provides a roadmap for upcoming scientific capabilities. 6 | 7 | --- 8 | 9 | ## What is SoftPotato? 10 | 11 | SoftPotato is a ground-up rewrite of the SoftPotato electrochemistry framework with the following goals: 12 | 13 | - Clear, minimal scientific APIs 14 | - Deterministic numerical behavior 15 | - Strict validation and error handling 16 | - Modular design aligned with electrochemistry literature 17 | - Milestone-driven development (each milestone is usable and testable) 18 | 19 | SoftPotato is intended for **scientists**, not just developers. You should be able to understand what the code does without reading the source. 20 | 21 | --- 22 | 23 | ## Current project state 24 | 25 | - Version: v3.0 (pre-release) 26 | - Current milestone: **M1 – Time Grids & Potential Waveforms** 27 | - License: MIT 28 | - Language: Python (NumPy-based) 29 | 30 | --- 31 | 32 | ## Milestone M0 – Project Skeleton 33 | 34 | **Status: Complete** 35 | 36 | M0 establishes the **non-scientific foundation** of SoftPotato. 37 | 38 | ### What M0 provides 39 | 40 | - A valid, installable Python package 41 | - Versioning and package metadata 42 | - Repository structure (`src/`, `tests/`, `docs/`) 43 | - CI-ready tooling for linting, typing, and testing 44 | - No scientific functionality 45 | 46 | ### What M0 does not provide 47 | 48 | - No electrochemical models 49 | - No waveforms 50 | - No time grids 51 | - No numerical simulations 52 | 53 | M0 exists to ensure that everything built later has a solid, maintainable base. 54 | 55 | --- 56 | 57 | ## Milestone M1 – Time Grids & Potential Waveforms 58 | 59 | **Status: Complete** 60 | 61 | M1 introduces the **first scientific API** in SoftPotato: deterministic definitions of electrochemical experiments. 62 | 63 | The goal of M1 is to define **time and potential in a single, validated representation** that later solvers (diffusion, kinetics, mechanisms) can consume. 64 | 65 | --- 66 | 67 | ### Scientific outputs (M1) 68 | 69 | All waveforms return: 70 | 71 | - A NumPy array of shape `(n, 2)` 72 | - Column 0: potential `E` 73 | - Column 1: time `t` 74 | - `t` is always strictly increasing 75 | 76 | --- 77 | 78 | ### Available waveforms 79 | 80 | #### Linear Sweep Voltammetry (LSV) 81 | 82 | ### lsv(E_start, E_end, dE, scan_rate) 83 | 84 | - Potential resolution defined by `dE` 85 | - Time step derived as: 86 | 87 | dt_step = dE / scan_rate 88 | 89 | - Exact endpoint enforcement 90 | - Supports increasing and decreasing sweeps 91 | 92 | --- 93 | 94 | #### Cyclic Voltammetry (CV) 95 | 96 | ### cv(E_start, E_vertex, scan_rate, dE, E_end=None, cycles=1) 97 | 98 | - Deterministic forward and reverse scans 99 | - No duplicated vertex points 100 | - No duplicated samples between cycles 101 | - Time strictly increasing across cycles 102 | 103 | --- 104 | 105 | #### Potential Step 106 | 107 | ### step(E_before, E_after, dt, t_end) 108 | 109 | - Uniform time grid with explicit `dt` 110 | - Instantaneous step at `t = 0` 111 | - No snapping to `t_end` 112 | 113 | --- 114 | 115 | ## Design guarantees (M1) 116 | 117 | - Resolution-driven APIs (`dE` or `dt`, not array length) 118 | - Internally derived time grids 119 | - Strict input validation 120 | - Deterministic output 121 | - No hidden smoothing or adaptive behavior 122 | 123 | --- 124 | 125 | ## Documentation structure 126 | 127 | - `index.md` – Project overview and roadmap (this page) 128 | - `waveforms.md` – Detailed waveform behavior and examples 129 | - `ROADMAP.md` – Full milestone plan 130 | - `CHANGELOG.md` – Versioned changes 131 | 132 | --- 133 | 134 | ## Roadmap summary 135 | 136 | SoftPotato v3.0 is developed in **incremental, reviewable milestones**. 137 | 138 | ### Completed 139 | 140 | - **M0** – Project skeleton 141 | - **M1** – Time grids & potential waveforms 142 | 143 | --- 144 | 145 | ### Planned 146 | 147 | - **M2 – Diffusion** 148 | - 1D diffusion solvers 149 | - Finite-difference discretization 150 | - Boundary conditions tied to waveform output 151 | 152 | - **M3 – Electrode kinetics** 153 | - Butler–Volmer and related rate laws 154 | - Coupling between surface concentration and current 155 | 156 | - **M4 – Mechanism composition** 157 | - E, EC, CE, ECE mechanisms 158 | - Modular reaction graphs 159 | 160 | - **M5 – Validation** 161 | - Comparison against analytical solutions 162 | - Regression tests against literature benchmarks 163 | 164 | Each milestone builds **only** on completed, validated behavior from previous milestones. 165 | 166 | --- 167 | 168 | ## Getting started 169 | 170 | ### Development install 171 | 172 | ```bash 173 | pip install -e ".[dev]" 174 | ``` 175 | 176 | ### Run tests 177 | 178 | ```bash 179 | pytest 180 | ``` 181 | 182 | ### Build and preview docs 183 | 184 | ```bash 185 | mkdocs serve 186 | ``` 187 | 188 | --- 189 | 190 | ## Philosophy 191 | 192 | SoftPotato is designed around the idea that: 193 | 194 | - Numerical experiments should be reproducible 195 | - APIs should reflect physical assumptions 196 | - Validation should happen at the boundary, not downstream 197 | - Scientific code deserves the same rigor as production software 198 | 199 | If you are looking for a transparent, extensible foundation for electrochemical simulations, you are in the right place. 200 | 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SoftPotato v3.0 2 | 3 | SoftPotato is a **modular electrochemistry simulation toolkit** designed for scientists who want transparent, deterministic, and testable numerical experiments. 4 | 5 | Version 3.0 is a ground-up rewrite focused on **clean scientific APIs**, strict validation, and a milestone-driven roadmap. 6 | 7 | This README documents the project state at **Milestone M0 and Milestone M1**. 8 | 9 | --- 10 | 11 | ## Project status 12 | 13 | - Version: v3.0 (pre-release) 14 | - Current milestone: **M1 – Time Grids & Potential Waveforms** 15 | - License: MIT 16 | - Intended audience: electrochemists, physical chemists, scientific Python users 17 | 18 | --- 19 | 20 | ## Installation 21 | 22 | ### Development install 23 | 24 | ```bash 25 | pip install -e ".[dev]" 26 | ``` 27 | 28 | This installs SoftPotato in editable mode together with linting, typing, and test dependencies. 29 | 30 | --- 31 | 32 | ## Milestone overview 33 | 34 | ### M0 – Project Skeleton (Completed) 35 | 36 | M0 establishes the **non-scientific foundation** of the project. 37 | 38 | #### Goals 39 | 40 | - Create a valid, installable Python package 41 | - Establish versioning, licensing, and repository structure 42 | - Set up CI-compatible tooling (lint, type-check, tests) 43 | - Provide no scientific behavior yet 44 | 45 | #### What exists in M0 46 | 47 | - Importable `softpotato` package 48 | - Exposed version string 49 | - Project metadata in `pyproject.toml` 50 | - Empty but structured `src/`, `tests/`, and `docs/` 51 | - ROADMAP and CHANGELOG scaffolding 52 | 53 | #### What does NOT exist in M0 54 | 55 | - No electrochemical models 56 | - No solvers 57 | - No waveforms 58 | - No time grids 59 | - No numerical results 60 | 61 | M0 is purely structural. 62 | 63 | --- 64 | 65 | ### M1 – Time Grids & Potential Waveforms (Completed) 66 | 67 | M1 introduces the **first scientific API**: deterministic experiment definitions. 68 | 69 | The goal of M1 is to provide a **single, validated representation of time and potential** that later solvers can consume. 70 | 71 | --- 72 | 73 | ## Scientific API (as of M1) 74 | 75 | All scientific outputs are NumPy arrays of shape `(n, 2)`: 76 | 77 | - Column 0: potential `E` 78 | - Column 1: time `t` 79 | 80 | Time is **always strictly increasing**. 81 | 82 | --- 83 | 84 | ### Potential waveforms 85 | 86 | #### Linear Sweep Voltammetry (LSV) 87 | 88 | ```python 89 | lsv(E_start, E_end, dE, scan_rate) 90 | ``` 91 | 92 | - Users specify potential resolution via `dE` 93 | - Time step is derived internally: 94 | 95 | dt_step = dE / scan_rate 96 | 97 | - Exact endpoint enforcement 98 | - Supports increasing and decreasing sweeps 99 | 100 | --- 101 | 102 | #### Cyclic Voltammetry (CV) 103 | 104 | ```python 105 | cv(E_start, E_vertex, scan_rate, dE, E_end=None, cycles=1) 106 | ``` 107 | 108 | - Forward and reverse scans constructed deterministically 109 | - No duplicated vertex points 110 | - No duplicated samples between cycles 111 | - Time strictly increasing across cycles 112 | 113 | --- 114 | 115 | #### Potential Step 116 | 117 | ```python 118 | step(E_before, E_after, dt, t_end) 119 | ``` 120 | 121 | - Uniform time grid with explicit `dt` 122 | - Instantaneous step at `t = 0` 123 | - No snapping to `t_end` 124 | - Final time sample satisfies: 125 | 126 | t[-1] ≤ t_end and t[-1] > t_end − dt 127 | 128 | --- 129 | 130 | ## Example usage 131 | 132 | ### Linear sweep 133 | 134 | ```python 135 | import softpotato as sp 136 | w = sp.lsv(0.0, 1.0, dE=0.25, scan_rate=0.5) 137 | print(w) 138 | ``` 139 | 140 | --- 141 | 142 | ### Cyclic voltammetry 143 | 144 | ```python 145 | import softpotato as sp 146 | w = sp.cv(0.0, 1.0, scan_rate=0.5, dE=0.5) 147 | print(w) 148 | ``` 149 | 150 | --- 151 | 152 | ### Potential step 153 | 154 | ```python 155 | import softpotato as sp 156 | w = sp.step(0.0, 1.0, dt=0.01, t_end=0.04) 157 | print(w) 158 | ``` 159 | 160 | --- 161 | 162 | ## Validation guarantees (M1) 163 | 164 | All waveform constructors enforce: 165 | 166 | - Finite numeric inputs 167 | - Positive `dE`, `scan_rate`, `dt`, and `t_end` 168 | - Integer `cycles ≥ 1` 169 | - Deterministic output 170 | - Strictly increasing time 171 | 172 | Invalid inputs raise clear `TypeError` or `ValueError`. 173 | 174 | --- 175 | 176 | ## What is intentionally out of scope (≤ M1) 177 | 178 | - Diffusion solvers 179 | - Butler–Volmer kinetics 180 | - Double-layer capacitance 181 | - iR drop 182 | - Adaptive or non-uniform grids 183 | - Mechanism definitions (E, EC, CE, etc.) 184 | 185 | These are planned for later milestones. 186 | 187 | --- 188 | 189 | ## Roadmap snapshot 190 | 191 | - M0: Project skeleton ✔ 192 | - M1: Time grids & potential waveforms ✔ 193 | - M2: 1D diffusion solvers 194 | - M3: Electrode kinetics 195 | - M4: Mechanism composition 196 | - M5: Validation against analytical solutions 197 | 198 | See `ROADMAP.md` for full details. 199 | 200 | --- 201 | 202 | ## Documentation 203 | 204 | - `docs/waveforms.md` – Detailed waveform behavior and examples 205 | - `ROADMAP.md` – Milestone plan and scope 206 | - `CHANGELOG.md` – Versioned changes 207 | 208 | --- 209 | 210 | ## Development workflow 211 | 212 | ### Lint 213 | 214 | ```bash 215 | ruff check . 216 | ruff format . 217 | ``` 218 | 219 | ### Type-check 220 | 221 | ```bash 222 | mypy . 223 | ``` 224 | 225 | ### Tests 226 | 227 | ```bash 228 | pytest 229 | ``` 230 | 231 | --- 232 | 233 | ## Design philosophy 234 | 235 | SoftPotato prioritizes: 236 | 237 | - Determinism over convenience 238 | - Explicit validation over silent assumptions 239 | - Electrochemistry-first terminology 240 | - Testability at every milestone 241 | 242 | The project is designed so each milestone produces **scientifically meaningful, reviewable behavior**. 243 | 244 | --- 245 | 246 | ## License 247 | 248 | MIT License. See `LICENSE` for details. 249 | 250 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # SoftPotato v3.0 — Transport & Mechanisms Roadmap (1D Planar FD Engine) 2 | 3 | This roadmap defines the delivery plan for the **SoftPotato v3.0** core numerical engine 4 | supporting **1D planar diffusion (Fick)** with selectable **grids**, **solvers**, **potential waveforms**, 5 | and user-selectable **mechanisms** (E / EC / CE / EE / arbitrary E–C networks). 6 | 7 | Naming alignment (SoftPotato v3.0): 8 | - **Waveforms**: potential programs used by the SoftPotato “wizard” / experiment builders 9 | - **Species**: `Species` objects (D, bulk concentration, charge, etc.) 10 | - **Geometry**: v1 is **Planar 1D**; future geometries can follow the same interface 11 | - **Mechanisms**: composed from **E steps** (electrode) and **C steps** (chemical bulk) 12 | - **Engine**: FD operator + time integrators (CN/BE) + BC handlers 13 | 14 | --- 15 | 16 | ## SP3-M0 — Repo scaffolding & CI 17 | **Goal:** installable SoftPotato v3 module with CI gates. 18 | 19 | ### Tasks 20 | - [x] Create `pyproject.toml` for `softpotato` / `softpotato.core` (PEP 517/518) 21 | - [x] Establish `src/softpotato/` package layout for the engine module 22 | - [x] Add `pytest` harness + smoke tests 23 | - [x] Configure linting (ruff / black) 24 | - [x] GitHub Actions: tests + lint on PR/push 25 | - [x] Initial docs skeleton (`README.md`, `docs/`) 26 | 27 | ### Acceptance criteria 28 | - [x] `pip install -e .` succeeds 29 | - [x] `pytest` passes on clean checkout 30 | - [x] CI green on main 31 | 32 | --- 33 | 34 | ## SP3-M1 — Waveforms (Potential Programs) 35 | **Goal:** waveform generators return `np.ndarray (n,2)` with columns `[E, t]`. 36 | 37 | ### M1: Time grids & potential waveforms (update) 38 | 39 | - Provide deterministic potential waveform generators that return `np.ndarray (n,2)` with columns `[E, t]`. 40 | - Scan waveforms (LSV/CV) are specified by potential resolution `dE` and `scan_rate`, and derive time internally via: 41 | - `dt_step = dE / scan_rate` 42 | - Step waveforms are specified by time resolution `dt` and `t_end`, and derive the uniform time array internally. 43 | - No adaptive stepping, smoothing, iR-drop, or capacitance in M1. 44 | 45 | 46 | ### Tasks 47 | - [x] Linear sweep (LSV) 48 | - [x] Cyclic voltammetry (CV, multi-cycle) 49 | - [x] Potential step 50 | - [x] Validation utilities (shape, monotonic time) 51 | - [x] From-arrays constructor 52 | 53 | ### Acceptance criteria 54 | **Tests** 55 | - shape `(n,2)`; time strictly increasing; scan rate matches; CV turning points correct 56 | 57 | **Plots** 58 | - example scripts produce `E(t)` for CV and LSV without errors 59 | 60 | --- 61 | 62 | ## SP3-M2 — Geometry: Planar1D grid & diffusion operator 63 | **Goal:** accurate spatial discretization for Planar 1D. 64 | 65 | ### Tasks 66 | - [ ] `PlanarGrid1D` (uniform initially) + node conventions 67 | - [ ] Laplacian operator (tri-diagonal bands) 68 | - [ ] Stability diagnostics (explicit dt limit estimator) 69 | 70 | ### Acceptance criteria 71 | **Tests** 72 | - grid monotonicity; Laplacian of `x^2` ≈ constant; operator shapes correct 73 | 74 | **Plots** 75 | - grid spacing sanity plot 76 | 77 | --- 78 | 79 | ## SP3-M3 — Engine: time integrators (diffusion only) 80 | **Goal:** stable diffusion solver foundation. 81 | 82 | ### Tasks 83 | - [ ] Thomas solver (tri-diagonal) 84 | - [ ] Backward Euler integrator 85 | - [ ] Crank–Nicolson integrator (default) 86 | - [ ] Dirichlet boundary support (x=0 and x=L) 87 | - [ ] `SimulationResult` container + `simulate_planar_1d()` skeleton 88 | 89 | ### Acceptance criteria 90 | **Tests** 91 | - gaussian diffusion trend; CN/BE stable for large dt; no negative concentrations 92 | 93 | **Plots** 94 | - diffusion relaxation example `c(x,t)` 95 | 96 | --- 97 | 98 | ## SP3-M4 — Mechanism: E (reversible/Nernst) 99 | **Goal:** single electrode step `O + e ⇌ R` at x=0 with Nernst constraint. 100 | 101 | ### Tasks 102 | - [ ] multi-species state array 103 | - [ ] reversible boundary (Nernst / algebraic constraint) 104 | - [ ] flux → current conversion (sign conventions documented) 105 | - [ ] far-field bulk boundary at x=L 106 | 107 | ### Acceptance criteria 108 | **Tests** 109 | - surface ratio matches Nernst; flux sign/charge balance consistent; correct limiting behavior 110 | 111 | **Plots** 112 | - reversible CV: `E(t)`, `i(t)`, and `i(E)` 113 | 114 | --- 115 | 116 | ## SP3-M5 — Mechanism: E (Butler–Volmer) 117 | **Goal:** quasi-reversible electrode kinetics. 118 | 119 | ### Tasks 120 | - [ ] Butler–Volmer flux law boundary 121 | - [ ] safeguards against negative concentrations / divergence 122 | - [ ] per-step current output 123 | 124 | ### Acceptance criteria 125 | **Tests** 126 | - BV → Nernst as `k0 → ∞`; smaller `k0` reduces current; no NaNs 127 | 128 | **Plots** 129 | - peak separation vs `k0` example 130 | 131 | --- 132 | 133 | ## SP3-M6 — Mechanism: C steps (bulk kinetics) + operator splitting 134 | **Goal:** enable EC and CE (diffusion–reaction). 135 | 136 | ### Tasks 137 | - [ ] mass-action C-step kinetics (start: first-order; then reversible) 138 | - [ ] reaction term assembly `R(C)` for all species 139 | - [ ] operator splitting: diffusion step + reaction step per node 140 | - [ ] mass-balance diagnostics for closed systems 141 | 142 | ### Acceptance criteria 143 | **Tests** 144 | - first-order decay matches analytic; closed-system mass conserved; dt convergence 145 | 146 | **Plots** 147 | - EC chrono + CV dependence on k 148 | 149 | --- 150 | 151 | ## SP3-M7 — Mechanism framework + compiler (E / EC / CE) 152 | **Goal:** user selects mechanisms via a SoftPotato v3 mechanism builder. 153 | 154 | ### Tasks 155 | - [ ] `EStep` and `CStep` objects (explicit species mapping) 156 | - [ ] `Mechanism` builder API 157 | - [ ] mechanism compiler wiring species/reactions/boundaries/observables 158 | - [ ] optional shorthand parser (`"E"`, `"EC"`, `"CE"`) only when mapping provided 159 | 160 | ### Acceptance criteria 161 | **Tests** 162 | - species registry stable; invalid mechanisms raise clear errors; compiled EC ≈ manual EC 163 | 164 | **Plots** 165 | - one example per mechanism using only `Mechanism()` + waveform + engine 166 | 167 | --- 168 | 169 | ## SP3-M8 — Multi-E (EE) + surface solver 170 | **Goal:** multiple electrode steps with robust surface nonlinear solve. 171 | 172 | ### Tasks 173 | - [ ] multi-E boundary handling 174 | - [ ] surface Newton solver with damping/fallback 175 | - [ ] per-step currents + total current 176 | 177 | ### Acceptance criteria 178 | **Tests** 179 | - additivity for independent couples; sequential redox yields two waves; solver converges robustly 180 | 181 | **Plots** 182 | - two-wave CV + per-step current breakdown 183 | 184 | --- 185 | 186 | ## SP3-M9 — General E–C networks + robustness 187 | **Goal:** arbitrary E/C networks (ECE, EC′, branching). 188 | 189 | ### Tasks 190 | - [ ] stoichiometric matrix representation 191 | - [ ] branched & reversible C steps 192 | - [ ] network diagnostics (conservation, negativity, convergence) 193 | - [ ] optional adaptive dt or fully coupled implicit mode for stiff systems 194 | 195 | ### Acceptance criteria 196 | **Tests** 197 | - conserved quantities remain constant; stiff systems stable; parser/compiler consistency 198 | 199 | **Plots** 200 | - catalytic EC′ signature + branching selectivity demo 201 | 202 | --- 203 | 204 | ## Definition of Done (per PR) 205 | - Tests added/updated 206 | - Examples updated if public API changes 207 | - Docs updated 208 | - CI passes 209 | 210 | -------------------------------------------------------------------------------- /tests/test_m0_skeleton.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import sys 5 | from importlib import metadata, resources 6 | from pathlib import Path 7 | from types import ModuleType 8 | from typing import Any 9 | 10 | import pytest 11 | 12 | 13 | def _repo_root() -> Path: 14 | return Path(__file__).resolve().parents[1] 15 | 16 | 17 | def _read_text(path: Path) -> str: 18 | return path.read_text(encoding="utf-8", errors="strict") 19 | 20 | 21 | def _ensure_src_on_path() -> None: 22 | """Allow running `pytest` from a clean checkout without requiring editable install.""" 23 | root = _repo_root() 24 | src = root / "src" 25 | if src.is_dir(): 26 | sys.path.insert(0, str(src)) 27 | 28 | 29 | def _import_softpotato() -> ModuleType: 30 | try: 31 | import softpotato # type: ignore 32 | 33 | return softpotato 34 | except ModuleNotFoundError: 35 | _ensure_src_on_path() 36 | try: 37 | import softpotato # type: ignore 38 | 39 | return softpotato 40 | except ModuleNotFoundError as e: 41 | root = _repo_root() 42 | hint = ( 43 | "Could not import 'softpotato'. Expected either:\n" 44 | " (a) editable install: `pip install -e .`\n" 45 | " (b) src layout present: `src/softpotato/__init__.py`\n" 46 | f"Repo root: {root}\n" 47 | f"Checked for: {root / 'src' / 'softpotato' / '__init__.py'}" 48 | ) 49 | raise AssertionError(hint) from e 50 | 51 | 52 | def _find_ci_workflow(root: Path) -> Path: 53 | candidates = [ 54 | root / ".github" / "workflows" / "ci.yml", 55 | root / ".github" / "workflows" / "ci.yaml", 56 | root / ".github" / "workflows" / "tests.yml", 57 | root / ".github" / "workflows" / "tests.yaml", 58 | ] 59 | for p in candidates: 60 | if p.is_file(): 61 | return p 62 | pytest.fail( 63 | "Missing CI workflow. Expected one of: " 64 | + ", ".join(str(p.relative_to(root)) for p in candidates) 65 | ) 66 | raise AssertionError 67 | 68 | 69 | # --- Acceptance: Install + Tests (import + version) --- 70 | 71 | 72 | def test_imports_and_exposes_version_string() -> None: 73 | """ 74 | AC: 75 | - Install: `python -c "import softpotato; print(softpotato.__version__)"` prints a version string. 76 | - Tests: at least one smoke test asserts import + expected top-level symbols. 77 | """ 78 | softpotato = _import_softpotato() 79 | 80 | assert hasattr(softpotato, "__version__"), "softpotato.__version__ must exist" 81 | v = softpotato.__version__ 82 | assert isinstance(v, str), "__version__ must be a string" 83 | assert v.strip(), "__version__ must be non-empty" 84 | assert re.match( 85 | r"^\d+\.\d+\.\d+(" r"(a|b|rc)\d+|" r"([.-][0-9A-Za-z.]+)" r")?$", 86 | v, 87 | ), f"Unexpected version: {v}" 88 | 89 | 90 | def test_runtime_version_matches_distribution_metadata() -> None: 91 | """ 92 | AC: 93 | - Install: printed version should be consistent with installed package metadata. 94 | Notes: 95 | - If you're running pytest without `pip install -e .`, skip (CI should install). 96 | """ 97 | softpotato = _import_softpotato() 98 | try: 99 | dist_v = metadata.version("softpotato") 100 | except metadata.PackageNotFoundError: 101 | pytest.skip( 102 | "softpotato distribution metadata not found; run `pip install -e .` to enforce this locally" 103 | ) 104 | 105 | assert softpotato.__version__ == dist_v, ( 106 | "softpotato.__version__ should match the installed distribution version " 107 | f"(module={softpotato.__version__!r}, dist={dist_v!r})" 108 | ) 109 | 110 | 111 | # --- Acceptance: Lint/Types intent (typed marker) --- 112 | 113 | 114 | def test_package_is_typed_marker_present() -> None: 115 | """ 116 | AC: 117 | - Lint / Format / Types: skeleton intends to be type-checked; typed marker should exist. 118 | """ 119 | _import_softpotato() 120 | root = resources.files("softpotato") 121 | marker = root.joinpath("py.typed") 122 | assert marker.is_file(), "Missing 'py.typed' marker in softpotato package" 123 | 124 | 125 | # --- Acceptance: Docs skeleton --- 126 | 127 | 128 | def test_docs_skeleton_exists() -> None: 129 | """ 130 | AC: 131 | - Documentation skeleton: docs/ exists with minimal landing page + mkdocs stub. 132 | """ 133 | root = _repo_root() 134 | mkdocs_yml = root / "mkdocs.yml" 135 | docs_index = root / "docs" / "index.md" 136 | 137 | assert mkdocs_yml.is_file(), "mkdocs.yml missing (docs build config stub required)" 138 | assert docs_index.is_file(), "docs/index.md missing (minimal landing page required)" 139 | assert _read_text(docs_index).strip(), "docs/index.md should not be empty" 140 | 141 | 142 | # --- Acceptance: CI workflow presence + gates --- 143 | 144 | 145 | @pytest.mark.parametrize("needle", ["push", "pull_request"]) 146 | def test_ci_workflow_triggers_present(needle: str) -> None: 147 | """ 148 | AC: 149 | - CI: GitHub Actions workflow runs on push + pull_request. 150 | """ 151 | root = _repo_root() 152 | wf = _find_ci_workflow(root) 153 | text = _read_text(wf) 154 | 155 | assert ( 156 | "on:" in text or "\non\n" in text 157 | ), "Workflow should define triggers under `on:`" 158 | assert ( 159 | needle in text 160 | ), f"Workflow trigger '{needle}' not found in {wf.relative_to(root)}" 161 | 162 | 163 | @pytest.mark.parametrize( 164 | "cmd_fragment", ["ruff check", "black --check", "mypy", "pytest"] 165 | ) 166 | def test_ci_workflow_includes_quality_gates(cmd_fragment: str) -> None: 167 | """ 168 | AC: 169 | - CI: workflow enforces ruff, black, mypy, pytest. 170 | """ 171 | root = _repo_root() 172 | wf = _find_ci_workflow(root) 173 | text = _read_text(wf) 174 | 175 | assert cmd_fragment in text, ( 176 | f"Expected CI workflow to run '{cmd_fragment}' " 177 | f"(missing in {wf.relative_to(root)})" 178 | ) 179 | 180 | 181 | # --- Acceptance: Packaging/toolchain invariants (PEP 517/518 + python>=3.10) --- 182 | 183 | 184 | def test_pyproject_declares_hatchling_and_python310_plus() -> None: 185 | """ 186 | AC: 187 | - Constraints: Python >=3.10, packaging via PEP 517/518 (hatchling). 188 | """ 189 | 190 | try: 191 | import tomllib # type: ignore[import-not-found] # py>=3.11 192 | except ModuleNotFoundError: # py<=3.10 193 | import tomli as tomllib # type: ignore[import-not-found] 194 | 195 | root = _repo_root() 196 | pyproject = root / "pyproject.toml" 197 | assert pyproject.is_file(), "pyproject.toml missing" 198 | 199 | data: dict[str, Any] = tomllib.loads(_read_text(pyproject)) 200 | 201 | build = data.get("build-system", {}) 202 | assert ( 203 | build.get("build-backend") == "hatchling.build" 204 | ), "Expected hatchling build backend" 205 | requires = build.get("requires", []) 206 | assert any( 207 | str(req).startswith("hatchling") for req in requires 208 | ), "Expected hatchling in build requirements" 209 | 210 | project = data.get("project", {}) 211 | assert ( 212 | project.get("requires-python") == ">=3.10" 213 | ), "Expected requires-python to be '>=3.10'" 214 | -------------------------------------------------------------------------------- /tests/test_m1_waveforms.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | from collections.abc import Callable 5 | from typing import Any 6 | 7 | import numpy as np 8 | import pytest 9 | 10 | 11 | def _assert_strictly_increasing(x: np.ndarray) -> None: 12 | dx = np.diff(x) 13 | assert np.all(dx > 0.0), f"Expected strictly increasing; min diff={dx.min()!r}" 14 | 15 | 16 | def _assert_monotone_nonincreasing(x: np.ndarray) -> None: 17 | dx = np.diff(x) 18 | assert np.all(dx <= 0.0), f"Expected monotone nonincreasing; max diff={dx.max()!r}" 19 | 20 | 21 | def _assert_monotone_nondecreasing(x: np.ndarray) -> None: 22 | dx = np.diff(x) 23 | assert np.all(dx >= 0.0), f"Expected monotone nondecreasing; min diff={dx.min()!r}" 24 | 25 | 26 | def _assert_raises_one_of( 27 | exc_types: tuple[type[BaseException], ...], 28 | fn: Callable[..., Any], 29 | *args: Any, 30 | **kwargs: Any, 31 | ) -> None: 32 | try: 33 | fn(*args, **kwargs) 34 | except exc_types: 35 | return 36 | except Exception as e: # pragma: no cover 37 | pytest.fail(f"Raised unexpected exception type: {type(e).__name__}: {e}") 38 | pytest.fail(f"Did not raise one of {exc_types}") 39 | 40 | 41 | # ---------------------------- 42 | # LSV 43 | # ---------------------------- 44 | 45 | 46 | def test_lsv_small_exact_increasing_example() -> None: 47 | """ 48 | AC: lsv(0.0, 1.0, dE=0.25, scan_rate=0.5) 49 | - E = [0.0, 0.25, 0.5, 0.75, 1.0] 50 | - dt_step = 0.25 / 0.5 = 0.5 51 | - t = [0.0, 0.5, 1.0, 1.5, 2.0] 52 | - shape (5,2), time strictly increasing 53 | """ 54 | import softpotato as sp 55 | 56 | w = sp.lsv(0.0, 1.0, 0.25, 0.5) 57 | assert isinstance(w, np.ndarray) 58 | assert w.shape == (5, 2) 59 | 60 | E = w[:, 0] 61 | t = w[:, 1] 62 | 63 | np.testing.assert_allclose( 64 | E, np.array([0.0, 0.25, 0.5, 0.75, 1.0]), rtol=0.0, atol=1e-12 65 | ) 66 | np.testing.assert_allclose( 67 | t, np.array([0.0, 0.5, 1.0, 1.5, 2.0]), rtol=0.0, atol=1e-12 68 | ) 69 | _assert_strictly_increasing(t) 70 | _assert_monotone_nondecreasing(E) 71 | 72 | 73 | def test_lsv_decreasing_sweep_has_decreasing_E_and_increasing_t() -> None: 74 | """ 75 | AC: decreasing sweep works (E decreases, t strictly increases). 76 | """ 77 | import softpotato as sp 78 | 79 | w = sp.lsv(1.0, 0.0, 0.25, 0.5) 80 | 81 | E = w[:, 0] 82 | t = w[:, 1] 83 | 84 | _assert_strictly_increasing(t) 85 | _assert_monotone_nonincreasing(E) 86 | assert math.isclose(float(E[0]), 1.0, rel_tol=0.0, abs_tol=1e-12) 87 | assert math.isclose(float(E[-1]), 0.0, rel_tol=0.0, abs_tol=1e-12) 88 | 89 | 90 | def test_lsv_endpoint_is_exact_even_when_not_multiple_of_dE() -> None: 91 | """ 92 | Spec: always ensure final E is exactly E_end (append if needed, no duplicates). 93 | Pick a case where (E_end-E_start)/dE is not an integer. 94 | """ 95 | import softpotato as sp 96 | 97 | w = sp.lsv(0.0, 1.0, 0.3, 1.0) 98 | E = w[:, 0] 99 | t = w[:, 1] 100 | 101 | _assert_strictly_increasing(t) 102 | _assert_monotone_nondecreasing(E) 103 | assert math.isclose(float(E[0]), 0.0, rel_tol=0.0, abs_tol=1e-12) 104 | assert math.isclose(float(E[-1]), 1.0, rel_tol=0.0, abs_tol=1e-12) 105 | 106 | # No duplicate endpoint: 107 | if E.size >= 2: 108 | assert not math.isclose(float(E[-2]), float(E[-1]), rel_tol=0.0, abs_tol=1e-14) 109 | 110 | 111 | # ---------------------------- 112 | # CV 113 | # ---------------------------- 114 | 115 | 116 | def test_cv_small_exact_cycle_end_default_is_start_no_duplicate_vertex() -> None: 117 | """ 118 | AC: cv(E_start=0.0, E_vertex=1.0, scan_rate=0.5, dE=0.5, E_end=None, cycles=1) 119 | - E_end_cycle = E_start = 0.0 120 | - path [0.0, 0.5, 1.0, 0.5, 0.0] 121 | - dt_step = 0.5/0.5 = 1.0 so t=[0,1,2,3,4] 122 | """ 123 | import softpotato as sp 124 | 125 | w = sp.cv(0.0, 1.0, 0.5, 0.5, E_end=None, cycles=1) 126 | assert w.shape == (5, 2) 127 | 128 | E = w[:, 0] 129 | t = w[:, 1] 130 | 131 | np.testing.assert_allclose( 132 | E, np.array([0.0, 0.5, 1.0, 0.5, 0.0]), rtol=0.0, atol=1e-12 133 | ) 134 | np.testing.assert_allclose( 135 | t, np.array([0.0, 1.0, 2.0, 3.0, 4.0]), rtol=0.0, atol=1e-12 136 | ) 137 | _assert_strictly_increasing(t) 138 | 139 | # No duplicated vertex at the join between segments: 140 | # The vertex should appear exactly once in the whole waveform for this symmetric case. 141 | assert int(np.sum(np.isclose(E, 1.0, rtol=0.0, atol=1e-12))) == 1 142 | 143 | 144 | def test_cv_cycles_two_no_duplicate_cycle_boundary_and_time_continuous() -> None: 145 | """ 146 | AC: cycles=2 repeats without duplicating the boundary sample between cycles. 147 | For the small example, expected E length is 9: 148 | [0.0, 0.5, 1.0, 0.5, 0.0, 0.5, 1.0, 0.5, 0.0] 149 | """ 150 | import softpotato as sp 151 | 152 | w = sp.cv(0.0, 1.0, 0.5, 0.5, E_end=None, cycles=2) 153 | E = w[:, 0] 154 | t = w[:, 1] 155 | 156 | expected_E = np.array([0.0, 0.5, 1.0, 0.5, 0.0, 0.5, 1.0, 0.5, 0.0]) 157 | expected_t = np.arange(expected_E.size, dtype=float) # dt_step=1 158 | 159 | assert w.shape == (expected_E.size, 2) 160 | np.testing.assert_allclose(E, expected_E, rtol=0.0, atol=1e-12) 161 | np.testing.assert_allclose(t, expected_t, rtol=0.0, atol=1e-12) 162 | _assert_strictly_increasing(t) 163 | 164 | # Boundary de-dup: there must not be two consecutive 0.0s at the cycle boundary. 165 | # The boundary occurs after the first cycle ends at 0.0; next point should be 0.5. 166 | boundary_idx = 4 167 | assert math.isclose(float(E[boundary_idx]), 0.0, rel_tol=0.0, abs_tol=1e-12) 168 | assert math.isclose(float(E[boundary_idx + 1]), 0.5, rel_tol=0.0, abs_tol=1e-12) 169 | 170 | 171 | # ---------------------------- 172 | # STEP 173 | # ---------------------------- 174 | 175 | 176 | def test_step_uniform_time_grid_divisible_and_instantaneous_step_at_t0() -> None: 177 | """ 178 | AC: step(E_before=0.0, E_after=1.0, dt=0.01, t_end=0.04) 179 | - t=[0,0.01,0.02,0.03,0.04] 180 | - E[0]==1.0 (instantaneous step at t=0) 181 | """ 182 | import softpotato as sp 183 | 184 | w = sp.step(0.0, 1.0, 0.01, 0.04) 185 | assert w.shape == (5, 2) 186 | 187 | E = w[:, 0] 188 | t = w[:, 1] 189 | 190 | np.testing.assert_allclose( 191 | t, np.array([0.0, 0.01, 0.02, 0.03, 0.04]), rtol=0.0, atol=1e-15 192 | ) 193 | _assert_strictly_increasing(t) 194 | np.testing.assert_allclose(np.diff(t), np.full(4, 0.01), rtol=0.0, atol=1e-15) 195 | 196 | assert math.isclose(float(E[0]), 1.0, rel_tol=0.0, abs_tol=1e-12) 197 | np.testing.assert_allclose(E, np.full_like(t, 1.0), rtol=0.0, atol=1e-12) 198 | 199 | 200 | def test_step_non_divisible_t_end_does_not_snap_and_last_time_is_within_one_dt() -> ( 201 | None 202 | ): 203 | """ 204 | Spec recommendation: do not snap final sample to t_end; keep strict uniform dt. 205 | Guarantee: t[-1] <= t_end and t[-1] > t_end - dt. 206 | """ 207 | import softpotato as sp 208 | 209 | dt = 0.03 210 | t_end = 0.10 211 | w = sp.step(0.0, 1.0, dt, t_end) 212 | t = w[:, 1] 213 | 214 | _assert_strictly_increasing(t) 215 | np.testing.assert_allclose( 216 | np.diff(t), np.full(t.size - 1, dt), rtol=0.0, atol=1e-15 217 | ) 218 | 219 | assert float(t[0]) == 0.0 220 | assert float(t[-1]) <= t_end + 1e-15 221 | assert float(t[-1]) > (t_end - dt) - 1e-15 222 | 223 | 224 | # ---------------------------- 225 | # Validation / errors 226 | # ---------------------------- 227 | 228 | 229 | @pytest.mark.parametrize( 230 | "args", 231 | [ 232 | ("lsv", (0.0, 1.0, 0.0, 1.0)), # dE <= 0 233 | ("lsv", (0.0, 1.0, -0.1, 1.0)), 234 | ("lsv", (0.0, 1.0, 0.1, 0.0)), # scan_rate <= 0 235 | ("lsv", (0.0, 1.0, 0.1, -1.0)), 236 | ("cv", (0.0, 1.0, 0.0, 0.1)), # scan_rate <= 0 237 | ("cv", (0.0, 1.0, -1.0, 0.1)), 238 | ("cv", (0.0, 1.0, 1.0, 0.0)), # dE <= 0 239 | ("step", (0.0, 1.0, 0.0, 0.1)), # dt <= 0 240 | ("step", (0.0, 1.0, -0.1, 0.1)), 241 | ("step", (0.0, 1.0, 0.01, 0.0)), # t_end <= 0 242 | ("step", (0.0, 1.0, 0.01, -1.0)), 243 | ], 244 | ) 245 | def test_invalid_positive_constraints_raise_value_error( 246 | args: tuple[str, tuple[float, float, float, float]], 247 | ) -> None: 248 | import softpotato as sp 249 | 250 | fn_name, call_args = args 251 | fn = getattr(sp, fn_name) 252 | with pytest.raises(ValueError): 253 | fn(*call_args) 254 | 255 | 256 | @pytest.mark.parametrize( 257 | "fn_name, call_args", 258 | [ 259 | ("lsv", (0.0, 1.0, np.nan, 1.0)), 260 | ("lsv", (0.0, 1.0, 0.1, np.inf)), 261 | ("cv", (0.0, 1.0, 1.0, np.nan)), 262 | ("cv", (0.0, 1.0, np.inf, 0.1)), 263 | ("step", (0.0, 1.0, np.nan, 0.1)), 264 | ("step", (0.0, 1.0, 0.01, np.inf)), 265 | ], 266 | ) 267 | def test_nonfinite_inputs_raise_value_error( 268 | fn_name: str, 269 | call_args: tuple[float, float, float, float], 270 | ) -> None: 271 | import softpotato as sp 272 | 273 | fn = getattr(sp, fn_name) 274 | with pytest.raises(ValueError): 275 | fn(*call_args) 276 | 277 | 278 | def test_cv_cycles_must_be_int_and_at_least_one() -> None: 279 | import softpotato as sp 280 | 281 | with pytest.raises(ValueError): 282 | sp.cv(0.0, 1.0, 1.0, 0.1, cycles=0) 283 | 284 | with pytest.raises(ValueError): 285 | sp.cv(0.0, 1.0, 1.0, 0.1, cycles=-1) 286 | 287 | # Spec allows either ValueError or TypeError for non-int cycles; accept either. 288 | _assert_raises_one_of( 289 | (TypeError, ValueError), sp.cv, 0.0, 1.0, 1.0, 0.1, cycles=1.5 290 | ) 291 | 292 | 293 | # ---------------------------- 294 | # Property-style invariants (cheap regression checks) 295 | # ---------------------------- 296 | 297 | 298 | def test_waveforms_return_float64_two_columns_and_time_strictly_increasing() -> None: 299 | """ 300 | QA: simple invariant-style checks on a few representative parameter sets. 301 | """ 302 | import softpotato as sp 303 | 304 | waves = [ 305 | sp.lsv(0.0, 0.8, 0.2, 0.4), 306 | sp.lsv(0.8, -0.1, 0.3, 1.2), 307 | sp.cv(0.0, 1.0, 0.5, 0.25, E_end=None, cycles=1), 308 | sp.cv(0.2, -0.6, 0.8, 0.1, E_end=0.2, cycles=2), 309 | sp.step(0.0, 1.0, 0.02, 0.11), 310 | ] 311 | for w in waves: 312 | assert isinstance(w, np.ndarray) 313 | assert w.ndim == 2 and w.shape[1] == 2 314 | assert w.dtype == np.float64 315 | _assert_strictly_increasing(w[:, 1]) 316 | assert np.all(np.isfinite(w)), "Waveforms must be finite" 317 | --------------------------------------------------------------------------------