Microgrid Energy Dispatch Optimizer

Build a MILP optimizer that minimizes diesel costs for a remote mining site.

Introduction

30 Second Summary

Remote mining sites burn through thousands of dollars in diesel fuel every day because their generators run at full power around the clock. With solar panels and batteries on site, there is a smarter way to schedule energy production, but finding the optimal schedule hour by hour is a complex puzzle.

In this project, you will build a Python-based dispatch optimizer that pulls real weather data, formulates the scheduling problem as a mathematical program, and solves it to produce a cost-minimized 24-hour generation plan for a remote microgrid.

What You'll Build

A Mixed-Integer Linear Programming (MILP) dispatch optimizer for a mining site in Chile's Atacama Desert, using PuLP and real solar/wind data from NASA's POWER API.

By the end of this project, you'll have:

  • A weather data pipeline that fetches real solar irradiance and wind speed from the NASA POWER API and converts them into renewable capacity factors.
  • A MILP optimization engine that models solar PV, wind, diesel, and battery storage with physical constraints and produces an optimal hourly dispatch schedule.
  • A visual comparison showing 30-50% fuel cost savings versus a naive diesel-first baseline strategy.
  • Secret Mission: Extend the optimizer to a 7-day planning horizon with a fuel delivery constraint, where the solver must strategically conserve diesel before a scheduled resupply.

Are there any prerequisites?

You should be comfortable with Python fundamentals like functions, dictionaries, and loops. A basic understanding of what optimization means (minimizing a cost function subject to constraints) is helpful, but no prior experience with linear programming libraries is needed.

Before We Start

Before you can optimize anything, you need a clean workspace with the right tools installed. The optimizer you are building relies on a mathematical solver, data processing libraries, and an HTTP client to pull real weather data.

In this step, you will create a project folder, set up a Python virtual environment, and install the four packages that power the optimizer.

In this step, get ready to:
  • Create a project directory with a virtual environment and file structure.
  • Install PuLP, pandas, matplotlib, and requests.
  • Verify the CBC solver is available and ready to solve optimization problems.
Create the project directory and virtual environment

A virtual environment keeps your project's packages isolated from your system Python. This prevents version conflicts between projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir microgrid-optimizer
cd microgrid-optimizer
  • Create a virtual environment by running:

macOS

python3 -m venv venv

Windows

python -m venv venv
  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On Windows, you may need to enable script execution first. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned and then try the activate command again.

On macOS, make sure you used source before the path. Without it, the shell tries to run the script as a program instead of sourcing it.

Why isn't my virtual environment activating?

  • Create the four project files by running:

macOS

touch config.py weather.py optimizer.py main.py

Windows

New-Item config.py, weather.py, optimizer.py, main.py -ItemType File
  • Confirm the files exist by running:
ls

You should see config.py, weather.py, optimizer.py, main.py, and the venv folder in the output.

What are these four files for?

  • config.py defines the mining site's energy assets and constraints.
  • weather.py fetches and processes real solar and wind data from NASA.
  • optimizer.py contains the MILP optimization engine.
  • main.py ties everything together and produces the final visualization.
  • Open the microgrid-optimizer folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the microgrid-optimizer folder, and click Open.
Install the dependencies

The optimizer relies on four Python packages. Each handles a different part of the pipeline, from mathematical solving to data visualization.

  • Install all four project dependencies by running this command in your terminal (make sure you still see (venv) in your prompt):
pip install pulp pandas matplotlib requests

What do these packages do?

  • PuLP is a linear programming modeler that lets you define optimization problems in Python. It bundles the CBC solver, so you do not need to install a separate solver.
  • pandas handles tabular data. You will use it to store hourly weather data and dispatch schedules.
  • matplotlib produces charts and visualizations. You will use it to plot the optimized dispatch schedule.
  • requests makes HTTP calls to the NASA POWER API to fetch real weather data.

Wait for the installation to finish. You should see Successfully installed followed by a list of package names at the end of the output.

Seeing a permissions error or 'pip not found'?

Make sure your virtual environment is active. You should see (venv) in your terminal prompt. If not, run the activation command from the previous substep again.

If you see a network timeout, check your internet connection and try running the install command again.

Help me fix my pip install error.

Verify PuLP and the CBC solver

PuLP formulates the optimization problem, but the CBC solver does the heavy lifting of finding the optimal solution. You need to confirm CBC is available before building the optimizer.

  • Open a Python REPL by running:
python
  • Check which solvers are available by running this in the REPL:
import pulp
pulp.listSolvers(onlyAvailable=True)

✔️ I see PULP_CBC_CMD in the list

The CBC solver is ready. The output should look something like ['PULP_CBC_CMD'] (you may see additional solvers listed too).

  • Exit the REPL by typing:
exit()

ⓧ I see an empty list or PULP_CBC_CMD is missing

The CBC solver did not install correctly. Exit the REPL and reinstall PuLP with a fresh download.

  • Exit the REPL by typing exit().
  • Reinstall PuLP by running:
pip uninstall pulp -y
pip install pulp
  • Open the Python REPL again and re-run the solver check.

Help me get the CBC solver working with PuLP.

What is CBC?

CBC (Coin-or Branch and Cut) is an open-source solver for Mixed-Integer Linear Programming problems. It finds the mathematically optimal solution to problems with linear constraints and a mix of continuous and binary decision variables.

PuLP bundles CBC so you do not need to install it separately. Commercial solvers like Gurobi are faster on large problems, but CBC handles the scale of this project easily.

Your environment is ready. Next up, you will pull real solar and wind data from NASA's POWER API to feed into the optimizer.

Fetch Real Weather Data from NASA POWER

Your optimizer needs to know how much solar energy and wind are available at each hour of the day. Without real weather data, the model would have no way to determine when renewables can contribute and when diesel must pick up the slack.

In this step, you will build a weather data pipeline that pulls hourly solar irradiance and wind speed from NASA's free POWER API and converts them into capacity factors the optimizer can use directly.

In this step, get ready to:
  • Define site coordinates and wind parameters in config.py.
  • Write a function that fetches hourly data from the NASA POWER API.
  • Convert raw irradiance and wind speed into capacity factors with a synthetic fallback.
Define the site coordinates and wind turbine parameters

The weather module needs to know where the mining site is located and what the wind turbine's operating characteristics are. You will add these to config.py now and expand the file with the full site model in the next step.

  • Open config.py in VS Code and add the site location and wind turbine configuration:
# Site location in the Atacama Desert, Chile
SITE = {
    "name": "Atacama Mining Site Alpha",
    "latitude": -23.5,
    "longitude": -68.5,
}

# Wind turbine operating parameters
WIND = {
    "capacity_kw": 50,
    "cut_in_speed_ms": 3.0,
    "rated_speed_ms": 12.0,
    "cut_out_speed_ms": 25.0,
    "om_cost_per_kwh": 0.008,
}

What does this code do?

SITE stores the GPS coordinates for the Atacama Desert mining location. The NASA POWER API uses these to return weather data for that exact grid cell.

WIND defines the turbine's power curve thresholds. Below cut_in_speed_ms (3 m/s) the turbine produces nothing. Between cut-in and rated_speed_ms (12 m/s) output scales linearly. Above cut_out_speed_ms (25 m/s) the turbine shuts down for safety.

  • Save config.py.
Write the NASA POWER API fetch function

NASA's POWER API provides free, globally available climate data from satellite observations. It requires no API key. You will call its hourly endpoint to get surface irradiance and wind speed for a specific date.

  • Open weather.py and add the imports and the API fetch function:
import requests
import pandas as pd
from config import SITE, WIND


