Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Model.initialize #212

Merged
merged 22 commits into from
Feb 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions RELEASE_NOTES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Next release
All changes
-----------

- `#212 <https://github.com/iiasa/ixmp/pull/212>`_: Add :meth:`Model.initialize` API to help populate new Scenarios according to a model scheme.
- `#267 <https://github.com/iiasa/ixmp/pull/267>`_: Apply units to reported quantities.
- `#254 <https://github.com/iiasa/ixmp/pull/254>`_: Remove deprecated items:

Expand Down
15 changes: 13 additions & 2 deletions doc/source/api-model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,33 @@ Provided models
.. automodule:: ixmp.model
:members: get_model, MODELS

.. currentmodule:: ixmp.model.gams

.. autoclass:: ixmp.model.gams.GAMSModel
:members:

.. currentmodule:: ixmp.model.dantzig

.. autoclass:: ixmp.model.dantzig.DantzigModel
:members:


Model API
---------

.. currentmodule:: ixmp.model.base

.. autoclass:: ixmp.model.base.Model
:members: name, __init__, run
:members: name, __init__, initialize, initialize_items, run

In the following, the words REQUIRED, OPTIONAL, etc. have specific meanings as described in `IETF RFC 2119 <https://tools.ietf.org/html/rfc2119>`_.
In the following, the words **required**, **optional**, etc. have specific meanings as described in `IETF RFC 2119 <https://tools.ietf.org/html/rfc2119>`_.

Model is an **abstract** class; this means it MUST be subclassed.
It has two REQURIED methods that MUST be overridden by subclasses:

.. autosummary::
name
__init__
initialize
initialize_items
run
1 change: 1 addition & 0 deletions doc/source/api-python.rst
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ Utilities
---------

.. currentmodule:: ixmp.utils
:members: update_par

.. automethod:: ixmp.utils.parse_url

Expand Down
2 changes: 2 additions & 0 deletions ixmp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .backend.jdbc import JDBCBackend
from .model import MODELS
from .model.gams import GAMSModel
from .model.dantzig import DantzigModel
from ixmp.reporting import Reporter # noqa: F401

__version__ = get_versions()['version']
Expand All @@ -22,4 +23,5 @@
MODELS.update({
'default': GAMSModel,
'gams': GAMSModel,
'dantzig': DantzigModel,
})
5 changes: 5 additions & 0 deletions ixmp/backend/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ def init_ts(self, ts: TimeSeries, annotation=None):
Returns
-------
None

Raises
------
RuntimeError
if *ts* is newly created and :meth:`commit` has not been called.
"""

@abstractmethod
Expand Down
7 changes: 5 additions & 2 deletions ixmp/backend/jdbc.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,10 @@ def get(self, ts, version):
ts.scheme = jobj.getScheme()

def check_out(self, ts, timeseries_only):
self.jindex[ts].checkOut(timeseries_only)
try:
self.jindex[ts].checkOut(timeseries_only)
except java.IxException as e:
raise RuntimeError(e)

def commit(self, ts, comment):
self.jindex[ts].commit(comment)
Expand Down Expand Up @@ -724,7 +727,7 @@ def item_set_elements(self, s, type, name, elements):
# Re-raise as Python ValueError
raise ValueError(msg) from e
else: # pragma: no cover
raise RuntimeError('unhandled Java exception') from e
raise RuntimeError(str(e))

self.cache_invalidate(s, type, name)

Expand Down
9 changes: 8 additions & 1 deletion ixmp/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,12 +595,13 @@ class Scenario(TimeSeries):
scheme = None

def __init__(self, mp, model, scenario, version=None, scheme=None,
annotation=None, cache=False):
annotation=None, cache=False, **model_init_args):
if not isinstance(mp, Platform):
raise ValueError('mp is not a valid `ixmp.Platform` instance')

# Set attributes
self.platform = mp
self.scheme = scheme
self.model = model
self.scenario = scenario

Expand All @@ -615,6 +616,12 @@ def __init__(self, mp, model, scenario, version=None, scheme=None,
raise RuntimeError(f'{model}/{scenario} is a MESSAGE-scheme '
'scenario; use message_ix.Scenario().')

# Retrieve the Model class correlating to the *scheme*
model_class = get_model(scheme).__class__

# Use the model class to initialize the Scenario
model_class.initialize(self, **model_init_args)

@property
def _cache(self):
return hasattr(self.platform._backend, '_cache')
Expand Down
76 changes: 76 additions & 0 deletions ixmp/model/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import logging

from abc import ABC, abstractmethod


log = logging.getLogger(__name__)


class Model(ABC): # pragma: no cover
#: Name of the model.
name = 'base'
Expand All @@ -18,6 +23,77 @@ def __init__(self, name, **kwargs):
"""
pass

@classmethod
def initialize(cls, scenario):
"""Set up *scenario* with required items.

Implementations of :meth:`initialize`:

- **may** add sets, set elements, and/or parameter values.
- **may** accept any number of keyword arguments to control behaviour.
- **must not** modify existing parameter data in *scenario*, either by
deleting or overwriting values.

Parameters
----------
scenario : .Scenario
Scenario object to initialize.

See also
--------
initialize_items
"""
log.debug('No initialization for {!r}-scheme Scenario'
.format(scenario.scheme))

@classmethod
def initialize_items(cls, scenario, items):
"""Helper for :meth:`initialize`.

All of the *items* are added to *scenario*. Existing items are not
modified.

Parameters
----------
scenario : .Scenario
Scenario object to initialize.
items : dict of (str -> dict)
Each key is the name of an ixmp item (set, parameter, equation, or
variable) to initialize. Each dict **must** have the key 'ix_type';
one of 'set', 'par', 'equ', or 'var'; any other entries are keyword
arguments to the methods :meth:`.init_set` etc.

See also
--------
.init_equ
.init_par
.init_set
.init_var
"""
try:
# If *scenario* is already committed to the Backend, it must be
# checked out.
scenario.check_out()
except RuntimeError:
# If *scenario* is new (has not been committed), the checkout
# attempt raises an exception
pass

