Voyage-Leg Fuel Consumption Breakdown

This use case demonstrates how Toqua's Ship Kernels break down fuel consumption over a voyage leg into key contributing factors (waves, wind, currents, fouling, calm water use, etc.). While this example shows a breakdown over the voyage leg duration, similar code can be applied to daily, per-point, yearly, or any other time window you want to analyze.

Use Case

Notebook Structure

Chapter 1: Data Preparation

  1. Load Sensor Data
  2. Data Adjustment 1: Remove Obsolete Columns & Add Voyage Column
  3. Data Adjustment 2: Add Fuel Contributions: Wind, Waves, Current, Draft, and Fuel Quality
  4. Data Adjustment 3: Add Fuel Contribution: Hull Degradation
  5. Data Adjustment 4: Apply Fuel Contribution Percentages to Actual Fuel Consumption Rate
  6. Data Adjustment 5: Convert Daily Fuel Rate (mt/day) to Fuel Consumption

Chapter 2: Visualization

  1. Data Aggregation
  2. SelectVoyage_ID
  3. Standard Visual: Voyage-Level Fuel Breakdown
  4. Advanced Visual: "Daily Fuel Breakdown
  5. Combined Standard & Advanced Visuals

Toy Example

During a single five-minute logging window the bridge records the values below. These are exactly the inputs the Toqua model needs for a fuel prediction. For this example we assume a ship that is sailing empty.

Variable (model field) Snapshot reading
Speed over ground (sog) 12.7 kn
True wind
(speed & direction)
19.4 kn from 200°
Wave height & direction 2 m from 190°
Current speed & direction 1 kn from 20°
Draft (draft_avg) 12.0 m
Fuel specific energy
(fuel_specific_energy)
40.0 MJ /kg
Measured ME fuel rate 40 mt /day

Feeding those raw numbers to the Toqua API returns a fuel rate of 32 mt/day. That is what the model believes the engine should burn under the real conditions.

When we “switch off” one variable at a time, we are building a simple what-if experiment around the model’s baseline prediction of 32 mt/day. Each row in the table shows what happens when a single influence is replaced by its neutral reference value while every other input stays exactly as observed. If the new prediction drops below the baseline (as it does when we remove wind, waves, or ship is empty/laden), that factor is adding fuel; if it rises above the baseline (as with the helping current), the factor was actually saving fuel.

Factor neutralised Reference value sent to the API New model output Effect on fuel
Wind Wind speed = 0 kn 30 mt /day +2 mt/day (wind costs 2)
Waves Wave height = 0 m 31 mt /day +1 mt/day (waves cost 1)
Current Current speed = 0 kn 34 mt /day –2 mt/day (current saves 2)
Draft Draft = 8 m (ballast) 28 mt /day +4 mt/day (deep draft costs 4)
Fuel quality Fuel = 42.7 mt /day 31 mt /day +1 mt/day (poor fuel costs 1)

The model’s metadata shows the hull is about five percent fouled, which means roughness alone adds roughly two tonnes of fuel per day to the bill.

Once we’ve estimated the model-based impact of each factor, we convert that difference into a percentage relative to the baseline prediction. This percentage is then applied to the vessel’s actual measured fuel rate, translating model behaviour into real-world terms. The result is a consistent, data-grounded estimate of how much each variable contributed to the fuel consumption during that interval.

Because five minutes are 5⁄1440 of a day, each daily quantity shrinks by that factor for an individual log interval.

Authorize

Let's first make sure we're authorized to use the API. Fill in your API key and run the following code.

If everything is alright, you should see a list of ships.

API_KEY = "your-api-key"
import json
import requests

API_URL = "https://api.toqua.ai"

url = "https://api.toqua.ai/ships/"
headers = {"accept": "application/json", "X-API-Key": API_KEY}
response = requests.get(url, headers=headers)

print(json.dumps(response.json(), indent=2))

Fill in the IMO number of your ship below

IMO_NUMBER = "your-imo-number"

Chapter 1: Data Preparation

Download necessary libraries.

import pandas as pd
from IPython.display import display
from typing import List, Dict, Tuple
import ssl
import json
from urllib.request import Request, urlopen
from urllib.error import HTTPError
import requests
import numpy as np
from tqdm import tqdm
import time
import matplotlib.pyplot as plt
from plotly.subplots import make_subplots
import plotly.graph_objects as go

1.1) Load Sensor Data

Load data in CSV format.

df = pd.read_csv("./example_sensor_data.csv")

Display first 10 rows.

display(df.head(10))
datetime_end sog stw me_power me_rpm me_fo_consumption heading trim sea_depth operating_mode ... wind_wave_height wind_wave_period datetime_start_nr datetime_end_nr me_fo_consumption_nr heading_nr water_displacement operating_mode_nr draft_avg fo_ncv
61632 2025-01-01 00:00:00+00:00 12.7320 12.6540 15134.1330 61.9750 76.32 89.6430 0.5090 4084.0 NaN ... 0.360000 2.410000 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61633 2025-01-01 00:05:00+00:00 12.7465 12.6550 15111.0500 61.9850 76.32 89.9940 0.5260 4079.0 NaN ... 0.370417 2.432917 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61634 2025-01-01 00:10:00+00:00 12.7610 12.6560 15087.9670 61.9950 76.32 90.3450 0.5430 4082.0 NaN ... 0.380833 2.455833 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61635 2025-01-01 00:15:00+00:00 12.7305 12.6530 7543.9835 62.0975 77.04 91.3225 0.5815 4082.0 NaN ... 0.391250 2.478750 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61636 2025-01-01 00:20:00+00:00 12.7000 12.6500 0.0000 62.2000 77.76 92.3000 0.6200 4082.0 NaN ... 0.401667 2.501667 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61637 2025-01-01 00:25:00+00:00 12.7140 12.6640 0.0000 62.1000 77.04 92.1910 0.5625 4085.0 NaN ... 0.412083 2.524583 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61638 2025-01-01 00:30:00+00:00 12.7280 12.6780 0.0000 62.0000 76.32 92.0820 0.5050 4082.0 NaN ... 0.422500 2.547500 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61639 2025-01-01 00:35:00+00:00 12.7475 12.6670 0.0000 62.0125 76.32 91.9750 0.5335 4077.0 NaN ... 0.432917 2.570417 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61640 2025-01-01 00:40:00+00:00 12.7670 12.6560 0.0000 62.0250 76.32 91.8680 0.5620 4072.0 NaN ... 0.443333 2.593333 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2
61641 2025-01-01 00:45:00+00:00 12.8145 12.6605 0.0000 61.9975 76.32 91.9330 0.5810 4067.0 NaN ... 0.453750 2.616250 NaN 2025-01-01 20:00:00+00:00 73.04 89.0 NaN NaN 20.3 40.2

10 rows × 62 columns

1.2) Data Adjustment 1: Remove Obsolete Columns & Add Voyage Column

To simplify the analysis, we keep only the columns that are essential for fuel breakdown, such as vessel position, weather conditions, and engine metrics.

columns_to_keep = ["datetime_end", "imo_number", "heading", "me_fo_consumption", "me_power", "sog", "draft_avg", "fo_ncv", "current_speed", "current_dir",
    "wave_height", "wave_dir", "wind_speed", "wind_dir"]

data_filtered = df[columns_to_keep].copy()

Display first 10 rows.

display(data_filtered.head(10))
datetime_end imo_number heading me_fo_consumption me_power sog draft_avg fo_ncv current_speed current_dir wave_height wave_dir wind_speed wind_dir
61632 2025-01-01 00:00:00+00:00 9419620 89.6430 76.32 15134.1330 12.7320 20.3 40.2 0.680000 122.810000 1.510000 105.26000 9.38000 66.50000
61633 2025-01-01 00:05:00+00:00 9419620 89.9940 76.32 15111.0500 12.7465 20.3 40.2 0.676667 122.747083 1.512917 105.36875 9.35375 66.81125
61634 2025-01-01 00:10:00+00:00 9419620 90.3450 76.32 15087.9670 12.7610 20.3 40.2 0.673333 122.684167 1.515833 105.47750 9.32750 67.12250
61635 2025-01-01 00:15:00+00:00 9419620 91.3225 77.04 7543.9835 12.7305 20.3 40.2 0.670000 122.621250 1.518750 105.58625 9.30125 67.43375
61636 2025-01-01 00:20:00+00:00 9419620 92.3000 77.76 0.0000 12.7000 20.3 40.2 0.666667 122.558333 1.521667 105.69500 9.27500 67.74500
61637 2025-01-01 00:25:00+00:00 9419620 92.1910 77.04 0.0000 12.7140 20.3 40.2 0.663333 122.495417 1.524583 105.80375 9.24875 68.05625
61638 2025-01-01 00:30:00+00:00 9419620 92.0820 76.32 0.0000 12.7280 20.3 40.2 0.660000 122.432500 1.527500 105.91250 9.22250 68.36750
61639 2025-01-01 00:35:00+00:00 9419620 91.9750 76.32 0.0000 12.7475 20.3 40.2 0.656667 122.369583 1.530417 106.02125 9.19625 68.67875
61640 2025-01-01 00:40:00+00:00 9419620 91.8680 76.32 0.0000 12.7670 20.3 40.2 0.653333 122.306667 1.533333 106.13000 9.17000 68.99000
61641 2025-01-01 00:45:00+00:00 9419620 91.9330 76.32 0.0000 12.8145 20.3 40.2 0.650000 122.243750 1.536250 106.23875 9.14375 69.30125

