From 82abc977ca2966b69053060bd911d8c4e11c1f03 Mon Sep 17 00:00:00 2001
From: l-lan <l.lan@vu.nl>
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 <l.lan@vu.nl>
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.
+
+        <KEY>_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 <l.lan@vu.nl>
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 <l.lan@vu.nl>
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.
-
-        <KEY>_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 <l.lan@vu.nl>
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 <l.lan@vu.nl>
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 <l.lan@vu.nl>
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 <l.lan@vu.nl>
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 <l.lan@vu.nl>
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 <l.lan@vu.nl>
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 <l.lan@vu.nl>
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)
