Package nemo

The National Electricity Market Optimiser (NEMO)

Expand source code
# Copyright (C) 2017 Ben Elliston
#
# This file is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.

"""The National Electricity Market Optimiser (NEMO)"""

import nemo.nem  # noqa: F401
from nemo.context import Context
from nemo.sim import run

__all__ = ['Context', 'run']

Sub-modules

nemo.configfile

Configuration file processing (eg, filenames).

nemo.context

A simulation context encapsulates all simulation state ensuring that there is never any residual state left behind after a simulation run. It also …

nemo.costs

Generation technology costs.

nemo.generators

Simulated electricity generators for the NEMO framework.

nemo.nem

A National Electricity Market (NEM) simulation.

nemo.polygons

Support code for the 43 polygons of the AEMO study.

nemo.regions

Market regions consisting of one or more polygons.

nemo.scenarios

Supply and demand side scenarios.

nemo.sim

The core of the simulation engine.

nemo.transmission

Transmission model details.

nemo.utils

Utility functions (eg, plotting).

Functions

def run(context, starthour=None, endhour=None)

Run the simulation.

>>> from nemo import Context
>>> c = Context()
>>> c.regions = None
>>> run(c)
Traceback (most recent call last):
  ...
ValueError: regions is not a list
Expand source code
def run(context, starthour=None, endhour=None):
    """Run the simulation.

    >>> from nemo import Context
    >>> c = Context()
    >>> c.regions = None
    >>> run(c)
    Traceback (most recent call last):
      ...
    ValueError: regions is not a list
    """
    if not isinstance(context.regions, list):
        raise ValueError('regions is not a list')

    if starthour is None:
        starthour = context.demand.index.min()
    if endhour is None:
        endhour = context.demand.index.max()
    date_range = pd.date_range(starthour, endhour, freq='H')

    _sim(context, date_range)

    # Calculate unserved energy.
    agg_demand = context.demand.sum(axis=1)
    agg_generation = context.generation.sum(axis=1)
    unserved = agg_demand - agg_generation
    # Ignore unserved events very close to 0 (rounding errors)
    context.unserved = unserved[~np.isclose(unserved, 0)]

Classes

class Context

All simulation state is kept in a Context object.

Initialise a default context.

