Package nemo

The National Electricity Market Optimiser (NEMO).

Sub-modules

nemo.configfile

Configuration file processing (eg, filenames).

nemo.context

Implementation of the Context class …

nemo.costs

Generation technology costs.

nemo.generators

Simulated electricity generators for the NEMO framework.

nemo.nem

A National Electricity Market (NEM) simulation.

nemo.penalties

Penalty functions for the optimisation.

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 side scenarios.

nemo.sim

The core of the simulation engine.

nemo.storage

Storage classes …

nemo.types

Useful internal types.

nemo.utils

Utility functions (eg, plotting).

Functions

def plot(context, filename=None, xlim=None, *, spills=False, showlegend=True)
Expand source code
def plot(context, filename=None, xlim=None, *, spills=False, showlegend=True):
    """Produce a pretty plot of supply and demand."""
    if xlim is not None and not isinstance(xlim, tuple):
        raise ValueError(xlim)

    if xlim is None:
        starttime = context.demand.index[0]
        ninety_days = 24 * 90
        if context.timesteps() > ninety_days:
            endtime = starttime + timedelta(days=90)
        else:
            endtime = context.demand.index[-1]
        timerange = (starttime, endtime)
    else:
        if len(xlim) != 2:
            raise ValueError(xlim)
        timerange = xlim

    _figure(context, spills, showlegend, timerange)
    if not filename:
        plt.show()
    else:
        plt.savefig(filename)

Produce a pretty plot of supply and demand.

def run(context, starthour=None, endhour=None)
Expand source code
def run(context, starthour=None, endhour=None):
    """Run the simulation."""
    if not isinstance(context.regions, list):
        raise TypeError

    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)]

Run the simulation.

Classes

class Context
Expand source code
class Context:
    """All simulation state is kept in a Context object."""

    # pylint: disable=too-many-instance-attributes
    def __init__(self):
        """Initialise a default context."""
        self.verbose = 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)

        self.relstd = 0.002  # 0.002% unserved energy
        self.generators = [generators.CCGT(polygons.WILDCARD, 20000),
                           generators.OCGT(polygons.WILDCARD, 20000)]
        self.storages = None
        self.demand = hourly_demand.copy()
        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.costs = costs.NullCosts()

    def years(self):
        """Return the number of years from the number of simulation hours."""
        return self.hours / (365 * 24)

    def timesteps(self):
        """Return the number of timesteps."""
        return len(self.demand)

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

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

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

    def unserved_percent(self):
        """Return the total unserved energy as a percentage of total demand."""
        # 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 set_capacities(self, caps):
        """Set generator capacities from a list."""
        num = 0
        for gen in self.generators:
            for (setter, min_cap, max_cap) in gen.setters:
                # keep parameters within bounds
                newval = max(min(caps[num], max_cap), min_cap)
                setter(newval)
                num += 1
        # Check every parameter has been set.
        msg = f'{num} != {len(caps)}'
        if num != len(caps):
            raise ValueError(msg)

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

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

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

All simulation state is kept in a Context object.

Initialise a default context.

Methods

def set_capacities(self, caps)
Expand source code
def set_capacities(self, caps):
    """Set generator capacities from a list."""
    num = 0
    for gen in self.generators:
        for (setter, min_cap, max_cap) in gen.setters:
            # keep parameters within bounds
            newval = max(min(caps[num], max_cap), min_cap)
            setter(newval)
            num += 1
    # Check every parameter has been set.
    msg = f'{num} != {len(caps)}'
    if num != len(caps):
        raise ValueError(msg)

Set generator capacities from a list.

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

Return total surplus energy.

def timesteps(self)
Expand source code
def timesteps(self):
    """Return the number of timesteps."""
    return len(self.demand)

Return the number of timesteps.

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

Return the total demand from the data frame.

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

Return the total unserved energy.

def unserved_percent(self)
Expand source code
def unserved_percent(self):
    """Return the total unserved energy as a percentage of total demand."""
    # 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

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

def years(self)
Expand source code
def years(self):
    """Return the number of years from the number of simulation hours."""
    return self.hours / (365 * 24)

Return the number of years from the number of simulation hours.