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

@@ -10,7 +10,7 @@ import pandas as pd
10
10
  import numpy as np
11
11
  from typing import Dict, Any, Optional, Callable
12
12
 
13
- from pyconvexity.core.types import StaticValue, TimeseriesPoint
13
+ from pyconvexity.core.types import StaticValue
14
14
  from pyconvexity.models import (
15
15
  list_components_by_type, set_static_attribute, set_timeseries_attribute
16
16
  )
@@ -210,25 +210,20 @@ class ResultStorage:
210
210
  if component_series.isna().all():
211
211
  continue
212
212
 
213
- # Convert to TimeseriesPoint list
214
- timeseries_points = []
215
- for period_index, (timestamp_idx, value) in enumerate(component_series.items()):
213
+ # Convert to efficient values array
214
+ values = []
215
+ for value in component_series.values:
216
216
  if pd.isna(value):
217
- continue
218
-
219
- timestamp = int(timestamp_idx.timestamp()) if hasattr(timestamp_idx, 'timestamp') else period_index
220
- timeseries_points.append(TimeseriesPoint(
221
- timestamp=timestamp,
222
- value=float(value),
223
- period_index=period_index
224
- ))
217
+ values.append(0.0) # Fill NaN with 0.0
218
+ else:
219
+ values.append(float(value))
225
220
 
226
- if not timeseries_points:
221
+ if not values:
227
222
  continue
228
223
 
229
- # Store using atomic utility
224
+ # Store using efficient format
230
225
  try:
231
- set_timeseries_attribute(conn, component_id, attr_name, timeseries_points, scenario_id)
226
+ set_timeseries_attribute(conn, component_id, attr_name, values, scenario_id)
232
227
  stored_count += 1
233
228
  except Exception as e:
234
229
  # Handle validation errors gracefully
@@ -350,262 +345,43 @@ class ResultStorage:
350
345
  network: 'pypsa.Network',
351
346
  solve_result: Dict[str, Any]
352
347
  ) -> Dict[str, Any]:
353
- """Calculate network statistics in the format expected by the frontend."""
348
+ """Calculate network statistics - focusing only on capacity for now."""
354
349
  try:
355
- # Calculate basic statistics
356
- total_generation_mwh = 0.0
357
- total_load_mwh = 0.0
358
- unmet_load_mwh = 0.0
359
-
360
- # Calculate generation statistics (simple sum of all positive generator output)
361
- if hasattr(network, 'generators_t') and hasattr(network.generators_t, 'p'):
362
- gen_data = network.generators_t.p
363
- if not gen_data.empty:
364
- # Debug: Log what's in the generators DataFrame
365
- logger.info(f"Generators DataFrame columns: {list(gen_data.columns)}")
366
- logger.info(f"Generators DataFrame shape: {gen_data.shape}")
367
-
368
- # Total generation - only count positive generation (ignore negative values like storage charging)
369
- # CRITICAL: Apply snapshot weightings to convert MW to MWh
370
- weightings = network.snapshot_weightings
371
- if isinstance(weightings, pd.DataFrame):
372
- if 'objective' in weightings.columns:
373
- weighting_values = weightings['objective'].values
374
- else:
375
- weighting_values = weightings.iloc[:, 0].values
376
- else:
377
- weighting_values = weightings.values
378
-
379
- # Apply weightings and clip to positive values
380
- total_generation_mwh = float((gen_data.clip(lower=0).values * weighting_values[:, None]).sum())
381
-
382
- # Debug logging
383
- raw_sum = gen_data.sum().sum()
384
- clipped_sum = gen_data.clip(lower=0).sum().sum()
385
-
386
- logger.info(f"Generation calculation: raw_sum={raw_sum}, clipped_sum={clipped_sum}")
387
-
388
- # Check for negative generator values
389
- negative_gen_columns = []
390
- for col in gen_data.columns:
391
- if (gen_data[col] < 0).any():
392
- negative_gen_columns.append(col)
393
- min_val = gen_data[col].min()
394
- logger.warning(f"Generator column '{col}' has negative values (min: {min_val})")
395
-
396
- if negative_gen_columns:
397
- logger.info(f"Found {len(negative_gen_columns)} generator columns with negative values: {negative_gen_columns}")
398
-
399
- # Calculate unmet load if component type mapping available
400
- if hasattr(network, '_component_type_map'):
401
- unmet_load_total = 0.0
402
- for gen_name, gen_type in network._component_type_map.items():
403
- if gen_type == 'UNMET_LOAD' and gen_name in gen_data.columns:
404
- # Unmet load should be positive (it's generation to meet unserved demand)
405
- unmet_load_total += max(0, gen_data[gen_name].sum())
406
- unmet_load_mwh = float(unmet_load_total)
407
-
408
- # Calculate load statistics
409
- if hasattr(network, 'loads_t') and hasattr(network.loads_t, 'p'):
410
- load_data = network.loads_t.p
411
- if not load_data.empty:
412
- # Debug: Log what's in the loads DataFrame
413
- logger.info(f"Loads DataFrame columns: {list(load_data.columns)}")
414
- logger.info(f"Loads DataFrame shape: {load_data.shape}")
415
- logger.info(f"Sample loads data (first 5 columns): {load_data.iloc[:3, :5].to_dict()}")
416
-
417
- # CRITICAL: Apply snapshot weightings to convert MW to MWh
418
- weightings = network.snapshot_weightings
419
- if isinstance(weightings, pd.DataFrame):
420
- if 'objective' in weightings.columns:
421
- weighting_values = weightings['objective'].values
422
- else:
423
- weighting_values = weightings.iloc[:, 0].values
424
- else:
425
- weighting_values = weightings.values
426
-
427
- total_load_mwh = float(abs((load_data.values * weighting_values[:, None]).sum()))
428
- logger.info(f"Total load calculation with weightings: {total_load_mwh} MWh")
429
- logger.info(f"Total load calculation without weightings: {abs(load_data.sum().sum())} MW")
430
-
431
- # Check if any columns have negative values (which shouldn't be in loads)
432
- negative_columns = []
433
- for col in load_data.columns:
434
- if (load_data[col] < 0).any():
435
- negative_columns.append(col)
436
- min_val = load_data[col].min()
437
- logger.warning(f"Load column '{col}' has negative values (min: {min_val})")
438
-
439
- if negative_columns:
440
- logger.error(f"Found {len(negative_columns)} load columns with negative values: {negative_columns}")
441
- else:
442
- total_load_mwh = 0.0
443
-
444
- # Calculate transmission losses from links (CORRECTED)
445
- total_link_losses_mwh = 0.0
446
- total_link_flow_mwh = 0.0
447
- if hasattr(network, 'links_t') and hasattr(network.links_t, 'p0'):
448
- link_p0_data = network.links_t.p0 # Power at bus0
449
- link_p1_data = network.links_t.p1 # Power at bus1
450
-
451
- if not link_p0_data.empty and not link_p1_data.empty:
452
- logger.info(f"Links p0 DataFrame columns: {list(link_p0_data.columns)}")
453
- logger.info(f"Links p0 DataFrame shape: {link_p0_data.shape}")
454
- logger.info(f"Links p1 DataFrame columns: {list(link_p1_data.columns)}")
455
- logger.info(f"Links p1 DataFrame shape: {link_p1_data.shape}")
456
-
457
- # CORRECT calculation: For each link and timestep, calculate losses properly
458
- # Losses occur when power flows through a link with efficiency < 1.0
459
- # p1 = p0 * efficiency, so losses = p0 - p1 = p0 * (1 - efficiency)
460
-
461
- link_losses_by_link = {}
462
- total_losses = 0.0
463
- total_flow = 0.0
464
-
465
- for link_name in link_p0_data.columns:
466
- p0_series = link_p0_data[link_name] # Power input to link
467
- p1_series = link_p1_data[link_name] # Power output from link
468
-
469
- # Calculate losses for this link across all timesteps
470
- # Losses should always be positive regardless of flow direction
471
- # For each timestep: losses = abs(p0) * (1 - efficiency)
472
- # But we don't have efficiency here, so use: losses = abs(p0) - abs(p1)
473
-
474
- # Calculate losses properly for each timestep
475
- timestep_losses = abs(p0_series) - abs(p1_series)
476
- link_losses = timestep_losses.sum()
477
- link_flow = abs(p0_series).sum() # Total absolute flow through this link
478
-
479
- link_losses_by_link[link_name] = {
480
- 'losses_mwh': link_losses,
481
- 'flow_mwh': link_flow,
482
- 'loss_rate': (link_losses / link_flow * 100) if link_flow > 0 else 0
483
- }
484
-
485
- total_losses += link_losses
486
- total_flow += link_flow
487
-
488
- # Log details for first few links
489
- if len(link_losses_by_link) <= 5:
490
- avg_p0 = p0_series.mean()
491
- avg_p1 = p1_series.mean()
492
- logger.info(f" Link '{link_name}': avg_p0={avg_p0:.1f}MW, avg_p1={avg_p1:.1f}MW, losses={link_losses:.1f}MWh, flow={link_flow:.1f}MWh")
493
-
494
- total_link_losses_mwh = total_losses
495
- total_link_flow_mwh = total_flow
496
-
497
- # Summary statistics
498
- logger.info(f"Link transmission analysis:")
499
- logger.info(f" Total link flow: {total_link_flow_mwh:.1f} MWh")
500
- logger.info(f" Total link losses: {total_link_losses_mwh:.1f} MWh")
501
- logger.info(f" Average loss rate: {(total_link_losses_mwh/total_link_flow_mwh*100):.2f}%")
502
- logger.info(f" Number of links: {len(link_losses_by_link)}")
503
-
504
- # Show top 5 links by losses
505
- top_loss_links = sorted(link_losses_by_link.items(), key=lambda x: x[1]['losses_mwh'], reverse=True)[:5]
506
- logger.info(f" Top 5 links by losses:")
507
- for link_name, stats in top_loss_links:
508
- logger.info(f" {link_name}: {stats['losses_mwh']:.1f} MWh ({stats['loss_rate']:.2f}%)")
509
-
510
- # Calculate storage losses if any
511
- total_storage_losses_mwh = 0.0
512
- storage_charging_mwh = 0.0
513
- storage_discharging_mwh = 0.0
514
-
515
- # Check for storage units
516
- if hasattr(network, 'storage_units_t') and hasattr(network.storage_units_t, 'p'):
517
- storage_data = network.storage_units_t.p
518
- if not storage_data.empty:
519
- logger.info(f"Storage units DataFrame columns: {list(storage_data.columns)}")
520
- logger.info(f"Storage units DataFrame shape: {storage_data.shape}")
521
-
522
- # Storage: positive = discharge (generation), negative = charge (consumption)
523
- total_storage_power = storage_data.sum().sum()
524
- storage_discharging_mwh = storage_data.clip(lower=0).sum().sum() # Positive values
525
- storage_charging_mwh = abs(storage_data.clip(upper=0).sum().sum()) # Negative values made positive
526
-
527
- logger.info(f"Storage analysis:")
528
- logger.info(f" Total storage net: {total_storage_power:.1f} MWh")
529
- logger.info(f" Storage discharging: {storage_discharging_mwh:.1f} MWh")
530
- logger.info(f" Storage charging: {storage_charging_mwh:.1f} MWh")
531
-
532
- # Storage losses = charging - discharging (due to round-trip efficiency)
533
- total_storage_losses_mwh = storage_charging_mwh - storage_discharging_mwh
534
- if total_storage_losses_mwh < 0:
535
- logger.warning(f"Negative storage losses: {total_storage_losses_mwh:.1f} MWh (net discharge)")
536
- total_storage_losses_mwh = 0.0 # Don't count net discharge as negative loss
537
-
538
- # Check for other PyPSA components that might consume energy
539
- other_consumption_mwh = 0.0
540
-
541
- # Check stores
542
- if hasattr(network, 'stores_t') and hasattr(network.stores_t, 'p'):
543
- stores_data = network.stores_t.p
544
- if not stores_data.empty:
545
- stores_consumption = abs(stores_data.sum().sum())
546
- other_consumption_mwh += stores_consumption
547
- logger.info(f"Stores consumption: {stores_consumption:.1f} MWh")
548
-
549
- # Total consumption (link losses already accounted for in PyPSA generation)
550
- total_consumption_with_losses_mwh = (total_load_mwh + total_storage_losses_mwh + other_consumption_mwh)
551
-
552
- # Detailed energy balance analysis
553
- logger.info(f"=== DETAILED ENERGY BALANCE ANALYSIS ===")
554
- logger.info(f"GENERATION SIDE:")
555
- logger.info(f" Total generation: {total_generation_mwh:.1f} MWh")
556
- logger.info(f" Storage discharging: {storage_discharging_mwh:.1f} MWh")
557
- logger.info(f" Total supply: {total_generation_mwh + storage_discharging_mwh:.1f} MWh")
558
- logger.info(f"")
559
- logger.info(f"CONSUMPTION SIDE:")
560
- logger.info(f" Load demand: {total_load_mwh:.1f} MWh")
561
- logger.info(f" Storage charging: {storage_charging_mwh:.1f} MWh")
562
- logger.info(f" Link losses: {total_link_losses_mwh:.1f} MWh (for info only - already in generation)")
563
- logger.info(f" Storage losses: {total_storage_losses_mwh:.1f} MWh")
564
- logger.info(f" Other consumption: {other_consumption_mwh:.1f} MWh")
565
- logger.info(f" Total consumption: {total_load_mwh + storage_charging_mwh + total_storage_losses_mwh + other_consumption_mwh:.1f} MWh")
566
- logger.info(f"")
567
- logger.info(f"BALANCE CHECK:")
568
- total_supply = total_generation_mwh + storage_discharging_mwh
569
- total_consumption = total_load_mwh + storage_charging_mwh + total_storage_losses_mwh + other_consumption_mwh
570
- balance_error = total_supply - total_consumption
571
- logger.info(f" Supply - Consumption = {balance_error:.1f} MWh")
572
- logger.info(f" Balance error %: {(balance_error/total_supply*100):.3f}%")
573
- logger.info(f"=========================================")
574
-
575
- # Calculate carrier-specific statistics first
350
+ # Calculate carrier-specific statistics
576
351
  carrier_stats = self._calculate_carrier_statistics(conn, network_id, network)
577
352
 
578
- # Calculate totals from carrier statistics
579
- total_capital_cost = sum(carrier_stats["capital_cost_by_carrier"].values())
580
- total_operational_cost = sum(carrier_stats["operational_cost_by_carrier"].values())
581
- total_emissions = sum(carrier_stats["emissions_by_carrier"].values())
582
-
583
- # Calculate derived statistics
353
+ # Calculate basic network statistics
584
354
  total_cost = solve_result.get('objective_value', 0.0)
585
- unmet_load_percentage = (unmet_load_mwh / total_load_mwh * 100) if total_load_mwh > 0 else 0.0
586
- load_factor = (total_generation_mwh / total_load_mwh) if total_load_mwh > 0 else 0.0
355
+ total_generation_mwh = sum(carrier_stats.get("dispatch_by_carrier", {}).values())
356
+ total_emissions_tonnes = sum(carrier_stats.get("emissions_by_carrier", {}).values())
357
+ total_capital_cost = sum(carrier_stats.get("capital_cost_by_carrier", {}).values())
358
+ total_operational_cost = sum(carrier_stats.get("operational_cost_by_carrier", {}).values())
359
+ total_system_cost = sum(carrier_stats.get("total_system_cost_by_carrier", {}).values())
587
360
 
588
- logger.info(f"Cost breakdown: Capital=${total_capital_cost:.0f}, Operational=${total_operational_cost:.0f}, Total Objective=${total_cost:.0f}")
361
+ # Calculate unmet load statistics
362
+ unmet_load_mwh = carrier_stats.get("dispatch_by_carrier", {}).get("Unmet Load", 0.0)
363
+ total_demand_mwh = self._calculate_total_demand(network)
364
+ unmet_load_percentage = (unmet_load_mwh / (total_demand_mwh + 1e-6)) * 100 if total_demand_mwh > 0 else 0.0
589
365
 
590
366
  # Create nested structure expected by frontend
