luminus-py 0.2.1__py3-none-any.whl
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.
- luminus/__init__.py +29 -0
- luminus/client.py +512 -0
- luminus/exceptions.py +26 -0
- luminus/models.py +206 -0
- luminus/result.py +171 -0
- luminus_py-0.2.1.dist-info/METADATA +196 -0
- luminus_py-0.2.1.dist-info/RECORD +9 -0
- luminus_py-0.2.1.dist-info/WHEEL +5 -0
- luminus_py-0.2.1.dist-info/top_level.txt +1 -0
luminus/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .client import Luminus
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
LuminusConfigurationError,
|
|
4
|
+
LuminusError,
|
|
5
|
+
LuminusProtocolError,
|
|
6
|
+
LuminusStartupError,
|
|
7
|
+
LuminusToolError,
|
|
8
|
+
LuminusTransportError,
|
|
9
|
+
LuminusUpstreamError,
|
|
10
|
+
)
|
|
11
|
+
from .models import GridConnectionQueueSnapshot, GridProximitySnapshot, SiteRevenueEstimate
|
|
12
|
+
from .result import LuminusResult
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Luminus",
|
|
16
|
+
"GridConnectionQueueSnapshot",
|
|
17
|
+
"GridProximitySnapshot",
|
|
18
|
+
"LuminusError",
|
|
19
|
+
"LuminusConfigurationError",
|
|
20
|
+
"LuminusProtocolError",
|
|
21
|
+
"LuminusStartupError",
|
|
22
|
+
"LuminusToolError",
|
|
23
|
+
"LuminusTransportError",
|
|
24
|
+
"LuminusUpstreamError",
|
|
25
|
+
"LuminusResult",
|
|
26
|
+
"SiteRevenueEstimate",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
__version__ = "0.2.1"
|
luminus/client.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import weakref
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from itertools import count
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
16
|
+
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
LuminusConfigurationError,
|
|
19
|
+
LuminusError,
|
|
20
|
+
LuminusProtocolError,
|
|
21
|
+
LuminusStartupError,
|
|
22
|
+
LuminusToolError,
|
|
23
|
+
LuminusTransportError,
|
|
24
|
+
LuminusUpstreamError,
|
|
25
|
+
)
|
|
26
|
+
from .models import GridConnectionQueueSnapshot, GridProximitySnapshot, SiteRevenueEstimate
|
|
27
|
+
from .result import LuminusResult
|
|
28
|
+
|
|
29
|
+
DEFAULT_PROTOCOL_VERSION = "2025-03-26"
|
|
30
|
+
DEFAULT_CLIENT_NAME = "luminus-py"
|
|
31
|
+
DEFAULT_CLIENT_VERSION = "0.2.1"
|
|
32
|
+
|
|
33
|
+
_ACTIVE_CLIENTS: "weakref.WeakSet[Luminus]" = weakref.WeakSet()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _close_active_clients() -> None: # pragma: no cover - process-exit behaviour
|
|
37
|
+
for client in list(_ACTIVE_CLIENTS):
|
|
38
|
+
try:
|
|
39
|
+
client.close()
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
atexit.register(_close_active_clients)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _PipePump(threading.Thread):
|
|
48
|
+
def __init__(self, pipe, sink: "queue.Queue[str]"):
|
|
49
|
+
super().__init__(daemon=True)
|
|
50
|
+
self._pipe = pipe
|
|
51
|
+
self._sink = sink
|
|
52
|
+
|
|
53
|
+
def run(self) -> None: # pragma: no cover - exercised indirectly
|
|
54
|
+
try:
|
|
55
|
+
for line in self._pipe:
|
|
56
|
+
self._sink.put(line.rstrip("\r\n"))
|
|
57
|
+
finally:
|
|
58
|
+
self._sink.put("")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Luminus:
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
command: Sequence[str] | str | None = None,
|
|
65
|
+
*,
|
|
66
|
+
profile: str = "full",
|
|
67
|
+
cwd: str | Path | None = None,
|
|
68
|
+
env: Mapping[str, str] | None = None,
|
|
69
|
+
request_timeout: float = 30.0,
|
|
70
|
+
startup_timeout: float = 10.0,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.profile = profile
|
|
73
|
+
self.cwd = str(cwd) if cwd is not None else None
|
|
74
|
+
self.request_timeout = request_timeout
|
|
75
|
+
self._request_ids = count(1)
|
|
76
|
+
self._stdout_queue: queue.Queue[str] = queue.Queue()
|
|
77
|
+
self._stderr_queue: queue.Queue[str] = queue.Queue()
|
|
78
|
+
self._noise_lines: list[str] = []
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
self._closed = False
|
|
81
|
+
self._tool_cache: dict[str, dict[str, Any]] = {}
|
|
82
|
+
|
|
83
|
+
resolved_command = self._resolve_command(command, profile)
|
|
84
|
+
self._spawn_command = list(resolved_command)
|
|
85
|
+
self._user_env = {key: str(value) for key, value in (env or {}).items()}
|
|
86
|
+
self._startup_timeout = startup_timeout
|
|
87
|
+
|
|
88
|
+
merged_env = os.environ.copy()
|
|
89
|
+
merged_env.setdefault("DOTENV_CONFIG_QUIET", "true")
|
|
90
|
+
if self._user_env:
|
|
91
|
+
merged_env.update(self._user_env)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
self._process = subprocess.Popen(
|
|
95
|
+
resolved_command,
|
|
96
|
+
cwd=self.cwd,
|
|
97
|
+
env=merged_env,
|
|
98
|
+
stdin=subprocess.PIPE,
|
|
99
|
+
stdout=subprocess.PIPE,
|
|
100
|
+
stderr=subprocess.PIPE,
|
|
101
|
+
text=True,
|
|
102
|
+
encoding="utf-8",
|
|
103
|
+
bufsize=1,
|
|
104
|
+
)
|
|
105
|
+
except FileNotFoundError as exc:
|
|
106
|
+
raise LuminusStartupError(
|
|
107
|
+
"Could not start luminus-mcp. Install it on PATH or pass an explicit command=[...]."
|
|
108
|
+
) from exc
|
|
109
|
+
|
|
110
|
+
if self._process.stdin is None or self._process.stdout is None or self._process.stderr is None:
|
|
111
|
+
raise LuminusStartupError("Failed to open stdio pipes to luminus-mcp.")
|
|
112
|
+
|
|
113
|
+
self._stdout_pump = _PipePump(self._process.stdout, self._stdout_queue)
|
|
114
|
+
self._stderr_pump = _PipePump(self._process.stderr, self._stderr_queue)
|
|
115
|
+
self._stdout_pump.start()
|
|
116
|
+
self._stderr_pump.start()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
init_result = self._request(
|
|
120
|
+
"initialize",
|
|
121
|
+
{
|
|
122
|
+
"protocolVersion": DEFAULT_PROTOCOL_VERSION,
|
|
123
|
+
"capabilities": {},
|
|
124
|
+
"clientInfo": {"name": DEFAULT_CLIENT_NAME, "version": DEFAULT_CLIENT_VERSION},
|
|
125
|
+
},
|
|
126
|
+
timeout=startup_timeout,
|
|
127
|
+
)
|
|
128
|
+
except LuminusTransportError as exc:
|
|
129
|
+
self.close()
|
|
130
|
+
raise LuminusStartupError(str(exc)) from exc
|
|
131
|
+
self.protocol_version = init_result.get("protocolVersion", DEFAULT_PROTOCOL_VERSION)
|
|
132
|
+
self.server_info = init_result.get("serverInfo", {})
|
|
133
|
+
self._send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
|
|
134
|
+
_ACTIVE_CLIENTS.add(self)
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> "Luminus":
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
140
|
+
self.close()
|
|
141
|
+
|
|
142
|
+
def __del__(self) -> None: # pragma: no cover - best-effort cleanup
|
|
143
|
+
try:
|
|
144
|
+
self.close()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
if self._closed:
|
|
150
|
+
return
|
|
151
|
+
self._closed = True
|
|
152
|
+
_ACTIVE_CLIENTS.discard(self)
|
|
153
|
+
|
|
154
|
+
if self._process.poll() is None:
|
|
155
|
+
self._process.terminate()
|
|
156
|
+
try:
|
|
157
|
+
self._process.wait(timeout=5)
|
|
158
|
+
except subprocess.TimeoutExpired: # pragma: no cover
|
|
159
|
+
self._process.kill()
|
|
160
|
+
self._process.wait(timeout=5)
|
|
161
|
+
|
|
162
|
+
def refresh_tools(self) -> dict[str, dict[str, Any]]:
|
|
163
|
+
result = self._request("tools/list", {})
|
|
164
|
+
self._tool_cache = {
|
|
165
|
+
tool["name"]: tool
|
|
166
|
+
for tool in result.get("tools", [])
|
|
167
|
+
if isinstance(tool, dict) and "name" in tool
|
|
168
|
+
}
|
|
169
|
+
return dict(self._tool_cache)
|
|
170
|
+
|
|
171
|
+
def list_tools(self) -> list[str]:
|
|
172
|
+
return list(self.refresh_tools().keys())
|
|
173
|
+
|
|
174
|
+
def tool_specs(self) -> dict[str, dict[str, Any]]:
|
|
175
|
+
if not self._tool_cache:
|
|
176
|
+
self.refresh_tools()
|
|
177
|
+
return dict(self._tool_cache)
|
|
178
|
+
|
|
179
|
+
def describe_tool(self, name: str) -> dict[str, Any]:
|
|
180
|
+
specs = self.tool_specs()
|
|
181
|
+
if name not in specs:
|
|
182
|
+
raise KeyError(f"Tool {name!r} is not available from this Luminus server")
|
|
183
|
+
return dict(specs[name])
|
|
184
|
+
|
|
185
|
+
def call_tool(self, name: str, arguments: Mapping[str, Any] | None = None) -> LuminusResult:
|
|
186
|
+
result = self._request(
|
|
187
|
+
"tools/call",
|
|
188
|
+
{"name": name, "arguments": dict(arguments or {})},
|
|
189
|
+
timeout=self.request_timeout,
|
|
190
|
+
)
|
|
191
|
+
parsed = self._parse_tool_result(result)
|
|
192
|
+
if result.get("isError"):
|
|
193
|
+
self._raise_tool_error(name, parsed)
|
|
194
|
+
return LuminusResult(tool_name=name, raw=parsed, raw_response=result)
|
|
195
|
+
|
|
196
|
+
def get_day_ahead_prices(self, **arguments: Any) -> LuminusResult:
|
|
197
|
+
return self.call_tool("get_day_ahead_prices", arguments)
|
|
198
|
+
|
|
199
|
+
def get_generation_mix(self, **arguments: Any) -> LuminusResult:
|
|
200
|
+
return self.call_tool("get_generation_mix", arguments)
|
|
201
|
+
|
|
202
|
+
def get_outages_frame(self, **arguments: Any):
|
|
203
|
+
return self.call_tool_to_pandas("get_outages", arguments, data_key="outages")
|
|
204
|
+
|
|
205
|
+
def screen_site(self, **arguments: Any) -> LuminusResult:
|
|
206
|
+
return self.call_tool("screen_site", arguments)
|
|
207
|
+
|
|
208
|
+
def get_server_status(self) -> LuminusResult:
|
|
209
|
+
return self.call_tool("get_server_status", {})
|
|
210
|
+
|
|
211
|
+
def get_cross_border_flows_many(
|
|
212
|
+
self,
|
|
213
|
+
corridors: Iterable[tuple[str, str]],
|
|
214
|
+
*,
|
|
215
|
+
parallel: bool = False,
|
|
216
|
+
max_workers: int | None = None,
|
|
217
|
+
**arguments: Any,
|
|
218
|
+
):
|
|
219
|
+
return self.call_many_to_pandas(
|
|
220
|
+
"get_cross_border_flows",
|
|
221
|
+
[
|
|
222
|
+
{**arguments, "from_zone": from_zone, "to_zone": to_zone}
|
|
223
|
+
for from_zone, to_zone in corridors
|
|
224
|
+
],
|
|
225
|
+
data_key="flows",
|
|
226
|
+
request_prefix="request_",
|
|
227
|
+
parallel=parallel,
|
|
228
|
+
max_workers=max_workers,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def get_grid_proximity_substations(self, **arguments: Any):
|
|
232
|
+
return self.call_tool_to_pandas("get_grid_proximity", arguments, data_key="substations")
|
|
233
|
+
|
|
234
|
+
def get_grid_proximity_lines(self, **arguments: Any):
|
|
235
|
+
return self.call_tool_to_pandas("get_grid_proximity", arguments, data_key="lines")
|
|
236
|
+
|
|
237
|
+
def get_grid_proximity_snapshot(self, **arguments: Any) -> GridProximitySnapshot:
|
|
238
|
+
return self.call_tool("get_grid_proximity", arguments).to_model(GridProximitySnapshot)
|
|
239
|
+
|
|
240
|
+
def get_grid_connection_queue_projects(self, **arguments: Any):
|
|
241
|
+
return self.call_tool_to_pandas("get_grid_connection_queue", arguments, data_key="projects")
|
|
242
|
+
|
|
243
|
+
def get_grid_connection_queue_sites(self, **arguments: Any):
|
|
244
|
+
return self.call_tool_to_pandas("get_grid_connection_queue", arguments, data_key="connection_sites")
|
|
245
|
+
|
|
246
|
+
def get_grid_connection_queue_snapshot(self, **arguments: Any) -> GridConnectionQueueSnapshot:
|
|
247
|
+
return self.call_tool("get_grid_connection_queue", arguments).to_model(GridConnectionQueueSnapshot)
|
|
248
|
+
|
|
249
|
+
def estimate_site_revenue_frame(self, **arguments: Any):
|
|
250
|
+
return self.call_tool("estimate_site_revenue", arguments).to_flat_pandas()
|
|
251
|
+
|
|
252
|
+
def estimate_site_revenue_estimate(self, **arguments: Any) -> SiteRevenueEstimate:
|
|
253
|
+
return self.call_tool("estimate_site_revenue", arguments).to_model(SiteRevenueEstimate)
|
|
254
|
+
|
|
255
|
+
def call_tool_to_pandas(
|
|
256
|
+
self,
|
|
257
|
+
name: str,
|
|
258
|
+
arguments: Mapping[str, Any] | None = None,
|
|
259
|
+
*,
|
|
260
|
+
data_key: str | None = None,
|
|
261
|
+
):
|
|
262
|
+
return self.call_tool(name, arguments).to_pandas(data_key=data_key)
|
|
263
|
+
|
|
264
|
+
def call_tool_to_geojson(
|
|
265
|
+
self,
|
|
266
|
+
name: str,
|
|
267
|
+
arguments: Mapping[str, Any] | None = None,
|
|
268
|
+
*,
|
|
269
|
+
data_key: str | None = None,
|
|
270
|
+
) -> dict[str, Any]:
|
|
271
|
+
return self.call_tool(name, arguments).to_geojson(data_key=data_key)
|
|
272
|
+
|
|
273
|
+
def call_tool_to_geodataframe(
|
|
274
|
+
self,
|
|
275
|
+
name: str,
|
|
276
|
+
arguments: Mapping[str, Any] | None = None,
|
|
277
|
+
*,
|
|
278
|
+
data_key: str | None = None,
|
|
279
|
+
crs: str = "EPSG:4326",
|
|
280
|
+
):
|
|
281
|
+
return self.call_tool(name, arguments).to_geodataframe(data_key=data_key, crs=crs)
|
|
282
|
+
|
|
283
|
+
def call_many(
|
|
284
|
+
self,
|
|
285
|
+
name: str,
|
|
286
|
+
argument_sets: Iterable[Mapping[str, Any]],
|
|
287
|
+
*,
|
|
288
|
+
parallel: bool = False,
|
|
289
|
+
max_workers: int | None = None,
|
|
290
|
+
) -> list[LuminusResult]:
|
|
291
|
+
jobs = [dict(arguments) for arguments in argument_sets]
|
|
292
|
+
if not parallel or len(jobs) <= 1:
|
|
293
|
+
return [self.call_tool(name, arguments) for arguments in jobs]
|
|
294
|
+
|
|
295
|
+
worker_count = max_workers or min(4, len(jobs))
|
|
296
|
+
|
|
297
|
+
def _run(arguments: Mapping[str, Any]) -> LuminusResult:
|
|
298
|
+
child = self._spawn_child_client()
|
|
299
|
+
try:
|
|
300
|
+
return child.call_tool(name, arguments)
|
|
301
|
+
finally:
|
|
302
|
+
child.close()
|
|
303
|
+
|
|
304
|
+
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
|
305
|
+
return list(executor.map(_run, jobs))
|
|
306
|
+
|
|
307
|
+
def call_many_to_pandas(
|
|
308
|
+
self,
|
|
309
|
+
name: str,
|
|
310
|
+
argument_sets: Iterable[Mapping[str, Any]],
|
|
311
|
+
*,
|
|
312
|
+
data_key: str | None = None,
|
|
313
|
+
include_request_args: bool = True,
|
|
314
|
+
request_prefix: str = "request_",
|
|
315
|
+
parallel: bool = False,
|
|
316
|
+
max_workers: int | None = None,
|
|
317
|
+
):
|
|
318
|
+
try:
|
|
319
|
+
import pandas as pd
|
|
320
|
+
except ImportError as exc: # pragma: no cover
|
|
321
|
+
raise RuntimeError(
|
|
322
|
+
"pandas is not installed. Install luminus-py[notebook] or add pandas manually."
|
|
323
|
+
) from exc
|
|
324
|
+
|
|
325
|
+
frames = []
|
|
326
|
+
jobs = [dict(arguments) for arguments in argument_sets]
|
|
327
|
+
results = self.call_many(name, jobs, parallel=parallel, max_workers=max_workers)
|
|
328
|
+
for args, result in zip(jobs, results, strict=False):
|
|
329
|
+
frame = result.to_pandas(data_key=data_key)
|
|
330
|
+
if include_request_args:
|
|
331
|
+
for key, value in args.items():
|
|
332
|
+
frame[f"{request_prefix}{key}"] = value
|
|
333
|
+
frames.append(frame)
|
|
334
|
+
|
|
335
|
+
if not frames:
|
|
336
|
+
return pd.DataFrame()
|
|
337
|
+
return pd.concat(frames, ignore_index=True)
|
|
338
|
+
|
|
339
|
+
def get_day_ahead_prices_many(
|
|
340
|
+
self,
|
|
341
|
+
zones: Iterable[str],
|
|
342
|
+
*,
|
|
343
|
+
parallel: bool = False,
|
|
344
|
+
max_workers: int | None = None,
|
|
345
|
+
**arguments: Any,
|
|
346
|
+
):
|
|
347
|
+
return self.call_many_to_pandas(
|
|
348
|
+
"get_day_ahead_prices",
|
|
349
|
+
[{**arguments, "zone": zone} for zone in zones],
|
|
350
|
+
request_prefix="request_",
|
|
351
|
+
parallel=parallel,
|
|
352
|
+
max_workers=max_workers,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def get_generation_mix_many(
|
|
356
|
+
self,
|
|
357
|
+
zones: Iterable[str],
|
|
358
|
+
*,
|
|
359
|
+
parallel: bool = False,
|
|
360
|
+
max_workers: int | None = None,
|
|
361
|
+
**arguments: Any,
|
|
362
|
+
):
|
|
363
|
+
return self.call_many_to_pandas(
|
|
364
|
+
"get_generation_mix",
|
|
365
|
+
[{**arguments, "zone": zone} for zone in zones],
|
|
366
|
+
data_key="generation",
|
|
367
|
+
request_prefix="request_",
|
|
368
|
+
parallel=parallel,
|
|
369
|
+
max_workers=max_workers,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def compare_sites_rankings(self, **arguments: Any):
|
|
373
|
+
return self.call_tool_to_pandas("compare_sites", arguments, data_key="rankings")
|
|
374
|
+
|
|
375
|
+
def compare_sites_rankings_geojson(self, **arguments: Any) -> dict[str, Any]:
|
|
376
|
+
return self.call_tool_to_geojson("compare_sites", arguments, data_key="rankings")
|
|
377
|
+
|
|
378
|
+
def compare_sites_rankings_geodataframe(self, **arguments: Any):
|
|
379
|
+
return self.call_tool_to_geodataframe("compare_sites", arguments, data_key="rankings")
|
|
380
|
+
|
|
381
|
+
def __getattr__(self, name: str):
|
|
382
|
+
if name.startswith("_"):
|
|
383
|
+
raise AttributeError(name)
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
tool = self.describe_tool(name)
|
|
387
|
+
except (KeyError, LuminusError) as exc:
|
|
388
|
+
raise AttributeError(name) from exc
|
|
389
|
+
|
|
390
|
+
def _dynamic_tool(**arguments: Any) -> LuminusResult:
|
|
391
|
+
return self.call_tool(name, arguments)
|
|
392
|
+
|
|
393
|
+
_dynamic_tool.__name__ = name
|
|
394
|
+
_dynamic_tool.__qualname__ = f"{self.__class__.__name__}.{name}"
|
|
395
|
+
_dynamic_tool.__doc__ = tool.get("description") or f"Call the {name} MCP tool."
|
|
396
|
+
return _dynamic_tool
|
|
397
|
+
|
|
398
|
+
def __dir__(self) -> list[str]:
|
|
399
|
+
names = set(super().__dir__())
|
|
400
|
+
try:
|
|
401
|
+
names.update(self.tool_specs().keys())
|
|
402
|
+
except LuminusError:
|
|
403
|
+
pass
|
|
404
|
+
return sorted(names)
|
|
405
|
+
|
|
406
|
+
def _spawn_child_client(self) -> "Luminus":
|
|
407
|
+
return Luminus(
|
|
408
|
+
command=list(self._spawn_command),
|
|
409
|
+
profile=self.profile,
|
|
410
|
+
cwd=self.cwd,
|
|
411
|
+
env=self._user_env,
|
|
412
|
+
request_timeout=self.request_timeout,
|
|
413
|
+
startup_timeout=self._startup_timeout,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def _resolve_command(self, command: Sequence[str] | str | None, profile: str) -> list[str]:
|
|
417
|
+
if command is None:
|
|
418
|
+
executable = shutil.which("luminus-mcp")
|
|
419
|
+
if executable is None:
|
|
420
|
+
raise LuminusStartupError(
|
|
421
|
+
"luminus-mcp was not found on PATH. Install it first or pass command=[...]."
|
|
422
|
+
)
|
|
423
|
+
return [executable, "--profile", profile]
|
|
424
|
+
|
|
425
|
+
parts = [command] if isinstance(command, str) else list(command)
|
|
426
|
+
if "--profile" not in parts:
|
|
427
|
+
parts.extend(["--profile", profile])
|
|
428
|
+
return [str(part) for part in parts]
|
|
429
|
+
|
|
430
|
+
def _send(self, message: Mapping[str, Any]) -> None:
|
|
431
|
+
if self._process.poll() is not None:
|
|
432
|
+
raise LuminusTransportError(self._crash_message("luminus-mcp exited before the request completed."))
|
|
433
|
+
assert self._process.stdin is not None
|
|
434
|
+
self._process.stdin.write(json.dumps(message) + "\n")
|
|
435
|
+
self._process.stdin.flush()
|
|
436
|
+
|
|
437
|
+
def _request(self, method: str, params: Mapping[str, Any], timeout: float | None = None) -> dict[str, Any]:
|
|
438
|
+
with self._lock:
|
|
439
|
+
request_id = next(self._request_ids)
|
|
440
|
+
self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
|
|
441
|
+
return self._wait_for_response(request_id, timeout or self.request_timeout)
|
|
442
|
+
|
|
443
|
+
def _wait_for_response(self, request_id: int, timeout: float) -> dict[str, Any]:
|
|
444
|
+
deadline = time.monotonic() + timeout
|
|
445
|
+
while True:
|
|
446
|
+
remaining = deadline - time.monotonic()
|
|
447
|
+
if remaining <= 0:
|
|
448
|
+
raise LuminusTransportError(
|
|
449
|
+
self._crash_message(f"Timed out waiting for response to request {request_id}.")
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if self._process.poll() is not None and self._stdout_queue.empty():
|
|
453
|
+
raise LuminusTransportError(self._crash_message("luminus-mcp exited unexpectedly."))
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
line = self._stdout_queue.get(timeout=remaining)
|
|
457
|
+
except queue.Empty as exc:
|
|
458
|
+
raise LuminusTransportError(
|
|
459
|
+
self._crash_message(f"Timed out waiting for response to request {request_id}.")
|
|
460
|
+
) from exc
|
|
461
|
+
|
|
462
|
+
if not line:
|
|
463
|
+
continue
|
|
464
|
+
if not line.startswith("{"):
|
|
465
|
+
self._noise_lines.append(line)
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
message = json.loads(line)
|
|
470
|
+
except json.JSONDecodeError:
|
|
471
|
+
self._noise_lines.append(line)
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
if "id" not in message:
|
|
475
|
+
continue
|
|
476
|
+
if message.get("id") != request_id:
|
|
477
|
+
continue
|
|
478
|
+
if "error" in message:
|
|
479
|
+
error = message["error"]
|
|
480
|
+
raise LuminusProtocolError(f"{error.get('message', 'Unknown MCP error')} (code={error.get('code')})")
|
|
481
|
+
return message.get("result", {})
|
|
482
|
+
|
|
483
|
+
def _parse_tool_result(self, result: Mapping[str, Any]) -> Any:
|
|
484
|
+
content = result.get("content", [])
|
|
485
|
+
if len(content) == 1 and isinstance(content[0], dict) and content[0].get("type") == "text":
|
|
486
|
+
text = content[0].get("text", "")
|
|
487
|
+
try:
|
|
488
|
+
return json.loads(text)
|
|
489
|
+
except json.JSONDecodeError:
|
|
490
|
+
return text
|
|
491
|
+
return dict(result)
|
|
492
|
+
|
|
493
|
+
def _raise_tool_error(self, tool_name: str, payload: Any) -> None:
|
|
494
|
+
message = payload if isinstance(payload, str) else json.dumps(payload)
|
|
495
|
+
lower = message.lower()
|
|
496
|
+
if "configuration error" in lower or "api key" in lower:
|
|
497
|
+
raise LuminusConfigurationError(f"{tool_name} failed: {message}")
|
|
498
|
+
if "upstream" in lower or "timed out" in lower or "no data" in lower:
|
|
499
|
+
raise LuminusUpstreamError(f"{tool_name} failed: {message}")
|
|
500
|
+
raise LuminusToolError(f"{tool_name} failed: {message}")
|
|
501
|
+
|
|
502
|
+
def _crash_message(self, prefix: str) -> str:
|
|
503
|
+
stderr_lines: list[str] = []
|
|
504
|
+
while not self._stderr_queue.empty():
|
|
505
|
+
line = self._stderr_queue.get_nowait()
|
|
506
|
+
if line:
|
|
507
|
+
stderr_lines.append(line)
|
|
508
|
+
|
|
509
|
+
details = stderr_lines[-5:] or self._noise_lines[-5:]
|
|
510
|
+
if not details:
|
|
511
|
+
return prefix
|
|
512
|
+
return prefix + " Last output: " + " | ".join(details)
|
luminus/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class LuminusError(Exception):
|
|
2
|
+
"""Base exception for luminus-py."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LuminusTransportError(LuminusError):
|
|
6
|
+
"""Process, pipe, or timeout failure while talking to luminus-mcp."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LuminusStartupError(LuminusTransportError):
|
|
10
|
+
"""The luminus-mcp subprocess could not be started or initialized."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LuminusProtocolError(LuminusError):
|
|
14
|
+
"""The server returned an MCP/JSON-RPC level error."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LuminusToolError(LuminusError):
|
|
18
|
+
"""The MCP server returned a tool-level error payload."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LuminusConfigurationError(LuminusToolError):
|
|
22
|
+
"""The requested tool failed because required configuration is missing."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LuminusUpstreamError(LuminusToolError):
|
|
26
|
+
"""The requested tool failed because an upstream source errored or timed out."""
|
luminus/models.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class GridProximitySubstation:
|
|
9
|
+
name: str | None
|
|
10
|
+
voltage_kv: int | None
|
|
11
|
+
operator: str | None
|
|
12
|
+
distance_km: float
|
|
13
|
+
lat: float
|
|
14
|
+
lon: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class GridProximityLine:
|
|
19
|
+
voltage_kv: int
|
|
20
|
+
operator: str | None
|
|
21
|
+
distance_km: float
|
|
22
|
+
cables: int | None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(slots=True)
|
|
26
|
+
class GridProximitySummary:
|
|
27
|
+
nearest_substation_km: float | None
|
|
28
|
+
nearest_line_km: float | None
|
|
29
|
+
max_nearby_voltage_kv: int | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class GridProximitySnapshot:
|
|
34
|
+
lat: float
|
|
35
|
+
lon: float
|
|
36
|
+
radius_km: float
|
|
37
|
+
substations: list[GridProximitySubstation]
|
|
38
|
+
lines: list[GridProximityLine]
|
|
39
|
+
summary: GridProximitySummary
|
|
40
|
+
source_metadata: dict[str, Any]
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> "GridProximitySnapshot":
|
|
44
|
+
return cls(
|
|
45
|
+
lat=float(payload["lat"]),
|
|
46
|
+
lon=float(payload["lon"]),
|
|
47
|
+
radius_km=float(payload["radius_km"]),
|
|
48
|
+
substations=[
|
|
49
|
+
GridProximitySubstation(
|
|
50
|
+
name=item.get("name"),
|
|
51
|
+
voltage_kv=item.get("voltage_kv"),
|
|
52
|
+
operator=item.get("operator"),
|
|
53
|
+
distance_km=float(item["distance_km"]),
|
|
54
|
+
lat=float(item["lat"]),
|
|
55
|
+
lon=float(item["lon"]),
|
|
56
|
+
)
|
|
57
|
+
for item in payload.get("substations", [])
|
|
58
|
+
],
|
|
59
|
+
lines=[
|
|
60
|
+
GridProximityLine(
|
|
61
|
+
voltage_kv=int(item.get("voltage_kv", 0)),
|
|
62
|
+
operator=item.get("operator"),
|
|
63
|
+
distance_km=float(item["distance_km"]),
|
|
64
|
+
cables=item.get("cables"),
|
|
65
|
+
)
|
|
66
|
+
for item in payload.get("lines", [])
|
|
67
|
+
],
|
|
68
|
+
summary=GridProximitySummary(**dict(payload.get("summary", {}))),
|
|
69
|
+
source_metadata=dict(payload.get("source_metadata", {})),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class GridConnectionQueueFilters:
|
|
75
|
+
connection_site_query: str | None
|
|
76
|
+
project_name_query: str | None
|
|
77
|
+
host_to: str | None
|
|
78
|
+
plant_type: str | None
|
|
79
|
+
project_status: str | None
|
|
80
|
+
agreement_type: str | None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True)
|
|
84
|
+
class GridConnectionQueueSummary:
|
|
85
|
+
matched_projects: int
|
|
86
|
+
returned_projects: int
|
|
87
|
+
total_connected_mw: float
|
|
88
|
+
total_net_change_mw: float
|
|
89
|
+
total_cumulative_capacity_mw: float
|
|
90
|
+
earliest_effective_from: str | None
|
|
91
|
+
latest_effective_from: str | None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(slots=True)
|
|
95
|
+
class GridConnectionSiteSummary:
|
|
96
|
+
connection_site: str
|
|
97
|
+
project_count: int
|
|
98
|
+
total_net_change_mw: float
|
|
99
|
+
total_connected_mw: float
|
|
100
|
+
total_cumulative_capacity_mw: float
|
|
101
|
+
plant_types: list[str]
|
|
102
|
+
project_statuses: list[str]
|
|
103
|
+
earliest_effective_from: str | None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(slots=True)
|
|
107
|
+
class GridConnectionProject:
|
|
108
|
+
project_name: str
|
|
109
|
+
customer_name: str | None
|
|
110
|
+
connection_site: str
|
|
111
|
+
stage: int | None
|
|
112
|
+
mw_connected: float
|
|
113
|
+
mw_increase_decrease: float
|
|
114
|
+
cumulative_total_capacity_mw: float
|
|
115
|
+
mw_effective_from: str | None
|
|
116
|
+
project_status: str | None
|
|
117
|
+
agreement_type: str | None
|
|
118
|
+
host_to: str | None
|
|
119
|
+
plant_type: str | None
|
|
120
|
+
project_id: str | None
|
|
121
|
+
project_number: str | None
|
|
122
|
+
gate: int | None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(slots=True)
|
|
126
|
+
class GridConnectionQueueSnapshot:
|
|
127
|
+
filters: GridConnectionQueueFilters
|
|
128
|
+
summary: GridConnectionQueueSummary
|
|
129
|
+
connection_sites: list[GridConnectionSiteSummary]
|
|
130
|
+
projects: list[GridConnectionProject]
|
|
131
|
+
source_metadata: dict[str, Any]
|
|
132
|
+
disclaimer: str
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> "GridConnectionQueueSnapshot":
|
|
136
|
+
return cls(
|
|
137
|
+
filters=GridConnectionQueueFilters(**dict(payload.get("filters", {}))),
|
|
138
|
+
summary=GridConnectionQueueSummary(**dict(payload.get("summary", {}))),
|
|
139
|
+
connection_sites=[
|
|
140
|
+
GridConnectionSiteSummary(**dict(item))
|
|
141
|
+
for item in payload.get("connection_sites", [])
|
|
142
|
+
],
|
|
143
|
+
projects=[
|
|
144
|
+
GridConnectionProject(**dict(item))
|
|
145
|
+
for item in payload.get("projects", [])
|
|
146
|
+
],
|
|
147
|
+
source_metadata=dict(payload.get("source_metadata", {})),
|
|
148
|
+
disclaimer=str(payload.get("disclaimer", "")),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(slots=True)
|
|
153
|
+
class SiteRevenueTerrain:
|
|
154
|
+
elevation_m: float
|
|
155
|
+
slope_deg: float
|
|
156
|
+
aspect_cardinal: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(slots=True)
|
|
160
|
+
class SiteRevenueMetrics:
|
|
161
|
+
estimated_annual_revenue_eur: float
|
|
162
|
+
annual_generation_mwh: float | None = None
|
|
163
|
+
capacity_factor: float | None = None
|
|
164
|
+
capture_price_eur_mwh: float | None = None
|
|
165
|
+
daily_spread_eur_mwh: float | None = None
|
|
166
|
+
daily_revenue_eur: float | None = None
|
|
167
|
+
arb_signal: str | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(slots=True)
|
|
171
|
+
class PriceSnapshot:
|
|
172
|
+
date: str
|
|
173
|
+
peak_eur_mwh: float
|
|
174
|
+
off_peak_eur_mwh: float
|
|
175
|
+
mean_eur_mwh: float
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@dataclass(slots=True)
|
|
179
|
+
class SiteRevenueEstimate:
|
|
180
|
+
lat: float
|
|
181
|
+
lon: float
|
|
182
|
+
zone: str
|
|
183
|
+
technology: str
|
|
184
|
+
capacity_mw: float
|
|
185
|
+
terrain: SiteRevenueTerrain | None
|
|
186
|
+
revenue: SiteRevenueMetrics
|
|
187
|
+
price_snapshot: PriceSnapshot | None
|
|
188
|
+
caveats: list[str]
|
|
189
|
+
disclaimer: str
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> "SiteRevenueEstimate":
|
|
193
|
+
terrain_payload = payload.get("terrain")
|
|
194
|
+
price_payload = payload.get("price_snapshot")
|
|
195
|
+
return cls(
|
|
196
|
+
lat=float(payload["lat"]),
|
|
197
|
+
lon=float(payload["lon"]),
|
|
198
|
+
zone=str(payload["zone"]),
|
|
199
|
+
technology=str(payload["technology"]),
|
|
200
|
+
capacity_mw=float(payload["capacity_mw"]),
|
|
201
|
+
terrain=SiteRevenueTerrain(**dict(terrain_payload)) if isinstance(terrain_payload, Mapping) else None,
|
|
202
|
+
revenue=SiteRevenueMetrics(**dict(payload.get("revenue", {}))),
|
|
203
|
+
price_snapshot=PriceSnapshot(**dict(price_payload)) if isinstance(price_payload, Mapping) else None,
|
|
204
|
+
caveats=[str(item) for item in payload.get("caveats", [])],
|
|
205
|
+
disclaimer=str(payload.get("disclaimer", "")),
|
|
206
|
+
)
|
luminus/result.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Mapping, Protocol, TypeVar
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SupportsFromDict(Protocol[T]):
|
|
11
|
+
@classmethod
|
|
12
|
+
def from_dict(cls, payload: Mapping[str, Any]) -> T: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class LuminusResult:
|
|
17
|
+
tool_name: str
|
|
18
|
+
raw: Any
|
|
19
|
+
raw_response: Mapping[str, Any] | None = field(default=None)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def data(self) -> Any:
|
|
23
|
+
return self.raw
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Any:
|
|
26
|
+
return self.raw
|
|
27
|
+
|
|
28
|
+
def _resolve_value(self, data_key: str | None = None) -> Any:
|
|
29
|
+
value = self.raw
|
|
30
|
+
if data_key is not None:
|
|
31
|
+
if not isinstance(value, dict) or data_key not in value:
|
|
32
|
+
raise KeyError(f"{data_key!r} not found in {self.tool_name} result")
|
|
33
|
+
value = value[data_key]
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
def _frame_rows(self, data_key: str | None = None) -> list[dict[str, Any]]:
|
|
37
|
+
value = self._resolve_value(data_key=data_key)
|
|
38
|
+
|
|
39
|
+
if isinstance(value, list):
|
|
40
|
+
if all(isinstance(item, dict) for item in value):
|
|
41
|
+
return value
|
|
42
|
+
return [{"value": item} for item in value]
|
|
43
|
+
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
list_keys = [key for key, item in value.items() if isinstance(item, list)]
|
|
46
|
+
if len(list_keys) == 1:
|
|
47
|
+
list_key = list_keys[0]
|
|
48
|
+
rows = value[list_key]
|
|
49
|
+
meta = {
|
|
50
|
+
key: item
|
|
51
|
+
for key, item in value.items()
|
|
52
|
+
if key != list_key and not isinstance(item, (dict, list))
|
|
53
|
+
}
|
|
54
|
+
if all(isinstance(item, dict) for item in rows):
|
|
55
|
+
return [{**meta, **row} for row in rows]
|
|
56
|
+
return [{**meta, "value": item} for item in rows]
|
|
57
|
+
return [value]
|
|
58
|
+
|
|
59
|
+
return [{"value": value}]
|
|
60
|
+
|
|
61
|
+
def _extract_lon_lat(self, row: Mapping[str, Any]) -> tuple[float, float] | None:
|
|
62
|
+
if "lon" in row and "lat" in row:
|
|
63
|
+
lon = row.get("lon")
|
|
64
|
+
lat = row.get("lat")
|
|
65
|
+
if isinstance(lon, (int, float)) and isinstance(lat, (int, float)):
|
|
66
|
+
return float(lon), float(lat)
|
|
67
|
+
|
|
68
|
+
if "longitude" in row and "latitude" in row:
|
|
69
|
+
lon = row.get("longitude")
|
|
70
|
+
lat = row.get("latitude")
|
|
71
|
+
if isinstance(lon, (int, float)) and isinstance(lat, (int, float)):
|
|
72
|
+
return float(lon), float(lat)
|
|
73
|
+
|
|
74
|
+
coords = row.get("coordinates")
|
|
75
|
+
if isinstance(coords, (list, tuple)) and len(coords) >= 2:
|
|
76
|
+
lon, lat = coords[0], coords[1]
|
|
77
|
+
if isinstance(lon, (int, float)) and isinstance(lat, (int, float)):
|
|
78
|
+
return float(lon), float(lat)
|
|
79
|
+
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def to_pandas(self, data_key: str | None = None):
|
|
83
|
+
try:
|
|
84
|
+
import pandas as pd
|
|
85
|
+
except ImportError as exc: # pragma: no cover
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
"pandas is not installed. Install luminus-py[notebook] or add pandas manually."
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
return pd.DataFrame(self._frame_rows(data_key=data_key))
|
|
91
|
+
|
|
92
|
+
def to_flat_pandas(self, data_key: str | None = None, *, sep: str = "."):
|
|
93
|
+
try:
|
|
94
|
+
import pandas as pd
|
|
95
|
+
except ImportError as exc: # pragma: no cover
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
"pandas is not installed. Install luminus-py[notebook] or add pandas manually."
|
|
98
|
+
) from exc
|
|
99
|
+
|
|
100
|
+
value = self._resolve_value(data_key=data_key)
|
|
101
|
+
if isinstance(value, list):
|
|
102
|
+
return pd.json_normalize(value, sep=sep)
|
|
103
|
+
return pd.json_normalize(value, sep=sep)
|
|
104
|
+
|
|
105
|
+
def to_model(self, model_type: type[SupportsFromDict[T]], data_key: str | None = None) -> T:
|
|
106
|
+
value = self._resolve_value(data_key=data_key)
|
|
107
|
+
if not isinstance(value, Mapping):
|
|
108
|
+
raise TypeError(f"{self.tool_name} result is not a mapping and cannot be converted to {model_type.__name__}")
|
|
109
|
+
return model_type.from_dict(value)
|
|
110
|
+
|
|
111
|
+
def to_geojson(self, data_key: str | None = None) -> dict[str, Any]:
|
|
112
|
+
features: list[dict[str, Any]] = []
|
|
113
|
+
for row in self._frame_rows(data_key=data_key):
|
|
114
|
+
lon_lat = self._extract_lon_lat(row)
|
|
115
|
+
if lon_lat is None:
|
|
116
|
+
continue
|
|
117
|
+
lon, lat = lon_lat
|
|
118
|
+
properties = {
|
|
119
|
+
key: value
|
|
120
|
+
for key, value in row.items()
|
|
121
|
+
if key not in {"lon", "lat", "longitude", "latitude", "coordinates"}
|
|
122
|
+
}
|
|
123
|
+
features.append(
|
|
124
|
+
{
|
|
125
|
+
"type": "Feature",
|
|
126
|
+
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
|
127
|
+
"properties": properties,
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not features:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"{self.tool_name} does not contain any rows with lat/lon-style coordinates. "
|
|
134
|
+
"Pass data_key=... if the geospatial rows live under a nested list."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return {"type": "FeatureCollection", "features": features}
|
|
138
|
+
|
|
139
|
+
def to_geodataframe(self, data_key: str | None = None, *, crs: str = "EPSG:4326"):
|
|
140
|
+
try:
|
|
141
|
+
import geopandas as gpd
|
|
142
|
+
except ImportError as exc: # pragma: no cover
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"geopandas is not installed. Install luminus-py[gis] or luminus-py[all]."
|
|
145
|
+
) from exc
|
|
146
|
+
|
|
147
|
+
rows: list[dict[str, Any]] = []
|
|
148
|
+
for row in self._frame_rows(data_key=data_key):
|
|
149
|
+
lon_lat = self._extract_lon_lat(row)
|
|
150
|
+
if lon_lat is None:
|
|
151
|
+
continue
|
|
152
|
+
lon, lat = lon_lat
|
|
153
|
+
cleaned = {
|
|
154
|
+
key: value
|
|
155
|
+
for key, value in row.items()
|
|
156
|
+
if key not in {"lon", "lat", "longitude", "latitude", "coordinates"}
|
|
157
|
+
}
|
|
158
|
+
cleaned["lon"] = lon
|
|
159
|
+
cleaned["lat"] = lat
|
|
160
|
+
rows.append(cleaned)
|
|
161
|
+
|
|
162
|
+
if not rows:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"{self.tool_name} does not contain any rows with lat/lon-style coordinates. "
|
|
165
|
+
"Pass data_key=... if the geospatial rows live under a nested list."
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
frame = gpd.GeoDataFrame(rows)
|
|
169
|
+
frame["geometry"] = gpd.points_from_xy(frame["lon"], frame["lat"])
|
|
170
|
+
frame.set_crs(crs, inplace=True)
|
|
171
|
+
return frame
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: luminus-py
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Notebook-friendly Python client for luminus-mcp
|
|
5
|
+
Author: Keith So
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kitfunso/luminus
|
|
8
|
+
Project-URL: Repository, https://github.com/kitfunso/luminus
|
|
9
|
+
Project-URL: Issues, https://github.com/kitfunso/luminus/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/kitfunso/luminus/blob/master/CHANGELOG.md
|
|
11
|
+
Keywords: mcp,jupyter,notebook,energy,electricity,gis
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: notebook
|
|
25
|
+
Requires-Dist: pandas>=2.0; extra == "notebook"
|
|
26
|
+
Provides-Extra: gis
|
|
27
|
+
Requires-Dist: geopandas>=0.14; extra == "gis"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: pandas>=2.0; extra == "all"
|
|
30
|
+
Requires-Dist: geopandas>=0.14; extra == "all"
|
|
31
|
+
|
|
32
|
+
# luminus-py
|
|
33
|
+
|
|
34
|
+
A notebook-friendly Python client for `luminus-mcp`.
|
|
35
|
+
|
|
36
|
+
This package starts the existing Node MCP server under the hood, calls tools over stdio, and returns Python-native result objects with optional pandas helpers.
|
|
37
|
+
|
|
38
|
+
Any MCP tool exposed by `luminus-mcp` is callable directly as a Python method, so the SDK does not need a hand-written wrapper for every tool.
|
|
39
|
+
|
|
40
|
+
The SDK also includes geospatial helpers for notebook workflows: `to_geojson()` for lightweight mapping and `to_geodataframe()` for GeoPandas users.
|
|
41
|
+
|
|
42
|
+
Roadmap: see [`../docs/python-sdk-roadmap.md`](../docs/python-sdk-roadmap.md).
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install luminus-py[notebook]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For GIS notebook work:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install luminus-py[all]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
You also need `luminus-mcp` itself available on your machine, because the Python SDK starts the existing Node MCP server under the hood.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install -g luminus-mcp@0.2.0
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API keys
|
|
63
|
+
|
|
64
|
+
Keyed tools use the same auth model as the Node server. Resolution order is:
|
|
65
|
+
1. environment variables like `ENTSOE_API_KEY`
|
|
66
|
+
2. `~/.luminus/keys.json`
|
|
67
|
+
|
|
68
|
+
Example `~/.luminus/keys.json`:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"ENTSOE_API_KEY": "...",
|
|
73
|
+
"GIE_API_KEY": "...",
|
|
74
|
+
"FINGRID_API_KEY": "..."
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Per-notebook overrides are also supported:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
lum = Luminus(profile="trader", env={"ENTSOE_API_KEY": "..."})
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Quick start
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from luminus import Luminus
|
|
88
|
+
|
|
89
|
+
with Luminus(profile="trader") as lum:
|
|
90
|
+
prices = lum.get_day_ahead_prices(zone="DE")
|
|
91
|
+
df = prices.to_pandas()
|
|
92
|
+
|
|
93
|
+
# Any MCP tool can be called directly
|
|
94
|
+
flows = lum.get_cross_border_flows(from_zone="DE", to_zone="NL")
|
|
95
|
+
site = lum.compare_sites(sites=[
|
|
96
|
+
{"name": "A", "lat": 52.1, "lon": 0.1},
|
|
97
|
+
{"name": "B", "lat": 52.2, "lon": 0.2},
|
|
98
|
+
], country="GB")
|
|
99
|
+
|
|
100
|
+
# GIS-friendly exports
|
|
101
|
+
geojson = site.to_geojson(data_key="rankings")
|
|
102
|
+
|
|
103
|
+
# Batch several calls into one DataFrame
|
|
104
|
+
multi_zone = lum.call_many_to_pandas(
|
|
105
|
+
"get_day_ahead_prices",
|
|
106
|
+
[{"zone": "DE"}, {"zone": "FR"}, {"zone": "NL"}],
|
|
107
|
+
parallel=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# One-shot export helpers
|
|
111
|
+
prices_df = lum.call_tool_to_pandas("get_day_ahead_prices", {"zone": "DE"})
|
|
112
|
+
rankings_geojson = lum.call_tool_to_geojson("compare_sites", {
|
|
113
|
+
"country": "GB",
|
|
114
|
+
"sites": [
|
|
115
|
+
{"label": "A", "lat": 52.1, "lon": 0.1},
|
|
116
|
+
{"label": "B", "lat": 52.2, "lon": 0.2},
|
|
117
|
+
],
|
|
118
|
+
}, data_key="rankings")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Notebook demos
|
|
122
|
+
|
|
123
|
+
Polished notebook demos live in [`examples/`](examples/):
|
|
124
|
+
|
|
125
|
+
- [Trader workflow](examples/trader_workflow.ipynb)
|
|
126
|
+
- [GIS siting workflow](examples/gis_siting_workflow.ipynb)
|
|
127
|
+
- [BESS shortlist workflow](examples/bess_shortlist_workflow.ipynb)
|
|
128
|
+
|
|
129
|
+
## Notebook-first helpers
|
|
130
|
+
|
|
131
|
+
The Python SDK now ships a few opinionated helpers for high-usage analyst flows:
|
|
132
|
+
|
|
133
|
+
- `lum.get_outages_frame(...)`
|
|
134
|
+
- `lum.get_cross_border_flows_many([...])`
|
|
135
|
+
- `lum.get_grid_proximity_substations(...)`
|
|
136
|
+
- `lum.get_grid_proximity_lines(...)`
|
|
137
|
+
- `lum.get_grid_proximity_snapshot(...)`
|
|
138
|
+
- `lum.get_grid_connection_queue_projects(...)`
|
|
139
|
+
- `lum.get_grid_connection_queue_sites(...)`
|
|
140
|
+
- `lum.get_grid_connection_queue_snapshot(...)`
|
|
141
|
+
- `lum.estimate_site_revenue_frame(...)`
|
|
142
|
+
- `lum.estimate_site_revenue_estimate(...)`
|
|
143
|
+
|
|
144
|
+
Example:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
from luminus import GridProximitySnapshot, Luminus, SiteRevenueEstimate
|
|
148
|
+
|
|
149
|
+
with Luminus(profile="gis") as lum:
|
|
150
|
+
outages = lum.get_outages_frame(zone="DE", type="generation")
|
|
151
|
+
flows = lum.get_cross_border_flows_many([("DE", "NL"), ("FR", "DE")])
|
|
152
|
+
substations = lum.get_grid_proximity_substations(lat=52.0, lon=0.1)
|
|
153
|
+
queue = lum.get_grid_connection_queue_projects(connection_site_query="Berkswell")
|
|
154
|
+
revenue = lum.estimate_site_revenue_frame(
|
|
155
|
+
lat=52.0,
|
|
156
|
+
lon=0.1,
|
|
157
|
+
zone="GB",
|
|
158
|
+
technology="bess",
|
|
159
|
+
capacity_mw=20,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
proximity: GridProximitySnapshot = lum.get_grid_proximity_snapshot(lat=52.0, lon=0.1)
|
|
163
|
+
estimate: SiteRevenueEstimate = lum.estimate_site_revenue_estimate(
|
|
164
|
+
lat=52.0,
|
|
165
|
+
lon=0.1,
|
|
166
|
+
zone="GB",
|
|
167
|
+
technology="bess",
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Errors and typed models
|
|
172
|
+
|
|
173
|
+
- Startup failures now raise `LuminusStartupError`.
|
|
174
|
+
- Tool-side configuration failures raise `LuminusConfigurationError`.
|
|
175
|
+
- Tool-side upstream/data-source failures raise `LuminusUpstreamError`.
|
|
176
|
+
- Dynamic whole-surface access still works through `LuminusResult`, and common GIS/revenue flows also expose opt-in typed models.
|
|
177
|
+
|
|
178
|
+
## Notes
|
|
179
|
+
|
|
180
|
+
- Use `lum.list_tools()` to see the live tool surface for the active profile.
|
|
181
|
+
- Use `lum.describe_tool("tool_name")` to inspect the MCP description/schema metadata.
|
|
182
|
+
- Use `lum.call_many()` / `lum.call_many_to_pandas()` for generic multi-zone or multi-site notebook pulls.
|
|
183
|
+
- Use `parallel=True` on batch helpers when you want the SDK to fan out across multiple MCP subprocesses.
|
|
184
|
+
- Use `lum.get_day_ahead_prices_many()` and `lum.get_generation_mix_many()` for common analyst workflows.
|
|
185
|
+
- Use `lum.compare_sites_rankings()` together with `lum.compare_sites_rankings_geojson()` and `lum.compare_sites_rankings_geodataframe()` for ranked siting output.
|
|
186
|
+
- Use the typed snapshots only when they help notebook readability; the raw dynamic MCP surface is still available.
|
|
187
|
+
- Notebook demos live in [`examples/`](examples/).
|
|
188
|
+
- Use `to_geojson()` for lightweight mapping and `to_geodataframe()` when GeoPandas is installed.
|
|
189
|
+
|
|
190
|
+
- Requires `luminus-mcp` to be available on `PATH`, unless you pass an explicit command.
|
|
191
|
+
- By default the client starts `luminus-mcp --profile <profile>`.
|
|
192
|
+
- For local repo development you can point it at the built server directly:
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
lum = Luminus(command=["node", r"C:\Users\skf_s\luminus\dist\index.js"], profile="gis")
|
|
196
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
luminus/__init__.py,sha256=_VkqfBKd9s11gOl8l7o06nRk_k3gzvEdGLQg2pBmuWY,711
|
|
2
|
+
luminus/client.py,sha256=tkP6e82_vu7uxOtHqJseamSgYP55gGd4rfh0wWRI6aw,18759
|
|
3
|
+
luminus/exceptions.py,sha256=-L6budfNM4KIPrpBM6if3eeYDvNvazAZ2xKDCScisVc,787
|
|
4
|
+
luminus/models.py,sha256=3E-FGHpXs5o45DdRRPugUi3SfbFAlnGg1udw9r_9g8M,6205
|
|
5
|
+
luminus/result.py,sha256=h-W1MzkjXSv5G4J5ODbTuiTdNa6ZEJLflQhJfRmOqAA,6371
|
|
6
|
+
luminus_py-0.2.1.dist-info/METADATA,sha256=r9n5k48SiAT-IoV462IGMDspevIkvZ2Vs9okLpuwPdE,6974
|
|
7
|
+
luminus_py-0.2.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
luminus_py-0.2.1.dist-info/top_level.txt,sha256=QZbm58e06XmpdHCp0wmvC8pVwEXD6rFSgZzwbt50IaM,8
|
|
9
|
+
luminus_py-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
luminus
|