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.
Files changed (53) hide show
  1. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/CHANGELOG.md +16 -0
  2. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/PKG-INFO +4 -1
  3. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/examples/01_basic_capacity_planning.py +1 -0
  4. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/examples/02_scenario_comparison.py +1 -0
  5. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/pyproject.toml +6 -1
  6. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/__init__.py +1 -1
  7. site_calc_investment-1.2.2/site_calc_investment/mcp/__init__.py +15 -0
  8. site_calc_investment-1.2.2/site_calc_investment/mcp/config.py +34 -0
  9. site_calc_investment-1.2.2/site_calc_investment/mcp/data_loaders.py +171 -0
  10. site_calc_investment-1.2.2/site_calc_investment/mcp/scenario.py +515 -0
  11. site_calc_investment-1.2.2/site_calc_investment/mcp/server.py +704 -0
  12. site_calc_investment-1.2.2/tests/test_mcp/__init__.py +0 -0
  13. site_calc_investment-1.2.2/tests/test_mcp/conftest.py +114 -0
  14. site_calc_investment-1.2.2/tests/test_mcp/test_data_loaders.py +118 -0
  15. site_calc_investment-1.2.2/tests/test_mcp/test_integration.py +230 -0
  16. site_calc_investment-1.2.2/tests/test_mcp/test_mcp_production.py +184 -0
  17. site_calc_investment-1.2.2/tests/test_mcp/test_scenario.py +437 -0
  18. site_calc_investment-1.2.2/tests/test_mcp/test_tools.py +441 -0
  19. site_calc_investment-1.2.2/uv.lock +2431 -0
  20. site_calc_investment-1.2.1/uv.lock +0 -1052
  21. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/.github/workflows/ci.yml +0 -0
  22. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/.github/workflows/publish.yml +0 -0
  23. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/.gitignore +0 -0
  24. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/CONTRIBUTING.md +0 -0
  25. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/LICENSE +0 -0
  26. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/MIGRATION_GUIDE.md +0 -0
  27. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/QUICK_START.md +0 -0
  28. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/README.md +0 -0
  29. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/READY_TO_PUBLISH.md +0 -0
  30. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/docs/INVESTMENT_CLIENT_SPEC.md +0 -0
  31. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/examples/03_financial_analysis.py +0 -0
  32. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/publish_to_github.bat +0 -0
  33. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/publish_to_github.sh +0 -0
  34. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/analysis/__init__.py +0 -0
  35. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/analysis/comparison.py +0 -0
  36. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/analysis/financial.py +0 -0
  37. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/api/__init__.py +0 -0
  38. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/api/client.py +0 -0
  39. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/exceptions.py +0 -0
  40. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/__init__.py +0 -0
  41. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/common.py +0 -0
  42. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/devices.py +0 -0
  43. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/requests.py +0 -0
  44. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/site_calc_investment/models/responses.py +0 -0
  45. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/conftest.py +0 -0
  46. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_api_client.py +0 -0
  47. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_common_models.py +0 -0
  48. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_device_models.py +0 -0
  49. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_financial_analysis.py +0 -0
  50. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_production.py +0 -0
  51. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_request_models.py +0 -0
  52. {site_calc_investment-1.2.1 → site_calc_investment-1.2.2}/tests/test_scenario_comparison.py +0 -0
  53. {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.1
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
@@ -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")
@@ -125,6 +125,7 @@ def create_scenario(
125
125
 
126
126
 
127
127
  def main():
128
+ """Run scenario comparison example comparing three battery sizes."""
128
129
  print("=" * 60)
129
130
  print("BATTERY CAPACITY SIZING: SCENARIO COMPARISON")
130
131
  print("=" * 60)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "site-calc-investment"
3
- version = "1.2.1"
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"
@@ -3,7 +3,7 @@
3
3
  Python client for long-term capacity planning and investment ROI analysis.
4
4
  """
5
5
 
6
- __version__ = "1.2.1"
6
+ __version__ = "1.2.2"
7
7
 
8
8
  from site_calc_investment.analysis import (
9
9
  aggregate_annual,
@@ -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