Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# FlexiMORP environment variables
# Copy to .env and replace dummy values with real keys (never commit .env)

# External data APIs (optional — synthetic demo data works without keys)
# NREL: https://developer.nrel.gov
NREL_API_KEY=your-nrel-api-key-here

# NASA: https://api.nasa.gov (DEMO_KEY works for limited testing)
NASA_API_KEY=DEMO_KEY

# Copernicus CDS: https://climate.copernicus.eu
COPERNICUS_API_KEY=your-copernicus-cds-api-key-here

# OpenWeather: https://openweathermap.org/api
OPENWEATHER_API_KEY=your-openweather-api-key-here

# Cache directory for API responses (optional)
FLEXIMORP_CACHE_DIR=./cache
25 changes: 25 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,28 @@
.vscode/
.claude/
.worktrees/
.cursor/

# Local environment and tooling
.env
.venv/
.pytest_cache/
__pycache__/
*.py[cod]
.mypy_cache/
.ruff_cache/
*.egg-info/

# Jupyter
.ipynb_checkpoints/
*.nbconvert.ipynb

# API response cache and notebook artefacts (keep directory placeholders)
cache/
notebooks/**/outputs/*
!notebooks/**/outputs/.gitkeep

# Editor / OS noise
*.swp
*~
.idea/
3 changes: 2 additions & 1 deletion fleximorpv2/baseline_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,8 @@ def _evaluate_platform_performance(self,
capex=economic_performance['capex'],
opex=economic_performance['opex'],
revenue=economic_performance['revenue'],
project_life=self.config.economic['project_lifetime']
project_life=self.config.economic['project_lifetime'],
annual_energy=tech_performance.get('annual_energy', 0.0)
)

# Combine all performance metrics
Expand Down
19 changes: 15 additions & 4 deletions fleximorpv2/models/economics.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class RevenueModel:
capacity_payments: float
total_annual_revenue: float
electricity_price: float
annual_energy: float
generation_profile: np.ndarray


