diff --git a/docs/endpoints/about-endpoints.md b/docs/endpoints/about-endpoints.md index bcf23fa9..ad78245f 100644 --- a/docs/endpoints/about-endpoints.md +++ b/docs/endpoints/about-endpoints.md @@ -5,22 +5,23 @@ The endpoints module allows you to query Vortexa's data. The VortexaSDK currently contains the following endpoints: 1. Cargo Movements -1. Voyages -1. Charterers -1. Geographies -1. Products -1. Vessels -1. Cargo Time Series -1. EIA Forecasts -1. Tonne-miles -1. Vessel Availability -1. Crude Onshore Inventories -1. Freight Pricing -1. Vessel Summary -1. Vessel Positions -1. Canal transit -1. Canal transit Time Series -1. Refineries +2. Voyages +3. Charterers +4. Geographies +5. Products +6. Vessels +7. Cargo Time Series +8. EIA Forecasts +9. Tonne-miles +10. Vessel Availability +11. Crude Onshore Inventories +12. Freight Pricing +13. Vessel Summary +14. Vessel Positions +15. Canal transit +16. Canal transit Time Series +17. Refineries +18. Voyage Calculator Each endpoint offers either one, or both, of two different functionalities: diff --git a/tests/endpoints/test_voyage_calculator.py b/tests/endpoints/test_voyage_calculator.py new file mode 100644 index 00000000..96765e91 --- /dev/null +++ b/tests/endpoints/test_voyage_calculator.py @@ -0,0 +1,132 @@ +from tests.testcases import TestCaseUsingRealAPI +from vortexasdk import VoyageCalculator + +ras_tanura = "539db1548407fd97024391d01a6a2be239b1100f070a137d54a79907d03db6c8" +rotterdam = "68faf65af1345067f11dc6723b8da32f00e304a6f33c000118fccd81947deb4e" + + +class TestVoyageCalculator(TestCaseUsingRealAPI): + def test_calculate_eta(self): + result = VoyageCalculator().search( + 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_list = result.to_list() + assert len(result_list) == 1 + assert "ETA" in result_list[0] + assert "ETD" in result_list[0] + assert "speed" in result_list[0] + assert "duration" in result_list[0] + + def test_calculate_etd(self): + result = VoyageCalculator().search( + type="ETD", + vessel_status="vessel_status_ballast", + origin=ras_tanura, + destination=rotterdam, + vessel_class="oil_vlcc", + ETA="2024-04-01T00:00:00.000Z", + speed=12, + ) + + result_list = result.to_list() + assert len(result_list) == 1 + assert "ETD" in result_list[0] + + def test_calculate_speed(self): + result = VoyageCalculator().search( + type="speed", + vessel_status="vessel_status_laden_known", + origin=ras_tanura, + destination=rotterdam, + vessel_class="oil_vlcc", + ETD="2024-03-01T00:00:00.000Z", + ETA="2024-04-01T00:00:00.000Z", + ) + + result_list = result.to_list() + assert len(result_list) == 1 + assert "speed" in result_list[0] + assert result_list[0]["speed"] > 0 + + def test_calculate_with_latlong_origin(self): + result = VoyageCalculator().search( + type="ETA", + vessel_status="vessel_status_laden_known", + origin={"lat": 26.6, "long": 50.1}, + destination=rotterdam, + vessel_class="oil_vlcc", + ETD="2024-03-01T00:00:00.000Z", + speed=12, + ) + + result_list = result.to_list() + assert len(result_list) == 1 + + def test_calculate_with_avoid_zone(self): + result = VoyageCalculator().search( + 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_list = result.to_list() + assert len(result_list) == 1 + + def test_to_df(self): + result = VoyageCalculator().search( + 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, + ) + + df = result.to_df() + assert len(df) == 1 + assert "ETA" in df.columns + assert "ETD" in df.columns + assert "speed" in df.columns + assert "duration" in df.columns + + def test_to_df_with_columns(self): + result = VoyageCalculator().search( + 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, + ) + + df = result.to_df(columns=["ETA", "speed"]) + assert len(df.columns) == 2 + + def test_calculate_with_delay_factor(self): + result = VoyageCalculator().search( + 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, + voyage_delay_factor=0.2, + ) + + result_list = result.to_list() + assert len(result_list) == 1 diff --git a/vortexasdk/__init__.py b/vortexasdk/__init__.py index 36805d01..b6d80652 100644 --- a/vortexasdk/__init__.py +++ b/vortexasdk/__init__.py @@ -37,6 +37,10 @@ VoyagesCongestionBreakdown, VoyagesTopHits, VoyagesSearchEnriched, + VoyageCalculator, + VoyageCalculatorType, + VoyageCalculatorVesselStatus, + VoyageCalculatorAvoidZone, VesselSummary, VesselPositions, Refineries, @@ -102,6 +106,10 @@ "VoyagesCongestionBreakdown", "VoyagesTopHits", "VoyagesSearchEnriched", + "VoyageCalculator", + "VoyageCalculatorType", + "VoyageCalculatorVesselStatus", + "VoyageCalculatorAvoidZone", "VesselSummary", "VesselPositions", "Refineries", diff --git a/vortexasdk/endpoints/__init__.py b/vortexasdk/endpoints/__init__.py index be202027..89f47e63 100644 --- a/vortexasdk/endpoints/__init__.py +++ b/vortexasdk/endpoints/__init__.py @@ -63,6 +63,12 @@ ) from vortexasdk.endpoints.voyages_top_hits import VoyagesTopHits from vortexasdk.endpoints.voyages_search_enriched import VoyagesSearchEnriched +from vortexasdk.endpoints.voyage_calculator import ( + VoyageCalculator, + VoyageCalculatorType, + VoyageCalculatorVesselStatus, + VoyageCalculatorAvoidZone, +) from vortexasdk.endpoints.fixtures import Fixtures from vortexasdk.endpoints.vessel_summary import VesselSummary from vortexasdk.endpoints.vessel_positions import VesselPositions @@ -154,6 +160,11 @@ "AnywhereFreightPricingTopPortsOrigin", "AnywhereFreightPricingVesselClassesDetails", "AnywhereFreightPricingForecastExplanation", + "VoyageCalculator", + # Voyage Calculator types + "VoyageCalculatorType", + "VoyageCalculatorVesselStatus", + "VoyageCalculatorAvoidZone", # AFP types for user type annotations "AfpAvoidZone", "AfpExplanationFrequency", diff --git a/vortexasdk/endpoints/endpoints.py b/vortexasdk/endpoints/endpoints.py index 896c8173..3cdd37eb 100644 --- a/vortexasdk/endpoints/endpoints.py +++ b/vortexasdk/endpoints/endpoints.py @@ -69,6 +69,8 @@ FIXTURES = "/v5/search/fixtures" +VOYAGE_CALCULATOR = "/v5/voyages/voyage-calculator" + CANAL_TRANSIT = "/v5/canal-transit" CANAL_TRANSIT_SEARCH = "/v5/canal-transit/search" CANAL_TRANSIT_TIME_SERIES = "/v5/canal-transit/time-series" diff --git a/vortexasdk/endpoints/voyage_calculator.py b/vortexasdk/endpoints/voyage_calculator.py new file mode 100644 index 00000000..c30c9c53 --- /dev/null +++ b/vortexasdk/endpoints/voyage_calculator.py @@ -0,0 +1,143 @@ +from typing import Any, Dict, List, Optional, Union + +from vortexasdk.endpoints.endpoints import VOYAGE_CALCULATOR +from vortexasdk.endpoints.voyage_calculator_result import ( + VoyageCalculatorResult, +) +from vortexasdk.operations import Search + +from typing_extensions import Literal + +VoyageCalculatorType = Literal["speed", "ETA", "ETD"] +VoyageCalculatorVesselStatus = Literal[ + "vessel_status_ballast", + "vessel_status_laden_known", + "vessel_status_laden_unknown", +] +VoyageCalculatorAvoidZone = Literal["Panama Canal", "Suez Canal"] + +LatLong = Dict[str, float] + + +class VoyageCalculator(Search): + """ + Voyage Calculator endpoint. + + Calculates voyage routes, ETAs, ETDs, or speeds between an origin and destination. + The calculator accounts for vessel class, laden/ballast status, canal avoidance + zones, and optional waypoints. + """ + + def __init__(self) -> None: + Search.__init__(self, VOYAGE_CALCULATOR) + + def search( + self, + type: VoyageCalculatorType, + vessel_status: VoyageCalculatorVesselStatus, + origin: Union[str, LatLong], + destination: Union[str, LatLong], + vessel_id: Optional[str] = None, + vessel_class: Optional[str] = None, + waypoints: Optional[List[str]] = None, + ETA: Optional[str] = None, + ETD: Optional[str] = None, + speed: Optional[float] = None, + avoid_zone: Optional[List[VoyageCalculatorAvoidZone]] = None, + voyage_delay_factor: Optional[float] = None, + ) -> "VoyageCalculatorResult": + """ + Calculate a voyage route between an origin and destination. + + # Arguments + type: The type of calculation to perform. One of: + - `'speed'`: Calculate the speed required given an ETD and ETA. + - `'ETA'`: Calculate the ETA given an ETD and speed. + - `'ETD'`: Calculate the ETD given an ETA and speed. + + vessel_status: Whether the vessel is laden or ballast. One of: + `'vessel_status_ballast'`, `'vessel_status_laden_known'`, `'vessel_status_laden_unknown'`. + + origin: The origin of the voyage. Can be either: + - A string ID (vessel ID for current position, or geography ID for centroid). + - A dict with `lat` and `long` keys, e.g. `{"lat": 51.9, "long": 4.5}`. + + destination: The destination of the voyage. Can be either: + - A geography ID string. + - A dict with `lat` and `long` keys, e.g. `{"lat": 29.9, "long": 32.5}`. + + vessel_id: A vessel identifier (IMO, MMSI, vessel name, or Vortexa ID). + Used to determine the vessel's current position (when origin is a vessel ID) + and deadweight tonnage for routing. + + vessel_class: Vessel class used to determine DWT when `vessel_id` is not provided. + Examples: `'oil_vlcc'`, `'oil_suezmax_lr3'`, `'oil_aframax_lr2'`, `'lng_conventional_lng'`. + + waypoints: A list of geography IDs representing intermediate waypoints. + + ETA: Estimated time of arrival as an ISO 8601 date string (e.g. `'2024-03-15T00:00:00.000Z'`). + Required when `type` is `'speed'` or `'ETD'`. + + ETD: Estimated time of departure as an ISO 8601 date string (e.g. `'2024-03-01T00:00:00.000Z'`). + Required when `type` is `'speed'` or `'ETA'`. + + speed: Speed in knots. Required when `type` is `'ETA'` or `'ETD'`. + + avoid_zone: A list of zones to avoid in routing. Options: `'Panama Canal'`, `'Suez Canal'`. + + voyage_delay_factor: A factor between 0 and 1 to simulate increased voyage duration. + For example, 0.2 means 120% of the original duration. + + # Returns + `VoyageCalculatorResult` + + # Example + + _Calculate ETA for a VLCC travelling from Ras Tanura to Rotterdam at 12 knots._ + + ```python + >>> from vortexasdk import VoyageCalculator + >>> ras_tanura = "539db1548407fd97024391d01a6a2be239b1100f070a137d54a79907d03db6c8" + >>> rotterdam = "68faf65af1345067f11dc6723b8da32f00e304a6f33c000118fccd81947deb4e" + >>> result = VoyageCalculator().search( + ... 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, + ... ) + >>> df = result.to_df() + + ``` + + Returns a DataFrame with columns: + + | | ETA | ETD | duration | speed | + |---:|:-------------------------|:-------------------------|-----------:|--------:| + | 0 | 2024-03-25T14:30:00.000Z | 2024-03-01T00:00:00.000Z | 590.5 | 12 | + + """ + api_params: Dict[str, Any] = { + "type": type, + "vessel_status": vessel_status, + "origin": origin, + "destination": destination, + "vessel_id": vessel_id, + "vessel_class": vessel_class, + "waypoints": waypoints, + "ETA": ETA, + "ETD": ETD, + "speed": speed, + "avoid_zone": avoid_zone, + "voyage_delay_factor": voyage_delay_factor, + } + + response = super().search_with_client( + response_type="breakdown", **api_params + ) + + return VoyageCalculatorResult( + records=response["data"], reference=response.get("reference", {}) + ) diff --git a/vortexasdk/endpoints/voyage_calculator_result.py b/vortexasdk/endpoints/voyage_calculator_result.py new file mode 100644 index 00000000..ed6e1ae6 --- /dev/null +++ b/vortexasdk/endpoints/voyage_calculator_result.py @@ -0,0 +1,44 @@ +from typing import List, Optional, Union + +import pandas as pd +from typing_extensions import Literal + +from vortexasdk.api.search_result import Result + + +class VoyageCalculatorResult(Result): + """ + Container class holding results returned from the voyage calculator endpoint. + + This class has `to_list()` and `to_df()` methods for representing results. + """ + + def to_list(self) -> List[dict]: + """Represent 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 voyage calculations as a `pd.DataFrame`. + + # 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 8873ad42..44e67809 100644 --- a/vortexasdk/version.py +++ b/vortexasdk/version.py @@ -1 +1 @@ -__version__ = "1.0.30a1" +__version__ = "1.0.31"