Skip to content

Commit f27d946

Browse files
authored
Correctly parse LOWER_ROW edge weight format (#135)
1 parent 6d3c0a2 commit f27d946

3 files changed

Lines changed: 53 additions & 104 deletions

File tree

tests/parse/test_parse_distances.py

Lines changed: 16 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@
22
import pytest
33
from numpy.testing import assert_almost_equal, assert_equal, assert_raises
44

5-
from vrplib.parse.parse_distances import (
6-
from_eilon,
7-
from_lower_row,
8-
is_triangular_number,
9-
parse_distances,
10-
)
5+
from vrplib.parse.parse_distances import parse_distances
116

127

138
@pytest.mark.parametrize(
@@ -68,33 +63,24 @@ def test_parse_euclidean_distances(edge_weight_type, desired):
6863

6964

7065
@pytest.mark.parametrize(
71-
"comment, func", [("Eilon", from_eilon), (None, from_lower_row)]
66+
"data",
67+
[
68+
[[1, 2, 3, 4, 5, 6]], # single line
69+
[[1, 2, 3, 4], [5, 6]], # ragged lines
70+
[[1], [2, 3], [4, 5, 6]], # proper triangular rows
71+
],
7272
)
73-
def test_parse_lower_row(comment, func):
73+
def test_parse_lower_row(data):
7474
"""
75-
Tests if a ``LOWER ROW`` instance is parsed as Eilon instance or regular
76-
instance. Eilon instances do not contain a proper lower row matrix, but
77-
a lower column matrix instead. The current way of detecting an Eilon
78-
instance is by means of the ``COMMENT`` field, which is checked for
79-
including "Eilon".
75+
Tests that LOWER_ROW instances are parsed correctly regardless of how
76+
the values are wrapped across lines. See #134.
8077
"""
81-
instance = {
82-
"data": np.array([[1], [2, 3], [4, 5, 6]], dtype=object),
83-
"edge_weight_type": "EXPLICIT",
84-
"edge_weight_format": "LOWER_ROW",
85-
"comment": comment,
86-
}
87-
88-
assert_equal(parse_distances(**instance), func(instance["data"]))
89-
90-
91-
def test_from_lower_row():
92-
"""
93-
Tests that a lower row triangular matrix is correctly transformed into a
94-
full matrix.
95-
"""
96-
triangular_matrix = np.array([[1], [2, 3], [4, 5, 6]], dtype=object)
97-
actual = from_lower_row(triangular_matrix)
78+
data = np.array(data, dtype=object)
79+
actual = parse_distances(
80+
data,
81+
edge_weight_type="EXPLICIT",
82+
edge_weight_format="LOWER_ROW",
83+
)
9884
desired = np.array(
9985
[
10086
[0, 1, 2, 4],
@@ -105,31 +91,3 @@ def test_from_lower_row():
10591
)
10692

10793
assert_equal(actual, desired)
108-
109-
110-
def test_from_eilon():
111-
"""
112-
Tests that the distance matrix of Eilon instances is correctly transformed.
113-
These distance matrices have entries corresponding to the lower column
114-
triangular matrices. But the distance matrix is not a triangular matrix,
115-
so they are flattened first.
116-
"""
117-
eilon = np.array([[1, 2, 3, 4], [5, 6]], dtype=object)
118-
actual = from_eilon(eilon)
119-
desired = np.array(
120-
[
121-
[0, 1, 2, 3],
122-
[1, 0, 4, 5],
123-
[2, 4, 0, 6],
124-
[3, 5, 6, 0],
125-
]
126-
)
127-
128-
assert_equal(actual, desired)
129-
130-
131-
@pytest.mark.parametrize(
132-
"n, res", [(1, True), (3, True), (4, False), (630, True), (1000, False)]
133-
)
134-
def test_is_triangular_number(n, res):
135-
assert_equal(is_triangular_number(n), res)

tests/read/test_read_instance.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,25 @@ def test_do_not_compute_edge_weights(tmp_path):
115115

116116
instance = read_instance(tmp_path / name, "solomon", False)
117117
assert_("edge_weight" not in instance)
118+
119+
120+
def test_read_explicit_lower_row_instance_objective():
121+
"""
122+
Tests that the E-n13-k4 instance with EXPLICIT LOWER_ROW edge weights
123+
is read correctly by verifying the known optimal solution cost of 247.
124+
"""
125+
instance = read_instance("tests/data/E-n13-k4.vrp")
126+
edge_weight = instance["edge_weight"]
127+
128+
# Known optimal solution routes (0-indexed customer IDs).
129+
# Depot is node 0; customers are nodes 1-12.
130+
routes = [[1], [8, 5, 3], [9, 12, 10, 6], [11, 4, 7, 2]]
131+
132+
total_cost = 0
133+
for route in routes:
134+
total_cost += edge_weight[0, route[0]]
135+
for idx in range(len(route) - 1):
136+
total_cost += edge_weight[route[idx], route[idx + 1]]
137+
total_cost += edge_weight[route[-1], 0]
138+
139+
assert_equal(total_cost, 247)

vrplib/parse/parse_distances.py

Lines changed: 15 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from itertools import combinations
2-
31
import numpy as np
42

53

@@ -61,11 +59,6 @@ def parse_distances(
6159

6260
if edge_weight_type == "EXPLICIT":
6361
if edge_weight_format == "LOWER_ROW":
64-
# TODO Eilon instances edge weight specifications are incorrect in
65-
# (C)VRPLIB format. Find a better way to identify Eilon instances.
66-
if comment is not None and "Eilon" in comment:
67-
return from_eilon(data)
68-
6962
return from_lower_row(data)
7063

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

10093

101-
def from_lower_row(triangular: np.ndarray) -> np.ndarray:
94+
def from_lower_row(data: np.ndarray) -> np.ndarray:
10295
"""
103-
Computes a full distances matrix from a lower row triangular matrix.
104-
The triangular matrix should not contain the diagonal.
96+
Computes a full distances matrix from a LOWER_ROW edge weight section.
97+
98+
The input is treated as a continuous 1D stream of values (as specified
99+
by TSPLIB95), regardless of how the values are wrapped across lines.
105100
106101
Parameters
107102
----------
108-
triangular
109-
A list of lists, each list representing the entries of a row in a
110-
lower triangular matrix without diagonal entries.
103+
data
104+
Edge weight data, possibly as a ragged array of rows.
111105
112106
Returns
113107
-------
114108
np.ndarray
115-
A n-by-n distances matrix.
116-
"""
117-
n = len(triangular) + 1
118-
distances = np.zeros((n, n))
119-
120-
for i in range(n - 1):
121-
distances[i + 1, : i + 1] = triangular[i]
122-
123-
return distances + distances.T
124-
125-
126-
def from_eilon(edge_weights: np.ndarray) -> np.ndarray:
109+
An n-by-n distances matrix.
127110
"""
128-
Computes a full distances matrix from the Eilon instances with "LOWER_ROW"
129-
edge weight format. The specification is incorrect, instead the edge weight
130-
section needs to be parsed as a flattend, column-wise triangular matrix.
111+
flattened = np.concatenate(data).astype(float)
131112

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

137118
distances = np.zeros((n, n))
138-
indices = sorted([(i, j) for (i, j) in combinations(range(n), r=2)])
139-
140-
for idx, (i, j) in enumerate(indices):
141-
d_ij = flattened[idx]
142-
distances[i, j] = d_ij
143-
distances[j, i] = d_ij
119+
distances[np.tril_indices(n, k=-1)] = flattened
120+
distances += distances.T
144121

145122
return distances
146-
147-
148-
def is_triangular_number(n):
149-
"""
150-
Checks if n is a triangular number.
151-
"""
152-
i = int((2 * n) ** 0.5)
153-
return i * (i + 1) == 2 * n

0 commit comments

Comments
 (0)