10 rows × 16 columns

Each data point is matched to a voyage using timestamps from Toqua's API. You can choose between customer voyages or auto voyages (automatically detected by Toqua). To distinguish between an empty and a loaded vessel, we use auto voyages. These represent individual legs of a journey, rather than the round trips typically shown in customer voyages. This distinction allows us to more accurately isolate the effect of draft.

def get_voyages(numeric_imo: str, api_key: str, voyage_type: str = "auto") -> List[Dict]:
    url = f"https://api.toqua.ai/ships/{numeric_imo}/voyages"
    headers = {
        "accept": "application/json",
        "X-API-Key": api_key,
    }

    response = requests.get(url, headers=headers)
    
    if response.status_code == 404:
        return []
    response.raise_for_status()
    
    all_voys = response.json().get("voyages", [])
    filtered_voys = [v for v in all_voys if v.get("voyage_id_source") == voyage_type]
    return filtered_voys

def get_voyage_times(voyage_id: str, numeric_imo: str, api_key: str, voyage_type: str = "customer") -> Tuple[pd.Timestamp, pd.Timestamp]:
    voyages = get_voyages(numeric_imo, api_key, voyage_type=voyage_type)
    for v in voyages:
        if v["voyage_id"] == voyage_id:
            return (
                pd.to_datetime(v["departure"]["datetime"]),
                pd.to_datetime(v["arrival"]["datetime"])
            )
    raise ValueError(f"Voyage {voyage_id!r} not found for {numeric_imo}")

def assign_voyage_ids_to_df(df: pd.DataFrame, numeric_imo: str, api_key: str, voyage_type: str = "customer") -> pd.DataFrame:
    voyages = get_voyages(numeric_imo, api_key, voyage_type=voyage_type)
    print(f"📦 Found {len(voyages)} {voyage_type} voyages")

    df["datetime_end"] = pd.to_datetime(df["datetime_end"])
    if df["datetime_end"].dt.tz is None:
        df["datetime_end"] = df["datetime_end"].dt.tz_localize("UTC")

    df["voyage_id"] = None  # initialize

    for v in voyages:
        voyage_id = v.get("voyage_id")
        dep_raw = v.get("departure", {}).get("datetime")
        arr_raw = v.get("arrival", {}).get("datetime")

        if not dep_raw or not arr_raw:
            print(f"⚠️ Skipping voyage {voyage_id}: missing times")
            continue

        try:
            dep = pd.to_datetime(dep_raw)
            arr = pd.to_datetime(arr_raw)

            if dep.tzinfo is None:
                dep = dep.tz_localize("UTC")
            if arr.tzinfo is None:
                arr = arr.tz_localize("UTC")

            mask = (df["datetime_end"] >= dep) & (df["datetime_end"] <= arr)
            df.loc[mask, "voyage_id"] = voyage_id
            print(f"✅ Assigned to voyage {voyage_id}: {mask.sum()} rows")
        except Exception as e:
            print(f"❌ Error tagging voyage {voyage_id}: {e}")

    df["voyage_id"] = df["voyage_id"].fillna("0")
    return df

data_filtered_voyage = assign_voyage_ids_to_df(data_filtered,IMO_NUMBER, API_KEY, voyage_type= "auto")
Found 8 auto voyages
✅ Assigned to voyage voyage_13: 2300 rows
✅ Assigned to voyage voyage_14: 3472 rows
✅ Assigned to voyage voyage_15: 6851 rows
✅ Assigned to voyage voyage_17: 4005 rows
✅ Assigned to voyage voyage_0:  4256 rows
✅ Assigned to voyage voyage_1:  6842 rows
✅ Assigned to voyage voyage_2:  7191 rows
✅ Assigned to voyage voyage_3:  6698 rows

It's useful to assess the loading condition of a specific leg that a vessel sails. We do this by comparing the average draft during that leg to the vessel's ballast draft (i.e., the draft when unloaded). If the average draft is significantly higher, we classify the leg as 'laden'; otherwise, we consider it 'ballast'.

avg_draft_per_voyage = (data_filtered_voyage.groupby("voyage_id", dropna=False)["draft_avg"].mean().reset_index().rename(columns={"draft_avg": "avg_draft_avg"}))

ballast_draft = ship_info.get("ballast_draft")

voyage_metadata = get_voyages(IMO_NUMBER, API_KEY, voyage_type="auto")

# create a dataframe with voyage_id and voyage_period
voyage_dates = []
for v in voyage_metadata:
    voyage_id = v["voyage_id"]
    dep = v.get("departure", {}).get("datetime")
    arr = v.get("arrival", {}).get("datetime")
    if dep and arr:
        dep_date = pd.to_datetime(dep).strftime("%Y-%m-%d")
        arr_date = pd.to_datetime(arr).strftime("%Y-%m-%d")
        voyage_dates.append({
            "voyage_id": voyage_id,
            "voyage_period": f"{dep_date} → {arr_date}"
        })

voyage_dates_df = pd.DataFrame(voyage_dates)

# merge voyage period into the average draft dataframe
avg_draft_per_voyage = avg_draft_per_voyage.merge(voyage_dates_df,on="voyage_id",how="left")

# Decide draft mode: 'laden' or 'ballast'
def classify_draft_mode(row, ballast_draft, tolerance=1):
    if pd.isna(row["avg_draft_avg"]):
        return "unknown"
    return "ballast" if row["avg_draft_avg"] <= ballast_draft + tolerance else "laden"

avg_draft_per_voyage["draft_mode"] = avg_draft_per_voyage.apply(
    lambda row: classify_draft_mode(row, ballast_draft, tolerance=1),
    axis=1
)

# reorder columns as requested
avg_draft_per_voyage = avg_draft_per_voyage[["voyage_id", "voyage_period", "avg_draft_avg", "draft_mode"]]

display(avg_draft_per_voyage)
voyage_id voyage_period avg_draft_avg draft_mode
1 voyage_0 2024-12-31 → 2025-01-15 20.206532 laden
2 voyage_1 2025-01-17 → 2025-02-17 9.725791 ballast
3 voyage_13 2024-09-23 → 2024-10-02 9.667417 ballast
4 voyage_14 2024-10-03 → 2024-10-16 9.533438 ballast
5 voyage_15 2024-10-17 → 2024-11-14 20.336016 laden
6 voyage_17 2024-11-29 → 2024-12-17 9.495834 ballast
7 voyage_2 2025-02-18 → 2025-03-21 20.352844 laden
8 voyage_3 2025-03-22 → 2025-04-22 9.668478 ballast

8 rows × 4 columns

Now we still have to merge the two datasets (data_filtered_voyage & avg_draft_per_voyage) with each other.

data_with_draft_info = data_filtered_voyage.merge(avg_draft_per_voyage[["voyage_id", "avg_draft_avg", "draft_mode"]], on="voyage_id", how="left")

1.3) Data Adjustment 2: Add Fuel Contributions: Wind, Waves, Current, Draft, and Fuel Energy Density

Check for missing values in each column. It's important that predictor variables used for modeling have no missing values. If any predictor contains missing values, the corresponding rows will be removed.

missing_counts = data_with_draft_info.isna().sum()
print(missing_counts)
draft_avg                0
fo_ncv                   0
current_speed            0
current_dir              0
wave_height              0
wave_dir                 0
wind_speed               0
wind_dir                 0
voyage_id                0
dtype: int64

