From e79d3843552c6ac5240b00ed8327184353c7e5d3 Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Sun, 5 Apr 2026 18:25:34 +0200 Subject: [PATCH 1/5] Remove eilon shortcut --- tests/parse/test_parse_distances.py | 74 +++++++---------------------- vrplib/parse/parse_distances.py | 62 +++++++----------------- 2 files changed, 32 insertions(+), 104 deletions(-) diff --git a/tests/parse/test_parse_distances.py b/tests/parse/test_parse_distances.py index 50c4eef0..5fee837a 100644 --- a/tests/parse/test_parse_distances.py +++ b/tests/parse/test_parse_distances.py @@ -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( @@ -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], @@ -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) diff --git a/vrplib/parse/parse_distances.py b/vrplib/parse/parse_distances.py index c01b04c7..7a58db52 100644 --- a/vrplib/parse/parse_distances.py +++ b/vrplib/parse/parse_distances.py @@ -1,5 +1,3 @@ -from itertools import combinations - import numpy as np @@ -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": @@ -98,56 +91,33 @@ 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.fromiter( + (v for row in data for v in row), dtype=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 + # n * (n - 1) / 2 = m => n = (1 + sqrt(1 + 8m)) / 2 + m = flattened.size + n = (1 + int((1 + 8 * m) ** 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 From 3b4c2f2611ec3e247b58d78dc09c56625b7ee3c4 Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Sun, 5 Apr 2026 18:31:13 +0200 Subject: [PATCH 2/5] Verify instance E-n13-k4 --- tests/read/test_read_instance.py | 26 ++++++++++++++++++++++++++ vrplib/parse/parse_distances.py | 6 +++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/read/test_read_instance.py b/tests/read/test_read_instance.py index d1f1672e..79f6b944 100644 --- a/tests/read/test_read_instance.py +++ b/tests/read/test_read_instance.py @@ -1,9 +1,13 @@ +from pathlib import Path + import numpy as np from numpy.testing import assert_, assert_equal, assert_raises from pytest import mark from vrplib.read import read_instance +DATA_DIR = Path(__file__).parent.parent / "data" + @mark.parametrize("instance_format", ["CVRPLIB", "LKH", "VRP"]) def test_raise_unknown_instance_format(tmp_path, instance_format): @@ -115,3 +119,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(DATA_DIR / "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) diff --git a/vrplib/parse/parse_distances.py b/vrplib/parse/parse_distances.py index 7a58db52..f052c4b7 100644 --- a/vrplib/parse/parse_distances.py +++ b/vrplib/parse/parse_distances.py @@ -108,10 +108,10 @@ def from_lower_row(data: np.ndarray) -> np.ndarray: np.ndarray An n-by-n distances matrix. """ - flattened = np.fromiter( - (v for row in data for v in row), dtype=float - ) + flattened = np.concatenate(data).astype(float) + # The flattened data represents the lower triangle of a symmetric matrix. + # Derive the matrix size (https://en.wikipedia.org/wiki/Triangular_number). # n * (n - 1) / 2 = m => n = (1 + sqrt(1 + 8m)) / 2 m = flattened.size n = (1 + int((1 + 8 * m) ** 0.5)) // 2 From 5837a8c7be1dc9b53be4a2a56c598459bf9fca3d Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Sun, 5 Apr 2026 18:34:26 +0200 Subject: [PATCH 3/5] Simplify a bit --- vrplib/parse/parse_distances.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/vrplib/parse/parse_distances.py b/vrplib/parse/parse_distances.py index f052c4b7..6cd908d4 100644 --- a/vrplib/parse/parse_distances.py +++ b/vrplib/parse/parse_distances.py @@ -112,9 +112,8 @@ def from_lower_row(data: np.ndarray) -> np.ndarray: # The flattened data represents the lower triangle of a symmetric matrix. # Derive the matrix size (https://en.wikipedia.org/wiki/Triangular_number). - # n * (n - 1) / 2 = m => n = (1 + sqrt(1 + 8m)) / 2 - m = flattened.size - n = (1 + int((1 + 8 * m) ** 0.5)) // 2 + # 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)) distances[np.tril_indices(n, k=-1)] = flattened From ac5ba9391edb78f4b42750dc492f432f7843cf1f Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Sun, 5 Apr 2026 18:37:54 +0200 Subject: [PATCH 4/5] Simplify --- tests/read/test_read_instance.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/read/test_read_instance.py b/tests/read/test_read_instance.py index 79f6b944..b7d77cd9 100644 --- a/tests/read/test_read_instance.py +++ b/tests/read/test_read_instance.py @@ -1,13 +1,9 @@ -from pathlib import Path - import numpy as np from numpy.testing import assert_, assert_equal, assert_raises from pytest import mark from vrplib.read import read_instance -DATA_DIR = Path(__file__).parent.parent / "data" - @mark.parametrize("instance_format", ["CVRPLIB", "LKH", "VRP"]) def test_raise_unknown_instance_format(tmp_path, instance_format): @@ -126,7 +122,7 @@ 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(DATA_DIR / "E-n13-k4.vrp") + instance = read_instance("tests/data/E-n13-k4.vrp") edge_weight = instance["edge_weight"] # Known optimal solution routes (0-indexed customer IDs). From 7cb9794c2a3534df29253644cd5caa1678b5a9de Mon Sep 17 00:00:00 2001 From: Leon Lan Date: Sun, 5 Apr 2026 20:51:55 +0200 Subject: [PATCH 5/5] Apply suggestion from @leonlan --- vrplib/parse/parse_distances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vrplib/parse/parse_distances.py b/vrplib/parse/parse_distances.py index 6cd908d4..3d8b5e1c 100644 --- a/vrplib/parse/parse_distances.py +++ b/vrplib/parse/parse_distances.py @@ -111,7 +111,7 @@ def from_lower_row(data: np.ndarray) -> np.ndarray: flattened = np.concatenate(data).astype(float) # The flattened data represents the lower triangle of a symmetric matrix. - # Derive the matrix size (https://en.wikipedia.org/wiki/Triangular_number). + # 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