Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type hint and validate Instance and Solution dictionaries #100

Closed
wants to merge 13 commits into from
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pytest = "^7.1.2"
codecov = "^2.1.13"
pytest-cov = "^4.0.0"
pre-commit = "^2.19.0"
mypy = "^1.2.0"

[pytest]
pythonpath = [".", "vrplib"]
Expand Down
10 changes: 6 additions & 4 deletions tests/parse/test_parse_solution.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@
"text, data",
[
("", {"routes": []}), # empty solution
(
"Route #1: 1 2 3\n Route #2: 5 6\n COST: 10",
{"routes": [[1, 2, 3], [5, 6]], "cost": 10},
),
(
"Route #1: 1 \n Route #2: 6\n comment: VRPLIB",
{"routes": [[1], [6]], "comment": "VRPLIB"},
),
(
# lower case cost
"Route #1: 1 2 3\n Route #2: 5 6\n COST: 10",
{"routes": [[1, 2, 3], [5, 6]], "cost": 10},
),
(
# parse time on whitespace instead of colon
"Route #1: 1 \n Route #2: 6\n time 180.23",
{"routes": [[1], [6]], "time": 180.23},
),
Expand Down
23 changes: 23 additions & 0 deletions vrplib/parse/Instance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import TypedDict, Union

import numpy as np


class Instance(TypedDict, total=False):
# Specifications
name: str
dimension: int # number of depots + number of customers
edge_weight_format: str
edge_weight_type: str
display_type: str
comment: str
vehicles: int # number of vehicles
capacity: int

# Data sections
node_coord: np.ndarray
demand: np.ndarray
depot: Union[int, np.ndarray]
service_time: np.ndarray
time_window: np.ndarray
edge_weight: np.ndarray
19 changes: 9 additions & 10 deletions vrplib/parse/parse_distances.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from itertools import combinations
from typing import List, Optional, Union
from typing import List, Optional

import numpy as np

from .Instance import Instance


def parse_distances(
data: List,
edge_weight_type: str,
edge_weight_format: Optional[str] = None,
node_coord: Optional[np.ndarray] = None,
comment: Optional[str] = None,
**kwargs: Union[float, str, np.ndarray], # noqa
**kwargs: Instance, # noqa
) -> np.ndarray:
"""
Parses the distances. The specification "edge_weight_type" describes how
Expand All @@ -35,8 +37,7 @@ def parse_distances(

Returns
-------
np.ndarray
An n-by-n distances matrix.
An n-by-n distances matrix.
"""
if "2D" in edge_weight_type: # Euclidean distance on node coordinates
if node_coord is None:
Expand Down Expand Up @@ -83,8 +84,7 @@ def pairwise_euclidean(coords: np.ndarray) -> np.ndarray:

Returns
-------
np.ndarray
An n-by-n Euclidean distances matrix.
An n-by-n Euclidean distances matrix.

"""
diff = coords[:, np.newaxis, :] - coords
Expand All @@ -93,7 +93,7 @@ def pairwise_euclidean(coords: np.ndarray) -> np.ndarray:
return np.sqrt(square_dist)


def from_lower_row(triangular: np.ndarray) -> np.ndarray:
def from_lower_row(triangular: List) -> np.ndarray:
"""
Computes a full distances matrix from a lower row triangular matrix.
The triangular matrix should not contain the diagonal.
Expand All @@ -106,8 +106,7 @@ def from_lower_row(triangular: np.ndarray) -> np.ndarray:

Returns
-------
np.ndarray
A n-by-n distances matrix.
A n-by-n distances matrix.
"""
n = len(triangular) + 1
distances = np.zeros((n, n))
Expand All @@ -118,7 +117,7 @@ def from_lower_row(triangular: np.ndarray) -> np.ndarray:
return distances + distances.T


def from_eilon(edge_weights: np.ndarray) -> np.ndarray:
def from_eilon(edge_weights: List) -> np.ndarray:
"""
Computes a full distances matrix from the Eilon instances with "LOWER_ROW"
edge weight format. The specification is incorrect, instead the edge weight
Expand Down
5 changes: 2 additions & 3 deletions vrplib/parse/parse_solomon.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from typing import Dict, List, Union
from typing import List

import numpy as np

from .Instance import Instance
from .parse_distances import pairwise_euclidean
from .parse_utils import text2lines

Instance = Dict[str, Union[str, float, np.ndarray]]


def parse_solomon(text: str, compute_edge_weights: bool = True) -> Instance:
"""
Expand Down
14 changes: 8 additions & 6 deletions vrplib/parse/parse_solution.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
from typing import Dict, List, Union
from typing import List, TypedDict

from .parse_utils import infer_type, text2lines

Solution = Dict[str, Union[float, str, List]]

class Solution(TypedDict, total=False):
routes: List[List[int]]
cost: float


def parse_solution(text: str) -> Solution:
Expand All @@ -17,19 +20,18 @@ def parse_solution(text: str) -> Solution:

Returns
-------
dict
The soluion data.
The soluion data.
"""
solution: Solution = {"routes": []}

for line in text2lines(text):
if "Route" in line:
route = [int(idx) for idx in line.split(":")[1].split(" ") if idx]
solution["routes"].append(route) # type: ignore
solution["routes"].append(route)
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)]
solution[k.lower()] = infer_type(v)
solution[k.lower()] = infer_type(v) # type: ignore
else: # Ignore lines without keyword-value pairs
continue

