sars 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python-version: ["3.9", "3.11", "3.12"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: "${{ matrix.python-version }}"
14
+ - run: pip install -e ".[dev]"
15
+ - run: pytest
16
+ - run: ruff check src/
@@ -0,0 +1,18 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+ jobs:
6
+ publish:
7
+ runs-on: ubuntu-latest
8
+ environment: pypi
9
+ permissions:
10
+ id-token: write
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.11"
16
+ - run: pip install build
17
+ - run: python -m build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
sars-0.1.0/.gitignore ADDED
@@ -0,0 +1,209 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
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
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ env/
142
+ venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+ /.claude
209
+ CLAUDE.md
@@ -0,0 +1,15 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ title: "sars: Species-Area Relationship curve fitting in Python"
4
+ type: software
5
+ authors:
6
+ - family-names: McMeen
7
+ given-names: John
8
+ license: MIT
9
+ repository-code: "https://github.com/jmcmeen/sars"
10
+ keywords:
11
+ - species-area relationship
12
+ - SAR
13
+ - macroecology
14
+ - biodiversity
15
+ - ecology
sars-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John McMeen
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.
sars-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: sars
3
+ Version: 0.1.0
4
+ Summary: Species-area relationship curve fitting in Python
5
+ Project-URL: Homepage, https://github.com/jmcmeen/sars
6
+ Project-URL: Repository, https://github.com/jmcmeen/sars
7
+ Project-URL: Issues, https://github.com/jmcmeen/sars/issues
8
+ Author: John McMeen
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 John McMeen
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: SAR,biodiversity,biogeography,ecology,macroecology,species-area relationship
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
37
+ Requires-Python: >=3.9
38
+ Requires-Dist: matplotlib>=3.5
39
+ Requires-Dist: numpy>=1.22
40
+ Requires-Dist: pandas>=1.4
41
+ Requires-Dist: scipy>=1.8
42
+ Provides-Extra: all
43
+ Requires-Dist: geopandas>=0.12; extra == 'all'
44
+ Requires-Dist: plotly>=5; extra == 'all'
45
+ Requires-Dist: shapely>=2.0; extra == 'all'
46
+ Provides-Extra: dev
47
+ Requires-Dist: mypy; extra == 'dev'
48
+ Requires-Dist: pytest-cov; extra == 'dev'
49
+ Requires-Dist: pytest>=7; extra == 'dev'
50
+ Requires-Dist: ruff; extra == 'dev'
51
+ Provides-Extra: geo
52
+ Requires-Dist: geopandas>=0.12; extra == 'geo'
53
+ Requires-Dist: shapely>=2.0; extra == 'geo'
54
+ Provides-Extra: interactive
55
+ Requires-Dist: plotly>=5; extra == 'interactive'
56
+ Description-Content-Type: text/markdown
57
+
58
+ # sars
59
+
60
+ Species-area relationship curve fitting in Python.
61
+
62
+ A conceptual mirror of the R [`sars`](https://cran.r-project.org/package=sars) package (Matthews et al. 2019), native to the Python scientific stack.
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ pip install sars
68
+ ```
69
+
70
+ ## Quick start
71
+
72
+ ```python
73
+ import sars
74
+
75
+ # Fit the power-law SAR model
76
+ import pandas as pd
77
+ data = pd.DataFrame({"area": [1, 2, 5, 10, 50], "species": [10, 15, 25, 40, 80]})
78
+ fit = sars.sar_power(data)
79
+ print(fit)
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT
sars-0.1.0/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # sars
2
+
3
+ Species-area relationship curve fitting in Python.
4
+
5
+ A conceptual mirror of the R [`sars`](https://cran.r-project.org/package=sars) package (Matthews et al. 2019), native to the Python scientific stack.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install sars
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ import sars
17
+
18
+ # Fit the power-law SAR model
19
+ import pandas as pd
20
+ data = pd.DataFrame({"area": [1, 2, 5, 10, 50], "species": [10, 15, 25, 40, 80]})
21
+ fit = sars.sar_power(data)
22
+ print(fit)
23
+ ```
24
+
25
+ ## License
26
+
27
+ MIT
sars-0.1.0/deploy.sh ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env bash
2
+ # deploy.sh — build and publish sars to PyPI
3
+ # Usage:
4
+ # ./deploy.sh → publish to PyPI (production)
5
+ # ./deploy.sh --test → publish to TestPyPI first (dry run)
6
+ #
7
+ # Requires: pip install build twine
8
+ # Requires: .env file with PYPI_API_TOKEN set (see .env.example)
9
+
10
+ set -euo pipefail
11
+
12
+ # ── Load environment ──────────────────────────────────────────────────────────
13
+ if [ ! -f .env ]; then
14
+ echo "Error: .env file not found. Copy .env.example and fill in your token."
15
+ exit 1
16
+ fi
17
+ export $(grep -v '^#' .env | xargs)
18
+
19
+ if [ -z "${PYPI_API_TOKEN:-}" ]; then
20
+ echo "Error: PYPI_API_TOKEN not set in .env"
21
+ exit 1
22
+ fi
23
+
24
+ # ── Parse args ────────────────────────────────────────────────────────────────
25
+ TEST_MODE=false
26
+ if [ "${1:-}" = "--test" ]; then
27
+ TEST_MODE=true
28
+ echo "→ Test mode: publishing to TestPyPI"
29
+ else
30
+ echo "→ Production mode: publishing to PyPI"
31
+ fi
32
+
33
+ # ── Preflight checks ──────────────────────────────────────────────────────────
34
+ echo ""
35
+ echo "── Preflight ────────────────────────────────────────────────────────────"
36
+
37
+ # Confirm on the right branch
38
+ BRANCH=$(git rev-parse --abbrev-ref HEAD)
39
+ if [ "$BRANCH" != "main" ] && [ "$TEST_MODE" = false ]; then
40
+ echo "Error: production deploys must be from main (currently on '$BRANCH')"
41
+ exit 1
42
+ fi
43
+
44
+ # Confirm clean working tree
45
+ if ! git diff --quiet || ! git diff --cached --quiet; then
46
+ echo "Error: uncommitted changes present. Commit or stash before deploying."
47
+ exit 1
48
+ fi
49
+
50
+ # Run tests
51
+ echo "Running test suite..."
52
+ pip install -e ".[dev]" -q
53
+ pytest --tb=short -q
54
+ echo "Tests passed."
55
+
56
+ # Lint
57
+ echo "Running ruff..."
58
+ ruff check src/
59
+ echo "Lint passed."
60
+
61
+ # ── Get version ───────────────────────────────────────────────────────────────
62
+ VERSION=$(python -c "import tomllib; f=open('pyproject.toml','rb'); d=tomllib.load(f); print(d['project']['version'])")
63
+ echo ""
64
+ echo "── Building sars v${VERSION} ─────────────────────────────────────────────"
65
+
66
+ # Confirm with user on production deploy
67
+ if [ "$TEST_MODE" = false ]; then
68
+ read -p "Publish sars v${VERSION} to PyPI? [y/N] " confirm
69
+ if [ "${confirm}" != "y" ] && [ "${confirm}" != "Y" ]; then
70
+ echo "Aborted."
71
+ exit 0
72
+ fi
73
+ fi
74
+
75
+ # ── Build ─────────────────────────────────────────────────────────────────────
76
+ echo ""
77
+ echo "── Building ─────────────────────────────────────────────────────────────"
78
+ rm -rf dist/ build/
79
+ python -m build
80
+ echo "Build complete:"
81
+ ls -lh dist/
82
+
83
+ # ── Publish ───────────────────────────────────────────────────────────────────
84
+ echo ""
85
+ echo "── Publishing ───────────────────────────────────────────────────────────"
86
+
87
+ if [ "$TEST_MODE" = true ]; then
88
+ TWINE_PASSWORD="${PYPI_API_TOKEN}" twine upload \
89
+ --repository testpypi \
90
+ --username __token__ \
91
+ dist/*
92
+ echo ""
93
+ echo "✓ Published to TestPyPI."
94
+ echo " Install with: pip install --index-url https://test.pypi.org/simple/ sars==${VERSION}"
95
+ else
96
+ TWINE_PASSWORD="${PYPI_API_TOKEN}" twine upload \
97
+ --username __token__ \
98
+ dist/*
99
+ echo ""
100
+ echo "✓ Published to PyPI."
101
+ echo " Install with: pip install sars==${VERSION}"
102
+
103
+ # Tag the release
104
+ if ! git tag | grep -q "v${VERSION}"; then
105
+ git tag "v${VERSION}"
106
+ git push origin "v${VERSION}"
107
+ echo " Git tag v${VERSION} pushed."
108
+ fi
109
+ fi
110
+
111
+ echo ""
112
+ echo "Done."
sars-0.1.0/env.example ADDED
@@ -0,0 +1,12 @@
1
+ # .env.example — copy to .env and fill in your values
2
+ # NEVER commit .env to version control.
3
+ # Add .env to .gitignore immediately.
4
+
5
+ # PyPI API token — get from pypi.org/manage/account/token/
6
+ # Scope: "Entire account" for first deploy; narrow to project after first publish.
7
+ # Token format: pypi-AgEIcHlwaS5vcmcA...
8
+ PYPI_API_TOKEN=pypi-your-token-here
9
+
10
+ # TestPyPI token (optional) — get from test.pypi.org/manage/account/token/
11
+ # Used with: ./deploy.sh --test
12
+ # TESTPYPI_API_TOKEN=pypi-your-test-token-here
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sars"
7
+ version = "0.1.0"
8
+ description = "Species-area relationship curve fitting in Python"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [{ name = "John McMeen" }]
12
+ requires-python = ">=3.9"
13
+ keywords = ["species-area relationship", "SAR", "macroecology",
14
+ "biodiversity", "ecology", "biogeography"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
21
+ ]
22
+ dependencies = [
23
+ "numpy>=1.22",
24
+ "scipy>=1.8",
25
+ "pandas>=1.4",
26
+ "matplotlib>=3.5",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=7", "pytest-cov", "ruff", "mypy"]
31
+ interactive = ["plotly>=5"]
32
+ geo = ["geopandas>=0.12", "shapely>=2.0"]
33
+ all = ["sars[interactive,geo]"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/jmcmeen/sars"
37
+ Repository = "https://github.com/jmcmeen/sars"
38
+ Issues = "https://github.com/jmcmeen/sars/issues"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/sars"]
42
+
43
+ [tool.ruff]
44
+ line-length = 88
45
+ target-version = "py39"
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "N", "UP", "B"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ addopts = "--cov=sars --cov-report=term-missing"
@@ -0,0 +1,14 @@
1
+ """sars — Species-area relationship curve fitting in Python.
2
+
3
+ Conceptual mirror of the R `sars` package (Matthews et al. 2019),
4
+ native to the Python scientific stack.
5
+ """
6
+
7
+ from sars._io import load_galap
8
+ from sars._models import SARFit, sar_power
9
+
10
+ __all__ = [
11
+ "SARFit",
12
+ "sar_power",
13
+ "load_galap",
14
+ ]
@@ -0,0 +1,3 @@
1
+ """Multi-model inference, averaging, and bootstrap CI."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,25 @@
1
+ """Data loading and I/O adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def load_galap():
7
+ """Load the Galapagos plant species-area dataset.
8
+
9
+ Returns the Preston (1962) 16-island dataset as shipped in the R sars
10
+ package (Albemarle/Isabela excluded).
11
+
12
+ Returns
13
+ -------
14
+ pd.DataFrame
15
+ DataFrame with columns 'area' and 'species'.
16
+
17
+ Raises
18
+ ------
19
+ NotImplementedError
20
+ Until the R reference data is generated and committed.
21
+ """
22
+ raise NotImplementedError(
23
+ "Run tests/r_reference/generate_r_reference.R first, "
24
+ "then this function will read from galap.csv"
25
+ )
@@ -0,0 +1,196 @@
1
+ """SAR model definitions and fitting routines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from scipy.optimize import least_squares
10
+
11
+
12
+ @dataclass
13
+ class SARFit:
14
+ """Result of fitting a single SAR model.
15
+
16
+ Attributes
17
+ ----------
18
+ model : str
19
+ Short model identifier matching R sars function suffix (e.g. 'power').
20
+ params : dict[str, float]
21
+ Fitted parameter values keyed by parameter name.
22
+ r_squared : float
23
+ Coefficient of determination in arithmetic space.
24
+ aic : float
25
+ Akaike Information Criterion (normal log-likelihood convention).
26
+ aicc : float
27
+ AIC corrected for small sample size.
28
+ bic : float
29
+ Bayesian Information Criterion.
30
+ n : int
31
+ Number of observations used in fit.
32
+ converged : bool
33
+ Whether the NLS solver converged.
34
+ data : pd.DataFrame
35
+ Original data (columns: area, species).
36
+ """
37
+
38
+ model: str
39
+ params: dict
40
+ r_squared: float
41
+ aic: float
42
+ aicc: float
43
+ bic: float
44
+ n: int
45
+ converged: bool
46
+ data: pd.DataFrame
47
+
48
+ def predict(self, area: float | np.ndarray) -> np.ndarray:
49
+ """Predict species richness for given area value(s)."""
50
+ area = np.asarray(area, dtype=float)
51
+ if self.model == "power":
52
+ return self.params["c"] * area ** self.params["z"]
53
+ raise NotImplementedError(f"predict not yet implemented for '{self.model}'")
54
+
55
+ def __repr__(self) -> str:
56
+ p = " ".join(f"{k}={v:.4f}" for k, v in self.params.items())
57
+ return (
58
+ f"SARFit(model='{self.model}', {p}, "
59
+ f"R²={self.r_squared:.4f}, AICc={self.aicc:.2f})"
60
+ )
61
+
62
+
63
+ def _compute_ic(k: int, n: int, rss: float) -> tuple[float, float, float]:
64
+ """Compute AIC, AICc, and BIC from residual sum of squares.
65
+
66
+ Uses the normal log-likelihood convention consistent with R sars:
67
+ logL = -n/2 * log(2*pi*rss/n) - n/2
68
+
69
+ Parameters
70
+ ----------
71
+ k : int
72
+ Number of estimated parameters (including sigma).
73
+ n : int
74
+ Number of observations.
75
+ rss : float
76
+ Residual sum of squares.
77
+
78
+ Returns
79
+ -------
80
+ tuple[float, float, float]
81
+ (AIC, AICc, BIC)
82
+ """
83
+ # k already includes sigma in R sars convention
84
+ log_lik = -n / 2.0 * np.log(2.0 * np.pi * rss / n) - n / 2.0
85
+ aic = -2.0 * log_lik + 2.0 * k
86
+ # AICc correction
87
+ if n - k - 1 > 0:
88
+ aicc = aic + (2.0 * k * (k + 1.0)) / (n - k - 1.0)
89
+ else:
90
+ aicc = np.inf
91
+ bic = -2.0 * log_lik + k * np.log(n)
92
+ return aic, aicc, bic
93
+
94
+
95
+ def sar_power(data: pd.DataFrame, grid_start: bool = True) -> SARFit:
96
+ """Fit the power law SAR model: S = c * A^z
97
+
98
+ The Arrhenius (1921) power model is the most widely used SAR model.
99
+ Fitted using nonlinear least squares in arithmetic space with a grid
100
+ of starting values to avoid local minima.
101
+
102
+ Parameters
103
+ ----------
104
+ data : pd.DataFrame
105
+ DataFrame with columns 'area' (float, km²) and 'species' (int).
106
+ grid_start : bool
107
+ If True, use a grid of starting values. Recommended for all use.
108
+
109
+ Returns
110
+ -------
111
+ SARFit
112
+ Fitted model with params {'c': ..., 'z': ...}.
113
+
114
+ References
115
+ ----------
116
+ Arrhenius O (1921) Species and area. Journal of Ecology 9:95-99.
117
+ """
118
+ area = np.asarray(data["area"], dtype=float)
119
+ species = np.asarray(data["species"], dtype=float)
120
+ n = len(area)
121
+
122
+ def residuals(p: np.ndarray) -> np.ndarray:
123
+ c, z = p
124
+ return species - c * area**z
125
+
126
+ best_cost = np.inf
127
+ best_result = None
128
+
129
+ if grid_start:
130
+ # Grid of starting values — log-space OLS gives a good anchor
131
+ log_a = np.log(area)
132
+ log_s = np.log(np.maximum(species, 1e-10))
133
+ slope, intercept = np.polyfit(log_a, log_s, 1)
134
+ c_ols = np.exp(intercept)
135
+ z_ols = slope
136
+
137
+ # Build grid around OLS estimate + wide exploration
138
+ c_starts = np.array([c_ols * f for f in [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]])
139
+ z_starts = np.array([z_ols * f for f in [0.1, 0.5, 1.0, 1.5, 2.0]])
140
+ c_starts = np.append(c_starts, [1.0, 10.0, 50.0, 100.0])
141
+ z_starts = np.append(z_starts, [0.1, 0.2, 0.3, 0.5, 0.8])
142
+ else:
143
+ c_starts = np.array([1.0, 10.0, 50.0])
144
+ z_starts = np.array([0.1, 0.3, 0.5])
145
+
146
+ for c0 in c_starts:
147
+ for z0 in z_starts:
148
+ if c0 <= 0:
149
+ continue
150
+ try:
151
+ result = least_squares(
152
+ residuals,
153
+ x0=[c0, z0],
154
+ bounds=([1e-10, -5.0], [1e6, 5.0]),
155
+ method="trf",
156
+ max_nfev=2000,
157
+ )
158
+ if result.cost < best_cost:
159
+ best_cost = result.cost
160
+ best_result = result
161
+ except Exception:
162
+ continue
163
+
164
+ if best_result is None or not best_result.success:
165
+ return SARFit(
166
+ model="power",
167
+ params={"c": np.nan, "z": np.nan},
168
+ r_squared=np.nan,
169
+ aic=np.nan,
170
+ aicc=np.nan,
171
+ bic=np.nan,
172
+ n=n,
173
+ converged=False,
174
+ data=data,
175
+ )
176
+
177
+ c_fit, z_fit = best_result.x
178
+ rss = 2.0 * best_result.cost # least_squares minimises 0.5 * sum(r^2)
179
+ ss_tot = np.sum((species - np.mean(species)) ** 2)
180
+ r_squared = 1.0 - rss / ss_tot
181
+
182
+ # k = number of model params + 1 for sigma (R sars convention)
183
+ k = 3 # c, z, sigma
184
+ aic, aicc, bic = _compute_ic(k, n, rss)
185
+
186
+ return SARFit(
187
+ model="power",
188
+ params={"c": c_fit, "z": z_fit},
189
+ r_squared=r_squared,
190
+ aic=aic,
191
+ aicc=aicc,
192
+ bic=bic,
193
+ n=n,
194
+ converged=True,
195
+ data=data,
196
+ )
@@ -0,0 +1,3 @@
1
+ """Plotting functions for SAR fits."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,3 @@
1
+ """Threshold / piecewise SAR models."""
2
+
3
+ from __future__ import annotations
File without changes
@@ -0,0 +1,21 @@
1
+ import pandas as pd
2
+ import pytest
3
+ from pathlib import Path
4
+
5
+ REF = Path("tests/r_reference")
6
+
7
+
8
+ @pytest.fixture(scope="session")
9
+ def galap():
10
+ path = REF / "galap.csv"
11
+ if not path.exists():
12
+ pytest.skip("Run generate_r_reference.R first")
13
+ return pd.read_csv(path)
14
+
15
+
16
+ @pytest.fixture(scope="session")
17
+ def r_reference():
18
+ path = REF / "all_models_galap.csv"
19
+ if not path.exists():
20
+ pytest.skip("Run generate_r_reference.R first")
21
+ return pd.read_csv(path).set_index("model")
File without changes
@@ -0,0 +1,38 @@
1
+ # Rscript tests/r_reference/generate_r_reference.R
2
+ library(sars)
3
+
4
+ # 1. Export the exact galap dataset (16 rows)
5
+ write.csv(galap, "tests/r_reference/galap.csv", row.names = FALSE)
6
+ cat("Exported galap:", nrow(galap), "rows\n")
7
+
8
+ # 2. Fit all 20 models and export reference values
9
+ models <- c("power","powerR","epm1","epm2","p1","p2","loga","koba",
10
+ "mmf","monod","negexpo","chapman","weibull3","asymp",
11
+ "ratio","gompertz","weibull4","betap","heleg","linear")
12
+
13
+ results <- list()
14
+ for (m in models) {
15
+ fn <- tryCatch(get(paste0("sar_", m)), error = function(e) NULL)
16
+ if (is.null(fn)) next
17
+ tryCatch({
18
+ fit <- fn(data = galap)
19
+ results[[m]] <- data.frame(
20
+ model = m,
21
+ converged = isTRUE(fit$converged),
22
+ r2 = fit$R2,
23
+ aic = fit$AIC,
24
+ aicc = fit$AICc,
25
+ bic = fit$BIC,
26
+ params = paste(names(fit$par), round(fit$par, 6),
27
+ sep = "=", collapse = "; ")
28
+ )
29
+ }, error = function(e) {
30
+ results[[m]] <<- data.frame(model=m, converged=FALSE,
31
+ r2=NA, aic=NA, aicc=NA, bic=NA, params=NA)
32
+ })
33
+ }
34
+
35
+ df <- do.call(rbind, results)
36
+ write.csv(df, "tests/r_reference/all_models_galap.csv", row.names = FALSE)
37
+ cat("Reference table written.\n\nPower law:\n")
38
+ print(sar_power(galap))
@@ -0,0 +1,29 @@
1
+ import pandas as pd
2
+ import pytest
3
+
4
+ from sars import sar_power
5
+
6
+
7
+ def test_power_law_spot_check():
8
+ """Quick sanity check using confirmed R values (no R script needed)."""
9
+ partial = pd.DataFrame({
10
+ "area": [0.20, 0.90, 1.00, 1.80, 1.87, 4.40, 7.10,
11
+ 7.50, 18.00, 20.00],
12
+ "species": [ 48, 7, 52, 14, 42, 22, 103,
13
+ 48, 79, 119],
14
+ })
15
+ fit = sar_power(partial)
16
+ assert fit.converged is True
17
+ assert 0.0 < fit.params["z"] < 1.0 # z must be in plausible SAR range
18
+ assert fit.params["c"] > 0
19
+ assert 0.0 < fit.r_squared <= 1.0
20
+
21
+
22
+ def test_power_law_matches_r(galap, r_reference):
23
+ """Full validation: must match R sars::sar_power(galap) exactly."""
24
+ fit = sar_power(galap)
25
+ ref = r_reference.loc["power"]
26
+ assert fit.r_squared == pytest.approx(ref["r2"], abs=0.005)
27
+ assert fit.aicc == pytest.approx(ref["aicc"], abs=0.1)
28
+ assert fit.params["c"] == pytest.approx(33.1792, abs=0.05)
29
+ assert fit.params["z"] == pytest.approx(0.2832, abs=0.005)