for name, item_info in items.items():
# Copy so that pop() below does not modify *items*
item_info = item_info.copy()

# Get the appropriate method, e.g. init_set or init_par
ix_type = item_info.pop('ix_type')
init_method = getattr(scenario, 'init_{}'.format(ix_type))

try:
# Add the item
init_method(name=name, **item_info)
except ValueError:
# Item already exists
pass

@abstractmethod
def run(self, scenario):
"""Execute the model.
Expand Down
File renamed without changes.
91 changes: 91 additions & 0 deletions ixmp/model/dantzig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from collections import ChainMap
from pathlib import Path

import pandas as pd

from ixmp.utils import update_par
from .gams import GAMSModel


ITEMS = {
# Plants
'i': dict(ix_type='set'),
# Markets
'j': dict(ix_type='set'),
# Capacity of plant i in cases
'a': dict(ix_type='par', idx_sets=['i']),
# Demand at market j in cases
'b': dict(ix_type='par', idx_sets=['j']),
# Distance between plant i and market j
'd': dict(ix_type='par', idx_sets=['i', 'j']),
# Transport cost per case per 1000 miles
'f': dict(ix_type='par', idx_sets=None),
# Decision variables and equations
'x': dict(ix_type='var', idx_sets=['i', 'j']),
'z': dict(ix_type='var', idx_sets=None),
'cost': dict(ix_type='equ', idx_sets=None),
'demand': dict(ix_type='equ', idx_sets=['j']),
'supply': dict(ix_type='equ', idx_sets=['i']),
}

DATA = {
'i': ['seattle', 'san-diego'],
'j': ['new-york', 'chicago', 'topeka'],
'a': pd.DataFrame([
['seattle', 350, 'cases'],
['san-diego', 600, 'cases'],
], columns='i value unit'.split()),
'b': pd.DataFrame([
['new-york', 325, 'cases'],
['chicago', 300, 'cases'],
['topeka', 275, 'cases'],
], columns='j value unit'.split()),
'd': pd.DataFrame([
['seattle', 'new-york', 2.5, 'km'],
['seattle', 'chicago', 1.7, 'km'],
['seattle', 'topeka', 1.8, 'km'],
['san-diego', 'new-york', 2.5, 'km'],
['san-diego', 'chicago', 1.8, 'km'],
['san-diego', 'topeka', 1.4, 'km'],
], columns='i j value unit'.split()),
'f': (90.0, 'USD_per_km'),
}


class DantzigModel(GAMSModel):
"""Dantzig's cannery/transport problem as a :class:`GAMSModel`.

Provided for testing :mod:`ixmp` code.
"""
name = 'dantzig'

defaults = ChainMap({
# Override keys from GAMSModel
'model_file': Path(__file__).with_name('dantzig.gms'),
}, GAMSModel.defaults)

@classmethod
def initialize(cls, scenario, with_data=False):
"""Initialize the problem.

If *with_data* is :obj:`True` (default: :obj:`False`), the set and
parameter values from the original problem are also populated.
Otherwise, the sets and parameters are left empty.
"""
# Initialize the ixmp items
cls.initialize_items(scenario, ITEMS)

if not with_data:
return

# Add set elements
scenario.add_set('i', DATA['i'])
scenario.add_set('j', DATA['j'])

# Add parameter values
update_par(scenario, 'a', DATA['a'])
update_par(scenario, 'b', DATA['b'])
update_par(scenario, 'd', DATA['d'])

# TODO avoid overwriting the existing value
scenario.change_scalar('f', *DATA['f'])
17 changes: 13 additions & 4 deletions ixmp/model/gams.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import os
from pathlib import Path
from subprocess import check_call
from tempfile import TemporaryDirectory


from ixmp.backend import ItemType
from ixmp.backend.jdbc import JDBCBackend
from ixmp.model.base import Model
from ixmp.utils import as_str_list

Expand Down Expand Up @@ -77,15 +77,17 @@ class GAMSModel(Model):
defaults = {
'model_file': '{model_name}.gms',
'case': "{scenario.model}_{scenario.scenario}",
'in_file': '{model_name}_in.gdx',
'out_file': '{model_name}_out.gdx',
'in_file': str(Path('{temp_dir}', '{model_name}_in.gdx')),
'out_file': str(Path('{temp_dir}', '{model_name}_out.gdx')),
'solve_args': ['--in="{in_file}"', '--out="{out_file}"'],

# Not formatted
'gams_args': ['LogOption=4'],
'check_solution': True,
'comment': None,
'equ_list': None,
'var_list': None,
'use_temp_dir': True,
}

def __init__(self, name=None, **model_options):
Expand All @@ -99,6 +101,12 @@ def run(self, scenario):

self.scenario = scenario

if self.use_temp_dir:
# Create a temporary directory; automatically deleted at the end of
# the context
_temp_dir = TemporaryDirectory()
self.temp_dir = _temp_dir.name

def format(key):
value = getattr(self, key)
try:
Expand Down Expand Up @@ -135,7 +143,8 @@ def format(key):
'write to GDX files, e.g. JDBCBackend')

# Invoke GAMS
check_call(command, shell=os.name == 'nt', cwd=model_file.parent)
cwd = self.temp_dir if self.use_temp_dir else model_file.parent
check_call(command, shell=os.name == 'nt', cwd=cwd)

# Read model solution
backend.read_file(self.out_file, ItemType.MODEL, **s_arg,
Expand Down
Loading