diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d8de508 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index f50053e..d20351e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/fleximorpv2/baseline_optimization.py b/fleximorpv2/baseline_optimization.py index 1ee1303..26c947a 100644 --- a/fleximorpv2/baseline_optimization.py +++ b/fleximorpv2/baseline_optimization.py @@ -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 diff --git a/fleximorpv2/models/economics.py b/fleximorpv2/models/economics.py index 29bad1e..75aaa13 100644 --- a/fleximorpv2/models/economics.py +++ b/fleximorpv2/models/economics.py @@ -38,6 +38,7 @@ class RevenueModel: capacity_payments: float total_annual_revenue: float electricity_price: float + annual_energy: float generation_profile: np.ndarray @@ -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) @@ -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 @@ -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) diff --git a/fleximorpv2/models/technologies.py b/fleximorpv2/models/technologies.py index 1f5467b..45aee40 100644 --- a/fleximorpv2/models/technologies.py +++ b/fleximorpv2/models/technologies.py @@ -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 @@ -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(): @@ -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({ @@ -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 @@ -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, @@ -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: @@ -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']) @@ -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, @@ -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, @@ -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: @@ -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', {}) @@ -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, @@ -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, @@ -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]]: diff --git a/fleximorpv2/uncertainty_analysis.py b/fleximorpv2/uncertainty_analysis.py index d789c25..98062b7 100644 --- a/fleximorpv2/uncertainty_analysis.py +++ b/fleximorpv2/uncertainty_analysis.py @@ -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'] @@ -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 diff --git a/fleximorpv2/utils/financial.py b/fleximorpv2/utils/financial.py index 3e90af2..92292ce 100644 --- a/fleximorpv2/utils/financial.py +++ b/fleximorpv2/utils/financial.py @@ -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 @@ -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 diff --git a/notebooks/audit/01_financial_primitives.ipynb b/notebooks/audit/01_financial_primitives.ipynb new file mode 100644 index 0000000..b914ad8 --- /dev/null +++ b/notebooks/audit/01_financial_primitives.ipynb @@ -0,0 +1,205 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 01 \u2014 Financial primitives\n", + "\n", + "**Code under test:** `fleximorpv2/utils/financial.py`\n", + "\n", + "**Purpose:** Confirm NPV, IRR, LCOE, and payback match hand calculations before trusting pipeline results.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Golden-case LCOE\n", + "\n", + "**Run the next cell** after filling in `EXPECTED` from your hand calculation.\n", + "\n", + "Use this toy project (flat inputs, no tax complexity):\n", + "\n", + "| Input | Value |\n", + "|-------|-------|\n", + "| CAPEX | \u00a31,000,000 |\n", + "| OPEX | \u00a350,000 / yr (flat) |\n", + "| Annual energy | 10,000 MWh / yr (flat, no degradation first) |\n", + "| Discount rate | 8% |\n", + "| Project life | 20 years |\n", + "\n", + "**Formula:** LCOE = (CAPEX + \u03a3 OPEX/(1+r)^t) / \u03a3 Energy/(1+r)^t\n", + "\n", + "**Pass if:** `assert_close` within 0.01% of your spreadsheet.\n", + "\n", + "**Then:** repeat with degradation enabled in `calculate_lcoe` (hard-coded 0.5%/yr) \u2014 LCOE should increase slightly.\n", + "\n", + "**Also inspect:** `calculate_metrics()` \u2014 if `annual_energy` is omitted it guesses from `revenue / 0.1`. List any callers that hit this path." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "from fleximorpv2.utils.financial import FinancialCalculator\n", + "\n", + "config = load_config(\"alaska\")\n", + "calc = FinancialCalculator(config)\n", + "\n", + "# Hand-compute LCOE for the table above, then set:\n", + "EXPECTED = None # \u00a3/MWh\n", + "\n", + "capex = 1_000_000\n", + "opex = 50_000\n", + "annual_energy = 10_000 # MWh/yr\n", + "discount_rate = 0.08\n", + "project_life = 20\n", + "\n", + "lcoe = calc.calculate_lcoe(capex, opex, annual_energy, discount_rate, project_life)\n", + "\n", + "if EXPECTED is None:\n", + " raise ValueError(\"Set EXPECTED from your hand calculation, then re-run\")\n", + "assert_close(lcoe, EXPECTED, label=\"LCOE golden case\")\n", + "print(f\"LCOE = {lcoe:.2f} \u00a3/MWh\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 \u2014 NPV and IRR on a 3-year toy cash flow\n", + "\n", + "**Run the next cell** with a simple cash flow you can replicate in Excel (`=NPV`, `=IRR`).\n", + "\n", + "**Pass if:** NPV sign is correct (\u2212CAPEX at year 0, inflows discounted from year 1) and IRR within 0.1 pp of spreadsheet." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cash_flows = np.array([120_000, 130_000, 140_000]) # years 1\u20133\n", + "initial_investment = 300_000\n", + "discount_rate = 0.08\n", + "\n", + "npv = calc.calculate_npv(cash_flows, discount_rate, initial_investment)\n", + "irr = calc.calculate_irr(cash_flows, initial_investment)\n", + "\n", + "print(f\"NPV = {npv:,.0f}\")\n", + "print(f\"IRR = {irr:.2%}\")\n", + "# TODO: set EXPECTED_NPV / EXPECTED_IRR from spreadsheet and assert_close" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3 \u2014 Sensitivity (direction checks)\n", + "\n", + "**Run the next cell** to plot LCOE vs CAPEX and vs annual energy.\n", + "\n", + "**Pass if:** LCOE rises when CAPEX rises or energy falls (monotonic, no sign inversions)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "capex_range = np.linspace(0.5e6, 2.0e6, 20)\n", + "lcoe_vs_capex = [\n", + " calc.calculate_lcoe(c, 50_000, 10_000, 0.08, 20) for c in capex_range\n", + "]\n", + "\n", + "energy_range = np.linspace(5_000, 20_000, 20)\n", + "lcoe_vs_energy = [\n", + " calc.calculate_lcoe(1e6, 50_000, e, 0.08, 20) for e in energy_range\n", + "]\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(10, 4))\n", + "axes[0].plot(capex_range / 1e6, lcoe_vs_capex)\n", + "axes[0].set(xlabel=\"CAPEX (\u00a3M)\", ylabel=\"LCOE (\u00a3/MWh)\", title=\"Higher CAPEX \u2192 higher LCOE\")\n", + "axes[1].plot(energy_range, lcoe_vs_energy)\n", + "axes[1].set(xlabel=\"Annual energy (MWh/yr)\", ylabel=\"LCOE (\u00a3/MWh)\", title=\"More energy \u2192 lower LCOE\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/02_wind_power_curve.ipynb b/notebooks/audit/02_wind_power_curve.ipynb new file mode 100644 index 0000000..6762b65 --- /dev/null +++ b/notebooks/audit/02_wind_power_curve.ipynb @@ -0,0 +1,151 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 02 \u2014 Wind power curve\n", + "\n", + "**Code under test:** `fleximorpv2/models/technologies.py`\n", + "\n", + "**Purpose:** Verify `_wind_power_curve` and capacity-factor logic for offshore wind.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Power curve shape\n", + "\n", + "**Run the next cell** to print normalised power at key wind speeds (Blyth config defaults: cut-in 3 m/s, rated 12 m/s, cut-out 25 m/s).\n", + "\n", + "**Pass if:**\n", + "- 0 power below cut-in and above cut-out\n", + "- Cubic rise between cut-in and rated (~0.125 at mid-point speed 7.5 m/s)\n", + "- 1.0 (rated) from rated speed to cut-out" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "from fleximorpv2.models.technologies import TechnologyModel\n", + "\n", + "config = load_config(\"blyth\")\n", + "model = TechnologyModel(config)\n", + "params = model.technology_params[\"wind\"]\n", + "\n", + "for speed in [0, 2, 3, 7.5, 12, 20, 25, 26]:\n", + " p = model._wind_power_curve(speed, params)\n", + " print(f\"{speed:4.1f} m/s \u2192 {p:.3f} (normalised)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 \u2014 Constant-wind capacity factor\n", + "\n", + "**Run the next cell** with synthetic `ResourceData` (constant 8 m/s, 8760 hours).\n", + "\n", + "**Pass if:** CF is between 0.25 and 0.55 after wake loss (15%) and availability; `annual_energy \u2248 CF \u00d7 capacity \u00d7 8760` MWh/yr." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.models.technologies import ResourceData\n", + "\n", + "n = 8760\n", + "resource = ResourceData(\n", + " wind_speed=np.full(n, 8.0),\n", + " solar_irradiance=np.zeros(n),\n", + " wave_height=np.zeros(n),\n", + " wave_period=np.zeros(n),\n", + " temperature=np.full(n, 10.0),\n", + " timestamps=np.arange(n),\n", + ")\n", + "design = {\"wind_capacity\": 1.0, \"solar_capacity\": 0.0, \"wave_capacity\": 0.0}\n", + "perf = model.calculate_performance(design, resource)\n", + "\n", + "print(perf)\n", + "assert_energy_balance(perf[\"annual_energy\"], perf[\"total_capacity\"], perf[\"capacity_factor\"])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/03_solar_and_degradation.ipynb b/notebooks/audit/03_solar_and_degradation.ipynb new file mode 100644 index 0000000..ca0b9a6 --- /dev/null +++ b/notebooks/audit/03_solar_and_degradation.ipynb @@ -0,0 +1,131 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 03 \u2014 Solar output\n", + "\n", + "**Code under test:** `fleximorpv2/models/technologies.py`\n", + "\n", + "**Purpose:** Verify irradiance, temperature derating, and Alaska ice/seasonal modifiers.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Temperature derating direction\n", + "\n", + "**Run the next cell** with identical irradiance but two temperatures (0\u00b0C vs 35\u00b0C).\n", + "\n", + "**Pass if:** colder run produces **more** annual energy (temp coefficient is \u22120.004/\u00b0C)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "from fleximorpv2.models.technologies import TechnologyModel, ResourceData\n", + "\n", + "config = load_config(\"alaska\")\n", + "model = TechnologyModel(config)\n", + "n = 8760\n", + "irradiance = np.full(n, 200.0) # W/m\u00b2\n", + "\n", + "\n", + "def solar_energy(temp_c: float) -> float:\n", + " resource = ResourceData(\n", + " wind_speed=np.zeros(n),\n", + " solar_irradiance=irradiance,\n", + " wave_height=np.zeros(n),\n", + " wave_period=np.zeros(n),\n", + " temperature=np.full(n, temp_c),\n", + " timestamps=np.arange(n),\n", + " )\n", + " design = {\"wind_capacity\": 0.0, \"solar_capacity\": 1.0, \"hydro_capacity\": 0.0}\n", + " return model.calculate_performance(design, resource)[\"annual_energy\"]\n", + "\n", + "\n", + "cold = solar_energy(0.0)\n", + "hot = solar_energy(35.0)\n", + "print(f\"Cold (0\u00b0C): {cold:.0f} MWh/yr\")\n", + "print(f\"Hot (35\u00b0C): {hot:.0f} MWh/yr\")\n", + "assert cold > hot, \"Expected colder site to outperform hot site at same irradiance\"\n", + "print(\"PASS temperature derating direction\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/04_wave_and_hydro.ipynb b/notebooks/audit/04_wave_and_hydro.ipynb new file mode 100644 index 0000000..f69d906 --- /dev/null +++ b/notebooks/audit/04_wave_and_hydro.ipynb @@ -0,0 +1,130 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 04 \u2014 Wave and hydro\n", + "\n", + "**Code under test:** `fleximorpv2/models/technologies.py`\n", + "\n", + "**Purpose:** Verify site-appropriate technologies: wave on Blyth, hydro on Alaska/Eastport.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Enabled technologies match each site\n", + "\n", + "**Run the next cell.**\n", + "\n", + "**Pass if:** Blyth includes `wave`; Alaska and Eastport include `hydro` (river/tidal) and not wave as primary." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "\n", + "for site in (\"blyth\", \"alaska\", \"eastport\"):\n", + " techs = load_config(site).get_enabled_technologies()\n", + " print(f\"{site}: {techs}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 \u2014 Wave / hydro performance smoke test\n", + "\n", + "**Run the next cell** after building synthetic resource arrays.\n", + "\n", + "**Pass if:** outputs are finite, non-negative, and CF stays in [0, 1]." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO: build ResourceData with wave_height/period for Blyth\n", + "# and river flow proxy for Alaska hydro; print TechnologyPerformance\n", + "raise NotImplementedError(\"Add wave (Blyth) and hydro (Alaska) golden cases\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/05_platform_sizing.ipynb b/notebooks/audit/05_platform_sizing.ipynb new file mode 100644 index 0000000..4b670fc --- /dev/null +++ b/notebooks/audit/05_platform_sizing.ipynb @@ -0,0 +1,119 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 05 \u2014 Platform sizing\n", + "\n", + "**Code under test:** `fleximorpv2/models/platform.py`\n", + "\n", + "**Purpose:** Check platform area, load limits, and depth-driven platform type.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Design platform from sample variables\n", + "\n", + "**Run the next cell** with a fixed design dict.\n", + "\n", + "**Pass if:** `design_platform` returns specs without error; footprint scales with area; document that `load_utilization=0.8` in platform.py is a placeholder, not measured load." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "from fleximorpv2.models.platform import PlatformModel\n", + "\n", + "config = load_config(\"blyth\")\n", + "platform = PlatformModel(config)\n", + "design_vars = {\n", + " \"wind_capacity\": 50.0,\n", + " \"solar_capacity\": 10.0,\n", + " \"wave_capacity\": 5.0,\n", + " \"platform_area\": 50_000,\n", + " \"water_depth\": 35,\n", + " \"distance_to_shore\": 5,\n", + "}\n", + "tech_perf = {\"total_load_requirement\": 8000, \"total_space_requirement\": 40_000}\n", + "specs = platform.design_platform(design_vars, tech_perf)\n", + "print(specs)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/06_cost_stack.ipynb b/notebooks/audit/06_cost_stack.ipynb new file mode 100644 index 0000000..f1f7686 --- /dev/null +++ b/notebooks/audit/06_cost_stack.ipynb @@ -0,0 +1,120 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 06 \u2014 Cost stack\n", + "\n", + "**Code under test:** `fleximorpv2/models/economics.py`\n", + "\n", + "**Purpose:** Reconcile CAPEX/OPEX components and flag placeholder revenue logic.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Cost breakdown sums\n", + "\n", + "**Run the next cell** with a fixed design + performance dict.\n", + "\n", + "**Pass if:**\n", + "- `total_capex` equals sum of technology + platform + installation + grid + development lines\n", + "- Grid cost grows when `distance_to_shore` increases\n", + "- Note: `generation_profile=np.ones(8760)` is a placeholder \u2014 revenue timing is not hourly-realistic" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "from fleximorpv2.models.economics import EconomicModel\n", + "\n", + "config = load_config(\"eastport\")\n", + "economics = EconomicModel(config)\n", + "design_vars = {\"platform_area\": 10_000, \"water_depth\": 30, \"distance_to_shore\": 2}\n", + "tech_perf = {\n", + " \"total_technology_capex\": 2e7,\n", + " \"total_technology_opex\": 4e5,\n", + " \"annual_energy\": 50_000,\n", + "}\n", + "metrics = economics.calculate_economics(design_vars, tech_perf)\n", + "print({k: v for k, v in metrics.items() if \"capex\" in k or \"opex\" in k or k in (\"capex\", \"opex\")})\n", + "# TODO: assert component sum == metrics[\"capex\"]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/07_uncertainty_sampling.ipynb b/notebooks/audit/07_uncertainty_sampling.ipynb new file mode 100644 index 0000000..c953c60 --- /dev/null +++ b/notebooks/audit/07_uncertainty_sampling.ipynb @@ -0,0 +1,120 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 07 \u2014 Uncertainty sampling\n", + "\n", + "**Code under test:** `fleximorpv2/uncertainty_analysis.py`\n", + "\n", + "**Purpose:** Compare Monte Carlo vs Latin Hypercube and confirm scenarios move LCOE.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Sampling method comparison\n", + "\n", + "**Run the next cell** (small `n_runs=50` for speed).\n", + "\n", + "**Pass if:**\n", + "- Both methods complete without error\n", + "- LCOE distribution has non-zero spread when uncertainty params are enabled\n", + "- LHS mean LCOE is in the same ballpark as MC (not identical, but same order of magnitude)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "from fleximorpv2.baseline_optimization import BaselineOptimization\n", + "from fleximorpv2.uncertainty_analysis import UncertaintyAnalysis\n", + "\n", + "config = load_config(\"alaska\")\n", + "config.uncertainty[\"monte_carlo_runs\"] = 50\n", + "baseline = BaselineOptimization(config).optimize(\"production\", 200_000, method=\"scipy\")\n", + "analyzer = UncertaintyAnalysis(config)\n", + "\n", + "comparison = analyzer.compare_sampling_methods(\n", + " baseline_design=baseline.optimal_design,\n", + " n_runs=50,\n", + ")\n", + "print(comparison[\"convergence_analysis\"])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/08_optimization_sanity.ipynb b/notebooks/audit/08_optimization_sanity.ipynb new file mode 100644 index 0000000..332eb01 --- /dev/null +++ b/notebooks/audit/08_optimization_sanity.ipynb @@ -0,0 +1,123 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 08 \u2014 Baseline optimization\n", + "\n", + "**Code under test:** `fleximorpv2/baseline_optimization.py`\n", + "\n", + "**Purpose:** Smoke-test the optimizer on Alaska and inspect constraints.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Run baseline optimize\n", + "\n", + "**Run the next cell.**\n", + "\n", + "Uses `target_type='production'`, `target_value=200_000`. The code comments say **kWh**; `TechnologyModel` reports **MWh/yr** \u2014 compare printed `annual_energy` to the target and note whether units align.\n", + "\n", + "**Pass if:**\n", + "- Optimisation completes without exception\n", + "- LCOE > 0\n", + "- Total capacity \u2264 config max (Alaska: 2 MW)\n", + "- CapEx within budget if config sets one" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "from fleximorpv2.baseline_optimization import BaselineOptimization\n", + "\n", + "config = load_config(\"alaska\")\n", + "config.uncertainty[\"monte_carlo_runs\"] = 10\n", + "opt = BaselineOptimization(config)\n", + "results = opt.optimize(\"production\", 200_000, method=\"scipy\")\n", + "\n", + "assert_positive(results.financial_metrics[\"lcoe\"], label=\"LCOE\")\n", + "print(\"Target production (as passed):\", 200_000)\n", + "print(\"Annual energy (engine output):\", results.technical_metrics[\"annual_energy\"])\n", + "print(\"Financial:\", results.financial_metrics)\n", + "print(\"Technical:\", results.technical_metrics)\n", + "print(\"Capacities:\", results.technology_capacities)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/09_cross_module_consistency.ipynb b/notebooks/audit/09_cross_module_consistency.ipynb new file mode 100644 index 0000000..437252e --- /dev/null +++ b/notebooks/audit/09_cross_module_consistency.ipynb @@ -0,0 +1,145 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 09 \u2014 Cross-module consistency\n", + "\n", + "**Code under test:** `baseline pipeline + webapp/app.py`\n", + "\n", + "**Purpose:** Check that energy, LCOE, and capacity metrics agree across layers.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Energy balance on baseline result\n", + "\n", + "**Run the next cell.**\n", + "\n", + "**Pass if:** `annual_energy \u2248 capacity_factor \u00d7 total_capacity \u00d7 8760` (within 5%).\n", + "\n", + "If this fails with `annual_energy=0`, the bug is in technology/economic coupling \u2014 log it in Obsidian findings." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config\n", + "from fleximorpv2.baseline_optimization import BaselineOptimization\n", + "\n", + "config = load_config(\"alaska\")\n", + "config.uncertainty[\"monte_carlo_runs\"] = 10\n", + "results = BaselineOptimization(config).optimize(\"production\", 200_000, method=\"scipy\")\n", + "\n", + "assert_energy_balance(\n", + " results.technical_metrics[\"annual_energy\"],\n", + " results.technical_metrics[\"total_capacity\"],\n", + " results.technical_metrics[\"capacity_factor\"],\n", + ")\n", + "assert_cf_bounds(results.technical_metrics[\"capacity_factor\"])\n", + "print(\"Financial:\", results.financial_metrics)\n", + "print(\"Technical:\", results.technical_metrics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 \u2014 Manual LCOE cross-check\n", + "\n", + "**Run the next cell** after computing LCOE from CAPEX, OPEX, and energy independently.\n", + "\n", + "**Pass if:** manual LCOE within ~10% of `results.financial_metrics['lcoe']` (wider tolerance until cost stack is validated in notebook 06)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# TODO: pull capex/opex/energy from results or cost_breakdown and compare LCOE\n", + "manual_lcoe = None # \u00a3/MWh from notebook 06 logic\n", + "reported = results.financial_metrics[\"lcoe\"]\n", + "print(f\"Reported LCOE: {reported:.2f} \u00a3/MWh\")\n", + "if manual_lcoe is not None:\n", + " assert_close(reported, manual_lcoe, label=\"LCOE cross-check\", rtol=0.10)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/audit/_audit_helpers.py b/notebooks/audit/_audit_helpers.py new file mode 100644 index 0000000..4595d6a --- /dev/null +++ b/notebooks/audit/_audit_helpers.py @@ -0,0 +1,88 @@ +"""Shared helpers for FlexiMORP calculation audit notebooks.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np + + +def _find_repo_root() -> Path: + for candidate in [Path.cwd(), *Path.cwd().parents]: + if (candidate / "fleximorpv2").is_dir() and (candidate / "notebooks").is_dir(): + return candidate + raise RuntimeError( + "Could not find fleximorp-project root. " + "Open Jupyter from the repo or notebooks/ directory." + ) + + +REPO_ROOT = _find_repo_root() +AUDIT_DIR = REPO_ROOT / "notebooks" / "audit" +OUTPUT_DIR = AUDIT_DIR / "outputs" +SITE_OUTPUT_DIR = REPO_ROOT / "notebooks" / "sites" / "outputs" + +for path in (REPO_ROOT, AUDIT_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +OUTPUT_DIR.mkdir(exist_ok=True) +SITE_OUTPUT_DIR.mkdir(exist_ok=True) + + +def reload_fleximorp() -> None: + """Clear cached fleximorpv2 modules when iterating on library code.""" + to_remove = [key for key in sys.modules if key.startswith("fleximorpv2")] + for key in to_remove: + del sys.modules[key] + + +def assert_close(actual: float, expected: float, *, label: str, rtol: float = 1e-4) -> None: + """Assert two floats match within relative tolerance.""" + if not np.isfinite(actual) or not np.isfinite(expected): + raise AssertionError(f"{label}: non-finite values actual={actual}, expected={expected}") + if expected == 0: + if abs(actual) > rtol: + raise AssertionError(f"{label}: expected 0, got {actual}") + return + rel_err = abs(actual - expected) / abs(expected) + if rel_err > rtol: + raise AssertionError( + f"{label}: actual={actual:.6g}, expected={expected:.6g}, rel_err={rel_err:.2e}" + ) + print(f"PASS {label}: {actual:.6g} ≈ {expected:.6g}") + + +def assert_energy_balance( + annual_energy_mwh: float, + capacity_mw: float, + capacity_factor: float, + *, + label: str = "energy balance", + tolerance: float = 0.05, +) -> None: + """annual_energy ≈ CF × capacity × 8760.""" + if capacity_mw <= 0: + raise AssertionError(f"{label}: capacity must be positive, got {capacity_mw}") + expected = capacity_factor * capacity_mw * 8760.0 + rel_err = abs(annual_energy_mwh - expected) / expected if expected else float("inf") + if rel_err > tolerance: + raise AssertionError( + f"{label}: annual_energy={annual_energy_mwh}, " + f"CF×cap×8760={expected}, rel_err={rel_err:.2%}" + ) + print(f"PASS {label}: {annual_energy_mwh:.1f} MWh/yr") + + +def assert_cf_bounds(capacity_factor: float, *, label: str = "capacity factor") -> None: + if not 0 <= capacity_factor <= 1.01: + raise AssertionError(f"{label}: CF={capacity_factor} outside [0, 1]") + print(f"PASS {label}: {capacity_factor:.3f}") + + +def assert_positive(value: float, *, label: str) -> None: + if value <= 0: + raise AssertionError(f"{label}: expected > 0, got {value}") + print(f"PASS {label}: {value:.6g}") diff --git a/notebooks/audit/_generate_scaffold.py b/notebooks/audit/_generate_scaffold.py new file mode 100644 index 0000000..ad2aac9 --- /dev/null +++ b/notebooks/audit/_generate_scaffold.py @@ -0,0 +1,647 @@ +#!/usr/bin/env python3 +"""Generate FlexiMORP audit notebook scaffolds.""" + +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent.parent + + +def md(text: str) -> dict: + text = text.replace("\\n", "\n") + return {"cell_type": "markdown", "metadata": {}, "source": text.splitlines(keepends=True)} + + +def code(text: str) -> dict: + return { + "cell_type": "code", + "execution_count": None, + "metadata": {}, + "outputs": [], + "source": text.splitlines(keepends=True), + } + + +def notebook(*cells) -> dict: + return { + "cells": list(cells), + "metadata": { + "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}, + "language_info": {"name": "python", "version": "3.11.0"}, + }, + "nbformat": 4, + "nbformat_minor": 5, + } + + +NOTEBOOK_INTRO = ( + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** — " + "what it does, what to inspect in the output, and what counts as a pass.\n\n" + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). " + "These notebooks are the lab workbook, not the checklist." +) + + +SETUP = code( + """import sys +import warnings +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +warnings.filterwarnings("error", category=RuntimeWarning) + + +def _repo_root() -> Path: + for candidate in [Path.cwd(), *Path.cwd().parents]: + if (candidate / "fleximorpv2").is_dir(): + return candidate + raise RuntimeError( + "Could not find fleximorp-project root. " + "Open Jupyter from the repo or a notebooks/ subdirectory." + ) + + +_repo = _repo_root() +_audit = _repo / "notebooks" / "audit" +for path in (_repo, _audit): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from _audit_helpers import ( + REPO_ROOT, + OUTPUT_DIR, + SITE_OUTPUT_DIR, + reload_fleximorp, + assert_close, + assert_energy_balance, + assert_cf_bounds, + assert_positive, +) + +reload_fleximorp() +np.random.seed(42) +print(f"Repo root: {REPO_ROOT}") +print(f"Audit outputs: {OUTPUT_DIR}") +print(f"Site outputs: {SITE_OUTPUT_DIR}")""" +) + + +def audit_title(heading: str, module: str, purpose: str) -> str: + return ( + f"# {heading}\n\n" + f"**Code under test:** `{module}`\n\n" + f"**Purpose:** {purpose}\n\n" + f"{NOTEBOOK_INTRO}" + ) + + +AUDIT_NOTEBOOKS = { + "01_financial_primitives.ipynb": { + "title": audit_title( + "01 — Financial primitives", + "fleximorpv2/utils/financial.py", + "Confirm NPV, IRR, LCOE, and payback match hand calculations before trusting pipeline results.", + ), + "sections": [ + ( + "## Step 1 — Golden-case LCOE\n\n" + "**Run the next cell** after filling in `EXPECTED` from your hand calculation.\n\n" + "Use this toy project (flat inputs, no tax complexity):\n\n" + "| Input | Value |\n" + "|-------|-------|\n" + "| CAPEX | £1,000,000 |\n" + "| OPEX | £50,000 / yr (flat) |\n" + "| Annual energy | 10,000 MWh / yr (flat, no degradation first) |\n" + "| Discount rate | 8% |\n" + "| Project life | 20 years |\n\n" + "**Formula:** LCOE = (CAPEX + Σ OPEX/(1+r)^t) / Σ Energy/(1+r)^t\n\n" + "**Pass if:** `assert_close` within 0.01% of your spreadsheet.\n\n" + "**Then:** repeat with degradation enabled in `calculate_lcoe` (hard-coded 0.5%/yr) — LCOE should increase slightly.\n\n" + "**Also inspect:** `calculate_metrics()` — if `annual_energy` is omitted it guesses from `revenue / 0.1`. List any callers that hit this path.", + code( + """from fleximorpv2.config import load_config +from fleximorpv2.utils.financial import FinancialCalculator + +config = load_config("alaska") +calc = FinancialCalculator(config) + +# Hand-compute LCOE for the table above, then set: +EXPECTED = None # £/MWh + +capex = 1_000_000 +opex = 50_000 +annual_energy = 10_000 # MWh/yr +discount_rate = 0.08 +project_life = 20 + +lcoe = calc.calculate_lcoe(capex, opex, annual_energy, discount_rate, project_life) + +if EXPECTED is None: + raise ValueError("Set EXPECTED from your hand calculation, then re-run") +assert_close(lcoe, EXPECTED, label="LCOE golden case") +print(f"LCOE = {lcoe:.2f} £/MWh")""" + ), + ), + ( + "## Step 2 — NPV and IRR on a 3-year toy cash flow\n\n" + "**Run the next cell** with a simple cash flow you can replicate in Excel (`=NPV`, `=IRR`).\n\n" + "**Pass if:** NPV sign is correct (−CAPEX at year 0, inflows discounted from year 1) and IRR within 0.1 pp of spreadsheet.", + code( + """cash_flows = np.array([120_000, 130_000, 140_000]) # years 1–3 +initial_investment = 300_000 +discount_rate = 0.08 + +npv = calc.calculate_npv(cash_flows, discount_rate, initial_investment) +irr = calc.calculate_irr(cash_flows, initial_investment) + +print(f"NPV = {npv:,.0f}") +print(f"IRR = {irr:.2%}") +# TODO: set EXPECTED_NPV / EXPECTED_IRR from spreadsheet and assert_close""" + ), + ), + ( + "## Step 3 — Sensitivity (direction checks)\n\n" + "**Run the next cell** to plot LCOE vs CAPEX and vs annual energy.\n\n" + "**Pass if:** LCOE rises when CAPEX rises or energy falls (monotonic, no sign inversions).", + code( + """capex_range = np.linspace(0.5e6, 2.0e6, 20) +lcoe_vs_capex = [ + calc.calculate_lcoe(c, 50_000, 10_000, 0.08, 20) for c in capex_range +] + +energy_range = np.linspace(5_000, 20_000, 20) +lcoe_vs_energy = [ + calc.calculate_lcoe(1e6, 50_000, e, 0.08, 20) for e in energy_range +] + +fig, axes = plt.subplots(1, 2, figsize=(10, 4)) +axes[0].plot(capex_range / 1e6, lcoe_vs_capex) +axes[0].set(xlabel="CAPEX (£M)", ylabel="LCOE (£/MWh)", title="Higher CAPEX → higher LCOE") +axes[1].plot(energy_range, lcoe_vs_energy) +axes[1].set(xlabel="Annual energy (MWh/yr)", ylabel="LCOE (£/MWh)", title="More energy → lower LCOE") +plt.tight_layout() +plt.show()""" + ), + ), + ], + }, + "02_wind_power_curve.ipynb": { + "title": audit_title( + "02 — Wind power curve", + "fleximorpv2/models/technologies.py", + "Verify `_wind_power_curve` and capacity-factor logic for offshore wind.", + ), + "sections": [ + ( + "## Step 1 — Power curve shape\n\n" + "**Run the next cell** to print normalised power at key wind speeds (Blyth config defaults: cut-in 3 m/s, rated 12 m/s, cut-out 25 m/s).\n\n" + "**Pass if:**\n" + "- 0 power below cut-in and above cut-out\n" + "- Cubic rise between cut-in and rated (~0.125 at mid-point speed 7.5 m/s)\n" + "- 1.0 (rated) from rated speed to cut-out", + code( + """from fleximorpv2.config import load_config +from fleximorpv2.models.technologies import TechnologyModel + +config = load_config("blyth") +model = TechnologyModel(config) +params = model.technology_params["wind"] + +for speed in [0, 2, 3, 7.5, 12, 20, 25, 26]: + p = model._wind_power_curve(speed, params) + print(f"{speed:4.1f} m/s → {p:.3f} (normalised)")""" + ), + ), + ( + "## Step 2 — Constant-wind capacity factor\n\n" + "**Run the next cell** with synthetic `ResourceData` (constant 8 m/s, 8760 hours).\n\n" + "**Pass if:** CF is between 0.25 and 0.55 after wake loss (15%) and availability; " + "`annual_energy ≈ CF × capacity × 8760` MWh/yr.", + code( + """from fleximorpv2.models.technologies import ResourceData + +n = 8760 +resource = ResourceData( + wind_speed=np.full(n, 8.0), + solar_irradiance=np.zeros(n), + wave_height=np.zeros(n), + wave_period=np.zeros(n), + temperature=np.full(n, 10.0), + timestamps=np.arange(n), +) +design = {"wind_capacity": 1.0, "solar_capacity": 0.0, "wave_capacity": 0.0} +perf = model.calculate_performance(design, resource) + +print(perf) +assert_energy_balance(perf["annual_energy"], perf["total_capacity"], perf["capacity_factor"])""" + ), + ), + ], + }, + "03_solar_and_degradation.ipynb": { + "title": audit_title( + "03 — Solar output", + "fleximorpv2/models/technologies.py", + "Verify irradiance, temperature derating, and Alaska ice/seasonal modifiers.", + ), + "sections": [ + ( + "## Step 1 — Temperature derating direction\n\n" + "**Run the next cell** with identical irradiance but two temperatures (0°C vs 35°C).\n\n" + "**Pass if:** colder run produces **more** annual energy (temp coefficient is −0.004/°C).", + code( + """from fleximorpv2.config import load_config +from fleximorpv2.models.technologies import TechnologyModel, ResourceData + +config = load_config("alaska") +model = TechnologyModel(config) +n = 8760 +irradiance = np.full(n, 200.0) # W/m² + + +def solar_energy(temp_c: float) -> float: + resource = ResourceData( + wind_speed=np.zeros(n), + solar_irradiance=irradiance, + wave_height=np.zeros(n), + wave_period=np.zeros(n), + temperature=np.full(n, temp_c), + timestamps=np.arange(n), + ) + design = {"wind_capacity": 0.0, "solar_capacity": 1.0, "hydro_capacity": 0.0} + return model.calculate_performance(design, resource)["annual_energy"] + + +cold = solar_energy(0.0) +hot = solar_energy(35.0) +print(f"Cold (0°C): {cold:.0f} MWh/yr") +print(f"Hot (35°C): {hot:.0f} MWh/yr") +assert cold > hot, "Expected colder site to outperform hot site at same irradiance" +print("PASS temperature derating direction")""" + ), + ), + ], + }, + "04_wave_and_hydro.ipynb": { + "title": audit_title( + "04 — Wave and hydro", + "fleximorpv2/models/technologies.py", + "Verify site-appropriate technologies: wave on Blyth, hydro on Alaska/Eastport.", + ), + "sections": [ + ( + "## Step 1 — Enabled technologies match each site\n\n" + "**Run the next cell.**\n\n" + "**Pass if:** Blyth includes `wave`; Alaska and Eastport include `hydro` (river/tidal) and not wave as primary.", + code( + """from fleximorpv2.config import load_config + +for site in ("blyth", "alaska", "eastport"): + techs = load_config(site).get_enabled_technologies() + print(f"{site}: {techs}")""" + ), + ), + ( + "## Step 2 — Wave / hydro performance smoke test\n\n" + "**Run the next cell** after building synthetic resource arrays.\n\n" + "**Pass if:** outputs are finite, non-negative, and CF stays in [0, 1].", + code( + """# TODO: build ResourceData with wave_height/period for Blyth +# and river flow proxy for Alaska hydro; print TechnologyPerformance +raise NotImplementedError("Add wave (Blyth) and hydro (Alaska) golden cases")""" + ), + ), + ], + }, + "05_platform_sizing.ipynb": { + "title": audit_title( + "05 — Platform sizing", + "fleximorpv2/models/platform.py", + "Check platform area, load limits, and depth-driven platform type.", + ), + "sections": [ + ( + "## Step 1 — Design platform from sample variables\n\n" + "**Run the next cell** with a fixed design dict.\n\n" + "**Pass if:** `design_platform` returns specs without error; footprint scales with area; " + "document that `load_utilization=0.8` in platform.py is a placeholder, not measured load.", + code( + """from fleximorpv2.config import load_config +from fleximorpv2.models.platform import PlatformModel + +config = load_config("blyth") +platform = PlatformModel(config) +design_vars = { + "wind_capacity": 50.0, + "solar_capacity": 10.0, + "wave_capacity": 5.0, + "platform_area": 50_000, + "water_depth": 35, + "distance_to_shore": 5, +} +tech_perf = {"total_load_requirement": 8000, "total_space_requirement": 40_000} +specs = platform.design_platform(design_vars, tech_perf) +print(specs)""" + ), + ), + ], + }, + "06_cost_stack.ipynb": { + "title": audit_title( + "06 — Cost stack", + "fleximorpv2/models/economics.py", + "Reconcile CAPEX/OPEX components and flag placeholder revenue logic.", + ), + "sections": [ + ( + "## Step 1 — Cost breakdown sums\n\n" + "**Run the next cell** with a fixed design + performance dict.\n\n" + "**Pass if:**\n" + "- `total_capex` equals sum of technology + platform + installation + grid + development lines\n" + "- Grid cost grows when `distance_to_shore` increases\n" + "- Note: `generation_profile=np.ones(8760)` is a placeholder — revenue timing is not hourly-realistic", + code( + """from fleximorpv2.config import load_config +from fleximorpv2.models.economics import EconomicModel + +config = load_config("eastport") +economics = EconomicModel(config) +design_vars = {"platform_area": 10_000, "water_depth": 30, "distance_to_shore": 2} +tech_perf = { + "total_technology_capex": 2e7, + "total_technology_opex": 4e5, + "annual_energy": 50_000, +} +metrics = economics.calculate_economics(design_vars, tech_perf) +print({k: v for k, v in metrics.items() if "capex" in k or "opex" in k or k in ("capex", "opex")}) +# TODO: assert component sum == metrics["capex"]""" + ), + ), + ], + }, + "07_uncertainty_sampling.ipynb": { + "title": audit_title( + "07 — Uncertainty sampling", + "fleximorpv2/uncertainty_analysis.py", + "Compare Monte Carlo vs Latin Hypercube and confirm scenarios move LCOE.", + ), + "sections": [ + ( + "## Step 1 — Sampling method comparison\n\n" + "**Run the next cell** (small `n_runs=50` for speed).\n\n" + "**Pass if:**\n" + "- Both methods complete without error\n" + "- LCOE distribution has non-zero spread when uncertainty params are enabled\n" + "- LHS mean LCOE is in the same ballpark as MC (not identical, but same order of magnitude)", + code( + """from fleximorpv2.config import load_config +from fleximorpv2.baseline_optimization import BaselineOptimization +from fleximorpv2.uncertainty_analysis import UncertaintyAnalysis + +config = load_config("alaska") +config.uncertainty["monte_carlo_runs"] = 50 +baseline = BaselineOptimization(config).optimize("production", 200_000, method="scipy") +analyzer = UncertaintyAnalysis(config) + +comparison = analyzer.compare_sampling_methods( + baseline_design=baseline.optimal_design, + n_runs=50, +) +print(comparison["convergence_analysis"])""" + ), + ), + ], + }, + "08_optimization_sanity.ipynb": { + "title": audit_title( + "08 — Baseline optimization", + "fleximorpv2/baseline_optimization.py", + "Smoke-test the optimizer on Alaska and inspect constraints.", + ), + "sections": [ + ( + "## Step 1 — Run baseline optimize\n\n" + "**Run the next cell.**\n\n" + "Uses `target_type='production'`, `target_value=200_000`. " + "The code comments say **kWh**; `TechnologyModel` reports **MWh/yr** — compare printed " + "`annual_energy` to the target and note whether units align.\n\n" + "**Pass if:**\n" + "- Optimisation completes without exception\n" + "- LCOE > 0\n" + "- Total capacity ≤ config max (Alaska: 2 MW)\n" + "- CapEx within budget if config sets one", + code( + """from fleximorpv2.config import load_config +from fleximorpv2.baseline_optimization import BaselineOptimization + +config = load_config("alaska") +config.uncertainty["monte_carlo_runs"] = 10 +opt = BaselineOptimization(config) +results = opt.optimize("production", 200_000, method="scipy") + +assert_positive(results.financial_metrics["lcoe"], label="LCOE") +print("Target production (as passed):", 200_000) +print("Annual energy (engine output):", results.technical_metrics["annual_energy"]) +print("Financial:", results.financial_metrics) +print("Technical:", results.technical_metrics) +print("Capacities:", results.technology_capacities)""" + ), + ), + ], + }, + "09_cross_module_consistency.ipynb": { + "title": audit_title( + "09 — Cross-module consistency", + "baseline pipeline + webapp/app.py", + "Check that energy, LCOE, and capacity metrics agree across layers.", + ), + "sections": [ + ( + "## Step 1 — Energy balance on baseline result\n\n" + "**Run the next cell.**\n\n" + "**Pass if:** `annual_energy ≈ capacity_factor × total_capacity × 8760` (within 5%).\n\n" + "If this fails with `annual_energy=0`, the bug is in technology/economic coupling — log it in Obsidian findings.", + code( + """from fleximorpv2.config import load_config +from fleximorpv2.baseline_optimization import BaselineOptimization + +config = load_config("alaska") +config.uncertainty["monte_carlo_runs"] = 10 +results = BaselineOptimization(config).optimize("production", 200_000, method="scipy") + +assert_energy_balance( + results.technical_metrics["annual_energy"], + results.technical_metrics["total_capacity"], + results.technical_metrics["capacity_factor"], +) +assert_cf_bounds(results.technical_metrics["capacity_factor"]) +print("Financial:", results.financial_metrics) +print("Technical:", results.technical_metrics)""" + ), + ), + ( + "## Step 2 — Manual LCOE cross-check\n\n" + "**Run the next cell** after computing LCOE from CAPEX, OPEX, and energy independently.\n\n" + "**Pass if:** manual LCOE within ~10% of `results.financial_metrics['lcoe']` (wider tolerance until cost stack is validated in notebook 06).", + code( + """# TODO: pull capex/opex/energy from results or cost_breakdown and compare LCOE +manual_lcoe = None # £/MWh from notebook 06 logic +reported = results.financial_metrics["lcoe"] +print(f"Reported LCOE: {reported:.2f} £/MWh") +if manual_lcoe is not None: + assert_close(reported, manual_lcoe, label="LCOE cross-check", rtol=0.10)""" + ), + ), + ], + }, +} + +SITE_DETAILS = { + "alaska": { + "place": "Igiugig, Alaska (riverine)", + "techs": "wind, solar, hydro", + "resource_note": "Expect synthetic wind, solar irradiance, and temperature. No wave array.", + "context": "Remote river community — check arctic cost premiums and low capacity limits (2 MW max).", + }, + "blyth": { + "place": "Blyth, UK North Sea (offshore)", + "techs": "wind, solar, wave", + "resource_note": "Expect wind, solar, **and wave** statistics from the data loader.", + "context": "Commercial offshore scale — capacity bounds up to hundreds of MW in config.", + }, + "eastport": { + "place": "Eastport, Maine (nearshore)", + "techs": "wind, solar, hydro (tidal / ORPC TidGen)", + "resource_note": "Wave is disabled in config; hydro represents tidal stream.", + "context": "Fishing-industry constraints — verify tidal/hydro CF is plausible, not wave.", + }, +} + + +def site_notebook(meta: dict) -> dict: + site = meta["site"] + info = SITE_DETAILS[site] + return notebook( + md( + f"# {info['place']} — site pipeline\n\n" + f"**Config:** `data/{site}/config.yaml`\n\n" + f"**Technologies:** {info['techs']}\n\n" + f"{info['context']}\n\n" + f"{NOTEBOOK_INTRO}\n\n" + "**Important:** Do not catch errors and substitute dummy numbers (unlike `notebooks/alaska_analysis.ipynb`)." + ), + SETUP, + md( + f"## Step 1 — Load and validate config\n\n" + f"**Run the next cell** to load `data/{site}/config.yaml`.\n\n" + f"**Pass if:** `validate_config()` succeeds and enabled techs are `{info['techs']}`." + ), + code( + f"""from fleximorpv2.config import load_config, validate_config + +config = load_config("{site}") +validate_config(config) +print(f"Site: {{config.name}}") +print(f"Coords: {{config.coordinates}}") +print(f"Techs: {{config.get_enabled_technologies()}}") +print(f"Discount rate: {{config.economic.get('discount_rate')}}") +print(f"Max capacity (config): {{config.optimization.get('constraints', {{}}).get('max_total_capacity', 'not set')}}")""" + ), + md( + f"## Step 2 — Inspect synthetic resource data\n\n" + f"**Run the next cell.** {info['resource_note']}\n\n" + f"**Pass if:** means are finite and physically plausible for the site (print values and judge — no API keys required)." + ), + code( + """from fleximorpv2.utils.data_loader import APIDataLoader + +loader = APIDataLoader(config) +resource = loader.load_weather_data( + coordinates=config.coordinates, + technologies=config.get_enabled_technologies(), +) +print("Wind mean (m/s):", float(resource.wind_speed.mean())) +print("Solar mean (W/m²):", float(resource.solar_irradiance.mean())) +if len(resource.wave_height): + print("Wave Hs mean (m):", float(resource.wave_height.mean())) +print("Temperature mean (°C):", float(resource.temperature.mean()))""" + ), + md( + "## Step 3 — Baseline optimization\n\n" + "**Run the next cell.**\n\n" + "Compare `target_value=200_000` to printed `annual_energy` — confirm whether the engine treats both as kWh or MWh (see notebook 08).\n\n" + "**Pass if:** optimisation completes, LCOE > 0, and capacity respects config limits." + ), + code( + """from fleximorpv2.baseline_optimization import BaselineOptimization + +config.uncertainty["monte_carlo_runs"] = 10 +results = BaselineOptimization(config).optimize("production", 200_000, method="scipy") + +assert_positive(results.financial_metrics["lcoe"], label="LCOE") +print("Target:", 200_000, "| Annual energy:", results.technical_metrics["annual_energy"]) +print("Financial:", results.financial_metrics) +print("Technical:", results.technical_metrics) +print("Capacities:", results.technology_capacities) +optimal_design = results.optimal_design""" + ), + md( + "## Step 4 — Uncertainty (Monte Carlo)\n\n" + "**Run the next cell** with `n_runs=50` for a quick pass; re-run locally with 500 for convergence.\n\n" + "**Pass if:** mean LCOE is same order of magnitude as baseline LCOE." + ), + code( + """from fleximorpv2.uncertainty_analysis import UncertaintyAnalysis + +analyzer = UncertaintyAnalysis(config) +uncertainty = analyzer.analyze_uncertainty( + baseline_design=optimal_design, + sampling_method="monte_carlo", + reoptimize=False, +) +print("Baseline LCOE:", results.financial_metrics["lcoe"]) +print("Mean LCOE under uncertainty:", uncertainty.mean_performance.get("lcoe")) +print("Risk metrics:", uncertainty.risk_metrics)""" + ), + md( + "## Step 5 — Optional extensions\n\n" + "Run only after Steps 1–4 pass:\n" + "- `fleximorpv2/flexible_design.py` — real options value should be ≥ 0\n" + "- `fleximorpv2/sensitivity_analysis.py` — parameter rankings stable across two runs\n" + "- Save plots to `notebooks/sites/outputs/`" + ), + code("# Optional — flexible design and sensitivity\n"), + ) + + +SITE_NOTEBOOKS = { + "alaska_pipeline.ipynb": {"site": "alaska"}, + "blyth_pipeline.ipynb": {"site": "blyth"}, + "eastport_pipeline.ipynb": {"site": "eastport"}, +} + + +def main() -> None: + audit_dir = REPO / "notebooks" / "audit" + sites_dir = REPO / "notebooks" / "sites" + + for filename, meta in AUDIT_NOTEBOOKS.items(): + cells = [md(meta["title"]), SETUP] + for section_md, section_code in meta["sections"]: + cells.extend([md(section_md), section_code]) + path = audit_dir / filename + path.write_text(json.dumps(notebook(*cells), indent=1) + "\n") + print(f"Wrote {path.relative_to(REPO)}") + + for filename, meta in SITE_NOTEBOOKS.items(): + path = sites_dir / filename + path.write_text(json.dumps(site_notebook(meta), indent=1) + "\n") + print(f"Wrote {path.relative_to(REPO)}") + + +if __name__ == "__main__": + main() diff --git a/notebooks/audit/outputs/.gitkeep b/notebooks/audit/outputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/notebooks/sites/alaska_pipeline.ipynb b/notebooks/sites/alaska_pipeline.ipynb new file mode 100644 index 0000000..234644b --- /dev/null +++ b/notebooks/sites/alaska_pipeline.ipynb @@ -0,0 +1,230 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Igiugig, Alaska (riverine) \u2014 site pipeline\n", + "\n", + "**Config:** `data/alaska/config.yaml`\n", + "\n", + "**Technologies:** wind, solar, hydro\n", + "\n", + "Remote river community \u2014 check arctic cost premiums and low capacity limits (2 MW max).\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist.\n", + "\n", + "**Important:** Do not catch errors and substitute dummy numbers (unlike `notebooks/alaska_analysis.ipynb`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Load and validate config\n", + "\n", + "**Run the next cell** to load `data/alaska/config.yaml`.\n", + "\n", + "**Pass if:** `validate_config()` succeeds and enabled techs are `wind, solar, hydro`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config, validate_config\n", + "\n", + "config = load_config(\"alaska\")\n", + "validate_config(config)\n", + "print(f\"Site: {config.name}\")\n", + "print(f\"Coords: {config.coordinates}\")\n", + "print(f\"Techs: {config.get_enabled_technologies()}\")\n", + "print(f\"Discount rate: {config.economic.get('discount_rate')}\")\n", + "print(f\"Max capacity (config): {config.optimization.get('constraints', {}).get('max_total_capacity', 'not set')}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 \u2014 Inspect synthetic resource data\n", + "\n", + "**Run the next cell.** Expect synthetic wind, solar irradiance, and temperature. No wave array.\n", + "\n", + "**Pass if:** means are finite and physically plausible for the site (print values and judge \u2014 no API keys required)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.utils.data_loader import APIDataLoader\n", + "\n", + "loader = APIDataLoader(config)\n", + "resource = loader.load_weather_data(\n", + " coordinates=config.coordinates,\n", + " technologies=config.get_enabled_technologies(),\n", + ")\n", + "print(\"Wind mean (m/s):\", float(resource.wind_speed.mean()))\n", + "print(\"Solar mean (W/m\u00b2):\", float(resource.solar_irradiance.mean()))\n", + "if len(resource.wave_height):\n", + " print(\"Wave Hs mean (m):\", float(resource.wave_height.mean()))\n", + "print(\"Temperature mean (\u00b0C):\", float(resource.temperature.mean()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3 \u2014 Baseline optimization\n", + "\n", + "**Run the next cell.**\n", + "\n", + "Compare `target_value=200_000` to printed `annual_energy` \u2014 confirm whether the engine treats both as kWh or MWh (see notebook 08).\n", + "\n", + "**Pass if:** optimisation completes, LCOE > 0, and capacity respects config limits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.baseline_optimization import BaselineOptimization\n", + "\n", + "config.uncertainty[\"monte_carlo_runs\"] = 10\n", + "results = BaselineOptimization(config).optimize(\"production\", 200_000, method=\"scipy\")\n", + "\n", + "assert_positive(results.financial_metrics[\"lcoe\"], label=\"LCOE\")\n", + "print(\"Target:\", 200_000, \"| Annual energy:\", results.technical_metrics[\"annual_energy\"])\n", + "print(\"Financial:\", results.financial_metrics)\n", + "print(\"Technical:\", results.technical_metrics)\n", + "print(\"Capacities:\", results.technology_capacities)\n", + "optimal_design = results.optimal_design" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4 \u2014 Uncertainty (Monte Carlo)\n", + "\n", + "**Run the next cell** with `n_runs=50` for a quick pass; re-run locally with 500 for convergence.\n", + "\n", + "**Pass if:** mean LCOE is same order of magnitude as baseline LCOE." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.uncertainty_analysis import UncertaintyAnalysis\n", + "\n", + "analyzer = UncertaintyAnalysis(config)\n", + "uncertainty = analyzer.analyze_uncertainty(\n", + " baseline_design=optimal_design,\n", + " sampling_method=\"monte_carlo\",\n", + " reoptimize=False,\n", + ")\n", + "print(\"Baseline LCOE:\", results.financial_metrics[\"lcoe\"])\n", + "print(\"Mean LCOE under uncertainty:\", uncertainty.mean_performance.get(\"lcoe\"))\n", + "print(\"Risk metrics:\", uncertainty.risk_metrics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5 \u2014 Optional extensions\n", + "\n", + "Run only after Steps 1\u20134 pass:\n", + "- `fleximorpv2/flexible_design.py` \u2014 real options value should be \u2265 0\n", + "- `fleximorpv2/sensitivity_analysis.py` \u2014 parameter rankings stable across two runs\n", + "- Save plots to `notebooks/sites/outputs/`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Optional \u2014 flexible design and sensitivity\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/sites/blyth_pipeline.ipynb b/notebooks/sites/blyth_pipeline.ipynb new file mode 100644 index 0000000..56f0b96 --- /dev/null +++ b/notebooks/sites/blyth_pipeline.ipynb @@ -0,0 +1,230 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Blyth, UK North Sea (offshore) \u2014 site pipeline\n", + "\n", + "**Config:** `data/blyth/config.yaml`\n", + "\n", + "**Technologies:** wind, solar, wave\n", + "\n", + "Commercial offshore scale \u2014 capacity bounds up to hundreds of MW in config.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist.\n", + "\n", + "**Important:** Do not catch errors and substitute dummy numbers (unlike `notebooks/alaska_analysis.ipynb`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Load and validate config\n", + "\n", + "**Run the next cell** to load `data/blyth/config.yaml`.\n", + "\n", + "**Pass if:** `validate_config()` succeeds and enabled techs are `wind, solar, wave`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config, validate_config\n", + "\n", + "config = load_config(\"blyth\")\n", + "validate_config(config)\n", + "print(f\"Site: {config.name}\")\n", + "print(f\"Coords: {config.coordinates}\")\n", + "print(f\"Techs: {config.get_enabled_technologies()}\")\n", + "print(f\"Discount rate: {config.economic.get('discount_rate')}\")\n", + "print(f\"Max capacity (config): {config.optimization.get('constraints', {}).get('max_total_capacity', 'not set')}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 \u2014 Inspect synthetic resource data\n", + "\n", + "**Run the next cell.** Expect wind, solar, **and wave** statistics from the data loader.\n", + "\n", + "**Pass if:** means are finite and physically plausible for the site (print values and judge \u2014 no API keys required)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.utils.data_loader import APIDataLoader\n", + "\n", + "loader = APIDataLoader(config)\n", + "resource = loader.load_weather_data(\n", + " coordinates=config.coordinates,\n", + " technologies=config.get_enabled_technologies(),\n", + ")\n", + "print(\"Wind mean (m/s):\", float(resource.wind_speed.mean()))\n", + "print(\"Solar mean (W/m\u00b2):\", float(resource.solar_irradiance.mean()))\n", + "if len(resource.wave_height):\n", + " print(\"Wave Hs mean (m):\", float(resource.wave_height.mean()))\n", + "print(\"Temperature mean (\u00b0C):\", float(resource.temperature.mean()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3 \u2014 Baseline optimization\n", + "\n", + "**Run the next cell.**\n", + "\n", + "Compare `target_value=200_000` to printed `annual_energy` \u2014 confirm whether the engine treats both as kWh or MWh (see notebook 08).\n", + "\n", + "**Pass if:** optimisation completes, LCOE > 0, and capacity respects config limits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.baseline_optimization import BaselineOptimization\n", + "\n", + "config.uncertainty[\"monte_carlo_runs\"] = 10\n", + "results = BaselineOptimization(config).optimize(\"production\", 200_000, method=\"scipy\")\n", + "\n", + "assert_positive(results.financial_metrics[\"lcoe\"], label=\"LCOE\")\n", + "print(\"Target:\", 200_000, \"| Annual energy:\", results.technical_metrics[\"annual_energy\"])\n", + "print(\"Financial:\", results.financial_metrics)\n", + "print(\"Technical:\", results.technical_metrics)\n", + "print(\"Capacities:\", results.technology_capacities)\n", + "optimal_design = results.optimal_design" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4 \u2014 Uncertainty (Monte Carlo)\n", + "\n", + "**Run the next cell** with `n_runs=50` for a quick pass; re-run locally with 500 for convergence.\n", + "\n", + "**Pass if:** mean LCOE is same order of magnitude as baseline LCOE." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.uncertainty_analysis import UncertaintyAnalysis\n", + "\n", + "analyzer = UncertaintyAnalysis(config)\n", + "uncertainty = analyzer.analyze_uncertainty(\n", + " baseline_design=optimal_design,\n", + " sampling_method=\"monte_carlo\",\n", + " reoptimize=False,\n", + ")\n", + "print(\"Baseline LCOE:\", results.financial_metrics[\"lcoe\"])\n", + "print(\"Mean LCOE under uncertainty:\", uncertainty.mean_performance.get(\"lcoe\"))\n", + "print(\"Risk metrics:\", uncertainty.risk_metrics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5 \u2014 Optional extensions\n", + "\n", + "Run only after Steps 1\u20134 pass:\n", + "- `fleximorpv2/flexible_design.py` \u2014 real options value should be \u2265 0\n", + "- `fleximorpv2/sensitivity_analysis.py` \u2014 parameter rankings stable across two runs\n", + "- Save plots to `notebooks/sites/outputs/`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Optional \u2014 flexible design and sensitivity\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/sites/eastport_pipeline.ipynb b/notebooks/sites/eastport_pipeline.ipynb new file mode 100644 index 0000000..10f4292 --- /dev/null +++ b/notebooks/sites/eastport_pipeline.ipynb @@ -0,0 +1,230 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Eastport, Maine (nearshore) \u2014 site pipeline\n", + "\n", + "**Config:** `data/eastport/config.yaml`\n", + "\n", + "**Technologies:** wind, solar, hydro (tidal / ORPC TidGen)\n", + "\n", + "Fishing-industry constraints \u2014 verify tidal/hydro CF is plausible, not wave.\n", + "\n", + "Run cells **top to bottom**. Each markdown cell explains the **next code cell** \u2014 what it does, what to inspect in the output, and what counts as a pass.\n", + "\n", + "Track overall audit progress in Obsidian (`FlexiMORP Calculation Audit.md`). These notebooks are the lab workbook, not the checklist.\n", + "\n", + "**Important:** Do not catch errors and substitute dummy numbers (unlike `notebooks/alaska_analysis.ipynb`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import warnings\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "warnings.filterwarnings(\"error\", category=RuntimeWarning)\n", + "\n", + "\n", + "def _repo_root() -> Path:\n", + " for candidate in [Path.cwd(), *Path.cwd().parents]:\n", + " if (candidate / \"fleximorpv2\").is_dir():\n", + " return candidate\n", + " raise RuntimeError(\n", + " \"Could not find fleximorp-project root. \"\n", + " \"Open Jupyter from the repo or a notebooks/ subdirectory.\"\n", + " )\n", + "\n", + "\n", + "_repo = _repo_root()\n", + "_audit = _repo / \"notebooks\" / \"audit\"\n", + "for path in (_repo, _audit):\n", + " path_str = str(path)\n", + " if path_str not in sys.path:\n", + " sys.path.insert(0, path_str)\n", + "\n", + "from _audit_helpers import (\n", + " REPO_ROOT,\n", + " OUTPUT_DIR,\n", + " SITE_OUTPUT_DIR,\n", + " reload_fleximorp,\n", + " assert_close,\n", + " assert_energy_balance,\n", + " assert_cf_bounds,\n", + " assert_positive,\n", + ")\n", + "\n", + "reload_fleximorp()\n", + "np.random.seed(42)\n", + "print(f\"Repo root: {REPO_ROOT}\")\n", + "print(f\"Audit outputs: {OUTPUT_DIR}\")\n", + "print(f\"Site outputs: {SITE_OUTPUT_DIR}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1 \u2014 Load and validate config\n", + "\n", + "**Run the next cell** to load `data/eastport/config.yaml`.\n", + "\n", + "**Pass if:** `validate_config()` succeeds and enabled techs are `wind, solar, hydro (tidal / ORPC TidGen)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.config import load_config, validate_config\n", + "\n", + "config = load_config(\"eastport\")\n", + "validate_config(config)\n", + "print(f\"Site: {config.name}\")\n", + "print(f\"Coords: {config.coordinates}\")\n", + "print(f\"Techs: {config.get_enabled_technologies()}\")\n", + "print(f\"Discount rate: {config.economic.get('discount_rate')}\")\n", + "print(f\"Max capacity (config): {config.optimization.get('constraints', {}).get('max_total_capacity', 'not set')}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2 \u2014 Inspect synthetic resource data\n", + "\n", + "**Run the next cell.** Wave is disabled in config; hydro represents tidal stream.\n", + "\n", + "**Pass if:** means are finite and physically plausible for the site (print values and judge \u2014 no API keys required)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.utils.data_loader import APIDataLoader\n", + "\n", + "loader = APIDataLoader(config)\n", + "resource = loader.load_weather_data(\n", + " coordinates=config.coordinates,\n", + " technologies=config.get_enabled_technologies(),\n", + ")\n", + "print(\"Wind mean (m/s):\", float(resource.wind_speed.mean()))\n", + "print(\"Solar mean (W/m\u00b2):\", float(resource.solar_irradiance.mean()))\n", + "if len(resource.wave_height):\n", + " print(\"Wave Hs mean (m):\", float(resource.wave_height.mean()))\n", + "print(\"Temperature mean (\u00b0C):\", float(resource.temperature.mean()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3 \u2014 Baseline optimization\n", + "\n", + "**Run the next cell.**\n", + "\n", + "Compare `target_value=200_000` to printed `annual_energy` \u2014 confirm whether the engine treats both as kWh or MWh (see notebook 08).\n", + "\n", + "**Pass if:** optimisation completes, LCOE > 0, and capacity respects config limits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.baseline_optimization import BaselineOptimization\n", + "\n", + "config.uncertainty[\"monte_carlo_runs\"] = 10\n", + "results = BaselineOptimization(config).optimize(\"production\", 200_000, method=\"scipy\")\n", + "\n", + "assert_positive(results.financial_metrics[\"lcoe\"], label=\"LCOE\")\n", + "print(\"Target:\", 200_000, \"| Annual energy:\", results.technical_metrics[\"annual_energy\"])\n", + "print(\"Financial:\", results.financial_metrics)\n", + "print(\"Technical:\", results.technical_metrics)\n", + "print(\"Capacities:\", results.technology_capacities)\n", + "optimal_design = results.optimal_design" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4 \u2014 Uncertainty (Monte Carlo)\n", + "\n", + "**Run the next cell** with `n_runs=50` for a quick pass; re-run locally with 500 for convergence.\n", + "\n", + "**Pass if:** mean LCOE is same order of magnitude as baseline LCOE." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from fleximorpv2.uncertainty_analysis import UncertaintyAnalysis\n", + "\n", + "analyzer = UncertaintyAnalysis(config)\n", + "uncertainty = analyzer.analyze_uncertainty(\n", + " baseline_design=optimal_design,\n", + " sampling_method=\"monte_carlo\",\n", + " reoptimize=False,\n", + ")\n", + "print(\"Baseline LCOE:\", results.financial_metrics[\"lcoe\"])\n", + "print(\"Mean LCOE under uncertainty:\", uncertainty.mean_performance.get(\"lcoe\"))\n", + "print(\"Risk metrics:\", uncertainty.risk_metrics)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5 \u2014 Optional extensions\n", + "\n", + "Run only after Steps 1\u20134 pass:\n", + "- `fleximorpv2/flexible_design.py` \u2014 real options value should be \u2265 0\n", + "- `fleximorpv2/sensitivity_analysis.py` \u2014 parameter rankings stable across two runs\n", + "- Save plots to `notebooks/sites/outputs/`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Optional \u2014 flexible design and sensitivity\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/sites/outputs/.gitkeep b/notebooks/sites/outputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_financial_calculator.py b/tests/test_financial_calculator.py new file mode 100644 index 0000000..7a10286 --- /dev/null +++ b/tests/test_financial_calculator.py @@ -0,0 +1,134 @@ +""" +Regression tests for financial and economic metric calculations. +""" + +import pytest +import numpy as np + +from fleximorpv2.config import SiteConfig, TechnologyConfig +from fleximorpv2.models.technologies import TechnologyModel, ResourceData +from fleximorpv2.models.economics import EconomicModel +from fleximorpv2.utils.financial import FinancialCalculator + + +@pytest.fixture +def minimal_config(): + return SiteConfig( + name="Test site", + coordinates=[0.0, 0.0], + technologies={}, + optimization={}, + uncertainty={}, + flexibility={}, + economic={ + "discount_rate": 0.08, + "project_lifetime": 20, + "electricity_price": 0.10, + "tax_rate": 0.25, + }, + ) + + +def test_calculate_metrics_requires_annual_energy_for_lcoe(minimal_config): + calc = FinancialCalculator(minimal_config) + + with pytest.raises(ValueError, match="annual_energy is required"): + calc.calculate_metrics( + capex=1_000_000, + opex=50_000, + revenue=1_000_000, + project_life=20, + ) + + +def test_calculate_metrics_uses_supplied_annual_energy(minimal_config): + calc = FinancialCalculator(minimal_config) + + capex = 1_000_000 + opex = 50_000 + annual_energy = 10_000 + revenue = annual_energy * minimal_config.economic["electricity_price"] * 1000 + + metrics = calc.calculate_metrics( + capex=capex, + opex=opex, + revenue=revenue, + project_life=20, + annual_energy=annual_energy, + ) + direct_lcoe = calc.calculate_lcoe(capex, opex, annual_energy, 0.08, 20) + + assert metrics["lcoe"] == pytest.approx(direct_lcoe) + assert metrics["lcoe"] > 1.0 + + +def test_economic_revenues_preserve_annual_energy_for_lcoe(minimal_config): + model = EconomicModel(minimal_config) + tech_performance = { + "annual_energy": 10_000, + "total_capacity": 2.0, + } + + revenues = model._calculate_revenues(tech_performance) + metrics = model._calculate_economic_metrics( + costs={"capex": 1_000_000, "opex": 50_000}, + revenues=revenues, + ) + + assert revenues["annual_energy"] == tech_performance["annual_energy"] + assert revenues["electricity_revenue"] == pytest.approx(1_000_000) + assert metrics["lcoe"] > 1.0 + + +def test_technology_model_returns_hourly_generation_profile(): + config = SiteConfig( + name="Test site", + coordinates=[0.0, 0.0], + technologies={ + "wind": TechnologyConfig( + enabled=True, + cost_per_mw=2500, + capacity_factor=0.4, + technical_params={"availability": 1.0}, + ) + }, + optimization={}, + uncertainty={}, + flexibility={}, + economic={"discount_rate": 0.08, "project_lifetime": 20, "electricity_price": 0.10}, + ) + model = TechnologyModel(config) + + wind_speed = np.tile([2.0, 15.0], 4380) + resource = ResourceData( + wind_speed=wind_speed, + solar_irradiance=np.zeros(8760), + wave_height=np.zeros(8760), + wave_period=np.ones(8760), + temperature=np.zeros(8760), + timestamps=np.arange(8760), + ) + perf = model.calculate_performance({"wind_capacity": 2.0}, resource) + + assert "generation_profile" in perf + assert perf["generation_profile"].shape == (8760,) + assert not np.allclose(perf["generation_profile"], np.ones(8760)) + assert perf["annual_energy"] == pytest.approx(float(np.sum(perf["generation_profile"]))) + assert perf["annual_energy"] > 0 + + +def test_economic_model_uses_real_generation_profile(minimal_config): + model = EconomicModel(minimal_config) + generation_profile = np.tile([0.0, 2.0], 4380) + tech_performance = { + "annual_energy": float(np.sum(generation_profile)), + "generation_profile": generation_profile, + "total_capacity": 2.0, + } + + revenues = model._calculate_revenues(tech_performance) + + assert revenues["annual_energy"] == pytest.approx(float(np.sum(generation_profile))) + assert model.revenue_model is not None + assert model.revenue_model.generation_profile.shape == (8760,) + assert np.array_equal(model.revenue_model.generation_profile, generation_profile)