def fetch_nasa_power_data(date_start: str, date_end: str) -> dict:
    # NASA POWER hourly endpoint for point-based time series
    base_url = "https://power.larc.nasa.gov/api/temporal/hourly/point"
    params = {
        "parameters": "ALLSKY_SFC_SW_DWN,WS10M",
        "community": "RE",
        "longitude": SITE["longitude"],
        "latitude": SITE["latitude"],
        "start": date_start,
        "end": date_end,
        "format": "JSON",
        "time-standard": "LST",
    }

    response = requests.get(base_url, params=params, timeout=60)
    response.raise_for_status()
    data = response.json()

    # The JSON nests hourly values under properties -> parameter
    return data["properties"]["parameter"]

What does this code do?

ALLSKY_SFC_SW_DWN requests surface shortwave downward irradiance in W/m2. WS10M requests wind speed at 10 meters in m/s.

community=RE selects the Renewable Energy parameter set. time-standard=LST returns data in Local Solar Time so noon aligns with peak sun at the site.

The response JSON nests hourly data under data["properties"]["parameter"]. Each parameter contains keys like 2024011500 through 2024011523 representing each hour of the day.

  • Save weather.py.

Seeing an import error?

If you see an error about SITE or WIND not being found, make sure you saved config.py with both dictionaries from the previous substep.

help me fix an import error in my weather module

Convert raw data into capacity factors

Raw irradiance in W/m2 and wind speed in m/s are not directly usable by the optimizer. Capacity factors express generation as a fraction (0 to 1) of each asset's maximum output, making them unit-agnostic inputs to the optimization model.

  • Below the fetch_nasa_power_data function in weather.py, add the two conversion functions:

def irradiance_to_solar_cf(irradiance_wm2: float) -> float:
    # Negative values are NASA's missing-data indicator
    if irradiance_wm2 < 0:
        return 0.0
    # Divide by 1000 W/m2 (Standard Test Condition for PV panels)
    return min(irradiance_wm2 / 1000.0, 1.0)


def wind_speed_to_cf(speed_ms: float) -> float:
    # Use the turbine's power curve thresholds from config
    cut_in = WIND["cut_in_speed_ms"]
    rated = WIND["rated_speed_ms"]
    cut_out = WIND["cut_out_speed_ms"]

    if speed_ms < cut_in or speed_ms > cut_out:
        return 0.0
    elif speed_ms >= rated:
        return 1.0
    else:
        # Linear ramp between cut-in and rated speed
        return (speed_ms - cut_in) / (rated - cut_in)

What does this code do?

irradiance_to_solar_cf converts raw irradiance to a 0-1 fraction by dividing by 1000 W/m2. This is the Standard Test Condition (STC) irradiance used to rate solar panels. At 800 W/m2, the panel operates at 80% of its rated capacity.

wind_speed_to_cf implements a simplified linear power curve. Below 3 m/s, the turbine cannot spin. Between 3 and 12 m/s, output scales linearly. Above 12 m/s, the turbine runs at full capacity. Above 25 m/s, it shuts down.

NASA POWER uses -999.0 for missing values, so the negative check in the solar function handles those gracefully.

  • Save weather.py.
  • Below the conversion functions, add the orchestrator that fetches data and builds the DataFrame:

def get_capacity_factors(date: str) -> pd.DataFrame:
    try:
        raw_data = fetch_nasa_power_data(date, date)
        solar_raw = raw_data["ALLSKY_SFC_SW_DWN"]
        wind_raw = raw_data["WS10M"]

        hours = []
        solar_cfs = []
        wind_cfs = []

        for hour in range(24):
            # Keys are formatted as YYYYMMDD + two-digit hour
            key = f"{date}{hour:02d}"
            irr = solar_raw.get(key, 0)
            ws = wind_raw.get(key, 0)

            # Handle missing values from NASA POWER
            if irr < 0:
                irr = 0
            if ws < 0:
                ws = 0

            hours.append(hour)
            solar_cfs.append(irradiance_to_solar_cf(irr))
            wind_cfs.append(wind_speed_to_cf(ws))

        df = pd.DataFrame({"hour": hours, "solar_cf": solar_cfs, "wind_cf": wind_cfs})
        print(f"Successfully fetched NASA POWER data for {date}")
        return df

    except Exception as e:
        print(f"NASA POWER API unavailable ({e}), using synthetic data")
        return generate_synthetic_data()

What does this code do?

get_capacity_factors ties everything together. It calls the API, loops through 24 hours, converts each value to a capacity factor, and returns a tidy DataFrame.

The try/except block ensures the optimizer keeps working even if the API is down. If anything goes wrong (network timeout, rate limiting, server error), it falls through to synthetic data.

  • Save weather.py.
  • Finally, add the synthetic fallback and the test block at the bottom of weather.py:

def generate_synthetic_data() -> pd.DataFrame:
    # Typical Atacama profiles when API is unavailable
    import math

    hours = list(range(24))
    solar_cfs = []
    wind_cfs = []

    for h in hours:
        # Solar: bell curve peaking at solar noon (hour 13 LST)
        if 6 <= h <= 20:
            angle = math.pi * (h - 6) / 14
            solar_cfs.append(0.85 * math.sin(angle))
        else:
            solar_cfs.append(0.0)

        # Wind: moderate with slight afternoon peak
        base_wind = 5.0 + 2.0 * math.sin(math.pi * (h - 8) / 16)
        wind_cfs.append(wind_speed_to_cf(base_wind))

    return pd.DataFrame({"hour": hours, "solar_cf": solar_cfs, "wind_cf": wind_cfs})


if __name__ == "__main__":
    df = get_capacity_factors("20240115")
    print(df.to_string(index=False))

What does this code do?

generate_synthetic_data creates realistic Atacama Desert capacity factors using a sine-based solar curve (peaking around 0.85 at noon) and moderate wind speeds. This ensures the optimizer can run even without internet access.

The if __name__ == "__main__" block lets you test the module directly. It fetches data for January 15, 2024 and prints the resulting 24-row DataFrame.

  • Save weather.py.
  • Run the weather module by executing this command in your terminal:
python weather.py

You should see a 24-row table with columns hour, solar_cf, and wind_cf. Solar capacity factors peak around 0.7-0.9 at midday and drop to 0 at night.

What if I see synthetic data?

If the output says "NASA POWER API unavailable... using synthetic data", the API might be temporarily down or your network may be blocking it. The synthetic data still follows realistic Atacama patterns, so your optimizer will produce valid results.

Getting a different error?

If you see a ModuleNotFoundError for requests or pandas, make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.

help me debug why my weather.py module is not running correctly

✔️ Awesome, I've got everything!

Great. Double check you have saved both config.py and weather.py.

ⓧ I'd like to double check the full code

Here is the complete weather.py file:

import requests
import pandas as pd
from config import SITE, WIND


def fetch_nasa_power_data(date_start: str, date_end: str) -> dict:
    base_url = "https://power.larc.nasa.gov/api/temporal/hourly/point"
    params = {
        "parameters": "ALLSKY_SFC_SW_DWN,WS10M",
        "community": "RE",
        "longitude": SITE["longitude"],
        "latitude": SITE["latitude"],
        "start": date_start,
        "end": date_end,
        "format": "JSON",
        "time-standard": "LST",
    }
    response = requests.get(base_url, params=params, timeout=60)
    response.raise_for_status()
    data = response.json()
    return data["properties"]["parameter"]


def irradiance_to_solar_cf(irradiance_wm2: float) -> float:
    if irradiance_wm2 < 0:
        return 0.0
    return min(irradiance_wm2 / 1000.0, 1.0)


def wind_speed_to_cf(speed_ms: float) -> float:
    cut_in = WIND["cut_in_speed_ms"]
    rated = WIND["rated_speed_ms"]
    cut_out = WIND["cut_out_speed_ms"]
    if speed_ms < cut_in or speed_ms > cut_out:
        return 0.0
    elif speed_ms >= rated:
        return 1.0
    else:
        return (speed_ms - cut_in) / (rated - cut_in)


