Repository: PyVRP/PyVRP (python)

Issue:
High-level interfaces `solve_cvrp` and `solve_vrptw`
From https://github.com/N-Wouda/PyVRP/issues/48#issuecomment-1406684143

> I think we should do the following.
> 
> First, we offer two high-level solver routines in the pyvrp package: `solve_cvrp()` and `solver_vrptw()`. These take only the bare minimum of arguments (perhaps only a ProblemData instance) and are intended for users that only want to solve the problem, and do not care at all about _how_. This is intentionally a black box. These routines return a Result object that contains some statistics, and the best solution, and everything else is basically a hidden implementation detail.
> 
> For these functions, we compile two versions of our codebase: one where we use the TWS data, and another where we do not, using compile time flags. The compiler will optimise for each case. In the high-level solver routines, we then use the appropriate binary to implement the solver function.
> 
> For people's own use cases, we expose only the full/complete components, not the faster, problem-specific implementations.


Pull request description:
Simpler modelling interface
This PR:

- [x] Closes #217, closes #132.
- [x] Adds and passes relevant tests.
- [x] Has well-formatted code and documentation.

<details>

**Notes**:

- It is essential that you add a test when making code changes.
  This keeps the code coverage level up, and helps ensure the changes work as intended.
- When fixing a bug, you must add a test that would produce the bug in the master branch, and then show that it is fixed with the new code. 
- New code additions must be well formatted. Changes should pass the pre-commit workflow, which you can set up locally using [pre-commit](https://pre-commit.com/#intro). 
- Docstring additions must render correctly, including escapes and LaTeX.

</details>


Patch:
diff --git a/docs/source/api/pyvrp.rst b/docs/source/api/pyvrp.rst
index bc77bfa..9161b1d 100644
--- a/docs/source/api/pyvrp.rst
+++ b/docs/source/api/pyvrp.rst
@@ -15,6 +15,11 @@ This object can be used to obtain the best observed solution, and detailed runti
 
    Have a look at the examples to see how these classes relate!
 
+.. automodule:: pyvrp.Model
+
+   .. autoclass:: Model
+      :members:
+
 .. automodule:: pyvrp._CostEvaluator
    
    .. autoapiclass:: CostEvaluator
@@ -22,10 +27,10 @@ This object can be used to obtain the best observed solution, and detailed runti
 
 .. automodule:: pyvrp.GeneticAlgorithm
 
-   .. autoapiclass:: GeneticAlgorithmParams
+   .. autoclass:: GeneticAlgorithmParams
       :members:
 
-   .. autoapiclass:: GeneticAlgorithm
+   .. autoclass:: GeneticAlgorithm
       :members:
 
 .. automodule:: pyvrp._Individual
@@ -38,10 +43,10 @@ This object can be used to obtain the best observed solution, and detailed runti
 
 .. automodule:: pyvrp.PenaltyManager
 
-   .. autoapiclass:: PenaltyParams
+   .. autoclass:: PenaltyParams
       :members:
 
-   .. autoapiclass:: PenaltyManager
+   .. autoclass:: PenaltyManager
       :members: 
 
 .. automodule:: pyvrp.Population
diff --git a/pyvrp/Model.py b/pyvrp/Model.py
new file mode 100644
index 0000000..e22c924
--- /dev/null
+++ b/pyvrp/Model.py
@@ -0,0 +1,283 @@
+from typing import List, Union
+from warnings import warn
+
+import numpy as np
+
+from pyvrp.GeneticAlgorithm import GeneticAlgorithm, GeneticAlgorithmParams
+from pyvrp.PenaltyManager import PenaltyManager
+from pyvrp.Population import Population, PopulationParams
+from pyvrp.Result import Result
+from pyvrp._Individual import Individual
+from pyvrp._ProblemData import Client, ProblemData
+from pyvrp._XorShift128 import XorShift128
+from pyvrp.constants import MAX_USER_VALUE, MAX_VALUE
+from pyvrp.crossover import selective_route_exchange as srex
+from pyvrp.diversity import broken_pairs_distance as bpd
+from pyvrp.exceptions import ScalingWarning
+from pyvrp.stop import StoppingCriterion
+
+Depot = Client
+
+
+class Edge:
+
+    __slots__ = ["frm", "to", "distance", "duration"]
+
+    def __init__(self, frm: Client, to: Client, distance: int, duration: int):
+        self.frm = frm
+        self.to = to
+        self.distance = distance
+        self.duration = duration
+
+
+class VehicleType:
+
+    __slots__ = ["number", "capacity"]
+
+    def __init__(self, number: int, capacity: int):
+        self.number = number
+        self.capacity = capacity
+
+
+class Model:
+    """
+    A simple interface for modelling vehicle routing problems with PyVRP.
+    """
+
+    def __init__(self):
+        self._clients: List[Client] = []
+        self._depots: List[Depot] = []
+        self._edges: List[Edge] = []
+        self._vehicle_types: List[VehicleType] = []
+
+    @property
+    def locations(self) -> List[Client]:
+        """
+        Returns all locations (depots and clients) in the current model. The
+        routes returned by :meth:`~solve` can be used to index these locations.
+        """
+        return self._depots + self._clients
+
+    @classmethod
+    def from_data(cls, data: ProblemData) -> "Model":
+        """
+        Constructs a model instance from the given data.
+
+        Parameters
+        ----------
+        data
+            Problem data to feed into the model.
+
+        Returns
+        -------
+        Model
+            A model instance representing the given data.
+        """
+        clients = [data.client(idx) for idx in range(data.num_clients + 1)]
+        edges = [
+            Edge(
+                clients[frm],
+                clients[to],
+                data.dist(frm, to),
+                data.duration(frm, to),
+            )
+            for frm in range(data.num_clients + 1)
+            for to in range(data.num_clients + 1)
+        ]
+
+        self = Model()
+        self._clients = clients[1:]
+        self._depots = clients[:1]
+        self._edges = edges
+        vehicle_types = [VehicleType(data.num_vehicles, data.vehicle_capacity)]
+        self._vehicle_types = vehicle_types
+
+        return self
+
+    def add_client(
+        self,
+        x: int,
+        y: int,
+        demand: int = 0,
+        service_duration: int = 0,
+        tw_early: int = 0,
+        tw_late: int = 0,
+        prize: int = 0,
+        required: bool = True,
+    ) -> Client:
+        """
+        Adds a client with the given attributes to the model. Returns the
+        created :class:`~pyvrp._ProblemData.Client` instance.
+        """
+        client = Client(
+            x,
+            y,
+            demand,
+            service_duration,
+            tw_early,
+            tw_late,
+            prize,
+            required,
+        )
+
+        self._clients.append(client)
+        return client
+
+    def add_depot(
+        self, x: int, y: int, tw_early: int = 0, tw_late: int = 0
+    ) -> Depot:
+        """
+        Adds a depot with the given attributes to the model. Returns the
+        created :class:`~pyvrp._ProblemData.Client` instance.
+
+        .. warning::
+
+           PyVRP does not yet support multi-depot VRPs. For now, only one depot
+           can be added to the model.
+        """
+        if len(self._depots) >= 1:
+            msg = "PyVRP does not yet support multi-depot VRPs."
+            raise ValueError(msg)
+
+        depot = Depot(x, y, tw_early=tw_early, tw_late=tw_late)
+        self._depots.append(depot)
+        return depot
+
+    def add_edge(
+        self,
+        frm: Union[Client, Depot],
+        to: Union[Client, Depot],
+        distance: int,
+        duration: int = 0,
+    ) -> Edge:
+        """
+        Adds an edge :math:`(i, j)` between ``frm`` (:math:`i`) and ``to``
+        (:math:`j`). The edge can be given distance and duration attributes.
+        Distance is required, but the default duration is zero. Returns the
+        created edge.
+
+        Raises
+        ------
+        ValueError
+            When either distance or duration is a negative value.
+        """
+        if distance < 0 or duration < 0:
+            raise ValueError("Cannot have negative edge distance or duration.")
+
+        edge = Edge(frm, to, distance, duration)
+        self._edges.append(edge)
+        return edge
+
+    def add_vehicle_type(self, number: int, capacity: int) -> VehicleType:
+        """
+        Adds a vehicle type with the given number of vehicles of given capacity
+        to the model. Returns the created vehicle type.
+
+        .. warning::
+
+           PyVRP does not yet support heterogeneous fleet VRPs. For now, only
+           one vehicle type can be added to the model.
+
+        Raises
+        ------
+        ValueError
+            When either the vehicle number or capacity is not a positive value.
+        """
+        if len(self._vehicle_types) >= 1:
+            msg = "PyVRP does not yet support heterogeneous fleet VRPs."
+            raise ValueError(msg)
+
+        if number <= 0:
+            raise ValueError("Must have positive number of vehicles.")
+
+        if capacity < 0:
+            raise ValueError("Cannot have negative vehicle capacity.")
+
+        vehicle_type = VehicleType(number, capacity)
+        self._vehicle_types.append(vehicle_type)
+        return vehicle_type
+
+    def data(self) -> ProblemData:
+        """
+        Creates and returns a :class:`~pyvrp._ProblemData.ProblemData` instance
+        from this model's attributes.
+        """
+        locs = self.locations
+        loc2idx = {id(loc): idx for idx, loc in enumerate(locs)}
+
+        num_vehicles = self._vehicle_types[0].number
+        vehicle_capacity = self._vehicle_types[0].capacity
+
+        max_data_value = max(max(e.distance, e.duration) for e in self._edges)
+        if max_data_value > MAX_USER_VALUE:
+            msg = """
+            The maximum distance or duration value is very large. This might
+            impact numerical stability. Consider rescaling your input data.
+            """
+            warn(msg, ScalingWarning)
+
+        # Default value is a sufficiently large value to make sure any edges
+        # not set below are never traversed.
+        distances = np.full((len(locs), len(locs)), MAX_VALUE, dtype=int)
+        durations = np.full((len(locs), len(locs)), MAX_VALUE, dtype=int)
+
+        for edge in self._edges:
+            frm = loc2idx[id(edge.frm)]
+            to = loc2idx[id(edge.to)]
+            distances[frm, to] = edge.distance
+            durations[frm, to] = edge.duration
+
+        return ProblemData(
+            locs,
+            num_vehicles,
+            vehicle_capacity,
+            distances,
+            durations,
+        )
+
+    def solve(self, stop: StoppingCriterion, seed: int = 0) -> Result:
+        """
+        Solve this model.
+
+        Parameters
+        ----------
+        stop
+            Stopping criterion to use.
+        seed, optional
+            Seed value to use for the PRNG, by default 0.
+
+        Returns
+        -------
+        Result
+            The solution result object, containing the best found solution.
+        """
+        # These cause a circular import, so the imports needed to be postponed
+        # to here (where they are actually used).
+        from pyvrp.educate import (
+            NODE_OPERATORS,
+            ROUTE_OPERATORS,
+            LocalSearch,
+            compute_neighbours,
+        )
+
+        data = self.data()
+        rng = XorShift128(seed=seed)
+        ls = LocalSearch(data, rng, compute_neighbours(data))
+
+        for op in NODE_OPERATORS:
+            ls.add_node_operator(op(data))
+
+        for op in ROUTE_OPERATORS:
+            ls.add_route_operator(op(data))
+
+        pm = PenaltyManager()
+        pop_params = PopulationParams()
+        pop = Population(bpd, pop_params)
+        init = [
+            Individual.make_random(data, rng)
+            for _ in range(pop_params.min_pop_size)
+        ]
+
+        gen_params = GeneticAlgorithmParams(collect_statistics=True)
+        algo = GeneticAlgorithm(data, pm, rng, pop, ls, srex, init, gen_params)
+        return algo.run(stop)
diff --git a/pyvrp/__init__.py b/pyvrp/__init__.py
index fbab870..42b5771 100644
--- a/pyvrp/__init__.py
+++ b/pyvrp/__init__.py
@@ -1,4 +1,5 @@
 from .GeneticAlgorithm import GeneticAlgorithm, GeneticAlgorithmParams
+from .Model import Model
 from .PenaltyManager import PenaltyManager, PenaltyParams
 from .Population import Population, PopulationParams
 from .Result import Result
diff --git a/pyvrp/constants.py b/pyvrp/constants.py
new file mode 100644
index 0000000..c498a28
--- /dev/null
+++ b/pyvrp/constants.py
@@ -0,0 +1,2 @@
+MAX_USER_VALUE = 2 << 25
+MAX_VALUE = 2 << 28
diff --git a/pyvrp/exceptions.py b/pyvrp/exceptions.py
index 1c23845..1ff5b42 100644
--- a/pyvrp/exceptions.py
+++ b/pyvrp/exceptions.py
@@ -4,8 +4,6 @@ class StatisticsNotCollectedError(Exception):
     that can only be used when statistics are available.
     """
 
-    pass
-
 
 class EmptySolutionWarning(UserWarning):
     """
@@ -13,4 +11,9 @@ class EmptySolutionWarning(UserWarning):
     forbidden, per se, but very odd.
     """
 
-    pass
+
+class ScalingWarning(UserWarning):
+    """
+    Raised when the distance or duration values in the problem are very large,
+    which could cause the algorithm to start using forbidden edges as well.
+    """
diff --git a/pyvrp/read.py b/pyvrp/read.py
index a182943..b255cd7 100644
--- a/pyvrp/read.py
+++ b/pyvrp/read.py
@@ -2,10 +2,14 @@ import functools
 import pathlib
 from numbers import Number
 from typing import Callable, Dict, List, Union
+from warnings import warn
 
 import numpy as np
 import vrplib
 
+from pyvrp.constants import MAX_USER_VALUE
+from pyvrp.exceptions import ScalingWarning
+
 from ._ProblemData import Client, ProblemData
 
 _Routes = List[List[int]]
@@ -165,6 +169,13 @@ def read(
         for idx in range(dimension)
     ]
 
+    if max(distances.max(), durations.max()) > MAX_USER_VALUE:
+        msg = """
+        The maximum distance or duration value is very large. This might
+        impact numerical stability. Consider rescaling your input data.
+        """
+        warn(msg, ScalingWarning)
+
     return ProblemData(
         clients,
         num_vehicles,


Test patch:
diff --git a/pyvrp/tests/data/ReallyLargeDistance.txt b/pyvrp/tests/data/ReallyLargeDistance.txt
new file mode 100644
index 0000000..f8880a3
--- /dev/null
+++ b/pyvrp/tests/data/ReallyLargeDistance.txt
@@ -0,0 +1,41 @@
+NAME : TEST
+COMMENT : Small problem instance with four customers
+DIMENSION : 5
+EDGE_WEIGHT_TYPE : EXPLICIT
+EDGE_WEIGHT_FORMAT : FULL_MATRIX
+CAPACITY : 10
+VEHICLES : 3
+NODE_COORD_SECTION
+1    2334   726
+2    226    1297
+3    590    530
+4    435    718
+5    1191   639
+EDGE_WEIGHT_SECTION
+0     1544  1944  1931  1000000000
+1726     0  1992  1427  1593
+1965  1975     0  621   1090
+2063  1433   647  0     818
+1475  1594  1090  828   0
+DEMAND_SECTION
+1    0
+2    5
+3    5
+4    3
+5    5
+TIME_WINDOW_SECTION
+1    0      45000
+2    15600  22500
+3    12000  19500
+4    8400   15300
+5    12000  19500
+SERVICE_TIME_SECTION
+1    0
+2    360
+3    360
+4    420
+5    360
+DEPOT_SECTION
+1
+-1
+EOF
diff --git a/pyvrp/tests/test_Model.py b/pyvrp/tests/test_Model.py
new file mode 100644
index 0000000..2de7419
--- /dev/null
+++ b/pyvrp/tests/test_Model.py
@@ -0,0 +1,242 @@
+from numpy.testing import assert_, assert_equal, assert_raises, assert_warns
+from pytest import mark
+
+from pyvrp import Model
+from pyvrp.constants import MAX_USER_VALUE, MAX_VALUE
+from pyvrp.exceptions import ScalingWarning
+from pyvrp.stop import MaxIterations
+from pyvrp.tests.helpers import read
+
+
+def test_model_data():
+    model = Model()
+
+    # Let's add some data: a single client, and edges from/to the depot.
+    depot = model.add_depot(0, 0)
+    client = model.add_client(0, 1, demand=1)
+    model.add_edge(depot, client, 1, 1)
+    model.add_edge(client, depot, 1, 1)
+    model.add_vehicle_type(1, 1)
+
+    # The model should now have one client.
+    data = model.data()
+    assert_equal(data.num_clients, 1)
+
+
+def test_add_depot_raises_more_than_one_depot():
+    model = Model()
+    model.add_depot(0, 0)  # first depot should be OK
+
+    with assert_raises(ValueError):
+        model.add_depot(0, 1)  # second (and more) should not be
+
+
+def test_add_edge_raises_negative_distance_or_duration():
+    model = Model()
+    depot = model.add_depot(0, 0)
+    client = model.add_client(0, 1)
+
+    model.add_edge(depot, client, distance=0, duration=0)  # zero should be OK
+
+    with assert_raises(ValueError):  # negative distance should not be OK
+        model.add_edge(client, depot, distance=-1, duration=0)
+
+    with assert_raises(ValueError):  # negative duration should also not be OK
+        model.add_edge(client, depot, distance=0, duration=-1)
+
+
+def test_add_vehicle_type_raises_more_than_one_type():
+    model = Model()
+    model.add_vehicle_type(1, 10)  # first type should be OK
+
+    with assert_raises(ValueError):
+        model.add_vehicle_type(2, 10)  # second (and more) should not be
+
+
+@mark.parametrize(
+    "number, capacity",
+    [
+        (0, 1),  # zero vehicles is not OK (but zero capacity is)
+        (-1, 1),  # negative vehicles is not OK
+        (1, -1),  # negative capacity is not OK
+    ],
+)
+def test_add_vehicle_type_raises_negative_number_or_capacity(number, capacity):
+    model = Model()
+
+    with assert_raises(ValueError):
+        model.add_vehicle_type(number, capacity)
+
+
+def test_add_client_attributes():
+    model = Model()
+    client = model.add_client(
+        x=1,
+        y=2,
+        demand=3,
+        service_duration=4,
+        tw_early=5,
+        tw_late=6,
+        prize=7,
+        required=False,
+    )
+
+    assert_equal(client.x, 1)
+    assert_equal(client.y, 2)
+    assert_equal(client.demand, 3)
+    assert_equal(client.service_duration, 4)
+    assert_equal(client.tw_early, 5)
+    assert_equal(client.tw_late, 6)
+    assert_equal(client.prize, 7)
+    assert_(not client.required)
+
+
+def test_add_depot_attributes():
+    model = Model()
+    depot = model.add_depot(x=1, y=0, tw_early=3, tw_late=5)
+
+    assert_equal(depot.x, 1)
+    assert_equal(depot.y, 0)
+    assert_equal(depot.tw_early, 3)
+    assert_equal(depot.tw_late, 5)
+
+
+def test_add_edge():
+    model = Model()
+    depot = model.add_depot(0, 0)
+    client = model.add_client(0, 1)
+    edge = model.add_edge(depot, client, distance=15, duration=49)
+
+    assert_(edge.frm is depot)
+    assert_(edge.to is client)
+    assert_equal(edge.distance, 15)
+    assert_equal(edge.duration, 49)
+
+
+def test_add_vehicle_type():
+    model = Model()
+    vehicle_type = model.add_vehicle_type(number=10, capacity=998)
+
+    assert_equal(vehicle_type.number, 10)
+    assert_equal(vehicle_type.capacity, 998)
+
+
+def test_from_data():
+    read_data = read("data/E-n22-k4.txt", round_func="dimacs")
+    model = Model.from_data(read_data)
+    model_data = model.data()
+
+    # We can first check if the overall problem dimension numbers agree.
+    assert_equal(model_data.num_clients, read_data.num_clients)
+    assert_equal(model_data.num_vehicles, read_data.num_vehicles)
+    assert_equal(model_data.vehicle_capacity, read_data.vehicle_capacity)
+
+    # It's a bit cumbersome to compare the whole matrices, so we use a few
+    # sample traces from the distance and duration matrices instead.
+    assert_equal(model_data.dist(3, 4), read_data.dist(3, 4))
+    assert_equal(model_data.duration(2, 1), read_data.duration(2, 1))
+
+
+def test_from_data_and_solve():
+    # Solve the small E-n22-k4 instance using the from_data constructor.
+    data = read("data/E-n22-k4.txt", round_func="dimacs")
+    model = Model.from_data(data)
+    res = model.solve(stop=MaxIterations(100), seed=0)
+    assert_equal(res.cost(), 3_743)
+    assert_(res.is_feasible())
+
+    data = read("data/OkSmall.txt")
+    model = Model.from_data(data)
+    res = model.solve(stop=MaxIterations(100), seed=0)
+    assert_equal(res.cost(), 9_155)
+    assert_(res.is_feasible())
+
+
+def test_model_and_solve():
+    # Solve the small OkSmall instance using the from_data constructor.
+    data = read("data/OkSmall.txt")
+    model = Model.from_data(data)
+    res = model.solve(stop=MaxIterations(100), seed=0)
+    assert_equal(res.cost(), 9_155)
+    assert_(res.is_feasible())
+
+    # Now do the same thing, but model the instance using the modelling API.
+    # This should of course result in the same solution.
+    model = Model()
+    model.add_vehicle_type(number=3, capacity=10)
+    depot = model.add_depot(x=2334, y=726, tw_early=0, tw_late=45000)
+    clients = [
+        model.add_client(226, 1297, 5, 360, 15600, 22500),
+        model.add_client(590, 530, 5, 360, 12000, 19500),
+        model.add_client(435, 718, 3, 420, 8400, 15300),
+        model.add_client(1191, 639, 5, 360, 12000, 19500),
+    ]
+
+    edge_weights = [
+        [0, 1544, 1944, 1931, 1476],
+        [1726, 0, 1992, 1427, 1593],
+        [1965, 1975, 0, 621, 1090],
+        [2063, 1433, 647, 0, 818],
+        [1475, 1594, 1090, 828, 0],
+    ]
+
+    for idx, client in enumerate(clients, 1):
+        from_depot = edge_weights[0][idx]
+        to_depot = edge_weights[idx][0]
+
+        model.add_edge(depot, client, from_depot, from_depot)
+        model.add_edge(client, depot, to_depot, to_depot)
+
+        for other_idx, other in enumerate(clients, 1):
+            from_client = edge_weights[idx][other_idx]
+            to_client = edge_weights[other_idx][idx]
+
+            model.add_edge(client, other, from_client, from_client)
+            model.add_edge(other, client, to_client, to_client)
+
+    res = model.solve(stop=MaxIterations(100), seed=0)
+    assert_equal(res.cost(), 9_155)
+    assert_(res.is_feasible())
+
+
+def test_partial_distance_duration_matrix():
+    model = Model()
+    depot = model.add_depot(0, 0)
+    clients = [model.add_client(0, 1), model.add_client(1, 0)]
+
+    # Only a subset of all possible edges exist. The model should be able to
+    # handle that.
+    model.add_edge(depot, clients[0], distance=1)
+    model.add_edge(clients[0], clients[1], distance=2)
+    model.add_edge(clients[1], depot, distance=1)
+
+    model.add_vehicle_type(number=1, capacity=0)
+
+    # These edges were not set, so their distance values should default to the
+    # maximum value we use for such edges.
+    data = model.data()
+    assert_equal(data.dist(0, 2), MAX_VALUE)
+    assert_equal(data.dist(1, 0), MAX_VALUE)
+
+    res = model.solve(stop=MaxIterations(100), seed=4)
+    assert_equal(res.best.num_routes(), 1)
+    assert_equal(res.cost(), 4)  # depot -> client 1 -> client 2 -> depot
+    assert_(res.is_feasible())
+
+
+def test_data_warns_about_scaling_issues(recwarn):
+    model = Model()
+    model.add_vehicle_type(number=1, capacity=0)
+    depot = model.add_depot(0, 0)
+    client = model.add_client(1, 1)
+
+    # Normal distance sizes; should all be OK.
+    for distance in [1, 10, 100, 1_000, 10_000, 100_000, MAX_USER_VALUE]:
+        model.add_edge(client, depot, distance=distance)
+        model.data()
+        assert_equal(len(recwarn), 0)
+
+    # But a value exceeding the maximum user value is not OK. This should warn.
+    model.add_edge(depot, client, distance=MAX_USER_VALUE + 1)
+    with assert_warns(ScalingWarning):
+        model.data()
diff --git a/pyvrp/tests/test_read.py b/pyvrp/tests/test_read.py
index 2d555e6..367affe 100644
--- a/pyvrp/tests/test_read.py
+++ b/pyvrp/tests/test_read.py
@@ -1,6 +1,7 @@
-from numpy.testing import assert_equal, assert_raises
+from numpy.testing import assert_equal, assert_raises, assert_warns
 from pytest import mark
 
+from pyvrp.exceptions import ScalingWarning
 from pyvrp.tests.helpers import read
 
 
@@ -166,4 +167,13 @@ def test_reading_RC208_instance():  # Solomon style instance
         assert_equal(data.client(client).service_duration, 100)
 
 
+def test_warns_about_scaling_issues(recwarn):
+    # But 100 million is too large. That's really close to INT_MAX, and might
+    # result in issues. So now a warning should be issued.
+    with assert_warns(ScalingWarning):
+        # The arc from the depot to client 4 is really large (one billion), so
+        # that should trigger a warning.
+        read("data/ReallyLargeDistance.txt")
+
+
 # TODO test round funcs