pyconvexity 0.1.2__py3-none-any.whl → 0.1.4__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 (43) hide show
  1. pyconvexity/__init__.py +57 -8
  2. pyconvexity/_version.py +1 -2
  3. pyconvexity/core/__init__.py +0 -2
  4. pyconvexity/core/database.py +158 -0
  5. pyconvexity/core/types.py +105 -18
  6. pyconvexity/data/README.md +101 -0
  7. pyconvexity/data/__init__.py +18 -0
  8. pyconvexity/data/__pycache__/__init__.cpython-313.pyc +0 -0
  9. pyconvexity/data/loaders/__init__.py +3 -0
  10. pyconvexity/data/loaders/__pycache__/__init__.cpython-313.pyc +0 -0
  11. pyconvexity/data/loaders/__pycache__/cache.cpython-313.pyc +0 -0
  12. pyconvexity/data/loaders/cache.py +212 -0
  13. pyconvexity/data/schema/01_core_schema.sql +12 -12
  14. pyconvexity/data/schema/02_data_metadata.sql +17 -321
  15. pyconvexity/data/sources/__init__.py +5 -0
  16. pyconvexity/data/sources/__pycache__/__init__.cpython-313.pyc +0 -0
  17. pyconvexity/data/sources/__pycache__/gem.cpython-313.pyc +0 -0
  18. pyconvexity/data/sources/gem.py +412 -0
  19. pyconvexity/io/__init__.py +32 -0
  20. pyconvexity/io/excel_exporter.py +1012 -0
  21. pyconvexity/io/excel_importer.py +1109 -0
  22. pyconvexity/io/netcdf_exporter.py +192 -0
  23. pyconvexity/io/netcdf_importer.py +1602 -0
  24. pyconvexity/models/__init__.py +7 -0
  25. pyconvexity/models/attributes.py +209 -72
  26. pyconvexity/models/components.py +3 -0
  27. pyconvexity/models/network.py +17 -15
  28. pyconvexity/models/scenarios.py +177 -0
  29. pyconvexity/solvers/__init__.py +29 -0
  30. pyconvexity/solvers/pypsa/__init__.py +24 -0
  31. pyconvexity/solvers/pypsa/api.py +421 -0
  32. pyconvexity/solvers/pypsa/batch_loader.py +304 -0
  33. pyconvexity/solvers/pypsa/builder.py +566 -0
  34. pyconvexity/solvers/pypsa/constraints.py +321 -0
  35. pyconvexity/solvers/pypsa/solver.py +1106 -0
  36. pyconvexity/solvers/pypsa/storage.py +1574 -0
  37. pyconvexity/timeseries.py +327 -0
  38. pyconvexity/validation/rules.py +2 -2
  39. {pyconvexity-0.1.2.dist-info → pyconvexity-0.1.4.dist-info}/METADATA +5 -2
  40. pyconvexity-0.1.4.dist-info/RECORD +46 -0
  41. pyconvexity-0.1.2.dist-info/RECORD +0 -20
  42. {pyconvexity-0.1.2.dist-info → pyconvexity-0.1.4.dist-info}/WHEEL +0 -0
  43. {pyconvexity-0.1.2.dist-info → pyconvexity-0.1.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,412 @@
1
+ """
2
+ Global Energy Monitor (GEM) data integration for PyConvexity.
3
+
4
+ This module provides functions to load power plant data from GEM's Global Integrated Power dataset
5
+ and integrate it with PyConvexity models.
6
+ """
7
+
8
+ import sqlite3
9
+ import pandas as pd
10
+ import yaml
11
+ import country_converter as coco
12
+ from pathlib import Path
13
+ from typing import Dict, Any, Optional, List
14
+ import logging
15
+
16
+ from pyconvexity.core.types import CreateComponentRequest, StaticValue
17
+ from pyconvexity.models.components import create_component
18
+ from pyconvexity.models.attributes import set_static_attribute
19
+ from pyconvexity.data.loaders.cache import DataCache
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Default path to GEM data - can be overridden
24
+ DEFAULT_GEM_DATA_PATH = None
25
+
26
+ def _get_gem_data_path() -> Path:
27
+ """Get the path to GEM data file."""
28
+ global DEFAULT_GEM_DATA_PATH
29
+
30
+ if DEFAULT_GEM_DATA_PATH:
31
+ return Path(DEFAULT_GEM_DATA_PATH)
32
+
33
+ # Try to find the examples data
34
+ possible_paths = [
35
+ Path(__file__).parent.parent.parent.parent.parent / "examples" / "data" / "raw" / "global-energy-monitor" / "Global-Integrated-Power-August-2025.xlsx",
36
+ Path("data/raw/global-energy-monitor/Global-Integrated-Power-August-2025.xlsx"),
37
+ Path("../examples/data/raw/global-energy-monitor/Global-Integrated-Power-August-2025.xlsx"),
38
+ ]
39
+
40
+ for path in possible_paths:
41
+ if path.exists():
42
+ return path
43
+
44
+ raise FileNotFoundError(
45
+ "GEM data file not found. Please set the path using set_gem_data_path() or "
46
+ "ensure the file exists at one of the expected locations."
47
+ )
48
+
49
+ def set_gem_data_path(path: str):
50
+ """Set the path to the GEM data file."""
51
+ global DEFAULT_GEM_DATA_PATH
52
+ DEFAULT_GEM_DATA_PATH = path
53
+
54
+ def _load_gem_mapping() -> Dict[str, Any]:
55
+ """Load the GEM to carriers mapping configuration."""
56
+ # Try to find the mapping file
57
+ possible_paths = [
58
+ Path(__file__).parent.parent.parent.parent.parent / "examples" / "schema" / "gem_mapping.yaml",
59
+ Path("schema/gem_mapping.yaml"),
60
+ Path("../examples/schema/gem_mapping.yaml"),
61
+ ]
62
+
63
+ for mapping_file in possible_paths:
64
+ if mapping_file.exists():
65
+ with open(mapping_file, 'r') as f:
66
+ return yaml.safe_load(f)
67
+
68
+ # Fallback to embedded mapping if file not found
69
+ logger.warning("GEM mapping file not found, using embedded mapping")
70
+ return _get_embedded_gem_mapping()
71
+
72
+ def _get_embedded_gem_mapping() -> Dict[str, Any]:
73
+ """Embedded GEM mapping as fallback."""
74
+ return {
75
+ 'technology_mapping': {
76
+ # Nuclear
77
+ "pressurized water reactor": ["nuclear", "nuclear", "pressurized-water-reactor"],
78
+ "boiling water reactor": ["nuclear", "nuclear", "boiling-water-reactor"],
79
+ "small modular reactor": ["nuclear", "nuclear", "small-modular-reactor"],
80
+
81
+ # Thermal coal
82
+ "subcritical": ["thermal", "coal", "subcritical"],
83
+ "supercritical": ["thermal", "coal", "supercritical"],
84
+ "ultra-supercritical": ["thermal", "coal", "supercritical"],
85
+
86
+ # Thermal gas
87
+ "combined cycle": ["thermal", "gas", "combined-cycle"],
88
+ "gas turbine": ["thermal", "gas", "gas-turbine"],
89
+
90
+ # Renewables
91
+ "PV": ["renewables", "solar", "photovoltaic"],
92
+ "Solar Thermal": ["renewables", "solar", "thermal"],
93
+ "Onshore": ["renewables", "wind", "onshore"],
94
+ "Offshore hard mount": ["renewables", "wind", "offshore"],
95
+ "Offshore floating": ["renewables", "wind", "offshore"],
96
+ "run-of-river": ["renewables", "hydro", "run-of-river"],
97
+ "pumped storage": ["storage", "pumped-hydro", "unspecified"],
98
+
99
+ # Storage
100
+ "battery": ["storage", "battery", "lithium-ion"],
101
+
102
+ # Bioenergy
103
+ "biomass": ["bioenergy", "biomass", "unspecified"],
104
+ "biogas": ["bioenergy", "biogas", "unspecified"],
105
+ },
106
+ 'type_fallback': {
107
+ "nuclear": ["nuclear", "nuclear", "unspecified"],
108
+ "coal": ["thermal", "coal", "unspecified"],
109
+ "oil/gas": ["thermal", "gas", "unspecified"],
110
+ "wind": ["renewables", "wind", "unspecified"],
111
+ "solar": ["renewables", "solar", "unspecified"],
112
+ "geothermal": ["renewables", "geothermal", "unspecified"],
113
+ "hydropower": ["renewables", "hydro", "unspecified"],
114
+ "bioenergy": ["bioenergy", "biomass", "unspecified"],
115
+ },
116
+ 'default_mapping': ["unknown", "unspecified", "unspecified"]
117
+ }
118
+
119
+ def _map_technology_to_carriers(technology: str, gem_type: str, mapping_config: Dict[str, Any]) -> tuple:
120
+ """
121
+ Map GEM technology to carriers schema.
122
+
123
+ Args:
124
+ technology: GEM Technology field value
125
+ gem_type: GEM Type field value
126
+ mapping_config: Loaded mapping configuration
127
+
128
+ Returns:
129
+ tuple: (category, carrier, type)
130
+ """
131
+ # Clean the technology string
132
+ if pd.isna(technology):
133
+ technology = "unknown"
134
+ else:
135
+ technology = str(technology).strip()
136
+
137
+ # Try to map using technology mapping
138
+ tech_mapping = mapping_config['technology_mapping']
139
+ if technology in tech_mapping:
140
+ mapped = tech_mapping[technology]
141
+ if len(mapped) == 2:
142
+ return mapped[0], mapped[1], "unspecified"
143
+ elif len(mapped) == 3:
144
+ return mapped[0], mapped[1], mapped[2]
145
+
146
+ # Fallback to type mapping
147
+ type_fallback = mapping_config['type_fallback']
148
+ if pd.notna(gem_type) and str(gem_type).strip() in type_fallback:
149
+ mapped = type_fallback[str(gem_type).strip()]
150
+ if len(mapped) == 2:
151
+ return mapped[0], mapped[1], "unspecified"
152
+ elif len(mapped) == 3:
153
+ return mapped[0], mapped[1], mapped[2]
154
+
155
+ # Default mapping
156
+ default = mapping_config['default_mapping']
157
+ return default[0], default[1], default[2]
158
+
159
+ def get_generators_from_gem(
160
+ country: str,
161
+ gem_data_path: Optional[str] = None,
162
+ technology_types: Optional[List[str]] = None,
163
+ min_capacity_mw: float = 0.0,
164
+ status_filter: Optional[List[str]] = None,
165
+ use_cache: bool = True
166
+ ) -> pd.DataFrame:
167
+ """
168
+ Load generator data from GEM for a specific country.
169
+
170
+ Args:
171
+ country: ISO 3-letter country code (e.g., "USA", "DEU", "CHN")
172
+ gem_data_path: Optional path to GEM Excel file (overrides default)
173
+ technology_types: Optional list of technology types to filter by
174
+ min_capacity_mw: Minimum capacity in MW (default: 0.0)
175
+ status_filter: Optional list of status values (default: ["operating", "construction"])
176
+ use_cache: Whether to use cached data (default: True)
177
+
178
+ Returns:
179
+ pandas.DataFrame: Generator data with columns:
180
+ - plant_name: Name of the power plant
181
+ - country_iso_3: ISO 3-letter country code
182
+ - category: Energy category (nuclear, thermal, renewables, etc.)
183
+ - carrier: Energy carrier (coal, gas, solar, wind, etc.)
184
+ - type: Technology type (subcritical, combined-cycle, photovoltaic, etc.)
185
+ - capacity_mw: Capacity in megawatts
186
+ - start_year: Year the plant started operation
187
+ - latitude: Latitude coordinate
188
+ - longitude: Longitude coordinate
189
+
190
+ Raises:
191
+ FileNotFoundError: If GEM data file cannot be found
192
+ ValueError: If country code is invalid
193
+ """
194
+ if gem_data_path:
195
+ set_gem_data_path(gem_data_path)
196
+
197
+ # Validate country code
198
+ country = country.upper()
199
+ if len(country) != 3:
200
+ raise ValueError(f"Country code must be 3 letters, got: {country}")
201
+
202
+ # Set default status filter
203
+ if status_filter is None:
204
+ status_filter = ["operating", "construction"]
205
+
206
+ # Create cache key
207
+ cache_filters = {
208
+ 'country': country,
209
+ 'technology_types': technology_types,
210
+ 'min_capacity_mw': min_capacity_mw,
211
+ 'status_filter': status_filter
212
+ }
213
+
214
+ # Try to get cached data
215
+ cache = DataCache()
216
+ if use_cache:
217
+ cached_data = cache.get_cached_data('gem_generators', cache_filters)
218
+ if cached_data is not None:
219
+ return cached_data
220
+
221
+ # Load and process data
222
+ logger.info(f"Loading GEM data for country: {country}")
223
+
224
+ gem_file = _get_gem_data_path()
225
+ df = pd.read_excel(gem_file, sheet_name='Power facilities')
226
+
227
+ # Apply status filter
228
+ df = df[df['Status'].isin(status_filter)]
229
+
230
+ # Filter out captive industry use
231
+ df = df[~df['Captive Industry Use'].isin(['power', 'heat', 'both'])]
232
+
233
+ # Convert country names to ISO codes
234
+ country_codes_3 = coco.convert(
235
+ names=df['Country/area'],
236
+ to='ISO3',
237
+ not_found='pass'
238
+ )
239
+ df['country_iso_3'] = country_codes_3
240
+
241
+ # Filter by country
242
+ df = df[df['country_iso_3'] == country]
243
+
244
+ if len(df) == 0:
245
+ logger.warning(f"No generators found for country: {country}")
246
+ return pd.DataFrame()
247
+
248
+ # Rename columns
249
+ df = df.rename(columns={
250
+ 'Plant / Project name': 'plant_name',
251
+ 'Capacity (MW)': 'capacity_mw',
252
+ 'Start year': 'start_year',
253
+ 'Latitude': 'latitude',
254
+ 'Longitude': 'longitude'
255
+ })
256
+
257
+ # Clean start_year column - convert non-numeric values to NaN
258
+ df['start_year'] = pd.to_numeric(df['start_year'], errors='coerce')
259
+
260
+ # Load mapping configuration and apply technology mapping
261
+ mapping_config = _load_gem_mapping()
262
+
263
+ df['category'] = None
264
+ df['carrier'] = None
265
+ df['type'] = None
266
+
267
+ for idx, row in df.iterrows():
268
+ category, carrier, tech_type = _map_technology_to_carriers(
269
+ row['Technology'], row['Type'], mapping_config
270
+ )
271
+ df.at[idx, 'category'] = category
272
+ df.at[idx, 'carrier'] = carrier
273
+ df.at[idx, 'type'] = tech_type
274
+
275
+ # Apply filters
276
+ if technology_types:
277
+ df = df[df['carrier'].isin(technology_types)]
278
+
279
+ if min_capacity_mw > 0:
280
+ df = df[df['capacity_mw'] >= min_capacity_mw]
281
+
282
+ # Aggregate plants by key attributes
283
+ df = (
284
+ df.groupby(['country_iso_3', 'category', 'carrier', 'type', 'plant_name'])
285
+ .agg({
286
+ 'capacity_mw': 'sum',
287
+ 'start_year': 'first',
288
+ 'latitude': 'first',
289
+ 'longitude': 'first',
290
+ })
291
+ .reset_index()
292
+ )
293
+
294
+ # Select final columns
295
+ result_df = df[[
296
+ 'plant_name',
297
+ 'country_iso_3',
298
+ 'category',
299
+ 'carrier',
300
+ 'type',
301
+ 'capacity_mw',
302
+ 'start_year',
303
+ 'latitude',
304
+ 'longitude',
305
+ ]]
306
+
307
+ # Cache the result
308
+ if use_cache:
309
+ cache.cache_data('gem_generators', result_df, cache_filters)
310
+
311
+ logger.info(f"Loaded {len(result_df)} generators for {country}")
312
+ return result_df
313
+
314
+ def add_gem_generators_to_network(
315
+ conn: sqlite3.Connection,
316
+ network_id: int,
317
+ generators_df: pd.DataFrame,
318
+ bus_mapping: Optional[Dict[str, int]] = None,
319
+ carrier_mapping: Optional[Dict[str, int]] = None
320
+ ) -> List[int]:
321
+ """
322
+ Add GEM generators to a PyConvexity network.
323
+
324
+ Args:
325
+ conn: Database connection
326
+ network_id: ID of the network to add generators to
327
+ generators_df: DataFrame from get_generators_from_gem()
328
+ bus_mapping: Optional mapping from region/location to bus IDs
329
+ carrier_mapping: Optional mapping from carrier names to carrier IDs
330
+
331
+ Returns:
332
+ List of created generator component IDs
333
+
334
+ Raises:
335
+ ValueError: If required data is missing
336
+ """
337
+ if generators_df.empty:
338
+ logger.warning("No generators to add")
339
+ return []
340
+
341
+ created_ids = []
342
+ name_counter = {} # Track duplicate names
343
+
344
+ for _, gen in generators_df.iterrows():
345
+ # Determine bus_id (simplified - could be enhanced with spatial mapping)
346
+ bus_id = None
347
+ if bus_mapping:
348
+ # Try different possible column names for bus assignment
349
+ for col_name in ['region', 'nearest_bus', 'bus', 'bus_name']:
350
+ if col_name in gen and pd.notna(gen[col_name]):
351
+ bus_id = bus_mapping.get(gen[col_name])
352
+ if bus_id:
353
+ break
354
+
355
+ # Determine carrier_id
356
+ carrier_id = None
357
+ if carrier_mapping:
358
+ carrier_id = carrier_mapping.get(gen['carrier'])
359
+
360
+ # Make generator name unique
361
+ base_name = gen['plant_name']
362
+ if base_name in name_counter:
363
+ name_counter[base_name] += 1
364
+ unique_name = f"{base_name}_{name_counter[base_name]}"
365
+ else:
366
+ name_counter[base_name] = 0
367
+ unique_name = base_name
368
+
369
+ # Create generator component
370
+ component_id = create_component(
371
+ conn=conn,
372
+ network_id=network_id,
373
+ component_type="GENERATOR",
374
+ name=unique_name,
375
+ latitude=gen['latitude'] if pd.notna(gen['latitude']) else None,
376
+ longitude=gen['longitude'] if pd.notna(gen['longitude']) else None,
377
+ carrier_id=carrier_id,
378
+ bus_id=bus_id
379
+ )
380
+
381
+ # Set generator attributes
382
+ set_static_attribute(conn, component_id, "p_nom", StaticValue(float(gen['capacity_mw'])))
383
+
384
+ # Set marginal costs based on technology
385
+ marginal_cost = 0.0 # Default for renewables (wind, solar, hydro)
386
+ if gen['carrier'] == 'gas':
387
+ marginal_cost = 50.0 # €/MWh for gas
388
+ elif gen['carrier'] == 'coal':
389
+ marginal_cost = 35.0 # €/MWh for coal
390
+ elif gen['carrier'] == 'biomass':
391
+ marginal_cost = 45.0 # €/MWh for biomass
392
+ elif gen['carrier'] == 'nuclear':
393
+ marginal_cost = 15.0 # €/MWh for nuclear
394
+ # Wind, solar, hydro, pumped-hydro remain at 0.0
395
+
396
+ set_static_attribute(conn, component_id, "marginal_cost", StaticValue(marginal_cost))
397
+
398
+ if pd.notna(gen['start_year']):
399
+ try:
400
+ set_static_attribute(conn, component_id, "build_year", StaticValue(int(gen['start_year'])))
401
+ except:
402
+ pass # Skip if build_year attribute doesn't exist
403
+
404
+ # Technology metadata would be stored here if the database had a metadata table
405
+ # For now, the Ireland demo will use the original generator dataframe for technology info
406
+
407
+ created_ids.append(component_id)
408
+
409
+ logger.debug(f"Created generator {component_id}: {gen['plant_name']} ({gen['capacity_mw']} MW)")
410
+
411
+ logger.info(f"Added {len(created_ids)} generators to network {network_id}")
412
+ return created_ids
@@ -0,0 +1,32 @@
1
+ """
2
+ Input/Output operations for PyConvexity.
3
+
4
+ Provides functionality for importing and exporting energy system models
5
+ in various formats including Excel, CSV, and other data formats.
6
+ """
7
+
8
+ # Import main classes for easy access
9
+ try:
10
+ from .excel_exporter import ExcelModelExporter
11
+ from .excel_importer import ExcelModelImporter
12
+
13
+ __all__ = [
14
+ "ExcelModelExporter",
15
+ "ExcelModelImporter"
16
+ ]
17
+ except ImportError:
18
+ # Excel dependencies not available
19
+ __all__ = []
20
+
21
+ # NetCDF I/O functionality
22
+ try:
23
+ from .netcdf_exporter import NetCDFModelExporter
24
+ from .netcdf_importer import NetCDFModelImporter
25
+
26
+ __all__.extend([
27
+ "NetCDFModelExporter",
28
+ "NetCDFModelImporter"
29
+ ])
30
+ except ImportError:
31
+ # NetCDF/PyPSA dependencies not available
32
+ pass