In this case, there are no missing values, thus we do not have to remove any observations.

In the next step below, we use Toqua’s API to estimate how much wind, waves, current, and draft individually contribute to the vessel’s fuel consumption. We begin by predicting fuel use based on the actual observed conditions. Then, for each of these factors, we run a new prediction where that specific factor is set to a neutral reference value (e.g. no wind, calm sea, zero current, or ballast/laden draft depending on draft_mode), while all other inputs remain unchanged. The difference between the original and adjusted predictions reveals the fuel impact of that specific condition.

def get_ship_metadata(imo_number: str, api_key: str) -> Dict:
    url = f"https://api.toqua.ai/ships/{imo_number}"
    headers = {
        "accept": "application/json",
        "X-API-Key": api_key
    }
    response = requests.get(url, headers=headers)
    if response.ok:
        return response.json()
    else:
        raise Exception(f"❌ API Error {response.status_code}: {response.text}")

def get_predict_url(imo_number: str) -> str:
    return f"https://api.toqua.ai/ships/{imo_number}/models/latest/predict"

ship_info = get_ship_metadata(IMO_NUMBER, API_KEY)
ballast_draft = ship_info.get("ballast_draft")

predict_url = get_predict_url(IMO_NUMBER)

# necessary variables doing prediction
feature_map = {
    "sog": "sog",
    "wave_direction": "wave_dir",
    "wave_height": "wave_height",
    "wind_direction": "wind_dir",
    "wind_speed": "wind_speed",
    "current_direction": "current_dir",
    "current_speed": "current_speed",
    "draft_avg": "draft_avg",
    "fuel_specific_energy": "fo_ncv"
}

# determining neutral value draft_avg
ship_url = f"https://api.toqua.ai/ships/{IMO_NUMBER}"
ship_response = requests.get(ship_url, headers=headers)
if ship_response.ok:
    ship_info = ship_response.json()
    ballast_draft = ship_info.get("ballast_draft")
else:
    raise Exception(f"❌ API Error {ship_response.status_code}: {ship_response.text}")

laden_draft = ship_info.get("laden_draft")
# neutral values for decomposition
neutral_values = {
    "wind_speed": 0,
    "wave_height": 0,
    "current_speed": 0,
    "fuel_specific_energy": 41.6
}

measured_col = "me_fo_consumption"

# send a batch of rows to the API and return predicted ME FO consumption (= fuel_consumption)
def predict_batch(df_chunk, max_retries=3, delay=5): 
    api_input = {
        "data": {api_name: df_chunk[df_col].tolist()
                 for api_name, df_col in feature_map.items()}
    }
    for attempt in range(1, max_retries + 1):
        try:
            response = requests.post(predict_url, headers=headers, json=api_input, timeout=60)
            if response.ok:
                predictions = response.json()
                pred_list = predictions.get("me_fo_consumption", [None] * len(df_chunk))
                clean_list = [p if p is not None else np.nan for p in pred_list]
                return pd.Series(clean_list, index=df_chunk.index)
            elif response.status_code >= 500:
                print(f"⚠️ API error {response.status_code} on attempt {attempt}. Retrying in {delay}s...")
                time.sleep(delay)
            else:
                print(f"❌ API Error {response.status_code}: {response.text}")
                break 
        except requests.exceptions.RequestException as e:
            print(f"⚠️ Request failed on attempt {attempt}: {e}. Retrying in {delay}s...")
            time.sleep(delay)
    print("❌ Max retries reached. Returning NaNs for this chunk.")
    return pd.Series([np.nan] * len(df_chunk), index=df_chunk.index)

def compute_contributions_batched(df, chunk_size):
    all_chunks = []
    for start in range(0, len(df), chunk_size):
        end = min(start + chunk_size, len(df))
        df_chunk = df.iloc[start:end].copy()
        print(f"\n🔹 Processing rows {start} to {end - 1}...")
        df_chunk.replace([np.inf, -np.inf], np.nan, inplace=True)
        df_chunk.fillna(0, inplace=True)

        # 🔹 Baseline prediction
        print("📡 Sending baseline API request...")
        baseline_pred = predict_batch(df_chunk)
        df_chunk["predicted_consumption"] = baseline_pred

        # 🔹 Neutralized predictions
        for var in list(neutral_values.keys()) + ["draft_avg"]:
            print(f"📡 Sending API request with '{var}' neutralized...")
            df_neutralized = df_chunk.copy()
            df_col_name = feature_map.get(var, var)

            if var == "draft_avg":
                df_neutralized[df_col_name] = df_chunk.apply(
                    lambda row: ballast_draft if row["draft_mode"] == "ballast" else laden_draft,
                    axis=1
                )
            else:
                df_neutralized[df_col_name] = neutral_values[var]

            neutral_pred = predict_batch(df_neutralized)
            contrib_col = f"contrib_{var}"
            df_chunk[contrib_col] = df_chunk["predicted_consumption"] - neutral_pred
            df_chunk[f"{contrib_col}_pct"] = (
                df_chunk[contrib_col] / df_chunk["predicted_consumption"] * 100
            )


        all_chunks.append(df_chunk)
    df_result = pd.concat(all_chunks, ignore_index=True)
    print(f"\n✅ All predictions completed: {df_result.shape[0]} rows.")
    return df_result


df_results = compute_contributions_batched(data_with_draft_info, chunk_size = 10000)
Processing rows 0 to 9999...
📡 Sending baseline API request...
📡 Sending API request with 'wind_speed' neutralized...
📡 Sending API request with 'wave_height' neutralized...
📡 Sending API request with 'current_speed' neutralized...
📡 Sending API request with 'draft_avg' neutralized...
📡 Sending API request with 'fuel_specific_energy' neutralized...

Processing rows 10000 to 19999...
📡 Sending baseline API request...
📡 Sending API request with 'wind_speed' neutralized...
📡 Sending API request with 'wave_height' neutralized...
📡 Sending API request with 'current_speed' neutralized...
📡 Sending API request with 'draft_avg' neutralized...
📡 Sending API request with 'fuel_specific_energy' neutralized...

Processing rows 20000 to 29999...
📡 Sending baseline API request...
📡 Sending API request with 'wind_speed' neutralized...
📡 Sending API request with 'wave_height' neutralized...
📡 Sending API request with 'current_speed' neutralized...
📡 Sending API request with 'draft_avg' neutralized...
📡 Sending API request with 'fuel_specific_energy' neutralized...
...
📡 Sending API request with 'draft_avg' neutralized...
📡 Sending API request with 'fuel_specific_energy' neutralized...

✅ All predictions completed: 51733 rows.

Display first 10 rows.

selected_columns = ["datetime_end", "me_fo_consumption", "predicted_consumption",
    "contrib_wind_speed", "contrib_wind_speed_pct",
    "contrib_wave_height", "contrib_wave_height_pct",
    "contrib_current_speed", "contrib_current_speed_pct",
    "contrib_draft_avg", "contrib_draft_avg_pct",
    "contrib_fuel_specific_energy", "contrib_fuel_specific_energy_pct"
]
summary_df = df_results[selected_columns].copy()
display(summary_df.head(10))
datetime_end me_fo_consumption predicted_consumption contrib_wind_speed contrib_wind_speed_pct contrib_wave_height contrib_wave_height_pct contrib_current_speed contrib_current_speed_pct contrib_draft_avg contrib_draft_avg_pct contrib_fuel_specific_energy contrib_fuel_specific_energy_pct
0 2025-01-01 00:00:00+00:00 76.32 63.610741 2.259228 3.551646 3.001180 4.718040 -8.663999 -13.620340 18.792921 29.543629 3.724282 5.854801
1 2025-01-01 00:05:00+00:00 76.32 63.813933 2.236507 3.504731 3.013753 4.722720 -8.618769 -13.506093 18.838409 29.520840 3.736179 5.854801
2 2025-01-01 00:10:00+00:00 76.32 64.017413 2.213814 3.458143 3.026335 4.727363 -8.573630 -13.392653 18.883870 29.498021 3.748092 5.854801
3 2025-01-01 00:15:00+00:00 77.04 63.694999 2.176231 3.416643 3.026545 4.751621 -8.489462 -13.328302 18.816144 29.541007 3.729215 5.854801
4 2025-01-01 00:20:00+00:00 77.76 63.373610 2.139124 3.375418 3.026782 4.776092 -8.405844 -13.263950 18.748568 29.584189 3.710399 5.854801
5 2025-01-01 00:25:00+00:00 77.04 63.570553 2.116600 3.329530 3.039202 4.780833 -8.360639 -13.151749 18.792696 29.561952 3.721929 5.854801
6 2025-01-01 00:30:00+00:00 76.32 63.767791 2.094088 3.283927 3.051635 4.785542 -8.315465 -13.040228 18.836813 29.539698 3.733477 5.854801
7 2025-01-01 00:35:00+00:00 76.32 64.029667 2.073332 3.238080 3.065674 4.787896 -8.274878 -12.923506 18.894801 29.509448 3.748810 5.854801
8 2025-01-01 00:40:00+00:00 76.32 64.292049 2.052517 3.192489 3.079714 4.790194 -8.234323 -12.807685 18.952778 29.479194 3.764171 5.854801
9 2025-01-01 00:45:00+00:00 76.32 64.884311 2.040307 3.144531 3.101741 4.780417 -8.217199 -12.664385 19.081362 29.408284 3.798847 5.854801

