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
package/lib/aliases.json
ADDED
|
@@ -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
|
package/lib/entities.py
ADDED
|
@@ -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]
|
package/lib/inventory.py
ADDED
|
@@ -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
|