def get_capacity_factors(date: str) -> pd.DataFrame:
    try:
        raw_data = fetch_nasa_power_data(date, date)
        solar_raw = raw_data["ALLSKY_SFC_SW_DWN"]
        wind_raw = raw_data["WS10M"]
        hours = []
        solar_cfs = []
        wind_cfs = []
        for hour in range(24):
            key = f"{date}{hour:02d}"
            irr = solar_raw.get(key, 0)
            ws = wind_raw.get(key, 0)
            if irr < 0:
                irr = 0
            if ws < 0:
                ws = 0
            hours.append(hour)
            solar_cfs.append(irradiance_to_solar_cf(irr))
            wind_cfs.append(wind_speed_to_cf(ws))
        df = pd.DataFrame({"hour": hours, "solar_cf": solar_cfs, "wind_cf": wind_cfs})
        print(f"Successfully fetched NASA POWER data for {date}")
        return df
    except Exception as e:
        print(f"NASA POWER API unavailable ({e}), using synthetic data")
        return generate_synthetic_data()


def generate_synthetic_data() -> pd.DataFrame:
    import math
    hours = list(range(24))
    solar_cfs = []
    wind_cfs = []
    for h in hours:
        if 6 <= h <= 20:
            angle = math.pi * (h - 6) / 14
            solar_cfs.append(0.85 * math.sin(angle))
        else:
            solar_cfs.append(0.0)
        base_wind = 5.0 + 2.0 * math.sin(math.pi * (h - 8) / 16)
        wind_cfs.append(wind_speed_to_cf(base_wind))
    return pd.DataFrame({"hour": hours, "solar_cf": solar_cfs, "wind_cf": wind_cfs})


if __name__ == "__main__":
    df = get_capacity_factors("20240115")
    print(df.to_string(index=False))

Your weather pipeline is live. Next up, you will define the full site model with all generation assets, battery storage, and the demand profile that the optimizer will schedule against.

Define the Site Model and Constraints

You now have a weather pipeline that can fetch real solar and wind data for any location on Earth. The next question is: what equipment is actually on the ground at this site?

The optimizer needs a precise model of every generation asset, its physical limits, and the electricity demand it has to meet. In this step, you will build the site configuration that describes what the mining camp has to work with.

In this step, get ready to:
  • Define the site's generation assets (solar, wind, diesel, battery) as Python dictionaries.
  • Define operational constraints for fuel logistics and generator startup costs.
  • Define a 24-hour electricity demand profile representing mining operations.
Define the site location and renewable assets

The optimizer treats each energy source as a set of parameters: maximum output, efficiency, and cost. These numbers constrain what the solver is allowed to do.

  • Open config.py in VS Code and add the site identification and renewable asset definitions by pasting this code:
"""
Microgrid site configuration for a remote mining operation
in Chile's Atacama Desert.
"""

# Site coordinates (Atacama Desert, Chile)
SITE = {
    "name": "Atacama Mining Site Alpha",
    "latitude": -23.5,
    "longitude": -68.5,
}

# Solar PV array - already installed, only O&M cost
SOLAR = {
    "capacity_kw": 150,
    "capital_cost_per_kw": 0,
    "om_cost_per_kwh": 0.005,
}

# Wind turbine with standard power curve thresholds
WIND = {
    "capacity_kw": 50,
    "cut_in_speed_ms": 3.0,
    "rated_speed_ms": 12.0,
    "cut_out_speed_ms": 25.0,
    "om_cost_per_kwh": 0.008,
}

What does this code do?

Each dictionary models one physical asset at the site.

  • SITE stores the GPS coordinates that weather.py already imports to call the NASA POWER API.
  • SOLAR defines a 150 kW array. The capacity_kw is the hard ceiling the optimizer respects. When the solar capacity factor is 0.8, the max output is 150 x 0.8 = 120 kW.
  • WIND includes the same cut-in, rated, and cut-out speeds your wind_speed_to_cf() function already references.
  • Below the WIND dictionary, add the diesel generator and battery storage definitions:
# Diesel genset - expensive fuel, must respect minimum load when on
DIESEL = {
    "capacity_kw": 200,
    "min_load_kw": 40,
    "fuel_consumption_rate_l_per_kwh": 0.25,
    "fuel_cost_per_liter": 1.80,
    "startup_cost": 15.0,
}

# Lithium-ion battery with 90% round-trip efficiency
BATTERY = {
    "capacity_kwh": 400,
    "max_charge_kw": 100,
    "max_discharge_kw": 100,
    "charge_efficiency": 0.95,
    "discharge_efficiency": 0.95,
    "soc_min_pct": 0.10,
    "soc_max_pct": 0.90,
    "initial_soc_pct": 0.50,
}

What does this code do?

  • DIESEL has a min_load_kw of 40. Real diesel generators cannot run below ~20% of rated capacity without damage. This constraint is what forces the optimizer to use a binary variable (on/off), making the problem a Mixed-Integer Linear Program instead of a simpler LP.
  • BATTERY models separate charge and discharge efficiencies (both 0.95). Multiplied together, the round-trip efficiency is ~90%. The SOC limits of 10%-90% prevent deep cycling that degrades the cells.
  • Save config.py.

You will not see output yet. The next substep adds the remaining dictionaries.

Define the fuel tank operational constraints

Remote sites depend on fuel truck deliveries. The optimizer needs to know how much diesel is available so it does not plan a schedule that drains the tank before the next truck arrives.

  • Below the BATTERY dictionary in config.py, add the fuel tank definition:
# Fuel logistics - tank level constrains how much diesel the optimizer can burn
FUEL_TANK = {
    "capacity_liters": 2000,
    "current_level_liters": 1200,
    "next_delivery_days": 3,
}

Why track the fuel tank?

With 1200 liters on hand and the next delivery 3 days away, the optimizer must budget fuel carefully. At $1.80/liter (remote delivery premium), every liter the optimizer saves directly translates to cost savings. The startup_cost of $15 per generator start event (defined in DIESEL) also discourages rapid on-off cycling.

  • Save config.py.

You will not see output yet. The demand profile in the next chunk completes the file.

Define the 24-hour demand profile

The demand curve tells the optimizer exactly how much electricity the site needs in each hour. Mining operations have a distinctive pattern: low overnight, high during active drilling hours.

  • Below FUEL_TANK in config.py, add the demand profile and hour index:
# 24-hour demand profile (kW) for mining operations
# Index 0 = midnight, index 23 = 11 PM
DEMAND_KW = [
    80, 80, 80, 80, 80, 80,       # 00:00-05:00 overnight base load
    120, 160, 180, 180, 180, 180,  # 06:00-11:00 ramp up to active mining
    180, 180, 180, 180, 180, 160,  # 12:00-17:00 peak mining operations
    120, 120, 100, 100, 80, 80,    # 18:00-23:00 wind down
]

HOURS = list(range(24))

What does this code do?

  • DEMAND_KW is a list of 24 values (one per hour). The site needs 80 kW overnight for camp and ventilation, ramps to 180 kW during active mining from 06:00-17:00, then steps down in the evening.
  • HOURS is a convenience list [0, 1, 2, ... 23] that the optimizer will loop over to create decision variables for each time slot.
  • Save config.py.
  • Verify the configuration by running this command in your terminal:
python -c "from config import DEMAND_KW, SITE; print(SITE); print(f'Daily demand: {sum(DEMAND_KW)} kWh')"

You should see the site dictionary printed followed by Daily demand: 2880 kWh. That confirms the demand sums to an average of 120 kW over 24 hours.

Seeing a ModuleNotFoundError or KeyError?

  • Make sure you are running the command from inside your project folder (the one containing config.py).
  • Check that your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
  • Confirm that the dictionary keys are spelled exactly as shown. Python dictionary key names are case-sensitive.