10 rows × 13 columns

1.4) Data Adjustment 3: Add Fuel Contribution: Hull Degradation

We estimate the impact of hull degradation using vessel-specific model metadata, which defines clean-hull benchmark periods (e.g., post-dry dock). Over time, fouling increases fuel consumption, especially during inactive periods. These effects are isolated separately from other factors to reflect gradual hull performance decay.

# looking up different periods, every period has its own fouling percentage
def get_fouling_periods(imo_number: str, api_key: str) -> pd.DataFrame:
    """Extract fouling periods from performance_over_time (calibrated)."""
    url = f"https://api.toqua.ai/performance/{imo_number}"
    headers = {
        "accept": "application/json",
        "X-API-Key": api_key
    }

    response = requests.get(url, headers=headers, timeout=30)
    if not response.ok:
        raise Exception(f"❌ API Error {response.status_code}: {response.text}")

    perf_data = response.json()
    perf_times = perf_data.get("performance_over_time", {})

    starts = perf_times.get("datetime_start", [])
    ends = perf_times.get("datetime_end", [])
    percentages = perf_times.get("calibrated_excess_fuel_consumption_percentage", [])

    fouling_periods = []
    for start_str, end_str, percent in zip(starts, ends, percentages):
        if start_str and end_str and percent is not None:
            start = pd.to_datetime(start_str)
            end = pd.to_datetime(end_str)
            fouling_pct = percent / 100  # Convert % to fraction

            fouling_periods.append({
                "start": start,
                "end": end,
                "fouling_pct": fouling_pct
            })

    return pd.DataFrame(fouling_periods)


# get fouling periods
periods_df = get_fouling_periods(IMO_NUMBER, API_KEY)
print("📦 Fouling periods found (from performance_over_time):\n", periods_df)

# map fouling effects to each observation
def get_fouling_pct(timestamp):
    """Return fouling percentage for a timestamp."""
    for _, row in periods_df.iterrows():
        if row["start"] <= timestamp <= row["end"]:
            return row["fouling_pct"]
    return 0 

df_results["fouling_pct"] = df_results["datetime_end"].apply(get_fouling_pct)
Fouling periods found (from performance_over_time):
            start                       end            fouling_pct
0 2024-07-26 03:30:00+00:00 2025-03-13 22:45:00+00:00          0.0
1 2025-03-14 00:00:00+00:00 2025-03-21 20:05:00+00:00          0.0
2 2025-03-23 00:55:00+00:00 2025-04-13 01:50:00+00:00          0.0
3 2025-04-29 09:10:00+00:00 2025-04-29 23:30:00+00:00          0.0

Display first 10 rows.

selected_columns = ["datetime_end","me_fo_consumption", "predicted_consumption", "fouling_pct"]
summary_df = df_results[selected_columns].copy()
display(summary_df.head(10))
datetime_end me_fo_consumption predicted_consumption fouling_pct
0 2025-01-01 00:00:00+00:00 76.32 63.610741 0.0
1 2025-01-01 00:05:00+00:00 76.32 63.813933 0.0
2 2025-01-01 00:10:00+00:00 76.32 64.017413 0.0
3 2025-01-01 00:15:00+00:00 77.04 63.694999 0.0
4 2025-01-01 00:20:00+00:00 77.76 63.373610 0.0
5 2025-01-01 00:25:00+00:00 77.04 63.570553 0.0
6 2025-01-01 00:30:00+00:00 76.32 63.767791 0.0
7 2025-01-01 00:35:00+00:00 76.32 64.029667 0.0
8 2025-01-01 00:40:00+00:00 76.32 64.292049 0.0
9 2025-01-01 00:45:00+00:00 76.32 64.884311 0.0

10 rows × 4 columns

1.5) Data Adjustment 4: Apply Fuel Contribution Percentages to Actual Fuel Consumption Rate

To quantify each factor’s contribution in absolute terms, we apply the modeled contribution percentages (calculated as contribution divided by predicted fuel consumption) to the vessel’s actual fuel consumption rate (mt/day). Summing these gives the total explained fuel use. The remaining unexplained portion is labeled as "fuel in calm water", representing baseline consumption under neutral conditions (e.g., calm weather, clean hull).

percent_cols = ["contrib_wind_speed_pct", "contrib_wave_height_pct", "contrib_current_speed_pct", "contrib_draft_avg_pct", "contrib_fuel_specific_energy_pct", "fouling_pct"]

for pct_col in percent_cols:
    abs_col = pct_col.replace("_pct", "")  
    abs_col_full = f"abs_{abs_col}"       
    df_results[abs_col_full] = df_results["me_fo_consumption"] * df_results[pct_col] / 100

abs_contrib_cols = [f"abs_{col.replace('_pct', '')}" for col in percent_cols]

df_results["total_modeled_contrib"] = df_results[abs_contrib_cols].sum(axis=1)
df_results["fuel_calm_water"] = df_results["me_fo_consumption"] - df_results["total_modeled_contrib"]

Display first 10 rows.

selected_columns = ["datetime_end","me_fo_consumption", "predicted_consumption", 
    "abs_contrib_wind_speed", "abs_contrib_wave_height",
    "abs_contrib_current_speed", "abs_contrib_draft_avg",
    "abs_contrib_fuel_specific_energy", "abs_fouling",
    "total_modeled_contrib", "fuel_calm_water"]
summary_df = df_results[selected_columns].copy()
display(summary_df.head(10))
datetime_end me_fo_consumption predicted_consumption abs_contrib_wind_speed abs_contrib_wave_height abs_contrib_current_speed abs_contrib_draft_avg abs_contrib_fuel_specific_energy abs_fouling total_modeled_contrib fuel_calm_water
0 2025-01-01 00:00:00+00:00 76.32 63.610741 2.710616 3.600808 -10.395044 22.547698 4.468384 0.0 22.932462 53.387538
1 2025-01-01 00:05:00+00:00 76.32 63.813933 2.674811 3.604380 -10.307850 22.530305 4.468384 0.0 22.970029 53.349971
2 2025-01-01 00:10:00+00:00 76.32 64.017413 2.639255 3.607923 -10.221273 22.512890 4.468384 0.0 23.007179 53.312821
3 2025-01-01 00:15:00+00:00 77.04 63.694999 2.632182 3.660649 -10.268124 22.758392 4.510539 0.0 23.293637 53.746363
4 2025-01-01 00:20:00+00:00 77.76 63.373610 2.624725 3.713889 -10.314047 23.004665 4.552693 0.0 23.581925 54.178075
5 2025-01-01 00:25:00+00:00 77.04 63.570553 2.565070 3.683154 -10.132107 22.774528 4.510539 0.0 23.401183 53.638817
6 2025-01-01 00:30:00+00:00 76.32 63.767791 2.506293 3.652326 -9.952302 22.544698 4.468384 0.0 23.219398 53.100602
7 2025-01-01 00:35:00+00:00 76.32 64.029667 2.471302 3.654122 -9.863220 22.521610 4.468384 0.0 23.252199 53.067801
8 2025-01-01 00:40:00+00:00 76.32 64.292049 2.436508 3.655876 -9.774825 22.498521 4.468384 0.0 23.284464 53.035536
9 2025-01-01 00:45:00+00:00 76.32 64.884311 2.399906 3.648414 -9.665458 22.444402 4.468384 0.0 23.295648 53.024352

10 rows × 11 columns

1.6) Data Adjustment 5: Convert Daily Fuel Rate (mt/day) to Fuel Consumption

