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.
@@ -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
@@ -0,0 +1,5 @@
1
+ """API client for investment optimization."""
2
+
3
+ from site_calc_investment.api.client import InvestmentClient
4
+
5
+ __all__ = ["InvestmentClient"]