Expand Down
26 changes: 17 additions & 9 deletions vrplib/parse/parse_vrplib.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import re
from typing import Dict, List, Tuple, Union
from typing import List, Tuple, Union

import numpy as np

from .Instance import Instance
from .parse_distances import parse_distances
from .parse_utils import infer_type, text2lines

Instance = Dict[str, Union[str, float, np.ndarray]]


def parse_vrplib(text: str, compute_edge_weights: bool = True) -> Instance:
"""
Expand All @@ -33,17 +32,17 @@ def parse_vrplib(text: str, compute_edge_weights: bool = True) -> Instance:
dict
The instance data.
"""
instance = {}
instance = Instance()

specs, sections = group_specifications_and_sections(text2lines(text))

for spec in specs:
key, value = parse_specification(spec)
instance[key] = value
instance[key] = value # type: ignore

for section in sections:
section, data = parse_section(section, instance)
instance[section] = data
instance[section] = data # type: ignore
Copy link
Member Author

@leonlan leonlan May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypedDict requires string literals, but since I don't know what kinds of section are parsed I need to ignore this.

I'm not sure if it even makes sense to use a TypedDict if I ignore this line.

Copy link
Member Author

@leonlan leonlan May 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's OK. Even though this line is no longer checked, the returned instance is assumed to be of type Instance, which used by mypy to do static type checking.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But if we do this, we also need to validate that the instance indeed follows the types as specified in Instance.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can use pydantic for this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make a new validate module that validates the instance and solution outputs according to VRPLIB.


if instance and compute_edge_weights and "edge_weight" not in instance:
# Compute edge weights if there was no explicit edge weight section
Expand Down Expand Up @@ -97,9 +96,18 @@ def parse_specification(line: str) -> Tuple[str, Union[float, str]]:
return k.lower(), infer_type(v)


def parse_section(lines: List, instance: Dict) -> np.ndarray:
def parse_section(
lines: List[str], instance: Instance
) -> Tuple[str, np.ndarray]:
"""
Parses the data section into numpy arrays.
Parses the lines corresponding to a section and returns the data.

Parameters
----------
lines
The lines corresponding to a section.
instance
The instance data parsed so far.
"""
section = _remove_suffix(lines[0].strip(), "_SECTION").lower()
data_ = [[infer_type(n) for n in line.split()] for line in lines[1:]]
Expand All @@ -124,5 +132,5 @@ def parse_section(lines: List, instance: Dict) -> np.ndarray:
return section, data


def _remove_suffix(name: str, suffix: str):
def _remove_suffix(name: str, suffix: str) -> str:
return name[: -len(suffix)] if name.endswith(suffix) else name
9 changes: 8 additions & 1 deletion vrplib/read/read_instance.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import os
from typing import Union

from vrplib.parse import parse_solomon, parse_vrplib


def read_instance(path, instance_format="vrplib", compute_edge_weights=True):
def read_instance(
path: Union[str, os.PathLike],
instance_format: str = "vrplib",
compute_edge_weights: bool = True,
):
"""
Reads the instance from the passed-in file path.

Expand Down
5 changes: 4 additions & 1 deletion vrplib/read/read_solution.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import os
from typing import Union

from vrplib.parse import parse_solution


def read_solution(path: str):
def read_solution(path: Union[str, os.PathLike]):
"""
Reads the solution from the passed-in file path.

Expand Down