{
 "cells": [
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "tags": [
     "injected-warnings",
     "hide-cell"
    ]
   },
   "outputs": [],
   "source": [
    "# General notebook settings\n",
    "import logging\n",
    "import warnings\n",
    "\n",
    "import pypsa\n",
    "\n",
    "warnings.filterwarnings(\"error\", category=DeprecationWarning)\n",
    "# pandas<3.0.3 sets the `locs` attribute deprecated in matplotlib>=3.11\n",
    "warnings.filterwarnings(\"ignore\", message=\"The locs attribute was deprecated\")\n",
    "logging.getLogger(\"gurobipy\").propagate = False\n",
    "pypsa.options.params.optimize.log_to_console = False"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Modular Expansion with Unit Commitment\n",
    "\n",
    "This tutorial demonstrates **modular expansion** combined with **unit commitment** in PyPSA. When both `p_nom_mod > 0` and `committable=True` are set, the status variable represents the **number of committed modules** (integer) rather than a simple binary on/off.\n",
    "\n",
    "This formulation is ideal for modeling technologies that come in standardized sizes: modular gas turbines, nuclear reactors, or HVDC interconnectors. The optimizer co-optimizes both how many modules to build AND how many to operate at each time step.\n",
    "\n",
    "For the complete mathematical formulation, constraint names, and detailed explanation of the module-level commitment formulation, see [Capacity Limits: Modular and Committable Components](https://docs.pypsa.org/latest/user-guide/optimization/capacity-limits/#modular-and-committable-components).\n",
    "\n",
    "For continuous capacity expansion with unit commitment (big-M formulation), see the [Committable + Extendable Components](https://docs.pypsa.org/latest/examples/committable-extendable/) tutorial."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "import pandas as pd\n",
    "\n",
    "import pypsa"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Basic Example: Modular Gas Turbines\n",
    "\n",
    "Let's model a fleet of gas turbines that:\n",
    "- Come in 200 MW modules\n",
    "- Can be committed/decommitted based on load\n",
    "- Have a 10% minimum load when running\n",
    "\n",
    "The optimizer will decide both how many modules to build AND how many to run in each hour."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "n = pypsa.Network(snapshots=range(4))\n",
    "\n",
    "n.add(\"Bus\", \"bus\", carrier=\"electricity\")\n",
    "n.add(\"Carrier\", \"electricity\")\n",
    "\n",
    "# Variable load pattern - requires different numbers of modules\n",
    "load_profile = [4000, 6000, 5000, 800]\n",
    "n.add(\"Load\", \"load\", bus=\"bus\", p_set=load_profile)\n",
    "\n",
    "# Add a modular, committable, extendable generator\n",
    "# Capacity must be built in 200 MW modules\n",
    "n.add(\n",
    "    \"Generator\",\n",
    "    \"modular_gas\",\n",
    "    bus=\"bus\",\n",
    "    p_nom_extendable=True,\n",
    "    committable=True,\n",
    "    p_nom_mod=200,  # Must build in 200 MW increments\n",
    "    p_nom_max=10000,\n",
    "    p_min_pu=0.1,  # 10% minimum load per committed module\n",
    "    marginal_cost=1,\n",
    "    capital_cost=1,\n",
    "    stand_by_cost=1,  # Penalize keeping modules online unnecessarily\n",
    ")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "n.optimize(log_to_console=False)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "p_nom_opt = n.generators.p_nom_opt[\"modular_gas\"]\n",
    "p_nom_mod = n.generators.p_nom_mod[\"modular_gas\"]\n",
    "n_modules = p_nom_opt / p_nom_mod\n",
    "\n",
    "print(f\"Optimal capacity: {p_nom_opt:.0f} MW\")\n",
    "print(f\"Module size: {p_nom_mod:.0f} MW\")\n",
    "print(f\"Number of modules built: {n_modules:.0f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Verify the capacity is indeed a multiple of the module size\n",
    "assert abs(n_modules - round(n_modules)) < 1e-6, (\n",
    "    \"Capacity should be a multiple of module size!\"\n",
    ")\n",
    "print(\"Capacity is correctly constrained to module size multiples.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# The status variable now represents number of committed modules\n",
    "results = pd.DataFrame(\n",
    "    {\n",
    "        \"Load\": load_profile,\n",
    "        \"Modules Committed\": n.generators_t.status[\"modular_gas\"].astype(int),\n",
    "        \"Dispatch\": n.generators_t.p[\"modular_gas\"],\n",
    "    }\n",
    ")\n",
    "results"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Notice how the number of committed modules changes with load - the optimizer only commits as many modules as needed to meet demand while respecting minimum part-load constraints.\n",
    "\n",
    "**Key insight**: In period 3, demand drops to 800 MW, so only 4 modules (800 MW capacity) are needed. The other modules are kept offline to avoid stand-by costs."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Visualize modules committed vs load\n",
    "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6), sharex=True)\n",
    "\n",
    "# Dispatch\n",
    "ax1.bar(\n",
    "    results.index, results[\"Dispatch\"], alpha=0.7, color=\"steelblue\", label=\"Dispatch\"\n",
    ")\n",
    "ax1.plot(results.index, results[\"Load\"], \"ro-\", label=\"Load\")\n",
    "ax1.set_ylabel(\"Power [MW]\")\n",
    "ax1.set_title(\"Dispatch vs Load\")\n",
    "ax1.legend()\n",
    "\n",
    "# Modules committed\n",
    "ax2.bar(results.index, results[\"Modules Committed\"], color=\"green\", alpha=0.7)\n",
    "ax2.axhline(\n",
    "    y=n_modules,\n",
    "    color=\"red\",\n",
    "    linestyle=\"--\",\n",
    "    label=f\"Total modules built ({int(n_modules)})\",\n",
    ")\n",
    "ax2.set_ylabel(\"Modules Committed\")\n",
    "ax2.set_xlabel(\"Hour\")\n",
    "ax2.set_title(\"Module Commitment Schedule\")\n",
    "ax2.legend()\n",
    "\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Modular Interconnector with Commitment\n",
    "\n",
    "The modular commitment feature also works for **Links**. This is useful for:\n",
    "- HVDC interconnectors with discrete cable capacities\n",
    "- Electrolyzers that come in standardized stack sizes\n",
    "- Any conversion technology with modular capacity and operational constraints"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "n = pypsa.Network(snapshots=range(4))\n",
    "\n",
    "n.add(\"Bus\", \"zone_A\", carrier=\"electricity\")\n",
    "n.add(\"Bus\", \"zone_B\", carrier=\"electricity\")\n",
    "n.add(\"Carrier\", \"electricity\")\n",
    "\n",
    "# Cheap generation in zone A\n",
    "n.add(\"Generator\", \"gen_A\", bus=\"zone_A\", p_nom=1000, marginal_cost=20)\n",
    "\n",
    "# Load in zone B\n",
    "load_B = [300, 500, 400, 150]\n",
    "n.add(\"Load\", \"load_B\", bus=\"zone_B\", p_set=load_B)\n",
    "\n",
    "# Expensive backup in zone B\n",
    "n.add(\"Generator\", \"gen_B\", bus=\"zone_B\", p_nom=600, marginal_cost=100)\n",
    "\n",
    "# Committable + extendable + modular interconnector\n",
    "n.add(\n",
    "    \"Link\",\n",
    "    \"interconnector\",\n",
    "    bus0=\"zone_A\",\n",
    "    bus1=\"zone_B\",\n",
    "    p_nom_extendable=True,\n",
    "    committable=True,\n",
    "    p_nom_mod=150,  # 150 MW modules (e.g., HVDC cables)\n",
    "    p_nom_max=600,\n",
    "    p_min_pu=0.2,  # Minimum flow when active\n",
    "    marginal_cost=5,\n",
    "    capital_cost=300,\n",
    "    start_up_cost=50,\n",
    ")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "n.optimize(log_to_console=False)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "p_nom_opt = n.links.p_nom_opt[\"interconnector\"]\n",
    "p_nom_mod = n.links.p_nom_mod[\"interconnector\"]\n",
    "print(f\"Optimal interconnector capacity: {p_nom_opt:.0f} MW\")\n",
    "print(f\"Number of {p_nom_mod:.0f} MW modules: {p_nom_opt / p_nom_mod:.0f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Verify n_mod variable exists for modular link\n",
    "print(\n",
    "    f\"n_mod variable value: {n.model.variables['Link-n_mod'].solution.loc['interconnector']}\"\n",
    ")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pd.DataFrame(\n",
    "    {\n",
    "        \"Load_B\": load_B,\n",
    "        \"Link_Status\": n.links_t.status[\"interconnector\"],\n",
    "        \"Link_Flow\": n.links_t.p0[\"interconnector\"],\n",
    "        \"Gen_B_Dispatch\": n.generators_t.p[\"gen_B\"],\n",
    "    }\n",
    ")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Start-up and Shut-down Dynamics\n",
    "\n",
    "With modular commitment, start-up and shut-down costs are incurred when the number of committed modules changes. Let's examine this behavior in detail."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "n = pypsa.Network(snapshots=range(6))\n",
    "\n",
    "n.add(\"Bus\", \"bus\", carrier=\"electricity\")\n",
    "n.add(\"Carrier\", \"electricity\")\n",
    "# Load that varies significantly\n",
    "load_profile = [500, 1000, 600, 200, 800, 400]\n",
    "n.add(\"Load\", \"load\", bus=\"bus\", p_set=load_profile)\n",
    "\n",
    "n.add(\n",
    "    \"Generator\",\n",
    "    \"modular_plant\",\n",
    "    bus=\"bus\",\n",
    "    p_nom_extendable=True,\n",
    "    committable=True,\n",
    "    p_nom_mod=100,  # 100 MW modules\n",
    "    p_nom_max=2000,\n",
    "    p_min_pu=0.4,  # 20% minimum load per module\n",
    "    marginal_cost=30,\n",
    "    capital_cost=100,\n",
    "    start_up_cost=10,  # Significant start-up cost per module\n",
    "    shut_down_cost=5,  # Shut-down cost per module\n",
    ")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "n.optimize(log_to_console=False)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "p_nom_opt = n.generators.p_nom_opt[\"modular_plant\"]\n",
    "p_nom_mod = n.generators.p_nom_mod[\"modular_plant\"]\n",
    "n_modules_built = int(p_nom_opt / p_nom_mod)\n",
    "\n",
    "print(f\"Total capacity built: {p_nom_opt:.0f} MW ({n_modules_built} modules)\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Examine commitment dynamics\n",
    "results = pd.DataFrame(\n",
    "    {\n",
    "        \"Load\": load_profile,\n",
    "        \"Modules_Committed\": n.generators_t.status[\"modular_plant\"].astype(int),\n",
    "        \"Dispatch\": n.generators_t.p[\"modular_plant\"].round(1),\n",
    "        \"Start_ups\": n.generators_t.start_up[\"modular_plant\"].astype(int),\n",
    "        \"Shut_downs\": n.generators_t.shut_down[\"modular_plant\"].astype(int),\n",
    "    }\n",
    ")\n",
    "results"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Calculate costs\n",
    "gen = n.generators.loc[\"modular_plant\"]\n",
    "total_startup_cost = results[\"Start_ups\"].sum() * gen.start_up_cost\n",
    "total_shutdown_cost = results[\"Shut_downs\"].sum() * gen.shut_down_cost\n",
    "\n",
    "print(f\"Total start-up cost: {total_startup_cost:.0f}\")\n",
    "print(f\"Total shut-down cost: {total_shutdown_cost:.0f}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Visualize commitment schedule with start-ups and shut-downs\n",
    "fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)\n",
    "\n",
    "# Dispatch vs Load\n",
    "axes[0].bar(\n",
    "    results.index, results[\"Dispatch\"], alpha=0.7, color=\"steelblue\", label=\"Dispatch\"\n",
    ")\n",
    "axes[0].plot(results.index, results[\"Load\"], \"ro-\", label=\"Load\")\n",
    "axes[0].set_ylabel(\"Power [MW]\")\n",
    "axes[0].set_title(\"Dispatch vs Load\")\n",
    "axes[0].legend()\n",
    "\n",
    "# Modules committed\n",
    "axes[1].bar(results.index, results[\"Modules_Committed\"], color=\"green\", alpha=0.7)\n",
    "axes[1].axhline(\n",
    "    y=n_modules_built,\n",
    "    color=\"red\",\n",
    "    linestyle=\"--\",\n",
    "    label=f\"Total built ({n_modules_built})\",\n",
    ")\n",
    "axes[1].set_ylabel(\"Modules Online\")\n",
    "axes[1].set_title(\"Module Commitment\")\n",
    "axes[1].legend()\n",
    "\n",
    "# Start-ups and shut-downs\n",
    "x = results.index\n",
    "width = 0.35\n",
    "axes[2].bar(\n",
    "    x - width / 2, results[\"Start_ups\"], width, label=\"Start-ups\", color=\"orange\"\n",
    ")\n",
    "axes[2].bar(\n",
    "    x + width / 2, results[\"Shut_downs\"], width, label=\"Shut-downs\", color=\"purple\"\n",
    ")\n",
    "axes[2].set_ylabel(\"Number of Modules\")\n",
    "axes[2].set_xlabel(\"Hour\")\n",
    "axes[2].set_title(\"Module Start-ups and Shut-downs\")\n",
    "axes[2].legend()\n",
    "\n",
    "plt.tight_layout()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Summary\n",
    "\n",
    "This tutorial demonstrated modular expansion with unit commitment in PyPSA:\n",
    "\n",
    "1. **Modular Capacity**: Set `p_nom_mod` to force capacity to be built in discrete blocks\n",
    "2. **Module-Level Commitment**: When combined with `committable=True`, status represents the number of committed modules (integer variable)\n",
    "3. **Generators and Links**: The feature works for both component types\n",
    "4. **Start-up/Shut-down Costs**: Apply when the number of committed modules changes\n",
    "5. **Minimum Load**: `p_min_pu` is enforced per committed module\n",
    "\n",
    "## Related Material:\n",
    "- [Capacity Limits: Modular and Committable Components](https://docs.pypsa.org/latest/user-guide/optimization/capacity-limits/#modular-and-committable-components) - Complete mathematical formulation\n",
    "- [Committable + Extendable Components](https://docs.pypsa.org/latest/examples/committable-extendable/) - Continuous capacity with big-M formulation"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "",
   "language": "python",
   "name": ""
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.11"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
