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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ luminus