Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"assets": [],
"options": {
"default_duration": null,
"default_lat": null,
"default_lon": null,
"duration": 30,
"exploration_level": 4,
"input": "input.json",
"output": "output.json",
"solver": "vroom",
"threads": 6,
"version": "1.15.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"assets": [],
"options": {
"default_duration": null,
"default_lat": null,
"default_lon": null,
"duration": 30,
"exploration_level": 4,
"input": "input.json",
"output": "output.json",
"solver": "vroom",
"threads": 6,
"version": "1.15.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"assets": [],
"options": {
"default_duration": null,
"default_lat": null,
"default_lon": null,
"duration": 30,
"exploration_level": 4,
"input": "input.json",
"output": "output.json",
"solver": "vroom",
"threads": 6,
"version": "1.15.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"assets": [],
"options": {
"default_duration": null,
"default_lat": null,
"default_lon": null,
"duration": 30,
"exploration_level": 4,
"input": "input.json",
"output": "output.json",
"solver": "vroom",
"threads": 6,
"version": "1.15.0"
Expand Down
16 changes: 4 additions & 12 deletions .nextmv/golden/python-pyvroom-routing/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,12 @@ func TestGolden(t *testing.T) {
Key: "$.statistics.run.duration",
Replacement: golden.StableFloat,
},
{
Key: "$.options.output",
Replacement: "output.json",
},
{
Key: "$.options.input",
Replacement: "input.json",
},
},
UseStdIn: true,
UseStdOut: true,
ExecutionConfig: &golden.ExecutionConfig{
Command: "python3",
Args: []string{"../../../python-pyvroom-routing/main.py"},
InputFlag: "-input",
OutputFlag: "-output",
Command: "python3",
Args: []string{"../../../python-pyvroom-routing/main.py"},
},
},
)
Expand Down
3 changes: 1 addition & 2 deletions .nextmv/readme/python-pyvroom-routing/1.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
python3 main.py -input input.json -output output.json \
-duration 30 -exploration_level 4 -threads 6
cat input.json | python3 main.py > output.json
3 changes: 1 addition & 2 deletions python-pyvroom-routing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ Pyvroom package. We solve a vehicle routing problem.
1. Run the app.

```bash
python3 main.py -input input.json -output output.json \
-duration 30 -exploration_level 4 -threads 6
cat input.json | python3 main.py > output.json
```

## Mirror running on Nextmv Cloud locally
Expand Down
47 changes: 45 additions & 2 deletions python-pyvroom-routing/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,49 @@ python:
pip-requirements: requirements.txt

configuration:
# Specify the format the app reads and writes.
content:
format: "json" # Read JSON from stdin and write JSON to stdout.
format: "json"
options:
items:
- name: duration
option_type: int
default: 30
description: "Max runtime duration (in seconds)."
ui:
control_type: input
display_name: "Max Runtime (seconds)"
- name: exploration_level
option_type: int
default: 4
description: "Exploration level for the solver."
ui:
control_type: input
display_name: "Exploration Level"
- name: threads
option_type: int
default: 6
description: "Number of threads to use."
ui:
control_type: input
display_name: "Threads"
- name: default_duration
option_type: int
default: null
description: "Default service duration (in seconds) for all stops. Stop-level duration values take precedence."
ui:
control_type: input
display_name: "Stop Service Duration Default (seconds)"
- name: default_lat
option_type: float
default: null
description: "Latitude of the default start and end location for all vehicles. Vehicle-level start_location and end_location take precedence. If unset, the input data must supply the vehicle locations."
ui:
control_type: input
display_name: "Vehicle Start Latitude (Default)"
- name: default_lon
option_type: float
default: null
description: "Longitude of the default start and end location for all vehicles. Vehicle-level start_location and end_location take precedence. If unset, the input data must supply the vehicle locations."
ui:
control_type: input
display_name: "Vehicle Start Longitude (Default)"
115 changes: 81 additions & 34 deletions python-pyvroom-routing/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import numbers
import os
import time
from datetime import datetime, timedelta
from importlib.metadata import version
from typing import Any

Expand All @@ -11,15 +13,18 @@
def main() -> None:
"""Entry point for the program."""

options = nextmv.Options(
nextmv.Option("input", str, "", "Path to input file. Default is stdin.", False),
nextmv.Option("output", str, "", "Path to output file. Default is stdout.", False),
nextmv.Option("duration", int, 30, "Max runtime duration (in seconds).", False),
nextmv.Option("exploration_level", int, 4, "Exploration level for the solver.", False),
nextmv.Option("threads", int, 6, "Number of threads to use.", False),
)

input = nextmv.load(options=options, path=options.input)
manifest = nextmv.Manifest.from_yaml(os.path.dirname(os.path.abspath(__file__)))
options = manifest.extract_options()

input = nextmv.load(options=options)
if options.default_duration is not None:
input.data.setdefault("defaults", {}).setdefault("stops", {})
input.data["defaults"]["stops"].setdefault("duration", options.default_duration)
if options.default_lat is not None and options.default_lon is not None:
depot = {"lat": options.default_lat, "lon": options.default_lon}
input.data.setdefault("defaults", {}).setdefault("vehicles", {})
input.data["defaults"]["vehicles"].setdefault("start_location", depot)
input.data["defaults"]["vehicles"].setdefault("end_location", depot)
apply_defaults(input.data)
validate_input(input.data)
process_duration_matrix(input.data)
Expand All @@ -30,7 +35,7 @@ def main() -> None:

model = DecisionModel()
output = model.solve(input)
nextmv.write(output, path=options.output)
nextmv.write(output)


class DecisionModel(nextmv.Model):
Expand Down Expand Up @@ -81,15 +86,14 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:

# Add the stops.
for i in range(len(input.data["stops"])):
problem_instance.add_job(
vroom.Job(
id=i,
location=i,
default_service=durations[i],
delivery=[-quantities[i]],
pickup=[quantities[i]],
)
job = vroom.Job(
id=i,
location=i,
default_service=durations[i],
delivery=[-quantities[i]],
pickup=[quantities[i]],
)
problem_instance.add_job(job)

# Solve the problem.
solution = problem_instance.solve(
Expand All @@ -115,7 +119,13 @@ def solve(self, input: nextmv.Input) -> nextmv.Output:
vehicle_routes = {}
planned_stops = set()

def convert_stop(t: str, stop: dict[str, Any], row: dict[str, Any], prev_cumulative_travel: int):
def convert_stop(
t: str,
stop: dict[str, Any],
row: dict[str, Any],
prev_cumulative_travel: int,
base_time: datetime | None,
):
arrival_time = int(row["arrival"])
waiting_time = int(row["waiting_time"])
setup = int(row["setup"])
Expand All @@ -124,14 +134,22 @@ def convert_stop(t: str, stop: dict[str, Any], row: dict[str, Any], prev_cumulat
travel_duration = cumulative_travel_duration - prev_cumulative_travel
start_time = arrival_time + waiting_time
end_time = start_time + setup + service
if base_time is not None:
fmt_arrival = format_rfc3339(base_time + timedelta(seconds=arrival_time))
fmt_start = format_rfc3339(base_time + timedelta(seconds=start_time))
fmt_end = format_rfc3339(base_time + timedelta(seconds=end_time))
else:
fmt_arrival = arrival_time
fmt_start = start_time
fmt_end = end_time
step = {
"stop": stop,
"type": t,
"travel_duration": travel_duration,
"cumulative_travel_duration": cumulative_travel_duration,
"arrival_time": arrival_time,
"start_time": start_time,
"end_time": end_time,
"arrival_time": fmt_arrival,
"start_time": fmt_start,
"end_time": fmt_end,
"setup": setup,
"duration": service,
"waiting_time": waiting_time,
Expand All @@ -140,16 +158,20 @@ def convert_stop(t: str, stop: dict[str, Any], row: dict[str, Any], prev_cumulat

# Iterate dataframe to translate the routes into output format.
prev_cumulative_travel_by_vehicle: dict[str, int] = {}
base_time_by_vehicle: dict[str, datetime | None] = {}
for _, row in solution.routes.iterrows():
vehicle = vehicles_by_idx[row["vehicle_id"]]
vid = vehicle["id"]

if vid not in vehicle_routes:
vehicle_routes[vid] = []
prev_cumulative_travel_by_vehicle[vid] = 0
raw_start = vehicle.get("start_time")
base_time_by_vehicle[vid] = parse_rfc3339(raw_start) if raw_start else None

vehicle_route = vehicle_routes[vid]
prev_cumulative_travel = prev_cumulative_travel_by_vehicle[vid]
base_time = base_time_by_vehicle[vid]

match row["type"]:
case "start":
Expand All @@ -162,6 +184,7 @@ def convert_stop(t: str, stop: dict[str, Any], row: dict[str, Any], prev_cumulat
},
row,
prev_cumulative_travel,
base_time,
)
vehicle_route.append(step)
prev_cumulative_travel_by_vehicle[vid] = prev
Expand All @@ -175,13 +198,14 @@ def convert_stop(t: str, stop: dict[str, Any], row: dict[str, Any], prev_cumulat
},
row,
prev_cumulative_travel,
base_time,
)
vehicle_route.append(step)
prev_cumulative_travel_by_vehicle[vid] = prev
case "job":
stop = stops_by_idx[row["location_index"]]
planned_stops.add(stop["id"])
step, prev = convert_stop("stop", stop, row, prev_cumulative_travel)
step, prev = convert_stop("stop", stop, row, prev_cumulative_travel, base_time)
vehicle_route.append(step)
prev_cumulative_travel_by_vehicle[vid] = prev
case _:
Expand Down Expand Up @@ -414,22 +438,30 @@ def calculate_distance_matrix(input_data: dict[str, Any]) -> np.ndarray:
lons_destination=lons_destination,
)

# Add 0 distances for missing start and end locations (to make a full matrix).
for vehicle in input_data["vehicles"]:
# Reshape to 2D before inserting rows/columns for missing vehicle locations.
n_init = len(input_data["stops"]) + len(has_start) + len(has_end)
distances = distances.reshape(n_init, n_init)

# Insert 0 rows/columns at the correct positions for missing start/end locations.
# The final layout is: [stops..., v0_start, v0_end, v1_start, v1_end, ...]
# We track an insertion offset as we insert rows/cols, shifting subsequent indices.
n_stops = len(input_data["stops"])
insert_offset = 0
for i, vehicle in enumerate(input_data["vehicles"]):
start_idx = n_stops + 2 * i + insert_offset
if vehicle["id"] not in has_start:
distances = np.insert(distances, len(distances), 0, axis=0)
distances = np.insert(distances, len(distances), 0, axis=1)
distances = np.insert(distances, start_idx, 0, axis=0)
distances = np.insert(distances, start_idx, 0, axis=1)
insert_offset += 1
end_idx = n_stops + 2 * i + 1 + insert_offset
if vehicle["id"] not in has_end:
distances = np.insert(distances, len(distances), 0, axis=0)
distances = np.insert(distances, len(distances), 0, axis=1)

# Convert the distances to a square matrix.
num_locations = len(input_data["stops"]) + 2 * len(input_data["vehicles"])
matrix = distances.reshape(num_locations, num_locations)
distances = np.insert(distances, end_idx, 0, axis=0)
distances = np.insert(distances, end_idx, 0, axis=1)
insert_offset += 1

end = time.time()
nextmv.log(f"Distance matrix calculation took {round(end - start, 2)} seconds.")
return matrix
return distances


def process_duration_matrix(input_data: dict[str, Any]) -> None:
Expand Down Expand Up @@ -471,5 +503,20 @@ def haversine(
return earth_radius * c


def format_rfc3339(value: datetime) -> str:
"""Formats a datetime as an RFC3339 string, using Z instead of +00:00 for UTC."""
s = value.isoformat()
if s.endswith("+00:00"):
s = s[:-6] + "Z"
return s


def parse_rfc3339(value: str) -> datetime:
"""Parses an RFC3339 string into a datetime, handling Z as UTC offset."""
if value.endswith("Z"):
value = value[:-1] + "+00:00"
return datetime.fromisoformat(value)


if __name__ == "__main__":
main()
Loading