vortexa-claude-skills 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/VERSION +1 -0
- package/bin/.gitkeep +0 -0
- package/bin/setup.js +302 -0
- package/commands/vortexa/_check-setup.md +9 -0
- package/commands/vortexa/_skill-template.md +100 -0
- package/commands/vortexa/breakdown.md +294 -0
- package/commands/vortexa/cargo-flows.md +247 -0
- package/commands/vortexa/compare.md +315 -0
- package/commands/vortexa/custom.md +214 -0
- package/commands/vortexa/explain.md +124 -0
- package/commands/vortexa/init.md +133 -0
- package/commands/vortexa/oow.md +189 -0
- package/commands/vortexa/seasonal.md +185 -0
- package/commands/vortexa/voyages.md +285 -0
- package/context/.gitkeep +0 -0
- package/context/cargo-movements.md +738 -0
- package/context/date-units.md +188 -0
- package/context/endpoint-template.md +176 -0
- package/context/entity-resolution.md +217 -0
- package/context/guardrails.md +161 -0
- package/context/reference-endpoints.md +651 -0
- package/context/voyages.md +636 -0
- package/lib/__init__.py +4 -0
- package/lib/aliases.json +52 -0
- package/lib/api.py +20 -0
- package/lib/entities.py +254 -0
- package/lib/inventory.py +140 -0
- package/lib/movements.py +242 -0
- package/lib/requirements.txt +6 -0
- package/lib/seasonal.py +200 -0
- package/lib/timeseries.py +271 -0
- package/lib/utils.py +120 -0
- package/lib/vessels.py +192 -0
- package/lib/visualization.py +164 -0
- package/lib/voyages.py +236 -0
- package/package.json +28 -0
- package/templates/.env.template +3 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Plotly chart builders with Vortexa brand theme.
|
|
2
|
+
|
|
3
|
+
Theme auto-registers on import. All chart functions accept DataFrames
|
|
4
|
+
and return go.Figure objects without calling the Vortexa API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import plotly.graph_objects as go
|
|
8
|
+
import plotly.io as pio
|
|
9
|
+
import plotly.express as px
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# -- Vortexa dark theme --------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
vortexa_colors = [
|
|
15
|
+
'#0090B9', '#A1F3FF', '#A891F4', '#A2DB40', '#0E534D',
|
|
16
|
+
'#20E1C7', '#FFA1B7', '#6B32CF', '#518FE0', '#F4C160',
|
|
17
|
+
'#B07E1E', '#124E67', '#FF6B90', '#850E9A', '#2F4F9E',
|
|
18
|
+
'#72ADE8', '#EB8F4C', '#D7581F', '#8B1043', '#4A0055',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
pio.templates["vortexa_dark"] = go.layout.Template(
|
|
22
|
+
layout={
|
|
23
|
+
'colorway': vortexa_colors,
|
|
24
|
+
'paper_bgcolor': '#0f172a',
|
|
25
|
+
'plot_bgcolor': '#1e293b',
|
|
26
|
+
'font': {'family': 'Arial, Helvetica, sans-serif', 'size': 13, 'color': '#e2e8f0'},
|
|
27
|
+
'title': {'font': {'size': 18, 'color': '#ffffff'}},
|
|
28
|
+
'xaxis': {'gridcolor': '#334155', 'linecolor': '#475569', 'zerolinecolor': '#475569'},
|
|
29
|
+
'yaxis': {'gridcolor': '#334155', 'linecolor': '#475569', 'zerolinecolor': '#475569'},
|
|
30
|
+
'hovermode': 'x unified',
|
|
31
|
+
'legend': {'bgcolor': 'rgba(0,0,0,0)', 'font': {'color': '#e2e8f0'}},
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
pio.templates.default = "vortexa_dark"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# -- Chart functions -----------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def seasonal_chart(data, title="Seasonal Pattern", y_label="Volume"):
|
|
40
|
+
"""Multi-year seasonal overlay with min/max band, average, last year, current year.
|
|
41
|
+
|
|
42
|
+
Accepts the DataFrame from seasonal_charts() in lib/seasonal.py.
|
|
43
|
+
Columns: Date, Min., Range Y1-Y2, Average Y1-Y2, Last year, Current year.
|
|
44
|
+
"""
|
|
45
|
+
df = data.copy()
|
|
46
|
+
cols = list(df.columns)
|
|
47
|
+
|
|
48
|
+
fig = go.Figure()
|
|
49
|
+
|
|
50
|
+
# Invisible min trace as band baseline
|
|
51
|
+
fig.add_trace(go.Scatter(
|
|
52
|
+
x=df[cols[0]], y=df["Min."], mode="lines",
|
|
53
|
+
line=dict(width=0), showlegend=False, hoverinfo="skip",
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
# Range band filled to min trace
|
|
57
|
+
range_col = cols[1]
|
|
58
|
+
fig.add_trace(go.Scatter(
|
|
59
|
+
x=df[cols[0]], y=df["Min."] + df[range_col],
|
|
60
|
+
fill="tonexty", fillcolor="rgba(0,144,185,0.15)",
|
|
61
|
+
line=dict(width=0), name=range_col,
|
|
62
|
+
))
|
|
63
|
+
|
|
64
|
+
# Average line
|
|
65
|
+
avg_col = cols[2]
|
|
66
|
+
fig.add_trace(go.Scatter(
|
|
67
|
+
x=df[cols[0]], y=df[avg_col], mode="lines",
|
|
68
|
+
name=avg_col, line=dict(color=vortexa_colors[0], width=2),
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
# Last year dashed
|
|
72
|
+
if "Last year" in df.columns:
|
|
73
|
+
fig.add_trace(go.Scatter(
|
|
74
|
+
x=df[cols[0]], y=df["Last year"], mode="lines",
|
|
75
|
+
name="Last year", line=dict(color=vortexa_colors[9], dash="dash", width=2),
|
|
76
|
+
))
|
|
77
|
+
|
|
78
|
+
# Current year (forward-looking -- filter out empty values)
|
|
79
|
+
if "Current year" in df.columns:
|
|
80
|
+
cy = df[df["Current year"] != ""].copy()
|
|
81
|
+
if not cy.empty:
|
|
82
|
+
fig.add_trace(go.Scatter(
|
|
83
|
+
x=cy[cols[0]], y=cy["Current year"], mode="lines",
|
|
84
|
+
name="Current year",
|
|
85
|
+
line=dict(color=vortexa_colors[5], width=3),
|
|
86
|
+
connectgaps=False,
|
|
87
|
+
))
|
|
88
|
+
|
|
89
|
+
fig.update_layout(title=title, yaxis_title=y_label)
|
|
90
|
+
return fig
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def oow_area_chart(ts_wide, freq="daily", top_n=8, title=None):
|
|
94
|
+
"""Stacked area chart for cargo-on-water by location.
|
|
95
|
+
|
|
96
|
+
Accepts wide DataFrame from cargo_on_water_ts() in lib/movements.py
|
|
97
|
+
(date index, location columns, optional Total column).
|
|
98
|
+
"""
|
|
99
|
+
ts = ts_wide.copy()
|
|
100
|
+
|
|
101
|
+
if "Total" in ts.columns:
|
|
102
|
+
ts = ts.drop(columns=["Total"])
|
|
103
|
+
|
|
104
|
+
freq_map = {"weekly": "W-SUN", "monthly": "MS"}
|
|
105
|
+
if freq.lower() in freq_map:
|
|
106
|
+
ts = ts.resample(freq_map[freq.lower()]).sum()
|
|
107
|
+
|
|
108
|
+
from lib.utils import top_n_with_other
|
|
109
|
+
ts = top_n_with_other(ts, n=top_n)
|
|
110
|
+
|
|
111
|
+
plot_df = ts.reset_index().rename(columns={ts.index.name or "index": "date"})
|
|
112
|
+
ycols = [c for c in plot_df.columns if c != "date"]
|
|
113
|
+
|
|
114
|
+
fig = px.area(
|
|
115
|
+
plot_df, x="date", y=ycols,
|
|
116
|
+
title=title or f"Cargo on Water by Region (Top {top_n})",
|
|
117
|
+
template="vortexa_dark",
|
|
118
|
+
)
|
|
119
|
+
fig.update_traces(stackgroup="one")
|
|
120
|
+
fig.update_layout(xaxis_rangeslider_visible=True)
|
|
121
|
+
return fig
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def flow_split_bars(plot_df, title="Flow Split", y_label="Volume"):
|
|
125
|
+
"""Stacked bar chart for breakdown results.
|
|
126
|
+
|
|
127
|
+
Accepts wide DataFrame from flows_time_series_split() in lib/timeseries.py
|
|
128
|
+
(date index or column, category columns, optional Total column).
|
|
129
|
+
"""
|
|
130
|
+
df = plot_df.copy()
|
|
131
|
+
|
|
132
|
+
# Detect and normalise date column
|
|
133
|
+
if "date" not in df.columns and "Date" not in df.columns:
|
|
134
|
+
df = df.reset_index()
|
|
135
|
+
if "Date" in df.columns and "date" not in df.columns:
|
|
136
|
+
df = df.rename(columns={"Date": "date"})
|
|
137
|
+
|
|
138
|
+
cols = [c for c in df.columns if c not in ("date", "Date", "Total")]
|
|
139
|
+
|
|
140
|
+
fig = px.bar(
|
|
141
|
+
df, x="date", y=cols, barmode="stack",
|
|
142
|
+
title=title, template="vortexa_dark",
|
|
143
|
+
)
|
|
144
|
+
fig.update_layout(yaxis_title=y_label)
|
|
145
|
+
return fig
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def comparison_overlay_chart(series_dict, x_labels, title="Comparison", y_label="Volume"):
|
|
149
|
+
"""Overlay N series on the same chart for period or region comparison.
|
|
150
|
+
|
|
151
|
+
series_dict: mapping of series name to list of values, e.g. {"2025": [...], "2024": [...]}.
|
|
152
|
+
x_labels: common x-axis labels, e.g. ["Jan", "Feb", ...].
|
|
153
|
+
"""
|
|
154
|
+
fig = go.Figure()
|
|
155
|
+
|
|
156
|
+
for i, (name, values) in enumerate(series_dict.items()):
|
|
157
|
+
fig.add_trace(go.Scatter(
|
|
158
|
+
x=x_labels, y=values, mode="lines+markers",
|
|
159
|
+
name=name,
|
|
160
|
+
line=dict(color=vortexa_colors[i % len(vortexa_colors)], width=2),
|
|
161
|
+
))
|
|
162
|
+
|
|
163
|
+
fig.update_layout(title=title, yaxis_title=y_label)
|
|
164
|
+
return fig
|
package/lib/voyages.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Voyage queries and enriched search utilities."""
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from lib.utils import _to_dt, _cols
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_keep_cols = [
|
|
11
|
+
"VOYAGE ID", "IMO", "VESSEL NAME", "VESSEL CLASS", "VOYAGE STATUS",
|
|
12
|
+
"START DATE", "END DATE",
|
|
13
|
+
"LATEST PRODUCT GROUP", "LATEST PRODUCT",
|
|
14
|
+
"ORIGIN PORT", "DESTINATION SHIPPING REGION", "DESTINATION PORT",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def live_voyages_now(
|
|
19
|
+
now, destination_id, vessel_type, voyage_status,
|
|
20
|
+
latest_product_group=None,
|
|
21
|
+
):
|
|
22
|
+
"""Query live voyages with fallback to 30-day window."""
|
|
23
|
+
from vortexasdk import VoyagesSearchEnriched
|
|
24
|
+
|
|
25
|
+
def run_query(tmin, tmax):
|
|
26
|
+
return VoyagesSearchEnriched().search(
|
|
27
|
+
time_min=tmin, time_max=tmax,
|
|
28
|
+
voyage_date_range_activity="active",
|
|
29
|
+
voyage_status=voyage_status,
|
|
30
|
+
destinations=[destination_id],
|
|
31
|
+
vessels=[vessel_type],
|
|
32
|
+
movement_status="moving",
|
|
33
|
+
columns="all",
|
|
34
|
+
).to_df()
|
|
35
|
+
|
|
36
|
+
def filter_product(df):
|
|
37
|
+
if latest_product_group is None or df.empty or "LATEST PRODUCT GROUP" not in df.columns:
|
|
38
|
+
return df
|
|
39
|
+
key = latest_product_group.strip().lower()
|
|
40
|
+
return df[df["LATEST PRODUCT GROUP"].astype(str).str.strip().str.lower() == key]
|
|
41
|
+
|
|
42
|
+
def select_cols(df):
|
|
43
|
+
cols = [c for c in _keep_cols if c in df.columns]
|
|
44
|
+
return df[cols].reset_index(drop=True)
|
|
45
|
+
|
|
46
|
+
df_live = filter_product(run_query(now, now))
|
|
47
|
+
if not df_live.empty:
|
|
48
|
+
return select_cols(df_live)
|
|
49
|
+
|
|
50
|
+
df_30 = filter_product(run_query(now - timedelta(days=30), now))
|
|
51
|
+
if not df_30.empty:
|
|
52
|
+
print("No live voyages. Showing last 30 days.")
|
|
53
|
+
return select_cols(df_30)
|
|
54
|
+
|
|
55
|
+
print("No voyages found (live or last 30 days).")
|
|
56
|
+
return pd.DataFrame(columns=_keep_cols)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def voyages_ts_with_alerts(
|
|
60
|
+
time_min, time_max, destination_id, vessel_type,
|
|
61
|
+
voyage_status, latest_products=None,
|
|
62
|
+
):
|
|
63
|
+
"""Voyage timeseries with percentile alert bands.
|
|
64
|
+
|
|
65
|
+
Returns (ts_dataframe, alert_stats_dict) where alert_stats_dict
|
|
66
|
+
contains p20, p80 thresholds for downstream visualization.
|
|
67
|
+
"""
|
|
68
|
+
import numpy as np
|
|
69
|
+
from vortexasdk import VoyagesTimeseries
|
|
70
|
+
|
|
71
|
+
lp = None
|
|
72
|
+
if latest_products not in (None, "None", [], ()):
|
|
73
|
+
lp = list(latest_products) if isinstance(latest_products, (list, tuple, set)) else [latest_products]
|
|
74
|
+
|
|
75
|
+
kwargs = dict(
|
|
76
|
+
time_min=time_min, time_max=time_max,
|
|
77
|
+
voyage_status=voyage_status,
|
|
78
|
+
destinations=[destination_id],
|
|
79
|
+
vessels=[vessel_type],
|
|
80
|
+
movement_status="moving",
|
|
81
|
+
voyage_date_range_activity="active",
|
|
82
|
+
breakdown_property="vessel_count",
|
|
83
|
+
breakdown_frequency="day",
|
|
84
|
+
)
|
|
85
|
+
if lp:
|
|
86
|
+
kwargs["latest_products"] = lp
|
|
87
|
+
|
|
88
|
+
ts_raw = VoyagesTimeseries().search(**kwargs).to_df()
|
|
89
|
+
|
|
90
|
+
if {"key", "value"}.issubset(ts_raw.columns):
|
|
91
|
+
ts = ts_raw[["key", "value"]].rename(columns={"key": "date", "value": "count"})
|
|
92
|
+
else:
|
|
93
|
+
date_col = "date" if "date" in ts_raw.columns else ts_raw.columns[0]
|
|
94
|
+
val_col = "value" if "value" in ts_raw.columns else ts_raw.columns[-1]
|
|
95
|
+
ts = ts_raw[[date_col, val_col]].rename(columns={date_col: "date", val_col: "count"})
|
|
96
|
+
|
|
97
|
+
ts["date"] = pd.to_datetime(ts["date"]).dt.floor("D")
|
|
98
|
+
if ts.empty:
|
|
99
|
+
return ts.assign(count=0.0), {"p20": 0.0, "p80": 0.0}
|
|
100
|
+
|
|
101
|
+
full_idx = pd.date_range(ts["date"].min(), ts["date"].max(), freq="D")
|
|
102
|
+
ts = (
|
|
103
|
+
ts.set_index("date")
|
|
104
|
+
.reindex(full_idx, fill_value=0.0)
|
|
105
|
+
.rename_axis("date")
|
|
106
|
+
.reset_index()
|
|
107
|
+
)
|
|
108
|
+
ts["count"] = ts["count"].astype(float)
|
|
109
|
+
|
|
110
|
+
p20 = float(np.percentile(ts["count"], 20))
|
|
111
|
+
p80 = float(np.percentile(ts["count"], 80))
|
|
112
|
+
|
|
113
|
+
return ts, {"p20": p20, "p80": p80}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def post_ballast_distribution_and_medians(
|
|
117
|
+
origin_ids, destination_ids,
|
|
118
|
+
time_min, time_max,
|
|
119
|
+
vessels="oil_aframax",
|
|
120
|
+
latest_products=None,
|
|
121
|
+
split_layer="FINAL DESTINATION SHIPPING REGION",
|
|
122
|
+
top_n=5,
|
|
123
|
+
):
|
|
124
|
+
"""Post-discharge ballast analysis with destination distribution and median durations.
|
|
125
|
+
|
|
126
|
+
Returns dict with keys: cohort, detail, counts_monthly, proportions_monthly, medians.
|
|
127
|
+
"""
|
|
128
|
+
from vortexasdk import VoyagesSearchEnriched
|
|
129
|
+
|
|
130
|
+
tmin, tmax = _to_dt(time_min), _to_dt(time_max)
|
|
131
|
+
|
|
132
|
+
cohort = VoyagesSearchEnriched().search(
|
|
133
|
+
origins=list(origin_ids or []),
|
|
134
|
+
destinations=list(destination_ids or []),
|
|
135
|
+
time_min=tmin, time_max=tmax,
|
|
136
|
+
vessels=[vessels] if isinstance(vessels, str) else list(vessels or []),
|
|
137
|
+
voyage_status="laden",
|
|
138
|
+
voyage_date_range_activity="arrivals",
|
|
139
|
+
columns="all",
|
|
140
|
+
latest_products=(list(latest_products) if latest_products not in (None, [], ()) else None),
|
|
141
|
+
).to_df()
|
|
142
|
+
|
|
143
|
+
empty_result = {
|
|
144
|
+
"cohort": pd.DataFrame(), "detail": pd.DataFrame(),
|
|
145
|
+
"counts_monthly": pd.DataFrame(), "proportions_monthly": pd.DataFrame(),
|
|
146
|
+
"medians": pd.DataFrame(),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if cohort is None or cohort.empty:
|
|
150
|
+
return empty_result
|
|
151
|
+
|
|
152
|
+
cohort["END DATE"] = _to_dt(cohort["END DATE"])
|
|
153
|
+
cohort = (
|
|
154
|
+
cohort.dropna(subset=["END DATE", "NEXT VOYAGE ID"])
|
|
155
|
+
.loc[cohort["END DATE"] <= tmax]
|
|
156
|
+
.copy()
|
|
157
|
+
)
|
|
158
|
+
if cohort.empty:
|
|
159
|
+
return empty_result
|
|
160
|
+
|
|
161
|
+
next_ids = cohort["NEXT VOYAGE ID"].astype(str).str.strip()
|
|
162
|
+
next_ids = next_ids[next_ids.ne("")].unique().tolist()
|
|
163
|
+
if not next_ids:
|
|
164
|
+
return empty_result
|
|
165
|
+
|
|
166
|
+
nxt = VoyagesSearchEnriched().search(voyage_id=next_ids, columns="all").to_df()
|
|
167
|
+
if nxt is None or nxt.empty:
|
|
168
|
+
return empty_result
|
|
169
|
+
|
|
170
|
+
nxt["START DATE"] = _to_dt(nxt["START DATE"])
|
|
171
|
+
nxt["END DATE"] = _to_dt(nxt["END DATE"])
|
|
172
|
+
nxt = nxt.dropna(subset=["START DATE", "END DATE"]).copy()
|
|
173
|
+
nxt = nxt[nxt["VOYAGE STATUS"].astype(str).str.upper().eq("BALLAST")].copy()
|
|
174
|
+
if nxt.empty:
|
|
175
|
+
return empty_result
|
|
176
|
+
|
|
177
|
+
base = cohort[_cols(cohort, ["IMO", "VESSEL NAME", "VOYAGE ID", "NEXT VOYAGE ID"])].copy()
|
|
178
|
+
j = base.merge(nxt, left_on="NEXT VOYAGE ID", right_on="VOYAGE ID", how="inner", suffixes=("_COHORT", "_NEXT"))
|
|
179
|
+
|
|
180
|
+
label_col = None
|
|
181
|
+
for candidate in [split_layer, "FINAL DESTINATION SHIPPING REGION", "DESTINATION SHIPPING REGION"]:
|
|
182
|
+
if candidate in j.columns:
|
|
183
|
+
label_col = candidate
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
j["duration_days"] = (j["END DATE"] - j["START DATE"]).dt.days
|
|
187
|
+
|
|
188
|
+
keep = ["IMO", "VESSEL NAME", "VOYAGE ID_COHORT", "NEXT VOYAGE ID", "START DATE", "END DATE", "duration_days", "VOYAGE STATUS"]
|
|
189
|
+
if label_col:
|
|
190
|
+
keep.append(label_col)
|
|
191
|
+
detail = j[_cols(j, keep)].copy()
|
|
192
|
+
if label_col:
|
|
193
|
+
detail = detail.rename(columns={label_col: "BALLAST DEST SPLIT"})
|
|
194
|
+
|
|
195
|
+
if detail.empty or "BALLAST DEST SPLIT" not in detail.columns:
|
|
196
|
+
return empty_result
|
|
197
|
+
|
|
198
|
+
detail["month"] = detail["START DATE"].dt.to_period("M").dt.to_timestamp()
|
|
199
|
+
months_index = pd.date_range(
|
|
200
|
+
start=max(detail["month"].min(), tmin.floor("D")),
|
|
201
|
+
end=min(detail["month"].max(), tmax.floor("D")),
|
|
202
|
+
freq="MS",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
pivot_counts = detail.groupby(["month", "BALLAST DEST SPLIT"]).size().unstack(fill_value=0)
|
|
206
|
+
pivot_counts = pivot_counts.reindex(months_index, fill_value=0)
|
|
207
|
+
|
|
208
|
+
totals = pivot_counts.sum(axis=0).sort_values(ascending=False)
|
|
209
|
+
keep_labels = totals.head(top_n).index.tolist()
|
|
210
|
+
others = [c for c in pivot_counts.columns if c not in keep_labels]
|
|
211
|
+
|
|
212
|
+
counts_topn = pivot_counts.copy()
|
|
213
|
+
if others:
|
|
214
|
+
counts_topn["Other"] = counts_topn[others].sum(axis=1)
|
|
215
|
+
counts_topn = counts_topn[keep_labels + (["Other"] if others else [])]
|
|
216
|
+
|
|
217
|
+
proportions_topn = counts_topn.div(counts_topn.sum(axis=1), axis=0).fillna(0)
|
|
218
|
+
|
|
219
|
+
g = detail.dropna(subset=["duration_days"]).groupby("BALLAST DEST SPLIT", dropna=False)["duration_days"]
|
|
220
|
+
medians = (
|
|
221
|
+
pd.DataFrame({
|
|
222
|
+
"BALLAST DEST SPLIT": g.median().index,
|
|
223
|
+
"median_days": g.median().values,
|
|
224
|
+
"count": g.size().values,
|
|
225
|
+
})
|
|
226
|
+
.sort_values(["count", "BALLAST DEST SPLIT"], ascending=[False, True])
|
|
227
|
+
.reset_index(drop=True)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
"cohort": cohort[["IMO", "VESSEL NAME", "VOYAGE ID"]],
|
|
232
|
+
"detail": detail,
|
|
233
|
+
"counts_monthly": counts_topn,
|
|
234
|
+
"proportions_monthly": proportions_topn,
|
|
235
|
+
"medians": medians,
|
|
236
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vortexa-claude-skills",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Claude Code skills for Vortexa commodity/energy data analytics",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18.0.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"vortexa-skills": "bin/setup.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"commands/",
|
|
14
|
+
"context/",
|
|
15
|
+
"lib/",
|
|
16
|
+
"!lib/__pycache__/",
|
|
17
|
+
"bin/",
|
|
18
|
+
"templates/",
|
|
19
|
+
"VERSION",
|
|
20
|
+
"CHANGELOG.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test tests/node/",
|
|
24
|
+
"test:python": "pytest tests/python/",
|
|
25
|
+
"lint": "ruff check lib/",
|
|
26
|
+
"lint:fix": "ruff check --fix lib/"
|
|
27
|
+
}
|
|
28
|
+
}
|