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.
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ # Vortexa API Key
2
+ # Get your key from your Vortexa account manager
3
+ VORTEXA_API_KEY=your_key_here