From 82abc977ca2966b69053060bd911d8c4e11c1f03 Mon Sep 17 00:00:00 2001 From: l-lan Date: Sat, 19 Nov 2022 21:59:50 +0100 Subject: [PATCH 01/11] Initial code for writing instances --- cvrplib/__init__.py | 1 + cvrplib/write.py | 94 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_write.py | 2 + 3 files changed, 97 insertions(+) create mode 100644 cvrplib/write.py create mode 100644 tests/test_write.py diff --git a/cvrplib/__init__.py b/cvrplib/__init__.py index 3a5c938b..9679e9a2 100644 --- a/cvrplib/__init__.py +++ b/cvrplib/__init__.py @@ -1,3 +1,4 @@ from .download import download from .list_names import list_names from .read import read +from .write import write diff --git a/cvrplib/write.py b/cvrplib/write.py new file mode 100644 index 00000000..c210d7b7 --- /dev/null +++ b/cvrplib/write.py @@ -0,0 +1,94 @@ +import numpy as np + + +def write(path, instance, name="problem", euclidean=False, is_vrptw=True): + with open(path, "w") as f: + capacity = instance["capacity"] + dimension = len(instance["coords"]) + comment = ... + vrp_type = ... + write_preamble( + f, name, comment, vrp_type, dimension, euclidean, capacity + ) + + if not euclidean: + write_edge_weights(f, instance["duration_matrix"]) + + # Write data sections + write_coords(f, instance["coords"]) + write_demands(f, instance["demands"]) + write_is_depot(f, instance["is_depot"]) + + if is_vrptw: + write_service_times(f, instance["service_times"]) + write_time_windows(f, instance["time_windows"]) + write_release_times(f, instance["release_times"]) + + f.write("EOF\n") + + +def write_preamble(f, name, comment, vrp_type, dimension, euclidean, capacity): + preamble = [ + ("NAME", name), + ("COMMENT", comment), + # For HGS we need an extra row... # TODO check this + ("TYPE", vrp_type), + ("DIMENSION", dimension), + ("EDGE_WEIGHT_TYPE", "EUC_2D" if euclidean else "EXPLICIT"), + ([] if euclidean else [("EDGE_WEIGHT_FORMAT", "FULL_MATRIX")]), + [("CAPACITY", capacity)], + ] + + f.write("\n".join([f"{k} : {v}" for k, v in preamble])) + f.write("\n") + + +def write_edge_weights(f, duration_matrix): + f.write("EDGE_WEIGHT_SECTION\n") + + for row in duration_matrix: + f.write("\t".join(map(str, row))) + f.write("\n") + + +def write_coords(f, coords): + f.write("NODE_COORD_SECTION\n") + f.write( + "\n".join([f"{idx}\t{x}\t{y}" for idx, (x, y) in enumerate(coords, 1)]) + ) + f.write("\n") + + +def write_demands(f, demands): + write_section(f, "DEMAND", demands) + + +def write_is_depot(f, is_depot): + f.write("DEPOT_SECTION\n") + + for i in np.flatnonzero(is_depot): + f.write(f"{i+1}\n") + f.write("-1\n") + + +def write_service_times(f, service_times): + write_section(f, "SERVICE_TIME", service_times) + + +def write_time_windows(f, tw): + f.write("TIME_WINDOW_SECTION\n") + f.write( + "\n".join([f"{idx}\t{l}\t{u}" for idx, (l, u) in enumerate(tw, 1)]) + ) + f.write("\n") + + +def write_release_times(f, release_times): + write_section(f, "RELEASE_TIME", release_times) + + +def write_section(f, name, data): + # TODO make this work for data with more than 1 dimension + f.write(f"{name}_SECTION\n") + f.write("\n".join([f"{idx}\t{s}" for idx, s in enumerate(data, 1)])) + f.write("\n") diff --git a/tests/test_write.py b/tests/test_write.py new file mode 100644 index 00000000..386afec9 --- /dev/null +++ b/tests/test_write.py @@ -0,0 +1,2 @@ +def test_write_instance(): + pass From 2cbf6b6af84fb54908563c01221ea48a7c953624 Mon Sep 17 00:00:00 2001 From: l-lan Date: Sun, 20 Nov 2022 09:54:39 +0100 Subject: [PATCH 02/11] Working version of write feature --- cvrplib/write.py | 129 +++++++++++++++++++++-------------------------- 1 file changed, 57 insertions(+), 72 deletions(-) diff --git a/cvrplib/write.py b/cvrplib/write.py index c210d7b7..867d1915 100644 --- a/cvrplib/write.py +++ b/cvrplib/write.py @@ -1,49 +1,69 @@ +from typing import Dict, Iterable, Union + import numpy as np -def write(path, instance, name="problem", euclidean=False, is_vrptw=True): +def write( + path: str, + specifications: Dict[str, Union[str, int]], + sections: Dict[str, Iterable], +): + """ + Write a VRP instance to file following the LKH-3 VRPLIB format [1]. + + path + The path of the file. + specfications + A dictionary of key value pairs, which will be written to file as + KEY : VALUE. + sections + A dictionary of key value pairs, which will be written as sections. + + _SECTION + 1 a b c + 2 d e f + ... + + References + ---------- + .. [1] Helsgaun, K. (2017). An Extension of the Lin-Kernighan-Helsgaun TSP + Solver for Constrained Traveling Salesman and Vehicle Routing + Problems. + http://webhotel4.ruc.dk/~keld/research/LKH-3/LKH-3_REPORT.pdf + + """ with open(path, "w") as f: - capacity = instance["capacity"] - dimension = len(instance["coords"]) - comment = ... - vrp_type = ... - write_preamble( - f, name, comment, vrp_type, dimension, euclidean, capacity - ) - - if not euclidean: - write_edge_weights(f, instance["duration_matrix"]) - - # Write data sections - write_coords(f, instance["coords"]) - write_demands(f, instance["demands"]) - write_is_depot(f, instance["is_depot"]) - - if is_vrptw: - write_service_times(f, instance["service_times"]) - write_time_windows(f, instance["time_windows"]) - write_release_times(f, instance["release_times"]) + rows = [f"{k.upper()} : {v}" for k, v in specifications.items()] + f.write("\n".join(rows)) + f.write("\n") + + for section_name, data in sections.items(): + write_section(f, section_name.upper(), data) f.write("EOF\n") -def write_preamble(f, name, comment, vrp_type, dimension, euclidean, capacity): - preamble = [ - ("NAME", name), - ("COMMENT", comment), - # For HGS we need an extra row... # TODO check this - ("TYPE", vrp_type), - ("DIMENSION", dimension), - ("EDGE_WEIGHT_TYPE", "EUC_2D" if euclidean else "EXPLICIT"), - ([] if euclidean else [("EDGE_WEIGHT_FORMAT", "FULL_MATRIX")]), - [("CAPACITY", capacity)], - ] +def write_section(f, name: str, data: Iterable): + """ + Write a data section to file. A data section starts with the section name + in all uppercase. It is then followed by rows corresponding to the data + entries. Each row starts with the index (starting at 1) and the data + elements are separated by tabs. + """ + # Sections that have a different format + if name == "EDGE_WEIGHT": + write_edge_weight_section(f, data) + elif name == "DEPOT": + write_depot_section(f, data) + else: + f.write(f"{name}_SECTION\n") - f.write("\n".join([f"{k} : {v}" for k, v in preamble])) - f.write("\n") + for idx, elts in enumerate(data, 1): + row = f"{idx}\t" + "\t".join(str(elt) for elt in elts) + f.write(row + "\n") -def write_edge_weights(f, duration_matrix): +def write_edge_weight_section(f, duration_matrix): f.write("EDGE_WEIGHT_SECTION\n") for row in duration_matrix: @@ -51,44 +71,9 @@ def write_edge_weights(f, duration_matrix): f.write("\n") -def write_coords(f, coords): - f.write("NODE_COORD_SECTION\n") - f.write( - "\n".join([f"{idx}\t{x}\t{y}" for idx, (x, y) in enumerate(coords, 1)]) - ) - f.write("\n") - - -def write_demands(f, demands): - write_section(f, "DEMAND", demands) - - -def write_is_depot(f, is_depot): +def write_depot_section(f, depots): f.write("DEPOT_SECTION\n") - for i in np.flatnonzero(is_depot): + for i in np.flatnonzero(depots): f.write(f"{i+1}\n") f.write("-1\n") - - -def write_service_times(f, service_times): - write_section(f, "SERVICE_TIME", service_times) - - -def write_time_windows(f, tw): - f.write("TIME_WINDOW_SECTION\n") - f.write( - "\n".join([f"{idx}\t{l}\t{u}" for idx, (l, u) in enumerate(tw, 1)]) - ) - f.write("\n") - - -def write_release_times(f, release_times): - write_section(f, "RELEASE_TIME", release_times) - - -def write_section(f, name, data): - # TODO make this work for data with more than 1 dimension - f.write(f"{name}_SECTION\n") - f.write("\n".join([f"{idx}\t{s}" for idx, s in enumerate(data, 1)])) - f.write("\n") From ae289d77e6a94ecfec791df5a7ac9969fd702380 Mon Sep 17 00:00:00 2001 From: l-lan Date: Sun, 27 Nov 2022 21:04:23 +0100 Subject: [PATCH 03/11] Implement write instance --- cvrplib/__init__.py | 1 + cvrplib/read/parse_vrplib.py | 10 ++- cvrplib/write/__init__.py | 2 + cvrplib/{write.py => write/write_instance.py} | 21 +++-- tests/read/test_read_instance.py | 1 - tests/test_write.py | 2 - tests/write/__init__.py | 0 tests/write/test_write_instance.py | 78 +++++++++++++++++++ 8 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 cvrplib/write/__init__.py rename cvrplib/{write.py => write/write_instance.py} (80%) delete mode 100644 tests/test_write.py create mode 100644 tests/write/__init__.py create mode 100644 tests/write/test_write_instance.py diff --git a/cvrplib/__init__.py b/cvrplib/__init__.py index 968e330b..380fe7ad 100644 --- a/cvrplib/__init__.py +++ b/cvrplib/__init__.py @@ -1,2 +1,3 @@ from .download import download_instance, download_solution, list_names from .read import read_instance, read_solution +from .write import write_instance, write_solution diff --git a/cvrplib/read/parse_vrplib.py b/cvrplib/read/parse_vrplib.py index e3577495..dcbe4e02 100644 --- a/cvrplib/read/parse_vrplib.py +++ b/cvrplib/read/parse_vrplib.py @@ -20,7 +20,9 @@ def parse_vrplib(lines: List[str]): """ data = parse_specifications(lines) data.update(parse_sections(lines)) - data.update(parse_distances(data)) + + distances = parse_distances(data) + data.update(distances if distances else {}) return data @@ -53,7 +55,7 @@ def parse_sections(lines: List[str]) -> Dict[str, Any]: name = line.split("_SECTION")[0].strip() elif "EOF" in line: - continue + break elif name is not None: row = [_int_or_float(num) for num in line.split()] @@ -70,7 +72,9 @@ def parse_sections(lines: List[str]) -> Dict[str, Any]: section_name = section_name.lower() if section_name == "depot": - data[section_name] = section_data[0][0] - 1 + depot_data = np.array(section_data) + depot_data[:-1] -= 1 # normalize to zero-based indices + data[section_name] = depot_data elif section_name == "edge_weight": data[section_name] = section_data else: diff --git a/cvrplib/write/__init__.py b/cvrplib/write/__init__.py new file mode 100644 index 00000000..50a09a0b --- /dev/null +++ b/cvrplib/write/__init__.py @@ -0,0 +1,2 @@ +from .write_instance import write_instance +from .write_solution import write_solution diff --git a/cvrplib/write.py b/cvrplib/write/write_instance.py similarity index 80% rename from cvrplib/write.py rename to cvrplib/write/write_instance.py index 867d1915..f871b819 100644 --- a/cvrplib/write.py +++ b/cvrplib/write/write_instance.py @@ -3,7 +3,7 @@ import numpy as np -def write( +def write_instance( path: str, specifications: Dict[str, Union[str, int]], sections: Dict[str, Iterable], @@ -58,9 +58,15 @@ def write_section(f, name: str, data: Iterable): else: f.write(f"{name}_SECTION\n") - for idx, elts in enumerate(data, 1): - row = f"{idx}\t" + "\t".join(str(elt) for elt in elts) - f.write(row + "\n") + # TODO Refactor this + if len(np.shape(data)) == 1: + for idx, elt in enumerate(data, 1): + row = f"{idx}\t{elt}" + f.write(row + "\n") + else: + for idx, elts in enumerate(data, 1): + row = f"{idx}\t" + "\t".join(str(elt) for elt in elts) + f.write(row + "\n") def write_edge_weight_section(f, duration_matrix): @@ -74,6 +80,7 @@ def write_edge_weight_section(f, duration_matrix): def write_depot_section(f, depots): f.write("DEPOT_SECTION\n") - for i in np.flatnonzero(depots): - f.write(f"{i+1}\n") - f.write("-1\n") + for idx in depots[:-1].flatten(): + f.write(f"{idx + 1}\n") + + f.write("-1\n") # terminate section diff --git a/tests/read/test_read_instance.py b/tests/read/test_read_instance.py index 9facf717..56e04f0b 100644 --- a/tests/read/test_read_instance.py +++ b/tests/read/test_read_instance.py @@ -83,7 +83,6 @@ def test_C101(): assert instance["n_vehicles"] == 25 assert instance["capacity"] == 200 assert instance["node_coord"][N] == [55, 85] - assert instance["distances"][0][1] == 19 assert instance["demands"][N] == 20 assert instance["service_times"][N] == 90 assert instance["earliest"][N] == 647 diff --git a/tests/test_write.py b/tests/test_write.py deleted file mode 100644 index 386afec9..00000000 --- a/tests/test_write.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_write_instance(): - pass diff --git a/tests/write/__init__.py b/tests/write/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/write/test_write_instance.py b/tests/write/test_write_instance.py new file mode 100644 index 00000000..78f01c70 --- /dev/null +++ b/tests/write/test_write_instance.py @@ -0,0 +1,78 @@ +import pytest +from numpy.testing import assert_equal + +from cvrplib import read_instance, write_instance + +from .._utils import selected_cases + + +def test_write_dummy_instance(tmp_path): + """ + Tests if writing a small dummy instance is done correctly. + + TODO Can we take an instance for write_instance? + Is there a distinction between specs and sections? + """ + name = "C101" + + specs = dict(name=name, type="VRPTW", dimension=101, capacity=200) + sections = dict( + node_coord=[[40, 50], [45, 68], [45, 70], [42, 66]], + demand=[0, 10, 30, 10], + ) + write_instance(tmp_path / name, specs, sections) + + with open(tmp_path / name, "r") as fi: + target = "\n".join( + [ + "NAME : C101", + "TYPE : VRPTW", + "DIMENSION : 101", + "CAPACITY : 200", + "NODE_COORD_SECTION", + "1\t40\t50", + "2\t45\t68", + "3\t45\t70", + "4\t42\t66", + "DEMAND_SECTION", + "1\t0", + "2\t10", + "3\t30", + "4\t10", + "EOF", + "", + ] + ) + + assert_equal(fi.read(), target) + + +@pytest.mark.parametrize("case", selected_cases()) +def test_write_real_instance(tmp_path, case): + """ + Test an original VRPLIB instance. + """ + original = read_instance(case.instance_path) + print(original.keys()) + + specs = [ + "name", + "capacity", + "dimension", + "type", + "comment", + "edge_weight_type", + "edge_weight_format", + "node_coord_type", + "service_time", + "display_data_type", + "distance", + ] + write_instance( + tmp_path / case.instance_name, + {k: v for k, v in original.items() if k in specs}, + {k: v for k, v in original.items() if k not in specs}, + ) + new = read_instance(tmp_path / case.instance_name) + + assert_equal(original, new) From b2cb3f88c88aa576fedd3c4b283a4d476c6b8fb9 Mon Sep 17 00:00:00 2001 From: l-lan Date: Sun, 27 Nov 2022 21:21:21 +0100 Subject: [PATCH 04/11] Take instance as argument to write_instance --- cvrplib/write/write_instance.py | 38 ++++++++++------------------- tests/write/test_write_instance.py | 39 +++++++++--------------------- 2 files changed, 24 insertions(+), 53 deletions(-) diff --git a/cvrplib/write/write_instance.py b/cvrplib/write/write_instance.py index f871b819..af86fff8 100644 --- a/cvrplib/write/write_instance.py +++ b/cvrplib/write/write_instance.py @@ -1,28 +1,16 @@ -from typing import Dict, Iterable, Union +from typing import Any, Dict, Iterable import numpy as np -def write_instance( - path: str, - specifications: Dict[str, Union[str, int]], - sections: Dict[str, Iterable], -): +def write_instance(path: str, instance: Dict[str, Any]): """ Write a VRP instance to file following the LKH-3 VRPLIB format [1]. path The path of the file. - specfications - A dictionary of key value pairs, which will be written to file as - KEY : VALUE. - sections - A dictionary of key value pairs, which will be written as sections. - - _SECTION - 1 a b c - 2 d e f - ... + instance + The instance dictionary, containing problem specifications and data. References ---------- @@ -32,15 +20,15 @@ def write_instance( http://webhotel4.ruc.dk/~keld/research/LKH-3/LKH-3_REPORT.pdf """ - with open(path, "w") as f: - rows = [f"{k.upper()} : {v}" for k, v in specifications.items()] - f.write("\n".join(rows)) - f.write("\n") - - for section_name, data in sections.items(): - write_section(f, section_name.upper(), data) - - f.write("EOF\n") + with open(path, "w") as fi: + for k, v in instance.items(): + if isinstance(v, (np.ndarray, list)): + write_section(fi, k.upper(), v) + else: + fi.write(f"{k.upper()} : {v}") + fi.write("\n") + + fi.write("EOF\n") def write_section(f, name: str, data: Iterable): diff --git a/tests/write/test_write_instance.py b/tests/write/test_write_instance.py index 78f01c70..08173285 100644 --- a/tests/write/test_write_instance.py +++ b/tests/write/test_write_instance.py @@ -8,19 +8,20 @@ def test_write_dummy_instance(tmp_path): """ - Tests if writing a small dummy instance is done correctly. - - TODO Can we take an instance for write_instance? - Is there a distinction between specs and sections? + Tests if writing a small dummy instance yields the correct result. """ name = "C101" - specs = dict(name=name, type="VRPTW", dimension=101, capacity=200) - sections = dict( + instance = dict( + name=name, + type="VRPTW", + dimension=101, + capacity=200, node_coord=[[40, 50], [45, 68], [45, 70], [42, 66]], demand=[0, 10, 30, 10], ) - write_instance(tmp_path / name, specs, sections) + + write_instance(tmp_path / name, instance) with open(tmp_path / name, "r") as fi: target = "\n".join( @@ -48,31 +49,13 @@ def test_write_dummy_instance(tmp_path): @pytest.mark.parametrize("case", selected_cases()) -def test_write_real_instance(tmp_path, case): +def test_write_read_vrplib_instance(tmp_path, case): """ - Test an original VRPLIB instance. + Tests if writing a VRPLIB instance and reading it yields the same result. """ original = read_instance(case.instance_path) - print(original.keys()) - specs = [ - "name", - "capacity", - "dimension", - "type", - "comment", - "edge_weight_type", - "edge_weight_format", - "node_coord_type", - "service_time", - "display_data_type", - "distance", - ] - write_instance( - tmp_path / case.instance_name, - {k: v for k, v in original.items() if k in specs}, - {k: v for k, v in original.items() if k not in specs}, - ) + write_instance(tmp_path / case.instance_name, original) new = read_instance(tmp_path / case.instance_name) assert_equal(original, new) From f3c89a631453bfd9fc5dcb979dd4b973e29a9063 Mon Sep 17 00:00:00 2001 From: l-lan Date: Sun, 27 Nov 2022 21:51:42 +0100 Subject: [PATCH 05/11] Improve docstrings --- cvrplib/write/write_instance.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/cvrplib/write/write_instance.py b/cvrplib/write/write_instance.py index af86fff8..ea3b918b 100644 --- a/cvrplib/write/write_instance.py +++ b/cvrplib/write/write_instance.py @@ -33,15 +33,14 @@ def write_instance(path: str, instance: Dict[str, Any]): def write_section(f, name: str, data: Iterable): """ - Write a data section to file. A data section starts with the section name - in all uppercase. It is then followed by rows corresponding to the data - entries. Each row starts with the index (starting at 1) and the data - elements are separated by tabs. + Writes a data section to file. + + A data section starts with the section name in all uppercase. It is then + followed by row entries consisting of one or multiple values. """ - # Sections that have a different format - if name == "EDGE_WEIGHT": + if name == "EDGE_WEIGHT": # no index write_edge_weight_section(f, data) - elif name == "DEPOT": + elif name == "DEPOT": # no index write_depot_section(f, data) else: f.write(f"{name}_SECTION\n") @@ -58,6 +57,9 @@ def write_section(f, name: str, data: Iterable): def write_edge_weight_section(f, duration_matrix): + """ + Writes the edge weight section. Rows do not start with index. + """ f.write("EDGE_WEIGHT_SECTION\n") for row in duration_matrix: @@ -66,6 +68,10 @@ def write_edge_weight_section(f, duration_matrix): def write_depot_section(f, depots): + """ + Writes the depot section. Rows correspond to the index of the depot(s), + where the final row -1 to indicate termination. + """ f.write("DEPOT_SECTION\n") for idx in depots[:-1].flatten(): From b380420f07ae99fd5a89b7d9c92d2e9ef36713bc Mon Sep 17 00:00:00 2001 From: l-lan Date: Sun, 27 Nov 2022 22:15:28 +0100 Subject: [PATCH 06/11] Renaming and docstring improvements --- cvrplib/write/write_instance.py | 2 +- tests/write/test_write_instance.py | 56 ++++++++++++++++-------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/cvrplib/write/write_instance.py b/cvrplib/write/write_instance.py index ea3b918b..28d79669 100644 --- a/cvrplib/write/write_instance.py +++ b/cvrplib/write/write_instance.py @@ -5,7 +5,7 @@ def write_instance(path: str, instance: Dict[str, Any]): """ - Write a VRP instance to file following the LKH-3 VRPLIB format [1]. + Writes a VRP instance to file following the VRPLIB format [1]. path The path of the file. diff --git a/tests/write/test_write_instance.py b/tests/write/test_write_instance.py index 08173285..4e0ddcd3 100644 --- a/tests/write/test_write_instance.py +++ b/tests/write/test_write_instance.py @@ -6,12 +6,11 @@ from .._utils import selected_cases -def test_write_dummy_instance(tmp_path): +def test_dummy_instance(tmp_path): """ Tests if writing a small dummy instance yields the correct result. """ name = "C101" - instance = dict( name=name, type="VRPTW", @@ -23,28 +22,28 @@ def test_write_dummy_instance(tmp_path): write_instance(tmp_path / name, instance) - with open(tmp_path / name, "r") as fi: - target = "\n".join( - [ - "NAME : C101", - "TYPE : VRPTW", - "DIMENSION : 101", - "CAPACITY : 200", - "NODE_COORD_SECTION", - "1\t40\t50", - "2\t45\t68", - "3\t45\t70", - "4\t42\t66", - "DEMAND_SECTION", - "1\t0", - "2\t10", - "3\t30", - "4\t10", - "EOF", - "", - ] - ) + target = "\n".join( + [ + "NAME : C101", + "TYPE : VRPTW", + "DIMENSION : 101", + "CAPACITY : 200", + "NODE_COORD_SECTION", + "1\t40\t50", + "2\t45\t68", + "3\t45\t70", + "4\t42\t66", + "DEMAND_SECTION", + "1\t0", + "2\t10", + "3\t30", + "4\t10", + "EOF", + "", + ] + ) + with open(tmp_path / name, "r") as fi: assert_equal(fi.read(), target) @@ -53,9 +52,12 @@ def test_write_read_vrplib_instance(tmp_path, case): """ Tests if writing a VRPLIB instance and reading it yields the same result. """ - original = read_instance(case.instance_path) + desired = read_instance(case.instance_path) + + write_instance(tmp_path / case.instance_name, desired) + actual = read_instance(tmp_path / case.instance_name) + + assert_equal(actual, desired) - write_instance(tmp_path / case.instance_name, original) - new = read_instance(tmp_path / case.instance_name) - assert_equal(original, new) +# TODO Test LKH-3 instances From 0071871891bb5717d52f8deb723a92bdfcd80488 Mon Sep 17 00:00:00 2001 From: l-lan Date: Mon, 28 Nov 2022 10:43:31 +0100 Subject: [PATCH 07/11] Add write solutions; refactor solution parser --- cvrplib/read/parse_solution.py | 33 ++++++++----------- cvrplib/read/parse_vrplib.py | 4 +-- cvrplib/read/utils.py | 10 ++++++ cvrplib/write/write_solution.py | 21 ++++++++++++ tests/read/test_read_solution.py | 3 +- tests/write/test_write_solution.py | 53 ++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 cvrplib/write/write_solution.py create mode 100644 tests/write/test_write_solution.py diff --git a/cvrplib/read/parse_solution.py b/cvrplib/read/parse_solution.py index 9b1a2c71..04156240 100644 --- a/cvrplib/read/parse_solution.py +++ b/cvrplib/read/parse_solution.py @@ -1,7 +1,11 @@ from typing import Dict, List, Union +from .utils import infer_type -def parse_solution(lines: List[str]) -> Dict[str, Union[List, float]]: +SolutionData = Union[int, float, str, List] + + +def parse_solution(lines: List[str]) -> Dict[str, SolutionData]: """ Parses the text of a solution file formatted in VRPLIB style. A solution consists of routes, which are indexed from 1 to n, and possibly other data. @@ -16,28 +20,19 @@ def parse_solution(lines: List[str]) -> Dict[str, Union[List, float]]: A dictionary that contains solution data. """ - data: Dict[str, Union[List, float]] = {} - - routes = [] + data: Dict[str, SolutionData] = {"routes": []} for line in lines: line = line.strip().lower() - if not line.startswith("route"): + if "route" in line: + route = [int(idx) for idx in line.split(":")[1].split(" ") if idx] + data["routes"].append(route) # type:ignore + elif ":" in line or " " in line: # split at first colon or whitespace + split_at = ":" if ":" in line else " " + k, v = [word.strip() for word in line.split(split_at, 1)] + data[k] = infer_type(v) + else: # ignore non keyword-value pairs continue - route = [int(cust) for cust in line.split(":")[1].split(" ") if cust] - routes.append(route) - - data["routes"] = routes - - # Find the cost - for line in lines: - line = line.strip().lower() - - if "cost" in line: - cost = line.lstrip("cost ") - data["cost"] = int(cost) if cost.isdigit() else float(cost) - break - return data diff --git a/cvrplib/read/parse_vrplib.py b/cvrplib/read/parse_vrplib.py index dcbe4e02..31056dc4 100644 --- a/cvrplib/read/parse_vrplib.py +++ b/cvrplib/read/parse_vrplib.py @@ -8,7 +8,7 @@ import numpy as np -from .utils import euclidean +from .utils import euclidean, infer_type def parse_vrplib(lines: List[str]): @@ -37,7 +37,7 @@ def parse_specifications(lines: List[str]) -> Dict[str, Any]: for line in lines: if ": " in line: k, v = [x.strip() for x in re.split("\\s*: ", line, maxsplit=1)] - data[k.lower()] = int(v) if v.isnumeric() else v + data[k.lower()] = infer_type(v) return data diff --git a/cvrplib/read/utils.py b/cvrplib/read/utils.py index 80af1bf2..8608262d 100644 --- a/cvrplib/read/utils.py +++ b/cvrplib/read/utils.py @@ -75,3 +75,13 @@ def strip_lines(lines): Strip all lines and return the non-empty ones. """ return [line1 for line1 in (line.strip() for line in lines) if line1] + + +def infer_type(s): + try: + return int(s) + except ValueError: + try: + return float(s) + except ValueError: + return s diff --git a/cvrplib/write/write_solution.py b/cvrplib/write/write_solution.py new file mode 100644 index 00000000..771b25f7 --- /dev/null +++ b/cvrplib/write/write_solution.py @@ -0,0 +1,21 @@ +from typing import Dict, List, Union + +SolutionData = Union[int, float, str, List[List[int]]] + + +def write_solution(path: str, solution: Dict[str, SolutionData]): + """ + Writes a VRP solution to file following # TODO + """ + + with open(path, "w") as fi: + for k, v in solution.items(): + if k == "routes": + for idx, route in enumerate(v, 1): # type: ignore + fi.write( + f"Route {idx} : {' '.join([str(s) for s in route])}" + ) + fi.write("\n") + else: + fi.write(f"{k.capitalize()} : {v}") + fi.write("\n") diff --git a/tests/read/test_read_solution.py b/tests/read/test_read_solution.py index 2b6a60e4..80924471 100644 --- a/tests/read/test_read_solution.py +++ b/tests/read/test_read_solution.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from numpy.testing import assert_equal from cvrplib import read_solution @@ -13,7 +14,7 @@ def test_read_solution(case): Read the case solution and verify its cost. """ solution = read_solution(case.solution_path) - assert solution["cost"] == pytest.approx(case.cost, 2) + assert_equal(solution["cost"], case.cost) @pytest.mark.parametrize( diff --git a/tests/write/test_write_solution.py b/tests/write/test_write_solution.py new file mode 100644 index 00000000..eb47b1f6 --- /dev/null +++ b/tests/write/test_write_solution.py @@ -0,0 +1,53 @@ +import pytest +from numpy.testing import assert_equal + +from cvrplib import read_solution, write_solution + +from .._utils import selected_cases + + +def test_dummy_solution(tmp_path): + """ + Tests if writing a dummy solution yields the correct result. + """ + name = "test.sol" + + solution = dict( + routes=[[1, 2], [3, 4], [5]], + cost=100, + time=123.45, + name=name, + ) + + write_solution(tmp_path / name, solution) + + target = "\n".join( + [ + "Route 1 : 1 2", + "Route 2 : 3 4", + "Route 3 : 5", + "Cost : 100", + "Time : 123.45", + "Name : test.sol", + "", + ] + ) + + with open(tmp_path / name, "r") as fi: + assert_equal(fi.read(), target) + + +@pytest.mark.parametrize("case", selected_cases()[:2]) +def test_cvrplib_solutions(tmp_path, case): + """ + Tests if writing a VRPLIB instance and reading it yields the same result. + """ + desired = read_solution(case.solution_path) + + write_solution(tmp_path / "test.sol", desired) + actual = read_solution(tmp_path / "test.sol") + + assert_equal(actual, desired) + + +# TODO Test LKH-3 solutions From 865159f17cef7ce2c649471dbefa1717e6a9dc0c Mon Sep 17 00:00:00 2001 From: l-lan Date: Mon, 28 Nov 2022 11:09:10 +0100 Subject: [PATCH 08/11] Update typing and renaming vars --- cvrplib/read/parse_solution.py | 16 ++++++++-------- cvrplib/read/parse_vrplib.py | 2 +- cvrplib/write/write_solution.py | 13 +++++++++---- tests/read/test_read_instance.py | 1 - 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/cvrplib/read/parse_solution.py b/cvrplib/read/parse_solution.py index 04156240..d26be4d0 100644 --- a/cvrplib/read/parse_solution.py +++ b/cvrplib/read/parse_solution.py @@ -2,10 +2,10 @@ from .utils import infer_type -SolutionData = Union[int, float, str, List] +Solution = Dict[str, Union[int, float, str, List]] -def parse_solution(lines: List[str]) -> Dict[str, SolutionData]: +def parse_solution(lines: List[str]) -> Solution: """ Parses the text of a solution file formatted in VRPLIB style. A solution consists of routes, which are indexed from 1 to n, and possibly other data. @@ -20,19 +20,19 @@ def parse_solution(lines: List[str]) -> Dict[str, SolutionData]: A dictionary that contains solution data. """ - data: Dict[str, SolutionData] = {"routes": []} + solution: Solution = {"routes": []} for line in lines: line = line.strip().lower() if "route" in line: route = [int(idx) for idx in line.split(":")[1].split(" ") if idx] - data["routes"].append(route) # type:ignore - elif ":" in line or " " in line: # split at first colon or whitespace + solution["routes"].append(route) # type: ignore + elif ":" in line or " " in line: # Split at first colon or whitespace split_at = ":" if ":" in line else " " k, v = [word.strip() for word in line.split(split_at, 1)] - data[k] = infer_type(v) - else: # ignore non keyword-value pairs + solution[k] = infer_type(v) + else: # Ignore lines without keyword-value pairs continue - return data + return solution diff --git a/cvrplib/read/parse_vrplib.py b/cvrplib/read/parse_vrplib.py index 31056dc4..9745466e 100644 --- a/cvrplib/read/parse_vrplib.py +++ b/cvrplib/read/parse_vrplib.py @@ -73,7 +73,7 @@ def parse_sections(lines: List[str]) -> Dict[str, Any]: if section_name == "depot": depot_data = np.array(section_data) - depot_data[:-1] -= 1 # normalize to zero-based indices + depot_data[:-1] -= 1 # Normalize depot indices to start at zero data[section_name] = depot_data elif section_name == "edge_weight": data[section_name] = section_data diff --git a/cvrplib/write/write_solution.py b/cvrplib/write/write_solution.py index 771b25f7..09be5c35 100644 --- a/cvrplib/write/write_solution.py +++ b/cvrplib/write/write_solution.py @@ -1,13 +1,18 @@ from typing import Dict, List, Union -SolutionData = Union[int, float, str, List[List[int]]] +Solution = Dict[str, Union[int, float, str, List[List[int]]]] -def write_solution(path: str, solution: Dict[str, SolutionData]): - """ - Writes a VRP solution to file following # TODO +def write_solution(path: str, solution: Solution): """ + Writes a VRP solution to file following the VRPLIB convention. + + path + The file path. + solution + The dictionary containing solution data. + """ with open(path, "w") as fi: for k, v in solution.items(): if k == "routes": diff --git a/tests/read/test_read_instance.py b/tests/read/test_read_instance.py index 56e04f0b..4ebbec31 100644 --- a/tests/read/test_read_instance.py +++ b/tests/read/test_read_instance.py @@ -8,7 +8,6 @@ from .._utils import CVRPLIB_DATA_DIR, LKH_3_DATA_DIR, selected_cases -# TODO Rename "cvrp" to VRPLIB # TODO Add more tests to this - maybe make a csv? instances = [ From 7af00e06c8ed20ae21dbd549ce58b6975ad34fe2 Mon Sep 17 00:00:00 2001 From: l-lan Date: Mon, 28 Nov 2022 11:09:54 +0100 Subject: [PATCH 09/11] Remove _int_or_float from parse_vrplib --- cvrplib/read/parse_vrplib.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/cvrplib/read/parse_vrplib.py b/cvrplib/read/parse_vrplib.py index 9745466e..fa5370f0 100644 --- a/cvrplib/read/parse_vrplib.py +++ b/cvrplib/read/parse_vrplib.py @@ -58,7 +58,7 @@ def parse_sections(lines: List[str]) -> Dict[str, Any]: break elif name is not None: - row = [_int_or_float(num) for num in line.split()] + row = [infer_type(num) for num in line.split()] # Most sections start with an index that we do not want to keep if name not in ["EDGE_WEIGHT", "DEPOT"]: @@ -171,8 +171,3 @@ def from_flattened(edge_weights: List[List[int]], n: int) -> List[List[int]]: distances[j][i] = d_ij return distances - - -def _int_or_float(num: str): - """Return an integer if num is an integer string and float otherwise.""" - return int(num) if num.isnumeric() else float(num) From eb3539eca157da853157effa7beb18fdb935c058 Mon Sep 17 00:00:00 2001 From: l-lan Date: Mon, 28 Nov 2022 18:50:28 +0100 Subject: [PATCH 10/11] Minor changes --- cvrplib/read/parse_vrplib.py | 9 +++++---- cvrplib/write/write_instance.py | 34 ++++++++++++++++----------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/cvrplib/read/parse_vrplib.py b/cvrplib/read/parse_vrplib.py index fa5370f0..7629b267 100644 --- a/cvrplib/read/parse_vrplib.py +++ b/cvrplib/read/parse_vrplib.py @@ -51,12 +51,12 @@ def parse_sections(lines: List[str]) -> Dict[str, Any]: sections = defaultdict(list) for line in lines: - if "_SECTION" in line: - name = line.split("_SECTION")[0].strip() - - elif "EOF" in line: + if "EOF" in line: break + elif "_SECTION" in line: + name = line.split("_SECTION")[0].strip() + elif name is not None: row = [infer_type(num) for num in line.split()] @@ -73,6 +73,7 @@ def parse_sections(lines: List[str]) -> Dict[str, Any]: if section_name == "depot": depot_data = np.array(section_data) + # TODO Keep this or remove? depot_data[:-1] -= 1 # Normalize depot indices to start at zero data[section_name] = depot_data elif section_name == "edge_weight": diff --git a/cvrplib/write/write_instance.py b/cvrplib/write/write_instance.py index 28d79669..70ae60bb 100644 --- a/cvrplib/write/write_instance.py +++ b/cvrplib/write/write_instance.py @@ -31,50 +31,50 @@ def write_instance(path: str, instance: Dict[str, Any]): fi.write("EOF\n") -def write_section(f, name: str, data: Iterable): +def write_section(fi, name: str, data: Iterable): """ Writes a data section to file. A data section starts with the section name in all uppercase. It is then followed by row entries consisting of one or multiple values. """ - if name == "EDGE_WEIGHT": # no index - write_edge_weight_section(f, data) - elif name == "DEPOT": # no index - write_depot_section(f, data) + if name == "EDGE_WEIGHT": + write_edge_weight_section(fi, data) + elif name == "DEPOT": + write_depot_section(fi, data) else: - f.write(f"{name}_SECTION\n") + fi.write(f"{name}_SECTION\n") # TODO Refactor this if len(np.shape(data)) == 1: for idx, elt in enumerate(data, 1): row = f"{idx}\t{elt}" - f.write(row + "\n") + fi.write(row + "\n") else: for idx, elts in enumerate(data, 1): row = f"{idx}\t" + "\t".join(str(elt) for elt in elts) - f.write(row + "\n") + fi.write(row + "\n") -def write_edge_weight_section(f, duration_matrix): +def write_edge_weight_section(fi, duration_matrix): """ Writes the edge weight section. Rows do not start with index. """ - f.write("EDGE_WEIGHT_SECTION\n") + fi.write("EDGE_WEIGHT_SECTION\n") for row in duration_matrix: - f.write("\t".join(map(str, row))) - f.write("\n") + fi.write("\t".join(map(str, row))) + fi.write("\n") -def write_depot_section(f, depots): +def write_depot_section(fi, depots): """ Writes the depot section. Rows correspond to the index of the depot(s), - where the final row -1 to indicate termination. + where the final value is -1 to indicate termination. """ - f.write("DEPOT_SECTION\n") + fi.write("DEPOT_SECTION\n") for idx in depots[:-1].flatten(): - f.write(f"{idx + 1}\n") + fi.write(f"{idx + 1}\n") - f.write("-1\n") # terminate section + fi.write("-1\n") From f66642f9b8bbda04863efc1aaa3c4ab5c526fe64 Mon Sep 17 00:00:00 2001 From: l-lan Date: Mon, 28 Nov 2022 19:04:10 +0100 Subject: [PATCH 11/11] Add write tests for LKH-3 --- tests/read/test_read_instance.py | 1 - tests/write/test_write_instance.py | 31 +++++++++++++++++++++++++----- tests/write/test_write_solution.py | 24 ++++++++++++++++++----- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/tests/read/test_read_instance.py b/tests/read/test_read_instance.py index 4ebbec31..510bb6ef 100644 --- a/tests/read/test_read_instance.py +++ b/tests/read/test_read_instance.py @@ -93,7 +93,6 @@ def test_C101(): ) def test_lkh_3_vrplib(path): """ - TODO Maybe add more instances TODO Test for instance values """ read_instance(path) diff --git a/tests/write/test_write_instance.py b/tests/write/test_write_instance.py index 4e0ddcd3..d8bedc9f 100644 --- a/tests/write/test_write_instance.py +++ b/tests/write/test_write_instance.py @@ -1,12 +1,14 @@ +from pathlib import Path + import pytest from numpy.testing import assert_equal from cvrplib import read_instance, write_instance -from .._utils import selected_cases +from .._utils import LKH_3_DATA_DIR, selected_cases -def test_dummy_instance(tmp_path): +def test_dummy(tmp_path): """ Tests if writing a small dummy instance yields the correct result. """ @@ -48,9 +50,9 @@ def test_dummy_instance(tmp_path): @pytest.mark.parametrize("case", selected_cases()) -def test_write_read_vrplib_instance(tmp_path, case): +def test_cvrplib(tmp_path, case): """ - Tests if writing a VRPLIB instance and reading it yields the same result. + Tests if writing a CVRPLIB instance and reading it yields the same result. """ desired = read_instance(case.instance_path) @@ -60,4 +62,23 @@ def test_write_read_vrplib_instance(tmp_path, case): assert_equal(actual, desired) -# TODO Test LKH-3 instances +@pytest.mark.parametrize( + "instance_path", Path(LKH_3_DATA_DIR).glob("*/INSTANCES/*vrp*") +) +def test_lkh_3(tmp_path, instance_path): + """ + Tests if writing an LKH-3 instance and reading it yields the same result. + """ + # These instances are incorrectly formatted, because the depot section + # does not terminate with -1. + invalid = ["S-E016-03m", "D022-04g", "R-E016-03m"] + + if any(name in str(instance_path) for name in invalid): + return + + desired = read_instance(instance_path) + + write_instance(tmp_path / "test.vrp", desired) + actual = read_instance(tmp_path / "test.vrp") + + assert_equal(actual, desired) diff --git a/tests/write/test_write_solution.py b/tests/write/test_write_solution.py index eb47b1f6..220c6d9b 100644 --- a/tests/write/test_write_solution.py +++ b/tests/write/test_write_solution.py @@ -1,12 +1,14 @@ +from pathlib import Path + import pytest from numpy.testing import assert_equal from cvrplib import read_solution, write_solution -from .._utils import selected_cases +from .._utils import LKH_3_DATA_DIR, selected_cases -def test_dummy_solution(tmp_path): +def test_dummy(tmp_path): """ Tests if writing a dummy solution yields the correct result. """ @@ -38,9 +40,9 @@ def test_dummy_solution(tmp_path): @pytest.mark.parametrize("case", selected_cases()[:2]) -def test_cvrplib_solutions(tmp_path, case): +def test_cvrplib(tmp_path, case): """ - Tests if writing a VRPLIB instance and reading it yields the same result. + Tests if writing a CVRPLIB instance and reading it yields the same result. """ desired = read_solution(case.solution_path) @@ -50,4 +52,16 @@ def test_cvrplib_solutions(tmp_path, case): assert_equal(actual, desired) -# TODO Test LKH-3 solutions +@pytest.mark.parametrize( + "solution_path", Path(LKH_3_DATA_DIR).glob("*/SOLUTIONS/*.sol") +) +def test_lkh_3(tmp_path, solution_path): + """ + Tests if writing a LKH-3 instance and reading it yields the same result. + """ + desired = read_solution(solution_path) + + write_solution(tmp_path / "test.sol", desired) + actual = read_solution(tmp_path / "test.sol") + + assert_equal(actual, desired)