To align the fuel contribution data with the original sensor resolution, we convert daily fuel consumption rates (in mt/day) to 5-minute fuel consumption intervals. This is done by multiplying each rate by the fraction of a day that 5 minutes represents (5 / 1440). This allows for finer-grained time-based analysis and visualizations at the sensor level. It could be possible that the time intervals are different, in this case you can change the 5 minute constant.

#  define your columns with fuel rate (mt/day) 
rate_columns = [
    'fuel_calm_water',
    'abs_contrib_wind_speed',
    'abs_contrib_wave_height',
    'abs_contrib_current_speed',
    'abs_contrib_draft_avg',
    'abs_contrib_fuel_specific_energy',
    'abs_fouling'
]

# convert timestamp to datetime and extract date 
df_results['datetime_end'] = pd.to_datetime(df_results['datetime_end'])
df_results['date'] = df_results['datetime_end'].dt.date

# flatten voyage_id if needed 
df_results['voyage_id'] = df_results['voyage_id'].apply(lambda x: x[0] if isinstance(x, (list, tuple)) else x)

# convert rate columns to 5-minute fuel consumption 
conversion_factor = 5 / 1440  # 5 minutes in fraction of a day
for col in rate_columns:
    df_results[f'{col}_interval'] = df[col] * conversion_factor

Display first 10 rows.

selected_columns = ["datetime_end","me_fo_consumption", "predicted_consumption", 
    "abs_contrib_wind_speed_interval", "abs_contrib_wave_height_interval",
    "abs_contrib_current_speed_interval", "abs_contrib_draft_avg_interval",
    "abs_contrib_fuel_specific_energy_interval", "abs_fouling_interval",
    "fuel_calm_water_interval"]
summary_df = df_results[selected_columns].copy()
display(summary_df.head(10))
datetime_end me_fo_consumption predicted_consumption abs_contrib_wind_speed_interval abs_contrib_wave_height_interval abs_contrib_current_speed_interval abs_contrib_draft_avg_interval abs_contrib_fuel_specific_energy_interval abs_fouling_interval fuel_calm_water_interval
0 2025-01-01 00:00:00+00:00 76.32 63.610741 0.009412 0.012503 -0.036094 0.078291 0.015515 0.0 0.185373
1 2025-01-01 00:05:00+00:00 76.32 63.813933 0.009288 0.012515 -0.035791 0.078230 0.015515 0.0 0.185243
2 2025-01-01 00:10:00+00:00 76.32 64.017413 0.009164 0.012528 -0.035491 0.078170 0.015515 0.0 0.185114
3 2025-01-01 00:15:00+00:00 77.04 63.694999 0.009140 0.012711 -0.035653 0.079022 0.015662 0.0 0.186619
4 2025-01-01 00:20:00+00:00 77.76 63.373610 0.009114 0.012895 -0.035813 0.079877 0.015808 0.0 0.188118
5 2025-01-01 00:25:00+00:00 77.04 63.570553 0.008906 0.012789 -0.035181 0.079078 0.015662 0.0 0.186246
6 2025-01-01 00:30:00+00:00 76.32 63.767791 0.008702 0.012682 -0.034557 0.078280 0.015515 0.0 0.184377
7 2025-01-01 00:35:00+00:00 76.32 64.029667 0.008581 0.012688 -0.034247 0.078200 0.015515 0.0 0.184263
8 2025-01-01 00:40:00+00:00 76.32 64.292049 0.008460 0.012694 -0.033940 0.078120 0.015515 0.0 0.184151
9 2025-01-01 00:45:00+00:00 76.32 64.884311 0.008333 0.012668 -0.033561 0.077932 0.015515 0.0 0.184112

10 rows × 10 columns

Chapter 2: Visualization

2.1) Data Aggregation

To ensure our visualizations are easy to interpret, we choose to work with daily data. Therefore we aggregate the observations.

# determine dominant voyage_id per day 
dominant_voyage = (
    df.groupby(['date', 'voyage_id'])
    .size()
    .reset_index(name='count')
    .sort_values(['date', 'count'], ascending=[True, False])
    .drop_duplicates('date')
    .set_index('date')['voyage_id']
)

# aggregate daily fuel consumption + average SOG 🔧
daily_agg = df.groupby('date').agg({
    **{f'{col}_interval': 'sum' for col in rate_columns},
    'sog': 'mean'  # 🔧 this is the added line
}).reset_index()

# add dominant voyage_id 
daily_agg['voyage_id'] = daily_agg['date'].map(dominant_voyage)

# reorder columns 🔧
final_columns = ['date', 'voyage_id', 'sog'] + [f'{col}_interval' for col in rate_columns]
daily_agg = daily_agg[final_columns]

# rename columns to remove '_interval' suffix for clarity 
daily_agg = daily_agg.rename(columns={f'{col}_interval': col for col in rate_columns})

Display first 10 rows.

display(daily_agg.head(10))
date voyage_id fuel_calm_water abs_contrib_wind_speed abs_contrib_wave_height abs_contrib_current_speed abs_contrib_draft_avg abs_contrib_fuel_specific_energy abs_fouling
0 2025-01-01 105.0 46.227939 2.036099 4.937362 -4.748823 22.728256 4.426669 0.0
1 2025-01-02 105.0 46.054673 1.318579 3.326746 -2.053326 21.573877 4.366950 0.0
2 2025-01-03 105.0 37.900553 1.945329 1.202183 -4.272410 16.044509 3.284836 0.0
3 2025-01-04 105.0 28.021021 0.406430 0.443879 -3.501153 11.956041 2.321282 0.0
4 2025-01-05 105.0 38.544299 0.791945 0.123574 -5.080655 11.343402 2.794936 0.0
5 2025-01-06 105.0 33.927465 3.583168 3.694088 3.228230 17.629800 3.772248 0.0
6 2025-01-07 105.0 43.288161 3.202408 6.709829 -2.490501 20.367375 4.420228 0.0
7 2025-01-08 105.0 56.462922 1.598441 4.885300 -13.868701 21.425023 4.384514 0.0
8 2025-01-09 105.0 39.684568 2.427397 5.845581 -5.125130 18.884470 3.838115 0.0
9 2025-01-10 105.0 35.093584 2.807922 16.738849 -6.186009 21.093066 4.325088 0.0

10 rows × 9 columns

2.2) Select Voyage ID

Below is a list of the different voyages undertaken by this vessel.

# list of unique voyage_id
unique_voyage_ids = list(daily_agg['voyage_id'].unique())
print(unique_voyage_ids)
['voyage_13', 'voyage_14', 'voyage_15', 'voyage_17', 'voyage_0', 'voyage_1', 'voyage_2', 'voyage_3']

Select the voyage you would like to visualize.

selected_voyage_id = 'voyage_13'

This section displays the aggregated data corresponding to the selected voyage.

df_voyage = daily_agg[daily_agg['voyage_id'] == selected_voyage_id].copy()

Display 10 first rows.

display(df_voyage.head(10))
date voyage_id fuel_calm_water abs_contrib_wind_speed abs_contrib_wave_height abs_contrib_current_speed abs_contrib_draft_avg abs_contrib_fuel_specific_energy abs_fouling
213 2025-01-01 105.0 46.227937 2.036099 4.937362 -4.748823 22.728256 4.426669 0.0
214 2025-01-02 105.0 46.054673 1.318580 3.326747 -2.053326 21.573877 4.366950 0.0
215 2025-01-03 105.0 37.900552 1.945330 1.202183 -4.272410 16.044509 3.284836 0.0
216 2025-01-04 105.0 28.021020 0.406430 0.443879 -3.501153 11.956041 2.321282 0.0
217 2025-01-05 105.0 38.544298 0.791946 0.123574 -5.080655 11.343402 2.794936 0.0
218 2025-01-06 105.0 33.927465 3.583168 3.694089 3.228230 17.629799 3.772248 0.0
219 2025-01-07 105.0 43.288161 3.202408 6.709829 -2.490502 20.367375 4.420228 0.0
220 2025-01-08 105.0 56.462923 1.598441 4.885300 -13.868701 21.425023 4.384514 0.0
221 2025-01-09 105.0 39.684567 2.427397 5.845581 -5.125129 18.884469 3.838115 0.0
222 2025-01-10 105.0 35.093582 2.807923 16.738850 -6.186009 21.093066 4.325088 0.0

10 rows × 9 columns

This block prepares the fuel consumption data by selecting relevant components, converting the date column to datetime format, and setting it as the index.

