Skip to content
Merged
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
74 changes: 16 additions & 58 deletions tests/parse/test_parse_distances.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@
import pytest
from numpy.testing import assert_almost_equal, assert_equal, assert_raises

from vrplib.parse.parse_distances import (
from_eilon,
from_lower_row,
is_triangular_number,
parse_distances,
)
from vrplib.parse.parse_distances import parse_distances


@pytest.mark.parametrize(
Expand Down Expand Up @@ -68,33 +63,24 @@ def test_parse_euclidean_distances(edge_weight_type, desired):


@pytest.mark.parametrize(
"comment, func", [("Eilon", from_eilon), (None, from_lower_row)]
"data",
[
[[1, 2, 3, 4, 5, 6]], # single line
[[1, 2, 3, 4], [5, 6]], # ragged lines
[[1], [2, 3], [4, 5, 6]], # proper triangular rows
],
)
def test_parse_lower_row(comment, func):
def test_parse_lower_row(data):
"""
Tests if a ``LOWER ROW`` instance is parsed as Eilon instance or regular
instance. Eilon instances do not contain a proper lower row matrix, but
a lower column matrix instead. The current way of detecting an Eilon
instance is by means of the ``COMMENT`` field, which is checked for
including "Eilon".
Tests that LOWER_ROW instances are parsed correctly regardless of how
the values are wrapped across lines. See #134.
"""
instance = {
"data": np.array([[1], [2, 3], [4, 5, 6]], dtype=object),
"edge_weight_type": "EXPLICIT",
"edge_weight_format": "LOWER_ROW",
"comment": comment,
}

assert_equal(parse_distances(**instance), func(instance["data"]))


def test_from_lower_row():
"""
Tests that a lower row triangular matrix is correctly transformed into a
full matrix.
"""
triangular_matrix = np.array([[1], [2, 3], [4, 5, 6]], dtype=object)
actual = from_lower_row(triangular_matrix)
data = np.array(data, dtype=object)
actual = parse_distances(
data,
edge_weight_type="EXPLICIT",
edge_weight_format="LOWER_ROW",
)
desired = np.array(
[
[0, 1, 2, 4],
Expand All @@ -105,31 +91,3 @@ def test_from_lower_row():
)

assert_equal(actual, desired)


def test_from_eilon():
"""
Tests that the distance matrix of Eilon instances is correctly transformed.
These distance matrices have entries corresponding to the lower column
triangular matrices. But the distance matrix is not a triangular matrix,
so they are flattened first.
"""
eilon = np.array([[1, 2, 3, 4], [5, 6]], dtype=object)
actual = from_eilon(eilon)
desired = np.array(
[
[0, 1, 2, 3],
[1, 0, 4, 5],
[2, 4, 0, 6],
[3, 5, 6, 0],
]
)

assert_equal(actual, desired)


@pytest.mark.parametrize(
"n, res", [(1, True), (3, True), (4, False), (630, True), (1000, False)]
)
def test_is_triangular_number(n, res):
assert_equal(is_triangular_number(n), res)
22 changes: 22 additions & 0 deletions tests/read/test_read_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,25 @@ def test_do_not_compute_edge_weights(tmp_path):

instance = read_instance(tmp_path / name, "solomon", False)
assert_("edge_weight" not in instance)


def test_read_explicit_lower_row_instance_objective():
"""
Tests that the E-n13-k4 instance with EXPLICIT LOWER_ROW edge weights
is read correctly by verifying the known optimal solution cost of 247.
"""
instance = read_instance("tests/data/E-n13-k4.vrp")
edge_weight = instance["edge_weight"]

# Known optimal solution routes (0-indexed customer IDs).
# Depot is node 0; customers are nodes 1-12.
routes = [[1], [8, 5, 3], [9, 12, 10, 6], [11, 4, 7, 2]]

total_cost = 0
for route in routes:
total_cost += edge_weight[0, route[0]]
for idx in range(len(route) - 1):
total_cost += edge_weight[route[idx], route[idx + 1]]
total_cost += edge_weight[route[-1], 0]

assert_equal(total_cost, 247)
61 changes: 15 additions & 46 deletions vrplib/parse/parse_distances.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from itertools import combinations

import numpy as np


Expand Down Expand Up @@ -61,11 +59,6 @@ def parse_distances(

if edge_weight_type == "EXPLICIT":
if edge_weight_format == "LOWER_ROW":
# TODO Eilon instances edge weight specifications are incorrect in
# (C)VRPLIB format. Find a better way to identify Eilon instances.
if comment is not None and "Eilon" in comment:
return from_eilon(data)

return from_lower_row(data)

if edge_weight_format == "FULL_MATRIX":
Expand Down Expand Up @@ -98,56 +91,32 @@ def pairwise_euclidean(coords: np.ndarray) -> np.ndarray:
return np.sqrt(sq_dist)


def from_lower_row(triangular: np.ndarray) -> np.ndarray:
def from_lower_row(data: np.ndarray) -> np.ndarray:
"""
Computes a full distances matrix from a lower row triangular matrix.
The triangular matrix should not contain the diagonal.
Computes a full distances matrix from a LOWER_ROW edge weight section.

The input is treated as a continuous 1D stream of values (as specified
by TSPLIB95), regardless of how the values are wrapped across lines.

Parameters
----------
triangular
A list of lists, each list representing the entries of a row in a
lower triangular matrix without diagonal entries.
data
Edge weight data, possibly as a ragged array of rows.

Returns
-------
np.ndarray
A n-by-n distances matrix.
"""
n = len(triangular) + 1
distances = np.zeros((n, n))

for i in range(n - 1):
distances[i + 1, : i + 1] = triangular[i]

return distances + distances.T


def from_eilon(edge_weights: np.ndarray) -> np.ndarray:
An n-by-n distances matrix.
"""
Computes a full distances matrix from the Eilon instances with "LOWER_ROW"
edge weight format. The specification is incorrect, instead the edge weight
section needs to be parsed as a flattend, column-wise triangular matrix.
flattened = np.concatenate(data).astype(float)

See https://github.com/leonlan/VRPLIB/issues/40.
"""
flattened = [dist for row in edge_weights for dist in row]
n = int((2 * len(flattened)) ** 0.5) + 1 # The (n+1)-th triangular number
# The flattened data represents the lower triangle of a symmetric matrix.
# See https://en.wikipedia.org/wiki/Triangular_number.
# m = n * (n - 1) / 2 => n = (1 + sqrt(1 + 8m)) / 2
n = (1 + int((1 + 8 * flattened.size) ** 0.5)) // 2

distances = np.zeros((n, n))
indices = sorted([(i, j) for (i, j) in combinations(range(n), r=2)])

for idx, (i, j) in enumerate(indices):
d_ij = flattened[idx]
distances[i, j] = d_ij
distances[j, i] = d_ij
distances[np.tril_indices(n, k=-1)] = flattened
distances += distances.T

return distances


def is_triangular_number(n):
"""
Checks if n is a triangular number.
"""
i = int((2 * n) ** 0.5)
return i * (i + 1) == 2 * n
Loading