site-calc-investment 1.2.0__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.0 → site_calc_investment-1.2.2}/CHANGELOG.md +29 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/PKG-INFO +28 -17
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/QUICK_START.md +1 -1
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/README.md +24 -16
- site_calc_investment-1.2.2/examples/01_basic_capacity_planning.py +174 -0
- site_calc_investment-1.2.2/examples/02_scenario_comparison.py +211 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/pyproject.toml +6 -1
- {site_calc_investment-1.2.0 → 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.0/examples/01_basic_capacity_planning.py +0 -172
- site_calc_investment-1.2.0/examples/02_scenario_comparison.py +0 -197
- site_calc_investment-1.2.0/uv.lock +0 -1052
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/.github/workflows/ci.yml +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/.github/workflows/publish.yml +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/.gitignore +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/CONTRIBUTING.md +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/LICENSE +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/MIGRATION_GUIDE.md +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/READY_TO_PUBLISH.md +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/docs/INVESTMENT_CLIENT_SPEC.md +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/examples/03_financial_analysis.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/publish_to_github.bat +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/publish_to_github.sh +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/analysis/__init__.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/analysis/comparison.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/analysis/financial.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/api/__init__.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/api/client.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/exceptions.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/models/__init__.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/models/common.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/models/devices.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/models/requests.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/site_calc_investment/models/responses.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/tests/conftest.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/tests/test_api_client.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/tests/test_common_models.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/tests/test_device_models.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/tests/test_financial_analysis.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/tests/test_production.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/tests/test_request_models.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/tests/test_scenario_comparison.py +0 -0
- {site_calc_investment-1.2.0 → site_calc_investment-1.2.2}/verify_ready.py +0 -0
|
@@ -5,6 +5,35 @@ 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
|
+
|
|
24
|
+
## [1.2.1] - 2026-02-03
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **README Quick Start**: Fixed example code to use correct model classes and valid parameter values
|
|
28
|
+
- Use `TimeSpanInvestment` instead of `TimeSpan`
|
|
29
|
+
- Use valid `objective` values (`maximize_profit`, `minimize_cost`, `maximize_self_consumption`)
|
|
30
|
+
- Add required `project_lifetime_years` to `InvestmentParameters`
|
|
31
|
+
- Fix `time_limit_seconds` max value (900, not 3600)
|
|
32
|
+
- **Capabilities table**: Corrected timeout from "3600 seconds" to "900 seconds (15 minutes) max"
|
|
33
|
+
- **QUICK_START.md**: Added `pypi` environment name for Trusted Publishing setup
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
8
37
|
## [1.2.0] - 2026-02-03
|
|
9
38
|
|
|
10
39
|
### Changed
|
|
@@ -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
|
|
@@ -52,9 +55,10 @@ from datetime import datetime
|
|
|
52
55
|
from zoneinfo import ZoneInfo
|
|
53
56
|
from site_calc_investment import InvestmentClient
|
|
54
57
|
from site_calc_investment.models import (
|
|
55
|
-
|
|
58
|
+
Resolution, Site, Battery, ElectricityImport, ElectricityExport,
|
|
56
59
|
InvestmentPlanningRequest, InvestmentParameters, OptimizationConfig
|
|
57
60
|
)
|
|
61
|
+
from site_calc_investment.models.requests import TimeSpanInvestment
|
|
58
62
|
|
|
59
63
|
# Initialize client
|
|
60
64
|
client = InvestmentClient(
|
|
@@ -62,13 +66,16 @@ client = InvestmentClient(
|
|
|
62
66
|
api_key="inv_your_api_key_here"
|
|
63
67
|
)
|
|
64
68
|
|
|
65
|
-
# Create
|
|
66
|
-
timespan =
|
|
69
|
+
# Create 1-week planning horizon (1-hour resolution)
|
|
70
|
+
timespan = TimeSpanInvestment(
|
|
67
71
|
start=datetime(2025, 1, 1, tzinfo=ZoneInfo("Europe/Prague")),
|
|
68
|
-
intervals=
|
|
72
|
+
intervals=168, # 1 week = 7 days × 24 hours
|
|
69
73
|
resolution=Resolution.HOUR_1
|
|
70
74
|
)
|
|
71
75
|
|
|
76
|
+
# Generate hourly prices (example: day/night pattern)
|
|
77
|
+
prices = [30.0 if h % 24 < 6 else 80.0 if 8 <= h % 24 < 20 else 50.0 for h in range(168)]
|
|
78
|
+
|
|
72
79
|
# Define devices (NO ancillary_services field)
|
|
73
80
|
battery = Battery(
|
|
74
81
|
name="Battery1",
|
|
@@ -82,14 +89,20 @@ battery = Battery(
|
|
|
82
89
|
|
|
83
90
|
grid_import = ElectricityImport(
|
|
84
91
|
name="GridImport",
|
|
85
|
-
properties={"price":
|
|
92
|
+
properties={"price": prices, "max_import": 10.0}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
grid_export = ElectricityExport(
|
|
96
|
+
name="GridExport",
|
|
97
|
+
properties={"price": prices, "max_export": 10.0}
|
|
86
98
|
)
|
|
87
99
|
|
|
88
|
-
site = Site(site_id="investment_site", devices=[battery, grid_import])
|
|
100
|
+
site = Site(site_id="investment_site", devices=[battery, grid_import, grid_export])
|
|
89
101
|
|
|
90
102
|
# Investment parameters
|
|
91
103
|
inv_params = InvestmentParameters(
|
|
92
104
|
discount_rate=0.05,
|
|
105
|
+
project_lifetime_years=10, # Required field
|
|
93
106
|
device_capital_costs={"Battery1": 500000}, # €500k CAPEX
|
|
94
107
|
device_annual_opex={"Battery1": 5000} # €5k/year O&M
|
|
95
108
|
)
|
|
@@ -100,19 +113,17 @@ request = InvestmentPlanningRequest(
|
|
|
100
113
|
timespan=timespan,
|
|
101
114
|
investment_parameters=inv_params,
|
|
102
115
|
optimization_config=OptimizationConfig(
|
|
103
|
-
objective="
|
|
104
|
-
time_limit_seconds=
|
|
116
|
+
objective="maximize_profit", # Options: maximize_profit, minimize_cost, maximize_self_consumption
|
|
117
|
+
time_limit_seconds=300 # Max 900 seconds (15 min)
|
|
105
118
|
)
|
|
106
119
|
)
|
|
107
120
|
|
|
108
121
|
job = client.create_planning_job(request)
|
|
109
|
-
result = client.wait_for_completion(job.job_id, poll_interval=
|
|
122
|
+
result = client.wait_for_completion(job.job_id, poll_interval=5, timeout=600)
|
|
110
123
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
print(f"
|
|
114
|
-
print(f"IRR: {metrics.irr*100:.2f}%")
|
|
115
|
-
print(f"Payback: {metrics.payback_period_years:.1f} years")
|
|
124
|
+
print(f"Status: {result.status}")
|
|
125
|
+
print(f"Solver: {result.summary.solver_status}")
|
|
126
|
+
print(f"Profit: €{result.summary.expected_profit:,.2f}")
|
|
116
127
|
```
|
|
117
128
|
|
|
118
129
|
## Features
|
|
@@ -135,7 +146,7 @@ print(f"Payback: {metrics.payback_period_years:.1f} years")
|
|
|
135
146
|
| Resolution | 1-hour only |
|
|
136
147
|
| ANS Support | No |
|
|
137
148
|
| Binary Variables | Relaxed to continuous |
|
|
138
|
-
| Timeout |
|
|
149
|
+
| Timeout | 900 seconds (15 minutes) max |
|
|
139
150
|
|
|
140
151
|
## Supported Devices
|
|
141
152
|
|
|
@@ -203,7 +214,7 @@ print(comparison) # DataFrame with NPV, IRR, costs, revenues
|
|
|
203
214
|
|
|
204
215
|
## Documentation
|
|
205
216
|
|
|
206
|
-
Full documentation available at: https://
|
|
217
|
+
Full documentation available at: https://github.com/stranma/site-calc-investment#readme
|
|
207
218
|
|
|
208
219
|
## Examples
|
|
209
220
|
|
|
@@ -114,7 +114,7 @@ To enable `pip install site-calc-investment`:
|
|
|
114
114
|
- Owner: `YOUR-GITHUB-USERNAME`
|
|
115
115
|
- Repository name: `site-calc-investment`
|
|
116
116
|
- Workflow name: `publish.yml`
|
|
117
|
-
- Environment name:
|
|
117
|
+
- Environment name: `pypi`
|
|
118
118
|
|
|
119
119
|
3. **Done!** No API tokens needed (uses trusted publishing)
|
|
120
120
|
|
|
@@ -15,9 +15,10 @@ from datetime import datetime
|
|
|
15
15
|
from zoneinfo import ZoneInfo
|
|
16
16
|
from site_calc_investment import InvestmentClient
|
|
17
17
|
from site_calc_investment.models import (
|
|
18
|
-
|
|
18
|
+
Resolution, Site, Battery, ElectricityImport, ElectricityExport,
|
|
19
19
|
InvestmentPlanningRequest, InvestmentParameters, OptimizationConfig
|
|
20
20
|
)
|
|
21
|
+
from site_calc_investment.models.requests import TimeSpanInvestment
|
|
21
22
|
|
|
22
23
|
# Initialize client
|
|
23
24
|
client = InvestmentClient(
|
|
@@ -25,13 +26,16 @@ client = InvestmentClient(
|
|
|
25
26
|
api_key="inv_your_api_key_here"
|
|
26
27
|
)
|
|
27
28
|
|
|
28
|
-
# Create
|
|
29
|
-
timespan =
|
|
29
|
+
# Create 1-week planning horizon (1-hour resolution)
|
|
30
|
+
timespan = TimeSpanInvestment(
|
|
30
31
|
start=datetime(2025, 1, 1, tzinfo=ZoneInfo("Europe/Prague")),
|
|
31
|
-
intervals=
|
|
32
|
+
intervals=168, # 1 week = 7 days × 24 hours
|
|
32
33
|
resolution=Resolution.HOUR_1
|
|
33
34
|
)
|
|
34
35
|
|
|
36
|
+
# Generate hourly prices (example: day/night pattern)
|
|
37
|
+
prices = [30.0 if h % 24 < 6 else 80.0 if 8 <= h % 24 < 20 else 50.0 for h in range(168)]
|
|
38
|
+
|
|
35
39
|
# Define devices (NO ancillary_services field)
|
|
36
40
|
battery = Battery(
|
|
37
41
|
name="Battery1",
|
|
@@ -45,14 +49,20 @@ battery = Battery(
|
|
|
45
49
|
|
|
46
50
|
grid_import = ElectricityImport(
|
|
47
51
|
name="GridImport",
|
|
48
|
-
properties={"price":
|
|
52
|
+
properties={"price": prices, "max_import": 10.0}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
grid_export = ElectricityExport(
|
|
56
|
+
name="GridExport",
|
|
57
|
+
properties={"price": prices, "max_export": 10.0}
|
|
49
58
|
)
|
|
50
59
|
|
|
51
|
-
site = Site(site_id="investment_site", devices=[battery, grid_import])
|
|
60
|
+
site = Site(site_id="investment_site", devices=[battery, grid_import, grid_export])
|
|
52
61
|
|
|
53
62
|
# Investment parameters
|
|
54
63
|
inv_params = InvestmentParameters(
|
|
55
64
|
discount_rate=0.05,
|
|
65
|
+
project_lifetime_years=10, # Required field
|
|
56
66
|
device_capital_costs={"Battery1": 500000}, # €500k CAPEX
|
|
57
67
|
device_annual_opex={"Battery1": 5000} # €5k/year O&M
|
|
58
68
|
)
|
|
@@ -63,19 +73,17 @@ request = InvestmentPlanningRequest(
|
|
|
63
73
|
timespan=timespan,
|
|
64
74
|
investment_parameters=inv_params,
|
|
65
75
|
optimization_config=OptimizationConfig(
|
|
66
|
-
objective="
|
|
67
|
-
time_limit_seconds=
|
|
76
|
+
objective="maximize_profit", # Options: maximize_profit, minimize_cost, maximize_self_consumption
|
|
77
|
+
time_limit_seconds=300 # Max 900 seconds (15 min)
|
|
68
78
|
)
|
|
69
79
|
)
|
|
70
80
|
|
|
71
81
|
job = client.create_planning_job(request)
|
|
72
|
-
result = client.wait_for_completion(job.job_id, poll_interval=
|
|
82
|
+
result = client.wait_for_completion(job.job_id, poll_interval=5, timeout=600)
|
|
73
83
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
print(f"
|
|
77
|
-
print(f"IRR: {metrics.irr*100:.2f}%")
|
|
78
|
-
print(f"Payback: {metrics.payback_period_years:.1f} years")
|
|
84
|
+
print(f"Status: {result.status}")
|
|
85
|
+
print(f"Solver: {result.summary.solver_status}")
|
|
86
|
+
print(f"Profit: €{result.summary.expected_profit:,.2f}")
|
|
79
87
|
```
|
|
80
88
|
|
|
81
89
|
## Features
|
|
@@ -98,7 +106,7 @@ print(f"Payback: {metrics.payback_period_years:.1f} years")
|
|
|
98
106
|
| Resolution | 1-hour only |
|
|
99
107
|
| ANS Support | No |
|
|
100
108
|
| Binary Variables | Relaxed to continuous |
|
|
101
|
-
| Timeout |
|
|
109
|
+
| Timeout | 900 seconds (15 minutes) max |
|
|
102
110
|
|
|
103
111
|
## Supported Devices
|
|
104
112
|
|
|
@@ -166,7 +174,7 @@ print(comparison) # DataFrame with NPV, IRR, costs, revenues
|
|
|
166
174
|
|
|
167
175
|
## Documentation
|
|
168
176
|
|
|
169
|
-
Full documentation available at: https://
|
|
177
|
+
Full documentation available at: https://github.com/stranma/site-calc-investment#readme
|
|
170
178
|
|
|
171
179
|
## Examples
|
|
172
180
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Basic Capacity Planning Example
|
|
2
|
+
|
|
3
|
+
This example demonstrates a simple 1-week battery optimization
|
|
4
|
+
for capacity sizing and investment ROI analysis.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
from site_calc_investment import (
|
|
12
|
+
Battery,
|
|
13
|
+
ElectricityExport,
|
|
14
|
+
ElectricityImport,
|
|
15
|
+
InvestmentClient,
|
|
16
|
+
InvestmentParameters,
|
|
17
|
+
InvestmentPlanningRequest,
|
|
18
|
+
OptimizationConfig,
|
|
19
|
+
Site,
|
|
20
|
+
)
|
|
21
|
+
from site_calc_investment.models.requests import TimeSpanInvestment
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
"""Run basic capacity planning example with 1-week battery optimization."""
|
|
26
|
+
# Get credentials from environment
|
|
27
|
+
api_url = os.environ.get("INVESTMENT_API_URL_DEV") or os.environ.get("INVESTMENT_API_URL")
|
|
28
|
+
api_key = os.environ.get("INVESTMENT_API_KEY_DEV") or os.environ.get("INVESTMENT_API_KEY")
|
|
29
|
+
|
|
30
|
+
if not api_url or not api_key:
|
|
31
|
+
print("ERROR: Set INVESTMENT_API_URL and INVESTMENT_API_KEY environment variables")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Initialize client
|
|
35
|
+
client = InvestmentClient(
|
|
36
|
+
base_url=api_url,
|
|
37
|
+
api_key=api_key,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Create 1-week planning horizon (1-hour resolution)
|
|
41
|
+
timespan = TimeSpanInvestment(
|
|
42
|
+
start=datetime(2025, 1, 1, tzinfo=ZoneInfo("Europe/Prague")),
|
|
43
|
+
intervals=168, # 1 week = 7 days x 24 hours
|
|
44
|
+
)
|
|
45
|
+
print(f"Planning horizon: {timespan.intervals} hourly intervals ({timespan.intervals / 24:.0f} days)")
|
|
46
|
+
|
|
47
|
+
# Generate price profile with day/night pattern
|
|
48
|
+
prices = []
|
|
49
|
+
for hour in range(168):
|
|
50
|
+
hour_of_day = hour % 24
|
|
51
|
+
if 9 <= hour_of_day <= 20:
|
|
52
|
+
prices.append(80.0) # Day: high price
|
|
53
|
+
else:
|
|
54
|
+
prices.append(30.0) # Night: low price
|
|
55
|
+
|
|
56
|
+
print(f"Price profile generated: {len(prices)} values")
|
|
57
|
+
print(" Day price: EUR 80/MWh, Night price: EUR 30/MWh")
|
|
58
|
+
|
|
59
|
+
# Define 10 MW / 20 MWh battery (2-hour duration)
|
|
60
|
+
battery = Battery(
|
|
61
|
+
name="Battery1",
|
|
62
|
+
properties={
|
|
63
|
+
"capacity": 20.0, # MWh
|
|
64
|
+
"max_power": 10.0, # MW (2-hour discharge)
|
|
65
|
+
"efficiency": 0.90, # 90% round-trip
|
|
66
|
+
"initial_soc": 0.5, # Start at 50%
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Market devices (grid connections)
|
|
71
|
+
grid_import = ElectricityImport(
|
|
72
|
+
name="GridImport",
|
|
73
|
+
properties={"price": prices, "max_import": 20.0},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
grid_export = ElectricityExport(
|
|
77
|
+
name="GridExport",
|
|
78
|
+
properties={"price": prices, "max_export": 20.0},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Create site
|
|
82
|
+
site = Site(
|
|
83
|
+
site_id="battery_investment_site",
|
|
84
|
+
description="Battery capacity planning example",
|
|
85
|
+
devices=[battery, grid_import, grid_export],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Investment parameters
|
|
89
|
+
inv_params = InvestmentParameters(
|
|
90
|
+
discount_rate=0.05, # 5% discount rate
|
|
91
|
+
project_lifetime_years=10, # Required field
|
|
92
|
+
device_capital_costs={
|
|
93
|
+
"Battery1": 2_000_000 # EUR 2M CAPEX (EUR 100/kWh)
|
|
94
|
+
},
|
|
95
|
+
device_annual_opex={
|
|
96
|
+
"Battery1": 20_000 # EUR 20k/year O&M
|
|
97
|
+
},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Create optimization request
|
|
101
|
+
request = InvestmentPlanningRequest(
|
|
102
|
+
sites=[site],
|
|
103
|
+
timespan=timespan,
|
|
104
|
+
investment_parameters=inv_params,
|
|
105
|
+
optimization_config=OptimizationConfig(
|
|
106
|
+
objective="maximize_profit", # Options: maximize_profit, minimize_cost, maximize_self_consumption
|
|
107
|
+
time_limit_seconds=300, # 5 minutes max
|
|
108
|
+
relax_binary_variables=True,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
print("\n" + "=" * 60)
|
|
113
|
+
print("Submitting optimization job...")
|
|
114
|
+
print("=" * 60)
|
|
115
|
+
|
|
116
|
+
# Submit job
|
|
117
|
+
job = client.create_planning_job(request)
|
|
118
|
+
print(f"\nJob ID: {job.job_id}")
|
|
119
|
+
print(f"Status: {job.status}")
|
|
120
|
+
|
|
121
|
+
# Wait for completion
|
|
122
|
+
print("\nWaiting for optimization to complete...")
|
|
123
|
+
|
|
124
|
+
result = client.wait_for_completion(
|
|
125
|
+
job.job_id,
|
|
126
|
+
poll_interval=5, # Check every 5 seconds
|
|
127
|
+
timeout=600, # 10 minute maximum
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
print("\n" + "=" * 60)
|
|
131
|
+
print("OPTIMIZATION RESULTS")
|
|
132
|
+
print("=" * 60)
|
|
133
|
+
|
|
134
|
+
# Summary
|
|
135
|
+
summary = result.summary
|
|
136
|
+
print(f"\nSolver Status: {summary.solver_status}")
|
|
137
|
+
print(f"Solve Time: {summary.solve_time_seconds:.1f}s")
|
|
138
|
+
|
|
139
|
+
if summary.expected_profit is not None:
|
|
140
|
+
print(f"Expected Profit: EUR {summary.expected_profit:,.2f}")
|
|
141
|
+
|
|
142
|
+
# Investment metrics (if available)
|
|
143
|
+
if result.investment_metrics:
|
|
144
|
+
metrics = result.investment_metrics
|
|
145
|
+
print("\nINVESTMENT METRICS:")
|
|
146
|
+
if metrics.total_revenue_10y is not None:
|
|
147
|
+
print(f" Total Revenue (10y): EUR {metrics.total_revenue_10y:>15,.0f}")
|
|
148
|
+
if metrics.total_costs_10y is not None:
|
|
149
|
+
print(f" Total Costs (10y): EUR {metrics.total_costs_10y:>15,.0f}")
|
|
150
|
+
if metrics.npv is not None:
|
|
151
|
+
print(f" NPV: EUR {metrics.npv:>15,.0f}")
|
|
152
|
+
if metrics.irr is not None:
|
|
153
|
+
print(f" IRR: {metrics.irr * 100:>15.2f}%")
|
|
154
|
+
if metrics.payback_period_years is not None:
|
|
155
|
+
print(f" Payback Period: {metrics.payback_period_years:>15.1f} years")
|
|
156
|
+
|
|
157
|
+
# Device schedules (first 24 hours)
|
|
158
|
+
site_result = result.sites.get("battery_investment_site")
|
|
159
|
+
if site_result:
|
|
160
|
+
battery_schedule = site_result.device_schedules.get("Battery1")
|
|
161
|
+
if battery_schedule and battery_schedule.flows.get("electricity"):
|
|
162
|
+
print("\nBATTERY OPERATION (First 24 hours):")
|
|
163
|
+
el_flow = battery_schedule.flows["electricity"]
|
|
164
|
+
soc = battery_schedule.soc or []
|
|
165
|
+
|
|
166
|
+
for hour in range(min(24, len(el_flow))):
|
|
167
|
+
soc_str = f"{soc[hour]:.1%}" if hour < len(soc) else "N/A"
|
|
168
|
+
print(f" Hour {hour:2d}: Power {el_flow[hour]:>7.2f} MW | SOC {soc_str}")
|
|
169
|
+
|
|
170
|
+
print("\n" + "=" * 60)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Scenario Comparison Example
|
|
2
|
+
|
|
3
|
+
This example compares three different battery sizes to find the optimal
|
|
4
|
+
capacity for investment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
|
|
11
|
+
from site_calc_investment import (
|
|
12
|
+
Battery,
|
|
13
|
+
ElectricityExport,
|
|
14
|
+
ElectricityImport,
|
|
15
|
+
InvestmentClient,
|
|
16
|
+
InvestmentParameters,
|
|
17
|
+
InvestmentPlanningRequest,
|
|
18
|
+
OptimizationConfig,
|
|
19
|
+
Site,
|
|
20
|
+
compare_scenarios,
|
|
21
|
+
)
|
|
22
|
+
from site_calc_investment.models.requests import TimeSpanInvestment
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_prices(days: int = 7):
|
|
26
|
+
"""Create price profile with daily pattern."""
|
|
27
|
+
prices = []
|
|
28
|
+
for day in range(days):
|
|
29
|
+
for hour in range(24):
|
|
30
|
+
if 9 <= hour <= 20:
|
|
31
|
+
prices.append(80.0) # Day: high price
|
|
32
|
+
else:
|
|
33
|
+
prices.append(30.0) # Night: low price
|
|
34
|
+
return prices
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_scenario(
|
|
38
|
+
client: InvestmentClient,
|
|
39
|
+
capacity_mwh: float,
|
|
40
|
+
prices: list,
|
|
41
|
+
timespan: TimeSpanInvestment,
|
|
42
|
+
) -> tuple:
|
|
43
|
+
"""Create and run a scenario with given battery capacity.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
(scenario_name, result)
|
|
47
|
+
"""
|
|
48
|
+
scenario_name = f"{capacity_mwh:.0f} MWh Battery"
|
|
49
|
+
print(f"\n{'=' * 60}")
|
|
50
|
+
print(f"SCENARIO: {scenario_name}")
|
|
51
|
+
print(f"{'=' * 60}")
|
|
52
|
+
|
|
53
|
+
# Battery sized for 2-hour duration
|
|
54
|
+
battery = Battery(
|
|
55
|
+
name="Battery1",
|
|
56
|
+
properties={
|
|
57
|
+
"capacity": capacity_mwh,
|
|
58
|
+
"max_power": capacity_mwh / 2, # 2-hour discharge
|
|
59
|
+
"efficiency": 0.90,
|
|
60
|
+
"initial_soc": 0.5,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
grid_import = ElectricityImport(
|
|
65
|
+
name="GridImport",
|
|
66
|
+
properties={"price": prices, "max_import": 50.0},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
grid_export = ElectricityExport(
|
|
70
|
+
name="GridExport",
|
|
71
|
+
properties={"price": prices, "max_export": 50.0},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
site = Site(
|
|
75
|
+
site_id=f"site_{capacity_mwh:.0f}mwh",
|
|
76
|
+
devices=[battery, grid_import, grid_export],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Capital cost: EUR 100/kWh
|
|
80
|
+
capex = capacity_mwh * 1000 * 100 # EUR 100/kWh
|
|
81
|
+
|
|
82
|
+
# O&M: EUR 1/kWh/year
|
|
83
|
+
opex = capacity_mwh * 1000 * 1 # EUR 1/kWh/year
|
|
84
|
+
|
|
85
|
+
inv_params = InvestmentParameters(
|
|
86
|
+
discount_rate=0.05,
|
|
87
|
+
project_lifetime_years=10, # Required field
|
|
88
|
+
device_capital_costs={"Battery1": capex},
|
|
89
|
+
device_annual_opex={"Battery1": opex},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
request = InvestmentPlanningRequest(
|
|
93
|
+
sites=[site],
|
|
94
|
+
timespan=timespan,
|
|
95
|
+
investment_parameters=inv_params,
|
|
96
|
+
optimization_config=OptimizationConfig(
|
|
97
|
+
objective="maximize_profit", # Valid options: maximize_profit, minimize_cost, maximize_self_consumption
|
|
98
|
+
time_limit_seconds=300,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
print(f" Capacity: {capacity_mwh:.0f} MWh")
|
|
103
|
+
print(f" Power: {capacity_mwh / 2:.0f} MW")
|
|
104
|
+
print(f" CAPEX: EUR {capex:,.0f}")
|
|
105
|
+
print(f" Annual O&M: EUR {opex:,.0f}")
|
|
106
|
+
|
|
107
|
+
job = client.create_planning_job(request)
|
|
108
|
+
print(f"\n Job ID: {job.job_id}")
|
|
109
|
+
print(" Waiting for completion...")
|
|
110
|
+
|
|
111
|
+
result = client.wait_for_completion(job.job_id, poll_interval=5, timeout=600)
|
|
112
|
+
|
|
113
|
+
print(f" Completed in {result.summary.solve_time_seconds:.0f}s")
|
|
114
|
+
|
|
115
|
+
if result.investment_metrics:
|
|
116
|
+
metrics = result.investment_metrics
|
|
117
|
+
npv_str = f"EUR {metrics.npv:,.0f}" if metrics.npv else "N/A"
|
|
118
|
+
irr_str = f"{metrics.irr * 100:.2f}%" if metrics.irr else "N/A"
|
|
119
|
+
payback_str = f"{metrics.payback_period_years:.1f} years" if metrics.payback_period_years else "N/A"
|
|
120
|
+
print(f"\n NPV: {npv_str}")
|
|
121
|
+
print(f" IRR: {irr_str}")
|
|
122
|
+
print(f" Payback: {payback_str}")
|
|
123
|
+
|
|
124
|
+
return scenario_name, result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main():
|
|
128
|
+
"""Run scenario comparison example comparing three battery sizes."""
|
|
129
|
+
print("=" * 60)
|
|
130
|
+
print("BATTERY CAPACITY SIZING: SCENARIO COMPARISON")
|
|
131
|
+
print("=" * 60)
|
|
132
|
+
print("\nComparing three battery sizes over 1-week horizon")
|
|
133
|
+
print("Goal: Find optimal capacity for maximum profit")
|
|
134
|
+
|
|
135
|
+
# Get credentials from environment
|
|
136
|
+
api_url = os.environ.get("INVESTMENT_API_URL_DEV") or os.environ.get("INVESTMENT_API_URL")
|
|
137
|
+
api_key = os.environ.get("INVESTMENT_API_KEY_DEV") or os.environ.get("INVESTMENT_API_KEY")
|
|
138
|
+
|
|
139
|
+
if not api_url or not api_key:
|
|
140
|
+
print("\nERROR: Set INVESTMENT_API_URL and INVESTMENT_API_KEY environment variables")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# Initialize client
|
|
144
|
+
client = InvestmentClient(
|
|
145
|
+
base_url=api_url,
|
|
146
|
+
api_key=api_key,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# 1-week planning
|
|
150
|
+
timespan = TimeSpanInvestment(
|
|
151
|
+
start=datetime(2025, 1, 1, tzinfo=ZoneInfo("Europe/Prague")),
|
|
152
|
+
intervals=168, # 1 week
|
|
153
|
+
)
|
|
154
|
+
prices = create_prices(days=7)
|
|
155
|
+
|
|
156
|
+
print(f"\nPrices: {len(prices)} hourly values")
|
|
157
|
+
print(" Day price: EUR 80/MWh, Night price: EUR 30/MWh")
|
|
158
|
+
|
|
159
|
+
# Test three capacities
|
|
160
|
+
capacities = [10.0, 20.0, 30.0] # MWh
|
|
161
|
+
|
|
162
|
+
scenarios = []
|
|
163
|
+
for capacity in capacities:
|
|
164
|
+
name, result = create_scenario(client, capacity, prices, timespan)
|
|
165
|
+
scenarios.append((name, result))
|
|
166
|
+
|
|
167
|
+
# Compare scenarios
|
|
168
|
+
print("\n" + "=" * 60)
|
|
169
|
+
print("SCENARIO COMPARISON")
|
|
170
|
+
print("=" * 60)
|
|
171
|
+
|
|
172
|
+
names = [s[0] for s in scenarios]
|
|
173
|
+
results = [s[1] for s in scenarios]
|
|
174
|
+
|
|
175
|
+
comparison = compare_scenarios(results, names=names)
|
|
176
|
+
|
|
177
|
+
# Print comparison table
|
|
178
|
+
print(f"\n{'Scenario':<20} {'Profit':>15} {'NPV':>15} {'IRR':>10}")
|
|
179
|
+
print("-" * 65)
|
|
180
|
+
|
|
181
|
+
for i, name in enumerate(comparison["names"]):
|
|
182
|
+
profit = comparison.get("profit", [None] * len(names))[i]
|
|
183
|
+
npv = comparison.get("npv", [None] * len(names))[i]
|
|
184
|
+
irr = comparison.get("irr", [None] * len(names))[i]
|
|
185
|
+
|
|
186
|
+
profit_str = f"EUR {profit:,.0f}" if profit is not None else "N/A"
|
|
187
|
+
npv_str = f"EUR {npv:,.0f}" if npv is not None else "N/A"
|
|
188
|
+
irr_str = f"{irr * 100:.2f}%" if irr is not None else "N/A"
|
|
189
|
+
|
|
190
|
+
print(f"{name:<20} {profit_str:>15} {npv_str:>15} {irr_str:>10}")
|
|
191
|
+
|
|
192
|
+
# Find optimal by profit
|
|
193
|
+
profit_values = comparison.get("profit", [])
|
|
194
|
+
if profit_values:
|
|
195
|
+
valid_profits = [(i, v) for i, v in enumerate(profit_values) if v is not None]
|
|
196
|
+
if valid_profits:
|
|
197
|
+
best_idx, best_profit = max(valid_profits, key=lambda x: x[1])
|
|
198
|
+
best_name = comparison["names"][best_idx]
|
|
199
|
+
|
|
200
|
+
print("\n" + "=" * 60)
|
|
201
|
+
print(f"OPTIMAL CONFIGURATION: {best_name}")
|
|
202
|
+
print("=" * 60)
|
|
203
|
+
print(f" Profit: EUR {best_profit:,.0f}")
|
|
204
|
+
else:
|
|
205
|
+
print("\nNo profit data available for comparison")
|
|
206
|
+
|
|
207
|
+
print("=" * 60)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
main()
|
|
@@ -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"
|