site-calc-investment 1.2.0__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.
- site_calc_investment/__init__.py +100 -0
- site_calc_investment/analysis/__init__.py +17 -0
- site_calc_investment/analysis/comparison.py +121 -0
- site_calc_investment/analysis/financial.py +202 -0
- site_calc_investment/api/__init__.py +5 -0
- site_calc_investment/api/client.py +442 -0
- site_calc_investment/exceptions.py +91 -0
- site_calc_investment/models/__init__.py +84 -0
- site_calc_investment/models/common.py +174 -0
- site_calc_investment/models/devices.py +263 -0
- site_calc_investment/models/requests.py +133 -0
- site_calc_investment/models/responses.py +105 -0
- site_calc_investment-1.2.0.dist-info/METADATA +256 -0
- site_calc_investment-1.2.0.dist-info/RECORD +16 -0
- site_calc_investment-1.2.0.dist-info/WHEEL +4 -0
- site_calc_investment-1.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Site-Calc Investment Client
|
|
2
|
+
|
|
3
|
+
Python client for long-term capacity planning and investment ROI analysis.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "1.2.0"
|
|
7
|
+
|
|
8
|
+
from site_calc_investment.analysis import (
|
|
9
|
+
aggregate_annual,
|
|
10
|
+
calculate_irr,
|
|
11
|
+
calculate_npv,
|
|
12
|
+
calculate_payback_period,
|
|
13
|
+
compare_scenarios,
|
|
14
|
+
)
|
|
15
|
+
from site_calc_investment.api.client import InvestmentClient
|
|
16
|
+
from site_calc_investment.exceptions import (
|
|
17
|
+
ApiError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
ForbiddenFeatureError,
|
|
20
|
+
JobNotFoundError,
|
|
21
|
+
LimitExceededError,
|
|
22
|
+
OptimizationError,
|
|
23
|
+
SiteCalcError,
|
|
24
|
+
TimeoutError,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
from site_calc_investment.models import (
|
|
28
|
+
CHP,
|
|
29
|
+
# Device models (NO ancillary_services)
|
|
30
|
+
Battery,
|
|
31
|
+
ElectricityDemand,
|
|
32
|
+
ElectricityExport,
|
|
33
|
+
ElectricityImport,
|
|
34
|
+
GasImport,
|
|
35
|
+
HeatAccumulator,
|
|
36
|
+
HeatDemand,
|
|
37
|
+
HeatExport,
|
|
38
|
+
InvestmentMetrics,
|
|
39
|
+
InvestmentParameters,
|
|
40
|
+
# Request models
|
|
41
|
+
InvestmentPlanningRequest,
|
|
42
|
+
InvestmentPlanningResponse,
|
|
43
|
+
# Response models
|
|
44
|
+
Job,
|
|
45
|
+
Location,
|
|
46
|
+
OptimizationConfig,
|
|
47
|
+
Photovoltaic,
|
|
48
|
+
Resolution,
|
|
49
|
+
Schedule,
|
|
50
|
+
# Site and configuration
|
|
51
|
+
Site,
|
|
52
|
+
# Core models
|
|
53
|
+
TimeSpan,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
# Client
|
|
58
|
+
"InvestmentClient",
|
|
59
|
+
# Core
|
|
60
|
+
"TimeSpan",
|
|
61
|
+
"Resolution",
|
|
62
|
+
"Location",
|
|
63
|
+
# Devices
|
|
64
|
+
"Battery",
|
|
65
|
+
"CHP",
|
|
66
|
+
"HeatAccumulator",
|
|
67
|
+
"Photovoltaic",
|
|
68
|
+
"HeatDemand",
|
|
69
|
+
"ElectricityDemand",
|
|
70
|
+
"ElectricityImport",
|
|
71
|
+
"ElectricityExport",
|
|
72
|
+
"GasImport",
|
|
73
|
+
"HeatExport",
|
|
74
|
+
# Configuration
|
|
75
|
+
"Site",
|
|
76
|
+
"Schedule",
|
|
77
|
+
"InvestmentParameters",
|
|
78
|
+
"OptimizationConfig",
|
|
79
|
+
# Requests/Responses
|
|
80
|
+
"InvestmentPlanningRequest",
|
|
81
|
+
"Job",
|
|
82
|
+
"InvestmentPlanningResponse",
|
|
83
|
+
"InvestmentMetrics",
|
|
84
|
+
# Analysis
|
|
85
|
+
"calculate_npv",
|
|
86
|
+
"calculate_irr",
|
|
87
|
+
"calculate_payback_period",
|
|
88
|
+
"aggregate_annual",
|
|
89
|
+
"compare_scenarios",
|
|
90
|
+
# Exceptions
|
|
91
|
+
"SiteCalcError",
|
|
92
|
+
"ApiError",
|
|
93
|
+
"ValidationError",
|
|
94
|
+
"AuthenticationError",
|
|
95
|
+
"ForbiddenFeatureError",
|
|
96
|
+
"LimitExceededError",
|
|
97
|
+
"TimeoutError",
|
|
98
|
+
"OptimizationError",
|
|
99
|
+
"JobNotFoundError",
|
|
100
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Financial analysis helpers for investment planning."""
|
|
2
|
+
|
|
3
|
+
from site_calc_investment.analysis.comparison import compare_scenarios
|
|
4
|
+
from site_calc_investment.analysis.financial import (
|
|
5
|
+
aggregate_annual,
|
|
6
|
+
calculate_irr,
|
|
7
|
+
calculate_npv,
|
|
8
|
+
calculate_payback_period,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"calculate_npv",
|
|
13
|
+
"calculate_irr",
|
|
14
|
+
"calculate_payback_period",
|
|
15
|
+
"aggregate_annual",
|
|
16
|
+
"compare_scenarios",
|
|
17
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Scenario comparison utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from site_calc_investment.models.responses import InvestmentPlanningResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def compare_scenarios(
|
|
9
|
+
scenarios: List[InvestmentPlanningResponse],
|
|
10
|
+
names: Optional[List[str]] = None,
|
|
11
|
+
) -> Dict[str, Any]:
|
|
12
|
+
"""Compare multiple optimization scenarios.
|
|
13
|
+
|
|
14
|
+
Extracts key metrics from each scenario for easy comparison.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
scenarios: List of optimization results
|
|
18
|
+
names: Optional names for each scenario (default: "Scenario 1", "Scenario 2", ...)
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Dictionary with comparison data suitable for printing or DataFrame conversion
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> results = [result_5mw, result_10mw, result_15mw]
|
|
25
|
+
>>> comparison = compare_scenarios(results, names=["5 MW", "10 MW", "15 MW"])
|
|
26
|
+
>>> for name, metrics in zip(comparison["names"], comparison["npv"]):
|
|
27
|
+
... print(f"{name}: NPV = €{metrics:,.0f}")
|
|
28
|
+
5 MW: NPV = €1,250,000
|
|
29
|
+
10 MW: NPV = €1,850,000
|
|
30
|
+
15 MW: NPV = €1,600,000
|
|
31
|
+
"""
|
|
32
|
+
if not scenarios:
|
|
33
|
+
raise ValueError("At least one scenario is required")
|
|
34
|
+
|
|
35
|
+
if names is None:
|
|
36
|
+
names = [f"Scenario {i + 1}" for i in range(len(scenarios))]
|
|
37
|
+
|
|
38
|
+
if len(names) != len(scenarios):
|
|
39
|
+
raise ValueError(f"Number of names ({len(names)}) must match number of scenarios ({len(scenarios)})")
|
|
40
|
+
|
|
41
|
+
comparison: Dict[str, Any] = {
|
|
42
|
+
"names": names,
|
|
43
|
+
"total_revenue": [],
|
|
44
|
+
"total_costs": [],
|
|
45
|
+
"profit": [],
|
|
46
|
+
"npv": [],
|
|
47
|
+
"irr": [],
|
|
48
|
+
"payback_years": [],
|
|
49
|
+
"solve_time_seconds": [],
|
|
50
|
+
"solver_status": [],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for scenario in scenarios:
|
|
54
|
+
summary = scenario.summary
|
|
55
|
+
inv_metrics = scenario.investment_metrics
|
|
56
|
+
|
|
57
|
+
# Calculate total revenue - use investment metrics if available
|
|
58
|
+
if inv_metrics and inv_metrics.total_revenue_10y is not None:
|
|
59
|
+
total_revenue = inv_metrics.total_revenue_10y
|
|
60
|
+
else:
|
|
61
|
+
# Fallback: calculate from profit + cost
|
|
62
|
+
profit = summary.expected_profit or 0.0
|
|
63
|
+
cost = summary.total_cost or 0.0
|
|
64
|
+
total_revenue = profit + cost
|
|
65
|
+
|
|
66
|
+
comparison["total_revenue"].append(total_revenue)
|
|
67
|
+
comparison["total_costs"].append(summary.total_cost)
|
|
68
|
+
comparison["profit"].append(summary.expected_profit)
|
|
69
|
+
comparison["npv"].append(inv_metrics.npv if inv_metrics else None)
|
|
70
|
+
comparison["irr"].append(inv_metrics.irr if inv_metrics else None)
|
|
71
|
+
comparison["payback_years"].append(inv_metrics.payback_period_years if inv_metrics else None)
|
|
72
|
+
comparison["solve_time_seconds"].append(summary.solve_time_seconds)
|
|
73
|
+
comparison["solver_status"].append(summary.solver_status)
|
|
74
|
+
|
|
75
|
+
return comparison
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def print_comparison(comparison: dict) -> None:
|
|
79
|
+
"""Print scenario comparison in a readable format.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
comparison: Comparison dictionary from compare_scenarios()
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
>>> comparison = compare_scenarios([result1, result2, result3], names=["Small", "Medium", "Large"])
|
|
86
|
+
>>> print_comparison(comparison)
|
|
87
|
+
=== Scenario Comparison ===
|
|
88
|
+
...
|
|
89
|
+
"""
|
|
90
|
+
print("=" * 80)
|
|
91
|
+
print("SCENARIO COMPARISON")
|
|
92
|
+
print("=" * 80)
|
|
93
|
+
|
|
94
|
+
for i, name in enumerate(comparison["names"]):
|
|
95
|
+
print(f"\n{name}:")
|
|
96
|
+
print(f" Total Revenue: €{comparison['total_revenue'][i]:>15,.0f}")
|
|
97
|
+
print(f" Total Costs: €{comparison['total_costs'][i]:>15,.0f}")
|
|
98
|
+
print(f" Profit: €{comparison['profit'][i]:>15,.0f}")
|
|
99
|
+
|
|
100
|
+
if comparison["npv"][i] is not None:
|
|
101
|
+
print(f" NPV: €{comparison['npv'][i]:>15,.0f}")
|
|
102
|
+
|
|
103
|
+
if comparison["irr"][i] is not None:
|
|
104
|
+
print(f" IRR: {comparison['irr'][i] * 100:>15.2f}%")
|
|
105
|
+
|
|
106
|
+
if comparison["payback_years"][i] is not None:
|
|
107
|
+
print(f" Payback: {comparison['payback_years'][i]:>15.1f} years")
|
|
108
|
+
|
|
109
|
+
print(f" Solve Time: {comparison['solve_time_seconds'][i]:>15.1f}s")
|
|
110
|
+
print(f" Solver Status: {comparison['solver_status'][i]:>15}")
|
|
111
|
+
|
|
112
|
+
print("\n" + "=" * 80)
|
|
113
|
+
|
|
114
|
+
# Find best scenario by NPV
|
|
115
|
+
npv_values = [v for v in comparison["npv"] if v is not None]
|
|
116
|
+
if npv_values:
|
|
117
|
+
best_idx = comparison["npv"].index(max(npv_values))
|
|
118
|
+
print(f"\nBest Scenario (by NPV): {comparison['names'][best_idx]}")
|
|
119
|
+
print(f"NPV: €{comparison['npv'][best_idx]:,.0f}")
|
|
120
|
+
|
|
121
|
+
print("=" * 80)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Financial analysis functions."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def calculate_npv(
|
|
7
|
+
cash_flows: List[float],
|
|
8
|
+
discount_rate: float,
|
|
9
|
+
initial_investment: float = 0,
|
|
10
|
+
) -> float:
|
|
11
|
+
"""Calculate Net Present Value.
|
|
12
|
+
|
|
13
|
+
NPV = Sum of (cash_flow_t / (1 + discount_rate)^t) + initial_investment
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
cash_flows: Annual cash flows (revenues - costs)
|
|
17
|
+
discount_rate: Discount rate (e.g., 0.05 for 5%)
|
|
18
|
+
initial_investment: Initial investment (negative for CAPEX, default: 0)
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Net present value in same currency as cash flows
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
>>> cash_flows = [100000, 105000, 110000, 115000, 120000]
|
|
25
|
+
>>> npv = calculate_npv(cash_flows, 0.05, initial_investment=-500000)
|
|
26
|
+
>>> print(f"NPV: €{npv:,.0f}")
|
|
27
|
+
NPV: €-23,162
|
|
28
|
+
"""
|
|
29
|
+
npv = initial_investment
|
|
30
|
+
|
|
31
|
+
for t, cash_flow in enumerate(cash_flows, start=1):
|
|
32
|
+
npv += cash_flow / ((1 + discount_rate) ** t)
|
|
33
|
+
|
|
34
|
+
return npv
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def calculate_irr(cash_flows: List[float], initial_guess: float = 0.1) -> Optional[float]:
|
|
38
|
+
"""Calculate Internal Rate of Return.
|
|
39
|
+
|
|
40
|
+
IRR is the discount rate that makes NPV = 0.
|
|
41
|
+
Uses Newton-Raphson method for root finding.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
cash_flows: Annual cash flows INCLUDING initial investment as first element
|
|
45
|
+
(e.g., [-500000, 100000, 105000, ...])
|
|
46
|
+
initial_guess: Starting guess for IRR (default: 0.1 = 10%)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Internal rate of return as fraction (e.g., 0.12 = 12%), or None if no solution
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
>>> cash_flows = [-500000, 100000, 105000, 110000, 115000, 120000]
|
|
53
|
+
>>> irr = calculate_irr(cash_flows)
|
|
54
|
+
>>> if irr:
|
|
55
|
+
... print(f"IRR: {irr*100:.2f}%")
|
|
56
|
+
IRR: 8.52%
|
|
57
|
+
"""
|
|
58
|
+
if len(cash_flows) < 2:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
# numpy.irr was removed in numpy 1.20+, use Newton-Raphson directly
|
|
62
|
+
return _irr_newton_raphson(cash_flows, initial_guess)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _irr_newton_raphson(cash_flows: List[float], initial_guess: float, max_iterations: int = 100) -> Optional[float]:
|
|
66
|
+
"""Calculate IRR using Newton-Raphson method.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
cash_flows: Cash flows with initial investment as first element
|
|
70
|
+
initial_guess: Starting guess
|
|
71
|
+
max_iterations: Maximum iterations
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
IRR or None if no convergence
|
|
75
|
+
"""
|
|
76
|
+
rate = initial_guess
|
|
77
|
+
tolerance = 1e-6
|
|
78
|
+
|
|
79
|
+
for _ in range(max_iterations):
|
|
80
|
+
# Calculate NPV and derivative
|
|
81
|
+
npv: float = 0.0
|
|
82
|
+
npv_derivative: float = 0.0
|
|
83
|
+
|
|
84
|
+
for t, cash_flow in enumerate(cash_flows):
|
|
85
|
+
discount_factor = (1 + rate) ** t
|
|
86
|
+
npv += cash_flow / discount_factor
|
|
87
|
+
if t > 0:
|
|
88
|
+
npv_derivative -= t * cash_flow / ((1 + rate) ** (t + 1))
|
|
89
|
+
|
|
90
|
+
# Check convergence
|
|
91
|
+
if abs(npv) < tolerance:
|
|
92
|
+
return rate
|
|
93
|
+
|
|
94
|
+
# Newton-Raphson update
|
|
95
|
+
if abs(npv_derivative) < tolerance:
|
|
96
|
+
return None # Derivative too small
|
|
97
|
+
|
|
98
|
+
rate = rate - npv / npv_derivative
|
|
99
|
+
|
|
100
|
+
# Check for reasonable range
|
|
101
|
+
if rate < -0.99 or rate > 10: # -99% to 1000%
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
return None # No convergence
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def calculate_payback_period(cash_flows: List[float]) -> Optional[float]:
|
|
108
|
+
"""Calculate simple payback period.
|
|
109
|
+
|
|
110
|
+
Payback period is the time it takes for cumulative cash flows
|
|
111
|
+
to become positive (recover initial investment).
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
cash_flows: Annual cash flows INCLUDING initial investment as first element
|
|
115
|
+
(e.g., [-500000, 100000, 105000, ...])
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Payback period in years (fractional), or None if never pays back
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
>>> cash_flows = [-500000, 100000, 120000, 140000, 160000]
|
|
122
|
+
>>> payback = calculate_payback_period(cash_flows)
|
|
123
|
+
>>> print(f"Payback: {payback:.1f} years")
|
|
124
|
+
Payback: 3.6 years
|
|
125
|
+
"""
|
|
126
|
+
if len(cash_flows) < 2:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
cumulative: float = 0.0
|
|
130
|
+
for year, cash_flow in enumerate(cash_flows):
|
|
131
|
+
cumulative += cash_flow
|
|
132
|
+
|
|
133
|
+
if cumulative >= 0:
|
|
134
|
+
# Interpolate within the year
|
|
135
|
+
if year == 0:
|
|
136
|
+
return 0.0
|
|
137
|
+
|
|
138
|
+
# How much was needed at start of this year
|
|
139
|
+
prev_cumulative = cumulative - cash_flow
|
|
140
|
+
|
|
141
|
+
# Fraction of year needed
|
|
142
|
+
fraction = -prev_cumulative / cash_flow
|
|
143
|
+
|
|
144
|
+
# Return year - 1 + fraction because year 0 is initial investment
|
|
145
|
+
return (year - 1) + fraction
|
|
146
|
+
|
|
147
|
+
return None # Never pays back
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def aggregate_annual(
|
|
151
|
+
hourly_values: List[float],
|
|
152
|
+
prices: Optional[List[float]] = None,
|
|
153
|
+
years: int = 1,
|
|
154
|
+
) -> List[float]:
|
|
155
|
+
"""Aggregate hourly values into annual totals.
|
|
156
|
+
|
|
157
|
+
If prices are provided, calculates annual revenue.
|
|
158
|
+
Otherwise, calculates annual sum (e.g., energy).
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
hourly_values: Hourly power or flow values (MW)
|
|
162
|
+
prices: Optional hourly prices (EUR/MWh)
|
|
163
|
+
years: Number of years (for validation)
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of annual values (one per year)
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> hourly_export = [2.5] * 8760 * 10 # 10 years of constant 2.5 MW
|
|
170
|
+
>>> prices = [30.0] * 8760 * 10
|
|
171
|
+
>>> annual_revenues = aggregate_annual(hourly_export, prices, years=10)
|
|
172
|
+
>>> print(f"Year 1 revenue: €{annual_revenues[0]:,.0f}")
|
|
173
|
+
Year 1 revenue: €657,000
|
|
174
|
+
"""
|
|
175
|
+
hours_per_year = 8760
|
|
176
|
+
expected_length = hours_per_year * years
|
|
177
|
+
|
|
178
|
+
if len(hourly_values) != expected_length:
|
|
179
|
+
raise ValueError(f"Expected {expected_length} hourly values for {years} years, got {len(hourly_values)}")
|
|
180
|
+
|
|
181
|
+
if prices is not None and len(prices) != expected_length:
|
|
182
|
+
raise ValueError(f"Prices length {len(prices)} doesn't match hourly_values length {len(hourly_values)}")
|
|
183
|
+
|
|
184
|
+
annual_values = []
|
|
185
|
+
|
|
186
|
+
for year in range(years):
|
|
187
|
+
start_idx = year * hours_per_year
|
|
188
|
+
end_idx = (year + 1) * hours_per_year
|
|
189
|
+
|
|
190
|
+
year_values = hourly_values[start_idx:end_idx]
|
|
191
|
+
|
|
192
|
+
if prices is not None:
|
|
193
|
+
year_prices = prices[start_idx:end_idx]
|
|
194
|
+
# Revenue = sum(MW * hours * EUR/MWh) = sum(MW * EUR/MWh) for 1-hour intervals
|
|
195
|
+
annual_value = sum(v * p for v, p in zip(year_values, year_prices))
|
|
196
|
+
else:
|
|
197
|
+
# Just sum the values
|
|
198
|
+
annual_value = sum(year_values)
|
|
199
|
+
|
|
200
|
+
annual_values.append(annual_value)
|
|
201
|
+
|
|
202
|
+
return annual_values
|