fuel_components = [
    'fuel_calm_water',
    'abs_contrib_wind_speed',
    'abs_contrib_wave_height',
    'abs_contrib_current_speed',
    'abs_contrib_draft_avg',
    'abs_contrib_fuel_specific_energy',
    'abs_fouling'
] 
df_voyage['date'] = pd.to_datetime(df_voyage['date']) 
df_voyage.set_index('date', inplace=True)

2.3) Standard Visual: Voyage-leg Fuel Breakdown

This plot summarizes the total contribution of each factor over the full voyage using a horizontal bar chart. Labels show both absolute values and percentage shares.

from plotly.subplots import make_subplots
import plotly.graph_objects as go

# Define colors for consistency
component_colors = {
    'fuel_calm_water': '#4c78a8',
    'abs_contrib_wind_speed': '#f58518',
    'abs_contrib_wave_height': '#54a24b',
    'abs_contrib_current_speed': '#b79a20',
    'abs_contrib_draft_avg': '#e45756',
    'abs_contrib_fuel_specific_energy': '#72b7b2',
    'abs_fouling': '#9d7de5'
}

# Extract draft_mode and avg_draft_avg for the selected voyage
draft_info = avg_draft_per_voyage.loc[avg_draft_per_voyage["voyage_id"] == selected_voyage_id]

if not draft_info.empty:
    draft_mode = draft_info.iloc[0]["draft_mode"]
    draft_ref_value = (ballast_draft if draft_mode == "ballast" else laden_draft)
    draft_label = ("Draft<br>"f"<span style='font-size:11px; color:gray'>(ref: {draft_ref_value})</span>")
else:
    draft_label = "Draft"

# Reference lcv 
lcv_label = ("Fuel Energy Density <br>"f"<span style='font-size:11px; color:gray'>(ref: 41.6 MJ/kg)</span>")

# Get benchmark info
model_url = f"https://api.toqua.ai/ships/{IMO_NUMBER}/models/latest/metadata"
model_response = requests.get(model_url, headers=headers)
benchmark_label = "Hull Degradation"

if model_response.ok:
    model_info = model_response.json()
    periods = model_info.get("periods", [])
    bm = next((p for p in periods if p.get("type") == "benchmark"), None)
    
    if bm and bm.get("start_date") and bm.get("end_date"):
        start = bm["start_date"][:10]
        end = bm["end_date"][:10]
        benchmark_label = ("Hull Degradation<br>"f"<span style='font-size:11px; color:gray'>(ref: {start} → {end})</span>")
else:
    raise Exception(f"❌ API Error {model_response.status_code}: {model_response.text}")

# Set final legend labels
legend_labels = {
    'fuel_calm_water': 'Calm Water',
    'abs_contrib_wind_speed': 'Wind',
    'abs_contrib_wave_height': 'Waves',
    'abs_contrib_current_speed': 'Currents',
    'abs_contrib_draft_avg': draft_label,
    'abs_contrib_fuel_specific_energy': lcv_label,
    'abs_fouling': benchmark_label
}

component_totals = df_voyage[fuel_components].sum()

# Ensure calm water is first
calm_water = ['fuel_calm_water']
weather_order = ['abs_contrib_wind_speed', 'abs_contrib_wave_height', 'abs_contrib_current_speed']

# Identify zero and non-zero components
zero_components = component_totals[component_totals == 0].index.tolist()
non_zero_components = [c for c in component_totals.index if c not in zero_components]

# Extract non-zero components by categories
non_zero_calm = [c for c in calm_water if c in non_zero_components]
non_zero_weather = [c for c in weather_order if c in non_zero_components]
non_zero_others = [c for c in non_zero_components if c not in non_zero_calm + non_zero_weather]

# Final order: Calm Water → Weather → Other non-zero → Zero components
ordered_components = (
    non_zero_calm +
    non_zero_weather +
    non_zero_others +
    [c for c in zero_components if c not in non_zero_calm + non_zero_weather + non_zero_others]
)

total_sum = component_totals.sum()

weather_components = ['abs_contrib_wind_speed', 'abs_contrib_wave_height', 'abs_contrib_current_speed']
weather_total = component_totals.loc[component_totals.index.intersection(weather_components)].sum()
weather_pct = (weather_total / total_sum) * 100 if total_sum else 0

fig = make_subplots(
    rows=1, cols=1,
    specs=[[{"type": "bar"}]]
)

# Plot: Horizontal breakdown
y_labels = []
y_vals = []

for i, col in enumerate(component_totals.index):
    label = legend_labels.get(col, col)
    color = component_colors.get(col, '#999999')
    value = component_totals[col]
    pct = (value / total_sum) * 100
    text = f"{value:.1f} mt<br>({pct:.1f}%)"

    # Styled label with exact color
    styled_label = label  # plain default color
    y_labels.append(styled_label)
    y_vals.append(i)
    
    fig.add_trace(go.Bar(
        x=[value],
        y=[i],
        orientation='h',
        marker_color=color,
        text=None,  # we'll handle text ourselves
        hoverinfo='skip',
        showlegend=False,
        cliponaxis=False
    ))

    fig.add_annotation(
        x=max(value, 0) + 5,  # offset to the right of the bar end
        y=i,
        text=text,
        showarrow=False,
        font=dict(size=11, color="black"),
        xanchor='left',
        yanchor='middle'
    )

    annotation_x = max(value, 0) + 5  # ensures even 0 or negative bars show label on the right

    fig.add_annotation(
        x=annotation_x,
        y=i,
        text=text,
        showarrow=False,
        font=dict(size=11, color="black"),
        xanchor='left',
        yanchor='middle'
    )
    
weather_indices = [i for i, col in enumerate(component_totals.index) if col in weather_components]
if weather_indices:
    y_top = max(weather_indices) +0.5
    y_bottom = min(weather_indices) -0.5
    y_mid = (y_top + y_bottom) / 2

# Layout and styling
total_days = df_voyage.index.nunique()
total_fuel = df_voyage[fuel_components].sum(axis=1).sum()
avg_fuel_per_day = total_fuel / total_days if total_days else 0
avg_speed = df_voyage['sog'].mean()  

fig.update_layout(
    barmode='relative',
    height=500,
    width=1000,
    template='plotly_white',
    title=dict(
        text=(
            f'Voyage-Leg Fuel Breakdown<br>'
            f'<span style="font-size:12px; color:gray">'
            f'Voyage Leg ID: <b>{selected_voyage_id}</b> &nbsp;&nbsp;|&nbsp;&nbsp; '
            f'Days at Sea: <b>{total_days}</b> &nbsp;&nbsp;|&nbsp;&nbsp; '
            f'Total Fuel Consumption: <b>{total_fuel:.1f} metric tons</b> &nbsp;&nbsp;|&nbsp;&nbsp; '
            f'Average ME Speed-Cons: <b>{avg_fuel_per_day:.1f} mt/day</b> at <b>{avg_speed:.1f} knots</b>'
            f'</span>'
        ),
        x=0.5,
        xanchor='center'
    ),
    xaxis_title='Total Contribution (metric tons)',
    
    yaxis=dict(tickmode='array',tickvals=y_vals,ticktext=y_labels,automargin=True, autorange ='reversed'),
    margin=dict(t=100, l=180, r=30, b=30),
    legend=dict(orientation='v',yanchor='top',y=1,xanchor='left',x=-0.15)
)

# Positioning logic 
weather_bar_max = max([component_totals.get(c, 0) for c in weather_components])
x_offset = weather_bar_max + 120  # further to the right
x_text = x_offset + 5

# Draw brace-like accolade using path
cap_length = 25  # horizontal line length
line_color = "gray"
line_width = 2

# Vertical line
fig.add_shape(
    type="line",
    xref="x", yref="y",
    x0=x_offset, x1=x_offset,
    y0=y_bottom, y1=y_top,
    line=dict(color=line_color, width=line_width)
)

# Top cap
fig.add_shape(
    type="line",
    xref="x", yref="y",
    x0=x_offset, x1=x_offset - cap_length,
    y0=y_top, y1=y_top,
    line=dict(color=line_color, width=line_width)
)

# Bottom cap
fig.add_shape(
    type="line",
    xref="x", yref="y",
    x0=x_offset, x1=x_offset - cap_length,
    y0=y_bottom, y1=y_bottom,
    line=dict(color=line_color, width=line_width)
)