Help me debug a ModuleNotFoundError when importing config.py in my microgrid project.

✔️ Awesome, I've got everything!

Great. Double check you have saved config.py and your output shows 2880 kWh.

ⓧ I'd like to double check the full code

Here is the complete config.py file. Make sure yours matches exactly:

"""
Microgrid site configuration for a remote mining operation
in Chile's Atacama Desert.
"""

SITE = {
    "name": "Atacama Mining Site Alpha",
    "latitude": -23.5,
    "longitude": -68.5,
}

SOLAR = {
    "capacity_kw": 150,
    "capital_cost_per_kw": 0,
    "om_cost_per_kwh": 0.005,
}

WIND = {
    "capacity_kw": 50,
    "cut_in_speed_ms": 3.0,
    "rated_speed_ms": 12.0,
    "cut_out_speed_ms": 25.0,
    "om_cost_per_kwh": 0.008,
}

DIESEL = {
    "capacity_kw": 200,
    "min_load_kw": 40,
    "fuel_consumption_rate_l_per_kwh": 0.25,
    "fuel_cost_per_liter": 1.80,
    "startup_cost": 15.0,
}

BATTERY = {
    "capacity_kwh": 400,
    "max_charge_kw": 100,
    "max_discharge_kw": 100,
    "charge_efficiency": 0.95,
    "discharge_efficiency": 0.95,
    "soc_min_pct": 0.10,
    "soc_max_pct": 0.90,
    "initial_soc_pct": 0.50,
}

FUEL_TANK = {
    "capacity_liters": 2000,
    "current_level_liters": 1200,
    "next_delivery_days": 3,
}

DEMAND_KW = [
    80, 80, 80, 80, 80, 80,
    120, 160, 180, 180, 180, 180,
    180, 180, 180, 180, 180, 160,
    120, 120, 100, 100, 80, 80,
]

HOURS = list(range(24))

Your site model is locked in. The optimizer now knows exactly what assets are available, what they cost to run, and how much electricity the mine demands each hour. Next up, you will build the MILP optimizer itself and watch it figure out the cheapest way to keep the lights on.

Build and Solve the MILP Dispatch Optimizer

Your site model defines what the microgrid can do, and your weather data defines what nature offers each hour. Now it is time to answer the core question: given those inputs, what is the cheapest way to keep the lights on for 24 hours?

You will formulate this question as a Mixed-Integer Linear Program (MILP). The solver will explore thousands of possible dispatch combinations and return the mathematically optimal one. This is the same technique used by commercial microgrid energy management systems.

In this step, get ready to:
  • Create an optimization problem with decision variables for each generation source and the battery.
  • Add physical constraints that keep the solution realistic (energy balance, fuel limits, battery bounds).
  • Solve the MILP and compare results against a naive diesel-first baseline.
Create the optimization problem and decision variables

A MILP has three parts: decision variables (what the solver controls), constraints (physical rules it must obey), and an objective (what it minimizes). You will build each part in sequence.

  • Open optimizer.py in VS Code and add the module docstring, imports, function signature, and decision variables by pasting this code:
"""
MILP-based microgrid dispatch optimizer using PuLP.
Minimizes total operating cost over a 24-hour horizon.
"""

import pulp
import pandas as pd
from config import (
    SOLAR, WIND, DIESEL, BATTERY, FUEL_TANK,
    DEMAND_KW, HOURS,
)


def build_and_solve(capacity_factors: pd.DataFrame) -> dict:
    T = HOURS
    solar_cf = capacity_factors["solar_cf"].tolist()
    wind_cf = capacity_factors["wind_cf"].tolist()

    prob = pulp.LpProblem("Microgrid_Dispatch", pulp.LpMinimize)

    p_solar = pulp.LpVariable.dicts("p_solar", T, lowBound=0)
    p_wind = pulp.LpVariable.dicts("p_wind", T, lowBound=0)
    p_diesel = pulp.LpVariable.dicts("p_diesel", T, lowBound=0)
    p_charge = pulp.LpVariable.dicts("p_charge", T, lowBound=0)
    p_discharge = pulp.LpVariable.dicts("p_discharge", T, lowBound=0)
    soc = pulp.LpVariable.dicts("soc", T, lowBound=0)
    diesel_on = pulp.LpVariable.dicts("diesel_on", T, cat="Binary")
    diesel_start = pulp.LpVariable.dicts("diesel_start", T, lowBound=0)

What does this code do?

You create an LpProblem set to minimize (not maximize) cost. Then you define eight sets of decision variables, one value per hour (0-23).

  • p_solar, p_wind, p_diesel: power output from each generation source (continuous, kW).
  • p_charge, p_discharge, soc: battery charging rate, discharging rate, and state of charge (continuous, kW/kWh).
  • diesel_on: a binary variable (0 or 1) that tracks whether the generator is running. This is what makes the problem a MILP instead of a plain LP.
  • diesel_start: captures on/off transitions so the solver can penalize frequent startups.

You will not see any output yet. The next chunk adds constraints, then you will solve and verify.

Add physical constraints to the optimization model

Without constraints, the solver would just set solar and wind to infinity and call it a day. Constraints encode the physical reality of the microgrid: supply must equal demand, renewables are limited by weather, diesel has a minimum operating threshold, and the battery cannot charge beyond its capacity.

  • Add the following constraint loop directly below the diesel_start variable definition in optimizer.py:
    for t in T:
        prob += (
            p_solar[t] + p_wind[t] + p_diesel[t] + p_discharge[t] - p_charge[t]
            >= DEMAND_KW[t],
            f"energy_balance_{t}",
        )
        prob += (p_solar[t] <= SOLAR["capacity_kw"] * solar_cf[t], f"solar_limit_{t}")
        prob += (p_wind[t] <= WIND["capacity_kw"] * wind_cf[t], f"wind_limit_{t}")
        prob += (p_diesel[t] <= DIESEL["capacity_kw"] * diesel_on[t], f"diesel_max_{t}")
        prob += (p_diesel[t] >= DIESEL["min_load_kw"] * diesel_on[t], f"diesel_min_{t}")
        prob += (p_charge[t] <= BATTERY["max_charge_kw"], f"charge_limit_{t}")
        prob += (p_discharge[t] <= BATTERY["max_discharge_kw"], f"discharge_limit_{t}")

        soc_min = BATTERY["capacity_kwh"] * BATTERY["soc_min_pct"]
        soc_max = BATTERY["capacity_kwh"] * BATTERY["soc_max_pct"]
        prob += soc[t] >= soc_min, f"soc_lower_{t}"
        prob += soc[t] <= soc_max, f"soc_upper_{t}"

        if t == 0:
            initial_soc = BATTERY["capacity_kwh"] * BATTERY["initial_soc_pct"]
            prob += (
                soc[t] == initial_soc
                + BATTERY["charge_efficiency"] * p_charge[t]
                - p_discharge[t] / BATTERY["discharge_efficiency"],
                f"soc_dynamics_{t}",
            )
        else:
            prob += (
                soc[t] == soc[t - 1]
                + BATTERY["charge_efficiency"] * p_charge[t]
                - p_discharge[t] / BATTERY["discharge_efficiency"],
                f"soc_dynamics_{t}",
            )

        if t == 0:
            prob += diesel_start[t] >= diesel_on[t] - 0, f"startup_{t}"
        else:
            prob += (
                diesel_start[t] >= diesel_on[t] - diesel_on[t - 1],
                f"startup_{t}",
            )

    prob += (
        pulp.lpSum(
            DIESEL["fuel_consumption_rate_l_per_kwh"] * p_diesel[t] for t in T
        )
        <= FUEL_TANK["current_level_liters"],
        "fuel_tank_limit",
    )

