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,52 @@
1
+ {
2
+ "geography": {
3
+ "AG": {"official": "Arabian Gulf", "layer": "shipping_region_v2"},
4
+ "MEG": {"official": "Middle East Gulf", "layer": "shipping_region_v2"},
5
+ "KSA": {"official": "Saudi Arabia", "layer": "country"},
6
+ "USG": {"official": "US Gulf Coast", "layer": "shipping_region_v2"},
7
+ "WAF": {"official": "West Africa", "layer": "shipping_region_v2"},
8
+ "PADD I": {"official": "US Atlantic Coast", "layer": "shipping_region_v2"},
9
+ "PADD 1": {"official": "US Atlantic Coast", "layer": "shipping_region_v2"},
10
+ "ARA": {"official": ["Amsterdam", "Rotterdam", "Antwerp"], "layer": "port", "multi": true},
11
+ "NWE": {"official": "Northwest Europe", "layer": "shipping_region_v2"},
12
+ "SEA": {"official": "Southeast Asia", "layer": "shipping_region_v2"},
13
+ "NEA": {"official": "Northeast Asia", "layer": "shipping_region_v2"},
14
+ "Med": {"official": "Mediterranean", "layer": "shipping_region_v2"},
15
+ "SAEC": {"official": "South America East Coast", "layer": "shipping_region_v2"},
16
+ "USWC": {"official": "US West Coast", "layer": "shipping_region_v2"},
17
+ "USAC": {"official": "US Atlantic Coast", "layer": "shipping_region_v2"},
18
+ "US": {"official": "United States", "layer": "country"},
19
+ "UAE": {"official": "United Arab Emirates", "layer": "country"},
20
+ "UK": {"official": "United Kingdom", "layer": "country"}
21
+ },
22
+ "product": {
23
+ "CPP": {"official": "Clean Petroleum Products", "layer": "group"},
24
+ "DPP": {"official": "Dirty Petroleum Products", "layer": "group"},
25
+ "crude": {"official": "Crude & Condensates", "layer": "group"},
26
+ "HSFO": {"official": "High Sulphur Fuel Oil", "layer": "category"},
27
+ "LSFO": {"official": "Low Sulphur Fuel Oil", "layer": "category"},
28
+ "ULSD": {"official": "ULSD", "layer": "grade"},
29
+ "mogas": {"official": "Gasoline / Mogas", "layer": "group_product"},
30
+ "gasoil": {"official": "Gasoil/Diesel", "layer": "group_product"},
31
+ "jet": {"official": "Jet/Kero", "layer": "group_product"},
32
+ "naphtha": {"official": "Naphtha", "layer": "group_product"},
33
+ "fuel oil": {"official": "Fuel Oil", "layer": "group_product"},
34
+ "condensate": {"official": "Condensate", "layer": "group_product"}
35
+ },
36
+ "vessel_class": {
37
+ "VLCC": "oil_vlcc",
38
+ "Suezmax": "oil_suezmax",
39
+ "Aframax": "oil_aframax",
40
+ "Panamax": "oil_panamax",
41
+ "MR": "oil_mr2",
42
+ "MR2": "oil_mr2",
43
+ "MR1": "oil_mr1",
44
+ "LR1": "oil_lr1",
45
+ "LR2": "oil_lr2",
46
+ "Handymax": "oil_handymax",
47
+ "Handysize": "oil_handysize",
48
+ "VLGC": "lpg_vlgc",
49
+ "LGC": "lpg_lgc",
50
+ "MGC": "lpg_mgc"
51
+ }
52
+ }
package/lib/api.py ADDED
@@ -0,0 +1,20 @@
1
+ """Vortexa API authentication and HTTP utilities."""
2
+ import os
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+
8
+ def get_api_key():
9
+ """Return the Vortexa API key from environment.
10
+
11
+ Raises EnvironmentError with clear instructions if not set.
12
+ """
13
+ key = os.environ.get("VORTEXA_API_KEY")
14
+ if not key:
15
+ raise EnvironmentError(
16
+ "VORTEXA_API_KEY not set. "
17
+ "Run /vortexa:init to set up your environment, "
18
+ "or add VORTEXA_API_KEY to your .env file."
19
+ )
20
+ return key
@@ -0,0 +1,254 @@
1
+ """Entity ID resolution -- translates human names to Vortexa hex IDs."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ _aliases = None
7
+
8
+
9
+ def _load_aliases():
10
+ global _aliases
11
+ if _aliases is None:
12
+ path = Path(__file__).parent / "aliases.json"
13
+ with open(path) as f:
14
+ _aliases = json.load(f)
15
+ return _aliases
16
+
17
+
18
+ class EntityCache:
19
+ """Per-session cache for resolved entity IDs."""
20
+
21
+ def __init__(self):
22
+ self._cache = {}
23
+
24
+ def get(self, term, entity_type, layer=None):
25
+ key = (term.lower().strip(), entity_type, layer)
26
+ return self._cache.get(key)
27
+
28
+ def put(self, term, entity_type, layer, hex_id):
29
+ key = (term.lower().strip(), entity_type, layer)
30
+ self._cache[key] = hex_id
31
+
32
+ def clear(self):
33
+ self._cache.clear()
34
+
35
+
36
+ def resolve(term, entity_type=None, layer=None, cache=None):
37
+ """Resolve a human-readable name to a Vortexa 64-char hex ID.
38
+
39
+ Returns (hex_id, []) on single match, (None, candidates) on multiple,
40
+ (None, []) on zero matches.
41
+ """
42
+ aliases = _load_aliases()
43
+ term_stripped = term.strip()
44
+ term_lower = term_stripped.lower()
45
+
46
+ # Check cache
47
+ if cache is not None:
48
+ cached = cache.get(term_stripped, entity_type, layer)
49
+ if cached is not None:
50
+ return cached, []
51
+
52
+ # Check alias map -- auto-detect entity_type if not provided
53
+ alias_entry = None
54
+ alias_type = entity_type
55
+
56
+ if entity_type:
57
+ section = aliases.get(entity_type, {})
58
+ for alias_key, val in section.items():
59
+ if alias_key.lower() == term_lower:
60
+ alias_entry = val
61
+ break
62
+ else:
63
+ for section_name in ("geography", "product", "vessel_class"):
64
+ section = aliases.get(section_name, {})
65
+ for alias_key, val in section.items():
66
+ if alias_key.lower() == term_lower:
67
+ alias_entry = val
68
+ alias_type = section_name
69
+ break
70
+ if alias_entry is not None:
71
+ break
72
+
73
+ # Vessel class: return SDK class string directly
74
+ if alias_type == "vessel_class":
75
+ if alias_entry is not None:
76
+ sdk_class = alias_entry if isinstance(alias_entry, str) else alias_entry
77
+ if cache is not None:
78
+ cache.put(term_stripped, "vessel_class", None, sdk_class)
79
+ return sdk_class, []
80
+ return None, []
81
+
82
+ # Expand alias to official name + layer
83
+ if alias_entry is not None:
84
+ if isinstance(alias_entry, dict):
85
+ if alias_entry.get("multi"):
86
+ # Multi-entity alias (e.g. ARA -> multiple ports)
87
+ ids = []
88
+ official_list = alias_entry["official"]
89
+ entry_layer = alias_entry.get("layer", layer)
90
+ for name in official_list:
91
+ hex_id, _ = _resolve_by_type(name, alias_type, entry_layer)
92
+ if hex_id:
93
+ ids.append(hex_id)
94
+ if cache is not None and ids:
95
+ cache.put(term_stripped, alias_type, entry_layer, ids)
96
+ return (ids if ids else None), []
97
+ term_stripped = alias_entry["official"]
98
+ layer = alias_entry.get("layer", layer)
99
+ else:
100
+ term_stripped = alias_entry
101
+
102
+ if alias_type is None:
103
+ return None, [{"error": "entity_type required -- could not auto-detect from alias map"}]
104
+
105
+ hex_id, candidates = _resolve_by_type(term_stripped, alias_type, layer)
106
+
107
+ if hex_id and cache is not None:
108
+ cache.put(term.strip(), alias_type, layer, hex_id)
109
+
110
+ return hex_id, candidates
111
+
112
+
113
+ def _resolve_by_type(term, entity_type, layer=None):
114
+ """Route to the correct type-specific resolver."""
115
+ if entity_type == "geography":
116
+ return resolve_geography(term, layer)
117
+ elif entity_type == "product":
118
+ return resolve_product(term, layer)
119
+ elif entity_type == "vessel":
120
+ return resolve_vessel(term)
121
+ elif entity_type == "corporate":
122
+ return resolve_corporate(term)
123
+ return None, []
124
+
125
+
126
+ def resolve_geography(term, layer=None, exact=False):
127
+ """Resolve a geography name to Vortexa hex ID."""
128
+ from vortexasdk import Geographies
129
+
130
+ results = Geographies().search(
131
+ term=term, filter_layer=layer,
132
+ exact_term_match=exact,
133
+ ).to_df()
134
+
135
+ if results.empty:
136
+ return None, []
137
+
138
+ if layer and "layer" in results.columns:
139
+ exact_layer = results[results["layer"].apply(
140
+ lambda x: x == layer if isinstance(x, str) else layer in x if isinstance(x, list) else False
141
+ )]
142
+ if not exact_layer.empty:
143
+ results = exact_layer
144
+
145
+ if len(results) == 1:
146
+ return results.iloc[0]["id"], []
147
+
148
+ candidates = _extract_candidates(results, "layer")
149
+ return None, candidates
150
+
151
+
152
+ def resolve_product(term, layer=None, exact=False):
153
+ """Resolve a product name to Vortexa hex ID."""
154
+ from vortexasdk import Products
155
+
156
+ results = Products().search(
157
+ term=term, filter_layer=layer,
158
+ exact_term_match=exact,
159
+ ).to_df()
160
+
161
+ if results.empty:
162
+ return None, []
163
+
164
+ if layer and "layer" in results.columns:
165
+ exact_layer = results[results["layer"].apply(
166
+ lambda x: x == layer if isinstance(x, str) else layer in x if isinstance(x, list) else False
167
+ )]
168
+ if not exact_layer.empty:
169
+ results = exact_layer
170
+
171
+ if len(results) == 1:
172
+ return results.iloc[0]["id"], []
173
+
174
+ candidates = _extract_candidates(results, "layer")
175
+ return None, candidates
176
+
177
+
178
+ def resolve_vessel(term, vessel_classes=None):
179
+ """Resolve a vessel name to Vortexa hex ID."""
180
+ from vortexasdk import Vessels
181
+
182
+ kwargs = {"term": term}
183
+ if vessel_classes:
184
+ kwargs["vessel_classes"] = vessel_classes
185
+
186
+ results = Vessels().search(**kwargs).to_df()
187
+
188
+ if results.empty:
189
+ return None, []
190
+
191
+ if len(results) == 1:
192
+ return results.iloc[0]["id"], []
193
+
194
+ candidates = []
195
+ for _, row in results.head(3).iterrows():
196
+ candidates.append({
197
+ "name": row.get("name", ""),
198
+ "layer": row.get("vessel_class", ""),
199
+ "parent": str(row.get("imo", "")),
200
+ "id": row["id"],
201
+ })
202
+ return None, candidates
203
+
204
+
205
+ def resolve_corporate(term, exact=False):
206
+ """Resolve a corporate entity name to Vortexa hex ID."""
207
+ from vortexasdk import Corporations
208
+
209
+ results = Corporations().search(
210
+ term=term, exact_term_match=exact,
211
+ ).to_df()
212
+
213
+ if results.empty:
214
+ return None, []
215
+
216
+ if len(results) == 1:
217
+ return results.iloc[0]["id"], []
218
+
219
+ candidates = _extract_candidates(results, "corporate_entity_type")
220
+ return None, candidates
221
+
222
+
223
+ def _extract_candidates(results, layer_col="layer"):
224
+ """Extract top 3 candidates from search results for disambiguation."""
225
+ candidates = []
226
+ for _, row in results.head(3).iterrows():
227
+ layer_val = row.get(layer_col, "")
228
+ if isinstance(layer_val, list):
229
+ layer_val = ", ".join(str(x) for x in layer_val)
230
+ parent = ""
231
+ for col in ("parent", "leaf"):
232
+ if col in row.index and row[col]:
233
+ val = row[col]
234
+ if isinstance(val, list) and val:
235
+ parent = str(val[0].get("name", "")) if isinstance(val[0], dict) else str(val[0])
236
+ elif isinstance(val, dict):
237
+ parent = str(val.get("name", ""))
238
+ elif isinstance(val, str):
239
+ parent = val
240
+ break
241
+ candidates.append({
242
+ "name": row.get("name", ""),
243
+ "layer": str(layer_val),
244
+ "parent": parent,
245
+ "id": row["id"],
246
+ })
247
+ return candidates
248
+
249
+
250
+ def disambiguate(matches, term):
251
+ """Format top 3 matches for user presentation. Returns list of candidate dicts."""
252
+ if not matches:
253
+ return []
254
+ return matches[:3]
@@ -0,0 +1,140 @@
1
+ """LNG and onshore inventory queries."""
2
+
3
+ import os
4
+
5
+ import pandas as pd
6
+ import requests
7
+
8
+
9
+ def lng_inventory_timeseries(
10
+ time_min, time_max,
11
+ unit_operator="fill",
12
+ split_property="location_country",
13
+ unit="cbm",
14
+ location_ids=None,
15
+ terminal_ids=None,
16
+ storage_type=None,
17
+ ):
18
+ """Query LNG inventory timeseries via REST API. Returns wide DataFrame."""
19
+ api_key = os.environ.get("VORTEXA_API_KEY")
20
+ base_url = "https://api.vortexa.com/v5/lng-inventories/timeseries"
21
+
22
+ payload = {
23
+ "timeseries_unit_operator": unit_operator,
24
+ "timeseries_split_property": split_property,
25
+ "timeseries_frequency": "day",
26
+ "timeseries_unit": unit,
27
+ "order": "gas_day",
28
+ "order_direction": "asc",
29
+ "size": 500,
30
+ "time_min": time_min.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
31
+ "time_max": time_max.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
32
+ }
33
+ if location_ids:
34
+ payload["location_ids"] = location_ids
35
+ if terminal_ids:
36
+ payload["terminal_ids"] = terminal_ids
37
+ if storage_type:
38
+ payload["storage_type"] = storage_type
39
+
40
+ headers = {"x-api-key": api_key, "Content-Type": "application/json"}
41
+ response = requests.post(base_url, json=payload, headers=headers)
42
+ response.raise_for_status()
43
+
44
+ data = response.json()
45
+ timeseries_data = data.get("data", [])
46
+
47
+ if not timeseries_data:
48
+ return pd.DataFrame()
49
+
50
+ if split_property == "quantity":
51
+ records = [{"date": entry["key"], "value": entry.get("value", 0)} for entry in timeseries_data]
52
+ df = pd.DataFrame(records)
53
+ df["date"] = pd.to_datetime(df["date"])
54
+ df = df.set_index("date").sort_index()
55
+ df.columns = ["Total"]
56
+ else:
57
+ records = [
58
+ {"date": entry["key"], "label": item["label"], "value": item["value"]}
59
+ for entry in timeseries_data
60
+ for item in entry.get("breakdown", [])
61
+ ]
62
+ df = pd.DataFrame(records)
63
+ df["date"] = pd.to_datetime(df["date"])
64
+ df = df.pivot(index="date", columns="label", values="value").fillna(0).sort_index()
65
+ df["Total"] = df.sum(axis=1)
66
+ df.columns.name = None
67
+
68
+ return df
69
+
70
+
71
+ def topn_onshore_inventory_timeseries(
72
+ *,
73
+ time_min,
74
+ time_max,
75
+ location_id,
76
+ frequency="weekly",
77
+ top_n=10,
78
+ storage_types=None,
79
+ unit_operator="fill",
80
+ unit="b",
81
+ ):
82
+ """Onshore inventories Top-N terminals via SDK. Returns wide DataFrame."""
83
+ from vortexasdk import OnshoreInventoriesTimeseries
84
+
85
+ freq_map = {
86
+ "weekly": "week", "week": "week", "w": "week",
87
+ "monthly": "month", "month": "month", "m": "month",
88
+ }
89
+ freq = freq_map.get(frequency.lower())
90
+ if freq not in {"week", "month"}:
91
+ raise ValueError("frequency must be weekly or monthly")
92
+
93
+ res = OnshoreInventoriesTimeseries().search(
94
+ location_ids=[location_id],
95
+ time_min=time_min,
96
+ time_max=time_max,
97
+ timeseries_frequency=freq,
98
+ timeseries_split_property="storage_terminal",
99
+ timeseries_unit_operator=unit_operator,
100
+ timeseries_unit=unit,
101
+ storage_types=list(storage_types) if storage_types else None,
102
+ ).to_list()
103
+
104
+ if not res:
105
+ return pd.DataFrame(columns=["date", "Other"])
106
+
107
+ def _val(x, k):
108
+ if hasattr(x, k):
109
+ return getattr(x, k)
110
+ if isinstance(x, dict):
111
+ return x.get(k)
112
+ return None
113
+
114
+ rows = []
115
+ for it in res:
116
+ d = pd.to_datetime(getattr(it, "key"))
117
+ t = float(getattr(it, "value", 0.0) or 0.0)
118
+ b = getattr(it, "breakdown", None) or []
119
+ if not b:
120
+ rows.append((d, "Other", t))
121
+ else:
122
+ s = 0.0
123
+ for x in b:
124
+ val = float(_val(x, "value") or 0.0)
125
+ lab = _val(x, "label") or _val(x, "name") or _val(x, "id") or "Unknown"
126
+ rows.append((d, lab, val))
127
+ s += val
128
+ rows.append((d, "Other", max(t - s, 0.0)))
129
+
130
+ df = pd.DataFrame(rows, columns=["date", "series", "value"])
131
+ means = df[df.series != "Other"].groupby("series")["value"].mean().nlargest(top_n)
132
+ keep = set(means.index)
133
+ df["series"] = df["series"].where(df["series"].isin(keep) | (df["series"] == "Other"), "Other")
134
+
135
+ df_p = df.groupby(["date", "series"], as_index=False)["value"].sum()
136
+ wide = df_p.pivot(index="date", columns="series", values="value").fillna(0.0)
137
+ ordered = [s for s in means.index if s in wide.columns] + (["Other"] if "Other" in wide.columns else [])
138
+ wide = wide.reindex(columns=ordered).reset_index()
139
+
140
+ return wide