site-calc-investment 1.2.1__tar.gz → 1.2.2__tar.gz
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.
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/CHANGELOG.md +16 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/PKG-INFO +4 -1
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/examples/01_basic_capacity_planning.py +1 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/examples/02_scenario_comparison.py +1 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/pyproject.toml +6 -1
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/__init__.py +1 -1
- site_calc_investment-1.2.2/site_calc_investment/mcp/__init__.py +15 -0
- site_calc_investment-1.2.2/site_calc_investment/mcp/config.py +34 -0
- site_calc_investment-1.2.2/site_calc_investment/mcp/data_loaders.py +171 -0
- site_calc_investment-1.2.2/site_calc_investment/mcp/scenario.py +515 -0
- site_calc_investment-1.2.2/site_calc_investment/mcp/server.py +704 -0
- site_calc_investment-1.2.2/tests/test_mcp/__init__.py +0 -0
- site_calc_investment-1.2.2/tests/test_mcp/conftest.py +114 -0
- site_calc_investment-1.2.2/tests/test_mcp/test_data_loaders.py +118 -0
- site_calc_investment-1.2.2/tests/test_mcp/test_integration.py +230 -0
- site_calc_investment-1.2.2/tests/test_mcp/test_mcp_production.py +184 -0
- site_calc_investment-1.2.2/tests/test_mcp/test_scenario.py +437 -0
- site_calc_investment-1.2.2/tests/test_mcp/test_tools.py +441 -0
- site_calc_investment-1.2.2/uv.lock +2431 -0
- site_calc_investment-1.2.1/uv.lock +0 -1052
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/.github/workflows/ci.yml +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/.github/workflows/publish.yml +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/.gitignore +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/CONTRIBUTING.md +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/LICENSE +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/MIGRATION_GUIDE.md +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/QUICK_START.md +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/README.md +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/READY_TO_PUBLISH.md +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/docs/INVESTMENT_CLIENT_SPEC.md +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/examples/03_financial_analysis.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/publish_to_github.bat +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/publish_to_github.sh +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/analysis/__init__.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/analysis/comparison.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/analysis/financial.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/api/__init__.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/api/client.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/exceptions.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/__init__.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/common.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/devices.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/requests.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/responses.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/conftest.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_api_client.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_common_models.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_device_models.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_financial_analysis.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_production.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_request_models.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_scenario_comparison.py +0 -0
- {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/verify_ready.py +0 -0
|
@@ -5,6 +5,22 @@ All notable changes to the Site-Calc Investment Client will be documented in thi
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.2] - 2026-02-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **MCP Server**: FastMCP-based MCP server exposing 14 tools for LLM-driven investment planning
|
|
12
|
+
- Stateful builder pattern: create scenario -> add devices -> set timespan -> review -> submit -> get results
|
|
13
|
+
- All 10 device types supported with data shorthand (scalar expansion, CSV/JSON file loading)
|
|
14
|
+
- 3 result detail levels: summary, monthly, full
|
|
15
|
+
- Install via `pip install site-calc-investment[mcp]`
|
|
16
|
+
- CLI entry point: `site-calc-investment-mcp`
|
|
17
|
+
- **`get_device_schema` tool**: Returns property schemas for each device type with types, units, and examples
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Package now has `[mcp]` optional dependency group (`fastmcp>=2.0`)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
8
24
|
## [1.2.1] - 2026-02-03
|
|
9
25
|
|
|
10
26
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: site-calc-investment
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.2
|
|
4
4
|
Summary: Python client for Site-Calc investment planning (capacity sizing, ROI analysis)
|
|
5
5
|
Project-URL: Homepage, https://github.com/stranma/site-calc-investment
|
|
6
6
|
Project-URL: Documentation, https://github.com/stranma/site-calc-investment#readme
|
|
@@ -25,6 +25,7 @@ Requires-Dist: numpy>=1.24
|
|
|
25
25
|
Requires-Dist: pydantic>=2.0
|
|
26
26
|
Requires-Dist: python-dateutil>=2.8
|
|
27
27
|
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: fastmcp>=2.0; extra == 'dev'
|
|
28
29
|
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
29
30
|
Requires-Dist: pandas>=2.0; extra == 'dev'
|
|
30
31
|
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
@@ -33,6 +34,8 @@ Requires-Dist: pytest-timeout>=2.2; extra == 'dev'
|
|
|
33
34
|
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
34
35
|
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
35
36
|
Requires-Dist: tzdata>=2024.1; extra == 'dev'
|
|
37
|
+
Provides-Extra: mcp
|
|
38
|
+
Requires-Dist: fastmcp>=2.0; extra == 'mcp'
|
|
36
39
|
Description-Content-Type: text/markdown
|
|
37
40
|
|
|
38
41
|
# Site-Calc Investment Client
|
{site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/examples/01_basic_capacity_planning.py
RENAMED
|
@@ -22,6 +22,7 @@ from site_calc_investment.models.requests import TimeSpanInvestment
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def main():
|
|
25
|
+
"""Run basic capacity planning example with 1-week battery optimization."""
|
|
25
26
|
# Get credentials from environment
|
|
26
27
|
api_url = os.environ.get("INVESTMENT_API_URL_DEV") or os.environ.get("INVESTMENT_API_URL")
|
|
27
28
|
api_key = os.environ.get("INVESTMENT_API_KEY_DEV") or os.environ.get("INVESTMENT_API_KEY")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "site-calc-investment"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.2"
|
|
4
4
|
description = "Python client for Site-Calc investment planning (capacity sizing, ROI analysis)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -29,6 +29,7 @@ dependencies = [
|
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
|
+
mcp = ["fastmcp>=2.0"]
|
|
32
33
|
dev = [
|
|
33
34
|
"pytest>=7.0",
|
|
34
35
|
"pytest-cov>=4.0",
|
|
@@ -38,8 +39,12 @@ dev = [
|
|
|
38
39
|
"mypy>=1.0",
|
|
39
40
|
"pandas>=2.0", # For scenario comparison
|
|
40
41
|
"tzdata>=2024.1", # Timezone data for Windows
|
|
42
|
+
"fastmcp>=2.0", # For MCP server tests
|
|
41
43
|
]
|
|
42
44
|
|
|
45
|
+
[project.scripts]
|
|
46
|
+
site-calc-investment-mcp = "site_calc_investment.mcp:main"
|
|
47
|
+
|
|
43
48
|
[project.urls]
|
|
44
49
|
Homepage = "https://github.com/stranma/site-calc-investment"
|
|
45
50
|
Documentation = "https://github.com/stranma/site-calc-investment#readme"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""MCP server for Site-Calc investment planning.
|
|
2
|
+
|
|
3
|
+
Exposes investment optimization tools to LLM agents via FastMCP.
|
|
4
|
+
Install with: pip install site-calc-investment[mcp]
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from site_calc_investment.mcp.server import mcp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
"""Entry point for the MCP server."""
|
|
12
|
+
mcp.run()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
__all__ = ["main", "mcp"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Configuration for the MCP server, loaded from environment variables."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Config:
|
|
9
|
+
"""MCP server configuration from environment variables."""
|
|
10
|
+
|
|
11
|
+
api_url: str
|
|
12
|
+
api_key: str
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def from_env(cls) -> "Config":
|
|
16
|
+
"""Load configuration from environment variables.
|
|
17
|
+
|
|
18
|
+
:raises ValueError: If required environment variables are missing.
|
|
19
|
+
"""
|
|
20
|
+
api_url = os.environ.get("INVESTMENT_API_URL", "")
|
|
21
|
+
api_key = os.environ.get("INVESTMENT_API_KEY", "")
|
|
22
|
+
|
|
23
|
+
if not api_url:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"INVESTMENT_API_URL environment variable is required. "
|
|
26
|
+
"Set it to the Site-Calc API URL (e.g., http://site-calc-prod-alb-xxx.elb.amazonaws.com)"
|
|
27
|
+
)
|
|
28
|
+
if not api_key:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"INVESTMENT_API_KEY environment variable is required. "
|
|
31
|
+
"Set it to your investment API key (starts with 'inv_')"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return cls(api_url=api_url, api_key=api_key)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Data loading utilities for resolving price/profile shorthand to arrays."""
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Optional, Union
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_price_or_profile(
|
|
10
|
+
value: Union[float, int, list[float], dict[str, Any]],
|
|
11
|
+
expected_length: Optional[int],
|
|
12
|
+
) -> list[float]:
|
|
13
|
+
"""Resolve a price or profile value to a flat list of floats.
|
|
14
|
+
|
|
15
|
+
Accepts:
|
|
16
|
+
- float/int: expanded to constant array of expected_length
|
|
17
|
+
- list[float]: validated length (if expected_length set), returned as-is
|
|
18
|
+
- {"file": "path.csv"}: loaded from CSV (first numeric column)
|
|
19
|
+
- {"file": "path.csv", "column": "price_eur"}: specific column from CSV
|
|
20
|
+
- {"file": "path.json"}: loaded from JSON (flat array)
|
|
21
|
+
|
|
22
|
+
:param value: The value to resolve.
|
|
23
|
+
:param expected_length: Expected array length (from timespan). None skips length validation.
|
|
24
|
+
:returns: List of floats.
|
|
25
|
+
:raises ValueError: If the value cannot be resolved or has wrong length.
|
|
26
|
+
:raises FileNotFoundError: If a referenced file does not exist.
|
|
27
|
+
"""
|
|
28
|
+
if isinstance(value, (int, float)):
|
|
29
|
+
if expected_length is None:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"Cannot expand scalar value without a timespan. Set the timespan first, or provide an explicit array."
|
|
32
|
+
)
|
|
33
|
+
return [float(value)] * expected_length
|
|
34
|
+
|
|
35
|
+
if isinstance(value, list):
|
|
36
|
+
result = [float(v) for v in value]
|
|
37
|
+
if expected_length is not None and len(result) != expected_length:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Array length {len(result)} does not match expected length {expected_length} "
|
|
40
|
+
f"(from timespan). Provide exactly {expected_length} values."
|
|
41
|
+
)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
return _load_from_file(value, expected_length)
|
|
46
|
+
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Unsupported value type: {type(value).__name__}. "
|
|
49
|
+
"Expected a number (flat value), list of numbers, or "
|
|
50
|
+
'{"file": "path.csv"} for file loading.'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_from_file(spec: dict[str, Any], expected_length: Optional[int]) -> list[float]:
|
|
55
|
+
"""Load data from a file reference.
|
|
56
|
+
|
|
57
|
+
:param spec: Dict with "file" key and optional "column" key.
|
|
58
|
+
:param expected_length: Expected array length.
|
|
59
|
+
:returns: List of floats loaded from file.
|
|
60
|
+
"""
|
|
61
|
+
file_path = spec.get("file")
|
|
62
|
+
if not file_path:
|
|
63
|
+
raise ValueError('File reference must include a "file" key with the path.')
|
|
64
|
+
|
|
65
|
+
if not os.path.exists(file_path):
|
|
66
|
+
raise FileNotFoundError(
|
|
67
|
+
f"Data file not found: {file_path}. Provide an absolute path to a CSV or JSON file on the local filesystem."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
71
|
+
column = spec.get("column")
|
|
72
|
+
|
|
73
|
+
if ext == ".json":
|
|
74
|
+
result = _load_json(file_path)
|
|
75
|
+
elif ext in (".csv", ".tsv", ".txt"):
|
|
76
|
+
result = _load_csv(file_path, column)
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError(f"Unsupported file format: '{ext}'. Supported formats: .csv, .tsv, .json")
|
|
79
|
+
|
|
80
|
+
if expected_length is not None and len(result) != expected_length:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"File '{file_path}' has {len(result)} values, but expected {expected_length} "
|
|
83
|
+
f"(from timespan). The file must contain exactly {expected_length} values."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_json(file_path: str) -> list[float]:
|
|
90
|
+
"""Load a flat array from a JSON file."""
|
|
91
|
+
with open(file_path, encoding="utf-8") as f:
|
|
92
|
+
data = json.load(f)
|
|
93
|
+
|
|
94
|
+
if not isinstance(data, list):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"JSON file '{file_path}' must contain a flat array of numbers, but got {type(data).__name__}."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
return [float(v) for v in data]
|
|
101
|
+
except (TypeError, ValueError) as e:
|
|
102
|
+
raise ValueError(f"JSON file '{file_path}' contains non-numeric values: {e}") from e
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_csv(file_path: str, column: Optional[str] = None) -> list[float]:
|
|
106
|
+
"""Load numeric data from a CSV file.
|
|
107
|
+
|
|
108
|
+
If column is specified, reads that column by header name.
|
|
109
|
+
Otherwise, reads the first numeric column.
|
|
110
|
+
"""
|
|
111
|
+
with open(file_path, encoding="utf-8", newline="") as f:
|
|
112
|
+
sample = f.read(8192)
|
|
113
|
+
f.seek(0)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
dialect = csv.Sniffer().sniff(sample)
|
|
117
|
+
except csv.Error:
|
|
118
|
+
dialect = csv.excel # type: ignore[assignment]
|
|
119
|
+
|
|
120
|
+
has_header = csv.Sniffer().has_header(sample)
|
|
121
|
+
f.seek(0)
|
|
122
|
+
|
|
123
|
+
reader = csv.reader(f, dialect)
|
|
124
|
+
|
|
125
|
+
if has_header:
|
|
126
|
+
headers = next(reader)
|
|
127
|
+
if column:
|
|
128
|
+
try:
|
|
129
|
+
col_idx = headers.index(column)
|
|
130
|
+
except ValueError:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"Column '{column}' not found in '{file_path}'. Available columns: {', '.join(headers)}"
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
col_idx = _find_first_numeric_column(headers, file_path)
|
|
136
|
+
else:
|
|
137
|
+
if column:
|
|
138
|
+
raise ValueError(f"Cannot use column='{column}' with '{file_path}': the file has no header row.")
|
|
139
|
+
col_idx = 0
|
|
140
|
+
|
|
141
|
+
values: list[float] = []
|
|
142
|
+
for row_num, row in enumerate(reader, start=2 if has_header else 1):
|
|
143
|
+
if not row or all(cell.strip() == "" for cell in row):
|
|
144
|
+
continue
|
|
145
|
+
if col_idx >= len(row):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Row {row_num} in '{file_path}' has only {len(row)} columns, "
|
|
148
|
+
f"but column index {col_idx} was expected."
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
values.append(float(row[col_idx]))
|
|
152
|
+
except ValueError:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Non-numeric value '{row[col_idx]}' at row {row_num}, column {col_idx} in '{file_path}'."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if not values:
|
|
158
|
+
raise ValueError(f"No data found in '{file_path}'.")
|
|
159
|
+
|
|
160
|
+
return values
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _find_first_numeric_column(headers: list[str], file_path: str) -> int:
|
|
164
|
+
"""Find the first column that looks numeric based on the header name."""
|
|
165
|
+
numeric_hints = ["price", "value", "cost", "demand", "power", "mw", "mwh", "eur", "profile"]
|
|
166
|
+
for i, h in enumerate(headers):
|
|
167
|
+
h_lower = h.lower().strip()
|
|
168
|
+
for hint in numeric_hints:
|
|
169
|
+
if hint in h_lower:
|
|
170
|
+
return i
|
|
171
|
+
return 0
|