diff --git a/tests/endpoints/test_voyage_calculator.py b/tests/endpoints/test_voyage_calculator.py index 96765e91..302e34f3 100644 --- a/tests/endpoints/test_voyage_calculator.py +++ b/tests/endpoints/test_voyage_calculator.py @@ -1,3 +1,5 @@ +from unittest import TestCase + from tests.testcases import TestCaseUsingRealAPI from vortexasdk import VoyageCalculator @@ -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=[]) diff --git a/vortexasdk/__init__.py b/vortexasdk/__init__.py index b6d80652..fdf2f5c0 100644 --- a/vortexasdk/__init__.py +++ b/vortexasdk/__init__.py @@ -38,6 +38,7 @@ VoyagesTopHits, VoyagesSearchEnriched, VoyageCalculator, + VoyageCalculatorRoute, VoyageCalculatorType, VoyageCalculatorVesselStatus, VoyageCalculatorAvoidZone, @@ -107,6 +108,7 @@ "VoyagesTopHits", "VoyagesSearchEnriched", "VoyageCalculator", + "VoyageCalculatorRoute", "VoyageCalculatorType", "VoyageCalculatorVesselStatus", "VoyageCalculatorAvoidZone", diff --git a/vortexasdk/endpoints/__init__.py b/vortexasdk/endpoints/__init__.py index 89f47e63..fd1d94a2 100644 --- a/vortexasdk/endpoints/__init__.py +++ b/vortexasdk/endpoints/__init__.py @@ -65,6 +65,7 @@ from vortexasdk.endpoints.voyages_search_enriched import VoyagesSearchEnriched from vortexasdk.endpoints.voyage_calculator import ( VoyageCalculator, + VoyageCalculatorRoute, VoyageCalculatorType, VoyageCalculatorVesselStatus, VoyageCalculatorAvoidZone, @@ -162,6 +163,7 @@ "AnywhereFreightPricingForecastExplanation", "VoyageCalculator", # Voyage Calculator types + "VoyageCalculatorRoute", "VoyageCalculatorType", "VoyageCalculatorVesselStatus", "VoyageCalculatorAvoidZone", diff --git a/vortexasdk/endpoints/voyage_calculator.py b/vortexasdk/endpoints/voyage_calculator.py index c30c9c53..8441b7ae 100644 --- a/vortexasdk/endpoints/voyage_calculator.py +++ b/vortexasdk/endpoints/voyage_calculator.py @@ -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[ @@ -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): """ @@ -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", []), + ) diff --git a/vortexasdk/endpoints/voyage_calculator_result.py b/vortexasdk/endpoints/voyage_calculator_result.py index ed6e1ae6..eafd5095 100644 --- a/vortexasdk/endpoints/voyage_calculator_result.py +++ b/vortexasdk/endpoints/voyage_calculator_result.py @@ -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 @@ -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] diff --git a/vortexasdk/version.py b/vortexasdk/version.py index 44e67809..ccc27dc2 100644 --- a/vortexasdk/version.py +++ b/vortexasdk/version.py @@ -1 +1 @@ -__version__ = "1.0.31" +__version__ = "1.0.32"