semita 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.
- semita-0.1.0/.github/workflows/release.yml +112 -0
- semita-0.1.0/.github/workflows/test.yml +62 -0
- semita-0.1.0/.gitignore +12 -0
- semita-0.1.0/LICENSE +21 -0
- semita-0.1.0/MANIFEST.in +4 -0
- semita-0.1.0/PKG-INFO +113 -0
- semita-0.1.0/README.md +87 -0
- semita-0.1.0/csrc/astar.c +90 -0
- semita-0.1.0/csrc/costs.c +25 -0
- semita-0.1.0/csrc/dijkstra.c +54 -0
- semita-0.1.0/csrc/heap.h +69 -0
- semita-0.1.0/csrc/jps.c +170 -0
- semita-0.1.0/csrc/module.c +245 -0
- semita-0.1.0/csrc/path.c +45 -0
- semita-0.1.0/csrc/search.h +117 -0
- semita-0.1.0/pyproject.toml +77 -0
- semita-0.1.0/setup.cfg +4 -0
- semita-0.1.0/setup.py +39 -0
- semita-0.1.0/src/semita/__init__.py +128 -0
- semita-0.1.0/src/semita/_search.pyi +9 -0
- semita-0.1.0/src/semita/_version.py +24 -0
- semita-0.1.0/src/semita/py.typed +0 -0
- semita-0.1.0/src/semita.egg-info/PKG-INFO +113 -0
- semita-0.1.0/src/semita.egg-info/SOURCES.txt +29 -0
- semita-0.1.0/src/semita.egg-info/dependency_links.txt +1 -0
- semita-0.1.0/src/semita.egg-info/requires.txt +8 -0
- semita-0.1.0/src/semita.egg-info/scm_file_list.json +25 -0
- semita-0.1.0/src/semita.egg-info/scm_version.json +8 -0
- semita-0.1.0/src/semita.egg-info/top_level.txt +1 -0
- semita-0.1.0/tests/test_semita.py +456 -0
- semita-0.1.0/uv.lock +300 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# A published GitHub release, tagged `v` and semita's version, builds every wheel and the sdist and uploads them to
|
|
4
|
+
# PyPI. A pull request builds and checks the same files without uploading them.
|
|
5
|
+
on:
|
|
6
|
+
pull_request:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
# Every job reads the repository at most; `publish` adds the identity PyPI trusts.
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
sdist:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v5
|
|
19
|
+
with:
|
|
20
|
+
# The version is the tag, which setuptools-scm reads from git.
|
|
21
|
+
fetch-depth: 0
|
|
22
|
+
persist-credentials: false
|
|
23
|
+
|
|
24
|
+
- name: Install uv
|
|
25
|
+
uses: astral-sh/setup-uv@v7
|
|
26
|
+
|
|
27
|
+
- name: Build
|
|
28
|
+
run: uv build --sdist
|
|
29
|
+
|
|
30
|
+
- uses: actions/upload-artifact@v6
|
|
31
|
+
with:
|
|
32
|
+
name: sdist
|
|
33
|
+
path: dist/*.tar.gz
|
|
34
|
+
|
|
35
|
+
wheels:
|
|
36
|
+
# Every wheel is built from the sdist, so it carries the sdist's version and needs no git history.
|
|
37
|
+
needs: sdist
|
|
38
|
+
name: wheels on ${{ matrix.os }}
|
|
39
|
+
runs-on: ${{ matrix.os }}
|
|
40
|
+
strategy:
|
|
41
|
+
fail-fast: false
|
|
42
|
+
matrix:
|
|
43
|
+
# Each wheel is built and tested on its own architecture: Linux x86_64 and aarch64, Windows AMD64, and macOS
|
|
44
|
+
# arm64 and x86_64. pyproject.toml's [tool.cibuildwheel] says what is built and how it is tested.
|
|
45
|
+
os: [ubuntu-latest, ubuntu-24.04-arm, windows-latest, macos-latest, macos-15-intel]
|
|
46
|
+
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/download-artifact@v7
|
|
49
|
+
with:
|
|
50
|
+
name: sdist
|
|
51
|
+
path: sdist
|
|
52
|
+
|
|
53
|
+
- name: Find the sdist
|
|
54
|
+
id: sdist
|
|
55
|
+
shell: bash
|
|
56
|
+
run: echo "path=$(ls sdist/semita-*.tar.gz)" >> "$GITHUB_OUTPUT"
|
|
57
|
+
|
|
58
|
+
- name: Build and test
|
|
59
|
+
uses: pypa/cibuildwheel@v4.2.1
|
|
60
|
+
with:
|
|
61
|
+
package-dir: ${{ steps.sdist.outputs.path }}
|
|
62
|
+
|
|
63
|
+
- uses: actions/upload-artifact@v6
|
|
64
|
+
with:
|
|
65
|
+
name: wheels-${{ matrix.os }}
|
|
66
|
+
path: wheelhouse/*.whl
|
|
67
|
+
|
|
68
|
+
check:
|
|
69
|
+
# What would be uploaded, all of it, and for a release the tag's version. Each wheel has already been audited for
|
|
70
|
+
# the stable ABI alone, by the audit-command in pyproject.toml.
|
|
71
|
+
needs: [wheels, sdist]
|
|
72
|
+
runs-on: ubuntu-latest
|
|
73
|
+
steps:
|
|
74
|
+
- uses: actions/download-artifact@v7
|
|
75
|
+
with:
|
|
76
|
+
path: dist
|
|
77
|
+
merge-multiple: true
|
|
78
|
+
|
|
79
|
+
- name: List the files
|
|
80
|
+
run: ls -l dist
|
|
81
|
+
|
|
82
|
+
- name: The tag is v and a version
|
|
83
|
+
if: github.event_name == 'release'
|
|
84
|
+
run: |
|
|
85
|
+
tag="$GITHUB_REF_NAME"
|
|
86
|
+
if [[ "$tag" != v* ]]; then
|
|
87
|
+
echo "A release's tag is v and its version, such as v0.2.0, not $tag"
|
|
88
|
+
exit 1
|
|
89
|
+
fi
|
|
90
|
+
if [ ! -f "dist/semita-${tag#v}.tar.gz" ]; then
|
|
91
|
+
echo "setuptools-scm did not read $tag as semita's version, and built $(ls dist)"
|
|
92
|
+
exit 1
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
publish:
|
|
96
|
+
if: github.event_name == 'release'
|
|
97
|
+
needs: check
|
|
98
|
+
runs-on: ubuntu-latest
|
|
99
|
+
# PyPI trusts this workflow from this environment alone, so no token is stored anywhere.
|
|
100
|
+
environment:
|
|
101
|
+
name: pypi
|
|
102
|
+
url: https://pypi.org/project/semita/
|
|
103
|
+
permissions:
|
|
104
|
+
id-token: write
|
|
105
|
+
steps:
|
|
106
|
+
- uses: actions/download-artifact@v7
|
|
107
|
+
with:
|
|
108
|
+
path: dist
|
|
109
|
+
merge-multiple: true
|
|
110
|
+
|
|
111
|
+
- name: Upload to PyPI
|
|
112
|
+
uses: pypa/gh-action-pypi-publish@v1.14.2
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ${{ matrix.os }}
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
15
|
+
python-version: ["3.12", "3.13"]
|
|
16
|
+
env:
|
|
17
|
+
# The C compiler's warnings fail the build here.
|
|
18
|
+
SEMITA_WARNINGS_AS_ERRORS: "1"
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v5
|
|
22
|
+
|
|
23
|
+
- name: Install uv
|
|
24
|
+
uses: astral-sh/setup-uv@v7
|
|
25
|
+
with:
|
|
26
|
+
enable-cache: true
|
|
27
|
+
|
|
28
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
29
|
+
run: uv python install ${{ matrix.python-version }}
|
|
30
|
+
|
|
31
|
+
- name: Install, compiling the extension
|
|
32
|
+
run: uv sync --python ${{ matrix.python-version }} --extra dev
|
|
33
|
+
|
|
34
|
+
- name: Lint
|
|
35
|
+
run: uv run ruff check .
|
|
36
|
+
|
|
37
|
+
- name: Format
|
|
38
|
+
run: uv run ruff format --check .
|
|
39
|
+
|
|
40
|
+
- name: Type check
|
|
41
|
+
run: uv run pyright
|
|
42
|
+
|
|
43
|
+
- name: Test
|
|
44
|
+
run: uv run pytest -v
|
|
45
|
+
|
|
46
|
+
wheel:
|
|
47
|
+
# One wheel on the stable ABI serves every Python from 3.12; this checks the one built here loads on the newest.
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
steps:
|
|
50
|
+
- uses: actions/checkout@v5
|
|
51
|
+
|
|
52
|
+
- name: Install uv
|
|
53
|
+
uses: astral-sh/setup-uv@v7
|
|
54
|
+
|
|
55
|
+
- name: Build
|
|
56
|
+
run: SEMITA_WARNINGS_AS_ERRORS=1 uv build
|
|
57
|
+
|
|
58
|
+
- name: Load the wheel on Python 3.14
|
|
59
|
+
run: |
|
|
60
|
+
wheel=$(ls dist/semita-*-cp312-abi3-*.whl)
|
|
61
|
+
uv run --isolated --no-project --python 3.14 --with "$wheel" python -c \
|
|
62
|
+
"import numpy, semita; assert semita.find_path(numpy.ones((3, 3), dtype=bool), (0, 0), (2, 2)) is not None"
|
semita-0.1.0/.gitignore
ADDED
semita-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Max Nusspickel
|
|
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.
|
semita-0.1.0/MANIFEST.in
ADDED
semita-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: semita
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shortest paths and distances on a grid, in C, for Python
|
|
5
|
+
Author-email: Max Nusspickel <max.nusspickel@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/maxnus/semita
|
|
8
|
+
Keywords: pathfinding,jump point search,dijkstra,grid
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Programming Language :: C
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: numpy>=2.1
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
21
|
+
Requires-Dist: scipy>=1.14; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
23
|
+
Requires-Dist: pyright>=1.1.390; extra == "dev"
|
|
24
|
+
Requires-Dist: setuptools>=77; extra == "dev"
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# semita
|
|
28
|
+
|
|
29
|
+
Shortest and cheapest paths and distances on a grid, in C, for Python: jump point search for one path, A* for one
|
|
30
|
+
where tiles cost differently, and Dijkstra for the cheapest paths from a point to every tile. *Semita* is Latin for a
|
|
31
|
+
footpath.
|
|
32
|
+
|
|
33
|
+
Written for [NachOS](https://github.com/maxnus/NachOS), a StarCraft II bot library, though nothing in it is about
|
|
34
|
+
StarCraft.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import numpy
|
|
38
|
+
|
|
39
|
+
import semita
|
|
40
|
+
|
|
41
|
+
passable = numpy.ones((5, 5), dtype=bool)
|
|
42
|
+
passable[2, :4] = False # a wall, open at its far end
|
|
43
|
+
|
|
44
|
+
path = semita.find_path(passable, (0, 0), (4, 0))
|
|
45
|
+
assert round(path.distance, 2) == 10.83 # round the wall's end
|
|
46
|
+
assert path.tiles[:3].tolist() == [[0, 0], [1, 1], [1, 2]]
|
|
47
|
+
|
|
48
|
+
assert semita.find_path(passable, (0, 0), (2, 1)) is None # the end is blocked
|
|
49
|
+
|
|
50
|
+
paths = semita.cheapest_paths_from(passable, (0, 0)) # to every tile, in one pass
|
|
51
|
+
assert paths.distances[4, 0] == path.distance
|
|
52
|
+
assert paths.distances[2, 1] == float("inf")
|
|
53
|
+
assert paths.path_to((4, 0)).cost == path.cost
|
|
54
|
+
|
|
55
|
+
# Costs: each value is the cost of moving a unit of distance across its tile, 1 on normal ground and more where it
|
|
56
|
+
# is best avoided; 0 blocks, as False does.
|
|
57
|
+
costs = numpy.ones((7, 7))
|
|
58
|
+
costs[2:5, 2:5] = 10.0 # a patch to keep out of
|
|
59
|
+
around = semita.find_path(costs, (3, 0), (3, 6))
|
|
60
|
+
assert round(around.cost, 2) == round(around.distance, 2) == 7.66 # round the patch, on ground costing 1
|
|
61
|
+
assert semita.find_path(numpy.ones((7, 7), dtype=bool), (3, 0), (3, 6)).distance == 6.0 # straight, on bools
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## The rules
|
|
65
|
+
|
|
66
|
+
- A grid is a 2-D numpy array of bools or real numbers, or anything numpy reads as one, indexed `[i, j]`. Its values
|
|
67
|
+
are the tiles' costs: the cost of moving one unit of distance across each, 1 for normal ground, less where movement
|
|
68
|
+
is faster, more where it is slower or best avoided.
|
|
69
|
+
- 0 and `inf` block a tile. A grid of bools is the simplest: `True` costs 1 and `False` blocks. Otherwise a cost is
|
|
70
|
+
positive; NaN and negatives raise `ValueError`.
|
|
71
|
+
- A step goes to one of the eight neighboring tiles, 1 long straight and √2 diagonally, and only between passable
|
|
72
|
+
tiles. A diagonal step needs both tiles beside it passable too, so no path cuts a corner. A step costs its length
|
|
73
|
+
times the mean of the costs of the two tiles it joins, so a path costs the same both ways.
|
|
74
|
+
- The start is a path to itself, costing nothing, even on a blocked tile, but no step leaves or enters a blocked one.
|
|
75
|
+
- A start or an end outside the grid raises `IndexError`; a negative index counts as outside.
|
|
76
|
+
- `find_path` returns a `Path`, its `distance`, its `cost` and its `tiles` from the start to the end, or `None` if
|
|
77
|
+
there is no path. Of several equally cheap paths it returns one, but which is not part of the contract.
|
|
78
|
+
- `cheapest_paths_from` returns the cheapest path from the start to every tile, found in one pass, as
|
|
79
|
+
`CheapestPaths`: their `costs` and their `distances`, two read-only arrays of the grid's shape, `inf` where no path
|
|
80
|
+
reaches, and `path_to(tile)`, a tile's path as a `Path`, or `None` where none reaches. Where several paths to a tile
|
|
81
|
+
cost the same, it holds one of them. On a grid of bools the distances are the costs.
|
|
82
|
+
- A grid whose passable tiles all cost the same, a grid of bools included, is searched by jump point search; any other
|
|
83
|
+
by A*, which visits more tiles, the more so the cheaper the cheapest tile is, since its estimate of what is left is
|
|
84
|
+
the straight distance times that cost.
|
|
85
|
+
- A grid of bools, or a C-contiguous one of float64, is read as it is; any other is converted on each call, so one
|
|
86
|
+
searched often is best kept as one of those.
|
|
87
|
+
|
|
88
|
+
Each search allocates its own memory and releases the GIL, so searches run in parallel on threads. A grid must not be
|
|
89
|
+
written to while a search reads it.
|
|
90
|
+
|
|
91
|
+
## Building
|
|
92
|
+
|
|
93
|
+
semita is a C extension on Python's stable ABI, so one wheel serves every Python from 3.12 on a platform. Installing
|
|
94
|
+
from source compiles it with the platform's own compiler: MSVC on Windows, gcc or clang elsewhere.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
uv sync --extra dev
|
|
98
|
+
uv run pytest
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The tests check every search against scipy's Dijkstra over the same steps.
|
|
102
|
+
|
|
103
|
+
## Releasing
|
|
104
|
+
|
|
105
|
+
Publish a GitHub release tagged `v` and the new version, such as `v0.2.0`. The version is the tag, which
|
|
106
|
+
setuptools-scm reads from git; no file holds it.
|
|
107
|
+
|
|
108
|
+
The `release` workflow then builds the sdist, builds from it a wheel for Linux x86_64 and aarch64, Windows AMD64 and
|
|
109
|
+
macOS arm64 and x86_64, tests and audits each for the stable ABI alone, checks that the tag is a version,
|
|
110
|
+
and uploads them to PyPI. Every pull request builds and checks the same files without uploading them.
|
|
111
|
+
|
|
112
|
+
PyPI trusts the workflow through trusted publishing, from the repository's `pypi` environment, so no token is stored
|
|
113
|
+
anywhere.
|
semita-0.1.0/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# semita
|
|
2
|
+
|
|
3
|
+
Shortest and cheapest paths and distances on a grid, in C, for Python: jump point search for one path, A* for one
|
|
4
|
+
where tiles cost differently, and Dijkstra for the cheapest paths from a point to every tile. *Semita* is Latin for a
|
|
5
|
+
footpath.
|
|
6
|
+
|
|
7
|
+
Written for [NachOS](https://github.com/maxnus/NachOS), a StarCraft II bot library, though nothing in it is about
|
|
8
|
+
StarCraft.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import numpy
|
|
12
|
+
|
|
13
|
+
import semita
|
|
14
|
+
|
|
15
|
+
passable = numpy.ones((5, 5), dtype=bool)
|
|
16
|
+
passable[2, :4] = False # a wall, open at its far end
|
|
17
|
+
|
|
18
|
+
path = semita.find_path(passable, (0, 0), (4, 0))
|
|
19
|
+
assert round(path.distance, 2) == 10.83 # round the wall's end
|
|
20
|
+
assert path.tiles[:3].tolist() == [[0, 0], [1, 1], [1, 2]]
|
|
21
|
+
|
|
22
|
+
assert semita.find_path(passable, (0, 0), (2, 1)) is None # the end is blocked
|
|
23
|
+
|
|
24
|
+
paths = semita.cheapest_paths_from(passable, (0, 0)) # to every tile, in one pass
|
|
25
|
+
assert paths.distances[4, 0] == path.distance
|
|
26
|
+
assert paths.distances[2, 1] == float("inf")
|
|
27
|
+
assert paths.path_to((4, 0)).cost == path.cost
|
|
28
|
+
|
|
29
|
+
# Costs: each value is the cost of moving a unit of distance across its tile, 1 on normal ground and more where it
|
|
30
|
+
# is best avoided; 0 blocks, as False does.
|
|
31
|
+
costs = numpy.ones((7, 7))
|
|
32
|
+
costs[2:5, 2:5] = 10.0 # a patch to keep out of
|
|
33
|
+
around = semita.find_path(costs, (3, 0), (3, 6))
|
|
34
|
+
assert round(around.cost, 2) == round(around.distance, 2) == 7.66 # round the patch, on ground costing 1
|
|
35
|
+
assert semita.find_path(numpy.ones((7, 7), dtype=bool), (3, 0), (3, 6)).distance == 6.0 # straight, on bools
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## The rules
|
|
39
|
+
|
|
40
|
+
- A grid is a 2-D numpy array of bools or real numbers, or anything numpy reads as one, indexed `[i, j]`. Its values
|
|
41
|
+
are the tiles' costs: the cost of moving one unit of distance across each, 1 for normal ground, less where movement
|
|
42
|
+
is faster, more where it is slower or best avoided.
|
|
43
|
+
- 0 and `inf` block a tile. A grid of bools is the simplest: `True` costs 1 and `False` blocks. Otherwise a cost is
|
|
44
|
+
positive; NaN and negatives raise `ValueError`.
|
|
45
|
+
- A step goes to one of the eight neighboring tiles, 1 long straight and √2 diagonally, and only between passable
|
|
46
|
+
tiles. A diagonal step needs both tiles beside it passable too, so no path cuts a corner. A step costs its length
|
|
47
|
+
times the mean of the costs of the two tiles it joins, so a path costs the same both ways.
|
|
48
|
+
- The start is a path to itself, costing nothing, even on a blocked tile, but no step leaves or enters a blocked one.
|
|
49
|
+
- A start or an end outside the grid raises `IndexError`; a negative index counts as outside.
|
|
50
|
+
- `find_path` returns a `Path`, its `distance`, its `cost` and its `tiles` from the start to the end, or `None` if
|
|
51
|
+
there is no path. Of several equally cheap paths it returns one, but which is not part of the contract.
|
|
52
|
+
- `cheapest_paths_from` returns the cheapest path from the start to every tile, found in one pass, as
|
|
53
|
+
`CheapestPaths`: their `costs` and their `distances`, two read-only arrays of the grid's shape, `inf` where no path
|
|
54
|
+
reaches, and `path_to(tile)`, a tile's path as a `Path`, or `None` where none reaches. Where several paths to a tile
|
|
55
|
+
cost the same, it holds one of them. On a grid of bools the distances are the costs.
|
|
56
|
+
- A grid whose passable tiles all cost the same, a grid of bools included, is searched by jump point search; any other
|
|
57
|
+
by A*, which visits more tiles, the more so the cheaper the cheapest tile is, since its estimate of what is left is
|
|
58
|
+
the straight distance times that cost.
|
|
59
|
+
- A grid of bools, or a C-contiguous one of float64, is read as it is; any other is converted on each call, so one
|
|
60
|
+
searched often is best kept as one of those.
|
|
61
|
+
|
|
62
|
+
Each search allocates its own memory and releases the GIL, so searches run in parallel on threads. A grid must not be
|
|
63
|
+
written to while a search reads it.
|
|
64
|
+
|
|
65
|
+
## Building
|
|
66
|
+
|
|
67
|
+
semita is a C extension on Python's stable ABI, so one wheel serves every Python from 3.12 on a platform. Installing
|
|
68
|
+
from source compiles it with the platform's own compiler: MSVC on Windows, gcc or clang elsewhere.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv sync --extra dev
|
|
72
|
+
uv run pytest
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The tests check every search against scipy's Dijkstra over the same steps.
|
|
76
|
+
|
|
77
|
+
## Releasing
|
|
78
|
+
|
|
79
|
+
Publish a GitHub release tagged `v` and the new version, such as `v0.2.0`. The version is the tag, which
|
|
80
|
+
setuptools-scm reads from git; no file holds it.
|
|
81
|
+
|
|
82
|
+
The `release` workflow then builds the sdist, builds from it a wheel for Linux x86_64 and aarch64, Windows AMD64 and
|
|
83
|
+
macOS arm64 and x86_64, tests and audits each for the stable ABI alone, checks that the tag is a version,
|
|
84
|
+
and uploads them to PyPI. Every pull request builds and checks the same files without uploading them.
|
|
85
|
+
|
|
86
|
+
PyPI trusts the workflow through trusted publishing, from the repository's `pypi` environment, so no token is stored
|
|
87
|
+
anywhere.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The cheapest path between two tiles on a grid with costs, by A*. Its estimate of what is left, the octile distance
|
|
3
|
+
* times the cheapest cost on the grid, never exceeds what a step there can cost, so the first time the search
|
|
4
|
+
* settles the end it has found the cheapest path.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
#include <math.h>
|
|
8
|
+
#include <stdint.h>
|
|
9
|
+
#include <stdlib.h>
|
|
10
|
+
|
|
11
|
+
#include "heap.h"
|
|
12
|
+
#include "search.h"
|
|
13
|
+
|
|
14
|
+
/* What a search knows of a tile: nothing yet, a cost to it that may still fall, or its final cost. */
|
|
15
|
+
enum { UNREACHED = 0, REACHED = 1, SETTLED = 2 };
|
|
16
|
+
|
|
17
|
+
SearchStatus find_cheapest_path(
|
|
18
|
+
const Grid *grid, int start, int end, double cheapest, int **tiles, int *tile_count, double *cost,
|
|
19
|
+
double *distance
|
|
20
|
+
) {
|
|
21
|
+
int height = grid->height;
|
|
22
|
+
int end_i = end / height, end_j = end % height;
|
|
23
|
+
*tiles = NULL;
|
|
24
|
+
*tile_count = 0;
|
|
25
|
+
*cost = *distance = INFINITY;
|
|
26
|
+
if (start == end) {
|
|
27
|
+
int *path = malloc(2 * sizeof *path);
|
|
28
|
+
if (!path) return SEARCH_OUT_OF_MEMORY;
|
|
29
|
+
path[0] = end_i;
|
|
30
|
+
path[1] = end_j;
|
|
31
|
+
*tiles = path;
|
|
32
|
+
*tile_count = 1;
|
|
33
|
+
*cost = *distance = 0.0;
|
|
34
|
+
return SEARCH_DONE;
|
|
35
|
+
}
|
|
36
|
+
if (!is_passable(grid, start / height, start % height) || !is_passable(grid, end_i, end_j)) return SEARCH_DONE;
|
|
37
|
+
|
|
38
|
+
size_t count = (size_t)grid->width * (size_t)height;
|
|
39
|
+
double *best = malloc(count * sizeof *best);
|
|
40
|
+
int *parent = malloc(count * sizeof *parent);
|
|
41
|
+
uint8_t *state = calloc(count, 1);
|
|
42
|
+
Heap heap = {0};
|
|
43
|
+
SearchStatus status = SEARCH_DONE;
|
|
44
|
+
if (!best || !parent || !state) {
|
|
45
|
+
status = SEARCH_OUT_OF_MEMORY;
|
|
46
|
+
goto done;
|
|
47
|
+
}
|
|
48
|
+
state[start] = REACHED;
|
|
49
|
+
best[start] = 0.0;
|
|
50
|
+
parent[start] = -1;
|
|
51
|
+
if (heap_push(&heap, cheapest * octile_distance(start / height, start % height, end_i, end_j), start)) {
|
|
52
|
+
status = SEARCH_OUT_OF_MEMORY;
|
|
53
|
+
goto done;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
while (heap.size > 0) {
|
|
57
|
+
int tile = heap_pop(&heap).tile;
|
|
58
|
+
if (state[tile] == SETTLED) continue;
|
|
59
|
+
state[tile] = SETTLED;
|
|
60
|
+
if (tile == end) {
|
|
61
|
+
status = write_path(grid, end, parent, tiles, tile_count, distance);
|
|
62
|
+
if (status == SEARCH_DONE) *cost = best[end];
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
int i = tile / height, j = tile % height;
|
|
66
|
+
for (int step = 0; step < 8; step++) {
|
|
67
|
+
int di = STEP_I[step], dj = STEP_J[step];
|
|
68
|
+
if (!can_step(grid, i, j, di, dj)) continue;
|
|
69
|
+
int neighbor = tile + di * height + dj;
|
|
70
|
+
if (state[neighbor] == SETTLED) continue;
|
|
71
|
+
double neighbor_cost = best[tile] + step_cost(grid, tile, di, dj);
|
|
72
|
+
if (state[neighbor] == UNREACHED || neighbor_cost < best[neighbor]) {
|
|
73
|
+
state[neighbor] = REACHED;
|
|
74
|
+
best[neighbor] = neighbor_cost;
|
|
75
|
+
parent[neighbor] = tile;
|
|
76
|
+
double estimate = neighbor_cost + cheapest * octile_distance(i + di, j + dj, end_i, end_j);
|
|
77
|
+
if (heap_push(&heap, estimate, neighbor)) {
|
|
78
|
+
status = SEARCH_OUT_OF_MEMORY;
|
|
79
|
+
goto done;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
done:
|
|
85
|
+
heap_free(&heap);
|
|
86
|
+
free(best);
|
|
87
|
+
free(parent);
|
|
88
|
+
free(state);
|
|
89
|
+
return status;
|
|
90
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* The costs a grid gives its tiles, checked before a search reads them. */
|
|
2
|
+
|
|
3
|
+
#include <math.h>
|
|
4
|
+
#include <stddef.h>
|
|
5
|
+
|
|
6
|
+
#include "search.h"
|
|
7
|
+
|
|
8
|
+
SearchStatus check_costs(const Grid *grid, double *cheapest, double *dearest, int *bad_tile) {
|
|
9
|
+
size_t tile_count = (size_t)grid->width * (size_t)grid->height;
|
|
10
|
+
double lowest = INFINITY, highest = 0.0;
|
|
11
|
+
for (size_t tile = 0; tile < tile_count; tile++) {
|
|
12
|
+
double cost = grid->costs[tile];
|
|
13
|
+
if (cost == 0.0 || cost == INFINITY) continue;
|
|
14
|
+
/* Written so that NaN fails it too. */
|
|
15
|
+
if (!(cost > 0.0)) {
|
|
16
|
+
*bad_tile = (int)tile;
|
|
17
|
+
return SEARCH_BAD_COST;
|
|
18
|
+
}
|
|
19
|
+
if (cost < lowest) lowest = cost;
|
|
20
|
+
if (cost > highest) highest = cost;
|
|
21
|
+
}
|
|
22
|
+
*cheapest = lowest;
|
|
23
|
+
*dearest = highest;
|
|
24
|
+
return SEARCH_DONE;
|
|
25
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* The cheapest path from one tile to every tile, by Dijkstra's algorithm: each path's cost, its length and the tile
|
|
2
|
+
it arrives from. */
|
|
3
|
+
|
|
4
|
+
#include <math.h>
|
|
5
|
+
#include <stdint.h>
|
|
6
|
+
#include <stdlib.h>
|
|
7
|
+
|
|
8
|
+
#include "heap.h"
|
|
9
|
+
#include "search.h"
|
|
10
|
+
|
|
11
|
+
SearchStatus fill_flood(const Grid *grid, int start, double *costs, double *distances, int *parents) {
|
|
12
|
+
int height = grid->height;
|
|
13
|
+
size_t tile_count = (size_t)grid->width * (size_t)height;
|
|
14
|
+
/* A tile's parent is written when it is reached, and read only where its cost is finite. */
|
|
15
|
+
for (size_t tile = 0; tile < tile_count; tile++) costs[tile] = distances[tile] = INFINITY;
|
|
16
|
+
costs[start] = distances[start] = 0.0;
|
|
17
|
+
parents[start] = -1;
|
|
18
|
+
if (!is_passable(grid, start / height, start % height)) return SEARCH_DONE;
|
|
19
|
+
|
|
20
|
+
uint8_t *settled = calloc(tile_count, 1);
|
|
21
|
+
if (!settled) return SEARCH_OUT_OF_MEMORY;
|
|
22
|
+
Heap heap = {0};
|
|
23
|
+
SearchStatus status = SEARCH_DONE;
|
|
24
|
+
if (heap_push(&heap, 0.0, start)) {
|
|
25
|
+
status = SEARCH_OUT_OF_MEMORY;
|
|
26
|
+
goto done;
|
|
27
|
+
}
|
|
28
|
+
while (heap.size > 0) {
|
|
29
|
+
int tile = heap_pop(&heap).tile;
|
|
30
|
+
if (settled[tile]) continue;
|
|
31
|
+
settled[tile] = 1;
|
|
32
|
+
int i = tile / height, j = tile % height;
|
|
33
|
+
for (int step = 0; step < 8; step++) {
|
|
34
|
+
int di = STEP_I[step], dj = STEP_J[step];
|
|
35
|
+
if (!can_step(grid, i, j, di, dj)) continue;
|
|
36
|
+
int neighbor = tile + di * height + dj;
|
|
37
|
+
if (settled[neighbor]) continue;
|
|
38
|
+
double cost = costs[tile] + step_cost(grid, tile, di, dj);
|
|
39
|
+
if (cost < costs[neighbor]) {
|
|
40
|
+
costs[neighbor] = cost;
|
|
41
|
+
distances[neighbor] = distances[tile] + (di && dj ? SQRT_2 : 1.0);
|
|
42
|
+
parents[neighbor] = tile;
|
|
43
|
+
if (heap_push(&heap, cost, neighbor)) {
|
|
44
|
+
status = SEARCH_OUT_OF_MEMORY;
|
|
45
|
+
goto done;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
done:
|
|
51
|
+
heap_free(&heap);
|
|
52
|
+
free(settled);
|
|
53
|
+
return status;
|
|
54
|
+
}
|
semita-0.1.0/csrc/heap.h
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A binary min-heap of tiles keyed by cost, growing as it fills. A tile may be pushed again at a lower cost; the
|
|
3
|
+
* searches skip the entries they have already settled.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
#ifndef SEMITA_HEAP_H
|
|
7
|
+
#define SEMITA_HEAP_H
|
|
8
|
+
|
|
9
|
+
#include <stddef.h>
|
|
10
|
+
#include <stdlib.h>
|
|
11
|
+
|
|
12
|
+
typedef struct {
|
|
13
|
+
double cost;
|
|
14
|
+
int tile;
|
|
15
|
+
} HeapEntry;
|
|
16
|
+
|
|
17
|
+
typedef struct {
|
|
18
|
+
HeapEntry *entries;
|
|
19
|
+
size_t size;
|
|
20
|
+
size_t capacity;
|
|
21
|
+
} Heap;
|
|
22
|
+
|
|
23
|
+
#define HEAP_FIRST_CAPACITY 256
|
|
24
|
+
|
|
25
|
+
/* Push `tile` at `cost`: 0 if pushed, -1 if memory ran out. */
|
|
26
|
+
static inline int heap_push(Heap *heap, double cost, int tile) {
|
|
27
|
+
if (heap->size == heap->capacity) {
|
|
28
|
+
size_t capacity = heap->capacity ? 2 * heap->capacity : HEAP_FIRST_CAPACITY;
|
|
29
|
+
HeapEntry *entries = realloc(heap->entries, capacity * sizeof *entries);
|
|
30
|
+
if (!entries) return -1;
|
|
31
|
+
heap->entries = entries;
|
|
32
|
+
heap->capacity = capacity;
|
|
33
|
+
}
|
|
34
|
+
size_t position = heap->size++;
|
|
35
|
+
while (position > 0) {
|
|
36
|
+
size_t parent = (position - 1) / 2;
|
|
37
|
+
if (heap->entries[parent].cost <= cost) break;
|
|
38
|
+
heap->entries[position] = heap->entries[parent];
|
|
39
|
+
position = parent;
|
|
40
|
+
}
|
|
41
|
+
heap->entries[position].cost = cost;
|
|
42
|
+
heap->entries[position].tile = tile;
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Remove and return the cheapest entry. The heap must not be empty. */
|
|
47
|
+
static inline HeapEntry heap_pop(Heap *heap) {
|
|
48
|
+
HeapEntry cheapest = heap->entries[0];
|
|
49
|
+
HeapEntry last = heap->entries[--heap->size];
|
|
50
|
+
size_t position = 0;
|
|
51
|
+
for (;;) {
|
|
52
|
+
size_t child = 2 * position + 1;
|
|
53
|
+
if (child >= heap->size) break;
|
|
54
|
+
if (child + 1 < heap->size && heap->entries[child + 1].cost < heap->entries[child].cost) child++;
|
|
55
|
+
if (last.cost <= heap->entries[child].cost) break;
|
|
56
|
+
heap->entries[position] = heap->entries[child];
|
|
57
|
+
position = child;
|
|
58
|
+
}
|
|
59
|
+
if (heap->size > 0) heap->entries[position] = last;
|
|
60
|
+
return cheapest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static inline void heap_free(Heap *heap) {
|
|
64
|
+
free(heap->entries);
|
|
65
|
+
heap->entries = NULL;
|
|
66
|
+
heap->size = heap->capacity = 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#endif /* SEMITA_HEAP_H */
|