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,442 @@
1
+ """Investment Client for Site-Calc API."""
2
+
3
+ import time
4
+ import warnings
5
+ from typing import Any, Optional
6
+
7
+ import httpx
8
+
9
+ from site_calc_investment import __version__
10
+ from site_calc_investment.exceptions import (
11
+ ApiError,
12
+ AuthenticationError,
13
+ ForbiddenFeatureError,
14
+ JobNotFoundError,
15
+ LimitExceededError,
16
+ OptimizationError,
17
+ SiteCalcError,
18
+ TimeoutError,
19
+ ValidationError,
20
+ )
21
+ from site_calc_investment.models.requests import InvestmentPlanningRequest
22
+ from site_calc_investment.models.responses import InvestmentPlanningResponse, Job
23
+
24
+
25
+ class InvestmentClient:
26
+ """Client for Site-Calc investment planning API.
27
+
28
+ This client is specifically for long-term capacity planning and
29
+ investment ROI analysis. It:
30
+ - Only supports 1-hour resolution
31
+ - Maximum 100,000 intervals (~11 years)
32
+ - Does NOT support ancillary services
33
+ - Only has access to /device-planning endpoint
34
+
35
+ Example:
36
+ >>> client = InvestmentClient(
37
+ ... base_url="https://api.site-calc.example.com",
38
+ ... api_key="inv_your_key_here"
39
+ ... )
40
+ >>> job = client.create_planning_job(request)
41
+ >>> result = client.wait_for_completion(job.job_id, timeout=7200)
42
+ >>> print(f"NPV: €{result.summary.investment_metrics.npv:,.0f}")
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ base_url: str,
48
+ api_key: str,
49
+ timeout: float = 900.0,
50
+ max_retries: int = 3,
51
+ ):
52
+ """Initialize the investment client.
53
+
54
+ Args:
55
+ base_url: Base URL of the API (e.g., "https://api.site-calc.example.com")
56
+ api_key: API key with 'inv_' prefix (investment client)
57
+ timeout: Default request timeout in seconds (default: 1 hour)
58
+ max_retries: Maximum number of retry attempts for failed requests
59
+
60
+ Raises:
61
+ ValueError: If API key doesn't start with 'inv_'
62
+ """
63
+ if not api_key.startswith("inv_"):
64
+ raise ValueError("API key must start with 'inv_' for investment client")
65
+
66
+ self.base_url = base_url.rstrip("/")
67
+ self.api_key = api_key
68
+ self.timeout = timeout
69
+ self.max_retries = max_retries
70
+ self.max_intervals = 100_000
71
+
72
+ self._client = httpx.Client(
73
+ base_url=self.base_url,
74
+ headers={
75
+ "Authorization": f"Bearer {self.api_key}",
76
+ "Content-Type": "application/json",
77
+ "Accept": "application/json",
78
+ },
79
+ timeout=timeout,
80
+ )
81
+ self._version_checked = False
82
+
83
+ def __enter__(self) -> "InvestmentClient":
84
+ """Context manager entry."""
85
+ return self
86
+
87
+ def __exit__(
88
+ self,
89
+ exc_type: type[BaseException] | None,
90
+ exc_val: BaseException | None,
91
+ exc_tb: object,
92
+ ) -> None:
93
+ """Context manager exit."""
94
+ self.close()
95
+
96
+ def close(self) -> None:
97
+ """Close the HTTP client."""
98
+ self._client.close()
99
+
100
+ def _validate_server_version(self) -> None:
101
+ """Check server API version compatibility and warn if mismatched.
102
+
103
+ Compares client MAJOR.MINOR with server api_version.
104
+ Only runs once per client instance.
105
+ """
106
+ if self._version_checked:
107
+ return
108
+
109
+ self._version_checked = True
110
+ client_api_version = ".".join(__version__.split(".")[:2])
111
+
112
+ try:
113
+ response = self._client.get("/health")
114
+ if response.status_code == 200:
115
+ health = response.json()
116
+ server_api_version = health.get("api_version")
117
+ if server_api_version and client_api_version != server_api_version:
118
+ warnings.warn(
119
+ f"Client version {__version__} (API {client_api_version}) may not be compatible "
120
+ f"with server API {server_api_version}. Consider upgrading.",
121
+ UserWarning,
122
+ stacklevel=3,
123
+ )
124
+ except Exception:
125
+ pass
126
+
127
+ def _handle_error(self, response: httpx.Response) -> None:
128
+ """Handle API error responses.
129
+
130
+ Args:
131
+ response: HTTP response with error status
132
+
133
+ Raises:
134
+ Appropriate exception based on status code and error details
135
+ """
136
+ details: dict[str, Any] | None = None
137
+ try:
138
+ error_data = response.json()
139
+ # Handle FastAPI error format: {"detail": ...}
140
+ if "detail" in error_data:
141
+ detail = error_data["detail"]
142
+ if isinstance(detail, list):
143
+ # Pydantic validation error: [{"msg": ..., "loc": [...], ...}]
144
+ messages = [item.get("msg", str(item)) for item in detail]
145
+ message = "; ".join(messages)
146
+ details = {"validation_errors": detail}
147
+ else:
148
+ # Simple detail string
149
+ message = str(detail)
150
+ details = None
151
+ code = None
152
+ else:
153
+ # Handle custom error format: {"error": {"code": ..., "message": ...}}
154
+ error = error_data.get("error", {})
155
+ code = error.get("code")
156
+ message = error.get("message", "Unknown error")
157
+ details = error.get("details")
158
+ except Exception:
159
+ message = response.text or f"HTTP {response.status_code}"
160
+ code = None
161
+ details = None
162
+
163
+ if response.status_code == 400:
164
+ raise ValidationError(message, code, details)
165
+ elif response.status_code == 401:
166
+ raise AuthenticationError(message, code, details)
167
+ elif response.status_code == 403:
168
+ if code == "forbidden_feature":
169
+ raise ForbiddenFeatureError(message, code, details)
170
+ elif code == "limit_exceeded" or code == "invalid_resolution":
171
+ requested = details.get("requested") if details and isinstance(details, dict) else None
172
+ max_allowed = details.get("max_allowed") if details and isinstance(details, dict) else None
173
+ raise LimitExceededError(message, requested, max_allowed, code, details)
174
+ else:
175
+ raise ApiError(message, code, details)
176
+ elif response.status_code == 404:
177
+ if "job" in message.lower() or "not found" in message.lower():
178
+ raise JobNotFoundError(message, code, details)
179
+ raise ApiError(message, code, details)
180
+ elif response.status_code == 408 or response.status_code == 504:
181
+ raise TimeoutError(message, code=code)
182
+ elif response.status_code == 422:
183
+ raise ValidationError(message, code, details)
184
+ elif response.status_code >= 500:
185
+ raise ApiError(f"Server error: {message}", code, details)
186
+ else:
187
+ raise ApiError(message, code, details)
188
+
189
+ def _request_with_retry(
190
+ self,
191
+ method: str,
192
+ path: str,
193
+ **kwargs: Any,
194
+ ) -> httpx.Response:
195
+ """Make HTTP request with retry logic.
196
+
197
+ Args:
198
+ method: HTTP method (GET, POST, DELETE)
199
+ path: API path
200
+ **kwargs: Additional arguments for httpx
201
+
202
+ Returns:
203
+ HTTP response
204
+
205
+ Raises:
206
+ Various exceptions based on response status
207
+ """
208
+ self._validate_server_version()
209
+ last_exception: SiteCalcError | None = None
210
+
211
+ for attempt in range(self.max_retries):
212
+ try:
213
+ response = self._client.request(method, path, **kwargs)
214
+
215
+ if response.status_code < 400:
216
+ return response
217
+
218
+ # Don't retry client errors (4xx) except timeouts
219
+ if 400 <= response.status_code < 500 and response.status_code not in [408, 429]:
220
+ self._handle_error(response)
221
+
222
+ # Retry server errors (5xx) and specific client errors
223
+ if attempt < self.max_retries - 1:
224
+ wait_time = 2**attempt # Exponential backoff
225
+ time.sleep(wait_time)
226
+ continue
227
+
228
+ self._handle_error(response)
229
+
230
+ except httpx.TimeoutException:
231
+ last_exception = TimeoutError(f"Request timeout after {self.timeout}s", timeout=self.timeout)
232
+ if attempt < self.max_retries - 1:
233
+ time.sleep(2**attempt)
234
+ continue
235
+ raise last_exception
236
+ except httpx.RequestError as e:
237
+ last_exception = ApiError(f"Request failed: {str(e)}")
238
+ if attempt < self.max_retries - 1:
239
+ time.sleep(2**attempt)
240
+ continue
241
+ raise last_exception
242
+
243
+ if last_exception:
244
+ raise last_exception
245
+ raise ApiError("Request failed after retries")
246
+
247
+ def create_planning_job(self, request: InvestmentPlanningRequest) -> Job:
248
+ """Create a long-term investment planning job.
249
+
250
+ Args:
251
+ request: Investment planning request
252
+
253
+ Returns:
254
+ Job object with job_id and initial status
255
+
256
+ Raises:
257
+ ValidationError: If request is invalid
258
+ ForbiddenFeatureError: If using forbidden features (ANS)
259
+ LimitExceededError: If exceeding client limits
260
+ AuthenticationError: If API key is invalid
261
+
262
+ Example:
263
+ >>> request = InvestmentPlanningRequest(
264
+ ... sites=[site],
265
+ ... timespan=TimeSpan.for_years(2025, 10),
266
+ ... investment_parameters=inv_params
267
+ ... )
268
+ >>> job = client.create_planning_job(request)
269
+ >>> print(f"Job ID: {job.job_id}")
270
+ """
271
+ payload = request.model_dump_for_api()
272
+
273
+ response = self._request_with_retry(
274
+ "POST",
275
+ "/api/v1/jobs/device-planning",
276
+ json=payload,
277
+ )
278
+
279
+ return Job(**response.json())
280
+
281
+ def get_job_status(self, job_id: str) -> Job:
282
+ """Get current job status.
283
+
284
+ Args:
285
+ job_id: Job identifier
286
+
287
+ Returns:
288
+ Job object with current status
289
+
290
+ Raises:
291
+ JobNotFoundError: If job doesn't exist
292
+
293
+ Example:
294
+ >>> job = client.get_job_status(job_id)
295
+ >>> print(f"Status: {job.status}, Progress: {job.progress}%")
296
+ """
297
+ response = self._request_with_retry(
298
+ "GET",
299
+ f"/api/v1/jobs/{job_id}",
300
+ )
301
+
302
+ return Job(**response.json())
303
+
304
+ def get_job_result(self, job_id: str) -> InvestmentPlanningResponse:
305
+ """Get job result (must be completed).
306
+
307
+ Args:
308
+ job_id: Job identifier
309
+
310
+ Returns:
311
+ Complete optimization result
312
+
313
+ Raises:
314
+ JobNotFoundError: If job doesn't exist
315
+ ApiError: If job is not completed
316
+
317
+ Example:
318
+ >>> result = client.get_job_result(job_id)
319
+ >>> print(f"NPV: €{result.investment_metrics.npv:,.0f}")
320
+ """
321
+ response = self._request_with_retry(
322
+ "GET",
323
+ f"/api/v1/jobs/{job_id}/result",
324
+ )
325
+
326
+ data = response.json()
327
+
328
+ # Extract result from wrapper and flatten
329
+ result_data = {
330
+ "job_id": str(data.get("job_id")),
331
+ "status": data.get("status"),
332
+ **data.get("result", {}),
333
+ }
334
+
335
+ return InvestmentPlanningResponse(**result_data)
336
+
337
+ def cancel_job(self, job_id: str) -> Job:
338
+ """Cancel a running job.
339
+
340
+ Args:
341
+ job_id: Job identifier
342
+
343
+ Returns:
344
+ Job object with cancelled status
345
+
346
+ Raises:
347
+ JobNotFoundError: If job doesn't exist
348
+ ApiError: If job cannot be cancelled (already completed)
349
+
350
+ Example:
351
+ >>> cancelled = client.cancel_job(job_id)
352
+ >>> print(f"Status: {cancelled.status}")
353
+ """
354
+ response = self._request_with_retry(
355
+ "DELETE",
356
+ f"/api/v1/jobs/{job_id}",
357
+ )
358
+
359
+ return Job(**response.json())
360
+
361
+ def cancel_all_jobs(self) -> dict[str, object]:
362
+ """Cancel all pending or running jobs.
363
+
364
+ Cancels all jobs that are currently pending or running for the
365
+ authenticated user. Useful for cleanup when shutting down or
366
+ when jobs are no longer needed.
367
+
368
+ Returns:
369
+ Dictionary with:
370
+ - cancelled_count: Number of jobs cancelled
371
+ - cancelled_jobs: List of cancelled job IDs
372
+ - message: Status message
373
+
374
+ Raises:
375
+ AuthenticationError: If API key is invalid
376
+
377
+ Example:
378
+ >>> result = client.cancel_all_jobs()
379
+ >>> print(f"Cancelled {result['cancelled_count']} jobs")
380
+ """
381
+ response = self._request_with_retry(
382
+ "DELETE",
383
+ "/api/v1/jobs",
384
+ )
385
+
386
+ result: dict[str, object] = response.json()
387
+ return result
388
+
389
+ def wait_for_completion(
390
+ self,
391
+ job_id: str,
392
+ poll_interval: float = 30,
393
+ timeout: Optional[float] = 7200,
394
+ ) -> InvestmentPlanningResponse:
395
+ """Wait for job to complete and return result.
396
+
397
+ Polls the job status at regular intervals until completion or timeout.
398
+
399
+ Args:
400
+ job_id: Job identifier
401
+ poll_interval: Seconds between status checks (default: 30s)
402
+ timeout: Maximum wait time in seconds (default: 2 hours, None=unlimited)
403
+
404
+ Returns:
405
+ Complete optimization result
406
+
407
+ Raises:
408
+ TimeoutError: If timeout is exceeded
409
+ JobNotFoundError: If job doesn't exist
410
+ OptimizationError: If job fails
411
+
412
+ Example:
413
+ >>> result = client.wait_for_completion(
414
+ ... job_id,
415
+ ... poll_interval=30,
416
+ ... timeout=7200
417
+ ... )
418
+ >>> print(f"Solved in {result.summary.solve_time_seconds:.1f}s")
419
+ """
420
+ start_time = time.time()
421
+
422
+ while True:
423
+ job = self.get_job_status(job_id)
424
+
425
+ if job.status == "completed":
426
+ return self.get_job_result(job_id)
427
+ elif job.status == "failed":
428
+ error_msg: str = str(job.error.get("message", "Unknown error")) if job.error else "Unknown error"
429
+ error_code = job.error.get("code") if job.error else None
430
+ error_details = job.error.get("details") if job.error else None
431
+ raise OptimizationError(error_msg, error_code, error_details)
432
+ elif job.status == "cancelled":
433
+ raise ApiError("Job was cancelled")
434
+
435
+ # Check timeout
436
+ if timeout is not None:
437
+ elapsed = time.time() - start_time
438
+ if elapsed > timeout:
439
+ raise TimeoutError(f"Job did not complete within {timeout}s", timeout=timeout)
440
+
441
+ # Wait before next poll
442
+ time.sleep(poll_interval)
@@ -0,0 +1,91 @@
1
+ # SYNC: This file may be synced between investment and operational clients
2
+ """Custom exceptions for the investment client."""
3
+
4
+ from typing import Any, Dict, Optional
5
+
6
+
7
+ class SiteCalcError(Exception):
8
+ """Base exception for all Site-Calc client errors."""
9
+
10
+ def __init__(self, message: str, code: Optional[str] = None, details: Optional[Dict[str, Any]] = None):
11
+ self.message = message
12
+ self.code = code
13
+ self.details = details or {}
14
+ super().__init__(self.message)
15
+
16
+
17
+ class ApiError(SiteCalcError):
18
+ """General API error."""
19
+
20
+ pass
21
+
22
+
23
+ class ValidationError(SiteCalcError):
24
+ """Request validation failed."""
25
+
26
+ pass
27
+
28
+
29
+ class AuthenticationError(SiteCalcError):
30
+ """Authentication failed (invalid API key)."""
31
+
32
+ pass
33
+
34
+
35
+ class ForbiddenFeatureError(SiteCalcError):
36
+ """Attempted to use a feature not available for this client type.
37
+
38
+ Examples:
39
+ - Investment client trying to use ancillary_services
40
+ - Investment client trying to access /optimal-bidding endpoint
41
+ """
42
+
43
+ pass
44
+
45
+
46
+ class LimitExceededError(SiteCalcError):
47
+ """Request exceeded client limits.
48
+
49
+ Examples:
50
+ - Too many intervals
51
+ - Wrong resolution
52
+ - Request too large
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ message: str,
58
+ requested: Optional[int] = None,
59
+ max_allowed: Optional[int] = None,
60
+ code: Optional[str] = None,
61
+ details: Optional[Dict[str, Any]] = None,
62
+ ):
63
+ self.requested = requested
64
+ self.max_allowed = max_allowed
65
+ super().__init__(message, code, details)
66
+
67
+
68
+ class TimeoutError(SiteCalcError):
69
+ """Request or operation timed out."""
70
+
71
+ def __init__(self, message: str, timeout: Optional[float] = None, code: Optional[str] = None):
72
+ self.timeout = timeout
73
+ super().__init__(message, code)
74
+
75
+
76
+ class OptimizationError(SiteCalcError):
77
+ """Optimization solver error.
78
+
79
+ Examples:
80
+ - Infeasible problem
81
+ - Unbounded problem
82
+ - Solver timeout
83
+ """
84
+
85
+ pass
86
+
87
+
88
+ class JobNotFoundError(SiteCalcError):
89
+ """Job ID not found."""
90
+
91
+ pass
@@ -0,0 +1,84 @@
1
+ """Data models for investment client."""
2
+
3
+ from site_calc_investment.models.common import Location, Resolution, TimeSpan
4
+ from site_calc_investment.models.devices import (
5
+ CHP,
6
+ # Devices
7
+ Battery,
8
+ # Properties
9
+ BatteryProperties,
10
+ CHPProperties,
11
+ DemandProperties,
12
+ Device,
13
+ ElectricityDemand,
14
+ ElectricityExport,
15
+ ElectricityImport,
16
+ GasImport,
17
+ HeatAccumulator,
18
+ HeatAccumulatorProperties,
19
+ HeatDemand,
20
+ HeatExport,
21
+ MarketExportProperties,
22
+ MarketImportProperties,
23
+ Photovoltaic,
24
+ PhotovoltaicProperties,
25
+ # Schedule
26
+ Schedule,
27
+ )
28
+ from site_calc_investment.models.requests import (
29
+ InvestmentParameters,
30
+ InvestmentPlanningRequest,
31
+ OptimizationConfig,
32
+ Site,
33
+ TimeSpanInvestment,
34
+ )
35
+ from site_calc_investment.models.responses import (
36
+ DeviceSchedule,
37
+ InvestmentMetrics,
38
+ InvestmentPlanningResponse,
39
+ Job,
40
+ SiteResult,
41
+ Summary,
42
+ )
43
+
44
+ __all__ = [
45
+ # Common
46
+ "TimeSpan",
47
+ "Resolution",
48
+ "Location",
49
+ # Device Properties
50
+ "BatteryProperties",
51
+ "CHPProperties",
52
+ "HeatAccumulatorProperties",
53
+ "PhotovoltaicProperties",
54
+ "DemandProperties",
55
+ "MarketImportProperties",
56
+ "MarketExportProperties",
57
+ # Schedule
58
+ "Schedule",
59
+ # Devices
60
+ "Battery",
61
+ "CHP",
62
+ "HeatAccumulator",
63
+ "Photovoltaic",
64
+ "HeatDemand",
65
+ "ElectricityDemand",
66
+ "ElectricityImport",
67
+ "ElectricityExport",
68
+ "GasImport",
69
+ "HeatExport",
70
+ "Device",
71
+ # Request models
72
+ "Site",
73
+ "InvestmentParameters",
74
+ "OptimizationConfig",
75
+ "TimeSpanInvestment",
76
+ "InvestmentPlanningRequest",
77
+ # Response models
78
+ "Job",
79
+ "DeviceSchedule",
80
+ "SiteResult",
81
+ "InvestmentMetrics",
82
+ "Summary",
83
+ "InvestmentPlanningResponse",
84
+ ]