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,174 @@
1
+ # SYNC: This file may be synced between investment and operational clients
2
+ """Common models shared across the investment client."""
3
+
4
+ from datetime import date, datetime, timedelta
5
+ from enum import Enum
6
+ from zoneinfo import ZoneInfo
7
+
8
+ from pydantic import BaseModel, Field, computed_field, field_validator
9
+
10
+
11
+ class Resolution(str, Enum):
12
+ """Time resolution for optimization intervals."""
13
+
14
+ MINUTES_15 = "15min"
15
+ HOUR_1 = "1h"
16
+
17
+ @property
18
+ def minutes(self) -> int:
19
+ """Get resolution in minutes."""
20
+ return 15 if self == Resolution.MINUTES_15 else 60
21
+
22
+ @property
23
+ def intervals_per_day(self) -> int:
24
+ """Get number of intervals per day."""
25
+ return 96 if self == Resolution.MINUTES_15 else 24
26
+
27
+
28
+ class TimeSpan(BaseModel):
29
+ """Time period for optimization.
30
+
31
+ Represents a time period with explicit interval count, allowing precise
32
+ control over array sizes and computed end time.
33
+
34
+ Examples:
35
+ Full day at 15-minute resolution:
36
+ >>> ts = TimeSpan.for_day(date(2025, 11, 6), Resolution.MINUTES_15)
37
+ >>> ts.intervals
38
+ 96
39
+ >>> ts.duration
40
+ timedelta(days=1)
41
+
42
+ Custom 10-year planning:
43
+ >>> ts = TimeSpan(
44
+ ... start=datetime(2025, 1, 1, tzinfo=ZoneInfo("Europe/Prague")),
45
+ ... intervals=87600,
46
+ ... resolution=Resolution.HOUR_1
47
+ ... )
48
+ >>> ts.years
49
+ 10.0
50
+ """
51
+
52
+ start: datetime = Field(..., description="Start time (Europe/Prague timezone required)")
53
+ intervals: int = Field(..., ge=1, le=100_000, description="Number of time intervals")
54
+ resolution: Resolution = Field(..., description="Time resolution (15min or 1h)")
55
+
56
+ @field_validator("start")
57
+ @classmethod
58
+ def validate_timezone(cls, v: datetime) -> datetime:
59
+ """Ensure timezone is Europe/Prague."""
60
+ prague_tz = ZoneInfo("Europe/Prague")
61
+ if v.tzinfo is None:
62
+ raise ValueError("Timezone must be specified")
63
+ if v.tzinfo != prague_tz:
64
+ raise ValueError(f"Timezone must be Europe/Prague, got {v.tzinfo}")
65
+ return v
66
+
67
+ @computed_field # type: ignore[misc]
68
+ @property
69
+ def end(self) -> datetime:
70
+ """Computed end time based on start, intervals, and resolution."""
71
+ delta = timedelta(minutes=self.intervals * self.resolution.minutes)
72
+ return self.start + delta
73
+
74
+ @computed_field # type: ignore[misc]
75
+ @property
76
+ def duration(self) -> timedelta:
77
+ """Total duration of the time period."""
78
+ return timedelta(minutes=self.intervals * self.resolution.minutes)
79
+
80
+ @computed_field # type: ignore[misc]
81
+ @property
82
+ def years(self) -> float:
83
+ """Duration in years (approximate, using 365.25 days/year)."""
84
+ return self.duration.total_seconds() / (365.25 * 24 * 3600)
85
+
86
+ @classmethod
87
+ def for_day(cls, date: date, resolution: Resolution) -> "TimeSpan":
88
+ """Create timespan for a full day.
89
+
90
+ Args:
91
+ date: The date to optimize
92
+ resolution: Time resolution (15min or 1h)
93
+
94
+ Returns:
95
+ TimeSpan covering the full day
96
+
97
+ Example:
98
+ >>> ts = TimeSpan.for_day(date(2025, 11, 6), Resolution.HOUR_1)
99
+ >>> ts.intervals
100
+ 24
101
+ """
102
+ start = datetime.combine(date, datetime.min.time()).replace(tzinfo=ZoneInfo("Europe/Prague"))
103
+ return cls(start=start, intervals=resolution.intervals_per_day, resolution=resolution)
104
+
105
+ @classmethod
106
+ def for_hours(cls, start: datetime, hours: int, resolution: Resolution) -> "TimeSpan":
107
+ """Create timespan for N hours.
108
+
109
+ Args:
110
+ start: Start datetime (must have Europe/Prague timezone)
111
+ hours: Number of hours
112
+ resolution: Time resolution
113
+
114
+ Returns:
115
+ TimeSpan covering the specified hours
116
+
117
+ Example:
118
+ >>> start = datetime(2025, 11, 6, tzinfo=ZoneInfo("Europe/Prague"))
119
+ >>> ts = TimeSpan.for_hours(start, 48, Resolution.HOUR_1)
120
+ >>> ts.intervals
121
+ 48
122
+ """
123
+ intervals = hours * (60 // resolution.minutes)
124
+ return cls(start=start, intervals=intervals, resolution=resolution)
125
+
126
+ @classmethod
127
+ def for_years(cls, start_year: int, years: int, resolution: Resolution = Resolution.HOUR_1) -> "TimeSpan":
128
+ """Create timespan for N years.
129
+
130
+ Args:
131
+ start_year: Starting year (e.g., 2025)
132
+ years: Number of years
133
+ resolution: Time resolution (defaults to 1h)
134
+
135
+ Returns:
136
+ TimeSpan covering the specified years
137
+
138
+ Example:
139
+ >>> ts = TimeSpan.for_years(2025, 10)
140
+ >>> ts.intervals
141
+ 87600
142
+ >>> ts.years
143
+ 10.0
144
+ """
145
+ start = datetime(start_year, 1, 1, tzinfo=ZoneInfo("Europe/Prague"))
146
+ intervals = years * 8760 # 8760 hours per year
147
+ return cls(start=start, intervals=intervals, resolution=resolution)
148
+
149
+ def to_api_dict(self) -> dict:
150
+ """Convert to API format.
151
+
152
+ Returns:
153
+ Dictionary with period_start, period_end, resolution for API requests
154
+ """
155
+ return {
156
+ "period_start": self.start.isoformat(),
157
+ "period_end": self.end.isoformat(),
158
+ "resolution": self.resolution.value,
159
+ }
160
+
161
+
162
+ class Location(BaseModel):
163
+ """Geographic location for devices like photovoltaic systems.
164
+
165
+ Attributes:
166
+ latitude: Latitude in degrees (-90 to 90)
167
+ longitude: Longitude in degrees (-180 to 180)
168
+
169
+ Example:
170
+ >>> prague = Location(latitude=50.0751, longitude=14.4378)
171
+ """
172
+
173
+ latitude: float = Field(..., ge=-90, le=90, description="Latitude in degrees")
174
+ longitude: float = Field(..., ge=-180, le=180, description="Longitude in degrees")
@@ -0,0 +1,263 @@
1
+ """Device models for investment client (NO ancillary services)."""
2
+
3
+ from typing import List, Literal, Optional, Union
4
+
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+ from site_calc_investment.models.common import Location
8
+
9
+ # Device Properties Models
10
+
11
+
12
+ class BatteryProperties(BaseModel):
13
+ """Battery storage properties."""
14
+
15
+ capacity: float = Field(..., gt=0, description="Energy capacity (MWh)")
16
+ max_power: float = Field(..., gt=0, description="Power rating for charge/discharge (MW)")
17
+ efficiency: float = Field(..., gt=0, le=1, description="Round-trip efficiency (0-1)")
18
+ initial_soc: float = Field(0.5, ge=0, le=1, description="Initial state of charge (0-1)")
19
+ soc_anchor_interval_hours: Optional[int] = Field(
20
+ None,
21
+ gt=0,
22
+ description="If set, force SOC to target at regular intervals (hours). E.g., 4320 = every 6 months",
23
+ )
24
+ soc_anchor_target: float = Field(
25
+ 0.5,
26
+ ge=0,
27
+ le=1,
28
+ description="Target SOC fraction at anchor points (0-1)",
29
+ )
30
+
31
+
32
+ class CHPProperties(BaseModel):
33
+ """Combined Heat and Power properties."""
34
+
35
+ gas_input: float = Field(..., gt=0, description="Gas consumption at full load (MW)")
36
+ el_output: float = Field(..., gt=0, description="Electricity generation at full load (MW)")
37
+ heat_output: float = Field(..., gt=0, description="Heat generation at full load (MW)")
38
+ is_binary: bool = Field(False, description="True=on/off only (relaxed for investment), False=modulating")
39
+ min_power: Optional[float] = Field(None, ge=0, le=1, description="Min power fraction if modulation limited")
40
+
41
+
42
+ class HeatAccumulatorProperties(BaseModel):
43
+ """Heat accumulator (thermal storage) properties."""
44
+
45
+ capacity: float = Field(..., gt=0, description="Thermal energy capacity (MWh)")
46
+ max_power: float = Field(..., gt=0, description="Charge/discharge power (MW)")
47
+ efficiency: float = Field(..., gt=0, le=1, description="Storage efficiency (0-1)")
48
+ initial_soc: float = Field(0.5, ge=0, le=1, description="Initial state of charge (0-1)")
49
+ loss_rate: float = Field(0.001, ge=0, description="Standing losses (fraction/hour)")
50
+
51
+
52
+ class PhotovoltaicProperties(BaseModel):
53
+ """Photovoltaic system properties."""
54
+
55
+ peak_power_mw: float = Field(..., gt=0, description="Peak power capacity (MW)")
56
+ location: Location = Field(..., description="Geographic location")
57
+ tilt: int = Field(..., ge=0, le=90, description="Panel tilt angle (degrees)")
58
+ azimuth: int = Field(..., ge=0, lt=360, description="Azimuth angle (degrees, 180=south)")
59
+ generation_profile: Optional[List[float]] = Field(None, description="Optional normalized generation profile (0-1)")
60
+
61
+ @field_validator("generation_profile")
62
+ @classmethod
63
+ def validate_profile(cls, v: Optional[List[float]]) -> Optional[List[float]]:
64
+ """Validate generation profile values are between 0 and 1."""
65
+ if v is not None:
66
+ if not all(0 <= val <= 1 for val in v):
67
+ raise ValueError("Generation profile values must be between 0 and 1")
68
+ return v
69
+
70
+
71
+ class DemandProperties(BaseModel):
72
+ """Demand properties (heat or electricity)."""
73
+
74
+ max_demand_profile: List[float] = Field(..., description="Maximum demand profile (MW, not MWh!)")
75
+ min_demand_profile: Union[List[float], float] = Field(
76
+ 0, description="Minimum demand profile (MW) or constant value"
77
+ )
78
+
79
+ @field_validator("max_demand_profile", "min_demand_profile")
80
+ @classmethod
81
+ def validate_positive(cls, v: Union[List[float], float]) -> Union[List[float], float]:
82
+ """Validate demand values are non-negative."""
83
+ if isinstance(v, list):
84
+ if not all(val >= 0 for val in v):
85
+ raise ValueError("Demand values must be non-negative")
86
+ elif isinstance(v, (int, float)):
87
+ if v < 0:
88
+ raise ValueError("Demand value must be non-negative")
89
+ return v
90
+
91
+
92
+ class MarketImportProperties(BaseModel):
93
+ """Market import device properties (electricity or gas)."""
94
+
95
+ price: List[float] = Field(..., description="Price profile (EUR/MWh)")
96
+ max_import: float = Field(..., gt=0, description="Maximum import capacity (MW)")
97
+ max_import_unit_cost: Optional[float] = Field(
98
+ None, ge=0, description="Optional reserved capacity cost (EUR/MW/year)"
99
+ )
100
+
101
+
102
+ class MarketExportProperties(BaseModel):
103
+ """Market export device properties (electricity or heat)."""
104
+
105
+ price: List[float] = Field(..., description="Price profile (EUR/MWh)")
106
+ max_export: float = Field(..., gt=0, description="Maximum export capacity (MW)")
107
+ max_export_unit_cost: Optional[float] = Field(None, ge=0, description="Optional export capacity cost (EUR/MW/year)")
108
+
109
+
110
+ # Schedule Model
111
+
112
+
113
+ class Schedule(BaseModel):
114
+ """Operational schedule constraints.
115
+
116
+ Defines when and how a device can operate with runtime constraints
117
+ and binary availability arrays.
118
+ """
119
+
120
+ # Runtime constraints
121
+ min_continuous_run_hours: Optional[float] = Field(None, ge=0, description="Minimum runtime once started")
122
+ max_continuous_run_hours: Optional[float] = Field(None, ge=0, description="Maximum continuous operation")
123
+ max_hours_per_day: Optional[float] = Field(None, ge=0, le=24, description="Total hours per day")
124
+ max_starts_per_day: Optional[int] = Field(None, ge=0, description="Maximum number of startups")
125
+ min_downtime_hours: Optional[float] = Field(None, ge=0, description="Minimum off time between runs")
126
+
127
+ # Binary availability arrays
128
+ can_run: Optional[List[Union[int, float]]] = Field(
129
+ None, description="0=cannot run, 1=can run (or fractional for PV)"
130
+ )
131
+ must_run: Optional[List[int]] = Field(None, description="1=must run")
132
+
133
+ # Power ranges when must_run=1
134
+ min_power: Optional[List[float]] = Field(None, description="Minimum power when must_run=1 (MW)")
135
+ max_power: Optional[List[float]] = Field(None, description="Maximum power when must_run=1 (MW)")
136
+
137
+ @field_validator("can_run")
138
+ @classmethod
139
+ def validate_can_run(cls, v: Optional[List[Union[int, float]]]) -> Optional[List[Union[int, float]]]:
140
+ """Validate can_run array."""
141
+ if v is not None:
142
+ if len(v) not in [24, 96]:
143
+ raise ValueError("can_run array length must be 24 (1-hour) or 96 (15-min)")
144
+ # Allow fractional values for PV, but validate range
145
+ if not all(0 <= val <= 1 for val in v):
146
+ raise ValueError("can_run values must be between 0 and 1")
147
+ return v
148
+
149
+ @field_validator("must_run")
150
+ @classmethod
151
+ def validate_must_run(cls, v: Optional[List[int]]) -> Optional[List[int]]:
152
+ """Validate must_run is binary."""
153
+ if v is not None:
154
+ if len(v) not in [24, 96]:
155
+ raise ValueError("must_run array length must be 24 (1-hour) or 96 (15-min)")
156
+ if not all(val in [0, 1] for val in v):
157
+ raise ValueError("must_run must contain only 0 or 1")
158
+ return v
159
+
160
+
161
+ # Device Models
162
+
163
+
164
+ class Battery(BaseModel):
165
+ """Battery storage device (NO ancillary services for investment client)."""
166
+
167
+ name: str = Field(..., description="Unique device identifier")
168
+ type: Literal["battery"] = "battery"
169
+ properties: BatteryProperties
170
+ schedule: Optional[Schedule] = None
171
+
172
+
173
+ class CHP(BaseModel):
174
+ """Combined Heat and Power device.
175
+
176
+ Note: is_binary is automatically relaxed to continuous for investment planning.
177
+ """
178
+
179
+ name: str = Field(..., description="Unique device identifier")
180
+ type: Literal["chp"] = "chp"
181
+ properties: CHPProperties
182
+ schedule: Optional[Schedule] = None
183
+
184
+
185
+ class HeatAccumulator(BaseModel):
186
+ """Heat accumulator (thermal storage) device."""
187
+
188
+ name: str = Field(..., description="Unique device identifier")
189
+ type: Literal["heat_accumulator"] = "heat_accumulator"
190
+ properties: HeatAccumulatorProperties
191
+ schedule: Optional[Schedule] = None
192
+
193
+
194
+ class Photovoltaic(BaseModel):
195
+ """Photovoltaic system device."""
196
+
197
+ name: str = Field(..., description="Unique device identifier")
198
+ type: Literal["photovoltaic"] = "photovoltaic"
199
+ properties: PhotovoltaicProperties
200
+ schedule: Optional[Schedule] = None
201
+
202
+
203
+ class HeatDemand(BaseModel):
204
+ """Heat demand device."""
205
+
206
+ name: str = Field(..., description="Unique device identifier")
207
+ type: Literal["heat_demand"] = "heat_demand"
208
+ properties: DemandProperties
209
+
210
+
211
+ class ElectricityDemand(BaseModel):
212
+ """Electricity demand device."""
213
+
214
+ name: str = Field(..., description="Unique device identifier")
215
+ type: Literal["electricity_demand"] = "electricity_demand"
216
+ properties: DemandProperties
217
+
218
+
219
+ class ElectricityImport(BaseModel):
220
+ """Electricity import (grid connection for buying)."""
221
+
222
+ name: str = Field(..., description="Unique device identifier")
223
+ type: Literal["electricity_import"] = "electricity_import"
224
+ properties: MarketImportProperties
225
+
226
+
227
+ class ElectricityExport(BaseModel):
228
+ """Electricity export (grid connection for selling)."""
229
+
230
+ name: str = Field(..., description="Unique device identifier")
231
+ type: Literal["electricity_export"] = "electricity_export"
232
+ properties: MarketExportProperties
233
+
234
+
235
+ class GasImport(BaseModel):
236
+ """Gas import (gas supply connection)."""
237
+
238
+ name: str = Field(..., description="Unique device identifier")
239
+ type: Literal["gas_import"] = "gas_import"
240
+ properties: MarketImportProperties
241
+
242
+
243
+ class HeatExport(BaseModel):
244
+ """Heat export (district heating connection)."""
245
+
246
+ name: str = Field(..., description="Unique device identifier")
247
+ type: Literal["heat_export"] = "heat_export"
248
+ properties: MarketExportProperties
249
+
250
+
251
+ # Union type for all devices
252
+ Device = Union[
253
+ Battery,
254
+ CHP,
255
+ HeatAccumulator,
256
+ Photovoltaic,
257
+ HeatDemand,
258
+ ElectricityDemand,
259
+ ElectricityImport,
260
+ ElectricityExport,
261
+ GasImport,
262
+ HeatExport,
263
+ ]
@@ -0,0 +1,133 @@
1
+ """Request models for investment client."""
2
+
3
+ from typing import Dict, List, Literal, Optional
4
+
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+ from site_calc_investment.models.common import Resolution, TimeSpan
8
+ from site_calc_investment.models.devices import Device
9
+
10
+
11
+ class Site(BaseModel):
12
+ """Site definition with devices.
13
+
14
+ A site represents a physical location with multiple devices
15
+ that are optimized together.
16
+ """
17
+
18
+ site_id: str = Field(..., description="Unique site identifier")
19
+ description: Optional[str] = Field(None, description="Optional site description")
20
+ devices: List[Device] = Field(..., min_length=1, description="List of devices at this site")
21
+
22
+ @field_validator("devices")
23
+ @classmethod
24
+ def validate_unique_names(cls, v: List[Device]) -> List[Device]:
25
+ """Ensure all device names are unique within a site."""
26
+ names = [d.name for d in v]
27
+ if len(names) != len(set(names)):
28
+ raise ValueError("Device names must be unique within a site")
29
+ return v
30
+
31
+
32
+ class InvestmentParameters(BaseModel):
33
+ """Financial parameters for investment analysis.
34
+
35
+ These parameters are used to calculate NPV, IRR, and other
36
+ investment metrics.
37
+ """
38
+
39
+ discount_rate: float = Field(..., ge=0, le=0.5, description="Annual discount rate for NPV (0-0.5, e.g., 0.05 = 5%)")
40
+ project_lifetime_years: int = Field(..., ge=1, le=50, description="Project lifetime in years")
41
+ investment_budget: Optional[float] = Field(None, ge=0, description="Maximum investment budget in EUR")
42
+ carbon_price: Optional[float] = Field(None, ge=0, description="Carbon price in EUR/tCO2")
43
+ device_capital_costs: Optional[Dict[str, float]] = Field(
44
+ None, description="CAPEX for each device (EUR), keyed by device name"
45
+ )
46
+ device_annual_opex: Optional[Dict[str, float]] = Field(
47
+ None, description="Annual O&M costs (EUR/year), keyed by device name"
48
+ )
49
+ price_escalation_rate: Optional[float] = Field(
50
+ None, ge=0, le=1, description="Annual price escalation rate (e.g., 0.02 = 2%)"
51
+ )
52
+
53
+
54
+ class OptimizationConfig(BaseModel):
55
+ """Optimization configuration."""
56
+
57
+ objective: Literal["maximize_profit", "minimize_cost", "maximize_self_consumption"] = Field(
58
+ "maximize_profit", description="Optimization objective"
59
+ )
60
+ time_limit_seconds: int = Field(300, gt=0, le=900, description="Solver timeout (max 15 minutes)")
61
+ relax_binary_variables: bool = Field(
62
+ True, description="Relax binary CHP variables to continuous (recommended for long horizons)"
63
+ )
64
+
65
+
66
+ class TimeSpanInvestment(TimeSpan):
67
+ """TimeSpan with investment client validation.
68
+
69
+ Investment clients:
70
+ - Only support 1-hour resolution
71
+ - Maximum 100,000 intervals
72
+ """
73
+
74
+ resolution: Literal[Resolution.HOUR_1] = Resolution.HOUR_1 # type: ignore
75
+
76
+ @field_validator("intervals")
77
+ @classmethod
78
+ def validate_max_intervals(cls, v: int) -> int:
79
+ """Investment client limited to 100,000 intervals."""
80
+ if v > 100_000:
81
+ raise ValueError("Investment client limited to 100,000 intervals (~11 years)")
82
+ return v
83
+
84
+ @field_validator("resolution")
85
+ @classmethod
86
+ def validate_resolution(cls, v: Resolution) -> Resolution:
87
+ """Investment client only supports 1-hour resolution."""
88
+ if v != Resolution.HOUR_1:
89
+ raise ValueError("Investment client only supports 1-hour resolution")
90
+ return v
91
+
92
+
93
+ class InvestmentPlanningRequest(BaseModel):
94
+ """Request for long-term investment planning optimization.
95
+
96
+ This request creates a device planning job for capacity sizing
97
+ and investment ROI analysis over multi-year horizons.
98
+
99
+ Example:
100
+ >>> request = InvestmentPlanningRequest(
101
+ ... sites=[site],
102
+ ... timespan=TimeSpanInvestment.for_years(2025, 10),
103
+ ... investment_parameters=InvestmentParameters(
104
+ ... discount_rate=0.05,
105
+ ... device_capital_costs={"Battery1": 500000}
106
+ ... ),
107
+ ... optimization_config=OptimizationConfig(
108
+ ... objective="maximize_npv",
109
+ ... time_limit_seconds=3600
110
+ ... )
111
+ ... )
112
+ """
113
+
114
+ sites: List[Site] = Field(..., min_length=1, max_length=50, description="Sites to optimize (max 50)")
115
+ timespan: TimeSpanInvestment = Field(..., description="Time period (1-hour resolution only)")
116
+ investment_parameters: Optional[InvestmentParameters] = Field(
117
+ None, description="Optional financial parameters for ROI calculation"
118
+ )
119
+ optimization_config: OptimizationConfig = Field(
120
+ default=OptimizationConfig(), # type: ignore[call-arg]
121
+ description="Optimization configuration",
122
+ )
123
+
124
+ def model_dump_for_api(self) -> dict:
125
+ """Convert to API format.
126
+
127
+ Returns:
128
+ Dictionary ready for JSON serialization and API submission
129
+ """
130
+ data = self.model_dump()
131
+ # Convert timespan to API format
132
+ data["timespan"] = self.timespan.to_api_dict()
133
+ return data
@@ -0,0 +1,105 @@
1
+ """Response models for investment client."""
2
+
3
+ from datetime import datetime
4
+ from typing import Dict, List, Literal, Optional
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class Job(BaseModel):
10
+ """Job status and metadata.
11
+
12
+ Represents an asynchronous optimization job submitted to the API.
13
+ """
14
+
15
+ job_id: str = Field(..., description="Unique job identifier")
16
+ status: Literal["pending", "running", "completed", "failed", "cancelled"] = Field(..., description="Job status")
17
+ created_at: Optional[datetime] = Field(None, description="Job creation timestamp")
18
+ started_at: Optional[datetime] = Field(None, description="Job start timestamp")
19
+ completed_at: Optional[datetime] = Field(None, description="Job completion timestamp")
20
+ failed_at: Optional[datetime] = Field(None, description="Job failure timestamp")
21
+ progress: Optional[int] = Field(None, ge=0, le=100, description="Progress percentage (optional)")
22
+ message: Optional[str] = Field(None, description="Status message")
23
+ error: Optional[Dict] = Field(None, description="Error details if failed")
24
+ estimated_completion_seconds: Optional[int] = Field(None, description="Estimated completion time")
25
+ # Fields returned by server for completed/failed jobs
26
+ objective_value: Optional[float] = Field(None, description="Optimization objective value")
27
+ solver_time: Optional[float] = Field(None, description="Solver execution time in seconds")
28
+ total_time: Optional[float] = Field(None, description="Total job time in seconds")
29
+ error_code: Optional[str] = Field(None, description="Error code (e.g., PROBLEM_INFEASIBLE, SOLVER_TIMEOUT)")
30
+ suggestion: Optional[str] = Field(None, description="Suggestion for resolving errors")
31
+
32
+
33
+ class DeviceSchedule(BaseModel):
34
+ """Optimized schedule for a single device.
35
+
36
+ Contains flows (power/energy by material), state of charge (for storage),
37
+ and any binary status indicators.
38
+ """
39
+
40
+ flows: Dict[str, List[float]] = Field(
41
+ ..., description="Material flows keyed by material (e.g., 'electricity', 'gas', 'heat')"
42
+ )
43
+ soc: Optional[List[float]] = Field(None, description="State of charge (0-1) for storage devices")
44
+ binary_status: Optional[List[int]] = Field(None, description="Binary on/off status (0/1) for CHP")
45
+ ancillary_reservations: Optional[Dict[str, List[float]]] = Field(
46
+ None, description="Reserved capacity by service (e.g., {'afrr_plus': [...], 'afrr_minus': [...]})"
47
+ )
48
+
49
+
50
+ class SiteResult(BaseModel):
51
+ """Optimization results for a single site."""
52
+
53
+ device_schedules: Dict[str, DeviceSchedule] = Field(..., description="Device schedules keyed by device name")
54
+ grid_flows: Optional[Dict[str, List[float]]] = Field(None, description="Grid import/export flows")
55
+
56
+
57
+ class InvestmentMetrics(BaseModel):
58
+ """Investment analysis metrics.
59
+
60
+ Financial metrics calculated from the optimization results.
61
+ NPV and IRR are typically calculated client-side using the
62
+ annual_revenue_by_year and annual_costs_by_year arrays along
63
+ with investment_parameters (discount_rate, device_capital_costs).
64
+ """
65
+
66
+ npv: Optional[float] = Field(None, description="Net present value (EUR) - typically calculated client-side")
67
+ irr: Optional[float] = Field(None, description="Internal rate of return (fraction, e.g., 0.12 = 12%)")
68
+ payback_period_years: Optional[float] = Field(None, description="Simple payback period (years)")
69
+ total_revenue_10y: Optional[float] = Field(None, description="Total revenue over planning horizon (EUR)")
70
+ total_costs_10y: Optional[float] = Field(None, description="Total costs over planning horizon (EUR)")
71
+ annual_revenue_by_year: Optional[List[float]] = Field(None, description="Annual revenue for each year")
72
+ annual_costs_by_year: Optional[List[float]] = Field(None, description="Annual costs for each year")
73
+
74
+
75
+ class Summary(BaseModel):
76
+ """Optimization summary with investment metrics."""
77
+
78
+ total_da_revenue: Optional[float] = Field(None, description="Day-ahead market revenue (EUR)")
79
+ total_ancillary_revenue: Optional[float] = Field(None, description="Total ANS capacity payments (EUR)")
80
+ total_cost: Optional[float] = Field(None, description="Total operational costs (EUR)")
81
+ expected_profit: Optional[float] = Field(None, description="Expected profit (revenue - cost) (EUR)")
82
+ solver_status: str = Field(..., description="Solver status (optimal, timeout, infeasible, etc.)")
83
+ solve_time_seconds: float = Field(..., ge=0, description="Solver execution time")
84
+ sites_count: Optional[int] = Field(None, description="Number of sites optimized")
85
+
86
+
87
+ class InvestmentPlanningResponse(BaseModel):
88
+ """Complete response for investment planning optimization.
89
+
90
+ Contains optimized device schedules for all sites, grid flows,
91
+ and financial analysis metrics.
92
+
93
+ Example:
94
+ >>> result = client.wait_for_completion(job_id)
95
+ >>> print(f"NPV: €{result.investment_metrics.npv:,.0f}")
96
+ >>> print(f"IRR: {result.investment_metrics.irr*100:.2f}%")
97
+ """
98
+
99
+ job_id: str = Field(..., description="Job identifier")
100
+ status: Literal["completed"] = "completed"
101
+ sites: Dict[str, SiteResult] = Field(..., description="Results keyed by site_id")
102
+ summary: Summary = Field(..., description="Optimization summary and metrics")
103
+ investment_metrics: Optional[InvestmentMetrics] = Field(
104
+ None, description="Investment analysis metrics (if investment_parameters provided)"
105
+ )