diff --git a/.github/workflows/go-test-lint.yml b/.github/workflows/go-test-lint.yml index ec2691e..0deb49e 100644 --- a/.github/workflows/go-test-lint.yml +++ b/.github/workflows/go-test-lint.yml @@ -29,5 +29,5 @@ jobs: uses: actions/setup-go@v5 with: go-version: ${{ env.GO_VERSION }} - - name: go test - run: go test ./... + - name: go test (exclude golden file tests from Python package) + run: go test $(go list ./... | grep -v github.com/nextmv-io/nextroute/src) diff --git a/.github/workflows/header.yml b/.github/workflows/header.yml index 0d292dd..d52eecc 100644 --- a/.github/workflows/header.yml +++ b/.github/workflows/header.yml @@ -3,6 +3,10 @@ on: [push] jobs: check-header: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: ["go", "python"] steps: - name: git clone uses: actions/checkout@v4 @@ -12,6 +16,6 @@ jobs: with: python-version: "3.12" - - name: check header in .go files + - name: check header in ${{ matrix.language }} files run: | - python .nextmv/check_header.py + HEADER_CHECK_LANGUAGE=${{ matrix.language }} python .nextmv/check_header.py diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml new file mode 100644 index 0000000..987d7f1 --- /dev/null +++ b/.github/workflows/python-lint.yml @@ -0,0 +1,25 @@ +name: python lint +on: [push] +jobs: + python-lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + steps: + - name: git clone + uses: actions/checkout@v4 + + - name: set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: lint with ruff + run: ruff check --output-format=github -v . diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml new file mode 100644 index 0000000..31d6433 --- /dev/null +++ b/.github/workflows/python-test.yml @@ -0,0 +1,49 @@ +name: python test +on: [push] + +env: + GO_VERSION: 1.23 + +jobs: + python-test: + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + platform: [ubuntu-latest, windows-latest, macos-latest, macos-13] + steps: + - name: git clone + uses: actions/checkout@v4 + + - name: set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Python unit tests + run: python -m unittest + working-directory: src + + # There appears to be a bug around + # `nextmv-io/sdk@v1.8.0/golden/file.go:75` specifically in Windows. When + # attempting to remove a temp file, the following error is encountered: + # `panic: remove C:\Users\RUNNER~1\AppData\Local\Temp\output1368198263: + # The process cannot access the file because it is being used by another + # process.` We need to figure out why it only happens in Windows. Until + # then, we will not run the golden file tests in Windows. + # Source:https://github.com/nextmv-io/nextroute/actions/runs/11414952328/job/31764458969?pr=65 + - name: set up Go + if: ${{ matrix.platform != 'windows-latest' }} + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: golden file tests from Python package + if: ${{ matrix.platform != 'windows-latest' }} + run: go test $(go list ./... | grep github.com/nextmv-io/nextroute/src) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e00251..ea55228 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ name: release +run-name: Release ${{ inputs.VERSION }} (pre-release - ${{ inputs.IS_PRE_RELEASE }}) by @${{ github.actor }} from ${{ github.ref_name }} on: workflow_dispatch: @@ -12,8 +13,12 @@ on: default: true type: boolean +env: + GO_VERSION: 1.23 + PYTHON_VERSION: 3.12 + jobs: - release: + bump-version: runs-on: ubuntu-latest env: VERSION: ${{ inputs.VERSION }} @@ -36,6 +41,24 @@ jobs: exit 1 fi fi + + - name: ensure version is not already released + run: | + if git ls-remote --tags origin | grep -q "refs/tags/$VERSION"; then + echo "Version $VERSION already exists" + exit 1 + fi + + - name: set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: set up go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + - name: configure git with the bot credentials run: | mkdir -p ~/.ssh @@ -58,8 +81,29 @@ jobs: git rev-parse --short HEAD - - name: push release tag + - name: install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + working-directory: ./nextroute + + - name: bump version in version file for Go run: | + echo $VERSION > VERSION + working-directory: ./nextroute + + - name: upgrade version with hatch for Python + run: hatch version ${{ env.VERSION }} + working-directory: ./nextroute + + - name: commit version bump + run: | + git add VERSION + git add src/nextroute/__about__.py + + git commit -S -m "Bump version to $VERSION" + git push + git tag $VERSION git push origin $VERSION working-directory: ./nextroute @@ -76,3 +120,139 @@ jobs: --generate-notes \ --title $VERSION $PRERELEASE_FLAG working-directory: ./nextroute + + build-sdist: + name: wheels-sdist + needs: bump-version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + + - name: Build sdist + run: pipx run build --sdist + + - name: Check metadata + run: pipx run twine check dist/* + + - uses: actions/upload-artifact@v4 + with: + name: wheels-artifacts-sdist + path: dist/*.tar.gz + + build-wheels: + name: wheels-${{ matrix.platform }} + needs: bump-version + runs-on: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: + include: + - image: ubuntu-latest + platform: linux + - image: macos-13 + platform: macos-amd64 + - image: macos-14 + platform: macos-arm64 + - image: windows-latest + platform: windows + + steps: + - name: git clone ${{ github.ref_name }} + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + + - name: set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: set up go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Set up QEMU + if: matrix.platform == 'linux' + uses: docker/setup-qemu-action@v3 + with: + platforms: all + + - name: Build wheels + if: matrix.platform != 'macos-arm64' + uses: pypa/cibuildwheel@v2.21.3 + env: + MACOSX_DEPLOYMENT_TARGET: 13.0 + + - name: Build wheels + if: matrix.platform == 'macos-arm64' + uses: pypa/cibuildwheel@v2.21.3 + env: + # TODO: default wheel repair does not recognize the arm64 wheel. + # This seems like a bug in delocate-wheel, which is the tool used by cibuildwheel, + # since the binary works fine when installed. However, we skip a more thorough + # investigation for now and just disable the repair step. + CIBW_REPAIR_WHEEL_COMMAND: "" + MACOSX_DEPLOYMENT_TARGET: 14.0 + + - name: Verify clean directory + run: git diff --exit-code + shell: bash + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-artifacts-${{ matrix.platform }} + path: wheelhouse/*.whl + + release: + runs-on: ubuntu-latest + needs: [build-wheels, build-sdist] + strategy: + matrix: + include: + - target-env: pypi + target-url: https://pypi.org/p/nextroute + - target-env: testpypi + target-url: https://test.pypi.org/p/nextroute + environment: + name: ${{ matrix.target-env }} + url: ${{ matrix.target-url }} + permissions: + contents: read + id-token: write # This is required for trusted publishing to PyPI + steps: + - uses: actions/download-artifact@v4 + with: + merge-multiple: true + path: dist + + - name: Print directory tree for reference + uses: jaywcjlove/github-action-folder-tree@main + with: + path: ./ + + - name: Publish package distributions to PyPI + if: ${{ matrix.target-env == 'pypi' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: ./dist + + - name: Publish package distributions to TestPyPI + if: ${{ matrix.target-env == 'testpypi' }} + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + packages-dir: ./dist + + notify: + runs-on: ubuntu-latest + needs: release + if: ${{ needs.release.result == 'success' && inputs.IS_PRE_RELEASE == false }} + steps: + - name: notify slack + run: | + export DATA="{\"text\":\"Release notification - nextroute ${{ inputs.VERSION }} (see / )\"}" + curl -X POST -H 'Content-type: application/json' --data "$DATA" ${{ secrets.SLACK_URL_MISSION_CONTROL }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f33dcae --- /dev/null +++ b/.gitignore @@ -0,0 +1,171 @@ +*.html +*.png + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv*/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# VSCode +.vscode/ + +# Ignore the nextmv binary we build for the Python wheels +nextroute.exe +src/nextroute/bin/ +wheelhouse/ diff --git a/.golangci.yml b/.golangci.yml index a8439f6..81c308e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -320,3 +320,17 @@ issues: - path: solution_sequence_generator\.go linters: - nestif + - path: factory/vehicles\.go + linters: + - gocyclo + # 'vehicle_ids' want 'vehicle_i_ds' <- no, we don't want this + - path: schema/input\.go + linters: + - tagliatelle + # We do not need the context check here + - path: check/format\.go + linters: + - contextcheck + - path: factory/validate\.go + linters: + - nestif diff --git a/.nextmv/add_header.py b/.nextmv/add_header.py index 1da107c..9327f9f 100644 --- a/.nextmv/add_header.py +++ b/.nextmv/add_header.py @@ -10,7 +10,7 @@ missing = [] checked = 0 for file in go_files: - with open(file, "r") as f: + with open(file) as f: first_line = f.readline().strip() if first_line != HEADER: missing.append(file) diff --git a/.nextmv/check_header.py b/.nextmv/check_header.py index cb28bdd..b75f4fd 100644 --- a/.nextmv/check_header.py +++ b/.nextmv/check_header.py @@ -1,27 +1,61 @@ # Description: This script checks if the header is present in all go files. import glob +import os import sys -HEADER = "// © 2019-present nextmv.io inc" - -# List all go files in all subdirectories -go_files = glob.glob("**/*.go", recursive=True) - -# Check if the header is the first line of each file -missing = [] -checked = 0 -for file in go_files: - with open(file, "r") as f: - first_line = f.readline().strip() - if first_line != HEADER: - missing.append(file) - checked += 1 - -# Print the results -if missing: - print(f"Missing header in {len(missing)} of {checked} files:") - for file in missing: - print(f" {file}") - sys.exit(1) -else: - print(f"Header is present in all {checked} files") +HEADER = "© 2019-present nextmv.io inc" + +GO_HEADER = f"// {HEADER}" +GO_IGNORE = [] + +PYTHON_HEADER = f"# {HEADER}" +PYTHON_IGNORE = ["venv/*", "src/tests/*"] + + +def main() -> None: + """Checks if the header is present all files, for the given language.""" + + check_var = os.getenv("HEADER_CHECK_LANGUAGE", "go") + if check_var == "go": + files = glob.glob("**/*.go", recursive=True) + header = GO_HEADER + ignore = GO_IGNORE + elif check_var == "python": + files = glob.glob("**/*.py", recursive=True) + header = PYTHON_HEADER + ignore = PYTHON_IGNORE + else: + raise ValueError(f"Unsupported language: {check_var}") + + check(files, header, ignore) + + +def check(files: list[str], header: str, ignore: list[str]) -> None: + """Checks if the header is present in all files.""" + + # Check if the header is the first line of each file + missing = [] + checked = 0 + for file in files: + # Check if the path is in the ignore list with a glob pattern. + if any(glob.fnmatch.fnmatch(file, pattern) for pattern in ignore): + continue + + with open(file) as f: + first_line = f.readline().strip() + if first_line != header: + missing.append(file) + checked += 1 + + # Print the results + if missing: + print(f"Missing header in {len(missing)} of {checked} files:") + for file in missing: + print(f" {file}") + sys.exit(1) + else: + print(f"Header is present in all {checked} files") + + +if __name__ == "__main__": + main() diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..042080a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "useTabs": false, + "overrides": [ + { + "files": "*.yml", + "options": { + "tabWidth": 2, + "singleQuote": false + } + } + ] +} diff --git a/.prettierrc.yml b/.prettierrc.yml deleted file mode 100644 index 0f4487e..0000000 --- a/.prettierrc.yml +++ /dev/null @@ -1 +0,0 @@ -tabWidth: 2 diff --git a/LICENSE.md b/LICENSE similarity index 100% rename from LICENSE.md rename to LICENSE diff --git a/README.md b/README.md index df4c0b6..1f600f0 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,24 @@ -# nextroute +# Nextroute -Welcome to Nextmv's **nextroute**, a feature-rich Vehicle Routing Problem (VRP) +Welcome to Nextmv's **Nextroute**, a feature-rich Vehicle Routing Problem (VRP) solver written in pure Go. Designed with a focus on maintainability, -feature-richness, and extensibility, nextroute is built to handle real-world +feature-richness, and extensibility, Nextroute is built to handle real-world applications across [all platforms that Go (cross)compiles to](https://go.dev/doc/install/source#environment). Our goal is not to compete on specific VRP type benchmarks, but to provide a robust and versatile tool that can adapt to a variety of routing use-cases. Whether you're optimizing the routes for a small fleet of delivery vans in a -city or managing complex logistics for a global supply chain, nextroute is +city or managing complex logistics for a global supply chain, Nextroute is equipped to help you find efficient solutions. +You can work with Nextroute in a variety of ways: + +* Go package: Import the `nextroute` package in your Go project and use the + solver directly. +* Python package: Use the `nextroute` Python package as an interface to the Go + solver. + ## Features | Feature | Description | @@ -53,13 +60,38 @@ equipped to help you find efficient solutions. ## License -Please note that nextroute is provided as _source-available_ software (not +Please note that Nextroute is provided as _source-available_ software (not _open-source_). For further information, please refer to the [LICENSE](./LICENSE.md) file. +## Installation + +* Go + + Install the Go package with the following command: + + ```bash + go get github.com/nextmv-io/nextroute + ``` + +* Python + + Install the Python package with the following command: + + ```bash + pip install nextroute + ``` + ## Usage -A first run can be done with the following command: +For further information on how to get started, features, deployment, etc., +please refer to the [official +documentation](https://www.nextmv.io/docs/vehicle-routing/get-started). + +### Go + +A first run can be done with the following command. Stand at the root of the +repository and run: ```bash go run cmd/main.go -runner.input.path cmd/input.json -solve.duration 5s @@ -68,16 +100,43 @@ go run cmd/main.go -runner.input.path cmd/input.json -solve.duration 5s This will run the solver for 5 seconds and output the result to the console. In order to start a _new project_, please refer to the sample app in the -[community-apps repository](https://github.com/nextmv-io/community-apps/tree/develop/nextroute). +[community-apps repository](https://github.com/nextmv-io/community-apps/tree/develop/go-nextroute). If you have [Nextmv CLI](https://www.nextmv.io/docs/platform/installation#nextmv-cli) installed, you can create a new project with the following command: ```bash -nextmv community clone -a nextroute +nextmv community clone -a go-nextroute ``` -For further information on how to get started, features, deployment, etc., -please refer to the [official documentation](https://www.nextmv.io/docs/vehicle-routing). +### Python + +A first run can be done by executing the following script. Stand at the root of +the repository and execute it: + +```python +import json + +import nextroute + +with open("cmd/input.json") as f: + data = json.load(f) + +input = nextroute.schema.Input.from_dict(data) +options = nextroute.Options(SOLVE_DURATION=5) +output = nextroute.solve(input, options) +print(json.dumps(output.to_dict(), indent=2)) +``` + +This will run the solver for 5 seconds and output the result to the console. + +In order to start a _new project_, please refer to the sample app in the +[community-apps repository](https://github.com/nextmv-io/community-apps/tree/develop/python-nextroute). +If you have [Nextmv CLI](https://www.nextmv.io/docs/platform/installation#nextmv-cli) +installed, you can create a new project with the following command: + +```bash +nextmv community clone -a python-nextroute +``` ## Local benchmarking @@ -106,5 +165,5 @@ benchstat develop.txt new.txt We try our best to version our software thoughtfully and only break APIs and behaviors when we have a good reason to. -- Minor (`v1.^.0`) tags: new features, might be breaking. -- Patch (`v1.0.^`) tags: bug fixes. +* Minor (`v1.^.0`) tags: new features, might be breaking. +* Patch (`v1.0.^`) tags: bug fixes. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..bf7b70e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v1.10.0 diff --git a/check/check.go b/check/check.go index 74c1182..8fe4f1a 100644 --- a/check/check.go +++ b/check/check.go @@ -11,6 +11,7 @@ import ( "github.com/nextmv-io/nextroute" "github.com/nextmv-io/nextroute/check/schema" "github.com/nextmv-io/nextroute/common" + "github.com/nextmv-io/sdk/run/statistics" ) // ModelCheck is the check of a model returning a [Output]. @@ -222,7 +223,7 @@ SolutionPlanUnitLoop: *m.output.PlanUnits[solutionPlanUnitIdx].VehiclesHaveMoves++ } - value := bestMove.Value() + value := statistics.Float64(bestMove.Value()) vehicleDetails := &schema.VehiclesWithMovesDetail{ VehicleID: solutionVehicle.ModelVehicle().ID(), DeltaObjectiveEstimate: &value, @@ -268,7 +269,7 @@ SolutionPlanUnitLoop: if planned { moveIsImprovement = true vehicleDetails.WasPlannable = true - deltaObjective := m.solution.Score() - actualScoreBeforeMove + deltaObjective := statistics.Float64(m.solution.Score() - actualScoreBeforeMove) vehicleDetails.DeltaObjective = &deltaObjective m.output.PlanUnits[solutionPlanUnitIdx].HasPlannableBestMove = true diff --git a/check/schema/schema.go b/check/schema/schema.go index b875816..7d74fb8 100644 --- a/check/schema/schema.go +++ b/check/schema/schema.go @@ -3,6 +3,8 @@ // Package schema contains the core schemas for nextroute. package schema +import "github.com/nextmv-io/sdk/run/statistics" + // Output is the output of the check. type Output struct { // Error is the error raised during the check. @@ -119,10 +121,10 @@ type VehiclesWithMovesDetail struct { VehicleID string `json:"vehicle_id"` // DeltaObjectiveEstimate is the estimate of the delta of the objective of // that will be incurred by the move. - DeltaObjectiveEstimate *float64 `json:"delta_objective_estimate,omitempty"` + DeltaObjectiveEstimate *statistics.Float64 `json:"delta_objective_estimate,omitempty"` // DeltaObjective is the actual delta of the objective of that will be // incurred by the move. - DeltaObjective *float64 `json:"delta_objective,omitempty"` + DeltaObjective *statistics.Float64 `json:"delta_objective,omitempty"` // FailedConstraints are the constraints that are violated for the move. FailedConstraints []string `json:"failed_constraints,omitempty"` // WasPlannable is true if the move was plannable, false otherwise. diff --git a/cmd/.gitignore b/cmd/.gitignore index 66edeea..9dea863 100644 --- a/cmd/.gitignore +++ b/cmd/.gitignore @@ -1,2 +1,6 @@ cmd +main *.json +*.sh +*.yaml +*.yml diff --git a/cmd/main.go b/cmd/main.go index 2601b18..e489b6c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,7 +5,10 @@ package main import ( "context" + "fmt" "log" + "os" + "strings" "github.com/nextmv-io/nextroute" "github.com/nextmv-io/nextroute/check" @@ -16,6 +19,12 @@ import ( ) func main() { + // If the only argument is 'version', print the version and exit. + if len(os.Args) == 2 && strings.TrimLeft(os.Args[1], "-") == "version" { + fmt.Println(nextroute.Version()) + return + } + // Continue with runner based execution. runner := run.CLI(solver) err := runner.Run(context.Background()) if err != nil { diff --git a/common/utils.go b/common/utils.go index add6109..49916d4 100644 --- a/common/utils.go +++ b/common/utils.go @@ -433,3 +433,38 @@ func Reverse[T any](slice []T) []T { } return slice } + +// TryAssertFloat64Matrix tries to assert that the given matrix is a +// [][]float64. Returns true if successful and the matrix otherwise false. +func TryAssertFloat64Matrix(matrix []any) ([][]float64, bool) { + if len(matrix) == 0 { + return nil, false + } + + result := make([][]float64, len(matrix)) + for i, row := range matrix { + if _, ok := row.([]any); !ok { + return nil, false + } + for _, r := range row.([]any) { + if _, ok := r.(float64); !ok { + return nil, false + } + result[i] = append(result[i], r.(float64)) + } + } + return result, true +} + +// TryAssertStringSlice attempts to convert a slice of any to a slice of strings. +func TryAssertStringSlice(slice []any) ([]string, bool) { + result := make([]string, len(slice)) + for i, v := range slice { + s, ok := v.(string) + if !ok { + return nil, false + } + result[i] = s + } + return result, true +} diff --git a/factory/factory.go b/factory/factory.go index 20f5667..813d40e 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -150,6 +150,10 @@ func appendObjectiveModifiers( modifiers = append(modifiers, addCapacityObjective) } + if options.Objectives.StopBalance > 0.0 { + modifiers = append(modifiers, addStopBalanceObjective) + } + return modifiers } diff --git a/factory/format.go b/factory/format.go index d368a4c..b26d565 100644 --- a/factory/format.go +++ b/factory/format.go @@ -133,17 +133,15 @@ func toPlannedStopOutput(solutionStop nextroute.SolutionStop) schema.PlannedStop int(math.Max(arrival.Sub(*inputStop.TargetArrivalTime).Seconds(), 0.0)) } - if inputStop.MixingItems != nil { - mixItems := make(map[string]nextroute.MixItem) - for _, constraint := range solutionStop.Vehicle().ModelVehicle().Model().Constraints() { - if noMixConstraint, ok := constraint.(nextroute.NoMixConstraint); ok { - mixItems[strings.TrimPrefix(noMixConstraint.ID(), "no_mix_")] = noMixConstraint.Value(solutionStop) - } - } - if len(mixItems) > 0 { - plannedStopOutput.MixItems = mixItems + mixItems := make(map[string]nextroute.MixItem) + for _, constraint := range solutionStop.Vehicle().ModelVehicle().Model().Constraints() { + if noMixConstraint, ok := constraint.(nextroute.NoMixConstraint); ok { + mixItems[strings.TrimPrefix(noMixConstraint.ID(), "no_mix_")] = noMixConstraint.Value(solutionStop) } } + if len(mixItems) > 0 { + plannedStopOutput.MixItems = mixItems + } } hasTravelDistance := solutionStop.Previous().ModelStop().Location().IsValid() && diff --git a/factory/model.go b/factory/model.go index 9935061..bf98da8 100644 --- a/factory/model.go +++ b/factory/model.go @@ -37,6 +37,7 @@ type Options struct { VehiclesDuration float64 `json:"vehicles_duration" usage:"factor to weigh the vehicles duration objective" default:"1.0"` UnplannedPenalty float64 `json:"unplanned_penalty" usage:"factor to weigh the unplanned objective" default:"1.0"` Cluster float64 `json:"cluster" usage:"factor to weigh the cluster objective" default:"0.0"` + StopBalance float64 `json:"stop_balance" usage:"factor to weigh the stop balance objective" default:"0.0"` } `json:"objectives"` Properties struct { Disable struct { diff --git a/factory/objective_stop_balance.go b/factory/objective_stop_balance.go new file mode 100644 index 0000000..8c11214 --- /dev/null +++ b/factory/objective_stop_balance.go @@ -0,0 +1,21 @@ +// © 2019-present nextmv.io inc + +package factory + +import ( + "github.com/nextmv-io/nextroute" + "github.com/nextmv-io/nextroute/schema" +) + +// addStopBalanceObjective adds the stop balance objective to the model. +func addStopBalanceObjective( + _ schema.Input, + model nextroute.Model, + options Options, +) (nextroute.Model, error) { + balance := nextroute.NewStopBalanceObjective() + if _, err := model.Objective().NewTerm(options.Objectives.StopBalance, balance); err != nil { + return nil, err + } + return model, nil +} diff --git a/factory/validate.go b/factory/validate.go index 1d3e060..696595e 100644 --- a/factory/validate.go +++ b/factory/validate.go @@ -8,6 +8,7 @@ import ( "math" "reflect" "strings" + "time" "github.com/nextmv-io/nextroute/common" nmerror "github.com/nextmv-io/nextroute/common/errors" @@ -268,17 +269,121 @@ func validateConstraints(input schema.Input, modelOptions Options) error { } } - if input.DurationMatrix != nil && modelOptions.Validate.Enable.Matrix { - durationMatrix := *input.DurationMatrix - if err := validateMatrix( - input, - durationMatrix, - modelOptions.Validate.Enable.MatrixAsymmetryTolerance, - "duration"); err != nil { + if input.DurationMatrix == nil { + return nil + } + switch matrix := input.DurationMatrix.(type) { + case [][]float64: + if modelOptions.Validate.Enable.Matrix { + if err := validateMatrix( + input, + matrix, + modelOptions.Validate.Enable.MatrixAsymmetryTolerance, + "duration"); err != nil { + return err + } + } + case schema.TimeDependentMatrix: + return validateTimeDependentMatrix(input, matrix, modelOptions, true) + case []schema.TimeDependentMatrix: + return validateTimeDependentMatricesAndIDs(input, matrix, modelOptions) + case map[string]any: + timeDependentMatrix, err := convertToTimeDependentMatrix(matrix) + if err != nil { + return err + } + return validateTimeDependentMatrix(input, timeDependentMatrix, modelOptions, true) + case []any: + // In this case we have a single matrix that can be a float64 matrix or a + // multi duration matrix. + return validateFloatOrMultiDurationMatrix(input, matrix, modelOptions) + default: + return nmerror.NewInputDataError(fmt.Errorf("invalid duration matrix type %T", matrix)) + } + return nil +} + +func validateFloatOrMultiDurationMatrix(input schema.Input, matrix []any, modelOptions Options) error { + if floatMatrix, ok := common.TryAssertFloat64Matrix(matrix); ok { + if modelOptions.Validate.Enable.Matrix { + return validateMatrix( + input, + floatMatrix, + modelOptions.Validate.Enable.MatrixAsymmetryTolerance, + "duration") + } + return nil + } + + timeDependentMatrices, err := convertToTimeDependentMatrices(matrix) + if err != nil { + return err + } + return validateTimeDependentMatricesAndIDs(input, timeDependentMatrices, modelOptions) +} + +// Converts a time-dependent matrices from a slice of interfaces to []schema.TimeDependentMatrix. +func convertToTimeDependentMatrices(data []any) ([]schema.TimeDependentMatrix, error) { + var result []schema.TimeDependentMatrix + + for i, item := range data { + if matrixMap, ok := item.(map[string]any); ok { + matrix, err := convertToTimeDependentMatrix(matrixMap) + if err != nil { + return nil, err + } + result = append(result, matrix) + } else { + return nil, fmt.Errorf("invalid time-dependent matrix format at index %v", i) + } + } + + return result, nil +} + +func validateTimeDependentMatricesAndIDs( + input schema.Input, + timeDependentMatrices []schema.TimeDependentMatrix, + modelOptions Options, +) error { + vIDs := make(map[string]bool) + for _, durationMatrix := range timeDependentMatrices { + if len(durationMatrix.VehicleIDs) == 0 { + return nmerror.NewInputDataError(fmt.Errorf( + "vehicle ids are not set for duration matrix", + )) + } + for _, vID := range durationMatrix.VehicleIDs { + if _, ok := vIDs[vID]; ok { + return nmerror.NewInputDataError(fmt.Errorf( + "duplicate vehicle id in duration matrices: %s", + durationMatrix.VehicleIDs, + )) + } + vIDs[vID] = true + } + if err := validateTimeDependentMatrix(input, durationMatrix, modelOptions, false); err != nil { return err } } + // Make sure all vehicles in the input have a duration matrix defined. + for _, vehicle := range input.Vehicles { + if _, ok := vIDs[vehicle.ID]; !ok { + return nmerror.NewInputDataError(fmt.Errorf( + "vehicle id %s is not defined in duration matrices", + vehicle.ID, + )) + } + } + + // Make sure there is no definition in the input that does not exist as a vehicle. + if len(vIDs) != len(input.Vehicles) { + return nmerror.NewInputDataError(fmt.Errorf( + "vehicle ids in duration matrices do not match vehicle ids in input: %v", + vIDs, + )) + } return nil } @@ -507,6 +612,73 @@ func validateAlternateStop(idx int, stop schema.AlternateStop) error { return nil } +func validateTimeDependentMatrix( + input schema.Input, + durationMatrices schema.TimeDependentMatrix, + modelOptions Options, + isSingleMatrix bool, +) error { + if modelOptions.Validate.Enable.Matrix { + if err := validateMatrix( + input, + durationMatrices.DefaultMatrix, + modelOptions.Validate.Enable.MatrixAsymmetryTolerance, + "time_dependent_duration"); err != nil { + return err + } + } + if isSingleMatrix { + if len(durationMatrices.VehicleIDs) != 0 { + return nmerror.NewInputDataError(fmt.Errorf( + "single matrix has vehicle ids set, it must be empty")) + } + } + for i, tf := range durationMatrices.MatrixTimeFrames { + if tf.Matrix == nil && tf.ScalingFactor == nil { + return nmerror.NewInputDataError(fmt.Errorf( + "duration for time frame %d is missing both matrix and scaling factor", i)) + } + + if tf.Matrix != nil && tf.ScalingFactor != nil { + return nmerror.NewInputDataError(fmt.Errorf( + "duration for time frame %d has both matrix and scaling factor, only one is allowed", i)) + } + + if tf.Matrix != nil && modelOptions.Validate.Enable.Matrix { + if err := validateMatrix( + input, + tf.Matrix, + modelOptions.Validate.Enable.MatrixAsymmetryTolerance, + fmt.Sprintf("time_dependent_duration for time frame %d", i), + ); err != nil { + return err + } + } + + if tf.ScalingFactor != nil { + if *tf.ScalingFactor <= 0 { + return nmerror.NewInputDataError(fmt.Errorf( + "time_dependent_duration for time frame %d has invalid scaling factor %v", i, *tf.ScalingFactor)) + } + } + + if tf.StartTime.IsZero() { + return nmerror.NewInputDataError(fmt.Errorf( + "time_dependent_duration for time frame %d has no start time", i)) + } + if tf.EndTime.IsZero() { + return nmerror.NewInputDataError(fmt.Errorf( + "time_dependent_duration for time frame %d has no end time", i)) + } + if tf.StartTime.After(tf.EndTime) || tf.StartTime.Equal(tf.EndTime) { + return nmerror.NewInputDataError(fmt.Errorf( + "time_dependent_duration for time frame %d has invalid start and end time, "+ + "start time is after or equal to end time", i)) + } + } + return nil +} + func validateStops( input schema.Input, allStopIDs map[string]bool, @@ -937,3 +1109,70 @@ func validateResources(input schema.Input, modelOptions Options) error { return nil } + +// Converts a time-dependent matrix from a JSON map to a schema.TimeDependentMatrix. +func convertToTimeDependentMatrix(data map[string]any) (schema.TimeDependentMatrix, error) { + var result schema.TimeDependentMatrix + + if dMatrix, ok := data["default_matrix"].([]any); ok { + if fMatrix, ok := common.TryAssertFloat64Matrix(dMatrix); ok { + result.DefaultMatrix = fMatrix + } else { + return result, fmt.Errorf("invalid or missing default_matrix") + } + } else { + return result, fmt.Errorf("invalid or missing default_matrix") + } + + if vIDs, ok := data["vehicle_ids"].([]any); ok { + if vehicleIDs, ok := common.TryAssertStringSlice(vIDs); ok { + result.VehicleIDs = vehicleIDs + } + } + + if timeFrames, ok := data["matrix_time_frames"].([]any); ok { + result.MatrixTimeFrames = make([]schema.MatrixTimeFrame, len(timeFrames)) + for i, tf := range timeFrames { + timeFrame, ok := tf.(map[string]any) + if !ok { + return result, fmt.Errorf("invalid time frame at index %d", i) + } + + var mtf schema.MatrixTimeFrame + + if mMatrix, ok := timeFrame["matrix"].([]any); ok { + if fMatrix, ok := common.TryAssertFloat64Matrix(mMatrix); ok { + mtf.Matrix = fMatrix + } + } + + if scalingFactor, ok := timeFrame["scaling_factor"].(float64); ok { + mtf.ScalingFactor = &scalingFactor + } + + if startTime, ok := timeFrame["start_time"].(string); ok { + t, err := time.Parse(time.RFC3339, startTime) + if err != nil { + return result, fmt.Errorf("invalid start_time at index %d: %v", i, err) + } + mtf.StartTime = t + } else { + return result, fmt.Errorf("missing or invalid start_time at index %d", i) + } + + if endTime, ok := timeFrame["end_time"].(string); ok { + t, err := time.Parse(time.RFC3339, endTime) + if err != nil { + return result, fmt.Errorf("invalid end_time at index %d: %v", i, err) + } + mtf.EndTime = t + } else { + return result, fmt.Errorf("missing or invalid end_time at index %d", i) + } + + result.MatrixTimeFrames[i] = mtf + } + } + + return result, nil +} diff --git a/factory/vehicles.go b/factory/vehicles.go index 0adfb02..5cf18c0 100644 --- a/factory/vehicles.go +++ b/factory/vehicles.go @@ -3,6 +3,7 @@ package factory import ( + "errors" "fmt" "github.com/nextmv-io/nextroute" @@ -22,7 +23,60 @@ func addVehicles( return nil, err } - travelDuration := travelDurationExpression(input) + var travelDuration nextroute.DurationExpression + travelDurationMap := make(map[string]*nextroute.DurationExpression) + switch matrix := input.DurationMatrix.(type) { + case [][]float64: + travelDuration = travelDurationExpression(matrix) + case schema.TimeDependentMatrix: + travelDuration, err = dependentTravelDurationExpression(matrix, model) + if err != nil { + return nil, err + } + case []schema.TimeDependentMatrix: + for _, durationMatrix := range matrix { + m, err := dependentTravelDurationExpression(durationMatrix, model) + if err != nil { + return nil, err + } + for _, vehicleID := range durationMatrix.VehicleIDs { + travelDurationMap[vehicleID] = &m + } + } + case map[string]any: + timeDependentMatrix, err := convertToTimeDependentMatrix(matrix) + if err != nil { + return nil, err + } + travelDuration, err = dependentTravelDurationExpression(timeDependentMatrix, model) + if err != nil { + return nil, err + } + case []any: + // First, try to assert it as [][]float64 + if floatMatrix, ok := common.TryAssertFloat64Matrix(matrix); ok { + travelDuration = travelDurationExpression(floatMatrix) + } else { + // If it's not [][]float64, try to assert it as []schema.DurationMatrices + timeDependentMatrices, err := convertToTimeDependentMatrices(matrix) + if err != nil { + return nil, err + } + for _, durationMatrix := range timeDependentMatrices { + m, err := dependentTravelDurationExpression(durationMatrix, model) + if err != nil { + return nil, err + } + for _, vehicleID := range durationMatrix.VehicleIDs { + travelDurationMap[vehicleID] = &m + } + } + } + case nil: + default: + return nil, fmt.Errorf("invalid duration matrix type: %T", matrix) + } + durationGroupsExpression := NewDurationGroupsExpression(model.NumberOfStops(), len(input.Vehicles)) distanceExpression := distanceExpression(input.DistanceMatrix) @@ -35,11 +89,15 @@ func addVehicles( } for idx, inputVehicle := range input.Vehicles { + td := travelDuration + if travelDurationMap[inputVehicle.ID] != nil { + td = *travelDurationMap[inputVehicle.ID] + } vehicleType, err := newVehicleType( inputVehicle, model, distanceExpression, - travelDuration, + td, durationGroupsExpression, ) if err != nil { @@ -105,12 +163,26 @@ func newVehicleType( )) } - vehicleType, err := model.NewVehicleType( - nextroute.NewTimeIndependentDurationExpression(durationExpression), - durationGroupsExpression, - ) - if err != nil { - return nil, err + var vehicleType nextroute.ModelVehicleType + switch expression := durationExpression.(type) { + case nextroute.TimeDependentDurationExpression: + vt, err := model.NewVehicleType( + expression, + durationGroupsExpression, + ) + if err != nil { + return nil, err + } + vehicleType = vt + default: + vt, err := model.NewVehicleType( + nextroute.NewTimeIndependentDurationExpression(durationExpression), + durationGroupsExpression, + ) + if err != nil { + return nil, err + } + vehicleType = vt } vehicleType.SetID(vehicle.ID) @@ -184,12 +256,12 @@ func newVehicle( // travelDurationExpressions returns the expressions that define how vehicles // travel from one stop to another and the time it takes them to process a stop // (service it). -func travelDurationExpression(input schema.Input) nextroute.DurationExpression { +func travelDurationExpression(matrix [][]float64) nextroute.DurationExpression { var travelDuration nextroute.DurationExpression - if input.DurationMatrix != nil { + if matrix != nil { travelDuration = nextroute.NewDurationExpression( "travelDuration", - nextroute.NewMeasureByIndexExpression(measure.Matrix(*input.DurationMatrix)), + nextroute.NewMeasureByIndexExpression(measure.Matrix(matrix)), common.Second, ) } @@ -197,6 +269,45 @@ func travelDurationExpression(input schema.Input) nextroute.DurationExpression { return travelDuration } +func dependentTravelDurationExpression( + durationMatrices schema.TimeDependentMatrix, + model nextroute.Model, +) (nextroute.DurationExpression, error) { + if durationMatrices.DefaultMatrix == nil { + return nil, errors.New("no duration matrix provided") + } + defaultExpression := nextroute.NewDurationExpression( + "default_duration_expression", + nextroute.NewMeasureByIndexExpression(measure.Matrix(durationMatrices.DefaultMatrix)), + common.Second, + ) + + timeExpression, err := nextroute.NewTimeDependentDurationExpression(model, defaultExpression) + if err != nil { + return nil, err + } + + for i, tf := range durationMatrices.MatrixTimeFrames { + if tf.ScalingFactor != nil { + scaledExpression := nextroute.NewScaledDurationExpression(defaultExpression, *tf.ScalingFactor) + if err := timeExpression.SetExpression(tf.StartTime, tf.EndTime, scaledExpression); err != nil { + return nil, err + } + } else { + trafficExpression := nextroute.NewDurationExpression( + fmt.Sprintf("traffic_duration_expression_%d", i), + nextroute.NewMeasureByIndexExpression(measure.Matrix(tf.Matrix)), + common.Second, + ) + if err := timeExpression.SetExpression(tf.StartTime, tf.EndTime, trafficExpression); err != nil { + return nil, err + } + } + } + + return timeExpression, nil +} + // distanceExpression creates a distance expression for later use. func distanceExpression(distanceMatrix *[][]float64) nextroute.DistanceExpression { distanceExpression := nextroute.NewHaversineExpression() diff --git a/go.mod b/go.mod index e40b967..556a09d 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module github.com/nextmv-io/nextroute go 1.21 require ( - github.com/nextmv-io/sdk v1.6.0 + github.com/nextmv-io/sdk v1.8.1 gonum.org/v1/gonum v0.14.0 ) require ( github.com/danielgtaylor/huma v1.14.1 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/gorilla/schema v1.2.0 // indirect + github.com/gorilla/schema v1.4.1 // indirect github.com/iancoleman/strcase v0.2.0 // indirect github.com/itzg/go-flagsfiller v1.9.1 // indirect github.com/sergi/go-diff v1.3.1 // indirect diff --git a/go.sum b/go.sum index d219ca0..029c8a1 100644 --- a/go.sum +++ b/go.sum @@ -212,8 +212,8 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= -github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/graphql-go/graphql v0.7.9/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI= github.com/graphql-go/graphql v0.8.0/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= github.com/graphql-go/handler v0.2.3/go.mod h1:leLF6RpV5uZMN1CdImAxuiayrYYhOk33bZciaUGaXeU= @@ -301,8 +301,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nextmv-io/sdk v1.6.0 h1:WU9/znVN9XzXNUXWoQu9t6xZCHZ4AOaKBi4yqFVLLG0= -github.com/nextmv-io/sdk v1.6.0/go.mod h1:4kKTivuXdlx2ky+ZkBeUkTPIc8BTJ0PqKFYF3B+wCy4= +github.com/nextmv-io/sdk v1.8.1 h1:CYhhDtd4ZeFYfHXSinVQpvH4mIPJHOqtQGUaSwBfpp8= +github.com/nextmv-io/sdk v1.8.1/go.mod h1:Y48XLPcIOOxRgO86ICNpqGrH2N5+dd1TDNvef/FD2Kc= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= diff --git a/model_constraint_no_mix.go b/model_constraint_no_mix.go index a508cb8..0b9937c 100644 --- a/model_constraint_no_mix.go +++ b/model_constraint_no_mix.go @@ -369,6 +369,14 @@ func (l *noMixConstraintImpl) EstimateIsViolated( deltaQuantity += insertMixItem.Quantity } + if !hasRemoveMixItem && !hasInsertMixItem { + // If the stop is not associated with any mix item, then the constraint + // cannot be violated (as it is not mixing any new item between existing + // ones). Note that the content name of all stops of a move is the same, + // so it is enough to check the first stop. + return false, constNoPositionsHint + } + tour := previousNoMixData.tour if previousNoMixData.content.Quantity == 0 { diff --git a/model_expression_duration.go b/model_expression_duration.go index 47e0e7b..6c95525 100644 --- a/model_expression_duration.go +++ b/model_expression_duration.go @@ -262,9 +262,7 @@ func (s *stopDurationExpressionImpl) Value( _ ModelStop, stop ModelStop, ) float64 { - index := stop.Index() - - if value, ok := s.values[index]; ok { + if value, ok := s.values[stop.Index()]; ok { return value } return s.defaultValue @@ -347,9 +345,7 @@ func (v *vehicleTypeDurationExpressionImpl) Value( _ ModelStop, _ ModelStop, ) float64 { - index := vehicleType.Index() - - if value, ok := v.values[index]; ok { + if value, ok := v.values[vehicleType.Index()]; ok { return value } return v.defaultValue diff --git a/model_maximum.go b/model_maximum.go index a5d9268..ecc2cea 100644 --- a/model_maximum.go +++ b/model_maximum.go @@ -67,6 +67,7 @@ type maximumImpl struct { resourceExpression ModelExpression maximumByVehicleType []float64 penaltyOffset float64 + hasNoEffect []bool } func (l *maximumImpl) PenaltyOffset() float64 { @@ -107,18 +108,28 @@ func (l *maximumImpl) Lock(model Model) error { ) } + planUnits := model.PlanStopsUnits() + + l.hasNoEffect = make([]bool, len(planUnits)) + if !l.hasStopExpressionAndNoNegativeValues { return nil } - planUnits := model.PlanStopsUnits() l.deltas = make([]float64, len(planUnits)) - for _, planUnit := range model.PlanStopsUnits() { + + for _, planUnit := range planUnits { delta := 0.0 + hasNoEffect := true for _, stop := range planUnit.Stops() { - delta += l.Expression().Value(nil, nil, stop) + value := l.Expression().Value(nil, nil, stop) + delta += value + if value != 0 { + hasNoEffect = false + } } l.deltas[planUnit.Index()] = delta + l.hasNoEffect[planUnit.Index()] = hasNoEffect } return nil @@ -180,14 +191,18 @@ func (l *maximumImpl) DoesStopHaveViolations(s SolutionStop) bool { func (l *maximumImpl) EstimateIsViolated( move SolutionMoveStops, ) (isViolated bool, stopPositionsHint StopPositionsHint) { + moveImpl := move.(*solutionMoveStopsImpl) + + if l.hasNoEffect[moveImpl.planUnit.modelPlanStopsUnit.Index()] { + return false, constNoPositionsHint + } + // All contributions to the level are negative, no need to check // it will always be below the implied minimum level of zero. if l.hasNegativeValues && !l.hasPositiveValues { return true, constSkipVehiclePositionsHint } - moveImpl := move.(*solutionMoveStopsImpl) - vehicle := moveImpl.vehicle() vehicleType := vehicle.ModelVehicle().VehicleType() @@ -302,6 +317,10 @@ func (l *maximumImpl) EstimateDeltaValue( ) (deltaValue float64) { moveImpl := move.(*solutionMoveStopsImpl) + if l.hasNoEffect[moveImpl.planUnit.modelPlanStopsUnit.Index()] { + return 0.0 + } + vehicle := moveImpl.vehicle() hasViolation := vehicle.Last().ObjectiveData(l).(*maximumObjectiveDate).hasViolation diff --git a/model_objective_stop_balancing.go b/model_objective_stop_balancing.go new file mode 100644 index 0000000..cdb31c6 --- /dev/null +++ b/model_objective_stop_balancing.go @@ -0,0 +1,55 @@ +// © 2019-present nextmv.io inc + +package nextroute + +// NewStopBalanceObjective returns a new StopBalanceObjective. +func NewStopBalanceObjective() ModelObjective { + return &balanceObjectiveImpl{} +} + +type balanceObjectiveImpl struct { +} + +func (t *balanceObjectiveImpl) EstimateDeltaValue( + move Move, +) float64 { + solution := move.Solution() + oldMax, newMax := t.maxStops(solution, move) + return float64(newMax - oldMax) +} + +func (t *balanceObjectiveImpl) Value(solution Solution) float64 { + maxBefore, _ := t.maxStops(solution, nil) + return float64(maxBefore) +} + +func (t *balanceObjectiveImpl) maxStops(solution Solution, move SolutionMoveStops) (int, int) { + max := 0 + maxBefore := 0 + moveExists := move != nil + var vehicle SolutionVehicle + if moveExists { + vehicle = move.Vehicle() + } + + for _, v := range solution.(*solutionImpl).vehicles { + numberOfStops := v.NumberOfStops() + if max < numberOfStops { + max = numberOfStops + } + if maxBefore < numberOfStops { + maxBefore = numberOfStops + } + if moveExists && v.Index() == vehicle.Index() { + length := move.StopPositionsLength() + if max < numberOfStops+length { + max = numberOfStops + length + } + } + } + return maxBefore, max +} + +func (t *balanceObjectiveImpl) String() string { + return "stop_balance" +} diff --git a/model_objective_stop_balancing_test.go b/model_objective_stop_balancing_test.go new file mode 100644 index 0000000..f9ec21e --- /dev/null +++ b/model_objective_stop_balancing_test.go @@ -0,0 +1,48 @@ +// © 2019-present nextmv.io inc + +package nextroute_test + +import ( + "testing" + + "github.com/nextmv-io/nextroute" +) + +func TestBalanceObjective_EstimateDeltaValue(_ *testing.T) { + // TODO implement +} + +func TestBalanceObjective(t *testing.T) { + model, err := createModel( + input( + vehicleTypes("truck"), + []Vehicle{ + vehicles( + "truck", + depot(), + 1, + )[0], + }, + planSingleStops(), + planPairSequences(), + ), + ) + if err != nil { + t.Error(err) + } + + balanceObjective := nextroute.NewStopBalanceObjective() + + if len(model.Objective().Terms()) != 0 { + t.Error("model objective should be empty") + } + + _, err = model.Objective().NewTerm(1.0, balanceObjective) + if err != nil { + t.Error(err) + } + + if len(model.Objective().Terms()) != 1 { + t.Error("model objective should have an objective") + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2075f85 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools", "wheel", "cibuildwheel", "hatch", "ruff"] + +[project] +authors = [ + { email = "tech@nextmv.io", name = "Nextmv" } +] +classifiers = [ + "License :: Other/Proprietary License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "pydantic>=2.5.2", +] +description = "Nextroute is an engine for solving Vehicle Routing Problems (VRPs)." +dynamic = [ + "version", +] +keywords = [ + "decision engineering", + "decision science", + "decisions", + "nextmv", + "optimization", + "operations research", + "solver", + "vehicle routing problem", +] +license = { file = "LICENSE" } +maintainers = [ + { email = "tech@nextmv.io", name = "Nextmv" } +] +name = "nextroute" +readme = "README.md" +requires-python = ">=3.8" + +[project.urls] +Homepage = "https://www.nextmv.io" +Documentation = "https://www.nextmv.io/docs/vehicle-routing" +Repository = "https://github.com/nextmv-io/nextroute" + +[tool.ruff] +target-version = "py38" +lint.select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "C", # flake8-comprehensions + "B", # flake8-bugbear + "UP", # pyupgrade +] +line-length = 120 + +[tool.hatch.version] +path = "src/nextroute/__about__.py" + +[tool.setuptools.packages.find] +where = ["src/"] +exclude = ["tests"] + +[tool.setuptools.package-data] +nextroute = ["bin/*.exe"] + +[tool.cibuildwheel] +test-command = 'python -c "exec(\"import nextroute\nprint(nextroute.nextroute_version())\")"' +build = "cp3{8,9,10,11,12}-*" +skip = "*musllinux*" +archs = "native" +manylinux-x86_64-image = "quay.io/pypa/manylinux_2_28_x86_64" +manylinux-aarch64-image = "quay.io/pypa/manylinux_2_28_aarch64" + +[tool.cibuildwheel.linux] +archs = ["x86_64", "aarch64"] +before-all = """ +dnf update -y +dnf -y install go +""" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e9039c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +build>=1.0.3 +pydantic>=2.5.2 +ruff>=0.1.7 +twine>=4.0.2 +hatch>=1.13.0 +nextmv>=0.13.1 diff --git a/schema/input.go b/schema/input.go index 785f6d0..be600e7 100644 --- a/schema/input.go +++ b/schema/input.go @@ -20,7 +20,10 @@ type Input struct { // StopGroups group of stops that must be part of the same route. StopGroups *[][]string `json:"stop_groups,omitempty"` // DurationMatrix matrix of durations in seconds between stops. - DurationMatrix *[][]float64 `json:"duration_matrix,omitempty"` + // It can be a single matrix of type [][]float64 or of type DurationMatrix. + // The latter allows to pass time dependent matrices by either scaling a + // default matrix or by passing a matrix per time frame. + DurationMatrix any `json:"duration_matrix,omitempty"` // DistanceMatrix matrix of distances in meters between stops. DistanceMatrix *[][]float64 `json:"distance_matrix,omitempty"` // DurationGroups duration in seconds added when approaching the group. @@ -33,6 +36,33 @@ type Input struct { AlternateStops *[]AlternateStop `json:"alternate_stops,omitempty"` } +// TimeDependentMatrix represents time-dependent duration matrices. +type TimeDependentMatrix struct { + // VehicleIDs is a list of vehicle IDs for which the duration matrix is defined. + // Must be empty for a single matrix. + VehicleIDs []string `json:"vehicle_ids,omitempty"` + // DefaultMatrix is the default duration matrix used for undefined time frames + DefaultMatrix [][]float64 `json:"default_matrix"` + + // MatrixTimeFrames contains time-dependent matrices or scaling factors + MatrixTimeFrames []MatrixTimeFrame `json:"matrix_time_frames,omitempty"` +} + +// MatrixTimeFrame represents a time-dependent duration matrix or scaling factor. +type MatrixTimeFrame struct { + // StartTime is the start time of the time frame + StartTime time.Time `json:"start_time"` + + // EndTime is the end time of the time frame + EndTime time.Time `json:"end_time"` + + // Matrix is the full duration matrix for this time frame + Matrix [][]float64 `json:"matrix,omitempty"` + + // ScalingFactor is applied to the default matrix during this time frame + ScalingFactor *float64 `json:"scaling_factor,omitempty"` +} + // Defaults contains default values for vehicles and stops. type Defaults struct { // Vehicles default values for vehicles. diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b7042fc --- /dev/null +++ b/setup.py @@ -0,0 +1,90 @@ +# © 2019-present nextmv.io inc + +import os +import platform +import subprocess + +from setuptools import Distribution, setup + +try: + from wheel.bdist_wheel import bdist_wheel as _bdist_wheel + + class MyWheel(_bdist_wheel): + def finalize_options(self): + _bdist_wheel.finalize_options(self) + self.root_is_pure = False + + def get_tag(self): + python, abi, plat = _bdist_wheel.get_tag(self) + # python, abi = "py3", "none" + return python, abi, plat + + class MyDistribution(Distribution): + def __init__(self, *attrs): + Distribution.__init__(self, *attrs) + self.cmdclass["bdist_wheel"] = MyWheel + + def is_pure(self): + return False + + def has_ext_modules(self): + return True + +except ImportError: + + class MyDistribution(Distribution): + def is_pure(self): + return False + + def has_ext_modules(self): + return True + + +# Compile Nextroute binary. We cross-compile (if necessary) for the current +# platform. We also set CGO_ENABLED=0 to ensure that the binary is statically +# linked. +goos = platform.system().lower() +goarch = platform.machine().lower() + +if goos not in ["linux", "windows", "darwin"]: + raise Exception(f"unsupported operating system: {goos}") + +# Translate the architecture to the Go convention. +if goarch == "x86_64": + goarch = "amd64" +elif goarch == "aarch64": + goarch = "arm64" + +if goarch not in ["amd64", "arm64"]: + raise Exception(f"unsupported architecture: {goarch}") + +# Compile the binary. +print(f"Compiling Nextroute binary for {goos} {goarch}...") +cwd = os.getcwd() +standalone_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "cmd") +os.chdir(standalone_dir) +call = ["go", "build", "-o", "../src/nextroute/bin/nextroute.exe", "."] + +try: + subprocess.check_call( + call, + env={ + **os.environ, + "GOOS": goos, + "GOARCH": goarch, + "CGO_ENABLED": "0", + }, + ) +finally: + os.chdir(cwd) + + +# Get version from version file. +__version__ = "v0.0.0" +exec(open("./src/nextroute/__about__.py").read()) + +# Setup package. +setup( + distclass=MyDistribution, + version=__version__, +) diff --git a/solution_move_units.go b/solution_move_units.go index 8a62d21..17b41b1 100644 --- a/solution_move_units.go +++ b/solution_move_units.go @@ -11,11 +11,11 @@ func newSolutionMoveUnits( planUnit *solutionPlanUnitsUnitImpl, moves SolutionMoves, ) solutionMoveUnitsImpl { - if len(moves) != len(planUnit.SolutionPlanUnits()) { + if len(moves) != len(planUnit.solutionPlanUnits) { panic( fmt.Sprintf("moves and SolutionPlanUnits must have the same length: %v != %v", len(moves), - len(planUnit.SolutionPlanUnits()), + len(planUnit.solutionPlanUnits), ), ) } @@ -40,7 +40,6 @@ func newNotExecutableSolutionMoveUnits(planUnit *solutionPlanUnitsUnitImpl) *sol solution: planUnit.Solution().(*solutionImpl), planUnit: planUnit, valueSeen: 1, - allowed: false, } } diff --git a/solution_unplan.go b/solution_unplan.go index 759026f..ef2a0f9 100644 --- a/solution_unplan.go +++ b/solution_unplan.go @@ -28,13 +28,15 @@ func UnplanIsland( stop = stop.Previous() } if distance.Value(common.Meters) > 0 { - closestStops, err := solutionStop.ModelStop().ClosestStops() + closestStops, err := solutionStop.modelStop().closestStops() if err != nil { return err } for _, closeModelStop := range closestStops { - if haversineDistance(solutionStop.ModelStop().Location(), closeModelStop.Location()).Value(common.Meters) <= - distance.Value(common.Meters) { + d := haversineDistance( + solutionStop.ModelStop().Location(), + closeModelStop.Location()).Value(common.Meters) + if d <= distance.Value(common.Meters) { unplanUnits = append(unplanUnits, solutionStop.PlanStopsUnit()) break } diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..791a39e --- /dev/null +++ b/src/README.md @@ -0,0 +1,3 @@ +# Nextroute Python Source + +This `src` directory contains the source code for the Nextroute Python package. diff --git a/src/nextroute/__about__.py b/src/nextroute/__about__.py new file mode 100644 index 0000000..6b7862a --- /dev/null +++ b/src/nextroute/__about__.py @@ -0,0 +1,3 @@ +# © 2019-present nextmv.io inc + +__version__ = "v1.10.0" diff --git a/src/nextroute/__init__.py b/src/nextroute/__init__.py new file mode 100644 index 0000000..5f60bbc --- /dev/null +++ b/src/nextroute/__init__.py @@ -0,0 +1,18 @@ +# © 2019-present nextmv.io inc + +""" +The Nextroute Python interface. + +Nextroute is a flexible engine for solving Vehicle Routing Problems (VRPs). The +core of Nextroute is written in Go and this package provides a Python interface +to it. +""" + +from .__about__ import __version__ +from .options import Options as Options +from .options import Verbosity as Verbosity +from .solve import solve as solve +from .version import nextroute_version as nextroute_version + +VERSION = __version__ +"""The version of the Nextroute Python package.""" diff --git a/src/nextroute/base_model.py b/src/nextroute/base_model.py new file mode 100644 index 0000000..b593b60 --- /dev/null +++ b/src/nextroute/base_model.py @@ -0,0 +1,24 @@ +# © 2019-present nextmv.io inc + +""" +JSON class for data wrangling JSON objects. +""" + +from typing import Any, Dict + +from pydantic import BaseModel + + +class BaseModel(BaseModel): + """Base class for data wrangling tasks with JSON.""" + + @classmethod + def from_dict(cls, data: Dict[str, Any]): + """Instantiates the class from a dict.""" + + return cls(**data) + + def to_dict(self) -> Dict[str, Any]: + """Converts the class to a dict.""" + + return self.model_dump(mode="json", exclude_none=True, by_alias=True) diff --git a/src/nextroute/check/__init__.py b/src/nextroute/check/__init__.py new file mode 100644 index 0000000..c18070b --- /dev/null +++ b/src/nextroute/check/__init__.py @@ -0,0 +1,28 @@ +# © 2019-present nextmv.io inc + +""" +Check provides a plugin that allows you to check models and solutions. + +Checking a model or a solution checks the unplanned plan units. It checks each +individual plan unit if it can be added to the solution. If the plan unit can +be added to the solution, the report will include on how many vehicles and +what the impact would be on the objective value. If the plan unit cannot be +added to the solution, the report will include the reason why it cannot be +added to the solution. + +The check can be invoked on a nextroute.Model or a nextroute.Solution. If the +check is invoked on a model, an empty solution is created and the check is +executed on this empty solution. An empty solution is a solution with all the +initial stops that are fixed, initial stops that are not fixed are not added +to the solution. The check is executed on the unplanned plan units of the +solution. If the check is invoked on a solution, it is executed on the +unplanned plan units of the solution. +""" + +from .schema import Objective as Objective +from .schema import ObjectiveTerm as ObjectiveTerm +from .schema import Output as Output +from .schema import PlanUnit as PlanUnit +from .schema import Solution as Solution +from .schema import Summary as Summary +from .schema import Vehicle as Vehicle diff --git a/src/nextroute/check/schema.py b/src/nextroute/check/schema.py new file mode 100644 index 0000000..55ebc65 --- /dev/null +++ b/src/nextroute/check/schema.py @@ -0,0 +1,145 @@ +# © 2019-present nextmv.io inc + +""" +This module contains definitions for the schema in the Nextroute check. +""" + +from typing import Dict, List, Optional + +from nextroute.base_model import BaseModel + + +class ObjectiveTerm(BaseModel): + """Check of the individual terms of the objective for a move.""" + + base: Optional[float] = None + """Base of the objective term.""" + factor: Optional[float] = None + """Factor of the objective term.""" + name: Optional[str] = None + """Name of the objective term.""" + value: Optional[float] = None + """Value of the objective term, which is equivalent to `self.base * + self.factor`.""" + + +class Objective(BaseModel): + """Estimate of an objective of a move.""" + + terms: Optional[List[ObjectiveTerm]] = None + """Check of the individual terms of the objective.""" + value: Optional[float] = None + """Value of the objective.""" + vehicle: Optional[str] = None + """ID of the vehicle for which it reports the objective.""" + + +class Solution(BaseModel): + """Solution that the check has been executed on.""" + + objective: Optional[Objective] = None + """Objective of the start solution.""" + plan_units_planned: Optional[int] = None + """Number of plan units planned in the start solution.""" + plan_units_unplanned: Optional[int] = None + """Number of plan units unplanned in the start solution.""" + stops_planned: Optional[int] = None + """Number of stops planned in the start solution.""" + vehicles_not_used: Optional[int] = None + """Number of vehicles not used in the start solution.""" + vehicles_used: Optional[int] = None + """Number of vehicles used in the start solution.""" + + +class Summary(BaseModel): + """Summary of the check.""" + + moves_failed: Optional[int] = None + """number of moves that failed. A move can fail if the estimate of a + constraint is incorrect. A constraint is incorrect if `ModelConstraint. + EstimateIsViolated` returns true and one of the violation checks returns + false. Violation checks are implementations of one or more of the + interfaces [SolutionStopViolationCheck], [SolutionVehicleViolationCheck] or + [SolutionViolationCheck] on the same constraint. Most constraints do not + need and do not have violation checks as the estimate is perfect. The + number of moves failed can be more than one per plan unit as we continue to + try moves on different vehicles until we find a move that is executable or + all vehicles have been visited.""" + plan_units_best_move_failed: Optional[int] = None + """Number of plan units for which the best move can not be planned. This + should not happen if all the constraints are implemented correct.""" + plan_units_best_move_found: Optional[int] = None + """Number of plan units for which at least one move has been found and the + move is executable.""" + plan_units_best_move_increases_objective: Optional[int] = None + """Number of plan units for which the best move is executable but would + increase the objective value instead of decreasing it.""" + plan_units_checked: Optional[int] = None + """Number of plan units that have been checked. If this is less than + `self.plan_units_to_be_checked` the check timed out.""" + plan_units_have_no_move: Optional[int] = None + """Number of plan units for which no feasible move has been found. This + implies there is no move that can be executed without violating a + constraint.""" + plan_units_to_be_checked: Optional[int] = None + """Number of plan units to be checked.""" + + +class PlanUnit(BaseModel): + """Check of a plan unit.""" + + best_move_failed: Optional[bool] = None + """True if the plan unit's best move failed to execute.""" + best_move_increases_objective: Optional[bool] = None + """True if the best move for the plan unit increases the objective.""" + best_move_objective: Optional[Objective] = None + """Estimate of the objective of the best move if the plan unit has a best + move.""" + constraints: Optional[Dict[str, int]] = None + """Constraints that are violated for the plan unit.""" + has_best_move: Optional[bool] = None + """True if a move is found for the plan unit. A plan unit has no move found + if the plan unit is over-constrained or the move found is too expensive.""" + stops: Optional[List[str]] = None + """IDs of the sops in the plan unit.""" + vehicles_have_moves: Optional[int] = None + """Number of vehicles that have moves for the plan unit. Only calculated if + the verbosity is very high.""" + vehicles_with_moves: Optional[List[str]] = None + """IDs of the vehicles that have moves for the plan unit. Only calculated + if the verbosity is very high.""" + + +class Vehicle(BaseModel): + """Check of a vehicle.""" + + id: str + """ID of the vehicle.""" + + plan_units_have_moves: Optional[int] = None + """Number of plan units that have moves for the vehicle. Only calculated if + the depth is medium.""" + + +class Output(BaseModel): + """Output of a feasibility check.""" + + duration_maximum: Optional[float] = None + """Maximum duration of the check, in seconds.""" + duration_used: Optional[float] = None + """Duration used by the check, in seconds.""" + error: Optional[str] = None + """Error raised during the check.""" + plan_units: Optional[List[PlanUnit]] = None + """Check of the individual plan units.""" + remark: Optional[str] = None + """Remark of the check. It can be "ok", "timeout" or anything else that + should explain itself.""" + solution: Optional[Solution] = None + """Start soltuion of the check.""" + summary: Optional[Summary] = None + """Summary of the check.""" + vehicles: Optional[List[Vehicle]] = None + """Check of the vehicles.""" + verbosity: Optional[str] = None + """Verbosity level of the check.""" diff --git a/src/nextroute/options.py b/src/nextroute/options.py new file mode 100644 index 0000000..b565416 --- /dev/null +++ b/src/nextroute/options.py @@ -0,0 +1,212 @@ +# © 2019-present nextmv.io inc + +""" +Options for working with the Nextroute engine. +""" + +import json +from enum import Enum +from typing import Any, Dict, List + +from pydantic import Field + +from nextroute.base_model import BaseModel + +# Arguments that require a duration suffix. +_DURATIONS_ARGS = [ + "-check.duration", + "-solve.duration", +] + +# Arguments that require a string enum. +_STR_ENUM_ARGS = [ + "CHECK_VERBOSITY", +] + + +class Verbosity(str, Enum): + """Format of an `Input`.""" + + OFF = "off" + """The check engine is not run.""" + LOW = "low" + """Low verbosity for the check engine.""" + MEDIUM = "medium" + """Medium verbosity for the check engine.""" + HIGH = "high" + """High verbosity for the check engine.""" + + +class Options(BaseModel): + """Options for using Nextroute.""" + + CHECK_DURATION: float = 30 + """Maximum duration of the check, in seconds.""" + CHECK_VERBOSITY: Verbosity = Verbosity.OFF + """Verbosity of the check engine.""" + FORMAT_DISABLE_PROGRESSION: bool = False + """Whether to disable the progression series.""" + MODEL_CONSTRAINTS_DISABLE_ATTRIBUTES: bool = False + """Ignore the compatibility attributes constraint.""" + MODEL_CONSTRAINTS_DISABLE_CAPACITIES: List[str] = Field(default_factory=list) + """Ignore the capacity constraint for the given resource names.""" + MODEL_CONSTRAINTS_DISABLE_CAPACITY: bool = False + """Ignore the capacity constraint for all resources.""" + MODEL_CONSTRAINTS_DISABLE_DISTANCELIMIT: bool = False + """Ignore the distance limit constraint.""" + MODEL_CONSTRAINTS_DISABLE_GROUPS: bool = False + """Ignore the groups constraint.""" + MODEL_CONSTRAINTS_DISABLE_MAXIMUMDURATION: bool = False + """Ignore the maximum duration constraint.""" + MODEL_CONSTRAINTS_DISABLE_MAXIMUMSTOPS: bool = False + """Ignore the maximum stops constraint.""" + MODEL_CONSTRAINTS_DISABLE_MAXIMUMWAITSTOP: bool = False + """Ignore the maximum stop wait constraint.""" + MODEL_CONSTRAINTS_DISABLE_MAXIMUMWAITVEHICLE: bool = False + """Ignore the maximum vehicle wait constraint.""" + MODEL_CONSTRAINTS_DISABLE_MIXINGITEMS: bool = False + """Ignore the do not mix items constraint.""" + MODEL_CONSTRAINTS_DISABLE_PRECEDENCE: bool = False + """Ignore the precedence (pickups & deliveries) constraint.""" + MODEL_CONSTRAINTS_DISABLE_STARTTIMEWINDOWS: bool = False + """Ignore the start time windows constraint.""" + MODEL_CONSTRAINTS_DISABLE_VEHICLEENDTIME: bool = False + """Ignore the vehicle end time constraint.""" + MODEL_CONSTRAINTS_DISABLE_VEHICLESTARTTIME: bool = False + """Ignore the vehicle start time constraint.""" + MODEL_CONSTRAINTS_ENABLE_CLUSTER: bool = False + """Enable the cluster constraint.""" + MODEL_OBJECTIVES_CAPACITIES: str = "" + """ + Capacity objective, provide triple for each resource + `name:default;factor:1.0;offset;0.0`. + """ + MODEL_OBJECTIVES_CLUSTER: float = 0.0 + """Factor to weigh the cluster objective.""" + MODEL_OBJECTIVES_EARLYARRIVALPENALTY: float = 1.0 + """Factor to weigh the early arrival objective.""" + MODEL_OBJECTIVES_LATEARRIVALPENALTY: float = 1.0 + """Factor to weigh the late arrival objective.""" + MODEL_OBJECTIVES_MINSTOPS: float = 1.0 + """Factor to weigh the min stops objective.""" + MODEL_OBJECTIVES_TRAVELDURATION: float = 0.0 + """Factor to weigh the travel duration objective.""" + MODEL_OBJECTIVES_UNPLANNEDPENALTY: float = 1.0 + """Factor to weigh the unplanned objective.""" + MODEL_OBJECTIVES_VEHICLEACTIVATIONPENALTY: float = 1.0 + """Factor to weigh the vehicle activation objective.""" + MODEL_OBJECTIVES_VEHICLESDURATION: float = 1.0 + """Factor to weigh the vehicles duration objective.""" + MODEL_PROPERTIES_DISABLE_DURATIONGROUPS: bool = False + """Ignore the durations groups of stops.""" + MODEL_PROPERTIES_DISABLE_DURATIONS: bool = False + """Ignore the durations of stops.""" + MODEL_PROPERTIES_DISABLE_INITIALSOLUTION: bool = False + """Ignore the initial solution.""" + MODEL_PROPERTIES_DISABLE_STOPDURATIONMULTIPLIERS: bool = False + """Ignore the stop duration multipliers defined on vehicles.""" + MODEL_VALIDATE_DISABLE_RESOURCES: bool = False + """Disable the resources validation.""" + MODEL_VALIDATE_DISABLE_STARTTIME: bool = False + """Disable the start time validation.""" + MODEL_VALIDATE_ENABLE_MATRIX: bool = False + """Enable matrix validation.""" + MODEL_VALIDATE_ENABLE_MATRIXASYMMETRYTOLERANCE: int = 20 + """Percentage of acceptable matrix asymmetry, requires matrix validation enabled.""" + SOLVE_DURATION: float = 5 + """Maximum duration, in seconds, of the solver.""" + SOLVE_ITERATIONS: int = -1 + """ + Maximum number of iterations, -1 assumes no limit; iterations are counted + after start solutions are generated. + """ + SOLVE_PARALLELRUNS: int = -1 + """ + Maximum number of parallel runs, -1 results in using all available + resources. + """ + SOLVE_RUNDETERMINISTICALLY: bool = False + """Run the parallel solver deterministically.""" + SOLVE_STARTSOLUTIONS: int = -1 + """ + Number of solutions to generate on top of those passed in; one solution + generated with sweep algorithm, the rest generated randomly. + """ + + def to_args(self) -> List[str]: + """ + Convert the options to command-line arguments. + + Returns + ---------- + List[str] + The flattened options as a list of strings. + """ + + opt_dict = self.to_dict() + + default_options = Options() + default_options_dict = default_options.to_dict() + + args = [] + for key, value in opt_dict.items(): + # We only care about custom options, so we skip the default ones. + default_value = default_options_dict.get(key) + if value == default_value: + continue + + key = f"-{key.replace('_', '.').lower()}" + + str_value = json.dumps(value) + if key in _DURATIONS_ARGS: + str_value = str_value + "s" # Transforms into seconds. + + if str_value.startswith('"') and str_value.endswith('"'): + str_value = str_value[1:-1] + + # Nextroute’s Go implementation does not support boolean flags with + # values. If the value is a boolean, then we only append the key if + # the value is True. + should_append_value = True + if isinstance(value, bool): + if not value: + continue + + should_append_value = False + + args.append(key) + if should_append_value: + args.append(str_value) + + return args + + @classmethod + def extract_from_dict(cls, data: Dict[str, Any]) -> "Options": + """ + Extracts options from a dictionary. This dictionary may contain more + keys that are not part of the Nextroute options. + + Parameters + ---------- + data : Dict[str, Any] + The dictionary to extract options from. + + Returns + ---------- + Options + The Nextroute options. + """ + + options = cls() + for key, value in data.items(): + key = key.upper() + if not hasattr(options, key): + continue + + # Enums need to be handled manually. + if key == "CHECK_VERBOSITY": + value = Verbosity(value) + + setattr(options, key, value) + + return options diff --git a/src/nextroute/schema/__init__.py b/src/nextroute/schema/__init__.py new file mode 100644 index 0000000..c32d5aa --- /dev/null +++ b/src/nextroute/schema/__init__.py @@ -0,0 +1,29 @@ +# © 2019-present nextmv.io inc + +""" +Schema (class) definitions for the entities in Nextroute. +""" + +from .input import Defaults as Defaults +from .input import DurationGroup as DurationGroup +from .input import Input as Input +from .location import Location as Location +from .output import ObjectiveOutput as ObjectiveOutput +from .output import Output as Output +from .output import PlannedStopOutput as PlannedStopOutput +from .output import Solution as Solution +from .output import StopOutput as StopOutput +from .output import VehicleOutput as VehicleOutput +from .output import Version as Version +from .statistics import DataPoint as DataPoint +from .statistics import ResultStatistics as ResultStatistics +from .statistics import RunStatistics as RunStatistics +from .statistics import Series as Series +from .statistics import SeriesData as SeriesData +from .statistics import Statistics as Statistics +from .stop import AlternateStop as AlternateStop +from .stop import Stop as Stop +from .stop import StopDefaults as StopDefaults +from .vehicle import InitialStop as InitialStop +from .vehicle import Vehicle as Vehicle +from .vehicle import VehicleDefaults as VehicleDefaults diff --git a/src/nextroute/schema/input.py b/src/nextroute/schema/input.py new file mode 100644 index 0000000..9d4bfc6 --- /dev/null +++ b/src/nextroute/schema/input.py @@ -0,0 +1,87 @@ +# © 2019-present nextmv.io inc + +""" +Defines the input class. +""" + +from datetime import datetime +from typing import Any, List, Optional, Union + +from nextroute.base_model import BaseModel +from nextroute.schema.stop import AlternateStop, Stop, StopDefaults +from nextroute.schema.vehicle import Vehicle, VehicleDefaults + + +class Defaults(BaseModel): + """Default values for vehicles and stops.""" + + stops: Optional[StopDefaults] = None + """Default values for stops.""" + vehicles: Optional[VehicleDefaults] = None + """Default values for vehicles.""" + + +class DurationGroup(BaseModel): + """Represents a group of stops that get additional duration whenever a stop + of the group is approached for the first time.""" + + duration: int + """Duration to add when visiting the group.""" + group: List[str] + """Stop IDs contained in the group.""" + + +class MatrixTimeFrame(BaseModel): + """Represents a time-dependent duration matrix or scaling factor.""" + + start_time: datetime + """Start time of the time frame.""" + end_time: datetime + """End time of the time frame.""" + matrix: Optional[List[List[float]]] = None + """Duration matrix for the time frame.""" + scaling_factor: Optional[float] = None + """Scaling factor for the time frame.""" + + +class TimeDependentMatrix(BaseModel): + """Represents time-dependent duration matrices.""" + + vehicle_ids: Optional[List[str]] = None + """Vehicle IDs for which the duration matrix is defined.""" + default_matrix: List[List[float]] + """Default duration matrix.""" + matrix_time_frames: Optional[List[MatrixTimeFrame]] = None + """Time-dependent duration matrices.""" + + +class Input(BaseModel): + """Input schema for Nextroute.""" + + stops: List[Stop] + """Stops that must be visited by the vehicles.""" + vehicles: List[Vehicle] + """Vehicles that service the stops.""" + + alternate_stops: Optional[List[AlternateStop]] = None + """A set of alternate stops for the vehicles.""" + custom_data: Optional[Any] = None + """Arbitrary data associated with the input.""" + defaults: Optional[Defaults] = None + """Default values for vehicles and stops.""" + distance_matrix: Optional[List[List[float]]] = None + """Matrix of travel distances in meters between stops.""" + duration_groups: Optional[List[DurationGroup]] = None + """Duration in seconds added when approaching the group.""" + duration_matrix: Optional[ + Union[ + List[List[float]], + TimeDependentMatrix, + List[TimeDependentMatrix], + ] + ] = None + """Matrix of travel durations in seconds between stops as a single matrix or duration matrices.""" + options: Optional[Any] = None + """Arbitrary options.""" + stop_groups: Optional[List[List[str]]] = None + """Groups of stops that must be part of the same route.""" diff --git a/src/nextroute/schema/location.py b/src/nextroute/schema/location.py new file mode 100644 index 0000000..45efbc3 --- /dev/null +++ b/src/nextroute/schema/location.py @@ -0,0 +1,16 @@ +# © 2019-present nextmv.io inc + +""" +Defines the location class. +""" + +from nextroute.base_model import BaseModel + + +class Location(BaseModel): + """Location represents a geographical location.""" + + lat: float + """Latitude of the location.""" + lon: float + """Longitude of the location.""" diff --git a/src/nextroute/schema/output.py b/src/nextroute/schema/output.py new file mode 100644 index 0000000..85bdd05 --- /dev/null +++ b/src/nextroute/schema/output.py @@ -0,0 +1,140 @@ +# © 2019-present nextmv.io inc + +""" +Defines the output class. +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from nextroute.base_model import BaseModel +from nextroute.check.schema import Output as CheckOutput +from nextroute.schema.location import Location +from nextroute.schema.statistics import Statistics + + +class Version(BaseModel): + """A version used for solving.""" + + sdk: str + """Nextmv SDK.""" + + +class StopOutput(BaseModel): + """Basic structure for the output of a stop.""" + + id: str + """ID of the stop.""" + location: Location + """Location of the stop.""" + + custom_data: Optional[Any] = None + """Custom data of the stop.""" + + +class PlannedStopOutput(BaseModel): + """Output of a stop planned in the solution.""" + + stop: StopOutput + """Basic information on the stop.""" + + arrival_time: Optional[datetime] = None + """Actual arrival time at this stop.""" + cumulative_travel_distance: Optional[float] = None + """Cumulative distance to travel from the first stop to this one, in meters.""" + cumulative_travel_duration: Optional[float] = None + """Cumulative duration to travel from the first stop to this one, in seconds.""" + custom_data: Optional[Any] = None + """Custom data of the stop.""" + duration: Optional[float] = None + """Duration of the service at the stop, in seconds.""" + early_arrival_duration: Optional[float] = None + """Duration of early arrival at the stop, in seconds.""" + end_time: Optional[datetime] = None + """End time of the service at the stop.""" + late_arrival_duration: Optional[float] = None + """Duration of late arrival at the stop, in seconds.""" + mix_items: Optional[Any] = None + """Mix items at the stop.""" + start_time: Optional[datetime] = None + """Start time of the service at the stop.""" + target_arrival_time: Optional[datetime] = None + """Target arrival time at this stop.""" + travel_distance: Optional[float] = None + """Distance to travel from the previous stop to this one, in meters.""" + travel_duration: Optional[float] = None + """Duration to travel from the previous stop to this one, in seconds.""" + waiting_duration: Optional[float] = None + """Waiting duratino at the stop, in seconds.""" + + +class VehicleOutput(BaseModel): + """Output of a vehicle in the solution.""" + + id: str + """ID of the vehicle.""" + + alternate_stops: Optional[List[str]] = None + """List of alternate stops that were planned on the vehicle.""" + custom_data: Optional[Any] = None + """Custom data of the vehicle.""" + route: Optional[List[PlannedStopOutput]] = None + """Route of the vehicle, which is a list of stops that were planned on + it.""" + route_duration: Optional[float] = None + """Total duration of the vehicle's route, in seconds.""" + route_stops_duration: Optional[float] = None + """Total duration of the stops of the vehicle, in seconds.""" + route_travel_distance: Optional[float] = None + """Total travel distance of the vehicle, in meters.""" + route_travel_duration: Optional[float] = None + """Total travel duration of the vehicle, in seconds.""" + route_waiting_duration: Optional[float] = None + """Total waiting duration of the vehicle, in seconds.""" + + +class ObjectiveOutput(BaseModel): + """Information of the objective (value function).""" + + name: str + """Name of the objective.""" + + base: Optional[float] = None + """Base of the objective.""" + custom_data: Optional[Any] = None + """Custom data of the objective.""" + factor: Optional[float] = None + """Factor of the objective.""" + objectives: Optional[List[Dict[str, Any]]] = None + """List of objectives. Each list is actually of the same class + `ObjectiveOutput`, but we avoid a recursive definition here.""" + value: Optional[float] = None + """Value of the objective, which is equivalent to `self.base * + self.factor`.""" + + +class Solution(BaseModel): + """Solution to a Vehicle Routing Problem (VRP).""" + + unplanned: Optional[List[StopOutput]] = None + """List of stops that were not planned in the solution.""" + vehicles: Optional[List[VehicleOutput]] = None + """List of vehicles in the solution.""" + objective: Optional[ObjectiveOutput] = None + """Information of the objective (value function).""" + check: Optional[CheckOutput] = None + """Check of the solution, if enabled.""" + + +class Output(BaseModel): + """Output schema for Nextroute.""" + + options: Dict[str, Any] + """Options used to obtain this output.""" + version: Version + """Versions used for the solution.""" + + solutions: Optional[List[Solution]] = None + """Solutions to the problem.""" + statistics: Optional[Statistics] = None + """Statistics of the solution.""" diff --git a/src/nextroute/schema/statistics.py b/src/nextroute/schema/statistics.py new file mode 100644 index 0000000..bb40319 --- /dev/null +++ b/src/nextroute/schema/statistics.py @@ -0,0 +1,149 @@ +# © 2019-present nextmv.io inc + +""" +Schema for statistics. +""" + +from typing import Any, Dict, List, Optional, Union + +from pydantic import Field + +from nextroute.base_model import BaseModel + + +class RunStatistics(BaseModel): + """ + Statistics about a general run. + + Parameters + ---------- + duration : float, optional + Duration of the run in seconds. + iterations : int, optional + Number of iterations. + custom : Union[Any, Dict[str, Any]], optional + Custom statistics created by the user. Can normally expect a `Dict[str, + Any]`. + """ + + duration: Optional[float] = None + """Duration of the run in seconds.""" + iterations: Optional[int] = None + """Number of iterations.""" + custom: Optional[ + Union[ + Any, + Dict[str, Any], + ] + ] = None + """Custom statistics created by the user. Can normally expect a `Dict[str, + Any]`.""" + + +class ResultStatistics(BaseModel): + """ + Statistics about a specific result. + + Parameters + ---------- + duration : float, optional + Duration of the run in seconds. + value : float, optional + Value of the result. + custom : Union[Any, Dict[str, Any]], optional + Custom statistics created by the user. Can normally expect a `Dict[str, + Any]`. + """ + + duration: Optional[float] = None + """Duration of the run in seconds.""" + value: Optional[float] = None + """Value of the result.""" + custom: Optional[ + Union[ + Any, + Dict[str, Any], + ] + ] = None + """Custom statistics created by the user. Can normally expect a `Dict[str, + Any]`.""" + + +class DataPoint(BaseModel): + """ + A data point. + + Parameters + ---------- + x : float + X coordinate of the data point. + y : float + Y coordinate of the data point. + """ + + x: float + """X coordinate of the data point.""" + y: float + """Y coordinate of the data point.""" + + +class Series(BaseModel): + """ + A series of data points. + + Parameters + ---------- + name : str, optional + Name of the series. + data_points : List[DataPoint], optional + Data of the series. + """ + + name: Optional[str] = None + """Name of the series.""" + data_points: Optional[List[DataPoint]] = None + """Data of the series.""" + + +class SeriesData(BaseModel): + """ + Data of a series. + + Parameters + ---------- + value : Series, optional + A series for the value of the solution. + custom : List[Series], optional + A list of series for custom statistics. + """ + + value: Optional[Series] = None + """A series for the value of the solution.""" + custom: Optional[List[Series]] = None + """A list of series for custom statistics.""" + + +class Statistics(BaseModel): + """ + Statistics of a solution. + + Parameters + ---------- + run : RunStatistics, optional + Statistics about the run. + result : ResultStatistics, optional + Statistics about the last result. + series_data : SeriesData, optional + Series data about some metric. + statistics_schema : str, optional + Schema (version). This class only supports `v1`. + """ + + run: Optional[RunStatistics] = None + """Statistics about the run.""" + result: Optional[ResultStatistics] = None + """Statistics about the last result.""" + series_data: Optional[SeriesData] = None + """Data of the series.""" + statistics_schema: Optional[str] = Field(alias="schema", default="v1") + """Schema (version). This class only supports `v1`.""" diff --git a/src/nextroute/schema/stop.py b/src/nextroute/schema/stop.py new file mode 100644 index 0000000..e98c1a7 --- /dev/null +++ b/src/nextroute/schema/stop.py @@ -0,0 +1,65 @@ +# © 2019-present nextmv.io inc + +""" +Defines the stop class. +""" + +from datetime import datetime +from typing import Any, List, Optional + +from nextroute.base_model import BaseModel +from nextroute.schema.location import Location + + +class StopDefaults(BaseModel): + """Default values for a stop.""" + + compatibility_attributes: Optional[List[str]] = None + """Attributes that the stop is compatible with.""" + duration: Optional[int] = None + """Duration of the stop in seconds.""" + early_arrival_time_penalty: Optional[float] = None + """Penalty per second for arriving at the stop before the target arrival time.""" + late_arrival_time_penalty: Optional[float] = None + """Penalty per second for arriving at the stop after the target arrival time.""" + max_wait: Optional[int] = None + """Maximum waiting duration in seconds at the stop.""" + quantity: Optional[Any] = None + """Quantity of the stop.""" + start_time_window: Optional[Any] = None + """Time window in which the stop can start service.""" + target_arrival_time: Optional[datetime] = None + """Target arrival time at the stop.""" + unplanned_penalty: Optional[int] = None + """Penalty for not planning a stop.""" + + +class Stop(StopDefaults): + """Stop is a location that must be visited by a vehicle in a Vehicle + Routing Problem (VRP.)""" + + id: str + """Unique identifier for the stop.""" + location: Location + """Location of the stop.""" + + custom_data: Optional[Any] = None + """Arbitrary data associated with the stop.""" + mixing_items: Optional[Any] = None + """Defines the items that are inserted or removed from the vehicle when visiting the stop.""" + precedes: Optional[Any] = None + """Stops that must be visited after this one on the same route.""" + succeeds: Optional[Any] = None + """Stops that must be visited before this one on the same route.""" + + +class AlternateStop(StopDefaults): + """An alternate stop can be serviced instead of another stop.""" + + id: str + """Unique identifier for the stop.""" + location: Location + """Location of the stop.""" + + custom_data: Optional[Any] = None + """Arbitrary data associated with the stop.""" diff --git a/src/nextroute/schema/vehicle.py b/src/nextroute/schema/vehicle.py new file mode 100644 index 0000000..ce26344 --- /dev/null +++ b/src/nextroute/schema/vehicle.py @@ -0,0 +1,72 @@ +# © 2019-present nextmv.io inc + +""" +Defines the vehicle class. +""" + +from datetime import datetime +from typing import Any, List, Optional + +from nextroute.base_model import BaseModel +from nextroute.schema.location import Location + + +class InitialStop(BaseModel): + """Represents a stop that is already planned on a vehicle.""" + + id: str + """Unique identifier of the stop.""" + + fixed: Optional[bool] = None + """Whether the stop is fixed on the route.""" + + +class VehicleDefaults(BaseModel): + """Default values for vehicles.""" + + activation_penalty: Optional[int] = None + """Penalty of using the vehicle.""" + alternate_stops: Optional[List[str]] = None + """A set of alternate stops for which only one should be serviced.""" + capacity: Optional[Any] = None + """Capacity of the vehicle.""" + compatibility_attributes: Optional[List[str]] = None + """Attributes that the vehicle is compatible with.""" + end_location: Optional[Location] = None + """Location where the vehicle ends.""" + end_time: Optional[datetime] = None + """Latest time at which the vehicle ends its route.""" + max_distance: Optional[int] = None + """Maximum distance in meters that the vehicle can travel.""" + max_duration: Optional[int] = None + """Maximum duration in seconds that the vehicle can travel.""" + max_stops: Optional[int] = None + """Maximum number of stops that the vehicle can visit.""" + max_wait: Optional[int] = None + """Maximum aggregated waiting time that the vehicle can wait across route stops.""" + min_stops: Optional[int] = None + """Minimum stops that a vehicle should visit.""" + min_stops_penalty: Optional[float] = None + """Penalty for not visiting the minimum number of stops.""" + speed: Optional[float] = None + """Speed of the vehicle in meters per second.""" + start_level: Optional[Any] = None + """Initial level of the vehicle.""" + start_location: Optional[Location] = None + """Location where the vehicle starts.""" + start_time: Optional[datetime] = None + """Time when the vehicle starts its route.""" + + +class Vehicle(VehicleDefaults): + """A vehicle services stops in a Vehicle Routing Problem (VRP).""" + + id: str + """Unique identifier of the vehicle.""" + + custom_data: Optional[Any] = None + """Arbitrary custom data.""" + initial_stops: Optional[List[InitialStop]] = None + """Initial stops planned on the vehicle.""" + stop_duration_multiplier: Optional[float] = None + """Multiplier for the duration of stops.""" diff --git a/src/nextroute/solve.py b/src/nextroute/solve.py new file mode 100644 index 0000000..4f61743 --- /dev/null +++ b/src/nextroute/solve.py @@ -0,0 +1,145 @@ +# © 2019-present nextmv.io inc + +""" +Methods for solving a Vehicle Routing Problem with Nextroute. +""" + +import json +import os +import platform +import subprocess +from typing import Any, Dict, Union + +from nextroute.options import Options +from nextroute.schema.input import Input +from nextroute.schema.output import Output + +SUPPORTED_OS = ["linux", "windows", "darwin"] +"""The operating systems supported by the Nextroute engine.""" +SUPPORTED_ARCHITECTURES = ["amd64", "x86_64", "arm64", "aarch64"] +"""The architectures supported by the Nextroute engine.""" + + +def solve( + input: Union[Input, Dict[str, Any]], + options: Union[Options, Dict[str, Any]], +) -> Output: + """ + Solve a Vehicle Routing Problem (VRP) using the Nextroute engine. The input + and options are passed to the engine, and the output is returned. The input + and options can be provided as dictionaries or as objects, although the + recommended way is to use the classes, as they provide validation. + + Examples + -------- + + * Using default options to load an input from a file. + ```python + import json + + import nextroute + + with open("input.json") as f: + data = json.load(f) + + input = nextroute.schema.Input.from_dict(data) + options = nextroute.Options() + output = nextroute.solve(input, options) + print(output) + ``` + + * Using custom options to load an input from a file. + ```python + import json + + import nextroute + + with open("input.json") as f: + data = json.load(f) + + input = nextroute.schema.Input.from_dict(data) + options = nextroute.Options( + solve=nextroute.ParallelSolveOptions(duration=2), + ) + output = nextroute.solve(input, options) + print(output) + ``` + + * Using custom dict options to load an input from a file. + ```python + import json + + import nextroute + + with open("input.json") as f: + data = json.load(f) + + input = nextroute.schema.Input.from_dict(data) + options = { + "solve": { + "duration": 2, + }, + } + output = nextroute.solve(input, options) + print(output) + ``` + + + Parameters + ---------- + input : Union[schema.Input, Dict[str, Any]] + The input to the Nextroute engine. If a dictionary is provided, it will + be converted to an Input object to validate it. + options : Union[Options, Dict[str, Any]] + The options for the Nextroute engine. If a dictionary is provided, it + will be converted to an Options object. + + Returns + ------- + schema.Output + The output of the Nextroute engine. You can call the `to_dict` method + on this object to get a dictionary representation of the output. + """ + + if isinstance(input, dict): + input = Input.from_dict(input) + + input_stream = json.dumps(input.to_dict()) + + if isinstance(options, dict): + options = Options.from_dict(options) + + os_name = platform.system().lower() + if os_name not in SUPPORTED_OS: + raise Exception(f'unsupported operating system: "{os_name}", supported os are: {", ".join(SUPPORTED_OS)}') + + architecture = platform.machine().lower() + if architecture not in SUPPORTED_ARCHITECTURES: + raise Exception( + f'unsupported architecture: "{architecture}", supported arch are: {", ".join(SUPPORTED_ARCHITECTURES)}' + ) + + executable = os.path.join(os.path.dirname(__file__), "bin", "nextroute.exe") + if not os.path.exists(executable): + raise Exception(f"missing Nextroute binary: {executable}") + + option_args = options.to_args() + args = [executable] + option_args + + try: + result = subprocess.run( + args, + env=os.environ, + check=True, + text=True, + capture_output=True, + input=input_stream, + ) + + except subprocess.CalledProcessError as e: + raise Exception(f"error running Nextroute binary: {e.stderr}") from e + + raw_output = result.stdout + output = Output.from_dict(json.loads(raw_output)) + + return output diff --git a/src/nextroute/version.py b/src/nextroute/version.py new file mode 100644 index 0000000..113b152 --- /dev/null +++ b/src/nextroute/version.py @@ -0,0 +1,12 @@ +# © 2019-present nextmv.io inc + +import os +import subprocess + + +def nextroute_version() -> str: + """ + Get the version of the embedded Nextroute binary. + """ + executable = os.path.join(os.path.dirname(__file__), "bin", "nextroute.exe") + return subprocess.check_output([executable, "--version"]).decode().strip() diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..86dfdf3 --- /dev/null +++ b/src/tests/__init__.py @@ -0,0 +1 @@ +# © 2019-present nextmv.io inc diff --git a/src/tests/schema/__init__.py b/src/tests/schema/__init__.py new file mode 100644 index 0000000..86dfdf3 --- /dev/null +++ b/src/tests/schema/__init__.py @@ -0,0 +1 @@ +# © 2019-present nextmv.io inc diff --git a/src/tests/schema/input.json b/src/tests/schema/input.json new file mode 100644 index 0000000..bfb9969 --- /dev/null +++ b/src/tests/schema/input.json @@ -0,0 +1,263 @@ +{ + "defaults": { + "vehicles": { + "capacity": { + "bunnies": 20, + "rabbits": 10 + }, + "start_location": { + "lat": 35.791729813680874, + "lon": -78.7401685145487 + }, + "end_location": { + "lat": 35.791729813680874, + "lon": -78.7401685145487 + }, + "speed": 10 + }, + "stops": { + "duration": 300, + "quantity": { + "bunnies": -1, + "rabbits": -1 + }, + "unplanned_penalty": 200000, + "target_arrival_time": "2023-01-01T10:00:00Z", + "early_arrival_time_penalty": 1.5, + "late_arrival_time_penalty": 1.5 + } + }, + "stops": [ + { + "id": "s1", + "location": { + "lon": -78.90919, + "lat": 35.72389 + }, + "compatibility_attributes": ["premium"] + }, + { + "id": "s2", + "location": { + "lon": -78.813862, + "lat": 35.75712 + }, + "compatibility_attributes": ["premium"] + }, + { + "id": "s3", + "location": { + "lon": -78.92996, + "lat": 35.932795 + }, + "compatibility_attributes": ["premium"] + }, + { + "id": "s4", + "location": { + "lon": -78.505745, + "lat": 35.77772 + }, + "compatibility_attributes": ["premium"] + }, + { + "id": "s5", + "location": { + "lon": -78.75084, + "lat": 35.732995 + }, + "compatibility_attributes": ["premium"] + }, + { + "id": "s6", + "location": { + "lon": -78.788025, + "lat": 35.813025 + }, + "compatibility_attributes": ["premium"] + }, + { + "id": "s7", + "location": { + "lon": -78.749391, + "lat": 35.74261 + }, + "compatibility_attributes": ["premium"] + }, + { + "id": "s8", + "location": { + "lon": -78.94658, + "lat": 36.039135 + }, + "compatibility_attributes": ["basic"] + }, + { + "id": "s9", + "location": { + "lon": -78.64972, + "lat": 35.64796 + }, + "compatibility_attributes": ["basic"] + }, + { + "id": "s10", + "location": { + "lon": -78.747955, + "lat": 35.672955 + }, + "compatibility_attributes": ["basic"] + }, + { + "id": "s11", + "location": { + "lon": -78.83403, + "lat": 35.77013 + }, + "compatibility_attributes": ["basic"] + }, + { + "id": "s12", + "location": { + "lon": -78.864465, + "lat": 35.782855 + }, + "compatibility_attributes": ["basic"] + }, + { + "id": "s13", + "location": { + "lon": -78.952142, + "lat": 35.88029 + }, + "compatibility_attributes": ["basic"] + }, + { + "id": "s14", + "location": { + "lon": -78.52748, + "lat": 35.961465 + }, + "compatibility_attributes": ["basic"] + }, + { + "id": "s15", + "location": { + "lon": -78.89832, + "lat": 35.83202 + } + }, + { + "id": "s16", + "location": { + "lon": -78.63216, + "lat": 35.83458 + } + }, + { + "id": "s17", + "location": { + "lon": -78.76063, + "lat": 35.67337 + } + }, + { + "id": "s18", + "location": { + "lon": -78.911485, + "lat": 36.009015 + } + }, + { + "id": "s19", + "location": { + "lon": -78.522705, + "lat": 35.93663 + } + }, + { + "id": "s20", + "location": { + "lon": -78.995162, + "lat": 35.97414 + } + }, + { + "id": "s21", + "location": { + "lon": -78.50509, + "lat": 35.7606 + } + }, + { + "id": "s22", + "location": { + "lon": -78.828547, + "lat": 35.962635 + }, + "precedes": ["s16", "s23"] + }, + { + "id": "s23", + "location": { + "lon": -78.60914, + "lat": 35.84616 + }, + "start_time_window": [ + "2023-01-01T09:00:00-06:00", + "2023-01-01T09:30:00-06:00" + ] + }, + { + "id": "s24", + "location": { + "lon": -78.65521, + "lat": 35.740605 + }, + "start_time_window": [ + "2023-01-01T09:00:00-06:00", + "2023-01-01T09:30:00-06:00" + ], + "succeeds": "s25" + }, + { + "id": "s25", + "location": { + "lon": -78.92051, + "lat": 35.887575 + }, + "start_time_window": [ + "2023-01-01T09:00:00-06:00", + "2023-01-01T09:30:00-06:00" + ], + "precedes": "s26" + }, + { + "id": "s26", + "location": { + "lon": -78.84058, + "lat": 35.823865 + }, + "start_time_window": [ + "2023-01-01T09:00:00-06:00", + "2023-01-01T09:30:00-06:00" + ] + } + ], + "vehicles": [ + { + "id": "vehicle-0", + "start_time": "2023-01-01T06:00:00-06:00", + "end_time": "2023-01-01T10:00:00-06:00", + "activation_penalty": 4000, + "compatibility_attributes": ["premium"] + }, + { + "id": "vehicle-1", + "start_time": "2023-01-01T10:00:00-06:00", + "end_time": "2023-01-01T16:00:00-06:00", + "max_duration": 21000, + "compatibility_attributes": ["basic"] + } + ] +} diff --git a/src/tests/schema/output.json b/src/tests/schema/output.json new file mode 100644 index 0000000..f819bf9 --- /dev/null +++ b/src/tests/schema/output.json @@ -0,0 +1,851 @@ +{ + "version": { + "sdk": "v1.0.4" + }, + "options": { + "model": { + "constraints": { + "disable": { + "attributes": false, + "capacity": false, + "distance_limit": false, + "groups": false, + "maximum_duration": false, + "maximum_stops": false, + "maximum_wait_stop": false, + "maximum_wait_vehicle": false, + "mixing_items": false, + "precedence": false, + "vehicle_start_time": false, + "vehicle_end_time": false, + "start_time_windows": false + }, + "enable": { + "cluster": false + } + }, + "objectives": { + "min_stops": 1, + "early_arrival_penalty": 1, + "late_arrival_penalty": 1, + "vehicle_activation_penalty": 1, + "travel_duration": 0, + "vehicles_duration": 1, + "unplanned_penalty": 1, + "cluster": 0 + }, + "properties": { + "disable": { + "durations": false, + "stop_duration_multipliers": false, + "duration_groups": false, + "initial_solution": false + } + }, + "validate": { + "disable": { + "start_time": false, + "resources": false + }, + "enable": { + "matrix": false, + "matrix_asymmetry_tolerance": 20 + } + } + }, + "solve": { + "iterations": -1, + "duration": 30000000000, + "parallel_runs": -1, + "start_solutions": -1, + "run_deterministically": false + }, + "format": { + "disable": { + "progression": false + } + }, + "check": { + "duration": 30000000000, + "verbosity": "off" + } + }, + "solutions": [ + { + "unplanned": [ + { + "id": "s21", + "location": { + "lon": -78.50509, + "lat": 35.7606 + } + }, + { + "id": "s24", + "location": { + "lon": -78.65521, + "lat": 35.740605 + } + }, + { + "id": "s25", + "location": { + "lon": -78.92051, + "lat": 35.887575 + } + }, + { + "id": "s26", + "location": { + "lon": -78.84058, + "lat": 35.823865 + } + }, + { + "id": "s4", + "location": { + "lon": -78.505745, + "lat": 35.77772 + } + }, + { + "id": "s9", + "location": { + "lon": -78.64972, + "lat": 35.64796 + } + } + ], + "vehicles": [ + { + "id": "vehicle-0", + "route": [ + { + "stop": { + "id": "vehicle-0-start", + "location": { + "lon": -78.7401685145487, + "lat": 35.791729813680874 + } + }, + "travel_duration": 0, + "cumulative_travel_duration": 0, + "arrival_time": "2023-01-01T06:00:00-06:00", + "start_time": "2023-01-01T06:00:00-06:00", + "end_time": "2023-01-01T06:00:00-06:00" + }, + { + "stop": { + "id": "s7", + "location": { + "lon": -78.749391, + "lat": 35.74261 + } + }, + "travel_duration": 552, + "cumulative_travel_duration": 552, + "travel_distance": 5524, + "cumulative_travel_distance": 5524, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T06:09:12-06:00", + "start_time": "2023-01-01T06:09:12-06:00", + "duration": 300, + "end_time": "2023-01-01T06:14:12-06:00", + "late_arrival_duration": 7752 + }, + { + "stop": { + "id": "s5", + "location": { + "lon": -78.75084, + "lat": 35.732995 + } + }, + "travel_duration": 107, + "cumulative_travel_duration": 660, + "travel_distance": 1077, + "cumulative_travel_distance": 6601, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T06:16:00-06:00", + "start_time": "2023-01-01T06:16:00-06:00", + "duration": 300, + "end_time": "2023-01-01T06:21:00-06:00", + "late_arrival_duration": 8160 + }, + { + "stop": { + "id": "s2", + "location": { + "lon": -78.813862, + "lat": 35.75712 + } + }, + "travel_duration": 628, + "cumulative_travel_duration": 1289, + "travel_distance": 6288, + "cumulative_travel_distance": 12889, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T06:31:29-06:00", + "start_time": "2023-01-01T06:31:29-06:00", + "duration": 300, + "end_time": "2023-01-01T06:36:29-06:00", + "late_arrival_duration": 9089 + }, + { + "stop": { + "id": "s1", + "location": { + "lon": -78.90919, + "lat": 35.72389 + } + }, + "travel_duration": 936, + "cumulative_travel_duration": 2225, + "travel_distance": 9363, + "cumulative_travel_distance": 22252, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T06:52:05-06:00", + "start_time": "2023-01-01T06:52:05-06:00", + "duration": 300, + "end_time": "2023-01-01T06:57:05-06:00", + "late_arrival_duration": 10325 + }, + { + "stop": { + "id": "s15", + "location": { + "lon": -78.89832, + "lat": 35.83202 + } + }, + "travel_duration": 1206, + "cumulative_travel_duration": 3431, + "travel_distance": 12063, + "cumulative_travel_distance": 34315, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T07:17:11-06:00", + "start_time": "2023-01-01T07:17:11-06:00", + "duration": 300, + "end_time": "2023-01-01T07:22:11-06:00", + "late_arrival_duration": 11831 + }, + { + "stop": { + "id": "s3", + "location": { + "lon": -78.92996, + "lat": 35.932795 + } + }, + "travel_duration": 1156, + "cumulative_travel_duration": 4588, + "travel_distance": 11562, + "cumulative_travel_distance": 45877, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T07:41:28-06:00", + "start_time": "2023-01-01T07:41:28-06:00", + "duration": 300, + "end_time": "2023-01-01T07:46:28-06:00", + "late_arrival_duration": 13288 + }, + { + "stop": { + "id": "s22", + "location": { + "lon": -78.828547, + "lat": 35.962635 + } + }, + "travel_duration": 971, + "cumulative_travel_duration": 5559, + "travel_distance": 9713, + "cumulative_travel_distance": 55590, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T08:02:39-06:00", + "start_time": "2023-01-01T08:02:39-06:00", + "duration": 300, + "end_time": "2023-01-01T08:07:39-06:00", + "late_arrival_duration": 14559 + }, + { + "stop": { + "id": "s6", + "location": { + "lon": -78.788025, + "lat": 35.813025 + } + }, + "travel_duration": 1703, + "cumulative_travel_duration": 7262, + "travel_distance": 17031, + "cumulative_travel_distance": 72621, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T08:36:02-06:00", + "start_time": "2023-01-01T08:36:02-06:00", + "duration": 300, + "end_time": "2023-01-01T08:41:02-06:00", + "late_arrival_duration": 16562 + }, + { + "stop": { + "id": "s16", + "location": { + "lon": -78.63216, + "lat": 35.83458 + } + }, + "travel_duration": 1425, + "cumulative_travel_duration": 8688, + "travel_distance": 14255, + "cumulative_travel_distance": 86876, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T09:04:48-06:00", + "start_time": "2023-01-01T09:04:48-06:00", + "duration": 300, + "end_time": "2023-01-01T09:09:48-06:00", + "late_arrival_duration": 18288 + }, + { + "stop": { + "id": "s23", + "location": { + "lon": -78.60914, + "lat": 35.84616 + } + }, + "travel_duration": 244, + "cumulative_travel_duration": 8932, + "travel_distance": 2442, + "cumulative_travel_distance": 89318, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T09:13:52-06:00", + "start_time": "2023-01-01T09:13:52-06:00", + "duration": 300, + "end_time": "2023-01-01T09:18:52-06:00", + "late_arrival_duration": 18832 + }, + { + "stop": { + "id": "vehicle-0-end", + "location": { + "lon": -78.7401685145487, + "lat": 35.791729813680874 + } + }, + "travel_duration": 1327, + "cumulative_travel_duration": 10259, + "travel_distance": 13274, + "cumulative_travel_distance": 102592, + "arrival_time": "2023-01-01T09:40:59-06:00", + "start_time": "2023-01-01T09:40:59-06:00", + "end_time": "2023-01-01T09:40:59-06:00" + } + ], + "route_travel_duration": 10259, + "route_travel_distance": 102592, + "route_stops_duration": 3000, + "route_duration": 13259 + }, + { + "id": "vehicle-1", + "route": [ + { + "stop": { + "id": "vehicle-1-start", + "location": { + "lon": -78.7401685145487, + "lat": 35.791729813680874 + } + }, + "travel_duration": 0, + "cumulative_travel_duration": 0, + "arrival_time": "2023-01-01T10:00:00-06:00", + "start_time": "2023-01-01T10:00:00-06:00", + "end_time": "2023-01-01T10:00:00-06:00" + }, + { + "stop": { + "id": "s17", + "location": { + "lon": -78.76063, + "lat": 35.67337 + } + }, + "travel_duration": 1328, + "cumulative_travel_duration": 1328, + "travel_distance": 13289, + "cumulative_travel_distance": 13289, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T10:22:08-06:00", + "start_time": "2023-01-01T10:22:08-06:00", + "duration": 300, + "end_time": "2023-01-01T10:27:08-06:00", + "late_arrival_duration": 22928 + }, + { + "stop": { + "id": "s10", + "location": { + "lon": -78.747955, + "lat": 35.672955 + } + }, + "travel_duration": 114, + "cumulative_travel_duration": 1443, + "travel_distance": 1145, + "cumulative_travel_distance": 14434, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T10:29:03-06:00", + "start_time": "2023-01-01T10:29:03-06:00", + "duration": 300, + "end_time": "2023-01-01T10:34:03-06:00", + "late_arrival_duration": 23343 + }, + { + "stop": { + "id": "s11", + "location": { + "lon": -78.83403, + "lat": 35.77013 + } + }, + "travel_duration": 1330, + "cumulative_travel_duration": 2774, + "travel_distance": 13309, + "cumulative_travel_distance": 27743, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T10:56:14-06:00", + "start_time": "2023-01-01T10:56:14-06:00", + "duration": 300, + "end_time": "2023-01-01T11:01:14-06:00", + "late_arrival_duration": 24974 + }, + { + "stop": { + "id": "s12", + "location": { + "lon": -78.864465, + "lat": 35.782855 + } + }, + "travel_duration": 308, + "cumulative_travel_duration": 3083, + "travel_distance": 3088, + "cumulative_travel_distance": 30831, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T11:06:23-06:00", + "start_time": "2023-01-01T11:06:23-06:00", + "duration": 300, + "end_time": "2023-01-01T11:11:23-06:00", + "late_arrival_duration": 25583 + }, + { + "stop": { + "id": "s13", + "location": { + "lon": -78.952142, + "lat": 35.88029 + } + }, + "travel_duration": 1341, + "cumulative_travel_duration": 4424, + "travel_distance": 13411, + "cumulative_travel_distance": 44242, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T11:33:44-06:00", + "start_time": "2023-01-01T11:33:44-06:00", + "duration": 300, + "end_time": "2023-01-01T11:38:44-06:00", + "late_arrival_duration": 27224 + }, + { + "stop": { + "id": "s20", + "location": { + "lon": -78.995162, + "lat": 35.97414 + } + }, + "travel_duration": 1113, + "cumulative_travel_duration": 5537, + "travel_distance": 11131, + "cumulative_travel_distance": 55373, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T11:57:17-06:00", + "start_time": "2023-01-01T11:57:17-06:00", + "duration": 300, + "end_time": "2023-01-01T12:02:17-06:00", + "late_arrival_duration": 28637 + }, + { + "stop": { + "id": "s8", + "location": { + "lon": -78.94658, + "lat": 36.039135 + } + }, + "travel_duration": 844, + "cumulative_travel_duration": 6382, + "travel_distance": 8445, + "cumulative_travel_distance": 63818, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T12:16:22-06:00", + "start_time": "2023-01-01T12:16:22-06:00", + "duration": 300, + "end_time": "2023-01-01T12:21:22-06:00", + "late_arrival_duration": 29782 + }, + { + "stop": { + "id": "s18", + "location": { + "lon": -78.911485, + "lat": 36.009015 + } + }, + "travel_duration": 460, + "cumulative_travel_duration": 6842, + "travel_distance": 4601, + "cumulative_travel_distance": 68419, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T12:29:02-06:00", + "start_time": "2023-01-01T12:29:02-06:00", + "duration": 300, + "end_time": "2023-01-01T12:34:02-06:00", + "late_arrival_duration": 30542 + }, + { + "stop": { + "id": "s14", + "location": { + "lon": -78.52748, + "lat": 35.961465 + } + }, + "travel_duration": 3495, + "cumulative_travel_duration": 10337, + "travel_distance": 34953, + "cumulative_travel_distance": 103372, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T13:32:17-06:00", + "start_time": "2023-01-01T13:32:17-06:00", + "duration": 300, + "end_time": "2023-01-01T13:37:17-06:00", + "late_arrival_duration": 34337 + }, + { + "stop": { + "id": "s19", + "location": { + "lon": -78.522705, + "lat": 35.93663 + } + }, + "travel_duration": 279, + "cumulative_travel_duration": 10617, + "travel_distance": 2794, + "cumulative_travel_distance": 106166, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T13:41:57-06:00", + "start_time": "2023-01-01T13:41:57-06:00", + "duration": 300, + "end_time": "2023-01-01T13:46:57-06:00", + "late_arrival_duration": 34917 + }, + { + "stop": { + "id": "vehicle-1-end", + "location": { + "lon": -78.7401685145487, + "lat": 35.791729813680874 + } + }, + "travel_duration": 2536, + "cumulative_travel_duration": 13154, + "travel_distance": 25369, + "cumulative_travel_distance": 131535, + "arrival_time": "2023-01-01T14:29:14-06:00", + "start_time": "2023-01-01T14:29:14-06:00", + "end_time": "2023-01-01T14:29:14-06:00" + } + ], + "route_travel_duration": 13154, + "route_travel_distance": 131535, + "route_stops_duration": 3000, + "route_duration": 16154 + } + ], + "objective": { + "name": "1 * vehicle_activation_penalty + 1 * vehicles_duration + 1 * unplanned_penalty + 1 * early_arrival_penalty + 1 * late_arrival_penalty", + "objectives": [ + { + "name": "vehicle_activation_penalty", + "factor": 1, + "base": 4000, + "value": 4000 + }, + { + "name": "vehicles_duration", + "factor": 1, + "base": 29413.842347860336, + "value": 29413.842347860336 + }, + { + "name": "unplanned_penalty", + "factor": 1, + "base": 1200000, + "value": 1200000 + }, + { + "name": "early_arrival_penalty", + "factor": 1, + "value": 0 + }, + { + "name": "late_arrival_penalty", + "factor": 1, + "base": 616441.7213555574, + "value": 616441.7213555574 + } + ], + "value": 1849855.5637034178 + } + } + ], + "statistics": { + "schema": "v1", + "run": { + "duration": 30.000849301, + "iterations": 465176 + }, + "result": { + "duration": 29.927211845, + "value": 1849855.5637034178, + "custom": { + "activated_vehicles": 2, + "unplanned_stops": 4, + "max_travel_duration": 13154, + "max_duration": 16154, + "min_travel_duration": 10259, + "min_duration": 13259, + "max_stops_in_vehicle": 10, + "min_stops_in_vehicle": 10 + } + }, + "series_data": { + "value": { + "name": "1 * vehicle_activation_penalty + 1 * vehicles_duration + 1 * unplanned_penalty + 1 * early_arrival_penalty + 1 * late_arrival_penalty", + "data_points": [ + { + "x": 0.020842667, + "y": 2234344.7645682096 + }, + { + "x": 0.032997817, + "y": 2093402.032223463 + }, + { + "x": 0.033048328, + "y": 1947534.4139026403 + }, + { + "x": 0.039701579, + "y": 1890176.909870863 + }, + { + "x": 0.078813374, + "y": 1886204.0638763905 + }, + { + "x": 0.079158687, + "y": 1885495.5472869873 + }, + { + "x": 0.079277793, + "y": 1883426.0389726162 + }, + { + "x": 0.093256976, + "y": 1881908.785944581 + }, + { + "x": 0.113405891, + "y": 1875424.7203474045 + }, + { + "x": 0.307581538, + "y": 1873276.893983841 + }, + { + "x": 0.348684455, + "y": 1868549.87184906 + }, + { + "x": 0.598760729, + "y": 1867817.255899191 + }, + { + "x": 0.605771215, + "y": 1866863.8481647968 + }, + { + "x": 0.618908364, + "y": 1864543.5895097256 + }, + { + "x": 0.618960483, + "y": 1863885.0290094614 + }, + { + "x": 0.859121106, + "y": 1863562.3404604197 + }, + { + "x": 1.161325635, + "y": 1862488.1202926636 + }, + { + "x": 1.500369885, + "y": 1861753.3104981184 + }, + { + "x": 1.642751038, + "y": 1859471.9136766195 + }, + { + "x": 2.171013945, + "y": 1858156.1083689928 + }, + { + "x": 2.171313761, + "y": 1856647.7867827415 + }, + { + "x": 3.208299811, + "y": 1856477.4777450562 + }, + { + "x": 3.227739753, + "y": 1856098.1994230747 + }, + { + "x": 3.302338785, + "y": 1853902.9517987967 + }, + { + "x": 29.927211845, + "y": 1849855.5637034178 + } + ] + }, + "custom": [ + { + "name": "iterations", + "data_points": [ + { + "x": 0.020842667, + "y": 0 + }, + { + "x": 0.032997817, + "y": 105 + }, + { + "x": 0.033048328, + "y": 105 + }, + { + "x": 0.039701579, + "y": 191 + }, + { + "x": 0.078813374, + "y": 448 + }, + { + "x": 0.079158687, + "y": 704 + }, + { + "x": 0.079277793, + "y": 705 + }, + { + "x": 0.093256976, + "y": 868 + }, + { + "x": 0.113405891, + "y": 1073 + }, + { + "x": 0.307581538, + "y": 3747 + }, + { + "x": 0.348684455, + "y": 4243 + }, + { + "x": 0.598760729, + "y": 7450 + }, + { + "x": 0.605771215, + "y": 7478 + }, + { + "x": 0.618908364, + "y": 7760 + }, + { + "x": 0.618960483, + "y": 7760 + }, + { + "x": 0.859121106, + "y": 11302 + }, + { + "x": 1.161325635, + "y": 16152 + }, + { + "x": 1.500369885, + "y": 21264 + }, + { + "x": 1.642751038, + "y": 23336 + }, + { + "x": 2.171013945, + "y": 32376 + }, + { + "x": 2.171313761, + "y": 32376 + }, + { + "x": 3.208299811, + "y": 49195 + }, + { + "x": 3.227739753, + "y": 49465 + }, + { + "x": 3.302338785, + "y": 50605 + }, + { + "x": 29.927211845, + "y": 463955 + } + ] + } + ] + } + } +} diff --git a/src/tests/schema/output_with_check.json b/src/tests/schema/output_with_check.json new file mode 100644 index 0000000..860f7d5 --- /dev/null +++ b/src/tests/schema/output_with_check.json @@ -0,0 +1,954 @@ +{ + "version": { + "sdk": "v1.0.4" + }, + "options": { + "model": { + "constraints": { + "disable": { + "attributes": false, + "capacity": false, + "distance_limit": false, + "groups": false, + "maximum_duration": false, + "maximum_stops": false, + "maximum_wait_stop": false, + "maximum_wait_vehicle": false, + "mixing_items": false, + "precedence": false, + "vehicle_start_time": false, + "vehicle_end_time": false, + "start_time_windows": false + }, + "enable": { + "cluster": false + } + }, + "objectives": { + "min_stops": 1, + "early_arrival_penalty": 1, + "late_arrival_penalty": 1, + "vehicle_activation_penalty": 1, + "travel_duration": 0, + "vehicles_duration": 1, + "unplanned_penalty": 1, + "cluster": 0 + }, + "properties": { + "disable": { + "durations": false, + "stop_duration_multipliers": false, + "duration_groups": false, + "initial_solution": false + } + }, + "validate": { + "disable": { + "start_time": false, + "resources": false + }, + "enable": { + "matrix": false, + "matrix_asymmetry_tolerance": 20 + } + } + }, + "solve": { + "iterations": -1, + "duration": 30000000000, + "parallel_runs": -1, + "start_solutions": -1, + "run_deterministically": false + }, + "format": { + "disable": { + "progression": false + } + }, + "check": { + "duration": 30000000000, + "verbosity": "high" + } + }, + "solutions": [ + { + "unplanned": [ + { + "id": "s1", + "location": { + "lon": -78.90919, + "lat": 35.72389 + } + }, + { + "id": "s19", + "location": { + "lon": -78.522705, + "lat": 35.93663 + } + }, + { + "id": "s24", + "location": { + "lon": -78.65521, + "lat": 35.740605 + } + }, + { + "id": "s25", + "location": { + "lon": -78.92051, + "lat": 35.887575 + } + }, + { + "id": "s26", + "location": { + "lon": -78.84058, + "lat": 35.823865 + } + }, + { + "id": "s9", + "location": { + "lon": -78.64972, + "lat": 35.64796 + } + } + ], + "vehicles": [ + { + "id": "vehicle-0", + "route": [ + { + "stop": { + "id": "vehicle-0-start", + "location": { + "lon": -78.7401685145487, + "lat": 35.791729813680874 + } + }, + "travel_duration": 0, + "cumulative_travel_duration": 0, + "arrival_time": "2023-01-01T06:00:00-06:00", + "start_time": "2023-01-01T06:00:00-06:00", + "end_time": "2023-01-01T06:00:00-06:00" + }, + { + "stop": { + "id": "s7", + "location": { + "lon": -78.749391, + "lat": 35.74261 + } + }, + "travel_duration": 552, + "cumulative_travel_duration": 552, + "travel_distance": 5524, + "cumulative_travel_distance": 5524, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T06:09:12-06:00", + "start_time": "2023-01-01T06:09:12-06:00", + "duration": 300, + "end_time": "2023-01-01T06:14:12-06:00", + "late_arrival_duration": 7752 + }, + { + "stop": { + "id": "s5", + "location": { + "lon": -78.75084, + "lat": 35.732995 + } + }, + "travel_duration": 107, + "cumulative_travel_duration": 660, + "travel_distance": 1077, + "cumulative_travel_distance": 6601, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T06:16:00-06:00", + "start_time": "2023-01-01T06:16:00-06:00", + "duration": 300, + "end_time": "2023-01-01T06:21:00-06:00", + "late_arrival_duration": 8160 + }, + { + "stop": { + "id": "s2", + "location": { + "lon": -78.813862, + "lat": 35.75712 + } + }, + "travel_duration": 628, + "cumulative_travel_duration": 1289, + "travel_distance": 6288, + "cumulative_travel_distance": 12889, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T06:31:29-06:00", + "start_time": "2023-01-01T06:31:29-06:00", + "duration": 300, + "end_time": "2023-01-01T06:36:29-06:00", + "late_arrival_duration": 9089 + }, + { + "stop": { + "id": "s6", + "location": { + "lon": -78.788025, + "lat": 35.813025 + } + }, + "travel_duration": 663, + "cumulative_travel_duration": 1952, + "travel_distance": 6638, + "cumulative_travel_distance": 19527, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T06:47:32-06:00", + "start_time": "2023-01-01T06:47:32-06:00", + "duration": 300, + "end_time": "2023-01-01T06:52:32-06:00", + "late_arrival_duration": 10052 + }, + { + "stop": { + "id": "s3", + "location": { + "lon": -78.92996, + "lat": 35.932795 + } + }, + "travel_duration": 1846, + "cumulative_travel_duration": 3799, + "travel_distance": 18463, + "cumulative_travel_distance": 37990, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T07:23:19-06:00", + "start_time": "2023-01-01T07:23:19-06:00", + "duration": 300, + "end_time": "2023-01-01T07:28:19-06:00", + "late_arrival_duration": 12199 + }, + { + "stop": { + "id": "s22", + "location": { + "lon": -78.828547, + "lat": 35.962635 + } + }, + "travel_duration": 971, + "cumulative_travel_duration": 4770, + "travel_distance": 9713, + "cumulative_travel_distance": 47703, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T07:44:30-06:00", + "start_time": "2023-01-01T07:44:30-06:00", + "duration": 300, + "end_time": "2023-01-01T07:49:30-06:00", + "late_arrival_duration": 13470 + }, + { + "stop": { + "id": "s16", + "location": { + "lon": -78.63216, + "lat": 35.83458 + } + }, + "travel_duration": 2270, + "cumulative_travel_duration": 7041, + "travel_distance": 22708, + "cumulative_travel_distance": 70411, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T08:27:21-06:00", + "start_time": "2023-01-01T08:27:21-06:00", + "duration": 300, + "end_time": "2023-01-01T08:32:21-06:00", + "late_arrival_duration": 16041 + }, + { + "stop": { + "id": "s4", + "location": { + "lon": -78.505745, + "lat": 35.77772 + } + }, + "travel_duration": 1303, + "cumulative_travel_duration": 8345, + "travel_distance": 13035, + "cumulative_travel_distance": 83446, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T08:54:05-06:00", + "start_time": "2023-01-01T08:54:05-06:00", + "duration": 300, + "end_time": "2023-01-01T08:59:05-06:00", + "late_arrival_duration": 17645 + }, + { + "stop": { + "id": "s21", + "location": { + "lon": -78.50509, + "lat": 35.7606 + } + }, + "travel_duration": 190, + "cumulative_travel_duration": 8535, + "travel_distance": 1904, + "cumulative_travel_distance": 85350, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T09:02:15-06:00", + "start_time": "2023-01-01T09:02:15-06:00", + "duration": 300, + "end_time": "2023-01-01T09:07:15-06:00", + "late_arrival_duration": 18135 + }, + { + "stop": { + "id": "s23", + "location": { + "lon": -78.60914, + "lat": 35.84616 + } + }, + "travel_duration": 1336, + "cumulative_travel_duration": 9871, + "travel_distance": 13362, + "cumulative_travel_distance": 98712, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T09:29:31-06:00", + "start_time": "2023-01-01T09:29:31-06:00", + "duration": 300, + "end_time": "2023-01-01T09:34:31-06:00", + "late_arrival_duration": 19771 + }, + { + "stop": { + "id": "vehicle-0-end", + "location": { + "lon": -78.7401685145487, + "lat": 35.791729813680874 + } + }, + "travel_duration": 1327, + "cumulative_travel_duration": 11199, + "travel_distance": 13274, + "cumulative_travel_distance": 111986, + "arrival_time": "2023-01-01T09:56:39-06:00", + "start_time": "2023-01-01T09:56:39-06:00", + "end_time": "2023-01-01T09:56:39-06:00" + } + ], + "route_travel_duration": 11199, + "route_travel_distance": 111986, + "route_stops_duration": 3000, + "route_duration": 14199 + }, + { + "id": "vehicle-1", + "route": [ + { + "stop": { + "id": "vehicle-1-start", + "location": { + "lon": -78.7401685145487, + "lat": 35.791729813680874 + } + }, + "travel_duration": 0, + "cumulative_travel_duration": 0, + "arrival_time": "2023-01-01T10:00:00-06:00", + "start_time": "2023-01-01T10:00:00-06:00", + "end_time": "2023-01-01T10:00:00-06:00" + }, + { + "stop": { + "id": "s10", + "location": { + "lon": -78.747955, + "lat": 35.672955 + } + }, + "travel_duration": 1322, + "cumulative_travel_duration": 1322, + "travel_distance": 13225, + "cumulative_travel_distance": 13225, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T10:22:02-06:00", + "start_time": "2023-01-01T10:22:02-06:00", + "duration": 300, + "end_time": "2023-01-01T10:27:02-06:00", + "late_arrival_duration": 22922 + }, + { + "stop": { + "id": "s17", + "location": { + "lon": -78.76063, + "lat": 35.67337 + } + }, + "travel_duration": 114, + "cumulative_travel_duration": 1437, + "travel_distance": 1145, + "cumulative_travel_distance": 14370, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T10:28:57-06:00", + "start_time": "2023-01-01T10:28:57-06:00", + "duration": 300, + "end_time": "2023-01-01T10:33:57-06:00", + "late_arrival_duration": 23337 + }, + { + "stop": { + "id": "s11", + "location": { + "lon": -78.83403, + "lat": 35.77013 + } + }, + "travel_duration": 1263, + "cumulative_travel_duration": 2700, + "travel_distance": 12635, + "cumulative_travel_distance": 27005, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T10:55:00-06:00", + "start_time": "2023-01-01T10:55:00-06:00", + "duration": 300, + "end_time": "2023-01-01T11:00:00-06:00", + "late_arrival_duration": 24900 + }, + { + "stop": { + "id": "s12", + "location": { + "lon": -78.864465, + "lat": 35.782855 + } + }, + "travel_duration": 308, + "cumulative_travel_duration": 3009, + "travel_distance": 3088, + "cumulative_travel_distance": 30093, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T11:05:09-06:00", + "start_time": "2023-01-01T11:05:09-06:00", + "duration": 300, + "end_time": "2023-01-01T11:10:09-06:00", + "late_arrival_duration": 25509 + }, + { + "stop": { + "id": "s15", + "location": { + "lon": -78.89832, + "lat": 35.83202 + } + }, + "travel_duration": 626, + "cumulative_travel_duration": 3635, + "travel_distance": 6261, + "cumulative_travel_distance": 36354, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T11:20:35-06:00", + "start_time": "2023-01-01T11:20:35-06:00", + "duration": 300, + "end_time": "2023-01-01T11:25:35-06:00", + "late_arrival_duration": 26435 + }, + { + "stop": { + "id": "s13", + "location": { + "lon": -78.952142, + "lat": 35.88029 + } + }, + "travel_duration": 723, + "cumulative_travel_duration": 4359, + "travel_distance": 7234, + "cumulative_travel_distance": 43588, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T11:37:39-06:00", + "start_time": "2023-01-01T11:37:39-06:00", + "duration": 300, + "end_time": "2023-01-01T11:42:39-06:00", + "late_arrival_duration": 27459 + }, + { + "stop": { + "id": "s18", + "location": { + "lon": -78.911485, + "lat": 36.009015 + } + }, + "travel_duration": 1477, + "cumulative_travel_duration": 5836, + "travel_distance": 14774, + "cumulative_travel_distance": 58362, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T12:07:16-06:00", + "start_time": "2023-01-01T12:07:16-06:00", + "duration": 300, + "end_time": "2023-01-01T12:12:16-06:00", + "late_arrival_duration": 29236 + }, + { + "stop": { + "id": "s8", + "location": { + "lon": -78.94658, + "lat": 36.039135 + } + }, + "travel_duration": 460, + "cumulative_travel_duration": 6296, + "travel_distance": 4601, + "cumulative_travel_distance": 62963, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T12:19:56-06:00", + "start_time": "2023-01-01T12:19:56-06:00", + "duration": 300, + "end_time": "2023-01-01T12:24:56-06:00", + "late_arrival_duration": 29996 + }, + { + "stop": { + "id": "s20", + "location": { + "lon": -78.995162, + "lat": 35.97414 + } + }, + "travel_duration": 844, + "cumulative_travel_duration": 7141, + "travel_distance": 8445, + "cumulative_travel_distance": 71408, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T12:39:01-06:00", + "start_time": "2023-01-01T12:39:01-06:00", + "duration": 300, + "end_time": "2023-01-01T12:44:01-06:00", + "late_arrival_duration": 31141 + }, + { + "stop": { + "id": "s14", + "location": { + "lon": -78.52748, + "lat": 35.961465 + } + }, + "travel_duration": 4211, + "cumulative_travel_duration": 11352, + "travel_distance": 42112, + "cumulative_travel_distance": 113520, + "target_arrival_time": "2023-01-01T04:00:00-06:00", + "arrival_time": "2023-01-01T13:54:12-06:00", + "start_time": "2023-01-01T13:54:12-06:00", + "duration": 300, + "end_time": "2023-01-01T13:59:12-06:00", + "late_arrival_duration": 35652 + }, + { + "stop": { + "id": "vehicle-1-end", + "location": { + "lon": -78.7401685145487, + "lat": 35.791729813680874 + } + }, + "travel_duration": 2689, + "cumulative_travel_duration": 14042, + "travel_distance": 26896, + "cumulative_travel_distance": 140416, + "arrival_time": "2023-01-01T14:44:02-06:00", + "start_time": "2023-01-01T14:44:02-06:00", + "end_time": "2023-01-01T14:44:02-06:00" + } + ], + "route_travel_duration": 14042, + "route_travel_distance": 140416, + "route_stops_duration": 3000, + "route_duration": 17042 + } + ], + "objective": { + "name": "1 * vehicle_activation_penalty + 1 * vehicles_duration + 1 * unplanned_penalty + 1 * early_arrival_penalty + 1 * late_arrival_penalty", + "objectives": [ + { + "name": "vehicle_activation_penalty", + "factor": 1, + "base": 4000, + "value": 4000 + }, + { + "name": "vehicles_duration", + "factor": 1, + "base": 31241.599817276, + "value": 31241.599817276 + }, + { + "name": "unplanned_penalty", + "factor": 1, + "base": 1200000, + "value": 1200000 + }, + { + "name": "early_arrival_penalty", + "factor": 1, + "value": 0 + }, + { + "name": "late_arrival_penalty", + "factor": 1, + "base": 613367.1312507391, + "value": 613367.1312507391 + } + ], + "value": 1848608.731068015 + }, + "check": { + "remark": "completed", + "verbosity": "high", + "duration_maximum": 30, + "duration_used": 0.000089805, + "solution": { + "stops_planned": 20, + "plan_units_planned": 18, + "plan_units_unplanned": 4, + "vehicles_used": 2, + "vehicles_not_used": 0, + "objective": { + "value": 1848608.731068015, + "terms": [ + { + "name": "vehicle_activation_penalty", + "factor": 1, + "base": 4000, + "value": 4000 + }, + { + "name": "vehicles_duration", + "factor": 1, + "base": 31241.599817276, + "value": 31241.599817276 + }, + { + "name": "unplanned_penalty", + "factor": 1, + "base": 1200000, + "value": 1200000 + }, + { + "name": "early_arrival_penalty", + "factor": 1, + "base": 0, + "value": 0 + }, + { + "name": "late_arrival_penalty", + "factor": 1, + "base": 613367.1312507391, + "value": 613367.1312507391 + } + ] + } + }, + "summary": { + "plan_units_to_be_checked": 4, + "plan_units_checked": 4, + "plan_units_best_move_found": 0, + "plan_units_have_no_move": 4, + "plan_units_best_move_increases_objective": 0, + "plan_units_best_move_failed": 0, + "moves_failed": 0 + }, + "plan_units": [ + { + "stops": ["s1"], + "has_best_move": false, + "best_move_increases_objective": false, + "best_move_failed": false, + "vehicles_have_moves": 0, + "vehicles_with_moves": [], + "best_move_objective": null, + "constraints": { + "attributes": 1, + "capacity_rabbits": 1 + } + }, + { + "stops": ["s25", "s24", "s26"], + "has_best_move": false, + "best_move_increases_objective": false, + "best_move_failed": false, + "vehicles_have_moves": 0, + "vehicles_with_moves": [], + "best_move_objective": null, + "constraints": { + "capacity_rabbits": 4 + } + }, + { + "stops": ["s19"], + "has_best_move": false, + "best_move_increases_objective": false, + "best_move_failed": false, + "vehicles_have_moves": 0, + "vehicles_with_moves": [], + "best_move_objective": null, + "constraints": { + "capacity_rabbits": 2 + } + }, + { + "stops": ["s9"], + "has_best_move": false, + "best_move_increases_objective": false, + "best_move_failed": false, + "vehicles_have_moves": 0, + "vehicles_with_moves": [], + "best_move_objective": null, + "constraints": { + "attributes": 1, + "capacity_rabbits": 1 + } + } + ], + "vehicles": [ + { + "id": "vehicle-0", + "plan_units_have_moves": 0 + }, + { + "id": "vehicle-1", + "plan_units_have_moves": 0 + } + ] + } + } + ], + "statistics": { + "schema": "v1", + "run": { + "duration": 30.0008499, + "iterations": 454667 + }, + "result": { + "duration": 25.034078157, + "value": 1848608.731068015, + "custom": { + "activated_vehicles": 2, + "unplanned_stops": 4, + "max_travel_duration": 14042, + "max_duration": 17042, + "min_travel_duration": 11199, + "min_duration": 14199, + "max_stops_in_vehicle": 10, + "min_stops_in_vehicle": 10 + } + }, + "series_data": { + "value": { + "name": "1 * vehicle_activation_penalty + 1 * vehicles_duration + 1 * unplanned_penalty + 1 * early_arrival_penalty + 1 * late_arrival_penalty", + "data_points": [ + { + "x": 0.020828455, + "y": 2234344.7645682096 + }, + { + "x": 0.041680475, + "y": 2208163.7394895554 + }, + { + "x": 0.042005087, + "y": 2064972.738529563 + }, + { + "x": 0.042361206, + "y": 1946072.3564813137 + }, + { + "x": 0.04244768, + "y": 1890176.909870863 + }, + { + "x": 0.058798859, + "y": 1888734.5943188667 + }, + { + "x": 0.074109874, + "y": 1887576.3418796062 + }, + { + "x": 0.074168041, + "y": 1887197.0635576248 + }, + { + "x": 0.074227585, + "y": 1886204.0638763905 + }, + { + "x": 0.074319524, + "y": 1885495.5472869873 + }, + { + "x": 0.074370781, + "y": 1883426.0389726162 + }, + { + "x": 0.093890289, + "y": 1881908.785944581 + }, + { + "x": 0.121272098, + "y": 1875424.7203474045 + }, + { + "x": 0.322939732, + "y": 1867940.3031840324 + }, + { + "x": 0.323004807, + "y": 1867061.7577693462 + }, + { + "x": 0.624700544, + "y": 1866863.8481647968 + }, + { + "x": 0.629198801, + "y": 1865885.9779644012 + }, + { + "x": 0.654092511, + "y": 1860035.4072842598 + }, + { + "x": 1.093850952, + "y": 1856855.205402255 + }, + { + "x": 1.874368826, + "y": 1856285.5080245733 + }, + { + "x": 4.568560272, + "y": 1849063.3609787226 + }, + { + "x": 4.734165515, + "y": 1848806.1651448011 + }, + { + "x": 25.034078157, + "y": 1848608.731068015 + } + ] + }, + "custom": [ + { + "name": "iterations", + "data_points": [ + { + "x": 0.020828455, + "y": 0 + }, + { + "x": 0.041680475, + "y": 201 + }, + { + "x": 0.042005087, + "y": 201 + }, + { + "x": 0.042361206, + "y": 204 + }, + { + "x": 0.04244768, + "y": 205 + }, + { + "x": 0.058798859, + "y": 431 + }, + { + "x": 0.074109874, + "y": 431 + }, + { + "x": 0.074168041, + "y": 431 + }, + { + "x": 0.074227585, + "y": 678 + }, + { + "x": 0.074319524, + "y": 679 + }, + { + "x": 0.074370781, + "y": 679 + }, + { + "x": 0.093890289, + "y": 897 + }, + { + "x": 0.121272098, + "y": 1152 + }, + { + "x": 0.322939732, + "y": 3898 + }, + { + "x": 0.323004807, + "y": 3898 + }, + { + "x": 0.624700544, + "y": 8140 + }, + { + "x": 0.629198801, + "y": 8194 + }, + { + "x": 0.654092511, + "y": 8456 + }, + { + "x": 1.093850952, + "y": 16071 + }, + { + "x": 1.874368826, + "y": 28310 + }, + { + "x": 4.568560272, + "y": 69599 + }, + { + "x": 4.734165515, + "y": 72163 + }, + { + "x": 25.034078157, + "y": 378874 + } + ] + } + ] + } + } +} diff --git a/src/tests/schema/test_input.py b/src/tests/schema/test_input.py new file mode 100644 index 0000000..124081a --- /dev/null +++ b/src/tests/schema/test_input.py @@ -0,0 +1,59 @@ +# © 2019-present nextmv.io inc + +import json +import os +import unittest + +from nextroute.schema import Input, Stop, Vehicle + + +class TestInput(unittest.TestCase): + filepath = os.path.join(os.path.dirname(__file__), "input.json") + + def test_from_json(self): + with open(self.filepath) as f: + json_data = json.load(f) + + nextroute_input = Input.from_dict(json_data) + parsed = nextroute_input.to_dict() + + for s, stop in enumerate(parsed["stops"]): + original_stop = json_data["stops"][s] + self.assertEqual( + stop, + original_stop, + f"stop: parsed({stop}) and original ({original_stop}) should be equal", + ) + + for v, vehicle in enumerate(parsed["vehicles"]): + original_vehicle = json_data["vehicles"][v] + self.assertEqual( + vehicle, + original_vehicle, + f"vehicle: parsed ({vehicle}) and original ({original_vehicle}) should be equal", + ) + + self.assertEqual( + parsed["defaults"], + json_data["defaults"], + f"defaults: parsed ({parsed['defaults']}) and original ({json_data['defaults']}) should be equal", + ) + + def test_from_dict(self): + with open(self.filepath) as f: + json_data = json.load(f) + + nextroute_input = Input.from_dict(json_data) + stops = nextroute_input.stops + for stop in stops: + self.assertTrue( + isinstance(stop, Stop), + f"Stop ({stop}) should be of type Stop.", + ) + + vehicles = nextroute_input.vehicles + for vehicle in vehicles: + self.assertTrue( + isinstance(vehicle, Vehicle), + f"Vehicle ({vehicle}) should be of type Vehicle.", + ) diff --git a/src/tests/schema/test_output.py b/src/tests/schema/test_output.py new file mode 100644 index 0000000..c4126ed --- /dev/null +++ b/src/tests/schema/test_output.py @@ -0,0 +1,318 @@ +# © 2019-present nextmv.io inc + +import json +import math +import os +import unittest + +from nextroute import check as nextrouteCheck +from nextroute.schema import ( + Location, + ObjectiveOutput, + Output, + PlannedStopOutput, + ResultStatistics, + RunStatistics, + SeriesData, + Solution, + Statistics, + StopOutput, + VehicleOutput, + Version, +) + + +class TestOutput(unittest.TestCase): + filepath = os.path.join(os.path.dirname(__file__), "output.json") + + def test_from_json(self): + with open(self.filepath) as f: + json_data = json.load(f) + + nextroute_output = Output.from_dict(json_data) + parsed = nextroute_output.to_dict() + solution = parsed["solutions"][0] + + for s, stop in enumerate(solution["unplanned"]): + original_stop = json_data["solutions"][0]["unplanned"][s] + self.assertEqual( + stop, + original_stop, + f"stop: parsed({stop}) and original ({original_stop}) should be equal", + ) + + for v, vehicle in enumerate(solution["vehicles"]): + original_vehicle = json_data["solutions"][0]["vehicles"][v] + self.assertEqual( + vehicle, + original_vehicle, + f"vehicle: parsed ({vehicle}) and original ({original_vehicle}) should be equal", + ) + + self.assertEqual( + solution["objective"], + json_data["solutions"][0]["objective"], + f"objective: parsed ({solution['objective']}) and " + f"original ({json_data['solutions'][0]['objective']}) should be equal", + ) + + statistics = parsed["statistics"] + self.assertEqual( + statistics["run"], + json_data["statistics"]["run"], + f"run: parsed ({statistics['run']}) and original ({json_data['statistics']['run']}) should be equal", + ) + self.assertEqual( + statistics["result"], + json_data["statistics"]["result"], + f"result: parsed ({statistics['result']}) and " + f"original ({json_data['statistics']['result']}) should be equal", + ) + + def test_from_dict(self): + with open(self.filepath) as f: + json_data = json.load(f) + + nextroute_output = Output.from_dict(json_data) + + version = nextroute_output.version + self.assertTrue(isinstance(version, Version), "Version should be of type Version.") + + solutions = nextroute_output.solutions + for solution in solutions: + self.assertTrue( + isinstance(solution, Solution), + f"Solution ({solution}) should be of type Solution.", + ) + + unplanned = solution.unplanned + for stop in unplanned: + self.assertTrue( + isinstance(stop, StopOutput), + f"Stop ({stop}) should be of type StopOutput.", + ) + self.assertTrue( + stop.id is not None, + f"Stop ({stop}) should have an id.", + ) + self.assertNotEqual( + stop.id, + "", + f"Stop ({stop}) should have a valid id.", + ) + self.assertTrue( + isinstance(stop.location, Location), + f"Stop ({stop}) should have a location.", + ) + self.assertGreaterEqual( + stop.location.lat, + -90, + f"Stop ({stop}) should have a valid latitude.", + ) + self.assertLessEqual( + stop.location.lat, + 90, + f"Stop ({stop}) should have a valid latitude.", + ) + self.assertGreaterEqual( + stop.location.lon, + -180, + f"Stop ({stop}) should have a valid longitude.", + ) + self.assertLessEqual( + stop.location.lon, + 180, + f"Stop ({stop}) should have a valid longitude.", + ) + + vehicles = solution.vehicles + for vehicle in vehicles: + self.assertTrue( + isinstance(vehicle, VehicleOutput), + f"Vehicle ({vehicle}) should be of type VehicleOutput.", + ) + self.assertTrue( + vehicle.id is not None, + f"Vehicle ({vehicle}) should have an id.", + ) + self.assertNotEqual( + vehicle.id, + "", + f"Vehicle ({vehicle}) should have a valid id.", + ) + self.assertGreaterEqual( + vehicle.route_duration, + 0, + f"Vehicle ({vehicle}) should have a valid route duration.", + ) + self.assertGreaterEqual( + vehicle.route_stops_duration, + 0, + f"Vehicle ({vehicle}) should have a valid route stops duration.", + ) + self.assertGreaterEqual( + vehicle.route_travel_distance, + 0, + f"Vehicle ({vehicle}) should have a valid route travel distance.", + ) + self.assertGreaterEqual( + vehicle.route_travel_duration, + 0, + f"Vehicle ({vehicle}) should have a valid route travel duration.", + ) + + for stop in vehicle.route: + self.assertTrue( + isinstance(stop, PlannedStopOutput), + f"Stop ({stop}) should be of type PlannedStopOutput.", + ) + self.assertTrue( + isinstance(stop.stop, StopOutput), + f"Stop ({stop}) should have a stop.", + ) + self.assertGreaterEqual( + stop.travel_duration, + 0, + f"Stop ({stop}) should have a valid travel duration.", + ) + self.assertGreaterEqual( + stop.cumulative_travel_duration, + 0, + f"Stop ({stop}) should have a valid cumulative travel duration.", + ) + + objective = solution.objective + self.assertTrue( + isinstance(objective, ObjectiveOutput), + f"Objective ({objective}) should be of type ObjectiveOutput.", + ) + + statistics = nextroute_output.statistics + self.assertTrue( + isinstance(statistics, Statistics), + f"Statistics ({statistics}) should be of type Statistics.", + ) + + run_statistics = statistics.run + self.assertTrue( + isinstance(run_statistics, RunStatistics), + f"Run statistics ({run_statistics}) should be of type RunStatistics.", + ) + self.assertGreaterEqual( + run_statistics.duration, + 0, + f"Run statistics ({run_statistics}) should have a valid duration.", + ) + self.assertGreaterEqual( + run_statistics.iterations, + 0, + f"Run statistics ({run_statistics}) should have a valid number of iterations.", + ) + + result_statistics = statistics.result + self.assertTrue( + isinstance(result_statistics, ResultStatistics), + f"Result statistics ({result_statistics}) should be of type ResultStatistics.", + ) + self.assertGreaterEqual( + result_statistics.duration, + 0, + f"Result statistics ({result_statistics}) should have a valid duration.", + ) + self.assertGreaterEqual( + result_statistics.value, + 0, + f"Result statistics ({result_statistics}) should have a valid value.", + ) + + series_data = statistics.series_data + self.assertTrue( + isinstance(series_data, SeriesData), + f"Series data ({series_data}) should be of type SeriesData.", + ) + + def test_with_check(self): + with open(os.path.join(os.path.dirname(__file__), "output_with_check.json")) as f: + json_data = json.load(f) + + nextroute_output = Output.from_dict(json_data) + check = nextroute_output.solutions[0].check + self.assertTrue( + isinstance(check, nextrouteCheck.Output), + f"Check ({check}) should be of type nextrouteCheck.Output.", + ) + self.assertTrue( + isinstance(check.solution, nextrouteCheck.Solution), + f"Solution ({check.solution}) should be of type nextrouteCheck.checkSolution.", + ) + self.assertTrue( + isinstance(check.summary, nextrouteCheck.Summary), + f"Summary ({check.summary}) should be of type nextrouteCheck.Summary.", + ) + for plan_unit in check.plan_units: + self.assertTrue( + isinstance(plan_unit, nextrouteCheck.PlanUnit), + f"Plan unit ({plan_unit}) should be of type nextrouteCheck.PlanUnit.", + ) + + for vehicle in check.vehicles: + self.assertTrue( + isinstance(vehicle, nextrouteCheck.Vehicle), + f"Vehicle ({vehicle}) should be of type nextrouteCheck.Vehicle", + ) + + def test_result_statistics_decoding(self): + test_cases = [ + { + "name": "value is float", + "json_stats": '{"duration": 0.1, "value": 1.23}', + }, + { + "name": "value is nan", + "json_stats": '{"duration": 0.1, "value": "nan"}', + }, + { + "name": "value is infinity", + "json_stats": '{"duration": 0.1, "value": "inf"}', + }, + { + "name": "value is infinity 2", + "json_stats": '{"duration": 0.1, "value": "+inf"}', + }, + { + "name": "value is -infinity", + "json_stats": '{"duration": 0.1, "value": "-inf"}', + }, + ] + + for test in test_cases: + dict_stats = json.loads(test["json_stats"]) + stats = ResultStatistics.from_dict(dict_stats) + self.assertTrue(isinstance(stats, ResultStatistics)) + self.assertTrue(isinstance(stats.value, float)) + + def test_result_statistics_encoding(self): + test_cases = [ + { + "name": "value is float", + "stats": ResultStatistics(duration=0.1, value=1.23), + }, + { + "name": "value is nan", + "stats": ResultStatistics(duration=0.1, value=math.nan), + }, + { + "name": "value is infinity", + "stats": ResultStatistics(duration=0.1, value=math.inf), + }, + { + "name": "value is -infinity", + "stats": ResultStatistics(duration=0.1, value=-1 * math.inf), + }, + ] + + for test in test_cases: + stats = test["stats"] + dict_stats = stats.to_dict() + self.assertTrue(isinstance(dict_stats, dict)) + self.assertTrue(isinstance(dict_stats["value"], float)) diff --git a/src/tests/solve_golden/__init__.py b/src/tests/solve_golden/__init__.py new file mode 100644 index 0000000..86dfdf3 --- /dev/null +++ b/src/tests/solve_golden/__init__.py @@ -0,0 +1 @@ +# © 2019-present nextmv.io inc diff --git a/src/tests/solve_golden/main.py b/src/tests/solve_golden/main.py new file mode 100644 index 0000000..8e64836 --- /dev/null +++ b/src/tests/solve_golden/main.py @@ -0,0 +1,46 @@ +# This script is copied to the `src` root so that the `nextroute` import is +# resolved. It is fed an input via stdin and is meant to write the output to +# stdout. +from typing import Any, Dict + +import nextmv + +import nextroute + + +def main() -> None: + """Entry point for the program.""" + + parameters = [ + nextmv.Parameter("input", str, "", "Path to input file. Default is stdin.", False), + nextmv.Parameter("output", str, "", "Path to output file. Default is stdout.", False), + ] + + nextroute_options = nextroute.Options() + for name, default_value in nextroute_options.to_dict().items(): + parameters.append(nextmv.Parameter(name.lower(), type(default_value), default_value, name, False)) + + options = nextmv.Options(*parameters) + + input = nextmv.load_local(options=options, path=options.input) + + nextmv.log("Solving vehicle routing problem:") + nextmv.log(f" - stops: {len(input.data.get('stops', []))}") + nextmv.log(f" - vehicles: {len(input.data.get('vehicles', []))}") + + output = solve(input, options) + nextmv.write_local(output, path=options.output) + + +def solve(input: nextmv.Input, options: nextmv.Options) -> Dict[str, Any]: + """Solves the given problem and returns the solution.""" + + nextroute_input = nextroute.schema.Input.from_dict(input.data) + nextroute_options = nextroute.Options.extract_from_dict(options.to_dict()) + nextroute_output = nextroute.solve(nextroute_input, nextroute_options) + + return nextroute_output.to_dict() + + +if __name__ == "__main__": + main() diff --git a/src/tests/solve_golden/main_test.go b/src/tests/solve_golden/main_test.go new file mode 100644 index 0000000..4ffec88 --- /dev/null +++ b/src/tests/solve_golden/main_test.go @@ -0,0 +1,94 @@ +// © 2019-present nextmv.io inc + +package main + +import ( + "os" + "os/exec" + "path" + "testing" + + "github.com/nextmv-io/sdk/golden" +) + +const pythonFile = "main.py" + +var pythonFileDestination = path.Join("..", "..", pythonFile) + +func TestMain(m *testing.M) { + // Move the python file to the `src` so that the import path in that file + // is resolved. + input, err := os.ReadFile(pythonFile) + if err != nil { + panic(err) + } + err = os.WriteFile(pythonFileDestination, input, 0644) + if err != nil { + panic(err) + } + + // Compile the Go binary that is needed for this test. + cmd := exec.Command( + "go", "build", + "-o", path.Join("..", "..", "nextroute", "bin", "nextroute.exe"), + path.Join("..", "..", "..", "cmd", "main.go"), + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + panic(err) + } + + // Run the tests. + code := m.Run() + + // Clean up the python file. + err = os.Remove(pythonFileDestination) + if err != nil { + panic(err) + } + + os.Exit(code) +} + +func TestPythonSolveGolden(t *testing.T) { + // These golden file tests are based on the original Go golden file tests. + // It uses the `./tests/golden` directory (relative to the root of the + // project) as a data source. It executes a Python script that uses the + // Nextmv Python SDK to load options and read/write JSON files. + golden.FileTests( + t, + path.Join("..", "..", "..", "tests", "golden", "testdata"), + golden.Config{ + Args: []string{ + "-solve_duration", "10", + // for deterministic tests + "-format_disable_progression", "true", + "-solve_parallelruns", "1", + "-solve_iterations", "50", + "-solve_rundeterministically", "true", + "-solve_startsolutions", "1", + }, + TransientFields: []golden.TransientField{ + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.value", Replacement: golden.StableFloat}, + {Key: "$.options.nextmv.output", Replacement: "output.json"}, + {Key: "$.options.nextmv.input", Replacement: "input.json"}, + {Key: "$.statistics.result.custom.max_travel_duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.custom.min_travel_duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.custom.max_duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.custom.min_duration", Replacement: golden.StableFloat}, + }, + Thresholds: golden.Tresholds{ + Float: 0.01, + }, + ExecutionConfig: &golden.ExecutionConfig{ + Command: "python3", + Args: []string{pythonFileDestination}, + InputFlag: "-input", + OutputFlag: "-output", + }, + }, + ) +} diff --git a/src/tests/test_options.py b/src/tests/test_options.py new file mode 100644 index 0000000..2d95cd2 --- /dev/null +++ b/src/tests/test_options.py @@ -0,0 +1,83 @@ +import unittest + +import nextroute + + +class TestOptions(unittest.TestCase): + def test_options_default_values(self): + opt = nextroute.Options() + options_dict = opt.to_dict() + self.assertDictEqual( + options_dict, + { + "CHECK_DURATION": 30.0, + "CHECK_VERBOSITY": "off", + "FORMAT_DISABLE_PROGRESSION": False, + "MODEL_CONSTRAINTS_DISABLE_ATTRIBUTES": False, + "MODEL_CONSTRAINTS_DISABLE_CAPACITIES": [], + "MODEL_CONSTRAINTS_DISABLE_CAPACITY": False, + "MODEL_CONSTRAINTS_DISABLE_DISTANCELIMIT": False, + "MODEL_CONSTRAINTS_DISABLE_GROUPS": False, + "MODEL_CONSTRAINTS_DISABLE_MAXIMUMDURATION": False, + "MODEL_CONSTRAINTS_DISABLE_MAXIMUMSTOPS": False, + "MODEL_CONSTRAINTS_DISABLE_MAXIMUMWAITSTOP": False, + "MODEL_CONSTRAINTS_DISABLE_MAXIMUMWAITVEHICLE": False, + "MODEL_CONSTRAINTS_DISABLE_MIXINGITEMS": False, + "MODEL_CONSTRAINTS_DISABLE_PRECEDENCE": False, + "MODEL_CONSTRAINTS_DISABLE_STARTTIMEWINDOWS": False, + "MODEL_CONSTRAINTS_DISABLE_VEHICLEENDTIME": False, + "MODEL_CONSTRAINTS_DISABLE_VEHICLESTARTTIME": False, + "MODEL_CONSTRAINTS_ENABLE_CLUSTER": False, + "MODEL_OBJECTIVES_CAPACITIES": "", + "MODEL_OBJECTIVES_CLUSTER": 0.0, + "MODEL_OBJECTIVES_EARLYARRIVALPENALTY": 1.0, + "MODEL_OBJECTIVES_LATEARRIVALPENALTY": 1.0, + "MODEL_OBJECTIVES_MINSTOPS": 1.0, + "MODEL_OBJECTIVES_TRAVELDURATION": 0.0, + "MODEL_OBJECTIVES_UNPLANNEDPENALTY": 1.0, + "MODEL_OBJECTIVES_VEHICLEACTIVATIONPENALTY": 1.0, + "MODEL_OBJECTIVES_VEHICLESDURATION": 1.0, + "MODEL_PROPERTIES_DISABLE_DURATIONGROUPS": False, + "MODEL_PROPERTIES_DISABLE_DURATIONS": False, + "MODEL_PROPERTIES_DISABLE_INITIALSOLUTION": False, + "MODEL_PROPERTIES_DISABLE_STOPDURATIONMULTIPLIERS": False, + "MODEL_VALIDATE_DISABLE_RESOURCES": False, + "MODEL_VALIDATE_DISABLE_STARTTIME": False, + "MODEL_VALIDATE_ENABLE_MATRIX": False, + "MODEL_VALIDATE_ENABLE_MATRIXASYMMETRYTOLERANCE": 20, + "SOLVE_DURATION": 5.0, + "SOLVE_ITERATIONS": -1, + "SOLVE_PARALLELRUNS": -1, + "SOLVE_RUNDETERMINISTICALLY": False, + "SOLVE_STARTSOLUTIONS": -1, + }, + ) + + def test_options_to_args(self): + # Default options should not produce any arguments. + opt = nextroute.Options() + args = opt.to_args() + self.assertListEqual(args, []) + + # Only options that are not default should produce arguments. + opt2 = nextroute.Options( + CHECK_DURATION=4, + CHECK_VERBOSITY=nextroute.Verbosity.MEDIUM, + SOLVE_DURATION=4, + SOLVE_ITERATIONS=-1, # Default value should be skipped. + MODEL_CONSTRAINTS_DISABLE_ATTRIBUTES=True, + MODEL_VALIDATE_ENABLE_MATRIX=False, # This option should be skipped because it is bool False. + ) + args2 = opt2.to_args() + self.assertListEqual( + args2, + [ + "-check.duration", + "4.0s", + "-check.verbosity", + "medium", + "-model.constraints.disable.attributes", # Bool flags do not have values. + "-solve.duration", + "4.0s", + ], + ) diff --git a/tests/check/input.json.golden b/tests/check/input.json.golden index 97dcd4b..2f29eb5 100644 --- a/tests/check/input.json.golden +++ b/tests/check/input.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, diff --git a/tests/check/main_test.go b/tests/check/main_test.go index 28a73b6..7e3e722 100644 --- a/tests/check/main_test.go +++ b/tests/check/main_test.go @@ -32,10 +32,10 @@ func TestGolden(t *testing.T) { "-solve.startsolutions", "1", }, TransientFields: []golden.TransientField{ - {Key: ".version.sdk", Replacement: golden.StableVersion}, - {Key: ".statistics.result.duration", Replacement: golden.StableFloat}, - {Key: ".statistics.run.duration", Replacement: golden.StableFloat}, - {Key: ".solutions[0].check.duration_used", Replacement: golden.StableFloat}, + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.solutions[0].check.duration_used", Replacement: golden.StableFloat}, }, Thresholds: golden.Tresholds{ Float: 0.01, diff --git a/tests/custom_constraint/input.json.golden b/tests/custom_constraint/input.json.golden index 277bec4..f707dc7 100644 --- a/tests/custom_constraint/input.json.golden +++ b/tests/custom_constraint/input.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, diff --git a/tests/custom_constraint/main_test.go b/tests/custom_constraint/main_test.go index 50df77e..759b2cc 100644 --- a/tests/custom_constraint/main_test.go +++ b/tests/custom_constraint/main_test.go @@ -32,9 +32,9 @@ func TestGolden(t *testing.T) { "-solve.startsolutions", "1", }, TransientFields: []golden.TransientField{ - {Key: ".version.sdk", Replacement: golden.StableVersion}, - {Key: ".statistics.result.duration", Replacement: golden.StableFloat}, - {Key: ".statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, }, Thresholds: golden.Tresholds{ Float: 0.01, diff --git a/tests/custom_matrices/input.json.golden b/tests/custom_matrices/input.json.golden index 4f3abc2..4733348 100644 --- a/tests/custom_matrices/input.json.golden +++ b/tests/custom_matrices/input.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, diff --git a/tests/custom_matrices/main_test.go b/tests/custom_matrices/main_test.go index 28a73b6..d5ae5ad 100644 --- a/tests/custom_matrices/main_test.go +++ b/tests/custom_matrices/main_test.go @@ -32,9 +32,9 @@ func TestGolden(t *testing.T) { "-solve.startsolutions", "1", }, TransientFields: []golden.TransientField{ - {Key: ".version.sdk", Replacement: golden.StableVersion}, - {Key: ".statistics.result.duration", Replacement: golden.StableFloat}, - {Key: ".statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, {Key: ".solutions[0].check.duration_used", Replacement: golden.StableFloat}, }, Thresholds: golden.Tresholds{ diff --git a/tests/custom_objective/input.json.golden b/tests/custom_objective/input.json.golden index 9967889..ac6575b 100644 --- a/tests/custom_objective/input.json.golden +++ b/tests/custom_objective/input.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, diff --git a/tests/custom_objective/main_test.go b/tests/custom_objective/main_test.go index 50df77e..759b2cc 100644 --- a/tests/custom_objective/main_test.go +++ b/tests/custom_objective/main_test.go @@ -32,9 +32,9 @@ func TestGolden(t *testing.T) { "-solve.startsolutions", "1", }, TransientFields: []golden.TransientField{ - {Key: ".version.sdk", Replacement: golden.StableVersion}, - {Key: ".statistics.result.duration", Replacement: golden.StableFloat}, - {Key: ".statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, }, Thresholds: golden.Tresholds{ Float: 0.01, diff --git a/tests/custom_operators/input.json.golden b/tests/custom_operators/input.json.golden index a539e88..fd49968 100644 --- a/tests/custom_operators/input.json.golden +++ b/tests/custom_operators/input.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, diff --git a/tests/custom_operators/main_test.go b/tests/custom_operators/main_test.go index 28a73b6..d5ae5ad 100644 --- a/tests/custom_operators/main_test.go +++ b/tests/custom_operators/main_test.go @@ -32,9 +32,9 @@ func TestGolden(t *testing.T) { "-solve.startsolutions", "1", }, TransientFields: []golden.TransientField{ - {Key: ".version.sdk", Replacement: golden.StableVersion}, - {Key: ".statistics.result.duration", Replacement: golden.StableFloat}, - {Key: ".statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, {Key: ".solutions[0].check.duration_used", Replacement: golden.StableFloat}, }, Thresholds: golden.Tresholds{ diff --git a/tests/custom_output/main_test.go b/tests/custom_output/main_test.go index 28a73b6..d5ae5ad 100644 --- a/tests/custom_output/main_test.go +++ b/tests/custom_output/main_test.go @@ -32,9 +32,9 @@ func TestGolden(t *testing.T) { "-solve.startsolutions", "1", }, TransientFields: []golden.TransientField{ - {Key: ".version.sdk", Replacement: golden.StableVersion}, - {Key: ".statistics.result.duration", Replacement: golden.StableFloat}, - {Key: ".statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, {Key: ".solutions[0].check.duration_used", Replacement: golden.StableFloat}, }, Thresholds: golden.Tresholds{ diff --git a/tests/golden/main_test.go b/tests/golden/main_test.go index 98e6985..17a925d 100644 --- a/tests/golden/main_test.go +++ b/tests/golden/main_test.go @@ -35,9 +35,14 @@ func TestGolden(t *testing.T) { "-solve.startsolutions", "1", }, TransientFields: []golden.TransientField{ - {Key: ".version.sdk", Replacement: golden.StableVersion}, - {Key: ".statistics.result.duration", Replacement: golden.StableFloat}, - {Key: ".statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.value", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.custom.max_travel_duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.custom.min_travel_duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.custom.max_duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.result.custom.min_duration", Replacement: golden.StableFloat}, }, Thresholds: golden.Tresholds{ Float: 0.01, diff --git a/tests/golden/testdata/activation_penalty.json.golden b/tests/golden/testdata/activation_penalty.json.golden index c571835..ba1a164 100644 --- a/tests/golden/testdata/activation_penalty.json.golden +++ b/tests/golden/testdata/activation_penalty.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -216,16 +217,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 909, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 909, - "min_duration": 909, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 909, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 909.0466359667602 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/alternates.json.golden b/tests/golden/testdata/alternates.json.golden index eac78ce..4543e15 100644 --- a/tests/golden/testdata/alternates.json.golden +++ b/tests/golden/testdata/alternates.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -255,16 +256,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 913, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 913, - "min_duration": 11, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 2, - "min_travel_duration": 11, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 4000925.1454996876 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/basic.json.golden b/tests/golden/testdata/basic.json.golden index a73489a..e7ae855 100644 --- a/tests/golden/testdata/basic.json.golden +++ b/tests/golden/testdata/basic.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -205,16 +206,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 909, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 909, - "min_duration": 909, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 909, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 909.0466359667602 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/capacity.json.golden b/tests/golden/testdata/capacity.json.golden index 10d8b2c..c9e9f97 100644 --- a/tests/golden/testdata/capacity.json.golden +++ b/tests/golden/testdata/capacity.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -222,16 +223,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 791, + "max_duration": 0.123, "max_stops_in_vehicle": 4, - "max_travel_duration": 791, - "min_duration": 363, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 3, - "min_travel_duration": 363, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 1155.4805543604316 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/compatibility_attributes.json.golden b/tests/golden/testdata/compatibility_attributes.json.golden index c7cf69f..abdfb9a 100644 --- a/tests/golden/testdata/compatibility_attributes.json.golden +++ b/tests/golden/testdata/compatibility_attributes.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -242,16 +243,16 @@ "result": { "custom": { "activated_vehicles": 4, - "max_duration": 368, + "max_duration": 0.123, "max_stops_in_vehicle": 4, - "max_travel_duration": 368, - "min_duration": 0, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 368.0821280755173 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/complex_precedence.json.golden b/tests/golden/testdata/complex_precedence.json.golden index 12b81ba..21e1af1 100644 --- a/tests/golden/testdata/complex_precedence.json.golden +++ b/tests/golden/testdata/complex_precedence.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -451,16 +452,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 3571, + "max_duration": 0.123, "max_stops_in_vehicle": 8, - "max_travel_duration": 2761, - "min_duration": 2620, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 8, - "min_travel_duration": 1810, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 3333285.1173214912 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/custom_data.json.golden b/tests/golden/testdata/custom_data.json.golden index 11a2723..64af578 100644 --- a/tests/golden/testdata/custom_data.json.golden +++ b/tests/golden/testdata/custom_data.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -233,16 +234,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 909, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 909, - "min_duration": 909, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 909, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 909.0466359667602 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/defaults.json.golden b/tests/golden/testdata/defaults.json.golden index 7329cf3..9148728 100644 --- a/tests/golden/testdata/defaults.json.golden +++ b/tests/golden/testdata/defaults.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -397,16 +398,16 @@ "result": { "custom": { "activated_vehicles": 4, - "max_duration": 2157, + "max_duration": 0.123, "max_stops_in_vehicle": 2, - "max_travel_duration": 1557, - "min_duration": 824, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 524, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 11249.95995926857 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/direct_precedence.json.golden b/tests/golden/testdata/direct_precedence.json.golden index ab29c0d..aa6a0ae 100644 --- a/tests/golden/testdata/direct_precedence.json.golden +++ b/tests/golden/testdata/direct_precedence.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -194,16 +195,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1319, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 1319, - "min_duration": 1319, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 1319, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 1319.8793982515122 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/direct_precedence_linked.json.golden b/tests/golden/testdata/direct_precedence_linked.json.golden index d319218..b9e7d24 100644 --- a/tests/golden/testdata/direct_precedence_linked.json.golden +++ b/tests/golden/testdata/direct_precedence_linked.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -194,16 +195,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1321, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 1321, - "min_duration": 1321, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 1321, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 1321.4235337700516 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/distance_matrix.json.golden b/tests/golden/testdata/distance_matrix.json.golden index 52b5f63..6698bd4 100644 --- a/tests/golden/testdata/distance_matrix.json.golden +++ b/tests/golden/testdata/distance_matrix.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -147,16 +148,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 335, + "max_duration": 0.123, "max_stops_in_vehicle": 2, - "max_travel_duration": 335, - "min_duration": 0, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 335 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/duration_groups.json.golden b/tests/golden/testdata/duration_groups.json.golden index 58b73c2..094a814 100644 --- a/tests/golden/testdata/duration_groups.json.golden +++ b/tests/golden/testdata/duration_groups.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -209,16 +210,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1727, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 1127, - "min_duration": 1727, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 1127, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 1727.601181827492 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/duration_groups_with_stop_multiplier.json.golden b/tests/golden/testdata/duration_groups_with_stop_multiplier.json.golden index d8422d7..e99b204 100644 --- a/tests/golden/testdata/duration_groups_with_stop_multiplier.json.golden +++ b/tests/golden/testdata/duration_groups_with_stop_multiplier.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -209,16 +210,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 2327, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 1127, - "min_duration": 2327, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 1127, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 2327.601181827492 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/duration_matrix.json.golden b/tests/golden/testdata/duration_matrix.json.golden index b465359..6f3ea62 100644 --- a/tests/golden/testdata/duration_matrix.json.golden +++ b/tests/golden/testdata/duration_matrix.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -142,16 +143,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1380, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 1380, - "min_duration": 1380, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 3, - "min_travel_duration": 1380, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 1380 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/duration_matrix_time_dependent.md b/tests/golden/testdata/duration_matrix_time_dependent.md new file mode 100644 index 0000000..55d0977 --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent.md @@ -0,0 +1,13 @@ +# Time dependent duration matrix (duration_matrix_time_dependent*.json) + +The duration matrix time-dependent files define different duration matrices +usage scenarios where duration_matrix_time_dependent0 is the simplest one and +works as a baseline. +All other files are expected to have longer travel times due to scaling or matrices +with higher values in the time frames. An exception is file 3 which has a lower +travel time than file 0 because it splits stops on two vehicles. + +* file 0: "route_travel_duration": 1380 +* file 1: "route_travel_duration": 1498 +* file 2: "route_travel_duration": 1950 +* file 3: "route_travel_duration": 1140 diff --git a/tests/golden/testdata/duration_matrix_time_dependent0.json b/tests/golden/testdata/duration_matrix_time_dependent0.json new file mode 100644 index 0000000..0e23bd6 --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent0.json @@ -0,0 +1,31 @@ +{ + "duration_matrix": { + "default_matrix": [ + [0, 720, 1020, 0, 0], + [720, 0, 660, 0, 0], + [1020, 660, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0] + ] + }, + "stops": [ + { + "id": "Fushimi Inari Taisha", + "location": { "lon": 135.772695, "lat": 34.967146 } + }, + { + "id": "Kiyomizu-dera", + "location": { "lon": 135.78506, "lat": 34.994857 } + }, + { + "id": "Nijō Castle", + "location": { "lon": 135.748134, "lat": 35.014239 } + } + ], + "vehicles": [ + { + "id": "v1", + "start_time": "2023-01-01T12:00:00Z" + } + ] +} diff --git a/tests/golden/testdata/duration_matrix_time_dependent0.json.golden b/tests/golden/testdata/duration_matrix_time_dependent0.json.golden new file mode 100644 index 0000000..df19382 --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent0.json.golden @@ -0,0 +1,175 @@ +{ + "options": { + "check": { + "duration": 30000000000, + "verbosity": "off" + }, + "format": { + "disable": { + "progression": true + } + }, + "model": { + "constraints": { + "disable": { + "attributes": false, + "capacities": null, + "capacity": false, + "distance_limit": false, + "groups": false, + "maximum_duration": false, + "maximum_stops": false, + "maximum_wait_stop": false, + "maximum_wait_vehicle": false, + "mixing_items": false, + "precedence": false, + "start_time_windows": false, + "vehicle_end_time": false, + "vehicle_start_time": false + }, + "enable": { + "cluster": false + } + }, + "objectives": { + "capacities": "", + "cluster": 0, + "early_arrival_penalty": 1, + "late_arrival_penalty": 1, + "min_stops": 1, + "stop_balance": 0, + "travel_duration": 0, + "unplanned_penalty": 1, + "vehicle_activation_penalty": 1, + "vehicles_duration": 1 + }, + "properties": { + "disable": { + "duration_groups": false, + "durations": false, + "initial_solution": false, + "stop_duration_multipliers": false + } + }, + "validate": { + "disable": { + "resources": false, + "start_time": false + }, + "enable": { + "matrix": false, + "matrix_asymmetry_tolerance": 20 + } + } + }, + "solve": { + "duration": 10000000000, + "iterations": 50, + "parallel_runs": 1, + "run_deterministically": true, + "start_solutions": 1 + } + }, + "solutions": [ + { + "objective": { + "name": "1 * vehicles_duration + 1 * unplanned_penalty", + "objectives": [ + { + "base": 1380, + "factor": 1, + "name": "vehicles_duration", + "value": 1380 + }, + { + "factor": 1, + "name": "unplanned_penalty", + "value": 0 + } + ], + "value": 1380 + }, + "unplanned": [], + "vehicles": [ + { + "id": "v1", + "route": [ + { + "arrival_time": "2023-01-01T12:00:00Z", + "cumulative_travel_duration": 0, + "end_time": "2023-01-01T12:00:00Z", + "start_time": "2023-01-01T12:00:00Z", + "stop": { + "id": "Nijō Castle", + "location": { + "lat": 35.014239, + "lon": 135.748134 + } + }, + "travel_duration": 0 + }, + { + "arrival_time": "2023-01-01T12:11:00Z", + "cumulative_travel_distance": 3994, + "cumulative_travel_duration": 660, + "end_time": "2023-01-01T12:11:00Z", + "start_time": "2023-01-01T12:11:00Z", + "stop": { + "id": "Kiyomizu-dera", + "location": { + "lat": 34.994857, + "lon": 135.78506 + } + }, + "travel_distance": 3994, + "travel_duration": 660 + }, + { + "arrival_time": "2023-01-01T12:23:00Z", + "cumulative_travel_distance": 7274, + "cumulative_travel_duration": 1380, + "end_time": "2023-01-01T12:23:00Z", + "start_time": "2023-01-01T12:23:00Z", + "stop": { + "id": "Fushimi Inari Taisha", + "location": { + "lat": 34.967146, + "lon": 135.772695 + } + }, + "travel_distance": 3280, + "travel_duration": 720 + } + ], + "route_duration": 1380, + "route_travel_distance": 7274, + "route_travel_duration": 1380 + } + ] + } + ], + "statistics": { + "result": { + "custom": { + "activated_vehicles": 1, + "max_duration": 0.123, + "max_stops_in_vehicle": 3, + "max_travel_duration": 0.123, + "min_duration": 0.123, + "min_stops_in_vehicle": 3, + "min_travel_duration": 0.123, + "unplanned_stops": 0 + }, + "duration": 0.123, + "value": 0.123 + }, + "run": { + "duration": 0.123, + "iterations": 50 + }, + "schema": "v1" + }, + "version": { + "sdk": "VERSION" + } +} diff --git a/tests/golden/testdata/duration_matrix_time_dependent1.json b/tests/golden/testdata/duration_matrix_time_dependent1.json new file mode 100644 index 0000000..297a2bb --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent1.json @@ -0,0 +1,55 @@ +{ + "duration_matrix": { + "default_matrix": [ + [0, 720, 1020, 0, 0], + [720, 0, 660, 0, 0], + [1020, 660, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0] + ], + "matrix_time_frames": [ + { + "start_time": "2023-01-01T12:03:00Z", + "end_time": "2023-01-01T12:10:00Z", + "matrix": [ + [0, 800, 1100, 0, 0], + [800, 0, 740, 0, 0], + [1100, 740, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0] + ] + }, + { + "start_time": "2023-01-01T12:12:00Z", + "end_time": "2023-01-01T12:20:00Z", + "matrix": [ + [0, 850, 1150, 0, 0], + [850, 0, 790, 0, 0], + [1150, 790, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0] + ] + } + ] + }, + "stops": [ + { + "id": "Fushimi Inari Taisha", + "location": { "lon": 135.772695, "lat": 34.967146 } + }, + { + "id": "Kiyomizu-dera", + "location": { "lon": 135.78506, "lat": 34.994857 } + }, + { + "id": "Nijō Castle", + "location": { "lon": 135.748134, "lat": 35.014239 } + } + ], + "vehicles": [ + { + "id": "v1", + "start_time": "2023-01-01T12:00:00Z" + } + ] +} diff --git a/tests/golden/testdata/duration_matrix_time_dependent1.json.golden b/tests/golden/testdata/duration_matrix_time_dependent1.json.golden new file mode 100644 index 0000000..3f9947d --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent1.json.golden @@ -0,0 +1,175 @@ +{ + "options": { + "check": { + "duration": 30000000000, + "verbosity": "off" + }, + "format": { + "disable": { + "progression": true + } + }, + "model": { + "constraints": { + "disable": { + "attributes": false, + "capacities": null, + "capacity": false, + "distance_limit": false, + "groups": false, + "maximum_duration": false, + "maximum_stops": false, + "maximum_wait_stop": false, + "maximum_wait_vehicle": false, + "mixing_items": false, + "precedence": false, + "start_time_windows": false, + "vehicle_end_time": false, + "vehicle_start_time": false + }, + "enable": { + "cluster": false + } + }, + "objectives": { + "capacities": "", + "cluster": 0, + "early_arrival_penalty": 1, + "late_arrival_penalty": 1, + "min_stops": 1, + "stop_balance": 0, + "travel_duration": 0, + "unplanned_penalty": 1, + "vehicle_activation_penalty": 1, + "vehicles_duration": 1 + }, + "properties": { + "disable": { + "duration_groups": false, + "durations": false, + "initial_solution": false, + "stop_duration_multipliers": false + } + }, + "validate": { + "disable": { + "resources": false, + "start_time": false + }, + "enable": { + "matrix": false, + "matrix_asymmetry_tolerance": 20 + } + } + }, + "solve": { + "duration": 10000000000, + "iterations": 50, + "parallel_runs": 1, + "run_deterministically": true, + "start_solutions": 1 + } + }, + "solutions": [ + { + "objective": { + "name": "1 * vehicles_duration + 1 * unplanned_penalty", + "objectives": [ + { + "base": 1498.8171701431274, + "factor": 1, + "name": "vehicles_duration", + "value": 1498.8171701431274 + }, + { + "factor": 1, + "name": "unplanned_penalty", + "value": 0 + } + ], + "value": 1498.8171701431274 + }, + "unplanned": [], + "vehicles": [ + { + "id": "v1", + "route": [ + { + "arrival_time": "2023-01-01T12:00:00Z", + "cumulative_travel_duration": 0, + "end_time": "2023-01-01T12:00:00Z", + "start_time": "2023-01-01T12:00:00Z", + "stop": { + "id": "Nijō Castle", + "location": { + "lat": 35.014239, + "lon": 135.748134 + } + }, + "travel_duration": 0 + }, + { + "arrival_time": "2023-01-01T12:11:45Z", + "cumulative_travel_distance": 3994, + "cumulative_travel_duration": 705, + "end_time": "2023-01-01T12:11:45Z", + "start_time": "2023-01-01T12:11:45Z", + "stop": { + "id": "Kiyomizu-dera", + "location": { + "lat": 34.994857, + "lon": 135.78506 + } + }, + "travel_distance": 3994, + "travel_duration": 705 + }, + { + "arrival_time": "2023-01-01T12:24:58Z", + "cumulative_travel_distance": 7274, + "cumulative_travel_duration": 1498, + "end_time": "2023-01-01T12:24:58Z", + "start_time": "2023-01-01T12:24:58Z", + "stop": { + "id": "Fushimi Inari Taisha", + "location": { + "lat": 34.967146, + "lon": 135.772695 + } + }, + "travel_distance": 3280, + "travel_duration": 793 + } + ], + "route_duration": 1498, + "route_travel_distance": 7274, + "route_travel_duration": 1498 + } + ] + } + ], + "statistics": { + "result": { + "custom": { + "activated_vehicles": 1, + "max_duration": 0.123, + "max_stops_in_vehicle": 3, + "max_travel_duration": 0.123, + "min_duration": 0.123, + "min_stops_in_vehicle": 3, + "min_travel_duration": 0.123, + "unplanned_stops": 0 + }, + "duration": 0.123, + "value": 0.123 + }, + "run": { + "duration": 0.123, + "iterations": 50 + }, + "schema": "v1" + }, + "version": { + "sdk": "VERSION" + } +} diff --git a/tests/golden/testdata/duration_matrix_time_dependent2.json b/tests/golden/testdata/duration_matrix_time_dependent2.json new file mode 100644 index 0000000..daec12f --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent2.json @@ -0,0 +1,43 @@ +{ + "duration_matrix": { + "default_matrix": [ + [0, 720, 1020, 0, 0], + [720, 0, 660, 0, 0], + [1020, 660, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0] + ], + "matrix_time_frames": [ + { + "start_time": "2023-01-01T12:03:00Z", + "end_time": "2023-01-01T12:10:00Z", + "scaling_factor": 2.0 + }, + { + "start_time": "2023-01-01T12:12:00Z", + "end_time": "2023-01-01T12:20:00Z", + "scaling_factor": 4.0 + } + ] + }, + "stops": [ + { + "id": "Fushimi Inari Taisha", + "location": { "lon": 135.772695, "lat": 34.967146 } + }, + { + "id": "Kiyomizu-dera", + "location": { "lon": 135.78506, "lat": 34.994857 } + }, + { + "id": "Nijō Castle", + "location": { "lon": 135.748134, "lat": 35.014239 } + } + ], + "vehicles": [ + { + "id": "v1", + "start_time": "2023-01-01T12:00:00Z" + } + ] +} diff --git a/tests/golden/testdata/duration_matrix_time_dependent2.json.golden b/tests/golden/testdata/duration_matrix_time_dependent2.json.golden new file mode 100644 index 0000000..227944d --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent2.json.golden @@ -0,0 +1,175 @@ +{ + "options": { + "check": { + "duration": 30000000000, + "verbosity": "off" + }, + "format": { + "disable": { + "progression": true + } + }, + "model": { + "constraints": { + "disable": { + "attributes": false, + "capacities": null, + "capacity": false, + "distance_limit": false, + "groups": false, + "maximum_duration": false, + "maximum_stops": false, + "maximum_wait_stop": false, + "maximum_wait_vehicle": false, + "mixing_items": false, + "precedence": false, + "start_time_windows": false, + "vehicle_end_time": false, + "vehicle_start_time": false + }, + "enable": { + "cluster": false + } + }, + "objectives": { + "capacities": "", + "cluster": 0, + "early_arrival_penalty": 1, + "late_arrival_penalty": 1, + "min_stops": 1, + "stop_balance": 0, + "travel_duration": 0, + "unplanned_penalty": 1, + "vehicle_activation_penalty": 1, + "vehicles_duration": 1 + }, + "properties": { + "disable": { + "duration_groups": false, + "durations": false, + "initial_solution": false, + "stop_duration_multipliers": false + } + }, + "validate": { + "disable": { + "resources": false, + "start_time": false + }, + "enable": { + "matrix": false, + "matrix_asymmetry_tolerance": 20 + } + } + }, + "solve": { + "duration": 10000000000, + "iterations": 50, + "parallel_runs": 1, + "run_deterministically": true, + "start_solutions": 1 + } + }, + "solutions": [ + { + "objective": { + "name": "1 * vehicles_duration + 1 * unplanned_penalty", + "objectives": [ + { + "base": 1950, + "factor": 1, + "name": "vehicles_duration", + "value": 1950 + }, + { + "factor": 1, + "name": "unplanned_penalty", + "value": 0 + } + ], + "value": 1950 + }, + "unplanned": [], + "vehicles": [ + { + "id": "v1", + "route": [ + { + "arrival_time": "2023-01-01T12:00:00Z", + "cumulative_travel_duration": 0, + "end_time": "2023-01-01T12:00:00Z", + "start_time": "2023-01-01T12:00:00Z", + "stop": { + "id": "Nijō Castle", + "location": { + "lat": 35.014239, + "lon": 135.748134 + } + }, + "travel_duration": 0 + }, + { + "arrival_time": "2023-01-01T12:20:30Z", + "cumulative_travel_distance": 3994, + "cumulative_travel_duration": 1230, + "end_time": "2023-01-01T12:20:30Z", + "start_time": "2023-01-01T12:20:30Z", + "stop": { + "id": "Kiyomizu-dera", + "location": { + "lat": 34.994857, + "lon": 135.78506 + } + }, + "travel_distance": 3994, + "travel_duration": 1230 + }, + { + "arrival_time": "2023-01-01T12:32:30Z", + "cumulative_travel_distance": 7274, + "cumulative_travel_duration": 1950, + "end_time": "2023-01-01T12:32:30Z", + "start_time": "2023-01-01T12:32:30Z", + "stop": { + "id": "Fushimi Inari Taisha", + "location": { + "lat": 34.967146, + "lon": 135.772695 + } + }, + "travel_distance": 3280, + "travel_duration": 720 + } + ], + "route_duration": 1950, + "route_travel_distance": 7274, + "route_travel_duration": 1950 + } + ] + } + ], + "statistics": { + "result": { + "custom": { + "activated_vehicles": 1, + "max_duration": 0.123, + "max_stops_in_vehicle": 3, + "max_travel_duration": 0.123, + "min_duration": 0.123, + "min_stops_in_vehicle": 3, + "min_travel_duration": 0.123, + "unplanned_stops": 0 + }, + "duration": 0.123, + "value": 0.123 + }, + "run": { + "duration": 0.123, + "iterations": 50 + }, + "schema": "v1" + }, + "version": { + "sdk": "VERSION" + } +} diff --git a/tests/golden/testdata/duration_matrix_time_dependent3.json b/tests/golden/testdata/duration_matrix_time_dependent3.json new file mode 100644 index 0000000..2fc08f6 --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent3.json @@ -0,0 +1,81 @@ +{ + "duration_matrix": [ + { + "vehicle_ids": ["v1"], + "default_matrix": [ + [0, 720, 1020, 0, 0], + [720, 0, 660, 0, 0], + [1020, 660, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0] + ], + "matrix_time_frames": [ + { + "start_time": "2023-01-01T12:03:00Z", + "end_time": "2023-01-01T12:10:00Z", + "scaling_factor": 2.0 + }, + { + "start_time": "2023-01-01T12:12:00Z", + "end_time": "2023-01-01T12:20:00Z", + "scaling_factor": 2.0 + } + ] + }, + { + "vehicle_ids": ["v2"], + "default_matrix": [ + [0, 720, 1020, 0, 0], + [720, 0, 660, 0, 0], + [1020, 660, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0] + ], + "matrix_time_frames": [ + { + "start_time": "2023-01-01T12:03:00Z", + "end_time": "2023-01-01T12:10:00Z", + "scaling_factor": 4.0 + }, + { + "start_time": "2023-01-01T12:12:00Z", + "end_time": "2023-01-01T12:20:00Z", + "scaling_factor": 4.0 + } + ] + } + ], + "stops": [ + { + "id": "Fushimi Inari Taisha", + "location": { + "lon": 135.772695, + "lat": 34.967146 + } + }, + { + "id": "Kiyomizu-dera", + "location": { + "lon": 135.78506, + "lat": 34.994857 + } + }, + { + "id": "Nijō Castle", + "location": { + "lon": 135.748134, + "lat": 35.014239 + } + } + ], + "vehicles": [ + { + "id": "v1", + "start_time": "2023-01-01T12:00:00Z" + }, + { + "id": "v2", + "start_time": "2023-01-01T12:00:00Z" + } + ] +} diff --git a/tests/golden/testdata/duration_matrix_time_dependent3.json.golden b/tests/golden/testdata/duration_matrix_time_dependent3.json.golden new file mode 100644 index 0000000..e7afcea --- /dev/null +++ b/tests/golden/testdata/duration_matrix_time_dependent3.json.golden @@ -0,0 +1,180 @@ +{ + "options": { + "check": { + "duration": 30000000000, + "verbosity": "off" + }, + "format": { + "disable": { + "progression": true + } + }, + "model": { + "constraints": { + "disable": { + "attributes": false, + "capacities": null, + "capacity": false, + "distance_limit": false, + "groups": false, + "maximum_duration": false, + "maximum_stops": false, + "maximum_wait_stop": false, + "maximum_wait_vehicle": false, + "mixing_items": false, + "precedence": false, + "start_time_windows": false, + "vehicle_end_time": false, + "vehicle_start_time": false + }, + "enable": { + "cluster": false + } + }, + "objectives": { + "capacities": "", + "cluster": 0, + "early_arrival_penalty": 1, + "late_arrival_penalty": 1, + "min_stops": 1, + "stop_balance": 0, + "travel_duration": 0, + "unplanned_penalty": 1, + "vehicle_activation_penalty": 1, + "vehicles_duration": 1 + }, + "properties": { + "disable": { + "duration_groups": false, + "durations": false, + "initial_solution": false, + "stop_duration_multipliers": false + } + }, + "validate": { + "disable": { + "resources": false, + "start_time": false + }, + "enable": { + "matrix": false, + "matrix_asymmetry_tolerance": 20 + } + } + }, + "solve": { + "duration": 10000000000, + "iterations": 50, + "parallel_runs": 1, + "run_deterministically": true, + "start_solutions": 1 + } + }, + "solutions": [ + { + "objective": { + "name": "1 * vehicles_duration + 1 * unplanned_penalty", + "objectives": [ + { + "base": 1140, + "factor": 1, + "name": "vehicles_duration", + "value": 1140 + }, + { + "factor": 1, + "name": "unplanned_penalty", + "value": 0 + } + ], + "value": 1140 + }, + "unplanned": [], + "vehicles": [ + { + "id": "v1", + "route": [ + { + "arrival_time": "2023-01-01T12:00:00Z", + "cumulative_travel_duration": 0, + "end_time": "2023-01-01T12:00:00Z", + "start_time": "2023-01-01T12:00:00Z", + "stop": { + "id": "Kiyomizu-dera", + "location": { + "lat": 34.994857, + "lon": 135.78506 + } + }, + "travel_duration": 0 + }, + { + "arrival_time": "2023-01-01T12:19:00Z", + "cumulative_travel_distance": 3280, + "cumulative_travel_duration": 1140, + "end_time": "2023-01-01T12:19:00Z", + "start_time": "2023-01-01T12:19:00Z", + "stop": { + "id": "Fushimi Inari Taisha", + "location": { + "lat": 34.967146, + "lon": 135.772695 + } + }, + "travel_distance": 3280, + "travel_duration": 1140 + } + ], + "route_duration": 1140, + "route_travel_distance": 3280, + "route_travel_duration": 1140 + }, + { + "id": "v2", + "route": [ + { + "arrival_time": "2023-01-01T12:00:00Z", + "cumulative_travel_duration": 0, + "end_time": "2023-01-01T12:00:00Z", + "start_time": "2023-01-01T12:00:00Z", + "stop": { + "id": "Nijō Castle", + "location": { + "lat": 35.014239, + "lon": 135.748134 + } + }, + "travel_duration": 0 + } + ], + "route_duration": 0, + "route_travel_duration": 0 + } + ] + } + ], + "statistics": { + "result": { + "custom": { + "activated_vehicles": 2, + "max_duration": 0.123, + "max_stops_in_vehicle": 2, + "max_travel_duration": 0.123, + "min_duration": 0.123, + "min_stops_in_vehicle": 1, + "min_travel_duration": 0.123, + "unplanned_stops": 0 + }, + "duration": 0.123, + "value": 0.123 + }, + "run": { + "duration": 0.123, + "iterations": 50 + }, + "schema": "v1" + }, + "version": { + "sdk": "VERSION" + } +} diff --git a/tests/golden/testdata/early_arrival_penalty.json.golden b/tests/golden/testdata/early_arrival_penalty.json.golden index 61518bc..c5ef05d 100644 --- a/tests/golden/testdata/early_arrival_penalty.json.golden +++ b/tests/golden/testdata/early_arrival_penalty.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -236,16 +237,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1927, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 1927, - "min_duration": 1927, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 1927, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 4680.8435616493225 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/initial_stops.json.golden b/tests/golden/testdata/initial_stops.json.golden index 08c9c18..0893ef2 100644 --- a/tests/golden/testdata/initial_stops.json.golden +++ b/tests/golden/testdata/initial_stops.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -222,16 +223,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 542, + "max_duration": 0.123, "max_stops_in_vehicle": 4, - "max_travel_duration": 542, - "min_duration": 224, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 3, - "min_travel_duration": 224, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 767.0466501941119 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/initial_stops_infeasible_compatibility.json.golden b/tests/golden/testdata/initial_stops_infeasible_compatibility.json.golden index ebcd026..e083b66 100644 --- a/tests/golden/testdata/initial_stops_infeasible_compatibility.json.golden +++ b/tests/golden/testdata/initial_stops_infeasible_compatibility.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -217,16 +218,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 684, + "max_duration": 0.123, "max_stops_in_vehicle": 5, - "max_travel_duration": 684, - "min_duration": 0, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 1 }, "duration": 0.123, - "value": 1000684.9267442165 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/initial_stops_infeasible_max_duration.json.golden b/tests/golden/testdata/initial_stops_infeasible_max_duration.json.golden index 8bddde4..ceea65b 100644 --- a/tests/golden/testdata/initial_stops_infeasible_max_duration.json.golden +++ b/tests/golden/testdata/initial_stops_infeasible_max_duration.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -187,16 +188,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1815, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 15, - "min_duration": 1815, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 3, - "min_travel_duration": 15, + "min_travel_duration": 0.123, "unplanned_stops": 2 }, "duration": 0.123, - "value": 41815.004618406296 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/initial_stops_infeasible_remove_all.json.golden b/tests/golden/testdata/initial_stops_infeasible_remove_all.json.golden index 1d6dfe0..bcbab69 100644 --- a/tests/golden/testdata/initial_stops_infeasible_remove_all.json.golden +++ b/tests/golden/testdata/initial_stops_infeasible_remove_all.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -125,16 +126,16 @@ "result": { "custom": { "activated_vehicles": 0, - "max_duration": 0, + "max_duration": 0.123, "max_stops_in_vehicle": 0, - "max_travel_duration": 0, - "min_duration": 9223372036854776000, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 9223372036854776000, - "min_travel_duration": 9223372036854776000, + "min_travel_duration": 0.123, "unplanned_stops": 3 }, "duration": 0.123, - "value": 60000 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/initial_stops_infeasible_temporal.json.golden b/tests/golden/testdata/initial_stops_infeasible_temporal.json.golden index 5dbbc60..ebfe494 100644 --- a/tests/golden/testdata/initial_stops_infeasible_temporal.json.golden +++ b/tests/golden/testdata/initial_stops_infeasible_temporal.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -213,16 +214,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 1809, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 9, - "min_duration": 1809, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 3, - "min_travel_duration": 9, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 3619.999972343445 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/initial_stops_infeasible_tuple.json.golden b/tests/golden/testdata/initial_stops_infeasible_tuple.json.golden index ecc6b78..fbc159d 100644 --- a/tests/golden/testdata/initial_stops_infeasible_tuple.json.golden +++ b/tests/golden/testdata/initial_stops_infeasible_tuple.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -167,16 +168,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 620, + "max_duration": 0.123, "max_stops_in_vehicle": 1, - "max_travel_duration": 20, - "min_duration": 620, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 20, + "min_travel_duration": 0.123, "unplanned_stops": 4 }, "duration": 0.123, - "value": 80620.0061571598 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/late_arrival_penalty.json.golden b/tests/golden/testdata/late_arrival_penalty.json.golden index 4e93c70..5af1e81 100644 --- a/tests/golden/testdata/late_arrival_penalty.json.golden +++ b/tests/golden/testdata/late_arrival_penalty.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -246,16 +247,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 2453, + "max_duration": 0.123, "max_stops_in_vehicle": 4, - "max_travel_duration": 653, - "min_duration": 1819, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 3, - "min_travel_duration": 469, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 4908.276175618172 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/max_distance.json.golden b/tests/golden/testdata/max_distance.json.golden index 4bd92cb..6e0e630 100644 --- a/tests/golden/testdata/max_distance.json.golden +++ b/tests/golden/testdata/max_distance.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -184,16 +185,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 88, + "max_duration": 0.123, "max_stops_in_vehicle": 2, - "max_travel_duration": 88, - "min_duration": 60, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 2, - "min_travel_duration": 60, + "min_travel_duration": 0.123, "unplanned_stops": 3 }, "duration": 0.123, - "value": 6000148.909594993 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/max_duration.json.golden b/tests/golden/testdata/max_duration.json.golden index f5feb00..6db6f96 100644 --- a/tests/golden/testdata/max_duration.json.golden +++ b/tests/golden/testdata/max_duration.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -212,16 +213,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 1796, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 896, - "min_duration": 955, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 2, - "min_travel_duration": 355, + "min_travel_duration": 0.123, "unplanned_stops": 2 }, "duration": 0.123, - "value": 42751.797765016556 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/max_stops.json.golden b/tests/golden/testdata/max_stops.json.golden index a61a3b6..f981b03 100644 --- a/tests/golden/testdata/max_stops.json.golden +++ b/tests/golden/testdata/max_stops.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -190,16 +191,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 224, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 224, - "min_duration": 88, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 2, - "min_travel_duration": 88, + "min_travel_duration": 0.123, "unplanned_stops": 2 }, "duration": 0.123, - "value": 40312.949441222976 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/max_wait_stop.json.golden b/tests/golden/testdata/max_wait_stop.json.golden index b3ff703..679669a 100644 --- a/tests/golden/testdata/max_wait_stop.json.golden +++ b/tests/golden/testdata/max_wait_stop.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -153,16 +154,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 310, + "max_duration": 0.123, "max_stops_in_vehicle": 2, - "max_travel_duration": 0, - "min_duration": 310, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 2, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 2 }, "duration": 0.123, - "value": 40310 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/max_wait_vehicle.json.golden b/tests/golden/testdata/max_wait_vehicle.json.golden index 576d260..b4e76a5 100644 --- a/tests/golden/testdata/max_wait_vehicle.json.golden +++ b/tests/golden/testdata/max_wait_vehicle.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -205,16 +206,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1200, + "max_duration": 0.123, "max_stops_in_vehicle": 6, - "max_travel_duration": 0, - "min_duration": 1200, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 6, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 1 }, "duration": 0.123, - "value": 21200 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/min_stops.json.golden b/tests/golden/testdata/min_stops.json.golden index ea7b1ba..7c98e49 100644 --- a/tests/golden/testdata/min_stops.json.golden +++ b/tests/golden/testdata/min_stops.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -205,16 +206,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 909, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 909, - "min_duration": 909, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 909, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 909.0466359667603 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/multi_window.json.golden b/tests/golden/testdata/multi_window.json.golden index 87b4b6d..af24cce 100644 --- a/tests/golden/testdata/multi_window.json.golden +++ b/tests/golden/testdata/multi_window.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -152,16 +153,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 2400, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 0, - "min_duration": 2400, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 3, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 2400 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/no_mix.json.golden b/tests/golden/testdata/no_mix.json.golden index 2de194c..e532e6a 100644 --- a/tests/golden/testdata/no_mix.json.golden +++ b/tests/golden/testdata/no_mix.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -208,6 +209,12 @@ { "cumulative_travel_distance": 18249, "cumulative_travel_duration": 912, + "mix_items": { + "hazchem": { + "name": "", + "quantity": 0 + } + }, "stop": { "id": "Arashiyama Bamboo Forest", "location": { @@ -230,16 +237,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 912, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 912, - "min_duration": 912, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 912, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 912.631200191493 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/no_mix_null.json b/tests/golden/testdata/no_mix_null.json new file mode 100644 index 0000000..e92be6a --- /dev/null +++ b/tests/golden/testdata/no_mix_null.json @@ -0,0 +1,96 @@ +{ + "stops": [ + { + "id": "north", + "location": { + "lon": 7.6256, + "lat": 51.9714 + }, + "precedes": "south", + "mixing_items": { + "main": { + "name": "A", + "quantity": 1 + } + } + }, + { + "id": "south", + "location": { + "lon": 7.6256, + "lat": 51.9534 + }, + "precedes": "south_west", + "mixing_items": { + "main": { + "name": "A", + "quantity": -1 + } + } + }, + { + "id": "east", + "location": { + "lon": 7.6402, + "lat": 51.9624 + }, + "precedes": "west", + "mixing_items": { + "main": { + "name": "B", + "quantity": 1 + } + } + }, + { + "id": "west", + "location": { + "lon": 7.611, + "lat": 51.9624 + }, + "precedes": "north_west", + "mixing_items": { + "main": { + "name": "B", + "quantity": -1 + } + } + }, + { + "id": "north_east", + "location": { + "lon": 7.6359, + "lat": 51.9688 + }, + "mixing_items": null + }, + { + "id": "south_east", + "location": { + "lon": 7.6359, + "lat": 51.956 + }, + "mixing_items": null + }, + { + "id": "south_west", + "location": { + "lon": 7.6153, + "lat": 51.956 + } + }, + { + "id": "north_west", + "location": { + "lon": 7.6153, + "lat": 51.9688 + } + } + ], + "vehicles": [ + { + "id": "truck", + "speed": 20 + } + ] +} diff --git a/tests/golden/testdata/no_mix_null.json.golden b/tests/golden/testdata/no_mix_null.json.golden new file mode 100644 index 0000000..5386268 --- /dev/null +++ b/tests/golden/testdata/no_mix_null.json.golden @@ -0,0 +1,279 @@ +{ + "options": { + "check": { + "duration": 30000000000, + "verbosity": "off" + }, + "format": { + "disable": { + "progression": true + } + }, + "model": { + "constraints": { + "disable": { + "attributes": false, + "capacities": null, + "capacity": false, + "distance_limit": false, + "groups": false, + "maximum_duration": false, + "maximum_stops": false, + "maximum_wait_stop": false, + "maximum_wait_vehicle": false, + "mixing_items": false, + "precedence": false, + "start_time_windows": false, + "vehicle_end_time": false, + "vehicle_start_time": false + }, + "enable": { + "cluster": false + } + }, + "objectives": { + "capacities": "", + "cluster": 0, + "early_arrival_penalty": 1, + "late_arrival_penalty": 1, + "min_stops": 1, + "stop_balance": 0, + "travel_duration": 0, + "unplanned_penalty": 1, + "vehicle_activation_penalty": 1, + "vehicles_duration": 1 + }, + "properties": { + "disable": { + "duration_groups": false, + "durations": false, + "initial_solution": false, + "stop_duration_multipliers": false + } + }, + "validate": { + "disable": { + "resources": false, + "start_time": false + }, + "enable": { + "matrix": false, + "matrix_asymmetry_tolerance": 20 + } + } + }, + "solve": { + "duration": 10000000000, + "iterations": 50, + "parallel_runs": 1, + "run_deterministically": true, + "start_solutions": 1 + } + }, + "solutions": [ + { + "objective": { + "name": "1 * vehicles_duration + 1 * unplanned_penalty", + "objectives": [ + { + "base": 362.2360713145241, + "factor": 1, + "name": "vehicles_duration", + "value": 362.2360713145241 + }, + { + "factor": 1, + "name": "unplanned_penalty", + "value": 0 + } + ], + "value": 362.2360713145241 + }, + "unplanned": [], + "vehicles": [ + { + "id": "truck", + "route": [ + { + "cumulative_travel_duration": 0, + "mix_items": { + "main": { + "name": "B", + "quantity": 1 + } + }, + "stop": { + "id": "east", + "location": { + "lat": 51.9624, + "lon": 7.6402 + } + }, + "travel_duration": 0 + }, + { + "cumulative_travel_distance": 2000, + "cumulative_travel_duration": 100, + "mix_items": { + "main": { + "name": "B", + "quantity": 0 + } + }, + "stop": { + "id": "west", + "location": { + "lat": 51.9624, + "lon": 7.611 + } + }, + "travel_distance": 2000, + "travel_duration": 100 + }, + { + "cumulative_travel_distance": 2770, + "cumulative_travel_duration": 138, + "mix_items": { + "main": { + "name": "", + "quantity": 0 + } + }, + "stop": { + "id": "north_west", + "location": { + "lat": 51.9688, + "lon": 7.6153 + } + }, + "travel_distance": 770, + "travel_duration": 38 + }, + { + "cumulative_travel_distance": 3532, + "cumulative_travel_duration": 176, + "mix_items": { + "main": { + "name": "A", + "quantity": 1 + } + }, + "stop": { + "id": "north", + "location": { + "lat": 51.9714, + "lon": 7.6256 + } + }, + "travel_distance": 762, + "travel_duration": 38 + }, + { + "cumulative_travel_distance": 4294, + "cumulative_travel_duration": 214, + "mix_items": { + "main": { + "name": "A", + "quantity": 1 + } + }, + "stop": { + "id": "north_east", + "location": { + "lat": 51.9688, + "lon": 7.6359 + } + }, + "travel_distance": 762, + "travel_duration": 38 + }, + { + "cumulative_travel_distance": 5717, + "cumulative_travel_duration": 285, + "mix_items": { + "main": { + "name": "A", + "quantity": 1 + } + }, + "stop": { + "id": "south_east", + "location": { + "lat": 51.956, + "lon": 7.6359 + } + }, + "travel_distance": 1423, + "travel_duration": 71 + }, + { + "cumulative_travel_distance": 6479, + "cumulative_travel_duration": 324, + "mix_items": { + "main": { + "name": "A", + "quantity": 0 + } + }, + "stop": { + "id": "south", + "location": { + "lat": 51.9534, + "lon": 7.6256 + } + }, + "travel_distance": 762, + "travel_duration": 38 + }, + { + "cumulative_travel_distance": 7241, + "cumulative_travel_duration": 362, + "mix_items": { + "main": { + "name": "", + "quantity": 0 + } + }, + "stop": { + "id": "south_west", + "location": { + "lat": 51.956, + "lon": 7.6153 + } + }, + "travel_distance": 762, + "travel_duration": 38 + } + ], + "route_duration": 362, + "route_travel_distance": 7241, + "route_travel_duration": 362 + } + ] + } + ], + "statistics": { + "result": { + "custom": { + "activated_vehicles": 1, + "max_duration": 0.123, + "max_stops_in_vehicle": 8, + "max_travel_duration": 0.123, + "min_duration": 0.123, + "min_stops_in_vehicle": 8, + "min_travel_duration": 0.123, + "unplanned_stops": 0 + }, + "duration": 0.123, + "value": 0.123 + }, + "run": { + "duration": 0.123, + "iterations": 50 + }, + "schema": "v1" + }, + "version": { + "sdk": "VERSION" + } +} diff --git a/tests/golden/testdata/no_mix_null.md b/tests/golden/testdata/no_mix_null.md new file mode 100644 index 0000000..960cae4 --- /dev/null +++ b/tests/golden/testdata/no_mix_null.md @@ -0,0 +1,19 @@ +# Missing values in no mix constraint (no_mix_null.json) + +This example demonstrates missing values when defining a _no mix_ constraint. +I.e., some of the stops either have no `mixing_items` defined or have it set to +`null`. These stops can be planned anywhere in a route, independent of the +current mix of items in the vehicle. + +Find some notes about the example below: + +- There is only one vehicle servicing all the stops. +- Stops `north` and `south` belong to mixing group `A`. +- Stops `east` and `west` belong to mixing group `B`. +- The transports from north to south and from east to west cannot overlap. This + means that a route like `north -> east -> south -> west` is not feasible. +- All other stops can go anywhere in the route and should be planned in a way + that minimizes the total travel time. +- All stops are located on a circle with their names indicating the cardinal + direction they are located at. This is useful for checking the time-efficiency + of the solution. diff --git a/tests/golden/testdata/precedence.json.golden b/tests/golden/testdata/precedence.json.golden index b9d1f66..2c89619 100644 --- a/tests/golden/testdata/precedence.json.golden +++ b/tests/golden/testdata/precedence.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -194,16 +195,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1101, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 1101, - "min_duration": 1101, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 1101, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 1101.3248523907805 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/precedence pathologic.json b/tests/golden/testdata/precedence_pathologic.json similarity index 100% rename from tests/golden/testdata/precedence pathologic.json rename to tests/golden/testdata/precedence_pathologic.json diff --git a/tests/golden/testdata/precedence pathologic.json.golden b/tests/golden/testdata/precedence_pathologic.json.golden similarity index 97% rename from tests/golden/testdata/precedence pathologic.json.golden rename to tests/golden/testdata/precedence_pathologic.json.golden index 9b6a7b1..687ab72 100644 --- a/tests/golden/testdata/precedence pathologic.json.golden +++ b/tests/golden/testdata/precedence_pathologic.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -269,16 +270,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 1841, + "max_duration": 0.123, "max_stops_in_vehicle": 13, - "max_travel_duration": 1841, - "min_duration": 1841, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 13, - "min_travel_duration": 1841, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 1841.9036093736736 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/start_level.json.golden b/tests/golden/testdata/start_level.json.golden index 5864c38..eb609fa 100644 --- a/tests/golden/testdata/start_level.json.golden +++ b/tests/golden/testdata/start_level.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -207,16 +208,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 365, + "max_duration": 0.123, "max_stops_in_vehicle": 4, - "max_travel_duration": 365, - "min_duration": 365, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 4, - "min_travel_duration": 365, + "min_travel_duration": 0.123, "unplanned_stops": 3 }, "duration": 0.123, - "value": 3000365.6409371877 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/start_time_window.json.golden b/tests/golden/testdata/start_time_window.json.golden index 16c6643..3efad5f 100644 --- a/tests/golden/testdata/start_time_window.json.golden +++ b/tests/golden/testdata/start_time_window.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -235,16 +236,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 2387, + "max_duration": 0.123, "max_stops_in_vehicle": 5, - "max_travel_duration": 821, - "min_duration": 1500, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 2, - "min_travel_duration": 141, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 3887.616171836853 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/stop_duration.json.golden b/tests/golden/testdata/stop_duration.json.golden index 3314ad3..16208ba 100644 --- a/tests/golden/testdata/stop_duration.json.golden +++ b/tests/golden/testdata/stop_duration.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -234,16 +235,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 2109, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 909, - "min_duration": 2109, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 909, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 2109.046635866165 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/stop_duration_multiplier.json.golden b/tests/golden/testdata/stop_duration_multiplier.json.golden index b91363a..b96f74f 100644 --- a/tests/golden/testdata/stop_duration_multiplier.json.golden +++ b/tests/golden/testdata/stop_duration_multiplier.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -234,16 +235,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 3309, + "max_duration": 0.123, "max_stops_in_vehicle": 7, - "max_travel_duration": 909, - "min_duration": 3309, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 7, - "min_travel_duration": 909, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 3309.046635866165 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/stop_groups.json.golden b/tests/golden/testdata/stop_groups.json.golden index f1d092a..201e6f8 100644 --- a/tests/golden/testdata/stop_groups.json.golden +++ b/tests/golden/testdata/stop_groups.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -239,16 +240,16 @@ "result": { "custom": { "activated_vehicles": 3, - "max_duration": 347, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 347, - "min_duration": 164, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 2, - "min_travel_duration": 164, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 823.605193191854 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/template_input.json.golden b/tests/golden/testdata/template_input.json.golden index 0d9dd72..a52bc3d 100644 --- a/tests/golden/testdata/template_input.json.golden +++ b/tests/golden/testdata/template_input.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -604,16 +605,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 20258, + "max_duration": 0.123, "max_stops_in_vehicle": 10, - "max_travel_duration": 17258, - "min_duration": 14135, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 9, - "min_travel_duration": 11435, + "min_travel_duration": 0.123, "unplanned_stops": 7 }, "duration": 0.123, - "value": 2059665.4384450912 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/unplanned_penalty.json.golden b/tests/golden/testdata/unplanned_penalty.json.golden index afb418e..8957a30 100644 --- a/tests/golden/testdata/unplanned_penalty.json.golden +++ b/tests/golden/testdata/unplanned_penalty.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -183,16 +184,16 @@ "result": { "custom": { "activated_vehicles": 2, - "max_duration": 312, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 312, - "min_duration": 0, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 3 }, "duration": 0.123, - "value": 342.5445274502649 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/vehicle_start_end_location.json.golden b/tests/golden/testdata/vehicle_start_end_location.json.golden index 4924d73..d6d8a64 100644 --- a/tests/golden/testdata/vehicle_start_end_location.json.golden +++ b/tests/golden/testdata/vehicle_start_end_location.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -246,16 +247,16 @@ "result": { "custom": { "activated_vehicles": 4, - "max_duration": 255, + "max_duration": 0.123, "max_stops_in_vehicle": 3, - "max_travel_duration": 255, - "min_duration": 0, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 375.4720230604402 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/vehicle_start_end_time.json.golden b/tests/golden/testdata/vehicle_start_end_time.json.golden index 896a660..9230349 100644 --- a/tests/golden/testdata/vehicle_start_end_time.json.golden +++ b/tests/golden/testdata/vehicle_start_end_time.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -294,16 +295,16 @@ "result": { "custom": { "activated_vehicles": 4, - "max_duration": 4520, + "max_duration": 0.123, "max_stops_in_vehicle": 4, - "max_travel_duration": 2120, - "min_duration": 600, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 0, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 6321.044562101364 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/golden/testdata/vehicles_duration_objective.json.golden b/tests/golden/testdata/vehicles_duration_objective.json.golden index c4f3078..723bfd4 100644 --- a/tests/golden/testdata/vehicles_duration_objective.json.golden +++ b/tests/golden/testdata/vehicles_duration_objective.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0, "unplanned_penalty": 1, "vehicle_activation_penalty": 1, @@ -156,16 +157,16 @@ "result": { "custom": { "activated_vehicles": 1, - "max_duration": 412, + "max_duration": 0.123, "max_stops_in_vehicle": 1, - "max_travel_duration": 412, - "min_duration": 412, + "max_travel_duration": 0.123, + "min_duration": 0.123, "min_stops_in_vehicle": 1, - "min_travel_duration": 412, + "min_travel_duration": 0.123, "unplanned_stops": 0 }, "duration": 0.123, - "value": 412.53714394569397 + "value": 0.123 }, "run": { "duration": 0.123, diff --git a/tests/inline_options/input.json.golden b/tests/inline_options/input.json.golden index 8abf09d..4b2f4a2 100644 --- a/tests/inline_options/input.json.golden +++ b/tests/inline_options/input.json.golden @@ -37,6 +37,7 @@ "early_arrival_penalty": 1, "late_arrival_penalty": 1, "min_stops": 1, + "stop_balance": 0, "travel_duration": 0.5, "unplanned_penalty": 0.3, "vehicle_activation_penalty": 1, diff --git a/tests/inline_options/main_test.go b/tests/inline_options/main_test.go index 50df77e..759b2cc 100644 --- a/tests/inline_options/main_test.go +++ b/tests/inline_options/main_test.go @@ -32,9 +32,9 @@ func TestGolden(t *testing.T) { "-solve.startsolutions", "1", }, TransientFields: []golden.TransientField{ - {Key: ".version.sdk", Replacement: golden.StableVersion}, - {Key: ".statistics.result.duration", Replacement: golden.StableFloat}, - {Key: ".statistics.run.duration", Replacement: golden.StableFloat}, + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, }, Thresholds: golden.Tresholds{ Float: 0.01, diff --git a/tests/output_options/main.sh.golden b/tests/output_options/main.sh.golden index c34ef90..00d44b1 100644 --- a/tests/output_options/main.sh.golden +++ b/tests/output_options/main.sh.golden @@ -30,7 +30,8 @@ "travel_duration": 0, "vehicles_duration": 1, "unplanned_penalty": 1, - "cluster": 0 + "cluster": 0, + "stop_balance": 0 }, "properties": { "disable": { diff --git a/tests/stop_balancing_objective/input.json b/tests/stop_balancing_objective/input.json new file mode 100644 index 0000000..a0bde88 --- /dev/null +++ b/tests/stop_balancing_objective/input.json @@ -0,0 +1,192 @@ +{ + "defaults": { + "vehicles": { + "speed": 10 + } + }, + "stops": [ + { + "id": "s1", + "location": { + "lon": -78.90919, + "lat": 35.72389 + } + }, + { + "id": "s2", + "location": { + "lon": -78.813862, + "lat": 35.75712 + } + }, + { + "id": "s3", + "location": { + "lon": -78.92996, + "lat": 35.932795 + } + }, + { + "id": "s4", + "location": { + "lon": -78.505745, + "lat": 35.77772 + } + }, + { + "id": "s5", + "location": { + "lon": -78.75084, + "lat": 35.732995 + } + }, + { + "id": "s6", + "location": { + "lon": -78.788025, + "lat": 35.813025 + } + }, + { + "id": "s7", + "location": { + "lon": -78.749391, + "lat": 35.74261 + } + }, + { + "id": "s8", + "location": { + "lon": -78.94658, + "lat": 36.039135 + } + }, + { + "id": "s9", + "location": { + "lon": -78.64972, + "lat": 35.64796 + } + }, + { + "id": "s10", + "location": { + "lon": -78.747955, + "lat": 35.672955 + } + }, + { + "id": "s11", + "location": { + "lon": -78.83403, + "lat": 35.77013 + } + }, + { + "id": "s12", + "location": { + "lon": -78.864465, + "lat": 35.782855 + } + }, + { + "id": "s13", + "location": { + "lon": -78.952142, + "lat": 35.88029 + } + }, + { + "id": "s14", + "location": { + "lon": -78.52748, + "lat": 35.961465 + } + }, + { + "id": "s15", + "location": { + "lon": -78.89832, + "lat": 35.83202 + } + }, + { + "id": "s16", + "location": { + "lon": -78.63216, + "lat": 35.83458 + } + }, + { + "id": "s17", + "location": { + "lon": -78.76063, + "lat": 35.67337 + } + }, + { + "id": "s18", + "location": { + "lon": -78.911485, + "lat": 36.009015 + } + }, + { + "id": "s19", + "location": { + "lon": -78.522705, + "lat": 35.93663 + } + }, + { + "id": "s20", + "location": { + "lon": -78.995162, + "lat": 35.97414 + } + }, + { + "id": "s21", + "location": { + "lon": -78.50509, + "lat": 35.7606 + } + }, + { + "id": "s22", + "location": { + "lon": -78.828547, + "lat": 35.962635 + } + }, + { + "id": "s23", + "location": { + "lon": -78.60914, + "lat": 35.84616 + } + }, + { + "id": "s24", + "location": { + "lon": -78.65521, + "lat": 35.740605 + } + }, + { + "id": "s25", + "location": { + "lon": -78.92051, + "lat": 35.887575 + } + } + ], + "vehicles": [ + { + "id": "vehicle-0" + }, + { + "id": "vehicle-1" + } + ] +} diff --git a/tests/stop_balancing_objective/input.json.golden b/tests/stop_balancing_objective/input.json.golden new file mode 100644 index 0000000..3e27e8d --- /dev/null +++ b/tests/stop_balancing_objective/input.json.golden @@ -0,0 +1,464 @@ +{ + "options": { + "check": { + "duration": 30000000000, + "verbosity": "off" + }, + "format": { + "disable": { + "progression": true + } + }, + "model": { + "constraints": { + "disable": { + "attributes": false, + "capacities": null, + "capacity": false, + "distance_limit": false, + "groups": false, + "maximum_duration": false, + "maximum_stops": false, + "maximum_wait_stop": false, + "maximum_wait_vehicle": false, + "mixing_items": false, + "precedence": false, + "start_time_windows": false, + "vehicle_end_time": false, + "vehicle_start_time": false + }, + "enable": { + "cluster": false + } + }, + "objectives": { + "capacities": "", + "cluster": 0, + "early_arrival_penalty": 1, + "late_arrival_penalty": 1, + "min_stops": 1, + "stop_balance": 1000, + "travel_duration": 0, + "unplanned_penalty": 1, + "vehicle_activation_penalty": 1, + "vehicles_duration": 1 + }, + "properties": { + "disable": { + "duration_groups": false, + "durations": false, + "initial_solution": false, + "stop_duration_multipliers": false + } + }, + "validate": { + "disable": { + "resources": false, + "start_time": false + }, + "enable": { + "matrix": false, + "matrix_asymmetry_tolerance": 20 + } + } + }, + "solve": { + "duration": 10000000000, + "iterations": 10000, + "parallel_runs": 1, + "run_deterministically": true, + "start_solutions": 1 + } + }, + "solutions": [ + { + "objective": { + "name": "1 * vehicles_duration + 1 * unplanned_penalty + 1000 * stop_balance", + "objectives": [ + { + "base": 14812.966247110293, + "factor": 1, + "name": "vehicles_duration", + "value": 14812.966247110293 + }, + { + "factor": 1, + "name": "unplanned_penalty", + "value": 0 + }, + { + "base": 13, + "factor": 1000, + "name": "stop_balance", + "value": 13000 + } + ], + "value": 27812.966247110293 + }, + "unplanned": [], + "vehicles": [ + { + "id": "vehicle-0", + "route": [ + { + "cumulative_travel_duration": 0, + "stop": { + "id": "s22", + "location": { + "lat": 35.962635, + "lon": -78.828547 + } + }, + "travel_duration": 0 + }, + { + "cumulative_travel_distance": 9071, + "cumulative_travel_duration": 907, + "stop": { + "id": "s18", + "location": { + "lat": 36.009015, + "lon": -78.911485 + } + }, + "travel_distance": 9071, + "travel_duration": 907 + }, + { + "cumulative_travel_distance": 13672, + "cumulative_travel_duration": 1367, + "stop": { + "id": "s8", + "location": { + "lat": 36.039135, + "lon": -78.94658 + } + }, + "travel_distance": 4601, + "travel_duration": 460 + }, + { + "cumulative_travel_distance": 22117, + "cumulative_travel_duration": 2211, + "stop": { + "id": "s20", + "location": { + "lat": 35.97414, + "lon": -78.995162 + } + }, + "travel_distance": 8445, + "travel_duration": 844 + }, + { + "cumulative_travel_distance": 29572, + "cumulative_travel_duration": 2957, + "stop": { + "id": "s3", + "location": { + "lat": 35.932795, + "lon": -78.92996 + } + }, + "travel_distance": 7455, + "travel_duration": 745 + }, + { + "cumulative_travel_distance": 34671, + "cumulative_travel_duration": 3467, + "stop": { + "id": "s25", + "location": { + "lat": 35.887575, + "lon": -78.92051 + } + }, + "travel_distance": 5099, + "travel_duration": 509 + }, + { + "cumulative_travel_distance": 37633, + "cumulative_travel_duration": 3763, + "stop": { + "id": "s13", + "location": { + "lat": 35.88029, + "lon": -78.952142 + } + }, + "travel_distance": 2962, + "travel_duration": 296 + }, + { + "cumulative_travel_distance": 44867, + "cumulative_travel_duration": 4487, + "stop": { + "id": "s15", + "location": { + "lat": 35.83202, + "lon": -78.89832 + } + }, + "travel_distance": 7234, + "travel_duration": 723 + }, + { + "cumulative_travel_distance": 55033, + "cumulative_travel_duration": 5503, + "stop": { + "id": "s6", + "location": { + "lat": 35.813025, + "lon": -78.788025 + } + }, + "travel_distance": 10166, + "travel_duration": 1016 + }, + { + "cumulative_travel_distance": 61671, + "cumulative_travel_duration": 6167, + "stop": { + "id": "s2", + "location": { + "lat": 35.75712, + "lon": -78.813862 + } + }, + "travel_distance": 6638, + "travel_duration": 663 + }, + { + "cumulative_travel_distance": 63995, + "cumulative_travel_duration": 6400, + "stop": { + "id": "s11", + "location": { + "lat": 35.77013, + "lon": -78.83403 + } + }, + "travel_distance": 2324, + "travel_duration": 232 + }, + { + "cumulative_travel_distance": 67083, + "cumulative_travel_duration": 6708, + "stop": { + "id": "s12", + "location": { + "lat": 35.782855, + "lon": -78.864465 + } + }, + "travel_distance": 3088, + "travel_duration": 308 + }, + { + "cumulative_travel_distance": 74782, + "cumulative_travel_duration": 7478, + "stop": { + "id": "s1", + "location": { + "lat": 35.72389, + "lon": -78.90919 + } + }, + "travel_distance": 7699, + "travel_duration": 769 + } + ], + "route_duration": 7478, + "route_travel_distance": 74782, + "route_travel_duration": 7478 + }, + { + "id": "vehicle-1", + "route": [ + { + "cumulative_travel_duration": 0, + "stop": { + "id": "s9", + "location": { + "lat": 35.64796, + "lon": -78.64972 + } + }, + "travel_duration": 0 + }, + { + "cumulative_travel_distance": 9299, + "cumulative_travel_duration": 929, + "stop": { + "id": "s10", + "location": { + "lat": 35.672955, + "lon": -78.747955 + } + }, + "travel_distance": 9299, + "travel_duration": 929 + }, + { + "cumulative_travel_distance": 10444, + "cumulative_travel_duration": 1044, + "stop": { + "id": "s17", + "location": { + "lat": 35.67337, + "lon": -78.76063 + } + }, + "travel_distance": 1145, + "travel_duration": 114 + }, + { + "cumulative_travel_distance": 17132, + "cumulative_travel_duration": 1713, + "stop": { + "id": "s5", + "location": { + "lat": 35.732995, + "lon": -78.75084 + } + }, + "travel_distance": 6688, + "travel_duration": 668 + }, + { + "cumulative_travel_distance": 18209, + "cumulative_travel_duration": 1821, + "stop": { + "id": "s7", + "location": { + "lat": 35.74261, + "lon": -78.749391 + } + }, + "travel_distance": 1077, + "travel_duration": 107 + }, + { + "cumulative_travel_distance": 26711, + "cumulative_travel_duration": 2671, + "stop": { + "id": "s24", + "location": { + "lat": 35.740605, + "lon": -78.65521 + } + }, + "travel_distance": 8502, + "travel_duration": 850 + }, + { + "cumulative_travel_distance": 40439, + "cumulative_travel_duration": 4044, + "stop": { + "id": "s21", + "location": { + "lat": 35.7606, + "lon": -78.50509 + } + }, + "travel_distance": 13728, + "travel_duration": 1372 + }, + { + "cumulative_travel_distance": 42343, + "cumulative_travel_duration": 4234, + "stop": { + "id": "s4", + "location": { + "lat": 35.77772, + "lon": -78.505745 + } + }, + "travel_distance": 1904, + "travel_duration": 190 + }, + { + "cumulative_travel_distance": 55378, + "cumulative_travel_duration": 5538, + "stop": { + "id": "s16", + "location": { + "lat": 35.83458, + "lon": -78.63216 + } + }, + "travel_distance": 13035, + "travel_duration": 1303 + }, + { + "cumulative_travel_distance": 57820, + "cumulative_travel_duration": 5782, + "stop": { + "id": "s23", + "location": { + "lat": 35.84616, + "lon": -78.60914 + } + }, + "travel_distance": 2442, + "travel_duration": 244 + }, + { + "cumulative_travel_distance": 70541, + "cumulative_travel_duration": 7054, + "stop": { + "id": "s19", + "location": { + "lat": 35.93663, + "lon": -78.522705 + } + }, + "travel_distance": 12721, + "travel_duration": 1272 + }, + { + "cumulative_travel_distance": 73335, + "cumulative_travel_duration": 7334, + "stop": { + "id": "s14", + "location": { + "lat": 35.961465, + "lon": -78.52748 + } + }, + "travel_distance": 2794, + "travel_duration": 279 + } + ], + "route_duration": 7334, + "route_travel_distance": 73335, + "route_travel_duration": 7334 + } + ] + } + ], + "statistics": { + "result": { + "custom": { + "activated_vehicles": 2, + "max_duration": 7478, + "max_stops_in_vehicle": 13, + "max_travel_duration": 7478, + "min_duration": 7334, + "min_stops_in_vehicle": 12, + "min_travel_duration": 7334, + "unplanned_stops": 0 + }, + "duration": 0.123, + "value": 27812.966247110293 + }, + "run": { + "duration": 0.123, + "iterations": 10000 + }, + "schema": "v1" + }, + "version": { + "sdk": "VERSION" + } +} diff --git a/tests/stop_balancing_objective/main.go b/tests/stop_balancing_objective/main.go new file mode 100644 index 0000000..e133b4e --- /dev/null +++ b/tests/stop_balancing_objective/main.go @@ -0,0 +1,65 @@ +// © 2019-present nextmv.io inc + +// package main holds the implementation of the nextroute template. +package main + +import ( + "context" + "log" + + "github.com/nextmv-io/nextroute" + "github.com/nextmv-io/nextroute/check" + "github.com/nextmv-io/nextroute/factory" + "github.com/nextmv-io/nextroute/schema" + "github.com/nextmv-io/sdk/run" + runSchema "github.com/nextmv-io/sdk/run/schema" +) + +func main() { + runner := run.CLI(solver) + err := runner.Run(context.Background()) + if err != nil { + log.Fatal(err) + } +} + +type options struct { + Model factory.Options `json:"model,omitempty"` + Solve nextroute.ParallelSolveOptions `json:"solve,omitempty"` + Format nextroute.FormatOptions `json:"format,omitempty"` + Check check.Options `json:"check,omitempty"` +} + +func solver( + ctx context.Context, + input schema.Input, + options options, +) (runSchema.Output, error) { + // options.Model.Objectives.StopBalance = 1000.0 + model, err := factory.NewModel(input, options.Model) + if err != nil { + return runSchema.Output{}, err + } + + solver, err := nextroute.NewParallelSolver(model) + if err != nil { + return runSchema.Output{}, err + } + + solutions, err := solver.Solve(ctx, options.Solve) + if err != nil { + return runSchema.Output{}, err + } + last, err := solutions.Last() + if err != nil { + return runSchema.Output{}, err + } + + output, err := check.Format(ctx, options, options.Check, solver, last) + if err != nil { + return runSchema.Output{}, err + } + output.Statistics.Result.Custom = factory.DefaultCustomResultStatistics(last) + + return output, nil +} diff --git a/tests/stop_balancing_objective/main_test.go b/tests/stop_balancing_objective/main_test.go new file mode 100644 index 0000000..8cdb73a --- /dev/null +++ b/tests/stop_balancing_objective/main_test.go @@ -0,0 +1,45 @@ +// © 2019-present nextmv.io inc + +package main + +import ( + "os" + "testing" + + "github.com/nextmv-io/sdk/golden" +) + +func TestMain(m *testing.M) { + golden.Setup() + code := m.Run() + golden.Teardown() + os.Exit(code) +} + +// TestGolden executes a golden file test, where the .json input is fed and an +// output is expected. +func TestGolden(t *testing.T) { + golden.FileTests( + t, + "input.json", + golden.Config{ + Args: []string{ + "-solve.duration", "10s", + "-format.disable.progression", + "-solve.parallelruns", "1", + "-solve.iterations", "10000", + "-solve.rundeterministically", + "-solve.startsolutions", "1", + "-model.objectives.stopbalance", "1000.0", + }, + TransientFields: []golden.TransientField{ + {Key: "$.version.sdk", Replacement: golden.StableVersion}, + {Key: "$.statistics.result.duration", Replacement: golden.StableFloat}, + {Key: "$.statistics.run.duration", Replacement: golden.StableFloat}, + }, + Thresholds: golden.Tresholds{ + Float: 0.01, + }, + }, + ) +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..c363fc7 --- /dev/null +++ b/version.go @@ -0,0 +1,16 @@ +// © 2019-present nextmv.io inc + +package nextroute + +import ( + _ "embed" + "strings" +) + +//go:embed VERSION +var version string + +// Version returns the version of the nextroute module. +func Version() string { + return strings.TrimSpace(version) +}