Expand Down Expand Up @@ -224,11 +225,16 @@ def _calculate_revenues(self, tech_performance: Dict[str, float]) -> Dict[str, f
"""Calculate project revenues."""

# Annual energy generation
annual_energy = tech_performance.get('annual_energy', 0.0) # MWh/year
generation_profile = np.asarray(tech_performance.get('generation_profile', []), dtype=float)
if generation_profile.size > 0:
annual_energy = float(np.sum(generation_profile)) # MWh/year
else:
annual_energy = tech_performance.get('annual_energy', 0.0) # MWh/year
generation_profile = np.full(8760, annual_energy / 8760 if annual_energy > 0 else 0.0)

# Electricity revenue
electricity_price = self.config.economic.get('electricity_price', 0.10) # £/kWh
electricity_revenue = annual_energy * electricity_price * 1000 # Convert MWh to kWh
electricity_revenue = float(np.sum(generation_profile) * electricity_price * 1000)

# Subsidy revenue (technology-specific)
subsidy_revenue = self._calculate_subsidy_revenue(tech_performance)
Expand All @@ -247,11 +253,13 @@ def _calculate_revenues(self, tech_performance: Dict[str, float]) -> Dict[str, f
capacity_payments=capacity_payments,
total_annual_revenue=total_annual_revenue,
electricity_price=electricity_price,
generation_profile=np.ones(8760) # Placeholder hourly profile
annual_energy=annual_energy,
generation_profile=generation_profile
)

return {
'revenue': total_annual_revenue,
'annual_energy': annual_energy,
'electricity_revenue': electricity_revenue,
'subsidy_revenue': subsidy_revenue,
'capacity_payments': capacity_payments
Expand Down Expand Up @@ -434,7 +442,10 @@ def calculate_sensitivity_to_electricity_price(self,

for price in prices:
# Recalculate revenue at new price
tech_performance = {'annual_energy': self.revenue_model.electricity_revenue / (original_price * 1000)}
tech_performance = {
'annual_energy': self.revenue_model.annual_energy,
'generation_profile': self.revenue_model.generation_profile
}

# Update electricity price in config temporarily
original_config_price = self.config.economic.get('electricity_price', 0.10)
Expand Down
75 changes: 38 additions & 37 deletions fleximorpv2/models/technologies.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class TechnologyPerformance:
degradation_rate: float # per year
space_requirement: float # m²/MW
load_requirement: float # tonnes/MW
generation_profile: Optional[np.ndarray] = None


@dataclass
Expand Down Expand Up @@ -114,6 +115,7 @@ def calculate_performance(self,
total_annual_energy = 0.0
total_space_requirement = 0.0
total_load_requirement = 0.0
combined_generation_profile = None

# Calculate performance for each enabled technology
for tech_name in self.config.get_enabled_technologies():
Expand All @@ -130,6 +132,14 @@ def calculate_performance(self,
total_annual_energy += performance.annual_energy
total_space_requirement += performance.space_requirement
total_load_requirement += performance.load_requirement

if performance.generation_profile is not None:
if combined_generation_profile is None:
combined_generation_profile = np.array(performance.generation_profile, dtype=float)
else:
combined_generation_profile = combined_generation_profile + np.array(
performance.generation_profile, dtype=float
)

# Add technology-specific metrics
combined_metrics.update({
Expand All @@ -147,7 +157,8 @@ def calculate_performance(self,
'capacity_factor': combined_capacity_factor,
'total_space_requirement': total_space_requirement,
'total_load_requirement': total_load_requirement,
'technology_diversity': len(self.performance_data)
'technology_diversity': len(self.performance_data),
'generation_profile': combined_generation_profile
})

return combined_metrics
Expand Down Expand Up @@ -195,17 +206,12 @@ def _calculate_wind_performance(self,
# Apply wake losses
power_outputs *= (1 - params['wake_loss'])

# Calculate capacity factor
average_power = np.mean(power_outputs) # MW
capacity_factor = average_power / capacity if capacity > 0 else 0.0

# Calculate annual energy
annual_energy = capacity_factor * capacity * 8760 # MWh/year

# Apply availability factor
# Apply availability factor and convert hourly fraction to hourly energy
technical_params = tech_config.technical_params if tech_config.technical_params is not None else {}
availability = technical_params.get('availability', 0.95)
annual_energy *= availability
generation_profile = power_outputs * capacity * availability
annual_energy = float(np.sum(generation_profile))
capacity_factor = annual_energy / (capacity * 8760) if capacity > 0 else 0.0

return TechnologyPerformance(
capacity=capacity,
Expand All @@ -214,7 +220,8 @@ def _calculate_wind_performance(self,
availability=availability,
degradation_rate=0.002, # 0.2% per year for wind
space_requirement=capacity * params['area_per_mw'],
load_requirement=capacity * params['load_per_mw']
load_requirement=capacity * params['load_per_mw'],
generation_profile=generation_profile
)

def _wind_power_curve(self, wind_speed: float, params: Dict[str, float]) -> float:
Expand Down Expand Up @@ -299,15 +306,10 @@ def _calculate_solar_performance(self,
# Clip to maximum capacity
power_outputs = np.clip(power_outputs, 0, 1.0)

# Calculate capacity factor
capacity_factor = np.mean(power_outputs)

# Calculate annual energy
annual_energy = capacity_factor * capacity * 8760 # MWh/year

# Apply availability factor
availability = tech_config.technical_params.get('availability', 0.98)
annual_energy *= availability
generation_profile = power_outputs * capacity * availability
annual_energy = float(np.sum(generation_profile))
capacity_factor = annual_energy / (capacity * 8760) if capacity > 0 else 0.0

# Get space and load requirements based on deployment type
area_per_mw = deployment_config.get('area_per_mw', params['area_per_mw'])
Expand All @@ -324,7 +326,8 @@ def _calculate_solar_performance(self,
availability=availability,
degradation_rate=params['degradation_rate'],
space_requirement=capacity * area_per_mw,
load_requirement=capacity * load_per_mw
load_requirement=capacity * load_per_mw,
generation_profile=generation_profile
)

def _calculate_wave_performance(self,
Expand Down Expand Up @@ -355,15 +358,10 @@ def _calculate_wave_performance(self,
if max_power > 0:
power_outputs = power_outputs / max_power

# Calculate capacity factor
capacity_factor = np.mean(power_outputs)

# Calculate annual energy
annual_energy = capacity_factor * capacity * 8760 # MWh/year

# Apply availability factor
availability = params['availability_factor']
annual_energy *= availability
generation_profile = power_outputs * capacity * availability
annual_energy = float(np.sum(generation_profile))
capacity_factor = annual_energy / (capacity * 8760) if capacity > 0 else 0.0

return TechnologyPerformance(
capacity=capacity,
Expand All @@ -372,7 +370,8 @@ def _calculate_wave_performance(self,
availability=availability,
degradation_rate=0.01, # 1% per year for wave (higher due to harsh environment)
space_requirement=capacity * params['area_per_mw'],
load_requirement=capacity * params['load_per_mw']
load_requirement=capacity * params['load_per_mw'],
generation_profile=generation_profile
)

def _calculate_wave_power_density(self, height: float, period: float) -> float:
Expand Down Expand Up @@ -520,12 +519,10 @@ def _calculate_river_flow_performance(self,
else:
seasonal_cf_modifier = 1.0

capacity_factor = base_cf * seasonal_cf_modifier
annual_energy = capacity_factor * capacity * 8760

# Apply availability
availability = 0.85 # Lower than solar/wind due to maintenance complexity
annual_energy *= availability
capacity_factor = base_cf * seasonal_cf_modifier * availability
annual_energy = capacity_factor * capacity * 8760
generation_profile = np.full(8760, annual_energy / 8760 if annual_energy > 0 else 0.0)

# Get technical parameters
tech_params = hydro_config.get('technical_params', {})
Expand All @@ -539,7 +536,8 @@ def _calculate_river_flow_performance(self,
availability=availability,
degradation_rate=0.015, # Higher due to underwater environment
space_requirement=capacity * area_per_mw,
load_requirement=capacity * load_per_mw
load_requirement=capacity * load_per_mw,
generation_profile=generation_profile
)

def _calculate_tidal_performance(self,
Expand All @@ -558,13 +556,15 @@ def _calculate_tidal_performance(self,
availability=0.0,
degradation_rate=0.02,
space_requirement=capacity * 500,
load_requirement=capacity * 200
load_requirement=capacity * 200,
generation_profile=np.zeros(8760)
)

# Tidal-specific calculations would go here
# For now, return basic performance
capacity_factor = 0.35
annual_energy = capacity_factor * capacity * 8760 * 0.90
generation_profile = np.full(8760, annual_energy / 8760 if annual_energy > 0 else 0.0)

return TechnologyPerformance(
capacity=capacity,
Expand All @@ -573,7 +573,8 @@ def _calculate_tidal_performance(self,
availability=0.90,
degradation_rate=0.02,
space_requirement=capacity * 500,
load_requirement=capacity * 200
load_requirement=capacity * 200,
generation_profile=generation_profile
)

def get_technology_requirements(self, design_vars: Dict[str, Any]) -> Dict[str, Dict[str, float]]:
Expand Down
5 changes: 3 additions & 2 deletions fleximorpv2/uncertainty_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ def _evaluate_scenarios(self,
if 'electricity_price' in scenario:
# Recalculate revenue with new price
annual_energy = tech_performance.get('annual_energy', 0)
economic_performance['revenue'] = annual_energy * scenario['electricity_price']
economic_performance['revenue'] = annual_energy * scenario['electricity_price'] * 1000
elif 'electricity_price_multiplier' in scenario:
economic_performance['revenue'] *= scenario['electricity_price_multiplier']

Expand All @@ -347,7 +347,8 @@ def _evaluate_scenarios(self,
capex=economic_performance['capex'],
opex=economic_performance['opex'],
revenue=economic_performance['revenue'],
project_life=self.config.economic['project_lifetime']
project_life=self.config.economic['project_lifetime'],
annual_energy=tech_performance.get('annual_energy', 0.0)
)

# Combine results
Expand Down
10 changes: 7 additions & 3 deletions fleximorpv2/utils/financial.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ def calculate_metrics(self,
opex: Annual operating expenditure
revenue: Annual revenue
project_life: Project lifetime in years
**kwargs: Additional parameters
**kwargs: Additional parameters, including required annual_energy
in MWh/year for LCOE calculation

Returns:
Dictionary with financial metrics
Expand All @@ -94,8 +95,11 @@ def calculate_metrics(self,
npv = self.calculate_npv(cash_flow.net_cash_flows, discount_rate, capex)
irr = self.calculate_irr(cash_flow.net_cash_flows, capex)

# LCOE calculation
annual_energy = kwargs.get('annual_energy', revenue / 0.1) # Assume £100/MWh if not provided
# LCOE calculation. Energy cannot be inferred reliably from total
# revenue because revenue may include subsidies or capacity payments.
annual_energy = kwargs.get('annual_energy')
if annual_energy is None:
raise ValueError("annual_energy is required to calculate LCOE")
lcoe = self.calculate_lcoe(capex, opex, annual_energy, discount_rate, project_life)

# Other metrics
Expand Down
Loading