What do these constraints do?

  • Energy balance: total generation + battery discharge minus battery charging must meet or exceed demand every hour.
  • Renewable limits: solar and wind output cannot exceed installed capacity multiplied by that hour's capacity factor from weather data.
  • Diesel big-M: when diesel_on[t] is 0, both the max and min constraints force p_diesel[t] to zero. When it is 1, output must sit between 40 kW (minimum load) and 200 kW (capacity).
  • SOC dynamics: battery state of charge at hour t equals the previous hour's SOC plus energy charged (with 95% efficiency) minus energy discharged (divided by 95% efficiency).
  • Fuel tank limit: total diesel consumption across all 24 hours cannot exceed the 1,200 liters currently in the tank.

Why is the binary variable so important?

Real diesel generators cannot operate below about 20-30% of rated capacity without damage. Without the binary diesel_on variable, the solver could set diesel output to 1 kW (technically feasible in a pure LP) which is physically impossible. The binary makes this a Mixed-Integer problem, which is harder to solve but accurately models real equipment.

You will not see a change yet. The next chunk adds the objective function and solves the problem.

Set the objective, solve, and build a baseline for comparison

The objective function tells the solver what to minimize. Here, total cost is diesel fuel cost plus generator startup penalties plus a tiny renewable O&M term that prevents the solver from curtailing renewables wastefully.

  • Add the objective function, solver call, and result extraction below the fuel tank constraint in optimizer.py. This code is still inside the build_and_solve function:
    fuel_cost = pulp.lpSum(
        DIESEL["fuel_cost_per_liter"]
        * DIESEL["fuel_consumption_rate_l_per_kwh"]
        * p_diesel[t]
        for t in T
    )
    startup_cost = pulp.lpSum(DIESEL["startup_cost"] * diesel_start[t] for t in T)
    renewable_om = pulp.lpSum(
        SOLAR["om_cost_per_kwh"] * p_solar[t]
        + WIND["om_cost_per_kwh"] * p_wind[t]
        for t in T
    )
    prob += fuel_cost + startup_cost + renewable_om

    prob.solve(pulp.PULP_CBC_CMD(msg=0))

    status = pulp.LpStatus[prob.status]
    total_cost = pulp.value(prob.objective)

    schedule = pd.DataFrame({
        "hour": T,
        "demand_kw": DEMAND_KW,
        "solar_kw": [pulp.value(p_solar[t]) for t in T],
        "wind_kw": [pulp.value(p_wind[t]) for t in T],
        "diesel_kw": [pulp.value(p_diesel[t]) for t in T],
        "charge_kw": [pulp.value(p_charge[t]) for t in T],
        "discharge_kw": [pulp.value(p_discharge[t]) for t in T],
        "soc_kwh": [pulp.value(soc[t]) for t in T],
        "diesel_on": [pulp.value(diesel_on[t]) for t in T],
    })

    total_fuel_liters = sum(
        DIESEL["fuel_consumption_rate_l_per_kwh"] * pulp.value(p_diesel[t])
        for t in T
    )
    diesel_starts = sum(1 for t in T if pulp.value(diesel_start[t]) > 0.5)

    return {
        "status": status,
        "total_cost": total_cost,
        "total_fuel_liters": total_fuel_liters,
        "diesel_starts": diesel_starts,
        "schedule": schedule,
    }

What does this code do?

  • fuel_cost sums diesel cost across all hours: price per liter multiplied by consumption rate multiplied by output.
  • startup_cost penalizes each off-to-on transition at $15 per start event.
  • prob.solve(pulp.PULP_CBC_CMD(msg=0)) runs the CBC solver with output suppressed.
  • After solving, pulp.value() extracts the optimal value of each variable into a pandas DataFrame for easy analysis.

Now you need a baseline to compare against. A naive strategy is: renewables supply whatever they can, diesel covers the rest, and the battery is never used. This is how many remote sites actually operate before installing an EMS.

  • Add the compute_baseline function below build_and_solve in optimizer.py (outside of the build_and_solve function, at the module level):

def compute_baseline(capacity_factors: pd.DataFrame) -> dict:
    solar_cf = capacity_factors["solar_cf"].tolist()
    wind_cf = capacity_factors["wind_cf"].tolist()

    total_fuel = 0.0
    total_cost = 0.0
    diesel_starts = 0
    prev_diesel_on = False

    schedule_rows = []
    for t in HOURS:
        demand = DEMAND_KW[t]
        solar_avail = SOLAR["capacity_kw"] * solar_cf[t]
        wind_avail = WIND["capacity_kw"] * wind_cf[t]

        solar_used = min(solar_avail, demand)
        remaining = demand - solar_used
        wind_used = min(wind_avail, remaining)
        remaining -= wind_used

        diesel_used = remaining
        diesel_on = diesel_used > 0

        if diesel_on and diesel_used < DIESEL["min_load_kw"]:
            diesel_used = DIESEL["min_load_kw"]

        fuel = DIESEL["fuel_consumption_rate_l_per_kwh"] * diesel_used
        cost = DIESEL["fuel_cost_per_liter"] * fuel

        if diesel_on and not prev_diesel_on:
            diesel_starts += 1
            cost += DIESEL["startup_cost"]

        total_fuel += fuel
        total_cost += cost
        prev_diesel_on = diesel_on

        schedule_rows.append({
            "hour": t,
            "demand_kw": demand,
            "solar_kw": solar_used,
            "wind_kw": wind_used,
            "diesel_kw": diesel_used,
            "charge_kw": 0,
            "discharge_kw": 0,
        })

    return {
        "total_cost": total_cost,
        "total_fuel_liters": total_fuel,
        "diesel_starts": diesel_starts,
        "schedule": pd.DataFrame(schedule_rows),
    }

What does the baseline do?

The baseline follows a simple priority order each hour: use all available solar first, then wind, then diesel covers whatever remains. The battery is never touched. It also enforces the minimum-load rule (if diesel runs at all, it must produce at least 40 kW). This represents the "no optimization" scenario you are improving on.

  • Save optimizer.py.
  • Verify the optimizer works by opening your terminal and running a quick test:
python -c "from optimizer import build_and_solve, compute_baseline; from weather import get_capacity_factors; cf = get_capacity_factors('20240115'); r = build_and_solve(cf); print(f'Status: {r[\"status\"]}, Cost: ${r[\"total_cost\"]:.2f}'); b = compute_baseline(cf); print(f'Baseline cost: ${b[\"total_cost\"]:.2f}')"
  • You should see Status: Optimal followed by an optimized cost that is noticeably lower than the baseline cost. The baseline should be in the range of $800-$1,300 depending on weather data, and the optimized cost should show 30-50% savings.

Seeing an Infeasible or error status?

  • Check that the indentation inside build_and_solve is consistent. The entire for t in T: loop and the fuel tank constraint must be inside the function (indented one level).
  • Make sure the fuel_tank_limit constraint uses <= (less than or equal), not >=. Fuel consumed must be less than or equal to available fuel.
  • Verify that compute_baseline is defined at module level (no indent), not nested inside build_and_solve.

Why is my optimizer returning Infeasible instead of Optimal?

✔️ Awesome, I've got everything!

Great. Double check you have saved optimizer.py and the solver returned Optimal.

ⓧ I'd like to double check the full code

Here is the complete optimizer.py file. Compare your version against this:

"""
MILP-based microgrid dispatch optimizer using PuLP.
Minimizes total operating cost over a 24-hour horizon.
"""

import pulp
import pandas as pd
from config import (
    SOLAR, WIND, DIESEL, BATTERY, FUEL_TANK,
    DEMAND_KW, HOURS,
)


