vrp-model 0.1.1__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.
- vrp_model-0.1.1/.github/workflows/ci.yml +33 -0
- vrp_model-0.1.1/.github/workflows/publish.yml +47 -0
- vrp_model-0.1.1/.gitignore +7 -0
- vrp_model-0.1.1/PKG-INFO +140 -0
- vrp_model-0.1.1/README.md +122 -0
- vrp_model-0.1.1/pyproject.toml +33 -0
- vrp_model-0.1.1/rules/no-backwards-compatibility.mdc +15 -0
- vrp_model-0.1.1/rules/project-conventions.mdc +35 -0
- vrp_model-0.1.1/rules/verify-after-changes.mdc +14 -0
- vrp_model-0.1.1/tests/__init__.py +1 -0
- vrp_model-0.1.1/tests/_vroom_probe.py +21 -0
- vrp_model-0.1.1/tests/fixtures/vrplib/C1_2_1.txt +210 -0
- vrp_model-0.1.1/tests/fixtures/vrplib/E-n13-k4.vrp +35 -0
- vrp_model-0.1.1/tests/test_features.py +70 -0
- vrp_model-0.1.1/tests/test_model_solution.py +70 -0
- vrp_model-0.1.1/tests/test_model_views.py +80 -0
- vrp_model-0.1.1/tests/test_nextroute_solver.py +29 -0
- vrp_model-0.1.1/tests/test_ortools_solver.py +168 -0
- vrp_model-0.1.1/tests/test_pyvrp_solver.py +132 -0
- vrp_model-0.1.1/tests/test_registry.py +41 -0
- vrp_model-0.1.1/tests/test_solution_status.py +45 -0
- vrp_model-0.1.1/tests/test_solver_base.py +58 -0
- vrp_model-0.1.1/tests/test_solver_capability.py +44 -0
- vrp_model-0.1.1/tests/test_solver_options.py +42 -0
- vrp_model-0.1.1/tests/test_storage.py +26 -0
- vrp_model-0.1.1/tests/test_toy_instances_solvers.py +108 -0
- vrp_model-0.1.1/tests/test_toy_instances_verify.py +56 -0
- vrp_model-0.1.1/tests/test_travel_edges.py +35 -0
- vrp_model-0.1.1/tests/test_utils_distance.py +17 -0
- vrp_model-0.1.1/tests/test_utils_time.py +14 -0
- vrp_model-0.1.1/tests/test_validation.py +179 -0
- vrp_model-0.1.1/tests/test_vroom_solver.py +34 -0
- vrp_model-0.1.1/tests/test_vrplib_io.py +130 -0
- vrp_model-0.1.1/tests/test_vrplib_keys.py +45 -0
- vrp_model-0.1.1/tests/tiny_line_solver.py +20 -0
- vrp_model-0.1.1/tests/toy_instances.py +286 -0
- vrp_model-0.1.1/uv.lock +1030 -0
- vrp_model-0.1.1/vrp_model/__init__.py +65 -0
- vrp_model-0.1.1/vrp_model/core/__init__.py +38 -0
- vrp_model-0.1.1/vrp_model/core/errors.py +25 -0
- vrp_model-0.1.1/vrp_model/core/kinds.py +12 -0
- vrp_model-0.1.1/vrp_model/core/model.py +739 -0
- vrp_model-0.1.1/vrp_model/core/records.py +99 -0
- vrp_model-0.1.1/vrp_model/core/solution.py +31 -0
- vrp_model-0.1.1/vrp_model/core/storage.py +21 -0
- vrp_model-0.1.1/vrp_model/core/time_window_flex.py +26 -0
- vrp_model-0.1.1/vrp_model/core/travel_edges.py +54 -0
- vrp_model-0.1.1/vrp_model/core/views.py +276 -0
- vrp_model-0.1.1/vrp_model/io/__init__.py +35 -0
- vrp_model-0.1.1/vrp_model/io/vrplib_io.py +29 -0
- vrp_model-0.1.1/vrp_model/io/vrplib_keys.py +90 -0
- vrp_model-0.1.1/vrp_model/io/vrplib_normalize.py +232 -0
- vrp_model-0.1.1/vrp_model/io/vrplib_read.py +154 -0
- vrp_model-0.1.1/vrp_model/io/vrplib_types.py +58 -0
- vrp_model-0.1.1/vrp_model/io/vrplib_write.py +158 -0
- vrp_model-0.1.1/vrp_model/solvers/__init__.py +35 -0
- vrp_model-0.1.1/vrp_model/solvers/_helpers.py +52 -0
- vrp_model-0.1.1/vrp_model/solvers/base.py +26 -0
- vrp_model-0.1.1/vrp_model/solvers/hgs_cvrp/__init__.py +1 -0
- vrp_model-0.1.1/vrp_model/solvers/jsprit/__init__.py +1 -0
- vrp_model-0.1.1/vrp_model/solvers/nextroute/__init__.py +10 -0
- vrp_model-0.1.1/vrp_model/solvers/nextroute/bindings.py +17 -0
- vrp_model-0.1.1/vrp_model/solvers/nextroute/options.py +58 -0
- vrp_model-0.1.1/vrp_model/solvers/nextroute/solver.py +315 -0
- vrp_model-0.1.1/vrp_model/solvers/options.py +102 -0
- vrp_model-0.1.1/vrp_model/solvers/ortools/__init__.py +22 -0
- vrp_model-0.1.1/vrp_model/solvers/ortools/bindings.py +18 -0
- vrp_model-0.1.1/vrp_model/solvers/ortools/options.py +54 -0
- vrp_model-0.1.1/vrp_model/solvers/ortools/solver.py +535 -0
- vrp_model-0.1.1/vrp_model/solvers/pyvrp/__init__.py +18 -0
- vrp_model-0.1.1/vrp_model/solvers/pyvrp/bindings.py +66 -0
- vrp_model-0.1.1/vrp_model/solvers/pyvrp/options.py +24 -0
- vrp_model-0.1.1/vrp_model/solvers/pyvrp/solver.py +358 -0
- vrp_model-0.1.1/vrp_model/solvers/registry.py +21 -0
- vrp_model-0.1.1/vrp_model/solvers/status.py +40 -0
- vrp_model-0.1.1/vrp_model/solvers/vroom/__init__.py +10 -0
- vrp_model-0.1.1/vrp_model/solvers/vroom/bindings.py +17 -0
- vrp_model-0.1.1/vrp_model/solvers/vroom/options.py +48 -0
- vrp_model-0.1.1/vrp_model/solvers/vroom/solver.py +336 -0
- vrp_model-0.1.1/vrp_model/solvers/vrpsolvereasy/__init__.py +1 -0
- vrp_model-0.1.1/vrp_model/solvers/vrpy/__init__.py +1 -0
- vrp_model-0.1.1/vrp_model/utils/__init__.py +6 -0
- vrp_model-0.1.1/vrp_model/utils/distance.py +12 -0
- vrp_model-0.1.1/vrp_model/utils/time.py +8 -0
- vrp_model-0.1.1/vrp_model/validation/__init__.py +5 -0
- vrp_model-0.1.1/vrp_model/validation/consistency.py +58 -0
- vrp_model-0.1.1/vrp_model/validation/feasibility.py +230 -0
- vrp_model-0.1.1/vrp_model/validation/structure.py +18 -0
- vrp_model-0.1.1/vrp_model/validation/tags.py +20 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main, master]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
check:
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
15
|
+
python-version: ["3.12", "3.13"]
|
|
16
|
+
runs-on: ${{ matrix.os }}
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: astral-sh/setup-uv@v5
|
|
20
|
+
with:
|
|
21
|
+
version: "latest"
|
|
22
|
+
enable-cache: true
|
|
23
|
+
- name: Sync (all solver extras for integration tests)
|
|
24
|
+
run: >-
|
|
25
|
+
uv sync --all-extras --group dev --python ${{ matrix.python-version }}
|
|
26
|
+
- name: Ruff
|
|
27
|
+
run: |
|
|
28
|
+
uv run ruff check vrp_model tests
|
|
29
|
+
uv run ruff format vrp_model tests --check
|
|
30
|
+
- name: Ty
|
|
31
|
+
run: uv run ty check vrp_model
|
|
32
|
+
- name: Unit tests
|
|
33
|
+
run: uv run python -m unittest discover -s tests
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Publishes to Test PyPI first, then PyPI. Uses OIDC trusted publishing (no long-lived API tokens).
|
|
2
|
+
# Setup: register this repo as a trusted publisher on both https://test.pypi.org and https://pypi.org
|
|
3
|
+
# (Project → Publishing settings → Add a new pending publisher → GitHub).
|
|
4
|
+
|
|
5
|
+
name: Publish
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
inputs:
|
|
12
|
+
publish_to_pypi:
|
|
13
|
+
description: "After Test PyPI, publish to production PyPI (releases always do both)"
|
|
14
|
+
type: boolean
|
|
15
|
+
default: false
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
id-token: write
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
publish:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
|
|
27
|
+
- uses: astral-sh/setup-uv@v5
|
|
28
|
+
with:
|
|
29
|
+
version: "latest"
|
|
30
|
+
|
|
31
|
+
- name: Build sdist and wheel
|
|
32
|
+
run: uv build
|
|
33
|
+
|
|
34
|
+
# Attestations default to on; a second publish sees existing *.publish.attestation
|
|
35
|
+
# and fails. Disable for Test PyPI; production step keeps attestations.
|
|
36
|
+
- name: Publish to Test PyPI
|
|
37
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
38
|
+
with:
|
|
39
|
+
repository-url: https://test.pypi.org/legacy/
|
|
40
|
+
attestations: false
|
|
41
|
+
|
|
42
|
+
- name: Publish to PyPI
|
|
43
|
+
if: >-
|
|
44
|
+
github.event_name == 'release' ||
|
|
45
|
+
(github.event_name == 'workflow_dispatch' &&
|
|
46
|
+
github.event.inputs.publish_to_pypi == 'true')
|
|
47
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
vrp_model-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vrp-model
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Solver-agnostic VRP modeling library
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: vrplib>=2.0.2
|
|
7
|
+
Provides-Extra: nextroute
|
|
8
|
+
Requires-Dist: nextroute>=1.0; extra == 'nextroute'
|
|
9
|
+
Provides-Extra: ortools
|
|
10
|
+
Requires-Dist: ortools>=9.10; extra == 'ortools'
|
|
11
|
+
Provides-Extra: pyvrp
|
|
12
|
+
Requires-Dist: pyvrp>=0.13.3; extra == 'pyvrp'
|
|
13
|
+
Provides-Extra: vroom
|
|
14
|
+
Requires-Dist: numpy>=1.26; extra == 'vroom'
|
|
15
|
+
Requires-Dist: pandas>=2.0; extra == 'vroom'
|
|
16
|
+
Requires-Dist: pyvroom>=1.14; extra == 'vroom'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# vrp-model
|
|
20
|
+
|
|
21
|
+
Solver-agnostic vehicle routing: a canonical [`Model`](vrp_model/core/model.py), layered **validation**, automatic **feature detection**, and pluggable backends. Entities reference each other via view objects (`Depot`, `Vehicle`, `Job`) on the same model. Optional **`label`** is for display/export only.
|
|
22
|
+
|
|
23
|
+
**Python:** 3.11+ · **Core dependency:** [`vrplib`](https://pypi.org/project/vrplib/) (instance I/O).
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv sync # core only (no solver backends)
|
|
29
|
+
uv sync --extra pyvrp # PyVRP
|
|
30
|
+
uv sync --extra ortools # Google OR-Tools
|
|
31
|
+
uv sync --extra vroom # VROOM (pyvroom + NumPy + pandas)
|
|
32
|
+
uv sync --extra nextroute # Nextmv Nextroute
|
|
33
|
+
uv sync --extra pyvrp --extra ortools --extra vroom --extra nextroute --group dev
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Each extra installs the matching third-party package; solver classes raise [`SolverNotInstalledError`](vrp_model/core/errors.py) if the extra was not installed.
|
|
37
|
+
|
|
38
|
+
## Available solvers
|
|
39
|
+
|
|
40
|
+
Solvers register under short names (import the submodule once so registration runs, or construct the class directly):
|
|
41
|
+
|
|
42
|
+
| Registry name | Class | Extra | Notes |
|
|
43
|
+
|---------------|-------|-------|--------|
|
|
44
|
+
| `pyvrp` | [`PyVRPSolver`](vrp_model/solvers/pyvrp/solver.py) | `pyvrp` | In-process PyVRP; strong default for “classic” VRP with sparse matrices or Euclidean legs. |
|
|
45
|
+
| `ortools` | [`ORToolsSolver`](vrp_model/solvers/ortools/solver.py) | `ortools` | Google OR-Tools routing; broadest feature coverage in this repo. |
|
|
46
|
+
| `vroom` | [`VroomSolver`](vrp_model/solvers/vroom/solver.py) | `vroom` | [pyvroom](https://pypi.org/project/pyvroom/); matrix-based. On some platforms, matrix setup can fail unless NumPy and pyvroom versions match (see solver docstring). |
|
|
47
|
+
| `nextroute` | [`NextrouteSolver`](vrp_model/solvers/nextroute/solver.py) | `nextroute` | [Nextmv Nextroute](https://pypi.org/project/nextroute/); time windows use an anchor datetime in solver options. |
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from vrp_model.solvers.pyvrp import PyVRPSolver # registers "pyvrp"
|
|
51
|
+
from vrp_model.solvers import get
|
|
52
|
+
|
|
53
|
+
solver_cls = get("pyvrp")
|
|
54
|
+
result = solver_cls({"time_limit": 2.0, "msg": False}).solve(model)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Placeholder packages under `vrp_model/solvers/` (e.g. jsprit, vrpy) are **not** implemented; they are reserved for future work.
|
|
58
|
+
|
|
59
|
+
### What is modeled (VRP in this package)
|
|
60
|
+
|
|
61
|
+
Vehicle routing here means assigning jobs to vehicles (routes), respecting travel between unified **node ids** (depots and jobs), optional **capacity** dimensions, **time** logic (service durations, windows, and caps), **pickup–delivery** pairs, depot topology, and fleet diversity. The canonical [`Model`](vrp_model/core/model.py) holds jobs, vehicles, optional pickup–delivery links, and sparse **travel** overrides; [`Feature`](vrp_model/core/model.py) summarizes which constraint families appear so solvers can declare compatibility.
|
|
62
|
+
|
|
63
|
+
**Detection vs. adapters.** [`Model.detect_features()`](vrp_model/core/model.py) sets [`Feature`](vrp_model/core/model.py) from stored fields (e.g. any positive demand or non-empty vehicle capacity → `CAPACITY`; job or vehicle time windows → `TIME_WINDOWS`; soft penalties in [`TimeWindowFlex`](vrp_model/core/time_window_flex.py) → `FLEXIBLE_TIME_WINDOWS`). Other behavior—**service times**, Euclidean vs matrix travel, **primary optimization emphasis** (distance vs duration)—is not a `Feature` flag but is still passed through each solver adapter where the backend supports it.
|
|
64
|
+
|
|
65
|
+
### Solver capability matrix
|
|
66
|
+
|
|
67
|
+
Before solving, [`Solver.solve`](vrp_model/solvers/base.py) runs [`Model.validate()`](vrp_model/core/model.py) and [`Model.check_solver_compatibility(solver)`](vrp_model/core/model.py), which raises [`SolverCapabilityError`](vrp_model/core/errors.py) if a declared [`Feature`](vrp_model/core/model.py) is missing from the solver’s `supported_features`. One row per modeled capability:
|
|
68
|
+
|
|
69
|
+
| Feature | pyvrp | ortools | nextroute | vroom |
|
|
70
|
+
|---------------|:-----:|:-------:|:---------:|:-----:|
|
|
71
|
+
| Capacity (one or more resource dimensions; demands on jobs, caps on vehicles) | ✓ | ✓ | ✓ | ✓ |
|
|
72
|
+
| Hard time windows at jobs | ✓ | ✓ | ✓ | ✓ |
|
|
73
|
+
| Hard time windows at vehicles (shift / availability) | ✓ | ✓ | ✓ | ✓ |
|
|
74
|
+
| Pickup–delivery pairs (precedence and same vehicle) | ✓ | ✓ | ✓ | ✓ |
|
|
75
|
+
| Multi-depot (vehicles may start/end at different depots) | ✓ | ✓ | ✓ | ✓ |
|
|
76
|
+
| Heterogeneous fleet (distinct vehicle definitions) | ✓ | ✓ | ✓ | ✓ |
|
|
77
|
+
| Skills (jobs require a subset of vehicle skills) | ✗ | ✓ | ✓ | ✓ |
|
|
78
|
+
| Optional jobs / prize-collecting (mandatory vs skip penalty via `prize`) | ✓ | ✓ | ✗ | ✗ |
|
|
79
|
+
| Flexible time windows (linear soft penalties via `TimeWindowFlex`) | ✗ | ✓ | ✗ | ✗ |
|
|
80
|
+
| Vehicle fixed use cost (activation / fixed cost per route) | ✓ | ✓ | ✓ | ✓ |
|
|
81
|
+
| Maximum route distance per vehicle | ✓ | ✓ | ✓ | ✓ |
|
|
82
|
+
| Maximum route duration / shift length per vehicle | ✓ | ✓ | ✓ | ✓ |
|
|
83
|
+
| Route overtime (extra duration allowed + unit penalty on overage) | ✓ | ✓ | ✗ | ✗ |
|
|
84
|
+
| Maximum wait / time slack at nodes (`max_slack_time` on vehicles) | ✗ | ✓ | ✗ | ✗ |
|
|
85
|
+
| Service time at jobs (added into time accounting) | ✓ | ✓ | ✓ | ✓ |
|
|
86
|
+
|
|
87
|
+
**What each backend minimizes (not a `Feature` flag):** [`ORToolsSolver`](vrp_model/solvers/ortools/solver.py) minimizes total **travel distance** (arc cost from the distance matrix; time is a separate dimension). [`PyVRPSolver`](vrp_model/solvers/pyvrp/solver.py) minimizes PyVRP’s objective on the edge costs it receives as distance, with duration driving time feasibility. [`VroomSolver`](vrp_model/solvers/vroom/solver.py) passes duration and distance matrices; VROOM’s default behavior is **duration**-oriented for optimization. [`NextrouteSolver`](vrp_model/solvers/nextroute/solver.py) uses the Nextroute engine’s objective on the constructed instance.
|
|
88
|
+
|
|
89
|
+
## Model assumptions and travel
|
|
90
|
+
|
|
91
|
+
**Unified nodes:** The model stores one append-only list of nodes. Each row has a [`NodeKind`](vrp_model/core/kinds.py) (`DEPOT` or `JOB`). **`node_id`** is the row index—shared across depots and jobs in creation order. Use **`Depot.node_id`** and **`Job.node_id`** as keys in `(from_id, to_id)` travel maps.
|
|
92
|
+
|
|
93
|
+
**Locations:** Depot and job **`location`** are optional for *construction*, but feasibility validation requires every job to have coordinates **unless** you supply a non-empty sparse travel map (see below). Solvers may still synthesize coordinates internally when a location is missing (e.g. PyVRP).
|
|
94
|
+
|
|
95
|
+
**Sparse travel:** Travel is stored as `(from_id, to_id) → `[`TravelEdgeAttrs`](vrp_model/core/travel_edges.py) with optional **`distance`** and **`duration`** (`int` or `None`). At least one of distance or duration must be set on each stored edge. Model-level routing helpers treat a missing field on a stored edge as infinite cost; [`TRAVEL_COST_INF`](vrp_model/core/travel_edges.py) is the large sentinel (aligned with PyVRP’s `MAX_VALUE` scale).
|
|
96
|
+
|
|
97
|
+
- If **`travel_edges`** is **empty**, leg distance and duration fall back to **integer Euclidean** distances between planar coordinates for **all** pairs (depots and jobs). Validation then requires **every job** to have a **`location`**.
|
|
98
|
+
- If **`travel_edges`** is **non-empty**, the model uses **matrix-only** semantics: any directed pair not present in the map has infinite distance and duration (no Euclidean fallback for missing arcs).
|
|
99
|
+
|
|
100
|
+
Use **`set_travel_edges`**, **`update_travel_edge`**, and **`clear_travel_edges`** on the model; **`validate()`** checks node ids, forbids self-loops, and rejects negative costs.
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
## Solving and solutions
|
|
104
|
+
|
|
105
|
+
[`Solver.solve`](vrp_model/solvers/base.py) validates the model, checks capabilities, runs the backend, and attaches a [`Solution`](vrp_model/core/solution.py) to **`model.solution`**. The return value is [`SolutionStatus`](vrp_model/solvers/status.py) (mapped status, timing, stop reason, solver cost, etc.).
|
|
106
|
+
|
|
107
|
+
Use **`model.solution_cost()`**, **`model.is_solution_feasible()`**, and **`model.unassigned_jobs()`** for metrics; these raise **`SolutionUnavailableError`** if no solution is attached.
|
|
108
|
+
|
|
109
|
+
## VRPLIB (`vrplib`)
|
|
110
|
+
|
|
111
|
+
[`read_model`](vrp_model/io/vrplib_io.py) and [`vrplib_dict_to_model`](vrp_model/io/vrplib_io.py) build a `Model` **without** calling **`validate()`**. Call **`model.validate()`** before relying on consistency, or use **`Solver.solve`**, which validates first. [`write_vrplib_instance`](vrp_model/io/vrplib_io.py) / [`write_vrplib_solution`](vrp_model/io/vrplib_io.py) export instances and routes.
|
|
112
|
+
|
|
113
|
+
## Example
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from vrp_model import Model
|
|
117
|
+
from vrp_model.solvers.pyvrp import PyVRPSolver
|
|
118
|
+
|
|
119
|
+
model = Model()
|
|
120
|
+
depot = model.add_depot(location=(0.0, 0.0), label="hub")
|
|
121
|
+
vehicle = model.add_vehicle(10, depot, label="truck1")
|
|
122
|
+
job = model.add_job(3, location=(1.0, 2.0))
|
|
123
|
+
|
|
124
|
+
result = PyVRPSolver({"time_limit": 2.0, "msg": False}).solve(model)
|
|
125
|
+
solution = model.solution
|
|
126
|
+
assert result.mapped_status.name == "FEASIBLE"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
With OR-Tools installed: `from vrp_model.solvers.ortools import ORToolsSolver` and `ORToolsSolver({"time_limit": 5.0}).solve(model)`.
|
|
130
|
+
|
|
131
|
+
## Development
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
uv sync --group dev --extra pyvrp # CI uses this set
|
|
135
|
+
uv run python -m unittest discover -s tests
|
|
136
|
+
uv run ruff check vrp_model tests && uv run ruff format vrp_model tests --check
|
|
137
|
+
uv run ty check vrp_model
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Full solver coverage in tests requires installing the extras you care about (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml); default CI only adds `pyvrp`). Some tests skip backends that are not installed.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# vrp-model
|
|
2
|
+
|
|
3
|
+
Solver-agnostic vehicle routing: a canonical [`Model`](vrp_model/core/model.py), layered **validation**, automatic **feature detection**, and pluggable backends. Entities reference each other via view objects (`Depot`, `Vehicle`, `Job`) on the same model. Optional **`label`** is for display/export only.
|
|
4
|
+
|
|
5
|
+
**Python:** 3.11+ · **Core dependency:** [`vrplib`](https://pypi.org/project/vrplib/) (instance I/O).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv sync # core only (no solver backends)
|
|
11
|
+
uv sync --extra pyvrp # PyVRP
|
|
12
|
+
uv sync --extra ortools # Google OR-Tools
|
|
13
|
+
uv sync --extra vroom # VROOM (pyvroom + NumPy + pandas)
|
|
14
|
+
uv sync --extra nextroute # Nextmv Nextroute
|
|
15
|
+
uv sync --extra pyvrp --extra ortools --extra vroom --extra nextroute --group dev
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Each extra installs the matching third-party package; solver classes raise [`SolverNotInstalledError`](vrp_model/core/errors.py) if the extra was not installed.
|
|
19
|
+
|
|
20
|
+
## Available solvers
|
|
21
|
+
|
|
22
|
+
Solvers register under short names (import the submodule once so registration runs, or construct the class directly):
|
|
23
|
+
|
|
24
|
+
| Registry name | Class | Extra | Notes |
|
|
25
|
+
|---------------|-------|-------|--------|
|
|
26
|
+
| `pyvrp` | [`PyVRPSolver`](vrp_model/solvers/pyvrp/solver.py) | `pyvrp` | In-process PyVRP; strong default for “classic” VRP with sparse matrices or Euclidean legs. |
|
|
27
|
+
| `ortools` | [`ORToolsSolver`](vrp_model/solvers/ortools/solver.py) | `ortools` | Google OR-Tools routing; broadest feature coverage in this repo. |
|
|
28
|
+
| `vroom` | [`VroomSolver`](vrp_model/solvers/vroom/solver.py) | `vroom` | [pyvroom](https://pypi.org/project/pyvroom/); matrix-based. On some platforms, matrix setup can fail unless NumPy and pyvroom versions match (see solver docstring). |
|
|
29
|
+
| `nextroute` | [`NextrouteSolver`](vrp_model/solvers/nextroute/solver.py) | `nextroute` | [Nextmv Nextroute](https://pypi.org/project/nextroute/); time windows use an anchor datetime in solver options. |
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from vrp_model.solvers.pyvrp import PyVRPSolver # registers "pyvrp"
|
|
33
|
+
from vrp_model.solvers import get
|
|
34
|
+
|
|
35
|
+
solver_cls = get("pyvrp")
|
|
36
|
+
result = solver_cls({"time_limit": 2.0, "msg": False}).solve(model)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Placeholder packages under `vrp_model/solvers/` (e.g. jsprit, vrpy) are **not** implemented; they are reserved for future work.
|
|
40
|
+
|
|
41
|
+
### What is modeled (VRP in this package)
|
|
42
|
+
|
|
43
|
+
Vehicle routing here means assigning jobs to vehicles (routes), respecting travel between unified **node ids** (depots and jobs), optional **capacity** dimensions, **time** logic (service durations, windows, and caps), **pickup–delivery** pairs, depot topology, and fleet diversity. The canonical [`Model`](vrp_model/core/model.py) holds jobs, vehicles, optional pickup–delivery links, and sparse **travel** overrides; [`Feature`](vrp_model/core/model.py) summarizes which constraint families appear so solvers can declare compatibility.
|
|
44
|
+
|
|
45
|
+
**Detection vs. adapters.** [`Model.detect_features()`](vrp_model/core/model.py) sets [`Feature`](vrp_model/core/model.py) from stored fields (e.g. any positive demand or non-empty vehicle capacity → `CAPACITY`; job or vehicle time windows → `TIME_WINDOWS`; soft penalties in [`TimeWindowFlex`](vrp_model/core/time_window_flex.py) → `FLEXIBLE_TIME_WINDOWS`). Other behavior—**service times**, Euclidean vs matrix travel, **primary optimization emphasis** (distance vs duration)—is not a `Feature` flag but is still passed through each solver adapter where the backend supports it.
|
|
46
|
+
|
|
47
|
+
### Solver capability matrix
|
|
48
|
+
|
|
49
|
+
Before solving, [`Solver.solve`](vrp_model/solvers/base.py) runs [`Model.validate()`](vrp_model/core/model.py) and [`Model.check_solver_compatibility(solver)`](vrp_model/core/model.py), which raises [`SolverCapabilityError`](vrp_model/core/errors.py) if a declared [`Feature`](vrp_model/core/model.py) is missing from the solver’s `supported_features`. One row per modeled capability:
|
|
50
|
+
|
|
51
|
+
| Feature | pyvrp | ortools | nextroute | vroom |
|
|
52
|
+
|---------------|:-----:|:-------:|:---------:|:-----:|
|
|
53
|
+
| Capacity (one or more resource dimensions; demands on jobs, caps on vehicles) | ✓ | ✓ | ✓ | ✓ |
|
|
54
|
+
| Hard time windows at jobs | ✓ | ✓ | ✓ | ✓ |
|
|
55
|
+
| Hard time windows at vehicles (shift / availability) | ✓ | ✓ | ✓ | ✓ |
|
|
56
|
+
| Pickup–delivery pairs (precedence and same vehicle) | ✓ | ✓ | ✓ | ✓ |
|
|
57
|
+
| Multi-depot (vehicles may start/end at different depots) | ✓ | ✓ | ✓ | ✓ |
|
|
58
|
+
| Heterogeneous fleet (distinct vehicle definitions) | ✓ | ✓ | ✓ | ✓ |
|
|
59
|
+
| Skills (jobs require a subset of vehicle skills) | ✗ | ✓ | ✓ | ✓ |
|
|
60
|
+
| Optional jobs / prize-collecting (mandatory vs skip penalty via `prize`) | ✓ | ✓ | ✗ | ✗ |
|
|
61
|
+
| Flexible time windows (linear soft penalties via `TimeWindowFlex`) | ✗ | ✓ | ✗ | ✗ |
|
|
62
|
+
| Vehicle fixed use cost (activation / fixed cost per route) | ✓ | ✓ | ✓ | ✓ |
|
|
63
|
+
| Maximum route distance per vehicle | ✓ | ✓ | ✓ | ✓ |
|
|
64
|
+
| Maximum route duration / shift length per vehicle | ✓ | ✓ | ✓ | ✓ |
|
|
65
|
+
| Route overtime (extra duration allowed + unit penalty on overage) | ✓ | ✓ | ✗ | ✗ |
|
|
66
|
+
| Maximum wait / time slack at nodes (`max_slack_time` on vehicles) | ✗ | ✓ | ✗ | ✗ |
|
|
67
|
+
| Service time at jobs (added into time accounting) | ✓ | ✓ | ✓ | ✓ |
|
|
68
|
+
|
|
69
|
+
**What each backend minimizes (not a `Feature` flag):** [`ORToolsSolver`](vrp_model/solvers/ortools/solver.py) minimizes total **travel distance** (arc cost from the distance matrix; time is a separate dimension). [`PyVRPSolver`](vrp_model/solvers/pyvrp/solver.py) minimizes PyVRP’s objective on the edge costs it receives as distance, with duration driving time feasibility. [`VroomSolver`](vrp_model/solvers/vroom/solver.py) passes duration and distance matrices; VROOM’s default behavior is **duration**-oriented for optimization. [`NextrouteSolver`](vrp_model/solvers/nextroute/solver.py) uses the Nextroute engine’s objective on the constructed instance.
|
|
70
|
+
|
|
71
|
+
## Model assumptions and travel
|
|
72
|
+
|
|
73
|
+
**Unified nodes:** The model stores one append-only list of nodes. Each row has a [`NodeKind`](vrp_model/core/kinds.py) (`DEPOT` or `JOB`). **`node_id`** is the row index—shared across depots and jobs in creation order. Use **`Depot.node_id`** and **`Job.node_id`** as keys in `(from_id, to_id)` travel maps.
|
|
74
|
+
|
|
75
|
+
**Locations:** Depot and job **`location`** are optional for *construction*, but feasibility validation requires every job to have coordinates **unless** you supply a non-empty sparse travel map (see below). Solvers may still synthesize coordinates internally when a location is missing (e.g. PyVRP).
|
|
76
|
+
|
|
77
|
+
**Sparse travel:** Travel is stored as `(from_id, to_id) → `[`TravelEdgeAttrs`](vrp_model/core/travel_edges.py) with optional **`distance`** and **`duration`** (`int` or `None`). At least one of distance or duration must be set on each stored edge. Model-level routing helpers treat a missing field on a stored edge as infinite cost; [`TRAVEL_COST_INF`](vrp_model/core/travel_edges.py) is the large sentinel (aligned with PyVRP’s `MAX_VALUE` scale).
|
|
78
|
+
|
|
79
|
+
- If **`travel_edges`** is **empty**, leg distance and duration fall back to **integer Euclidean** distances between planar coordinates for **all** pairs (depots and jobs). Validation then requires **every job** to have a **`location`**.
|
|
80
|
+
- If **`travel_edges`** is **non-empty**, the model uses **matrix-only** semantics: any directed pair not present in the map has infinite distance and duration (no Euclidean fallback for missing arcs).
|
|
81
|
+
|
|
82
|
+
Use **`set_travel_edges`**, **`update_travel_edge`**, and **`clear_travel_edges`** on the model; **`validate()`** checks node ids, forbids self-loops, and rejects negative costs.
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
## Solving and solutions
|
|
86
|
+
|
|
87
|
+
[`Solver.solve`](vrp_model/solvers/base.py) validates the model, checks capabilities, runs the backend, and attaches a [`Solution`](vrp_model/core/solution.py) to **`model.solution`**. The return value is [`SolutionStatus`](vrp_model/solvers/status.py) (mapped status, timing, stop reason, solver cost, etc.).
|
|
88
|
+
|
|
89
|
+
Use **`model.solution_cost()`**, **`model.is_solution_feasible()`**, and **`model.unassigned_jobs()`** for metrics; these raise **`SolutionUnavailableError`** if no solution is attached.
|
|
90
|
+
|
|
91
|
+
## VRPLIB (`vrplib`)
|
|
92
|
+
|
|
93
|
+
[`read_model`](vrp_model/io/vrplib_io.py) and [`vrplib_dict_to_model`](vrp_model/io/vrplib_io.py) build a `Model` **without** calling **`validate()`**. Call **`model.validate()`** before relying on consistency, or use **`Solver.solve`**, which validates first. [`write_vrplib_instance`](vrp_model/io/vrplib_io.py) / [`write_vrplib_solution`](vrp_model/io/vrplib_io.py) export instances and routes.
|
|
94
|
+
|
|
95
|
+
## Example
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from vrp_model import Model
|
|
99
|
+
from vrp_model.solvers.pyvrp import PyVRPSolver
|
|
100
|
+
|
|
101
|
+
model = Model()
|
|
102
|
+
depot = model.add_depot(location=(0.0, 0.0), label="hub")
|
|
103
|
+
vehicle = model.add_vehicle(10, depot, label="truck1")
|
|
104
|
+
job = model.add_job(3, location=(1.0, 2.0))
|
|
105
|
+
|
|
106
|
+
result = PyVRPSolver({"time_limit": 2.0, "msg": False}).solve(model)
|
|
107
|
+
solution = model.solution
|
|
108
|
+
assert result.mapped_status.name == "FEASIBLE"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
With OR-Tools installed: `from vrp_model.solvers.ortools import ORToolsSolver` and `ORToolsSolver({"time_limit": 5.0}).solve(model)`.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv sync --group dev --extra pyvrp # CI uses this set
|
|
117
|
+
uv run python -m unittest discover -s tests
|
|
118
|
+
uv run ruff check vrp_model tests && uv run ruff format vrp_model tests --check
|
|
119
|
+
uv run ty check vrp_model
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Full solver coverage in tests requires installing the extras you care about (see [`.github/workflows/ci.yml`](.github/workflows/ci.yml); default CI only adds `pyvrp`). Some tests skip backends that are not installed.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vrp-model"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Solver-agnostic VRP modeling library"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = ["vrplib>=2.0.2"]
|
|
8
|
+
|
|
9
|
+
[project.optional-dependencies]
|
|
10
|
+
pyvrp = ["pyvrp>=0.13.3"]
|
|
11
|
+
ortools = ["ortools>=9.10"]
|
|
12
|
+
vroom = ["pyvroom>=1.14", "numpy>=1.26", "pandas>=2.0"]
|
|
13
|
+
nextroute = ["nextroute>=1.0"]
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"ruff>=0.8",
|
|
18
|
+
"ty>=0.0.1",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["vrp_model"]
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
target-version = "py311"
|
|
30
|
+
line-length = 100
|
|
31
|
+
|
|
32
|
+
[tool.ruff.lint]
|
|
33
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Do not preserve backward compatibility when changing this project
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# No backward compatibility
|
|
7
|
+
|
|
8
|
+
When changing **vrp-model / vrpulp** (public APIs, module layout, type names, dict keys, config, or behavior):
|
|
9
|
+
|
|
10
|
+
- **Do not** keep deprecated aliases, compatibility shims, re-exports “for old code,” dual naming, or version branches whose only purpose is to avoid breaking callers.
|
|
11
|
+
- **Do not** hesitate to rename types, move modules, or change signatures because external or hypothetical users might rely on the old shape.
|
|
12
|
+
- Prefer **one clear name and one behavior**; update **all in-repo** imports, tests, and docs in the same change.
|
|
13
|
+
- If something must remain stable for a short internal transition, treat that as an explicit exception and remove it in a follow-up—do not add open-ended compatibility layers.
|
|
14
|
+
|
|
15
|
+
This project is allowed to break its own API freely until you decide otherwise; optimize for clarity and correctness, not migration ergonomy.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Core project conventions for vrp-model development
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Project Conventions
|
|
7
|
+
|
|
8
|
+
## Code style
|
|
9
|
+
|
|
10
|
+
- Follow **PEP 8** for layout, imports, spacing, line length, and general Python style.
|
|
11
|
+
- Use **snake_case** for **functions**, **methods**, and **module-level variables**; **PascalCase** for **classes**; **UPPER_SNAKE_CASE** for **module-level constants**.
|
|
12
|
+
- Prefer descriptive names; align with what **ruff** enforces in this repo (run `ruff check` / `ruff format` after edits).
|
|
13
|
+
|
|
14
|
+
## Testing
|
|
15
|
+
|
|
16
|
+
- Use `unittest` (not pytest) for writing and running unit tests.
|
|
17
|
+
- Run tests via: `uv run python -m unittest discover -s tests`
|
|
18
|
+
|
|
19
|
+
## Package Manager
|
|
20
|
+
|
|
21
|
+
- Always use `uv` for dependency management, package installation, and running tools.
|
|
22
|
+
- Do **not** manually activate the virtual environment; `uv run` handles it automatically.
|
|
23
|
+
- Install packages: `uv pip install <package>`
|
|
24
|
+
- Sync dependencies: `uv sync`
|
|
25
|
+
- Build the project: `uv build`
|
|
26
|
+
|
|
27
|
+
## Linting and formatting
|
|
28
|
+
|
|
29
|
+
- Use **ruff** for linting and formatting (not black).
|
|
30
|
+
- Run: `uv run ruff check vrp_model` and `uv run ruff format vrp_model` (or `ruff format vrp_model --check` to only check).
|
|
31
|
+
|
|
32
|
+
## Type checking
|
|
33
|
+
|
|
34
|
+
- Use **ty** for type checking (not mypy or pyright).
|
|
35
|
+
- Run type checks via: `uv run ty check vrp_model`
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run unit tests, ty, and ruff checks after every vrp-model code change
|
|
3
|
+
alwaysApply: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Verify After Every Change
|
|
7
|
+
|
|
8
|
+
After making any code change to **vrp-model**, run all three checks before considering the task done:
|
|
9
|
+
|
|
10
|
+
1. **Unit tests**: `uv run --all-extras python -m unittest discover -s tests` — include every optional extra (`pyvrp`, `ortools`, `vroom`, `nextroute`) so solver-backed tests are not skipped for missing dependencies.
|
|
11
|
+
2. **Type checking**: `uv run ty check vrp_model`
|
|
12
|
+
3. **Linting and formatting**: `uv run ruff check vrp_model` and `uv run ruff format vrp_model --check`
|
|
13
|
+
|
|
14
|
+
If any check fails, fix the issues before proceeding.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test package (enables ``from tests.toy_instances`` when running as ``tests.*``)."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Shared VROOM / pyvroom matrix probe for skipping tests when matrices fail to load."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def vroom_matrix_ok() -> bool:
|
|
5
|
+
"""Return True if pyvroom accepts a minimal duration matrix on this platform."""
|
|
6
|
+
try:
|
|
7
|
+
import numpy as np
|
|
8
|
+
import vroom
|
|
9
|
+
except ModuleNotFoundError:
|
|
10
|
+
return False
|
|
11
|
+
try:
|
|
12
|
+
inp = vroom.Input()
|
|
13
|
+
m = np.require(
|
|
14
|
+
np.ascontiguousarray([[0, 1], [1, 0]], dtype=np.uint32),
|
|
15
|
+
dtype=np.uint32,
|
|
16
|
+
requirements=["C"],
|
|
17
|
+
)
|
|
18
|
+
inp.set_durations_matrix("car", m)
|
|
19
|
+
except RuntimeError:
|
|
20
|
+
return False
|
|
21
|
+
return True
|