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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions tests/endpoints/test_voyage_calculator.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from unittest import TestCase

from tests.testcases import TestCaseUsingRealAPI
from vortexasdk import VoyageCalculator

Expand Down Expand Up @@ -130,3 +132,152 @@ def test_calculate_with_delay_factor(self):

result_list = result.to_list()
assert len(result_list) == 1


class TestVoyageCalculatorBatch(TestCaseUsingRealAPI):
def test_batch_search(self):
routes = [
{
"type": "ETA",
"vessel_status": "vessel_status_laden_known",
"origin": ras_tanura,
"destination": rotterdam,
"vessel_class": "oil_vlcc",
"ETD": "2024-03-01T00:00:00.000Z",
"speed": 12,
},
{
"type": "speed",
"vessel_status": "vessel_status_ballast",
"origin": rotterdam,
"destination": ras_tanura,
"vessel_class": "oil_suezmax_lr3",
"ETD": "2024-03-01T00:00:00.000Z",
"ETA": "2024-04-01T00:00:00.000Z",
},
]
result = VoyageCalculator().batch_search(routes=routes)

result_list = result.to_list()
assert len(result_list) == 2

for item in result_list:
assert "origin" in item
assert "destination" in item
assert "vessel_class" in item

def test_batch_search_single_route(self):
routes = [
{
"type": "ETA",
"vessel_status": "vessel_status_laden_known",
"origin": ras_tanura,
"destination": rotterdam,
"vessel_class": "oil_vlcc",
"ETD": "2024-03-01T00:00:00.000Z",
"speed": 12,
},
]
result = VoyageCalculator().batch_search(routes=routes)

result_list = result.to_list()
assert len(result_list) == 1
assert "ETA" in result_list[0]
assert "origin" in result_list[0]

def test_batch_search_to_df(self):
routes = [
{
"type": "ETA",
"vessel_status": "vessel_status_laden_known",
"origin": ras_tanura,
"destination": rotterdam,
"vessel_class": "oil_vlcc",
"ETD": "2024-03-01T00:00:00.000Z",
"speed": 12,
},
]
result = VoyageCalculator().batch_search(routes=routes)

df = result.to_df()
assert len(df) == 1
assert "origin" in df.columns
assert "destination" in df.columns
assert "vessel_class" in df.columns

def test_batch_search_to_df_with_columns(self):
routes = [
{
"type": "ETA",
"vessel_status": "vessel_status_laden_known",
"origin": ras_tanura,
"destination": rotterdam,
"vessel_class": "oil_vlcc",
"ETD": "2024-03-01T00:00:00.000Z",
"speed": 12,
},
]
result = VoyageCalculator().batch_search(routes=routes)

df = result.to_df(columns=["origin", "ETA", "speed"])
available_cols = [
c for c in ["origin", "ETA", "speed"] if c in df.columns
]
assert len(df.columns) == len(available_cols)

def test_batch_search_metadata(self):
routes = [
{
"type": "ETA",
"vessel_status": "vessel_status_laden_known",
"origin": ras_tanura,
"destination": rotterdam,
"vessel_class": "oil_vlcc",
"ETD": "2024-03-01T00:00:00.000Z",
"speed": 12,
},
]
result = VoyageCalculator().batch_search(routes=routes)

assert hasattr(result, "metadata")
assert isinstance(result.metadata, list)

def test_batch_search_with_avoid_zone(self):
routes = [
{
"type": "ETA",
"vessel_status": "vessel_status_laden_known",
"origin": ras_tanura,
"destination": rotterdam,
"vessel_class": "oil_vlcc",
"ETD": "2024-03-01T00:00:00.000Z",
"speed": 12,
"avoid_zone": ["Suez Canal"],
},
]
result = VoyageCalculator().batch_search(routes=routes)

result_list = result.to_list()
assert len(result_list) == 1


class TestVoyageCalculatorBatchValidation(TestCase):
def test_batch_search_max_routes_exceeded(self):
routes = [
{
"type": "ETA",
"vessel_status": "vessel_status_laden_known",
"origin": ras_tanura,
"destination": rotterdam,
"vessel_class": "oil_vlcc",
"ETD": "2024-03-01T00:00:00.000Z",
"speed": 12,
}
] * 6

with self.assertRaises(ValueError):
VoyageCalculator().batch_search(routes=routes)