Expand source code
class Context():

    """All simulation state is kept in a Context object."""

    def __init__(self):
        """Initialise a default context."""
        self.verbose = False
        self.track_exchanges = False
        self.regions = regions.All
        self.startdate = startdate
        # Number of timesteps is determined by the number of demand rows.
        self.hours = len(hourly_regional_demand)
        # Estimate the number of years from the number of simulation hours.
        if self.hours == 8760 or self.hours == 8784:
            self.years = 1
        else:
            self.years = self.hours / (365.25 * 24)

        self.relstd = 0.002  # 0.002% unserved energy
        self.generators = [generators.CCGT(polygons.wildcard, 20000),
                           generators.OCGT(polygons.wildcard, 20000)]
        self.demand = hourly_demand.copy()
        self.timesteps = len(self.demand)
        self.spill = pd.DataFrame()
        self.generation = pd.DataFrame()
        self.unserved = pd.DataFrame()
        # System non-synchronous penetration limit
        self.nsp_limit = float(configfile.get('limits', 'nonsync-penetration'))
        self.exchanges = np.zeros((self.hours, polygons.numpolygons, polygons.numpolygons))
        self.costs = costs.NullCosts()

    def total_demand(self):
        """Return the total demand from the data frame."""
        return self.demand.values.sum()

    def unserved_energy(self):
        """Return the total unserved energy."""
        return self.unserved.values.sum()

    def surplus_energy(self):
        """Return total surplus energy."""
        return self.spill.values.sum()

    def unserved_percent(self):
        """Return the total unserved energy as a percentage of total demand.

        >>> import pandas as pd
        >>> c = Context()
        >>> c.unserved_percent()
        0.0
        >>> c.demand = pd.DataFrame()
        >>> c.unserved_percent()
        nan
        """
        # We can't catch ZeroDivision because numpy emits a warning
        # (which we would rather not suppress).
        if self.total_demand() == 0:
            return np.nan
        return self.unserved_energy() / self.total_demand() * 100

    def add_exchange(self, hour, src, dest, transfer):
        """Record an energy transfer from src to dest in given hour."""
        self.exchanges[hour, src - 1, dest - 1] += transfer

    def set_capacities(self, caps):
        """Set generator capacities from a list."""
        n = 0
        for gen in self.generators:
            for (setter, min_cap, max_cap) in gen.setters:
                # keep parameters within bounds
                newval = max(min(caps[n], max_cap), min_cap)
                setter(newval)
                n += 1
        # Check every parameter has been set.
        assert n == len(caps), '%d != %d' % (n, len(caps))

    def __str__(self):
        """A human-readable representation of the context."""
        s = ""
        if self.regions != regions.All:
            s += 'Regions: ' + str(self.regions) + '\n'
        if self.verbose:
            s += 'Generators:' + '\n'
            for g in self.generators:
                s += '\t' + str(g)
                summary = g.summary(self)
                if summary is not None:
                    s += '\n\t   ' + summary + '\n'
                else:
                    s += '\n'
        s += 'Timesteps: %d h\n' % self.hours
        total_demand = (self.total_demand() * ureg.MWh).to_compact()
        s += 'Demand energy: {}\n'.format(total_demand)
        surplus_energy = (self.surplus_energy() * ureg.MWh).to_compact()
        s += 'Unused surplus energy: {}\n'.format(surplus_energy)
        if self.surplus_energy() > 0:
            spill_series = self.spill[self.spill.sum(axis=1) > 0]
            s += 'Timesteps with unused surplus energy: %d\n' % len(spill_series)

        if self.unserved.empty:
            s += 'No unserved energy'
        else:
            s += 'Unserved energy: %.3f%%' % self.unserved_percent() + '\n'
            if self.unserved_percent() > self.relstd * 1.001:
                s += 'WARNING: reliability standard exceeded\n'
            s += 'Unserved total hours: ' + str(len(self.unserved)) + '\n'

            # A subtle trick: generate a date range and then substract
            # it from the timestamps of unserved events.  This will
            # produce a run of time detlas (for each consecutive hour,
            # the time delta between this timestamp and the
            # corresponding row from the range will be
            # constant). Group by the deltas.
            rng = pd.date_range(self.unserved.index[0], periods=len(self.unserved.index), freq='H')
            unserved_events = [k for k, g in self.unserved.groupby(self.unserved.index - rng)]
            s += 'Number of unserved energy events: ' + str(len(unserved_events)) + '\n'
            if not self.unserved.empty:
                usmin = (self.unserved.min() * ureg.MW).to_compact()
                usmax = (self.unserved.max() * ureg.MW).to_compact()
                s += 'Shortfalls (min, max): ({}, {})'.format(usmin, usmax)
        return s

    class JSONEncoder(json.JSONEncoder):
        """A custom encoder for Context objects."""
        def default(self, o):
            if isinstance(o, Context):
                result = []
                for g in o.generators:
                    tech = re.sub(r"<class 'generators\.(.*)'>",
                                  r'\1', str(type(g)))
                    result += [{'label': g.label, 'polygon': g.polygon,
                                'capacity': g.capacity, 'technology': tech}]
                return result
            return None

Class variables

var JSONEncoder

A custom encoder for Context objects.

Methods

def add_exchange(self, hour, src, dest, transfer)

Record an energy transfer from src to dest in given hour.

Expand source code
def add_exchange(self, hour, src, dest, transfer):
    """Record an energy transfer from src to dest in given hour."""
    self.exchanges[hour, src - 1, dest - 1] += transfer
def set_capacities(self, caps)

Set generator capacities from a list.

Expand source code
def set_capacities(self, caps):
    """Set generator capacities from a list."""
    n = 0
    for gen in self.generators:
        for (setter, min_cap, max_cap) in gen.setters:
            # keep parameters within bounds
            newval = max(min(caps[n], max_cap), min_cap)
            setter(newval)
            n += 1
    # Check every parameter has been set.
    assert n == len(caps), '%d != %d' % (n, len(caps))
def surplus_energy(self)

Return total surplus energy.

Expand source code
def surplus_energy(self):
    """Return total surplus energy."""
    return self.spill.values.sum()
def total_demand(self)

Return the total demand from the data frame.

Expand source code
def total_demand(self):
    """Return the total demand from the data frame."""
    return self.demand.values.sum()
def unserved_energy(self)

Return the total unserved energy.

Expand source code
def unserved_energy(self):
    """Return the total unserved energy."""
    return self.unserved.values.sum()
def unserved_percent(self)

Return the total unserved energy as a percentage of total demand.

>>> import pandas as pd
>>> c = Context()
>>> c.unserved_percent()
0.0
>>> c.demand = pd.DataFrame()
>>> c.unserved_percent()
nan
Expand source code
def unserved_percent(self):
    """Return the total unserved energy as a percentage of total demand.

    >>> import pandas as pd
    >>> c = Context()
    >>> c.unserved_percent()
    0.0
    >>> c.demand = pd.DataFrame()
    >>> c.unserved_percent()
    nan
    """
    # We can't catch ZeroDivision because numpy emits a warning
    # (which we would rather not suppress).
    if self.total_demand() == 0:
        return np.nan
    return self.unserved_energy() / self.total_demand() * 100