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