├── .github └── workflows │ ├── auto-benchmark.yml │ ├── go-test-lint.yml │ ├── header.yml │ ├── json-lint.yml │ ├── markdown-lint.yml │ ├── python-lint.yml │ ├── python-test.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .nextmv ├── add_header.py ├── benchmark.py ├── benchmark.requirements.txt └── check_header.py ├── .prettierrc ├── LICENSE ├── README.md ├── VERSION ├── check ├── check.go ├── doc.go ├── format.go ├── observer.go ├── options.go ├── schema.go └── schema │ └── schema.go ├── cmd ├── .gitignore ├── app.yaml ├── input.json └── main.go ├── common ├── alias.go ├── alias_test.go ├── boundingbox.go ├── boundingbox_test.go ├── distance.go ├── duration.go ├── errors │ └── errors.go ├── fast_haversine.go ├── haversine.go ├── intersect.go ├── location.go ├── location_test.go ├── nsmallest.go ├── nsmallest_test.go ├── rangecheck.go ├── rangecheck_test.go ├── slices.go ├── speed.go ├── statistics.go ├── statistics_test.go ├── utils.go └── utils_test.go ├── doc.go ├── factory ├── alternates.go ├── constraint_attributes.go ├── constraint_capacity.go ├── constraint_capacity_test.go ├── constraint_cluster.go ├── constraint_distance_limit.go ├── constraint_max_duration.go ├── constraint_max_stops.go ├── constraint_max_wait_stop.go ├── constraint_max_wait_vehicle.go ├── constraint_no_mix.go ├── constraint_shift.go ├── constraint_start_timewindows.go ├── construction.go ├── data.go ├── defaults.go ├── defaults_test.go ├── doc.go ├── duration_groups_expression.go ├── factory.go ├── format.go ├── group.go ├── initialsolution.go ├── model.go ├── objective_activation.go ├── objective_capacity.go ├── objective_cluster.go ├── objective_earliness_lateness.go ├── objective_min_stops.go ├── objective_stop_balance.go ├── objective_travel_duration.go ├── objective_unplanned.go ├── objective_vehicles_duration.go ├── plan_units.go ├── precedence.go ├── services.go ├── stops.go ├── validate.go └── vehicles.go ├── go.mod ├── go.sum ├── model.go ├── model_checkedat.go ├── model_cluster.go ├── model_complexity.go ├── model_constraint.go ├── model_constraint_attributes.go ├── model_constraint_attributes_test.go ├── model_constraint_cluster_test.go ├── model_constraint_maximum_duration.go ├── model_constraint_maximum_duration_test.go ├── model_constraint_maximum_stops.go ├── model_constraint_maximum_stops_test.go ├── model_constraint_maximum_test.go ├── model_constraint_maximum_travel_duration.go ├── model_constraint_maximum_wait_stop.go ├── model_constraint_maximum_wait_stop_test.go ├── model_constraint_maximum_wait_vehicle.go ├── model_constraint_maximum_wait_vehicle_test.go ├── model_constraint_no_mix.go ├── model_constraint_no_mix_test.go ├── model_constraint_successor_test.go ├── model_constraint_successors.go ├── model_data.go ├── model_directed_acyclic_graph.go ├── model_directed_acyclic_graph_test.go ├── model_expression.go ├── model_expression_binary.go ├── model_expression_composed.go ├── model_expression_custom.go ├── model_expression_duration.go ├── model_expression_haversine.go ├── model_expression_measure_byindex.go ├── model_expression_measure_bypoint.go ├── model_expression_sum.go ├── model_expression_time.go ├── model_expression_time_dependent.go ├── model_expression_time_dependent_test.go ├── model_expression_unary.go ├── model_haversine.go ├── model_identifier.go ├── model_latest.go ├── model_latest_test.go ├── model_maximum.go ├── model_objective.go ├── model_objective_cluster_test.go ├── model_objective_earliness.go ├── model_objective_earliness_test.go ├── model_objective_expression.go ├── model_objective_expression_test.go ├── model_objective_min_stops.go ├── model_objective_stop_balancing.go ├── model_objective_stop_balancing_test.go ├── model_objective_term.go ├── model_objective_travelduration.go ├── model_objective_travelduration_test.go ├── model_objective_unplanned.go ├── model_objective_vehicles.go ├── model_objective_vehicles_duration.go ├── model_plan_stops_unit.go ├── model_plan_stops_unit_test.go ├── model_plan_unit.go ├── model_plan_units_unit.go ├── model_plan_units_unit_test.go ├── model_statistics.go ├── model_stop.go ├── model_stop_test.go ├── model_stops_distance_queries.go ├── model_stops_distance_queries_test.go ├── model_test.go ├── model_vehicle.go ├── model_vehicle_test.go ├── model_vehicle_type.go ├── nextroute.code-workspace ├── observers ├── doc.go ├── performance_observer.go ├── solve_observer.go └── solve_performance_observer.go ├── pyproject.toml ├── requirements.txt ├── schema ├── custom_data.go ├── custom_data_test.go ├── input.go └── output.go ├── setup.py ├── solution.go ├── solution_construcation_sweep_test.go ├── solution_construction_random.go ├── solution_construction_sweep.go ├── solution_format.go ├── solution_initial_observer.go ├── solution_initial_stops_test.go ├── solution_move.go ├── solution_move_stops.go ├── solution_move_stops_generator.go ├── solution_move_stops_generator_test.go ├── solution_move_stops_test.go ├── solution_move_test.go ├── solution_move_units.go ├── solution_observer.go ├── solution_plan_stops_unit.go ├── solution_plan_unit.go ├── solution_plan_unit_collection.go ├── solution_plan_unit_collection_test.go ├── solution_plan_units_unit.go ├── solution_position_hint.go ├── solution_sequence_generator.go ├── solution_sequence_generator_test.go ├── solution_stop.go ├── solution_stop_generator.go ├── solution_stop_generator_test.go ├── solution_stop_position.go ├── solution_test.go ├── solution_unplan.go ├── solution_vehicle.go ├── solution_vehicle_test.go ├── solve_events.go ├── solve_information.go ├── solve_operator.go ├── solve_operator_and.go ├── solve_operator_or.go ├── solve_operator_plan.go ├── solve_operator_restart.go ├── solve_operator_unplan.go ├── solve_operator_unplan_clusters.go ├── solve_operator_unplan_location.go ├── solve_operator_unplan_vehicles.go ├── solve_parallel_events.go ├── solve_parameters.go ├── solve_progressioner.go ├── solve_solution_channel.go ├── solve_solver.go ├── solve_solver_parallel.go ├── solve_terminate.go ├── solve_terminate_test.go ├── solver.go ├── solver_parallel.go ├── src ├── README.md ├── nextroute │ ├── __about__.py │ ├── __init__.py │ ├── base_model.py │ ├── check │ │ ├── __init__.py │ │ └── schema.py │ ├── options.py │ ├── schema │ │ ├── __init__.py │ │ ├── input.py │ │ ├── location.py │ │ ├── output.py │ │ ├── statistics.py │ │ ├── stop.py │ │ └── vehicle.py │ ├── solve.py │ └── version.py └── tests │ ├── __init__.py │ ├── schema │ ├── __init__.py │ ├── input.json │ ├── output.json │ ├── output_with_check.json │ ├── test_input.py │ └── test_output.py │ ├── solve_golden │ ├── __init__.py │ ├── main.py │ └── main_test.go │ └── test_options.py ├── tests ├── check │ ├── input.json │ ├── input.json.golden │ ├── main.go │ └── main_test.go ├── custom_constraint │ ├── input.json │ ├── input.json.golden │ ├── main.go │ └── main_test.go ├── custom_matrices │ ├── input.json │ ├── input.json.golden │ ├── main.go │ └── main_test.go ├── custom_objective │ ├── input.json │ ├── input.json.golden │ ├── main.go │ └── main_test.go ├── custom_operators │ ├── input.json │ ├── input.json.golden │ ├── main.go │ └── main_test.go ├── custom_output │ ├── input.json │ ├── input.json.golden │ ├── main.go │ └── main_test.go ├── golden │ ├── benchmark_test.go │ ├── main_test.go │ └── testdata │ │ ├── activation_penalty.json │ │ ├── activation_penalty.json.go.golden │ │ ├── activation_penalty.json.python.golden │ │ ├── alternates.json │ │ ├── alternates.json.go.golden │ │ ├── alternates.json.python.golden │ │ ├── basic.json │ │ ├── basic.json.go.golden │ │ ├── basic.json.python.golden │ │ ├── capacity.json │ │ ├── capacity.json.go.golden │ │ ├── capacity.json.python.golden │ │ ├── compatibility_attributes.json │ │ ├── compatibility_attributes.json.go.golden │ │ ├── compatibility_attributes.json.python.golden │ │ ├── complex_precedence.json │ │ ├── complex_precedence.json.go.golden │ │ ├── complex_precedence.json.python.golden │ │ ├── custom_data.json │ │ ├── custom_data.json.go.golden │ │ ├── custom_data.json.python.golden │ │ ├── defaults.json │ │ ├── defaults.json.go.golden │ │ ├── defaults.json.python.golden │ │ ├── direct_precedence.json │ │ ├── direct_precedence.json.go.golden │ │ ├── direct_precedence.json.python.golden │ │ ├── direct_precedence_linked.json │ │ ├── direct_precedence_linked.json.go.golden │ │ ├── direct_precedence_linked.json.python.golden │ │ ├── distance_matrix.json │ │ ├── distance_matrix.json.go.golden │ │ ├── distance_matrix.json.python.golden │ │ ├── duration_groups.json │ │ ├── duration_groups.json.go.golden │ │ ├── duration_groups.json.python.golden │ │ ├── duration_groups_with_stop_multiplier.json │ │ ├── duration_groups_with_stop_multiplier.json.go.golden │ │ ├── duration_groups_with_stop_multiplier.json.python.golden │ │ ├── duration_matrix.json │ │ ├── duration_matrix.json.go.golden │ │ ├── duration_matrix.json.python.golden │ │ ├── duration_matrix_time_dependent.md │ │ ├── duration_matrix_time_dependent0.json │ │ ├── duration_matrix_time_dependent0.json.go.golden │ │ ├── duration_matrix_time_dependent0.json.python.golden │ │ ├── duration_matrix_time_dependent1.json │ │ ├── duration_matrix_time_dependent1.json.go.golden │ │ ├── duration_matrix_time_dependent1.json.python.golden │ │ ├── duration_matrix_time_dependent2.json │ │ ├── duration_matrix_time_dependent2.json.go.golden │ │ ├── duration_matrix_time_dependent2.json.python.golden │ │ ├── duration_matrix_time_dependent3.json │ │ ├── duration_matrix_time_dependent3.json.go.golden │ │ ├── duration_matrix_time_dependent3.json.python.golden │ │ ├── early_arrival_penalty.json │ │ ├── early_arrival_penalty.json.go.golden │ │ ├── early_arrival_penalty.json.python.golden │ │ ├── initial_stops.json │ │ ├── initial_stops.json.go.golden │ │ ├── initial_stops.json.python.golden │ │ ├── initial_stops_infeasible_compatibility.json │ │ ├── initial_stops_infeasible_compatibility.json.go.golden │ │ ├── initial_stops_infeasible_compatibility.json.python.golden │ │ ├── initial_stops_infeasible_compatibility.md │ │ ├── initial_stops_infeasible_max_duration.json │ │ ├── initial_stops_infeasible_max_duration.json.go.golden │ │ ├── initial_stops_infeasible_max_duration.json.python.golden │ │ ├── initial_stops_infeasible_max_duration.md │ │ ├── initial_stops_infeasible_remove_all.json │ │ ├── initial_stops_infeasible_remove_all.json.go.golden │ │ ├── initial_stops_infeasible_remove_all.json.python.golden │ │ ├── initial_stops_infeasible_remove_all.md │ │ ├── initial_stops_infeasible_temporal.json │ │ ├── initial_stops_infeasible_temporal.json.go.golden │ │ ├── initial_stops_infeasible_temporal.json.python.golden │ │ ├── initial_stops_infeasible_temporal.md │ │ ├── initial_stops_infeasible_tuple.json │ │ ├── initial_stops_infeasible_tuple.json.go.golden │ │ ├── initial_stops_infeasible_tuple.json.python.golden │ │ ├── initial_stops_infeasible_tuple.md │ │ ├── late_arrival_penalty.json │ │ ├── late_arrival_penalty.json.go.golden │ │ ├── late_arrival_penalty.json.python.golden │ │ ├── max_distance.json │ │ ├── max_distance.json.go.golden │ │ ├── max_distance.json.python.golden │ │ ├── max_duration.json │ │ ├── max_duration.json.go.golden │ │ ├── max_duration.json.python.golden │ │ ├── max_stops.json │ │ ├── max_stops.json.go.golden │ │ ├── max_stops.json.python.golden │ │ ├── max_wait_stop.json │ │ ├── max_wait_stop.json.go.golden │ │ ├── max_wait_stop.json.python.golden │ │ ├── max_wait_stop.md │ │ ├── max_wait_vehicle.json │ │ ├── max_wait_vehicle.json.go.golden │ │ ├── max_wait_vehicle.json.python.golden │ │ ├── max_wait_vehicle.md │ │ ├── min_stops.json │ │ ├── min_stops.json.go.golden │ │ ├── min_stops.json.python.golden │ │ ├── multi_window.json │ │ ├── multi_window.json.go.golden │ │ ├── multi_window.json.python.golden │ │ ├── multi_window.md │ │ ├── no_mix.json │ │ ├── no_mix.json.go.golden │ │ ├── no_mix.json.python.golden │ │ ├── no_mix_null.json │ │ ├── no_mix_null.json.go.golden │ │ ├── no_mix_null.json.python.golden │ │ ├── no_mix_null.md │ │ ├── precedence.json │ │ ├── precedence.json.go.golden │ │ ├── precedence.json.python.golden │ │ ├── precedence_pathologic.json │ │ ├── precedence_pathologic.json.go.golden │ │ ├── precedence_pathologic.json.python.golden │ │ ├── start_level.json │ │ ├── start_level.json.go.golden │ │ ├── start_level.json.python.golden │ │ ├── start_level.md │ │ ├── start_time_window.json │ │ ├── start_time_window.json.go.golden │ │ ├── start_time_window.json.python.golden │ │ ├── stop_duration.json │ │ ├── stop_duration.json.go.golden │ │ ├── stop_duration.json.python.golden │ │ ├── stop_duration_multiplier.json │ │ ├── stop_duration_multiplier.json.go.golden │ │ ├── stop_duration_multiplier.json.python.golden │ │ ├── stop_groups.json │ │ ├── stop_groups.json.go.golden │ │ ├── stop_groups.json.python.golden │ │ ├── template_input.json │ │ ├── template_input.json.go.golden │ │ ├── template_input.json.python.golden │ │ ├── unplanned_penalty.json │ │ ├── unplanned_penalty.json.go.golden │ │ ├── unplanned_penalty.json.python.golden │ │ ├── vehicle_start_end_location.json │ │ ├── vehicle_start_end_location.json.go.golden │ │ ├── vehicle_start_end_location.json.python.golden │ │ ├── vehicle_start_end_time.json │ │ ├── vehicle_start_end_time.json.go.golden │ │ ├── vehicle_start_end_time.json.python.golden │ │ ├── vehicles_duration_objective.json │ │ ├── vehicles_duration_objective.json.go.golden │ │ └── vehicles_duration_objective.json.python.golden ├── inline_options │ ├── input.json │ ├── input.json.golden │ ├── main.go │ └── main_test.go ├── output_options │ ├── main.sh │ ├── main.sh.golden │ └── main_test.go ├── plateau_stopping_criterion │ ├── input.json │ ├── input.json.duration.golden │ ├── input.json.iterations.golden │ ├── main.go │ └── main_test.go └── stop_balancing_objective │ ├── input.json │ ├── input.json.golden │ ├── main.go │ └── main_test.go └── version.go /.github/workflows/auto-benchmark.yml: -------------------------------------------------------------------------------- 1 | name: auto benchmark 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | 7 | env: 8 | GO_VERSION: 1.23 9 | PYTHON_VERSION: 3.12 10 | 11 | jobs: 12 | auto-benchmark: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: git clone 16 | uses: actions/checkout@v4 17 | 18 | - name: set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ env.GO_VERSION }} 22 | 23 | - name: set up Python ${{ env.PYTHON_VERSION }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ env.PYTHON_VERSION }} 27 | 28 | - name: install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r .nextmv/benchmark.requirements.txt 32 | 33 | - name: run acceptance test 34 | env: 35 | BENCHMARK_ACCOUNT_ID: ${{ vars.BENCHMARK_ACCOUNT_ID }} 36 | BENCHMARK_API_KEY_PROD: ${{ secrets.BENCHMARK_API_KEY_PROD }} 37 | SLACK_URL_DEV_SCIENCE: ${{ secrets.SLACK_URL_DEV_SCIENCE }} 38 | run: | 39 | export BRANCH_NAME=$(echo $GITHUB_REF | awk -F'/' '{print $3}') 40 | python .nextmv/benchmark.py 41 | -------------------------------------------------------------------------------- /.github/workflows/go-test-lint.yml: -------------------------------------------------------------------------------- 1 | name: go 2 | on: [push] 3 | 4 | env: 5 | GO_VERSION: 1.22.0 6 | 7 | jobs: 8 | # Job for running go linter 9 | go-lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: git clone 13 | uses: actions/checkout@v4 14 | - name: set up go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ env.GO_VERSION }} 18 | - name: golangci-lint 19 | uses: golangci/golangci-lint-action@v8 20 | with: 21 | version: v2.1 22 | # Job for running tests 23 | go-test: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: git clone 27 | uses: actions/checkout@v4 28 | - name: set up go 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: ${{ env.GO_VERSION }} 32 | - name: go test (exclude golden file tests from Python package) 33 | run: go test $(go list ./... | grep -v github.com/nextmv-io/nextroute/src) 34 | -------------------------------------------------------------------------------- /.github/workflows/header.yml: -------------------------------------------------------------------------------- 1 | name: header 2 | on: [push] 3 | jobs: 4 | check-header: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | language: ["go", "python"] 10 | steps: 11 | - name: git clone 12 | uses: actions/checkout@v4 13 | 14 | - name: set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.12" 18 | 19 | - name: check header in ${{ matrix.language }} files 20 | run: | 21 | HEADER_CHECK_LANGUAGE=${{ matrix.language }} python .nextmv/check_header.py 22 | -------------------------------------------------------------------------------- /.github/workflows/json-lint.yml: -------------------------------------------------------------------------------- 1 | name: json 2 | on: [push] 3 | jobs: 4 | json-lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: git clone 8 | uses: actions/checkout@v4 9 | 10 | - name: set up node 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: 18.8 14 | 15 | - name: install prettier 16 | run: npm install prettier@v2.7.1 --global 17 | 18 | - name: lint .json files with prettier 19 | run: prettier -c "**/*.json" 20 | -------------------------------------------------------------------------------- /.github/workflows/markdown-lint.yml: -------------------------------------------------------------------------------- 1 | name: markdown 2 | on: [push] 3 | jobs: 4 | markdown-lint: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: git clone 8 | uses: actions/checkout@v4 9 | 10 | - uses: DavidAnson/markdownlint-cli2-action@v7 11 | with: 12 | globs: "**/*.md" 13 | -------------------------------------------------------------------------------- /.github/workflows/python-lint.yml: -------------------------------------------------------------------------------- 1 | name: python lint 2 | on: [push] 3 | jobs: 4 | python-lint: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 10 | steps: 11 | - name: git clone 12 | uses: actions/checkout@v4 13 | 14 | - name: set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | 19 | - name: install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements.txt 23 | 24 | - name: lint with ruff 25 | run: ruff check --output-format=github -v . 26 | -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: python test 2 | on: [push] 3 | 4 | env: 5 | GO_VERSION: 1.23 6 | 7 | jobs: 8 | python-test: 9 | runs-on: ${{ matrix.platform }} 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 14 | platform: [ubuntu-latest, windows-latest, macos-latest, macos-13] 15 | steps: 16 | - name: git clone 17 | uses: actions/checkout@v4 18 | 19 | - name: set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install -r requirements.txt 28 | 29 | # There appears to be a bug around 30 | # `nextmv-io/sdk@v1.8.0/golden/file.go:75` specifically in Windows. When 31 | # attempting to remove a temp file, the following error is encountered: 32 | # `panic: remove C:\Users\RUNNER~1\AppData\Local\Temp\output1368198263: 33 | # The process cannot access the file because it is being used by another 34 | # process.` We need to figure out why it only happens in Windows. Until 35 | # then, we will not run the golden file tests in Windows. 36 | # Source:https://github.com/nextmv-io/nextroute/actions/runs/11414952328/job/31764458969?pr=65 37 | - name: set up Go 38 | if: ${{ matrix.platform != 'windows-latest' }} 39 | uses: actions/setup-go@v5 40 | with: 41 | go-version: ${{ env.GO_VERSION }} 42 | 43 | - name: Python unit tests 44 | run: python -m unittest 45 | working-directory: src 46 | 47 | - name: golden file tests from Python package 48 | if: ${{ matrix.platform != 'windows-latest' }} 49 | run: go test $(go list ./... | grep github.com/nextmv-io/nextroute/src) 50 | -------------------------------------------------------------------------------- /.nextmv/add_header.py: -------------------------------------------------------------------------------- 1 | # Description: This script adds a header to all go files that are missing it. 2 | import glob 3 | 4 | HEADER = "// © 2019-present nextmv.io inc" 5 | 6 | # List all go files in all subdirectories 7 | go_files = glob.glob("**/*.go", recursive=True) 8 | 9 | # Check if the header is the first line of each file 10 | missing = [] 11 | checked = 0 12 | for file in go_files: 13 | with open(file) as f: 14 | first_line = f.readline().strip() 15 | if first_line != HEADER: 16 | missing.append(file) 17 | checked += 1 18 | 19 | # Add the header to all missing files 20 | for file in missing: 21 | print(f"Adding header to {file}") 22 | with open(file) as f: 23 | content = f.read() 24 | with open(file, "w") as f: 25 | f.write(HEADER + "\n\n" + content) 26 | 27 | print(f"Checked {checked} files, added header to {len(missing)} files") 28 | -------------------------------------------------------------------------------- /.nextmv/benchmark.requirements.txt: -------------------------------------------------------------------------------- 1 | nextmv>=v0.14.2 2 | requests>=2.32.3 3 | -------------------------------------------------------------------------------- /.nextmv/check_header.py: -------------------------------------------------------------------------------- 1 | # Description: This script checks if the header is present in all go files. 2 | import glob 3 | import os 4 | import sys 5 | 6 | HEADER = "© 2019-present nextmv.io inc" 7 | 8 | GO_HEADER = f"// {HEADER}" 9 | GO_IGNORE = [] 10 | 11 | PYTHON_HEADER = f"# {HEADER}" 12 | PYTHON_IGNORE = ["venv/*", "src/tests/*"] 13 | 14 | 15 | def main() -> None: 16 | """Checks if the header is present all files, for the given language.""" 17 | 18 | check_var = os.getenv("HEADER_CHECK_LANGUAGE", "go") 19 | if check_var == "go": 20 | files = glob.glob("**/*.go", recursive=True) 21 | header = GO_HEADER 22 | ignore = GO_IGNORE 23 | elif check_var == "python": 24 | files = glob.glob("**/*.py", recursive=True) 25 | header = PYTHON_HEADER 26 | ignore = PYTHON_IGNORE 27 | else: 28 | raise ValueError(f"Unsupported language: {check_var}") 29 | 30 | check(files, header, ignore) 31 | 32 | 33 | def check(files: list[str], header: str, ignore: list[str]) -> None: 34 | """Checks if the header is present in all files.""" 35 | 36 | # Check if the header is the first line of each file 37 | missing = [] 38 | checked = 0 39 | for file in files: 40 | # Check if the path is in the ignore list with a glob pattern. 41 | if any(glob.fnmatch.fnmatch(file, pattern) for pattern in ignore): 42 | continue 43 | 44 | with open(file) as f: 45 | first_line = f.readline().strip() 46 | if first_line != header: 47 | missing.append(file) 48 | checked += 1 49 | 50 | # Print the results 51 | if missing: 52 | print(f"Missing header in {len(missing)} of {checked} files:") 53 | for file in missing: 54 | print(f" {file}") 55 | sys.exit(1) 56 | else: 57 | print(f"Header is present in all {checked} files") 58 | 59 | 60 | if __name__ == "__main__": 61 | main() 62 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": false, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "overrides": [ 7 | { 8 | "files": "*.yml", 9 | "options": { 10 | "tabWidth": 2, 11 | "singleQuote": false 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v1.11.4 2 | -------------------------------------------------------------------------------- /check/doc.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | /* 4 | Package check provides a package that allows you check models and solutions. 5 | 6 | Checking a model or a solution checks the unplanned plan units. It checks each 7 | individual plan unit if it can be added to the solution. If the plan unit can 8 | be added to the solution, the report will include on how many vehicles and 9 | what the impact would be on the objective value. If the plan unit cannot be 10 | added to the solution, the report will include the reason why it cannot be 11 | added to the solution. 12 | 13 | The check can be invoked on a nextroute.Model or a nextroute.Solution. If the 14 | check is invoked on a model, an empty solution is created and the check is 15 | executed on this empty solution. An empty solution is a solution with all the 16 | initial stops that are fixed, initial stops that are not fixed are not added 17 | to the solution. The check is executed on the unplanned plan units of the 18 | solution. If the check is invoked on a solution, it is executed on the 19 | unplanned plan units of the solution. 20 | */ 21 | package check 22 | -------------------------------------------------------------------------------- /check/format.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package check 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/nextmv-io/nextroute" 9 | "github.com/nextmv-io/nextroute/factory" 10 | runSchema "github.com/nextmv-io/sdk/run/schema" 11 | ) 12 | 13 | // Format formats a solution in a basic format using factory.ToSolutionOutput 14 | // to format each solution and also allows to check the solutions and add the 15 | // check to the output of each solution. 16 | func Format( 17 | ctx context.Context, 18 | options any, 19 | checkOptions Options, 20 | progressioner nextroute.Progressioner, 21 | solutions ...nextroute.Solution, 22 | ) (runSchema.Output, error) { 23 | return nextroute.Format( 24 | ctx, 25 | options, 26 | progressioner, 27 | func(solution nextroute.Solution) any { 28 | solutionOutput := factory.ToSolutionOutput(solution) 29 | if checkOptions.Duration > 0 && 30 | ToVerbosity(checkOptions.Verbosity) != Off { 31 | solutionCheckOutput, err := SolutionCheck( 32 | solution, 33 | checkOptions, 34 | ) 35 | if err == nil { 36 | solutionOutput.Check = &solutionCheckOutput 37 | } 38 | } 39 | return solutionOutput 40 | }, 41 | solutions..., 42 | ), nil 43 | } 44 | -------------------------------------------------------------------------------- /check/options.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package check 4 | 5 | import "time" 6 | 7 | // Options are the options for a check. 8 | type Options struct { 9 | Duration time.Duration `json:"duration" usage:"maximum duration of the check" default:"30s"` 10 | Verbosity string `json:"verbosity" usage:"{off, low, medium, high} verbosity of the check" default:"off"` 11 | } 12 | -------------------------------------------------------------------------------- /check/schema.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // Package check contains the schema for the check configuration. 4 | package check 5 | 6 | import "strings" 7 | 8 | // Verbosity is the verbosity of the check. 9 | type Verbosity int 10 | 11 | const ( 12 | // Off does not run the check. 13 | Off Verbosity = iota 14 | // Low checks if there is at least one move per plan unit. 15 | Low 16 | // Medium checks the number of moves per plan unit and the 17 | // number of vehicles that have moves. It also reports the number of 18 | // constraints that are violated for each plan unit if it does not fit 19 | // on any vehicle. 20 | Medium 21 | // High is identical to medium. 22 | High 23 | ) 24 | 25 | // ToVerbosity converts a string to a verbosity. The string can be 26 | // anything that starts with "o", "l", "m", "h" or "v" case-insensitive. 27 | // If the string does not start with one of these characters the 28 | // verbosity is off. 29 | func ToVerbosity(s string) Verbosity { 30 | ls := strings.ToLower(s) 31 | if strings.HasPrefix(ls, "o") { 32 | return Off 33 | } 34 | if strings.HasPrefix(ls, "l") { 35 | return Low 36 | } 37 | if strings.HasPrefix(ls, "m") { 38 | return Medium 39 | } 40 | if strings.HasPrefix(ls, "h") { 41 | return High 42 | } 43 | return Off 44 | } 45 | 46 | // String returns the string representation of the verbosity. 47 | func (v Verbosity) String() string { 48 | switch v { 49 | case Off: 50 | return "off" 51 | case Low: 52 | return "low" 53 | case Medium: 54 | return "medium" 55 | case High: 56 | return "high" 57 | default: 58 | return "unknown" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /cmd/.gitignore: -------------------------------------------------------------------------------- 1 | cmd 2 | main 3 | *.json 4 | *.sh 5 | *.yaml 6 | *.yml 7 | -------------------------------------------------------------------------------- /cmd/app.yaml: -------------------------------------------------------------------------------- 1 | # This manifest holds the information the app needs to run on the Nextmv Cloud. 2 | type: go 3 | runtime: ghcr.io/nextmv-io/runtime/default:latest 4 | build: 5 | command: go build -o main . 6 | environment: 7 | GOOS: linux 8 | GOARCH: arm64 9 | files: 10 | - main 11 | -------------------------------------------------------------------------------- /common/alias_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common_test 4 | 5 | import ( 6 | "math" 7 | "math/rand" 8 | "testing" 9 | 10 | "github.com/nextmv-io/nextroute/common" 11 | ) 12 | 13 | const ( 14 | samples = 1_000_000 15 | errorEpsilon = 0.001 16 | ) 17 | 18 | func TestAlias(t *testing.T) { 19 | testAlias(t, []float64{2, 2}, 22) 20 | testAlias(t, []float64{1, 2, 3}, 123) 21 | testAlias(t, []float64{6, 2, 1, 4, 2}, 62142) 22 | testAlias(t, []float64{1000, 1, 3, 10}, 10001310) 23 | } 24 | 25 | func testAlias(t *testing.T, weights []float64, seed int64) { 26 | sum := 0.0 27 | for i := 0; i < len(weights); i++ { 28 | sum += weights[i] 29 | } 30 | alias, err := common.NewAlias(weights) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | random := rand.New(rand.NewSource(seed)) 36 | counts := make([]int64, len(weights)) 37 | 38 | for i := 0; i < samples; i++ { 39 | counts[alias.Sample(random)]++ 40 | } 41 | 42 | for i := 0; i < len(weights); i++ { 43 | count := float64(counts[i]) / samples 44 | if math.Abs(count-weights[i]/sum) > errorEpsilon { 45 | t.Errorf( 46 | "Counts did not match, got %v, expected %v, seed %v", 47 | count, 48 | weights[i]/sum, 49 | seed, 50 | ) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /common/distance.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // Package common contains common types and functions. 4 | package common 5 | 6 | import "fmt" 7 | 8 | // DistanceUnit is the unit of distance. 9 | type DistanceUnit int 10 | 11 | // NewDistance returns a new distance. 12 | func NewDistance( 13 | value float64, 14 | unit DistanceUnit, 15 | ) Distance { 16 | switch unit { 17 | case Kilometers: 18 | value *= factorKilometersToMeters 19 | case Miles: 20 | value *= factorMilesToMeters 21 | } 22 | 23 | return Distance{ 24 | meters: value, 25 | unit: unit, 26 | } 27 | } 28 | 29 | const ( 30 | // Kilometers is 1000 meters. 31 | Kilometers DistanceUnit = iota 32 | // Meters is the distance travelled by light in a vacuum in 33 | // 1/299,792,458 seconds. 34 | Meters 35 | // Miles is 1609.34 meters. 36 | Miles 37 | ) 38 | 39 | const ( 40 | factorMetersToKilometers = 0.001 41 | factorMetersToMiles = 0.000621371 42 | ) 43 | 44 | const ( 45 | factorKilometersToMeters = 1000 46 | factorMilesToMeters = 1609.34 47 | ) 48 | 49 | // String returns the string representation of the distance unit. 50 | func (d DistanceUnit) String() string { 51 | switch d { 52 | case Kilometers: 53 | return "kilometers" 54 | case Meters: 55 | return "meters" 56 | case Miles: 57 | return "miles" 58 | } 59 | return fmt.Sprintf("unknown distance unit %v", int(d)) 60 | } 61 | 62 | // Distance is a distance in a given unit. 63 | type Distance struct { 64 | meters float64 65 | unit DistanceUnit 66 | } 67 | 68 | // Value returns the distance in the specified unit. 69 | func (d Distance) Value(unit DistanceUnit) float64 { 70 | returnValue := d.meters 71 | switch unit { 72 | case Kilometers: 73 | returnValue *= factorMetersToKilometers 74 | case Miles: 75 | returnValue *= factorMetersToMiles 76 | } 77 | return returnValue 78 | } 79 | -------------------------------------------------------------------------------- /common/duration.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // DurationUnit is the unit of duration. 11 | type DurationUnit int 12 | 13 | const ( 14 | // NanoSecond is 1/1,000,000,000 of a second. 15 | NanoSecond DurationUnit = iota 16 | // MicroSecond is 1/1,000,000 of a second. 17 | MicroSecond 18 | // MilliSecond is 1/1,000 of a second. 19 | MilliSecond 20 | // Second is the SI unit of time. 21 | Second 22 | // Minute is 60 seconds. 23 | Minute 24 | // Hour is 60 minutes. 25 | Hour 26 | // Day is 24 hours. 27 | Day 28 | ) 29 | 30 | // String returns the string representation of the duration unit. 31 | func (d DurationUnit) String() string { 32 | switch d { 33 | case NanoSecond: 34 | return "nanoseconds" 35 | case MicroSecond: 36 | return "microseconds" 37 | case MilliSecond: 38 | return "milliseconds" 39 | case Second: 40 | return "seconds" 41 | case Minute: 42 | return "minutes" 43 | case Hour: 44 | return "hours" 45 | case Day: 46 | return "days" 47 | } 48 | return fmt.Sprintf("unknown duration unit %v", int(d)) 49 | } 50 | 51 | // NewDuration returns a new duration by unit. 52 | func NewDuration(unit DurationUnit) time.Duration { 53 | switch unit { 54 | case NanoSecond: 55 | return time.Nanosecond 56 | case MicroSecond: 57 | return time.Microsecond 58 | case MilliSecond: 59 | return time.Millisecond 60 | case Second: 61 | return time.Second 62 | case Minute: 63 | return 60 * time.Second 64 | case Hour: 65 | return time.Hour 66 | case Day: 67 | return 24 * time.Hour 68 | default: 69 | panic(fmt.Sprintf("unknown duration unit %v", int(unit))) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /common/errors/errors.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // Package errors contains errors contains information about errors returned by nextmv functions. 4 | package errors 5 | 6 | import ( 7 | "fmt" 8 | ) 9 | 10 | // Error is the base interface for all errors returned by nextroute functions. 11 | type Error interface { 12 | error 13 | } 14 | 15 | // InputDataError is returned when there is an input data error. 16 | type InputDataError struct { 17 | error error 18 | } 19 | 20 | // Error returns the error message. 21 | func (e InputDataError) Error() string { 22 | return e.error.Error() 23 | } 24 | 25 | // NewInputDataError creates a new InputDataError. 26 | func NewInputDataError(err error) Error { 27 | return InputDataError{ 28 | error: fmt.Errorf("input data error: %w", err), 29 | } 30 | } 31 | 32 | // ModelCustomizationError is returned when there is an error in the custom model. 33 | type ModelCustomizationError struct { 34 | error error 35 | } 36 | 37 | // Error returns the error message. 38 | func (e ModelCustomizationError) Error() string { 39 | return e.error.Error() 40 | } 41 | 42 | // NewModelCustomizationError creates a new ModelCustomizationError. 43 | func NewModelCustomizationError(err error) Error { 44 | return ModelCustomizationError{ 45 | error: fmt.Errorf("input data error: %w", err), 46 | } 47 | } 48 | 49 | // ArgumentMismatchError is returned when an argument contains incorrect data. 50 | type ArgumentMismatchError struct { 51 | error error 52 | } 53 | 54 | // Error returns the error message. 55 | func (e ArgumentMismatchError) Error() string { 56 | return e.error.Error() 57 | } 58 | 59 | // NewArgumentMismatchError creates a new ArgumentMismatchError. 60 | func NewArgumentMismatchError(err error) Error { 61 | return ArgumentMismatchError{ 62 | error: fmt.Errorf("input data error: %w", err), 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /common/fast_haversine.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common 4 | 5 | import ( 6 | "fmt" 7 | "math" 8 | ) 9 | 10 | // NewFastHaversine returns a new FastHaversine. 11 | func NewFastHaversine(lat float64) FastHaversine { 12 | const RE = 6378.137 // equatorial radius 13 | const FE = 1 / 298.257223563 // flattening 14 | 15 | const E2 = FE * (2 - FE) 16 | const RAD = math.Pi / 180 17 | const m = RAD * RE * 1000 18 | coslat := math.Cos(lat * RAD) 19 | w2 := 1 / (1 - E2*(1-coslat*coslat)) 20 | w := math.Sqrt(w2) 21 | 22 | return FastHaversine{ 23 | kx: m * w * coslat, // based on normal radius of curvature 24 | ky: m * w * w2 * (1 - E2), // based on meridonal radius of curvature 25 | } 26 | } 27 | 28 | // FastHaversine is a fast approximation of the haversine distance. 29 | type FastHaversine struct { 30 | kx float64 31 | ky float64 32 | } 33 | 34 | func wrap(deg float64) float64 { 35 | for deg < -180 { 36 | deg += 360 37 | } 38 | for deg > 180 { 39 | deg -= 360 40 | } 41 | return deg 42 | } 43 | 44 | // Distance returns the distance between two locations in meters. 45 | func (f FastHaversine) Distance(from, to Location) (float64, error) { 46 | if !from.IsValid() || !to.IsValid() { 47 | return 0.0, 48 | fmt.Errorf( 49 | "from (lon: %f, lat: %f) (valid = %t) or "+ 50 | "to (lon: %f, lat: %f) (valid = %t) are invalid", 51 | from.Longitude(), 52 | from.Latitude(), 53 | from.IsValid(), 54 | to.Longitude(), 55 | to.Latitude(), 56 | to.IsValid(), 57 | ) 58 | } 59 | dx := wrap(from.Longitude()-to.Longitude()) * f.kx 60 | dy := (from.Latitude() - to.Latitude()) * f.ky 61 | return math.Sqrt(dx*dx + dy*dy), nil 62 | } 63 | -------------------------------------------------------------------------------- /common/haversine.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common 4 | 5 | import ( 6 | "fmt" 7 | "math" 8 | ) 9 | 10 | // Haversine calculates the distance between two locations using the 11 | // Haversine formula. Haversine is a good approximation for short 12 | // distances (up to a few hundred kilometers). 13 | func Haversine(from, to Location) (Distance, error) { 14 | if !from.IsValid() || !to.IsValid() { 15 | return Distance{}, 16 | fmt.Errorf( 17 | "from (lon: %f, lat: %f) (valid = %t) or "+ 18 | "to (lon: %f, lat: %f) (valid = %v) are invalid", 19 | from.Longitude(), 20 | from.Latitude(), 21 | from.IsValid(), 22 | to.Longitude(), 23 | to.Latitude(), 24 | to.IsValid(), 25 | ) 26 | } 27 | 28 | x1 := degreesToRadian(from.Longitude()) 29 | y1 := degreesToRadian(from.Latitude()) 30 | x2 := degreesToRadian(to.Longitude()) 31 | y2 := degreesToRadian(to.Latitude()) 32 | 33 | dx := x1 - x2 34 | dy := y1 - y2 35 | 36 | sdy := math.Sin(dy / 2) 37 | sdx := math.Sin(dx / 2) 38 | a := (sdy * sdy) + math.Cos(y1)*math.Cos(y2)*sdx*sdx 39 | 40 | return NewDistance( 41 | 2*radius*math.Atan2(math.Sqrt(a), math.Sqrt(1-a)), 42 | Meters, 43 | ), nil 44 | } 45 | 46 | func degreesToRadian(d float64) float64 { 47 | return d * math.Pi / 180.0 48 | } 49 | 50 | const radius = 6371 * 1000 51 | -------------------------------------------------------------------------------- /common/intersect.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common 4 | 5 | // Intersect returns the intersection of two slices. 6 | func Intersect[T comparable](a []T, b []T) []T { 7 | set := make([]T, 0) 8 | 9 | if len(a) == 0 || len(b) == 0 { 10 | return set 11 | } 12 | 13 | ref := a 14 | other := b 15 | 16 | if len(a) > len(b) { 17 | ref = b 18 | other = a 19 | } 20 | 21 | hash := make(map[T]struct{}, len(ref)) 22 | 23 | for _, v := range ref { 24 | hash[v] = struct{}{} 25 | } 26 | 27 | for _, v := range other { 28 | if _, ok := hash[v]; ok { 29 | set = append(set, v) 30 | } 31 | } 32 | 33 | return set 34 | } 35 | -------------------------------------------------------------------------------- /common/nsmallest.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common 4 | 5 | import ( 6 | "container/heap" 7 | ) 8 | 9 | // NSmallest returns the n-smallest items in the slice items using the 10 | // function f to determine the value of each item. If n is greater than 11 | // the length of items, all items are returned. 12 | func NSmallest[T any](items []T, f func(T) float64, n int) []T { 13 | if n <= 0 { 14 | return []T{} 15 | } 16 | if n >= len(items) { 17 | return items 18 | } 19 | h := &minHeap[T]{} 20 | heap.Init(h) 21 | 22 | for _, item := range items { 23 | value := f(item) 24 | if h.Len() < n { 25 | h.Push(itemValue[T]{item, f(item)}) 26 | } else if h.Peek().value > value { 27 | heap.Pop(h) 28 | heap.Push(h, itemValue[T]{ 29 | item: item, 30 | value: value, 31 | }) 32 | } 33 | } 34 | result := make([]T, n) 35 | for i := 0; i < n; i++ { 36 | result[i] = heap.Pop(h).(itemValue[T]).item 37 | } 38 | return result 39 | } 40 | 41 | type itemValue[T any] struct { 42 | item T 43 | value float64 44 | } 45 | 46 | type minHeap[T any] []itemValue[T] 47 | 48 | func (h minHeap[T]) Len() int { 49 | return len(h) 50 | } 51 | 52 | func (h minHeap[T]) Less(i, j int) bool { 53 | return h[i].value < h[j].value 54 | } 55 | 56 | func (h minHeap[T]) Swap(i, j int) { 57 | h[i], h[j] = h[j], h[i] 58 | } 59 | 60 | func (h *minHeap[T]) Push(x interface{}) { 61 | *h = append(*h, x.(itemValue[T])) 62 | } 63 | 64 | func (h *minHeap[T]) Pop() interface{} { 65 | old := *h 66 | n := len(old) 67 | x := old[n-1] 68 | *h = old[0 : n-1] 69 | return x 70 | } 71 | 72 | func (h *minHeap[T]) Peek() itemValue[T] { 73 | return (*h)[0] 74 | } 75 | -------------------------------------------------------------------------------- /common/nsmallest_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common_test 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/nextmv-io/nextroute/common" 9 | ) 10 | 11 | type TestItem interface { 12 | Value() float64 13 | } 14 | 15 | type TestItemImpl struct { 16 | value float64 17 | } 18 | 19 | func (t TestItemImpl) Value() float64 { 20 | return t.value 21 | } 22 | 23 | func TestSmallest(t *testing.T) { 24 | items := []TestItem{ 25 | TestItemImpl{value: 1}, 26 | TestItemImpl{value: 2}, 27 | TestItemImpl{value: 3}, 28 | TestItemImpl{value: 4}, 29 | TestItemImpl{value: 5}, 30 | } 31 | f := func(item TestItem) float64 { 32 | return item.Value() 33 | } 34 | result := common.NSmallest(items, f, 3) 35 | if len(result) != 3 { 36 | t.Errorf("Expected 3 items, got %v", len(result)) 37 | } 38 | if result[0].Value() != 1 { 39 | t.Errorf("Expected 1, got %v", result[0].Value()) 40 | } 41 | if result[1].Value() != 2 { 42 | t.Errorf("Expected 2, got %v", result[1].Value()) 43 | } 44 | if result[2].Value() != 3 { 45 | t.Errorf("Expected 3, got %v", result[2].Value()) 46 | } 47 | result = common.NSmallest(items, f, 10) 48 | if len(result) != 5 { 49 | t.Errorf("Expected 3 items, got %v", len(result)) 50 | } 51 | if result[0].Value() != 1 { 52 | t.Errorf("Expected 1, got %v", result[0].Value()) 53 | } 54 | if result[1].Value() != 2 { 55 | t.Errorf("Expected 2, got %v", result[1].Value()) 56 | } 57 | if result[2].Value() != 3 { 58 | t.Errorf("Expected 3, got %v", result[2].Value()) 59 | } 60 | if result[3].Value() != 4 { 61 | t.Errorf("Expected 4, got %v", result[3].Value()) 62 | } 63 | if result[4].Value() != 5 { 64 | t.Errorf("Expected 5, got %v", result[4].Value()) 65 | } 66 | 67 | result = common.NSmallest(items, f, 0) 68 | if len(result) != 0 { 69 | t.Errorf("Expected 0 items, got %v", len(result)) 70 | } 71 | 72 | result = common.NSmallest([]TestItem{}, f, 3) 73 | if len(result) != 0 { 74 | t.Errorf("Expected 0 items, got %v", len(result)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /common/slices.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common 4 | 5 | // CopySliceFrom cuts of a slice from `alloc` and copies the data from `data` 6 | // into it. It returns the new slice and the remaining slice of `alloc`. This 7 | // can be used in places where we allocate once and copy multiple times. 8 | func CopySliceFrom[T any](alloc []T, data []T) ([]T, []T) { 9 | n := len(data) 10 | newData, alloc := alloc[:n], alloc[n:] 11 | copy(newData, data) 12 | return newData, alloc 13 | } 14 | -------------------------------------------------------------------------------- /common/utils_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package common_test 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/nextmv-io/nextroute/common" 9 | ) 10 | 11 | func BenchmarkFilter(b *testing.B) { 12 | for i := 0; i < b.N; i++ { 13 | numberOfValues := 100 14 | values := make([]int, numberOfValues) 15 | for i := 0; i < numberOfValues; i++ { 16 | values[i] = i 17 | } 18 | _ = common.Filter(values, func(vehicle int) bool { 19 | return vehicle%2 == 0 20 | }) 21 | } 22 | } 23 | 24 | func TestDefensiveCopy(t *testing.T) { 25 | numberOfValues := 100 26 | values := make([]int, numberOfValues) 27 | for i := 0; i < numberOfValues; i++ { 28 | values[i] = i 29 | } 30 | copiedValues := common.DefensiveCopy(values) 31 | for i := 0; i < numberOfValues; i++ { 32 | if values[i] != copiedValues[i] { 33 | t.Errorf("Expected %v, got %v", values[i], copiedValues[i]) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // Package nextroute provides a solver for vehicle routing problems. 4 | package nextroute 5 | -------------------------------------------------------------------------------- /factory/constraint_attributes.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addAttributesConstraint adds the attributes constraint to the model. 11 | func addAttributesConstraint( 12 | input schema.Input, 13 | model nextroute.Model, 14 | _ Options, 15 | ) (nextroute.Model, error) { 16 | constraint, err := nextroute.NewAttributesConstraint() 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | presentInStops := false 22 | for s, stop := range input.Stops { 23 | if stop.CompatibilityAttributes == nil { 24 | continue 25 | } 26 | 27 | err := constraint.SetStopAttributes(model.Stops()[s], *stop.CompatibilityAttributes) 28 | if err != nil { 29 | return nil, err 30 | } 31 | presentInStops = true 32 | } 33 | 34 | presentInVehicles := false 35 | for v, vehicle := range input.Vehicles { 36 | if vehicle.CompatibilityAttributes == nil { 37 | continue 38 | } 39 | 40 | err = constraint.SetVehicleTypeAttributes(model.VehicleTypes()[v], *vehicle.CompatibilityAttributes) 41 | if err != nil { 42 | return nil, err 43 | } 44 | presentInVehicles = true 45 | } 46 | 47 | if !presentInStops && !presentInVehicles { 48 | return model, nil 49 | } 50 | 51 | err = model.AddConstraint(constraint) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | return model, nil 57 | } 58 | -------------------------------------------------------------------------------- /factory/constraint_cluster.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addClusterConstraint adds a constraint which limits stops only to be added 11 | // to the vehicle whose centroid is closest. 12 | func addClusterConstraint( 13 | _ schema.Input, 14 | model nextroute.Model, 15 | _ Options, 16 | ) (nextroute.Model, error) { 17 | cluster, err := nextroute.NewCluster() 18 | if err != nil { 19 | return model, err 20 | } 21 | err = model.AddConstraint(cluster) 22 | if err != nil { 23 | return model, err 24 | } 25 | return model, nil 26 | } 27 | -------------------------------------------------------------------------------- /factory/constraint_distance_limit.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "fmt" 7 | "math" 8 | 9 | "github.com/nextmv-io/nextroute" 10 | "github.com/nextmv-io/nextroute/common" 11 | "github.com/nextmv-io/nextroute/schema" 12 | ) 13 | 14 | // addDistanceLimitConstraint adds a distance limit for routes to the model. 15 | func addDistanceLimitConstraint( 16 | input schema.Input, 17 | model nextroute.Model, 18 | _ Options, 19 | ) (nextroute.Model, error) { 20 | composed := nextroute.NewComposedPerVehicleTypeExpression( 21 | nextroute.NewConstantExpression( 22 | "constant-route-distance", 23 | 0, 24 | ), 25 | ) 26 | 27 | limit := nextroute.NewVehicleTypeDistanceExpression( 28 | "distanceLimit", 29 | common.NewDistance(math.MaxFloat64, common.Meters), 30 | ) 31 | hasDistanceLimit := false 32 | for _, vehicleType := range model.VehicleTypes() { 33 | maxDistance := input.Vehicles[vehicleType.Index()].MaxDistance 34 | if maxDistance == nil { 35 | continue 36 | } 37 | 38 | hasDistanceLimit = true 39 | 40 | // Check if custom data is set properly. 41 | data, ok := vehicleType.Data().(vehicleTypeData) 42 | if !ok { 43 | return nil, fmt.Errorf("could not read custom data for vehicle %s", 44 | vehicleType.ID(), 45 | ) 46 | } 47 | 48 | // Get distance expression and set limit for the vehicle type. 49 | distanceExpression := data.DistanceExpression 50 | composed.Set(vehicleType, distanceExpression) 51 | err := limit.SetDistance(vehicleType, common.NewDistance(float64(*maxDistance), common.Meters)) 52 | if err != nil { 53 | return nil, err 54 | } 55 | } 56 | 57 | if !hasDistanceLimit { 58 | return model, nil 59 | } 60 | 61 | // Create and then add constraint to model. 62 | maxConstraint, err := nextroute.NewMaximum( 63 | composed, 64 | limit, 65 | ) 66 | if err != nil { 67 | return nil, err 68 | } 69 | maxConstraint.(nextroute.Identifier).SetID("distance_limit") 70 | 71 | err = model.AddConstraint(maxConstraint) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | return model, nil 77 | } 78 | -------------------------------------------------------------------------------- /factory/constraint_max_duration.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "time" 7 | 8 | "github.com/nextmv-io/nextroute" 9 | "github.com/nextmv-io/nextroute/schema" 10 | ) 11 | 12 | // addMaximumDurationConstraint uses the latestEndConstraint of the model. It 13 | // checks if, when adding the maximum duration to the vehicle's start time, the 14 | // end time happens before what is already set in the latestEndConstraint (the 15 | // constraint is created if it does not exist). If this end time is at an 16 | // earlier time than what is already set, then the value is changed. 17 | func addMaximumDurationConstraint( 18 | input schema.Input, 19 | model nextroute.Model, 20 | _ Options, 21 | ) (nextroute.Model, error) { 22 | latestEndExpression, model, err := latestEndExpression(model) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | present := false 28 | for v, inputVehicle := range input.Vehicles { 29 | if inputVehicle.MaxDuration == nil { 30 | continue 31 | } 32 | 33 | vehicle := model.Vehicles()[v] 34 | end := vehicle.Start().Add(time.Duration(*inputVehicle.MaxDuration) * time.Second) 35 | if end.Before(latestEndExpression.Time(vehicle.Last())) { 36 | latestEndExpression.SetTime(vehicle.Last(), end) 37 | } 38 | 39 | present = true 40 | } 41 | 42 | if !present { 43 | return model, nil 44 | } 45 | 46 | model, err = addLatestEndConstraint(model, latestEndExpression) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return model, nil 52 | } 53 | -------------------------------------------------------------------------------- /factory/constraint_max_stops.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "math" 7 | 8 | "github.com/nextmv-io/nextroute" 9 | "github.com/nextmv-io/nextroute/schema" 10 | ) 11 | 12 | // addMaximumStopsConstraint adds a MaximumStopsConstraint to the model. 13 | func addMaximumStopsConstraint( 14 | input schema.Input, 15 | model nextroute.Model, 16 | _ Options, 17 | ) (nextroute.Model, error) { 18 | limit := nextroute.NewVehicleTypeValueExpression( 19 | "stopsLimit", 20 | math.MaxFloat64, 21 | ) 22 | 23 | present := false 24 | for _, vehicleType := range model.VehicleTypes() { 25 | maxStops := input.Vehicles[vehicleType.Index()].MaxStops 26 | if maxStops == nil { 27 | continue 28 | } 29 | 30 | present = true 31 | 32 | err := limit.SetValue(vehicleType, float64(*maxStops)) 33 | if err != nil { 34 | return nil, err 35 | } 36 | } 37 | 38 | if !present { 39 | return model, nil 40 | } 41 | 42 | // Create and then add constraint to model. 43 | maxConstraint, err := nextroute.NewMaximumStopsConstraint(limit) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | err = model.AddConstraint(maxConstraint) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return model, nil 54 | } 55 | -------------------------------------------------------------------------------- /factory/constraint_max_wait_vehicle.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "time" 7 | 8 | "github.com/nextmv-io/nextroute" 9 | "github.com/nextmv-io/nextroute/schema" 10 | ) 11 | 12 | // addMaximumWaitVehicleConstraint adds a MaximumWaitVehicleConstraint to the 13 | // model. 14 | func addMaximumWaitVehicleConstraint( 15 | input schema.Input, 16 | model nextroute.Model, 17 | _ Options, 18 | ) (nextroute.Model, error) { 19 | vehicleLimit := nextroute.NewVehicleTypeDurationExpression("vehicle-wait-max", model.MaxDuration()) 20 | 21 | present := false 22 | 23 | // Add all maximum cumulative wait times of the vehicles. 24 | for _, vehicleType := range model.VehicleTypes() { 25 | maxWait := input.Vehicles[vehicleType.Index()].MaxWait 26 | if maxWait == nil { 27 | continue 28 | } 29 | present = true 30 | 31 | vehicleLimit.SetDuration(vehicleType, time.Duration(*maxWait)*time.Second) 32 | } 33 | 34 | if !present { 35 | return model, nil 36 | } 37 | 38 | // Create and then add constraint to model. 39 | maxConstraint, err := nextroute.NewMaximumWaitVehicleConstraint(vehicleLimit) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | err = model.AddConstraint(maxConstraint) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return model, nil 50 | } 51 | -------------------------------------------------------------------------------- /factory/constraint_shift.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addVehicleEndTimeConstraint uses the latestEndConstraint of the model. It checks if 11 | // the vehicle's shift end happens before what is already set in the 12 | // latestEndConstraint (the constraint is created if it does not exist). If the 13 | // shift end time is at an earlier time than what is already set, then the 14 | // value is changed. 15 | func addVehicleEndTimeConstraint( 16 | input schema.Input, 17 | model nextroute.Model, 18 | _ Options, 19 | ) (nextroute.Model, error) { 20 | latestEndExpression, model, err := latestEndExpression(model) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | present := false 26 | for v, inputVehicle := range input.Vehicles { 27 | if inputVehicle.EndTime == nil { 28 | continue 29 | } 30 | 31 | vehicle := model.Vehicles()[v] 32 | if inputVehicle.EndTime.Before(latestEndExpression.Time(vehicle.Last())) { 33 | latestEndExpression.SetTime(vehicle.Last(), *inputVehicle.EndTime) 34 | } 35 | 36 | present = true 37 | } 38 | 39 | if !present { 40 | return model, nil 41 | } 42 | 43 | model, err = addLatestEndConstraint(model, latestEndExpression) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return model, nil 49 | } 50 | -------------------------------------------------------------------------------- /factory/doc.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | /* 4 | Package factory holds functionality for creating a ready-to-go nextroute model. 5 | 6 | You can build the model as: 7 | 8 | model, err := factory.NewModel(input, opts.ModelOptions) 9 | if err != nil { 10 | return err 11 | } 12 | */ 13 | package factory 14 | -------------------------------------------------------------------------------- /factory/group.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addGroupInformation adds information to the Model data, when stops 11 | // have to be grouped together on the same vehicle but not necessarily 12 | // in a particular order. 13 | func addGroupInformation( 14 | input schema.Input, 15 | model nextroute.Model, 16 | _ Options, 17 | ) (nextroute.Model, error) { 18 | if input.StopGroups == nil || len(*input.StopGroups) == 0 { 19 | return model, nil 20 | } 21 | 22 | groups := make([]group, len(*input.StopGroups)) 23 | 24 | for index, stopGroup := range *input.StopGroups { 25 | groups[index] = group{ 26 | stops: map[string]struct{}{}, 27 | } 28 | for _, stopID := range stopGroup { 29 | groups[index].stops[stopID] = struct{}{} 30 | } 31 | } 32 | 33 | data, err := getModelData(model) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | data.groups = groups 39 | 40 | model.SetData(data) 41 | 42 | return model, nil 43 | } 44 | -------------------------------------------------------------------------------- /factory/initialsolution.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/nextmv-io/nextroute" 9 | nmerror "github.com/nextmv-io/nextroute/common/errors" 10 | "github.com/nextmv-io/nextroute/schema" 11 | ) 12 | 13 | // addInitialSolution sets the initial solution. 14 | func addInitialSolution( 15 | input schema.Input, 16 | model nextroute.Model, 17 | _ Options, 18 | ) (nextroute.Model, error) { 19 | data, err := getModelData(model) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | inputStopIDToModelStopIndex := map[string]int{} 25 | 26 | for idx, inputStop := range input.Stops { 27 | inputStopIDToModelStopIndex[inputStop.ID] = idx 28 | } 29 | 30 | modelStops := model.Stops() 31 | 32 | for idx, inputVehicle := range input.Vehicles { 33 | if inputVehicle.InitialStops == nil { 34 | continue 35 | } 36 | modelVehicle := model.Vehicles()[idx] 37 | 38 | for _, initialStop := range *inputVehicle.InitialStops { 39 | var modelStop nextroute.ModelStop 40 | 41 | if _, defined := inputStopIDToModelStopIndex[initialStop.ID]; defined { 42 | modelStop = modelStops[inputStopIDToModelStopIndex[initialStop.ID]] 43 | } else { 44 | modelStop, err = model.Stop(data.stopIDToIndex[alternateStopID(initialStop.ID, inputVehicle)]) 45 | if err != nil { 46 | return nil, err 47 | } 48 | } 49 | 50 | if modelStop == nil { 51 | return nil, nmerror.NewInputDataError(fmt.Errorf("initial stop `%s` on vehicle `%s` not found, "+ 52 | "stop must be defined in stops or alternate stops to be used as an initial stop", 53 | initialStop.ID, 54 | modelVehicle.ID(), 55 | )) 56 | } 57 | fixed := initialStop.Fixed != nil && *initialStop.Fixed 58 | 59 | err := modelVehicle.AddStop(modelStop, fixed) 60 | 61 | if err != nil { 62 | return nil, err 63 | } 64 | } 65 | } 66 | 67 | return model, nil 68 | } 69 | -------------------------------------------------------------------------------- /factory/objective_activation.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addActivationPenaltyObjective adds the initialization cost (per vehicle) 11 | // objective to the Model. 12 | func addActivationPenaltyObjective( 13 | input schema.Input, 14 | model nextroute.Model, 15 | options Options, 16 | ) (nextroute.Model, error) { 17 | activationPenalty := nextroute.NewVehicleTypeValueExpression("activation_penalty", 0.0) 18 | present := false 19 | for v, vehicle := range input.Vehicles { 20 | if vehicle.ActivationPenalty == nil || *vehicle.ActivationPenalty == 0 { 21 | continue 22 | } 23 | err := activationPenalty.SetValue(model.VehicleTypes()[v], float64(*vehicle.ActivationPenalty)) 24 | if err != nil { 25 | return nil, err 26 | } 27 | present = true 28 | } 29 | 30 | if !present { 31 | return model, nil 32 | } 33 | 34 | _, err := model. 35 | Objective(). 36 | NewTerm(options.Objectives.VehicleActivationPenalty, nextroute.NewVehiclesObjective(activationPenalty)) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return model, nil 42 | } 43 | -------------------------------------------------------------------------------- /factory/objective_cluster.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addClusterObjective adds an objective which prefers clustered routes. 11 | func addClusterObjective( 12 | _ schema.Input, 13 | model nextroute.Model, 14 | options Options, 15 | ) (nextroute.Model, error) { 16 | cluster, err := nextroute.NewCluster() 17 | if err != nil { 18 | return model, err 19 | } 20 | if _, err = model.Objective().NewTerm(options.Objectives.Cluster, cluster); err != nil { 21 | return nil, err 22 | } 23 | return model, nil 24 | } 25 | -------------------------------------------------------------------------------- /factory/objective_min_stops.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addMinStopsObjective adds the min stops per vehicle objective to the 11 | // Model. 12 | func addMinStopsObjective( 13 | input schema.Input, 14 | model nextroute.Model, 15 | options Options, 16 | ) (nextroute.Model, error) { 17 | minStops := nextroute.NewVehicleTypeValueExpression("min_stops", 0) 18 | minStopsPenalty := nextroute.NewVehicleTypeValueExpression("min_stops_penalty", 0) 19 | present := false 20 | for v, vehicle := range input.Vehicles { 21 | if vehicle.MinStops == nil || *vehicle.MinStops == 0 { 22 | continue 23 | } 24 | if vehicle.MinStopsPenalty == nil || *vehicle.MinStopsPenalty == 0.0 { 25 | continue 26 | } 27 | err := minStops.SetValue(model.VehicleTypes()[v], float64(*vehicle.MinStops)) 28 | if err != nil { 29 | return nil, err 30 | } 31 | err = minStopsPenalty.SetValue(model.VehicleTypes()[v], *vehicle.MinStopsPenalty) 32 | if err != nil { 33 | return nil, err 34 | } 35 | present = true 36 | } 37 | 38 | if !present { 39 | return model, nil 40 | } 41 | 42 | _, err := model. 43 | Objective(). 44 | NewTerm( 45 | options.Objectives.MinStops, 46 | nextroute.NewMinStopsObjective(minStops, minStopsPenalty), 47 | ) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return model, nil 53 | } 54 | -------------------------------------------------------------------------------- /factory/objective_stop_balance.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addStopBalanceObjective adds the stop balance objective to the model. 11 | func addStopBalanceObjective( 12 | _ schema.Input, 13 | model nextroute.Model, 14 | options Options, 15 | ) (nextroute.Model, error) { 16 | balance := nextroute.NewStopBalanceObjective() 17 | if _, err := model.Objective().NewTerm(options.Objectives.StopBalance, balance); err != nil { 18 | return nil, err 19 | } 20 | return model, nil 21 | } 22 | -------------------------------------------------------------------------------- /factory/objective_travel_duration.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addTravelDurationObjective adds the minimization of travel duration to the Model. 11 | func addTravelDurationObjective( 12 | _ schema.Input, 13 | model nextroute.Model, 14 | options Options, 15 | ) (nextroute.Model, error) { 16 | o := nextroute.NewTravelDurationObjective() 17 | _, err := model.Objective().NewTerm(options.Objectives.TravelDuration, o) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return model, nil 23 | } 24 | -------------------------------------------------------------------------------- /factory/objective_vehicles_duration.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/schema" 8 | ) 9 | 10 | // addVehiclesDurationObjective adds the minimization of the sum of vehicles 11 | // duration to the model. 12 | func addVehiclesDurationObjective( 13 | _ schema.Input, 14 | model nextroute.Model, 15 | options Options, 16 | ) (nextroute.Model, error) { 17 | o := nextroute.NewVehiclesDurationObjective() 18 | _, err := model.Objective().NewTerm(options.Objectives.VehiclesDuration, o) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return model, nil 24 | } 25 | -------------------------------------------------------------------------------- /factory/stops.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package factory 4 | 5 | import ( 6 | "github.com/nextmv-io/nextroute" 7 | "github.com/nextmv-io/nextroute/common" 8 | "github.com/nextmv-io/nextroute/schema" 9 | ) 10 | 11 | // addStops adds the stops to the Model. 12 | func addStops( 13 | input schema.Input, 14 | model nextroute.Model, 15 | _ Options, 16 | ) (nextroute.Model, error) { 17 | data, err := getModelData(model) 18 | if err != nil { 19 | return nil, err 20 | } 21 | for _, inputStop := range input.Stops { 22 | location, err := common.NewLocation( 23 | inputStop.Location.Lon, 24 | inputStop.Location.Lat, 25 | ) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | stop, err := model.NewStop(location) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | stop.SetID(inputStop.ID) 36 | stop.SetData(inputStop) 37 | data.stopIDToIndex[inputStop.ID] = stop.Index() 38 | } 39 | model.SetData(data) 40 | 41 | return model, nil 42 | } 43 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nextmv-io/nextroute 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/nextmv-io/sdk v1.8.3-0.20241219091227-002f36a342d6 7 | gonum.org/v1/gonum v0.14.0 8 | ) 9 | 10 | require ( 11 | github.com/danielgtaylor/huma v1.14.1 // indirect 12 | github.com/google/uuid v1.3.0 // indirect 13 | github.com/gorilla/schema v1.4.1 // indirect 14 | github.com/iancoleman/strcase v0.2.0 // indirect 15 | github.com/itzg/go-flagsfiller v1.9.1 // indirect 16 | github.com/sergi/go-diff v1.3.1 // indirect 17 | github.com/stretchr/testify v1.8.2 // indirect 18 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 19 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 20 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 21 | golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /model_checkedat.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // CheckedAt is the type indicating when to check a constraint when 6 | // a move it's consequences are being propagated. 7 | type CheckedAt int64 8 | 9 | const ( 10 | // AtEachStop indicates that the constraint should be checked at each stop. 11 | // A constraint that is registered to be checked at each stop will be 12 | // checked as soon as all values for the expressions a stop are known. The 13 | // stops later in a vehicle will not been updated yet. 14 | AtEachStop CheckedAt = 0 15 | // AtEachVehicle indicates that the constraint should be checked at each 16 | // vehicle. A constraint that is registered to be checked at each vehicle 17 | // will be checked as soon as all values for the expressions of the stops 18 | // of a vehicle are known. The stops of other vehicles will not been 19 | // updated yet. 20 | AtEachVehicle = 1 21 | // AtEachSolution indicates that the constraint should be checked at each 22 | // solution. A constraint that is registered to be checked at each solution 23 | // will be checked as soon as all values for the expressions of the stops 24 | // of all vehicles are known, which is by definition all the stops. 25 | AtEachSolution = 2 26 | // Never indicates that the constraint should never be checked. A constraint 27 | // that is registered to be checked never relies completely on its estimate 28 | // of allowed moves to be correct. Also, not checking a constraint can 29 | // result in solutions that are not valid when un-planning stops. 30 | Never = 3 31 | ) 32 | 33 | // CheckViolations is a list of all possible values for CheckedAt. 34 | var CheckViolations = []CheckedAt{ 35 | AtEachStop, 36 | AtEachVehicle, 37 | AtEachSolution, 38 | Never, 39 | } 40 | 41 | // String returns a string representation of the CheckedAt value. 42 | func (checkViolation CheckedAt) String() string { 43 | switch checkViolation { 44 | case AtEachStop: 45 | return "Each Stop" 46 | case AtEachVehicle: 47 | return "Each ModelVehicle" 48 | case AtEachSolution: 49 | return "Each Solution" 50 | case Never: 51 | return "Never" 52 | default: 53 | panic("unknown check violation") 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /model_complexity.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // Cost is type to indicate the cost of a function. 6 | type Cost uint64 7 | 8 | const ( 9 | // Constant is a constant cost function. 10 | Constant Cost = 0 11 | // LinearVehicle is a linear cost function with respect to the number of 12 | // vehicles. 13 | LinearVehicle = 1 14 | // LinearStop is a linear cost function with respect to the number of stops. 15 | LinearStop = 2 16 | // QuadraticVehicle is a quadratic cost function with respect to the number 17 | // of vehicles. 18 | QuadraticVehicle = 3 19 | // QuadraticStop is a quadratic cost function with respect to the number of 20 | // stops. 21 | QuadraticStop = 4 22 | // ExponentialVehicle is an exponential cost function with respect to the 23 | // number of vehicles. 24 | ExponentialVehicle = 5 25 | // ExponentialStop is an exponential cost function with respect to the 26 | // number of stops. 27 | ExponentialStop = 6 28 | // CrazyExpensive is a function that is so expensive that it should never 29 | // be used. 30 | CrazyExpensive = 7 31 | ) 32 | 33 | var costNames = map[Cost]string{ 34 | Constant: `O(1)`, 35 | LinearVehicle: `O(ModelVehicle)`, 36 | LinearStop: `O(Stop)`, 37 | QuadraticVehicle: `O(ModelVehicle^2)`, 38 | QuadraticStop: `O(Stop^2)`, 39 | ExponentialVehicle: `O(2^ModelVehicle`, 40 | ExponentialStop: `O(2^Stop)`, 41 | CrazyExpensive: `O(no)`, 42 | } 43 | 44 | // String returns the name of the cost. 45 | func (cost Cost) String() string { 46 | return costNames[cost] 47 | } 48 | 49 | // Complexity is the interface for constraints that have a complexity. 50 | type Complexity interface { 51 | // EstimationCost returns the cost of the Estimation function. 52 | EstimationCost() Cost 53 | } 54 | -------------------------------------------------------------------------------- /model_data.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // ModelData is a data interface available on several model constructs. It 6 | // allows to attach arbitrary data to a model construct. 7 | type ModelData interface { 8 | // Data returns the data. 9 | Data() any 10 | // SetData sets the data. 11 | SetData(any) 12 | } 13 | 14 | type modelDataImpl struct { 15 | data any 16 | } 17 | 18 | func newModelDataImpl() modelDataImpl { 19 | return modelDataImpl{ 20 | data: nil, 21 | } 22 | } 23 | 24 | func (d *modelDataImpl) Data() any { 25 | return d.data 26 | } 27 | 28 | func (d *modelDataImpl) SetData(data any) { 29 | d.data = data 30 | } 31 | -------------------------------------------------------------------------------- /model_expression.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import "sync/atomic" 6 | 7 | var expressionIndex uint32 8 | 9 | // NewModelExpressionIndex returns the next unique expression index. 10 | func NewModelExpressionIndex() int { 11 | return int(atomic.AddUint32(&expressionIndex, 1) - 1) 12 | } 13 | 14 | // ModelExpression is an expression that can be used in a model to define 15 | // values for constraints and objectives. The expression is evaluated for 16 | // each stop in the solution by invoking the Value() method. The value of 17 | // the expression is then used in the constraints and objective. 18 | type ModelExpression interface { 19 | // Index returns the unique index of the expression. 20 | Index() int 21 | 22 | // Name returns the name of the expression. 23 | Name() string 24 | 25 | // Value returns the value of the expression for the given vehicle type, 26 | // from stop and to stop. 27 | Value(ModelVehicleType, ModelStop, ModelStop) float64 28 | 29 | // HasNegativeValues returns true if the expression contains negative 30 | // values. 31 | HasNegativeValues() bool 32 | // HasPositiveValues returns true if the expression contains positive 33 | // values. 34 | HasPositiveValues() bool 35 | 36 | // SetName sets the name of the expression. 37 | SetName(string) 38 | } 39 | 40 | // ModelExpressions is a slice of ModelExpression. 41 | type ModelExpressions []ModelExpression 42 | -------------------------------------------------------------------------------- /model_expression_haversine.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/nextmv-io/nextroute/common" 9 | ) 10 | 11 | // NewHaversineExpression returns a new HaversineExpression. 12 | func NewHaversineExpression() DistanceExpression { 13 | return &haversineExpression{ 14 | index: NewModelExpressionIndex(), 15 | name: "haversine", 16 | } 17 | } 18 | 19 | type haversineExpression struct { 20 | name string 21 | index int 22 | } 23 | 24 | func (h *haversineExpression) HasNegativeValues() bool { 25 | return false 26 | } 27 | 28 | func (h *haversineExpression) HasPositiveValues() bool { 29 | return true 30 | } 31 | 32 | func (h *haversineExpression) String() string { 33 | return fmt.Sprintf("haversine[%v]", 34 | h.index, 35 | ) 36 | } 37 | 38 | func (h *haversineExpression) Distance( 39 | vehicleType ModelVehicleType, 40 | from, to ModelStop, 41 | ) common.Distance { 42 | return common.NewDistance(h.Value(vehicleType, from, to), common.Meters) 43 | } 44 | 45 | func (h *haversineExpression) Index() int { 46 | return h.index 47 | } 48 | 49 | func (h *haversineExpression) Name() string { 50 | return h.name 51 | } 52 | 53 | func (h *haversineExpression) SetName(n string) { 54 | h.name = n 55 | } 56 | 57 | func (h *haversineExpression) Value( 58 | vehicle ModelVehicleType, 59 | from ModelStop, 60 | to ModelStop, 61 | ) float64 { 62 | return haversineDistance( 63 | from.Location(), 64 | to.Location(), 65 | ).Value(vehicle.Model().DistanceUnit()) 66 | } 67 | -------------------------------------------------------------------------------- /model_expression_measure_byindex.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/nextmv-io/sdk/measure" 9 | ) 10 | 11 | // NewMeasureByIndexExpression returns a new MeasureByIndexExpression. 12 | // A MeasureByIndexExpression is a ModelExpression that uses a measure.ByIndex to 13 | // calculate the cost between two stops. 14 | func NewMeasureByIndexExpression(measure measure.ByIndex) ModelExpression { 15 | return &measureByIndexExpression{ 16 | index: NewModelExpressionIndex(), 17 | measure: measure, 18 | name: "measure_by_index", 19 | } 20 | } 21 | 22 | type measureByIndexExpression struct { 23 | measure measure.ByIndex 24 | name string 25 | index int 26 | } 27 | 28 | func (m *measureByIndexExpression) HasNegativeValues() bool { 29 | return false 30 | } 31 | 32 | func (m *measureByIndexExpression) HasPositiveValues() bool { 33 | return true 34 | } 35 | 36 | func (m *measureByIndexExpression) String() string { 37 | return fmt.Sprintf("measure_by_index[%v]", 38 | m.index, 39 | ) 40 | } 41 | 42 | func (m *measureByIndexExpression) Index() int { 43 | return m.index 44 | } 45 | 46 | func (m *measureByIndexExpression) Name() string { 47 | return m.name 48 | } 49 | 50 | func (m *measureByIndexExpression) SetName(n string) { 51 | m.name = n 52 | } 53 | 54 | func (m *measureByIndexExpression) Value(_ ModelVehicleType, from, to ModelStop) float64 { 55 | return m.measure.Cost( 56 | from.(*stopImpl).measureIndex, 57 | to.(*stopImpl).measureIndex, 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /model_expression_measure_bypoint.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/nextmv-io/sdk/measure" 9 | ) 10 | 11 | // NewMeasureByPointExpression returns a new MeasureByPointExpression. 12 | // A MeasureByPointExpression is a ModelExpression that uses a measure.ByPoint to 13 | // calculate the cost between two stops. 14 | func NewMeasureByPointExpression(measure measure.ByPoint) ModelExpression { 15 | return &measureByPointExpression{ 16 | index: NewModelExpressionIndex(), 17 | measure: measure, 18 | name: "measure_by_point", 19 | } 20 | } 21 | 22 | type measureByPointExpression struct { 23 | measure measure.ByPoint 24 | name string 25 | index int 26 | } 27 | 28 | func (m *measureByPointExpression) HasNegativeValues() bool { 29 | return false 30 | } 31 | 32 | func (m *measureByPointExpression) HasPositiveValues() bool { 33 | return true 34 | } 35 | 36 | func (m *measureByPointExpression) String() string { 37 | return fmt.Sprintf("measure_by_point[%v]", 38 | m.index, 39 | ) 40 | } 41 | 42 | func (m *measureByPointExpression) Index() int { 43 | return m.index 44 | } 45 | 46 | func (m *measureByPointExpression) Name() string { 47 | return m.name 48 | } 49 | 50 | func (m *measureByPointExpression) SetName(n string) { 51 | m.name = n 52 | } 53 | 54 | func (m *measureByPointExpression) Value(_ ModelVehicleType, from, to ModelStop) float64 { 55 | locFrom, locTo := from.Location(), to.Location() 56 | value := m.measure.Cost( 57 | measure.Point{locFrom.Longitude(), locFrom.Latitude()}, 58 | measure.Point{locTo.Longitude(), locTo.Latitude()}, 59 | ) 60 | return value 61 | } 62 | -------------------------------------------------------------------------------- /model_expression_unary.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import ( 6 | "fmt" 7 | ) 8 | 9 | // TermExpression is an expression that returns the product of the given factor 10 | // and the value of the given expression. 11 | type TermExpression interface { 12 | ModelExpression 13 | 14 | // Expression returns the expression. 15 | Expression() ModelExpression 16 | 17 | // Factor returns the factor. 18 | Factor() float64 19 | } 20 | 21 | // NewTermExpression returns a new TermExpression. 22 | func NewTermExpression( 23 | factor float64, 24 | expression ModelExpression, 25 | ) TermExpression { 26 | return &termExpression{ 27 | index: NewModelExpressionIndex(), 28 | expression: expression, 29 | factor: factor, 30 | name: fmt.Sprintf("%f * %s", factor, expression), 31 | } 32 | } 33 | 34 | type termExpression struct { 35 | expression ModelExpression 36 | name string 37 | index int 38 | factor float64 39 | } 40 | 41 | func (t *termExpression) HasNegativeValues() bool { 42 | if t.factor < 0 { 43 | return t.expression.HasPositiveValues() 44 | } 45 | return t.expression.HasNegativeValues() 46 | } 47 | 48 | func (t *termExpression) HasPositiveValues() bool { 49 | if t.factor < 0 { 50 | return t.expression.HasNegativeValues() 51 | } 52 | return t.expression.HasPositiveValues() 53 | } 54 | 55 | func (t *termExpression) String() string { 56 | return fmt.Sprintf("Term[%v] %v * %v", 57 | t.index, 58 | t.factor, 59 | t.expression, 60 | ) 61 | } 62 | 63 | func (t *termExpression) Index() int { 64 | return t.index 65 | } 66 | 67 | func (t *termExpression) Name() string { 68 | return t.name 69 | } 70 | 71 | func (t *termExpression) SetName(n string) { 72 | t.name = n 73 | } 74 | 75 | func (t *termExpression) Factor() float64 { 76 | return t.factor 77 | } 78 | 79 | func (t *termExpression) Expression() ModelExpression { 80 | return t.expression 81 | } 82 | 83 | func (t *termExpression) Value( 84 | vehicle ModelVehicleType, 85 | from, to ModelStop, 86 | ) float64 { 87 | return t.factor * t.expression.Value(vehicle, from, to) 88 | } 89 | -------------------------------------------------------------------------------- /model_haversine.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import "github.com/nextmv-io/nextroute/common" 6 | 7 | // haversineDistance processes the locations to make sure that they are valid 8 | // to return the corresponding distance. 9 | func haversineDistance(from, to common.Location) common.Distance { 10 | // this check is redudant here, as it's already done in the 11 | // in the Haversine function. 12 | // However we have to check it here to return a 0 distance without a heap allocation. 13 | // If common.Haversine returns an error then this will cause a heap allocation. 14 | // TODO: room for optimization here. 15 | if !from.IsValid() || !to.IsValid() { 16 | return common.NewDistance(0., common.Meters) 17 | } 18 | v, err := common.Haversine(from, to) 19 | if err != nil { 20 | panic(err) 21 | } 22 | return v 23 | } 24 | -------------------------------------------------------------------------------- /model_identifier.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // Identifier is an interface that can be used for identifying objects. 6 | type Identifier interface { 7 | // ID returns the identifier of the object. 8 | ID() string 9 | // SetID sets the identifier of the object. 10 | SetID(string) 11 | } 12 | -------------------------------------------------------------------------------- /model_objective_earliness_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute_test 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/nextmv-io/nextroute" 9 | ) 10 | 11 | // Simply test that we can add a new earliness objective to the model. 12 | func TestAddEarlinessObjective(t *testing.T) { 13 | model, err := createModel( 14 | input( 15 | vehicleTypes("truck"), 16 | []Vehicle{ 17 | vehicles( 18 | "truck", 19 | depot(), 20 | 1, 21 | )[0], 22 | }, 23 | planSingleStops(), 24 | planPairSequences(), 25 | ), 26 | ) 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | 31 | if len(model.Objective().Terms()) != 0 { 32 | t.Error("model objective should be empty") 33 | } 34 | 35 | targetTimeExpression := nextroute.NewStopTimeExpression("target_time", model.MaxTime()) 36 | factorExpression := nextroute.NewStopExpression( 37 | "earliness_penalty_factor", 38 | 1.0, 39 | ) 40 | 41 | earlinessObjective, err := nextroute.NewEarlinessObjective( 42 | targetTimeExpression, 43 | factorExpression, 44 | nextroute.OnArrival, 45 | ) 46 | if err != nil { 47 | t.Error(err) 48 | } 49 | _, err = model.Objective().NewTerm(1.0, earlinessObjective) 50 | if err != nil { 51 | t.Error(err) 52 | } 53 | 54 | if len(model.Objective().Terms()) != 1 { 55 | t.Error("model objective should have an objective") 56 | } 57 | } 58 | 59 | // This test simulates a move on a solution and checks that the objective is 60 | // being measured correctly based on a constant expression. 61 | func TestEarlinessObjective_EstimateDeltaValue(_ *testing.T) { 62 | // TODO: write test here 63 | } 64 | -------------------------------------------------------------------------------- /model_objective_stop_balancing.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // NewStopBalanceObjective returns a new StopBalanceObjective. 6 | func NewStopBalanceObjective() ModelObjective { 7 | return &balanceObjectiveImpl{} 8 | } 9 | 10 | type balanceObjectiveImpl struct { 11 | } 12 | 13 | func (t *balanceObjectiveImpl) EstimateDeltaValue( 14 | move Move, 15 | ) float64 { 16 | solution := move.Solution() 17 | oldMax, newMax := t.maxStops(solution, move) 18 | return float64(newMax - oldMax) 19 | } 20 | 21 | func (t *balanceObjectiveImpl) Value(solution Solution) float64 { 22 | maxBefore, _ := t.maxStops(solution, nil) 23 | return float64(maxBefore) 24 | } 25 | 26 | func (t *balanceObjectiveImpl) maxStops(solution Solution, move SolutionMoveStops) (int, int) { 27 | maximum := 0 28 | maximumBefore := 0 29 | moveExists := move != nil 30 | var vehicle SolutionVehicle 31 | if moveExists { 32 | vehicle = move.Vehicle() 33 | } 34 | 35 | for _, v := range solution.(*solutionImpl).vehicles { 36 | numberOfStops := v.NumberOfStops() 37 | if maximum < numberOfStops { 38 | maximum = numberOfStops 39 | } 40 | if maximumBefore < numberOfStops { 41 | maximumBefore = numberOfStops 42 | } 43 | if moveExists && v.Index() == vehicle.Index() { 44 | length := move.StopPositionsLength() 45 | if maximum < numberOfStops+length { 46 | maximum = numberOfStops + length 47 | } 48 | } 49 | } 50 | return maximumBefore, maximum 51 | } 52 | 53 | func (t *balanceObjectiveImpl) String() string { 54 | return "stop_balance" 55 | } 56 | -------------------------------------------------------------------------------- /model_objective_stop_balancing_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute_test 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/nextmv-io/nextroute" 9 | ) 10 | 11 | func TestBalanceObjective_EstimateDeltaValue(_ *testing.T) { 12 | // TODO implement 13 | } 14 | 15 | func TestBalanceObjective(t *testing.T) { 16 | model, err := createModel( 17 | input( 18 | vehicleTypes("truck"), 19 | []Vehicle{ 20 | vehicles( 21 | "truck", 22 | depot(), 23 | 1, 24 | )[0], 25 | }, 26 | planSingleStops(), 27 | planPairSequences(), 28 | ), 29 | ) 30 | if err != nil { 31 | t.Error(err) 32 | } 33 | 34 | balanceObjective := nextroute.NewStopBalanceObjective() 35 | 36 | if len(model.Objective().Terms()) != 0 { 37 | t.Error("model objective should be empty") 38 | } 39 | 40 | _, err = model.Objective().NewTerm(1.0, balanceObjective) 41 | if err != nil { 42 | t.Error(err) 43 | } 44 | 45 | if len(model.Objective().Terms()) != 1 { 46 | t.Error("model objective should have an objective") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /model_objective_term.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import ( 6 | "fmt" 7 | ) 8 | 9 | func newModelObjectiveTerm( 10 | factor float64, 11 | objective ModelObjective, 12 | ) ModelObjectiveTerm { 13 | return modelObjectiveTermImpl{ 14 | factor: factor, 15 | objective: objective, 16 | } 17 | } 18 | 19 | type modelObjectiveTermImpl struct { 20 | objective ModelObjective 21 | factor float64 22 | } 23 | 24 | func (m modelObjectiveTermImpl) Factor() float64 { 25 | return m.factor 26 | } 27 | 28 | func (m modelObjectiveTermImpl) Objective() ModelObjective { 29 | return m.objective 30 | } 31 | 32 | func (m modelObjectiveTermImpl) String() string { 33 | return fmt.Sprintf("%v * %v", m.factor, m.objective) 34 | } 35 | -------------------------------------------------------------------------------- /model_objective_travelduration.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // TravelDurationObjective is an objective that uses the travel duration as an 6 | // objective. 7 | type TravelDurationObjective interface { 8 | ModelObjective 9 | } 10 | 11 | // NewTravelDurationObjective returns a new TravelDurationObjective. 12 | func NewTravelDurationObjective() TravelDurationObjective { 13 | return &travelDurationObjectiveImpl{} 14 | } 15 | 16 | type travelDurationObjectiveImpl struct{} 17 | 18 | func (t *travelDurationObjectiveImpl) ModelExpressions() ModelExpressions { 19 | return ModelExpressions{} 20 | } 21 | 22 | func (t *travelDurationObjectiveImpl) EstimateDeltaValue(move SolutionMoveStops) float64 { 23 | return move.(*solutionMoveStopsImpl).deltaTravelDurationValue() 24 | } 25 | 26 | func (t *travelDurationObjectiveImpl) Value(solution Solution) float64 { 27 | solutionImp := solution.(*solutionImpl) 28 | 29 | score := 0.0 30 | for _, vehicle := range solutionImp.vehicles { 31 | score += vehicle.Last().CumulativeTravelDurationValue() 32 | } 33 | return score 34 | } 35 | 36 | func (t *travelDurationObjectiveImpl) String() string { 37 | return "travel_duration" 38 | } 39 | -------------------------------------------------------------------------------- /model_objective_travelduration_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute_test 4 | 5 | import ( 6 | "testing" 7 | 8 | "github.com/nextmv-io/nextroute" 9 | ) 10 | 11 | func TestTravelDurationObjective_EstimateDeltaValue(_ *testing.T) { 12 | // TODO implement 13 | } 14 | 15 | func TestTravelDurationObjective(t *testing.T) { 16 | model, err := createModel( 17 | input( 18 | vehicleTypes("truck"), 19 | []Vehicle{ 20 | vehicles( 21 | "truck", 22 | depot(), 23 | 1, 24 | )[0], 25 | }, 26 | planSingleStops(), 27 | planPairSequences(), 28 | ), 29 | ) 30 | if err != nil { 31 | t.Error(err) 32 | } 33 | 34 | travelDurationObjective := nextroute.NewTravelDurationObjective() 35 | 36 | if len(model.Objective().Terms()) != 0 { 37 | t.Error("model objective should be empty") 38 | } 39 | 40 | _, err = model.Objective().NewTerm(1.0, travelDurationObjective) 41 | if err != nil { 42 | t.Error(err) 43 | } 44 | 45 | if len(model.Objective().Terms()) != 1 { 46 | t.Error("model objective should have an objective") 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /model_objective_vehicles.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // VehiclesObjective is an objective that uses the number of vehicles as an 6 | // objective. Each vehicle that is not empty is scored by the given expression. 7 | // A vehicle is empty if it has no stops assigned to it (except for the first 8 | // and last visit). 9 | type VehiclesObjective interface { 10 | ModelObjective 11 | // ActivationPenalty returns the activation penalty expression. 12 | ActivationPenalty() VehicleTypeExpression 13 | } 14 | 15 | // NewVehiclesObjective returns a new VehiclesObjective. 16 | func NewVehiclesObjective( 17 | expression VehicleTypeExpression, 18 | ) VehiclesObjective { 19 | return &vehiclesObjectiveImpl{ 20 | expression: expression, 21 | } 22 | } 23 | 24 | type vehiclesObjectiveImpl struct { 25 | expression VehicleTypeExpression 26 | } 27 | 28 | func (t *vehiclesObjectiveImpl) ModelExpressions() ModelExpressions { 29 | return ModelExpressions{} 30 | } 31 | 32 | func (t *vehiclesObjectiveImpl) EstimateDeltaValue(move SolutionMoveStops) float64 { 33 | vehicle := move.(*solutionMoveStopsImpl).vehicle() 34 | 35 | if vehicle.NumberOfStops() == 0 { 36 | return t.expression.Value( 37 | vehicle.ModelVehicle().VehicleType(), 38 | nil, 39 | nil, 40 | ) 41 | } 42 | 43 | return 0.0 44 | } 45 | 46 | func (t *vehiclesObjectiveImpl) Value(solution Solution) float64 { 47 | vehicleCost := 0.0 48 | for _, vehicle := range solution.(*solutionImpl).vehiclesMutable() { 49 | if vehicle.NumberOfStops() > 0 { 50 | vehicleCost += t.expression.Value( 51 | vehicle.ModelVehicle().VehicleType(), 52 | nil, 53 | nil, 54 | ) 55 | } 56 | } 57 | return vehicleCost 58 | } 59 | 60 | func (t *vehiclesObjectiveImpl) String() string { 61 | return "vehicle_activation_penalty" 62 | } 63 | 64 | func (t *vehiclesObjectiveImpl) ActivationPenalty() VehicleTypeExpression { 65 | return t.expression 66 | } 67 | -------------------------------------------------------------------------------- /model_plan_unit.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // ModelPlanUnit is a plan unit. It is a unit defining what should be planned . 6 | // For example, a unit can be a pickup and a delivery stop that are required to 7 | // be planned together on the same vehicle. 8 | type ModelPlanUnit interface { 9 | ModelData 10 | 11 | // Index returns the index of the invoking unit. 12 | Index() int 13 | 14 | // IsFixed returns true if the PlanUnit is fixed. 15 | IsFixed() bool 16 | 17 | // PlanUnitsUnit returns the [ModelPlanUnitsUnit] associated with the unit 18 | // with a bool indicating if it actually has one. A plan unit is associated 19 | // with at most one plan units unit. Can be nil if the unit is not part of a 20 | // plan units unit in which case the second return argument will be false. 21 | PlanUnitsUnit() (ModelPlanUnitsUnit, bool) 22 | } 23 | 24 | // ModelPlanUnits is a slice of plan units . 25 | type ModelPlanUnits []ModelPlanUnit 26 | -------------------------------------------------------------------------------- /model_stop_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute_test 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | "time" 9 | 10 | "github.com/nextmv-io/nextroute" 11 | "github.com/nextmv-io/nextroute/common" 12 | ) 13 | 14 | func TestModelStop_ToEarliestStart(t *testing.T) { 15 | model, err := nextroute.NewModel() 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | 20 | s1, err := model.NewStop(common.NewInvalidLocation()) 21 | if err != nil { 22 | t.Fatal(err) 23 | } 24 | 25 | windows := [][2]float64{ 26 | {60, 120}, 27 | {240, 360}, 28 | {360, 420}, 29 | {480, 540}, 30 | } 31 | windowsAsTime := make([][2]time.Time, len(windows)) 32 | for i, w := range windows { 33 | windowsAsTime[i] = [2]time.Time{ 34 | model.Epoch().Add(time.Duration(w[0]) * model.DurationUnit()), 35 | model.Epoch().Add(time.Duration(w[1]) * model.DurationUnit()), 36 | } 37 | } 38 | err = s1.SetWindows(windowsAsTime) 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | tests := []struct { 44 | t float64 45 | want float64 46 | }{ 47 | {t: 0, want: 60}, 48 | {t: 120, want: 240}, 49 | {t: 130, want: 240}, 50 | {t: 240, want: 240}, 51 | {t: 300, want: 300}, 52 | {t: 480, want: 480}, 53 | {t: 539, want: 539}, 54 | {t: 540, want: 540}, 55 | } 56 | for _, tt := range tests { 57 | t.Run(fmt.Sprintf("%v -> %v", tt.t, tt.want), func(_ *testing.T) { 58 | earliest := s1.ToEarliestStartValue(tt.t) 59 | if earliest != tt.want { 60 | t.Errorf("got %v, want %v, at %v", earliest, tt.want, tt.t) 61 | } 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /model_vehicle_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute_test 4 | 5 | import ( 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/nextmv-io/nextroute" 10 | ) 11 | 12 | func TestModelVehicleImpl_AddStop(t *testing.T) { 13 | model, err := createModel( 14 | input( 15 | vehicleTypes("truck"), 16 | vehicles( 17 | "truck", 18 | depot(), 19 | 2, 20 | ), 21 | planSingleStops(), 22 | planPairSequences(), 23 | ), 24 | ) 25 | if err != nil { 26 | t.Fatal(err) 27 | } 28 | v1 := model.Vehicles()[0] 29 | 30 | count := 0 31 | for idx, planUnit := range model.PlanStopsUnits() { 32 | for _, stop := range planUnit.Stops() { 33 | if err := v1.AddStop( 34 | stop, 35 | idx < 3, 36 | ); err != nil { 37 | t.Fatal(err) 38 | } 39 | count++ 40 | if len(v1.Stops()) != count { 41 | t.Fatalf("expected %v stops, got %v", count, len(v1.Stops())) 42 | } 43 | } 44 | } 45 | 46 | solution, err := nextroute.NewSolution(model) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | sv1 := solution.SolutionVehicle(v1) 52 | 53 | for _, stop := range sv1.SolutionStops() { 54 | fmt.Println(stop) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /nextroute.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".", 5 | "name": "nextroute" 6 | }, 7 | ], 8 | "settings": { 9 | "go.lintTool": "golangci-lint", 10 | "go.lintFlags": ["--fast"] 11 | }, 12 | "launch": { 13 | "version": "0.2.0", 14 | "configurations": [ 15 | { 16 | "name": "nextroute", 17 | "type": "go", 18 | "request": "launch", 19 | "mode": "auto", 20 | "program": "${workspaceFolder}/cmd", 21 | "args": [ 22 | "-runner.input.path", 23 | "input.json", 24 | "-solve.duration", 25 | "5s", 26 | "-runner.output.path", 27 | "output.json" 28 | ] 29 | } 30 | ], 31 | "compounds": [] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /observers/doc.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | /* 4 | Package observers holds functionality for creating observers of what is 5 | going on in 6 | */ 7 | package observers 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | build>=1.0.3 2 | pydantic>=2.5.2 3 | ruff>=0.1.7 4 | twine>=4.0.2 5 | hatch>=1.13.0 6 | nextmv>=0.14.1 7 | -------------------------------------------------------------------------------- /schema/custom_data.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // Package schema provides the input and output schema for 4 | package schema 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | ) 10 | 11 | // ConvertCustomData converts the custom data into the given type. If the 12 | // conversion fails, an error is returned. 13 | func ConvertCustomData[T any](data any) (T, error) { 14 | // Marshal the data again in order to unmarshal it into the correct type. 15 | var b []byte 16 | var err error 17 | if rawCustomData, ok := data.(map[string]any); ok { 18 | // Typically, the custom data is a map. 19 | b, err = json.Marshal(rawCustomData) 20 | if err != nil { 21 | return *new(T), err 22 | } 23 | } else if rawCustomData, ok := data.([]any); ok { 24 | // Try slice, if not map. 25 | b, err = json.Marshal(rawCustomData) 26 | if err != nil { 27 | return *new(T), err 28 | } 29 | } else { 30 | return *new(T), errors.New("CustomData is not a map or slice") 31 | } 32 | 33 | // Unmarshal the custom data into the given custom type. 34 | value := new(T) 35 | if err := json.Unmarshal(b, value); err != nil { 36 | return *new(T), err 37 | } 38 | return *value, nil 39 | } 40 | -------------------------------------------------------------------------------- /schema/custom_data_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // Package schema provides the input and output schema for nextroute. 4 | package schema_test 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/nextmv-io/nextroute/schema" 10 | ) 11 | 12 | type customMap struct { 13 | Foo string `json:"foo"` 14 | } 15 | 16 | type customSlice []string 17 | 18 | func TestConvertCustomData(t *testing.T) { 19 | data := map[string]any{ 20 | "custom_objective": map[string]any{ 21 | "foo": "bar", 22 | }, 23 | "custom_constraint": []any{ 24 | "foo", 25 | "bar", 26 | }, 27 | } 28 | 29 | customObjective, err := schema.ConvertCustomData[customMap](data["custom_objective"]) 30 | if err != nil { 31 | t.Error(err) 32 | } 33 | if customObjective.Foo != "bar" { 34 | t.Errorf("expected %s, got %s", data["foo"], customObjective.Foo) 35 | } 36 | 37 | customConstraint, err := schema.ConvertCustomData[customSlice](data["custom_constraint"]) 38 | if err != nil { 39 | t.Error(err) 40 | } 41 | if len(customConstraint) != 2 { 42 | t.Errorf("expected %d, got %d", 2, len(customConstraint)) 43 | } 44 | for i, v := range customConstraint { 45 | if v != data["custom_constraint"].([]any)[i] { 46 | t.Errorf("expected %s, got %s", data["custom_constraint"].([]any)[i], v) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /solution_construcation_sweep_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute_test 4 | 5 | import ( 6 | "context" 7 | "testing" 8 | 9 | "github.com/nextmv-io/nextroute" 10 | "github.com/nextmv-io/nextroute/common" 11 | ) 12 | 13 | func TestSweepOneDepot(t *testing.T) { 14 | input := singleVehiclePlanSingleStopsModel() 15 | input.Vehicles = append(input.Vehicles, vehicles("truck", depot(), 1)...) 16 | model, err := createModel(input) 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | solution, err := nextroute.NewSweepSolution(context.Background(), model) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | if len(solution.Vehicles()) != 2 { 27 | t.Errorf("expected 2 vehicles, got %v", len(solution.Vehicles())) 28 | } 29 | } 30 | 31 | func TestSweepTwoDepots(t *testing.T) { 32 | input := singleVehiclePlanSingleStopsModel() 33 | 34 | location := Location{ 35 | Lat: 0, 36 | Lon: 0, 37 | } 38 | input.Vehicles = append(input.Vehicles, vehicles("truck", location, 1)...) 39 | model, err := createModel(input) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | _, err = nextroute.NewSweepSolution(context.Background(), model) 45 | if err != nil { 46 | if err.Error() != "sweep construction, not implemented for multiple start-end locations of input" { 47 | t.Fatal(err) 48 | } 49 | } 50 | } 51 | 52 | func TestSweepStartAndEndDifferent(t *testing.T) { 53 | input := singleVehiclePlanSingleStopsModel() 54 | 55 | location := Location{ 56 | Lat: common.NewInvalidLocation().Latitude(), 57 | Lon: common.NewInvalidLocation().Longitude(), 58 | IsValid: false, 59 | } 60 | input.Vehicles[0].StartLocation = location 61 | input.Vehicles[0].EndLocation = depot() 62 | model, err := createModel(input) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | 67 | _, err = nextroute.NewSweepSolution(context.Background(), model) 68 | if err != nil { 69 | t.Fatal(err) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /solution_plan_unit_collection_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute_test 4 | 5 | import ( 6 | "slices" 7 | "testing" 8 | 9 | "github.com/nextmv-io/nextroute" 10 | ) 11 | 12 | func TestSolutionPlanUnitCollection(t *testing.T) { 13 | model, err := createModel(singleVehiclePlanSingleStopsModel()) 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | solution, err := nextroute.NewSolution(model) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | sourcePlanUnits := slices.Clone(solution.UnPlannedPlanUnits().SolutionPlanUnits()) 23 | 24 | unplannedPlanUnitCollection := nextroute.NewSolutionPlanUnitCollection( 25 | solution.Random(), 26 | sourcePlanUnits, 27 | ) 28 | 29 | if unplannedPlanUnitCollection.Size() != 3 { 30 | t.Error("unplannedPlanUnitCollection.Size() should be 3") 31 | } 32 | 33 | if len(unplannedPlanUnitCollection.SolutionPlanUnits()) != 3 { 34 | t.Error("len(unplannedPlanUnitCollection.SolutionPlanUnits()) should be 3") 35 | } 36 | 37 | sourcePlanUnits[2] = nil 38 | 39 | if unplannedPlanUnitCollection.Size() != 3 { 40 | t.Error("unplannedPlanUnitCollection.Size() should be 3") 41 | } 42 | 43 | for _, planUnit := range unplannedPlanUnitCollection.SolutionPlanUnits() { 44 | if planUnit == nil { 45 | t.Error("planUnit should not be nil") 46 | } 47 | } 48 | 49 | if len(unplannedPlanUnitCollection.SolutionPlanUnits()) != 3 { 50 | t.Error("len(unplannedPlanUnitCollection.SolutionPlanUnits()) should be 3") 51 | } 52 | 53 | elements := unplannedPlanUnitCollection.RandomDraw(2) 54 | 55 | if len(elements) != 2 { 56 | t.Error("len(elements) should be 2") 57 | } 58 | 59 | unplannedPlanUnitCollection.Remove(elements[0]) 60 | 61 | elements = unplannedPlanUnitCollection.RandomDraw(2) 62 | 63 | if len(elements) != 2 { 64 | t.Error("len(elements) should be 2") 65 | } 66 | 67 | unplannedPlanUnitCollection.Remove(elements[1]) 68 | 69 | elements = unplannedPlanUnitCollection.RandomDraw(2) 70 | 71 | if len(elements) != 1 { 72 | t.Error("len(elements) should be 1") 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /solution_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute_test 4 | 5 | import ( 6 | "testing" 7 | "time" 8 | 9 | "github.com/nextmv-io/nextroute" 10 | ) 11 | 12 | func BenchmarkAllocationsSolution(b *testing.B) { 13 | for i := 0; i < b.N; i++ { 14 | b.StopTimer() 15 | model, err := createModel(singleVehiclePlanSequenceModel()) 16 | if err != nil { 17 | b.Error(err) 18 | } 19 | 20 | maximum := nextroute.NewVehicleTypeDurationExpression( 21 | "maximum duration", 22 | 3*time.Minute, 23 | ) 24 | expression := nextroute.NewStopExpression("test", 2.0) 25 | 26 | cnstr, err := nextroute.NewMaximum(expression, maximum) 27 | if err != nil { 28 | b.Error(err) 29 | } 30 | 31 | err = model.AddConstraint(cnstr) 32 | if err != nil { 33 | b.Error(err) 34 | } 35 | b.StartTimer() 36 | _, err = nextroute.NewSolution(model) 37 | if err != nil { 38 | b.Fatal(err) 39 | } 40 | } 41 | } 42 | 43 | // TestLimitAllocations tests the number of allocations in the solution creation. 44 | // We want to ensure that the number of allocations is limited and does not grow 45 | // accidentally. 46 | func TestLimitAllocations(t *testing.T) { 47 | model, err := createModel(singleVehiclePlanSequenceModel()) 48 | if err != nil { 49 | t.Error(err) 50 | } 51 | 52 | maximum := nextroute.NewVehicleTypeDurationExpression( 53 | "maximum duration", 54 | 3*time.Minute, 55 | ) 56 | expression := nextroute.NewStopExpression("test", 2.0) 57 | 58 | cnstr, err := nextroute.NewMaximum(expression, maximum) 59 | if err != nil { 60 | t.Error(err) 61 | } 62 | 63 | err = model.AddConstraint(cnstr) 64 | if err != nil { 65 | t.Error(err) 66 | } 67 | allocs := testing.AllocsPerRun(2, func() { 68 | _, err = nextroute.NewSolution(model) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | }) 73 | if allocs > 66 { 74 | t.Errorf("expected 66 allocations, got %v", allocs) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /solve_information.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import ( 6 | "time" 7 | ) 8 | 9 | // SolveInformation contains information about the current solve. 10 | type SolveInformation interface { 11 | // DeltaScore returns the delta score of the last executed solve operator. 12 | DeltaScore() float64 13 | 14 | // Iteration returns the current iteration. 15 | Iteration() int 16 | 17 | // Solver returns the solver. 18 | Solver() Solver 19 | // SolveOperators returns the solve-operators that has been executed in 20 | // the current iteration. 21 | SolveOperators() SolveOperators 22 | // Start returns the start time of the solver. 23 | Start() time.Time 24 | } 25 | 26 | type solveInformationImpl struct { 27 | start time.Time 28 | solver Solver 29 | solveOperators SolveOperators 30 | deltaScore float64 31 | iteration int 32 | } 33 | 34 | func (s *solveInformationImpl) Iteration() int { 35 | return s.iteration 36 | } 37 | 38 | func (s *solveInformationImpl) Solver() Solver { 39 | return s.solver 40 | } 41 | 42 | func (s *solveInformationImpl) SolveOperators() SolveOperators { 43 | return s.solveOperators 44 | } 45 | 46 | func (s *solveInformationImpl) Start() time.Time { 47 | return s.start 48 | } 49 | 50 | func (s *solveInformationImpl) DeltaScore() float64 { 51 | return s.deltaScore 52 | } 53 | -------------------------------------------------------------------------------- /solve_parallel_events.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // ParallelSolveEvents is a struct that contains events that are fired during a 6 | // solve invocation of the parallel solver. 7 | type ParallelSolveEvents struct { 8 | // End is fired when the parallel solver is done. The first payload is the 9 | // solver, the second payload is the number of iterations, the third payload 10 | // is the best solution found. 11 | End *BaseEvent3[ParallelSolver, int, Solution] 12 | 13 | // NewSolution is fired when a new solution is found. 14 | NewSolution *BaseEvent2[ParallelSolveInformation, Solution] 15 | 16 | // Start is fired when the parallel solver is started. The first payload is 17 | // the parallel solver, the second payload is the options, the third payload 18 | // is the number of parallel runs will be invoked. 19 | Start *BaseEvent3[ParallelSolver, ParallelSolveOptions, int] 20 | // StartSolver is fired when one of the solver that will run in parallel is 21 | // started. The first payload is the parallel solve information, the second 22 | // payload is the solver, the third payload is the solve options, the fourth 23 | // payload is the start solution. 24 | StartSolver *BaseEvent4[ParallelSolveInformation, Solver, SolveOptions, Solution] 25 | } 26 | 27 | // NewParallelSolveEvents creates a new instance of ParallelSolveEvents. 28 | func NewParallelSolveEvents() ParallelSolveEvents { 29 | return ParallelSolveEvents{ 30 | End: &BaseEvent3[ParallelSolver, int, Solution]{}, 31 | NewSolution: &BaseEvent2[ParallelSolveInformation, Solution]{}, 32 | Start: &BaseEvent3[ParallelSolver, ParallelSolveOptions, int]{}, 33 | StartSolver: &BaseEvent4[ParallelSolveInformation, Solver, SolveOptions, Solution]{}, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /solve_progressioner.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // Progressioner is an interface that can be implemented by a solver to indicate 6 | // that is can return the progression of the solver. 7 | type Progressioner interface { 8 | // Progression returns the progression of the solver. 9 | Progression() []ProgressionEntry 10 | } 11 | 12 | // ProgressionEntry is a single entry in the progression of the solver. 13 | type ProgressionEntry struct { 14 | ElapsedSeconds float64 `json:"elapsed_seconds"` 15 | Value float64 `json:"value"` 16 | Iterations int `json:"iterations"` 17 | } 18 | -------------------------------------------------------------------------------- /solve_solution_channel.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | // SolutionInfo contains solutions and error if one raised. 6 | type SolutionInfo struct { 7 | Solution 8 | Error error 9 | } 10 | 11 | // SolutionChannel is a channel of solutions. 12 | type SolutionChannel <-chan SolutionInfo 13 | 14 | // All returns all solutions in the channel. 15 | func (solutions SolutionChannel) All() ([]Solution, error) { 16 | solutionArray := make([]Solution, 0) 17 | for s := range solutions { 18 | if s.Error != nil { 19 | return nil, s.Error 20 | } 21 | solutionArray = append(solutionArray, s) 22 | } 23 | return solutionArray, nil 24 | } 25 | 26 | // Last returns the last solution in the channel. 27 | func (solutions SolutionChannel) Last() (Solution, error) { 28 | var solution Solution 29 | for s := range solutions { 30 | if s.Error != nil { 31 | return nil, s.Error 32 | } 33 | solution = s 34 | } 35 | return solution, nil 36 | } 37 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Nextroute Python Source 2 | 3 | This `src` directory contains the source code for the Nextroute Python package. 4 | -------------------------------------------------------------------------------- /src/nextroute/__about__.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | 3 | __version__ = "v1.11.4" 4 | -------------------------------------------------------------------------------- /src/nextroute/__init__.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | 3 | """ 4 | The Nextroute Python interface. 5 | 6 | Nextroute is a flexible engine for solving Vehicle Routing Problems (VRPs). The 7 | core of Nextroute is written in Go and this package provides a Python interface 8 | to it. 9 | """ 10 | 11 | from .__about__ import __version__ 12 | from .options import Options as Options 13 | from .options import Verbosity as Verbosity 14 | from .solve import solve as solve 15 | from .version import nextroute_version as nextroute_version 16 | 17 | VERSION = __version__ 18 | """The version of the Nextroute Python package.""" 19 | -------------------------------------------------------------------------------- /src/nextroute/base_model.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | 3 | """ 4 | JSON class for data wrangling JSON objects. 5 | """ 6 | 7 | from typing import Any, Dict 8 | 9 | from pydantic import BaseModel 10 | 11 | 12 | class BaseModel(BaseModel): 13 | """Base class for data wrangling tasks with JSON.""" 14 | 15 | @classmethod 16 | def from_dict(cls, data: Dict[str, Any]): 17 | """Instantiates the class from a dict.""" 18 | 19 | return cls(**data) 20 | 21 | def to_dict(self) -> Dict[str, Any]: 22 | """Converts the class to a dict.""" 23 | 24 | return self.model_dump(mode="json", exclude_none=True, by_alias=True) 25 | -------------------------------------------------------------------------------- /src/nextroute/check/__init__.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | 3 | """ 4 | Check provides a plugin that allows you to check models and solutions. 5 | 6 | Checking a model or a solution checks the unplanned plan units. It checks each 7 | individual plan unit if it can be added to the solution. If the plan unit can 8 | be added to the solution, the report will include on how many vehicles and 9 | what the impact would be on the objective value. If the plan unit cannot be 10 | added to the solution, the report will include the reason why it cannot be 11 | added to the solution. 12 | 13 | The check can be invoked on a nextroute.Model or a nextroute.Solution. If the 14 | check is invoked on a model, an empty solution is created and the check is 15 | executed on this empty solution. An empty solution is a solution with all the 16 | initial stops that are fixed, initial stops that are not fixed are not added 17 | to the solution. The check is executed on the unplanned plan units of the 18 | solution. If the check is invoked on a solution, it is executed on the 19 | unplanned plan units of the solution. 20 | """ 21 | 22 | from .schema import Objective as Objective 23 | from .schema import ObjectiveTerm as ObjectiveTerm 24 | from .schema import Output as Output 25 | from .schema import PlanUnit as PlanUnit 26 | from .schema import Solution as Solution 27 | from .schema import Summary as Summary 28 | from .schema import Vehicle as Vehicle 29 | -------------------------------------------------------------------------------- /src/nextroute/schema/__init__.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | 3 | """ 4 | Schema (class) definitions for the entities in Nextroute. 5 | """ 6 | 7 | from .input import Defaults as Defaults 8 | from .input import DurationGroup as DurationGroup 9 | from .input import Input as Input 10 | from .location import Location as Location 11 | from .output import ObjectiveOutput as ObjectiveOutput 12 | from .output import Output as Output 13 | from .output import PlannedStopOutput as PlannedStopOutput 14 | from .output import Solution as Solution 15 | from .output import StopOutput as StopOutput 16 | from .output import VehicleOutput as VehicleOutput 17 | from .output import Version as Version 18 | from .statistics import DataPoint as DataPoint 19 | from .statistics import ResultStatistics as ResultStatistics 20 | from .statistics import RunStatistics as RunStatistics 21 | from .statistics import Series as Series 22 | from .statistics import SeriesData as SeriesData 23 | from .statistics import Statistics as Statistics 24 | from .stop import AlternateStop as AlternateStop 25 | from .stop import Stop as Stop 26 | from .stop import StopDefaults as StopDefaults 27 | from .vehicle import InitialStop as InitialStop 28 | from .vehicle import Vehicle as Vehicle 29 | from .vehicle import VehicleDefaults as VehicleDefaults 30 | -------------------------------------------------------------------------------- /src/nextroute/schema/location.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | 3 | """ 4 | Defines the location class. 5 | """ 6 | 7 | from nextroute.base_model import BaseModel 8 | 9 | 10 | class Location(BaseModel): 11 | """Location represents a geographical location.""" 12 | 13 | lat: float 14 | """Latitude of the location.""" 15 | lon: float 16 | """Longitude of the location.""" 17 | -------------------------------------------------------------------------------- /src/nextroute/version.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | 3 | import os 4 | import subprocess 5 | 6 | 7 | def nextroute_version() -> str: 8 | """ 9 | Get the version of the embedded Nextroute binary. 10 | """ 11 | executable = os.path.join(os.path.dirname(__file__), "bin", "nextroute.exe") 12 | return subprocess.check_output([executable, "--version"]).decode().strip() 13 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | -------------------------------------------------------------------------------- /src/tests/schema/__init__.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | -------------------------------------------------------------------------------- /src/tests/schema/test_input.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | 3 | import json 4 | import os 5 | import unittest 6 | 7 | from nextroute.schema import Input, Stop, Vehicle 8 | 9 | 10 | class TestInput(unittest.TestCase): 11 | filepath = os.path.join(os.path.dirname(__file__), "input.json") 12 | 13 | def test_from_json(self): 14 | with open(self.filepath) as f: 15 | json_data = json.load(f) 16 | 17 | nextroute_input = Input.from_dict(json_data) 18 | parsed = nextroute_input.to_dict() 19 | 20 | for s, stop in enumerate(parsed["stops"]): 21 | original_stop = json_data["stops"][s] 22 | self.assertEqual( 23 | stop, 24 | original_stop, 25 | f"stop: parsed({stop}) and original ({original_stop}) should be equal", 26 | ) 27 | 28 | for v, vehicle in enumerate(parsed["vehicles"]): 29 | original_vehicle = json_data["vehicles"][v] 30 | self.assertEqual( 31 | vehicle, 32 | original_vehicle, 33 | f"vehicle: parsed ({vehicle}) and original ({original_vehicle}) should be equal", 34 | ) 35 | 36 | self.assertEqual( 37 | parsed["defaults"], 38 | json_data["defaults"], 39 | f"defaults: parsed ({parsed['defaults']}) and original ({json_data['defaults']}) should be equal", 40 | ) 41 | 42 | def test_from_dict(self): 43 | with open(self.filepath) as f: 44 | json_data = json.load(f) 45 | 46 | nextroute_input = Input.from_dict(json_data) 47 | stops = nextroute_input.stops 48 | for stop in stops: 49 | self.assertTrue( 50 | isinstance(stop, Stop), 51 | f"Stop ({stop}) should be of type Stop.", 52 | ) 53 | 54 | vehicles = nextroute_input.vehicles 55 | for vehicle in vehicles: 56 | self.assertTrue( 57 | isinstance(vehicle, Vehicle), 58 | f"Vehicle ({vehicle}) should be of type Vehicle.", 59 | ) 60 | -------------------------------------------------------------------------------- /src/tests/solve_golden/__init__.py: -------------------------------------------------------------------------------- 1 | # © 2019-present nextmv.io inc 2 | -------------------------------------------------------------------------------- /src/tests/solve_golden/main.py: -------------------------------------------------------------------------------- 1 | # This script is copied to the `src` root so that the `nextroute` import is 2 | # resolved. It is fed an input via stdin and is meant to write the output to 3 | # stdout. 4 | import nextmv 5 | 6 | import nextroute 7 | 8 | 9 | def main() -> None: 10 | """Entry point for the program.""" 11 | 12 | parameters = [ 13 | nextmv.Parameter("input", str, "", "Path to input file. Default is stdin.", False), 14 | nextmv.Parameter("output", str, "", "Path to output file. Default is stdout.", False), 15 | ] 16 | 17 | default_options = nextroute.Options() 18 | for name, default_value in default_options.to_dict().items(): 19 | parameters.append(nextmv.Parameter(name.lower(), type(default_value), default_value, name, False)) 20 | 21 | options = nextmv.Options(*parameters) 22 | 23 | input = nextmv.load_local(options=options, path=options.input) 24 | 25 | nextmv.log("Solving vehicle routing problem:") 26 | nextmv.log(f" - stops: {len(input.data.get('stops', []))}") 27 | nextmv.log(f" - vehicles: {len(input.data.get('vehicles', []))}") 28 | 29 | model = DecisionModel() 30 | output = model.solve(input) 31 | nextmv.write_local(output, path=options.output) 32 | 33 | 34 | class DecisionModel(nextmv.Model): 35 | def solve(self, input: nextmv.Input) -> nextmv.Output: 36 | """Solves the given problem and returns the solution.""" 37 | 38 | nextroute_input = nextroute.schema.Input.from_dict(input.data) 39 | nextroute_options = nextroute.Options.extract_from_dict(input.options.to_dict()) 40 | nextroute_output = nextroute.solve(nextroute_input, nextroute_options) 41 | 42 | return nextmv.Output( 43 | options=input.options, 44 | solution=nextroute_output.solutions[0].to_dict(), 45 | statistics=nextroute_output.statistics.to_dict(), 46 | ) 47 | 48 | 49 | if __name__ == "__main__": 50 | main() 51 | -------------------------------------------------------------------------------- /tests/check/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 } 6 | }, 7 | { 8 | "id": "Kiyomizu-dera", 9 | "location": { "lon": 135.78506, "lat": 34.994857 } 10 | }, 11 | { 12 | "id": "Nijō Castle", 13 | "location": { "lon": 135.748134, "lat": 35.014239 } 14 | }, 15 | { 16 | "id": "Kyoto Imperial Palace", 17 | "location": { "lon": 135.762057, "lat": 35.025431 } 18 | }, 19 | { 20 | "id": "Gionmachi", 21 | "location": { "lon": 135.775682, "lat": 35.002457 } 22 | }, 23 | { 24 | "id": "Kinkaku-ji", 25 | "location": { "lon": 135.728898, "lat": 35.039705 } 26 | }, 27 | { 28 | "id": "Arashiyama Bamboo Forest", 29 | "location": { "lon": 135.672009, "lat": 35.017209 } 30 | } 31 | ], 32 | "vehicles": [ 33 | { 34 | "id": "v1", 35 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 36 | "speed": 20 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tests/check/main.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // package main holds the implementation of the nextroute template. 4 | package main 5 | 6 | import ( 7 | "context" 8 | "log" 9 | 10 | "github.com/nextmv-io/nextroute" 11 | "github.com/nextmv-io/nextroute/check" 12 | "github.com/nextmv-io/nextroute/factory" 13 | "github.com/nextmv-io/nextroute/schema" 14 | "github.com/nextmv-io/sdk/run" 15 | runSchema "github.com/nextmv-io/sdk/run/schema" 16 | ) 17 | 18 | func main() { 19 | runner := run.CLI(solver) 20 | err := runner.Run(context.Background()) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | } 25 | 26 | type options struct { 27 | Model factory.Options `json:"model,omitempty"` 28 | Solve nextroute.ParallelSolveOptions `json:"solve,omitempty"` 29 | Format nextroute.FormatOptions `json:"format,omitempty"` 30 | Check check.Options `json:"check,omitempty"` 31 | } 32 | 33 | func solver( 34 | ctx context.Context, 35 | input schema.Input, 36 | options options, 37 | ) (runSchema.Output, error) { 38 | // Customize options in the code. 39 | options.Solve.StartSolutions = 0 40 | options.Solve.Iterations = 0 41 | options.Check.Verbosity = "high" 42 | 43 | model, err := factory.NewModel(input, options.Model) 44 | if err != nil { 45 | return runSchema.Output{}, err 46 | } 47 | 48 | solver, err := nextroute.NewParallelSolver(model) 49 | if err != nil { 50 | return runSchema.Output{}, err 51 | } 52 | 53 | solutions, err := solver.Solve(ctx, options.Solve) 54 | if err != nil { 55 | return runSchema.Output{}, err 56 | } 57 | last, err := solutions.Last() 58 | if err != nil { 59 | return runSchema.Output{}, err 60 | } 61 | 62 | output, err := check.Format(ctx, options, options.Check, solver, last) 63 | if err != nil { 64 | return runSchema.Output{}, err 65 | } 66 | output.Statistics.Result.Custom = factory.DefaultCustomResultStatistics(last) 67 | 68 | return output, nil 69 | } 70 | -------------------------------------------------------------------------------- /tests/check/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | golden.Setup() 14 | code := m.Run() 15 | golden.Teardown() 16 | os.Exit(code) 17 | } 18 | 19 | // TestGolden executes a golden file test, where the .json input is fed and an 20 | // output is expected. 21 | func TestGolden(t *testing.T) { 22 | golden.FileTests( 23 | t, 24 | "input.json", 25 | golden.Config{ 26 | Args: []string{ 27 | "-solve.duration", "10s", 28 | "-format.disable.progression", 29 | "-solve.parallelruns", "1", 30 | "-solve.iterations", "50", 31 | "-solve.rundeterministically", 32 | "-solve.startsolutions", "1", 33 | }, 34 | TransientFields: []golden.TransientField{ 35 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 36 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 37 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 38 | {Key: "$.solutions[0].check.duration_used", Replacement: golden.StableFloat}, 39 | }, 40 | Thresholds: golden.Tresholds{ 41 | Float: 0.01, 42 | }, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /tests/custom_constraint/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "Fushimi Inari Taisha", 10 | "location": { "lon": 135.772695, "lat": 34.967146 }, 11 | "custom_data": { 12 | "type": "kosher" 13 | } 14 | }, 15 | { 16 | "id": "Kiyomizu-dera", 17 | "location": { "lon": 135.78506, "lat": 34.994857 }, 18 | "custom_data": { 19 | "type": "non-kosher" 20 | } 21 | }, 22 | { 23 | "id": "Nijō Castle", 24 | "location": { "lon": 135.748134, "lat": 35.014239 }, 25 | "custom_data": { 26 | "type": "kosher" 27 | } 28 | }, 29 | { 30 | "id": "Kyoto Imperial Palace", 31 | "location": { "lon": 135.762057, "lat": 35.025431 }, 32 | "custom_data": { 33 | "type": "non-kosher" 34 | } 35 | }, 36 | { 37 | "id": "Gionmachi", 38 | "location": { "lon": 135.775682, "lat": 35.002457 }, 39 | "custom_data": { 40 | "type": "kosher" 41 | } 42 | }, 43 | { 44 | "id": "Kinkaku-ji", 45 | "location": { "lon": 135.728898, "lat": 35.039705 }, 46 | "custom_data": { 47 | "type": "kosher" 48 | } 49 | }, 50 | { 51 | "id": "Arashiyama Bamboo Forest", 52 | "location": { "lon": 135.672009, "lat": 35.017209 }, 53 | "custom_data": { 54 | "type": "non-kosher" 55 | } 56 | } 57 | ], 58 | "vehicles": [ 59 | { 60 | "id": "v1", 61 | "start_location": { "lon": 135.672009, "lat": 35.017209 } 62 | }, 63 | { 64 | "id": "v2", 65 | "start_location": { "lon": 135.728898, "lat": 35.039705 } 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /tests/custom_constraint/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | golden.Setup() 14 | code := m.Run() 15 | golden.Teardown() 16 | os.Exit(code) 17 | } 18 | 19 | // TestGolden executes a golden file test, where the .json input is fed and an 20 | // output is expected. 21 | func TestGolden(t *testing.T) { 22 | golden.FileTests( 23 | t, 24 | "input.json", 25 | golden.Config{ 26 | Args: []string{ 27 | "-solve.duration", "10s", 28 | "-format.disable.progression", 29 | "-solve.parallelruns", "1", 30 | "-solve.iterations", "50", 31 | "-solve.rundeterministically", 32 | "-solve.startsolutions", "1", 33 | }, 34 | TransientFields: []golden.TransientField{ 35 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 36 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 37 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 38 | }, 39 | Thresholds: golden.Tresholds{ 40 | Float: 0.01, 41 | }, 42 | }, 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /tests/custom_matrices/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20, 5 | "start_time": "2023-01-01T06:00:00-06:00", 6 | "end_time": "2023-01-01T10:00:00-06:00" 7 | } 8 | }, 9 | "stops": [ 10 | { 11 | "id": "Fushimi Inari Taisha", 12 | "location": { "lon": 135.772695, "lat": 34.967146 } 13 | }, 14 | { 15 | "id": "Kiyomizu-dera", 16 | "location": { "lon": 135.78506, "lat": 34.994857 } 17 | }, 18 | { 19 | "id": "Nijō Castle", 20 | "location": { "lon": 135.748134, "lat": 35.014239 } 21 | }, 22 | { 23 | "id": "Kyoto Imperial Palace", 24 | "location": { "lon": 135.762057, "lat": 35.025431 } 25 | }, 26 | { 27 | "id": "Gionmachi", 28 | "location": { "lon": 135.775682, "lat": 35.002457 } 29 | }, 30 | { 31 | "id": "Kinkaku-ji", 32 | "location": { "lon": 135.728898, "lat": 35.039705 } 33 | }, 34 | { 35 | "id": "Arashiyama Bamboo Forest", 36 | "location": { "lon": 135.672009, "lat": 35.017209 } 37 | } 38 | ], 39 | "vehicles": [ 40 | { 41 | "id": "v1", 42 | "start_location": { "lon": 135.672009, "lat": 35.017209 } 43 | }, 44 | { 45 | "id": "v2", 46 | "start_location": { "lon": 135.728898, "lat": 35.039705 } 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /tests/custom_matrices/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | golden.Setup() 14 | code := m.Run() 15 | golden.Teardown() 16 | os.Exit(code) 17 | } 18 | 19 | // TestGolden executes a golden file test, where the .json input is fed and an 20 | // output is expected. 21 | func TestGolden(t *testing.T) { 22 | golden.FileTests( 23 | t, 24 | "input.json", 25 | golden.Config{ 26 | Args: []string{ 27 | "-solve.duration", "10s", 28 | "-format.disable.progression", 29 | "-solve.parallelruns", "1", 30 | "-solve.iterations", "50", 31 | "-solve.rundeterministically", 32 | "-solve.startsolutions", "1", 33 | }, 34 | TransientFields: []golden.TransientField{ 35 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 36 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 37 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 38 | {Key: ".solutions[0].check.duration_used", Replacement: golden.StableFloat}, 39 | }, 40 | Thresholds: golden.Tresholds{ 41 | Float: 0.01, 42 | }, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /tests/custom_objective/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "Fushimi Inari Taisha", 10 | "location": { "lon": 135.772695, "lat": 34.967146 } 11 | }, 12 | { 13 | "id": "Kiyomizu-dera", 14 | "location": { "lon": 135.78506, "lat": 34.994857 } 15 | }, 16 | { 17 | "id": "Nijō Castle", 18 | "location": { "lon": 135.748134, "lat": 35.014239 } 19 | }, 20 | { 21 | "id": "Kyoto Imperial Palace", 22 | "location": { "lon": 135.762057, "lat": 35.025431 } 23 | }, 24 | { 25 | "id": "Gionmachi", 26 | "location": { "lon": 135.775682, "lat": 35.002457 } 27 | }, 28 | { 29 | "id": "Kinkaku-ji", 30 | "location": { "lon": 135.728898, "lat": 35.039705 } 31 | }, 32 | { 33 | "id": "Arashiyama Bamboo Forest", 34 | "location": { "lon": 135.672009, "lat": 35.017209 } 35 | } 36 | ], 37 | "vehicles": [ 38 | { 39 | "id": "v1", 40 | "start_location": { "lon": 135.672009, "lat": 35.017209 } 41 | }, 42 | { 43 | "id": "v2", 44 | "start_location": { "lon": 135.728898, "lat": 35.039705 } 45 | } 46 | ], 47 | "custom_data": { 48 | "balance_penalty": 1000 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/custom_objective/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | golden.Setup() 14 | code := m.Run() 15 | golden.Teardown() 16 | os.Exit(code) 17 | } 18 | 19 | // TestGolden executes a golden file test, where the .json input is fed and an 20 | // output is expected. 21 | func TestGolden(t *testing.T) { 22 | golden.FileTests( 23 | t, 24 | "input.json", 25 | golden.Config{ 26 | Args: []string{ 27 | "-solve.duration", "10s", 28 | "-format.disable.progression", 29 | "-solve.parallelruns", "1", 30 | "-solve.iterations", "50", 31 | "-solve.rundeterministically", 32 | "-solve.startsolutions", "1", 33 | }, 34 | TransientFields: []golden.TransientField{ 35 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 36 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 37 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 38 | }, 39 | Thresholds: golden.Tresholds{ 40 | Float: 0.01, 41 | }, 42 | }, 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /tests/custom_operators/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20, 5 | "start_time": "2023-01-01T06:00:00-06:00", 6 | "end_time": "2023-01-01T10:00:00-06:00" 7 | } 8 | }, 9 | "stops": [ 10 | { 11 | "id": "Fushimi Inari Taisha", 12 | "location": { "lon": 135.772695, "lat": 34.967146 } 13 | }, 14 | { 15 | "id": "Kiyomizu-dera", 16 | "location": { "lon": 135.78506, "lat": 34.994857 } 17 | }, 18 | { 19 | "id": "Nijō Castle", 20 | "location": { "lon": 135.748134, "lat": 35.014239 } 21 | }, 22 | { 23 | "id": "Kyoto Imperial Palace", 24 | "location": { "lon": 135.762057, "lat": 35.025431 } 25 | }, 26 | { 27 | "id": "Gionmachi", 28 | "location": { "lon": 135.775682, "lat": 35.002457 } 29 | }, 30 | { 31 | "id": "Kinkaku-ji", 32 | "location": { "lon": 135.728898, "lat": 35.039705 } 33 | }, 34 | { 35 | "id": "Arashiyama Bamboo Forest", 36 | "location": { "lon": 135.672009, "lat": 35.017209 } 37 | } 38 | ], 39 | "vehicles": [ 40 | { 41 | "id": "v1", 42 | "start_location": { "lon": 135.672009, "lat": 35.017209 } 43 | }, 44 | { 45 | "id": "v2", 46 | "start_location": { "lon": 135.728898, "lat": 35.039705 } 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /tests/custom_operators/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | golden.Setup() 14 | code := m.Run() 15 | golden.Teardown() 16 | os.Exit(code) 17 | } 18 | 19 | // TestGolden executes a golden file test, where the .json input is fed and an 20 | // output is expected. 21 | func TestGolden(t *testing.T) { 22 | golden.FileTests( 23 | t, 24 | "input.json", 25 | golden.Config{ 26 | Args: []string{ 27 | "-solve.duration", "10s", 28 | "-format.disable.progression", 29 | "-solve.parallelruns", "1", 30 | "-solve.iterations", "50", 31 | "-solve.rundeterministically", 32 | "-solve.startsolutions", "1", 33 | }, 34 | TransientFields: []golden.TransientField{ 35 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 36 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 37 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 38 | {Key: ".solutions[0].check.duration_used", Replacement: golden.StableFloat}, 39 | }, 40 | Thresholds: golden.Tresholds{ 41 | Float: 0.01, 42 | }, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /tests/custom_output/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20, 5 | "start_time": "2023-01-01T06:00:00-06:00", 6 | "end_time": "2023-01-01T10:00:00-06:00" 7 | } 8 | }, 9 | "stops": [ 10 | { 11 | "id": "Fushimi Inari Taisha", 12 | "location": { "lon": 135.772695, "lat": 34.967146 } 13 | }, 14 | { 15 | "id": "Kiyomizu-dera", 16 | "location": { "lon": 135.78506, "lat": 34.994857 } 17 | }, 18 | { 19 | "id": "Nijō Castle", 20 | "location": { "lon": 135.748134, "lat": 35.014239 } 21 | }, 22 | { 23 | "id": "Kyoto Imperial Palace", 24 | "location": { "lon": 135.762057, "lat": 35.025431 } 25 | }, 26 | { 27 | "id": "Gionmachi", 28 | "location": { "lon": 135.775682, "lat": 35.002457 } 29 | }, 30 | { 31 | "id": "Kinkaku-ji", 32 | "location": { "lon": 135.728898, "lat": 35.039705 } 33 | }, 34 | { 35 | "id": "Arashiyama Bamboo Forest", 36 | "location": { "lon": 135.672009, "lat": 35.017209 } 37 | } 38 | ], 39 | "vehicles": [ 40 | { 41 | "id": "v1", 42 | "start_location": { "lon": 135.672009, "lat": 35.017209 } 43 | }, 44 | { 45 | "id": "v2", 46 | "start_location": { "lon": 135.728898, "lat": 35.039705 } 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /tests/custom_output/input.json.golden: -------------------------------------------------------------------------------- 1 | { 2 | "custom": "hello world", 3 | "value": 621.4304640293121 4 | } 5 | -------------------------------------------------------------------------------- /tests/custom_output/main.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // package main holds the implementation of the nextroute template. 4 | package main 5 | 6 | import ( 7 | "context" 8 | "log" 9 | 10 | "github.com/nextmv-io/nextroute" 11 | "github.com/nextmv-io/nextroute/check" 12 | "github.com/nextmv-io/nextroute/factory" 13 | "github.com/nextmv-io/nextroute/schema" 14 | "github.com/nextmv-io/sdk/run" 15 | ) 16 | 17 | func main() { 18 | runner := run.CLI(solver) 19 | err := runner.Run(context.Background()) 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | } 24 | 25 | type options struct { 26 | Model factory.Options `json:"model,omitempty"` 27 | Solve nextroute.ParallelSolveOptions `json:"solve,omitempty"` 28 | Format nextroute.FormatOptions `json:"format,omitempty"` 29 | Check check.Options `json:"check,omitempty"` 30 | } 31 | 32 | func solver( 33 | ctx context.Context, 34 | input schema.Input, 35 | options options, 36 | ) (customOutput, error) { 37 | model, err := factory.NewModel(input, options.Model) 38 | if err != nil { 39 | return customOutput{}, err 40 | } 41 | 42 | solver, err := nextroute.NewParallelSolver(model) 43 | if err != nil { 44 | return customOutput{}, err 45 | } 46 | 47 | solutions, err := solver.Solve(ctx, options.Solve) 48 | if err != nil { 49 | return customOutput{}, err 50 | } 51 | last, err := solutions.Last() 52 | if err != nil { 53 | return customOutput{}, err 54 | } 55 | out := toOutput(last) 56 | 57 | return out, nil 58 | } 59 | 60 | type customOutput struct { 61 | Custom string `json:"custom,omitempty"` 62 | Value float64 `json:"value,omitempty"` 63 | } 64 | 65 | func toOutput(solution nextroute.Solution) customOutput { 66 | value := 0.0 67 | for _, t := range solution.Model().Objective().Terms() { 68 | value += solution.ObjectiveValue(t.Objective()) 69 | } 70 | return customOutput{ 71 | Custom: "hello world", 72 | Value: value, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/custom_output/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | golden.Setup() 14 | code := m.Run() 15 | golden.Teardown() 16 | os.Exit(code) 17 | } 18 | 19 | // TestGolden executes a golden file test, where the .json input is fed and an 20 | // output is expected. 21 | func TestGolden(t *testing.T) { 22 | golden.FileTests( 23 | t, 24 | "input.json", 25 | golden.Config{ 26 | Args: []string{ 27 | "-solve.duration", "10s", 28 | "-format.disable.progression", 29 | "-solve.parallelruns", "1", 30 | "-solve.iterations", "50", 31 | "-solve.rundeterministically", 32 | "-solve.startsolutions", "1", 33 | }, 34 | TransientFields: []golden.TransientField{ 35 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 36 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 37 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 38 | {Key: ".solutions[0].check.duration_used", Replacement: golden.StableFloat}, 39 | }, 40 | Thresholds: golden.Tresholds{ 41 | Float: 0.01, 42 | }, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /tests/golden/benchmark_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "encoding/json" 8 | "os" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/nextmv-io/nextroute" 14 | "github.com/nextmv-io/nextroute/factory" 15 | "github.com/nextmv-io/nextroute/schema" 16 | "github.com/nextmv-io/sdk/run" 17 | ) 18 | 19 | func BenchmarkGolden(b *testing.B) { 20 | benchmarkFiles := []string{} 21 | files, err := os.ReadDir("testdata") 22 | if err != nil { 23 | b.Fatal(err) 24 | } 25 | for _, file := range files { 26 | if file.IsDir() { 27 | continue 28 | } 29 | if strings.HasSuffix(file.Name(), ".json") { 30 | benchmarkFiles = append(benchmarkFiles, "testdata/"+file.Name()) 31 | } 32 | } 33 | solveOptions := nextroute.ParallelSolveOptions{ 34 | Iterations: 200, 35 | Duration: 10 * time.Second, 36 | ParallelRuns: 1, 37 | StartSolutions: 1, 38 | RunDeterministically: true, 39 | } 40 | for _, file := range benchmarkFiles { 41 | b.Run(file, func(b *testing.B) { 42 | var input schema.Input 43 | data, err := os.ReadFile(file) 44 | if err != nil { 45 | b.Fatal(err) 46 | } 47 | if err := json.Unmarshal(data, &input); err != nil { 48 | b.Fatal(err) 49 | } 50 | model, err := factory.NewModel(input, factory.Options{}) 51 | if err != nil { 52 | b.Fatal(err) 53 | } 54 | b.ResetTimer() 55 | for i := 0; i < b.N; i++ { 56 | b.StopTimer() 57 | solver, err := nextroute.NewParallelSolver(model) 58 | if err != nil { 59 | b.Fatal(err) 60 | } 61 | ctx := context.Background() 62 | ctx = context.WithValue(ctx, run.Start, time.Now()) 63 | b.StartTimer() 64 | _, err = solver.Solve(ctx, solveOptions) 65 | if err != nil { 66 | b.Fatal(err) 67 | } 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/golden/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | cleanUp() 14 | golden.CopyFile("../../cmd/main.go", "main.go") 15 | golden.Setup() 16 | code := m.Run() 17 | cleanUp() 18 | os.Exit(code) 19 | } 20 | 21 | // TestGolden executes a golden file test, where the .json input is fed and a 22 | // .golden output is expected. 23 | func TestGolden(t *testing.T) { 24 | golden.FileTests( 25 | t, 26 | "testdata", 27 | golden.Config{ 28 | Args: []string{ 29 | "-solve.duration", "10s", 30 | "-format.disable.progression", 31 | "-solve.parallelruns", "1", 32 | "-solve.iterations", "50", 33 | "-solve.rundeterministically", 34 | // for deterministic tests 35 | "-solve.startsolutions", "1", 36 | }, 37 | GoldenExtension: ".go.golden", 38 | TransientFields: []golden.TransientField{ 39 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 40 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 41 | {Key: "$.statistics.result.value", Replacement: golden.StableFloat}, 42 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 43 | {Key: "$.statistics.result.custom.max_travel_duration", Replacement: golden.StableFloat}, 44 | {Key: "$.statistics.result.custom.min_travel_duration", Replacement: golden.StableFloat}, 45 | {Key: "$.statistics.result.custom.max_duration", Replacement: golden.StableFloat}, 46 | {Key: "$.statistics.result.custom.min_duration", Replacement: golden.StableFloat}, 47 | }, 48 | Thresholds: golden.Tresholds{ 49 | Float: 0.01, 50 | }, 51 | }, 52 | ) 53 | } 54 | 55 | func cleanUp() { 56 | keep := []string{ 57 | "testdata", 58 | "main_test.go", 59 | "benchmark_test.go", 60 | } 61 | golden.Reset(keep) 62 | } 63 | -------------------------------------------------------------------------------- /tests/golden/testdata/activation_penalty.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "Fushimi Inari Taisha", 10 | "location": { "lon": 135.772695, "lat": 34.967146 } 11 | }, 12 | { 13 | "id": "Kiyomizu-dera", 14 | "location": { "lon": 135.78506, "lat": 34.994857 } 15 | }, 16 | { 17 | "id": "Nijō Castle", 18 | "location": { "lon": 135.748134, "lat": 35.014239 } 19 | }, 20 | { 21 | "id": "Kyoto Imperial Palace", 22 | "location": { "lon": 135.762057, "lat": 35.025431 } 23 | }, 24 | { 25 | "id": "Gionmachi", 26 | "location": { "lon": 135.775682, "lat": 35.002457 } 27 | }, 28 | { 29 | "id": "Kinkaku-ji", 30 | "location": { "lon": 135.728898, "lat": 35.039705 } 31 | }, 32 | { 33 | "id": "Arashiyama Bamboo Forest", 34 | "location": { "lon": 135.672009, "lat": 35.017209 } 35 | } 36 | ], 37 | "vehicles": [ 38 | { 39 | "id": "v1", 40 | "activation_penalty": 1200 41 | }, 42 | { 43 | "id": "v2", 44 | "start_location": { "lon": 135.672009, "lat": 35.017209 } 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /tests/golden/testdata/alternates.json: -------------------------------------------------------------------------------- 1 | { 2 | "alternate_stops": [ 3 | { 4 | "id": "Inafuku", 5 | "location": { "lon": 135.770104, "lat": 34.9671591 } 6 | }, 7 | { 8 | "id": "Inari", 9 | "location": { "lon": 135.7666538, "lat": 34.9686029 } 10 | } 11 | ], 12 | "stops": [ 13 | { 14 | "id": "Fushimi Inari Taisha", 15 | "location": { "lon": 135.772695, "lat": 34.967146 } 16 | }, 17 | { 18 | "id": "Kiyomizu-dera", 19 | "location": { "lon": 135.78506, "lat": 34.994857 } 20 | }, 21 | { 22 | "id": "Nijō Castle", 23 | "location": { "lon": 135.748134, "lat": 35.014239 } 24 | }, 25 | { 26 | "id": "Kyoto Imperial Palace", 27 | "location": { "lon": 135.762057, "lat": 35.025431 } 28 | }, 29 | { 30 | "id": "Gionmachi", 31 | "location": { "lon": 135.775682, "lat": 35.002457 } 32 | }, 33 | { 34 | "id": "Kinkaku-ji", 35 | "location": { "lon": 135.728898, "lat": 35.039705 } 36 | }, 37 | { 38 | "id": "Arashiyama Bamboo Forest", 39 | "location": { "lon": 135.672009, "lat": 35.017209 } 40 | } 41 | ], 42 | "vehicles": [ 43 | { 44 | "id": "v1", 45 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 46 | "speed": 20, 47 | "alternate_stops": ["Inari", "Inafuku"] 48 | }, 49 | { 50 | "id": "v2", 51 | "start_location": { "lon": 135.772695, "lat": 34.967146 }, 52 | "speed": 20, 53 | "alternate_stops": ["Inafuku", "Inari"] 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /tests/golden/testdata/basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 } 6 | }, 7 | { 8 | "id": "Kiyomizu-dera", 9 | "location": { "lon": 135.78506, "lat": 34.994857 } 10 | }, 11 | { 12 | "id": "Nijō Castle", 13 | "location": { "lon": 135.748134, "lat": 35.014239 } 14 | }, 15 | { 16 | "id": "Kyoto Imperial Palace", 17 | "location": { "lon": 135.762057, "lat": 35.025431 } 18 | }, 19 | { 20 | "id": "Gionmachi", 21 | "location": { "lon": 135.775682, "lat": 35.002457 } 22 | }, 23 | { 24 | "id": "Kinkaku-ji", 25 | "location": { "lon": 135.728898, "lat": 35.039705 } 26 | }, 27 | { 28 | "id": "Arashiyama Bamboo Forest", 29 | "location": { "lon": 135.672009, "lat": 35.017209 } 30 | } 31 | ], 32 | "vehicles": [ 33 | { 34 | "id": "v1", 35 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 36 | "speed": 20 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tests/golden/testdata/capacity.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "Fushimi Inari Taisha", 10 | "location": { "lon": 135.772695, "lat": 34.967146 }, 11 | "quantity": -1 12 | }, 13 | { 14 | "id": "Kiyomizu-dera", 15 | "location": { "lon": 135.78506, "lat": 34.994857 }, 16 | "quantity": -1 17 | }, 18 | { 19 | "id": "Nijō Castle", 20 | "location": { "lon": 135.748134, "lat": 35.014239 }, 21 | "quantity": -1 22 | }, 23 | { 24 | "id": "Kyoto Imperial Palace", 25 | "location": { "lon": 135.762057, "lat": 35.025431 }, 26 | "quantity": { 27 | "pallets": -1, 28 | "bins": 1 29 | } 30 | }, 31 | { 32 | "id": "Gionmachi", 33 | "location": { "lon": 135.775682, "lat": 35.002457 }, 34 | "quantity": { 35 | "pallets": -1, 36 | "bins": -1 37 | } 38 | }, 39 | { 40 | "id": "Kinkaku-ji", 41 | "location": { "lon": 135.728898, "lat": 35.039705 }, 42 | "quantity": { 43 | "pallets": -1, 44 | "bins": -1 45 | } 46 | }, 47 | { 48 | "id": "Arashiyama Bamboo Forest", 49 | "location": { "lon": 135.672009, "lat": 35.017209 }, 50 | "quantity": { 51 | "pallets": -1, 52 | "bins": 1 53 | } 54 | } 55 | ], 56 | "vehicles": [ 57 | { 58 | "id": "v1", 59 | "capacity": 3, 60 | "start_level": 0, 61 | "start_location": { "lon": 135.772695, "lat": 34.967146 } 62 | }, 63 | { 64 | "id": "v2", 65 | "capacity": { 66 | "pallets": 4, 67 | "bins": 2 68 | }, 69 | "start_level": { 70 | "pallets": 0, 71 | "bins": 0 72 | }, 73 | "start_location": { "lon": 135.728898, "lat": 35.039705 } 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /tests/golden/testdata/compatibility_attributes.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "Fushimi Inari Taisha", 10 | "location": { "lon": 135.772695, "lat": 34.967146 } 11 | }, 12 | { 13 | "id": "Kiyomizu-dera", 14 | "location": { "lon": 135.78506, "lat": 34.994857 } 15 | }, 16 | { 17 | "id": "Nijō Castle", 18 | "location": { "lon": 135.748134, "lat": 35.014239 }, 19 | "compatibility_attributes": ["level_1"] 20 | }, 21 | { 22 | "id": "Kyoto Imperial Palace", 23 | "location": { "lon": 135.762057, "lat": 35.025431 }, 24 | "compatibility_attributes": ["level_1"] 25 | }, 26 | { 27 | "id": "Gionmachi", 28 | "location": { "lon": 135.775682, "lat": 35.002457 }, 29 | "compatibility_attributes": ["level_2"] 30 | }, 31 | { 32 | "id": "Kinkaku-ji", 33 | "location": { "lon": 135.728898, "lat": 35.039705 }, 34 | "compatibility_attributes": ["level_2"] 35 | }, 36 | { 37 | "id": "Arashiyama Bamboo Forest", 38 | "location": { "lon": 135.672009, "lat": 35.017209 }, 39 | "compatibility_attributes": ["level_3"] 40 | } 41 | ], 42 | "vehicles": [ 43 | { 44 | "id": "v1", 45 | "start_location": { "lon": 135.772695, "lat": 34.967146 } 46 | }, 47 | { 48 | "id": "v2", 49 | "compatibility_attributes": ["level_1"], 50 | "start_location": { "lon": 135.762057, "lat": 35.025431 } 51 | }, 52 | { 53 | "id": "v3", 54 | "compatibility_attributes": ["level_1", "level_2"], 55 | "start_location": { "lon": 135.78506, "lat": 34.994857 } 56 | }, 57 | { 58 | "id": "v4", 59 | "compatibility_attributes": ["level_3"] 60 | } 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /tests/golden/testdata/custom_data.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "custom_data": { 7 | "foo": "bar", 8 | "baz": 0 9 | } 10 | }, 11 | { 12 | "id": "Kiyomizu-dera", 13 | "location": { "lon": 135.78506, "lat": 34.994857 }, 14 | "custom_data": { 15 | "foo": "bar", 16 | "baz": 1 17 | } 18 | }, 19 | { 20 | "id": "Nijō Castle", 21 | "location": { "lon": 135.748134, "lat": 35.014239 }, 22 | "custom_data": { 23 | "foo": "bar", 24 | "baz": 2 25 | } 26 | }, 27 | { 28 | "id": "Kyoto Imperial Palace", 29 | "location": { "lon": 135.762057, "lat": 35.025431 }, 30 | "custom_data": { 31 | "foo": "bar", 32 | "baz": 3 33 | } 34 | }, 35 | { 36 | "id": "Gionmachi", 37 | "location": { "lon": 135.775682, "lat": 35.002457 }, 38 | "custom_data": { 39 | "foo": "bar", 40 | "roh": true 41 | } 42 | }, 43 | { 44 | "id": "Kinkaku-ji", 45 | "location": { "lon": 135.728898, "lat": 35.039705 }, 46 | "custom_data": { 47 | "foo": "bar", 48 | "roh": false 49 | } 50 | }, 51 | { 52 | "id": "Arashiyama Bamboo Forest", 53 | "location": { "lon": 135.672009, "lat": 35.017209 } 54 | } 55 | ], 56 | "vehicles": [ 57 | { 58 | "id": "v1", 59 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 60 | "speed": 20, 61 | "custom_data": { 62 | "lorem": "ipsum", 63 | "dolor": "sit amet" 64 | } 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /tests/golden/testdata/defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 15, 5 | "max_stops": 2, 6 | "start_time": "2023-01-01T12:00:00Z", 7 | "start_location": { 8 | "lon": 135.772695, 9 | "lat": 34.967146 10 | }, 11 | "end_location": { 12 | "lon": 135.772695, 13 | "lat": 34.967146 14 | } 15 | }, 16 | "stops": { 17 | "target_arrival_time": "2023-01-01T12:20:00Z", 18 | "duration": 300, 19 | "unplanned_penalty": 5000000, 20 | "early_arrival_time_penalty": 1.5, 21 | "late_arrival_time_penalty": 1.5 22 | } 23 | }, 24 | "stops": [ 25 | { 26 | "id": "Fushimi Inari Taisha", 27 | "location": { "lon": 135.772695, "lat": 34.967146 } 28 | }, 29 | { 30 | "id": "Kiyomizu-dera", 31 | "location": { "lon": 135.78506, "lat": 34.994857 } 32 | }, 33 | { 34 | "id": "Nijō Castle", 35 | "location": { "lon": 135.748134, "lat": 35.014239 } 36 | }, 37 | { 38 | "id": "Kyoto Imperial Palace", 39 | "location": { "lon": 135.762057, "lat": 35.025431 }, 40 | "compatibility_attributes": ["category_1"], 41 | "precedes": "Fushimi Inari Taisha" 42 | }, 43 | { 44 | "id": "Gionmachi", 45 | "location": { "lon": 135.775682, "lat": 35.002457 }, 46 | "compatibility_attributes": ["category_3"] 47 | }, 48 | { 49 | "id": "Kinkaku-ji", 50 | "location": { "lon": 135.728898, "lat": 35.039705 }, 51 | "compatibility_attributes": ["category_2"], 52 | "precedes": "Kiyomizu-dera" 53 | }, 54 | { 55 | "id": "Arashiyama Bamboo Forest", 56 | "location": { "lon": 135.672009, "lat": 35.017209 }, 57 | "compatibility_attributes": ["category_4"], 58 | "precedes": "Nijō Castle" 59 | } 60 | ], 61 | "vehicles": [ 62 | { 63 | "id": "v1", 64 | "compatibility_attributes": ["category_1"] 65 | }, 66 | { 67 | "id": "v2", 68 | "compatibility_attributes": ["category_2"] 69 | }, 70 | { 71 | "id": "v3", 72 | "compatibility_attributes": ["category_3"] 73 | }, 74 | { 75 | "id": "v4", 76 | "compatibility_attributes": ["category_4"] 77 | } 78 | ] 79 | } 80 | -------------------------------------------------------------------------------- /tests/golden/testdata/direct_precedence.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "precedes": "Kiyomizu-dera" 7 | }, 8 | { 9 | "id": "Kiyomizu-dera", 10 | "location": { "lon": 135.78506, "lat": 34.994857 } 11 | }, 12 | { 13 | "id": "Nijō Castle", 14 | "location": { "lon": 135.748134, "lat": 35.014239 }, 15 | "succeeds": "Kiyomizu-dera" 16 | }, 17 | { 18 | "id": "Kyoto Imperial Palace", 19 | "location": { "lon": 135.762057, "lat": 35.025431 }, 20 | "precedes": [ 21 | { "id": "Gionmachi" }, 22 | { "id": "Kinkaku-ji", "direct": true } 23 | ] 24 | }, 25 | { 26 | "id": "Gionmachi", 27 | "location": { "lon": 135.775682, "lat": 35.002457 } 28 | }, 29 | { 30 | "id": "Kinkaku-ji", 31 | "location": { "lon": 135.728898, "lat": 35.039705 } 32 | }, 33 | { 34 | "id": "Arashiyama Bamboo Forest", 35 | "location": { "lon": 135.672009, "lat": 35.017209 }, 36 | "succeeds": ["Gionmachi", "Kinkaku-ji"] 37 | } 38 | ], 39 | "vehicles": [ 40 | { 41 | "id": "v1", 42 | "speed": 20 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /tests/golden/testdata/direct_precedence_linked.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "precedes": "Kiyomizu-dera" 7 | }, 8 | { 9 | "id": "Kiyomizu-dera", 10 | "location": { "lon": 135.78506, "lat": 34.994857 } 11 | }, 12 | { 13 | "id": "Nijō Castle", 14 | "location": { "lon": 135.748134, "lat": 35.014239 }, 15 | "succeeds": "Kiyomizu-dera" 16 | }, 17 | { 18 | "id": "Kyoto Imperial Palace", 19 | "location": { "lon": 135.762057, "lat": 35.025431 }, 20 | "precedes": [ 21 | { "id": "Gionmachi" }, 22 | { "id": "Kinkaku-ji", "direct": true } 23 | ] 24 | }, 25 | { 26 | "id": "Gionmachi", 27 | "location": { "lon": 135.775682, "lat": 35.002457 } 28 | }, 29 | { 30 | "id": "Kinkaku-ji", 31 | "location": { "lon": 135.728898, "lat": 35.039705 }, 32 | "precedes": [{ "id": "Nijō Castle", "direct": true }] 33 | }, 34 | { 35 | "id": "Arashiyama Bamboo Forest", 36 | "location": { "lon": 135.672009, "lat": 35.017209 }, 37 | "succeeds": ["Gionmachi", "Kinkaku-ji"] 38 | } 39 | ], 40 | "vehicles": [ 41 | { 42 | "id": "v1", 43 | "speed": 20 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /tests/golden/testdata/distance_matrix.json: -------------------------------------------------------------------------------- 1 | { 2 | "distance_matrix": [ 3 | [0, 4300, 6700, 0, 0, 0, 0], 4 | [4300, 0, 4500, 0, 0, 0, 0], 5 | [6700, 4500, 0, 0, 0, 0, 0], 6 | [0, 0, 0, 0, 0, 0, 0, 0, 0], 7 | [0, 0, 0, 0, 0, 0, 0, 0, 0], 8 | [0, 0, 0, 0, 0, 0, 0, 0, 0], 9 | [0, 0, 0, 0, 0, 0, 0, 0, 0] 10 | ], 11 | "stops": [ 12 | { 13 | "id": "Fushimi Inari Taisha", 14 | "location": { "lon": 135.772695, "lat": 34.967146 } 15 | }, 16 | { 17 | "id": "Kiyomizu-dera", 18 | "location": { "lon": 135.78506, "lat": 34.994857 } 19 | }, 20 | { 21 | "id": "Nijō Castle", 22 | "location": { "lon": 135.748134, "lat": 35.014239 } 23 | } 24 | ], 25 | "vehicles": [ 26 | { 27 | "id": "v1", 28 | "speed": 10 29 | }, 30 | { 31 | "id": "v2", 32 | "speed": 20 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /tests/golden/testdata/duration_groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "duration_groups": [ 8 | { 9 | "group": ["Fushimi Inari Taisha", "Kiyomizu-dera"], 10 | "duration": 100 11 | }, 12 | { 13 | "group": ["Gionmachi", "Kinkaku-ji"], 14 | "duration": 200 15 | }, 16 | { 17 | "group": ["Arashiyama Bamboo Forest", "Nijō Castle"], 18 | "duration": 300 19 | } 20 | ], 21 | "stops": [ 22 | { 23 | "id": "Fushimi Inari Taisha", 24 | "location": { "lon": 135.772695, "lat": 34.967146 } 25 | }, 26 | { 27 | "id": "Kiyomizu-dera", 28 | "location": { "lon": 135.78506, "lat": 34.994857 } 29 | }, 30 | { 31 | "id": "Nijō Castle", 32 | "location": { "lon": 135.748134, "lat": 35.014239 } 33 | }, 34 | { 35 | "id": "Kyoto Imperial Palace", 36 | "location": { "lon": 135.762057, "lat": 35.025431 } 37 | }, 38 | { 39 | "id": "Gionmachi", 40 | "location": { "lon": 135.775682, "lat": 35.002457 } 41 | }, 42 | { 43 | "id": "Kinkaku-ji", 44 | "location": { "lon": 135.728898, "lat": 35.039705 } 45 | }, 46 | { 47 | "id": "Arashiyama Bamboo Forest", 48 | "location": { "lon": 135.672009, "lat": 35.017209 } 49 | } 50 | ], 51 | "vehicles": [ 52 | { 53 | "id": "v1", 54 | "start_location": { 55 | "lon": 135.772695, 56 | "lat": 34.967146 57 | } 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /tests/golden/testdata/duration_groups_with_stop_multiplier.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "duration_groups": [ 8 | { 9 | "group": ["Fushimi Inari Taisha", "Kiyomizu-dera"], 10 | "duration": 100 11 | }, 12 | { 13 | "group": ["Gionmachi", "Kinkaku-ji"], 14 | "duration": 200 15 | }, 16 | { 17 | "group": ["Arashiyama Bamboo Forest", "Nijō Castle"], 18 | "duration": 300 19 | } 20 | ], 21 | "stops": [ 22 | { 23 | "id": "Fushimi Inari Taisha", 24 | "location": { "lon": 135.772695, "lat": 34.967146 } 25 | }, 26 | { 27 | "id": "Kiyomizu-dera", 28 | "location": { "lon": 135.78506, "lat": 34.994857 } 29 | }, 30 | { 31 | "id": "Nijō Castle", 32 | "location": { "lon": 135.748134, "lat": 35.014239 } 33 | }, 34 | { 35 | "id": "Kyoto Imperial Palace", 36 | "location": { "lon": 135.762057, "lat": 35.025431 } 37 | }, 38 | { 39 | "id": "Gionmachi", 40 | "location": { "lon": 135.775682, "lat": 35.002457 } 41 | }, 42 | { 43 | "id": "Kinkaku-ji", 44 | "location": { "lon": 135.728898, "lat": 35.039705 } 45 | }, 46 | { 47 | "id": "Arashiyama Bamboo Forest", 48 | "location": { "lon": 135.672009, "lat": 35.017209 } 49 | } 50 | ], 51 | "vehicles": [ 52 | { 53 | "id": "v1", 54 | "start_location": { 55 | "lon": 135.772695, 56 | "lat": 34.967146 57 | }, 58 | "stop_duration_multiplier": 2 59 | } 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /tests/golden/testdata/duration_matrix.json: -------------------------------------------------------------------------------- 1 | { 2 | "duration_matrix": [ 3 | [0, 720, 1020, 0, 0], 4 | [720, 0, 660, 0, 0], 5 | [1020, 660, 0, 0, 0], 6 | [0, 0, 0, 0, 0], 7 | [0, 0, 0, 0, 0] 8 | ], 9 | "stops": [ 10 | { 11 | "id": "Fushimi Inari Taisha", 12 | "location": { "lon": 135.772695, "lat": 34.967146 } 13 | }, 14 | { 15 | "id": "Kiyomizu-dera", 16 | "location": { "lon": 135.78506, "lat": 34.994857 } 17 | }, 18 | { 19 | "id": "Nijō Castle", 20 | "location": { "lon": 135.748134, "lat": 35.014239 } 21 | } 22 | ], 23 | "vehicles": [ 24 | { 25 | "id": "v1" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /tests/golden/testdata/duration_matrix_time_dependent.md: -------------------------------------------------------------------------------- 1 | # Time dependent duration matrix (duration_matrix_time_dependent*.json) 2 | 3 | The duration matrix time-dependent files define different duration matrices 4 | usage scenarios where duration_matrix_time_dependent0 is the simplest one and 5 | works as a baseline. 6 | All other files are expected to have longer travel times due to scaling or matrices 7 | with higher values in the time frames. An exception is file 3 which has a lower 8 | travel time than file 0 because it splits stops on two vehicles. 9 | 10 | * file 0: "route_travel_duration": 1380 11 | * file 1: "route_travel_duration": 1498 12 | * file 2: "route_travel_duration": 1950 13 | * file 3: "route_travel_duration": 1140 14 | -------------------------------------------------------------------------------- /tests/golden/testdata/duration_matrix_time_dependent0.json: -------------------------------------------------------------------------------- 1 | { 2 | "duration_matrix": { 3 | "default_matrix": [ 4 | [0, 720, 1020, 0, 0], 5 | [720, 0, 660, 0, 0], 6 | [1020, 660, 0, 0, 0], 7 | [0, 0, 0, 0, 0], 8 | [0, 0, 0, 0, 0] 9 | ] 10 | }, 11 | "stops": [ 12 | { 13 | "id": "Fushimi Inari Taisha", 14 | "location": { "lon": 135.772695, "lat": 34.967146 } 15 | }, 16 | { 17 | "id": "Kiyomizu-dera", 18 | "location": { "lon": 135.78506, "lat": 34.994857 } 19 | }, 20 | { 21 | "id": "Nijō Castle", 22 | "location": { "lon": 135.748134, "lat": 35.014239 } 23 | } 24 | ], 25 | "vehicles": [ 26 | { 27 | "id": "v1", 28 | "start_time": "2023-01-01T12:00:00Z" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /tests/golden/testdata/duration_matrix_time_dependent1.json: -------------------------------------------------------------------------------- 1 | { 2 | "duration_matrix": { 3 | "default_matrix": [ 4 | [0, 720, 1020, 0, 0], 5 | [720, 0, 660, 0, 0], 6 | [1020, 660, 0, 0, 0], 7 | [0, 0, 0, 0, 0], 8 | [0, 0, 0, 0, 0] 9 | ], 10 | "matrix_time_frames": [ 11 | { 12 | "start_time": "2023-01-01T12:03:00Z", 13 | "end_time": "2023-01-01T12:10:00Z", 14 | "matrix": [ 15 | [0, 800, 1100, 0, 0], 16 | [800, 0, 740, 0, 0], 17 | [1100, 740, 0, 0, 0], 18 | [0, 0, 0, 0, 0], 19 | [0, 0, 0, 0, 0] 20 | ] 21 | }, 22 | { 23 | "start_time": "2023-01-01T12:12:00Z", 24 | "end_time": "2023-01-01T12:20:00Z", 25 | "matrix": [ 26 | [0, 850, 1150, 0, 0], 27 | [850, 0, 790, 0, 0], 28 | [1150, 790, 0, 0, 0], 29 | [0, 0, 0, 0, 0], 30 | [0, 0, 0, 0, 0] 31 | ] 32 | } 33 | ] 34 | }, 35 | "stops": [ 36 | { 37 | "id": "Fushimi Inari Taisha", 38 | "location": { "lon": 135.772695, "lat": 34.967146 } 39 | }, 40 | { 41 | "id": "Kiyomizu-dera", 42 | "location": { "lon": 135.78506, "lat": 34.994857 } 43 | }, 44 | { 45 | "id": "Nijō Castle", 46 | "location": { "lon": 135.748134, "lat": 35.014239 } 47 | } 48 | ], 49 | "vehicles": [ 50 | { 51 | "id": "v1", 52 | "start_time": "2023-01-01T12:00:00Z" 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /tests/golden/testdata/duration_matrix_time_dependent2.json: -------------------------------------------------------------------------------- 1 | { 2 | "duration_matrix": { 3 | "default_matrix": [ 4 | [0, 720, 1020, 0, 0], 5 | [720, 0, 660, 0, 0], 6 | [1020, 660, 0, 0, 0], 7 | [0, 0, 0, 0, 0], 8 | [0, 0, 0, 0, 0] 9 | ], 10 | "matrix_time_frames": [ 11 | { 12 | "start_time": "2023-01-01T12:03:00Z", 13 | "end_time": "2023-01-01T12:10:00Z", 14 | "scaling_factor": 2.0 15 | }, 16 | { 17 | "start_time": "2023-01-01T12:12:00Z", 18 | "end_time": "2023-01-01T12:20:00Z", 19 | "scaling_factor": 4.0 20 | } 21 | ] 22 | }, 23 | "stops": [ 24 | { 25 | "id": "Fushimi Inari Taisha", 26 | "location": { "lon": 135.772695, "lat": 34.967146 } 27 | }, 28 | { 29 | "id": "Kiyomizu-dera", 30 | "location": { "lon": 135.78506, "lat": 34.994857 } 31 | }, 32 | { 33 | "id": "Nijō Castle", 34 | "location": { "lon": 135.748134, "lat": 35.014239 } 35 | } 36 | ], 37 | "vehicles": [ 38 | { 39 | "id": "v1", 40 | "start_time": "2023-01-01T12:00:00Z" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /tests/golden/testdata/duration_matrix_time_dependent3.json: -------------------------------------------------------------------------------- 1 | { 2 | "duration_matrix": [ 3 | { 4 | "vehicle_ids": ["v1"], 5 | "default_matrix": [ 6 | [0, 720, 1020, 0, 0], 7 | [720, 0, 660, 0, 0], 8 | [1020, 660, 0, 0, 0], 9 | [0, 0, 0, 0, 0], 10 | [0, 0, 0, 0, 0] 11 | ], 12 | "matrix_time_frames": [ 13 | { 14 | "start_time": "2023-01-01T12:03:00Z", 15 | "end_time": "2023-01-01T12:10:00Z", 16 | "scaling_factor": 2.0 17 | }, 18 | { 19 | "start_time": "2023-01-01T12:12:00Z", 20 | "end_time": "2023-01-01T12:20:00Z", 21 | "scaling_factor": 2.0 22 | } 23 | ] 24 | }, 25 | { 26 | "vehicle_ids": ["v2"], 27 | "default_matrix": [ 28 | [0, 720, 1020, 0, 0], 29 | [720, 0, 660, 0, 0], 30 | [1020, 660, 0, 0, 0], 31 | [0, 0, 0, 0, 0], 32 | [0, 0, 0, 0, 0] 33 | ], 34 | "matrix_time_frames": [ 35 | { 36 | "start_time": "2023-01-01T12:03:00Z", 37 | "end_time": "2023-01-01T12:10:00Z", 38 | "scaling_factor": 4.0 39 | }, 40 | { 41 | "start_time": "2023-01-01T12:12:00Z", 42 | "end_time": "2023-01-01T12:20:00Z", 43 | "scaling_factor": 4.0 44 | } 45 | ] 46 | } 47 | ], 48 | "stops": [ 49 | { 50 | "id": "Fushimi Inari Taisha", 51 | "location": { 52 | "lon": 135.772695, 53 | "lat": 34.967146 54 | } 55 | }, 56 | { 57 | "id": "Kiyomizu-dera", 58 | "location": { 59 | "lon": 135.78506, 60 | "lat": 34.994857 61 | } 62 | }, 63 | { 64 | "id": "Nijō Castle", 65 | "location": { 66 | "lon": 135.748134, 67 | "lat": 35.014239 68 | } 69 | } 70 | ], 71 | "vehicles": [ 72 | { 73 | "id": "v1", 74 | "start_time": "2023-01-01T12:00:00Z" 75 | }, 76 | { 77 | "id": "v2", 78 | "start_time": "2023-01-01T12:00:00Z" 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /tests/golden/testdata/early_arrival_penalty.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20, 5 | "start_time": "2023-01-01T12:00:00Z" 6 | }, 7 | "stops": { 8 | "early_arrival_time_penalty": 1.5, 9 | "unplanned_penalty": 2000000 10 | } 11 | }, 12 | "stops": [ 13 | { 14 | "id": "Fushimi Inari Taisha", 15 | "location": { "lon": 135.772695, "lat": 34.967146 }, 16 | "target_arrival_time": "2023-01-01T12:00:00Z" 17 | }, 18 | { 19 | "id": "Kiyomizu-dera", 20 | "location": { "lon": 135.78506, "lat": 34.994857 }, 21 | "target_arrival_time": "2023-01-01T12:05:00Z" 22 | }, 23 | { 24 | "id": "Nijō Castle", 25 | "location": { "lon": 135.748134, "lat": 35.014239 }, 26 | "target_arrival_time": "2023-01-01T12:10:00Z" 27 | }, 28 | { 29 | "id": "Kyoto Imperial Palace", 30 | "location": { "lon": 135.762057, "lat": 35.025431 }, 31 | "target_arrival_time": "2023-01-01T12:15:00Z" 32 | }, 33 | { 34 | "id": "Gionmachi", 35 | "location": { "lon": 135.775682, "lat": 35.002457 }, 36 | "target_arrival_time": "2023-01-01T12:20:00Z" 37 | }, 38 | { 39 | "id": "Kinkaku-ji", 40 | "location": { "lon": 135.728898, "lat": 35.039705 }, 41 | "target_arrival_time": "2023-01-01T12:25:00Z" 42 | }, 43 | { 44 | "id": "Arashiyama Bamboo Forest", 45 | "location": { "lon": 135.672009, "lat": 35.017209 }, 46 | "target_arrival_time": "2023-01-01T12:30:00Z" 47 | } 48 | ], 49 | "vehicles": [ 50 | { 51 | "id": "v1" 52 | }, 53 | { 54 | "id": "v2" 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 } 6 | }, 7 | { 8 | "id": "Kiyomizu-dera", 9 | "location": { "lon": 135.78506, "lat": 34.994857 } 10 | }, 11 | { 12 | "id": "Nijō Castle", 13 | "location": { "lon": 135.748134, "lat": 35.014239 } 14 | }, 15 | { 16 | "id": "Kyoto Imperial Palace", 17 | "location": { "lon": 135.762057, "lat": 35.025431 } 18 | }, 19 | { 20 | "id": "Gionmachi", 21 | "location": { "lon": 135.775682, "lat": 35.002457 } 22 | }, 23 | { 24 | "id": "Kinkaku-ji", 25 | "location": { "lon": 135.728898, "lat": 35.039705 } 26 | }, 27 | { 28 | "id": "Arashiyama Bamboo Forest", 29 | "location": { "lon": 135.672009, "lat": 35.017209 } 30 | } 31 | ], 32 | "vehicles": [ 33 | { 34 | "id": "v1", 35 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 36 | "speed": 20, 37 | "initial_stops": [ 38 | { 39 | "id": "Nijō Castle", 40 | "fixed": true 41 | }, 42 | { 43 | "id": "Kyoto Imperial Palace", 44 | "fixed": true 45 | } 46 | ] 47 | }, 48 | { 49 | "id": "v2", 50 | "start_location": { "lon": 135.772695, "lat": 34.967146 }, 51 | "speed": 20, 52 | "initial_stops": [ 53 | { 54 | "id": "Kiyomizu-dera" 55 | } 56 | ] 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_compatibility.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "compatibility_attributes": ["special_feature_needed"] 7 | }, 8 | { 9 | "id": "Kiyomizu-dera", 10 | "location": { "lon": 135.78506, "lat": 34.994857 }, 11 | "compatibility_attributes": ["unavailable_feature_needed"] 12 | }, 13 | { 14 | "id": "Nijō Castle", 15 | "location": { "lon": 135.748134, "lat": 35.014239 } 16 | }, 17 | { 18 | "id": "Kyoto Imperial Palace", 19 | "location": { "lon": 135.762057, "lat": 35.025431 } 20 | }, 21 | { 22 | "id": "Gionmachi", 23 | "location": { "lon": 135.775682, "lat": 35.002457 } 24 | }, 25 | { 26 | "id": "Kinkaku-ji", 27 | "location": { "lon": 135.728898, "lat": 35.039705 } 28 | }, 29 | { 30 | "id": "Arashiyama Bamboo Forest", 31 | "location": { "lon": 135.672009, "lat": 35.017209 } 32 | } 33 | ], 34 | "vehicles": [ 35 | { 36 | "id": "v1", 37 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 38 | "speed": 20, 39 | "initial_stops": [ 40 | { 41 | "id": "Fushimi Inari Taisha" 42 | }, 43 | { 44 | "id": "Kyoto Imperial Palace" 45 | } 46 | ] 47 | }, 48 | { 49 | "id": "v2", 50 | "start_location": { "lon": 135.772695, "lat": 34.967146 }, 51 | "speed": 20, 52 | "compatibility_attributes": ["special_feature_needed"], 53 | "initial_stops": [ 54 | { 55 | "id": "Kiyomizu-dera" 56 | } 57 | ] 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_compatibility.md: -------------------------------------------------------------------------------- 1 | # Initial stop infeasible example (initial_stops_infeasible_compatibility.json) 2 | 3 | This example demonstrates the use of the `initial_stops` with optional (not 4 | `fixed`) stops while some of the initial stops are infeasible. 5 | 6 | Find some notes about the example below: 7 | 8 | - The stop `Fushimi Inari Taisha` is infeasible with the vehicle it is initially 9 | assigned to due to its `compatibility_attributes`. However, it can be assigned 10 | to the other vehicle (and should be). 11 | - The stop `Kiyomizu-dera` is incompatible with both vehicles and thus cannot be 12 | assigned to any of them. It should not be planned at all, but we still expect a 13 | solution including the other stops. 14 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_max_duration.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "stops": { 4 | "unplanned_penalty": 20000, 5 | "duration": 600 6 | } 7 | }, 8 | "stops": [ 9 | { 10 | "id": "stop1", 11 | "location": { "lat": 51.9636, "lon": 7.6293 } 12 | }, 13 | { 14 | "id": "stop2", 15 | "location": { "lat": 51.9635, "lon": 7.6439 } 16 | }, 17 | { 18 | "id": "stop3", 19 | "location": { "lat": 51.9635, "lon": 7.6585 } 20 | }, 21 | { 22 | "id": "stop4", 23 | "location": { "lat": 51.9635, "lon": 7.6731 } 24 | }, 25 | { 26 | "id": "stop5", 27 | "location": { "lat": 51.9635, "lon": 7.6877 } 28 | } 29 | ], 30 | "vehicles": [ 31 | { 32 | "id": "v1", 33 | "speed": 200, 34 | "start_time": "2023-01-01T12:00:00Z", 35 | "start_location": { "lat": 51.9635, "lon": 7.7023 }, 36 | "initial_stops": [ 37 | { "id": "stop1" }, 38 | { "id": "stop2" }, 39 | { "id": "stop3" }, 40 | { "id": "stop4" }, 41 | { "id": "stop5", "fixed": true } 42 | ], 43 | "max_duration": 2100 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_max_duration.md: -------------------------------------------------------------------------------- 1 | # Initial stop infeasible example (initial_stops_infeasible_max_duration.json) 2 | 3 | This example demonstrates the use of the `initial_stops` with optional (not 4 | `fixed`) stops when facing a constraint on the last stop. I.e., the max duration 5 | of the vehicle imposes a last start time on the end stop of the vehicle. 6 | 7 | Find some notes about the example below: 8 | 9 | - The only vehicle starts with all stops pre-assigned as `initial_stops`. 10 | - Every stop takes 10 minutes to be served and _some_ (fast) driving occurs 11 | between the stops. This means that only 3 stops can be served within the max 12 | duration of 35 minutes. All others should get removed in reverse order, but 13 | should be assigned again since the route is shortest as 5-4-3. 14 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_remove_all.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "stops": { 4 | "unplanned_penalty": 20000 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "stop1", 10 | "location": { "lat": 51.9636, "lon": 7.6293 }, 11 | "compatibility_attributes": ["unavailable"] 12 | }, 13 | { 14 | "id": "stop2", 15 | "location": { "lat": 51.9635, "lon": 7.6439 }, 16 | "compatibility_attributes": ["unavailable"] 17 | }, 18 | { 19 | "id": "stop3", 20 | "location": { "lat": 51.9635, "lon": 7.6585 }, 21 | "compatibility_attributes": ["unavailable"] 22 | } 23 | ], 24 | "vehicles": [ 25 | { 26 | "id": "v1", 27 | "speed": 20, 28 | "start_time": "2023-01-01T12:00:00Z", 29 | "initial_stops": [{ "id": "stop1" }, { "id": "stop2" }, { "id": "stop3" }] 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_remove_all.md: -------------------------------------------------------------------------------- 1 | # Initial stop infeasible example (initial_stops_infeasible_remove_all.json) 2 | 3 | This example demonstrates the use of the `initial_stops` when all stops need to 4 | be unplanned to make the initial solution feasible. 5 | 6 | Find some notes about the example below: 7 | 8 | - All stops are simply incompatible with all vehicles due to their 9 | `compatibility_attributes`. Hence, they all need to be removed from the 10 | initial solution. 11 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_temporal.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "start_time": "2023-01-01T12:00:00Z", 5 | "speed": 200 6 | }, 7 | "stops": { 8 | "duration": 600 9 | } 10 | }, 11 | "stops": [ 12 | { 13 | "id": "stop1", 14 | "location": { "lat": 51.96239909784941, "lon": 7.640195127867031 }, 15 | "precedes": "stop3" 16 | }, 17 | { 18 | "id": "stop2", 19 | "location": { "lat": 51.96239639139782, "lon": 7.654790254559089 } 20 | }, 21 | { 22 | "id": "stop3", 23 | "location": { "lat": 51.96239188064561, "lon": 7.669385378901211 }, 24 | "start_time_window": ["2023-01-01T12:20:00Z", "2023-01-01T12:25:00Z"], 25 | "max_wait": 60 26 | }, 27 | { 28 | "id": "stop4", 29 | "location": { "lat": 51.94899909828405, "lon": 7.653490766493093 } 30 | }, 31 | { 32 | "id": "stop5", 33 | "location": { "lat": 51.948996393136326, "lon": 7.6680815318127316 } 34 | }, 35 | { 36 | "id": "stop6", 37 | "location": { "lat": 51.948991884557245, "lon": 7.6826722947853625 }, 38 | "start_time_window": ["2023-01-01T12:00:00Z", "2023-01-01T12:05:00Z"] 39 | } 40 | ], 41 | "vehicles": [ 42 | { 43 | "id": "v1", 44 | "initial_stops": [{ "id": "stop1" }, { "id": "stop2" }, { "id": "stop3" }] 45 | }, 46 | { 47 | "id": "v2", 48 | "initial_stops": [{ "id": "stop4" }, { "id": "stop5" }, { "id": "stop6" }] 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_temporal.md: -------------------------------------------------------------------------------- 1 | # Initial stop infeasible example (initial_stops_infeasible_temporal.json) 2 | 3 | This example demonstrates the use of the `initial_stops` with optional (not 4 | `fixed`) stops while one of the initial stops is infeasible due to temporal 5 | constraints. 6 | 7 | Find some notes about the example below: 8 | 9 | - The stop `stop6` is infeasible in the sequence it is initially assigned as, 10 | since its window closes very early. However, it can simply be approached first 11 | to still get planned (and should be). 12 | - The stop `stop3` is challenging to handle as it is part of a plan unit with 13 | `stop1`. Hence, it will get assigned before `stop2` when its `max_wait` would 14 | cause a temporal violation. However, this is resolved when `stop2` eventually 15 | gets planned between `stop1` and `stop3`. Its `stop_duration` is sufficient to 16 | avoid long waiting time. 17 | 18 | TODO: add further stops that need to be removed for the route to become feasible 19 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_tuple.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "stops": { 4 | "unplanned_penalty": 20000, 5 | "duration": 600 6 | } 7 | }, 8 | "stops": [ 9 | { 10 | "id": "stop1", 11 | "precedes": "stop3", 12 | "compatibility_attributes": ["unavailable"], 13 | "location": { "lat": 51.9636, "lon": 7.6293 } 14 | }, 15 | { 16 | "id": "stop2", 17 | "location": { "lat": 51.9635, "lon": 7.6439 } 18 | }, 19 | { 20 | "id": "stop3", 21 | "location": { "lat": 51.9635, "lon": 7.6585 } 22 | }, 23 | { 24 | "id": "stop4", 25 | "precedes": "stop5", 26 | "location": { "lat": 51.9635, "lon": 7.6731 } 27 | }, 28 | { 29 | "id": "stop5", 30 | "max_wait": 1, 31 | "start_time_window": ["2023-01-01T14:00:00Z", "2023-01-01T14:05:00Z"], 32 | "location": { "lat": 51.9635, "lon": 7.6877 } 33 | } 34 | ], 35 | "vehicles": [ 36 | { 37 | "id": "v1", 38 | "speed": 200, 39 | "start_time": "2023-01-01T12:00:00Z", 40 | "start_location": { "lat": 51.9635, "lon": 7.7023 }, 41 | "initial_stops": [ 42 | { "id": "stop1" }, 43 | { "id": "stop2" }, 44 | { "id": "stop3" }, 45 | { "id": "stop4" }, 46 | { "id": "stop5" } 47 | ] 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /tests/golden/testdata/initial_stops_infeasible_tuple.md: -------------------------------------------------------------------------------- 1 | # Initial stop infeasible example (initial_stops_infeasible_tuple.json) 2 | 3 | This example demonstrates the use of the `initial_stops` with optional (not 4 | `fixed`) stops when facing a constraint on the last stop. I.e., the max duration 5 | of the vehicle imposes a last start time on the end stop of the vehicle. 6 | 7 | Find some notes about the example below: 8 | 9 | - The only vehicle starts with all stops pre-assigned as `initial_stops`. 10 | - Every stop takes 10 minutes to be served and _some_ (fast) driving occurs 11 | between the stops. This means that only 3 stops can be served within the max 12 | duration of 35 minutes. All others should get removed in reverse order, but 13 | should be assigned again since the route is shortest as 5-4-3. 14 | -------------------------------------------------------------------------------- /tests/golden/testdata/late_arrival_penalty.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20, 5 | "start_time": "2023-01-01T12:00:00Z" 6 | }, 7 | "stops": { 8 | "late_arrival_time_penalty": 1.5, 9 | "duration": 450, 10 | "unplanned_penalty": 2000000 11 | } 12 | }, 13 | "stops": [ 14 | { 15 | "id": "Fushimi Inari Taisha", 16 | "location": { "lon": 135.772695, "lat": 34.967146 }, 17 | "target_arrival_time": "2023-01-01T12:00:00Z" 18 | }, 19 | { 20 | "id": "Kiyomizu-dera", 21 | "location": { "lon": 135.78506, "lat": 34.994857 }, 22 | "target_arrival_time": "2023-01-01T12:05:00Z" 23 | }, 24 | { 25 | "id": "Nijō Castle", 26 | "location": { "lon": 135.748134, "lat": 35.014239 }, 27 | "target_arrival_time": "2023-01-01T12:10:00Z" 28 | }, 29 | { 30 | "id": "Kyoto Imperial Palace", 31 | "location": { "lon": 135.762057, "lat": 35.025431 }, 32 | "target_arrival_time": "2023-01-01T12:15:00Z" 33 | }, 34 | { 35 | "id": "Gionmachi", 36 | "location": { "lon": 135.775682, "lat": 35.002457 }, 37 | "target_arrival_time": "2023-01-01T12:20:00Z" 38 | }, 39 | { 40 | "id": "Kinkaku-ji", 41 | "location": { "lon": 135.728898, "lat": 35.039705 }, 42 | "target_arrival_time": "2023-01-01T12:25:00Z" 43 | }, 44 | { 45 | "id": "Arashiyama Bamboo Forest", 46 | "location": { "lon": 135.672009, "lat": 35.017209 }, 47 | "target_arrival_time": "2023-01-01T12:30:00Z" 48 | } 49 | ], 50 | "vehicles": [ 51 | { 52 | "id": "v1" 53 | }, 54 | { 55 | "id": "v2" 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /tests/golden/testdata/max_distance.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | }, 6 | "stops": { 7 | "unplanned_penalty": 2000000 8 | } 9 | }, 10 | "stops": [ 11 | { 12 | "id": "Fushimi Inari Taisha", 13 | "location": { "lon": 135.772695, "lat": 34.967146 } 14 | }, 15 | { 16 | "id": "Kiyomizu-dera", 17 | "location": { "lon": 135.78506, "lat": 34.994857 } 18 | }, 19 | { 20 | "id": "Nijō Castle", 21 | "location": { "lon": 135.748134, "lat": 35.014239 } 22 | }, 23 | { 24 | "id": "Kyoto Imperial Palace", 25 | "location": { "lon": 135.762057, "lat": 35.025431 } 26 | }, 27 | { 28 | "id": "Gionmachi", 29 | "location": { "lon": 135.775682, "lat": 35.002457 } 30 | }, 31 | { 32 | "id": "Kinkaku-ji", 33 | "location": { "lon": 135.728898, "lat": 35.039705 } 34 | }, 35 | { 36 | "id": "Arashiyama Bamboo Forest", 37 | "location": { "lon": 135.672009, "lat": 35.017209 } 38 | } 39 | ], 40 | "vehicles": [ 41 | { 42 | "id": "v1", 43 | "max_distance": 3000 44 | }, 45 | { 46 | "id": "v2", 47 | "max_distance": 4000 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /tests/golden/testdata/max_duration.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 5, 5 | "start_time": "2023-01-01T12:00:00Z" 6 | }, 7 | "stops": { 8 | "unplanned_penalty": 20000, 9 | "duration": 300 10 | } 11 | }, 12 | "stops": [ 13 | { 14 | "id": "Fushimi Inari Taisha", 15 | "location": { "lon": 135.772695, "lat": 34.967146 } 16 | }, 17 | { 18 | "id": "Kiyomizu-dera", 19 | "location": { "lon": 135.78506, "lat": 34.994857 } 20 | }, 21 | { 22 | "id": "Nijō Castle", 23 | "location": { "lon": 135.748134, "lat": 35.014239 } 24 | }, 25 | { 26 | "id": "Kyoto Imperial Palace", 27 | "location": { "lon": 135.762057, "lat": 35.025431 } 28 | }, 29 | { 30 | "id": "Gionmachi", 31 | "location": { "lon": 135.775682, "lat": 35.002457 } 32 | }, 33 | { 34 | "id": "Kinkaku-ji", 35 | "location": { "lon": 135.728898, "lat": 35.039705 } 36 | }, 37 | { 38 | "id": "Arashiyama Bamboo Forest", 39 | "location": { "lon": 135.672009, "lat": 35.017209 } 40 | } 41 | ], 42 | "vehicles": [ 43 | { 44 | "id": "v1", 45 | "max_duration": 1800 46 | }, 47 | { 48 | "id": "v2", 49 | "max_duration": 1200 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /tests/golden/testdata/max_stops.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | }, 6 | "stops": { 7 | "unplanned_penalty": 20000 8 | } 9 | }, 10 | "stops": [ 11 | { 12 | "id": "Fushimi Inari Taisha", 13 | "location": { "lon": 135.772695, "lat": 34.967146 } 14 | }, 15 | { 16 | "id": "Kiyomizu-dera", 17 | "location": { "lon": 135.78506, "lat": 34.994857 } 18 | }, 19 | { 20 | "id": "Nijō Castle", 21 | "location": { "lon": 135.748134, "lat": 35.014239 } 22 | }, 23 | { 24 | "id": "Kyoto Imperial Palace", 25 | "location": { "lon": 135.762057, "lat": 35.025431 } 26 | }, 27 | { 28 | "id": "Gionmachi", 29 | "location": { "lon": 135.775682, "lat": 35.002457 } 30 | }, 31 | { 32 | "id": "Kinkaku-ji", 33 | "location": { "lon": 135.728898, "lat": 35.039705 } 34 | }, 35 | { 36 | "id": "Arashiyama Bamboo Forest", 37 | "location": { "lon": 135.672009, "lat": 35.017209 } 38 | } 39 | ], 40 | "vehicles": [ 41 | { 42 | "id": "v1", 43 | "max_stops": 2 44 | }, 45 | { 46 | "id": "v2", 47 | "max_stops": 3 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /tests/golden/testdata/max_wait_stop.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 15, 5 | "start_time": "2023-01-01T12:00:00Z" 6 | }, 7 | "stops": { 8 | "unplanned_penalty": 20000, 9 | "duration": 10, 10 | "max_wait": 1800 11 | } 12 | }, 13 | "stops": [ 14 | { 15 | "id": "Kyoto Imperial Palace", 16 | "location": { "lon": 135.77159, "lat": 34.96714 }, 17 | "max_wait": 600, 18 | "start_time_window": ["2023-01-01T12:20:00Z", "2023-01-01T13:00:00Z"] 19 | }, 20 | { 21 | "id": "Gionmachi", 22 | "location": { "lon": 135.77159, "lat": 34.96714 }, 23 | "max_wait": 600, 24 | "start_time_window": ["2023-01-01T12:05:00Z", "2023-01-01T13:00:00Z"] 25 | }, 26 | { 27 | "id": "Kinkaku-ji", 28 | "location": { "lon": 135.77159, "lat": 34.96714 }, 29 | "start_time_window": ["2023-01-01T12:45:00Z", "2023-01-01T13:00:00Z"] 30 | }, 31 | { 32 | "id": "Arashiyama Bamboo Forest", 33 | "location": { "lon": 135.77159, "lat": 34.96714 } 34 | } 35 | ], 36 | "vehicles": [ 37 | { 38 | "id": "v1" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /tests/golden/testdata/max_wait_stop.md: -------------------------------------------------------------------------------- 1 | # Max wait time example (max_wait.json) 2 | 3 | This example demonstrates the use of the `max_wait` parameter to set a maximum 4 | wait time for a single stop. 5 | 6 | Find some notes about the example below: 7 | 8 | - The vehicles spend virtually all time waiting, since stops are so close and 9 | stop duration is short. 10 | - **Vehicles**: 11 | - `v1`: Starts its shift at 12:00. 12 | - **Stops**: 13 | - Defaults: stops have default `max_wait` of 30 minutes. 14 | - `Kyoto Imperial Palace`: Can't be serviced. The window opens _after_ the 15 | stop's maximum wait time and we can't spend sufficient time at other stops 16 | beforehand. 17 | - `Gionmachi`: Can be serviced. The window opens _before_ the stop's maximum 18 | wait time. 19 | - `Kinkaku-ji`: Can't be serviced. The _inherited_ maximum wait time is 30 20 | minutes, but the window opens _after_ that. Agaim, we can't spend sufficient 21 | time at other stops beforehand. 22 | - `Arashiyama Bamboo Forest`: Can be serviced. The stop has no window, so the 23 | maximum wait time is not relevant. 24 | -------------------------------------------------------------------------------- /tests/golden/testdata/max_wait_vehicle.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 15, 5 | "start_time": "2023-01-01T12:00:00Z", 6 | "max_wait": 600 7 | }, 8 | "stops": { 9 | "unplanned_penalty": 20000 10 | } 11 | }, 12 | "stops": [ 13 | { 14 | "id": "Fushimi Inari Taisha", 15 | "location": { "lon": 135.77159, "lat": 34.96714 }, 16 | "start_time_window": ["2023-01-01T12:45:00Z", "2023-01-01T13:00:00Z"] 17 | }, 18 | { 19 | "id": "Kiyomizu-dera", 20 | "location": { "lon": 135.77159, "lat": 34.96714 }, 21 | "start_time_window": ["2023-01-01T12:05:00Z", "2023-01-01T13:00:00Z"] 22 | }, 23 | { 24 | "id": "Nijō Castle", 25 | "location": { "lon": 135.77159, "lat": 34.96714 }, 26 | "start_time_window": ["2023-01-01T12:10:00Z", "2023-01-01T13:00:00Z"] 27 | }, 28 | { 29 | "id": "Kyoto Imperial Palace", 30 | "location": { "lon": 135.77159, "lat": 34.96714 }, 31 | "max_wait": 300, 32 | "start_time_window": ["2023-01-01T12:15:00Z", "2023-01-01T13:00:00Z"] 33 | }, 34 | { 35 | "id": "Gionmachi", 36 | "location": { "lon": 135.77159, "lat": 34.96714 }, 37 | "start_time_window": ["2023-01-01T12:05:00Z", "2023-01-01T13:00:00Z"] 38 | }, 39 | { 40 | "id": "Kinkaku-ji", 41 | "location": { "lon": 135.77159, "lat": 34.96714 }, 42 | "start_time_window": ["2023-01-01T12:20:00Z", "2023-01-01T13:00:00Z"] 43 | }, 44 | { 45 | "id": "Arashiyama Bamboo Forest", 46 | "location": { "lon": 135.77159, "lat": 34.96714 } 47 | } 48 | ], 49 | "vehicles": [ 50 | { 51 | "id": "v1" 52 | }, 53 | { 54 | "id": "v2", 55 | "max_wait": 1200 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /tests/golden/testdata/max_wait_vehicle.md: -------------------------------------------------------------------------------- 1 | # Max wait time example (max_wait.json) 2 | 3 | This example demonstrates the use of the `max_wait` parameter to set maximum 4 | accumulative wait times for vehicles. 5 | 6 | Find some notes about the example below: 7 | 8 | - The vehicles spend virtually all time waiting, since stops are so close and 9 | stop duration is short. 10 | - **Vehicles**: 11 | - `v1`: Has a maximum wait time of 30 minutes (inherited from defaults). 12 | - `v2`: Has a maximum wait time of 20 minutes. 13 | - **Stops**: 14 | - `Fushimi Inari Taisha`: Cannot be assigned, as the window opens _after_ the 15 | maximum accumulated time of all vehicles. 16 | - `Kiyomizu-dera`: Can only be serviced by the first vehicle, since the window 17 | opens after the maximum accumulated time of the second vehicle. 18 | - `Nijō Castle`: Can be serviced by both vehicles, since the window opens 19 | before the maximum accumulated time of both vehicles. 20 | - `Kyoto Imperial Palace`: Can only be serviced after waiting until the window 21 | opens at other stops beforehand (as it also has a per stop `max_wait`). 22 | - `Gionmachi`: Can be serviced by both vehicles, since its window is within 23 | accumulated vehicle and individual stop maxima. 24 | - `Kinkaku-ji`: Can be serviced, since the inherited individual stop maximum 25 | is not exceeded. 26 | - `Arashiyama Bamboo Forest`: Can be serviced, since it does not have a window 27 | that can cause waiting. 28 | -------------------------------------------------------------------------------- /tests/golden/testdata/min_stops.json: -------------------------------------------------------------------------------- 1 | { 2 | "options": { 3 | "min_stops": 1.0 4 | }, 5 | "defaults": { 6 | "vehicles": { 7 | "speed": 20, 8 | "min_stops": 3, 9 | "min_stops_penalty": 1000 10 | }, 11 | "stops": { 12 | "unplanned_penalty": 200000 13 | } 14 | }, 15 | "stops": [ 16 | { 17 | "id": "Fushimi Inari Taisha", 18 | "location": { 19 | "lon": 135.772695, 20 | "lat": 34.967146 21 | } 22 | }, 23 | { 24 | "id": "Kiyomizu-dera", 25 | "location": { 26 | "lon": 135.78506, 27 | "lat": 34.994857 28 | } 29 | }, 30 | { 31 | "id": "Nijō Castle", 32 | "location": { 33 | "lon": 135.748134, 34 | "lat": 35.014239 35 | } 36 | }, 37 | { 38 | "id": "Kyoto Imperial Palace", 39 | "location": { 40 | "lon": 135.762057, 41 | "lat": 35.025431 42 | } 43 | }, 44 | { 45 | "id": "Gionmachi", 46 | "location": { 47 | "lon": 135.775682, 48 | "lat": 35.002457 49 | } 50 | }, 51 | { 52 | "id": "Kinkaku-ji", 53 | "location": { 54 | "lon": 135.728898, 55 | "lat": 35.039705 56 | } 57 | }, 58 | { 59 | "id": "Arashiyama Bamboo Forest", 60 | "location": { 61 | "lon": 135.672009, 62 | "lat": 35.017209 63 | } 64 | } 65 | ], 66 | "vehicles": [ 67 | { 68 | "id": "v1" 69 | }, 70 | { 71 | "id": "v2" 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /tests/golden/testdata/multi_window.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 15, 5 | "start_time": "2023-01-01T12:00:00Z" 6 | }, 7 | "stops": { 8 | "unplanned_penalty": 20000, 9 | "duration": 600, 10 | "start_time_window": [ 11 | ["2023-01-01T12:00:00Z", "2023-01-01T12:05:00Z"], 12 | ["2023-01-01T12:30:00Z", "2023-01-01T13:35:00Z"] 13 | ] 14 | } 15 | }, 16 | "stops": [ 17 | { 18 | "id": "Kyoto Imperial Palace", 19 | "location": { "lon": 135.77159, "lat": 34.96714 } 20 | }, 21 | { 22 | "id": "Gionmachi", 23 | "location": { "lon": 135.77159, "lat": 34.96714 } 24 | }, 25 | { 26 | "id": "Kinkaku-ji", 27 | "location": { "lon": 135.77159, "lat": 34.96714 }, 28 | "start_time_window": ["2023-01-01T12:20:00Z", "2023-01-01T12:25:00Z"] 29 | } 30 | ], 31 | "vehicles": [ 32 | { 33 | "id": "v1" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /tests/golden/testdata/multi_window.md: -------------------------------------------------------------------------------- 1 | # Multi window example (multi_window.json) 2 | 3 | This example demonstrates the use of multiple service windows defined for stops. 4 | 5 | Find some notes about the example below: 6 | 7 | - The vehicles spend virtually all time waiting, since stops are so close and 8 | stop duration is short. 9 | - **Vehicles**: 10 | - `v1`: Vehicle starts when the earliest window opens. 11 | - **Stops**: 12 | - Defaults: stops have a default multi `start_time_window` that allows 13 | servicing between 12:00 & 12:05 and again between 12:30 & 12:35. 14 | - `Kyoto Imperial Palace` & `Gionmachi`: Share the same time windows, since 15 | they don't have a specific window defined. 16 | - `Kinkaku-ji`: Has a specific time window that allows servicing between 17 | 12:20 & 12:25. 18 | -------------------------------------------------------------------------------- /tests/golden/testdata/no_mix.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "precedes": "Kiyomizu-dera", 7 | "mixing_items": { 8 | "hazchem": { 9 | "name": "F-A-W-E", 10 | "quantity": 1 11 | } 12 | } 13 | }, 14 | { 15 | "id": "Kiyomizu-dera", 16 | "location": { "lon": 135.78506, "lat": 34.994857 }, 17 | "mixing_items": { 18 | "hazchem": { 19 | "name": "F-A-W-E", 20 | "quantity": -1 21 | } 22 | } 23 | }, 24 | { 25 | "id": "Nijō Castle", 26 | "location": { "lon": 135.748134, "lat": 35.014239 }, 27 | "precedes": "Kyoto Imperial Palace", 28 | "mixing_items": { 29 | "hazchem": { 30 | "name": "S-P-W-E", 31 | "quantity": 1 32 | } 33 | } 34 | }, 35 | { 36 | "id": "Kyoto Imperial Palace", 37 | "location": { "lon": 135.762057, "lat": 35.025431 }, 38 | "mixing_items": { 39 | "hazchem": { 40 | "name": "S-P-W-E", 41 | "quantity": -1 42 | } 43 | } 44 | }, 45 | { 46 | "id": "Gionmachi", 47 | "location": { "lon": 135.775682, "lat": 35.002457 }, 48 | "precedes": "Kinkaku-ji", 49 | "mixing_items": { 50 | "hazchem": { 51 | "name": "S-P-W-E", 52 | "quantity": 1 53 | } 54 | } 55 | }, 56 | { 57 | "id": "Kinkaku-ji", 58 | "location": { "lon": 135.728898, "lat": 35.039705 }, 59 | "mixing_items": { 60 | "hazchem": { 61 | "name": "S-P-W-E", 62 | "quantity": -1 63 | } 64 | } 65 | }, 66 | { 67 | "id": "Arashiyama Bamboo Forest", 68 | "location": { "lon": 135.672009, "lat": 35.017209 } 69 | } 70 | ], 71 | "vehicles": [ 72 | { 73 | "id": "v1", 74 | "speed": 20 75 | } 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /tests/golden/testdata/no_mix_null.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "north", 5 | "location": { 6 | "lon": 7.6256, 7 | "lat": 51.9714 8 | }, 9 | "precedes": "south", 10 | "mixing_items": { 11 | "main": { 12 | "name": "A", 13 | "quantity": 1 14 | } 15 | } 16 | }, 17 | { 18 | "id": "south", 19 | "location": { 20 | "lon": 7.6256, 21 | "lat": 51.9534 22 | }, 23 | "precedes": "south_west", 24 | "mixing_items": { 25 | "main": { 26 | "name": "A", 27 | "quantity": -1 28 | } 29 | } 30 | }, 31 | { 32 | "id": "east", 33 | "location": { 34 | "lon": 7.6402, 35 | "lat": 51.9624 36 | }, 37 | "precedes": "west", 38 | "mixing_items": { 39 | "main": { 40 | "name": "B", 41 | "quantity": 1 42 | } 43 | } 44 | }, 45 | { 46 | "id": "west", 47 | "location": { 48 | "lon": 7.611, 49 | "lat": 51.9624 50 | }, 51 | "precedes": "north_west", 52 | "mixing_items": { 53 | "main": { 54 | "name": "B", 55 | "quantity": -1 56 | } 57 | } 58 | }, 59 | { 60 | "id": "north_east", 61 | "location": { 62 | "lon": 7.6359, 63 | "lat": 51.9688 64 | }, 65 | "mixing_items": null 66 | }, 67 | { 68 | "id": "south_east", 69 | "location": { 70 | "lon": 7.6359, 71 | "lat": 51.956 72 | }, 73 | "mixing_items": null 74 | }, 75 | { 76 | "id": "south_west", 77 | "location": { 78 | "lon": 7.6153, 79 | "lat": 51.956 80 | } 81 | }, 82 | { 83 | "id": "north_west", 84 | "location": { 85 | "lon": 7.6153, 86 | "lat": 51.9688 87 | } 88 | } 89 | ], 90 | "vehicles": [ 91 | { 92 | "id": "truck", 93 | "speed": 20 94 | } 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /tests/golden/testdata/no_mix_null.md: -------------------------------------------------------------------------------- 1 | # Missing values in no mix constraint (no_mix_null.json) 2 | 3 | This example demonstrates missing values when defining a _no mix_ constraint. 4 | I.e., some of the stops either have no `mixing_items` defined or have it set to 5 | `null`. These stops can be planned anywhere in a route, independent of the 6 | current mix of items in the vehicle. 7 | 8 | Find some notes about the example below: 9 | 10 | - There is only one vehicle servicing all the stops. 11 | - Stops `north` and `south` belong to mixing group `A`. 12 | - Stops `east` and `west` belong to mixing group `B`. 13 | - The transports from north to south and from east to west cannot overlap. This 14 | means that a route like `north -> east -> south -> west` is not feasible. 15 | - All other stops can go anywhere in the route and should be planned in a way 16 | that minimizes the total travel time. 17 | - All stops are located on a circle with their names indicating the cardinal 18 | direction they are located at. This is useful for checking the time-efficiency 19 | of the solution. 20 | -------------------------------------------------------------------------------- /tests/golden/testdata/precedence.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "precedes": "Kiyomizu-dera" 7 | }, 8 | { 9 | "id": "Kiyomizu-dera", 10 | "location": { "lon": 135.78506, "lat": 34.994857 } 11 | }, 12 | { 13 | "id": "Nijō Castle", 14 | "location": { "lon": 135.748134, "lat": 35.014239 }, 15 | "succeeds": "Kiyomizu-dera" 16 | }, 17 | { 18 | "id": "Kyoto Imperial Palace", 19 | "location": { "lon": 135.762057, "lat": 35.025431 }, 20 | "precedes": [ 21 | { "id": "Gionmachi", "direct": true }, 22 | { "id": "Kinkaku-ji", "direct": false } 23 | ] 24 | }, 25 | { 26 | "id": "Gionmachi", 27 | "location": { "lon": 135.775682, "lat": 35.002457 } 28 | }, 29 | { 30 | "id": "Kinkaku-ji", 31 | "location": { "lon": 135.728898, "lat": 35.039705 } 32 | }, 33 | { 34 | "id": "Arashiyama Bamboo Forest", 35 | "location": { "lon": 135.672009, "lat": 35.017209 }, 36 | "succeeds": ["Gionmachi", "Kinkaku-ji"] 37 | } 38 | ], 39 | "vehicles": [ 40 | { 41 | "id": "v1", 42 | "speed": 20 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /tests/golden/testdata/precedence_pathologic.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "precedes": "Kiyomizu-dera" 7 | }, 8 | { 9 | "id": "Kiyomizu-dera", 10 | "location": { "lon": 135.78506, "lat": 34.994857 } 11 | }, 12 | { 13 | "id": "Nijō Castle", 14 | "location": { "lon": 135.748134, "lat": 35.014239 }, 15 | "precedes": "Kiyomizu-dera" 16 | }, 17 | { 18 | "id": "Kyoto Imperial Palace", 19 | "location": { "lon": 135.762057, "lat": 35.025431 }, 20 | "precedes": "Kiyomizu-dera" 21 | }, 22 | { 23 | "id": "Gionmachi", 24 | "location": { "lon": 135.775682, "lat": 35.002457 }, 25 | "precedes": "Kiyomizu-dera" 26 | }, 27 | { 28 | "id": "Kinkaku-ji", 29 | "location": { "lon": 135.728898, "lat": 35.039705 }, 30 | "precedes": "Kiyomizu-dera" 31 | }, 32 | { 33 | "id": "Arashiyama Bamboo Forest", 34 | "location": { "lon": 135.672009, "lat": 35.017209 }, 35 | "precedes": "Kiyomizu-dera" 36 | }, 37 | { 38 | "id": "Fushimi Inari Taisha Copy", 39 | "location": { "lon": 135.72696, "lat": 34.967146 }, 40 | "precedes": "Kiyomizu-dera" 41 | }, 42 | { 43 | "id": "Nijō Castle Copy", 44 | "location": { "lon": 135.748135, "lat": 35.014239 }, 45 | "precedes": "Kiyomizu-dera" 46 | }, 47 | { 48 | "id": "Kyoto Imperial Palace Copy", 49 | "location": { "lon": 135.762058, "lat": 35.025431 }, 50 | "precedes": "Kiyomizu-dera" 51 | }, 52 | { 53 | "id": "Gionmachi Copy", 54 | "location": { "lon": 135.775683, "lat": 35.002457 }, 55 | "precedes": "Kiyomizu-dera" 56 | }, 57 | { 58 | "id": "Kinkaku-ji Copy", 59 | "location": { "lon": 135.728899, "lat": 35.039705 }, 60 | "precedes": "Kiyomizu-dera" 61 | }, 62 | { 63 | "id": "Arashiyama Bamboo Forest Copy", 64 | "location": { "lon": 135.672008, "lat": 35.017209 }, 65 | "precedes": "Kiyomizu-dera" 66 | } 67 | ], 68 | "vehicles": [ 69 | { 70 | "id": "v1", 71 | "speed": 20 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /tests/golden/testdata/start_level.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | }, 6 | "stops": { 7 | "quantity": -1 8 | } 9 | }, 10 | "stops": [ 11 | { 12 | "id": "Fushimi Inari Taisha", 13 | "location": { "lon": 135.772695, "lat": 34.967146 } 14 | }, 15 | { 16 | "id": "Kiyomizu-dera", 17 | "location": { "lon": 135.78506, "lat": 34.994857 } 18 | }, 19 | { 20 | "id": "Nijō Castle", 21 | "location": { "lon": 135.748134, "lat": 35.014239 } 22 | }, 23 | { 24 | "id": "Kyoto Imperial Palace", 25 | "location": { "lon": 135.762057, "lat": 35.025431 } 26 | }, 27 | { 28 | "id": "Gionmachi", 29 | "location": { "lon": 135.775682, "lat": 35.002457 } 30 | }, 31 | { 32 | "id": "Kinkaku-ji", 33 | "location": { "lon": 135.728898, "lat": 35.039705 } 34 | }, 35 | { 36 | "id": "Arashiyama Bamboo Forest", 37 | "location": { "lon": 135.672009, "lat": 35.017209 } 38 | } 39 | ], 40 | "vehicles": [ 41 | { 42 | "id": "v1", 43 | "capacity": 4, 44 | "start_level": 0, 45 | "start_location": { "lon": 135.772695, "lat": 34.967146 } 46 | }, 47 | { 48 | "id": "v2", 49 | "capacity": 4, 50 | "start_level": 4, 51 | "start_location": { "lon": 135.728898, "lat": 35.039705 } 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /tests/golden/testdata/start_level.md: -------------------------------------------------------------------------------- 1 | # Start level example (start_level.json) 2 | 3 | This example demonstrates the use of the `start_level` parameter of the capacity 4 | feature. 5 | 6 | Find some notes about the example below: 7 | 8 | - All stops have a `-1` quantity (all pickups), hence, the vehicles can service 9 | as many as they have capacity but no more. 10 | - The first vehicle `v1` defines a start level of 0 and can therefore service 11 | as many stops as it has capacity. 12 | - The second vehicle `v2` defines a start level equal to its capacity and can 13 | therefore service no stops at all. 14 | -------------------------------------------------------------------------------- /tests/golden/testdata/start_time_window.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20, 5 | "start_time": "2023-01-01T12:00:00Z" 6 | }, 7 | "stops": { 8 | "duration": 300 9 | } 10 | }, 11 | "stops": [ 12 | { 13 | "id": "Fushimi Inari Taisha", 14 | "location": { "lon": 135.772695, "lat": 34.967146 }, 15 | "start_time_window": ["2023-01-01T12:00:00Z", "2023-01-01T12:05:00Z"] 16 | }, 17 | { 18 | "id": "Kiyomizu-dera", 19 | "location": { "lon": 135.78506, "lat": 34.994857 }, 20 | "start_time_window": ["2023-01-01T12:05:00Z", "2023-01-01T12:10:00Z"] 21 | }, 22 | { 23 | "id": "Nijō Castle", 24 | "location": { "lon": 135.748134, "lat": 35.014239 }, 25 | "start_time_window": ["2023-01-01T12:10:00Z", "2023-01-01T12:15:00Z"] 26 | }, 27 | { 28 | "id": "Kyoto Imperial Palace", 29 | "location": { "lon": 135.762057, "lat": 35.025431 }, 30 | "start_time_window": ["2023-01-01T12:15:00Z", "2023-01-01T12:20:00Z"] 31 | }, 32 | { 33 | "id": "Gionmachi", 34 | "location": { "lon": 135.775682, "lat": 35.002457 }, 35 | "start_time_window": ["2023-01-01T12:20:00Z", "2023-01-01T12:25:00Z"] 36 | }, 37 | { 38 | "id": "Kinkaku-ji", 39 | "location": { "lon": 135.728898, "lat": 35.039705 }, 40 | "start_time_window": ["2023-01-01T12:25:00Z", "2023-01-01T12:30:00Z"] 41 | }, 42 | { 43 | "id": "Arashiyama Bamboo Forest", 44 | "location": { "lon": 135.672009, "lat": 35.017209 }, 45 | "start_time_window": ["2023-01-01T12:30:00Z", "2023-01-01T12:35:00Z"] 46 | } 47 | ], 48 | "vehicles": [ 49 | { 50 | "id": "v1" 51 | }, 52 | { 53 | "id": "v2" 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /tests/golden/testdata/stop_duration.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "duration": 300 7 | }, 8 | { 9 | "id": "Kiyomizu-dera", 10 | "location": { "lon": 135.78506, "lat": 34.994857 }, 11 | "duration": 120 12 | }, 13 | { 14 | "id": "Nijō Castle", 15 | "location": { "lon": 135.748134, "lat": 35.014239 }, 16 | "duration": 180 17 | }, 18 | { 19 | "id": "Kyoto Imperial Palace", 20 | "location": { "lon": 135.762057, "lat": 35.025431 }, 21 | "duration": 600 22 | }, 23 | { 24 | "id": "Gionmachi", 25 | "location": { "lon": 135.775682, "lat": 35.002457 } 26 | }, 27 | { 28 | "id": "Kinkaku-ji", 29 | "location": { "lon": 135.728898, "lat": 35.039705 } 30 | }, 31 | { 32 | "id": "Arashiyama Bamboo Forest", 33 | "location": { "lon": 135.672009, "lat": 35.017209 } 34 | } 35 | ], 36 | "vehicles": [ 37 | { 38 | "id": "v1", 39 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 40 | "speed": 20, 41 | "start_time": "2023-01-01T12:00:00Z" 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /tests/golden/testdata/stop_duration_multiplier.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 }, 6 | "duration": 300 7 | }, 8 | { 9 | "id": "Kiyomizu-dera", 10 | "location": { "lon": 135.78506, "lat": 34.994857 }, 11 | "duration": 120 12 | }, 13 | { 14 | "id": "Nijō Castle", 15 | "location": { "lon": 135.748134, "lat": 35.014239 }, 16 | "duration": 180 17 | }, 18 | { 19 | "id": "Kyoto Imperial Palace", 20 | "location": { "lon": 135.762057, "lat": 35.025431 }, 21 | "duration": 600 22 | }, 23 | { 24 | "id": "Gionmachi", 25 | "location": { "lon": 135.775682, "lat": 35.002457 } 26 | }, 27 | { 28 | "id": "Kinkaku-ji", 29 | "location": { "lon": 135.728898, "lat": 35.039705 } 30 | }, 31 | { 32 | "id": "Arashiyama Bamboo Forest", 33 | "location": { "lon": 135.672009, "lat": 35.017209 } 34 | } 35 | ], 36 | "vehicles": [ 37 | { 38 | "id": "v1", 39 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 40 | "speed": 20, 41 | "start_time": "2023-01-01T12:00:00Z", 42 | "stop_duration_multiplier": 2 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /tests/golden/testdata/stop_groups.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stop_groups": [ 8 | ["Fushimi Inari Taisha", "Kiyomizu-dera"], 9 | ["Gionmachi", "Kinkaku-ji"], 10 | ["Arashiyama Bamboo Forest", "Nijō Castle"] 11 | ], 12 | "stops": [ 13 | { 14 | "id": "Fushimi Inari Taisha", 15 | "location": { "lon": 135.772695, "lat": 34.967146 } 16 | }, 17 | { 18 | "id": "Kiyomizu-dera", 19 | "location": { "lon": 135.78506, "lat": 34.994857 } 20 | }, 21 | { 22 | "id": "Nijō Castle", 23 | "location": { "lon": 135.748134, "lat": 35.014239 } 24 | }, 25 | { 26 | "id": "Kyoto Imperial Palace", 27 | "location": { "lon": 135.762057, "lat": 35.025431 } 28 | }, 29 | { 30 | "id": "Gionmachi", 31 | "location": { "lon": 135.775682, "lat": 35.002457 } 32 | }, 33 | { 34 | "id": "Kinkaku-ji", 35 | "location": { "lon": 135.728898, "lat": 35.039705 } 36 | }, 37 | { 38 | "id": "Arashiyama Bamboo Forest", 39 | "location": { "lon": 135.672009, "lat": 35.017209 } 40 | } 41 | ], 42 | "vehicles": [ 43 | { 44 | "id": "v1", 45 | "start_location": { 46 | "lon": 135.772695, 47 | "lat": 34.967146 48 | } 49 | }, 50 | { 51 | "id": "v2", 52 | "start_location": { 53 | "lon": 135.775682, 54 | "lat": 35.002457 55 | } 56 | }, 57 | { 58 | "id": "v3", 59 | "start_location": { 60 | "lon": 135.672009, 61 | "lat": 35.017209 62 | } 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /tests/golden/testdata/unplanned_penalty.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "Fushimi Inari Taisha", 10 | "location": { "lon": 135.772695, "lat": 34.967146 }, 11 | "unplanned_penalty": 10 12 | }, 13 | { 14 | "id": "Kiyomizu-dera", 15 | "location": { "lon": 135.78506, "lat": 34.994857 }, 16 | "unplanned_penalty": 10 17 | }, 18 | { 19 | "id": "Nijō Castle", 20 | "location": { "lon": 135.748134, "lat": 35.014239 }, 21 | "unplanned_penalty": 10 22 | }, 23 | { 24 | "id": "Kyoto Imperial Palace", 25 | "location": { "lon": 135.762057, "lat": 35.025431 }, 26 | "unplanned_penalty": 500000 27 | }, 28 | { 29 | "id": "Gionmachi", 30 | "location": { "lon": 135.775682, "lat": 35.002457 }, 31 | "unplanned_penalty": 500000 32 | }, 33 | { 34 | "id": "Kinkaku-ji", 35 | "location": { "lon": 135.728898, "lat": 35.039705 }, 36 | "unplanned_penalty": 500000 37 | }, 38 | { 39 | "id": "Arashiyama Bamboo Forest", 40 | "location": { "lon": 135.672009, "lat": 35.017209 }, 41 | "unplanned_penalty": 500000 42 | } 43 | ], 44 | "vehicles": [ 45 | { 46 | "id": "v1" 47 | }, 48 | { 49 | "id": "v2" 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /tests/golden/testdata/vehicle_start_end_location.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "Fushimi Inari Taisha", 10 | "location": { "lon": 135.772695, "lat": 34.967146 } 11 | }, 12 | { 13 | "id": "Kiyomizu-dera", 14 | "location": { "lon": 135.78506, "lat": 34.994857 } 15 | }, 16 | { 17 | "id": "Nijō Castle", 18 | "location": { "lon": 135.748134, "lat": 35.014239 } 19 | }, 20 | { 21 | "id": "Kyoto Imperial Palace", 22 | "location": { "lon": 135.762057, "lat": 35.025431 } 23 | }, 24 | { 25 | "id": "Kinkaku-ji", 26 | "location": { "lon": 135.728898, "lat": 35.039705 } 27 | }, 28 | { 29 | "id": "Arashiyama Bamboo Forest", 30 | "location": { "lon": 135.672009, "lat": 35.017209 } 31 | } 32 | ], 33 | "vehicles": [ 34 | { 35 | "id": "v1" 36 | }, 37 | { 38 | "id": "v2", 39 | "start_location": { 40 | "lon": 135.772695, 41 | "lat": 34.967146 42 | } 43 | }, 44 | { 45 | "id": "v3", 46 | "end_location": { 47 | "lon": 135.762057, 48 | "lat": 35.025431 49 | } 50 | }, 51 | { 52 | "id": "v4", 53 | "start_location": { 54 | "lon": 135.775683, 55 | "lat": 35.002458 56 | }, 57 | "end_location": { 58 | "lon": 135.775683, 59 | "lat": 35.002458 60 | } 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /tests/golden/testdata/vehicle_start_end_time.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 5 5 | }, 6 | "stops": { 7 | "duration": 600 8 | } 9 | }, 10 | "stops": [ 11 | { 12 | "id": "Fushimi Inari Taisha", 13 | "location": { "lon": 135.772695, "lat": 34.967146 } 14 | }, 15 | { 16 | "id": "Kiyomizu-dera", 17 | "location": { "lon": 135.78506, "lat": 34.994857 } 18 | }, 19 | { 20 | "id": "Nijō Castle", 21 | "location": { "lon": 135.748134, "lat": 35.014239 } 22 | }, 23 | { 24 | "id": "Kyoto Imperial Palace", 25 | "location": { "lon": 135.762057, "lat": 35.025431 } 26 | }, 27 | { 28 | "id": "Gionmachi", 29 | "location": { "lon": 135.775682, "lat": 35.002457 } 30 | }, 31 | { 32 | "id": "Kinkaku-ji", 33 | "location": { "lon": 135.728898, "lat": 35.039705 } 34 | }, 35 | { 36 | "id": "Arashiyama Bamboo Forest", 37 | "location": { "lon": 135.672009, "lat": 35.017209 } 38 | } 39 | ], 40 | "vehicles": [ 41 | { 42 | "id": "v1" 43 | }, 44 | { 45 | "id": "v2", 46 | "start_location": { 47 | "lon": 135.772695, 48 | "lat": 34.967146 49 | }, 50 | "start_time": "2023-01-01T12:00:00Z" 51 | }, 52 | { 53 | "id": "v3", 54 | "end_location": { 55 | "lon": 135.762057, 56 | "lat": 35.025431 57 | }, 58 | "start_time": "2023-01-01T12:00:00Z", 59 | "end_time": "2023-01-01T12:20:00Z" 60 | }, 61 | { 62 | "id": "v4", 63 | "start_location": { 64 | "lon": 135.775683, 65 | "lat": 35.002458 66 | }, 67 | "end_location": { 68 | "lon": 135.775683, 69 | "lat": 35.002458 70 | }, 71 | "start_time": "2023-01-01T12:00:00Z", 72 | "end_time": "2023-01-01T12:30:00Z" 73 | } 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /tests/golden/testdata/vehicles_duration_objective.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20 5 | } 6 | }, 7 | "stops": [ 8 | { 9 | "id": "Kyoto Imperial Palace", 10 | "location": { "lon": 135.762057, "lat": 35.025431 }, 11 | "start_time_window": ["2023-01-01T12:05:00Z", "2023-01-01T12:10:00Z"] 12 | } 13 | ], 14 | "vehicles": [ 15 | { 16 | "id": "v1", 17 | "start_time": "2023-01-01T12:00:00Z", 18 | "start_location": { "lon": 135.672009, "lat": 35.017209 } 19 | }, 20 | { 21 | "id": "v2", 22 | "start_time": "2023-01-01T09:00:00Z", 23 | "start_location": { "lon": 135.728898, "lat": 35.039705 } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tests/inline_options/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "stops": [ 3 | { 4 | "id": "Fushimi Inari Taisha", 5 | "location": { "lon": 135.772695, "lat": 34.967146 } 6 | }, 7 | { 8 | "id": "Kiyomizu-dera", 9 | "location": { "lon": 135.78506, "lat": 34.994857 } 10 | }, 11 | { 12 | "id": "Nijō Castle", 13 | "location": { "lon": 135.748134, "lat": 35.014239 } 14 | }, 15 | { 16 | "id": "Kyoto Imperial Palace", 17 | "location": { "lon": 135.762057, "lat": 35.025431 } 18 | }, 19 | { 20 | "id": "Gionmachi", 21 | "location": { "lon": 135.775682, "lat": 35.002457 } 22 | }, 23 | { 24 | "id": "Kinkaku-ji", 25 | "location": { "lon": 135.728898, "lat": 35.039705 } 26 | }, 27 | { 28 | "id": "Arashiyama Bamboo Forest", 29 | "location": { "lon": 135.672009, "lat": 35.017209 } 30 | } 31 | ], 32 | "vehicles": [ 33 | { 34 | "id": "v1", 35 | "start_location": { "lon": 135.672009, "lat": 35.017209 }, 36 | "speed": 20 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tests/inline_options/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | golden.Setup() 14 | code := m.Run() 15 | golden.Teardown() 16 | os.Exit(code) 17 | } 18 | 19 | // TestGolden executes a golden file test, where the .json input is fed and an 20 | // output is expected. 21 | func TestGolden(t *testing.T) { 22 | golden.FileTests( 23 | t, 24 | "input.json", 25 | golden.Config{ 26 | Args: []string{ 27 | "-solve.duration", "10s", 28 | "-format.disable.progression", 29 | "-solve.parallelruns", "1", 30 | "-solve.iterations", "50", 31 | "-solve.rundeterministically", 32 | "-solve.startsolutions", "1", 33 | }, 34 | TransientFields: []golden.TransientField{ 35 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 36 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 37 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 38 | }, 39 | Thresholds: golden.Tresholds{ 40 | Float: 0.01, 41 | }, 42 | }, 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /tests/output_options/main.sh: -------------------------------------------------------------------------------- 1 | go run . \ 2 | -solve.duration 10s \ 3 | -format.disable.progression \ 4 | -solve.parallelruns 1 \ 5 | -solve.iterations 50 \ 6 | -solve.rundeterministically \ 7 | -solve.startsolutions 1 \ 8 | -runner.input.path ../golden/testdata/template_input.json 2>/dev/null | jq ".options" # Silence bunny output, since we're only interested in the options 9 | -------------------------------------------------------------------------------- /tests/output_options/main.sh.golden: -------------------------------------------------------------------------------- 1 | { 2 | "model": { 3 | "constraints": { 4 | "disable": { 5 | "attributes": false, 6 | "capacity": false, 7 | "capacities": null, 8 | "distance_limit": false, 9 | "groups": false, 10 | "maximum_duration": false, 11 | "maximum_stops": false, 12 | "maximum_wait_stop": false, 13 | "maximum_wait_vehicle": false, 14 | "mixing_items": false, 15 | "precedence": false, 16 | "vehicle_start_time": false, 17 | "vehicle_end_time": false, 18 | "start_time_windows": false 19 | }, 20 | "enable": { 21 | "cluster": false 22 | } 23 | }, 24 | "objectives": { 25 | "capacities": "", 26 | "min_stops": 1, 27 | "early_arrival_penalty": 1, 28 | "late_arrival_penalty": 1, 29 | "vehicle_activation_penalty": 1, 30 | "travel_duration": 0, 31 | "vehicles_duration": 1, 32 | "unplanned_penalty": 1, 33 | "cluster": 0, 34 | "stop_balance": 0 35 | }, 36 | "properties": { 37 | "disable": { 38 | "durations": false, 39 | "stop_duration_multipliers": false, 40 | "duration_groups": false, 41 | "initial_solution": false 42 | }, 43 | "maximum_time_horizon": 15552000 44 | }, 45 | "validate": { 46 | "disable": { 47 | "start_time": false, 48 | "resources": false 49 | }, 50 | "enable": { 51 | "matrix": false, 52 | "matrix_asymmetry_tolerance": 20 53 | } 54 | } 55 | }, 56 | "solve": { 57 | "iterations": 50, 58 | "duration": 10000000000, 59 | "plateau": { 60 | "delay": 0, 61 | "duration": 0, 62 | "iterations": 0, 63 | "relative_threshold": 0, 64 | "absolute_threshold": -1 65 | }, 66 | "parallel_runs": 1, 67 | "start_solutions": 1, 68 | "run_deterministically": true 69 | }, 70 | "format": { 71 | "disable": { 72 | "progression": true 73 | } 74 | }, 75 | "check": { 76 | "duration": 30000000000, 77 | "verbosity": "off" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/output_options/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | cleanUp() 14 | golden.CopyFile("../../cmd/main.go", "main.go") 15 | code := m.Run() 16 | cleanUp() 17 | os.Exit(code) 18 | } 19 | 20 | // TestOptions tests showing the options repeated on the output. 21 | func TestOptions(t *testing.T) { 22 | golden.BashTest(t, ".", golden.BashConfig{ 23 | DisplayStdout: true, 24 | DisplayStderr: true, 25 | }) 26 | } 27 | 28 | func cleanUp() { 29 | golden.Reset([]string{ 30 | "testdata", 31 | "main_test.go", 32 | "main.sh", 33 | "main.sh.golden", 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /tests/plateau_stopping_criterion/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaults": { 3 | "vehicles": { 4 | "speed": 20, 5 | "start_time": "2023-01-01T06:00:00-06:00", 6 | "end_time": "2023-01-01T10:00:00-06:00" 7 | } 8 | }, 9 | "stops": [ 10 | { 11 | "id": "Fushimi Inari Taisha", 12 | "location": { "lon": 135.772695, "lat": 34.967146 } 13 | } 14 | ], 15 | "vehicles": [ 16 | { 17 | "id": "v1", 18 | "start_location": { "lon": 135.672009, "lat": 35.017209 } 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /tests/plateau_stopping_criterion/main.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // package main holds the implementation of the nextroute template. 4 | package main 5 | 6 | import ( 7 | "context" 8 | "log" 9 | 10 | "github.com/nextmv-io/nextroute" 11 | "github.com/nextmv-io/nextroute/check" 12 | "github.com/nextmv-io/nextroute/factory" 13 | "github.com/nextmv-io/nextroute/schema" 14 | "github.com/nextmv-io/sdk/run" 15 | runSchema "github.com/nextmv-io/sdk/run/schema" 16 | ) 17 | 18 | func main() { 19 | runner := run.CLI(solver) 20 | err := runner.Run(context.Background()) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | } 25 | 26 | type options struct { 27 | Model factory.Options `json:"model,omitempty"` 28 | Solve nextroute.ParallelSolveOptions `json:"solve,omitempty"` 29 | Format nextroute.FormatOptions `json:"format,omitempty"` 30 | Check check.Options `json:"check,omitempty"` 31 | } 32 | 33 | func solver( 34 | ctx context.Context, 35 | input schema.Input, 36 | options options, 37 | ) (runSchema.Output, error) { 38 | model, err := factory.NewModel(input, options.Model) 39 | if err != nil { 40 | return runSchema.Output{}, err 41 | } 42 | 43 | solver, err := nextroute.NewParallelSolver(model) 44 | if err != nil { 45 | return runSchema.Output{}, err 46 | } 47 | 48 | solutions, err := solver.Solve(ctx, options.Solve) 49 | if err != nil { 50 | return runSchema.Output{}, err 51 | } 52 | last, err := solutions.Last() 53 | if err != nil { 54 | return runSchema.Output{}, err 55 | } 56 | 57 | output, err := check.Format(ctx, options, options.Check, solver, last) 58 | if err != nil { 59 | return runSchema.Output{}, err 60 | } 61 | output.Statistics.Result.Custom = factory.DefaultCustomResultStatistics(last) 62 | 63 | return output, nil 64 | } 65 | -------------------------------------------------------------------------------- /tests/stop_balancing_objective/main.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | // package main holds the implementation of the nextroute template. 4 | package main 5 | 6 | import ( 7 | "context" 8 | "log" 9 | 10 | "github.com/nextmv-io/nextroute" 11 | "github.com/nextmv-io/nextroute/check" 12 | "github.com/nextmv-io/nextroute/factory" 13 | "github.com/nextmv-io/nextroute/schema" 14 | "github.com/nextmv-io/sdk/run" 15 | runSchema "github.com/nextmv-io/sdk/run/schema" 16 | ) 17 | 18 | func main() { 19 | runner := run.CLI(solver) 20 | err := runner.Run(context.Background()) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | } 25 | 26 | type options struct { 27 | Model factory.Options `json:"model,omitempty"` 28 | Solve nextroute.ParallelSolveOptions `json:"solve,omitempty"` 29 | Format nextroute.FormatOptions `json:"format,omitempty"` 30 | Check check.Options `json:"check,omitempty"` 31 | } 32 | 33 | func solver( 34 | ctx context.Context, 35 | input schema.Input, 36 | options options, 37 | ) (runSchema.Output, error) { 38 | // options.Model.Objectives.StopBalance = 1000.0 39 | model, err := factory.NewModel(input, options.Model) 40 | if err != nil { 41 | return runSchema.Output{}, err 42 | } 43 | 44 | solver, err := nextroute.NewParallelSolver(model) 45 | if err != nil { 46 | return runSchema.Output{}, err 47 | } 48 | 49 | solutions, err := solver.Solve(ctx, options.Solve) 50 | if err != nil { 51 | return runSchema.Output{}, err 52 | } 53 | last, err := solutions.Last() 54 | if err != nil { 55 | return runSchema.Output{}, err 56 | } 57 | 58 | output, err := check.Format(ctx, options, options.Check, solver, last) 59 | if err != nil { 60 | return runSchema.Output{}, err 61 | } 62 | output.Statistics.Result.Custom = factory.DefaultCustomResultStatistics(last) 63 | 64 | return output, nil 65 | } 66 | -------------------------------------------------------------------------------- /tests/stop_balancing_objective/main_test.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/nextmv-io/sdk/golden" 10 | ) 11 | 12 | func TestMain(m *testing.M) { 13 | golden.Setup() 14 | code := m.Run() 15 | golden.Teardown() 16 | os.Exit(code) 17 | } 18 | 19 | // TestGolden executes a golden file test, where the .json input is fed and an 20 | // output is expected. 21 | func TestGolden(t *testing.T) { 22 | golden.FileTests( 23 | t, 24 | "input.json", 25 | golden.Config{ 26 | Args: []string{ 27 | "-solve.duration", "10s", 28 | "-format.disable.progression", 29 | "-solve.parallelruns", "1", 30 | "-solve.iterations", "10000", 31 | "-solve.rundeterministically", 32 | "-solve.startsolutions", "1", 33 | "-model.objectives.stopbalance", "1000.0", 34 | }, 35 | TransientFields: []golden.TransientField{ 36 | {Key: "$.version.sdk", Replacement: golden.StableVersion}, 37 | {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, 38 | {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, 39 | }, 40 | Thresholds: golden.Tresholds{ 41 | Float: 0.01, 42 | }, 43 | }, 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | // © 2019-present nextmv.io inc 2 | 3 | package nextroute 4 | 5 | import ( 6 | _ "embed" 7 | "strings" 8 | ) 9 | 10 | //go:embed VERSION 11 | var version string 12 | 13 | // Version returns the version of the nextroute module. 14 | func Version() string { 15 | return strings.TrimSpace(version) 16 | } 17 | --------------------------------------------------------------------------------