pyconvexity 0.4.3__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.

Potentially problematic release.


This version of pyconvexity might be problematic. Click here for more details.

Files changed (42) hide show
  1. pyconvexity/__init__.py +226 -0
  2. pyconvexity/_version.py +1 -0
  3. pyconvexity/core/__init__.py +60 -0
  4. pyconvexity/core/database.py +485 -0
  5. pyconvexity/core/errors.py +106 -0
  6. pyconvexity/core/types.py +400 -0
  7. pyconvexity/data/README.md +101 -0
  8. pyconvexity/data/__init__.py +17 -0
  9. pyconvexity/data/loaders/__init__.py +3 -0
  10. pyconvexity/data/loaders/cache.py +213 -0
  11. pyconvexity/data/schema/01_core_schema.sql +420 -0
  12. pyconvexity/data/schema/02_data_metadata.sql +120 -0
  13. pyconvexity/data/schema/03_validation_data.sql +506 -0
  14. pyconvexity/data/sources/__init__.py +5 -0
  15. pyconvexity/data/sources/gem.py +442 -0
  16. pyconvexity/io/__init__.py +26 -0
  17. pyconvexity/io/excel_exporter.py +1226 -0
  18. pyconvexity/io/excel_importer.py +1381 -0
  19. pyconvexity/io/netcdf_exporter.py +197 -0
  20. pyconvexity/io/netcdf_importer.py +1833 -0
  21. pyconvexity/models/__init__.py +195 -0
  22. pyconvexity/models/attributes.py +730 -0
  23. pyconvexity/models/carriers.py +159 -0
  24. pyconvexity/models/components.py +611 -0
  25. pyconvexity/models/network.py +503 -0
  26. pyconvexity/models/results.py +148 -0
  27. pyconvexity/models/scenarios.py +234 -0
  28. pyconvexity/solvers/__init__.py +29 -0
  29. pyconvexity/solvers/pypsa/__init__.py +24 -0
  30. pyconvexity/solvers/pypsa/api.py +460 -0
  31. pyconvexity/solvers/pypsa/batch_loader.py +307 -0
  32. pyconvexity/solvers/pypsa/builder.py +675 -0
  33. pyconvexity/solvers/pypsa/constraints.py +405 -0
  34. pyconvexity/solvers/pypsa/solver.py +1509 -0
  35. pyconvexity/solvers/pypsa/storage.py +2048 -0
  36. pyconvexity/timeseries.py +330 -0
  37. pyconvexity/validation/__init__.py +25 -0
  38. pyconvexity/validation/rules.py +312 -0
  39. pyconvexity-0.4.3.dist-info/METADATA +47 -0
  40. pyconvexity-0.4.3.dist-info/RECORD +42 -0
  41. pyconvexity-0.4.3.dist-info/WHEEL +5 -0
  42. pyconvexity-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,400 @@