def build_and_solve(capacity_factors: pd.DataFrame) -> dict:
    T = HOURS
    solar_cf = capacity_factors["solar_cf"].tolist()
    wind_cf = capacity_factors["wind_cf"].tolist()

    prob = pulp.LpProblem("Microgrid_Dispatch", pulp.LpMinimize)

    p_solar = pulp.LpVariable.dicts("p_solar", T, lowBound=0)
    p_wind = pulp.LpVariable.dicts("p_wind", T, lowBound=0)
    p_diesel = pulp.LpVariable.dicts("p_diesel", T, lowBound=0)
    p_charge = pulp.LpVariable.dicts("p_charge", T, lowBound=0)
    p_discharge = pulp.LpVariable.dicts("p_discharge", T, lowBound=0)
    soc = pulp.LpVariable.dicts("soc", T, lowBound=0)
    diesel_on = pulp.LpVariable.dicts("diesel_on", T, cat="Binary")
    diesel_start = pulp.LpVariable.dicts("diesel_start", T, lowBound=0)

    for t in T:
        prob += (
            p_solar[t] + p_wind[t] + p_diesel[t] + p_discharge[t] - p_charge[t]
            >= DEMAND_KW[t],
            f"energy_balance_{t}",
        )
        prob += (p_solar[t] <= SOLAR["capacity_kw"] * solar_cf[t], f"solar_limit_{t}")
        prob += (p_wind[t] <= WIND["capacity_kw"] * wind_cf[t], f"wind_limit_{t}")
        prob += (p_diesel[t] <= DIESEL["capacity_kw"] * diesel_on[t], f"diesel_max_{t}")
        prob += (p_diesel[t] >= DIESEL["min_load_kw"] * diesel_on[t], f"diesel_min_{t}")
        prob += (p_charge[t] <= BATTERY["max_charge_kw"], f"charge_limit_{t}")
        prob += (p_discharge[t] <= BATTERY["max_discharge_kw"], f"discharge_limit_{t}")

        soc_min = BATTERY["capacity_kwh"] * BATTERY["soc_min_pct"]
        soc_max = BATTERY["capacity_kwh"] * BATTERY["soc_max_pct"]
        prob += soc[t] >= soc_min, f"soc_lower_{t}"
        prob += soc[t] <= soc_max, f"soc_upper_{t}"

        if t == 0:
            initial_soc = BATTERY["capacity_kwh"] * BATTERY["initial_soc_pct"]
            prob += (
                soc[t] == initial_soc
                + BATTERY["charge_efficiency"] * p_charge[t]
                - p_discharge[t] / BATTERY["discharge_efficiency"],
                f"soc_dynamics_{t}",
            )
        else:
            prob += (
                soc[t] == soc[t - 1]
                + BATTERY["charge_efficiency"] * p_charge[t]
                - p_discharge[t] / BATTERY["discharge_efficiency"],
                f"soc_dynamics_{t}",
            )

        if t == 0:
            prob += diesel_start[t] >= diesel_on[t] - 0, f"startup_{t}"
        else:
            prob += (
                diesel_start[t] >= diesel_on[t] - diesel_on[t - 1],
                f"startup_{t}",
            )

    prob += (
        pulp.lpSum(
            DIESEL["fuel_consumption_rate_l_per_kwh"] * p_diesel[t] for t in T
        )
        <= FUEL_TANK["current_level_liters"],
        "fuel_tank_limit",
    )

    fuel_cost = pulp.lpSum(
        DIESEL["fuel_cost_per_liter"]
        * DIESEL["fuel_consumption_rate_l_per_kwh"]
        * p_diesel[t]
        for t in T
    )
    startup_cost = pulp.lpSum(DIESEL["startup_cost"] * diesel_start[t] for t in T)
    renewable_om = pulp.lpSum(
        SOLAR["om_cost_per_kwh"] * p_solar[t]
        + WIND["om_cost_per_kwh"] * p_wind[t]
        for t in T
    )
    prob += fuel_cost + startup_cost + renewable_om

    prob.solve(pulp.PULP_CBC_CMD(msg=0))

    status = pulp.LpStatus[prob.status]
    total_cost = pulp.value(prob.objective)

    schedule = pd.DataFrame({
        "hour": T,
        "demand_kw": DEMAND_KW,
        "solar_kw": [pulp.value(p_solar[t]) for t in T],
        "wind_kw": [pulp.value(p_wind[t]) for t in T],
        "diesel_kw": [pulp.value(p_diesel[t]) for t in T],
        "charge_kw": [pulp.value(p_charge[t]) for t in T],
        "discharge_kw": [pulp.value(p_discharge[t]) for t in T],
        "soc_kwh": [pulp.value(soc[t]) for t in T],
        "diesel_on": [pulp.value(diesel_on[t]) for t in T],
    })

    total_fuel_liters = sum(
        DIESEL["fuel_consumption_rate_l_per_kwh"] * pulp.value(p_diesel[t])
        for t in T
    )
    diesel_starts = sum(1 for t in T if pulp.value(diesel_start[t]) > 0.5)

    return {
        "status": status,
        "total_cost": total_cost,
        "total_fuel_liters": total_fuel_liters,
        "diesel_starts": diesel_starts,
        "schedule": schedule,
    }


def compute_baseline(capacity_factors: pd.DataFrame) -> dict:
    solar_cf = capacity_factors["solar_cf"].tolist()
    wind_cf = capacity_factors["wind_cf"].tolist()

    total_fuel = 0.0
    total_cost = 0.0
    diesel_starts = 0
    prev_diesel_on = False

    schedule_rows = []
    for t in HOURS:
        demand = DEMAND_KW[t]
        solar_avail = SOLAR["capacity_kw"] * solar_cf[t]
        wind_avail = WIND["capacity_kw"] * wind_cf[t]

        solar_used = min(solar_avail, demand)
        remaining = demand - solar_used
        wind_used = min(wind_avail, remaining)
        remaining -= wind_used

        diesel_used = remaining
        diesel_on = diesel_used > 0

        if diesel_on and diesel_used < DIESEL["min_load_kw"]:
            diesel_used = DIESEL["min_load_kw"]

        fuel = DIESEL["fuel_consumption_rate_l_per_kwh"] * diesel_used
        cost = DIESEL["fuel_cost_per_liter"] * fuel

        if diesel_on and not prev_diesel_on:
            diesel_starts += 1
            cost += DIESEL["startup_cost"]

        total_fuel += fuel
        total_cost += cost
        prev_diesel_on = diesel_on

        schedule_rows.append({
            "hour": t,
            "demand_kw": demand,
            "solar_kw": solar_used,
            "wind_kw": wind_used,
            "diesel_kw": diesel_used,
            "charge_kw": 0,
            "discharge_kw": 0,
        })

    return {
        "total_cost": total_cost,
        "total_fuel_liters": total_fuel,
        "diesel_starts": diesel_starts,
        "schedule": pd.DataFrame(schedule_rows),
    }

Your optimizer is producing real cost savings by intelligently scheduling generation sources. Next up, you will visualize the dispatch schedule and see exactly how the optimizer deploys solar, wind, diesel, and battery hour by hour.

Visualize and Compare Against Baseline

Your optimizer can now produce a cost-minimized dispatch schedule. But a number in a terminal means nothing without context.

In this step, you will wire up the full pipeline in main.py, compute a naive diesel-first baseline for comparison, and produce a publication-quality stacked area chart that visually proves how much fuel the optimizer saves.

In this step, get ready to:
  • Build the main orchestration that fetches weather data, runs the optimizer, and computes the naive baseline.
  • Create a two-panel dispatch visualization showing generation stacks and battery state of charge.
  • Print a comparison summary showing fuel savings between the optimized dispatch and the baseline.
Set up the main entry point

The main.py file is the conductor that ties all your modules together. It fetches weather data, runs the MILP optimizer, computes a naive baseline for comparison, and then visualizes the results.

  • Open main.py in VS Code and add these imports at the top of the file:
"""
Main entry point: fetch data, optimize, compare, and visualize.
"""

import matplotlib.pyplot as plt
import pandas as pd
from weather import get_capacity_factors
from optimizer import build_and_solve, compute_baseline
from config import DEMAND_KW, HOURS