# Add nicely formatted label 
weather_text = f"Total Weather Margin: {weather_pct:.1f}%"
fig.add_annotation(
    x=x_text,
    y=y_mid,
    xref='x',
    yref='y',
    text=weather_text,
    showarrow=False,
    align='left',
    font=dict(size=12, color='black'),
    xanchor='left',
    yanchor='middle',
    bordercolor='rgba(0,0,0,0.05)',
    borderwidth=0
)

fig.show()

2.4) Advanced Visual: Daily Fuel Breakdown

This plot displays daily fuel consumption as a stacked bar chart, starting with calm water consumption at the base and layering the effects of operational and environmental conditions on top. A dashed line and numeric labels indicate total daily fuel consumption.

# advanced visual 

from plotly.subplots import make_subplots
import plotly.graph_objects as go

# --- Colors for consistency ---
component_colors = {
    'fuel_calm_water': '#4c78a8',
    'abs_contrib_wind_speed': '#f58518',
    'abs_contrib_wave_height': '#54a24b',
    'abs_contrib_current_speed': '#b79a20',
    'abs_contrib_draft_avg': '#e45756',
    'abs_contrib_fuel_specific_energy': '#72b7b2',
    'abs_fouling': '#9d7de5'
}

# --- Legend labels with references ---
draft_info = avg_draft_per_voyage.loc[avg_draft_per_voyage["voyage_id"] == selected_voyage_id]

if not draft_info.empty:
    draft_mode = draft_info.iloc[0]["draft_mode"]
    draft_ref_value = (
        ballast_draft
        if draft_mode == "ballast"
        else round(draft_info.iloc[0]["avg_draft_avg"], 1)
    )

    draft_label = (
        "Draft<br>"
        f"<span style='font-size:11px; color:gray'>(ref: {draft_ref_value})</span>"
    )
else:
    draft_label = "Draft"


ballast_draft = ship_info.get("ballast_draft")
lcv_label = "Fuel Energy Density <br><span style='font-size:11px; color:gray'>(ref: 41.6 MJ/kg)</span>"

# Get benchmark info
model_url = f"https://api.toqua.ai/ships/{IMO_NUMBER}/models/latest/metadata"
model_response = requests.get(model_url, headers=headers)
benchmark_label = "Hull Degradation"

if model_response.ok:
    model_info = model_response.json()
    periods = model_info.get("periods", [])
    bm = next((p for p in periods if p.get("type") == "benchmark"), None)
    
    if bm and bm.get("start_date") and bm.get("end_date"):
        start = bm["start_date"][:10]
        end = bm["end_date"][:10]
        benchmark_label = ("Hull Degradation<br>"
                           f"<span style='font-size:11px; color:gray'>(ref: {start} → {end})</span>"
        )
else:
    raise Exception(f"❌ API Error {model_response.status_code}: {model_response.text}")

legend_labels = {
    'fuel_calm_water': 'Calm Water',
    'abs_contrib_wind_speed': 'Wind',
    'abs_contrib_wave_height': 'Waves',
    'abs_contrib_current_speed': 'Currents',
    'abs_contrib_draft_avg': draft_label,
    'abs_contrib_fuel_specific_energy': lcv_label,
    'abs_fouling': benchmark_label
}

fig = make_subplots(
    rows=1, cols=1,
    subplot_titles=("Fuel Consumption Over Time",),
    specs=[[{"type": "bar"}]]
)
plot_order = fuel_components[::-1]
base_component = 'fuel_calm_water'
adjustment_components = [col for col in plot_order if col != base_component]
stacked_columns = [base_component] + adjustment_components
df_voyage['total_visible_bar'] = df_voyage[stacked_columns].sum(axis=1)

# -- Plot base (calm water) --
fig.add_trace(go.Bar(
    x=df_voyage.index,
    y=df_voyage[base_component],
    name=legend_labels.get(base_component, base_component),
    marker_color=component_colors.get(base_component, '#999999'),
    hoverinfo='x+y+name'
), row=1, col=1)

# -- Plot adjustment components stacked --
for col in adjustment_components:
    fig.add_trace(go.Bar(
        x=df_voyage.index,
        y=df_voyage[col],
        name=legend_labels.get(col, col),
        marker_color=component_colors.get(col, '#999999'),
        hoverinfo='x+y+name'
    ), row=1, col=1)

# -- Total fuel dashed line --
fig.add_trace(go.Scatter(
    x=df_voyage.index,
    y=df_voyage['total_visible_bar'],
    mode='lines+markers',
    name='Total Fuel',
    line=dict(color='black', width=2, dash='dash'),
    marker=dict(size=6),
    hoverinfo='x+y'
), row=1, col=1)

df_voyage['bar_top'] = (df_voyage[base_component] + df_voyage[adjustment_components].clip(lower=0).sum(axis=1))

# -- Text annotations above bars --
fig.add_trace(go.Scatter(
    x=df_voyage.index,
    y=df_voyage['bar_top'] + 0.5,
    mode='text',
    text=[f"{val:.1f} mt" for val in df_voyage['total_visible_bar']],
    textposition='top center',
    textfont=dict(size=11, color='black', family='Arial Black'),
    showlegend=False,
    hoverinfo='skip'
), row=1, col=1)

# -- Layout --
avg_fuel_per_day = total_fuel / total_days if total_days else 0
avg_speed = df_voyage['sog'].mean()  
fig.update_layout(
    barmode='relative',
    height=500,
    width=1200,
    template='plotly_white',
    title=dict(
        text=(
            f'Voyage-leg Fuel Breakdown<br>'
            f'<span style="font-size:12px; color:gray">'
            f'Voyage Leg ID: <b>{selected_voyage_id}</b> &nbsp;&nbsp;|&nbsp;&nbsp; '
            f'Days at Sea: <b>{total_days}</b> &nbsp;&nbsp;|&nbsp;&nbsp; '
            f'Total Fuel Consumption: <b>{total_fuel:.1f} metric tons</b> &nbsp;&nbsp;|&nbsp;&nbsp; '
            f'Average Speed-Cons: <b>{avg_fuel_per_day:.1f} mt/day</b> at <b>{avg_speed:.1f} knots</b>'
            f'</span>'),
        x=0.5,
        xanchor='center'
    ),
    legend_title=None,
    legend=dict(orientation='h',yanchor='bottom',y=-0.45,xanchor='center',x=0.5,font=dict(size=12),title=None),
    xaxis_title='Date',
    yaxis_title='Fuel Consumption (metric_tons/day)',
    margin=dict(t=100, l=30, r=30, b=20)
)

fig.show()

2.5) Combined Standard & Advanced Visuals

The code below generates a two-panel visualization of fuel consumption decomposition for a specific vessel voyage, using Plotly. It first assigns consistent colors and human-readable labels to each fuel component (e.g., wind, waves, current). These labels are dynamically enhanced with metadata from Toqua’s API:

from plotly.subplots import make_subplots
import plotly.graph_objects as go

# -------------------------------
# Define colors for consistency
# -------------------------------
component_colors = {
    'fuel_calm_water': '#4c78a8',
    'abs_contrib_wind_speed': '#f58518',
    'abs_contrib_wave_height': '#54a24b',
    'abs_contrib_current_speed': '#b79a20',
    'abs_contrib_draft_avg': '#e45756',
    'abs_contrib_fuel_specific_energy': '#72b7b2',
    'abs_fouling': '#9d7de5'
}

# Reference draft
draft_info = avg_draft_per_voyage.loc[avg_draft_per_voyage["voyage_id"] == selected_voyage_id]

if not draft_info.empty:
    draft_mode = draft_info.iloc[0]["draft_mode"]
    draft_ref_value = (
        ballast_draft
        if draft_mode == "ballast"
        else round(draft_info.iloc[0]["avg_draft_avg"], 1)
    )

    draft_label = (
        "Draft<br>"
        f"<span style='font-size:11px; color:gray'>(ref: {draft_ref_value})</span>"
    )
else:
    draft_label = "Draft"


# Get benchmark info
model_url = f"https://api.toqua.ai/ships/{IMO_NUMBER}/models/latest/metadata"
model_response = requests.get(model_url, headers=headers)
benchmark_label = "Hull Degradation"

if model_response.ok:
    model_info = model_response.json()
    periods = model_info.get("periods", [])
    bm = next((p for p in periods if p.get("type") == "benchmark"), None)
    
    if bm and bm.get("start_date") and bm.get("end_date"):
        start = bm["start_date"][:10]
        end = bm["end_date"][:10]
        benchmark_label = f"Hull Degradation (ref: {start} → {end})"