1
+ """
2
+ Core data types for PyConvexity.
3
+
4
+ These types mirror the Rust implementation while providing Python-specific
5
+ enhancements and better type safety.
6
+ """
7
+
8
+ import json
9
+ from dataclasses import dataclass
10
+ from typing import Dict, Any, Optional, List, Union
11
+
12
+
13
+ class StaticValue:
14
+ """
15
+ Represents a static (non-time-varying) attribute value.
16
+
17
+ Mirrors the Rust StaticValue enum while providing Python conveniences.
18
+ Supports float, int, bool, and string values with proper type conversion.
19
+ """
20
+
21
+ def __init__(self, value: Union[float, int, bool, str]):
22
+ # Check bool before int since bool is subclass of int in Python
23
+ if isinstance(value, bool):
24
+ self.data = {"Boolean": value}
25
+ elif isinstance(value, float):
26
+ self.data = {"Float": value}
27
+ elif isinstance(value, int):
28
+ self.data = {"Integer": value}
29
+ elif isinstance(value, str):
30
+ self.data = {"String": value}
31
+ else:
32
+ raise ValueError(f"Unsupported value type: {type(value)}")
33
+
34
+ def to_json(self) -> str:
35
+ """
36
+ Return raw value as JSON to match Rust serialization format.
37
+
38
+ Rust stores: 123.45, 42, true, "hello"
39
+ Not: {"Float": 123.45}, {"Integer": 42}, etc.
40
+ """
41
+ import math
42
+
43
+ if "Float" in self.data:
44
+ float_val = self.data["Float"]
45
+ # Ensure finite values only
46
+ if not math.isfinite(float_val):
47
+ raise ValueError(
48
+ f"Cannot serialize non-finite float value: {float_val}"
49
+ )
50
+ return json.dumps(float_val)
51
+ elif "Integer" in self.data:
52
+ return json.dumps(self.data["Integer"])
53
+ elif "Boolean" in self.data:
54
+ return json.dumps(self.data["Boolean"])
55
+ elif "String" in self.data:
56
+ return json.dumps(self.data["String"])
57
+ else:
58
+ # Fallback to original format if unknown
59
+ return json.dumps(self.data)
60
+
61
+ def data_type(self) -> str:
62
+ """Get data type name - mirrors Rust implementation"""
63
+ if "Float" in self.data:
64
+ return "float"
65
+ elif "Integer" in self.data:
66
+ return "int"
67
+ elif "Boolean" in self.data:
68
+ return "boolean"
69
+ elif "String" in self.data:
70
+ return "string"
71
+ else:
72
+ return "unknown"
73
+
74
+ def as_f64(self) -> float:
75
+ """Convert to float, mirroring Rust implementation"""
76
+ if "Float" in self.data:
77
+ return self.data["Float"]
78
+ elif "Integer" in self.data:
79
+ return float(self.data["Integer"])
80
+ elif "Boolean" in self.data:
81
+ return 1.0 if self.data["Boolean"] else 0.0
82
+ else:
83
+ try:
84
+ return float(self.data["String"])
85
+ except ValueError:
86
+ return 0.0
87
+
88
+ def value(self) -> Union[float, int, bool, str]:
89
+ """Get the raw Python value"""
90
+ if "Float" in self.data:
91
+ return self.data["Float"]
92
+ elif "Integer" in self.data:
93
+ return self.data["Integer"]
94
+ elif "Boolean" in self.data:
95
+ return self.data["Boolean"]
96
+ elif "String" in self.data:
97
+ return self.data["String"]
98
+ else:
99
+ raise ValueError("Unknown data type in StaticValue")
100
+
101
+ def __repr__(self) -> str:
102
+ return f"StaticValue({self.value()})"
103
+
104
+ def __eq__(self, other) -> bool:
105
+ if isinstance(other, StaticValue):
106
+ return self.data == other.data
107
+ return False
108
+
109
+
110
+ @dataclass
111
+ class Timeseries:
112
+ """
113
+ Efficient timeseries data structure matching the new Rust implementation.
114
+
115
+ Stores values as a flat array for maximum performance, matching the
116
+ unified Rust Timeseries struct.
117
+ """
118
+
119
+ values: List[float]
120
+ length: int
121
+ start_index: int
122
+ data_type: str
123
+ unit: Optional[str]
124
+ is_input: bool
125
+
126
+ def __post_init__(self):
127
+ # Ensure length matches values array
128
+ self.length = len(self.values)
129
+ # Ensure all values are float32-compatible
130
+ self.values = [float(v) for v in self.values]
131
+
132
+ def get_value(self, index: int) -> Optional[float]:
133
+ """Get value at specific index."""
134
+ if 0 <= index < len(self.values):
135
+ return self.values[index]
136
+ return None
137
+
138
+ def get_range(self, start: int, end: int) -> List[float]:
139
+ """Get a range of values efficiently."""
140
+ end = min(end, len(self.values))
141
+ start = min(start, end)
142
+ return self.values[start:end]
143
+
144
+ def sample(self, max_points: int) -> "Timeseries":
145
+ """Apply sampling if the timeseries is too large."""
146
+ if len(self.values) <= max_points:
147
+ return self
148
+
149
+ step = len(self.values) // max_points
150
+ sampled_values = []
151
+
152
+ for i in range(0, len(self.values), max(1, step)):
153
+ sampled_values.append(self.values[i])
154
+
155
+ # Always include the last point if not already included
156
+ if self.values and sampled_values[-1] != self.values[-1]:
157
+ sampled_values.append(self.values[-1])
158
+
159
+ return Timeseries(
160
+ values=sampled_values,
161
+ length=len(sampled_values),
162
+ start_index=self.start_index,
163
+ data_type=self.data_type,
164
+ unit=self.unit,
165
+ is_input=self.is_input,
166
+ )
167
+
168
+ def slice(self, start_index: int, end_index: int) -> "Timeseries":
169
+ """Apply range filtering."""
170
+ start = max(0, start_index - self.start_index)
171
+ end = max(0, end_index - self.start_index)
172
+ end = min(end, len(self.values))
173
+ start = min(start, end)
174
+
175
+ return Timeseries(
176
+ values=self.values[start:end],
177
+ length=end - start,
178
+ start_index=self.start_index + start,
179
+ data_type=self.data_type,
180
+ unit=self.unit,
181
+ is_input=self.is_input,
182
+ )
183
+
184
+
185
+ @dataclass
186
+ class TimeseriesMetadata:
187
+ """
188
+ Metadata about a timeseries without loading the full data.
189
+
190
+ Mirrors Rust TimeseriesMetadata struct.
191
+ """
192
+
193
+ length: int
194
+ start_time: int
195
+ end_time: int
196
+ start_index: int
197
+ end_index: int
198
+ data_type: str
199
+ unit: Optional[str]
200
+ is_input: bool
201
+
202
+
203
+ @dataclass
204
+ class TimePeriod:
205
+ """
206
+ Represents a time period in the network's time axis.
207
+
208
+ Mirrors Rust TimePeriod structure.
209
+ """
210
+
211
+ timestamp: int
212
+ period_index: int
213
+ formatted_time: str
214
+
215
+
216
+ @dataclass
217
+ class TimeseriesValidationResult:
218
+ """
219
+ Result of validating timeseries alignment with network time periods.
220
+
221
+ Mirrors Rust TimeseriesValidationResult.
222
+ """
223
+
224
+ is_valid: bool
225
+ missing_periods: List[int]
226
+ extra_periods: List[int]
227
+ total_network_periods: int
228
+ provided_periods: int
229
+
230
+
231
+ @dataclass
232
+ class ValidationRule:
233
+ """
234
+ Validation rule for component attributes.
235
+
236
+ Mirrors Rust ValidationRule with all fields.
237
+ """
238
+
239
+ component_type: str
240
+ attribute_name: str
241
+ data_type: str
242
+ unit: Optional[str]
243
+ default_value_string: Optional[str]
244
+ allowed_storage_types: str
245
+ allows_static: bool
246
+ allows_timeseries: bool
247
+ is_required: bool
248
+ is_input: bool
249
+ description: Optional[str]
250
+ default_value: Optional[StaticValue]
251
+
252
+
253
+ class AttributeValue:
254
+ """
255
+ Represents either a static value or timeseries data for a component attribute.
256
+
257
+ Uses efficient Timeseries format for optimal performance.
258
+ Mirrors Rust AttributeValue enum.
259
+ """
260
+
261
+ def __init__(self, value: Union[StaticValue, Timeseries]):
262
+ if isinstance(value, StaticValue):
263
+ self.variant = "Static"
264
+ self.static_value = value
265
+ self.timeseries_value = None
266
+ elif isinstance(value, Timeseries):
267
+ self.variant = "Timeseries"
268
+ self.static_value = None
269
+ self.timeseries_value = value
270
+ else:
271
+ raise ValueError(
272
+ f"AttributeValue must be StaticValue or Timeseries, got {type(value)}"
273
+ )
274
+
275
+ @classmethod
276
+ def static(cls, value: StaticValue) -> "AttributeValue":
277
+ """Create a static attribute value"""
278
+ return cls(value)
279
+
280
+ @classmethod
281
+ def timeseries(cls, timeseries: Timeseries) -> "AttributeValue":
282
+ """Create a timeseries attribute value (new format)"""
283
+ return cls(timeseries)
284
+
285
+ def is_static(self) -> bool:
286
+ """Check if this is a static value"""
287
+ return self.variant == "Static"
288
+
289
+ def is_timeseries(self) -> bool:
290
+ """Check if this is a timeseries value"""
291
+ return self.variant == "Timeseries"
292
+
293
+ def as_timeseries(self) -> Optional[Timeseries]:
294
+ """Get the timeseries data in new format"""
295
+ return self.timeseries_value if self.is_timeseries() else None
296
+
297
+ def __repr__(self) -> str:
298
+ if self.is_static():
299
+ return f"AttributeValue.static({self.static_value})"
300
+ else:
301
+ length = len(self.timeseries_value.values) if self.timeseries_value else 0
302
+ return f"AttributeValue.timeseries({length} points)"
303
+
304
+
305
+ @dataclass
306
+ class Component:
307
+ """
308
+ Represents a component in the energy system model (single network per database).
309
+
310
+ Mirrors Rust Component struct.
311
+ """
312
+
313
+ id: int
314
+ component_type: str
315
+ name: str
316
+ longitude: Optional[float] = None
317
+ latitude: Optional[float] = None
318
+ carrier_id: Optional[int] = None
319
+ bus_id: Optional[int] = None
320
+ bus0_id: Optional[int] = None
321
+ bus1_id: Optional[int] = None
322
+
323
+
324
+ @dataclass
325
+ class Network:
326
+ """
327
+ Represents a network/model in the system.
328
+
329
+ Enhanced version of network information with additional metadata.
330
+ """
331
+
332
+ id: int
333
+ name: str
334
+ description: Optional[str] = None
335
+ time_start: Optional[str] = None
336
+ time_end: Optional[str] = None
337
+ time_interval: Optional[str] = None
338
+ created_at: Optional[str] = None
339
+ updated_at: Optional[str] = None
340
+
341
+
342
+ @dataclass
343
+ class CreateComponentRequest:
344
+ """
345
+ Request structure for creating a new component (single network per database).
346
+
347
+ Mirrors Rust CreateComponentRequest.
348
+ """
349
+
350
+ component_type: str
351
+ name: str
352
+ description: Optional[str] = None
353
+ longitude: Optional[float] = None
354
+ latitude: Optional[float] = None
355
+ carrier_id: Optional[int] = None
356
+ bus_id: Optional[int] = None
357
+ bus0_id: Optional[int] = None
358
+ bus1_id: Optional[int] = None
359
+
360
+
361
+ @dataclass
362
+ class CreateNetworkRequest:
363
+ """
364
+ Request structure for creating a new network.
365
+
366
+ Mirrors Rust CreateNetworkRequest.
367
+ """
368
+
369
+ name: str
370
+ description: Optional[str] = None
371
+ time_resolution: Optional[str] = None
372
+ start_time: Optional[str] = None
373
+ end_time: Optional[str] = None
374
+
375
+
376
+ @dataclass
377
+ class Carrier:
378
+ """
379
+ Represents an energy carrier (e.g., electricity, heat, gas).
380
+ """
381
+
382
+ id: int
383
+ name: str
384
+ co2_emissions: float = 0.0
385
+ color: Optional[str] = None
386
+ nice_name: Optional[str] = None
387
+
388
+
389
+ @dataclass
390
+ class Scenario:
391
+ """
392
+ Represents a scenario within a network.
393
+ """
394
+
395
+ id: int
396
+ name: str
397
+ description: Optional[str] = None
398
+ is_master: bool = False
399
+ created_at: Optional[str] = None
400
+ updated_at: Optional[str] = None
@@ -0,0 +1,101 @@
1
+ # PyConvexity Data Module
2
+
3
+ The `pyconvexity.data` module provides functions for loading external energy data and integrating it with PyConvexity models. This is a simple, expert-friendly toolbox for working with real-world energy data.
4
+
5
+ ## Installation
6
+
7
+ Install PyConvexity with data dependencies:
8
+
9
+ ```bash
10
+ pip install pyconvexity[data]
11
+ ```
12
+
13
+ ## Current Data Sources
14
+
15
+ ### Global Energy Monitor (GEM)
16
+
17
+ Load power plant data from GEM's Global Integrated Power dataset.
18
+
19
+ **Setup:**
20
+ 1. Download the GEM Excel file: `Global-Integrated-Power-August-2025.xlsx`
21
+ 2. Place it in a `data/raw/global-energy-monitor/` directory, or set the path manually
22
+
23
+ **Usage:**
24
+
25
+ ```python
26
+ import pyconvexity as px
27
+
28
+ # Load generators for a specific country
29
+ generators = px.data.get_generators_from_gem(
30
+ country="USA", # ISO 3-letter country code
31
+ technology_types=["solar", "wind", "nuclear"], # Optional filter
32
+ min_capacity_mw=100.0 # Optional minimum capacity
33
+ )
34
+
35
+ # Create a network and add generators
36
+ px.create_database_with_schema("my_model.db")
37
+
38
+ with px.database_context("my_model.db") as conn:
39
+ network_id = px.create_network(conn, network_req)
40
+
41
+ # Create carriers
42
+ carriers = {}
43
+ for carrier_name in generators['carrier'].unique():
44
+ carriers[carrier_name] = px.create_carrier(conn, network_id, carrier_name)
45
+
46
+ # Add generators to network
47
+ generator_ids = px.data.add_gem_generators_to_network(
48
+ conn, network_id, generators, carrier_mapping=carriers
49
+ )
50
+ ```
51
+
52
+ ## Data Output Format
53
+
54
+ The `get_generators_from_gem()` function returns a pandas DataFrame with these columns:
55
+
56
+ - `plant_name`: Name of the power plant
57
+ - `country_iso_3`: ISO 3-letter country code
58
+ - `category`: Energy category (nuclear, thermal, renewables, storage, etc.)
59
+ - `carrier`: Energy carrier (coal, gas, solar, wind, nuclear, etc.)
60
+ - `type`: Technology type (subcritical, combined-cycle, photovoltaic, etc.)
61
+ - `capacity_mw`: Capacity in megawatts
62
+ - `start_year`: Year the plant started operation
63
+ - `latitude`: Latitude coordinate
64
+ - `longitude`: Longitude coordinate
65
+
66
+ ## Technology Mapping
67
+
68
+ GEM technologies are automatically mapped to a standardized schema:
69
+
70
+ - **Nuclear**: pressurized-water-reactor, boiling-water-reactor, small-modular-reactor
71
+ - **Thermal**: subcritical, supercritical, combined-cycle, gas-turbine
72
+ - **Renewables**: photovoltaic, thermal (solar), onshore/offshore (wind), run-of-river (hydro)
73
+ - **Storage**: lithium-ion (battery), pumped-hydro
74
+ - **Bioenergy**: biomass, biogas
75
+
76
+ ## Caching
77
+
78
+ Data is automatically cached for 7 days to improve performance. You can:
79
+
80
+ ```python
81
+ # Disable caching
82
+ generators = px.data.get_generators_from_gem(country="USA", use_cache=False)
83
+
84
+ # Clear cache
85
+ cache = px.data.DataCache()
86
+ cache.clear_cache('gem_generators')
87
+ ```
88
+
89
+ ## Examples
90
+
91
+ See `examples/gem_data_example.py` for a complete working example.
92
+
93
+ ## Future Data Sources
94
+
95
+ The framework is designed to be extensible. Planned additions include:
96
+
97
+ - IRENA Global Energy Atlas (renewable resource data)
98
+ - World Bank energy statistics
99
+ - IEA World Energy Outlook data
100
+ - OpenStreetMap transmission infrastructure
101
+ - NASA weather data for renewable profiles
@@ -0,0 +1,17 @@
1
+ """
2
+ PyConvexity Data Module
3
+
4
+ Provides functions for loading external energy data and integrating it with PyConvexity models.
5
+ This module offers a simple, expert-friendly toolbox for working with real-world energy data.
6
+ """
7
+
8
+ from .sources.gem import get_generators_from_gem, add_gem_generators_to_network
9
+ from .loaders.cache import DataCache
10
+
11
+ __all__ = [
12
+ # GEM (Global Energy Monitor) functions
13
+ "get_generators_from_gem",
14
+ "add_gem_generators_to_network",
15
+ # Caching utilities
16
+ "DataCache",
17
+ ]
@@ -0,0 +1,3 @@
1
+ """
2
+ Data loaders and caching utilities for PyConvexity.
3
+ """