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,1012 @@
1
+ """
2
+ Excel exporter for PyConvexity energy system models.
3
+ Exports complete network models to Excel workbooks with multiple sheets.
4
+ """
5
+
6
+ import logging
7
+ import sqlite3
8
+ from typing import Dict, Any, Optional, List
9
+ from pathlib import Path
10
+ import pandas as pd
11
+ from datetime import datetime
12
+ import json
13
+
14
+ # Import functions directly from pyconvexity
15
+ from pyconvexity.core.database import open_connection
16
+ from pyconvexity.core.errors import AttributeNotFound
17
+ from pyconvexity.models import (
18
+ list_components_by_type, list_carriers, get_network_info,
19
+ get_network_time_periods, get_attribute, list_component_attributes,
20
+ get_network_config
21
+ )
22
+ from pyconvexity.validation import list_validation_rules
23
+ from pyconvexity.models.attributes import get_timeseries as get_timeseries_conn
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ class ExcelModelExporter:
28
+ """Export entire network model to Excel workbook"""
29
+
30
+ def __init__(self):
31
+ self.logger = logging.getLogger(__name__)
32
+
33
+ def export_model_to_excel(
34
+ self,
35
+ db_path: str,
36
+ network_id: int,
37
+ output_path: str,
38
+ scenario_id: Optional[int] = None,
39
+ progress_callback: Optional[callable] = None
40
+ ) -> Dict[str, Any]:
41
+ """
42
+ Export complete network model to Excel workbook
43
+
44
+ Args:
45
+ db_path: Database path
46
+ network_id: Network ID to export
47
+ output_path: Excel file output path
48
+ scenario_id: Scenario ID (defaults to master scenario)
49
+ progress_callback: Optional callback for progress updates
50
+
51
+ Returns:
52
+ Export statistics and metadata
53
+ """
54
+
55
+ try:
56
+ if progress_callback:
57
+ progress_callback(0, "Starting Excel export...")
58
+
59
+ # Connect to database
60
+ conn = open_connection(db_path)
61
+
62
+ if progress_callback:
63
+ progress_callback(5, "Loading network information...")
64
+
65
+ # Get network information
66
+ network_info = get_network_info(conn, network_id)
67
+
68
+ # Get master scenario if no scenario specified
69
+ if scenario_id is None:
70
+ cursor = conn.execute(
71
+ "SELECT id FROM scenarios WHERE network_id = ? AND is_master = TRUE",
72
+ (network_id,)
73
+ )
74
+ scenario_result = cursor.fetchone()
75
+ if scenario_result:
76
+ scenario_id = scenario_result[0]
77
+ else:
78
+ raise ValueError("No master scenario found for network")
79
+
80
+ if progress_callback:
81
+ progress_callback(10, "Loading carriers...")
82
+
83
+ # Get carriers
84
+ carriers = list_carriers(conn, network_id)
85
+
86
+ if progress_callback:
87
+ progress_callback(15, "Loading components...")
88
+
89
+ # Get all component types
90
+ component_types = ['BUS', 'GENERATOR', 'LOAD', 'LINE', 'LINK', 'STORAGE_UNIT', 'STORE', 'CONSTRAINT']
91
+
92
+ # Load components by type
93
+ components_by_type = {}
94
+ for comp_type in component_types:
95
+ components = list_components_by_type(conn, network_id, comp_type)
96
+ components_by_type[comp_type] = components
97
+
98
+ if progress_callback:
99
+ progress_callback(25, "Processing component attributes...")
100
+
101
+ # Process components and their attributes
102
+ processed_components = {}
103
+ timeseries_data = {}
104
+
105
+ for comp_type, components in components_by_type.items():
106
+ processed_components[comp_type] = []
107
+ timeseries_data[comp_type] = {}
108
+
109
+ for component in components:
110
+ # Check for cancellation during processing
111
+ if progress_callback:
112
+ try:
113
+ progress_callback(None, None) # Check for cancellation
114
+ except KeyboardInterrupt:
115
+ self.logger.info("Excel export cancelled by user")
116
+ raise
117
+
118
+ # Get component attributes (all possible attributes for this component type)
119
+ attributes = self._get_component_attributes(conn, component.id, scenario_id, comp_type)
120
+
121
+ # Process component data
122
+ processed_component = self._process_component_for_excel(
123
+ component, attributes, carriers, components_by_type
124
+ )
125
+ processed_components[comp_type].append(processed_component)
126
+
127
+ # Extract timeseries data
128
+ for attr_name, attr_data in attributes.items():
129
+ if isinstance(attr_data, dict) and 'Timeseries' in attr_data:
130
+ if comp_type not in timeseries_data:
131
+ timeseries_data[comp_type] = {}
132
+ if attr_name not in timeseries_data[comp_type]:
133
+ timeseries_data[comp_type][attr_name] = {}
134
+
135
+ # Handle both new efficient format and legacy format
136
+ if 'values' in attr_data:
137
+ # New efficient format - store values directly
138
+ timeseries_data[comp_type][attr_name][component.name] = attr_data['values']
139
+ elif 'points' in attr_data:
140
+ # Legacy format - store the timeseries points
141
+ timeseries_data[comp_type][attr_name][component.name] = attr_data['points']
142
+
143
+ if progress_callback:
144
+ progress_callback(50, "Creating Excel workbook...")
145
+
146
+ # Check for cancellation before starting Excel creation
147
+ if progress_callback:
148
+ try:
149
+ progress_callback(None, None) # Check for cancellation
150
+ except KeyboardInterrupt:
151
+ self.logger.info("Excel export cancelled before workbook creation")
152
+ raise
153
+
154
+ # Get scenario information if scenario_id is provided
155
+ scenario_info = None
156
+ if scenario_id is not None:
157
+ scenario_info = self._get_scenario_info(conn, scenario_id)
158
+
159
+ # Create Excel workbook
160
+ with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
161
+ # Create overview sheet
162
+ self._create_overview_sheet(writer, network_info, processed_components, scenario_info)
163
+
164
+ # Create component sheets
165
+ for comp_type in component_types:
166
+ if processed_components[comp_type]:
167
+ # Check for cancellation during sheet creation
168
+ if progress_callback:
169
+ try:
170
+ progress_callback(None, None) # Check for cancellation
171
+ except KeyboardInterrupt:
172
+ self.logger.info(f"Excel export cancelled during {comp_type} sheet creation")
173
+ raise
174
+
175
+ self._create_component_sheet(writer, conn, comp_type, processed_components[comp_type])
176
+
177
+ # Create timeseries sheet if there's timeseries data
178
+ if comp_type in timeseries_data and timeseries_data[comp_type]:
179
+ self._create_timeseries_sheet(
180
+ writer, comp_type, timeseries_data[comp_type], network_id, conn
181
+ )
182
+
183
+ # Create carriers sheet
184
+ self._create_carriers_sheet(writer, carriers)
185
+
186
+ # Create network config sheet
187
+ self._create_network_config_sheet(writer, network_id, conn)
188
+
189
+ # Create statistics sheet if solve results are available
190
+ self._create_statistics_sheet(writer, network_id, scenario_id, conn)
191
+
192
+ # Create per-year statistics sheet if available
193
+ self._create_per_year_statistics_sheet(writer, network_id, scenario_id, conn)
194
+
195
+ if progress_callback:
196
+ progress_callback(100, "Excel export completed")
197
+
198
+ # Calculate statistics
199
+ stats = self._calculate_export_stats(processed_components, timeseries_data)
200
+
201
+ return {
202
+ "success": True,
203
+ "message": f"Network exported to Excel: {output_path}",
204
+ "output_path": output_path,
205
+ "stats": stats
206
+ }
207
+
208
+ except Exception as e:
209
+ self.logger.error(f"Excel export failed: {e}", exc_info=True)
210
+ if progress_callback:
211
+ progress_callback(None, f"Export failed: {str(e)}")
212
+ raise
213
+
214
+ def _get_component_attributes(self, conn, component_id: int, scenario_id: int, component_type: str) -> Dict[str, Any]:
215
+ """Get all possible attributes for a component type, with values where set"""
216
+ attributes = {}
217
+
218
+ # Get ALL possible attribute names for this component type from validation rules
219
+ validation_rules = list_validation_rules(conn, component_type)
220
+
221
+ for rule in validation_rules:
222
+ attr_name = rule.attribute_name
223
+ try:
224
+ # Try to get the attribute value (may not exist)
225
+ attr_value = get_attribute(conn, component_id, attr_name, scenario_id)
226
+
227
+ if attr_value.variant == "Static":
228
+ # Extract static value
229
+ static_value = attr_value.static_value
230
+ if static_value.data_type() == "float":
231
+ attributes[attr_name] = static_value.as_f64()
232
+ elif static_value.data_type() == "int":
233
+ attributes[attr_name] = int(static_value.as_f64())
234
+ elif static_value.data_type() == "boolean":
235
+ attributes[attr_name] = static_value.data["Boolean"]
236
+ elif static_value.data_type() == "string":
237
+ attributes[attr_name] = static_value.data["String"]
238
+ else:
239
+ attributes[attr_name] = static_value.data
240
+
241
+ elif attr_value.variant == "Timeseries":
242
+ # Use new efficient timeseries access
243
+ try:
244
+ timeseries = get_timeseries_conn(conn, component_id, attr_name, scenario_id)
245
+ if timeseries and timeseries.values:
246
+ attributes[attr_name] = {
247
+ 'Timeseries': True,
248
+ 'values': timeseries.values
249
+ }
250
+ else:
251
+ # Fallback to legacy method if new method fails
252
+ attributes[attr_name] = {
253
+ 'Timeseries': True,
254
+ 'points': attr_value.timeseries_value
255
+ }
256
+ except Exception as ts_e:
257
+ self.logger.warning(f"Failed to load timeseries {attr_name} for component {component_id}: {ts_e}")
258
+ # Fallback to legacy method
259
+ attributes[attr_name] = {
260
+ 'Timeseries': True,
261
+ 'points': attr_value.timeseries_value
262
+ }
263
+
264
+ except AttributeNotFound:
265
+ # Attribute not set - always use empty string for blank Excel cell
266
+ attributes[attr_name] = ""
267
+
268
+ except Exception as e:
269
+ self.logger.warning(f"Failed to load attribute {attr_name} for component {component_id}: {e}")
270
+ # Still include the attribute with empty value
271
+ attributes[attr_name] = ""
272
+ continue
273
+
274
+ return attributes
275
+
276
+ def _process_component_for_excel(self, component, attributes: Dict, carriers: List, components_by_type: Dict) -> Dict[str, Any]:
277
+ """Process a component for Excel export"""
278
+ processed = {
279
+ 'name': component.name,
280
+ 'type': component.component_type.lower(),
281
+ }
282
+
283
+ # Add carrier name
284
+ if component.carrier_id:
285
+ carrier = next((c for c in carriers if c['id'] == component.carrier_id), None)
286
+ carrier_name = carrier['name'] if carrier else 'CARRIER_NOT_FOUND'
287
+ processed['carrier'] = carrier_name
288
+ self.logger.info(f"Component '{component.name}' has carrier_id={component.carrier_id}, resolved to carrier: {carrier_name}")
289
+ else:
290
+ processed['carrier'] = '' # Use empty string for no carrier
291
+ self.logger.info(f"Component '{component.name}' has no carrier_id (carrier_id={component.carrier_id})")
292
+
293
+ # Add bus connections
294
+ if component.bus_id:
295
+ bus = next((b for b in components_by_type.get('BUS', []) if b.id == component.bus_id), None)
296
+ processed['bus'] = bus.name if bus else ''
297
+ else:
298
+ processed['bus'] = ''
299
+
300
+ if component.bus0_id:
301
+ bus0 = next((b for b in components_by_type.get('BUS', []) if b.id == component.bus0_id), None)
302
+ processed['bus0'] = bus0.name if bus0 else ''
303
+ else:
304
+ processed['bus0'] = ''
305
+
306
+ if component.bus1_id:
307
+ bus1 = next((b for b in components_by_type.get('BUS', []) if b.id == component.bus1_id), None)
308
+ processed['bus1'] = bus1.name if bus1 else ''
309
+ else:
310
+ processed['bus1'] = ''
311
+
312
+ # Add coordinates
313
+ processed['latitude'] = component.latitude if component.latitude is not None else ''
314
+ processed['longitude'] = component.longitude if component.longitude is not None else ''
315
+
316
+ # Add attributes
317
+ for attr_name, attr_value in attributes.items():
318
+ if isinstance(attr_value, dict) and 'Timeseries' in attr_value:
319
+ processed[attr_name] = '[timeseries]'
320
+ else:
321
+ # Special handling for carrier attribute - don't overwrite relationship carrier
322
+ if attr_name == 'carrier':
323
+ if component.carrier_id is not None:
324
+ self.logger.info(f"DEBUG: Skipping carrier attribute '{attr_value}' for '{component.name}' - using relationship carrier '{processed['carrier']}'")
325
+ continue # Skip the carrier attribute, keep the relationship carrier
326
+ else:
327
+ self.logger.info(f"DEBUG: Using carrier attribute '{attr_value}' for '{component.name}' (no relationship carrier)")
328
+
329
+ processed[attr_name] = attr_value
330
+
331
+ self.logger.info(f"DEBUG: Final processed data for '{component.name}': carrier='{processed.get('carrier', 'NOT_SET')}'")
332
+ return processed
333
+
334
+ def _filter_component_columns(self, conn, component_data: Dict[str, Any], component_type: str) -> Dict[str, Any]:
335
+ """Filter out unused columns based on component type, following DatabaseTable logic"""
336
+
337
+ filtered_data = {}
338
+
339
+ # Always include basic fields (name, carrier, latitude, longitude)
340
+ # Note: bus connections are NOT basic fields - they are component-type specific
341
+ # Note: "type" is NOT included - it's implicit based on the sheet/component type
342
+ # Note: CONSTRAINT components don't have carrier, latitude, or longitude - they are code-based rules
343
+ if component_type.upper() == 'CONSTRAINT':
344
+ basic_fields = ['name'] # Constraints only have name - no physical location or carrier
345
+ else:
346
+ basic_fields = ['name', 'carrier', 'latitude', 'longitude']
347
+
348
+ for field in basic_fields:
349
+ if field in component_data:
350
+ filtered_data[field] = component_data[field]
351
+ self.logger.info(f"Added basic field '{field}' = '{component_data[field]}' for component type {component_type}")
352
+ if field == 'carrier':
353
+ self.logger.info(f"DEBUG: Setting carrier field to '{component_data[field]}' from component_data")
354
+
355
+ # Add bus connection columns based on component type - EXACT DatabaseTable logic
356
+ component_type_lower = component_type.lower()
357
+ needs_bus_connection = component_type_lower in ['generator', 'load', 'storage_unit', 'store', 'unmet_load']
358
+ needs_two_bus_connections = component_type_lower in ['line', 'link']
359
+
360
+
361
+ if needs_bus_connection:
362
+ if 'bus' in component_data:
363
+ filtered_data['bus'] = component_data['bus']
364
+ elif needs_two_bus_connections:
365
+ if 'bus0' in component_data:
366
+ filtered_data['bus0'] = component_data['bus0']
367
+ if 'bus1' in component_data:
368
+ filtered_data['bus1'] = component_data['bus1']
369
+ else:
370
+ # Buses and other components don't get bus connection columns
371
+ self.logger.info(f"No bus connection columns for {component_type_lower}")
372
+
373
+ # Get validation rules to determine which attributes are input vs output
374
+ try:
375
+
376
+ # Add all other attributes that aren't filtered out
377
+ for key, value in component_data.items():
378
+ if key in filtered_data:
379
+ continue # Already handled
380
+
381
+ # Filter out unused attributes following DatabaseTable logic
382
+ should_exclude = False
383
+ exclude_reason = ""
384
+
385
+ # Note: Carrier attribute exclusion is now handled in _process_component_for_excel
386
+ # to prevent overwriting relationship carriers
387
+
388
+ # Remove location and carrier attributes for CONSTRAINT components (they don't have physical location or carriers)
389
+ if component_type.upper() == 'CONSTRAINT' and key in ['carrier', 'latitude', 'longitude']:
390
+ should_exclude = True
391
+ exclude_reason = f"constraint exclusion - constraints don't have {key}"
392
+
393
+ # Remove 'type' and 'unit' attributes for buses (not used in this application)
394
+ elif component_type.upper() == 'BUS' and key in ['type', 'unit']:
395
+ should_exclude = True
396
+ exclude_reason = f"bus-specific exclusion ({key})"
397
+
398
+ # Remove 'x' and 'y' coordinates for buses only - we use latitude/longitude instead
399
+ elif component_type.upper() == 'BUS' and key in ['x', 'y']:
400
+ should_exclude = True
401
+ exclude_reason = f"bus coordinate exclusion ({key})"
402
+
403
+ # Remove sub-network and slack generator attributes for buses
404
+ elif component_type.upper() == 'BUS' and key in ['sub_network', 'slack_generator']:
405
+ should_exclude = True
406
+ exclude_reason = f"bus network exclusion ({key})"
407
+
408
+ # CRITICAL: Remove bus connection columns for components that shouldn't have them
409
+ elif key in ['bus', 'bus0', 'bus1']:
410
+ if key == 'bus' and not needs_bus_connection:
411
+ should_exclude = True
412
+ exclude_reason = f"bus connection not needed for {component_type_lower}"
413
+ elif key in ['bus0', 'bus1'] and not needs_two_bus_connections:
414
+ should_exclude = True
415
+ exclude_reason = f"two-bus connection not needed for {component_type_lower}"
416
+
417
+
418
+ if should_exclude:
419
+ self.logger.info(f"Excluded {key}: {exclude_reason}")
420
+ else:
421
+ # Special handling for carrier attribute - don't overwrite relationship field
422
+ if key == 'carrier' and 'carrier' in filtered_data:
423
+ self.logger.info(f"Skipping carrier attribute '{value}' - keeping relationship carrier '{filtered_data['carrier']}'")
424
+ else:
425
+ filtered_data[key] = value
426
+ self.logger.info(f"Added attribute: {key} = {value}")
427
+
428
+ except Exception as e:
429
+ self.logger.warning(f"Could not load validation rules for filtering: {e}")
430
+ # Fallback: include all attributes except the basic exclusions
431
+ for key, value in component_data.items():
432
+ if key in filtered_data:
433
+ continue
434
+ if key == 'carrier': # Skip carrier attribute
435
+ continue
436
+ filtered_data[key] = value
437
+
438
+
439
+ return filtered_data
440
+
441
+ def _create_overview_sheet(self, writer, network_info: Dict, processed_components: Dict, scenario_info: Dict = None):
442
+ """Create overview sheet with network metadata"""
443
+ # Create key-value pairs as separate lists for two columns
444
+ keys = []
445
+ values = []
446
+
447
+ # Network information
448
+ keys.extend(['Name', 'Description', 'Time Start', 'Time End', 'Time Interval'])
449
+ values.extend([
450
+ network_info['name'],
451
+ network_info.get('description', ''),
452
+ network_info['time_start'],
453
+ network_info['time_end'],
454
+ network_info['time_interval']
455
+ ])
456
+
457
+ # Scenario information
458
+ if scenario_info:
459
+ keys.append('')
460
+ values.append('')
461
+ keys.extend(['Scenario Information', 'Scenario Name', 'Scenario Description', 'Is Master Scenario', 'Scenario Created'])
462
+ values.extend([
463
+ '',
464
+ scenario_info.get('name', 'Unknown'),
465
+ scenario_info.get('description', '') or 'No description',
466
+ 'Yes' if scenario_info.get('is_master', False) else 'No',
467
+ scenario_info.get('created_at', '')
468
+ ])
469
+
470
+ # Empty row
471
+ keys.append('')
472
+ values.append('')
473
+
474
+ # Export information
475
+ keys.extend(['Export Information', 'Export Date', 'Export Version'])
476
+ values.extend(['', datetime.now().strftime('%Y-%m-%d %H:%M:%S'), self._get_app_version()])
477
+
478
+ # Create two-column DataFrame
479
+ df = pd.DataFrame({
480
+ 'Property': keys,
481
+ 'Value': values
482
+ })
483
+ df.to_excel(writer, sheet_name='Overview', index=False)
484
+
485
+ def _get_scenario_info(self, conn, scenario_id: int) -> Dict[str, Any]:
486
+ """Get scenario information from database"""
487
+ try:
488
+ cursor = conn.execute("""
489
+ SELECT id, network_id, name, description, is_master, created_at
490
+ FROM scenarios
491
+ WHERE id = ?
492
+ """, (scenario_id,))
493
+
494
+ row = cursor.fetchone()
495
+ if not row:
496
+ self.logger.warning(f"No scenario found with ID {scenario_id}")
497
+ return {}
498
+
499
+ return {
500
+ 'id': row[0],
501
+ 'network_id': row[1],
502
+ 'name': row[2],
503
+ 'description': row[3],
504
+ 'is_master': bool(row[4]),
505
+ 'created_at': row[5]
506
+ }
507
+
508
+ except Exception as e:
509
+ self.logger.warning(f"Failed to retrieve scenario info: {e}")
510
+ return {}
511
+
512
+ def _create_component_sheet(self, writer, conn, component_type: str, components: List[Dict]):
513
+ """Create a sheet for a specific component type"""
514
+ if not components:
515
+ return
516
+
517
+ # Apply column filtering to each component
518
+ filtered_components = []
519
+ for component in components:
520
+ filtered_component = self._filter_component_columns(conn, component, component_type)
521
+ filtered_components.append(filtered_component)
522
+
523
+ # Convert to DataFrame
524
+ df = pd.DataFrame(filtered_components)
525
+
526
+ # Reorder columns to put core fields first
527
+ core_columns = ['name', 'carrier', 'bus', 'bus0', 'bus1', 'latitude', 'longitude']
528
+ other_columns = []
529
+ for col in df.columns:
530
+ if col not in core_columns:
531
+ other_columns.append(col)
532
+ ordered_columns = []
533
+ for col in core_columns:
534
+ if col in df.columns:
535
+ ordered_columns.append(col)
536
+ ordered_columns.extend(other_columns)
537
+
538
+ df = df[ordered_columns]
539
+
540
+ # Write to Excel with proper pluralization
541
+ sheet_name_mapping = {
542
+ 'BUS': 'Buses',
543
+ 'GENERATOR': 'Generators',
544
+ 'LOAD': 'Loads',
545
+ 'LINE': 'Lines',
546
+ 'LINK': 'Links',
547
+ 'STORAGE_UNIT': 'Storage Units',
548
+ 'STORE': 'Stores',
549
+ 'CONSTRAINT': 'Constraints'
550
+ }
551
+ sheet_name = sheet_name_mapping.get(component_type, f"{component_type.title()}s")
552
+ df.to_excel(writer, sheet_name=sheet_name, index=False)
553
+
554
+ def _create_timeseries_sheet(self, writer, component_type: str, timeseries_data: Dict, network_id: int, conn):
555
+ """Create a timeseries sheet for a component type"""
556
+ # Get network time periods
557
+ time_periods = get_network_time_periods(conn, network_id)
558
+ if not time_periods:
559
+ self.logger.warning(f"No time periods found for network {network_id}, skipping timeseries sheet for {component_type}")
560
+ return
561
+
562
+ self.logger.info(f"Creating timeseries sheet for {component_type} with {len(time_periods)} time periods")
563
+ self.logger.info(f"First few time periods: {[(p.formatted_time, p.timestamp, p.period_index) for p in time_periods[:3]]}")
564
+
565
+ # Create DataFrame with human-readable timestamps
566
+ timestamps = [period.formatted_time for period in time_periods] # Use formatted_time instead of timestamp
567
+ df_data = {'timestamp': timestamps}
568
+
569
+ # Add component columns for each attribute
570
+ for attr_name, component_data in timeseries_data.items():
571
+ for component_name, timeseries_data_item in component_data.items():
572
+ if isinstance(timeseries_data_item, list):
573
+ # Handle efficient format (list of values)
574
+ values = timeseries_data_item
575
+
576
+ # Pad or truncate to match time periods
577
+ while len(values) < len(timestamps):
578
+ values.append(0)
579
+ values = values[:len(timestamps)]
580
+ df_data[f"{component_name}_{attr_name}"] = values
581
+
582
+ df = pd.DataFrame(df_data)
583
+ sheet_name = f"{component_type.title()} Timeseries"
584
+ df.to_excel(writer, sheet_name=sheet_name, index=False)
585
+ self.logger.info(f"Created timeseries sheet '{sheet_name}' with {len(df)} rows and {len(df.columns)} columns")
586
+
587
+ def _create_carriers_sheet(self, writer, carriers: List[Dict]):
588
+ """Create carriers sheet"""
589
+ if not carriers:
590
+ return
591
+
592
+ df = pd.DataFrame(carriers)
593
+ df.to_excel(writer, sheet_name='Carriers', index=False)
594
+
595
+ def _create_network_config_sheet(self, writer, network_id: int, conn):
596
+ """Create network configuration sheet"""
597
+ try:
598
+ config = get_network_config(conn, network_id, None) # Master scenario
599
+ if config:
600
+ config_data = []
601
+ for param_name, param_value in config.items():
602
+ config_data.append({
603
+ 'Parameter': param_name,
604
+ 'Value': str(param_value),
605
+ 'Type': type(param_value).__name__,
606
+ 'Description': ''
607
+ })
608
+
609
+ if config_data:
610
+ df = pd.DataFrame(config_data)
611
+ df.to_excel(writer, sheet_name='Network Config', index=False)
612
+ except Exception as e:
613
+ self.logger.warning(f"Could not create network config sheet: {e}")
614
+
615
+ def _calculate_export_stats(self, processed_components: Dict, timeseries_data: Dict) -> Dict[str, Any]:
616
+ """Calculate export statistics"""
617
+ total_components = sum(len(components) for components in processed_components.values())
618
+ total_timeseries = sum(
619
+ len(attr_data)
620
+ for comp_data in timeseries_data.values()
621
+ for attr_data in comp_data.values()
622
+ )
623
+
624
+ return {
625
+ 'total_components': total_components,
626
+ 'total_timeseries': total_timeseries,
627
+ 'component_types': len(processed_components),
628
+ 'components_by_type': {
629
+ comp_type: len(components)
630
+ for comp_type, components in processed_components.items()
631
+ }
632
+ }
633
+
634
+ def _get_solve_results(self, conn, network_id: int, scenario_id: int) -> Optional[Dict[str, Any]]:
635
+ """Get solve results from the database"""
636
+ try:
637
+ cursor = conn.execute("""
638
+ SELECT results_json, metadata_json, solver_name, solve_status,
639
+ objective_value, solve_time_seconds, solved_at
640
+ FROM network_solve_results
641
+ WHERE network_id = ? AND scenario_id = ?
642
+ """, (network_id, scenario_id))
643
+
644
+ row = cursor.fetchone()
645
+ if not row:
646
+ self.logger.info(f"No solve results found for network {network_id}, scenario {scenario_id}")
647
+ return None
648
+
649
+ results_json_str, metadata_json_str, solver_name, solve_status, objective_value, solve_time, solved_at = row
650
+
651
+ # Parse the JSON results
652
+ if results_json_str:
653
+ results = json.loads(results_json_str)
654
+ # Add metadata from the table columns
655
+ results['solver_name'] = solver_name
656
+ results['solve_status'] = solve_status
657
+ results['objective_value'] = objective_value
658
+ results['solve_time_seconds'] = solve_time
659
+ results['solved_at'] = solved_at
660
+
661
+ if metadata_json_str:
662
+ metadata = json.loads(metadata_json_str)
663
+ results['metadata'] = metadata
664
+
665
+ return results
666
+
667
+ return None
668
+
669
+ except Exception as e:
670
+ self.logger.warning(f"Failed to retrieve solve results: {e}")
671
+ return None
672
+
673
+ def _get_solve_results_by_year(self, conn, network_id: int, scenario_id: int) -> Optional[Dict[int, Dict[str, Any]]]:
674
+ """Get per-year solve results from the database"""
675
+ try:
676
+ cursor = conn.execute("""
677
+ SELECT year, results_json, metadata_json
678
+ FROM network_solve_results_by_year
679
+ WHERE network_id = ? AND scenario_id = ?
680
+ ORDER BY year
681
+ """, (network_id, scenario_id))
682
+
683
+ rows = cursor.fetchall()
684
+ if not rows:
685
+ self.logger.info(f"No per-year solve results found for network {network_id}, scenario {scenario_id}")
686
+ return None
687
+
688
+ year_results = {}
689
+ for row in rows:
690
+ year, results_json_str, metadata_json_str = row
691
+
692
+ if results_json_str:
693
+ year_data = json.loads(results_json_str)
694
+
695
+ # Add metadata if available
696
+ if metadata_json_str:
697
+ metadata = json.loads(metadata_json_str)
698
+ year_data['metadata'] = metadata
699
+
700
+ year_results[year] = year_data
701
+
702
+ return year_results if year_results else None
703
+
704
+ except Exception as e:
705
+ self.logger.warning(f"Failed to retrieve per-year solve results: {e}")
706
+ return None
707
+
708
+ def _create_statistics_sheet(self, writer, network_id: int, scenario_id: int, conn):
709
+ """Create statistics sheet with full-run solve results (no per-year data)"""
710
+ try:
711
+ # Get solve results
712
+ solve_results = self._get_solve_results(conn, network_id, scenario_id)
713
+ if not solve_results:
714
+ self.logger.info("No solve results available, skipping statistics sheet")
715
+ return
716
+
717
+ # Prepare data for the statistics sheet
718
+ stats_data = []
719
+
720
+ # Section 1: Solve Summary
721
+ stats_data.extend([
722
+ ['SOLVE SUMMARY', ''],
723
+ ['Solver Name', solve_results.get('solver_name', 'Unknown')],
724
+ ['Solve Status', solve_results.get('solve_status', 'Unknown')],
725
+ ['Solve Time (seconds)', solve_results.get('solve_time_seconds', 0)],
726
+ ['Objective Value', solve_results.get('objective_value', 0)],
727
+ ['Solved At', solve_results.get('solved_at', '')],
728
+ ['', ''] # Empty row separator
729
+ ])
730
+
731
+ # Extract network statistics if available
732
+ network_stats = solve_results.get('network_statistics', {})
733
+
734
+ # Section 2: Core Network Statistics
735
+ core_summary = network_stats.get('core_summary', {})
736
+ if core_summary:
737
+ stats_data.extend([
738
+ ['CORE NETWORK STATISTICS', ''],
739
+ ['Total Generation (MWh)', core_summary.get('total_generation_mwh', 0)],
740
+ ['Total Demand (MWh)', core_summary.get('total_demand_mwh', 0)],
741
+ ['Total Cost', core_summary.get('total_cost', 0)],
742
+ ['Load Factor', core_summary.get('load_factor', 0)],
743
+ ['Unserved Energy (MWh)', core_summary.get('unserved_energy_mwh', 0)],
744
+ ['', '']
745
+ ])
746
+
747
+ # Section 3: Custom Statistics
748
+ custom_stats = network_stats.get('custom_statistics', {})
749
+ if custom_stats:
750
+ # Emissions by Carrier
751
+ emissions = custom_stats.get('emissions_by_carrier', {})
752
+ if emissions:
753
+ stats_data.extend([
754
+ ['EMISSIONS BY CARRIER (tons CO2)', '']
755
+ ])
756
+ for carrier, value in emissions.items():
757
+ if value > 0: # Only show carriers with emissions
758
+ stats_data.append([carrier, value])
759
+ stats_data.extend([
760
+ ['Total Emissions (tons CO2)', custom_stats.get('total_emissions_tons_co2', 0)],
761
+ ['', '']
762
+ ])
763
+
764
+ # Generation Dispatch by Carrier
765
+ dispatch = custom_stats.get('dispatch_by_carrier', {})
766
+ if dispatch:
767
+ stats_data.extend([
768
+ ['GENERATION DISPATCH BY CARRIER (MWh)', '']
769
+ ])
770
+ for carrier, value in dispatch.items():
771
+ if value > 0: # Only show carriers with generation
772
+ stats_data.append([carrier, value])
773
+ stats_data.append(['', ''])
774
+
775
+ # Power Capacity by Carrier (MW)
776
+ power_capacity = custom_stats.get('power_capacity_by_carrier', {})
777
+ if power_capacity:
778
+ stats_data.extend([
779
+ ['POWER CAPACITY BY CARRIER (MW)', '']
780
+ ])
781
+ for carrier, value in power_capacity.items():
782
+ if value > 0: # Only show carriers with capacity
783
+ stats_data.append([carrier, value])
784
+ stats_data.append(['', ''])
785
+
786
+ # Energy Capacity by Carrier (MWh)
787
+ energy_capacity = custom_stats.get('energy_capacity_by_carrier', {})
788
+ if energy_capacity:
789
+ stats_data.extend([
790
+ ['ENERGY CAPACITY BY CARRIER (MWh)', '']
791
+ ])
792
+ for carrier, value in energy_capacity.items():
793
+ if value > 0: # Only show carriers with capacity
794
+ stats_data.append([carrier, value])
795
+ stats_data.append(['', ''])
796
+
797
+ # Capital Costs by Carrier
798
+ capital_costs = custom_stats.get('capital_cost_by_carrier', {})
799
+ if capital_costs:
800
+ stats_data.extend([
801
+ ['CAPITAL COSTS BY CARRIER', '']
802
+ ])
803
+ for carrier, value in capital_costs.items():
804
+ if value > 0: # Only show carriers with costs
805
+ stats_data.append([carrier, value])
806
+ stats_data.extend([
807
+ ['Total Capital Cost', custom_stats.get('total_capital_cost', 0)],
808
+ ['', '']
809
+ ])
810
+
811
+ # Operational Costs by Carrier
812
+ op_costs = custom_stats.get('operational_cost_by_carrier', {})
813
+ if op_costs:
814
+ stats_data.extend([
815
+ ['OPERATIONAL COSTS BY CARRIER', '']
816
+ ])
817
+ for carrier, value in op_costs.items():
818
+ if value > 0: # Only show carriers with costs
819
+ stats_data.append([carrier, value])
820
+ stats_data.extend([
821
+ ['Total Operational Cost', custom_stats.get('total_operational_cost', 0)],
822
+ ['', '']
823
+ ])
824
+
825
+ # Total System Costs by Carrier
826
+ total_costs = custom_stats.get('total_system_cost_by_carrier', {})
827
+ if total_costs:
828
+ stats_data.extend([
829
+ ['TOTAL SYSTEM COSTS BY CARRIER', '']
830
+ ])
831
+ for carrier, value in total_costs.items():
832
+ if value > 0: # Only show carriers with costs
833
+ stats_data.append([carrier, value])
834
+ stats_data.extend([
835
+ ['Total Currency Cost', custom_stats.get('total_currency_cost', 0)],
836
+ ['Average Price per MWh', custom_stats.get('average_price_per_mwh', 0)],
837
+ ['', '']
838
+ ])
839
+
840
+ # Unmet Load Statistics
841
+ unmet_stats = custom_stats.get('unmet_load_statistics', {})
842
+ if unmet_stats:
843
+ stats_data.extend([
844
+ ['UNMET LOAD STATISTICS', ''],
845
+ ['Unmet Load (MWh)', unmet_stats.get('unmet_load_mwh', 0)],
846
+ ['Unmet Load Percentage', custom_stats.get('unmet_load_percentage', 0)],
847
+ ['Max Unmet Load Hour (MW)', custom_stats.get('max_unmet_load_hour_mw', 0)],
848
+ ['', '']
849
+ ])
850
+
851
+ # Section 4: Component Storage Statistics
852
+ storage_stats = solve_results.get('component_storage_stats', {})
853
+ if storage_stats:
854
+ stats_data.extend([
855
+ ['COMPONENT STORAGE STATISTICS', '']
856
+ ])
857
+ for key, value in storage_stats.items():
858
+ # Convert snake_case to readable format
859
+ readable_key = key.replace('_', ' ').title()
860
+ stats_data.append([readable_key, value])
861
+ stats_data.append(['', ''])
862
+
863
+ # Section 5: Runtime Information
864
+ runtime_info = network_stats.get('runtime_info', {})
865
+ if runtime_info:
866
+ stats_data.extend([
867
+ ['RUNTIME INFORMATION', '']
868
+ ])
869
+ for key, value in runtime_info.items():
870
+ # Convert snake_case to readable format
871
+ readable_key = key.replace('_', ' ').title()
872
+ stats_data.append([readable_key, value])
873
+ stats_data.append(['', ''])
874
+
875
+ # Section 6: Solver Information
876
+ solver_info = network_stats.get('solver_info', {})
877
+ if solver_info:
878
+ stats_data.extend([
879
+ ['SOLVER INFORMATION', '']
880
+ ])
881
+ for key, value in solver_info.items():
882
+ # Convert snake_case to readable format
883
+ readable_key = key.replace('_', ' ').title()
884
+ stats_data.append([readable_key, value])
885
+ stats_data.append(['', ''])
886
+
887
+ # Create DataFrame and write to Excel (simple 2-column format)
888
+ if stats_data:
889
+ df = pd.DataFrame(stats_data, columns=['Parameter', 'Value'])
890
+ df.to_excel(writer, sheet_name='Statistics', index=False)
891
+ self.logger.info(f"Created Statistics sheet with {len(stats_data)} rows")
892
+
893
+ except Exception as e:
894
+ self.logger.warning(f"Failed to create statistics sheet: {e}")
895
+ # Don't fail the entire export if statistics sheet fails
896
+
897
+ def _create_per_year_statistics_sheet(self, writer, network_id: int, scenario_id: int, conn):
898
+ """Create per-year statistics sheet in tidy data format"""
899
+ try:
900
+ # Get per-year solve results
901
+ year_results = self._get_solve_results_by_year(conn, network_id, scenario_id)
902
+ if not year_results:
903
+ self.logger.info("No per-year solve results available, skipping per-year statistics sheet")
904
+ return
905
+
906
+ # Prepare tidy data: Variable, Year, Carrier, Value, Units
907
+ tidy_data = []
908
+
909
+ # Get sorted years
910
+ years = sorted(year_results.keys())
911
+
912
+ # Define the statistics we want to include with their units
913
+ stat_definitions = [
914
+ ('dispatch_by_carrier', 'Generation Dispatch', 'MWh'),
915
+ ('power_capacity_by_carrier', 'Power Capacity', 'MW'),
916
+ ('energy_capacity_by_carrier', 'Energy Capacity', 'MWh'),
917
+ ('capital_cost_by_carrier', 'Capital Cost', 'Currency'),
918
+ ('operational_cost_by_carrier', 'Operational Cost', 'Currency'),
919
+ ('total_system_cost_by_carrier', 'Total System Cost', 'Currency'),
920
+ ('emissions_by_carrier', 'Emissions', 'tons CO2')
921
+ ]
922
+
923
+ # Process each statistic type
924
+ for stat_key, stat_name, units in stat_definitions:
925
+ # Collect all carriers across all years for this statistic
926
+ all_carriers = set()
927
+ for year in years:
928
+ year_data = year_results[year]
929
+ if 'network_statistics' in year_data and 'custom_statistics' in year_data['network_statistics']:
930
+ custom_stats = year_data['network_statistics']['custom_statistics']
931
+ if stat_key in custom_stats:
932
+ all_carriers.update(custom_stats[stat_key].keys())
933
+
934
+ # Add data rows for each carrier and year combination
935
+ for carrier in sorted(all_carriers):
936
+ for year in years:
937
+ year_data = year_results[year]
938
+ value = 0.0
939
+
940
+ if 'network_statistics' in year_data and 'custom_statistics' in year_data['network_statistics']:
941
+ custom_stats = year_data['network_statistics']['custom_statistics']
942
+ if stat_key in custom_stats and carrier in custom_stats[stat_key]:
943
+ value = custom_stats[stat_key][carrier]
944
+
945
+ # Only include rows with non-zero values to keep the data clean
946
+ if value > 0:
947
+ tidy_data.append([stat_name, year, carrier, value, units])
948
+
949
+ # Add core summary statistics (these don't have carriers)
950
+ core_stat_definitions = [
951
+ ('total_generation_mwh', 'Total Generation', 'MWh'),
952
+ ('total_demand_mwh', 'Total Demand', 'MWh'),
953
+ ('total_cost', 'Total Cost', 'Currency'),
954
+ ('load_factor', 'Load Factor', 'Ratio'),
955
+ ('unserved_energy_mwh', 'Unserved Energy', 'MWh'),
956
+ ('total_emissions_tons_co2', 'Total Emissions', 'tons CO2')
957
+ ]
958
+
959
+ for stat_key, stat_name, units in core_stat_definitions:
960
+ for year in years:
961
+ year_data = year_results[year]
962
+ value = 0.0
963
+
964
+ # Check both core_summary and custom_statistics
965
+ if 'network_statistics' in year_data:
966
+ network_stats = year_data['network_statistics']
967
+
968
+ # Try core_summary first
969
+ if 'core_summary' in network_stats and stat_key in network_stats['core_summary']:
970
+ value = network_stats['core_summary'][stat_key]
971
+ # Try custom_statistics as fallback
972
+ elif 'custom_statistics' in network_stats and stat_key in network_stats['custom_statistics']:
973
+ value = network_stats['custom_statistics'][stat_key]
974
+
975
+ # Include all core statistics (even zeros for completeness)
976
+ tidy_data.append([stat_name, year, 'Total', value, units])
977
+
978
+ # Create DataFrame and write to Excel
979
+ if tidy_data:
980
+ df = pd.DataFrame(tidy_data, columns=['Variable', 'Year', 'Carrier', 'Value', 'Units'])
981
+ df.to_excel(writer, sheet_name='Per-Year Statistics', index=False)
982
+ self.logger.info(f"Created Per-Year Statistics sheet with {len(tidy_data)} rows")
983
+ else:
984
+ self.logger.info("No per-year statistics data to export")
985
+
986
+ except Exception as e:
987
+ self.logger.warning(f"Failed to create per-year statistics sheet: {e}")
988
+ # Don't fail the entire export if per-year statistics sheet fails
989
+
990
+ def _get_app_version(self) -> str:
991
+ """Get the application version."""
992
+ try:
993
+ # Try to read from package.json in the project root
994
+ import json
995
+ import os
996
+ from pathlib import Path
997
+
998
+ # Look for package.json in parent directories
999
+ current_dir = Path(__file__).parent
1000
+ while current_dir != current_dir.parent:
1001
+ package_json = current_dir / "package.json"
1002
+ if package_json.exists():
1003
+ with open(package_json, "r") as f:
1004
+ package_data = json.load(f)
1005
+ return package_data.get("version", "1.0.0")
1006
+ current_dir = current_dir.parent
1007
+
1008
+ # Fallback version
1009
+ return "1.0.0"
1010
+ except Exception as e:
1011
+ self.logger.warning(f"Could not get app version: {e}")
1012
+ return "1.0.0"