Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Benchmark

on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]

env:
# Percentage by which the mean benchmark time is allowed to regress.
BENCHMARK_THRESHOLD: 10

jobs:
benchmark:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Cache GDAL
uses: actions/cache@v4
id: cache-gdal
with:
path: |
/usr/lib/x86_64-linux-gnu/libgdal*
/usr/include/gdal*
/usr/bin/gdal*
key: gdal-ubuntu-latest
- name: Install GDAL
if: steps.cache-gdal.outputs.cache-hit != 'true'
run: |
sudo apt-get update
sudo apt-get install -y gdal-bin libgdal-dev
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install --group test
pip install .

- name: Run benchmarks
run: make benchmark-check THRESHOLD="$BENCHMARK_THRESHOLD"

- name: Update benchmark baseline file
if: github.event_name == 'push'
run: make benchmark-update-baseline

- name: Open PR with updated baseline
if: github.event_name == 'push'
uses: peter-evans/create-pull-request@v7
with:
commit-message: "chore: update benchmark baseline"
title: "chore: update benchmark baseline"
body: |
Automated update of `.github/benchmark-baseline.json` from the
latest benchmark run on `main` (${{ github.sha }}).
branch: chore/update-benchmark-baseline
delete-branch: true
add-paths: .github/benchmark-baseline.json
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
* Performance benchmark tests (`tests/regression_tests/test_benchmarks.py`) for `compute_routes()` and `compute_smoothed_routes()`, run via `make benchmark`.

## [1.1.11] - 2026-07-02

### Added
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ The following commands are available via the Makefile:
- **`make install`** - Install package with all dependencies
- **`make test`** - Run fast tests only (excludes slow tests)
- **`make test-all`** - Run all tests including slow ones
- **`make benchmark`** - Run performance benchmark tests
- **`make benchmark-check`** - Run benchmarks and compare against the committed baseline (`.github/benchmark-baseline.json`), failing on regression (`THRESHOLD=10` for 10%, the default). This is the same check CI runs on pull requests.
- **`make benchmark-update-baseline`** - Regenerate `.github/benchmark-baseline.json` from a fresh run. CI does this automatically (via a PR) after every merge to `main`; run it yourself only if you need to refresh the baseline locally.
- **`make lint`** - Run linting checks with Ruff
- **`make format`** - Format code with Ruff
- **`make coverage`** - Generate coverage report (terminal and HTML)
Expand Down
24 changes: 20 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install test test-all lint format type-check coverage clean docs docs-clean help
.PHONY: install test test-all benchmark benchmark-check benchmark-update-baseline lint format type-check coverage clean docs docs-clean help

help: ## Show this help message
@echo "Available commands:"
Expand All @@ -9,10 +9,26 @@ install: ## Install package with all dependencies
@echo "PolarRoute installed in development mode."

test: ## Run fast tests only
pytest -m "not slow"
pytest -m "not slow and not benchmark"

test-all: ## Run all tests including slow ones
pytest
pytest -m "not benchmark"

benchmark: ## Run performance benchmark tests
pytest -m benchmark --benchmark-only

benchmark-check: ## Run benchmarks and compare against the committed baseline (.github/benchmark-baseline.json), failing on regression (use THRESHOLD=10 for 10%). Same check CI runs.
@if [ -f .github/benchmark-baseline.json ]; then \
pytest -m benchmark --benchmark-only --benchmark-json=benchmark-result.json \
--benchmark-compare=.github/benchmark-baseline.json \
--benchmark-compare-fail=mean:$(or $(THRESHOLD),10)% ; \
else \
pytest -m benchmark --benchmark-only --benchmark-json=benchmark-result.json ; \
fi

benchmark-update-baseline: ## Regenerate .github/benchmark-baseline.json from a fresh benchmark run (commit the result)
pytest -m benchmark --benchmark-only --benchmark-json=.github/benchmark-baseline.json
@echo "Updated .github/benchmark-baseline.json - review and commit it."

lint: ## Run linting checks
ruff check polar_route/ tests/
Expand All @@ -22,7 +38,7 @@ format: ## Format code with ruff
ruff check --fix polar_route/ tests/

coverage: ## Generate coverage report (terminal and HTML)
pytest --cov=polar_route --cov-report=term-missing --cov-report=html --cov-report=xml -m "not slow"
pytest --cov=polar_route --cov-report=term-missing --cov-report=html --cov-report=xml -m "not slow and not benchmark"
@echo "\nCoverage report generated. Open htmlcov/index.html to view the HTML report."

clean: ## Clean build artifacts and cache
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ extract_routes = "polar_route.cli:extract_routes_cli"
log_cli = true
log_format = "[%(asctime)s] %(levelname)s : %(message)s"
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')"
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"benchmark: marks performance benchmark tests (select with '-m benchmark')"
]

[dependency-groups]
Expand All @@ -71,4 +72,5 @@ docs = [
test = [
"pytest",
"pytest-xdist",
"pytest-benchmark",
]
73 changes: 73 additions & 0 deletions tests/regression_tests/test_benchmarks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""
Performance benchmark tests for route calculation.

These tests benchmark the wall-clock time of `compute_routes()` and
`compute_smoothed_routes()` against set of existing regression
test fixtures, ranging from small to large meshes.

Run with: `pytest -m benchmark --benchmark-only`
"""

import json

import pytest

from .utils import get_test_data_path, calculate_dijkstra_route, calculate_smoothed_route

DIJKSTRA_BENCHMARK_FILES = {
"small": "example_routes/dijkstra/time/twin_otter_tt_route_dijkstra.json",
"medium": "example_routes/dijkstra/fuel/checkerboard.json",
"large": "example_routes/dijkstra/fuel/gaussian_random_field.json",
}

SMOOTHED_BENCHMARK_FILES = {
"small": "example_routes/smoothed/time/multi_waypoint_blocked.json",
"medium": "example_routes/smoothed/fuel/checkerboard.json",
"large": "example_routes/smoothed/fuel/great_circle_forward.json",
}

BENCHMARK_ROUNDS = {
"small": 5,
"medium": 3,
"large": 1,
}


def _load_route(relative_path):
with open(get_test_data_path(relative_path), "r") as fp:
return json.load(fp)


@pytest.mark.benchmark
@pytest.mark.parametrize(
"size", ["small", "medium", "large"], ids=["small", "medium", "large"]
)
def test_benchmark_dijkstra(benchmark, size):
"""Benchmark `compute_routes()` (Dijkstra) across small/medium/large meshes."""
route = _load_route(DIJKSTRA_BENCHMARK_FILES[size])
config = route["config"]["route_info"]

benchmark.pedantic(
calculate_dijkstra_route,
args=(config, route),
rounds=BENCHMARK_ROUNDS[size],
iterations=1,
)


@pytest.mark.benchmark
@pytest.mark.slow
@pytest.mark.parametrize(
"size", ["small", "medium", "large"], ids=["small", "medium", "large"]
)
def test_benchmark_smoothed(benchmark, size):
"""Benchmark `compute_smoothed_routes()` across small/medium/large meshes."""
route = _load_route(SMOOTHED_BENCHMARK_FILES[size])
config = route["config"]["route_info"]

benchmark.pedantic(
calculate_smoothed_route,
args=(config, route),
rounds=BENCHMARK_ROUNDS[size],
iterations=1,
)
Loading