else:
    raise Exception(f"❌ API Error {model_response.status_code}: {model_response.text}")

# reference lcv
lcv_label = "Fuel Energy Density <br><span style='font-size:11px; color:gray'>(ref: 41.6 MJ/kg)</span>"


# Set final legend labels
legend_labels = {
    'fuel_calm_water': 'Calm Water',
    'abs_contrib_wind_speed': 'Wind',
    'abs_contrib_wave_height': 'Waves',
    'abs_contrib_current_speed': 'Current',
    'abs_contrib_draft_avg': draft_label,
    'abs_contrib_fuel_specific_energy': lcv_label,
    'abs_fouling': benchmark_label
}

plot_order = fuel_components[::-1]  # bottom to top stack

# -------------------------------
# Split data into + and - parts
# -------------------------------
df_pos = df_voyage[plot_order].clip(lower=0)
df_neg = df_voyage[plot_order].clip(upper=0)
component_totals = df_voyage[fuel_components].sum().sort_values()

# -------------------------------
# Create subplot layout
# -------------------------------
fig = make_subplots(
    rows=1, cols=2,
    column_widths=[0.55, 0.45],
    subplot_titles=("Fuel Consumption Over Time", "Contribution by Factor"),
    specs=[[{"type": "bar"}, {"type": "bar"}]]
)

# -------------------------------
# Left plot: Stacked bar over time
# -------------------------------
base_component = 'fuel_calm_water'
adjustment_components = [col for col in plot_order if col != base_component]
stacked_columns = [base_component] + adjustment_components
df_voyage['total_visible_bar'] = df_voyage[stacked_columns].sum(axis=1)

# Plot calm water baseline
fig.add_trace(go.Bar(
    x=df_voyage.index,
    y=df_voyage[base_component],
    name=legend_labels.get(base_component, base_component),
    marker_color=component_colors.get(base_component, '#999999'),
    hoverinfo='x+y+name'
), row=1, col=1)

# Plot all adjustment components stacked on top
for col in adjustment_components:
    fig.add_trace(go.Bar(
        x=df_voyage.index,
        y=df_voyage[col],
        name=legend_labels.get(col, col),
        marker_color=component_colors.get(col, '#999999'),
        hoverinfo='x+y+name'
    ), row=1, col=1)

# Compute total bar height for each day
df_voyage['bar_top'] = (df_voyage[base_component] + df_voyage[adjustment_components].clip(lower=0).sum(axis=1))

# Add dashed black line for total fuel
fig.add_trace(go.Scatter(
    x=df_voyage.index,
    y=df_voyage['total_visible_bar'],
    mode='lines+markers',
    name='Total Fuel',
    line=dict(color='black', width=2, dash='dash'),
    marker=dict(size=6),
    hoverinfo='x+y'
), row=1, col=1)

# Add total fuel value as text just above the bar
fig.add_trace(go.Scatter(
    x=df_voyage.index,
    y=df_voyage['bar_top'] + 0.5,  # minimal lift for readability
    mode='text',
    text=[f"{val:.1f} mt" for val in df_voyage['total_visible_bar']],
    textposition='top center',
    textfont=dict(
        size=11,
        color='black',
        family='Arial Black'
    ),
    showlegend=False,
    hoverinfo='skip'
), row=1, col=1)

# -------------------------------
# Right plot: Horizontal breakdown with ordering + annotation
# -------------------------------
component_totals = df_voyage[fuel_components].sum()

# Reorder: Calm Water → Weather → Other → Zero
calm_water = ['fuel_calm_water']
weather_order = ['abs_contrib_wind_speed', 'abs_contrib_wave_height', 'abs_contrib_current_speed']
zero_components = component_totals[component_totals == 0].index.tolist()
non_zero_components = [c for c in component_totals.index if c not in zero_components]
non_zero_calm = [c for c in calm_water if c in non_zero_components]
non_zero_weather = [c for c in weather_order if c in non_zero_components]
non_zero_others = [c for c in non_zero_components if c not in non_zero_calm + non_zero_weather]

ordered_components = (
    non_zero_calm +
    non_zero_weather +
    non_zero_others +
    [c for c in zero_components if c not in non_zero_calm + non_zero_weather + non_zero_others]
)

component_totals = component_totals.loc[ordered_components]
total_sum = component_totals.sum()

# Build bar + labels
y_labels = [legend_labels.get(col, col) for col in ordered_components]
y_vals = list(range(len(ordered_components)))

for i, col in enumerate(ordered_components):
    value = component_totals[col]
    pct = (value / total_sum) * 100 if total_sum else 0
    text = f"{value:.1f} mt<br>({pct:.1f}%)"

    fig.add_trace(go.Bar(
        x=[value],
        y=[i],
        orientation='h',
        marker_color=component_colors.get(col, '#999999'),
        text=None,
        hoverinfo='skip',
        showlegend=False,
        cliponaxis=False
    ), row=1, col=2)

    fig.add_annotation(
        x=max(value, 0) + 5,
        y=i,
        text=text,
        showarrow=False,
        font=dict(size=11, color="black"),
        xref='x2',
        yref='y2',
        xanchor='left',
        yanchor='middle',
        row=1, col=2
    )

# Add weather group brace
weather_components = weather_order
weather_indices = [i for i, col in enumerate(ordered_components) if col in weather_components]
if weather_indices:
    y_top = max(weather_indices) + 0.5
    y_bottom = min(weather_indices) - 0.5
    y_mid = (y_top + y_bottom) / 2
    weather_bar_max = max([component_totals.get(c, 0) for c in weather_components])
    x_offset = weather_bar_max + 120
    x_text = x_offset + 5

    cap_length = 25
    line_color = "gray"
    line_width = 2
    weather_pct = (component_totals[weather_components].sum() / total_sum) * 100 if total_sum else 0

    fig.add_shape(type="line", xref="x2", yref="y2",
                  x0=x_offset, x1=x_offset,
                  y0=y_bottom, y1=y_top,
                  line=dict(color=line_color, width=line_width),
                  row=1, col=2)

    fig.add_shape(type="line", xref="x2", yref="y2",
                  x0=x_offset, x1=x_offset - cap_length,
                  y0=y_top, y1=y_top,
                  line=dict(color=line_color, width=line_width),
                  row=1, col=2)

    fig.add_shape(type="line", xref="x2", yref="y2",
                  x0=x_offset, x1=x_offset - cap_length,
                  y0=y_bottom, y1=y_bottom,
                  line=dict(color=line_color, width=line_width),
                  row=1, col=2)

    fig.add_annotation(
        x=x_text,
        y=y_mid,
        xref='x2',
        yref='y2',
        text=f"Total Weather Margin: {weather_pct:.1f}%",
        showarrow=False,
        align='left',
        font=dict(size=12, color='black'),
        xanchor='left',
        yanchor='middle',
        row=1, col=2
    )

# Fix axis to reverse so Calm Water appears first
fig.update_yaxes(
    tickmode='array',
    tickvals=y_vals,
    ticktext=y_labels,
    automargin=True,
    autorange='reversed',
    row=1, col=2
)

# -------------------------------
# Layout and styling
# -------------------------------
total_days = df_voyage.index.nunique()
total_fuel = df_voyage['total_visible_bar'].sum()

fig.update_layout(
    barmode='relative',
    height=500,
    width=1650,
    template='plotly_white',
    title= dict(
                text=f'Voyage-Leg Fuel Contribution Breakdown Over Time<br>' 
                     f'<span style="font-size:12px; color:gray">'
                     f'Voyage Leg ID: <b>{selected_voyage_id}</b> &nbsp;&nbsp;|&nbsp;&nbsp; '
                     f'Days at Sea: <b>{total_days}</b> &nbsp;&nbsp;|&nbsp;&nbsp; '
                     f'Total Fuel Consumption: <b>{total_fuel:.1f} metric tons</b>'
                     f'</span>',
                x=0.5,
                xanchor='center'
    ),
    legend_title= None,
    legend=dict(orientation='h',yanchor='bottom', y=-0.45, xanchor='center', x=0.5),
    xaxis_title='Date',
    yaxis_title='Fuel Consumption (metric_tons/day)',
    xaxis2=dict(title='Total Contribution (metric_tons)', showgrid=True, zeroline=True, tickformat=".0f"),
    yaxis2=dict(automargin=True, showticklabels=False),
    margin=dict(t=100, l=30, r=30, b=10)
)

fig.show()