def test_batch_search_empty_routes(self):
with self.assertRaises(ValueError):
VoyageCalculator().batch_search(routes=[])
2 changes: 2 additions & 0 deletions vortexasdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
VoyagesTopHits,
VoyagesSearchEnriched,
VoyageCalculator,
VoyageCalculatorRoute,
VoyageCalculatorType,
VoyageCalculatorVesselStatus,
VoyageCalculatorAvoidZone,
Expand Down Expand Up @@ -107,6 +108,7 @@
"VoyagesTopHits",
"VoyagesSearchEnriched",
"VoyageCalculator",
"VoyageCalculatorRoute",
"VoyageCalculatorType",
"VoyageCalculatorVesselStatus",
"VoyageCalculatorAvoidZone",
Expand Down
2 changes: 2 additions & 0 deletions vortexasdk/endpoints/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from vortexasdk.endpoints.voyages_search_enriched import VoyagesSearchEnriched
from vortexasdk.endpoints.voyage_calculator import (
VoyageCalculator,
VoyageCalculatorRoute,
VoyageCalculatorType,
VoyageCalculatorVesselStatus,
VoyageCalculatorAvoidZone,
Expand Down Expand Up @@ -162,6 +163,7 @@
"AnywhereFreightPricingForecastExplanation",
"VoyageCalculator",
# Voyage Calculator types
"VoyageCalculatorRoute",
"VoyageCalculatorType",
"VoyageCalculatorVesselStatus",
"VoyageCalculatorAvoidZone",
Expand Down
129 changes: 127 additions & 2 deletions vortexasdk/endpoints/voyage_calculator.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
from typing import Any, Dict, List, Optional, Union

from typing_extensions import Literal, Required, TypedDict

from vortexasdk.client import _handle_response, default_client
from vortexasdk.endpoints.endpoints import VOYAGE_CALCULATOR
from vortexasdk.endpoints.voyage_calculator_result import (
VoyageCalculatorBatchResult,
VoyageCalculatorResult,
)
from vortexasdk.logger import get_logger
from vortexasdk.operations import Search

from typing_extensions import Literal
from vortexasdk.retry_session import _HEADERS as default_headers
from vortexasdk.retry_session import retry_post
from vortexasdk.utils import filter_empty_values

VoyageCalculatorType = Literal["speed", "ETA", "ETD"]
VoyageCalculatorVesselStatus = Literal[
Expand All @@ -18,6 +24,30 @@

LatLong = Dict[str, float]

logger = get_logger(__name__)


class VoyageCalculatorRoute(TypedDict, total=False):
"""
Route specification for batch voyage calculations.

Required keys: type, vessel_status, origin, destination.
Optional keys: vessel_id, vessel_class, waypoints, ETA, ETD, speed, avoid_zone, voyage_delay_factor.
"""

type: Required[VoyageCalculatorType]
vessel_status: Required[VoyageCalculatorVesselStatus]
origin: Required[Union[str, LatLong]]
destination: Required[Union[str, LatLong]]
vessel_id: str
vessel_class: str
waypoints: List[str]
ETA: str
ETD: str
speed: float
avoid_zone: List[VoyageCalculatorAvoidZone]
voyage_delay_factor: float


class VoyageCalculator(Search):
"""
Expand Down Expand Up @@ -141,3 +171,98 @@ def search(
return VoyageCalculatorResult(
records=response["data"], reference=response.get("reference", {})
)

def batch_search(
self,
routes: List[VoyageCalculatorRoute],
) -> "VoyageCalculatorBatchResult":
"""
Calculate voyage routes for multiple origin-destination pairs in a single request.

Accepts up to 5 route specifications and returns results with correlation fields
(origin, destination, vessel_class, avoid_zone) alongside the calculation results.

# Arguments
routes: A list of route dictionaries (max 5). Each route must contain:
- `type` (str, required): One of `'speed'`, `'ETA'`, `'ETD'`.
- `vessel_status` (str, required): One of `'vessel_status_ballast'`,
`'vessel_status_laden_known'`, `'vessel_status_laden_unknown'`.
- `origin` (str or dict, required): Geography ID string or `{"lat": float, "long": float}`.
- `destination` (str or dict, required): Geography ID string or `{"lat": float, "long": float}`.
- `vessel_id` (str, optional): Vessel identifier (IMO, MMSI, vessel name, or Vortexa ID).
- `vessel_class` (str, optional): E.g. `'oil_vlcc'`, `'oil_suezmax_lr3'`.
- `waypoints` (list, optional): List of geography IDs for intermediate waypoints.
- `ETA` (str, optional): ISO 8601 date string. Required when type is `'speed'` or `'ETD'`.
- `ETD` (str, optional): ISO 8601 date string. Required when type is `'speed'` or `'ETA'`.
- `speed` (float, optional): Speed in knots. Required when type is `'ETA'` or `'ETD'`.
- `avoid_zone` (list, optional): Zones to avoid: `'Panama Canal'`, `'Suez Canal'`.
- `voyage_delay_factor` (float, optional): Factor between 0 and 1 for increased duration.

# Returns
`VoyageCalculatorBatchResult`

# Example

_Calculate ETA and speed for two routes in a single batch request._

```python
>>> from vortexasdk import VoyageCalculator
>>> ras_tanura = "539db1548407fd97024391d01a6a2be239b1100f070a137d54a79907d03db6c8"
>>> rotterdam = "68faf65af1345067f11dc6723b8da32f00e304a6f33c000118fccd81947deb4e"
>>> routes = [
... {
... "type": "ETA",
... "vessel_status": "vessel_status_laden_known",
... "origin": ras_tanura,
... "destination": rotterdam,
... "vessel_class": "oil_vlcc",
... "ETD": "2024-03-01T00:00:00.000Z",
... "speed": 12,
... },
... {
... "type": "speed",
... "vessel_status": "vessel_status_ballast",
... "origin": rotterdam,
... "destination": ras_tanura,
... "vessel_class": "oil_suezmax_lr3",
... "ETD": "2024-03-01T00:00:00.000Z",
... "ETA": "2024-04-01T00:00:00.000Z",
... },
... ]
>>> result = VoyageCalculator().batch_search(routes=routes)
>>> df = result.to_df()

```

Returns a DataFrame with columns including correlation and calculation fields:

| | origin | destination | vessel_class | ETA | speed | duration |
|---:|:---------|:------------|:-----------------|:-------------------------|--------:|-----------:|
| 0 | 539db1.. | 68faf6.. | oil_vlcc | 2024-03-25T14:30:00.000Z | 12 | 590.5 |
| 1 | 68faf6.. | 539db1.. | oil_suezmax_lr3 | | 10.5 | 744.0 |

"""
if not routes:
raise ValueError("batch_search requires at least 1 route")
if len(routes) > 5:
raise ValueError(
f"batch_search accepts a maximum of 5 routes, got {len(routes)}"
)

cleaned_routes = [filter_empty_values(dict(route)) for route in routes]

client = default_client()
url = client._create_url(VOYAGE_CALCULATOR)

logger.info(f"Batch payload: {cleaned_routes}")
response = retry_post(
url, json=cleaned_routes, headers=default_headers
)

result = _handle_response(response)

return VoyageCalculatorBatchResult(
records=result["data"],
reference=result.get("reference", {}),
metadata=result.get("metadata", []),
)
46 changes: 45 additions & 1 deletion vortexasdk/endpoints/voyage_calculator_result.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import List, Optional, Union
from typing import Any, Dict, List, Optional, Union

import pandas as pd
from pydantic import Field
from typing_extensions import Literal

from vortexasdk.api.search_result import Result
Expand Down Expand Up @@ -42,3 +43,46 @@ def to_df(

available_columns = [col for col in columns if col in df.columns]
return df[available_columns]


class VoyageCalculatorBatchResult(Result):
"""
Container class holding results returned from the voyage calculator batch endpoint.

This class has `to_list()`, `to_df()`, and `metadata` for per-item status messages.
"""

metadata: List[Dict[str, Any]] = Field(default_factory=list)

def to_list(self) -> List[dict]:
"""Represent batch voyage calculations as a list of dictionaries."""
return super().to_list()

def to_df(
self, columns: Optional[Union[List[str], Literal["all"]]] = "all"
) -> pd.DataFrame:
"""
Represent batch voyage calculations as a `pd.DataFrame`.

Each row includes correlation fields (origin, destination, vessel_class, avoid_zone)
alongside the calculation results (ETA, ETD, speed, duration).

# Arguments
columns: Output columns present in the `pd.DataFrame`.
Enter `columns='all'` to return all available columns.
Enter a list of column names to return only those columns.

# Returns
`pd.DataFrame` with one row per calculated voyage.

"""
if not self.records:
return pd.DataFrame()

df = pd.json_normalize(self.records)

if columns is None or columns == "all":
return df

available_columns = [col for col in columns if col in df.columns]
return df[available_columns]
Loading
Loading