591
367
  network_statistics = {
592
368
  "core_summary": {
593
369
  "total_generation_mwh": total_generation_mwh,
594
- "total_demand_mwh": total_load_mwh, # Frontend expects "demand" not "load"
370
+ "total_demand_mwh": total_demand_mwh,
595
371
  "total_cost": total_cost,
596
- "load_factor": load_factor,
372
+ "load_factor": (total_demand_mwh / (total_generation_mwh + 1e-6)) if total_generation_mwh > 0 else 0.0,
597
373
  "unserved_energy_mwh": unmet_load_mwh
598
374
  },
599
375
  "custom_statistics": {
600
- # Include carrier-specific statistics
376
+ # Include carrier-specific statistics (capacity, dispatch, emissions, costs)
601
377
  **carrier_stats,
602
- "total_capital_cost": total_capital_cost, # Sum from carriers
603
- "total_operational_cost": total_operational_cost, # Sum from carriers
604
- "total_currency_cost": total_cost, # PyPSA objective (discounted total)
605
- "total_emissions_tons_co2": total_emissions, # Sum from carriers
606
- "average_price_per_mwh": (total_cost / total_generation_mwh) if total_generation_mwh > 0 else 0.0,
378
+ "total_capital_cost": total_capital_cost,
379
+ "total_operational_cost": total_operational_cost,
380
+ "total_currency_cost": total_system_cost, # Use calculated system cost instead of PyPSA objective
381
+ "total_emissions_tons_co2": total_emissions_tonnes,
382
+ "average_price_per_mwh": (total_system_cost / (total_generation_mwh + 1e-6)) if total_generation_mwh > 0 else 0.0,
607
383
  "unmet_load_percentage": unmet_load_percentage,
608
- "max_unmet_load_hour_mw": 0.0 # TODO: Calculate max hourly unmet load
384
+ "max_unmet_load_hour_mw": 0.0 # TODO: Calculate max hourly unmet load later
609
385
  },
610
386
  "runtime_info": {
611
387
  "component_count": (
@@ -620,7 +396,6 @@ class ResultStorage:
620
396
  }
621
397
 
622
398
  logger.info(f"Calculated network statistics: core_summary={network_statistics['core_summary']}")
623
- logger.info(f"Calculated custom statistics: custom_statistics={network_statistics['custom_statistics']}")
624
399
  return network_statistics
625
400
 
626
401
  except Exception as e:
@@ -635,6 +410,13 @@ class ResultStorage:
635
410
  "unserved_energy_mwh": 0.0
636
411
  },
637
412
  "custom_statistics": {
413
+ "dispatch_by_carrier": {},
414
+ "power_capacity_by_carrier": {},
415
+ "energy_capacity_by_carrier": {},
416
+ "emissions_by_carrier": {},
417
+ "capital_cost_by_carrier": {},
418
+ "operational_cost_by_carrier": {},
419
+ "total_system_cost_by_carrier": {},
638
420
  "total_capital_cost": 0.0,
639
421
  "total_operational_cost": 0.0,
640
422
  "total_currency_cost": 0.0,
@@ -653,50 +435,188 @@ class ResultStorage:
653
435
  "error": str(e)
654
436
  }
655
437
 
656
- def _serialize_results_json(self, solve_result: Dict[str, Any]) -> str:
657
- """Serialize solve results to JSON string."""
658
- import json
438
+ def _calculate_carrier_statistics(self, conn, network_id: int, network: 'pypsa.Network') -> Dict[str, Any]:
439
+ """
440
+ Calculate carrier-specific statistics directly from the network.
441
+ This is the primary calculation - per-year stats will be calculated separately.
442
+ """
659
443
  try:
660
- # Create a clean results dictionary
661
- results = {
662
- "success": solve_result.get("success", False),
663
- "status": solve_result.get("status", "unknown"),
664
- "solve_time": solve_result.get("solve_time", 0.0),
665
- "objective_value": solve_result.get("objective_value"),
666
- "solver_name": solve_result.get("solver_name", "unknown"),
667
- "run_id": solve_result.get("run_id"),
668
- "network_statistics": solve_result.get("network_statistics", {}),
669
- "pypsa_result": solve_result.get("pypsa_result", {})
444
+ # Calculate all-year statistics directly from the network
445
+ # Extract years from network snapshots
446
+ if hasattr(network.snapshots, 'levels'):
447
+ # Multi-period optimization - get years from period level
448
+ period_values = network.snapshots.get_level_values(0)
449
+ years = sorted(period_values.unique())
450
+ elif hasattr(network.snapshots, 'year'):
451
+ years = sorted(network.snapshots.year.unique())
452
+ elif hasattr(network, '_available_years'):
453
+ years = network._available_years
454
+ else:
455
+ years = [2020] # Fallback
456
+
457
+ logger.info(f"Calculating all-year carrier statistics for years: {years}")
458
+
459
+ # Calculate per-year statistics first
460
+ all_year_stats = {
461
+ "dispatch_by_carrier": {},
462
+ "power_capacity_by_carrier": {},
463
+ "energy_capacity_by_carrier": {},
464
+ "emissions_by_carrier": {},
465
+ "capital_cost_by_carrier": {},
466
+ "operational_cost_by_carrier": {},
467
+ "total_system_cost_by_carrier": {}
670
468
  }
671
- return json.dumps(results, default=self._json_serializer)
469
+
470
+ # Initialize all carriers with zero values
471
+ cursor = conn.execute("""
472
+ SELECT DISTINCT name FROM carriers WHERE network_id = ?
473
+ """, (network_id,))
474
+ all_carriers = [row[0] for row in cursor.fetchall()]
475
+
476
+ # Initialize all carriers with zero values (including special "Unmet Load" carrier)
477
+ all_carriers_with_unmet = all_carriers + ['Unmet Load']
478
+ for carrier in all_carriers_with_unmet:
479
+ all_year_stats["dispatch_by_carrier"][carrier] = 0.0
480
+ all_year_stats["power_capacity_by_carrier"][carrier] = 0.0
481
+ all_year_stats["energy_capacity_by_carrier"][carrier] = 0.0
482
+ all_year_stats["emissions_by_carrier"][carrier] = 0.0
483
+ all_year_stats["capital_cost_by_carrier"][carrier] = 0.0
484
+ all_year_stats["operational_cost_by_carrier"][carrier] = 0.0
485
+ all_year_stats["total_system_cost_by_carrier"][carrier] = 0.0
486
+
487
+ # Calculate statistics for each year and sum them up
488
+ for year in years:
489
+ year_stats = self._calculate_year_carrier_statistics(conn, network_id, network, year)
490
+
491
+ # Sum up all the statistics (including "Unmet Load")
492
+ for carrier in all_carriers_with_unmet:
493
+ # Sum dispatch, emissions, and costs across years
494
+ all_year_stats["dispatch_by_carrier"][carrier] += year_stats["dispatch_by_carrier"].get(carrier, 0.0)
495
+ all_year_stats["emissions_by_carrier"][carrier] += year_stats["emissions_by_carrier"].get(carrier, 0.0)
496
+ all_year_stats["capital_cost_by_carrier"][carrier] += year_stats["capital_cost_by_carrier"].get(carrier, 0.0)
497
+ all_year_stats["operational_cost_by_carrier"][carrier] += year_stats["operational_cost_by_carrier"].get(carrier, 0.0)
498
+ all_year_stats["total_system_cost_by_carrier"][carrier] += year_stats["total_system_cost_by_carrier"].get(carrier, 0.0)
499
+
500
+ # For capacity: use the last year (final capacity state)
501
+ if year == years[-1]:
502
+ all_year_stats["power_capacity_by_carrier"][carrier] = year_stats["power_capacity_by_carrier"].get(carrier, 0.0)
503
+ all_year_stats["energy_capacity_by_carrier"][carrier] = year_stats["energy_capacity_by_carrier"].get(carrier, 0.0)
504
+
505
+ logger.info(f"Calculated all-year carrier statistics:")
506
+ logger.info(f" Total dispatch: {sum(all_year_stats['dispatch_by_carrier'].values()):.2f} MWh")
507
+ logger.info(f" Total emissions: {sum(all_year_stats['emissions_by_carrier'].values()):.2f} tonnes CO2")
508
+ logger.info(f" Total capital cost: {sum(all_year_stats['capital_cost_by_carrier'].values()):.2f} USD")
509
+ logger.info(f" Total operational cost: {sum(all_year_stats['operational_cost_by_carrier'].values()):.2f} USD")
510
+ logger.info(f" Final power capacity: {sum(all_year_stats['power_capacity_by_carrier'].values()):.2f} MW")
511
+
512
+ return all_year_stats
513
+
672
514
  except Exception as e:
673
- logger.warning(f"Failed to serialize results JSON: {e}")
674
- return json.dumps({"error": "serialization_failed"})
515
+ logger.error(f"Failed to calculate carrier statistics: {e}", exc_info=True)
516
+ return {
517
+ "dispatch_by_carrier": {},
518
+ "power_capacity_by_carrier": {},
519
+ "energy_capacity_by_carrier": {},
520
+ "emissions_by_carrier": {},
521
+ "capital_cost_by_carrier": {},
522
+ "operational_cost_by_carrier": {},
523
+ "total_system_cost_by_carrier": {}
524
+ }
675
525
 
676
- def _serialize_metadata_json(self, solve_result: Dict[str, Any]) -> str:
677
- """Serialize solve metadata to JSON string."""
678
- import json
526
+ def _store_year_based_statistics(
527
+ self,
528
+ conn,
529
+ network_id: int,
530
+ network: 'pypsa.Network',
531
+ year_statistics: Dict[int, Dict[str, Any]],
532
+ scenario_id: Optional[int]
533
+ ) -> int:
534
+ """Store year-based statistics to database"""
679
535
  try:
680
- metadata = {
681
- "solver_name": solve_result.get("solver_name", "unknown"),
682
- "run_id": solve_result.get("run_id"),
683
- "multi_period": solve_result.get("multi_period", False),
684
- "years": solve_result.get("years", []),
685
- "network_name": solve_result.get("network_name"),
686
- "num_snapshots": solve_result.get("num_snapshots", 0)
687
- }
688
- return json.dumps(metadata, default=self._json_serializer)
536
+ import json
537
+ stored_count = 0
538
+
539
+ # Use master scenario if no scenario specified
540
+ if scenario_id is None:
541
+ from pyconvexity.models import get_master_scenario_id
542
+ scenario_id = get_master_scenario_id(conn, network_id)
543
+
544
+ # Check if network_solve_results_by_year table exists, create if not
545
+ conn.execute("""
546
+ CREATE TABLE IF NOT EXISTS network_solve_results_by_year (
547
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
548
+ network_id INTEGER NOT NULL,
549
+ scenario_id INTEGER NOT NULL,
550
+ year INTEGER NOT NULL,
551
+ results_json TEXT,
552
+ metadata_json TEXT,
553
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
554
+ FOREIGN KEY (network_id) REFERENCES networks(id),
555
+ FOREIGN KEY (scenario_id) REFERENCES scenarios(id),
556
+ UNIQUE(network_id, scenario_id, year)
557
+ )
558
+ """)
559
+
560
+ for year, stats in year_statistics.items():
561
+ try:
562
+ # Calculate proper year-specific carrier statistics
563
+ year_carrier_stats = self._calculate_year_carrier_statistics(conn, network_id, network, year)
564
+
565
+ # Merge year-specific carrier stats into the statistics
566
+ if "custom_statistics" in stats:
567
+ stats["custom_statistics"].update(year_carrier_stats)
568
+ else:
569
+ stats["custom_statistics"] = year_carrier_stats
570
+
571
+ # Wrap the year statistics in the same structure as overall results for consistency
572
+ year_result_wrapper = {
573
+ "success": True,
574
+ "year": year,
575
+ "network_statistics": stats
576
+ }
577
+
578
+ metadata = {
579
+ "year": year,
580
+ "network_id": network_id,
581
+ "scenario_id": scenario_id
582
+ }
583
+
584
+ conn.execute("""
585
+ INSERT OR REPLACE INTO network_solve_results_by_year
586
+ (network_id, scenario_id, year, results_json, metadata_json)
587
+ VALUES (?, ?, ?, ?, ?)
588
+ """, (
589
+ network_id,
590
+ scenario_id,
591
+ year,
592
+ json.dumps(year_result_wrapper, default=self._json_serializer),
593
+ json.dumps(metadata, default=self._json_serializer)
594
+ ))
595
+
596
+ stored_count += 1
597
+ logger.info(f"Stored year-based statistics for year {year}")
598
+
599
+ except Exception as e:
600
+ logger.error(f"Failed to store statistics for year {year}: {e}")
601
+ continue
602
+
603
+ logger.info(f"Successfully stored year-based statistics for {stored_count} years")
604
+ return stored_count
605
+
689
606
  except Exception as e:
690
- logger.warning(f"Failed to serialize metadata JSON: {e}")
691
- return json.dumps({"error": "serialization_failed"})
607
+ logger.error(f"Failed to store year-based statistics: {e}", exc_info=True)
608
+ return 0
692
609
 
693
- def _calculate_carrier_statistics(self, conn, network_id: int, network: 'pypsa.Network') -> Dict[str, Any]:
694
- """Calculate carrier-specific statistics that the frontend expects."""
610
+ def _calculate_year_carrier_statistics(self, conn, network_id: int, network: 'pypsa.Network', year: int) -> Dict[str, Any]:
611
+ """
612
+ Calculate carrier-specific statistics for a specific year.
613
+ For now, only calculate capacity statistics.
614
+ """
695
615
  try:
696
- # Initialize carrier statistics (separate power and energy capacity like old solver)
616
+ # Initialize carrier statistics
697
617
  carrier_stats = {
698
618
  "dispatch_by_carrier": {},
699
- "power_capacity_by_carrier": {}, # MW - Generators + Storage Units (power)
619
+ "power_capacity_by_carrier": {}, # MW - Generators + Storage Units (power) + Lines + Links
700
620
  "energy_capacity_by_carrier": {}, # MWh - Stores + Storage Units (energy)
701
621
  "emissions_by_carrier": {},
702
622
  "capital_cost_by_carrier": {},
@@ -710,8 +630,9 @@ class ResultStorage:
710
630
  """, (network_id,))
711
631
  all_carriers = [row[0] for row in cursor.fetchall()]
712
632
 
713
- # Initialize all carriers with zero values
714
- for carrier in all_carriers:
633
+ # Initialize all carriers with zero values (including special "Unmet Load" carrier)
634
+ all_carriers_with_unmet = all_carriers + ['Unmet Load']
635
+ for carrier in all_carriers_with_unmet:
715
636
  carrier_stats["dispatch_by_carrier"][carrier] = 0.0
716
637
  carrier_stats["power_capacity_by_carrier"][carrier] = 0.0
717
638
  carrier_stats["energy_capacity_by_carrier"][carrier] = 0.0
@@ -720,37 +641,41 @@ class ResultStorage:
720
641
  carrier_stats["operational_cost_by_carrier"][carrier] = 0.0
721
642
  carrier_stats["total_system_cost_by_carrier"][carrier] = 0.0
722
643
 
723
- # Calculate dispatch by carrier (generation + storage discharge)
644
+ # Calculate dispatch (generation) by carrier for this specific year
724
645
 
725
- # 1. GENERATORS - All generation
646
+ # 1. GENERATORS - Generation dispatch (including UNMET_LOAD)
726
647
  if hasattr(network, 'generators_t') and hasattr(network.generators_t, 'p'):
727
- # Get generator-carrier mapping
648
+ # Get generator-carrier mapping (include both GENERATOR and UNMET_LOAD)
728
649
  cursor = conn.execute("""
729
- SELECT c.name as component_name, carr.name as carrier_name
730
- FROM components c
731
- JOIN carriers carr ON c.carrier_id = carr.id
732
- WHERE c.network_id = ? AND c.component_type = 'GENERATOR'
733
- """, (network_id,))
734
-
650
+ SELECT c.name as component_name,
651
+ CASE
652
+ WHEN c.component_type = 'UNMET_LOAD' THEN 'Unmet Load'
653
+ ELSE carr.name
654
+ END as carrier_name
655
+ FROM components c
656
+ JOIN carriers carr ON c.carrier_id = carr.id
657
+ WHERE c.network_id = ? AND c.component_type IN ('GENERATOR', 'UNMET_LOAD')
658
+ """, (network_id,))
735
659
  generator_carriers = {row[0]: row[1] for row in cursor.fetchall()}
736
660
 
737
- # Calculate dispatch for each generator
738
- for gen_name in network.generators_t.p.columns:
739
- if gen_name in generator_carriers:
740
- carrier_name = generator_carriers[gen_name]
741
- # Apply snapshot weightings to convert MW to MWh
742
- weightings = network.snapshot_weightings
743
- if isinstance(weightings, pd.DataFrame):
744
- if 'objective' in weightings.columns:
745
- weighting_values = weightings['objective'].values
661
+ # Filter generation data for this specific year
662
+ year_generation = self._filter_timeseries_by_year(network.generators_t.p, network.snapshots, year)
663
+ if year_generation is not None and not year_generation.empty:
664
+ for gen_name in year_generation.columns:
665
+ if gen_name in generator_carriers:
666
+ carrier_name = generator_carriers[gen_name]
667
+
668
+ # Calculate generation for this year (ALWAYS apply snapshot weightings to convert MW to MWh)
669
+ year_weightings = self._get_year_weightings(network, year)
670
+ if year_weightings is not None:
671
+ generation_mwh = float((year_generation[gen_name].values * year_weightings).sum())
746
672
  else:
747
- weighting_values = weightings.iloc[:, 0].values
748
- else:
749
- weighting_values = weightings.values
750
-
751
- generation_mwh = float((network.generators_t.p[gen_name].values * weighting_values).sum())
752
- if carrier_name in carrier_stats["dispatch_by_carrier"]:
753
- carrier_stats["dispatch_by_carrier"][carrier_name] += generation_mwh
673
+ # Fallback: simple sum (will be incorrect for non-1H models)
674
+ generation_mwh = float(year_generation[gen_name].sum())
675
+ logger.warning(f"Could not apply snapshot weightings for {gen_name} in year {year} - energy may be incorrect")
676
+
677
+ if carrier_name in carrier_stats["dispatch_by_carrier"]:
678
+ carrier_stats["dispatch_by_carrier"][carrier_name] += generation_mwh
754
679
 
755
680
  # 2. STORAGE_UNITS - Discharge only (positive values)
756
681
  if hasattr(network, 'storage_units_t') and hasattr(network.storage_units_t, 'p'):
@@ -761,29 +686,26 @@ class ResultStorage:
761
686
  JOIN carriers carr ON c.carrier_id = carr.id
762
687
  WHERE c.network_id = ? AND c.component_type = 'STORAGE_UNIT'
763
688
  """, (network_id,))
764
-
765
689
  storage_unit_carriers = {row[0]: row[1] for row in cursor.fetchall()}
766
690
 
767
- # Calculate dispatch for each storage unit (discharge only)
768
- for su_name in network.storage_units_t.p.columns:
769
- if su_name in storage_unit_carriers:
770
- carrier_name = storage_unit_carriers[su_name]
771
- # Apply snapshot weightings and only count positive discharge
772
- weightings = network.snapshot_weightings
773
- if isinstance(weightings, pd.DataFrame):
774
- if 'objective' in weightings.columns:
775
- weighting_values = weightings['objective'].values
691
+ # Filter storage unit data for this specific year
692
+ year_storage = self._filter_timeseries_by_year(network.storage_units_t.p, network.snapshots, year)
693
+ if year_storage is not None and not year_storage.empty:
694
+ for su_name in year_storage.columns:
695
+ if su_name in storage_unit_carriers:
696
+ carrier_name = storage_unit_carriers[su_name]
697
+
698
+ # Calculate discharge for this year (positive values only, ALWAYS apply snapshot weightings)
699
+ year_weightings = self._get_year_weightings(network, year)
700
+ if year_weightings is not None:
701
+ discharge_mwh = float((year_storage[su_name].clip(lower=0).values * year_weightings).sum())
776
702
  else:
777
- weighting_values = weightings.iloc[:, 0].values
778
- else:
779
- weighting_values = weightings.values
780
-
781
- # Only count positive values (discharge)
782
- su_power = network.storage_units_t.p[su_name]
783
- discharge_mwh = float((su_power.clip(lower=0) * weighting_values).sum())
784
-
785
- if carrier_name in carrier_stats["dispatch_by_carrier"]:
786
- carrier_stats["dispatch_by_carrier"][carrier_name] += discharge_mwh
703
+ # Fallback: simple sum (will be incorrect for non-1H models)
704
+ discharge_mwh = float(year_storage[su_name].clip(lower=0).sum())
705
+ logger.warning(f"Could not apply snapshot weightings for storage unit {su_name} in year {year} - energy may be incorrect")
706
+
707
+ if carrier_name in carrier_stats["dispatch_by_carrier"]:
708
+ carrier_stats["dispatch_by_carrier"][carrier_name] += discharge_mwh
787
709
 
788
710
  # 3. STORES - Discharge only (positive values)
789
711
  if hasattr(network, 'stores_t') and hasattr(network.stores_t, 'p'):
@@ -794,719 +716,295 @@ class ResultStorage:
794
716
  JOIN carriers carr ON c.carrier_id = carr.id
795
717
  WHERE c.network_id = ? AND c.component_type = 'STORE'
796
718
  """, (network_id,))
797
-
798
719
  store_carriers = {row[0]: row[1] for row in cursor.fetchall()}
799
720
 
800
- # Calculate dispatch for each store (discharge only)
801
- for store_name in network.stores_t.p.columns:
802
- if store_name in store_carriers:
803
- carrier_name = store_carriers[store_name]
804
- # Apply snapshot weightings and only count positive discharge
805
- weightings = network.snapshot_weightings
806
- if isinstance(weightings, pd.DataFrame):
807
- if 'objective' in weightings.columns:
808
- weighting_values = weightings['objective'].values
721
+ # Filter store data for this specific year
722
+ year_stores = self._filter_timeseries_by_year(network.stores_t.p, network.snapshots, year)
723
+ if year_stores is not None and not year_stores.empty:
724
+ for store_name in year_stores.columns:
725
+ if store_name in store_carriers:
726
+ carrier_name = store_carriers[store_name]
727
+
728
+ # Calculate discharge for this year (positive values only, ALWAYS apply snapshot weightings)
729
+ year_weightings = self._get_year_weightings(network, year)
730
+ if year_weightings is not None:
731
+ discharge_mwh = float((year_stores[store_name].clip(lower=0).values * year_weightings).sum())
809
732
  else:
810
- weighting_values = weightings.iloc[:, 0].values
811
- else:
812
- weighting_values = weightings.values
813
-
814
- # Only count positive values (discharge)
815
- store_power = network.stores_t.p[store_name]
816
- discharge_mwh = float((store_power.clip(lower=0) * weighting_values).sum())
817
-
818
- if carrier_name in carrier_stats["dispatch_by_carrier"]:
819
- carrier_stats["dispatch_by_carrier"][carrier_name] += discharge_mwh
733
+ # Fallback: simple sum (will be incorrect for non-1H models)
734
+ discharge_mwh = float(year_stores[store_name].clip(lower=0).sum())
735
+ logger.warning(f"Could not apply snapshot weightings for store {store_name} in year {year} - energy may be incorrect")
736
+
737
+ if carrier_name in carrier_stats["dispatch_by_carrier"]:
738
+ carrier_stats["dispatch_by_carrier"][carrier_name] += discharge_mwh
820
739
 
821
- # Calculate capacity by carrier (power + energy capacity)
740
+ # Calculate emissions by carrier for this specific year
741
+ # Get emission factors for all carriers
742
+ cursor = conn.execute("""
743
+ SELECT name, co2_emissions FROM carriers WHERE network_id = ?
744
+ """, (network_id,))
745
+ emission_factors = {row[0]: row[1] for row in cursor.fetchall()}
822
746
 
823
- # 1. GENERATORS - Power capacity (MW)
824
- if hasattr(network, 'generators') and not network.generators.empty:
825
- # Get generator-carrier mapping
826
- cursor = conn.execute("""
827
- SELECT c.name as component_name, carr.name as carrier_name
828
- FROM components c
829
- JOIN carriers carr ON c.carrier_id = carr.id
830
- WHERE c.network_id = ? AND c.component_type = 'GENERATOR'
831
- """, (network_id,))
832
-
833
- generator_carriers = {row[0]: row[1] for row in cursor.fetchall()}
747
+ # Calculate emissions: dispatch (MWh) × emission factor (tonnes CO2/MWh) = tonnes CO2
748
+ for carrier_name, dispatch_mwh in carrier_stats["dispatch_by_carrier"].items():
749
+ # Handle None values safely
750
+ if dispatch_mwh is None:
751
+ dispatch_mwh = 0.0
834
752
 
835
- # Calculate capacity for each generator
836
- for gen_name in network.generators.index:
837
- if gen_name in generator_carriers:
838
- carrier_name = generator_carriers[gen_name]
839
- # Use p_nom_opt if available, otherwise p_nom (POWER capacity)
840
- if 'p_nom_opt' in network.generators.columns:
841
- capacity_mw = float(network.generators.loc[gen_name, 'p_nom_opt'])
842
- else:
843
- capacity_mw = float(network.generators.loc[gen_name, 'p_nom']) if 'p_nom' in network.generators.columns else 0.0
844
-
845
- if carrier_name in carrier_stats["power_capacity_by_carrier"]:
846
- carrier_stats["power_capacity_by_carrier"][carrier_name] += capacity_mw
847
-
848
- # 2. STORAGE_UNITS - Power capacity (MW) + Energy capacity (MWh)
849
- if hasattr(network, 'storage_units') and not network.storage_units.empty:
850
- # Get storage unit-carrier mapping
851
- cursor = conn.execute("""
852
- SELECT c.name as component_name, carr.name as carrier_name
853
- FROM components c
854
- JOIN carriers carr ON c.carrier_id = carr.id
855
- WHERE c.network_id = ? AND c.component_type = 'STORAGE_UNIT'
856
- """, (network_id,))
753
+ emission_factor = emission_factors.get(carrier_name, 0.0) # Default to 0 if no emission factor
754
+ if emission_factor is None:
755
+ emission_factor = 0.0
857
756
 
858
- storage_unit_carriers = {row[0]: row[1] for row in cursor.fetchall()}
757
+ emissions_tonnes = dispatch_mwh * emission_factor
859
758
 
860
- # Calculate capacity for each storage unit
861
- for su_name in network.storage_units.index:
862
- if su_name in storage_unit_carriers:
863
- carrier_name = storage_unit_carriers[su_name]
864
-
865
- # Power capacity (MW)
866
- if 'p_nom_opt' in network.storage_units.columns:
867
- p_nom_opt = float(network.storage_units.loc[su_name, 'p_nom_opt'])
868
- else:
869
- p_nom_opt = float(network.storage_units.loc[su_name, 'p_nom']) if 'p_nom' in network.storage_units.columns else 0.0
870
-
871
- if carrier_name in carrier_stats["power_capacity_by_carrier"]:
872
- carrier_stats["power_capacity_by_carrier"][carrier_name] += p_nom_opt
873
-
874
- # Energy capacity (MWh) using max_hours (matching old solver)
875
- max_hours = 1.0 # Default from validation data
876
- if 'max_hours' in network.storage_units.columns:
877
- max_hours = float(network.storage_units.loc[su_name, 'max_hours'])
878
- energy_capacity_mwh = p_nom_opt * max_hours
879
-
880
- if carrier_name in carrier_stats["energy_capacity_by_carrier"]:
881
- carrier_stats["energy_capacity_by_carrier"][carrier_name] += energy_capacity_mwh
759
+ if carrier_name in carrier_stats["emissions_by_carrier"]:
760
+ carrier_stats["emissions_by_carrier"][carrier_name] += emissions_tonnes
882
761
 
883
- # 3. STORES - Energy capacity (MWh) only
884
- if hasattr(network, 'stores') and not network.stores.empty:
885
- # Get store-carrier mapping
886
- cursor = conn.execute("""
887
- SELECT c.name as component_name, carr.name as carrier_name
888
- FROM components c
889
- JOIN carriers carr ON c.carrier_id = carr.id
890
- WHERE c.network_id = ? AND c.component_type = 'STORE'
891
- """, (network_id,))
892
-
893
- store_carriers = {row[0]: row[1] for row in cursor.fetchall()}
894
-
895
- # Calculate energy capacity for each store
896
- for store_name in network.stores.index:
897
- if store_name in store_carriers:
898
- carrier_name = store_carriers[store_name]
899
-
900
- # Energy capacity (MWh) - stores don't have power capacity, only energy
901
- if 'e_nom_opt' in network.stores.columns:
902
- e_nom_opt = float(network.stores.loc[store_name, 'e_nom_opt'])
903
- else:
904
- e_nom_opt = float(network.stores.loc[store_name, 'e_nom']) if 'e_nom' in network.stores.columns else 0.0
905
-
906
- # Stores contribute only to energy capacity
907
- if carrier_name in carrier_stats["energy_capacity_by_carrier"]:
908
- carrier_stats["energy_capacity_by_carrier"][carrier_name] += e_nom_opt
762
+ # Calculate capital costs by carrier for this specific year
763
+ # Capital costs are annualized and counted every year the component is active
909
764
 
910
- # 4. LINES - Apparent power capacity (MVA)
911
- if hasattr(network, 'lines') and not network.lines.empty:
912
- # Get line-carrier mapping
913
- cursor = conn.execute("""
914
- SELECT c.name as component_name, carr.name as carrier_name
915
- FROM components c
916
- JOIN carriers carr ON c.carrier_id = carr.id
917
- WHERE c.network_id = ? AND c.component_type = 'LINE'
918
- """, (network_id,))
919
-
920
- line_carriers = {row[0]: row[1] for row in cursor.fetchall()}
765
+ # Helper function to check if component is active in this year
766
+ def is_component_active(build_year, lifetime, current_year):
767
+ """Check if component is active in the current year based on build_year and lifetime"""
768
+ if pd.isna(build_year):
769
+ return True # No build year constraint
921
770
 
922
- # Calculate capacity for each line
923
- for line_name in network.lines.index:
924
- if line_name in line_carriers:
925
- carrier_name = line_carriers[line_name]
926
-
927
- # Apparent power capacity (MVA) - convert to MW equivalent for consistency
928
- if 's_nom_opt' in network.lines.columns:
929
- capacity_mva = float(network.lines.loc[line_name, 's_nom_opt'])
930
- else:
931
- capacity_mva = float(network.lines.loc[line_name, 's_nom']) if 's_nom' in network.lines.columns else 0.0
932
-
933
- # Convert MVA to MW (assume power factor = 1 for simplicity)
934
- capacity_mw = capacity_mva
935
-
936
- if carrier_name in carrier_stats["power_capacity_by_carrier"]:
937
- carrier_stats["power_capacity_by_carrier"][carrier_name] += capacity_mw
938
-
939
- # 5. LINKS - Power capacity (MW)
940
- if hasattr(network, 'links') and not network.links.empty:
941
- # Get link-carrier mapping
942
- cursor = conn.execute("""
943
- SELECT c.name as component_name, carr.name as carrier_name
944
- FROM components c
945
- JOIN carriers carr ON c.carrier_id = carr.id
946
- WHERE c.network_id = ? AND c.component_type = 'LINK'
947
- """, (network_id,))
771
+ build_year = int(build_year)
772
+ if build_year > current_year:
773
+ return False # Not built yet
948
774
 
949
- link_carriers = {row[0]: row[1] for row in cursor.fetchall()}
775
+ if pd.isna(lifetime) or lifetime == float('inf'):
776
+ return True # Infinite lifetime
950
777
 
951
- # Calculate capacity for each link
952
- for link_name in network.links.index:
953
- if link_name in link_carriers:
954
- carrier_name = link_carriers[link_name]
955
-
956
- # Power capacity (MW)
957
- if 'p_nom_opt' in network.links.columns:
958
- capacity_mw = float(network.links.loc[link_name, 'p_nom_opt'])
959
- else:
960
- capacity_mw = float(network.links.loc[link_name, 'p_nom']) if 'p_nom' in network.links.columns else 0.0
961
-
962
- if carrier_name in carrier_stats["power_capacity_by_carrier"]:
963
- carrier_stats["power_capacity_by_carrier"][carrier_name] += capacity_mw
964
-
965
- # Calculate emissions by carrier
966
- cursor = conn.execute("""
967
- SELECT name, co2_emissions
968
- FROM carriers
969
- WHERE network_id = ? AND co2_emissions IS NOT NULL
970
- ORDER BY name
971
- """, (network_id,))
972
-
973
- emission_factors = {}
974
- for row in cursor.fetchall():
975
- carrier_name, co2_emissions = row
976
- emission_factors[carrier_name] = co2_emissions
778
+ lifetime = int(lifetime)
779
+ end_year = build_year + lifetime - 1
780
+ return current_year <= end_year
977
781
 
978
- # Calculate emissions = dispatch * emission_factor
979
- for carrier, dispatch_mwh in carrier_stats["dispatch_by_carrier"].items():
980
- emission_factor = emission_factors.get(carrier, 0.0)
981
- emissions = dispatch_mwh * emission_factor
982
- carrier_stats["emissions_by_carrier"][carrier] = emissions
983
-
984
- # Calculate cost statistics by carrier (all component types)
985
-
986
- # 1. GENERATORS - Operational and capital costs
782
+ # 1. GENERATORS - Capital costs (including UNMET_LOAD)
987
783
  if hasattr(network, 'generators') and not network.generators.empty:
988
- # Get generator-carrier mapping
784
+ # Get generator info: carrier, capital_cost, build_year, lifetime (include UNMET_LOAD)
989
785
  cursor = conn.execute("""
990
- SELECT c.name as component_name, carr.name as carrier_name
786
+ SELECT c.name as component_name,
787
+ CASE
788
+ WHEN c.component_type = 'UNMET_LOAD' THEN 'Unmet Load'
789
+ ELSE carr.name
790
+ END as carrier_name
991
791
  FROM components c
992
792
  JOIN carriers carr ON c.carrier_id = carr.id
993
- WHERE c.network_id = ? AND c.component_type = 'GENERATOR'
793
+ WHERE c.network_id = ? AND c.component_type IN ('GENERATOR', 'UNMET_LOAD')
994
794
  """, (network_id,))
995
-
996
795
  generator_carriers = {row[0]: row[1] for row in cursor.fetchall()}
997
796
 
998
- # Calculate operational costs based on dispatch and marginal costs
999
- if hasattr(network, 'generators_t') and hasattr(network.generators_t, 'p'):
1000
- for gen_name in network.generators.index:
1001
- if gen_name in generator_carriers and gen_name in network.generators_t.p.columns:
1002
- carrier_name = generator_carriers[gen_name]
1003
-
1004
- # Get marginal cost for this generator
1005
- marginal_cost = 0.0
1006
- if 'marginal_cost' in network.generators.columns:
1007
- marginal_cost = float(network.generators.loc[gen_name, 'marginal_cost'])
1008
-
1009
- # Calculate operational cost = dispatch * marginal_cost (with weightings)
1010
- weightings = network.snapshot_weightings
1011
- if isinstance(weightings, pd.DataFrame):
1012
- if 'objective' in weightings.columns:
1013
- weighting_values = weightings['objective'].values
1014
- else:
1015
- weighting_values = weightings.iloc[:, 0].values
1016
- else:
1017
- weighting_values = weightings.values
1018
-
1019
- dispatch_mwh = float((network.generators_t.p[gen_name].values * weighting_values).sum())
1020
- operational_cost = dispatch_mwh * marginal_cost
1021
-
1022
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1023
- carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1024
-
1025
- # Calculate annual capital costs for all operational generators (matching old solver per-year logic)
1026
- for gen_name in network.generators.index:
1027
- if gen_name in generator_carriers:
1028
- carrier_name = generator_carriers[gen_name]
1029
-
1030
- # Get capital cost and capacity
1031
- capital_cost_per_mw = 0.0
1032
- if 'capital_cost' in network.generators.columns:
1033
- capital_cost_per_mw = float(network.generators.loc[gen_name, 'capital_cost'])
1034
-
1035
- capacity_mw = 0.0
1036
- if 'p_nom_opt' in network.generators.columns:
1037
- capacity_mw = float(network.generators.loc[gen_name, 'p_nom_opt'])
1038
- elif 'p_nom' in network.generators.columns:
1039
- capacity_mw = float(network.generators.loc[gen_name, 'p_nom'])
1040
-
1041
- # Annual capital cost for operational assets (undiscounted)
1042
- annual_capital_cost = capital_cost_per_mw * capacity_mw
1043
-
1044
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
1045
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1046
-
1047
- # Calculate operational costs including fixed costs (matching old solver)
1048
797
  for gen_name in network.generators.index:
1049
798
  if gen_name in generator_carriers:
1050
799
  carrier_name = generator_carriers[gen_name]
1051
800
 
1052
- # Fixed O&M costs (annual cost per MW of capacity)
1053
- fixed_cost_per_mw = 0.0
1054
- if 'fixed_cost' in network.generators.columns:
1055
- fixed_cost_per_mw = float(network.generators.loc[gen_name, 'fixed_cost'])
1056
-
1057
- capacity_mw = 0.0
1058
- if 'p_nom_opt' in network.generators.columns:
1059
- capacity_mw = float(network.generators.loc[gen_name, 'p_nom_opt'])
1060
- elif 'p_nom' in network.generators.columns:
1061
- capacity_mw = float(network.generators.loc[gen_name, 'p_nom'])
801
+ # Get build year and lifetime
802
+ build_year = network.generators.loc[gen_name, 'build_year'] if 'build_year' in network.generators.columns else None
803
+ lifetime = network.generators.loc[gen_name, 'lifetime'] if 'lifetime' in network.generators.columns else None
1062
804
 
1063
- fixed_cost_total = fixed_cost_per_mw * capacity_mw
1064
-
1065
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1066
- carrier_stats["operational_cost_by_carrier"][carrier_name] += fixed_cost_total
805
+ # Check if component is active in this year
806
+ if is_component_active(build_year, lifetime, year):
807
+ # Get capacity and capital cost
808
+ if 'p_nom_opt' in network.generators.columns:
809
+ capacity_mw = float(network.generators.loc[gen_name, 'p_nom_opt'])
810
+ else:
811
+ capacity_mw = float(network.generators.loc[gen_name, 'p_nom']) if 'p_nom' in network.generators.columns else 0.0
812
+
813
+ capital_cost_per_mw = float(network.generators.loc[gen_name, 'capital_cost']) if 'capital_cost' in network.generators.columns else 0.0
814
+
815
+ # Calculate annualized capital cost for this year
816
+ annual_capital_cost = capacity_mw * capital_cost_per_mw
817
+
818
+ if carrier_name in carrier_stats["capital_cost_by_carrier"]:
819
+ carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1067
820
 
1068
- # 2. STORAGE_UNITS - Operational and capital costs
821
+ # 2. STORAGE_UNITS - Capital costs
1069
822
  if hasattr(network, 'storage_units') and not network.storage_units.empty:
1070
- # Get storage unit-carrier mapping
1071
823
  cursor = conn.execute("""
1072
824
  SELECT c.name as component_name, carr.name as carrier_name
1073
825
  FROM components c
1074
826
  JOIN carriers carr ON c.carrier_id = carr.id
1075
827
  WHERE c.network_id = ? AND c.component_type = 'STORAGE_UNIT'
1076
828
  """, (network_id,))
1077
-
1078
829
  storage_unit_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1079
830
 
1080
- # Calculate operational costs (marginal costs for storage units)
1081
- if hasattr(network, 'storage_units_t') and hasattr(network.storage_units_t, 'p'):
1082
- for su_name in network.storage_units.index:
1083
- if su_name in storage_unit_carriers and su_name in network.storage_units_t.p.columns:
1084
- carrier_name = storage_unit_carriers[su_name]
1085
-
1086
- # Get marginal cost for this storage unit
1087
- marginal_cost = 0.0
1088
- if 'marginal_cost' in network.storage_units.columns:
1089
- marginal_cost = float(network.storage_units.loc[su_name, 'marginal_cost'])
1090
-
1091
- # Calculate operational cost = dispatch * marginal_cost (discharge only)
1092
- weightings = network.snapshot_weightings
1093
- if isinstance(weightings, pd.DataFrame):
1094
- if 'objective' in weightings.columns:
1095
- weighting_values = weightings['objective'].values
1096
- else:
1097
- weighting_values = weightings.iloc[:, 0].values
1098
- else:
1099
- weighting_values = weightings.values
1100
-
1101
- su_power = network.storage_units_t.p[su_name]
1102
- discharge_mwh = float((su_power.clip(lower=0) * weighting_values).sum())
1103
- operational_cost = discharge_mwh * marginal_cost
1104
-
1105
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1106
- carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1107
-
1108
- # Calculate fixed O&M costs for storage units (matching old solver)
1109
831
  for su_name in network.storage_units.index:
1110
832
  if su_name in storage_unit_carriers:
1111
833
  carrier_name = storage_unit_carriers[su_name]
1112
834
 
1113
- # Fixed O&M costs (annual cost per MW of capacity)
1114
- fixed_cost_per_mw = 0.0
1115
- if 'fixed_cost' in network.storage_units.columns:
1116
- fixed_cost_per_mw = float(network.storage_units.loc[su_name, 'fixed_cost'])
835
+ # Get build year and lifetime
836
+ build_year = network.storage_units.loc[su_name, 'build_year'] if 'build_year' in network.storage_units.columns else None
837
+ lifetime = network.storage_units.loc[su_name, 'lifetime'] if 'lifetime' in network.storage_units.columns else None
1117
838
 
1118
- capacity_mw = 0.0
1119
- if 'p_nom_opt' in network.storage_units.columns:
1120
- capacity_mw = float(network.storage_units.loc[su_name, 'p_nom_opt'])
1121
- elif 'p_nom' in network.storage_units.columns:
1122
- capacity_mw = float(network.storage_units.loc[su_name, 'p_nom'])
1123
-
1124
- fixed_cost_total = fixed_cost_per_mw * capacity_mw
1125
-
1126
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1127
- carrier_stats["operational_cost_by_carrier"][carrier_name] += fixed_cost_total
1128
-
1129
- # Calculate annual capital costs for all operational storage units
1130
- for su_name in network.storage_units.index:
1131
- if su_name in storage_unit_carriers:
1132
- carrier_name = storage_unit_carriers[su_name]
1133
-
1134
- # Get capital cost for this storage unit
1135
- capital_cost_per_mw = 0.0
1136
- if 'capital_cost' in network.storage_units.columns:
1137
- capital_cost_per_mw = float(network.storage_units.loc[su_name, 'capital_cost'])
1138
-
1139
- # Get capacity
1140
- capacity_mw = 0.0
1141
- if 'p_nom_opt' in network.storage_units.columns:
1142
- capacity_mw = float(network.storage_units.loc[su_name, 'p_nom_opt'])
1143
- elif 'p_nom' in network.storage_units.columns:
1144
- capacity_mw = float(network.storage_units.loc[su_name, 'p_nom'])
1145
-
1146
- # Annual capital cost for operational assets (undiscounted)
1147
- annual_capital_cost = capital_cost_per_mw * capacity_mw
1148
-
1149
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
1150
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
839
+ # Check if component is active in this year
840
+ if is_component_active(build_year, lifetime, year):
841
+ # Get power capacity and capital cost (per MW)
842
+ if 'p_nom_opt' in network.storage_units.columns:
843
+ capacity_mw = float(network.storage_units.loc[su_name, 'p_nom_opt'])
844
+ else:
845
+ capacity_mw = float(network.storage_units.loc[su_name, 'p_nom']) if 'p_nom' in network.storage_units.columns else 0.0
846
+
847
+ capital_cost_per_mw = float(network.storage_units.loc[su_name, 'capital_cost']) if 'capital_cost' in network.storage_units.columns else 0.0
848
+
849
+ # Calculate annualized capital cost for this year
850
+ annual_capital_cost = capacity_mw * capital_cost_per_mw
851
+
852
+ if carrier_name in carrier_stats["capital_cost_by_carrier"]:
853
+ carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1151
854
 
1152
- # 3. STORES - Operational and capital costs
855
+ # 3. STORES - Capital costs (per MWh)
1153
856
  if hasattr(network, 'stores') and not network.stores.empty:
1154
- # Get store-carrier mapping
1155
857
  cursor = conn.execute("""
1156
858
  SELECT c.name as component_name, carr.name as carrier_name
1157
859
  FROM components c
1158
860
  JOIN carriers carr ON c.carrier_id = carr.id
1159
861
  WHERE c.network_id = ? AND c.component_type = 'STORE'
1160
862
  """, (network_id,))
1161
-
1162
863
  store_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1163
864
 
1164
- # Calculate operational costs (marginal costs for stores)
1165
- if hasattr(network, 'stores_t') and hasattr(network.stores_t, 'p'):
1166
- for store_name in network.stores.index:
1167
- if store_name in store_carriers and store_name in network.stores_t.p.columns:
1168
- carrier_name = store_carriers[store_name]
1169
-
1170
- # Get marginal cost for this store
1171
- marginal_cost = 0.0
1172
- if 'marginal_cost' in network.stores.columns:
1173
- marginal_cost = float(network.stores.loc[store_name, 'marginal_cost'])
1174
-
1175
- # Calculate operational cost = dispatch * marginal_cost (discharge only)
1176
- weightings = network.snapshot_weightings
1177
- if isinstance(weightings, pd.DataFrame):
1178
- if 'objective' in weightings.columns:
1179
- weighting_values = weightings['objective'].values
1180
- else:
1181
- weighting_values = weightings.iloc[:, 0].values
1182
- else:
1183
- weighting_values = weightings.values
1184
-
1185
- store_power = network.stores_t.p[store_name]
1186
- discharge_mwh = float((store_power.clip(lower=0) * weighting_values).sum())
1187
- operational_cost = discharge_mwh * marginal_cost
1188
-
1189
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1190
- carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1191
-
1192
- # Calculate annual capital costs for all operational stores (based on energy capacity)
1193
865
  for store_name in network.stores.index:
1194
866
  if store_name in store_carriers:
1195
867
  carrier_name = store_carriers[store_name]
1196
868
 
1197
- # Get capital cost for this store (per MWh)
1198
- capital_cost_per_mwh = 0.0
1199
- if 'capital_cost' in network.stores.columns:
1200
- capital_cost_per_mwh = float(network.stores.loc[store_name, 'capital_cost'])
869
+ # Get build year and lifetime
870
+ build_year = network.stores.loc[store_name, 'build_year'] if 'build_year' in network.stores.columns else None
871
+ lifetime = network.stores.loc[store_name, 'lifetime'] if 'lifetime' in network.stores.columns else None
1201
872
 
1202
- # Get energy capacity
1203
- energy_capacity_mwh = 0.0
1204
- if 'e_nom_opt' in network.stores.columns:
1205
- energy_capacity_mwh = float(network.stores.loc[store_name, 'e_nom_opt'])
1206
- elif 'e_nom' in network.stores.columns:
1207
- energy_capacity_mwh = float(network.stores.loc[store_name, 'e_nom'])
1208
-
1209
- # Annual capital cost for operational assets (undiscounted)
1210
- annual_capital_cost = capital_cost_per_mwh * energy_capacity_mwh
1211
-
1212
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
1213
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
873
+ # Check if component is active in this year
874
+ if is_component_active(build_year, lifetime, year):
875
+ # Get energy capacity and capital cost (per MWh)
876
+ if 'e_nom_opt' in network.stores.columns:
877
+ capacity_mwh = float(network.stores.loc[store_name, 'e_nom_opt'])
878
+ else:
879
+ capacity_mwh = float(network.stores.loc[store_name, 'e_nom']) if 'e_nom' in network.stores.columns else 0.0
880
+
881
+ capital_cost_per_mwh = float(network.stores.loc[store_name, 'capital_cost']) if 'capital_cost' in network.stores.columns else 0.0
882
+
883
+ # Calculate annualized capital cost for this year
884
+ annual_capital_cost = capacity_mwh * capital_cost_per_mwh
885
+
886
+ if carrier_name in carrier_stats["capital_cost_by_carrier"]:
887
+ carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1214
888
 
1215
- # 4. LINES - Capital costs only (no operational costs for transmission lines)
889
+ # 4. LINES - Capital costs (per MVA)
1216
890
  if hasattr(network, 'lines') and not network.lines.empty:
1217
- # Get line-carrier mapping
1218
891
  cursor = conn.execute("""
1219
892
  SELECT c.name as component_name, carr.name as carrier_name
1220
893
  FROM components c
1221
894
  JOIN carriers carr ON c.carrier_id = carr.id
1222
895
  WHERE c.network_id = ? AND c.component_type = 'LINE'
1223
896
  """, (network_id,))
1224
-
1225
897
  line_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1226
898
 
1227
- # Calculate capital costs for lines (based on s_nom_opt capacity)
1228
899
  for line_name in network.lines.index:
1229
900
  if line_name in line_carriers:
1230
901
  carrier_name = line_carriers[line_name]
1231
902
 
1232
- # Get capital cost for this line (per MVA)
1233
- capital_cost_per_mva = 0.0
1234
- if 'capital_cost' in network.lines.columns:
1235
- capital_cost_per_mva = float(network.lines.loc[line_name, 'capital_cost'])
1236
-
1237
- # Get apparent power capacity (MVA)
1238
- capacity_mva = 0.0
1239
- if 's_nom_opt' in network.lines.columns:
1240
- capacity_mva = float(network.lines.loc[line_name, 's_nom_opt'])
1241
- elif 's_nom' in network.lines.columns:
1242
- capacity_mva = float(network.lines.loc[line_name, 's_nom'])
903
+ # Get build year and lifetime
904
+ build_year = network.lines.loc[line_name, 'build_year'] if 'build_year' in network.lines.columns else None
905
+ lifetime = network.lines.loc[line_name, 'lifetime'] if 'lifetime' in network.lines.columns else None
1243
906
 
1244
- # Annual capital cost for operational assets (undiscounted)
1245
- annual_capital_cost = capacity_mva * capital_cost_per_mva
1246
-
1247
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
1248
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
907
+ # Check if component is active in this year
908
+ if is_component_active(build_year, lifetime, year):
909
+ # Get apparent power capacity and capital cost (per MVA)
910
+ if 's_nom_opt' in network.lines.columns:
911
+ capacity_mva = float(network.lines.loc[line_name, 's_nom_opt'])
912
+ else:
913
+ capacity_mva = float(network.lines.loc[line_name, 's_nom']) if 's_nom' in network.lines.columns else 0.0
914
+
915
+ capital_cost_per_mva = float(network.lines.loc[line_name, 'capital_cost']) if 'capital_cost' in network.lines.columns else 0.0
916
+
917
+ # Calculate annualized capital cost for this year
918
+ annual_capital_cost = capacity_mva * capital_cost_per_mva
919
+
920
+ if carrier_name in carrier_stats["capital_cost_by_carrier"]:
921
+ carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1249
922
 
1250
- # 5. LINKS - Capital and operational costs
923
+ # 5. LINKS - Capital costs (per MW)
1251
924
  if hasattr(network, 'links') and not network.links.empty:
1252
- # Get link-carrier mapping
1253
925
  cursor = conn.execute("""
1254
926
  SELECT c.name as component_name, carr.name as carrier_name
1255
927
  FROM components c
1256
928
  JOIN carriers carr ON c.carrier_id = carr.id
1257
929
  WHERE c.network_id = ? AND c.component_type = 'LINK'
1258
930
  """, (network_id,))
1259
-
1260
931
  link_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1261
932
 
1262
- # Calculate operational costs (marginal costs for links)
1263
- if hasattr(network, 'links_t') and hasattr(network.links_t, 'p0'):
1264
- for link_name in network.links.index:
1265
- if link_name in link_carriers and link_name in network.links_t.p0.columns:
1266
- carrier_name = link_carriers[link_name]
1267
-
1268
- # Get marginal cost for this link
1269
- marginal_cost = 0.0
1270
- if 'marginal_cost' in network.links.columns:
1271
- marginal_cost = float(network.links.loc[link_name, 'marginal_cost'])
1272
-
1273
- # Calculate operational cost = flow * marginal_cost (use absolute flow)
1274
- weightings = network.snapshot_weightings
1275
- if isinstance(weightings, pd.DataFrame):
1276
- if 'objective' in weightings.columns:
1277
- weighting_values = weightings['objective'].values
1278
- else:
1279
- weighting_values = weightings.iloc[:, 0].values
1280
- else:
1281
- weighting_values = weightings.values
1282
-
1283
- # Use absolute flow for cost calculation
1284
- link_flow = abs(network.links_t.p0[link_name])
1285
- flow_mwh = float((link_flow * weighting_values).sum())
1286
- operational_cost = flow_mwh * marginal_cost
1287
-
1288
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1289
- carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1290
-
1291
- # Calculate capital costs for links
1292
933
  for link_name in network.links.index:
1293
934
  if link_name in link_carriers:
1294
935
  carrier_name = link_carriers[link_name]
1295
936
 
1296
- # Get capital cost for this link (per MW)
1297
- capital_cost_per_mw = 0.0
1298
- if 'capital_cost' in network.links.columns:
1299
- capital_cost_per_mw = float(network.links.loc[link_name, 'capital_cost'])
1300
-
1301
- # Get power capacity (MW)
1302
- capacity_mw = 0.0
1303
- if 'p_nom_opt' in network.links.columns:
1304
- capacity_mw = float(network.links.loc[link_name, 'p_nom_opt'])
1305
- elif 'p_nom' in network.links.columns:
1306
- capacity_mw = float(network.links.loc[link_name, 'p_nom'])
937
+ # Get build year and lifetime
938
+ build_year = network.links.loc[link_name, 'build_year'] if 'build_year' in network.links.columns else None
939
+ lifetime = network.links.loc[link_name, 'lifetime'] if 'lifetime' in network.links.columns else None
1307
940
 
1308
- # Annual capital cost for operational assets (undiscounted)
1309
- annual_capital_cost = capacity_mw * capital_cost_per_mw
1310
-
1311
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
1312
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1313
-
1314
- # Calculate total system cost = capital + operational
1315
- for carrier in all_carriers:
1316
- capital = carrier_stats["capital_cost_by_carrier"][carrier]
1317
- operational = carrier_stats["operational_cost_by_carrier"][carrier]
1318
- carrier_stats["total_system_cost_by_carrier"][carrier] = capital + operational
1319
-
1320
- logger.info(f"Calculated carrier statistics for {len(all_carriers)} carriers")
1321
- logger.info(f"Total dispatch: {sum(carrier_stats['dispatch_by_carrier'].values()):.2f} MWh")
1322
- logger.info(f"Total power capacity: {sum(carrier_stats['power_capacity_by_carrier'].values()):.2f} MW")
1323
- logger.info(f"Total energy capacity: {sum(carrier_stats['energy_capacity_by_carrier'].values()):.2f} MWh")
1324
- logger.info(f"Total emissions: {sum(carrier_stats['emissions_by_carrier'].values()):.2f} tCO2")
1325
- logger.info(f"Total capital cost: {sum(carrier_stats['capital_cost_by_carrier'].values()):.2f} USD")
1326
- logger.info(f"Total operational cost: {sum(carrier_stats['operational_cost_by_carrier'].values()):.2f} USD")
1327
- logger.info(f"Total system cost: {sum(carrier_stats['total_system_cost_by_carrier'].values()):.2f} USD")
1328
-
1329
- return carrier_stats
1330
-
1331
- except Exception as e:
1332
- logger.error(f"Failed to calculate carrier statistics: {e}", exc_info=True)
1333
- return {
1334
- "dispatch_by_carrier": {},
1335
- "power_capacity_by_carrier": {},
1336
- "energy_capacity_by_carrier": {},
1337
- "emissions_by_carrier": {},
1338
- "capital_cost_by_carrier": {},
1339
- "operational_cost_by_carrier": {},
1340
- "total_system_cost_by_carrier": {}
1341
- }
1342
-
1343
- def _store_year_based_statistics(
1344
- self,
1345
- conn,
1346
- network_id: int,
1347
- network: 'pypsa.Network',
1348
- year_statistics: Dict[int, Dict[str, Any]],
1349
- scenario_id: Optional[int]
1350
- ) -> int:
1351
- """Store year-based statistics to database"""
1352
- try:
1353
- import json
1354
- stored_count = 0
1355
-
1356
- # Use master scenario if no scenario specified
1357
- if scenario_id is None:
1358
- from pyconvexity.models import get_master_scenario_id
1359
- scenario_id = get_master_scenario_id(conn, network_id)
1360
-
1361
- # Check if network_solve_results_by_year table exists, create if not
1362
- conn.execute("""
1363
- CREATE TABLE IF NOT EXISTS network_solve_results_by_year (
1364
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1365
- network_id INTEGER NOT NULL,
1366
- scenario_id INTEGER NOT NULL,
1367
- year INTEGER NOT NULL,
1368
- results_json TEXT,
1369
- metadata_json TEXT,
1370
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
1371
- FOREIGN KEY (network_id) REFERENCES networks(id),
1372
- FOREIGN KEY (scenario_id) REFERENCES scenarios(id),
1373
- UNIQUE(network_id, scenario_id, year)
1374
- )
1375
- """)
1376
-
1377
- for year, stats in year_statistics.items():
1378
- try:
1379
- # Calculate proper year-specific carrier statistics
1380
- year_carrier_stats = self._calculate_year_carrier_statistics(conn, network_id, network, year)
1381
-
1382
- # Merge year-specific carrier stats into the statistics
1383
- if "custom_statistics" in stats:
1384
- stats["custom_statistics"].update(year_carrier_stats)
1385
- else:
1386
- stats["custom_statistics"] = year_carrier_stats
1387
-
1388
- # Wrap the year statistics in the same structure as overall results for consistency
1389
- year_result_wrapper = {
1390
- "success": True,
1391
- "year": year,
1392
- "network_statistics": stats
1393
- }
1394
-
1395
- metadata = {
1396
- "year": year,
1397
- "network_id": network_id,
1398
- "scenario_id": scenario_id
1399
- }
1400
-
1401
- conn.execute("""
1402
- INSERT OR REPLACE INTO network_solve_results_by_year
1403
- (network_id, scenario_id, year, results_json, metadata_json)
1404
- VALUES (?, ?, ?, ?, ?)
1405
- """, (
1406
- network_id,
1407
- scenario_id,
1408
- year,
1409
- json.dumps(year_result_wrapper, default=self._json_serializer),
1410
- json.dumps(metadata, default=self._json_serializer)
1411
- ))
1412
-
1413
- stored_count += 1
1414
- logger.info(f"Stored year-based statistics for year {year}")
1415
-
1416
- except Exception as e:
1417
- logger.error(f"Failed to store statistics for year {year}: {e}")
1418
- continue
1419
-
1420
- logger.info(f"Successfully stored year-based statistics for {stored_count} years")
1421
- return stored_count
1422
-
1423
- except Exception as e:
1424
- logger.error(f"Failed to store year-based statistics: {e}", exc_info=True)
1425
- return 0
1426
-
1427
- def _calculate_year_carrier_statistics(self, conn, network_id: int, network: 'pypsa.Network', year: int) -> Dict[str, Any]:
1428
- """
1429
- Calculate carrier-specific statistics for a specific year with proper database access.
1430
-
1431
- CRITICAL: This method now consistently applies snapshot weightings to ALL energy calculations
1432
- to convert MW to MWh, matching the old PyPSA solver behavior. This is essential for
1433
- multi-hourly models (e.g., 3-hourly models where each timestep = 3 hours).
1434
- """
1435
- try:
1436
- # Initialize carrier statistics (separate power and energy capacity like old solver)
1437
- carrier_stats = {
1438
- "dispatch_by_carrier": {},
1439
- "power_capacity_by_carrier": {}, # MW - Generators + Storage Units (power)
1440
- "energy_capacity_by_carrier": {}, # MWh - Stores + Storage Units (energy)
1441
- "emissions_by_carrier": {},
1442
- "capital_cost_by_carrier": {},
1443
- "operational_cost_by_carrier": {},
1444
- "total_system_cost_by_carrier": {}
1445
- }
1446
-
1447
- # Get all carriers from database
1448
- cursor = conn.execute("""
1449
- SELECT DISTINCT name FROM carriers WHERE network_id = ?
1450
- """, (network_id,))
1451
- all_carriers = [row[0] for row in cursor.fetchall()]
1452
-
1453
- # Initialize all carriers with zero values
1454
- for carrier in all_carriers:
1455
- carrier_stats["dispatch_by_carrier"][carrier] = 0.0
1456
- carrier_stats["power_capacity_by_carrier"][carrier] = 0.0
1457
- carrier_stats["energy_capacity_by_carrier"][carrier] = 0.0
1458
- carrier_stats["emissions_by_carrier"][carrier] = 0.0
1459
- carrier_stats["capital_cost_by_carrier"][carrier] = 0.0
1460
- carrier_stats["operational_cost_by_carrier"][carrier] = 0.0
1461
- carrier_stats["total_system_cost_by_carrier"][carrier] = 0.0
1462
-
1463
- # Get generator-carrier mapping from database
1464
- cursor = conn.execute("""
1465
- SELECT c.name as component_name, carr.name as carrier_name
1466
- FROM components c
1467
- JOIN carriers carr ON c.carrier_id = carr.id
1468
- WHERE c.network_id = ? AND c.component_type = 'GENERATOR'
1469
- """, (network_id,))
1470
- generator_carriers = {row[0]: row[1] for row in cursor.fetchall()}
941
+ # Check if component is active in this year
942
+ if is_component_active(build_year, lifetime, year):
943
+ # Get power capacity and capital cost (per MW)
944
+ if 'p_nom_opt' in network.links.columns:
945
+ capacity_mw = float(network.links.loc[link_name, 'p_nom_opt'])
946
+ else:
947
+ capacity_mw = float(network.links.loc[link_name, 'p_nom']) if 'p_nom' in network.links.columns else 0.0
948
+
949
+ capital_cost_per_mw = float(network.links.loc[link_name, 'capital_cost']) if 'capital_cost' in network.links.columns else 0.0
950
+
951
+ # Calculate annualized capital cost for this year
952
+ annual_capital_cost = capacity_mw * capital_cost_per_mw
953
+
954
+ if carrier_name in carrier_stats["capital_cost_by_carrier"]:
955
+ carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1471
956
 
1472
- # Calculate year-specific dispatch by carrier (all component types)
957
+ # Calculate operational costs by carrier for this specific year
958
+ # Operational costs = dispatch (MWh) × marginal_cost (currency/MWh)
959
+ # Only for components that are active in this year
1473
960
 
1474
- # 1. GENERATORS - Year-specific generation
961
+ # 1. GENERATORS - Operational costs (including UNMET_LOAD)
1475
962
  if hasattr(network, 'generators_t') and hasattr(network.generators_t, 'p'):
963
+ # Get generator info: carrier, marginal_cost, build_year, lifetime (include UNMET_LOAD)
964
+ cursor = conn.execute("""
965
+ SELECT c.name as component_name,
966
+ CASE
967
+ WHEN c.component_type = 'UNMET_LOAD' THEN 'Unmet Load'
968
+ ELSE carr.name
969
+ END as carrier_name
970
+ FROM components c
971
+ JOIN carriers carr ON c.carrier_id = carr.id
972
+ WHERE c.network_id = ? AND c.component_type IN ('GENERATOR', 'UNMET_LOAD')
973
+ """, (network_id,))
974
+ generator_carriers = {row[0]: row[1] for row in cursor.fetchall()}
975
+
1476
976
  # Filter generation data for this specific year
1477
977
  year_generation = self._filter_timeseries_by_year(network.generators_t.p, network.snapshots, year)
1478
978
  if year_generation is not None and not year_generation.empty:
1479
979
  for gen_name in year_generation.columns:
1480
980
  if gen_name in generator_carriers:
1481
981
  carrier_name = generator_carriers[gen_name]
1482
- # Calculate generation for this year (ALWAYS apply snapshot weightings to convert MW to MWh)
1483
- year_weightings = self._get_year_weightings(network, year)
1484
- if year_weightings is not None:
1485
- generation_mwh = float((year_generation[gen_name].values * year_weightings).sum())
1486
- else:
1487
- # Fallback: use all-year weightings if year-specific not available
1488
- weightings = network.snapshot_weightings
1489
- if isinstance(weightings, pd.DataFrame):
1490
- if 'objective' in weightings.columns:
1491
- weighting_values = weightings['objective'].values
1492
- else:
1493
- weighting_values = weightings.iloc[:, 0].values
1494
- else:
1495
- weighting_values = weightings.values
1496
- # Apply weightings to the filtered year data
1497
- if len(weighting_values) == len(year_generation):
1498
- generation_mwh = float((year_generation[gen_name].values * weighting_values).sum())
982
+
983
+ # Get build year and lifetime
984
+ build_year = network.generators.loc[gen_name, 'build_year'] if 'build_year' in network.generators.columns else None
985
+ lifetime = network.generators.loc[gen_name, 'lifetime'] if 'lifetime' in network.generators.columns else None
986
+
987
+ # Check if component is active in this year
988
+ if is_component_active(build_year, lifetime, year):
989
+ # Calculate generation for this year (already calculated above, but need to recalculate for operational costs)
990
+ year_weightings = self._get_year_weightings(network, year)
991
+ if year_weightings is not None:
992
+ generation_mwh = float((year_generation[gen_name].values * year_weightings).sum())
1499
993
  else:
1500
- # Last resort: simple sum (will be incorrect for non-1H models)
1501
994
  generation_mwh = float(year_generation[gen_name].sum())
1502
- logger.warning(f"Could not apply snapshot weightings for {gen_name} in year {year} - energy may be incorrect")
1503
-
1504
- if carrier_name in carrier_stats["dispatch_by_carrier"]:
1505
- carrier_stats["dispatch_by_carrier"][carrier_name] += generation_mwh
995
+
996
+ # Get marginal cost
997
+ marginal_cost = float(network.generators.loc[gen_name, 'marginal_cost']) if 'marginal_cost' in network.generators.columns else 0.0
998
+
999
+ # Calculate operational cost for this year
1000
+ operational_cost = generation_mwh * marginal_cost
1001
+
1002
+ if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1003
+ carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1506
1004
 
1507
- # 2. STORAGE_UNITS - Year-specific discharge
1005
+ # 2. STORAGE_UNITS - Operational costs (discharge only)
1508
1006
  if hasattr(network, 'storage_units_t') and hasattr(network.storage_units_t, 'p'):
1509
- # Get storage unit-carrier mapping
1007
+ # Get storage unit info: carrier, marginal_cost, build_year, lifetime
1510
1008
  cursor = conn.execute("""
1511
1009
  SELECT c.name as component_name, carr.name as carrier_name
1512
1010
  FROM components c
@@ -1521,33 +1019,32 @@ class ResultStorage:
1521
1019
  for su_name in year_storage.columns:
1522
1020
  if su_name in storage_unit_carriers:
1523
1021
  carrier_name = storage_unit_carriers[su_name]
1524
- # Calculate discharge for this year (positive values only, ALWAYS apply snapshot weightings)
1525
- year_weightings = self._get_year_weightings(network, year)
1526
- if year_weightings is not None:
1527
- discharge_mwh = float((year_storage[su_name].clip(lower=0).values * year_weightings).sum())
1528
- else:
1529
- # Fallback: use all-year weightings if year-specific not available
1530
- weightings = network.snapshot_weightings
1531
- if isinstance(weightings, pd.DataFrame):
1532
- if 'objective' in weightings.columns:
1533
- weighting_values = weightings['objective'].values
1534
- else:
1535
- weighting_values = weightings.iloc[:, 0].values
1536
- else:
1537
- weighting_values = weightings.values
1538
- # Apply weightings to the filtered year data
1539
- if len(weighting_values) == len(year_storage):
1540
- discharge_mwh = float((year_storage[su_name].clip(lower=0).values * weighting_values).sum())
1022
+
1023
+ # Get build year and lifetime
1024
+ build_year = network.storage_units.loc[su_name, 'build_year'] if 'build_year' in network.storage_units.columns else None
1025
+ lifetime = network.storage_units.loc[su_name, 'lifetime'] if 'lifetime' in network.storage_units.columns else None
1026
+
1027
+ # Check if component is active in this year
1028
+ if is_component_active(build_year, lifetime, year):
1029
+ # Calculate discharge for this year (positive values only)
1030
+ year_weightings = self._get_year_weightings(network, year)
1031
+ if year_weightings is not None:
1032
+ discharge_mwh = float((year_storage[su_name].clip(lower=0).values * year_weightings).sum())
1541
1033
  else:
1542
1034
  discharge_mwh = float(year_storage[su_name].clip(lower=0).sum())
1543
- logger.warning(f"Could not apply snapshot weightings for storage unit {su_name} in year {year} - energy may be incorrect")
1544
-
1545
- if carrier_name in carrier_stats["dispatch_by_carrier"]:
1546
- carrier_stats["dispatch_by_carrier"][carrier_name] += discharge_mwh
1035
+
1036
+ # Get marginal cost
1037
+ marginal_cost = float(network.storage_units.loc[su_name, 'marginal_cost']) if 'marginal_cost' in network.storage_units.columns else 0.0
1038
+
1039
+ # Calculate operational cost for this year
1040
+ operational_cost = discharge_mwh * marginal_cost
1041
+
1042
+ if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1043
+ carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1547
1044
 
1548
- # 3. STORES - Year-specific discharge
1045
+ # 3. STORES - Operational costs (discharge only)
1549
1046
  if hasattr(network, 'stores_t') and hasattr(network.stores_t, 'p'):
1550
- # Get store-carrier mapping
1047
+ # Get store info: carrier, marginal_cost, build_year, lifetime
1551
1048
  cursor = conn.execute("""
1552
1049
  SELECT c.name as component_name, carr.name as carrier_name
1553
1050
  FROM components c
@@ -1562,34 +1059,56 @@ class ResultStorage:
1562
1059
  for store_name in year_stores.columns:
1563
1060
  if store_name in store_carriers:
1564
1061
  carrier_name = store_carriers[store_name]
1565
- # Calculate discharge for this year (positive values only, ALWAYS apply snapshot weightings)
1566
- year_weightings = self._get_year_weightings(network, year)
1567
- if year_weightings is not None:
1568
- discharge_mwh = float((year_stores[store_name].clip(lower=0).values * year_weightings).sum())
1569
- else:
1570
- # Fallback: use all-year weightings if year-specific not available
1571
- weightings = network.snapshot_weightings
1572
- if isinstance(weightings, pd.DataFrame):
1573
- if 'objective' in weightings.columns:
1574
- weighting_values = weightings['objective'].values
1575
- else:
1576
- weighting_values = weightings.iloc[:, 0].values
1577
- else:
1578
- weighting_values = weightings.values
1579
- # Apply weightings to the filtered year data
1580
- if len(weighting_values) == len(year_stores):
1581
- discharge_mwh = float((year_stores[store_name].clip(lower=0).values * weighting_values).sum())
1062
+
1063
+ # Get build year and lifetime
1064
+ build_year = network.stores.loc[store_name, 'build_year'] if 'build_year' in network.stores.columns else None
1065
+ lifetime = network.stores.loc[store_name, 'lifetime'] if 'lifetime' in network.stores.columns else None
1066
+
1067
+ # Check if component is active in this year
1068
+ if is_component_active(build_year, lifetime, year):
1069
+ # Calculate discharge for this year (positive values only)
1070
+ year_weightings = self._get_year_weightings(network, year)
1071
+ if year_weightings is not None:
1072
+ discharge_mwh = float((year_stores[store_name].clip(lower=0).values * year_weightings).sum())
1582
1073
  else:
1583
1074
  discharge_mwh = float(year_stores[store_name].clip(lower=0).sum())
1584
- logger.warning(f"Could not apply snapshot weightings for store {store_name} in year {year} - energy may be incorrect")
1075
+
1076
+ # Get marginal cost
1077
+ marginal_cost = float(network.stores.loc[store_name, 'marginal_cost']) if 'marginal_cost' in network.stores.columns else 0.0
1078
+
1079
+ # Calculate operational cost for this year
1080
+ operational_cost = discharge_mwh * marginal_cost
1585
1081
 
1586
- if carrier_name in carrier_stats["dispatch_by_carrier"]:
1587
- carrier_stats["dispatch_by_carrier"][carrier_name] += discharge_mwh
1082
+ if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1083
+ carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1084
+
1085
+ # Calculate total system costs by carrier for this specific year
1086
+ # Total system cost = capital cost + operational cost
1087
+ for carrier_name in carrier_stats["capital_cost_by_carrier"]:
1088
+ capital_cost = carrier_stats["capital_cost_by_carrier"][carrier_name]
1089
+ operational_cost = carrier_stats["operational_cost_by_carrier"][carrier_name]
1090
+ total_system_cost = capital_cost + operational_cost
1091
+
1092
+ if carrier_name in carrier_stats["total_system_cost_by_carrier"]:
1093
+ carrier_stats["total_system_cost_by_carrier"][carrier_name] = total_system_cost
1588
1094
 
1589
- # Calculate year-specific capacity by carrier (capacity available in this year)
1095
+ # Calculate capacity by carrier for this specific year
1590
1096
 
1591
- # 1. GENERATORS - Year-specific power capacity
1097
+ # 4. GENERATORS - Power capacity (MW) (including UNMET_LOAD)
1592
1098
  if hasattr(network, 'generators') and not network.generators.empty:
1099
+ # Get generator-carrier mapping (include UNMET_LOAD)
1100
+ cursor = conn.execute("""
1101
+ SELECT c.name as component_name,
1102
+ CASE
1103
+ WHEN c.component_type = 'UNMET_LOAD' THEN 'Unmet Load'
1104
+ ELSE carr.name
1105
+ END as carrier_name
1106
+ FROM components c
1107
+ JOIN carriers carr ON c.carrier_id = carr.id
1108
+ WHERE c.network_id = ? AND c.component_type IN ('GENERATOR', 'UNMET_LOAD')
1109
+ """, (network_id,))
1110
+ generator_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1111
+
1593
1112
  for gen_name in network.generators.index:
1594
1113
  if gen_name in generator_carriers:
1595
1114
  carrier_name = generator_carriers[gen_name]
@@ -1610,8 +1129,8 @@ class ResultStorage:
1610
1129
 
1611
1130
  if carrier_name in carrier_stats["power_capacity_by_carrier"]:
1612
1131
  carrier_stats["power_capacity_by_carrier"][carrier_name] += capacity_mw
1613
-
1614
- # 2. STORAGE_UNITS - Year-specific power capacity
1132
+
1133
+ # 2. STORAGE_UNITS - Power capacity (MW) + Energy capacity (MWh)
1615
1134
  if hasattr(network, 'storage_units') and not network.storage_units.empty:
1616
1135
  # Get storage unit-carrier mapping
1617
1136
  cursor = conn.execute("""
@@ -1634,16 +1153,25 @@ class ResultStorage:
1634
1153
  is_available = False
1635
1154
 
1636
1155
  if is_available:
1637
- # Use p_nom_opt if available, otherwise p_nom
1156
+ # Power capacity (MW)
1638
1157
  if 'p_nom_opt' in network.storage_units.columns:
1639
- capacity_mw = float(network.storage_units.loc[su_name, 'p_nom_opt'])
1158
+ p_nom_opt = float(network.storage_units.loc[su_name, 'p_nom_opt'])
1640
1159
  else:
1641
- capacity_mw = float(network.storage_units.loc[su_name, 'p_nom']) if 'p_nom' in network.storage_units.columns else 0.0
1160
+ p_nom_opt = float(network.storage_units.loc[su_name, 'p_nom']) if 'p_nom' in network.storage_units.columns else 0.0
1642
1161
 
1643
1162
  if carrier_name in carrier_stats["power_capacity_by_carrier"]:
1644
- carrier_stats["power_capacity_by_carrier"][carrier_name] += capacity_mw
1163
+ carrier_stats["power_capacity_by_carrier"][carrier_name] += p_nom_opt
1164
+
1165
+ # Energy capacity (MWh) using max_hours
1166
+ max_hours = 1.0 # Default
1167
+ if 'max_hours' in network.storage_units.columns:
1168
+ max_hours = float(network.storage_units.loc[su_name, 'max_hours'])
1169
+ energy_capacity_mwh = p_nom_opt * max_hours
1170
+
1171
+ if carrier_name in carrier_stats["energy_capacity_by_carrier"]:
1172
+ carrier_stats["energy_capacity_by_carrier"][carrier_name] += energy_capacity_mwh
1645
1173
 
1646
- # 3. STORES - Year-specific energy capacity
1174
+ # 3. STORES - Energy capacity (MWh) only
1647
1175
  if hasattr(network, 'stores') and not network.stores.empty:
1648
1176
  # Get store-carrier mapping
1649
1177
  cursor = conn.execute("""
@@ -1666,17 +1194,16 @@ class ResultStorage:
1666
1194
  is_available = False
1667
1195
 
1668
1196
  if is_available:
1669
- # Use e_nom_opt if available, otherwise e_nom (energy capacity)
1197
+ # Energy capacity (MWh)
1670
1198
  if 'e_nom_opt' in network.stores.columns:
1671
1199
  capacity_mwh = float(network.stores.loc[store_name, 'e_nom_opt'])
1672
1200
  else:
1673
1201
  capacity_mwh = float(network.stores.loc[store_name, 'e_nom']) if 'e_nom' in network.stores.columns else 0.0
1674
1202
 
1675
- # Add to capacity (stores contribute energy capacity to the general "capacity" metric)
1676
- if carrier_name in carrier_stats["power_capacity_by_carrier"]:
1677
- carrier_stats["power_capacity_by_carrier"][carrier_name] += capacity_mwh
1678
-
1679
- # 4. LINES - Year-specific apparent power capacity
1203
+ if carrier_name in carrier_stats["energy_capacity_by_carrier"]:
1204
+ carrier_stats["energy_capacity_by_carrier"][carrier_name] += capacity_mwh
1205
+
1206
+ # 4. LINES - Apparent power capacity (MVA -> MW)
1680
1207
  if hasattr(network, 'lines') and not network.lines.empty:
1681
1208
  # Get line-carrier mapping
1682
1209
  cursor = conn.execute("""
@@ -1699,19 +1226,18 @@ class ResultStorage:
1699
1226
  is_available = False
1700
1227
 
1701
1228
  if is_available:
1702
- # Use s_nom_opt if available, otherwise s_nom (convert MVA to MW)
1229
+ # Apparent power capacity (MVA -> MW, assume power factor = 1)
1703
1230
  if 's_nom_opt' in network.lines.columns:
1704
1231
  capacity_mva = float(network.lines.loc[line_name, 's_nom_opt'])
1705
1232
  else:
1706
1233
  capacity_mva = float(network.lines.loc[line_name, 's_nom']) if 's_nom' in network.lines.columns else 0.0
1707
1234
 
1708
- # Convert MVA to MW (assume power factor = 1)
1709
- capacity_mw = capacity_mva
1235
+ capacity_mw = capacity_mva # Convert MVA to MW
1710
1236
 
1711
1237
  if carrier_name in carrier_stats["power_capacity_by_carrier"]:
1712
1238
  carrier_stats["power_capacity_by_carrier"][carrier_name] += capacity_mw
1713
1239
 
1714
- # 5. LINKS - Year-specific power capacity
1240
+ # 5. LINKS - Power capacity (MW)
1715
1241
  if hasattr(network, 'links') and not network.links.empty:
1716
1242
  # Get link-carrier mapping
1717
1243
  cursor = conn.execute("""
@@ -1734,7 +1260,7 @@ class ResultStorage:
1734
1260
  is_available = False
1735
1261
 
1736
1262
  if is_available:
1737
- # Use p_nom_opt if available, otherwise p_nom
1263
+ # Power capacity (MW)
1738
1264
  if 'p_nom_opt' in network.links.columns:
1739
1265
  capacity_mw = float(network.links.loc[link_name, 'p_nom_opt'])
1740
1266
  else:
@@ -1743,376 +1269,158 @@ class ResultStorage:
1743
1269
  if carrier_name in carrier_stats["power_capacity_by_carrier"]:
1744
1270
  carrier_stats["power_capacity_by_carrier"][carrier_name] += capacity_mw
1745
1271
 
1746
- # Calculate year-specific emissions (based on year-specific dispatch)
1272
+ logger.info(f"Calculated year {year} carrier statistics:")
1273
+ logger.info(f" Dispatch: {sum(carrier_stats['dispatch_by_carrier'].values()):.2f} MWh")
1274
+ logger.info(f" Emissions: {sum(carrier_stats['emissions_by_carrier'].values()):.2f} tonnes CO2")
1275
+ logger.info(f" Capital cost: {sum(carrier_stats['capital_cost_by_carrier'].values()):.2f} USD")
1276
+ logger.info(f" Operational cost: {sum(carrier_stats['operational_cost_by_carrier'].values()):.2f} USD")
1277
+ logger.info(f" Total system cost: {sum(carrier_stats['total_system_cost_by_carrier'].values()):.2f} USD")
1278
+ logger.info(f" Power capacity: {sum(carrier_stats['power_capacity_by_carrier'].values()):.2f} MW")
1279
+ logger.info(f" Energy capacity: {sum(carrier_stats['energy_capacity_by_carrier'].values()):.2f} MWh")
1280
+
1281
+ return carrier_stats
1282
+
1283
+ except Exception as e:
1284
+ logger.error(f"Failed to calculate year {year} carrier statistics: {e}", exc_info=True)
1285
+ return {
1286
+ "dispatch_by_carrier": {},
1287
+ "power_capacity_by_carrier": {},
1288
+ "energy_capacity_by_carrier": {},
1289
+ "emissions_by_carrier": {},
1290
+ "capital_cost_by_carrier": {},
1291
+ "operational_cost_by_carrier": {},
1292
+ "total_system_cost_by_carrier": {}
1293
+ }
1294
+
1295
+ def _sum_year_based_carrier_statistics(self, conn, network_id: int) -> Dict[str, Any]:
1296
+ """
1297
+ Sum up per-year carrier statistics for accurate multi-year totals.
1298
+ For capacity: take the LAST YEAR (final capacity) instead of maximum.
1299
+ """
1300
+ try:
1301
+ import json
1302
+
1303
+ # Initialize totals
1304
+ totals = {
1305
+ "dispatch_by_carrier": {},
1306
+ "power_capacity_by_carrier": {},
1307
+ "energy_capacity_by_carrier": {},
1308
+ "emissions_by_carrier": {},
1309
+ "capital_cost_by_carrier": {},
1310
+ "operational_cost_by_carrier": {},
1311
+ "total_system_cost_by_carrier": {}
1312
+ }
1313
+
1314
+ # Get all carriers from database
1747
1315
  cursor = conn.execute("""
1748
- SELECT name, co2_emissions
1749
- FROM carriers
1750
- WHERE network_id = ? AND co2_emissions IS NOT NULL
1751
- ORDER BY name
1316
+ SELECT DISTINCT name FROM carriers WHERE network_id = ?
1752
1317
  """, (network_id,))
1318
+ all_carriers = [row[0] for row in cursor.fetchall()]
1753
1319
 
1754
- emission_factors = {}
1755
- for row in cursor.fetchall():
1756
- carrier_name, co2_emissions = row
1757
- emission_factors[carrier_name] = co2_emissions
1758
-
1759
- # Calculate emissions = year_dispatch * emission_factor
1760
- for carrier, dispatch_mwh in carrier_stats["dispatch_by_carrier"].items():
1761
- emission_factor = emission_factors.get(carrier, 0.0)
1762
- emissions = dispatch_mwh * emission_factor
1763
- carrier_stats["emissions_by_carrier"][carrier] = emissions
1320
+ # Initialize all carriers with zero values (including special "Unmet Load" carrier)
1321
+ all_carriers_with_unmet = all_carriers + ['Unmet Load']
1322
+ for carrier in all_carriers_with_unmet:
1323
+ totals["dispatch_by_carrier"][carrier] = 0.0
1324
+ totals["power_capacity_by_carrier"][carrier] = 0.0
1325
+ totals["energy_capacity_by_carrier"][carrier] = 0.0
1326
+ totals["emissions_by_carrier"][carrier] = 0.0
1327
+ totals["capital_cost_by_carrier"][carrier] = 0.0
1328
+ totals["operational_cost_by_carrier"][carrier] = 0.0
1329
+ totals["total_system_cost_by_carrier"][carrier] = 0.0
1330
+
1331
+ # Get all year-based results, ordered by year
1332
+ cursor = conn.execute("""
1333
+ SELECT year, results_json FROM network_solve_results_by_year
1334
+ WHERE network_id = ?
1335
+ ORDER BY year
1336
+ """, (network_id,))
1764
1337
 
1765
- # Calculate year-specific costs (all component types)
1766
- # For multi-period models, costs are complex - capital costs are incurred at build time
1767
- # but operational costs are incurred when generating
1338
+ year_results = cursor.fetchall()
1339
+ logger.info(f"Found {len(year_results)} year-based results to sum for network {network_id}")
1768
1340
 
1769
- # 1. GENERATORS - Year-specific operational and capital costs
1770
- if hasattr(network, 'generators') and not network.generators.empty:
1771
- for gen_name in network.generators.index:
1772
- if gen_name in generator_carriers:
1773
- carrier_name = generator_carriers[gen_name]
1774
-
1775
- # Operational costs = year_dispatch * marginal_cost
1776
- if 'marginal_cost' in network.generators.columns:
1777
- year_dispatch = 0.0
1778
- if hasattr(network, 'generators_t') and hasattr(network.generators_t, 'p'):
1779
- year_generation = self._filter_timeseries_by_year(network.generators_t.p, network.snapshots, year)
1780
- if year_generation is not None and gen_name in year_generation.columns:
1781
- year_weightings = self._get_year_weightings(network, year)
1782
- if year_weightings is not None:
1783
- year_dispatch = float((year_generation[gen_name].values * year_weightings).sum())
1784
- else:
1785
- # Fallback: use all-year weightings if year-specific not available
1786
- weightings = network.snapshot_weightings
1787
- if isinstance(weightings, pd.DataFrame):
1788
- if 'objective' in weightings.columns:
1789
- weighting_values = weightings['objective'].values
1790
- else:
1791
- weighting_values = weightings.iloc[:, 0].values
1792
- else:
1793
- weighting_values = weightings.values
1794
- # Apply weightings to the filtered year data
1795
- if len(weighting_values) == len(year_generation):
1796
- year_dispatch = float((year_generation[gen_name].values * weighting_values).sum())
1797
- else:
1798
- year_dispatch = float(year_generation[gen_name].sum())
1799
- logger.warning(f"Could not apply snapshot weightings for operational cost calc of {gen_name} in year {year} - cost may be incorrect")
1800
-
1801
- marginal_cost = float(network.generators.loc[gen_name, 'marginal_cost'])
1802
- operational_cost = year_dispatch * marginal_cost
1803
-
1804
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1805
- carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1806
-
1807
- # Capital costs - include if asset is operational in this year (matching old solver)
1808
- if 'capital_cost' in network.generators.columns:
1809
- # Check if this generator is operational in this year
1810
- is_operational = True
1811
- if 'build_year' in network.generators.columns:
1812
- build_year = network.generators.loc[gen_name, 'build_year']
1813
- if pd.notna(build_year) and int(build_year) > year:
1814
- is_operational = False # Not built yet
1815
-
1816
- if is_operational:
1817
- capital_cost_per_mw = float(network.generators.loc[gen_name, 'capital_cost'])
1818
-
1819
- if 'p_nom_opt' in network.generators.columns:
1820
- capacity_mw = float(network.generators.loc[gen_name, 'p_nom_opt'])
1821
- elif 'p_nom' in network.generators.columns:
1822
- capacity_mw = float(network.generators.loc[gen_name, 'p_nom'])
1823
- else:
1824
- capacity_mw = 0.0
1825
-
1826
- # Annual capital cost for operational assets (undiscounted)
1827
- annual_capital_cost = capacity_mw * capital_cost_per_mw
1828
-
1829
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
1830
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1341
+ if not year_results:
1342
+ logger.warning(f"No year-based results found for network {network_id}")
1343
+ return totals
1831
1344
 
1832
- # 2. STORAGE_UNITS - Year-specific operational and capital costs
1833
- if hasattr(network, 'storage_units') and not network.storage_units.empty:
1834
- # Get storage unit-carrier mapping
1835
- cursor = conn.execute("""
1836
- SELECT c.name as component_name, carr.name as carrier_name
1837
- FROM components c
1838
- JOIN carriers carr ON c.carrier_id = carr.id
1839
- WHERE c.network_id = ? AND c.component_type = 'STORAGE_UNIT'
1840
- """, (network_id,))
1841
- storage_unit_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1842
-
1843
- for su_name in network.storage_units.index:
1844
- if su_name in storage_unit_carriers:
1845
- carrier_name = storage_unit_carriers[su_name]
1846
-
1847
- # Operational costs = year_discharge * marginal_cost
1848
- if 'marginal_cost' in network.storage_units.columns:
1849
- year_discharge = 0.0
1850
- if hasattr(network, 'storage_units_t') and hasattr(network.storage_units_t, 'p'):
1851
- year_storage = self._filter_timeseries_by_year(network.storage_units_t.p, network.snapshots, year)
1852
- if year_storage is not None and su_name in year_storage.columns:
1853
- year_weightings = self._get_year_weightings(network, year)
1854
- if year_weightings is not None:
1855
- year_discharge = float((year_storage[su_name].clip(lower=0).values * year_weightings).sum())
1856
- else:
1857
- # Fallback: use all-year weightings if year-specific not available
1858
- weightings = network.snapshot_weightings
1859
- if isinstance(weightings, pd.DataFrame):
1860
- if 'objective' in weightings.columns:
1861
- weighting_values = weightings['objective'].values
1862
- else:
1863
- weighting_values = weightings.iloc[:, 0].values
1864
- else:
1865
- weighting_values = weightings.values
1866
- # Apply weightings to the filtered year data
1867
- if len(weighting_values) == len(year_storage):
1868
- year_discharge = float((year_storage[su_name].clip(lower=0).values * weighting_values).sum())
1869
- else:
1870
- year_discharge = float(year_storage[su_name].clip(lower=0).sum())
1871
- logger.warning(f"Could not apply snapshot weightings for operational cost calc of storage unit {su_name} in year {year} - cost may be incorrect")
1872
-
1873
- marginal_cost = float(network.storage_units.loc[su_name, 'marginal_cost'])
1874
- operational_cost = year_discharge * marginal_cost
1875
-
1876
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1877
- carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1878
-
1879
- # Capital costs - include if asset is operational in this year (matching old solver)
1880
- if 'capital_cost' in network.storage_units.columns:
1881
- # Check if this storage unit is operational in this year
1882
- is_operational = True
1883
- if 'build_year' in network.storage_units.columns:
1884
- build_year = network.storage_units.loc[su_name, 'build_year']
1885
- if pd.notna(build_year) and int(build_year) > year:
1886
- is_operational = False # Not built yet
1887
-
1888
- if is_operational:
1889
- capital_cost_per_mw = float(network.storage_units.loc[su_name, 'capital_cost'])
1890
-
1891
- if 'p_nom_opt' in network.storage_units.columns:
1892
- capacity_mw = float(network.storage_units.loc[su_name, 'p_nom_opt'])
1893
- elif 'p_nom' in network.storage_units.columns:
1894
- capacity_mw = float(network.storage_units.loc[su_name, 'p_nom'])
1895
- else:
1896
- capacity_mw = 0.0
1897
-
1898
- # Annual capital cost for operational assets (undiscounted)
1899
- annual_capital_cost = capacity_mw * capital_cost_per_mw
1900
-
1901
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
1902
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1345
+ # For capacity: use the LAST YEAR only (final capacity state)
1346
+ last_year, last_results_json = year_results[-1]
1903
1347
 
1904
- # 3. STORES - Year-specific operational and capital costs
1905
- if hasattr(network, 'stores') and not network.stores.empty:
1906
- # Get store-carrier mapping
1907
- cursor = conn.execute("""
1908
- SELECT c.name as component_name, carr.name as carrier_name
1909
- FROM components c
1910
- JOIN carriers carr ON c.carrier_id = carr.id
1911
- WHERE c.network_id = ? AND c.component_type = 'STORE'
1912
- """, (network_id,))
1913
- store_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1348
+ try:
1349
+ results = json.loads(last_results_json)
1350
+ network_stats = results.get('network_statistics', {})
1351
+ custom_stats = network_stats.get('custom_statistics', {})
1914
1352
 
1915
- for store_name in network.stores.index:
1916
- if store_name in store_carriers:
1917
- carrier_name = store_carriers[store_name]
1918
-
1919
- # Operational costs = year_discharge * marginal_cost
1920
- if 'marginal_cost' in network.stores.columns:
1921
- year_discharge = 0.0
1922
- if hasattr(network, 'stores_t') and hasattr(network.stores_t, 'p'):
1923
- year_stores = self._filter_timeseries_by_year(network.stores_t.p, network.snapshots, year)
1924
- if year_stores is not None and store_name in year_stores.columns:
1925
- year_weightings = self._get_year_weightings(network, year)
1926
- if year_weightings is not None:
1927
- year_discharge = float((year_stores[store_name].clip(lower=0).values * year_weightings).sum())
1928
- else:
1929
- # Fallback: use all-year weightings if year-specific not available
1930
- weightings = network.snapshot_weightings
1931
- if isinstance(weightings, pd.DataFrame):
1932
- if 'objective' in weightings.columns:
1933
- weighting_values = weightings['objective'].values
1934
- else:
1935
- weighting_values = weightings.iloc[:, 0].values
1936
- else:
1937
- weighting_values = weightings.values
1938
- # Apply weightings to the filtered year data
1939
- if len(weighting_values) == len(year_stores):
1940
- year_discharge = float((year_stores[store_name].clip(lower=0).values * weighting_values).sum())
1941
- else:
1942
- year_discharge = float(year_stores[store_name].clip(lower=0).sum())
1943
- logger.warning(f"Could not apply snapshot weightings for operational cost calc of store {store_name} in year {year} - cost may be incorrect")
1944
-
1945
- marginal_cost = float(network.stores.loc[store_name, 'marginal_cost'])
1946
- operational_cost = year_discharge * marginal_cost
1947
-
1948
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
1949
- carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1950
-
1951
- # Capital costs - include if asset is operational in this year (matching old solver)
1952
- if 'capital_cost' in network.stores.columns:
1953
- # Check if this store is operational in this year
1954
- is_operational = True
1955
- if 'build_year' in network.stores.columns:
1956
- build_year = network.stores.loc[store_name, 'build_year']
1957
- if pd.notna(build_year) and int(build_year) > year:
1958
- is_operational = False # Not built yet
1959
-
1960
- if is_operational:
1961
- capital_cost_per_mwh = float(network.stores.loc[store_name, 'capital_cost'])
1962
-
1963
- if 'e_nom_opt' in network.stores.columns:
1964
- capacity_mwh = float(network.stores.loc[store_name, 'e_nom_opt'])
1965
- elif 'e_nom' in network.stores.columns:
1966
- capacity_mwh = float(network.stores.loc[store_name, 'e_nom'])
1967
- else:
1968
- capacity_mwh = 0.0
1969
-
1970
- # Annual capital cost for operational assets (undiscounted)
1971
- annual_capital_cost = capacity_mwh * capital_cost_per_mwh
1972
-
1973
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
1974
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1975
-
1976
- # 4. LINES - Year-specific capital costs (only count if built in this year)
1977
- if hasattr(network, 'lines') and not network.lines.empty:
1978
- # Get line-carrier mapping
1979
- cursor = conn.execute("""
1980
- SELECT c.name as component_name, carr.name as carrier_name
1981
- FROM components c
1982
- JOIN carriers carr ON c.carrier_id = carr.id
1983
- WHERE c.network_id = ? AND c.component_type = 'LINE'
1984
- """, (network_id,))
1985
- line_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1353
+ # Use last year's capacity as the all-year capacity
1354
+ power_capacity_by_carrier = custom_stats.get('power_capacity_by_carrier', {})
1355
+ for carrier, value in power_capacity_by_carrier.items():
1356
+ if carrier in totals["power_capacity_by_carrier"]:
1357
+ totals["power_capacity_by_carrier"][carrier] = float(value or 0)
1986
1358
 
1987
- for line_name in network.lines.index:
1988
- if line_name in line_carriers:
1989
- carrier_name = line_carriers[line_name]
1990
-
1991
- # Capital costs - include if asset is operational in this year (matching old solver)
1992
- if 'capital_cost' in network.lines.columns:
1993
- # Check if this line is operational in this year
1994
- is_operational = True
1995
- if 'build_year' in network.lines.columns:
1996
- build_year = network.lines.loc[line_name, 'build_year']
1997
- if pd.notna(build_year) and int(build_year) > year:
1998
- is_operational = False # Not built yet
1999
-
2000
- if is_operational:
2001
- capital_cost_per_mva = float(network.lines.loc[line_name, 'capital_cost'])
2002
-
2003
- if 's_nom_opt' in network.lines.columns:
2004
- capacity_mva = float(network.lines.loc[line_name, 's_nom_opt'])
2005
- elif 's_nom' in network.lines.columns:
2006
- capacity_mva = float(network.lines.loc[line_name, 's_nom'])
2007
- else:
2008
- capacity_mva = 0.0
2009
-
2010
- # Annual capital cost for operational assets (undiscounted)
2011
- annual_capital_cost = capacity_mva * capital_cost_per_mva
2012
-
2013
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
2014
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
2015
-
2016
- # 5. LINKS - Year-specific operational and capital costs
2017
- if hasattr(network, 'links') and not network.links.empty:
2018
- # Get link-carrier mapping
2019
- cursor = conn.execute("""
2020
- SELECT c.name as component_name, carr.name as carrier_name
2021
- FROM components c
2022
- JOIN carriers carr ON c.carrier_id = carr.id
2023
- WHERE c.network_id = ? AND c.component_type = 'LINK'
2024
- """, (network_id,))
2025
- link_carriers = {row[0]: row[1] for row in cursor.fetchall()}
1359
+ energy_capacity_by_carrier = custom_stats.get('energy_capacity_by_carrier', {})
1360
+ for carrier, value in energy_capacity_by_carrier.items():
1361
+ if carrier in totals["energy_capacity_by_carrier"]:
1362
+ totals["energy_capacity_by_carrier"][carrier] = float(value or 0)
2026
1363
 
2027
- # Operational costs for links (year-specific flow)
2028
- if hasattr(network, 'links_t') and hasattr(network.links_t, 'p0'):
2029
- for link_name in network.links.index:
2030
- if link_name in link_carriers:
2031
- carrier_name = link_carriers[link_name]
2032
-
2033
- # Get marginal cost for this link
2034
- marginal_cost = 0.0
2035
- if 'marginal_cost' in network.links.columns:
2036
- marginal_cost = float(network.links.loc[link_name, 'marginal_cost'])
2037
-
2038
- # Calculate operational cost = year_flow * marginal_cost
2039
- year_flow = 0.0
2040
- if link_name in network.links_t.p0.columns:
2041
- year_links = self._filter_timeseries_by_year(network.links_t.p0, network.snapshots, year)
2042
- if year_links is not None and link_name in year_links.columns:
2043
- year_weightings = self._get_year_weightings(network, year)
2044
- if year_weightings is not None:
2045
- year_flow = float((abs(year_links[link_name]).values * year_weightings).sum())
2046
- else:
2047
- # Fallback: use all-year weightings if year-specific not available
2048
- weightings = network.snapshot_weightings
2049
- if isinstance(weightings, pd.DataFrame):
2050
- if 'objective' in weightings.columns:
2051
- weighting_values = weightings['objective'].values
2052
- else:
2053
- weighting_values = weightings.iloc[:, 0].values
2054
- else:
2055
- weighting_values = weightings.values
2056
- # Apply weightings to the filtered year data
2057
- if len(weighting_values) == len(year_links):
2058
- year_flow = float((abs(year_links[link_name]).values * weighting_values).sum())
2059
- else:
2060
- year_flow = float(abs(year_links[link_name]).sum())
2061
- logger.warning(f"Could not apply snapshot weightings for operational cost calc of link {link_name} in year {year} - cost may be incorrect")
2062
-
2063
- operational_cost = year_flow * marginal_cost
2064
-
2065
- if carrier_name in carrier_stats["operational_cost_by_carrier"]:
2066
- carrier_stats["operational_cost_by_carrier"][carrier_name] += operational_cost
1364
+ logger.info(f"Used last year ({last_year}) capacity as all-year capacity")
2067
1365
 
2068
- # Capital costs for links - only count if built in this year
2069
- for link_name in network.links.index:
2070
- if link_name in link_carriers:
2071
- carrier_name = link_carriers[link_name]
2072
-
2073
- # Capital costs - include if asset is operational in this year (matching old solver)
2074
- if 'capital_cost' in network.links.columns:
2075
- # Check if this link is operational in this year
2076
- is_operational = True
2077
- if 'build_year' in network.links.columns:
2078
- build_year = network.links.loc[link_name, 'build_year']
2079
- if pd.notna(build_year) and int(build_year) > year:
2080
- is_operational = False # Not built yet
2081
-
2082
- if is_operational:
2083
- capital_cost_per_mw = float(network.links.loc[link_name, 'capital_cost'])
2084
-
2085
- if 'p_nom_opt' in network.links.columns:
2086
- capacity_mw = float(network.links.loc[link_name, 'p_nom_opt'])
2087
- elif 'p_nom' in network.links.columns:
2088
- capacity_mw = float(network.links.loc[link_name, 'p_nom'])
2089
- else:
2090
- capacity_mw = 0.0
2091
-
2092
- # Annual capital cost for operational assets (undiscounted)
2093
- annual_capital_cost = capacity_mw * capital_cost_per_mw
2094
-
2095
- if carrier_name in carrier_stats["capital_cost_by_carrier"]:
2096
- carrier_stats["capital_cost_by_carrier"][carrier_name] += annual_capital_cost
1366
+ except Exception as e:
1367
+ logger.error(f"Failed to process last year ({last_year}) results: {e}")
2097
1368
 
2098
- # Calculate total system cost = capital + operational (for this year)
2099
- for carrier in all_carriers:
2100
- capital = carrier_stats["capital_cost_by_carrier"][carrier]
2101
- operational = carrier_stats["operational_cost_by_carrier"][carrier]
2102
- carrier_stats["total_system_cost_by_carrier"][carrier] = capital + operational
1369
+ # For other stats (dispatch, emissions, costs): sum across all years
1370
+ for year, results_json in year_results:
1371
+ try:
1372
+ results = json.loads(results_json)
1373
+ network_stats = results.get('network_statistics', {})
1374
+ custom_stats = network_stats.get('custom_statistics', {})
1375
+
1376
+ # Sum dispatch (energy values - sum across years)
1377
+ dispatch_by_carrier = custom_stats.get('dispatch_by_carrier', {})
1378
+ for carrier, value in dispatch_by_carrier.items():
1379
+ if carrier in totals["dispatch_by_carrier"]:
1380
+ totals["dispatch_by_carrier"][carrier] += float(value or 0)
1381
+
1382
+ # Sum emissions (cumulative across years)
1383
+ emissions_by_carrier = custom_stats.get('emissions_by_carrier', {})
1384
+ for carrier, value in emissions_by_carrier.items():
1385
+ if carrier in totals["emissions_by_carrier"]:
1386
+ totals["emissions_by_carrier"][carrier] += float(value or 0)
1387
+
1388
+ # Sum capital costs (cumulative across years)
1389
+ capital_cost_by_carrier = custom_stats.get('capital_cost_by_carrier', {})
1390
+ for carrier, value in capital_cost_by_carrier.items():
1391
+ if carrier in totals["capital_cost_by_carrier"]:
1392
+ totals["capital_cost_by_carrier"][carrier] += float(value or 0)
1393
+
1394
+ # Sum operational costs (cumulative across years)
1395
+ operational_cost_by_carrier = custom_stats.get('operational_cost_by_carrier', {})
1396
+ for carrier, value in operational_cost_by_carrier.items():
1397
+ if carrier in totals["operational_cost_by_carrier"]:
1398
+ totals["operational_cost_by_carrier"][carrier] += float(value or 0)
1399
+
1400
+ # Sum total system costs (cumulative across years)
1401
+ total_system_cost_by_carrier = custom_stats.get('total_system_cost_by_carrier', {})
1402
+ for carrier, value in total_system_cost_by_carrier.items():
1403
+ if carrier in totals["total_system_cost_by_carrier"]:
1404
+ totals["total_system_cost_by_carrier"][carrier] += float(value or 0)
1405
+
1406
+ except Exception as e:
1407
+ logger.error(f"Failed to process year {year} results: {e}")
1408
+ continue
2103
1409
 
2104
- logger.info(f"Calculated year {year} carrier statistics:")
2105
- logger.info(f" Dispatch: {sum(carrier_stats['dispatch_by_carrier'].values()):.2f} MWh")
2106
- logger.info(f" Power capacity: {sum(carrier_stats['power_capacity_by_carrier'].values()):.2f} MW")
2107
- logger.info(f" Energy capacity: {sum(carrier_stats['energy_capacity_by_carrier'].values()):.2f} MWh")
2108
- logger.info(f" Emissions: {sum(carrier_stats['emissions_by_carrier'].values()):.2f} tCO2")
2109
- logger.info(f" Capital cost: {sum(carrier_stats['capital_cost_by_carrier'].values()):.2f} USD")
2110
- logger.info(f" Operational cost: {sum(carrier_stats['operational_cost_by_carrier'].values()):.2f} USD")
1410
+ logger.info(f"Summed carrier statistics across {len(year_results)} years:")
1411
+ logger.info(f" Final power capacity: {sum(totals['power_capacity_by_carrier'].values()):.2f} MW")
1412
+ logger.info(f" Final energy capacity: {sum(totals['energy_capacity_by_carrier'].values()):.2f} MWh")
1413
+ logger.info(f" Total dispatch: {sum(totals['dispatch_by_carrier'].values()):.2f} MWh")
1414
+ logger.info(f" Total emissions: {sum(totals['emissions_by_carrier'].values()):.2f} tonnes CO2")
1415
+ logger.info(f" Total capital cost: {sum(totals['capital_cost_by_carrier'].values()):.2f} USD")
1416
+ logger.info(f" Total operational cost: {sum(totals['operational_cost_by_carrier'].values()):.2f} USD")
1417
+ logger.info(f" Total system cost: {sum(totals['total_system_cost_by_carrier'].values()):.2f} USD")
2111
1418
 
2112
- return carrier_stats
1419
+ return totals
2113
1420
 
2114
1421
  except Exception as e:
2115
- logger.error(f"Failed to calculate year {year} carrier statistics: {e}", exc_info=True)
1422
+ logger.error(f"Failed to sum year-based carrier statistics: {e}", exc_info=True)
1423
+ # Return empty structure on error
2116
1424
  return {
2117
1425
  "dispatch_by_carrier": {},
2118
1426
  "power_capacity_by_carrier": {},
@@ -2123,10 +1431,46 @@ class ResultStorage:
2123
1431
  "total_system_cost_by_carrier": {}
2124
1432
  }
2125
1433
 
1434
+ def _serialize_results_json(self, solve_result: Dict[str, Any]) -> str:
1435
+ """Serialize solve results to JSON string."""
1436
+ import json
1437
+ try:
1438
+ # Create a clean results dictionary
1439
+ results = {
1440
+ "success": solve_result.get("success", False),
1441
+ "status": solve_result.get("status", "unknown"),
1442
+ "solve_time": solve_result.get("solve_time", 0.0),
1443
+ "objective_value": solve_result.get("objective_value"),
1444
+ "solver_name": solve_result.get("solver_name", "unknown"),
1445
+ "run_id": solve_result.get("run_id"),
1446
+ "network_statistics": solve_result.get("network_statistics", {}),
1447
+ "pypsa_result": solve_result.get("pypsa_result", {})
1448
+ }
1449
+ return json.dumps(results, default=self._json_serializer)
1450
+ except Exception as e:
1451
+ logger.warning(f"Failed to serialize results JSON: {e}")
1452
+ return json.dumps({"error": "serialization_failed"})
1453
+
1454
+ def _serialize_metadata_json(self, solve_result: Dict[str, Any]) -> str:
1455
+ """Serialize solve metadata to JSON string."""
1456
+ import json
1457
+ try:
1458
+ metadata = {
1459
+ "solver_name": solve_result.get("solver_name", "unknown"),
1460
+ "run_id": solve_result.get("run_id"),
1461
+ "multi_period": solve_result.get("multi_period", False),
1462
+ "years": solve_result.get("years", []),
1463
+ "network_name": solve_result.get("network_name"),
1464
+ "num_snapshots": solve_result.get("num_snapshots", 0)
1465
+ }
1466
+ return json.dumps(metadata, default=self._json_serializer)
1467
+ except Exception as e:
1468
+ logger.warning(f"Failed to serialize metadata JSON: {e}")
1469
+ return json.dumps({"error": "serialization_failed"})
1470
+
2126
1471
  def _filter_timeseries_by_year(self, timeseries_df: 'pd.DataFrame', snapshots: 'pd.Index', year: int) -> 'pd.DataFrame':
2127
- """Filter timeseries data by year - copied from solver for consistency"""
1472
+ """Filter timeseries data by year"""
2128
1473
  try:
2129
-
2130
1474
  # Handle MultiIndex case (multi-period optimization)
2131
1475
  if hasattr(snapshots, 'levels'):
2132
1476
  period_values = snapshots.get_level_values(0)
@@ -2149,9 +1493,8 @@ class ResultStorage:
2149
1493
  return None
2150
1494
 
2151
1495
  def _get_year_weightings(self, network: 'pypsa.Network', year: int) -> 'np.ndarray':
2152
- """Get snapshot weightings for a specific year - copied from solver for consistency"""
1496
+ """Get snapshot weightings for a specific year"""
2153
1497
  try:
2154
-
2155
1498
  # Filter snapshot weightings by year
2156
1499
  if hasattr(network.snapshots, 'levels'):
2157
1500
  period_values = network.snapshots.get_level_values(0)
@@ -2185,6 +1528,30 @@ class ResultStorage:
2185
1528
  logger.error(f"Failed to get year weightings for year {year}: {e}")
2186
1529
  return None
2187
1530
 
1531
+ def _calculate_total_demand(self, network: 'pypsa.Network') -> float:
1532
+ """Calculate total demand from loads in the network"""
1533
+ try:
1534
+ total_demand = 0.0
1535
+
1536
+ # Calculate demand from loads
1537
+ if hasattr(network, 'loads_t') and hasattr(network.loads_t, 'p'):
1538
+ # Apply snapshot weightings to convert MW to MWh
1539
+ weightings = network.snapshot_weightings
1540
+ if isinstance(weightings, pd.DataFrame):
1541
+ if 'objective' in weightings.columns:
1542
+ weighting_values = weightings['objective'].values
1543
+ else:
1544
+ weighting_values = weightings.iloc[:, 0].values
1545
+ else:
1546
+ weighting_values = weightings.values
1547
+
1548
+ total_demand = float((network.loads_t.p.values * weighting_values[:, None]).sum())
1549
+
1550
+ return total_demand
1551
+
1552
+ except Exception as e:
1553
+ logger.error(f"Failed to calculate total demand: {e}")
1554
+ return 0.0
2188
1555
 
2189
1556
  def _json_serializer(self, obj):
2190
1557
  """Convert numpy/pandas types to JSON serializable types"""
@@ -2204,4 +1571,4 @@ class ResultStorage:
2204
1571
  elif hasattr(obj, 'item'): # Handle numpy scalars
2205
1572
  return obj.item()
2206
1573
  else:
2207
- raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
1574
+ raise TypeError(f"Object of type {type(obj)} is not JSON serializable")