What does this code do?

  • matplotlib.pyplot gives you the charting tools for the dispatch visualization.
  • The local module imports pull in get_capacity_factors (weather data), build_and_solve (MILP optimizer), and compute_baseline (naive diesel-first strategy).
  • DEMAND_KW and HOURS are imported directly so the plotting function can overlay the demand line.
  • You won't see a change yet. The next code block defines the main() function that uses these imports.
  • Below the imports (leave a blank line), add the main() function and the entry point guard at the bottom of main.py:
def main():
    print("Microgrid Dispatch Optimizer")
    print("Site: Atacama Mining Site Alpha (-23.5, -68.5)")
    print("-" * 50)

    # Fetch real solar/wind data from NASA POWER
    print("\n[1/3] Fetching weather data...")
    capacity_factors = get_capacity_factors("20240115")

    # Run the MILP optimizer
    print("\n[2/3] Solving MILP dispatch optimization...")
    optimized = build_and_solve(capacity_factors)
    print(f"  Solver status: {optimized['status']}")

    # Compute a naive baseline for comparison
    print("\n[3/3] Computing naive baseline...")
    baseline = compute_baseline(capacity_factors)

    # Display results
    print_comparison(optimized, baseline)
    plot_dispatch(optimized, baseline, capacity_factors)


if __name__ == "__main__":
    main()

What does this code do?

The main() function runs a three-stage pipeline. First it fetches real weather data for January 15, 2024 from the NASA POWER API. Then it solves the MILP to get the optimal dispatch schedule.

Finally it computes the naive baseline (diesel covers whatever renewables cannot, battery never used) and passes both results to the comparison and visualization functions you will write next.

  • Save main.py.
  • You cannot run the script yet because print_comparison and plot_dispatch are not defined. The next two chunks add them.
Create the dispatch visualization

A stacked area chart is the standard way to present dispatch schedules. Each generation source stacks on top of the previous one, visually showing how demand is met at every hour.

  • In main.py, add the plot_dispatch() function between the imports and the main() function:
def plot_dispatch(optimized: dict, baseline: dict, capacity_factors: pd.DataFrame):
    schedule = optimized["schedule"]
    hours = schedule["hour"]

    # Create a 2-panel figure: dispatch on top, battery SOC below
    fig, axes = plt.subplots(2, 1, figsize=(12, 8), height_ratios=[3, 1])
    fig.suptitle("Optimized Microgrid Dispatch - Atacama Mining Site", fontsize=14)

    # Top panel: stacked generation dispatch
    ax1 = axes[0]
    ax1.fill_between(hours, 0, schedule["solar_kw"], alpha=0.7, color="#f4a742", label="Solar PV")
    ax1.fill_between(hours, schedule["solar_kw"], schedule["solar_kw"] + schedule["wind_kw"], alpha=0.7, color="#42a7f4", label="Wind")
    ax1.fill_between(hours, schedule["solar_kw"] + schedule["wind_kw"], schedule["solar_kw"] + schedule["wind_kw"] + schedule["diesel_kw"], alpha=0.7, color="#8c8c8c", label="Diesel")
    ax1.fill_between(hours, schedule["solar_kw"] + schedule["wind_kw"] + schedule["diesel_kw"], (schedule["solar_kw"] + schedule["wind_kw"] + schedule["diesel_kw"] + schedule["discharge_kw"]), alpha=0.7, color="#42f495", label="Battery Discharge")
    # Battery charging shown as negative (below the x-axis)
    ax1.fill_between(hours, 0, -schedule["charge_kw"], alpha=0.5, color="#a742f4", label="Battery Charging")
    ax1.plot(hours, DEMAND_KW, "k--", linewidth=2, label="Demand")
    ax1.set_ylabel("Power (kW)")
    ax1.set_xlabel("Hour of Day")
    ax1.legend(loc="upper left", fontsize=9)
    ax1.set_xlim(0, 23)
    ax1.axhline(y=0, color="black", linewidth=0.5)
    ax1.grid(True, alpha=0.3)

    # Bottom panel: battery state of charge over time
    ax2 = axes[1]
    ax2.plot(hours, schedule["soc_kwh"], "g-", linewidth=2, label="Battery SOC")
    ax2.axhline(y=40, color="r", linestyle=":", label="SOC Min (10%)")
    ax2.axhline(y=360, color="r", linestyle=":", label="SOC Max (90%)")
    ax2.set_ylabel("SOC (kWh)")
    ax2.set_xlabel("Hour of Day")
    ax2.set_xlim(0, 23)
    ax2.set_ylim(0, 400)
    ax2.legend(loc="upper right", fontsize=9)
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig("dispatch_results.png", dpi=150, bbox_inches="tight")
    plt.show()
    print("Saved visualization to dispatch_results.png")

What does this visualization show?

The top panel is a stacked area chart. Solar (orange) sits at the bottom, then wind (blue), diesel (grey), and battery discharge (green) stack on top. The dashed black line is the demand curve the supply must meet or exceed.

Battery charging appears below the x-axis (purple), representing energy flowing into storage rather than serving demand. The bottom panel tracks the battery's state of charge with red dotted lines marking the 10% and 90% safety limits.

  • Save main.py.
  • You still cannot run yet. The next chunk adds print_comparison(), the last missing piece.
Add the comparison summary and run the optimizer

The visualization tells the story visually. A printed comparison table gives the hard numbers: how many liters of diesel saved, how much money saved, and how many fewer generator start events occurred.

  • In main.py, add the print_comparison() function between plot_dispatch() and main():
def print_comparison(optimized: dict, baseline: dict):
    # Format a side-by-side comparison table
    print("\n" + "=" * 60)
    print("DISPATCH OPTIMIZATION RESULTS")
    print("=" * 60)
    print(f"{'Metric':<30} {'Optimized':>12} {'Baseline':>12}")
    print("-" * 60)
    print(f"{'Total fuel (liters)':<30} {optimized['total_fuel_liters']:>12.1f} {baseline['total_fuel_liters']:>12.1f}")
    print(f"{'Total cost ($)':<30} {optimized['total_cost']:>12.2f} {baseline['total_cost']:>12.2f}")
    savings_pct = (baseline["total_cost"] - optimized["total_cost"]) / baseline["total_cost"] * 100
    print(f"{'Fuel cost savings':<30} {savings_pct:>11.1f}% {'(reference)':>12}")
    print(f"{'Diesel start events':<30} {optimized['diesel_starts']:>12} {baseline['diesel_starts']:>12}")
    print("=" * 60)

What does this code do?

This function calculates the percentage savings between the optimized cost and the baseline cost, then formats everything as a neat terminal table. The metrics show total fuel consumed, total operating cost, percentage savings, and how many times the diesel generator was started (each start costs $15).

  • Save main.py.
  • Run the full optimizer pipeline by running this command in your terminal:
python main.py
  • You should see three progress messages in the terminal, then a comparison table showing the optimized cost is significantly lower than the baseline.
  • A matplotlib window should pop up with the two-panel dispatch chart. Solar (orange) dominates the midday hours. Diesel (grey) fills in at night and during peaks. Battery charging (purple below the axis) happens during solar peaks.
  • After you close the chart window, check your project folder. A file called dispatch_results.png should now exist.

Not seeing the chart window?

  • If you are running on a headless server or inside WSL without display forwarding, matplotlib cannot open a window. The file dispatch_results.png is still saved to disk. Open it from your file manager to see the chart.
  • If you see an import error, make sure your virtual environment is activated and all four packages were installed in Step 1.
  • If the solver status prints anything other than Optimal, double check that config.py and optimizer.py match the code from the previous steps.

Help me debug my main.py output

Why compare against a naive baseline?

An optimizer's value is only meaningful relative to the alternative. The naive baseline represents the simplest possible control strategy: use renewables when available, run diesel for everything else, ignore the battery entirely. This is roughly how many remote sites operate before installing an EMS.

Showing 30-50% fuel savings against this baseline proves the MILP is doing real work. It is not just shuffling numbers. It is strategically timing battery cycles and diesel starts to minimize cost.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file and that dispatch_results.png exists in your project folder.

ⓧ I'd like to double check the full code

Here is the complete main.py file for comparison:

"""
Main entry point: fetch data, optimize, compare, and visualize.
"""

import matplotlib.pyplot as plt
import pandas as pd
from weather import get_capacity_factors
from optimizer import build_and_solve, compute_baseline
from config import DEMAND_KW, HOURS


def plot_dispatch(optimized: dict, baseline: dict, capacity_factors: pd.DataFrame):
    schedule = optimized["schedule"]
    hours = schedule["hour"]

    fig, axes = plt.subplots(2, 1, figsize=(12, 8), height_ratios=[3, 1])
    fig.suptitle("Optimized Microgrid Dispatch - Atacama Mining Site", fontsize=14)

    ax1 = axes[0]
    ax1.fill_between(hours, 0, schedule["solar_kw"], alpha=0.7, color="#f4a742", label="Solar PV")
    ax1.fill_between(hours, schedule["solar_kw"], schedule["solar_kw"] + schedule["wind_kw"], alpha=0.7, color="#42a7f4", label="Wind")
    ax1.fill_between(hours, schedule["solar_kw"] + schedule["wind_kw"], schedule["solar_kw"] + schedule["wind_kw"] + schedule["diesel_kw"], alpha=0.7, color="#8c8c8c", label="Diesel")
    ax1.fill_between(hours, schedule["solar_kw"] + schedule["wind_kw"] + schedule["diesel_kw"], (schedule["solar_kw"] + schedule["wind_kw"] + schedule["diesel_kw"] + schedule["discharge_kw"]), alpha=0.7, color="#42f495", label="Battery Discharge")
    ax1.fill_between(hours, 0, -schedule["charge_kw"], alpha=0.5, color="#a742f4", label="Battery Charging")
    ax1.plot(hours, DEMAND_KW, "k--", linewidth=2, label="Demand")
    ax1.set_ylabel("Power (kW)")
    ax1.set_xlabel("Hour of Day")
    ax1.legend(loc="upper left", fontsize=9)
    ax1.set_xlim(0, 23)
    ax1.axhline(y=0, color="black", linewidth=0.5)
    ax1.grid(True, alpha=0.3)

    ax2 = axes[1]
    ax2.plot(hours, schedule["soc_kwh"], "g-", linewidth=2, label="Battery SOC")
    ax2.axhline(y=40, color="r", linestyle=":", label="SOC Min (10%)")
    ax2.axhline(y=360, color="r", linestyle=":", label="SOC Max (90%)")
    ax2.set_ylabel("SOC (kWh)")
    ax2.set_xlabel("Hour of Day")
    ax2.set_xlim(0, 23)
    ax2.set_ylim(0, 400)
    ax2.legend(loc="upper right", fontsize=9)
    ax2.grid(True, alpha=0.3)

    plt.tight_layout()
    plt.savefig("dispatch_results.png", dpi=150, bbox_inches="tight")
    plt.show()
    print("Saved visualization to dispatch_results.png")


def print_comparison(optimized: dict, baseline: dict):
    print("\n" + "=" * 60)
    print("DISPATCH OPTIMIZATION RESULTS")
    print("=" * 60)
    print(f"{'Metric':<30} {'Optimized':>12} {'Baseline':>12}")
    print("-" * 60)
    print(f"{'Total fuel (liters)':<30} {optimized['total_fuel_liters']:>12.1f} {baseline['total_fuel_liters']:>12.1f}")
    print(f"{'Total cost ($)':<30} {optimized['total_cost']:>12.2f} {baseline['total_cost']:>12.2f}")
    savings_pct = (baseline["total_cost"] - optimized["total_cost"]) / baseline["total_cost"] * 100
    print(f"{'Fuel cost savings':<30} {savings_pct:>11.1f}% {'(reference)':>12}")
    print(f"{'Diesel start events':<30} {optimized['diesel_starts']:>12} {baseline['diesel_starts']:>12}")
    print("=" * 60)


def main():
    print("Microgrid Dispatch Optimizer")
    print("Site: Atacama Mining Site Alpha (-23.5, -68.5)")
    print("-" * 50)

    print("\n[1/3] Fetching weather data...")
    capacity_factors = get_capacity_factors("20240115")

    print("\n[2/3] Solving MILP dispatch optimization...")
    optimized = build_and_solve(capacity_factors)
    print(f"  Solver status: {optimized['status']}")

    print("\n[3/3] Computing naive baseline...")
    baseline = compute_baseline(capacity_factors)

    print_comparison(optimized, baseline)
    plot_dispatch(optimized, baseline, capacity_factors)


if __name__ == "__main__":
    main()

Secret mission

Multi-Day Horizon with Fuel Delivery Planning

Your 24-hour optimizer works great for a single day. But what happens when fuel is scarce and the next delivery truck is days away? Extend the optimizer to a 7-day horizon where fuel logistics become the binding constraint. The optimizer must look ahead and strategically conserve fuel before the delivery arrives on day 4.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine with free, open-source tools and a free public API. There are no ongoing costs or cloud resources to worry about.

Decide whether to keep your project files intact, pause by deactivating your virtual environment, or delete everything entirely.

Resources you used:

  • Project files: config.py, weather.py, optimizer.py, main.py.
  • Generated visualizations: dispatch_results.png and dispatch_7day_results.png.
  • Virtual environment (venv folder) with installed packages: PuLP, pandas, matplotlib, requests.
  • NASA POWER API (free, no account required, no cleanup needed).

Keep everything running

No action needed. Choose this if you want to keep experimenting with different site configurations, demand profiles, or weather dates.

  • Your project files and virtual environment stay on your Desktop, ready to use.
  • You can re-run the optimizer anytime by activating the virtual environment and running python main.py.
  • The NASA POWER API is free and publicly accessible with no usage limits for this project's scale.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal session. Your files and installed packages remain on disk for later.

  • Deactivate the virtual environment by running:
deactivate
  • When you return, navigate back to your project folder and reactivate the environment with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).

Delete - I don't want to use this again

Remove the entire project folder including the virtual environment, all source code, and generated visualizations.

  • First, deactivate the virtual environment if it is still active:
deactivate
  • Navigate to your Desktop:
cd ~/Desktop
  • Delete the project folder (macOS/Linux):
rm -rf microgrid-optimizer
  • On Windows, use this command instead:
rmdir /s /q microgrid-optimizer
  • This removes all project files, the virtual environment, and any generated images in one step.
  • No cloud accounts or services need to be deleted. The NASA POWER API does not require an account.

Nice Work!

Nice Work!

You just built a production-grade microgrid dispatch optimizer that pulls real weather data and solves a Mixed-Integer Linear Program to minimize diesel costs at a remote mining site.

You've learned how to:

  • Build a MILP-based dispatch optimizer using PuLP and the CBC solver that schedules solar, wind, diesel, and battery resources to minimize fuel costs while respecting physical constraints like generator minimum load and battery SOC limits.
  • Integrate real-world NASA POWER API weather data (solar irradiance and wind speed) into an optimization model as location-specific renewable capacity factors.
  • Compare optimized dispatch against a naive baseline strategy and produce publication-quality stacked area visualizations demonstrating 30-50% fuel cost savings.
  • Secret Mission: Extended the optimizer to a 7-day planning horizon with fuel delivery logistics, showing how the solver strategically conserves fuel and pre-charges batteries to survive low-fuel periods before a scheduled delivery.

Ready to quiz yourself?